alvin-bot 4.6.0 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,154 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.7.0] — 2026-04-11
6
+
7
+ ### ✨ Sub-Agents Stufe 2 — live-stream, bounded queue, 24h stats
8
+
9
+ Stufe 2 of the sub-agents refinement spec lands alongside the same-day 4.6.0 release. Everything here builds on the Stufe 1 foundation and is fully unit-tested (85 passing tests).
10
+
11
+ #### A4 Live-Stream for user-spawns
12
+
13
+ `/subagents visibility live` enables a new delivery mode where user-spawned sub-agents stream their text incrementally into a single Telegram message, then post a completion banner as a separate message.
14
+
15
+ Implementation in `src/services/subagent-delivery.ts`:
16
+
17
+ - `LiveStream` class with `start()` / `update()` / `finalize()`
18
+ - `start()` posts an initial `⏳ <name> thinking…` placeholder and records its `message_id`
19
+ - `update()` is called on every text chunk from the agent's generator; it coalesces rapid updates via a throttle window of **800 ms** so we never exceed Telegram's edit rate limit. Multiple `update()` calls within the window collapse into a single edit with the latest accumulated text.
20
+ - `finalize()` flushes any pending text, replaces the `thinking…` header with the final body, then sends a new banner message so the user gets a completion notification (edits don't trigger push notifications).
21
+ - The live-stream message uses **plain text** (no `parse_mode`) so half-formed markdown during streaming can never cause an edit to be rejected. The final banner does use markdown.
22
+
23
+ Wiring in `runSubAgent`:
24
+
25
+ - Detects `effectiveVisibility === "live"` AND `source === "user"` AND `parentChatId`. Cron and implicit spawns are never live-streamed — cron because there's no interactive watcher, implicit because the parent Claude stream already shows everything inline.
26
+ - Creates the `LiveStream` via `createLiveStream()` before the for-await loop.
27
+ - Calls `liveStream.update(chunk.text)` on every text chunk.
28
+ - Calls `liveStream.finalize(info, result)` after the loop and marks `entry.delivered = true` so `spawnSubAgent.finally()` skips the regular `deliverSubAgentResult` path. If finalize fails, the `delivered` flag stays false and the normal banner delivery fires as a fallback.
29
+ - Falls back to `"banner"` mode transparently if the bot API doesn't support `editMessageText` (e.g. during tests or if `attachBotApi` was never called).
30
+
31
+ Tests added in `test/subagent-delivery.test.ts`:
32
+
33
+ - `start` posts an initial placeholder and stores the message_id
34
+ - `update` coalesces rapid calls into a single throttled edit within the 800 ms window
35
+ - `finalize` posts a banner as a new message
36
+ - `createLiveStream` returns `null` when `editMessageText` is missing
37
+
38
+ #### D3 Bounded priority queue
39
+
40
+ Previously, hitting `maxParallel` returned a hard reject. Now spawn requests that don't fit run into a **bounded priority queue**:
41
+
42
+ - Default cap: **20** slots (configurable via `/subagents queue <n>`, clamped to 0–200)
43
+ - Setting cap to 0 disables the queue entirely and restores the old reject-on-full behavior
44
+ - Priority order on drain: **user > cron > implicit**
45
+ - FIFO within each priority class
46
+ - Drains automatically when a running agent finishes — the `runSubAgent.finally()` now calls `drainQueue()` after cleanup
47
+
48
+ New fields:
49
+
50
+ - `SubAgentsConfig.queueCap: number` — persisted in `~/.alvin-bot/sub-agents.json`
51
+ - `SubAgentInfo.status: "queued"` — new valid state
52
+ - `SubAgentInfo.queuePosition?: number` — 1-based position in the queue, shown in `/subagents list` as `#N`
53
+
54
+ Functions in `subagents.ts`:
55
+
56
+ - `getQueueCap()` / `setQueueCap(n)` — public config accessors
57
+ - `drainQueue()` — called from `runSubAgent.finally()`, pops in priority order and transitions entries from `queued` to `running`
58
+ - `popHighestPriorityQueued()` — internal FIFO-per-priority scan
59
+ - `reindexQueue()` — keeps `SubAgentInfo.queuePosition` in sync after pop/cancel
60
+ - `cancelSubAgent()` now handles queued entries by removing them from the queue without starting `runSubAgent` at all
61
+ - `cancelAllSubAgents()` clears the pending queue before cancelling running agents, so shutdown doesn't spawn anything new
62
+ - `spawnSubAgent()` is split: queue decision first (run immediately vs queue vs reject), then `startRun()` helper starts the background loop
63
+
64
+ Reject messages stay priority-aware (D4) but now mention queue saturation:
65
+
66
+ - `user` spawn + pool full + cron/implicit in pool + queue full → *"Alle Slots belegt (N/M), davon X cron/implicit im Hintergrund. Queue voll (Q/C). /subagents list für Details …"*
67
+ - `user` spawn + pool full + user in pool + queue full → *"Alle Slots belegt (N/M) mit eigenen user-Spawns. Queue voll (Q/C). /subagents cancel <name> oder warten."*
68
+ - Non-user spawns + pool + queue full → *"Sub-agent limit reached (N running, Q/C queued). Wait for a running agent to finish or cancel one."*
69
+
70
+ Tests added in `test/subagents-queue.test.ts`:
71
+
72
+ - Default cap is 20
73
+ - Clamping (negative → 0, above 200 → 200, fractional floors)
74
+ - Round-trip through disk
75
+ - Third spawn at full pool lands as `status: "queued"` with `queuePosition: 1`
76
+ - Queue drains automatically when a running agent finishes
77
+ - Priority order: user spawns drain before cron at the same moment
78
+ - `cancelSubAgent` removes a queued entry
79
+
80
+ The existing priority-reject tests now explicitly set `queueCap = 0` to test the old reject path, and a new "queue enabled" test fills both pool and queue before asserting the reject message.
81
+
82
+ #### H3 24-hour run stats
83
+
84
+ New module `src/services/subagent-stats.ts` — a simple append-only JSON ring buffer persisted to `~/.alvin-bot/subagent-stats.json`. Each completed sub-agent run appends one entry:
85
+
86
+ ```ts
87
+ {
88
+ completedAt: number;
89
+ name: string;
90
+ source: "user" | "cron" | "implicit";
91
+ status: "completed" | "timeout" | "error" | "cancelled";
92
+ durationMs: number;
93
+ inputTokens: number;
94
+ outputTokens: number;
95
+ }
96
+ ```
97
+
98
+ On every load or append, entries older than 24 hours are pruned. A hard cap of 5000 entries protects against unbounded growth on high-frequency bots.
99
+
100
+ Accessors:
101
+
102
+ - `recordSubAgentRun(info, result)` — called from `runSubAgent.finally()` as a non-blocking side effect. Errors are logged but don't affect delivery.
103
+ - `getSubAgentStats()` — returns a `StatsSummary` with totals, per-source breakdown, and per-status counts.
104
+
105
+ New Telegram command **`/subagents stats`** renders the summary:
106
+
107
+ ```
108
+ 📊 Sub-Agent Stats — last 24h
109
+
110
+ Total: 44 runs · 165k in / 89k out · 12m
111
+
112
+ By source:
113
+ 👤 user: 12 runs · 45k in / 22k out
114
+ ⏰ cron: 8 runs · 31k in / 15k out
115
+ 🔗 implicit: 24 runs · 89k in / 52k out
116
+
117
+ By status:
118
+ ✅ completed: 42
119
+ ⚠️ cancelled: 1
120
+ ⏱️ timeout: 0
121
+ ❌ error: 1
122
+ ```
123
+
124
+ The JSON backing file is a deliberate short-term choice. When the SQLite migration lands (already scoped in a separate memory entry as `project_alvinbot_sqlite_migration.md`), we swap the backend without touching `getSubAgentStats()` or `recordSubAgentRun()` — both are designed as a narrow interface.
125
+
126
+ Tests added in `test/subagent-stats.test.ts`:
127
+
128
+ - Fresh install returns zeros
129
+ - Recording 3 runs updates totals + per-source breakdown
130
+ - Persistence + reload round-trip
131
+ - Entries older than 24h are pruned on load
132
+ - `byStatus` tracks cancelled/error/timeout separately
133
+
134
+ ### 🖥 CLI: `alvin-bot start` / `stop` now auto-detect LaunchAgent
135
+
136
+ The `start` and `stop` commands previously always went through pm2. That created a conflict after `alvin-bot launchd install`: the LaunchAgent ran the bot, but `alvin-bot start` would happily spawn a second instance via pm2, and `alvin-bot stop` would try to stop a pm2 process that didn't exist.
137
+
138
+ Now both commands check for `~/Library/LaunchAgents/com.alvinbot.app.plist` on macOS and switch transparently:
139
+
140
+ - **`alvin-bot start`** with a LaunchAgent present → `launchctl kickstart -k gui/$UID/com.alvinbot.app` (or `launchctl load -w` if not loaded yet). No pm2 involvement.
141
+ - **`alvin-bot stop`** with a LaunchAgent present → `launchctl unload -w` (doesn't remove the plist, just stops the daemon).
142
+ - **`alvin-bot start`** on macOS without a LaunchAgent → pm2 path + a helpful tip: *"💡 Tip: on macOS with Claude Code, switch to launchd for automatic Keychain access: alvin-bot launchd install"*.
143
+
144
+ Linux and Windows users are unaffected — they always get the pm2 path.
145
+
146
+ ### 🐛 Other
147
+
148
+ - `/subagents queue` is registered in the usage string for en/de/es/fr.
149
+ - `/subagents stats` is registered in the usage string for en/de/es/fr.
150
+ - `/subagents visibility` usage now lists `live` as a valid mode.
151
+ - Removed the leftover `alvin-bot-4.5.1.tgz` from the repo root.
152
+
5
153
  ## [4.6.0] — 2026-04-11
6
154
 
7
155
  ### ✨ Sub-Agents Stufe 1 — context-aware delivery, name-first addressing, shutdown notifications
package/bin/cli.js CHANGED
@@ -1396,40 +1396,93 @@ switch (cmd) {
1396
1396
  const fg = process.argv.includes("--foreground") || process.argv.includes("-f");
1397
1397
  if (fg) {
1398
1398
  import("../dist/index.js");
1399
- } else {
1400
- // Start via PM2 (background, survives terminal close, auto-restart on crash)
1401
- try {
1402
- execSync("pm2 --version", { stdio: "pipe" });
1403
- } catch {
1404
- // PM2 not installed install it
1405
- console.log("Installing PM2 for background operation...");
1399
+ break;
1400
+ }
1401
+
1402
+ // On macOS, if a LaunchAgent plist already exists, we're in "launchd
1403
+ // mode" — don't start pm2 in parallel. Reload the LaunchAgent instead
1404
+ // so a plain `alvin-bot start` still works as "bring the bot up".
1405
+ if (process.platform === "darwin") {
1406
+ const { plistPath, label } = launchdPaths();
1407
+ if (existsSync(plistPath)) {
1408
+ console.log(`🚀 Detected existing LaunchAgent (${label})`);
1409
+ console.log(` Reloading via 'launchctl kickstart -k'...`);
1406
1410
  try {
1407
- execSync("npm install -g pm2", { stdio: "inherit", timeout: 60000 });
1411
+ execSync(`launchctl kickstart -k gui/$(id -u)/${label}`, {
1412
+ stdio: "inherit",
1413
+ shell: "/bin/zsh",
1414
+ });
1408
1415
  } catch {
1409
- console.log("Could not install PM2. Starting in foreground instead.");
1410
- console.log("Tip: Install PM2 manually (npm install -g pm2) to run in background.\n");
1411
- await import("../dist/index.js");
1412
- break;
1416
+ // Maybe unloaded load it fresh
1417
+ try {
1418
+ execSync(`launchctl load -w "${plistPath}"`, { stdio: "inherit" });
1419
+ } catch (err) {
1420
+ console.log(`❌ launchctl load failed: ${err.message}`);
1421
+ process.exit(1);
1422
+ }
1413
1423
  }
1424
+ console.log("\n✅ Bot is running via launchd.");
1425
+ console.log(" Status: alvin-bot launchd status");
1426
+ console.log(" Stop: alvin-bot stop");
1427
+ console.log(" Logs: ~/.alvin-bot/logs/alvin-bot.out.log");
1428
+ process.exit(0);
1414
1429
  }
1415
- const cliPath = resolve(join(import.meta.dirname, "cli.js"));
1430
+ }
1431
+
1432
+ // Fall-through: pm2 path (Linux, Windows, or macOS without LaunchAgent)
1433
+ try {
1434
+ execSync("pm2 --version", { stdio: "pipe" });
1435
+ } catch {
1436
+ console.log("Installing PM2 for background operation...");
1416
1437
  try {
1417
- // Stop existing instance if running
1418
- execSync("pm2 delete alvin-bot", { stdio: "pipe" });
1419
- } catch { /* not running fine */ }
1420
- execSync(`pm2 start "${cliPath}" --name alvin-bot -- start --foreground`, {
1421
- stdio: "inherit",
1422
- timeout: 15000,
1423
- });
1424
- console.log("\n✅ Bot is running in the background.");
1425
- console.log(" Logs: pm2 logs alvin-bot");
1426
- console.log(" Stop: alvin-bot stop");
1427
- console.log(" Restart: alvin-bot start\n");
1428
- process.exit(0);
1438
+ execSync("npm install -g pm2", { stdio: "inherit", timeout: 60000 });
1439
+ } catch {
1440
+ console.log("Could not install PM2. Starting in foreground instead.");
1441
+ console.log("Tip: Install PM2 manually (npm install -g pm2) to run in background.\n");
1442
+ await import("../dist/index.js");
1443
+ break;
1444
+ }
1429
1445
  }
1430
- break;
1446
+ const cliPath = resolve(join(import.meta.dirname, "cli.js"));
1447
+ try {
1448
+ execSync("pm2 delete alvin-bot", { stdio: "pipe" });
1449
+ } catch { /* not running — fine */ }
1450
+ execSync(`pm2 start "${cliPath}" --name alvin-bot -- start --foreground`, {
1451
+ stdio: "inherit",
1452
+ timeout: 15000,
1453
+ });
1454
+ console.log("\n✅ Bot is running in the background via PM2.");
1455
+ console.log(" Logs: pm2 logs alvin-bot");
1456
+ console.log(" Stop: alvin-bot stop");
1457
+ console.log(" Restart: alvin-bot start");
1458
+ if (process.platform === "darwin") {
1459
+ console.log("");
1460
+ console.log(" 💡 Tip: on macOS with Claude Code, switch to launchd for");
1461
+ console.log(" automatic Keychain access: alvin-bot launchd install");
1462
+ }
1463
+ console.log("");
1464
+ process.exit(0);
1431
1465
  }
1432
1466
  case "stop": {
1467
+ // On macOS with a LaunchAgent, stopping means unloading the LaunchAgent,
1468
+ // not asking pm2 to stop a process it never managed.
1469
+ if (process.platform === "darwin") {
1470
+ const { plistPath, label } = launchdPaths();
1471
+ if (existsSync(plistPath)) {
1472
+ console.log(`⏹ Stopping LaunchAgent (${label})...`);
1473
+ try {
1474
+ execSync(`launchctl unload -w "${plistPath}"`, { stdio: "inherit" });
1475
+ console.log("✅ LaunchAgent stopped.");
1476
+ console.log(" (The plist is still installed. To remove it: alvin-bot launchd uninstall)");
1477
+ } catch (err) {
1478
+ console.log(`❌ launchctl unload failed: ${err.message}`);
1479
+ process.exit(1);
1480
+ }
1481
+ process.exit(0);
1482
+ }
1483
+ }
1484
+
1485
+ // Fall-through: pm2 path
1433
1486
  try {
1434
1487
  execSync("pm2 stop alvin-bot", { stdio: "inherit", timeout: 10000 });
1435
1488
  } catch {
@@ -1728,7 +1728,7 @@ export function registerCommands(bot) {
1728
1728
  // type both "/sub-agents" and "/subagents" — Telegram routes both to this.
1729
1729
  bot.command(["sub_agents", "subagents"], async (ctx) => {
1730
1730
  const lang = getSession(ctx.from.id).language;
1731
- const { listSubAgents, cancelSubAgent, getSubAgentResult, getMaxParallelAgents, getConfiguredMaxParallel, setMaxParallelAgents, findSubAgentByName, getVisibility, setVisibility, } = await import("../services/subagents.js");
1731
+ const { listSubAgents, cancelSubAgent, getSubAgentResult, getMaxParallelAgents, getConfiguredMaxParallel, setMaxParallelAgents, findSubAgentByName, getVisibility, setVisibility, getQueueCap, setQueueCap, } = await import("../services/subagents.js");
1732
1732
  const arg = (ctx.match || "").trim();
1733
1733
  const tokens = arg.split(/\s+/).filter(Boolean);
1734
1734
  const sub = tokens[0]?.toLowerCase() || "";
@@ -1741,7 +1741,8 @@ export function registerCommands(bot) {
1741
1741
  const ageLabel = ageSec < 60 ? `${ageSec}s` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m` : `${Math.floor(ageSec / 3600)}h`;
1742
1742
  const sourceBadge = a.source === "cron" ? "⏰" : a.source === "implicit" ? "🔗" : "👤";
1743
1743
  const depthTag = a.depth > 0 ? ` d${a.depth}` : "";
1744
- return `${indent}${sourceBadge} \`${shortId(a.id)}\` ${a.name} (${a.status}, ${ageLabel}${depthTag})`;
1744
+ const queueTag = a.status === "queued" && a.queuePosition ? ` #${a.queuePosition}` : "";
1745
+ return `${indent}${sourceBadge} \`${shortId(a.id)}\` ${a.name} (${a.status}${queueTag}, ${ageLabel}${depthTag})`;
1745
1746
  };
1746
1747
  // /sub-agents max <n>
1747
1748
  if (sub === "max") {
@@ -1754,7 +1755,50 @@ export function registerCommands(bot) {
1754
1755
  await ctx.reply(t("bot.subagents.maxSet", lang, { n, eff: effective }), { parse_mode: "Markdown" });
1755
1756
  return;
1756
1757
  }
1757
- // /sub-agents visibility <auto|banner|silent>
1758
+ // /subagents stats — show rolling 24h run stats (H3)
1759
+ if (sub === "stats") {
1760
+ const { getSubAgentStats } = await import("../services/subagent-stats.js");
1761
+ const s = getSubAgentStats();
1762
+ const formatTok = (n) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);
1763
+ const formatDur = (ms) => {
1764
+ const sec = Math.floor(ms / 1000);
1765
+ if (sec < 60)
1766
+ return `${sec}s`;
1767
+ const m = Math.floor(sec / 60);
1768
+ return `${m}m`;
1769
+ };
1770
+ const lines = [
1771
+ `📊 *Sub-Agent Stats* — last ${s.windowHours}h`,
1772
+ ``,
1773
+ `*Total:* ${s.total.runs} runs · ${formatTok(s.total.inputTokens)} in / ${formatTok(s.total.outputTokens)} out · ${formatDur(s.total.totalDurationMs)}`,
1774
+ ``,
1775
+ `*By source:*`,
1776
+ ` 👤 user: ${s.bySource.user.runs} runs · ${formatTok(s.bySource.user.inputTokens)} in / ${formatTok(s.bySource.user.outputTokens)} out`,
1777
+ ` ⏰ cron: ${s.bySource.cron.runs} runs · ${formatTok(s.bySource.cron.inputTokens)} in / ${formatTok(s.bySource.cron.outputTokens)} out`,
1778
+ ` 🔗 implicit: ${s.bySource.implicit.runs} runs · ${formatTok(s.bySource.implicit.inputTokens)} in / ${formatTok(s.bySource.implicit.outputTokens)} out`,
1779
+ ``,
1780
+ `*By status:*`,
1781
+ ` ✅ completed: ${s.byStatus.completed}`,
1782
+ ` ⚠️ cancelled: ${s.byStatus.cancelled}`,
1783
+ ` ⏱️ timeout: ${s.byStatus.timeout}`,
1784
+ ` ❌ error: ${s.byStatus.error}`,
1785
+ ];
1786
+ await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
1787
+ return;
1788
+ }
1789
+ // /subagents queue <n> — set bounded-queue cap (0 disables queue)
1790
+ if (sub === "queue") {
1791
+ const n = parseInt(tokens[1] || "", 10);
1792
+ if (isNaN(n)) {
1793
+ const current = getQueueCap();
1794
+ await ctx.reply(`Queue cap: *${current}* (${current === 0 ? "disabled" : "bounded"})\nUsage: \`/subagents queue <n>\` (0 disables the queue, max 200)`, { parse_mode: "Markdown" });
1795
+ return;
1796
+ }
1797
+ const effective = setQueueCap(n);
1798
+ await ctx.reply(`✅ Queue cap set to *${effective}* ${effective === 0 ? "(queue disabled — full pool rejects immediately)" : ""}`, { parse_mode: "Markdown" });
1799
+ return;
1800
+ }
1801
+ // /sub-agents visibility <auto|banner|silent|live>
1758
1802
  if (sub === "visibility") {
1759
1803
  const mode = tokens[1];
1760
1804
  if (!mode) {
package/dist/i18n.js CHANGED
@@ -519,10 +519,10 @@ const strings = {
519
519
  fr: "Durée : {sec}s · Tokens : {in}/{out}",
520
520
  },
521
521
  "bot.subagents.usage": {
522
- en: "Commands:\n/subagents — show status\n/subagents max <n> — set parallel limit (0=auto)\n/subagents visibility <auto|banner|silent> — delivery mode\n/subagents list — list all\n/subagents cancel <name|id> — cancel one\n/subagents result <name|id> — show result",
523
- de: "Befehle:\n/subagents — Status anzeigen\n/subagents max <n> — Parallel-Limit setzen (0=auto)\n/subagents visibility <auto|banner|silent> — Delivery-Modus\n/subagents list — alle anzeigen\n/subagents cancel <name|id> — abbrechen\n/subagents result <name|id> — Ergebnis anzeigen",
524
- es: "Comandos:\n/subagents — ver estado\n/subagents max <n> — establecer límite (0=auto)\n/subagents visibility <auto|banner|silent> — modo de entrega\n/subagents list — listar todos\n/subagents cancel <nombre|id> — cancelar uno\n/subagents result <nombre|id> — ver resultado",
525
- fr: "Commandes :\n/subagents — état\n/subagents max <n> — limite parallèle (0=auto)\n/subagents visibility <auto|banner|silent> — mode de livraison\n/subagents list — lister tous\n/subagents cancel <nom|id> — annuler un\n/subagents result <nom|id> — voir résultat",
522
+ en: "Commands:\n/subagents — show status\n/subagents max <n> — set parallel limit (0=auto)\n/subagents visibility <auto|banner|silent|live> — delivery mode\n/subagents queue <n> — bounded-queue cap (0 = disabled)\n/subagents stats — last 24h run stats\n/subagents list — list all\n/subagents cancel <name|id> — cancel one\n/subagents result <name|id> — show result",
523
+ de: "Befehle:\n/subagents — Status anzeigen\n/subagents max <n> — Parallel-Limit setzen (0=auto)\n/subagents visibility <auto|banner|silent|live> — Delivery-Modus\n/subagents list — alle anzeigen\n/subagents cancel <name|id> — abbrechen\n/subagents result <name|id> — Ergebnis anzeigen",
524
+ es: "Comandos:\n/subagents — ver estado\n/subagents max <n> — establecer límite (0=auto)\n/subagents visibility <auto|banner|silent|live> — modo de entrega\n/subagents list — listar todos\n/subagents cancel <nombre|id> — cancelar uno\n/subagents result <nombre|id> — ver resultado",
525
+ fr: "Commandes :\n/subagents — état\n/subagents max <n> — limite parallèle (0=auto)\n/subagents visibility <auto|banner|silent|live> — mode de livraison\n/subagents list — lister tous\n/subagents cancel <nom|id> — annuler un\n/subagents result <nom|id> — voir résultat",
526
526
  },
527
527
  "bot.subagents.visibilityLabel": {
528
528
  en: "Visibility:",
@@ -537,10 +537,10 @@ const strings = {
537
537
  fr: "✅ Visibilité réglée sur *{mode}*",
538
538
  },
539
539
  "bot.subagents.visibilityInvalid": {
540
- en: "❌ Invalid mode _{mode}_. Use: auto | banner | silent",
541
- de: "❌ Ungültiger Modus _{mode}_. Nutze: auto | banner | silent",
542
- es: "❌ Modo inválido _{mode}_. Usa: auto | banner | silent",
543
- fr: "❌ Mode invalide _{mode}_. Utilise : auto | banner | silent",
540
+ en: "❌ Invalid mode _{mode}_. Use: auto | banner | silent | live",
541
+ de: "❌ Ungültiger Modus _{mode}_. Nutze: auto | banner | silent | live",
542
+ es: "❌ Modo inválido _{mode}_. Usa: auto | banner | silent | live",
543
+ fr: "❌ Mode invalide _{mode}_. Utilise : auto | banner | silent | live",
544
544
  },
545
545
  // Relative time formatting (formatRelativeTime helper)
546
546
  "bot.time.justNow": {
package/dist/index.js CHANGED
@@ -134,6 +134,7 @@ if (hasTelegram) {
134
134
  attachBotApi({
135
135
  sendMessage: (chatId, text, opts) => botRef.api.sendMessage(chatId, text, opts),
136
136
  sendDocument: (chatId, doc, opts) => botRef.api.sendDocument(chatId, doc, opts),
137
+ editMessageText: (chatId, messageId, text, opts) => botRef.api.editMessageText(chatId, messageId, text, opts),
137
138
  });
138
139
  // Auth middleware — alle Messages durchlaufen das
139
140
  bot.use(authMiddleware);
@@ -53,6 +53,157 @@ function buildBanner(info, result) {
53
53
  const to = formatTokens(result.tokensUsed.output);
54
54
  return `${icon} *${info.name}* ${result.status} · ${dur} · ${ti} in / ${to} out`;
55
55
  }
56
+ // ── A4 Live-Stream ──────────────────────────────────────────
57
+ /**
58
+ * Per-spawn live-stream state. Edits a single Telegram message as the
59
+ * sub-agent produces text, throttled to ~800ms between edits. Posts a
60
+ * separate banner message at finalize so the user gets a completion
61
+ * notification (edits don't trigger Telegram notifications).
62
+ *
63
+ * The live message uses plain text (no parse_mode) so half-formed
64
+ * markdown during streaming can never crash the edit. The final banner
65
+ * does use markdown.
66
+ */
67
+ const LIVE_EDIT_THROTTLE_MS = 800;
68
+ const LIVE_INITIAL_TEXT = (name) => `⏳ ${name} thinking…`;
69
+ export class LiveStream {
70
+ api;
71
+ chatId;
72
+ agentName;
73
+ messageId = null;
74
+ lastEditAt = 0;
75
+ pendingText = null;
76
+ pendingTimer = null;
77
+ started = false;
78
+ failed = false;
79
+ constructor(api, chatId, agentName) {
80
+ this.api = api;
81
+ this.chatId = chatId;
82
+ this.agentName = agentName;
83
+ }
84
+ /** Post the initial placeholder message. Called before the first chunk. */
85
+ async start() {
86
+ if (!this.api.editMessageText) {
87
+ this.failed = true;
88
+ console.warn(`[subagent-live] bot api has no editMessageText — falling back`);
89
+ return;
90
+ }
91
+ try {
92
+ const initial = LIVE_INITIAL_TEXT(this.agentName);
93
+ const msg = await this.api.sendMessage(this.chatId, initial);
94
+ const msgId = msg.message_id;
95
+ if (typeof msgId === "number") {
96
+ this.messageId = msgId;
97
+ this.lastEditAt = Date.now();
98
+ this.started = true;
99
+ }
100
+ else {
101
+ console.warn(`[subagent-live] sendMessage returned no message_id`);
102
+ this.failed = true;
103
+ }
104
+ }
105
+ catch (err) {
106
+ console.error(`[subagent-live] start failed:`, err);
107
+ this.failed = true;
108
+ }
109
+ }
110
+ /**
111
+ * Record a new accumulated text state. Will schedule a throttled edit
112
+ * ~800ms after the previous edit. Later updates that arrive before
113
+ * the throttled flush coalesce — only the latest text is used.
114
+ */
115
+ update(text) {
116
+ if (!this.started || this.failed || this.messageId === null)
117
+ return;
118
+ this.pendingText = text;
119
+ if (this.pendingTimer)
120
+ return;
121
+ const elapsed = Date.now() - this.lastEditAt;
122
+ const delay = Math.max(0, LIVE_EDIT_THROTTLE_MS - elapsed);
123
+ this.pendingTimer = setTimeout(() => {
124
+ this.flush().catch((err) => {
125
+ console.warn(`[subagent-live] scheduled flush failed:`, err);
126
+ });
127
+ }, delay);
128
+ }
129
+ async flush() {
130
+ this.pendingTimer = null;
131
+ if (!this.pendingText || this.messageId === null || this.failed)
132
+ return;
133
+ if (!this.api.editMessageText) {
134
+ this.failed = true;
135
+ return;
136
+ }
137
+ // Cap edit length — Telegram rejects >4096 chars
138
+ const body = this.pendingText.slice(0, MAX_TG_CHUNK);
139
+ const display = `⏳ ${this.agentName}\n\n${body}`;
140
+ try {
141
+ await this.api.editMessageText(this.chatId, this.messageId, display);
142
+ this.lastEditAt = Date.now();
143
+ }
144
+ catch (err) {
145
+ // "message is not modified" is harmless (same content as before)
146
+ const msg = err instanceof Error ? err.message : String(err);
147
+ if (!/not modified/i.test(msg)) {
148
+ console.warn(`[subagent-live] edit failed:`, msg);
149
+ }
150
+ }
151
+ this.pendingText = null;
152
+ }
153
+ /**
154
+ * Flush any pending edit, then post the final banner as a new message
155
+ * so the user gets a notification. The live-stream message stays in
156
+ * place as the body; the banner is a separate message above/below it.
157
+ */
158
+ async finalize(info, result) {
159
+ if (this.pendingTimer) {
160
+ clearTimeout(this.pendingTimer);
161
+ this.pendingTimer = null;
162
+ }
163
+ if (this.pendingText) {
164
+ await this.flush();
165
+ }
166
+ this.started = false;
167
+ if (this.failed)
168
+ return;
169
+ // One last edit to remove the "thinking…" header (replace with final text)
170
+ if (this.messageId !== null && this.api.editMessageText) {
171
+ const finalBody = (result.output?.trim() || "(empty output)").slice(0, MAX_TG_CHUNK);
172
+ const finalDisplay = `${info.name}\n\n${finalBody}`;
173
+ try {
174
+ await this.api.editMessageText(this.chatId, this.messageId, finalDisplay);
175
+ }
176
+ catch {
177
+ // If the final edit fails, the "thinking…" header stays —
178
+ // the banner below will still communicate completion.
179
+ }
180
+ }
181
+ // Post the banner as a new message (notification-triggering)
182
+ const banner = buildBanner(info, result);
183
+ try {
184
+ await this.api.sendMessage(this.chatId, banner, { parse_mode: "Markdown" });
185
+ }
186
+ catch (err) {
187
+ console.error(`[subagent-live] finalize banner failed:`, err);
188
+ this.failed = true;
189
+ throw err;
190
+ }
191
+ }
192
+ }
193
+ /**
194
+ * Factory for LiveStream — returns null if the bot api isn't attached
195
+ * yet, or if the api doesn't support editMessageText. Callers check
196
+ * the return value and fall back to normal delivery if null.
197
+ */
198
+ export function createLiveStream(chatId, agentName) {
199
+ const api = getBotApi();
200
+ if (!api || !api.editMessageText) {
201
+ console.warn(`[subagent-live] no compatible bot api — live mode unavailable`);
202
+ return null;
203
+ }
204
+ return new LiveStream(api, chatId, agentName);
205
+ }
206
+ // ── Main delivery entry point ───────────────────────────────
56
207
  /**
57
208
  * Main delivery entry point. Resolves the effective visibility (override →
58
209
  * config default), then dispatches to the source-specific renderer.
@@ -68,6 +219,10 @@ export async function deliverSubAgentResult(info, result, opts = {}) {
68
219
  const effective = opts.visibility ?? getVisibility();
69
220
  if (effective === "silent")
70
221
  return;
222
+ // "live" mode is handled inline by runSubAgent via LiveStream. If we
223
+ // get here with "live" visibility it means the live-stream path wasn't
224
+ // applicable (wrong source, missing editMessageText, etc.) — fall
225
+ // through to the normal banner+final behavior below.
71
226
  const api = getBotApi();
72
227
  if (!api) {
73
228
  console.warn(`[subagent-delivery] no bot api available for ${info.name}`);