alvin-bot 4.14.0 → 4.14.1

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,40 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.14.1] — 2026-04-16
6
+
7
+ ### 🐛 Patch: `/subagents list` now shows v4.13+ dispatch agents too
8
+
9
+ **Bug Ali caught:** typing `/subagents list` in Telegram while a `alvin_dispatch_agent` sub-agent was actively running returned "no agents running" — even though the user could see the agent finish and deliver a result shortly after. Cross-platform effect too: `/alvin` slash command on Slack had the same display gap.
10
+
11
+ **Root cause:** two separate registries for sub-agents:
12
+ - `src/services/subagents.ts` `activeAgents` Map — used since v4.0.0 for bot-level sub-agents (cron spawns, implicit Task tool children, `/sub-agents spawn` CLI)
13
+ - `src/services/async-agent-watcher.ts` `pending` Map — used since v4.13 for detached `alvin_dispatch_agent` subprocesses
14
+
15
+ `/subagents list` only read from the first map. The entire v4.13+ dispatch path was invisible in the listing.
16
+
17
+ **Fix:** new `listActiveSubAgents()` helper in subagents.ts that merges both registries. Pending async-agent-watcher entries get synthesized into `SubAgentInfo` shape (status="running", source="cron", depth=0, platform preserved). The `/subagents list` handler and the default-render path both switch to the merged helper. The old `listSubAgents()` function stays pure (unchanged behavior) — cancel/result paths still use it because detached subprocess PIDs aren't tracked.
18
+
19
+ ### Technical details
20
+
21
+ - `listActiveSubAgents()` is async (lazy dynamic import of the watcher module to keep subagents.ts load order clean) — existing `listSubAgents()` remains sync for the v4.0.0 consumers
22
+ - Synthesis mapping: `PendingAsyncAgent.agentId → SubAgentInfo.id`, `description → name`, `startedAt → startedAt`, always `status="running"` (pending by definition), `source="cron"` (matches watcher's delivery banner), `depth=0`
23
+ - Platform field preserved so the renderer can show cross-platform context if desired later
24
+
25
+ ### Testing
26
+
27
+ - **Baseline**: 492 tests (v4.14.0)
28
+ - **New**: `test/list-subagents-merged.test.ts` — 6 tests (empty state, single slack agent, multi-platform merge, timestamp preservation, source tag, listSubAgents purity guard)
29
+ - **Total**: 498 tests, all green, TSC clean
30
+
31
+ ### Files changed
32
+
33
+ - **Modified**: `src/services/subagents.ts` (new listActiveSubAgents helper), `src/handlers/commands.ts` (both /subagents list paths switch to merged view)
34
+ - **NEW tests**: `test/list-subagents-merged.test.ts`
35
+ - **Version**: `package.json` 4.14.0 → 4.14.1
36
+
37
+ ---
38
+
5
39
  ## [4.14.0] — 2026-04-16
6
40
 
7
41
  ### ✨ Sub-agent dispatch on Slack, Discord, WhatsApp (Telegram unchanged)
@@ -1910,7 +1910,7 @@ export function registerCommands(bot) {
1910
1910
  // type both "/sub-agents" and "/subagents" — Telegram routes both to this.
1911
1911
  bot.command(["sub_agents", "subagents"], async (ctx) => {
1912
1912
  const lang = getSession(ctx.from.id).language;
1913
- const { listSubAgents, cancelSubAgent, getSubAgentResult, getMaxParallelAgents, getConfiguredMaxParallel, setMaxParallelAgents, findSubAgentByName, getVisibility, setVisibility, getQueueCap, setQueueCap, getDefaultTimeoutMs, setDefaultTimeoutMs, } = await import("../services/subagents.js");
1913
+ const { listSubAgents, listActiveSubAgents, cancelSubAgent, getSubAgentResult, getMaxParallelAgents, getConfiguredMaxParallel, setMaxParallelAgents, findSubAgentByName, getVisibility, setVisibility, getQueueCap, setQueueCap, getDefaultTimeoutMs, setDefaultTimeoutMs, } = await import("../services/subagents.js");
1914
1914
  const arg = (ctx.match || "").trim();
1915
1915
  const tokens = arg.split(/\s+/).filter(Boolean);
1916
1916
  const sub = tokens[0]?.toLowerCase() || "";
@@ -2040,8 +2040,10 @@ export function registerCommands(bot) {
2040
2040
  return;
2041
2041
  }
2042
2042
  // /sub-agents list — same rendering as the default, but forced
2043
+ // v4.14.1 — uses listActiveSubAgents (merged view) so v4.13+
2044
+ // alvin_dispatch_agent detached subprocesses also show up here.
2043
2045
  if (sub === "list") {
2044
- const agents = listSubAgents();
2046
+ const agents = await listActiveSubAgents();
2045
2047
  if (agents.length === 0) {
2046
2048
  await ctx.reply(t("bot.subagents.noneRunning", lang));
2047
2049
  return;
@@ -2142,7 +2144,8 @@ export function registerCommands(bot) {
2142
2144
  const timeoutLabel = currentTimeout <= 0
2143
2145
  ? `⏱ Timeout: *∞ (unlimited)*`
2144
2146
  : `⏱ Timeout: *${Math.round(currentTimeout / 1000)}s*`;
2145
- const agents = listSubAgents();
2147
+ // v4.14.1 merged view incl. v4.13+ alvin_dispatch_agent agents.
2148
+ const agents = await listActiveSubAgents();
2146
2149
  let body = "";
2147
2150
  if (agents.length === 0) {
2148
2151
  body = `\n${t("bot.subagents.noneRunning", lang)}`;
@@ -576,10 +576,69 @@ export function spawnSubAgent(agentConfig) {
576
576
  }
577
577
  /**
578
578
  * List all agents (active + recent completed).
579
+ *
580
+ * This is the v4.0.0 API — shows only agents from the bot-level
581
+ * registry (activeAgents Map). Does NOT include v4.13+ detached
582
+ * `alvin_dispatch_agent` subprocesses which live in async-agent-
583
+ * watcher. For the merged view used by `/subagents list`, use
584
+ * `listActiveSubAgents()` instead.
579
585
  */
580
586
  export function listSubAgents() {
581
587
  return [...activeAgents.values()].map((a) => ({ ...a.info }));
582
588
  }
589
+ /**
590
+ * v4.14.1 — Merged view of BOTH sub-agent registries:
591
+ * 1. Bot-level agents (subagents.ts activeAgents Map) — v4.0.0+
592
+ * the /sub-agents spawn CLI, cron-spawned sub-agents, implicit
593
+ * Task-tool children.
594
+ * 2. Detached `alvin_dispatch_agent` subprocesses (async-agent-
595
+ * watcher pending Map) — v4.13+ the MCP-tool-dispatched
596
+ * agents that survive parent aborts.
597
+ *
598
+ * The user doesn't care which registry an agent lives in — "is there
599
+ * anything running right now?" is the question `/subagents list`
600
+ * answers. This function unifies the view.
601
+ *
602
+ * Pending async agents are synthesized into SubAgentInfo shape:
603
+ * - id: PendingAsyncAgent.agentId (alvin-prefixed hex)
604
+ * - name: PendingAsyncAgent.description
605
+ * - status: "running" (we wouldn't be pending otherwise)
606
+ * - startedAt: PendingAsyncAgent.startedAt
607
+ * - source: "cron" — matches the delivery banner's source tag
608
+ * - depth: 0 — dispatch agents are always top-level (no nesting)
609
+ * - platform: preserved from the pending entry
610
+ * - parentChatId: from the pending entry
611
+ *
612
+ * Lazy import of the watcher keeps this function cheap for callers
613
+ * who only need the v4.0.0 view (importing the watcher pulls in its
614
+ * whole startup cost otherwise).
615
+ */
616
+ export async function listActiveSubAgents() {
617
+ const botLevel = listSubAgents();
618
+ let pending = [];
619
+ try {
620
+ // Lazy dynamic import so this module doesn't depend on the watcher
621
+ // at load time (preserves test isolation + avoids a circular boot).
622
+ const watcher = await import("./async-agent-watcher.js");
623
+ if (typeof watcher.listPendingAgents === "function") {
624
+ const raw = watcher.listPendingAgents();
625
+ pending = raw.map((p) => ({
626
+ id: p.agentId,
627
+ name: p.description,
628
+ status: "running",
629
+ startedAt: p.startedAt,
630
+ source: "cron",
631
+ depth: 0,
632
+ platform: p.platform,
633
+ parentChatId: p.chatId,
634
+ }));
635
+ }
636
+ }
637
+ catch {
638
+ /* never break listing because of merge errors */
639
+ }
640
+ return [...botLevel, ...pending];
641
+ }
583
642
  /**
584
643
  * Cancel a running sub-agent by ID.
585
644
  * Returns true if the agent was found and aborted.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.14.0",
3
+ "version": "4.14.1",
4
4
  "description": "Alvin Bot \u2014 Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,172 @@
1
+ /**
2
+ * v4.14.1 — `/subagents list` must show v4.13+ dispatch agents too.
3
+ *
4
+ * Root cause: `listSubAgents()` in subagents.ts only iterates the
5
+ * `activeAgents` Map (B1+B2 from v4.0.0). v4.13's `alvin_dispatch_agent`
6
+ * MCP tool writes into `async-agent-watcher.ts`'s `pending` Map instead.
7
+ * User-facing impact: "no subagents running" while the bot is visibly
8
+ * dispatching sub-agents.
9
+ *
10
+ * Fix strategy: a new `listActiveSubAgents()` helper that merges both
11
+ * registries into a unified SubAgentInfo-shaped list. The `/subagents
12
+ * list` handler uses this instead of the bare `listSubAgents()`.
13
+ * Cancel/result operations keep using the old registry — we can't
14
+ * cancel a detached `claude -p` subprocess anyway without knowing its
15
+ * PID, which isn't tracked.
16
+ */
17
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
18
+ import fs from "fs";
19
+ import os from "os";
20
+ import { resolve } from "path";
21
+
22
+ const TEST_DATA_DIR = resolve(
23
+ os.tmpdir(),
24
+ `alvin-list-merged-${process.pid}-${Date.now()}`,
25
+ );
26
+
27
+ beforeEach(async () => {
28
+ if (fs.existsSync(TEST_DATA_DIR)) {
29
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
30
+ }
31
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
32
+ process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
33
+ vi.resetModules();
34
+
35
+ vi.doMock("../src/services/subagent-delivery.js", () => ({
36
+ deliverSubAgentResult: async () => {},
37
+ attachBotApi: () => {},
38
+ __setBotApiForTest: () => {},
39
+ }));
40
+ });
41
+
42
+ afterEach(async () => {
43
+ try {
44
+ const mod = await import("../src/services/async-agent-watcher.js");
45
+ mod.stopWatcher();
46
+ mod.__resetForTest();
47
+ } catch {}
48
+ });
49
+
50
+ describe("listActiveSubAgents merged view (v4.14.1)", () => {
51
+ it("returns empty list when neither registry has agents", async () => {
52
+ const mod = await import("../src/services/subagents.js");
53
+ expect(await mod.listActiveSubAgents()).toEqual([]);
54
+ });
55
+
56
+ it("includes async-agent-watcher pending agents in the merged list", async () => {
57
+ const watcher = await import("../src/services/async-agent-watcher.js");
58
+ watcher.registerPendingAgent({
59
+ agentId: "alvin-abc123",
60
+ outputFile: `${TEST_DATA_DIR}/out.jsonl`,
61
+ description: "Research Higgsfield",
62
+ prompt: "...",
63
+ chatId: "C012SLACK",
64
+ userId: "U123",
65
+ toolUseId: null,
66
+ sessionKey: "slack:C012SLACK",
67
+ platform: "slack",
68
+ });
69
+
70
+ const mod = await import("../src/services/subagents.js");
71
+ const agents = await mod.listActiveSubAgents();
72
+ expect(agents).toHaveLength(1);
73
+ expect(agents[0].id).toBe("alvin-abc123");
74
+ expect(agents[0].name).toBe("Research Higgsfield");
75
+ expect(agents[0].status).toBe("running");
76
+ expect(agents[0].depth).toBe(0);
77
+ expect(agents[0].platform).toBe("slack");
78
+ });
79
+
80
+ it("merges multiple agents from both registries without dupes", async () => {
81
+ const watcher = await import("../src/services/async-agent-watcher.js");
82
+ watcher.registerPendingAgent({
83
+ agentId: "alvin-one",
84
+ outputFile: `${TEST_DATA_DIR}/a.jsonl`,
85
+ description: "Agent One",
86
+ prompt: "p",
87
+ chatId: 42,
88
+ userId: 42,
89
+ toolUseId: null,
90
+ sessionKey: "s",
91
+ platform: "telegram",
92
+ });
93
+ watcher.registerPendingAgent({
94
+ agentId: "alvin-two",
95
+ outputFile: `${TEST_DATA_DIR}/b.jsonl`,
96
+ description: "Agent Two",
97
+ prompt: "p",
98
+ chatId: 42,
99
+ userId: 42,
100
+ toolUseId: null,
101
+ sessionKey: "s",
102
+ platform: "telegram",
103
+ });
104
+
105
+ const mod = await import("../src/services/subagents.js");
106
+ const agents = await mod.listActiveSubAgents();
107
+ const ids = agents.map((a) => a.id).sort();
108
+ expect(ids).toEqual(["alvin-one", "alvin-two"]);
109
+ });
110
+
111
+ it("preserves startedAt timestamp for age rendering", async () => {
112
+ const fixedTs = Date.now() - 45_000; // 45 seconds ago
113
+ const watcher = await import("../src/services/async-agent-watcher.js");
114
+ watcher.registerPendingAgent({
115
+ agentId: "alvin-aged",
116
+ outputFile: `${TEST_DATA_DIR}/aged.jsonl`,
117
+ description: "Old agent",
118
+ prompt: "p",
119
+ chatId: 1,
120
+ userId: 1,
121
+ toolUseId: null,
122
+ sessionKey: "s",
123
+ platform: "slack",
124
+ });
125
+
126
+ const mod = await import("../src/services/subagents.js");
127
+ const agents = await mod.listActiveSubAgents();
128
+ expect(agents[0].startedAt).toBeGreaterThan(fixedTs - 1000);
129
+ expect(agents[0].startedAt).toBeLessThan(Date.now() + 1000);
130
+ });
131
+
132
+ it("tags async dispatch agents with source='cron' (matches v4.12 banner format)", async () => {
133
+ const watcher = await import("../src/services/async-agent-watcher.js");
134
+ watcher.registerPendingAgent({
135
+ agentId: "alvin-sourced",
136
+ outputFile: `${TEST_DATA_DIR}/s.jsonl`,
137
+ description: "sourced",
138
+ prompt: "p",
139
+ chatId: 1,
140
+ userId: 1,
141
+ toolUseId: null,
142
+ sessionKey: "s",
143
+ platform: "telegram",
144
+ });
145
+ const mod = await import("../src/services/subagents.js");
146
+ const agents = await mod.listActiveSubAgents();
147
+ // source='cron' = the ⏰ badge in /subagents list rendering. Matches
148
+ // the existing v4.12.x watcher delivery's SubAgentInfo.source value.
149
+ expect(agents[0].source).toBe("cron");
150
+ });
151
+
152
+ it("listSubAgents() (v4.0.0 API) is unchanged and doesn't include pending dispatches", async () => {
153
+ const watcher = await import("../src/services/async-agent-watcher.js");
154
+ watcher.registerPendingAgent({
155
+ agentId: "alvin-isolated",
156
+ outputFile: `${TEST_DATA_DIR}/iso.jsonl`,
157
+ description: "isolated",
158
+ prompt: "p",
159
+ chatId: 1,
160
+ userId: 1,
161
+ toolUseId: null,
162
+ sessionKey: "s",
163
+ platform: "telegram",
164
+ });
165
+ const mod = await import("../src/services/subagents.js");
166
+ // The original listSubAgents is kept pure — only the merged helper
167
+ // returns combined results. Cancel/result paths still use the
168
+ // bot-level registry.
169
+ expect(mod.listSubAgents()).toHaveLength(0);
170
+ expect(await mod.listActiveSubAgents()).toHaveLength(1);
171
+ });
172
+ });