openmausbot 0.1.61 → 0.1.63

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 (32) hide show
  1. package/README.md +71 -8
  2. package/dist/assets/index-BuntoOSX.js +264 -0
  3. package/dist/assets/{index-CAZ41wL2.js → index-C-SoIGdn.js} +1 -1
  4. package/dist/index.html +1 -1
  5. package/dist-server/container-mcp.js +16 -1
  6. package/dist-server/drivers/agents-proxy.js +8 -4
  7. package/dist-server/index.js +468 -141
  8. package/dist-server/openmausbot.js +33585 -14506
  9. package/dist-server/pair-cli.js +33585 -14506
  10. package/dist-server/server/auto-approve.js +6 -20
  11. package/dist-server/server/browser-bundle-release.js +56 -0
  12. package/dist-server/server/browser-engine-release.js +13 -5
  13. package/dist-server/server/browser-engine.js +114 -15
  14. package/dist-server/server/cli-api-setup.js +144 -0
  15. package/dist-server/server/cli-phone-setup.js +130 -0
  16. package/dist-server/server/cli-prompts.js +185 -0
  17. package/dist-server/server/cli-setup.js +303 -0
  18. package/dist-server/server/cli.js +375 -70
  19. package/dist-server/server/config.js +25 -1
  20. package/dist-server/server/default-model-selection.js +21 -0
  21. package/dist-server/server/drivers/agents-proxy.js +13 -4
  22. package/dist-server/server/drivers/claude.js +26 -1
  23. package/dist-server/server/drivers/openai-compat.js +4 -2
  24. package/dist-server/server/index.js +123 -89
  25. package/dist-server/server/recall-disclosure.js +45 -0
  26. package/dist-server/server/request-auth.js +6 -0
  27. package/dist-server/server/screen-frame-gate.js +36 -1
  28. package/dist-server/server/screen-frame-source.js +57 -0
  29. package/dist-server/server/sessions.js +52 -4
  30. package/dist-server/vps-container-mcp.js +16 -1
  31. package/package.json +1 -1
  32. package/dist/assets/index-Br81ZQCY.js +0 -264
@@ -4,6 +4,8 @@
4
4
  // `pnpm omb` (a checkout) — because scripts/bundle-server.mjs bundles this
5
5
  // file next to the server.
6
6
  //
7
+ // openmausbot setup [--data-dir ~/.openmausbot]
8
+ // openmausbot start [serve options]
7
9
  // openmausbot serve [--port 8799] [--data-dir ~/.openmausbot] [--label "cab mini"]
8
10
  // [--public-url https://host] [--tailscale | --tunnel] [--no-pair]
9
11
  // openmausbot pair [--label "My MacBook"] [--client] [--public-url https://host]
@@ -21,19 +23,21 @@
21
23
  // This module only exports; openmausbot.ts is the entry that runs main(), so
22
24
  // bundling this file into other entries (pair-cli.ts) never runs it twice.
23
25
  import { spawn } from "node:child_process";
24
- import { existsSync, readFileSync } from "node:fs";
26
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
25
27
  import { homedir } from "node:os";
26
28
  import { dirname, join, resolve } from "node:path";
27
29
  import { createInterface } from "node:readline/promises";
28
30
  import { fileURLToPath } from "node:url";
29
31
  import qrcode from "qrcode-terminal";
30
32
  import { explainTailscaleFailure, tailscaleServe, tailscaleServeOff, tailscaleStatus } from "./tailscale.js";
31
- import { browserEngineStatus, describeBrowserEngine, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, } from "./browser-engine.js";
33
+ import { defaultSetupIo, SetupCancelled } from "./cli-prompts.js";
34
+ import { normalizePhoneOrigin, phonePairingInstructions, runPhoneSetup } from "./cli-phone-setup.js";
32
35
  import { cleanupTunnelOrigin, createTunnelAccount, createTunnelOrigin, describeTunnelAccount, describeTunnelState, ensureCloudflared, guardianEntry, startTunnel, tunnelAccess, } from "./tunnel.js";
33
36
  const HERE = dirname(fileURLToPath(import.meta.url));
34
- const COMMANDS = ["serve", "pair", "sessions", "status", "login", "logout", "browser", "help", "--help", "-h"];
37
+ const COMMANDS = ["setup", "start", "serve", "pair", "sessions", "status", "login", "logout", "browser", "help", "--help", "-h"];
35
38
  export function parseArgs(argv, env = process.env) {
36
- const [command = "help", ...rest] = argv;
39
+ const implicitStart = !argv.length || (argv[0].startsWith("--") && argv[0] !== "--help");
40
+ const [command = "start", ...rest] = implicitStart ? ["start", ...argv] : argv;
37
41
  if (!COMMANDS.includes(command)) {
38
42
  return { error: `unknown command "${command}"` };
39
43
  }
@@ -74,6 +78,10 @@ export function parseArgs(argv, env = process.env) {
74
78
  options.client = true;
75
79
  else if (arg === "--no-pair")
76
80
  options.pair = false;
81
+ else if (arg === "--no-open")
82
+ options.open = false;
83
+ else if (arg === "--local")
84
+ options.local = true;
77
85
  else if (arg === "--json")
78
86
  options.json = true;
79
87
  else if (arg === "--email")
@@ -97,12 +105,17 @@ export function parseArgs(argv, env = process.env) {
97
105
  return { error: "--public-url must start with http:// or https://" };
98
106
  if (options.tailscale && options.tunnel)
99
107
  return { error: "choose one of --tailscale (your tailnet) and --tunnel (a public address)" };
108
+ if (options.local && (options.tailscale || options.tunnel || options.publicUrl))
109
+ return { error: "--local cannot be combined with a remote-access option" };
100
110
  if (options.command === "browser" && !options.browserAction)
101
111
  return { error: "browser needs an action: install or status" };
102
112
  return options;
103
113
  }
104
- export const USAGE = `openmausbot — run the server anywhere, pair devices to it
114
+ export const USAGE = `openmausbot — your team of AI bots, ready in a few steps
105
115
 
116
+ openmausbot set up once, then open your workspace
117
+ openmausbot setup [--data-dir DIR]
118
+ openmausbot start [the same options as serve]
106
119
  openmausbot serve [--port 8799] [--data-dir DIR] [--label NAME]
107
120
  [--public-url https://host] [--tailscale | --tunnel] [--no-pair]
108
121
  openmausbot pair [--label NAME] [--client] [--public-url https://host]
@@ -112,17 +125,21 @@ export const USAGE = `openmausbot — run the server anywhere, pair devices to i
112
125
  openmausbot logout
113
126
  openmausbot browser install [--with-deps] | status
114
127
 
115
- serve starts the server and prints a pairing link + QR code
128
+ setup choose AI access and optional phone access; keep existing bots and chats
129
+ start same as openmausbot: use your saved settings and open the workspace
130
+ serve starts the server without prompts and prints a pairing link + QR code
116
131
  pair mints a pairing code against a running server (--client: chat only)
117
132
  sessions lists paired devices; "sessions revoke ID" signs one out
118
133
  status what the server says about itself
119
134
  login signs this machine in to an OpenMausBot account (an emailed code)
120
135
  and reserves its public address for --tunnel
121
136
  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.
137
+ browser install: the bots' browser engine (agent-browser, pinned) into the
138
+ data dir, and Chrome for Testing into the user's browser cache.
139
+ --with-deps also installs
140
+ the Linux libraries Chrome needs (run as root once). Then run
141
+ browser install as the user running serve, from that user's home.
142
+ status: what the current user and data directory have.
126
143
 
127
144
  --tailscale serve over your tailnet: Tailscale terminates HTTPS and the
128
145
  link uses this machine's MagicDNS name (needs Tailscale signed in
@@ -130,6 +147,13 @@ browser install: the bots' browser engine (agent-browser, pinned) and a
130
147
  --tunnel serve at a public https://….openmausbot.com address through a
131
148
  Cloudflare tunnel: no domain, no proxy, no open port. Run
132
149
  \`openmausbot login\` once on this machine first.
150
+
151
+ --no-open do not open a browser window
152
+ --no-pair skip phone setup and do not print a pairing code
153
+ --local start locally this time, ignoring saved remote-access settings
154
+
155
+ Install once with \`npm install -g openmausbot\`, then type \`openmausbot\`.
156
+ Or run without a global install: \`npx openmausbot\`. Node 24+ is required.
133
157
  `;
134
158
  export function defaultIo() {
135
159
  return {
@@ -161,19 +185,105 @@ export function serverVersion(here = HERE) {
161
185
  const message = (error) => (error instanceof Error ? error.message : String(error));
162
186
  // ── talking to a running server (loopback = owner) ────────────────────
163
187
  async function api(port, path, init = {}) {
164
- const res = await fetch(`http://127.0.0.1:${port}${path}`, { method: init.method, body: init.body, headers: { "content-type": "application/json" } });
188
+ const res = await fetch(`http://127.0.0.1:${port}${path}`, { method: init.method, body: init.body, headers: { "content-type": "application/json" }, signal: AbortSignal.timeout(3000) });
165
189
  const body = await res.json().catch(() => ({}));
166
190
  return { status: res.status, body };
167
191
  }
168
- async function serverUp(port) {
192
+ async function serverUp(port, pid) {
169
193
  try {
170
- const { status } = await api(port, "/api/health");
171
- return status === 200;
194
+ const { status, body } = await api(port, "/api/health");
195
+ return status === 200 && body?.app === "openmausbot" && (pid === undefined || body.pid === pid);
172
196
  }
173
197
  catch {
174
198
  return false;
175
199
  }
176
200
  }
201
+ /** Check identity before reusing a running process. Never attach to another
202
+ * workspace just because it happens to be listening on the requested port. */
203
+ export async function isWorkspaceRunning(options) {
204
+ try {
205
+ const { status, body } = await api(options.port, "/api/health");
206
+ if (status !== 200 || body?.app !== "openmausbot")
207
+ return false;
208
+ const expected = readFileSync(join(options.dataDir, "environment-id"), "utf8").trim();
209
+ const descriptor = await api(options.port, "/.well-known/openmausbot/environment");
210
+ return /^[0-9a-f-]{36}$/i.test(expected) && descriptor.status === 200 && descriptor.body?.environmentId === expected;
211
+ }
212
+ catch {
213
+ return false;
214
+ }
215
+ }
216
+ /** No shell commands, credentials or remote URLs go to the OS URL opener. */
217
+ export async function openDashboard(port, env = process.env) {
218
+ if (env.SSH_CONNECTION || env.SSH_TTY || (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY))
219
+ return false;
220
+ const url = `http://127.0.0.1:${port}`;
221
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32.exe" : "xdg-open";
222
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
223
+ return new Promise((done) => {
224
+ const child = spawn(command, args, { stdio: "ignore", windowsHide: true });
225
+ const timer = setTimeout(() => { child.kill(); done(false); }, 3000);
226
+ child.once("error", () => { clearTimeout(timer); done(false); });
227
+ child.once("exit", (code) => { clearTimeout(timer); done(code === 0); });
228
+ });
229
+ }
230
+ /** A valid URL alone is not enough: its public descriptor must identify this
231
+ * exact server. This probe never sends a pairing code or an auth credential. */
232
+ export async function verifyPhoneEndpoint(port, origin) {
233
+ if (!normalizePhoneOrigin(origin))
234
+ return false;
235
+ try {
236
+ const local = await api(port, "/.well-known/openmausbot/environment");
237
+ const remote = await fetch(`${origin}/.well-known/openmausbot/environment`, { signal: AbortSignal.timeout(5000), redirect: "error" });
238
+ if (local.status !== 200 || !remote.ok)
239
+ return false;
240
+ const descriptor = await remote.json();
241
+ return typeof local.body?.environmentId === "string" && local.body.environmentId.length > 0
242
+ && descriptor.environmentId === local.body.environmentId;
243
+ }
244
+ catch {
245
+ return false;
246
+ }
247
+ }
248
+ export function applyStartupPreferences(options, saved) {
249
+ if (options.local)
250
+ return { ...options, tunnel: false, tailscale: false, publicUrl: undefined, phone: undefined };
251
+ if (!saved || options.tunnel || options.tailscale || options.publicUrl)
252
+ return options;
253
+ if (saved.access === "public-url" && (!saved.publicUrl || !normalizePhoneOrigin(saved.publicUrl))) {
254
+ throw new Error("The saved phone address is not a valid HTTPS origin. Run openmausbot setup to correct it, or openmausbot --local to start only on this computer.");
255
+ }
256
+ return {
257
+ ...options,
258
+ tunnel: saved.access === "tunnel", tailscale: saved.access === "tailscale",
259
+ publicUrl: saved.access === "public-url" ? saved.publicUrl : undefined,
260
+ phone: saved.access === "local" ? undefined : saved.phone,
261
+ };
262
+ }
263
+ function startupPreferences(options) {
264
+ const access = options.local ? "local" : options.tunnel ? "tunnel" : options.tailscale ? "tailscale" : options.publicUrl ? "public-url" : "local";
265
+ const publicUrl = access === "public-url" ? normalizePhoneOrigin(options.publicUrl) : null;
266
+ if (access === "public-url" && !publicUrl)
267
+ throw new Error("Use an HTTPS origin without a password, path or query for saved phone access. The address was not saved.");
268
+ return {
269
+ access,
270
+ ...(publicUrl ? { publicUrl } : {}),
271
+ ...(!options.local && options.phone ? { phone: options.phone } : {}),
272
+ };
273
+ }
274
+ async function showPhonePairing(options, origin, log) {
275
+ const ready = !!origin && await verifyPhoneEndpoint(options.port, origin);
276
+ if (!ready) {
277
+ log("Phone access is not reachable yet. Your local workspace is ready; no phone pairing code was created.");
278
+ log("Check the HTTPS connection, then run openmausbot pair again with the same --data-dir and --port.");
279
+ return false;
280
+ }
281
+ for (const line of phonePairingInstructions(options.phone ?? "ios", { origin: origin, ready }))
282
+ log(line);
283
+ log(await mintPairing(options.port, { client: true, label: options.label ?? (options.phone === "android" ? "Android" : "iPhone / iPad"), publicUrl: origin }));
284
+ log("Waiting for you to connect on the phone. Keep this terminal and the code private.");
285
+ return true;
286
+ }
177
287
  /** The pairing link a device opens, rendered as text and a QR code. */
178
288
  export function pairingBlock(input) {
179
289
  const lines = [`pairing code: ${input.code}`, `expires: ${new Date(input.expiresAt).toLocaleTimeString()} (single use)`];
@@ -214,6 +324,44 @@ export async function runPair(options) {
214
324
  console.error(`no OpenMausBot server on http://127.0.0.1:${options.port}; start one with \`openmausbot serve\` or set OMB_PORT`);
215
325
  return 1;
216
326
  }
327
+ if (process.stdin.isTTY && process.stdout.isTTY && !options.label && !options.client) {
328
+ const advertised = await api(options.port, "/api/auth/pairing");
329
+ let launch = options;
330
+ // The running server may use a one-time route override. Saved preferences
331
+ // describe the next launch, not necessarily the address working now.
332
+ let origin = options.publicUrl ?? (typeof advertised.body?.publicUrl === "string" ? advertised.body.publicUrl : undefined);
333
+ if (!origin) {
334
+ const { readCliStartup } = await import("./cli-setup.js");
335
+ launch = applyStartupPreferences(options, readCliStartup(options.dataDir));
336
+ origin = launch.publicUrl;
337
+ if (launch.tunnel)
338
+ origin = describeTunnelAccount(createTunnelAccount({ dataDir: options.dataDir, version: serverVersion() }).credentials.read()).address ?? undefined;
339
+ if (launch.tailscale) {
340
+ const status = await tailscaleStatus();
341
+ if (!("failure" in status) && status.status.dnsName)
342
+ origin = `https://${status.status.dnsName}`;
343
+ }
344
+ }
345
+ if (!origin || !normalizePhoneOrigin(origin)) {
346
+ console.log("Your workspace is running only on this computer. A phone cannot use its localhost address.");
347
+ console.log("Stop the server, run openmausbot setup and choose phone access, then start openmausbot again.");
348
+ return 1;
349
+ }
350
+ const ui = defaultSetupIo();
351
+ try {
352
+ const selected = await ui.choose("Which phone are you connecting?", ["iPhone / iPad — app or Safari", "Android — web browser", "Cancel"], 0);
353
+ if (selected === 2)
354
+ return 0;
355
+ launch = { ...launch, phone: selected === 0 ? "ios" : "android" };
356
+ return await showPhonePairing(launch, origin, ui.log) ? 0 : 1;
357
+ }
358
+ catch (error) {
359
+ if (!(error instanceof SetupCancelled))
360
+ throw error;
361
+ console.log("Pairing cancelled. Existing devices are unchanged.");
362
+ return 130;
363
+ }
364
+ }
217
365
  console.log(await mintPairing(options.port, { label: options.label, client: options.client, publicUrl: options.publicUrl }));
218
366
  if (options.client)
219
367
  console.log("(client scope: chat and approvals only; cannot change settings or pair others)");
@@ -343,6 +491,7 @@ export async function runLogout(options, io = defaultIo()) {
343
491
  return 0;
344
492
  }
345
493
  export async function runBrowser(options, io = defaultIo()) {
494
+ const { browserEngineStatus, describeBrowserEngine, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary } = await import("./browser-engine.js");
346
495
  const status = browserEngineStatus({ dataDir: options.dataDir });
347
496
  if (options.browserAction === "status") {
348
497
  io.log(describeBrowserEngine(status));
@@ -374,10 +523,12 @@ export async function runBrowser(options, io = defaultIo()) {
374
523
  catch (error) {
375
524
  io.error(`Chrome is not ready: ${message(error)}`);
376
525
  if (process.platform === "linux" && !options.withDeps)
377
- io.error("on Linux, Chrome needs system libraries: run `sudo openmausbot browser install --with-deps` once");
526
+ io.error("on Linux, install Chrome's system libraries with `sudo openmausbot browser install --with-deps`, then retry `openmausbot browser install` as the user running serve");
378
527
  return 1;
379
528
  }
380
- io.log("bots on this server can use a browser now: turn it on under Settings → Experimental, then per bot");
529
+ io.log("browser installed for this user and data directory; run serve as the same user, then enable it under Settings → Experimental and per bot");
530
+ if (process.platform === "linux" && options.withDeps)
531
+ io.log("if serve runs as another user, run `openmausbot browser install` from that user's login shell too");
381
532
  return 0;
382
533
  }
383
534
  /** Where the server bundle lives relative to this file: next to it in the
@@ -429,6 +580,7 @@ async function planTunnel(options, log) {
429
580
  return { access, binary, guardian, origin: createTunnelOrigin() };
430
581
  }
431
582
  export async function runServe(options, log = console.log) {
583
+ const { browserEngineStatus, describeBrowserEngine } = await import("./browser-engine.js");
432
584
  if (await serverUp(options.port)) {
433
585
  console.error(`something already answers on http://127.0.0.1:${options.port}; use \`openmausbot pair\` against it, or --port for a second server`);
434
586
  return 1;
@@ -464,6 +616,8 @@ export async function runServe(options, log = console.log) {
464
616
  OMB_PORT: String(options.port),
465
617
  OMB_WEBHOOK_PORT: process.env.OMB_WEBHOOK_PORT || String(options.port + 1),
466
618
  };
619
+ if (options.local)
620
+ delete env.OMB_PUBLIC_URL;
467
621
  if (entry.staticDir)
468
622
  env.OMB_STATIC_DIR = entry.staticDir;
469
623
  if (entry.skillsDir && !process.env.OMB_SKILLS_DIR)
@@ -472,21 +626,63 @@ export async function runServe(options, log = console.log) {
472
626
  env.OMB_ENVIRONMENT_LABEL = options.label;
473
627
  if (plan)
474
628
  env.OMB_TUNNEL_SOCKET = plan.origin.socketPath;
475
- if (tailscale) {
476
- const served = await tailscaleServe(tailscale, options.port);
477
- if ("failure" in served) {
478
- console.error(`--tailscale: ${explainTailscaleFailure(served.failure)}`);
479
- return 1;
629
+ let logPath;
630
+ let logFd;
631
+ let tailscaleServing = false;
632
+ let tailscaleAttempted = false;
633
+ let startupCancelled = false;
634
+ const cancelStartup = () => { startupCancelled = true; };
635
+ process.on("SIGINT", cancelStartup);
636
+ process.on("SIGTERM", cancelStartup);
637
+ let child;
638
+ try {
639
+ if (options.guided) {
640
+ const logsDir = join(options.dataDir, "logs");
641
+ mkdirSync(logsDir, { recursive: true, mode: 0o700 });
642
+ logPath = join(logsDir, `server-${Date.now()}-${process.pid}.log`);
643
+ logFd = openSync(logPath, "wx", 0o600);
644
+ log("\nStarting your workspace…");
480
645
  }
481
- publicUrl = served.origin;
482
- log(`tailscale: serving https://${tailscale.dnsName} → http://127.0.0.1:${options.port} (only your tailnet can reach it)`);
646
+ if (tailscale) {
647
+ tailscaleAttempted = true;
648
+ const served = await tailscaleServe(tailscale, options.port);
649
+ // The CLI can finish enabling background serving while cancellation is
650
+ // arriving. Wait for that bounded command, then undo it before exiting.
651
+ if (startupCancelled)
652
+ throw new SetupCancelled();
653
+ if ("failure" in served)
654
+ throw new Error(`--tailscale: ${explainTailscaleFailure(served.failure)}`);
655
+ tailscaleServing = true;
656
+ publicUrl = served.origin;
657
+ log(`tailscale: serving https://${tailscale.dnsName} → http://127.0.0.1:${options.port} (only your tailnet can reach it)`);
658
+ }
659
+ if (startupCancelled)
660
+ throw new SetupCancelled();
661
+ if (publicUrl)
662
+ env.OMB_PUBLIC_URL = publicUrl;
663
+ child = spawn(entry.command, entry.args, { env, stdio: ["ignore", logFd ?? "inherit", logFd ?? "inherit"] });
664
+ }
665
+ catch (error) {
666
+ if ((tailscaleServing || (startupCancelled && tailscaleAttempted)) && tailscale)
667
+ await tailscaleServeOff(tailscale).catch(() => undefined);
668
+ if (plan)
669
+ cleanupTunnelOrigin(plan.origin);
670
+ if (error instanceof SetupCancelled) {
671
+ log("Startup cancelled. No server was started; your saved work is unchanged.");
672
+ return 130;
673
+ }
674
+ throw error;
675
+ }
676
+ finally {
677
+ if (logFd !== undefined)
678
+ closeSync(logFd);
679
+ process.removeListener("SIGINT", cancelStartup);
680
+ process.removeListener("SIGTERM", cancelStartup);
483
681
  }
484
- if (publicUrl)
485
- env.OMB_PUBLIC_URL = publicUrl;
486
- const child = spawn(entry.command, entry.args, { env, stdio: ["ignore", "inherit", "inherit"] });
487
682
  let exited = null;
488
- child.on("exit", (code) => {
489
- exited = code ?? 1;
683
+ const childExit = new Promise((done) => {
684
+ child.once("error", () => { exited = 1; done(1); });
685
+ child.once("exit", (code, signal) => { exited = code ?? (signal === "SIGTERM" || signal === "SIGINT" ? 0 : 1); done(exited); });
490
686
  });
491
687
  let tunnel = null;
492
688
  let stopping = null;
@@ -495,60 +691,165 @@ export async function runServe(options, log = console.log) {
495
691
  // The gateway stops accepting before the server it forwards to goes away.
496
692
  if (tunnel)
497
693
  await tunnel.stop().catch(() => undefined);
498
- if (tailscale)
694
+ if (tailscaleServing && tailscale)
499
695
  await tailscaleServeOff(tailscale).catch(() => undefined);
500
- if (exited === null)
696
+ if (exited === null) {
501
697
  child.kill("SIGTERM");
698
+ const timer = setTimeout(() => child.kill("SIGKILL"), 10_000);
699
+ timer.unref();
700
+ await childExit;
701
+ clearTimeout(timer);
702
+ }
502
703
  if (plan)
503
704
  cleanupTunnelOrigin(plan.origin);
504
705
  })();
505
706
  return stopping;
506
707
  };
507
- process.on("SIGINT", () => void stop());
508
- process.on("SIGTERM", () => void stop());
509
- const deadline = Date.now() + 60_000;
510
- while (Date.now() < deadline && exited === null) {
511
- if (await serverUp(options.port))
512
- break;
513
- await new Promise((r) => setTimeout(r, 250));
514
- }
515
- if (exited !== null) {
516
- if (plan)
517
- cleanupTunnelOrigin(plan.origin);
518
- return exited;
708
+ const onSignal = () => { void stop(); };
709
+ process.on("SIGINT", onSignal);
710
+ process.on("SIGTERM", onSignal);
711
+ try {
712
+ const deadline = Date.now() + 60_000;
713
+ while (Date.now() < deadline && exited === null && !stopping) {
714
+ if (await serverUp(options.port, child.pid))
715
+ break;
716
+ await new Promise((r) => setTimeout(r, 250));
717
+ }
718
+ if (exited !== null) {
719
+ if (exited !== 0)
720
+ log(`OpenMausBot could not start.${logPath ? ` Details: ${logPath}` : " See the output above."}`);
721
+ return exited;
722
+ }
723
+ if (stopping)
724
+ return await childExit;
725
+ if (!(await serverUp(options.port, child.pid))) {
726
+ console.error(`OpenMausBot did not become ready within a minute.${logPath ? ` Details: ${logPath}` : " See its output above."}`);
727
+ await stop();
728
+ return 1;
729
+ }
730
+ if (stopping || exited !== null)
731
+ return await childExit;
732
+ if (plan && child.pid) {
733
+ tunnel = startTunnel({
734
+ dataDir: options.dataDir,
735
+ access: plan.access,
736
+ originTarget: { pid: child.pid, socketPath: plan.origin.socketPath },
737
+ binaryPath: plan.binary,
738
+ guardian: plan.guardian,
739
+ onState: (state) => log(describeTunnelState(state, plan.access.endpoint)),
740
+ });
741
+ tunnel.started.catch((error) => log(`tunnel: ${message(error)}`));
742
+ }
743
+ log("");
744
+ log(`OpenMausBot is running on http://127.0.0.1:${options.port}${publicUrl ? `, reachable at ${publicUrl}` : ""}`);
745
+ if (options.guided) {
746
+ log("Your bots and conversations are saved automatically.");
747
+ log(`Details if you need help: ${logPath}`);
748
+ if (options.open !== false && !await openDashboard(options.port))
749
+ log("Open the local address above in a browser on this computer.");
750
+ }
751
+ else {
752
+ log(`data: ${options.dataDir}`);
753
+ log(describeBrowserEngine(browserEngineStatus({ dataDir: options.dataDir })));
754
+ }
755
+ if (options.pair && options.phone) {
756
+ log("");
757
+ if (tunnel) {
758
+ // A connector may take a moment to become reachable; no pairing secret
759
+ // is created or sent to the public address until identity is verified.
760
+ log("Preparing the phone connection…");
761
+ let timer;
762
+ await Promise.race([tunnel.started.catch(() => undefined), childExit,
763
+ new Promise((done) => { timer = setTimeout(done, 15_000); })]);
764
+ if (timer)
765
+ clearTimeout(timer);
766
+ }
767
+ if (!stopping && exited === null)
768
+ await showPhonePairing(options, publicUrl, log);
769
+ }
770
+ else if (options.pair && !options.guided) {
771
+ log("");
772
+ log(await mintPairing(options.port, { label: options.label ? `${options.label} owner` : undefined, client: options.client, publicUrl: publicUrl ?? undefined }));
773
+ log("");
774
+ log("another device later: openmausbot pair --label \"Kitchen iPad\"");
775
+ }
776
+ log(options.guided ? "\nKeep this terminal open while using your bots. Ctrl+C stops the server, not your saved work." : "stop with Ctrl+C");
777
+ if (options.guided)
778
+ log("Next time: openmausbot · Change AI or phone setup: openmausbot setup · Pair another phone: openmausbot pair");
779
+ return await childExit;
519
780
  }
520
- if (!(await serverUp(options.port))) {
521
- console.error("the server did not answer within a minute; see its output above");
781
+ finally {
522
782
  await stop();
783
+ process.removeListener("SIGINT", onSignal);
784
+ process.removeListener("SIGTERM", onSignal);
785
+ }
786
+ }
787
+ /** Keep setup imports behind the data-dir override: config binds its paths
788
+ * when first imported. `serve` remains usable with stdin closed. */
789
+ export async function runOnboardingCommand(options, io = defaultIo(), startServer = runServe, flow = {}) {
790
+ const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
791
+ if (options.command === "setup" && !interactive) {
792
+ io.error("Setup needs an interactive terminal. Run `npx openmausbot setup` in a terminal, then use `npx openmausbot serve` for unattended starts.");
523
793
  return 1;
524
794
  }
525
- if (plan && child.pid) {
526
- tunnel = startTunnel({
527
- dataDir: options.dataDir,
528
- access: plan.access,
529
- originTarget: { pid: child.pid, socketPath: plan.origin.socketPath },
530
- binaryPath: plan.binary,
531
- guardian: plan.guardian,
532
- onState: (state) => log(describeTunnelState(state, plan.access.endpoint)),
533
- });
534
- tunnel.started.catch((error) => log(`tunnel: ${message(error)}`));
535
- }
536
- log("");
537
- log(`OpenMausBot is running on http://127.0.0.1:${options.port}${publicUrl ? `, reachable at ${publicUrl}` : ""}`);
538
- log(`data: ${options.dataDir}`);
539
- log(describeBrowserEngine(browserEngineStatus({ dataDir: options.dataDir })));
540
- if (options.pair) {
541
- log("");
542
- log(await mintPairing(options.port, { label: options.label ? `${options.label} owner` : undefined, publicUrl: publicUrl ?? undefined }));
543
- log("");
544
- log("another device later: openmausbot pair --label \"Kitchen iPad\"");
795
+ process.env.OMB_DATA_DIR = options.dataDir;
796
+ if (options.command !== "setup" && await (flow.running ?? isWorkspaceRunning)(options)) {
797
+ if (options.local || options.tunnel || options.tailscale || options.publicUrl) {
798
+ io.error("This workspace is already running. Stop it before changing local or remote access; the current connection was not changed.");
799
+ return 1;
800
+ }
801
+ io.log(`Your workspace is already running: http://127.0.0.1:${options.port}`);
802
+ io.log("No second server was started. Your existing bots and conversations are unchanged.");
803
+ if (interactive && options.open !== false)
804
+ await (flow.open ?? openDashboard)(options.port);
805
+ return 0;
806
+ }
807
+ const { runSetup, isSetupComplete, readCliStartup, saveCliStartup } = await import("./cli-setup.js");
808
+ const prompts = flow.prompts ?? defaultSetupIo();
809
+ try {
810
+ if (options.command === "setup" || !(await isSetupComplete(options.dataDir))) {
811
+ if (!interactive) {
812
+ io.error("No completed setup was found. Run `npx openmausbot setup` in an interactive terminal first, or use `npx openmausbot serve` with an existing configuration.");
813
+ return 1;
814
+ }
815
+ if (!(await runSetup({ dataDir: options.dataDir, port: options.port }))) {
816
+ io.log("Setup cancelled. Run openmausbot when you're ready.");
817
+ return 130;
818
+ }
819
+ }
820
+ const saved = readCliStartup(options.dataDir);
821
+ // An explicit setup revisits the access choice. Normal starts reuse consent
822
+ // instead of asking again or unexpectedly turning a local session public.
823
+ let launch = options.command === "setup" ? options : applyStartupPreferences(options, saved);
824
+ if (interactive && options.pair && !options.local && (options.command === "setup" || !saved)) {
825
+ io.log("\nOne optional step: connect your phone. You can skip this and start chatting here.");
826
+ const result = await (flow.phoneSetup ?? runPhoneSetup)(launch, prompts, {
827
+ accountReady: (value) => !!describeTunnelAccount(createTunnelAccount({ dataDir: value.dataDir, version: serverVersion() }).credentials.read()).email,
828
+ login: (value, ui) => runLogin(value, {
829
+ log: ui.log, error: ui.log,
830
+ ask: (question) => /code/i.test(question) ? ui.secret(question) : ui.ask(question),
831
+ }),
832
+ });
833
+ launch = { ...result.options, phone: result.phone };
834
+ saveCliStartup(options.dataDir, startupPreferences(launch));
835
+ }
836
+ if (options.command === "setup") {
837
+ io.log("\nAll set. Start with: openmausbot (or npx openmausbot without a global install).");
838
+ if (options.dataDir !== join(homedir(), ".openmausbot") || options.port !== 8799) {
839
+ io.log(`Use the same --data-dir (${options.dataDir}) and --port (${options.port}) options when starting.`);
840
+ }
841
+ return 0;
842
+ }
843
+ if (saved)
844
+ io.log("\nWelcome back. Using your saved AI connection.");
845
+ return startServer({ ...launch, guided: interactive });
846
+ }
847
+ catch (error) {
848
+ if (!(error instanceof SetupCancelled))
849
+ throw error;
850
+ io.log("\nSetup stopped. Any AI setup already saved is kept; no server was started. Run openmausbot setup to continue.");
851
+ return 130;
545
852
  }
546
- log("stop with Ctrl+C");
547
- return await new Promise((resolveExit) => {
548
- child.on("exit", (code) => {
549
- void stop().finally(() => resolveExit(code ?? 0));
550
- });
551
- });
552
853
  }
553
854
  export async function main(argv = process.argv.slice(2)) {
554
855
  const options = parseArgs(argv);
@@ -556,7 +857,11 @@ export async function main(argv = process.argv.slice(2)) {
556
857
  console.error(`${options.error}\n\n${USAGE}`);
557
858
  return 2;
558
859
  }
860
+ process.env.OMB_DATA_DIR = options.dataDir;
559
861
  switch (options.command) {
862
+ case "setup":
863
+ case "start":
864
+ return runOnboardingCommand(options);
560
865
  case "serve":
561
866
  return runServe(options);
562
867
  case "pair":
@@ -6,6 +6,7 @@ import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { z } from "zod";
8
8
  import { writeFileAtomic } from "./atomic.js";
9
+ import { EFFORT_LEVELS } from "./contracts.js";
9
10
  import { parseStoredMcpServer } from "./mcp-registry.js";
10
11
  import { parseJson, schemaIssue } from "./schema.js";
11
12
  const optionalText = z.string().optional();
@@ -209,7 +210,19 @@ const instanceConfigSchema = z.object({
209
210
  config: z.json().optional(),
210
211
  });
211
212
  const instanceConfigMapSchema = z.record(z.string(), instanceConfigSchema);
213
+ const defaultModelSelectionSchema = z.object({
214
+ instanceId: z.string().trim().min(1),
215
+ model: z.string().trim().min(1),
216
+ effort: z.enum(EFFORT_LEVELS).optional(),
217
+ });
212
218
  const appConfigSchema = z.object({
219
+ defaultModelSelection: defaultModelSelectionSchema.optional(),
220
+ /** CLI-only launch preferences. Never enable remote access implicitly. */
221
+ cliStartup: z.object({
222
+ access: z.enum(["local", "tunnel", "tailscale", "public-url"]),
223
+ publicUrl: z.string().url().optional(),
224
+ phone: z.enum(["ios", "android"]).optional(),
225
+ }).optional(),
213
226
  xai: z.object({ key: optionalText, url: optionalText }).optional(),
214
227
  /** `model` seeds the default selection; `provider` pins an OpenRouter
215
228
  * upstream (e.g. "fireworks"). Both are non-secret and optional. */
@@ -248,7 +261,7 @@ const appConfigSchema = z.object({
248
261
  const storedAppConfigSchema = appConfigSchema.extend({
249
262
  browserProfiles: storedBrowserProfilesSchema.optional(),
250
263
  });
251
- const appConfigPatchSchema = appConfigSchema.omit({ instances: true, mcpServers: true });
264
+ const appConfigPatchSchema = appConfigSchema.omit({ instances: true, mcpServers: true, cliStartup: true });
252
265
  const jsonObjectSchema = z.record(z.string(), z.json());
253
266
  /** Resolve a canonical profile record to its exact durable Electron
254
267
  * partition identity. Callers must never substitute the display/API id. */
@@ -530,6 +543,13 @@ export function saveConfig(patch) {
530
543
  // scalar, not a section: the merge loop above only walks objects
531
544
  if (checkedPatch.language !== undefined)
532
545
  disk.language = checkedPatch.language;
546
+ // A selection is replaced as one value, so changing engines also clears
547
+ // an effort level omitted from the new selection.
548
+ if (checkedPatch.defaultModelSelection !== undefined) {
549
+ disk.defaultModelSelection = checkedPatch.defaultModelSelection;
550
+ }
551
+ if (checkedPatch.cliStartup !== undefined)
552
+ disk.cliStartup = checkedPatch.cliStartup;
533
553
  // Custom MCP mutations go through their own dedicated local API, but
534
554
  // saveConfig remains the single atomic persistence boundary.
535
555
  if (checkedPatch.mcpServers !== undefined) {
@@ -718,6 +738,10 @@ export function instanceConfigs(cfg) {
718
738
  const merged = { ...current };
719
739
  // A per-instance value always wins over the workspace default.
720
740
  for (const [k, v] of Object.entries(defaults)) {
741
+ // Empty routing explicitly means "no upstream pin" for isolated
742
+ // API connections. Do not replace it with a workspace provider.
743
+ if (k === "provider" && typeof merged[k] === "string")
744
+ continue;
721
745
  if (typeof merged[k] !== "string" || !merged[k].trim())
722
746
  merged[k] = v;
723
747
  }