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
|
@@ -2,13 +2,109 @@
|
|
|
2
2
|
// the official pane has no Qwen Cloud catalog; live local hosts land in
|
|
3
3
|
// Custom and are written into ~/.qwen/settings.json modelProviders.
|
|
4
4
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { join } from "node:path";
|
|
7
8
|
import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../local-inject.js";
|
|
8
9
|
import { createAcpDriver } from "./core.js";
|
|
9
10
|
const EMPTY = { default: "", options: [] };
|
|
10
11
|
function qwenHome(env) {
|
|
11
|
-
|
|
12
|
+
const home = process.platform === "win32"
|
|
13
|
+
? env.USERPROFILE || env.HOME || homedir()
|
|
14
|
+
: env.HOME || env.USERPROFILE || homedir();
|
|
15
|
+
return join(home, ".qwen");
|
|
16
|
+
}
|
|
17
|
+
function isRecord(value) {
|
|
18
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
function qwenModelLabel(id, name) {
|
|
21
|
+
if (!name || name.toLocaleLowerCase().endsWith(id.toLocaleLowerCase()))
|
|
22
|
+
return id;
|
|
23
|
+
return `${id} — ${name}`;
|
|
24
|
+
}
|
|
25
|
+
const PROTOCOLS = new Set(["openai", "anthropic", "gemini", "vertex-ai"]);
|
|
26
|
+
function readQwenRoutes(env) {
|
|
27
|
+
let settings;
|
|
28
|
+
try {
|
|
29
|
+
settings = JSON.parse(readFileSync(join(qwenHome(env), "settings.json"), "utf8"));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
if (!isRecord(settings) || !isRecord(settings.modelProviders))
|
|
35
|
+
return [];
|
|
36
|
+
const routes = [];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const mappings = isRecord(settings.providerProtocol) ? settings.providerProtocol : {};
|
|
39
|
+
for (const [provider, rows] of Object.entries(settings.modelProviders)) {
|
|
40
|
+
const protocol = Object.hasOwn(mappings, provider) ? mappings[provider] : provider;
|
|
41
|
+
if (typeof protocol !== "string" || !PROTOCOLS.has(protocol) || !Array.isArray(rows))
|
|
42
|
+
continue;
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
if (!isRecord(row) || typeof row.id !== "string")
|
|
45
|
+
continue;
|
|
46
|
+
const model = row.id;
|
|
47
|
+
if (!model.trim())
|
|
48
|
+
continue;
|
|
49
|
+
if (row.baseUrl !== undefined && typeof row.baseUrl !== "string")
|
|
50
|
+
continue;
|
|
51
|
+
const baseUrl = typeof row.baseUrl === "string" && row.baseUrl ? row.baseUrl : undefined;
|
|
52
|
+
if (baseUrl) {
|
|
53
|
+
try {
|
|
54
|
+
new URL(baseUrl);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error("A Qwen model has an invalid endpoint. Fix its baseUrl in Qwen settings and refresh models.");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const identity = JSON.stringify([protocol, model, baseUrl ?? null]);
|
|
61
|
+
if (seen.has(identity))
|
|
62
|
+
continue;
|
|
63
|
+
seen.add(identity);
|
|
64
|
+
if (row.imageOnly === true || row.voiceOnly === true || row.fastOnly === true)
|
|
65
|
+
continue;
|
|
66
|
+
routes.push({ id: `${model}(${protocol})`, model, provider, protocol, baseUrl,
|
|
67
|
+
label: typeof row.name === "string" && row.name ? row.name : model,
|
|
68
|
+
envKey: typeof row.envKey === "string" ? row.envKey : undefined });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Qwen's ACP route identity, from QwenLM/qwen-code acpModelUtils.ts
|
|
72
|
+
// (Copyright 2025 Qwen Team, Apache-2.0). Never publish endpoints or env keys.
|
|
73
|
+
const counts = new Map();
|
|
74
|
+
for (const route of routes)
|
|
75
|
+
counts.set(route.id, (counts.get(route.id) ?? 0) + 1);
|
|
76
|
+
for (const route of routes) {
|
|
77
|
+
if (counts.get(route.id) === 1)
|
|
78
|
+
continue;
|
|
79
|
+
const endpoint = route.baseUrl ?? (route.protocol === "openai" ? "https://api.openai.com/v1" : "");
|
|
80
|
+
let publicEndpoint = null;
|
|
81
|
+
if (endpoint) {
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(endpoint);
|
|
84
|
+
url.username = "";
|
|
85
|
+
url.password = "";
|
|
86
|
+
url.search = "";
|
|
87
|
+
url.hash = "";
|
|
88
|
+
publicEndpoint = url.href;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new Error("A Qwen model has an invalid endpoint. Fix its baseUrl in Qwen settings and refresh models.");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const identity = [route.id, route.label, route.envKey ?? null, route.baseUrl === undefined, publicEndpoint];
|
|
95
|
+
route.id = `qwen-route:v1:${createHash("sha256").update(JSON.stringify(identity)).digest("base64url").slice(0, 16)}`;
|
|
96
|
+
}
|
|
97
|
+
if (new Set(routes.map((route) => route.id)).size !== routes.length) {
|
|
98
|
+
throw new Error("Qwen has indistinguishable model routes. Give them distinct names, envKey values, or public endpoints in Qwen settings.");
|
|
99
|
+
}
|
|
100
|
+
return routes;
|
|
101
|
+
}
|
|
102
|
+
/** Public metadata only. Use Qwen's provider-qualified ACP selectors, not raw model IDs. */
|
|
103
|
+
export function readQwenModelCatalog(env = process.env) {
|
|
104
|
+
const options = readQwenRoutes(env).map(({ id, model, label, provider }) => ({
|
|
105
|
+
id, label: qwenModelLabel(model, label), custom: true, provider,
|
|
106
|
+
}));
|
|
107
|
+
return { default: options[0]?.id ?? "", options };
|
|
12
108
|
}
|
|
13
109
|
function envKeyFor(hostId) {
|
|
14
110
|
return `OPENMAUSBOT_QWEN_${hostId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
|
|
@@ -68,8 +164,46 @@ export function ensureQwenInjectModel(modelId, env = process.env) {
|
|
|
68
164
|
return inject.model;
|
|
69
165
|
}
|
|
70
166
|
async function resolveModels(env) {
|
|
71
|
-
const catalog = await mergeLocalInject(
|
|
72
|
-
|
|
167
|
+
const catalog = await mergeLocalInject(readQwenModelCatalog(env), env);
|
|
168
|
+
const routes = readQwenRoutes(env);
|
|
169
|
+
const replacements = new Map();
|
|
170
|
+
for (const option of catalog.options) {
|
|
171
|
+
const injected = decodeInjectId(option.id);
|
|
172
|
+
if (!injected)
|
|
173
|
+
continue;
|
|
174
|
+
const endpoint = localHost(injected.host)?.baseUrl;
|
|
175
|
+
for (const route of routes) {
|
|
176
|
+
if (route.protocol === "openai" && route.model === injected.model && route.baseUrl === endpoint) {
|
|
177
|
+
replacements.set(route.id, option.id);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const options = catalog.options.filter((option) => !replacements.has(option.id));
|
|
182
|
+
const preferred = replacements.get(catalog.default) ?? catalog.default;
|
|
183
|
+
return { default: options.some((option) => option.id === preferred) ? preferred : options[0]?.id ?? "", options };
|
|
184
|
+
}
|
|
185
|
+
export function resolveQwenTurnModel(model, env) {
|
|
186
|
+
if (!model)
|
|
187
|
+
return model;
|
|
188
|
+
const injected = decodeInjectId(model);
|
|
189
|
+
if (injected)
|
|
190
|
+
ensureQwenInjectModel(model, env);
|
|
191
|
+
const routes = readQwenRoutes(env);
|
|
192
|
+
if (injected) {
|
|
193
|
+
const route = routes.find((row) => row.protocol === "openai" && row.model === injected.model
|
|
194
|
+
&& row.baseUrl === localHost(injected.host)?.baseUrl);
|
|
195
|
+
if (!route)
|
|
196
|
+
throw new Error("Qwen could not configure this local model. Refresh models and select it again.");
|
|
197
|
+
return route.id;
|
|
198
|
+
}
|
|
199
|
+
if (routes.some((route) => route.id === model))
|
|
200
|
+
return model;
|
|
201
|
+
const matches = routes.filter((route) => route.model === model);
|
|
202
|
+
if (matches.length > 1)
|
|
203
|
+
throw new Error("This Qwen model has multiple providers. Refresh models and select the intended provider again.");
|
|
204
|
+
if (!matches.length)
|
|
205
|
+
throw new Error("This Qwen model is no longer configured. Refresh models and select it again.");
|
|
206
|
+
return matches[0].id;
|
|
73
207
|
}
|
|
74
208
|
const support = {
|
|
75
209
|
driverKind: "qwenAgent",
|
|
@@ -77,7 +211,7 @@ const support = {
|
|
|
77
211
|
access: "custom",
|
|
78
212
|
models: EMPTY,
|
|
79
213
|
resolveModels,
|
|
80
|
-
resolveTurnModel:
|
|
214
|
+
resolveTurnModel: resolveQwenTurnModel,
|
|
81
215
|
defaultCli: "qwen",
|
|
82
216
|
nativeSource: "qwen.acp",
|
|
83
217
|
loginNote: "Qwen Code CLI is not installed",
|
|
@@ -89,7 +223,10 @@ const support = {
|
|
|
89
223
|
},
|
|
90
224
|
docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/overview/",
|
|
91
225
|
},
|
|
92
|
-
|
|
226
|
+
// A raw -m only changes the model within the saved provider. ACP switches
|
|
227
|
+
// the complete route and confirms it before any prompt leaves OMB.
|
|
228
|
+
spawnArgs: () => ["--acp"],
|
|
229
|
+
selectModel: { configId: "model" },
|
|
93
230
|
pickAuthMethod: () => null,
|
|
94
231
|
authFailure: "continue",
|
|
95
232
|
isAuthenticated: () => true,
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
// immediately, the peer runs after your
|
|
14
14
|
// current turn finishes, the result is
|
|
15
15
|
// delivered to the source conversation
|
|
16
|
+
// start_thread(title, msg, bot_id?) → open a real thread — on yourself for
|
|
17
|
+
// separate work, or on a teammate as a
|
|
18
|
+
// handoff that runs on its own
|
|
16
19
|
// create_bot(name, role, instructions) → Chiefs can add a specialist to
|
|
17
20
|
// their own section
|
|
18
21
|
// request_credential(id, reason?) → show a secure, allowlisted key card
|
|
@@ -45,6 +48,12 @@ let createdThisTurn = 0;
|
|
|
45
48
|
// so the refusal reaches the model without a round trip.
|
|
46
49
|
const MAX_ROOM_POSTS_PER_TURN = 3;
|
|
47
50
|
let roomPostsThisTurn = 0;
|
|
51
|
+
// A thread is a real turn with its own run. Five in one turn is a plan
|
|
52
|
+
// ("one per pull request"); more than that is a model that has stopped
|
|
53
|
+
// deciding. The harness holds the same ceiling; this copy exists so the
|
|
54
|
+
// refusal reaches the model without a round trip.
|
|
55
|
+
const MAX_THREADS_PER_TURN = 5;
|
|
56
|
+
let threadsOpenedThisTurn = 0;
|
|
48
57
|
const delegationTaskIdsThisTurn = new Set();
|
|
49
58
|
const WEEKDAYS = [
|
|
50
59
|
"monday",
|
|
@@ -64,7 +73,7 @@ const WEEKDAYS = [
|
|
|
64
73
|
const ROUTINE_SCHEDULE_SCHEMA = {
|
|
65
74
|
type: "object",
|
|
66
75
|
additionalProperties: false,
|
|
67
|
-
description: 'Either {"type":"once","at":RFC3339} for one future run, {"type":"weekly","time":"HH:MM","weekdays":[...]} for chosen days, {"type":"daily","time":"HH:MM"} for every day, or {"type":"interval","every_minutes":15
|
|
76
|
+
description: 'Either {"type":"once","at":RFC3339} for one future run, {"type":"weekly","time":"HH:MM","weekdays":[...]} for chosen days, {"type":"daily","time":"HH:MM"} for every day, or {"type":"interval","every_minutes":15} to repeat. Intervals can optionally be limited with weekdays, window_start + window_end, and ends_at.',
|
|
68
77
|
properties: {
|
|
69
78
|
type: {
|
|
70
79
|
type: "string",
|
|
@@ -82,7 +91,7 @@ const ROUTINE_SCHEDULE_SCHEMA = {
|
|
|
82
91
|
weekdays: {
|
|
83
92
|
type: "array",
|
|
84
93
|
items: { type: "string", enum: WEEKDAYS },
|
|
85
|
-
description: "
|
|
94
|
+
description: "For type weekly: required run days. For type interval: optional allowed days. Values use the computer's local timezone.",
|
|
86
95
|
},
|
|
87
96
|
every_minutes: {
|
|
88
97
|
type: "integer",
|
|
@@ -94,6 +103,30 @@ const ROUTINE_SCHEDULE_SCHEMA = {
|
|
|
94
103
|
type: "string",
|
|
95
104
|
description: "Optional for type interval: RFC3339 date-time with an explicit timezone offset that anchors the cadence. Omit to start one interval after confirmation.",
|
|
96
105
|
},
|
|
106
|
+
window_start: {
|
|
107
|
+
type: "string",
|
|
108
|
+
description: "Optional for type interval, together with window_end: local 24-hour HH:MM when runs may begin, inclusive.",
|
|
109
|
+
},
|
|
110
|
+
window_end: {
|
|
111
|
+
type: "string",
|
|
112
|
+
description: "Optional for type interval, together with window_start: local 24-hour HH:MM when the allowed window ends, exclusive. It must be later on the same day.",
|
|
113
|
+
},
|
|
114
|
+
ends_at: {
|
|
115
|
+
type: "string",
|
|
116
|
+
description: "Optional for type interval: inclusive RFC3339 date-time cutoff with an explicit timezone offset.",
|
|
117
|
+
},
|
|
118
|
+
every_day: {
|
|
119
|
+
type: "boolean",
|
|
120
|
+
description: "Only for an interval update: true removes an existing weekday restriction.",
|
|
121
|
+
},
|
|
122
|
+
all_day: {
|
|
123
|
+
type: "boolean",
|
|
124
|
+
description: "Only for an interval update: true removes an existing time-window restriction.",
|
|
125
|
+
},
|
|
126
|
+
never_ends: {
|
|
127
|
+
type: "boolean",
|
|
128
|
+
description: "Only for an interval update: true removes an existing end cutoff.",
|
|
129
|
+
},
|
|
97
130
|
},
|
|
98
131
|
required: ["type"],
|
|
99
132
|
};
|
|
@@ -111,7 +144,7 @@ const SHORT_WEEKDAYS = {
|
|
|
111
144
|
};
|
|
112
145
|
const SUPPORTED_SCHEDULES = 'Supported schedules: {"type":"once","at":"2026-09-01T09:00:00+05:30"} (future RFC3339 with explicit offset), ' +
|
|
113
146
|
'{"type":"weekly","time":"09:00","weekdays":["monday","friday"]}, {"type":"daily","time":"09:00"}, ' +
|
|
114
|
-
'or {"type":"interval","every_minutes":15}.';
|
|
147
|
+
'or {"type":"interval","every_minutes":15,"weekdays":["monday","friday"],"window_start":"09:00","window_end":"17:00"}.';
|
|
115
148
|
/** A schedule as the harness accepts it, or a message telling the model
|
|
116
149
|
* exactly what to send instead. Coercion first, error second: models
|
|
117
150
|
* routinely stringify nested objects, say "daily", or shorten weekday
|
|
@@ -135,7 +168,7 @@ function normalizeScheduleInput(args) {
|
|
|
135
168
|
: type === "weekly" || type === "daily"
|
|
136
169
|
? ["type", "time", "weekdays"]
|
|
137
170
|
: type === "interval"
|
|
138
|
-
? ["type", "every_minutes", "everyMinutes", "starts_at", "anchorAt"]
|
|
171
|
+
? ["type", "every_minutes", "everyMinutes", "starts_at", "anchorAt", "weekdays", "every_day", "window_start", "window_end", "window", "all_day", "ends_at", "endsAt", "never_ends"]
|
|
139
172
|
: null;
|
|
140
173
|
// Provider conversions may send unused optional fields as null. Ignore
|
|
141
174
|
// those, but never silently discard an actual scheduling constraint (for
|
|
@@ -181,6 +214,16 @@ function normalizeScheduleInput(args) {
|
|
|
181
214
|
return { schedule: { type: "weekly", time, weekdays: normalized } };
|
|
182
215
|
}
|
|
183
216
|
if (type === "interval") {
|
|
217
|
+
for (const flag of ["every_day", "all_day", "never_ends"]) {
|
|
218
|
+
if (raw[flag] != null && typeof raw[flag] !== "boolean") {
|
|
219
|
+
return { error: `"${flag}" must be true or false.` };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (raw.window != null && (!jsonRecord(raw.window)
|
|
223
|
+
|| Object.keys(raw.window).some((key) => key !== "start" && key !== "end")
|
|
224
|
+
|| typeof raw.window.start !== "string" || typeof raw.window.end !== "string")) {
|
|
225
|
+
return { error: '"window" must contain "start" and "end" in HH:MM, for example {"start":"09:00","end":"17:00"}.' };
|
|
226
|
+
}
|
|
184
227
|
const rawMinutes = raw.every_minutes ?? raw.everyMinutes;
|
|
185
228
|
const everyMinutes = Number(rawMinutes);
|
|
186
229
|
if (!Number.isInteger(everyMinutes) || everyMinutes < 5 || everyMinutes > 1_440) {
|
|
@@ -190,11 +233,68 @@ function normalizeScheduleInput(args) {
|
|
|
190
233
|
if (rawStart !== undefined && (typeof rawStart !== "string" || !rawStart.trim())) {
|
|
191
234
|
return { error: '"starts_at" must be an RFC3339 date-time with an explicit timezone offset.' };
|
|
192
235
|
}
|
|
236
|
+
if (raw.every_day === true && Array.isArray(raw.weekdays) && raw.weekdays.length > 0) {
|
|
237
|
+
return { error: 'Choose interval "weekdays" or "every_day", not both.' };
|
|
238
|
+
}
|
|
239
|
+
let intervalWeekdays;
|
|
240
|
+
if (raw.every_day === true) {
|
|
241
|
+
intervalWeekdays = null;
|
|
242
|
+
}
|
|
243
|
+
else if (raw.weekdays !== undefined) {
|
|
244
|
+
if (!Array.isArray(raw.weekdays) || raw.weekdays.length === 0) {
|
|
245
|
+
return { error: 'Interval "weekdays" must contain at least one full weekday name.' };
|
|
246
|
+
}
|
|
247
|
+
intervalWeekdays = [];
|
|
248
|
+
for (const day of raw.weekdays) {
|
|
249
|
+
const lower = String(day).trim().toLowerCase();
|
|
250
|
+
const full = WEEKDAYS.includes(lower)
|
|
251
|
+
? lower
|
|
252
|
+
: Object.hasOwn(SHORT_WEEKDAYS, lower)
|
|
253
|
+
? SHORT_WEEKDAYS[lower]
|
|
254
|
+
: undefined;
|
|
255
|
+
if (!full)
|
|
256
|
+
return { error: `Unsupported weekday "${String(day)}". Use full names: ${WEEKDAYS.join(", ")}.` };
|
|
257
|
+
if (!intervalWeekdays.includes(full))
|
|
258
|
+
intervalWeekdays.push(full);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const rawWindow = jsonRecord(raw.window) ? raw.window : undefined;
|
|
262
|
+
const windowStart = raw.window_start ?? rawWindow?.start;
|
|
263
|
+
const windowEnd = raw.window_end ?? rawWindow?.end;
|
|
264
|
+
if (raw.all_day === true && (windowStart !== undefined || windowEnd !== undefined)) {
|
|
265
|
+
return { error: 'Choose window_start + window_end or "all_day", not both.' };
|
|
266
|
+
}
|
|
267
|
+
let window;
|
|
268
|
+
if (raw.all_day === true) {
|
|
269
|
+
window = null;
|
|
270
|
+
}
|
|
271
|
+
else if (windowStart !== undefined || windowEnd !== undefined) {
|
|
272
|
+
if (typeof windowStart !== "string" || !windowStart.trim() || typeof windowEnd !== "string" || !windowEnd.trim()) {
|
|
273
|
+
return { error: 'An interval time window needs both "window_start" and "window_end" in 24-hour HH:MM.' };
|
|
274
|
+
}
|
|
275
|
+
window = { start: windowStart.trim(), end: windowEnd.trim() };
|
|
276
|
+
}
|
|
277
|
+
const rawEnd = raw.ends_at ?? raw.endsAt;
|
|
278
|
+
if (raw.never_ends === true && rawEnd !== undefined) {
|
|
279
|
+
return { error: 'Choose "ends_at" or "never_ends", not both.' };
|
|
280
|
+
}
|
|
281
|
+
let endsAt;
|
|
282
|
+
if (raw.never_ends === true)
|
|
283
|
+
endsAt = null;
|
|
284
|
+
else if (rawEnd !== undefined) {
|
|
285
|
+
if (typeof rawEnd !== "string" || !rawEnd.trim()) {
|
|
286
|
+
return { error: '"ends_at" must be an RFC3339 date-time with an explicit timezone offset.' };
|
|
287
|
+
}
|
|
288
|
+
endsAt = rawEnd.trim();
|
|
289
|
+
}
|
|
193
290
|
return {
|
|
194
291
|
schedule: {
|
|
195
292
|
type: "interval",
|
|
196
293
|
everyMinutes,
|
|
197
294
|
...(typeof rawStart === "string" ? { anchorAt: rawStart.trim() } : {}),
|
|
295
|
+
...(intervalWeekdays !== undefined ? { weekdays: intervalWeekdays } : {}),
|
|
296
|
+
...(window !== undefined ? { window } : {}),
|
|
297
|
+
...(endsAt !== undefined ? { endsAt } : {}),
|
|
198
298
|
},
|
|
199
299
|
};
|
|
200
300
|
}
|
|
@@ -291,6 +391,36 @@ const TOOLS = [
|
|
|
291
391
|
required: ["task_id"],
|
|
292
392
|
},
|
|
293
393
|
},
|
|
394
|
+
{
|
|
395
|
+
name: "list_threads",
|
|
396
|
+
description: "See your own threads and the threads you opened on teammates, newest first: each with its bot, title, state (running, waiting on the person, queued, or idle), whether the person has unread there, and the delegation id if it was a handoff. Use it to check how the threads you started are going before reporting to the person; write a thread's title as #Title when you mention it. A teammate's other threads are never listed — only the ones you opened. This is a read: it starts nothing and changes nothing.",
|
|
397
|
+
inputSchema: { type: "object", additionalProperties: false, properties: {} },
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: "close_thread",
|
|
401
|
+
description: "Mark a thread you opened (or one of your own) as finished once you have read its result: it goes idle in the person's sidebar with a note saying you closed it. Nothing is deleted — deleting stays the person's decision — and a thread that is still running cannot be closed; wait for it or leave it. Use the thread id from list_threads or from the start_thread result. If a close is refused, do not retry it.",
|
|
402
|
+
inputSchema: {
|
|
403
|
+
type: "object",
|
|
404
|
+
additionalProperties: false,
|
|
405
|
+
properties: { thread_id: { type: "string", description: "The thread id from list_threads or start_thread." } },
|
|
406
|
+
required: ["thread_id"],
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
name: "start_thread",
|
|
411
|
+
description: "Open a new thread: one conversation with its own history and its own run, shown to the person as a row under the bot it belongs to. Leave bot_id out to open it on yourself, for a separate job that should run on its own (\"review each pull request\" — one thread per pull request) instead of inside this conversation. Give bot_id (from list_bots) to open it on a teammate: that is a handoff into a fresh thread, which starts after your current turn ends and whose result is delivered here, like delegate_bot. The title becomes the row's name, so make it short and specific; write it as #Title when you mention it to the person. Do not use it for a question you need answered right now (ask_bot), for one task where the teammate's usual conversation is fine (delegate_bot), or for a note nobody has to act on. If a call is refused, do not retry it: say what you still wanted opened.",
|
|
412
|
+
inputSchema: {
|
|
413
|
+
type: "object",
|
|
414
|
+
additionalProperties: false,
|
|
415
|
+
properties: {
|
|
416
|
+
title: { type: "string", description: "The thread's name: one short line, at most 80 characters, specific enough to tell it apart from the others (for example \"QA: PR #412 login fix\")." },
|
|
417
|
+
message: { type: "string", description: "The complete first message of the thread — everything the run needs, since it will not see this conversation." },
|
|
418
|
+
bot_id: { type: "string", description: "Optional: the teammate's id from list_bots. Leave out to open the thread on yourself." },
|
|
419
|
+
folder: { type: "string", description: "Optional: the name of one of that bot's existing folders to file the thread under. Leave out unless the person named one." },
|
|
420
|
+
},
|
|
421
|
+
required: ["title", "message"],
|
|
422
|
+
},
|
|
423
|
+
},
|
|
294
424
|
{
|
|
295
425
|
name: "post_to_room",
|
|
296
426
|
description: "Put one message into a shared room you belong to, for example when the user asks you to tell the team something. Get group_id from list_rooms. This posts and returns: no room member's turn starts, nobody replies, and nothing comes back except confirmation — so never use it to ask a question or hand out work (use ask_bot or delegate_bot for those). Post once, say it in full, and tell the user what you posted. If a post is refused, do not retry it: say what you wanted to post in your reply instead.",
|
|
@@ -504,6 +634,14 @@ async function api(path, init) {
|
|
|
504
634
|
throw new Error(String(body.error ?? `HTTP ${res.status}`));
|
|
505
635
|
return body;
|
|
506
636
|
}
|
|
637
|
+
/** "1st", "2nd", "3rd", "4th" — the queue position as a person says it. */
|
|
638
|
+
function ordinal(n) {
|
|
639
|
+
const rem100 = n % 100;
|
|
640
|
+
if (rem100 >= 11 && rem100 <= 13)
|
|
641
|
+
return `${n}th`;
|
|
642
|
+
const rem10 = n % 10;
|
|
643
|
+
return `${n}${rem10 === 1 ? "st" : rem10 === 2 ? "nd" : rem10 === 3 ? "rd" : "th"}`;
|
|
644
|
+
}
|
|
507
645
|
function jsonRecord(value) {
|
|
508
646
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
509
647
|
}
|
|
@@ -738,6 +876,88 @@ async function callTool(name, args) {
|
|
|
738
876
|
}
|
|
739
877
|
return { text: `Task ${taskId} ended without a reply — ${String(r.status ?? "unknown")}${r.result ? `: ${String(r.result)}` : ""}.`, isError: true };
|
|
740
878
|
}
|
|
879
|
+
if (name === "list_threads") {
|
|
880
|
+
const query = new URLSearchParams({ fromBotId: BOT_ID, fromThreadId: THREAD_ID });
|
|
881
|
+
const r = await api(`/api/internal/threads?${query.toString()}`);
|
|
882
|
+
if (r.error)
|
|
883
|
+
return { text: `Couldn't list threads: ${String(r.error)}`, isError: true };
|
|
884
|
+
const threads = Array.isArray(r.threads) ? r.threads.filter(jsonRecord) : [];
|
|
885
|
+
if (!threads.length)
|
|
886
|
+
return { text: "No threads yet: you have none of your own beyond this one, and you have not opened any on a teammate." };
|
|
887
|
+
const stateWord = { running: "running", "waiting-on-you": "waiting on the person", queued: "queued", idle: "idle" };
|
|
888
|
+
const lines = threads.map((thread) => {
|
|
889
|
+
const where = thread.own === true ? "yours" : `on @${String(thread.botName)}`;
|
|
890
|
+
const state = stateWord[String(thread.state)] ?? String(thread.state);
|
|
891
|
+
const unread = thread.unread === true ? ", unread for the person" : "";
|
|
892
|
+
const handoff = typeof thread.delegationId === "string" && thread.delegationId ? ` [delegation id: ${thread.delegationId}]` : "";
|
|
893
|
+
return `- #${String(thread.title)} (${where}, ${state}${unread}) [thread id: ${String(thread.threadId)}]${handoff}`;
|
|
894
|
+
});
|
|
895
|
+
return { text: `Threads, newest first:\n${lines.join("\n")}` };
|
|
896
|
+
}
|
|
897
|
+
if (name === "close_thread") {
|
|
898
|
+
const threadId = String(args.thread_id ?? "").trim();
|
|
899
|
+
if (!threadId)
|
|
900
|
+
return { text: "close_thread needs the thread_id from list_threads or start_thread.", isError: true };
|
|
901
|
+
const r = await api(`/api/internal/threads/${encodeURIComponent(threadId)}/close`, { method: "POST", body: JSON.stringify({ fromBotId: BOT_ID, fromThreadId: THREAD_ID }) });
|
|
902
|
+
if (r.error)
|
|
903
|
+
return { text: `Couldn't close that thread: ${String(r.error)}`, isError: true };
|
|
904
|
+
return { text: `Closed #${String(r.title)}${r.botName ? ` on @${String(r.botName)}` : ""}. It stays in the person's sidebar, idle, with a note that you closed it.` };
|
|
905
|
+
}
|
|
906
|
+
if (name === "start_thread") {
|
|
907
|
+
const title = String(args.title ?? "").trim();
|
|
908
|
+
const message = String(args.message ?? "").trim();
|
|
909
|
+
if (!title || !message)
|
|
910
|
+
return { text: "start_thread needs title (one short line) and message (the complete first message).", isError: true };
|
|
911
|
+
if (threadsOpenedThisTurn >= MAX_THREADS_PER_TURN) {
|
|
912
|
+
return {
|
|
913
|
+
text: `You have already opened ${MAX_THREADS_PER_TURN} threads this turn, which is the limit. Do not retry — finish your turn and tell the person which threads you still wanted to open, so they can open them or ask you again.`,
|
|
914
|
+
isError: true,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
const toBotId = typeof args.bot_id === "string" ? args.bot_id.trim() : "";
|
|
918
|
+
const folder = typeof args.folder === "string" ? args.folder.trim() : "";
|
|
919
|
+
const body = { fromBotId: BOT_ID, fromThreadId: THREAD_ID, title, message, depth: DEPTH };
|
|
920
|
+
if (toBotId)
|
|
921
|
+
body.toBotId = toBotId;
|
|
922
|
+
if (folder)
|
|
923
|
+
body.folder = folder;
|
|
924
|
+
const r = await api("/api/internal/threads", { method: "POST", body: JSON.stringify(body) });
|
|
925
|
+
// A refusal opened nothing. A "failed" state opened the thread and could
|
|
926
|
+
// not start its turn — that one still counts, and still has an id.
|
|
927
|
+
if (r.error && r.state !== "failed")
|
|
928
|
+
return { text: `Couldn't open that thread: ${String(r.error)}`, isError: true };
|
|
929
|
+
threadsOpenedThisTurn += 1;
|
|
930
|
+
const threadTitle = String(r.title ?? title);
|
|
931
|
+
const threadId = String(r.threadId ?? "");
|
|
932
|
+
const where = r.self === true ? "on yourself" : `on @${String(r.botName ?? "that bot")}`;
|
|
933
|
+
const opened = `Opened thread #${threadTitle} ${where} [thread id: ${threadId}].`;
|
|
934
|
+
if (r.self === true) {
|
|
935
|
+
if (r.state === "running") {
|
|
936
|
+
return { text: `${opened} It is running now, in parallel with this conversation, and its result stays in that thread — it will not be delivered here. Mention it to the person as #${threadTitle}; use list_threads in a later turn to see how it is going.` };
|
|
937
|
+
}
|
|
938
|
+
if (r.state === "queued") {
|
|
939
|
+
const position = Number(r.position) || 1;
|
|
940
|
+
const limit = Number(r.limit) || 0;
|
|
941
|
+
return { text: `${opened} You are at your limit of ${limit} threads running at once, so it is ${ordinal(position)} in line and starts as soon as one of them finishes — nothing more to do. Mention it to the person as #${threadTitle}.` };
|
|
942
|
+
}
|
|
943
|
+
return { text: `${opened} It could not start: ${String(r.error ?? "unknown reason")}. The thread exists but nothing is running in it; tell the person.`, isError: true };
|
|
944
|
+
}
|
|
945
|
+
// A peer thread is a handoff: like delegate_bot, it starts after this
|
|
946
|
+
// turn and reports back here, so the id is a claim ticket the model
|
|
947
|
+
// must not cash in this same turn.
|
|
948
|
+
const delegationId = typeof r.delegationId === "string" ? r.delegationId.trim() : "";
|
|
949
|
+
if (delegationId)
|
|
950
|
+
delegationTaskIdsThisTurn.add(delegationId);
|
|
951
|
+
const approval = r.approvalRequired === true
|
|
952
|
+
? " The person must approve this handoff first; their card appears after your turn ends."
|
|
953
|
+
: "";
|
|
954
|
+
const timing = r.state === "queued"
|
|
955
|
+
? ` @${String(r.botName ?? "that bot")} can run ${Number(r.limit) || 0} threads at once and they are all spoken for, so it waits ${ordinal(Number(r.position) || 1)} in line for a free slot after this turn ends.`
|
|
956
|
+
: " It starts when this turn ends, like any handoff.";
|
|
957
|
+
return {
|
|
958
|
+
text: `${opened}${timing}${approval} Its result will be delivered to this conversation automatically (delegation id: ${delegationId || "unknown"}). Acknowledge it, mention it to the person as #${threadTitle}, and finish your turn; do not check or wait for it in this turn.`,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
741
961
|
if (name === "create_bot") {
|
|
742
962
|
const botName = String(args.name ?? "").trim();
|
|
743
963
|
const role = String(args.role ?? "").trim();
|
|
@@ -51,6 +51,7 @@ export function claudeLoginCode(value) {
|
|
|
51
51
|
const NOT_AVAILABLE = "This sign-in is no longer available. Start sign-in again.";
|
|
52
52
|
export class ClaudeLoginController {
|
|
53
53
|
flow = null;
|
|
54
|
+
logoutChild = null;
|
|
54
55
|
disposed = false;
|
|
55
56
|
options;
|
|
56
57
|
constructor(options) {
|
|
@@ -149,20 +150,112 @@ export class ClaudeLoginController {
|
|
|
149
150
|
await this.flow?.stopping;
|
|
150
151
|
if (this.flow?.terminationFailed)
|
|
151
152
|
throw new Error(this.flow.status.message);
|
|
153
|
+
if (this.logoutChild && !await this.stopChild(this.logoutChild)) {
|
|
154
|
+
throw new Error("Claude Code could not be stopped on this server. Ask the server administrator to stop the account command before trying again.");
|
|
155
|
+
}
|
|
152
156
|
}
|
|
153
157
|
async dispose() {
|
|
154
158
|
this.disposed = true;
|
|
155
159
|
await this.cancel();
|
|
156
160
|
this.flow = null;
|
|
157
161
|
}
|
|
162
|
+
/** Remove the sign-in Claude Code stores for this server account (its
|
|
163
|
+
* configuration directory) so a different subscription can sign in. A
|
|
164
|
+
* sign-in in progress is never pulled away underneath the browser
|
|
165
|
+
* completing it, and the CLI's own status is the verdict, as for sign-in. */
|
|
166
|
+
async signOut() {
|
|
167
|
+
if (this.disposed)
|
|
168
|
+
throw new Error("This provider was removed. Refresh Settings before signing out.");
|
|
169
|
+
if (this.flow?.status.phase === "waiting")
|
|
170
|
+
throw new Error("Finish or cancel the Claude sign-in in progress before signing out.");
|
|
171
|
+
await this.flow?.stopping;
|
|
172
|
+
if (this.disposed)
|
|
173
|
+
throw new Error("This provider was removed. Refresh Settings before signing out.");
|
|
174
|
+
const env = this.options.environment();
|
|
175
|
+
const home = env.HOME || env.USERPROFILE || homedir();
|
|
176
|
+
if (!isAbsolute(home) || (env.CLAUDE_CONFIG_DIR && !isAbsolute(env.CLAUDE_CONFIG_DIR))) {
|
|
177
|
+
throw new Error("Use an absolute HOME and CLAUDE_CONFIG_DIR path for this server's Claude provider before signing out.");
|
|
178
|
+
}
|
|
179
|
+
const homeKey = canonicalPath(resolve(env.CLAUDE_CONFIG_DIR || join(home, ".claude")));
|
|
180
|
+
if (authenticatingHomes.has(homeKey)) {
|
|
181
|
+
throw new Error("A Claude sign-in is running for this server account. Finish or cancel it before signing out.");
|
|
182
|
+
}
|
|
183
|
+
authenticatingHomes.add(homeKey);
|
|
184
|
+
try {
|
|
185
|
+
await this.logout(env);
|
|
186
|
+
if (this.disposed)
|
|
187
|
+
throw new Error("This provider was removed. Refresh Settings before signing out.");
|
|
188
|
+
const status = await this.authStatus(env);
|
|
189
|
+
if (status === "in")
|
|
190
|
+
throw new Error("Claude Code still reports a sign-in on this server. Update Claude Code and check its auth status before trying again.");
|
|
191
|
+
if (status === "unknown")
|
|
192
|
+
throw new Error("Claude Code could not confirm the sign-out on this server. Update Claude Code and check its auth status before trying again.");
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
// A command the OS could not stop may still change credentials. Keep
|
|
196
|
+
// this home reserved until that exact child actually exits.
|
|
197
|
+
const child = this.logoutChild;
|
|
198
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
199
|
+
child.once("close", () => authenticatingHomes.delete(homeKey));
|
|
200
|
+
}
|
|
201
|
+
else
|
|
202
|
+
authenticatingHomes.delete(homeKey);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** `claude auth logout`, bounded and non-interactive. Its output stays
|
|
206
|
+
* here; the status command afterwards decides whether it worked. */
|
|
207
|
+
logout(env) {
|
|
208
|
+
return new Promise((resolveDone, rejectDone) => {
|
|
209
|
+
if (this.disposed) {
|
|
210
|
+
rejectDone(new Error("This provider was removed. Refresh Settings before signing out."));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
let child;
|
|
214
|
+
try {
|
|
215
|
+
child = spawnCli(this.options.cli, ["auth", "logout"], { env: { ...env, NO_COLOR: "1" }, stdio: ["pipe", "pipe", "pipe"] });
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
rejectDone(new Error("Claude Code could not start on this server. Install or update the configured Claude CLI, then try again."));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.logoutChild = child;
|
|
222
|
+
child.stdin.end();
|
|
223
|
+
child.stdout.on("data", () => { });
|
|
224
|
+
child.stderr.on("data", () => { });
|
|
225
|
+
// A hung CLI must not hold the credential-home lock forever.
|
|
226
|
+
const timer = setTimeout(() => {
|
|
227
|
+
void this.stopChild(child).catch(() => false).then((stopped) => {
|
|
228
|
+
if (!stopped)
|
|
229
|
+
rejectDone(new Error("Claude Code could not be stopped on this server. Ask the server administrator to stop the account command before trying again."));
|
|
230
|
+
});
|
|
231
|
+
}, this.options.startupTimeoutMs ?? 30_000);
|
|
232
|
+
timer.unref();
|
|
233
|
+
child.once("error", (error) => {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
rejectDone(new Error(error.code === "ENOENT"
|
|
236
|
+
? "Claude Code is not installed on this server. Install it on the server, then try again."
|
|
237
|
+
: "Claude Code could not start on this server. Check the configured CLI path and its executable permissions."));
|
|
238
|
+
});
|
|
239
|
+
child.once("close", () => {
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
if (this.logoutChild === child)
|
|
242
|
+
this.logoutChild = null;
|
|
243
|
+
resolveDone();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
158
247
|
signedIn(env) {
|
|
248
|
+
return this.authStatus(env).then((status) => status === "in");
|
|
249
|
+
}
|
|
250
|
+
/** `claude auth status --json`: signed in, signed out, or not answered. */
|
|
251
|
+
authStatus(env) {
|
|
159
252
|
return new Promise((resolveStatus) => {
|
|
160
253
|
let child;
|
|
161
254
|
try {
|
|
162
255
|
child = spawnCli(this.options.cli, ["auth", "status", "--json"], { env: { ...env, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
163
256
|
}
|
|
164
257
|
catch {
|
|
165
|
-
resolveStatus(
|
|
258
|
+
resolveStatus("unknown");
|
|
166
259
|
return;
|
|
167
260
|
}
|
|
168
261
|
let stdout = "";
|
|
@@ -173,21 +266,22 @@ export class ClaudeLoginController {
|
|
|
173
266
|
child.stderr.on("data", () => { });
|
|
174
267
|
const timer = setTimeout(() => {
|
|
175
268
|
child.kill("SIGKILL");
|
|
176
|
-
resolveStatus(
|
|
269
|
+
resolveStatus("unknown");
|
|
177
270
|
}, 8_000);
|
|
178
271
|
timer.unref();
|
|
179
272
|
child.once("error", () => {
|
|
180
273
|
clearTimeout(timer);
|
|
181
|
-
resolveStatus(
|
|
274
|
+
resolveStatus("unknown");
|
|
182
275
|
});
|
|
183
|
-
child.once("close", () => {
|
|
276
|
+
child.once("close", (code) => {
|
|
184
277
|
clearTimeout(timer);
|
|
185
278
|
try {
|
|
186
279
|
const status = JSON.parse(stdout);
|
|
187
|
-
|
|
280
|
+
const loggedIn = typeof status === "object" && status !== null ? Reflect.get(status, "loggedIn") : undefined;
|
|
281
|
+
resolveStatus(code === 0 && loggedIn === true ? "in" : code === 1 && loggedIn === false ? "out" : "unknown");
|
|
188
282
|
}
|
|
189
283
|
catch {
|
|
190
|
-
resolveStatus(
|
|
284
|
+
resolveStatus("unknown");
|
|
191
285
|
}
|
|
192
286
|
});
|
|
193
287
|
});
|
|
@@ -286,7 +380,7 @@ export class ClaudeLoginController {
|
|
|
286
380
|
flow.completion.reject(new Error(message ?? "Claude sign-in did not finish."));
|
|
287
381
|
flow.completion = undefined;
|
|
288
382
|
}
|
|
289
|
-
flow.stopping = this.stopChild(flow)
|
|
383
|
+
flow.stopping = this.stopChild(flow.child)
|
|
290
384
|
.catch(() => false)
|
|
291
385
|
.then((stopped) => {
|
|
292
386
|
const release = () => authenticatingHomes.delete(flow.homeKey);
|
|
@@ -305,8 +399,7 @@ export class ClaudeLoginController {
|
|
|
305
399
|
if (phase === "succeeded")
|
|
306
400
|
void this.options.onAuthenticated?.().catch(() => { });
|
|
307
401
|
}
|
|
308
|
-
async stopChild(
|
|
309
|
-
const child = flow.child;
|
|
402
|
+
async stopChild(child) {
|
|
310
403
|
if (!child || (await killCliTree(child, this.options.terminateTimeoutMs ?? 1500)))
|
|
311
404
|
return true;
|
|
312
405
|
if (process.platform !== "win32" && child.pid) {
|