openmausbot 0.1.67 → 0.1.69

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 (41) hide show
  1. package/dist/assets/{index-BwpNqgCm.js → index-BmHupgVJ.js} +1 -1
  2. package/dist/assets/index-COp5lMnv.js +279 -0
  3. package/dist/assets/index-Jk7TIeyW.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +40 -2
  6. package/dist-server/drivers/agents-proxy.js +112 -7
  7. package/dist-server/enterprise/server/index.js +14587 -0
  8. package/dist-server/index.js +1732 -681
  9. package/dist-server/openmausbot.js +939 -381
  10. package/dist-server/pair-cli.js +939 -381
  11. package/dist-server/server/avatar-image.js +86 -34
  12. package/dist-server/server/bot-package.js +60 -14
  13. package/dist-server/server/brand.js +7 -0
  14. package/dist-server/server/cli.js +23 -1
  15. package/dist-server/server/config.js +30 -3
  16. package/dist-server/server/delegations.js +56 -11
  17. package/dist-server/server/drivers/acp/qwen.js +142 -5
  18. package/dist-server/server/drivers/agents-proxy.js +135 -8
  19. package/dist-server/server/drivers/codex-device-auth.js +102 -3
  20. package/dist-server/server/drivers/codex-identity.js +115 -0
  21. package/dist-server/server/drivers/codex.js +6 -0
  22. package/dist-server/server/harness/registry.js +2 -0
  23. package/dist-server/server/index.js +220 -65
  24. package/dist-server/server/package-export.js +7 -0
  25. package/dist-server/server/provider-auth-sessions.js +24 -0
  26. package/dist-server/server/routine-requests.js +270 -25
  27. package/dist-server/server/routines.js +215 -21
  28. package/dist-server/server/service-cli.js +63 -0
  29. package/dist-server/server/service-unit.js +129 -0
  30. package/dist-server/server/steer-queue.js +30 -1
  31. package/dist-server/server/store.js +30 -4
  32. package/dist-server/server/system-prompt.js +1 -0
  33. package/dist-server/server/turn-dispatch-guard.js +6 -0
  34. package/dist-server/shared/image-generation.js +28 -0
  35. package/dist-server/vps-container-mcp.js +40 -2
  36. package/enterprise/LICENSE +32 -0
  37. package/enterprise/README.md +89 -0
  38. package/enterprise/server/index.js +14587 -0
  39. package/package.json +2 -1
  40. package/dist/assets/index-A3CBTxwl.css +0 -1
  41. package/dist/assets/index-CDuyoVPJ.js +0 -278
@@ -1,4 +1,6 @@
1
1
  import { z } from "zod";
2
+ import { normalizeImageGenerationUrl } from "../shared/image-generation.js";
3
+ import { decodeGeneratedImage } from "./generated-image.js";
2
4
  export const AVATAR_DIRECTION_MAX_CHARS = 400;
3
5
  export const AVATAR_IMAGE_TIMEOUT_MS = 120_000;
4
6
  const MAX_UPSTREAM_RESPONSE_BYTES = 15 * 1024 * 1024;
@@ -6,8 +8,34 @@ export const avatarGenerationRequestSchema = z.object({
6
8
  prompt: z.string().trim().max(AVATAR_DIRECTION_MAX_CHARS).default(""),
7
9
  });
8
10
  const generatedImageResponseSchema = z.object({
9
- data: z.array(z.object({ b64_json: z.string().min(1) })).min(1),
11
+ data: z.array(z.object({ b64_json: z.string().min(1).optional(), url: z.string().optional() })).min(1),
10
12
  });
13
+ /** Public settings describe availability without returning provider secrets. */
14
+ export function avatarImageStatus(cfg) {
15
+ const provider = cfg.imageGen?.provider ?? "openai";
16
+ const openaiConfigured = Boolean(cfg.imageGen?.key?.trim());
17
+ const xaiConfigured = Boolean(cfg.xai?.key?.trim());
18
+ const customKeyConfigured = Boolean(cfg.imageGen?.customApiKey?.trim());
19
+ const customModel = cfg.imageGen?.customModel?.trim() ?? "";
20
+ let customUrl = "";
21
+ try {
22
+ if (cfg.imageGen?.customUrl?.trim())
23
+ customUrl = normalizeImageGenerationUrl(cfg.imageGen.customUrl);
24
+ }
25
+ catch {
26
+ // A manually edited invalid URL must not become an outbound request.
27
+ }
28
+ return {
29
+ provider,
30
+ configured: provider === "openai" ? openaiConfigured : provider === "xai" ? xaiConfigured : Boolean(customUrl && customModel),
31
+ model: provider === "openai" ? "gpt-image-2" : provider === "xai" ? "grok-imagine-image-2.0" : customModel,
32
+ customUrl,
33
+ customModel,
34
+ openaiConfigured,
35
+ xaiConfigured,
36
+ customKeyConfigured,
37
+ };
38
+ }
11
39
  /** Copy the mutable avatar fields before an asynchronous generation starts. */
12
40
  export function snapshotAvatarGenerationState(bot) {
13
41
  return { avatarUrl: bot.avatarUrl, avatarCrop: bot.avatarCrop };
@@ -53,7 +81,14 @@ async function boundedResponseText(response) {
53
81
  let received = 0;
54
82
  try {
55
83
  for (;;) {
56
- const { done, value } = await reader.read();
84
+ let chunk;
85
+ try {
86
+ chunk = await reader.read();
87
+ }
88
+ catch {
89
+ throw Object.assign(new Error("Could not read the generated avatar response"), { status: 502 });
90
+ }
91
+ const { done, value } = chunk;
57
92
  if (done)
58
93
  break;
59
94
  received += value.byteLength;
@@ -70,31 +105,45 @@ async function boundedResponseText(response) {
70
105
  reader.releaseLock();
71
106
  }
72
107
  }
73
- export async function generateAvatarImage(apiKey, bot, direction, fetchImpl = fetch, timeoutMs = AVATAR_IMAGE_TIMEOUT_MS) {
74
- if (!apiKey.trim())
75
- throw Object.assign(new Error("Add an OpenAI image API key first"), { status: 409 });
108
+ export async function generateAvatarImage(cfg, bot, direction, fetchImpl = fetch, timeoutMs = AVATAR_IMAGE_TIMEOUT_MS) {
109
+ const settings = avatarImageStatus(cfg);
110
+ const { provider, model } = settings;
111
+ if (!settings.configured) {
112
+ const message = provider === "openai" ? "Add an OpenAI image API key first"
113
+ : provider === "xai" ? "Add an xAI API key first"
114
+ : "Set a valid custom image API URL and model first";
115
+ throw Object.assign(new Error(message), { status: 409 });
116
+ }
117
+ const apiKey = (provider === "openai" ? cfg.imageGen?.key
118
+ : provider === "xai" ? cfg.xai?.key : cfg.imageGen?.customApiKey)?.trim();
119
+ const providerName = provider === "openai" ? "OpenAI" : provider === "xai" ? "xAI" : "Custom";
120
+ const url = provider === "openai" ? "https://api.openai.com/v1/images/generations"
121
+ : provider === "xai" ? "https://api.x.ai/v1/images/generations"
122
+ : `${settings.customUrl}/images/generations`;
123
+ const headers = { "content-type": "application/json" };
124
+ if (apiKey)
125
+ headers.authorization = `Bearer ${apiKey}`;
126
+ const body = {
127
+ model,
128
+ prompt: avatarGenerationPrompt(bot, direction),
129
+ ...(provider === "openai" ? { size: "1024x1024", quality: "low", output_format: "webp" }
130
+ : provider === "xai" ? { response_format: "b64_json", aspect_ratio: "1:1" }
131
+ : { response_format: "b64_json", size: "1024x1024", n: 1 }),
132
+ };
76
133
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
77
134
  let response;
78
135
  try {
79
- response = await fetchImpl("https://api.openai.com/v1/images/generations", {
136
+ response = await fetchImpl(url, {
80
137
  method: "POST",
81
- headers: {
82
- authorization: `Bearer ${apiKey.trim()}`,
83
- "content-type": "application/json",
84
- },
85
- body: JSON.stringify({
86
- model: "gpt-image-2",
87
- prompt: avatarGenerationPrompt(bot, direction),
88
- size: "1024x1024",
89
- quality: "low",
90
- output_format: "webp",
91
- }),
138
+ headers,
139
+ body: JSON.stringify(body),
140
+ redirect: "error",
92
141
  signal: timeoutSignal,
93
142
  });
94
143
  }
95
144
  catch (error) {
96
145
  const timedOut = timeoutSignal.aborted || (error instanceof Error && error.name === "TimeoutError");
97
- throw Object.assign(new Error(timedOut ? "Avatar generation timed out" : "Could not reach OpenAI image generation"), { status: 502 });
146
+ throw Object.assign(new Error(timedOut ? "Avatar generation timed out" : `Could not reach ${providerName} image generation`), { status: 502 });
98
147
  }
99
148
  let text;
100
149
  try {
@@ -110,15 +159,9 @@ export async function generateAvatarImage(apiKey, bot, direction, fetchImpl = fe
110
159
  throw error;
111
160
  }
112
161
  if (!response.ok) {
113
- let message = `OpenAI image generation failed (HTTP ${response.status})`;
114
- try {
115
- const parsed = z.object({ error: z.object({ message: z.string() }) }).safeParse(JSON.parse(text));
116
- if (parsed.success)
117
- message = parsed.data.error.message.slice(0, 500);
118
- }
119
- catch {
120
- // Keep the bounded status-only message for malformed upstream errors.
121
- }
162
+ // Upstream errors can repeat authorization headers, signed URLs, or other
163
+ // secrets. Return only the provider and HTTP status, never its raw body.
164
+ const message = `${providerName} image generation failed (HTTP ${response.status})`;
122
165
  throw Object.assign(new Error(message), { status: response.status === 401 ? 401 : 502 });
123
166
  }
124
167
  let parsedJson;
@@ -126,19 +169,28 @@ export async function generateAvatarImage(apiKey, bot, direction, fetchImpl = fe
126
169
  parsedJson = JSON.parse(text);
127
170
  }
128
171
  catch {
129
- throw Object.assign(new Error("OpenAI returned an invalid image response"), { status: 502 });
172
+ throw Object.assign(new Error(`${providerName} returned an invalid image response`), { status: 502 });
130
173
  }
131
174
  const parsed = generatedImageResponseSchema.safeParse(parsedJson);
132
175
  if (!parsed.success) {
133
- throw Object.assign(new Error("OpenAI returned no generated image"), { status: 502 });
176
+ throw Object.assign(new Error(`${providerName} returned no generated image`), { status: 502 });
134
177
  }
135
178
  const encoded = parsed.data.data[0].b64_json;
179
+ if (!encoded) {
180
+ // Do not fetch arbitrary URLs returned by a provider. Compatible gateways
181
+ // must support the requested inline base64 response format.
182
+ throw Object.assign(new Error(`${providerName} image generation must return base64 image data (b64_json)`), { status: 502 });
183
+ }
136
184
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 !== 0) {
137
- throw Object.assign(new Error("OpenAI returned invalid image data"), { status: 502 });
185
+ throw Object.assign(new Error(`${providerName} returned invalid image data`), { status: 502 });
138
186
  }
139
- const bytes = Buffer.from(encoded, "base64");
140
- if (bytes.byteLength === 0) {
141
- throw Object.assign(new Error("OpenAI returned an empty image"), { status: 502 });
187
+ try {
188
+ const image = decodeGeneratedImage(encoded);
189
+ if (image.mime === "image/gif")
190
+ throw new Error("unsupported avatar format");
191
+ return { bytes: image.bytes, mime: image.mime };
192
+ }
193
+ catch {
194
+ throw Object.assign(new Error(`${providerName} returned invalid or oversized image data; expected PNG, JPEG, or WebP`), { status: 502 });
142
195
  }
143
- return { bytes, mime: "image/webp" };
144
196
  }
@@ -27,6 +27,52 @@ const key = requiredText(64).regex(/^[a-z0-9][a-z0-9_-]*$/, {
27
27
  message: "may only contain lowercase letters, numbers, - and _",
28
28
  });
29
29
  const MAX_DATE_MS = 8_640_000_000_000_000;
30
+ const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
31
+ const intervalWeekdays = z.array(z.number().int().min(0).max(6)).min(1).max(7).refine((weekdays) => new Set(weekdays).size === weekdays.length, "must contain unique weekdays");
32
+ const intervalWindow = z.object({
33
+ start: z.string().regex(CLOCK_TIME, { message: "must use HH:MM" }),
34
+ end: z.string().regex(CLOCK_TIME, { message: "must use HH:MM" }),
35
+ }).strict().refine(({ start, end }) => start < end, {
36
+ message: "must end later on the same day",
37
+ });
38
+ const packageRoutineScheduleSchema = z.discriminatedUnion("type", [
39
+ z.object({ type: z.literal("once"), at: z.number().int() }),
40
+ z.object({
41
+ type: z.literal("daily"),
42
+ time: requiredText(5).regex(CLOCK_TIME, { message: "must use HH:MM" }),
43
+ weekdays: z.array(z.number().int().min(0).max(6)).min(1).max(7),
44
+ }),
45
+ z.object({
46
+ type: z.literal("interval"),
47
+ everyMinutes: z.number().int().min(5).max(1_440),
48
+ anchorAt: z.number().int().nonnegative().max(MAX_DATE_MS),
49
+ weekdays: intervalWeekdays.optional(),
50
+ window: intervalWindow.optional(),
51
+ endsAt: z.number().int().nonnegative().max(MAX_DATE_MS).optional(),
52
+ }),
53
+ ]).superRefine((schedule, context) => {
54
+ if (schedule.type !== "interval")
55
+ return;
56
+ if (schedule.window) {
57
+ const [startHour, startMinute] = schedule.window.start.split(":").map(Number);
58
+ const [endHour, endMinute] = schedule.window.end.split(":").map(Number);
59
+ const windowMinutes = endHour * 60 + endMinute - (startHour * 60 + startMinute);
60
+ if (windowMinutes < schedule.everyMinutes) {
61
+ context.addIssue({
62
+ code: "custom",
63
+ message: "must be at least as long as the interval cadence",
64
+ path: ["window"],
65
+ });
66
+ }
67
+ }
68
+ if (schedule.endsAt !== undefined && schedule.endsAt < schedule.anchorAt) {
69
+ context.addIssue({
70
+ code: "custom",
71
+ message: "must not be before the interval anchor",
72
+ path: ["endsAt"],
73
+ });
74
+ }
75
+ });
30
76
  const packageSchema = z.object({
31
77
  format: z.literal(BOT_PACKAGE_FORMAT, { error: "This is not an OpenMaus package" }),
32
78
  version: z.literal(BOT_PACKAGE_VERSION, { error: "Package version is not supported" }),
@@ -86,19 +132,7 @@ const packageSchema = z.object({
86
132
  agent: key,
87
133
  prompt: requiredText(20_000),
88
134
  runOn: z.enum(["maus", "cloud"]),
89
- schedule: z.discriminatedUnion("type", [
90
- z.object({ type: z.literal("once"), at: z.number().int() }),
91
- z.object({
92
- type: z.literal("daily"),
93
- time: requiredText(5).regex(/^([01]\d|2[0-3]):[0-5]\d$/, { message: "must use HH:MM" }),
94
- weekdays: z.array(z.number().int().min(0).max(6)).min(1).max(7),
95
- }),
96
- z.object({
97
- type: z.literal("interval"),
98
- everyMinutes: z.number().int().min(5).max(1_440),
99
- anchorAt: z.number().int().nonnegative().max(MAX_DATE_MS),
100
- }),
101
- ]),
135
+ schedule: packageRoutineScheduleSchema,
102
136
  durationMinutes: z.number().int().min(5).max(240),
103
137
  timeoutMinutes: z.number().int().min(5).max(240).optional(),
104
138
  enabledAfterInstall: z.literal(false),
@@ -200,6 +234,18 @@ export function parseBotPackage(value) {
200
234
  return parsed.data;
201
235
  }
202
236
  const list = (values) => values.map((value) => `- ${value}`).join("\n");
237
+ const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
238
+ function intervalScheduleText(schedule) {
239
+ const restrictions = [
240
+ schedule.weekdays?.length
241
+ ? `on ${schedule.weekdays.map((day) => WEEKDAY_NAMES[day]).join(", ")}`
242
+ : null,
243
+ schedule.window ? `during ${schedule.window.start}–${schedule.window.end}` : null,
244
+ schedule.endsAt !== undefined ? `until ${new Date(schedule.endsAt).toISOString()}` : null,
245
+ ].filter((part) => part !== null);
246
+ const cadence = `every ${schedule.everyMinutes} minutes from ${new Date(schedule.anchorAt).toISOString()}`;
247
+ return restrictions.length ? `${cadence}; ${restrictions.join("; ")}` : cadence;
248
+ }
203
249
  /** Render the public artifact. The frontmatter enables deterministic imports;
204
250
  * the body is deliberately complete enough for any Chief-of-Staff agent to
205
251
  * run without OpenMausBot or another proprietary parser. */
@@ -226,7 +272,7 @@ export function renderBotPackageMarkdown(document) {
226
272
  `**Schedule:** ${routine.schedule.type === "daily"
227
273
  ? `${routine.schedule.time} on weekdays ${routine.schedule.weekdays.join(", ")}`
228
274
  : routine.schedule.type === "interval"
229
- ? `every ${routine.schedule.everyMinutes} minutes from ${new Date(routine.schedule.anchorAt).toISOString()}`
275
+ ? intervalScheduleText(routine.schedule)
230
276
  : `once at ${routine.schedule.at}`} `,
231
277
  `**Run limit:** ${routine.timeoutMinutes === undefined ? "none" : `${routine.timeoutMinutes} minutes`} `,
232
278
  "**Initial state:** paused — the user must enable it",
@@ -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":
@@ -5,6 +5,7 @@ import { readFileSync, mkdirSync, existsSync, renameSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { z } from "zod";
8
+ import { normalizeImageGenerationUrl } from "../shared/image-generation.js";
8
9
  import { writeFileAtomic } from "./atomic.js";
9
10
  import { EFFORT_LEVELS } from "./contracts.js";
10
11
  import { parseStoredMcpServer } from "./mcp-registry.js";
@@ -16,6 +17,8 @@ const BROWSER_PROFILE_ID = /^[a-z0-9_-]{1,40}$/;
16
17
  export const DEFAULT_ROOM_TURN_TIMEOUT_MINUTES = 5;
17
18
  export const MIN_ROOM_TURN_TIMEOUT_MINUTES = 1;
18
19
  export const MAX_ROOM_TURN_TIMEOUT_MINUTES = 1_440;
20
+ export const DEFAULT_MAX_CONCURRENT_BOT_THREADS = 3;
21
+ export const MAX_CONCURRENT_BOT_THREADS = 10;
19
22
  export const DEFAULT_LOCAL_VM_MODE = "shared";
20
23
  export const DEFAULT_LOCAL_VM_MAX_INSTANCES = 2;
21
24
  export const MIN_LOCAL_VM_MAX_INSTANCES = 1;
@@ -245,14 +248,31 @@ const appConfigSchema = z.object({
245
248
  * engine: "elevenlabs" (default; needs a key) or "system" (the Mac's
246
249
  * built-in voices, no key). */
247
250
  tts: z.object({ key: optionalText, voice: optionalText, provider: z.enum(["elevenlabs", "system"]).optional() }).optional(),
248
- /** OpenAI key used only by the in-process avatar image generator. */
249
- imageGen: z.object({ key: optionalText }).optional(),
251
+ /** Avatar provider credentials stay separate; choosing a router never reuses a cloud key. */
252
+ imageGen: z.object({
253
+ provider: z.enum(["openai", "xai", "custom"]).optional(),
254
+ key: optionalText,
255
+ customApiKey: optionalText,
256
+ customUrl: z.string().trim().max(2048).transform((value, ctx) => {
257
+ if (!value)
258
+ return "";
259
+ try {
260
+ return normalizeImageGenerationUrl(value);
261
+ }
262
+ catch (error) {
263
+ ctx.addIssue({ code: "custom", message: error.message });
264
+ return z.NEVER;
265
+ }
266
+ }).optional(),
267
+ customModel: z.string().trim().max(200).refine((value) => !["\r", "\n", "\0"].some((character) => value.includes(character)), "Use a model ID without control characters").optional(),
268
+ }).optional(),
250
269
  /** Non-secret profile details shown in the sidebar. */
251
270
  profile: z.object({ name: optionalText, email: optionalText }).optional(),
252
271
  /** UI language override (BCP-47, lowercase). Empty/absent = follow the
253
272
  * system language. Unknown tags degrade to English in the renderer. */
254
273
  language: optionalText,
255
274
  rooms: roomConfigSchema.optional(),
275
+ threads: z.object({ maxConcurrentPerBot: z.number().int().min(1).max(MAX_CONCURRENT_BOT_THREADS) }).strict().optional(),
256
276
  localVm: localVmConfigSchema.optional(),
257
277
  features: featureConfigSchema.optional(),
258
278
  browserProfiles: browserProfilesSchema.optional(),
@@ -349,6 +369,9 @@ export function vpsSshAlias(cfg) {
349
369
  export function roomTurnTimeoutMinutes(cfg) {
350
370
  return cfg.rooms?.turnTimeoutMinutes ?? DEFAULT_ROOM_TURN_TIMEOUT_MINUTES;
351
371
  }
372
+ export function maxConcurrentBotThreads(cfg) {
373
+ return cfg.threads?.maxConcurrentPerBot ?? DEFAULT_MAX_CONCURRENT_BOT_THREADS;
374
+ }
352
375
  export function localVmMode(cfg) {
353
376
  return cfg.localVm?.mode ?? DEFAULT_LOCAL_VM_MODE;
354
377
  }
@@ -427,6 +450,8 @@ export function loadConfig() {
427
450
  cfg.imageGen = { ...cfg.imageGen };
428
451
  if (process.env.OMB_OPENAI_IMAGE_KEY !== undefined)
429
452
  cfg.imageGen.key = process.env.OMB_OPENAI_IMAGE_KEY;
453
+ if (process.env.OMB_CUSTOM_IMAGE_KEY !== undefined)
454
+ cfg.imageGen.customApiKey = process.env.OMB_CUSTOM_IMAGE_KEY;
430
455
  // The sign-in allow-list: env is how a headless box or a container is
431
456
  // bootstrapped before anyone can reach Settings.
432
457
  const splitEmails = (value) => value.split(/[,\s]+/).map((entry) => entry.trim().toLowerCase()).filter(Boolean);
@@ -455,6 +480,7 @@ export function syncCredentialEnv(patch) {
455
480
  [patch.opencodeGo?.apiKey, "OPENCODE_API_KEY"],
456
481
  [patch.tts?.key, "OMB_TTS_KEY"],
457
482
  [patch.imageGen?.key, "OMB_OPENAI_IMAGE_KEY"],
483
+ [patch.imageGen?.customApiKey, "OMB_CUSTOM_IMAGE_KEY"],
458
484
  ];
459
485
  for (const [value, name] of secrets) {
460
486
  if (value === undefined)
@@ -493,6 +519,7 @@ export const WORKSPACE_CREDENTIAL_ENV = [
493
519
  "OPENCODE_API_KEY",
494
520
  "OMB_TTS_KEY",
495
521
  "OMB_OPENAI_IMAGE_KEY",
522
+ "OMB_CUSTOM_IMAGE_KEY",
496
523
  "COMPOSIO_API_KEY",
497
524
  "OMB_COMPOSIO_BROKER_TOKEN",
498
525
  // Harness-private filesystem hints are not credentials themselves, but
@@ -544,7 +571,7 @@ export function saveConfig(patch, options = {}) {
544
571
  const storedProfiles = storedBrowserProfilesSchema.safeParse(disk.browserProfiles);
545
572
  if (storedProfiles.success)
546
573
  disk.browserProfiles = storedProfiles.data;
547
- for (const key of ["xai", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "localVm", "features"]) {
574
+ for (const key of ["xai", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "threads", "localVm", "features"]) {
548
575
  const section = checkedPatch[key];
549
576
  if (!section)
550
577
  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. */