openmausbot 0.1.67 → 0.1.68

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.
@@ -10,6 +10,7 @@ import { DATA_DIR } from "./config.js";
10
10
  import { entitled } from "./enterprise.js";
11
11
  const HEX_COLOUR = /^#[0-9a-fA-F]{6}$/;
12
12
  const MAX_LOGO_CHARS = 300_000;
13
+ const MAX_FAVICON_CHARS = 120_000;
13
14
  export const brandSchema = z
14
15
  .object({
15
16
  /** Product name shown in the window title, onboarding and settings. */
@@ -26,6 +27,12 @@ export const brandSchema = z
26
27
  .optional(),
27
28
  /** Where "get help" points for this deployment. */
28
29
  supportUrl: z.string().url().startsWith("https://", "supportUrl must be an https:// URL").optional(),
30
+ /** Tab and home-screen icon: an inline data:image/... URI (PNG or SVG, small) or an https:// URL. */
31
+ favicon: z
32
+ .string()
33
+ .max(MAX_FAVICON_CHARS, `favicon must be under ${MAX_FAVICON_CHARS} characters (a 64px PNG or a small SVG)`)
34
+ .refine((v) => v.startsWith("data:image/") || v.startsWith("https://"), "favicon must be a data:image/... URI or an https:// URL")
35
+ .optional(),
29
36
  })
30
37
  .strict();
31
38
  export const DEFAULT_BRAND = { name: "OpenMausBot" };
@@ -32,12 +32,13 @@ import qrcode from "qrcode-terminal";
32
32
  import { parseAllowList } from "./account-signin.js";
33
33
  import { writeFileAtomic } from "./atomic.js";
34
34
  import { ensureCaddy, normalizeDomainOption, startCaddy } from "./caddy.js";
35
+ import { runServiceCommand } from "./service-cli.js";
35
36
  import { explainTailscaleFailure, tailscaleServe, tailscaleServeOff, tailscaleStatus } from "./tailscale.js";
36
37
  import { defaultSetupIo, SetupCancelled } from "./cli-prompts.js";
37
38
  import { normalizePhoneOrigin, phonePairingInstructions, runPhoneSetup } from "./cli-phone-setup.js";
38
39
  import { cleanupTunnelOrigin, createTunnelAccount, createTunnelOrigin, describeTunnelAccount, describeTunnelState, ensureCloudflared, FLEET_CREDENTIAL_ENV, fleetAccess, fleetCredential, guardianEntry, startTunnel, tunnelAccess, } from "./tunnel.js";
39
40
  const HERE = dirname(fileURLToPath(import.meta.url));
40
- const COMMANDS = ["setup", "start", "serve", "pair", "sessions", "status", "login", "logout", "access", "browser", "help", "--help", "-h"];
41
+ const COMMANDS = ["setup", "start", "serve", "pair", "sessions", "status", "login", "logout", "access", "service", "browser", "help", "--help", "-h"];
41
42
  export function parseArgs(argv, env = process.env) {
42
43
  const implicitStart = !argv.length || (argv[0].startsWith("--") && argv[0] !== "--help");
43
44
  const [command = "start", ...rest] = implicitStart ? ["start", ...argv] : argv;
@@ -105,6 +106,8 @@ export function parseArgs(argv, env = process.env) {
105
106
  }
106
107
  else if (options.command === "access" && arg === "--chat-only")
107
108
  options.chatOnly = true;
109
+ else if (options.command === "service" && !options.serviceAction && (arg === "install" || arg === "uninstall"))
110
+ options.serviceAction = arg;
108
111
  else if (options.command === "browser" && (arg === "install" || arg === "status"))
109
112
  options.browserAction = arg;
110
113
  else if (options.command === "browser" && arg === "--with-deps")
@@ -124,6 +127,8 @@ export function parseArgs(argv, env = process.env) {
124
127
  return { error: "choose one of --tailscale (your tailnet) and --tunnel (a public address)" };
125
128
  if (options.command === "access" && !options.accessAction)
126
129
  return { error: "access needs one of: list, add EMAIL [--chat-only], remove EMAIL" };
130
+ if (options.command === "service" && !options.serviceAction)
131
+ return { error: "service needs one of: install [the same options as serve], uninstall" };
127
132
  if (options.domain && (options.tailscale || options.tunnel || options.publicUrl))
128
133
  return { error: "--domain already gives the server its address; drop --tailscale, --tunnel and --public-url" };
129
134
  if (options.local && (options.tailscale || options.tunnel || options.publicUrl))
@@ -145,6 +150,7 @@ export const USAGE = `openmausbot — your team of AI bots, ready in a few steps
145
150
  openmausbot login [--email you@example.com]
146
151
  openmausbot logout
147
152
  openmausbot access list | add EMAIL [--chat-only] | remove EMAIL
153
+ openmausbot service install [--domain HOST | --tunnel | --tailscale] [--port N] [--data-dir DIR] | uninstall
148
154
  openmausbot browser install [--with-deps] | status
149
155
 
150
156
  setup choose AI access and optional phone access; keep existing bots and chats
@@ -159,6 +165,10 @@ logout releases that address and signs out
159
165
  access who may sign in with an emailed code at /pair: an address or
160
166
  @domain; --chat-only gives chat and approvals without settings.
161
167
  Takes effect at once, no restart.
168
+ service keep the server running across reboots: writes a systemd unit
169
+ (Linux) or a launchd agent (macOS) for the same serve options and
170
+ prints the commands that install it. Install the package
171
+ permanently first (npm install -g openmausbot).
162
172
  browser install: the bots' browser engine (agent-browser, pinned) into the
163
173
  data dir, and Chrome for Testing into the user's browser cache.
164
174
  --with-deps also installs
@@ -1013,6 +1023,18 @@ export async function main(argv = process.argv.slice(2)) {
1013
1023
  return runLogin(options);
1014
1024
  case "access":
1015
1025
  return runAccess(options);
1026
+ case "service":
1027
+ return runServiceCommand({
1028
+ action: options.serviceAction ?? "install",
1029
+ dataDir: options.dataDir,
1030
+ port: options.port,
1031
+ domain: options.domain,
1032
+ tunnel: options.tunnel,
1033
+ tailscale: options.tailscale,
1034
+ label: options.label,
1035
+ script: process.argv[1] ?? "",
1036
+ node: process.execPath,
1037
+ }, { log: (line) => console.log(line), error: (line) => console.error(line) });
1016
1038
  case "logout":
1017
1039
  return runLogout(options);
1018
1040
  case "browser":
@@ -16,6 +16,8 @@ const BROWSER_PROFILE_ID = /^[a-z0-9_-]{1,40}$/;
16
16
  export const DEFAULT_ROOM_TURN_TIMEOUT_MINUTES = 5;
17
17
  export const MIN_ROOM_TURN_TIMEOUT_MINUTES = 1;
18
18
  export const MAX_ROOM_TURN_TIMEOUT_MINUTES = 1_440;
19
+ export const DEFAULT_MAX_CONCURRENT_BOT_THREADS = 3;
20
+ export const MAX_CONCURRENT_BOT_THREADS = 10;
19
21
  export const DEFAULT_LOCAL_VM_MODE = "shared";
20
22
  export const DEFAULT_LOCAL_VM_MAX_INSTANCES = 2;
21
23
  export const MIN_LOCAL_VM_MAX_INSTANCES = 1;
@@ -253,6 +255,7 @@ const appConfigSchema = z.object({
253
255
  * system language. Unknown tags degrade to English in the renderer. */
254
256
  language: optionalText,
255
257
  rooms: roomConfigSchema.optional(),
258
+ threads: z.object({ maxConcurrentPerBot: z.number().int().min(1).max(MAX_CONCURRENT_BOT_THREADS) }).strict().optional(),
256
259
  localVm: localVmConfigSchema.optional(),
257
260
  features: featureConfigSchema.optional(),
258
261
  browserProfiles: browserProfilesSchema.optional(),
@@ -349,6 +352,9 @@ export function vpsSshAlias(cfg) {
349
352
  export function roomTurnTimeoutMinutes(cfg) {
350
353
  return cfg.rooms?.turnTimeoutMinutes ?? DEFAULT_ROOM_TURN_TIMEOUT_MINUTES;
351
354
  }
355
+ export function maxConcurrentBotThreads(cfg) {
356
+ return cfg.threads?.maxConcurrentPerBot ?? DEFAULT_MAX_CONCURRENT_BOT_THREADS;
357
+ }
352
358
  export function localVmMode(cfg) {
353
359
  return cfg.localVm?.mode ?? DEFAULT_LOCAL_VM_MODE;
354
360
  }
@@ -544,7 +550,7 @@ export function saveConfig(patch, options = {}) {
544
550
  const storedProfiles = storedBrowserProfilesSchema.safeParse(disk.browserProfiles);
545
551
  if (storedProfiles.success)
546
552
  disk.browserProfiles = storedProfiles.data;
547
- for (const key of ["xai", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "localVm", "features"]) {
553
+ for (const key of ["xai", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "threads", "localVm", "features"]) {
548
554
  const section = checkedPatch[key];
549
555
  if (!section)
550
556
  continue;
@@ -251,7 +251,10 @@ export function queueDelegation(bus, from, item, maxDepth, sourceThreadId = from
251
251
  * of the target turn is delegated to `runTarget` so delegations.ts
252
252
  * stays free of harness-level concerns (commsDepth is the only thing
253
253
  * the caller needs). */
254
- export function drainDelegations(bus, approvalBus, threadId, runTarget) {
254
+ export function drainDelegations(bus, approvalBus, threadId, runTarget,
255
+ /** Terminal failures before dispatch also need to wake the source. A
256
+ * launched peer reports through its provider-turn finalizer instead. */
257
+ onSettled) {
255
258
  if (drainingThreads.has(threadId)) {
256
259
  queuedRedrains.add(threadId);
257
260
  return;
@@ -263,6 +266,10 @@ export function drainDelegations(bus, approvalBus, threadId, runTarget) {
263
266
  drainingThreads.add(threadId);
264
267
  void (async () => {
265
268
  for (const item of snapshot) {
269
+ // Stop/deletion may remove queued work while another item awaits a
270
+ // person's approval. A stale snapshot is never authority to launch it.
271
+ if (!pendingDelegations.get(threadId)?.some((candidate) => candidate.id === item.id))
272
+ continue;
266
273
  // A shared channel's thread is not owned by any single bot, so each
267
274
  // queued item carries its own source bot identity.
268
275
  const from = bus.store.botByThread(threadId) ??
@@ -307,8 +314,19 @@ export function drainDelegations(bus, approvalBus, threadId, runTarget) {
307
314
  finally {
308
315
  // A requeued item (busy target, retries left) stays for the drain
309
316
  // that the target's own settling turn will trigger.
317
+ const stillQueued = pendingDelegations.get(threadId)?.some((candidate) => candidate.id === item.id);
310
318
  if (outcome !== "requeued")
311
319
  acknowledgeDelegation(threadId, item.id);
320
+ if (outcome === "settled" && stillQueued) {
321
+ const receipt = findDelegationReceipt(item.id);
322
+ try {
323
+ if (receipt)
324
+ onSettled?.(receipt);
325
+ }
326
+ catch (error) {
327
+ console.error("delegation settled but its source could not be resumed", error);
328
+ }
329
+ }
312
330
  }
313
331
  }
314
332
  })().finally(() => {
@@ -321,7 +339,7 @@ export function drainDelegations(bus, approvalBus, threadId, runTarget) {
321
339
  const snapshotIds = new Set(snapshot.map((item) => item.id));
322
340
  const hasNewItems = pendingDelegations.get(threadId)?.some((item) => !snapshotIds.has(item.id)) ?? false;
323
341
  if (redrainRequested || hasNewItems) {
324
- drainDelegations(bus, approvalBus, threadId, runTarget);
342
+ drainDelegations(bus, approvalBus, threadId, runTarget, onSettled);
325
343
  }
326
344
  });
327
345
  }
@@ -368,6 +386,19 @@ export function discardDelegations(bus, threadId) {
368
386
  async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarget) {
369
387
  let sender = from;
370
388
  let target = bus.store.bot(item.toBotId);
389
+ // Retained approval authorizes the message, not a deleted conversation or
390
+ // revoked room membership. Check every retry before writing to its source.
391
+ if (!sourceThreadBelongsToBot(bus.store, sender.id, sourceThreadId)) {
392
+ recordDelegationReceipt({
393
+ id: item.id,
394
+ sourceThreadId,
395
+ toBotId: item.toBotId,
396
+ toBotName: target?.name ?? item.toBotId,
397
+ status: "dropped",
398
+ result: "the source conversation no longer belongs to the delegating bot",
399
+ });
400
+ return "settled";
401
+ }
371
402
  if (!target) {
372
403
  recordDelegationReceipt({
373
404
  id: item.id,
@@ -422,6 +453,23 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
422
453
  }
423
454
  if (sender.approvePeerComms && !item.approvalAlreadyGranted) {
424
455
  const verdict = await requestPeerApproval(approvalBus, sender, target, item.message, "delegate_bot", sourceThreadId);
456
+ if (!pendingDelegations.get(sourceThreadId)?.some((candidate) => candidate.id === item.id))
457
+ return "settled";
458
+ // Approval may have waited for minutes. Revalidate before even reporting
459
+ // a denial, which otherwise recreates a deleted source transcript.
460
+ const current = bus.store.bot(item.toBotId);
461
+ const currentSender = bus.store.bot(from.id);
462
+ if (!current || !currentSender || !sourceThreadBelongsToBot(bus.store, currentSender.id, sourceThreadId)) {
463
+ recordDelegationReceipt({
464
+ id: item.id,
465
+ sourceThreadId,
466
+ toBotId: item.toBotId,
467
+ toBotName: target.name,
468
+ status: "dropped",
469
+ result: "the peer or source conversation no longer exists",
470
+ });
471
+ return "settled";
472
+ }
425
473
  if (verdict !== "allow") {
426
474
  recordDelegationReceipt({
427
475
  id: item.id,
@@ -438,14 +486,11 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
438
486
  });
439
487
  return "settled";
440
488
  }
441
- // The approval could have been sitting for up to 15 minutes. Everything
442
- // checked above is a stale snapshot now: re-read both bots and re-check
443
- // busy, or an allow can start a second turn on a bot that is mid-turn —
444
- // and mirror a "Messaged @X" chip for an exchange that never happens.
445
- const current = bus.store.bot(item.toBotId);
446
- const currentSender = bus.store.bot(from.id);
447
- if (!current || !currentSender || !sourceThreadBelongsToBot(bus.store, currentSender.id, sourceThreadId))
448
- return "settled";
489
+ // A busy-target retry must not ask for the very same approval again.
490
+ item.approvalAlreadyGranted = true;
491
+ savePending();
492
+ // Recheck peer access and busy state too: an approval must not start a
493
+ // second turn or mirror an exchange that cannot actually happen.
449
494
  if (dropIfUnreachable(bus, currentSender, current, sourceThreadId, item)) {
450
495
  return "settled";
451
496
  }
@@ -490,7 +535,7 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
490
535
  const reasonLine = item.reason ? `\n\n[Reason: ${item.reason}]` : "";
491
536
  const prefixed = `[Delegated by @${sender.name}, another bot in this OpenMausBot workspace. Do the work and reply directly.]\n\n${item.message}${reasonLine}`;
492
537
  await runTarget(item.toBotId, prefixed, item.depth + 1, sourceThreadId, channel, item.id, sender.id);
493
- return "settled";
538
+ return "dispatched";
494
539
  }
495
540
  /** The source thread may be a bot's own task or a shared group where the
496
541
  * bot is a member. This is the same check the API gate uses for group turns. */
@@ -130,6 +130,20 @@ function normalizeScheduleInput(args) {
130
130
  if (!jsonRecord(raw))
131
131
  return { error: `The schedule must be a JSON object. ${SUPPORTED_SCHEDULES}` };
132
132
  const type = typeof raw.type === "string" ? raw.type.trim().toLowerCase() : "";
133
+ const fields = type === "once"
134
+ ? ["type", "at"]
135
+ : type === "weekly" || type === "daily"
136
+ ? ["type", "time", "weekdays"]
137
+ : type === "interval"
138
+ ? ["type", "every_minutes", "everyMinutes", "starts_at", "anchorAt"]
139
+ : null;
140
+ // Provider conversions may send unused optional fields as null. Ignore
141
+ // those, but never silently discard an actual scheduling constraint (for
142
+ // example timezone or a misspelled starts_at) and approve different work.
143
+ const unsupported = fields && Object.keys(raw).find((key) => raw[key] != null && !fields.includes(key));
144
+ if (unsupported) {
145
+ return { error: `Unsupported ${type} schedule field "${unsupported}". Weekly and daily times use the computer's timezone from list_routines. ${SUPPORTED_SCHEDULES}` };
146
+ }
133
147
  if (type === "once") {
134
148
  if (typeof raw.at !== "string" || !raw.at.trim()) {
135
149
  return { error: `A once schedule needs "at": a future RFC3339 date-time with an explicit offset, for example 2026-09-01T09:00:00+05:30.` };
@@ -500,7 +514,29 @@ function routineAction(value) {
500
514
  }
501
515
  function routineFields(args) {
502
516
  const fields = {};
503
- if (args.clear_timeout === true && typeof args.timeout_minutes === "number") {
517
+ // list_routines returns the harness names. Accept those when a model
518
+ // copies back a definition, as we already do for interval fields.
519
+ if (args.run_on != null && args.runOn != null && args.run_on !== args.runOn) {
520
+ return { fields, error: "Choose one run_on destination; run_on and runOn disagree." };
521
+ }
522
+ if (args.timeout_minutes != null && args.timeoutMinutes != null && args.timeout_minutes !== args.timeoutMinutes) {
523
+ return { fields, error: "Choose one timeout_minutes limit; timeout_minutes and timeoutMinutes disagree." };
524
+ }
525
+ const runOn = args.run_on ?? args.runOn;
526
+ const timeoutMinutes = args.timeout_minutes ?? args.timeoutMinutes;
527
+ if (runOn != null && runOn !== "maus" && runOn !== "cloud") {
528
+ return { fields, error: 'run_on must be "maus" or "cloud".' };
529
+ }
530
+ if (timeoutMinutes != null && (typeof timeoutMinutes !== "number" || !Number.isInteger(timeoutMinutes) || timeoutMinutes < 5 || timeoutMinutes > 240)) {
531
+ return { fields, error: "timeout_minutes must be a whole number from 5 to 240. Use clear_timeout to remove a limit." };
532
+ }
533
+ if (args.continuity != null && typeof args.continuity !== "boolean") {
534
+ return { fields, error: "continuity must be true or false." };
535
+ }
536
+ if (args.clear_timeout != null && typeof args.clear_timeout !== "boolean") {
537
+ return { fields, error: "clear_timeout must be true or false." };
538
+ }
539
+ if (args.clear_timeout === true && timeoutMinutes != null) {
504
540
  return { fields, error: "Choose timeout_minutes or clear_timeout, not both." };
505
541
  }
506
542
  if (typeof args.name === "string")
@@ -513,12 +549,12 @@ function routineFields(args) {
513
549
  return { fields, error: normalized.error };
514
550
  fields.schedule = normalized.schedule;
515
551
  }
516
- if (typeof args.run_on === "string")
517
- fields.runOn = args.run_on;
552
+ if (runOn != null)
553
+ fields.runOn = runOn;
518
554
  if (args.clear_timeout === true)
519
555
  fields.timeoutMinutes = null;
520
- else if (typeof args.timeout_minutes === "number")
521
- fields.timeoutMinutes = args.timeout_minutes;
556
+ else if (timeoutMinutes != null)
557
+ fields.timeoutMinutes = timeoutMinutes;
522
558
  if (typeof args.continuity === "boolean")
523
559
  fields.continuity = args.continuity;
524
560
  return { fields };