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 +148 -0
- package/bin/cli.js +79 -26
- package/dist/handlers/commands.js +47 -3
- 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/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
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
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
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(
|
|
1411
|
+
execSync(`launchctl kickstart -k gui/$(id -u)/${label}`, {
|
|
1412
|
+
stdio: "inherit",
|
|
1413
|
+
shell: "/bin/zsh",
|
|
1414
|
+
});
|
|
1408
1415
|
} catch {
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// /
|
|
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}`);
|