alvin-bot 4.5.0 → 4.6.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 (42) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +25 -2
  3. package/alvin-bot-4.5.1.tgz +0 -0
  4. package/bin/cli.js +246 -0
  5. package/dist/handlers/commands.js +461 -63
  6. package/dist/handlers/message.js +209 -14
  7. package/dist/i18n.js +470 -13
  8. package/dist/index.js +44 -5
  9. package/dist/providers/claude-sdk-provider.js +106 -14
  10. package/dist/providers/ollama-provider.js +32 -0
  11. package/dist/providers/openai-compatible.js +10 -1
  12. package/dist/providers/registry.js +112 -17
  13. package/dist/providers/types.js +25 -3
  14. package/dist/services/compaction.js +2 -0
  15. package/dist/services/cron.js +53 -42
  16. package/dist/services/heartbeat.js +41 -7
  17. package/dist/services/language-detect.js +12 -2
  18. package/dist/services/ollama-manager.js +339 -0
  19. package/dist/services/personality.js +20 -14
  20. package/dist/services/session.js +21 -3
  21. package/dist/services/subagent-delivery.js +111 -0
  22. package/dist/services/subagents.js +341 -27
  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/dist/tui/index.js +36 -30
  28. package/docs/HANDBOOK.md +819 -0
  29. package/package.json +7 -2
  30. package/test/claude-sdk-provider.test.ts +69 -0
  31. package/test/i18n.test.ts +108 -0
  32. package/test/registry.test.ts +201 -0
  33. package/test/subagent-delivery.test.ts +169 -0
  34. package/test/subagents-commands.test.ts +64 -0
  35. package/test/subagents-config.test.ts +108 -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 +60 -0
  40. package/test/subagents-shutdown.test.ts +126 -0
  41. package/test/subagents-toolset.test.ts +51 -0
  42. package/vitest.config.ts +17 -0
@@ -27,15 +27,20 @@ export function getSession(key) {
27
27
  totalCost: 0,
28
28
  costByProvider: {},
29
29
  queriesByProvider: {},
30
- effort: "high",
30
+ effort: "medium",
31
31
  voiceReply: false,
32
32
  messageCount: 0,
33
33
  toolUseCount: 0,
34
34
  totalInputTokens: 0,
35
35
  totalOutputTokens: 0,
36
+ lastTurnInputTokens: 0,
37
+ compactionCount: 0,
38
+ checkpointHintsInjected: 0,
39
+ sdkSubTaskCount: 0,
36
40
  history: [],
37
41
  language: "en",
38
42
  messageQueue: [],
43
+ lastSdkHistoryIndex: -1,
39
44
  };
40
45
  sessions.set(k, session);
41
46
  }
@@ -56,7 +61,12 @@ export function resetSession(key) {
56
61
  session.toolUseCount = 0;
57
62
  session.totalInputTokens = 0;
58
63
  session.totalOutputTokens = 0;
64
+ session.lastTurnInputTokens = 0;
65
+ session.compactionCount = 0;
66
+ session.checkpointHintsInjected = 0;
67
+ session.sdkSubTaskCount = 0;
59
68
  session.history = [];
69
+ session.lastSdkHistoryIndex = -1;
60
70
  session.startedAt = Date.now();
61
71
  // Reset budget warning flags so the user gets fresh warnings in the new session.
62
72
  session._budgetWarned80 = false;
@@ -138,13 +148,21 @@ export function stopSessionCleanup() {
138
148
  cleanupTimer = null;
139
149
  }
140
150
  }
141
- /** Add a message to conversation history (for non-SDK providers). */
151
+ /** Add a message to conversation history. Unified across all provider types
152
+ * — SDK providers resume from their filesystem session but we still track the
153
+ * transcript here so failovers (and the B2 bridge-message) have context. */
142
154
  export function addToHistory(key, message) {
143
155
  const session = getSession(key);
144
156
  session.history.push(message);
145
- // Trim oldest messages if history gets too long
157
+ // Trim oldest messages if history gets too long. Adjust lastSdkHistoryIndex
158
+ // by the number of dropped entries so it keeps pointing at the correct
159
+ // (now shifted) assistant turn — or collapses to -1 if it falls off the front.
146
160
  if (session.history.length > MAX_HISTORY) {
161
+ const dropped = session.history.length - MAX_HISTORY;
147
162
  session.history = session.history.slice(-MAX_HISTORY);
163
+ if (session.lastSdkHistoryIndex >= 0) {
164
+ session.lastSdkHistoryIndex = Math.max(-1, session.lastSdkHistoryIndex - dropped);
165
+ }
148
166
  }
149
167
  }
150
168
  /** Get all active sessions (for web UI session browser). */
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Sub-Agent Delivery Router (I3) — context-aware rendering of sub-agent
3
+ * results into Telegram. Source decides the delivery path:
4
+ * - implicit → no-op (main stream already shows the Task-tool result)
5
+ * - user → banner+final as a new message in parentChatId
6
+ * - cron → banner+final in chatId from the CronJob target
7
+ *
8
+ * The caller is responsible for passing a correct `parentChatId` on the
9
+ * SubAgentInfo. Lookup of the bot API is lazy so we can unit-test the
10
+ * module with a fake bot via __setBotApiForTest.
11
+ */
12
+ import { getVisibility } from "./subagents.js";
13
+ const MAX_TG_CHUNK = 3800; // below Telegram's 4096 limit with headroom
14
+ const FILE_UPLOAD_THRESHOLD = 20_000; // switch to .md file upload above this
15
+ let injectedApi = null;
16
+ let runtimeApi = null;
17
+ /** Test-only hook for injecting a fake bot API. Production code must NEVER call this. */
18
+ export function __setBotApiForTest(api) {
19
+ injectedApi = api;
20
+ }
21
+ /** Wire the grammy bot API once at startup (called from src/index.ts). */
22
+ export function attachBotApi(api) {
23
+ runtimeApi = api;
24
+ }
25
+ function getBotApi() {
26
+ return injectedApi ?? runtimeApi;
27
+ }
28
+ function formatTokens(n) {
29
+ if (n < 1000)
30
+ return `${n}`;
31
+ return `${(n / 1000).toFixed(1)}k`;
32
+ }
33
+ function formatDuration(ms) {
34
+ const s = Math.floor(ms / 1000);
35
+ if (s < 60)
36
+ return `${s}s`;
37
+ const m = Math.floor(s / 60);
38
+ const rem = s - m * 60;
39
+ return `${m}m ${rem}s`;
40
+ }
41
+ function statusIcon(status) {
42
+ switch (status) {
43
+ case "completed": return "✅";
44
+ case "timeout": return "⏱️";
45
+ case "cancelled": return "⚠️";
46
+ case "error": return "❌";
47
+ }
48
+ }
49
+ function buildBanner(info, result) {
50
+ const icon = statusIcon(result.status);
51
+ const dur = formatDuration(result.duration);
52
+ const ti = formatTokens(result.tokensUsed.input);
53
+ const to = formatTokens(result.tokensUsed.output);
54
+ return `${icon} *${info.name}* ${result.status} · ${dur} · ${ti} in / ${to} out`;
55
+ }
56
+ /**
57
+ * Main delivery entry point. Resolves the effective visibility (override →
58
+ * config default), then dispatches to the source-specific renderer.
59
+ *
60
+ * Errors are logged but never thrown — delivery must not break the sub-agent
61
+ * lifecycle. A failed Telegram send falls through silently.
62
+ */
63
+ export async function deliverSubAgentResult(info, result, opts = {}) {
64
+ // Implicit spawns: the Task-tool bridge in the main stream has already
65
+ // surfaced the output; extra delivery would be duplication.
66
+ if (info.source === "implicit")
67
+ return;
68
+ const effective = opts.visibility ?? getVisibility();
69
+ if (effective === "silent")
70
+ return;
71
+ const api = getBotApi();
72
+ if (!api) {
73
+ console.warn(`[subagent-delivery] no bot api available for ${info.name}`);
74
+ return;
75
+ }
76
+ if (!info.parentChatId) {
77
+ console.warn(`[subagent-delivery] missing parentChatId for ${info.name} (source=${info.source})`);
78
+ return;
79
+ }
80
+ const banner = buildBanner(info, result);
81
+ const body = result.output?.trim() || `(empty output)`;
82
+ try {
83
+ // Case 1: very long output → file upload with a short banner
84
+ if (body.length > FILE_UPLOAD_THRESHOLD) {
85
+ await api.sendMessage(info.parentChatId, banner, { parse_mode: "Markdown" });
86
+ try {
87
+ const { InputFile } = await import("grammy");
88
+ const buf = Buffer.from(body, "utf-8");
89
+ await api.sendDocument(info.parentChatId, new InputFile(buf, `${info.name}.md`));
90
+ }
91
+ catch (err) {
92
+ console.error(`[subagent-delivery] file upload failed:`, err);
93
+ await api.sendMessage(info.parentChatId, body.slice(0, MAX_TG_CHUNK));
94
+ }
95
+ return;
96
+ }
97
+ // Case 2: fits in a single message → banner + body joined
98
+ if (body.length + banner.length + 2 <= MAX_TG_CHUNK) {
99
+ await api.sendMessage(info.parentChatId, `${banner}\n\n${body}`, { parse_mode: "Markdown" });
100
+ return;
101
+ }
102
+ // Case 3: medium output → banner as its own message, body chunked
103
+ await api.sendMessage(info.parentChatId, banner, { parse_mode: "Markdown" });
104
+ for (let i = 0; i < body.length; i += MAX_TG_CHUNK) {
105
+ await api.sendMessage(info.parentChatId, body.slice(i, i + MAX_TG_CHUNK));
106
+ }
107
+ }
108
+ catch (err) {
109
+ console.error(`[subagent-delivery] send failed for ${info.name}:`, err);
110
+ }
111
+ }
@@ -6,25 +6,186 @@
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
+ let configCache = null;
19
+ function isValidVisibility(v) {
20
+ return v === "auto" || v === "banner" || v === "silent";
21
+ }
22
+ function loadSubAgentsConfig() {
23
+ if (configCache)
24
+ return configCache;
25
+ try {
26
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
27
+ const parsed = JSON.parse(raw);
28
+ configCache = {
29
+ maxParallel: typeof parsed.maxParallel === "number" ? parsed.maxParallel : 0,
30
+ visibility: isValidVisibility(parsed.visibility) ? parsed.visibility : "auto",
31
+ };
32
+ }
33
+ catch {
34
+ // File missing or invalid — seed from env var then default to auto
35
+ configCache = {
36
+ maxParallel: Number(process.env.MAX_SUBAGENTS) || 0,
37
+ visibility: "auto",
38
+ };
39
+ }
40
+ return configCache;
41
+ }
42
+ function saveSubAgentsConfig(cfg) {
43
+ try {
44
+ fs.mkdirSync(dirname(CONFIG_FILE), { recursive: true });
45
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), "utf-8");
46
+ configCache = cfg;
47
+ }
48
+ catch (err) {
49
+ console.error("[subagents] failed to write config:", err);
50
+ }
51
+ }
52
+ /** Resolves max parallel agents, interpreting 0 as "auto = cpu cores capped". */
53
+ export function getMaxParallelAgents() {
54
+ const cfg = loadSubAgentsConfig();
55
+ if (cfg.maxParallel === 0) {
56
+ return Math.min(os.cpus().length, ABSOLUTE_MAX_AGENTS);
57
+ }
58
+ return Math.min(Math.max(1, cfg.maxParallel), ABSOLUTE_MAX_AGENTS);
59
+ }
60
+ /** Returns the raw configured value (for display). 0 means "auto". */
61
+ export function getConfiguredMaxParallel() {
62
+ return loadSubAgentsConfig().maxParallel;
63
+ }
64
+ /** Sets max parallel agents. Value is clamped to [0, ABSOLUTE_MAX_AGENTS].
65
+ * Returns the resolved effective value (with auto-expansion if set to 0). */
66
+ export function setMaxParallelAgents(n) {
67
+ const clamped = Math.max(0, Math.min(Math.floor(n), ABSOLUTE_MAX_AGENTS));
68
+ const cfg = loadSubAgentsConfig();
69
+ saveSubAgentsConfig({ ...cfg, maxParallel: clamped });
70
+ return getMaxParallelAgents();
71
+ }
72
+ /** A4: Current default visibility mode for new spawns. */
73
+ export function getVisibility() {
74
+ return loadSubAgentsConfig().visibility;
75
+ }
76
+ /**
77
+ * A4: Set the default visibility mode. Throws if the value is invalid.
78
+ * Writes through to the on-disk config so restart-resilient.
79
+ */
80
+ export function setVisibility(mode) {
81
+ if (!isValidVisibility(mode)) {
82
+ throw new Error(`Invalid visibility mode "${mode}". Expected: auto | banner | silent.`);
83
+ }
84
+ const cfg = loadSubAgentsConfig();
85
+ saveSubAgentsConfig({ ...cfg, visibility: mode });
86
+ }
11
87
  // ── State ───────────────────────────────────────────────
12
88
  const activeAgents = new Map();
89
+ // ── Name resolver (B2) ──────────────────────────────────
90
+ /**
91
+ * Return all currently-tracked agents whose *base* name matches `base`.
92
+ * Base name = the part before any "#N" suffix.
93
+ */
94
+ function agentsByBaseName(base) {
95
+ const out = [];
96
+ for (const entry of activeAgents.values()) {
97
+ const info = entry.info;
98
+ const entryBase = info.name.replace(/#\d+$/, "");
99
+ if (entryBase === base)
100
+ out.push(info);
101
+ }
102
+ return out;
103
+ }
104
+ /**
105
+ * Given a requested name, return a unique variant. If no collision exists,
106
+ * returns `requested` unchanged (with the base form). Otherwise returns
107
+ * `base#N` with the smallest free N ≥ 2.
108
+ */
109
+ function resolveAgentName(requested) {
110
+ const base = requested.replace(/#\d+$/, "");
111
+ const siblings = agentsByBaseName(base);
112
+ if (siblings.length === 0)
113
+ return { name: base };
114
+ // Find the smallest free index ≥ 2. The bare base name counts as "#1".
115
+ const takenIndices = new Set();
116
+ for (const s of siblings) {
117
+ const m = s.name.match(/#(\d+)$/);
118
+ if (m)
119
+ takenIndices.add(parseInt(m[1], 10));
120
+ else
121
+ takenIndices.add(1);
122
+ }
123
+ let n = 2;
124
+ while (takenIndices.has(n))
125
+ n++;
126
+ return { name: `${base}#${n}`, index: n };
127
+ }
128
+ /**
129
+ * Public name-resolution API used by /sub-agents cancel / result.
130
+ * - Exact name match wins (e.g. "review#2" finds exactly that entry).
131
+ * - If only one agent matches the base name, returns that one.
132
+ * - If the caller opted into `ambiguousAsList`, returns a disambiguation
133
+ * marker with all candidates instead of a single result.
134
+ */
135
+ export function findSubAgentByName(name, opts = {}) {
136
+ // An explicit "base#N" query must always resolve to that exact entry,
137
+ // even when the caller opted into ambiguity. Otherwise users who type
138
+ // out the disambiguated form get an unhelpful 'which one?' reply.
139
+ const hasExplicitSuffix = /#\d+$/.test(name);
140
+ if (hasExplicitSuffix) {
141
+ for (const entry of activeAgents.values()) {
142
+ if (entry.info.name === name)
143
+ return { ...entry.info };
144
+ }
145
+ return null;
146
+ }
147
+ // No explicit suffix → base-name query. Ambiguity detection runs here
148
+ // when the caller opted in and there are multiple siblings.
149
+ const siblings = agentsByBaseName(name);
150
+ if (siblings.length === 0)
151
+ return null;
152
+ if (opts.ambiguousAsList && siblings.length > 1) {
153
+ return {
154
+ ambiguous: true,
155
+ candidates: siblings.map((s) => ({ ...s })),
156
+ };
157
+ }
158
+ // Without ambiguity opt-in, prefer an exact name match over just the
159
+ // first sibling — the bare base name is itself a unique key.
160
+ for (const entry of activeAgents.values()) {
161
+ if (entry.info.name === name)
162
+ return { ...entry.info };
163
+ }
164
+ return { ...siblings[0] };
165
+ }
13
166
  // ── Core execution ──────────────────────────────────────
14
- async function runSubAgent(id, agentConfig, abort) {
167
+ async function runSubAgent(id, agentConfig, abort, resolvedName) {
15
168
  const startTime = Date.now();
16
169
  const entry = activeAgents.get(id);
17
170
  try {
18
171
  const { getRegistry } = await import("../engine.js");
19
172
  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()}`;
173
+ // C3: inheritCwd (default true) decides whether the parent's working
174
+ // dir flows through. When false, we fall back to the home directory —
175
+ // useful for cron jobs that must run in a well-known root regardless
176
+ // of what the caller was doing.
177
+ const inheritCwd = agentConfig.inheritCwd ?? true;
178
+ const effectiveCwd = inheritCwd
179
+ ? agentConfig.workingDir || os.homedir()
180
+ : os.homedir();
181
+ 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
182
  let finalText = "";
22
183
  let inputTokens = 0;
23
184
  let outputTokens = 0;
24
185
  for await (const chunk of registry.queryWithFallback({
25
186
  prompt: agentConfig.prompt,
26
187
  systemPrompt,
27
- workingDir: agentConfig.workingDir || os.homedir(),
188
+ workingDir: effectiveCwd,
28
189
  effort: "high",
29
190
  abortSignal: abort.signal,
30
191
  })) {
@@ -35,15 +196,37 @@ async function runSubAgent(id, agentConfig, abort) {
35
196
  outputTokens = chunk.outputTokens || 0;
36
197
  }
37
198
  }
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";
199
+ // If cancelAllSubAgents has already taken over (shutdown path), don't
200
+ // overwrite the cancelled result it synthesised. Also: if the generator
201
+ // exited gracefully but the abort signal fired mid-stream (e.g. the
202
+ // provider's queryWithFallback returned `type:error` and we fell out
203
+ // of the loop without throwing), mark the run as cancelled rather
204
+ // than completed the result output is whatever we buffered.
205
+ if (entry.result && entry.result.status === "cancelled") {
206
+ // cancelAllSubAgents already set this; nothing to do.
207
+ }
208
+ else if (abort.signal.aborted) {
209
+ entry.result = {
210
+ id,
211
+ name: resolvedName,
212
+ status: "cancelled",
213
+ output: finalText,
214
+ tokensUsed: { input: inputTokens, output: outputTokens },
215
+ duration: Date.now() - startTime,
216
+ };
217
+ entry.info.status = "cancelled";
218
+ }
219
+ else {
220
+ entry.result = {
221
+ id,
222
+ name: resolvedName,
223
+ status: "completed",
224
+ output: finalText,
225
+ tokensUsed: { input: inputTokens, output: outputTokens },
226
+ duration: Date.now() - startTime,
227
+ };
228
+ entry.info.status = "completed";
229
+ }
47
230
  }
48
231
  catch (err) {
49
232
  const isAbort = err instanceof Error && err.message.includes("abort");
@@ -55,7 +238,7 @@ async function runSubAgent(id, agentConfig, abort) {
55
238
  : "error";
56
239
  entry.result = {
57
240
  id,
58
- name: agentConfig.name,
241
+ name: resolvedName,
59
242
  status,
60
243
  output: "",
61
244
  tokensUsed: { input: 0, output: 0 },
@@ -71,11 +254,47 @@ async function runSubAgent(id, agentConfig, abort) {
71
254
  * Returns the agent ID immediately (does NOT await completion).
72
255
  */
73
256
  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.`));
257
+ // F2: enforce depth cap before touching any state.
258
+ const depth = agentConfig.depth ?? 0;
259
+ if (depth > MAX_SUBAGENT_DEPTH) {
260
+ 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
+ }
262
+ // G1: toolset preset. Only "full" is supported in Stufe 1. The literal
263
+ // type blocks wrong values at compile time; the runtime check catches
264
+ // callers that bypass TypeScript (e.g. plugin code loaded at runtime).
265
+ const toolset = agentConfig.toolset ?? "full";
266
+ if (toolset !== "full") {
267
+ return Promise.reject(new Error(`Invalid toolset "${toolset}". Only "full" is supported in this version.`));
268
+ }
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
+ const maxParallel = getMaxParallelAgents();
273
+ if (running.length >= maxParallel) {
274
+ // D4: priority-aware reject messages — give callers context about
275
+ // WHO is holding the slots so they know whether to wait, cancel,
276
+ // or give up.
277
+ const source = agentConfig.source ?? "implicit";
278
+ const userSlots = running.filter((a) => a.info.source === "user").length;
279
+ const bgSlots = running.length - userSlots;
280
+ let message;
281
+ if (source === "user") {
282
+ if (bgSlots > 0) {
283
+ message = `Alle Slots belegt (${running.length}/${maxParallel}), davon ${bgSlots} cron/implicit im Hintergrund. /sub-agents list für Details oder /sub-agents cancel <name>.`;
284
+ }
285
+ else {
286
+ message = `Alle Slots belegt (${running.length}/${maxParallel}) mit eigenen user-Spawns. /sub-agents cancel <name> oder warten: /sub-agents list`;
287
+ }
288
+ }
289
+ else {
290
+ message = `Sub-agent limit reached (${maxParallel}). Wait for a running agent to finish or cancel one.`;
291
+ }
292
+ return Promise.reject(new Error(message));
78
293
  }
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;
79
298
  const id = crypto.randomUUID();
80
299
  const timeout = agentConfig.timeout ?? config.subAgentTimeout;
81
300
  const abort = new AbortController();
@@ -83,20 +302,51 @@ export function spawnSubAgent(agentConfig) {
83
302
  const timeoutId = setTimeout(() => abort.abort(), timeout);
84
303
  const info = {
85
304
  id,
86
- name: agentConfig.name,
305
+ name: resolvedName,
87
306
  status: "running",
88
307
  startedAt: Date.now(),
89
308
  model: agentConfig.model,
309
+ source: agentConfig.source,
310
+ depth,
311
+ parentChatId: agentConfig.parentChatId,
312
+ nameIndex: resolved.index,
90
313
  };
91
- activeAgents.set(id, { info, abort });
314
+ activeAgents.set(id, { info, abort, delivered: false });
92
315
  // Run in background — don't await
93
- runSubAgent(id, agentConfig, abort)
316
+ runSubAgent(id, agentConfig, abort, resolvedName)
94
317
  .finally(() => {
95
318
  clearTimeout(timeoutId);
319
+ // Call the onComplete callback if the caller provided one. This is
320
+ // how cron.ts turns the fire-and-forget spawnSubAgent() into a
321
+ // Promise that resolves when the work finishes. The callback runs
322
+ // inside a try/catch so a throwing callback can't break cleanup.
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
+ }
96
346
  // Auto-cleanup: remove completed agents after 30 minutes
97
347
  setTimeout(() => {
98
- const entry = activeAgents.get(id);
99
- if (entry && entry.info.status !== "running") {
348
+ const e = activeAgents.get(id);
349
+ if (e && e.info.status !== "running") {
100
350
  activeAgents.delete(id);
101
351
  }
102
352
  }, 30 * 60 * 1000);
@@ -129,14 +379,78 @@ export function getSubAgentResult(id) {
129
379
  const entry = activeAgents.get(id);
130
380
  return entry?.result ?? null;
131
381
  }
382
+ /**
383
+ * Cancel a sub-agent by name (or name#N). Returns true if a running agent
384
+ * was found and aborted. Uses findSubAgentByName for resolution; in an
385
+ * ambiguous case (multiple siblings under the same base name, caller did
386
+ * not disambiguate), cancels the first candidate.
387
+ */
388
+ export function cancelSubAgentByName(name) {
389
+ const match = findSubAgentByName(name);
390
+ if (!match || "ambiguous" in match)
391
+ return false;
392
+ return cancelSubAgent(match.id);
393
+ }
394
+ /**
395
+ * Get a sub-agent's result by name. Returns null if no such agent, no
396
+ * result yet (still running), or the name is ambiguous without explicit
397
+ * disambiguation.
398
+ */
399
+ export function getSubAgentResultByName(name) {
400
+ const match = findSubAgentByName(name);
401
+ if (!match || "ambiguous" in match)
402
+ return null;
403
+ return getSubAgentResult(match.id);
404
+ }
132
405
  /**
133
406
  * Cancel all active sub-agents. Used during shutdown.
407
+ *
408
+ * When notify=true (default), each running agent gets a Telegram
409
+ * delivery explaining that it was interrupted by a restart. Errors
410
+ * during delivery are logged but never block shutdown. The whole
411
+ * notify phase is capped at 5s so a hung Telegram send can't hold
412
+ * the process hostage.
134
413
  */
135
- export function cancelAllSubAgents() {
414
+ export async function cancelAllSubAgents(notify = true) {
415
+ const deliveryPromises = [];
416
+ // Iterate once: for each running agent (1) abort the SDK stream,
417
+ // (2) synthesise and store a cancelled SubAgentResult, (3) mark
418
+ // delivered=true so runSubAgent.finally() can't fire a second
419
+ // delivery on the next microtask, (4) queue the I3 delivery.
420
+ const runningEntries = [];
136
421
  for (const [id, entry] of activeAgents) {
137
- if (entry.info.status === "running") {
138
- entry.abort.abort();
139
- entry.info.status = "cancelled";
140
- }
422
+ if (entry.info.status !== "running")
423
+ continue;
424
+ entry.abort.abort();
425
+ entry.info.status = "cancelled";
426
+ const cancelResult = {
427
+ id,
428
+ name: entry.info.name,
429
+ status: "cancelled",
430
+ output: "⚠️ Agent wurde durch Bot-Restart unterbrochen. Bitte neu triggern.",
431
+ tokensUsed: { input: 0, output: 0 },
432
+ duration: Date.now() - entry.info.startedAt,
433
+ };
434
+ entry.result = cancelResult;
435
+ entry.delivered = true;
436
+ runningEntries.push({ id, info: entry.info, cancelResult });
437
+ }
438
+ if (!notify || runningEntries.length === 0)
439
+ return;
440
+ // Import once, then reuse. Doing one dynamic import per running agent
441
+ // races with Vitest's mock-resolution in tests and can occasionally
442
+ // resolve to the real module instead of the mock for later calls.
443
+ const { deliverSubAgentResult } = await import("./subagent-delivery.js");
444
+ for (const { id, info, cancelResult } of runningEntries) {
445
+ const p = Promise.resolve(deliverSubAgentResult(info, cancelResult)).catch((err) => {
446
+ console.error(`[subagents] shutdown-notify failed for ${id}:`, err);
447
+ });
448
+ deliveryPromises.push(p);
141
449
  }
450
+ // Wait up to 5s total — long enough for real Telegram sends, short
451
+ // enough that shutdown isn't held hostage by a hang.
452
+ await Promise.race([
453
+ Promise.all(deliveryPromises),
454
+ new Promise((r) => setTimeout(r, 5000)),
455
+ ]);
142
456
  }
@@ -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;