alvin-bot 4.5.1 → 4.7.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +278 -0
  2. package/README.md +25 -2
  3. package/bin/cli.js +325 -26
  4. package/dist/handlers/commands.js +505 -63
  5. package/dist/handlers/message.js +209 -14
  6. package/dist/i18n.js +470 -13
  7. package/dist/index.js +45 -5
  8. package/dist/providers/claude-sdk-provider.js +106 -14
  9. package/dist/providers/ollama-provider.js +32 -0
  10. package/dist/providers/openai-compatible.js +10 -1
  11. package/dist/providers/registry.js +112 -17
  12. package/dist/providers/types.js +25 -3
  13. package/dist/services/compaction.js +2 -0
  14. package/dist/services/cron.js +53 -42
  15. package/dist/services/heartbeat.js +41 -7
  16. package/dist/services/language-detect.js +12 -2
  17. package/dist/services/ollama-manager.js +339 -0
  18. package/dist/services/personality.js +20 -14
  19. package/dist/services/session.js +21 -3
  20. package/dist/services/subagent-delivery.js +266 -0
  21. package/dist/services/subagent-stats.js +123 -0
  22. package/dist/services/subagents.js +509 -42
  23. package/dist/services/telegram.js +28 -1
  24. package/dist/services/updater.js +158 -0
  25. package/dist/services/usage-tracker.js +11 -4
  26. package/dist/services/users.js +2 -1
  27. package/docs/HANDBOOK.md +856 -0
  28. package/package.json +7 -2
  29. package/test/claude-sdk-provider.test.ts +69 -0
  30. package/test/i18n.test.ts +108 -0
  31. package/test/registry.test.ts +201 -0
  32. package/test/subagent-delivery.test.ts +273 -0
  33. package/test/subagent-stats.test.ts +119 -0
  34. package/test/subagents-commands.test.ts +64 -0
  35. package/test/subagents-config.test.ts +114 -0
  36. package/test/subagents-depth.test.ts +58 -0
  37. package/test/subagents-inheritance.test.ts +67 -0
  38. package/test/subagents-name-resolver.test.ts +122 -0
  39. package/test/subagents-priority-reject.test.ts +88 -0
  40. package/test/subagents-queue.test.ts +127 -0
  41. package/test/subagents-shutdown.test.ts +126 -0
  42. package/test/subagents-toolset.test.ts +51 -0
  43. package/vitest.config.ts +17 -0
@@ -6,44 +6,286 @@
6
6
  * Results are stored and can be retrieved by the caller.
7
7
  */
8
8
  import os from "os";
9
+ import fs from "fs";
10
+ import { resolve, dirname } from "path";
9
11
  import crypto from "crypto";
10
12
  import { config } from "../config.js";
13
+ // ── File-based config (persistent, runtime-editable) ───────────────────
14
+ const DATA_DIR = process.env.ALVIN_DATA_DIR || resolve(os.homedir(), ".alvin-bot");
15
+ const CONFIG_FILE = resolve(DATA_DIR, "sub-agents.json");
16
+ const ABSOLUTE_MAX_AGENTS = 16; // Hard cap no matter what
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
20
+ let configCache = null;
21
+ function isValidVisibility(v) {
22
+ return v === "auto" || v === "banner" || v === "silent" || v === "live";
23
+ }
24
+ function loadSubAgentsConfig() {
25
+ if (configCache)
26
+ return configCache;
27
+ try {
28
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
29
+ const parsed = JSON.parse(raw);
30
+ configCache = {
31
+ maxParallel: typeof parsed.maxParallel === "number" ? parsed.maxParallel : 0,
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,
36
+ };
37
+ }
38
+ catch {
39
+ // File missing or invalid — seed from env var then default to auto
40
+ configCache = {
41
+ maxParallel: Number(process.env.MAX_SUBAGENTS) || 0,
42
+ visibility: "auto",
43
+ queueCap: DEFAULT_QUEUE_CAP,
44
+ };
45
+ }
46
+ return configCache;
47
+ }
48
+ function saveSubAgentsConfig(cfg) {
49
+ try {
50
+ fs.mkdirSync(dirname(CONFIG_FILE), { recursive: true });
51
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
52
+ configCache = cfg;
53
+ }
54
+ catch (err) {
55
+ console.error("[subagents] failed to write config:", err);
56
+ }
57
+ }
58
+ /** Resolves max parallel agents, interpreting 0 as "auto = cpu cores capped". */
59
+ export function getMaxParallelAgents() {
60
+ const cfg = loadSubAgentsConfig();
61
+ if (cfg.maxParallel === 0) {
62
+ return Math.min(os.cpus().length, ABSOLUTE_MAX_AGENTS);
63
+ }
64
+ return Math.min(Math.max(1, cfg.maxParallel), ABSOLUTE_MAX_AGENTS);
65
+ }
66
+ /** Returns the raw configured value (for display). 0 means "auto". */
67
+ export function getConfiguredMaxParallel() {
68
+ return loadSubAgentsConfig().maxParallel;
69
+ }
70
+ /** Sets max parallel agents. Value is clamped to [0, ABSOLUTE_MAX_AGENTS].
71
+ * Returns the resolved effective value (with auto-expansion if set to 0). */
72
+ export function setMaxParallelAgents(n) {
73
+ const clamped = Math.max(0, Math.min(Math.floor(n), ABSOLUTE_MAX_AGENTS));
74
+ const cfg = loadSubAgentsConfig();
75
+ saveSubAgentsConfig({ ...cfg, maxParallel: clamped });
76
+ return getMaxParallelAgents();
77
+ }
78
+ /** A4: Current default visibility mode for new spawns. */
79
+ export function getVisibility() {
80
+ return loadSubAgentsConfig().visibility;
81
+ }
82
+ /**
83
+ * A4: Set the default visibility mode. Throws if the value is invalid.
84
+ * Writes through to the on-disk config so restart-resilient.
85
+ */
86
+ export function setVisibility(mode) {
87
+ if (!isValidVisibility(mode)) {
88
+ throw new Error(`Invalid visibility mode "${mode}". Expected: auto | banner | silent | live.`);
89
+ }
90
+ const cfg = loadSubAgentsConfig();
91
+ saveSubAgentsConfig({ ...cfg, visibility: mode });
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
+ }
11
105
  // ── State ───────────────────────────────────────────────
12
106
  const activeAgents = new Map();
107
+ // ── Name resolver (B2) ──────────────────────────────────
108
+ /**
109
+ * Return all currently-tracked agents whose *base* name matches `base`.
110
+ * Base name = the part before any "#N" suffix.
111
+ */
112
+ function agentsByBaseName(base) {
113
+ const out = [];
114
+ for (const entry of activeAgents.values()) {
115
+ const info = entry.info;
116
+ const entryBase = info.name.replace(/#\d+$/, "");
117
+ if (entryBase === base)
118
+ out.push(info);
119
+ }
120
+ return out;
121
+ }
122
+ /**
123
+ * Given a requested name, return a unique variant. If no collision exists,
124
+ * returns `requested` unchanged (with the base form). Otherwise returns
125
+ * `base#N` with the smallest free N ≥ 2.
126
+ */
127
+ function resolveAgentName(requested) {
128
+ const base = requested.replace(/#\d+$/, "");
129
+ const siblings = agentsByBaseName(base);
130
+ if (siblings.length === 0)
131
+ return { name: base };
132
+ // Find the smallest free index ≥ 2. The bare base name counts as "#1".
133
+ const takenIndices = new Set();
134
+ for (const s of siblings) {
135
+ const m = s.name.match(/#(\d+)$/);
136
+ if (m)
137
+ takenIndices.add(parseInt(m[1], 10));
138
+ else
139
+ takenIndices.add(1);
140
+ }
141
+ let n = 2;
142
+ while (takenIndices.has(n))
143
+ n++;
144
+ return { name: `${base}#${n}`, index: n };
145
+ }
146
+ /**
147
+ * Public name-resolution API used by /sub-agents cancel / result.
148
+ * - Exact name match wins (e.g. "review#2" finds exactly that entry).
149
+ * - If only one agent matches the base name, returns that one.
150
+ * - If the caller opted into `ambiguousAsList`, returns a disambiguation
151
+ * marker with all candidates instead of a single result.
152
+ */
153
+ export function findSubAgentByName(name, opts = {}) {
154
+ // An explicit "base#N" query must always resolve to that exact entry,
155
+ // even when the caller opted into ambiguity. Otherwise users who type
156
+ // out the disambiguated form get an unhelpful 'which one?' reply.
157
+ const hasExplicitSuffix = /#\d+$/.test(name);
158
+ if (hasExplicitSuffix) {
159
+ for (const entry of activeAgents.values()) {
160
+ if (entry.info.name === name)
161
+ return { ...entry.info };
162
+ }
163
+ return null;
164
+ }
165
+ // No explicit suffix → base-name query. Ambiguity detection runs here
166
+ // when the caller opted in and there are multiple siblings.
167
+ const siblings = agentsByBaseName(name);
168
+ if (siblings.length === 0)
169
+ return null;
170
+ if (opts.ambiguousAsList && siblings.length > 1) {
171
+ return {
172
+ ambiguous: true,
173
+ candidates: siblings.map((s) => ({ ...s })),
174
+ };
175
+ }
176
+ // Without ambiguity opt-in, prefer an exact name match over just the
177
+ // first sibling — the bare base name is itself a unique key.
178
+ for (const entry of activeAgents.values()) {
179
+ if (entry.info.name === name)
180
+ return { ...entry.info };
181
+ }
182
+ return { ...siblings[0] };
183
+ }
13
184
  // ── Core execution ──────────────────────────────────────
14
- async function runSubAgent(id, agentConfig, abort) {
185
+ async function runSubAgent(id, agentConfig, abort, resolvedName) {
15
186
  const startTime = Date.now();
16
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
+ }
17
210
  try {
18
211
  const { getRegistry } = await import("../engine.js");
19
212
  const registry = getRegistry();
20
- const systemPrompt = `You are a sub-agent named "${agentConfig.name}". Complete the following task autonomously and report your results clearly when done. Working directory: ${agentConfig.workingDir || os.homedir()}`;
213
+ // C3: inheritCwd (default true) decides whether the parent's working
214
+ // dir flows through. When false, we fall back to the home directory —
215
+ // useful for cron jobs that must run in a well-known root regardless
216
+ // of what the caller was doing.
217
+ const inheritCwd = agentConfig.inheritCwd ?? true;
218
+ const effectiveCwd = inheritCwd
219
+ ? agentConfig.workingDir || os.homedir()
220
+ : os.homedir();
221
+ const systemPrompt = `You are a sub-agent named "${resolvedName}". Complete the following task autonomously and report your results clearly when done. Working directory: ${effectiveCwd}`;
21
222
  let finalText = "";
22
223
  let inputTokens = 0;
23
224
  let outputTokens = 0;
24
225
  for await (const chunk of registry.queryWithFallback({
25
226
  prompt: agentConfig.prompt,
26
227
  systemPrompt,
27
- workingDir: agentConfig.workingDir || os.homedir(),
228
+ workingDir: effectiveCwd,
28
229
  effort: "high",
29
230
  abortSignal: abort.signal,
30
231
  })) {
31
- if (chunk.type === "text")
232
+ if (chunk.type === "text") {
32
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
+ }
33
239
  if (chunk.type === "done") {
34
240
  inputTokens = chunk.inputTokens || 0;
35
241
  outputTokens = chunk.outputTokens || 0;
36
242
  }
37
243
  }
38
- entry.result = {
39
- id,
40
- name: agentConfig.name,
41
- status: "completed",
42
- output: finalText,
43
- tokensUsed: { input: inputTokens, output: outputTokens },
44
- duration: Date.now() - startTime,
45
- };
46
- entry.info.status = "completed";
244
+ // If cancelAllSubAgents has already taken over (shutdown path), don't
245
+ // overwrite the cancelled result it synthesised. Also: if the generator
246
+ // exited gracefully but the abort signal fired mid-stream (e.g. the
247
+ // provider's queryWithFallback returned `type:error` and we fell out
248
+ // of the loop without throwing), mark the run as cancelled rather
249
+ // than completed the result output is whatever we buffered.
250
+ if (entry.result && entry.result.status === "cancelled") {
251
+ // cancelAllSubAgents already set this; nothing to do.
252
+ }
253
+ else if (abort.signal.aborted) {
254
+ entry.result = {
255
+ id,
256
+ name: resolvedName,
257
+ status: "cancelled",
258
+ output: finalText,
259
+ tokensUsed: { input: inputTokens, output: outputTokens },
260
+ duration: Date.now() - startTime,
261
+ };
262
+ entry.info.status = "cancelled";
263
+ }
264
+ else {
265
+ entry.result = {
266
+ id,
267
+ name: resolvedName,
268
+ status: "completed",
269
+ output: finalText,
270
+ tokensUsed: { input: inputTokens, output: outputTokens },
271
+ duration: Date.now() - startTime,
272
+ };
273
+ entry.info.status = "completed";
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
+ }
47
289
  }
48
290
  catch (err) {
49
291
  const isAbort = err instanceof Error && err.message.includes("abort");
@@ -55,7 +297,7 @@ async function runSubAgent(id, agentConfig, abort) {
55
297
  : "error";
56
298
  entry.result = {
57
299
  id,
58
- name: agentConfig.name,
300
+ name: resolvedName,
59
301
  status,
60
302
  output: "",
61
303
  tokensUsed: { input: 0, output: 0 },
@@ -65,42 +307,179 @@ async function runSubAgent(id, agentConfig, abort) {
65
307
  entry.info.status = status;
66
308
  }
67
309
  }
68
- // ── Public API ──────────────────────────────────────────
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
+ }
69
320
  /**
70
- * Spawn an isolated sub-agent that runs in the background.
71
- * Returns the agent ID immediately (does NOT await completion).
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.
72
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
+ }
334
+ /**
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.
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
+ }
73
415
  export function spawnSubAgent(agentConfig) {
74
- // Check concurrency limit
75
- const runningCount = [...activeAgents.values()].filter((a) => a.info.status === "running").length;
76
- if (runningCount >= config.maxSubAgents) {
77
- return Promise.reject(new Error(`Sub-agent limit reached (${config.maxSubAgents}). Wait for a running agent to finish or cancel one.`));
416
+ // F2: enforce depth cap before touching any state.
417
+ const depth = agentConfig.depth ?? 0;
418
+ if (depth > MAX_SUBAGENT_DEPTH) {
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.`));
78
420
  }
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).
424
+ const toolset = agentConfig.toolset ?? "full";
425
+ if (toolset !== "full") {
426
+ return Promise.reject(new Error(`Invalid toolset "${toolset}". Only "full" is supported in this version.`));
427
+ }
428
+ const maxParallel = getMaxParallelAgents();
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;
79
435
  const id = crypto.randomUUID();
80
436
  const timeout = agentConfig.timeout ?? config.subAgentTimeout;
81
437
  const abort = new AbortController();
82
- // Set up timeout
83
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;
448
+ let message;
449
+ if (source === "user") {
450
+ if (bgSlots > 0) {
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>.`;
452
+ }
453
+ else {
454
+ message = `Alle Slots belegt (${running}/${maxParallel}) mit eigenen user-Spawns. Queue voll (${queuedLen}/${queueCap}). /subagents cancel <name> oder warten.`;
455
+ }
456
+ }
457
+ else {
458
+ message = `Sub-agent limit reached (${maxParallel} running, ${queuedLen}/${queueCap} queued). Wait for a running agent to finish or cancel one.`;
459
+ }
460
+ return Promise.reject(new Error(message));
461
+ }
84
462
  const info = {
85
463
  id,
86
- name: agentConfig.name,
87
- status: "running",
464
+ name: resolvedName,
465
+ status: willRunImmediately ? "running" : "queued",
88
466
  startedAt: Date.now(),
89
467
  model: agentConfig.model,
468
+ source: agentConfig.source,
469
+ depth,
470
+ parentChatId: agentConfig.parentChatId,
471
+ nameIndex: resolved.index,
472
+ queuePosition: willRunImmediately ? undefined : queuedLen + 1,
90
473
  };
91
- activeAgents.set(id, { info, abort });
92
- // Run in background don't await
93
- runSubAgent(id, agentConfig, abort)
94
- .finally(() => {
95
- clearTimeout(timeoutId);
96
- // Auto-cleanup: remove completed agents after 30 minutes
97
- setTimeout(() => {
98
- const entry = activeAgents.get(id);
99
- if (entry && entry.info.status !== "running") {
100
- activeAgents.delete(id);
101
- }
102
- }, 30 * 60 * 1000);
103
- });
474
+ activeAgents.set(id, { info, abort, delivered: false });
475
+ const queuedSpawn = { id, resolvedName, agentConfig, depth, timeoutId };
476
+ if (willRunImmediately) {
477
+ startRun(queuedSpawn);
478
+ }
479
+ else {
480
+ pendingQueue.push(queuedSpawn);
481
+ reindexQueue();
482
+ }
104
483
  return Promise.resolve(id);
105
484
  }
106
485
  /**
@@ -115,7 +494,21 @@ export function listSubAgents() {
115
494
  */
116
495
  export function cancelSubAgent(id) {
117
496
  const entry = activeAgents.get(id);
118
- if (!entry || entry.info.status !== "running")
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")
119
512
  return false;
120
513
  entry.abort.abort();
121
514
  entry.info.status = "cancelled";
@@ -129,14 +522,88 @@ export function getSubAgentResult(id) {
129
522
  const entry = activeAgents.get(id);
130
523
  return entry?.result ?? null;
131
524
  }
525
+ /**
526
+ * Cancel a sub-agent by name (or name#N). Returns true if a running agent
527
+ * was found and aborted. Uses findSubAgentByName for resolution; in an
528
+ * ambiguous case (multiple siblings under the same base name, caller did
529
+ * not disambiguate), cancels the first candidate.
530
+ */
531
+ export function cancelSubAgentByName(name) {
532
+ const match = findSubAgentByName(name);
533
+ if (!match || "ambiguous" in match)
534
+ return false;
535
+ return cancelSubAgent(match.id);
536
+ }
537
+ /**
538
+ * Get a sub-agent's result by name. Returns null if no such agent, no
539
+ * result yet (still running), or the name is ambiguous without explicit
540
+ * disambiguation.
541
+ */
542
+ export function getSubAgentResultByName(name) {
543
+ const match = findSubAgentByName(name);
544
+ if (!match || "ambiguous" in match)
545
+ return null;
546
+ return getSubAgentResult(match.id);
547
+ }
132
548
  /**
133
549
  * Cancel all active sub-agents. Used during shutdown.
550
+ *
551
+ * When notify=true (default), each running agent gets a Telegram
552
+ * delivery explaining that it was interrupted by a restart. Errors
553
+ * during delivery are logged but never block shutdown. The whole
554
+ * notify phase is capped at 5s so a hung Telegram send can't hold
555
+ * the process hostage.
134
556
  */
135
- export function cancelAllSubAgents() {
136
- for (const [id, entry] of activeAgents) {
137
- if (entry.info.status === "running") {
138
- entry.abort.abort();
557
+ export async function cancelAllSubAgents(notify = true) {
558
+ const deliveryPromises = [];
559
+ // Iterate once: for each running agent (1) abort the SDK stream,
560
+ // (2) synthesise and store a cancelled SubAgentResult, (3) mark
561
+ // delivered=true so runSubAgent.finally() can't fire a second
562
+ // delivery on the next microtask, (4) queue the I3 delivery.
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) {
139
570
  entry.info.status = "cancelled";
571
+ entry.delivered = true; // no delivery for queued-never-ran agents
140
572
  }
141
573
  }
574
+ for (const [id, entry] of activeAgents) {
575
+ if (entry.info.status !== "running")
576
+ continue;
577
+ entry.abort.abort();
578
+ entry.info.status = "cancelled";
579
+ const cancelResult = {
580
+ id,
581
+ name: entry.info.name,
582
+ status: "cancelled",
583
+ output: "⚠️ Agent wurde durch Bot-Restart unterbrochen. Bitte neu triggern.",
584
+ tokensUsed: { input: 0, output: 0 },
585
+ duration: Date.now() - entry.info.startedAt,
586
+ };
587
+ entry.result = cancelResult;
588
+ entry.delivered = true;
589
+ runningEntries.push({ id, info: entry.info, cancelResult });
590
+ }
591
+ if (!notify || runningEntries.length === 0)
592
+ return;
593
+ // Import once, then reuse. Doing one dynamic import per running agent
594
+ // races with Vitest's mock-resolution in tests and can occasionally
595
+ // resolve to the real module instead of the mock for later calls.
596
+ const { deliverSubAgentResult } = await import("./subagent-delivery.js");
597
+ for (const { id, info, cancelResult } of runningEntries) {
598
+ const p = Promise.resolve(deliverSubAgentResult(info, cancelResult)).catch((err) => {
599
+ console.error(`[subagents] shutdown-notify failed for ${id}:`, err);
600
+ });
601
+ deliveryPromises.push(p);
602
+ }
603
+ // Wait up to 5s total — long enough for real Telegram sends, short
604
+ // enough that shutdown isn't held hostage by a hang.
605
+ await Promise.race([
606
+ Promise.all(deliveryPromises),
607
+ new Promise((r) => setTimeout(r, 5000)),
608
+ ]);
142
609
  }
@@ -9,13 +9,38 @@ export class TelegramStreamer {
9
9
  pendingText = null;
10
10
  editTimer = null;
11
11
  lastSentText = "";
12
+ currentStatus = null;
13
+ lastFullText = "";
12
14
  constructor(chatId, api, replyToMessageId) {
13
15
  this.chatId = chatId;
14
16
  this.api = api;
15
17
  this.replyTo = replyToMessageId;
16
18
  }
19
+ /**
20
+ * Set a transient status line (e.g. "📖 Read file.html…") that gets
21
+ * appended to the current accumulated text. Passing null clears it.
22
+ * Used to surface tool-use activity so users see real progress instead
23
+ * of an endless typing indicator.
24
+ */
25
+ setStatus(status) {
26
+ if (this.currentStatus === status)
27
+ return;
28
+ this.currentStatus = status;
29
+ // Re-render with the previously accumulated text so the new status
30
+ // becomes visible immediately (throttled by the existing flush timer).
31
+ void this.update(this.lastFullText);
32
+ }
33
+ renderWithStatus(fullText) {
34
+ const truncated = this.truncate(fullText);
35
+ const hasBody = truncated.length > 0;
36
+ const body = hasBody ? truncated : "…";
37
+ if (!this.currentStatus)
38
+ return body;
39
+ return hasBody ? `${body}\n\n_${this.currentStatus}_` : `_${this.currentStatus}_`;
40
+ }
17
41
  async update(fullText) {
18
- const displayText = sanitizeTelegramMarkdown(this.truncate(fullText) || "...");
42
+ this.lastFullText = fullText;
43
+ const displayText = sanitizeTelegramMarkdown(this.renderWithStatus(fullText));
19
44
  if (!this.messageId) {
20
45
  const opts = { parse_mode: "Markdown" };
21
46
  if (this.replyTo)
@@ -52,6 +77,8 @@ export class TelegramStreamer {
52
77
  }
53
78
  }
54
79
  async finalize(fullText) {
80
+ // Drop any transient status line — final message should be clean text.
81
+ this.currentStatus = null;
55
82
  if (this.editTimer) {
56
83
  clearTimeout(this.editTimer);
57
84
  this.editTimer = null;