openmausbot 0.1.68 → 0.1.70

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-B2F__IRS.css +1 -0
  2. package/dist/assets/index-CLj3t5Ba.js +280 -0
  3. package/dist/assets/{index-HqooKFTS.js → index-D1RGhLUK.js} +1 -1
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +40 -2
  6. package/dist-server/drivers/agents-proxy.js +188 -5
  7. package/dist-server/index.js +2431 -984
  8. package/dist-server/local-computer-proxy.js +2 -0
  9. package/dist-server/openmausbot.js +923 -334
  10. package/dist-server/pair-cli.js +923 -334
  11. package/dist-server/prepare-cloudflared.js +20 -3
  12. package/dist-server/server/avatar-image.js +86 -34
  13. package/dist-server/server/bot-package.js +60 -14
  14. package/dist-server/server/config.js +23 -2
  15. package/dist-server/server/delegations.js +132 -74
  16. package/dist-server/server/drivers/acp/qwen.js +142 -5
  17. package/dist-server/server/drivers/agents-proxy.js +224 -4
  18. package/dist-server/server/drivers/claude-login-auth.js +102 -9
  19. package/dist-server/server/drivers/claude.js +11 -5
  20. package/dist-server/server/drivers/codex-device-auth.js +102 -3
  21. package/dist-server/server/drivers/codex-identity.js +115 -0
  22. package/dist-server/server/drivers/codex.js +6 -0
  23. package/dist-server/server/engine-install.js +137 -0
  24. package/dist-server/server/env-path.js +12 -0
  25. package/dist-server/server/harness/registry.js +33 -6
  26. package/dist-server/server/index.js +365 -26
  27. package/dist-server/server/package-export.js +7 -0
  28. package/dist-server/server/peer-provenance.js +6 -2
  29. package/dist-server/server/provider-auth-sessions.js +24 -0
  30. package/dist-server/server/routine-requests.js +270 -25
  31. package/dist-server/server/routines.js +297 -69
  32. package/dist-server/server/steer-queue.js +48 -1
  33. package/dist-server/server/store.js +22 -4
  34. package/dist-server/server/system-prompt.js +1 -0
  35. package/dist-server/server/team-backup.js +18 -4
  36. package/dist-server/shared/image-generation.js +28 -0
  37. package/dist-server/shared/team-backup.js +1 -0
  38. package/dist-server/vps-container-mcp.js +40 -2
  39. package/package.json +1 -1
  40. package/dist/assets/index-CbaejjQA.js +0 -278
  41. package/dist/assets/index-aJ5EWZOQ.css +0 -1
@@ -173,9 +173,25 @@ async function releaseBytes(asset) {
173
173
  const cached = cacheDirectory ? join(cacheDirectory, asset.name) : "";
174
174
  if (cached && existsSync(cached)) return readFileSync(cached);
175
175
  const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${asset.name}`;
176
- const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(12e4) });
177
- if (!response.ok) throw new Error(`could not download ${asset.name}: HTTP ${response.status}`);
178
- return Buffer.from(await response.arrayBuffer());
176
+ const retryableStatuses = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
177
+ for (let attempt = 1; attempt <= 3; attempt++) {
178
+ let failure;
179
+ try {
180
+ const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(12e4) });
181
+ if (response.ok) return Buffer.from(await response.arrayBuffer());
182
+ await response.body?.cancel().catch(() => {
183
+ });
184
+ failure = new Error(`could not download ${asset.name}: HTTP ${response.status}`);
185
+ if (!retryableStatuses.has(response.status)) throw failure;
186
+ } catch (error) {
187
+ if (!(error instanceof TypeError) && !["TimeoutError", "AbortError"].includes(error?.name)) throw error;
188
+ failure = error;
189
+ }
190
+ if (attempt === 3) throw failure;
191
+ const delay = 1e3 * 2 ** (attempt - 1);
192
+ console.warn(`cloudflared download attempt ${attempt}/3 failed: ${failure.message}; retrying in ${delay}ms`);
193
+ await new Promise((resolve) => setTimeout(resolve, delay));
194
+ }
179
195
  }
180
196
  async function stageTarget(root, target) {
181
197
  const asset = CLOUDFLARED_ASSETS[target];
@@ -251,6 +267,7 @@ export {
251
267
  executableTarget,
252
268
  parsePrepareCloudflaredArgs,
253
269
  prepareCloudflared,
270
+ releaseBytes,
254
271
  sha256,
255
272
  targetForCurrentHost,
256
273
  targetsForHost,
@@ -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",
@@ -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";
@@ -247,8 +248,24 @@ const appConfigSchema = z.object({
247
248
  * engine: "elevenlabs" (default; needs a key) or "system" (the Mac's
248
249
  * built-in voices, no key). */
249
250
  tts: z.object({ key: optionalText, voice: optionalText, provider: z.enum(["elevenlabs", "system"]).optional() }).optional(),
250
- /** OpenAI key used only by the in-process avatar image generator. */
251
- 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(),
252
269
  /** Non-secret profile details shown in the sidebar. */
253
270
  profile: z.object({ name: optionalText, email: optionalText }).optional(),
254
271
  /** UI language override (BCP-47, lowercase). Empty/absent = follow the
@@ -433,6 +450,8 @@ export function loadConfig() {
433
450
  cfg.imageGen = { ...cfg.imageGen };
434
451
  if (process.env.OMB_OPENAI_IMAGE_KEY !== undefined)
435
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;
436
455
  // The sign-in allow-list: env is how a headless box or a container is
437
456
  // bootstrapped before anyone can reach Settings.
438
457
  const splitEmails = (value) => value.split(/[,\s]+/).map((entry) => entry.trim().toLowerCase()).filter(Boolean);
@@ -461,6 +480,7 @@ export function syncCredentialEnv(patch) {
461
480
  [patch.opencodeGo?.apiKey, "OPENCODE_API_KEY"],
462
481
  [patch.tts?.key, "OMB_TTS_KEY"],
463
482
  [patch.imageGen?.key, "OMB_OPENAI_IMAGE_KEY"],
483
+ [patch.imageGen?.customApiKey, "OMB_CUSTOM_IMAGE_KEY"],
464
484
  ];
465
485
  for (const [value, name] of secrets) {
466
486
  if (value === undefined)
@@ -499,6 +519,7 @@ export const WORKSPACE_CREDENTIAL_ENV = [
499
519
  "OPENCODE_API_KEY",
500
520
  "OMB_TTS_KEY",
501
521
  "OMB_OPENAI_IMAGE_KEY",
522
+ "OMB_CUSTOM_IMAGE_KEY",
502
523
  "COMPOSIO_API_KEY",
503
524
  "OMB_COMPOSIO_BROKER_TOKEN",
504
525
  // Harness-private filesystem hints are not credentials themselves, but
@@ -86,19 +86,26 @@ export function threadsWaitingOn(toBotId) {
86
86
  }
87
87
  /** Mark a target's observed busy period as finished and return the source
88
88
  * threads that should be retried. This makes retries count distinct busy
89
- * periods, not unrelated drain requests on the same source thread. */
90
- export function releaseDelegationsWaitingOn(toBotId) {
91
- const threads = threadsWaitingOn(toBotId);
92
- if (!threads.length)
93
- return threads;
94
- for (const threadId of threads) {
95
- for (const item of pendingDelegations.get(threadId) ?? []) {
96
- if (item.toBotId === toBotId)
97
- delete item.waitingOnBusy;
89
+ * periods, not unrelated drain requests on the same source thread.
90
+ * `only` narrows the release: a bot that is still busy in one thread has
91
+ * nevertheless freed a slot for the fresh-thread handoffs waiting on it,
92
+ * while its active-thread handoffs go on waiting for it to go idle. */
93
+ export function releaseDelegationsWaitingOn(toBotId, only) {
94
+ const released = [];
95
+ for (const [threadId, items] of pendingDelegations) {
96
+ let any = false;
97
+ for (const item of items) {
98
+ if (item.toBotId !== toBotId || item.waitingOnBusy !== true || (only && !only(item)))
99
+ continue;
100
+ delete item.waitingOnBusy;
101
+ any = true;
98
102
  }
103
+ if (any)
104
+ released.push(threadId);
99
105
  }
100
- savePending();
101
- return threads;
106
+ if (released.length)
107
+ savePending();
108
+ return released;
102
109
  }
103
110
  function savePending() {
104
111
  try {
@@ -140,6 +147,9 @@ export function _loadPending() {
140
147
  if (typeof item.originatingGroupId === "string" && item.originatingGroupId) {
141
148
  loaded.originatingGroupId = item.originatingGroupId;
142
149
  }
150
+ if (typeof item.targetThreadId === "string" && item.targetThreadId) {
151
+ loaded.targetThreadId = item.targetThreadId;
152
+ }
143
153
  return [loaded];
144
154
  });
145
155
  if (items.length)
@@ -195,6 +205,7 @@ export function pendingDelegationSnapshot() {
195
205
  sourceBotId: item.sourceBotId,
196
206
  toBotId: item.toBotId,
197
207
  ...(item.reason ? { reason: item.reason } : {}),
208
+ ...(item.targetThreadId ? { targetThreadId: item.targetThreadId } : {}),
198
209
  })));
199
210
  }
200
211
  /** How many handoffs one turn may queue. Small on purpose: this is the only
@@ -231,18 +242,27 @@ export function queueDelegation(bus, from, item, maxDepth, sourceThreadId = from
231
242
  list.push({ ...item, id, sourceBotId: from.id, attempts: 0, ...(groupId ? { originatingGroupId: groupId } : {}) });
232
243
  pendingDelegations.set(sourceThreadId, list);
233
244
  savePending();
234
- const label = `Delegated to @${target.name}${item.reason ? `: ${item.reason}` : ""}`;
235
245
  const sourceGroup = sourceThreadId ? bus.store.groupByThread(sourceThreadId) : undefined;
236
- bus.store.appendMessage(sourceThreadId, {
246
+ // A fresh-thread handoff is announced as the thread it opened, with a
247
+ // link to it; a classic one as the delegation it is.
248
+ const openedThread = item.targetThreadId ? bus.store.taskByThread(target.id, item.targetThreadId) : undefined;
249
+ const chip = {
237
250
  role: "bot",
238
251
  kind: "activity",
239
252
  // settled at birth: queueing is the whole act. Left open, the chip
240
253
  // would spin until the transcript is closed — the chat never patches it
241
- tool: { name: label, ok: true },
242
- ...(sourceGroup && !sourceGroup.dm
243
- ? { from: { botId: from.id, name: from.name, color: from.color } }
244
- : {}),
245
- });
254
+ tool: {
255
+ name: openedThread
256
+ ? `Opened thread #${openedThread.title} on ${target.name}`
257
+ : `Delegated to @${target.name}${item.reason ? `: ${item.reason}` : ""}`,
258
+ ok: true,
259
+ },
260
+ };
261
+ if (openedThread)
262
+ chip.threadRef = { botId: target.id, threadId: openedThread.threadId, title: openedThread.title };
263
+ if (sourceGroup && !sourceGroup.dm)
264
+ chip.from = { botId: from.id, name: from.name, color: from.color };
265
+ bus.store.appendMessage(sourceThreadId, chip);
246
266
  return { result: "ok", id };
247
267
  }
248
268
  /** Drain queued delegations for a source thread (called on its
@@ -418,35 +438,12 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
418
438
  if (dropIfUnreachable(bus, sender, target, sourceThreadId, item)) {
419
439
  return "settled";
420
440
  }
421
- if (target.busy) {
422
- if (item.waitingOnBusy)
423
- return "requeued";
424
- item.attempts += 1;
425
- item.waitingOnBusy = true;
426
- if (item.attempts < MAX_BUSY_ATTEMPTS) {
427
- savePending();
428
- bus.store.appendMessage(sourceThreadId, {
429
- role: "bot",
430
- kind: "activity",
431
- tool: { name: `Delegation to @${target.name} waiting — they're busy (retry ${item.attempts}/${MAX_BUSY_ATTEMPTS} when they finish)` },
432
- });
433
- return "requeued";
434
- }
435
- recordDelegationReceipt({
436
- id: item.id,
437
- sourceThreadId,
438
- toBotId: target.id,
439
- toBotName: target.name,
440
- status: "busy_gave_up",
441
- result: `@${target.name} stayed busy through ${MAX_BUSY_ATTEMPTS} retries`,
442
- });
443
- bus.store.appendMessage(sourceThreadId, {
444
- role: "bot",
445
- kind: "activity",
446
- tool: { name: `Delegation to @${target.name} canceled — still busy after ${MAX_BUSY_ATTEMPTS} retries`, ok: false },
447
- });
441
+ if (dropIfThreadGone(bus, target, sourceThreadId, item)) {
448
442
  return "settled";
449
443
  }
444
+ const held = holdWhileTargetBusy(bus, target, sourceThreadId, item);
445
+ if (held)
446
+ return held;
450
447
  if (item.waitingOnBusy) {
451
448
  delete item.waitingOnBusy;
452
449
  savePending();
@@ -494,35 +491,12 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
494
491
  if (dropIfUnreachable(bus, currentSender, current, sourceThreadId, item)) {
495
492
  return "settled";
496
493
  }
497
- if (current.busy) {
498
- if (item.waitingOnBusy)
499
- return "requeued";
500
- item.attempts += 1;
501
- item.waitingOnBusy = true;
502
- if (item.attempts < MAX_BUSY_ATTEMPTS) {
503
- savePending();
504
- bus.store.appendMessage(sourceThreadId, {
505
- role: "bot",
506
- kind: "activity",
507
- tool: { name: `Delegation to @${current.name} waiting — they're busy (retry ${item.attempts}/${MAX_BUSY_ATTEMPTS} when they finish)` },
508
- });
509
- return "requeued";
510
- }
511
- recordDelegationReceipt({
512
- id: item.id,
513
- sourceThreadId,
514
- toBotId: current.id,
515
- toBotName: current.name,
516
- status: "busy_gave_up",
517
- result: `@${current.name} stayed busy through ${MAX_BUSY_ATTEMPTS} retries`,
518
- });
519
- bus.store.appendMessage(sourceThreadId, {
520
- role: "bot",
521
- kind: "activity",
522
- tool: { name: `Delegation to @${current.name} canceled — still busy after ${MAX_BUSY_ATTEMPTS} retries`, ok: false },
523
- });
494
+ if (dropIfThreadGone(bus, current, sourceThreadId, item)) {
524
495
  return "settled";
525
496
  }
497
+ const heldAfterApproval = holdWhileTargetBusy(bus, current, sourceThreadId, item);
498
+ if (heldAfterApproval)
499
+ return heldAfterApproval;
526
500
  sender = currentSender;
527
501
  target = current;
528
502
  }
@@ -533,10 +507,94 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
533
507
  const channel = getOrCreateChannel(bus.store, sender, target, originatingGroup);
534
508
  mirrorExchange(bus, sender, target, item.message, channel, sourceThreadId);
535
509
  const reasonLine = item.reason ? `\n\n[Reason: ${item.reason}]` : "";
536
- const prefixed = `[Delegated by @${sender.name}, another bot in this OpenMausBot workspace. Do the work and reply directly.]\n\n${item.message}${reasonLine}`;
537
- await runTarget(item.toBotId, prefixed, item.depth + 1, sourceThreadId, channel, item.id, sender.id);
510
+ // A fresh thread's first line gets the shared peer-provenance note from
511
+ // the harness (which knows whether the opener was unattended); the
512
+ // classic handoff keeps the prefix it has always had.
513
+ const prefixed = item.targetThreadId
514
+ ? item.message
515
+ : `[Delegated by @${sender.name}, another bot in this OpenMausBot workspace. Do the work and reply directly.]\n\n${item.message}${reasonLine}`;
516
+ await runTarget(item.toBotId, prefixed, item.depth + 1, sourceThreadId, channel, item.id, sender.id, item.targetThreadId);
538
517
  return "dispatched";
539
518
  }
519
+ /** A busy target holds the handoff. What "busy" means depends on where the
520
+ * turn will run: a classic delegation lands in the target's active thread,
521
+ * so it waits for the bot to go idle, with bounded retries so a stuck peer
522
+ * cannot pin the handoff forever. A fresh-thread handoff needs only a free
523
+ * slot, and waits in line for one without a bound — it IS the line, the
524
+ * same one a person's message joins at that limit. Returns null when the
525
+ * target can take the turn now. */
526
+ function holdWhileTargetBusy(bus, target, sourceThreadId, item) {
527
+ if (item.targetThreadId) {
528
+ if (bus.threadSlotFree ? bus.threadSlotFree(target.id) : !target.busy)
529
+ return null;
530
+ if (item.waitingOnBusy)
531
+ return "requeued";
532
+ item.waitingOnBusy = true;
533
+ savePending();
534
+ // one chip per wait, not one per retry: the line moves on every settle
535
+ if (item.attempts === 0) {
536
+ item.attempts = 1;
537
+ const title = bus.store.taskByThread(target.id, item.targetThreadId)?.title ?? "thread";
538
+ bus.store.appendMessage(sourceThreadId, {
539
+ role: "bot",
540
+ kind: "activity",
541
+ tool: { name: `Thread #${title} on @${target.name} waiting for a free slot` },
542
+ });
543
+ }
544
+ return "requeued";
545
+ }
546
+ if (!target.busy)
547
+ return null;
548
+ if (item.waitingOnBusy)
549
+ return "requeued";
550
+ item.attempts += 1;
551
+ item.waitingOnBusy = true;
552
+ if (item.attempts < MAX_BUSY_ATTEMPTS) {
553
+ savePending();
554
+ bus.store.appendMessage(sourceThreadId, {
555
+ role: "bot",
556
+ kind: "activity",
557
+ tool: { name: `Delegation to @${target.name} waiting — they're busy (retry ${item.attempts}/${MAX_BUSY_ATTEMPTS} when they finish)` },
558
+ });
559
+ return "requeued";
560
+ }
561
+ recordDelegationReceipt({
562
+ id: item.id,
563
+ sourceThreadId,
564
+ toBotId: target.id,
565
+ toBotName: target.name,
566
+ status: "busy_gave_up",
567
+ result: `@${target.name} stayed busy through ${MAX_BUSY_ATTEMPTS} retries`,
568
+ });
569
+ bus.store.appendMessage(sourceThreadId, {
570
+ role: "bot",
571
+ kind: "activity",
572
+ tool: { name: `Delegation to @${target.name} canceled — still busy after ${MAX_BUSY_ATTEMPTS} retries`, ok: false },
573
+ });
574
+ return "settled";
575
+ }
576
+ /** The thread a fresh-thread handoff was opened in may be deleted while the
577
+ * handoff waits. There is nowhere for the turn to run then, and running it
578
+ * in the target's active thread would put a bot's job in front of the
579
+ * person unasked. */
580
+ function dropIfThreadGone(bus, target, sourceThreadId, item) {
581
+ if (!item.targetThreadId || bus.store.taskByThread(target.id, item.targetThreadId))
582
+ return false;
583
+ recordDelegationReceipt({
584
+ id: item.id,
585
+ sourceThreadId,
586
+ toBotId: target.id,
587
+ toBotName: target.name,
588
+ status: "dropped",
589
+ result: `the thread opened on @${target.name} was deleted before it could start`,
590
+ });
591
+ bus.store.appendMessage(sourceThreadId, {
592
+ role: "bot",
593
+ kind: "activity",
594
+ tool: { name: `Thread on @${target.name} canceled — it was deleted before it could start`, ok: false },
595
+ });
596
+ return true;
597
+ }
540
598
  /** The source thread may be a bot's own task or a shared group where the
541
599
  * bot is a member. This is the same check the API gate uses for group turns. */
542
600
  function sourceThreadBelongsToBot(store, botId, threadId) {