alvin-bot 4.6.0 → 4.8.0
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/CHANGELOG.md +191 -0
- package/bin/cli.js +314 -27
- package/dist/handlers/commands.js +54 -4
- package/dist/i18n.js +8 -8
- package/dist/index.js +1 -0
- package/dist/services/subagent-delivery.js +155 -0
- package/dist/services/subagent-stats.js +123 -0
- package/dist/services/subagents.js +225 -72
- package/dist/tui/index.js +8 -1
- package/dist/version.js +24 -0
- package/dist/web/server.js +2 -1
- package/docs/HANDBOOK.md +39 -2
- package/package.json +1 -1
- package/test/subagent-delivery.test.ts +104 -0
- package/test/subagent-stats.test.ts +119 -0
- package/test/subagents-config.test.ts +7 -1
- package/test/subagents-priority-reject.test.ts +29 -1
- package/test/subagents-queue.test.ts +127 -0
- package/alvin-bot-4.5.1.tgz +0 -0
|
@@ -15,9 +15,11 @@ const DATA_DIR = process.env.ALVIN_DATA_DIR || resolve(os.homedir(), ".alvin-bot
|
|
|
15
15
|
const CONFIG_FILE = resolve(DATA_DIR, "sub-agents.json");
|
|
16
16
|
const ABSOLUTE_MAX_AGENTS = 16; // Hard cap no matter what
|
|
17
17
|
const MAX_SUBAGENT_DEPTH = 2; // F2: hard cap on nested spawning
|
|
18
|
+
const DEFAULT_QUEUE_CAP = 20; // D3: default bounded-queue size
|
|
19
|
+
const ABSOLUTE_MAX_QUEUE = 200; // D3: absolute ceiling on queue length
|
|
18
20
|
let configCache = null;
|
|
19
21
|
function isValidVisibility(v) {
|
|
20
|
-
return v === "auto" || v === "banner" || v === "silent";
|
|
22
|
+
return v === "auto" || v === "banner" || v === "silent" || v === "live";
|
|
21
23
|
}
|
|
22
24
|
function loadSubAgentsConfig() {
|
|
23
25
|
if (configCache)
|
|
@@ -28,6 +30,9 @@ function loadSubAgentsConfig() {
|
|
|
28
30
|
configCache = {
|
|
29
31
|
maxParallel: typeof parsed.maxParallel === "number" ? parsed.maxParallel : 0,
|
|
30
32
|
visibility: isValidVisibility(parsed.visibility) ? parsed.visibility : "auto",
|
|
33
|
+
queueCap: typeof parsed.queueCap === "number"
|
|
34
|
+
? Math.max(0, Math.min(Math.floor(parsed.queueCap), ABSOLUTE_MAX_QUEUE))
|
|
35
|
+
: DEFAULT_QUEUE_CAP,
|
|
31
36
|
};
|
|
32
37
|
}
|
|
33
38
|
catch {
|
|
@@ -35,6 +40,7 @@ function loadSubAgentsConfig() {
|
|
|
35
40
|
configCache = {
|
|
36
41
|
maxParallel: Number(process.env.MAX_SUBAGENTS) || 0,
|
|
37
42
|
visibility: "auto",
|
|
43
|
+
queueCap: DEFAULT_QUEUE_CAP,
|
|
38
44
|
};
|
|
39
45
|
}
|
|
40
46
|
return configCache;
|
|
@@ -79,11 +85,23 @@ export function getVisibility() {
|
|
|
79
85
|
*/
|
|
80
86
|
export function setVisibility(mode) {
|
|
81
87
|
if (!isValidVisibility(mode)) {
|
|
82
|
-
throw new Error(`Invalid visibility mode "${mode}". Expected: auto | banner | silent.`);
|
|
88
|
+
throw new Error(`Invalid visibility mode "${mode}". Expected: auto | banner | silent | live.`);
|
|
83
89
|
}
|
|
84
90
|
const cfg = loadSubAgentsConfig();
|
|
85
91
|
saveSubAgentsConfig({ ...cfg, visibility: mode });
|
|
86
92
|
}
|
|
93
|
+
/** D3: Current bounded-queue cap. 0 = queue disabled (reject on full pool). */
|
|
94
|
+
export function getQueueCap() {
|
|
95
|
+
return loadSubAgentsConfig().queueCap;
|
|
96
|
+
}
|
|
97
|
+
/** D3: Set the queue cap. Clamped to [0, ABSOLUTE_MAX_QUEUE].
|
|
98
|
+
* Returns the effective value after clamping. */
|
|
99
|
+
export function setQueueCap(n) {
|
|
100
|
+
const clamped = Math.max(0, Math.min(Math.floor(n), ABSOLUTE_MAX_QUEUE));
|
|
101
|
+
const cfg = loadSubAgentsConfig();
|
|
102
|
+
saveSubAgentsConfig({ ...cfg, queueCap: clamped });
|
|
103
|
+
return clamped;
|
|
104
|
+
}
|
|
87
105
|
// ── State ───────────────────────────────────────────────
|
|
88
106
|
const activeAgents = new Map();
|
|
89
107
|
// ── Name resolver (B2) ──────────────────────────────────
|
|
@@ -167,6 +185,28 @@ export function findSubAgentByName(name, opts = {}) {
|
|
|
167
185
|
async function runSubAgent(id, agentConfig, abort, resolvedName) {
|
|
168
186
|
const startTime = Date.now();
|
|
169
187
|
const entry = activeAgents.get(id);
|
|
188
|
+
// A4 live-stream state — set up if the effective visibility is "live"
|
|
189
|
+
// AND this is a user spawn with a parent chat. Cron and implicit spawns
|
|
190
|
+
// don't get live-streaming (cron because there's no interactive watcher,
|
|
191
|
+
// implicit because the parent Claude stream already shows everything).
|
|
192
|
+
let liveStream = null;
|
|
193
|
+
const effectiveVisibility = agentConfig.visibility ?? loadSubAgentsConfig().visibility;
|
|
194
|
+
if (effectiveVisibility === "live" &&
|
|
195
|
+
agentConfig.source === "user" &&
|
|
196
|
+
typeof agentConfig.parentChatId === "number") {
|
|
197
|
+
try {
|
|
198
|
+
const { createLiveStream } = await import("./subagent-delivery.js");
|
|
199
|
+
const stream = createLiveStream(agentConfig.parentChatId, resolvedName);
|
|
200
|
+
if (stream) {
|
|
201
|
+
await stream.start();
|
|
202
|
+
if (!stream.failed)
|
|
203
|
+
liveStream = stream;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
console.error(`[subagent ${id}] live-stream init failed:`, err);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
170
210
|
try {
|
|
171
211
|
const { getRegistry } = await import("../engine.js");
|
|
172
212
|
const registry = getRegistry();
|
|
@@ -189,8 +229,13 @@ async function runSubAgent(id, agentConfig, abort, resolvedName) {
|
|
|
189
229
|
effort: "high",
|
|
190
230
|
abortSignal: abort.signal,
|
|
191
231
|
})) {
|
|
192
|
-
if (chunk.type === "text")
|
|
232
|
+
if (chunk.type === "text") {
|
|
193
233
|
finalText = chunk.text || "";
|
|
234
|
+
// A4: push text updates into the throttled live-stream
|
|
235
|
+
if (liveStream && !liveStream.failed) {
|
|
236
|
+
liveStream.update(finalText);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
194
239
|
if (chunk.type === "done") {
|
|
195
240
|
inputTokens = chunk.inputTokens || 0;
|
|
196
241
|
outputTokens = chunk.outputTokens || 0;
|
|
@@ -227,6 +272,20 @@ async function runSubAgent(id, agentConfig, abort, resolvedName) {
|
|
|
227
272
|
};
|
|
228
273
|
entry.info.status = "completed";
|
|
229
274
|
}
|
|
275
|
+
// A4: finalize the live-stream if we had one. On success, mark the
|
|
276
|
+
// entry as delivered so spawnSubAgent.finally() skips the normal
|
|
277
|
+
// deliverSubAgentResult path — the live stream already posted the
|
|
278
|
+
// body, and finalize() already posted the banner.
|
|
279
|
+
if (liveStream && !liveStream.failed && entry.result) {
|
|
280
|
+
try {
|
|
281
|
+
await liveStream.finalize(entry.info, entry.result);
|
|
282
|
+
entry.delivered = true;
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
console.error(`[subagent ${id}] live-stream finalize failed:`, err);
|
|
286
|
+
// Let the normal delivery path fire as a fallback.
|
|
287
|
+
}
|
|
288
|
+
}
|
|
230
289
|
}
|
|
231
290
|
catch (err) {
|
|
232
291
|
const isAbort = err instanceof Error && err.message.includes("abort");
|
|
@@ -248,109 +307,179 @@ async function runSubAgent(id, agentConfig, abort, resolvedName) {
|
|
|
248
307
|
entry.info.status = status;
|
|
249
308
|
}
|
|
250
309
|
}
|
|
251
|
-
|
|
310
|
+
const pendingQueue = [];
|
|
311
|
+
/** Priority order used when draining the queue — higher index = lower priority. */
|
|
312
|
+
const SOURCE_PRIORITY = ["user", "cron", "implicit"];
|
|
313
|
+
function sourceOf(cfg) {
|
|
314
|
+
return cfg.source ?? "implicit";
|
|
315
|
+
}
|
|
316
|
+
/** Count how many agents are currently running. */
|
|
317
|
+
function runningCount() {
|
|
318
|
+
return [...activeAgents.values()].filter((a) => a.info.status === "running").length;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Pop the next queued spawn according to priority (user > cron > implicit)
|
|
322
|
+
* and within each priority in FIFO order. Returns null if the queue is empty.
|
|
323
|
+
*/
|
|
324
|
+
function popHighestPriorityQueued() {
|
|
325
|
+
for (const priority of SOURCE_PRIORITY) {
|
|
326
|
+
const idx = pendingQueue.findIndex((q) => sourceOf(q.agentConfig) === priority);
|
|
327
|
+
if (idx >= 0) {
|
|
328
|
+
const [entry] = pendingQueue.splice(idx, 1);
|
|
329
|
+
return entry;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
252
334
|
/**
|
|
253
|
-
*
|
|
254
|
-
*
|
|
335
|
+
* Recalculate queuePosition for every entry still in the queue. Called
|
|
336
|
+
* after a pop or a cancel so /subagents list reflects the current state.
|
|
255
337
|
*/
|
|
338
|
+
function reindexQueue() {
|
|
339
|
+
for (let i = 0; i < pendingQueue.length; i++) {
|
|
340
|
+
const q = pendingQueue[i];
|
|
341
|
+
const entry = activeAgents.get(q.id);
|
|
342
|
+
if (entry)
|
|
343
|
+
entry.info.queuePosition = i + 1;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** Drain as many queued spawns as fit into the current free slots. */
|
|
347
|
+
function drainQueue() {
|
|
348
|
+
const maxParallel = getMaxParallelAgents();
|
|
349
|
+
while (pendingQueue.length > 0 && runningCount() < maxParallel) {
|
|
350
|
+
const next = popHighestPriorityQueued();
|
|
351
|
+
if (!next)
|
|
352
|
+
break;
|
|
353
|
+
const entry = activeAgents.get(next.id);
|
|
354
|
+
if (!entry)
|
|
355
|
+
continue; // was cancelled while queued
|
|
356
|
+
reindexQueue();
|
|
357
|
+
// Transition to running
|
|
358
|
+
entry.info.status = "running";
|
|
359
|
+
entry.info.startedAt = Date.now();
|
|
360
|
+
entry.info.queuePosition = undefined;
|
|
361
|
+
startRun(next);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// ── Spawn pipeline ──────────────────────────────────────────
|
|
365
|
+
function startRun(q) {
|
|
366
|
+
const { id, resolvedName, agentConfig, timeoutId } = q;
|
|
367
|
+
const entry = activeAgents.get(id);
|
|
368
|
+
if (!entry)
|
|
369
|
+
return;
|
|
370
|
+
// Run in background — don't await
|
|
371
|
+
runSubAgent(id, agentConfig, entry.abort, resolvedName)
|
|
372
|
+
.finally(() => {
|
|
373
|
+
if (timeoutId)
|
|
374
|
+
clearTimeout(timeoutId);
|
|
375
|
+
const currentEntry = activeAgents.get(id);
|
|
376
|
+
if (agentConfig.onComplete && currentEntry?.result) {
|
|
377
|
+
try {
|
|
378
|
+
agentConfig.onComplete(currentEntry.result);
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
console.error(`[subagent ${id}] onComplete callback threw:`, err);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// I3: fire delivery router (non-blocking, errors logged). Guarded
|
|
385
|
+
// by the `delivered` flag.
|
|
386
|
+
if (currentEntry?.result && !currentEntry.delivered) {
|
|
387
|
+
currentEntry.delivered = true;
|
|
388
|
+
const resultSnapshot = currentEntry.result;
|
|
389
|
+
const infoSnapshot = currentEntry.info;
|
|
390
|
+
import("./subagent-delivery.js")
|
|
391
|
+
.then(({ deliverSubAgentResult }) => deliverSubAgentResult(infoSnapshot, resultSnapshot, {
|
|
392
|
+
visibility: agentConfig.visibility,
|
|
393
|
+
}))
|
|
394
|
+
.catch((err) => console.error(`[subagent ${id}] delivery failed:`, err));
|
|
395
|
+
}
|
|
396
|
+
// H3: record this run in the rolling 24h stats (non-blocking).
|
|
397
|
+
if (currentEntry?.result) {
|
|
398
|
+
const resultSnapshot = currentEntry.result;
|
|
399
|
+
const infoSnapshot = currentEntry.info;
|
|
400
|
+
import("./subagent-stats.js")
|
|
401
|
+
.then(({ recordSubAgentRun }) => recordSubAgentRun(infoSnapshot, resultSnapshot))
|
|
402
|
+
.catch((err) => console.error(`[subagent ${id}] stats recording failed:`, err));
|
|
403
|
+
}
|
|
404
|
+
// D3: drain the queue now that a slot has freed up
|
|
405
|
+
drainQueue();
|
|
406
|
+
// Auto-cleanup: remove completed agents after 30 minutes
|
|
407
|
+
setTimeout(() => {
|
|
408
|
+
const e = activeAgents.get(id);
|
|
409
|
+
if (e && e.info.status !== "running" && e.info.status !== "queued") {
|
|
410
|
+
activeAgents.delete(id);
|
|
411
|
+
}
|
|
412
|
+
}, 30 * 60 * 1000);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
256
415
|
export function spawnSubAgent(agentConfig) {
|
|
257
416
|
// F2: enforce depth cap before touching any state.
|
|
258
417
|
const depth = agentConfig.depth ?? 0;
|
|
259
418
|
if (depth > MAX_SUBAGENT_DEPTH) {
|
|
260
419
|
return Promise.reject(new Error(`Sub-agent depth limit reached (${MAX_SUBAGENT_DEPTH}). Agents can only spawn ${MAX_SUBAGENT_DEPTH} level(s) of nested agents.`));
|
|
261
420
|
}
|
|
262
|
-
// G1: toolset preset. Only "full" is supported
|
|
263
|
-
//
|
|
264
|
-
//
|
|
421
|
+
// G1: toolset preset. Only "full" is supported. The literal type blocks
|
|
422
|
+
// wrong values at compile time; the runtime check catches callers that
|
|
423
|
+
// bypass TypeScript (e.g. plugin code loaded at runtime).
|
|
265
424
|
const toolset = agentConfig.toolset ?? "full";
|
|
266
425
|
if (toolset !== "full") {
|
|
267
426
|
return Promise.reject(new Error(`Invalid toolset "${toolset}". Only "full" is supported in this version.`));
|
|
268
427
|
}
|
|
269
|
-
// Check concurrency limit — now reads from the file-backed config so
|
|
270
|
-
// /sub-agents max <n> edits take effect immediately without a restart.
|
|
271
|
-
const running = [...activeAgents.values()].filter((a) => a.info.status === "running");
|
|
272
428
|
const maxParallel = getMaxParallelAgents();
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
429
|
+
const queueCap = getQueueCap();
|
|
430
|
+
const running = runningCount();
|
|
431
|
+
const queuedLen = pendingQueue.length;
|
|
432
|
+
// B2: resolve the requested name to a unique variant.
|
|
433
|
+
const resolved = resolveAgentName(agentConfig.name);
|
|
434
|
+
const resolvedName = resolved.name;
|
|
435
|
+
const id = crypto.randomUUID();
|
|
436
|
+
const timeout = agentConfig.timeout ?? config.subAgentTimeout;
|
|
437
|
+
const abort = new AbortController();
|
|
438
|
+
const timeoutId = setTimeout(() => abort.abort(), timeout);
|
|
439
|
+
const willRunImmediately = running < maxParallel;
|
|
440
|
+
const canQueue = !willRunImmediately && queueCap > 0 && queuedLen < queueCap;
|
|
441
|
+
if (!willRunImmediately && !canQueue) {
|
|
442
|
+
// No slot, no queue room → priority-aware reject
|
|
443
|
+
clearTimeout(timeoutId);
|
|
444
|
+
const source = sourceOf(agentConfig);
|
|
445
|
+
const runningAgents = [...activeAgents.values()].filter((a) => a.info.status === "running");
|
|
446
|
+
const userSlots = runningAgents.filter((a) => a.info.source === "user").length;
|
|
447
|
+
const bgSlots = runningAgents.length - userSlots;
|
|
280
448
|
let message;
|
|
281
449
|
if (source === "user") {
|
|
282
450
|
if (bgSlots > 0) {
|
|
283
|
-
message = `Alle Slots belegt (${running
|
|
451
|
+
message = `Alle Slots belegt (${running}/${maxParallel}), davon ${bgSlots} cron/implicit im Hintergrund. Queue voll (${queuedLen}/${queueCap}). /subagents list für Details oder /subagents cancel <name>.`;
|
|
284
452
|
}
|
|
285
453
|
else {
|
|
286
|
-
message = `Alle Slots belegt (${running
|
|
454
|
+
message = `Alle Slots belegt (${running}/${maxParallel}) mit eigenen user-Spawns. Queue voll (${queuedLen}/${queueCap}). /subagents cancel <name> oder warten.`;
|
|
287
455
|
}
|
|
288
456
|
}
|
|
289
457
|
else {
|
|
290
|
-
message = `Sub-agent limit reached (${maxParallel}). Wait for a running agent to finish or cancel one.`;
|
|
458
|
+
message = `Sub-agent limit reached (${maxParallel} running, ${queuedLen}/${queueCap} queued). Wait for a running agent to finish or cancel one.`;
|
|
291
459
|
}
|
|
292
460
|
return Promise.reject(new Error(message));
|
|
293
461
|
}
|
|
294
|
-
// B2: resolve the requested name to a unique variant. On collision,
|
|
295
|
-
// append #N where N is the smallest free index ≥ 2.
|
|
296
|
-
const resolved = resolveAgentName(agentConfig.name);
|
|
297
|
-
const resolvedName = resolved.name;
|
|
298
|
-
const id = crypto.randomUUID();
|
|
299
|
-
const timeout = agentConfig.timeout ?? config.subAgentTimeout;
|
|
300
|
-
const abort = new AbortController();
|
|
301
|
-
// Set up timeout
|
|
302
|
-
const timeoutId = setTimeout(() => abort.abort(), timeout);
|
|
303
462
|
const info = {
|
|
304
463
|
id,
|
|
305
464
|
name: resolvedName,
|
|
306
|
-
status: "running",
|
|
465
|
+
status: willRunImmediately ? "running" : "queued",
|
|
307
466
|
startedAt: Date.now(),
|
|
308
467
|
model: agentConfig.model,
|
|
309
468
|
source: agentConfig.source,
|
|
310
469
|
depth,
|
|
311
470
|
parentChatId: agentConfig.parentChatId,
|
|
312
471
|
nameIndex: resolved.index,
|
|
472
|
+
queuePosition: willRunImmediately ? undefined : queuedLen + 1,
|
|
313
473
|
};
|
|
314
474
|
activeAgents.set(id, { info, abort, delivered: false });
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const entry = activeAgents.get(id);
|
|
324
|
-
if (agentConfig.onComplete && entry?.result) {
|
|
325
|
-
try {
|
|
326
|
-
agentConfig.onComplete(entry.result);
|
|
327
|
-
}
|
|
328
|
-
catch (err) {
|
|
329
|
-
console.error(`[subagent ${id}] onComplete callback threw:`, err);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
// I3: fire delivery router (non-blocking, errors logged). Dynamic
|
|
333
|
-
// import keeps the module graph free of circular edges. Guarded by
|
|
334
|
-
// the `delivered` flag so cancelAllSubAgents (shutdown path) and
|
|
335
|
-
// this finally() can't both post the result.
|
|
336
|
-
if (entry?.result && !entry.delivered) {
|
|
337
|
-
entry.delivered = true;
|
|
338
|
-
const resultSnapshot = entry.result;
|
|
339
|
-
const infoSnapshot = entry.info;
|
|
340
|
-
import("./subagent-delivery.js")
|
|
341
|
-
.then(({ deliverSubAgentResult }) => deliverSubAgentResult(infoSnapshot, resultSnapshot, {
|
|
342
|
-
visibility: agentConfig.visibility,
|
|
343
|
-
}))
|
|
344
|
-
.catch((err) => console.error(`[subagent ${id}] delivery failed:`, err));
|
|
345
|
-
}
|
|
346
|
-
// Auto-cleanup: remove completed agents after 30 minutes
|
|
347
|
-
setTimeout(() => {
|
|
348
|
-
const e = activeAgents.get(id);
|
|
349
|
-
if (e && e.info.status !== "running") {
|
|
350
|
-
activeAgents.delete(id);
|
|
351
|
-
}
|
|
352
|
-
}, 30 * 60 * 1000);
|
|
353
|
-
});
|
|
475
|
+
const queuedSpawn = { id, resolvedName, agentConfig, depth, timeoutId };
|
|
476
|
+
if (willRunImmediately) {
|
|
477
|
+
startRun(queuedSpawn);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
pendingQueue.push(queuedSpawn);
|
|
481
|
+
reindexQueue();
|
|
482
|
+
}
|
|
354
483
|
return Promise.resolve(id);
|
|
355
484
|
}
|
|
356
485
|
/**
|
|
@@ -365,7 +494,21 @@ export function listSubAgents() {
|
|
|
365
494
|
*/
|
|
366
495
|
export function cancelSubAgent(id) {
|
|
367
496
|
const entry = activeAgents.get(id);
|
|
368
|
-
if (!entry
|
|
497
|
+
if (!entry)
|
|
498
|
+
return false;
|
|
499
|
+
if (entry.info.status === "queued") {
|
|
500
|
+
// D3: remove from the pending queue, reindex, mark cancelled.
|
|
501
|
+
const idx = pendingQueue.findIndex((q) => q.id === id);
|
|
502
|
+
if (idx >= 0) {
|
|
503
|
+
const [removed] = pendingQueue.splice(idx, 1);
|
|
504
|
+
if (removed.timeoutId)
|
|
505
|
+
clearTimeout(removed.timeoutId);
|
|
506
|
+
reindexQueue();
|
|
507
|
+
}
|
|
508
|
+
entry.info.status = "cancelled";
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
if (entry.info.status !== "running")
|
|
369
512
|
return false;
|
|
370
513
|
entry.abort.abort();
|
|
371
514
|
entry.info.status = "cancelled";
|
|
@@ -418,6 +561,16 @@ export async function cancelAllSubAgents(notify = true) {
|
|
|
418
561
|
// delivered=true so runSubAgent.finally() can't fire a second
|
|
419
562
|
// delivery on the next microtask, (4) queue the I3 delivery.
|
|
420
563
|
const runningEntries = [];
|
|
564
|
+
// D3: clear the pending queue first so no entry starts during shutdown.
|
|
565
|
+
for (const q of pendingQueue.splice(0)) {
|
|
566
|
+
if (q.timeoutId)
|
|
567
|
+
clearTimeout(q.timeoutId);
|
|
568
|
+
const entry = activeAgents.get(q.id);
|
|
569
|
+
if (entry) {
|
|
570
|
+
entry.info.status = "cancelled";
|
|
571
|
+
entry.delivered = true; // no delivery for queued-never-ran agents
|
|
572
|
+
}
|
|
573
|
+
}
|
|
421
574
|
for (const [id, entry] of activeAgents) {
|
|
422
575
|
if (entry.info.status !== "running")
|
|
423
576
|
continue;
|
package/dist/tui/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { createInterface, cursorTo, clearLine as rlClearLine } from "readline";
|
|
|
20
20
|
import WebSocket from "ws";
|
|
21
21
|
import http from "http";
|
|
22
22
|
import { initI18n, t } from "../i18n.js";
|
|
23
|
+
import { BOT_VERSION } from "../version.js";
|
|
23
24
|
// Init i18n before anything else
|
|
24
25
|
initI18n();
|
|
25
26
|
// ── ANSI Colors & Styles ────────────────────────────────
|
|
@@ -410,11 +411,17 @@ async function handleCommand(cmd) {
|
|
|
410
411
|
}
|
|
411
412
|
break;
|
|
412
413
|
}
|
|
414
|
+
case "version":
|
|
415
|
+
case "v": {
|
|
416
|
+
console.log(`\n${C.bold}${C.brightCyan}🤖 Alvin Bot${C.reset} ${C.dim}v${BOT_VERSION}${C.reset}`);
|
|
417
|
+
console.log(`${C.dim}Node ${process.version} · ${process.platform}/${process.arch}${C.reset}\n`);
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
413
420
|
case "status":
|
|
414
421
|
case "s": {
|
|
415
422
|
try {
|
|
416
423
|
const data = await apiGet("/api/status");
|
|
417
|
-
console.log(`\n${C.bold}${C.brightCyan}${
|
|
424
|
+
console.log(`\n${C.bold}${C.brightCyan}🤖 Alvin Bot${C.reset} ${C.dim}v${BOT_VERSION}${C.reset}`);
|
|
418
425
|
console.log(`${C.gray}${"─".repeat(40)}${C.reset}`);
|
|
419
426
|
if (data.model) {
|
|
420
427
|
console.log(` ${C.cyan}${t("status.model")}${C.reset} ${data.model.model || data.model.name || "?"}`);
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central source of truth for the running Alvin Bot version.
|
|
3
|
+
* Read from package.json once at module load — subsequent imports
|
|
4
|
+
* return the cached string without touching disk.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from "fs";
|
|
7
|
+
import { dirname, resolve } from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
function readVersion() {
|
|
10
|
+
try {
|
|
11
|
+
// dist/version.js is two levels deep; package.json sits at the root
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const pkgPath = resolve(here, "..", "package.json");
|
|
14
|
+
const raw = readFileSync(pkgPath, "utf-8");
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
if (typeof parsed.version === "string")
|
|
17
|
+
return parsed.version;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
/* fall through to unknown */
|
|
21
|
+
}
|
|
22
|
+
return "unknown";
|
|
23
|
+
}
|
|
24
|
+
export const BOT_VERSION = readVersion();
|
package/dist/web/server.js
CHANGED
|
@@ -29,6 +29,7 @@ import { handleOpenAICompat } from "./openai-compat.js";
|
|
|
29
29
|
import { addCanvasClient } from "./canvas.js";
|
|
30
30
|
import { BOT_ROOT, ENV_FILE, PUBLIC_DIR, MEMORY_DIR, MEMORY_FILE, SOUL_FILE, DATA_DIR, MCP_CONFIG, SKILLS_DIR } from "../paths.js";
|
|
31
31
|
import { broadcast } from "../services/broadcast.js";
|
|
32
|
+
import { BOT_VERSION } from "../version.js";
|
|
32
33
|
const WEB_PORT = parseInt(process.env.WEB_PORT || "3100");
|
|
33
34
|
const WEB_PASSWORD = process.env.WEB_PASSWORD || "";
|
|
34
35
|
/** The actual port the Web UI is running on (may differ from WEB_PORT if busy). */
|
|
@@ -259,7 +260,7 @@ async function handleAPI(req, res, urlPath, body) {
|
|
|
259
260
|
}
|
|
260
261
|
const { config: appConfig } = await import("../config.js");
|
|
261
262
|
res.end(JSON.stringify({
|
|
262
|
-
bot: { version:
|
|
263
|
+
bot: { version: BOT_VERSION, uptime: process.uptime() },
|
|
263
264
|
model: modelInfo,
|
|
264
265
|
memory: { ...memory, vectors: index.entries, indexSize: index.sizeBytes },
|
|
265
266
|
plugins: plugins.length,
|
package/docs/HANDBOOK.md
CHANGED
|
@@ -310,6 +310,7 @@ This allows the "scatter-gather" pattern (main → orchestrator → 10 workers)
|
|
|
310
310
|
- `auto` (default) — source-based routing: implicit stays in the parent stream, user and cron get a banner+final delivery.
|
|
311
311
|
- `banner` — always send a banner+final, even for implicit spawns.
|
|
312
312
|
- `silent` — never send. The result is still stored in the activeAgents map for 30 minutes and pullable via `/subagents result <name>`.
|
|
313
|
+
- **`live`** — stream incremental updates into a single Telegram message as the agent thinks. Only applies to `source: "user"` spawns with a `parentChatId`. The live message is plain text (so half-formed markdown during streaming can't break the edit), updates are throttled to 800 ms between edits, and a separate banner message is posted at the end so you get a completion notification. If the bot API doesn't support `editMessageText` or the live setup fails, we fall through to `banner` mode automatically.
|
|
313
314
|
|
|
314
315
|
### 7.6 Inheritance
|
|
315
316
|
|
|
@@ -320,7 +321,29 @@ Sub-agents inherit from the spawning context:
|
|
|
320
321
|
- **Model and tools** — inherited via the provider registry.
|
|
321
322
|
- **Conversation history** — **not inherited.** Sub-agents receive only their own prompt. This forces clean, self-describing spawn requests.
|
|
322
323
|
|
|
323
|
-
### 7.7
|
|
324
|
+
### 7.7 Bounded priority queue
|
|
325
|
+
|
|
326
|
+
When the running pool hits `maxParallel`, new spawn requests land in a bounded queue instead of being rejected immediately.
|
|
327
|
+
|
|
328
|
+
- **Default cap:** 20 slots. Configure via `/subagents queue <n>` (clamped to 0–200).
|
|
329
|
+
- **Disable:** `/subagents queue 0` — restores the old reject-on-full behavior.
|
|
330
|
+
- **Priority order on drain:** `user > cron > implicit`. Within each priority class, FIFO.
|
|
331
|
+
- **`/subagents list`** shows queued entries with a `#N` suffix indicating their position.
|
|
332
|
+
- **Cancel a queued entry** with `/subagents cancel <name>` — it's removed from the queue without ever starting.
|
|
333
|
+
|
|
334
|
+
Reject is only triggered when the pool **and** the queue are both full. The reject message is priority-aware and names who's holding the slots.
|
|
335
|
+
|
|
336
|
+
### 7.8 Stats
|
|
337
|
+
|
|
338
|
+
`/subagents stats` shows a summary of the last 24 hours of sub-agent runs:
|
|
339
|
+
|
|
340
|
+
- Total runs + total tokens + total wall time
|
|
341
|
+
- Runs per source (user / cron / implicit)
|
|
342
|
+
- Runs per status (completed / cancelled / timeout / error)
|
|
343
|
+
|
|
344
|
+
The backing data is an append-only JSON ring buffer at `~/.alvin-bot/subagent-stats.json`. Entries older than 24 hours are pruned automatically. A hard cap of 5000 entries protects against runaway growth on very busy bots.
|
|
345
|
+
|
|
346
|
+
### 7.9 Shutdown notifications
|
|
324
347
|
|
|
325
348
|
When you restart the bot (SIGTERM), any still-running sub-agents get a cancellation delivery before the process exits:
|
|
326
349
|
|
|
@@ -433,7 +456,14 @@ All commands are triggered from any platform that supports commands (Telegram, D
|
|
|
433
456
|
| Command | Purpose |
|
|
434
457
|
|---|---|
|
|
435
458
|
| `/cron` | Manage scheduled jobs |
|
|
436
|
-
| `/subagents` |
|
|
459
|
+
| `/subagents` | Show sub-agent status |
|
|
460
|
+
| `/subagents max <n>` | Set max parallel (0 = auto) |
|
|
461
|
+
| `/subagents queue <n>` | Set bounded-queue cap (0 = disabled) |
|
|
462
|
+
| `/subagents visibility <auto\|banner\|silent\|live>` | Delivery mode |
|
|
463
|
+
| `/subagents list` | List all (queued + running + recent) |
|
|
464
|
+
| `/subagents cancel <name\|id>` | Cancel one |
|
|
465
|
+
| `/subagents result <name\|id>` | Show a completed result |
|
|
466
|
+
| `/subagents stats` | Last 24h run stats (by source + status) |
|
|
437
467
|
| `/webui` | Open web UI URL |
|
|
438
468
|
| `/setup` | Re-run the setup wizard flow from chat |
|
|
439
469
|
| `/restart` | Restart the bot process |
|
|
@@ -776,6 +806,13 @@ Alvin Bot follows semver for the **data directory format**. Minor version bumps
|
|
|
776
806
|
- Memory: no schema change.
|
|
777
807
|
- `.env`: no new required variables. `MAX_SUBAGENTS` and `SUBAGENT_TIMEOUT` are optional.
|
|
778
808
|
|
|
809
|
+
### 16.3 From 4.6.x to 4.7.0
|
|
810
|
+
|
|
811
|
+
- Sub-agents: new fields in `sub-agents.json` (`queueCap`, defaults to 20). Old files auto-upgrade.
|
|
812
|
+
- New file `~/.alvin-bot/subagent-stats.json` — auto-created when the first sub-agent finishes.
|
|
813
|
+
- `start`/`stop` now auto-detect the LaunchAgent on macOS. No migration needed; if you previously installed the LaunchAgent in 4.6.0, `alvin-bot start` now correctly reloads it instead of spawning a parallel pm2 process.
|
|
814
|
+
- No new required `.env` variables.
|
|
815
|
+
|
|
779
816
|
### 16.3 From git
|
|
780
817
|
|
|
781
818
|
```bash
|
package/package.json
CHANGED
|
@@ -167,3 +167,107 @@ describe("subagent-delivery (I3)", () => {
|
|
|
167
167
|
expect(sentMessages).toHaveLength(0);
|
|
168
168
|
});
|
|
169
169
|
});
|
|
170
|
+
|
|
171
|
+
describe("subagent-delivery LiveStream (A4)", () => {
|
|
172
|
+
const edits: Array<{ chatId: number; messageId: number; text: string }> = [];
|
|
173
|
+
let messageCounter = 100;
|
|
174
|
+
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
edits.length = 0;
|
|
177
|
+
messageCounter = 100;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
async function wireLiveApi() {
|
|
181
|
+
const mod = await import("../src/services/subagent-delivery.js");
|
|
182
|
+
mod.__setBotApiForTest({
|
|
183
|
+
sendMessage: async (chatId: number, text: string) => {
|
|
184
|
+
sentMessages.push({ chatId, text });
|
|
185
|
+
return { message_id: messageCounter++ };
|
|
186
|
+
},
|
|
187
|
+
sendDocument: async (chatId: number) => {
|
|
188
|
+
sentDocuments.push({ chatId });
|
|
189
|
+
return {};
|
|
190
|
+
},
|
|
191
|
+
editMessageText: async (chatId: number, messageId: number, text: string) => {
|
|
192
|
+
edits.push({ chatId, messageId, text });
|
|
193
|
+
return {};
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
return mod;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
it("start posts an initial 'thinking…' message and records messageId", async () => {
|
|
200
|
+
const mod = await wireLiveApi();
|
|
201
|
+
const stream = mod.createLiveStream(555, "code-review");
|
|
202
|
+
expect(stream).not.toBeNull();
|
|
203
|
+
await stream!.start();
|
|
204
|
+
|
|
205
|
+
expect(sentMessages).toHaveLength(1);
|
|
206
|
+
expect(sentMessages[0].chatId).toBe(555);
|
|
207
|
+
expect(sentMessages[0].text).toContain("thinking");
|
|
208
|
+
expect(stream!.failed).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("update coalesces multiple rapid calls into a single throttled edit", async () => {
|
|
212
|
+
const mod = await wireLiveApi();
|
|
213
|
+
const stream = mod.createLiveStream(1, "fast");
|
|
214
|
+
await stream!.start();
|
|
215
|
+
|
|
216
|
+
stream!.update("hello");
|
|
217
|
+
stream!.update("hello world");
|
|
218
|
+
stream!.update("hello world and more");
|
|
219
|
+
|
|
220
|
+
// Wait for the throttle window to elapse
|
|
221
|
+
await new Promise((r) => setTimeout(r, 900));
|
|
222
|
+
|
|
223
|
+
// Should have produced exactly one edit with the LAST text
|
|
224
|
+
expect(edits.length).toBe(1);
|
|
225
|
+
expect(edits[0].text).toContain("hello world and more");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("finalize posts a banner as a new message", async () => {
|
|
229
|
+
const mod = await wireLiveApi();
|
|
230
|
+
const stream = mod.createLiveStream(42, "done-agent");
|
|
231
|
+
await stream!.start();
|
|
232
|
+
stream!.update("final text");
|
|
233
|
+
await new Promise((r) => setTimeout(r, 900)); // let flush run
|
|
234
|
+
|
|
235
|
+
await stream!.finalize(
|
|
236
|
+
{
|
|
237
|
+
id: "x",
|
|
238
|
+
name: "done-agent",
|
|
239
|
+
status: "completed",
|
|
240
|
+
startedAt: Date.now() - 5000,
|
|
241
|
+
source: "user",
|
|
242
|
+
depth: 0,
|
|
243
|
+
parentChatId: 42,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: "x",
|
|
247
|
+
name: "done-agent",
|
|
248
|
+
status: "completed",
|
|
249
|
+
output: "final text",
|
|
250
|
+
tokensUsed: { input: 100, output: 50 },
|
|
251
|
+
duration: 5000,
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Two sends total: initial "thinking…" + final banner
|
|
256
|
+
expect(sentMessages.length).toBe(2);
|
|
257
|
+
const banner = sentMessages[sentMessages.length - 1].text;
|
|
258
|
+
expect(banner).toContain("done-agent");
|
|
259
|
+
expect(banner).toContain("completed");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("createLiveStream returns null when bot api lacks editMessageText", async () => {
|
|
263
|
+
const mod = await import("../src/services/subagent-delivery.js");
|
|
264
|
+
// Set an api that intentionally has no editMessageText
|
|
265
|
+
mod.__setBotApiForTest({
|
|
266
|
+
sendMessage: async () => ({ message_id: 1 }),
|
|
267
|
+
sendDocument: async () => ({}),
|
|
268
|
+
// no editMessageText
|
|
269
|
+
});
|
|
270
|
+
const stream = mod.createLiveStream(1, "no-edit");
|
|
271
|
+
expect(stream).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
});
|