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.
- package/dist/assets/index-B2F__IRS.css +1 -0
- package/dist/assets/index-CLj3t5Ba.js +280 -0
- package/dist/assets/{index-HqooKFTS.js → index-D1RGhLUK.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +40 -2
- package/dist-server/drivers/agents-proxy.js +188 -5
- package/dist-server/index.js +2431 -984
- package/dist-server/local-computer-proxy.js +2 -0
- package/dist-server/openmausbot.js +923 -334
- package/dist-server/pair-cli.js +923 -334
- package/dist-server/prepare-cloudflared.js +20 -3
- package/dist-server/server/avatar-image.js +86 -34
- package/dist-server/server/bot-package.js +60 -14
- package/dist-server/server/config.js +23 -2
- package/dist-server/server/delegations.js +132 -74
- package/dist-server/server/drivers/acp/qwen.js +142 -5
- package/dist-server/server/drivers/agents-proxy.js +224 -4
- package/dist-server/server/drivers/claude-login-auth.js +102 -9
- package/dist-server/server/drivers/claude.js +11 -5
- package/dist-server/server/drivers/codex-device-auth.js +102 -3
- package/dist-server/server/drivers/codex-identity.js +115 -0
- package/dist-server/server/drivers/codex.js +6 -0
- package/dist-server/server/engine-install.js +137 -0
- package/dist-server/server/env-path.js +12 -0
- package/dist-server/server/harness/registry.js +33 -6
- package/dist-server/server/index.js +365 -26
- package/dist-server/server/package-export.js +7 -0
- package/dist-server/server/peer-provenance.js +6 -2
- package/dist-server/server/provider-auth-sessions.js +24 -0
- package/dist-server/server/routine-requests.js +270 -25
- package/dist-server/server/routines.js +297 -69
- package/dist-server/server/steer-queue.js +48 -1
- package/dist-server/server/store.js +22 -4
- package/dist-server/server/system-prompt.js +1 -0
- package/dist-server/server/team-backup.js +18 -4
- package/dist-server/shared/image-generation.js +28 -0
- package/dist-server/shared/team-backup.js +1 -0
- package/dist-server/vps-container-mcp.js +40 -2
- package/package.json +1 -1
- package/dist/assets/index-CbaejjQA.js +0 -278
- 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
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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(
|
|
74
|
-
|
|
75
|
-
|
|
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(
|
|
136
|
+
response = await fetchImpl(url, {
|
|
80
137
|
method: "POST",
|
|
81
|
-
headers
|
|
82
|
-
|
|
83
|
-
|
|
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" :
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
185
|
+
throw Object.assign(new Error(`${providerName} returned invalid image data`), { status: 502 });
|
|
138
186
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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:
|
|
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
|
-
?
|
|
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
|
-
/**
|
|
251
|
-
imageGen: z.object({
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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: {
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
537
|
-
|
|
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) {
|