alvin-bot 4.13.1 → 4.14.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,106 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.14.0] — 2026-04-16
6
+
7
+ ### ✨ Sub-agent dispatch on Slack, Discord, WhatsApp (Telegram unchanged)
8
+
9
+ v4.13.0 shipped truly-detached sub-agents via the `mcp__alvin__dispatch_agent` MCP tool, but only Telegram passed the required `alvinDispatchContext` to the provider. Slack/Discord/WhatsApp users couldn't trigger background sub-agents — the tool was visible to Claude but effectively unreachable.
10
+
11
+ v4.14 wires the same dispatch path through the non-Telegram handler (`src/handlers/platform-message.ts`) and adds a platform-aware delivery router so results come back on the same platform they were dispatched from.
12
+
13
+ **Telegram is untouched.** The v4.13.0 Telegram pipeline (message.ts → Claude SDK → alvin_dispatch_agent → watcher → grammy-api delivery) is bit-for-bit identical. Only the types widened (`chatId: number | string`, `platform?: ...`), and the new code paths activate only when `platform !== "telegram"`.
14
+
15
+ ### Technical details
16
+
17
+ **Type widening** (`src/services/async-agent-watcher.ts`, `src/services/alvin-dispatch.ts`, `src/services/alvin-mcp-tools.ts`, `src/providers/types.ts`, `src/services/subagents.ts`):
18
+ - `PendingAsyncAgent.chatId` / `userId`: `number` → `number | string`
19
+ - `PendingAsyncAgent.platform?: "telegram" | "slack" | "discord" | "whatsapp"` (optional, undefined = telegram)
20
+ - `SubAgentInfo.parentChatId`: same widening
21
+ - `SubAgentInfo.platform?: ...` new field
22
+ - `DispatchInput`, `AlvinDispatchContext`, `QueryOptions.alvinDispatchContext`: same widening + `platform` field
23
+
24
+ Pre-v4.14 persisted `async-agents.json` entries keep working — missing `platform` field defaults to `telegram`, numeric `chatId` still routes through grammy.
25
+
26
+ **New module** `src/services/delivery-registry.ts`:
27
+ - `registerDeliveryAdapter({ platform, sendText, sendDocument? })` — called by each platform module at startup
28
+ - `getDeliveryAdapter(platform)` — watcher lookup
29
+ - Tiny surface: sendText + optional sendDocument, string | number chatId, no Markdown or live-stream
30
+
31
+ **Delivery router** `src/services/subagent-delivery.ts` `deliverSubAgentResult()`:
32
+ - Branches on `info.platform ?? "telegram"`:
33
+ - `telegram` → existing grammy path (unchanged Markdown parsing, file uploads, 3800-char chunking)
34
+ - `slack`/`discord`/`whatsapp` → new `deliverViaRegistry()` path — plain text (no Markdown), 3800-char chunks, optional file upload via adapter.sendDocument
35
+
36
+ **Adapter registration** in `src/platforms/slack.ts`, `src/platforms/discord.ts`, `src/platforms/whatsapp.ts`:
37
+ - Each platform's `start()` now calls `registerDeliveryAdapter` at the end
38
+ - The adapter's `sendText` wraps the existing platform `sendText` (no duplicate code)
39
+
40
+ **Handler wiring** `src/handlers/platform-message.ts`:
41
+ - When the active provider is SDK, `alvinDispatchContext: { chatId, userId, sessionKey, platform }` is passed in queryOpts — mirrors the Telegram handler's v4.13.0 behavior
42
+ - Claude sees the same `mcp__alvin__dispatch_agent` tool and uses it the same way
43
+
44
+ ### Testing
45
+
46
+ - **Baseline**: 483 tests (v4.13.2)
47
+ - **New**:
48
+ - `test/delivery-registry.test.ts` — 4 tests (register/get roundtrip, unregistered returns null, re-register replaces, per-platform isolation)
49
+ - `test/subagent-delivery-platform-routing.test.ts` — 5 tests (slack routes via registry not grammy, telegram defaults still use grammy, discord routes correctly, orphan platform skips gracefully, long output chunks on non-telegram adapters)
50
+ - **Total**: 492 tests, all green, TSC clean
51
+ - **Telegram regression guard**: the routing test explicitly verifies `info.platform=undefined` still hits grammy, and `info.platform='slack'` never touches grammy. That's the load-bearing invariant.
52
+
53
+ ### Files changed
54
+
55
+ - **NEW**: `src/services/delivery-registry.ts`, `test/delivery-registry.test.ts`, `test/subagent-delivery-platform-routing.test.ts`
56
+ - **Modified**: `src/services/async-agent-watcher.ts` (chatId widening + platform field), `src/services/subagent-delivery.ts` (platform router + plain-text banner variant), `src/services/alvin-dispatch.ts` (type widening), `src/services/alvin-mcp-tools.ts` (context pass-through), `src/services/subagents.ts` (SubAgentInfo.platform + widened parentChatId), `src/providers/types.ts` (QueryOptions.alvinDispatchContext extended), `src/handlers/platform-message.ts` (dispatch context), `src/platforms/slack.ts` / `discord.ts` / `whatsapp.ts` (adapter registration)
57
+ - **Version**: `package.json` 4.13.2 → 4.14.0 (minor bump — new public surface: delivery-registry, platform field)
58
+
59
+ ### Known limitations
60
+
61
+ - **Slack slash command context**: when a user invokes `/alvin <prompt>` in Slack, dispatch works (same codepath), but the sub-agent result delivery lands as a persistent channel message, not an ephemeral slash-command response. If you want ephemeral replies, use DM.
62
+ - **Discord/WhatsApp not smoke-tested**: the code paths match Slack, and the adapter registration is symmetric, but I only end-to-end tested Slack. YMMV until you run a real test.
63
+
64
+ ---
65
+
66
+ ## [4.13.2] — 2026-04-16
67
+
68
+ ### ✨ Slack: `/alvin` slash commands + rewritten setup guide
69
+
70
+ **Bug (carried over from v4.13.1):** Slash commands didn't work on Slack. When a user typed `/status` in a DM with the bot, Slack either hit its built-in `/status` (user status setter) or showed "Not a valid command" — nothing reached the bot. The Slack adapter only registered `message` + `app_mention` event handlers, no `command` handler; the manifest declared no slash commands.
71
+
72
+ **Why it was a gotcha**: Slack treats slash commands as a separate event type (`command`), not as message text. Apps must explicitly register each command in their manifest AND add a `app.command(...)` handler to receive the events. None of this had been set up.
73
+
74
+ **Fix**: v4.13.2 introduces a single namespaced command `/alvin` that takes a subcommand argument. Users type `/alvin status`, `/alvin new`, `/alvin effort high`, `/alvin help` — the Slack adapter parses the subcommand from `command.text` and forwards it as a `/status`/`/new`/etc. message through the existing `handlePlatformCommand` pipeline. Unknown subcommands fall through to normal LLM handling so `/alvin what's the weather` also works as a free-form query.
75
+
76
+ ### Technical details
77
+
78
+ **New parser** `src/platforms/slack-slash-parser.ts`: pure `parseSlackSlashCommand(text)` helper. Empty text → `/help`. Single word → `/<word>`. Word + args → `/<word> <args>`. Lowercases subcommand, preserves arg capitalization, strips defensive leading slash, collapses extra whitespace. 8 unit tests.
79
+
80
+ **Adapter change** `src/platforms/slack.ts`: new `app.command("/alvin", ...)` registration in `start()` (guarded with `typeof app.command === "function"` for test-mock compat). `ack()` fires immediately to meet Slack's 3-second requirement. New `handleSlashCommand(command)` method synthesizes an `IncomingMessage` with the translated `text` and the command's `channel_id`/`user_id` and forwards to the same `this.handler(...)` path as regular DMs. Response goes back via `chat.postMessage` (persistent, visible in channel history) rather than slash-command-native `respond()` (ephemeral) — matches DM behavior.
81
+
82
+ **Slack app manifest**: requires a new `features.slash_commands` entry declaring `/alvin` and a new `commands` OAuth scope. Both are in the manifest JSON the setup guide pastes in — no manual per-field config. Existing installations need a one-time re-install to pick up the new `commands` scope (Slack shows a yellow banner after manifest save).
83
+
84
+ **Setup guide rewrite** `src/web/setup-api.ts` Slack `setupSteps[]`: replaces the old 7-step "click-through every section" sequence with a 9-step manifest-paste flow that actually matches how the bot is currently set up (Messages Tab, Events, Socket Mode, slash commands — all covered in one JSON paste). Includes the full manifest JSON inline. New users get a working Slack app in ~2 minutes instead of hunting through the Slack API UI.
85
+
86
+ ### Testing
87
+
88
+ - **Baseline**: 475 tests (v4.13.1)
89
+ - **New**: `test/slack-slash-command.test.ts` — 8 tests (empty → /help, single word, args preservation, whitespace collapse, case insensitivity on subcommand, case preservation on args, defensive leading slash handling)
90
+ - **Total**: 483 tests, all green, TSC clean
91
+ - **Live smoke verification**: manifest pushed via Chrome browser automation, reinstall completed, Slack adapter re-registered with `app.command("/alvin")`. Live test of `/alvin status` pending user confirmation.
92
+
93
+ ### Files changed
94
+
95
+ - **NEW**: `src/platforms/slack-slash-parser.ts`, `test/slack-slash-command.test.ts`
96
+ - **Modified**: `src/platforms/slack.ts` (command registration + handler), `src/web/setup-api.ts` (slack setupSteps rewrite), `package.json` (4.13.1 → 4.13.2)
97
+
98
+ ### Known limitations
99
+
100
+ - **One command namespace only**: we register `/alvin` not individual `/status`/`/new` etc. because `/status` conflicts with Slack's built-in command. Side effect: slightly more typing for users (`/alvin status` vs `/status`). Alternative namespaces considered (`/alvin-status` as multiple commands each) would work too but require more manifest boilerplate; deferred unless users complain.
101
+ - **Channel responses are public**: when `/alvin status` is invoked in a channel, the bot's response is a normal `chat.postMessage` visible to the whole channel. If you want private responses there, use DM or switch the sendText call to use Slack's `response_url` (ephemeral). Deferred as enhancement — DM is the primary use case.
102
+
103
+ ---
104
+
5
105
  ## [4.13.1] — 2026-04-16
6
106
 
7
107
  ### 🐛 Patch: Slack Test Connection + PM2 → launchd migration for Maintenance UI
@@ -171,6 +171,18 @@ export async function handlePlatformMessage(msg, adapter) {
171
171
  effort: session.effort,
172
172
  sessionId: isSDK ? session.sessionId : null,
173
173
  history: !isSDK ? session.history : undefined,
174
+ // v4.14 — Expose alvin_dispatch_agent MCP tool on non-Telegram
175
+ // platforms too (Slack/Discord/WhatsApp). The watcher routes the
176
+ // eventual delivery via the platform's registered DeliveryAdapter.
177
+ // Only for SDK provider (where MCP tools are supported).
178
+ alvinDispatchContext: isSDK
179
+ ? {
180
+ chatId: msg.chatId,
181
+ userId: msg.userId,
182
+ sessionKey,
183
+ platform: msg.platform,
184
+ }
185
+ : undefined,
174
186
  };
175
187
  if (!isSDK) {
176
188
  addToHistory(sessionKey, { role: "user", content: fullText });
@@ -83,6 +83,20 @@ export class DiscordAdapter {
83
83
  });
84
84
  await this.client.login(this.token);
85
85
  console.log(`🎮 Discord adapter started (${this.client.user?.tag})`);
86
+ // v4.14 — Register with the delivery registry so the async-agent
87
+ // watcher can deliver background sub-agent results back to Discord.
88
+ try {
89
+ const { registerDeliveryAdapter } = await import("../services/delivery-registry.js");
90
+ registerDeliveryAdapter({
91
+ platform: "discord",
92
+ sendText: async (chatId, text) => {
93
+ await this.sendText(String(chatId), text);
94
+ },
95
+ });
96
+ }
97
+ catch (err) {
98
+ console.warn("[discord] failed to register delivery adapter:", err);
99
+ }
86
100
  }
87
101
  catch (err) {
88
102
  _discordState.status = "error";
@@ -0,0 +1,32 @@
1
+ /**
2
+ * v4.13.2 — Parse Slack `/alvin <subcommand> [args...]` slash command
3
+ * text into the platform-agnostic `/<subcommand> [args]` format that
4
+ * handlePlatformCommand already knows.
5
+ *
6
+ * Pure function — tested in isolation. Called from the Slack adapter's
7
+ * `app.command('/alvin')` handler.
8
+ *
9
+ * Rules:
10
+ * - Empty text → `/help` (useful default, shows the commands list)
11
+ * - Subcommand is lowercased for case-insensitive matching
12
+ * - Args are kept verbatim (preserve user capitalization)
13
+ * - A literal leading `/` on the subcommand is stripped defensively
14
+ * (handles `/alvin /status` which becomes just `/status`, not `//status`)
15
+ */
16
+ export function parseSlackSlashCommand(text) {
17
+ const trimmed = text.trim();
18
+ if (trimmed.length === 0)
19
+ return "/help";
20
+ // Split on first whitespace run — head is the subcommand, tail is args
21
+ const match = trimmed.match(/^(\S+)(?:\s+(.*))?$/);
22
+ if (!match)
23
+ return "/help";
24
+ let sub = (match[1] || "").toLowerCase();
25
+ // Strip a literal leading slash the user might have typed
26
+ if (sub.startsWith("/"))
27
+ sub = sub.slice(1);
28
+ if (sub.length === 0)
29
+ return "/help";
30
+ const args = (match[2] || "").trim();
31
+ return args ? `/${sub} ${args}` : `/${sub}`;
32
+ }
@@ -17,6 +17,7 @@
17
17
  * 7. Set env vars and restart bot
18
18
  */
19
19
  import fs from "fs";
20
+ import { parseSlackSlashCommand } from "./slack-slash-parser.js";
20
21
  let _slackState = {
21
22
  status: "disconnected",
22
23
  botName: null,
@@ -80,10 +81,50 @@ export class SlackAdapter {
80
81
  this.app.event("app_mention", async ({ event, say, client }) => {
81
82
  await this.handleMention(event, say, client);
82
83
  });
84
+ // v4.13.2 — Handle the /alvin slash command.
85
+ //
86
+ // Slack sends slash commands as their own "command" event type
87
+ // (not as regular messages), so without this handler users who
88
+ // type /status see "Not a valid command" from Slack's built-in
89
+ // /status (which sets their user status). We register /alvin as
90
+ // a namespaced parent and parse the subcommand from command.text.
91
+ //
92
+ // CRITICAL: Slack requires ack() within 3 seconds or the user
93
+ // sees "/alvin didn't respond". We ack FIRST, then do the work
94
+ // asynchronously via the normal handler pipeline.
95
+ //
96
+ // Defensive: older/mocked Bolt versions might not expose .command().
97
+ // Skip registration silently rather than crashing start().
98
+ if (typeof this.app.command === "function") {
99
+ this.app.command("/alvin", async ({ command, ack }) => {
100
+ await ack();
101
+ try {
102
+ await this.handleSlashCommand(command);
103
+ }
104
+ catch (err) {
105
+ console.error("[slack] /alvin command failed:", err);
106
+ }
107
+ });
108
+ }
83
109
  await this.app.start();
84
110
  _slackState.status = "connected";
85
111
  _slackState.connectedAt = Date.now();
86
112
  console.log(`\uD83D\uDCAC Slack connected (${_slackState.botName} @ ${_slackState.teamName})`);
113
+ // v4.14 — Register this adapter with the delivery registry so the
114
+ // async-agent watcher can deliver background sub-agent results
115
+ // back to Slack. The registry accepts string channel IDs directly.
116
+ try {
117
+ const { registerDeliveryAdapter } = await import("../services/delivery-registry.js");
118
+ registerDeliveryAdapter({
119
+ platform: "slack",
120
+ sendText: async (chatId, text) => {
121
+ await this.sendText(String(chatId), text);
122
+ },
123
+ });
124
+ }
125
+ catch (err) {
126
+ console.warn("[slack] failed to register delivery adapter:", err);
127
+ }
87
128
  }
88
129
  catch (err) {
89
130
  _slackState.status = "error";
@@ -160,6 +201,43 @@ export class SlackAdapter {
160
201
  };
161
202
  await this.handler(incoming);
162
203
  }
204
+ /**
205
+ * v4.13.2 — Handle /alvin slash command.
206
+ *
207
+ * Slack delivers these with command.text containing the part after
208
+ * "/alvin " (so "/alvin status" arrives with text="status"). We
209
+ * translate into a platform-agnostic "/<sub>[ args]" string and
210
+ * forward through the normal message handler — handlePlatformCommand
211
+ * picks it up since it starts with "/".
212
+ *
213
+ * The response goes back via the same sendText path as regular
214
+ * messages (chat.postMessage in command.channel_id). Slack allows
215
+ * this in addition to the slash-command-native respond() mechanism,
216
+ * and it keeps the codepath identical to message.im responses.
217
+ */
218
+ async handleSlashCommand(command) {
219
+ if (!this.handler)
220
+ return;
221
+ const translated = parseSlackSlashCommand(command.text || "");
222
+ const channelId = command.channel_id || "";
223
+ const userId = command.user_id || "";
224
+ const userName = command.user_name || userId;
225
+ const incoming = {
226
+ platform: "slack",
227
+ messageId: `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
228
+ chatId: channelId,
229
+ userId,
230
+ userName,
231
+ text: translated,
232
+ // Slack slash commands are always issued 1:1 in the sense of
233
+ // "one user invoking". isGroup reflects the CHANNEL context
234
+ // (channel_name=directmessage is a DM, otherwise channel/group).
235
+ isGroup: command.channel_name && command.channel_name !== "directmessage",
236
+ isMention: false,
237
+ isReplyToBot: false,
238
+ };
239
+ await this.handler(incoming);
240
+ }
163
241
  async handleMention(event, _say, client) {
164
242
  if (!this.handler)
165
243
  return;
@@ -217,6 +217,20 @@ export class WhatsAppAdapter {
217
217
  connectedAt: null, error: null, info: null,
218
218
  };
219
219
  await this.connect();
220
+ // v4.14 — Register with the delivery registry so the async-agent
221
+ // watcher can deliver background sub-agent results back to WhatsApp.
222
+ try {
223
+ const { registerDeliveryAdapter } = await import("../services/delivery-registry.js");
224
+ registerDeliveryAdapter({
225
+ platform: "whatsapp",
226
+ sendText: async (chatId, text) => {
227
+ await this.sendText(String(chatId), text);
228
+ },
229
+ });
230
+ }
231
+ catch (err) {
232
+ console.warn("[whatsapp] failed to register delivery adapter:", err);
233
+ }
220
234
  }
221
235
  async connect() {
222
236
  let baileys;
@@ -108,6 +108,7 @@ export function dispatchDetachedAgent(input) {
108
108
  userId: input.userId,
109
109
  toolUseId: null,
110
110
  sessionKey: input.sessionKey,
111
+ platform: input.platform,
111
112
  });
112
113
  // Increment the session's pendingBackgroundCount so the main handler
113
114
  // knows a background task is in flight (same signal path as SDK's
@@ -70,6 +70,7 @@ export function buildAlvinMcpServer(ctx) {
70
70
  chatId: ctx.chatId,
71
71
  userId: ctx.userId,
72
72
  sessionKey: ctx.sessionKey,
73
+ platform: ctx.platform,
73
74
  cwd: ctx.cwd,
74
75
  });
75
76
  return {
@@ -83,6 +83,7 @@ export function registerPendingAgent(input) {
83
83
  giveUpAt: input.giveUpAt ?? now + MAX_AGENT_AGE_MS,
84
84
  toolUseId: input.toolUseId,
85
85
  sessionKey: input.sessionKey,
86
+ platform: input.platform,
86
87
  };
87
88
  pending.set(input.agentId, entry);
88
89
  saveToDisk();
@@ -175,6 +176,7 @@ async function deliverAsCompleted(entry, output, tokensUsed) {
175
176
  source: "cron", // Reuse cron banner format — fits async background agents.
176
177
  depth: 0,
177
178
  parentChatId: entry.chatId,
179
+ platform: entry.platform,
178
180
  };
179
181
  const result = {
180
182
  id: entry.agentId,
@@ -202,6 +204,7 @@ async function deliverAsFailure(entry, status, error) {
202
204
  source: "cron",
203
205
  depth: 0,
204
206
  parentChatId: entry.chatId,
207
+ platform: entry.platform,
205
208
  };
206
209
  const result = {
207
210
  id: entry.agentId,
@@ -0,0 +1,21 @@
1
+ const adapters = new Map();
2
+ /**
3
+ * Register (or replace) an adapter for a platform. Idempotent —
4
+ * registering the same platform twice replaces the previous entry
5
+ * (handles platform-module reload during dev).
6
+ */
7
+ export function registerDeliveryAdapter(adapter) {
8
+ adapters.set(adapter.platform, adapter);
9
+ }
10
+ /** Look up the adapter for a platform. Returns null if not registered. */
11
+ export function getDeliveryAdapter(platform) {
12
+ return adapters.get(platform) ?? null;
13
+ }
14
+ /** List all registered adapters — used for /status and diagnostics. */
15
+ export function listDeliveryAdapters() {
16
+ return [...adapters.values()];
17
+ }
18
+ /** Test-only — reset the registry between tests. */
19
+ export function __resetForTest() {
20
+ adapters.clear();
21
+ }
@@ -244,7 +244,11 @@ export function createLiveStream(chatId, agentName) {
244
244
  * config default), then dispatches to the source-specific renderer.
245
245
  *
246
246
  * Errors are logged but never thrown — delivery must not break the sub-agent
247
- * lifecycle. A failed Telegram send falls through silently.
247
+ * lifecycle. A failed send falls through silently.
248
+ *
249
+ * v4.14 — routes by `info.platform`:
250
+ * - "telegram" (default) → existing grammy pipeline (unchanged)
251
+ * - "slack" / "discord" / "whatsapp" → delivery-registry lookup
248
252
  */
249
253
  export async function deliverSubAgentResult(info, result, opts = {}) {
250
254
  // Implicit spawns: the Task-tool bridge in the main stream has already
@@ -254,17 +258,28 @@ export async function deliverSubAgentResult(info, result, opts = {}) {
254
258
  const effective = opts.visibility ?? getVisibility();
255
259
  if (effective === "silent")
256
260
  return;
257
- // "live" mode is handled inline by runSubAgent via LiveStream. If we
258
- // get here with "live" visibility it means the live-stream path wasn't
259
- // applicable (wrong source, missing editMessageText, etc.) — fall
260
- // through to the normal banner+final behavior below.
261
+ if (!info.parentChatId) {
262
+ console.warn(`[subagent-delivery] missing parentChatId for ${info.name} (source=${info.source})`);
263
+ return;
264
+ }
265
+ // v4.14 — Platform routing. Telegram is the default path (unchanged).
266
+ const platform = info.platform ?? "telegram";
267
+ if (platform !== "telegram") {
268
+ await deliverViaRegistry(platform, info, result);
269
+ return;
270
+ }
271
+ // ── Telegram path (v4.12.x behavior, unchanged) ──────────────────
261
272
  const api = getBotApi();
262
273
  if (!api) {
263
274
  console.warn(`[subagent-delivery] no bot api available for ${info.name}`);
264
275
  return;
265
276
  }
266
- if (!info.parentChatId) {
267
- console.warn(`[subagent-delivery] missing parentChatId for ${info.name} (source=${info.source})`);
277
+ // Telegram's chatId is always a number at runtime; defensive cast.
278
+ const tgChatId = typeof info.parentChatId === "number"
279
+ ? info.parentChatId
280
+ : Number(info.parentChatId);
281
+ if (!Number.isFinite(tgChatId)) {
282
+ console.warn(`[subagent-delivery] invalid telegram chatId for ${info.name}`);
268
283
  return;
269
284
  }
270
285
  const banner = buildBanner(info, result);
@@ -272,32 +287,103 @@ export async function deliverSubAgentResult(info, result, opts = {}) {
272
287
  try {
273
288
  // Case 1: very long output → file upload with a short banner
274
289
  if (body.length > FILE_UPLOAD_THRESHOLD) {
275
- await sendWithMarkdownFallback(api, info.parentChatId, banner);
290
+ await sendWithMarkdownFallback(api, tgChatId, banner);
276
291
  try {
277
292
  const { InputFile } = await import("grammy");
278
293
  const buf = Buffer.from(body, "utf-8");
279
- await api.sendDocument(info.parentChatId, new InputFile(buf, `${info.name}.md`));
294
+ await api.sendDocument(tgChatId, new InputFile(buf, `${info.name}.md`));
280
295
  }
281
296
  catch (err) {
282
297
  console.error(`[subagent-delivery] file upload failed:`, err);
283
- await api.sendMessage(info.parentChatId, body.slice(0, MAX_TG_CHUNK));
298
+ await api.sendMessage(tgChatId, body.slice(0, MAX_TG_CHUNK));
284
299
  }
285
300
  return;
286
301
  }
287
302
  // Case 2: fits in a single message → banner + body joined
288
303
  if (body.length + banner.length + 2 <= MAX_TG_CHUNK) {
289
- await sendWithMarkdownFallback(api, info.parentChatId, `${banner}\n\n${body}`);
304
+ await sendWithMarkdownFallback(api, tgChatId, `${banner}\n\n${body}`);
290
305
  return;
291
306
  }
292
307
  // Case 3: medium output → banner as its own message, body chunked
293
- await sendWithMarkdownFallback(api, info.parentChatId, banner);
308
+ await sendWithMarkdownFallback(api, tgChatId, banner);
294
309
  for (let i = 0; i < body.length; i += MAX_TG_CHUNK) {
295
310
  // Body chunks are always sent as plain text — markdown across
296
311
  // arbitrary chunk boundaries would be inconsistent anyway.
297
- await api.sendMessage(info.parentChatId, body.slice(i, i + MAX_TG_CHUNK));
312
+ await api.sendMessage(tgChatId, body.slice(i, i + MAX_TG_CHUNK));
298
313
  }
299
314
  }
300
315
  catch (err) {
301
316
  console.error(`[subagent-delivery] send failed for ${info.name}:`, err);
302
317
  }
303
318
  }
319
+ /**
320
+ * v4.14 — Delivery path for non-Telegram platforms. Uses the adapter
321
+ * registered in delivery-registry (populated by each platform module
322
+ * at startup). Simpler than the Telegram path: no Markdown parsing,
323
+ * no live-stream mode, plain text only, chunked to a conservative
324
+ * 3800-char cap that all three platforms handle.
325
+ */
326
+ async function deliverViaRegistry(platform, info, result) {
327
+ const { getDeliveryAdapter } = await import("./delivery-registry.js");
328
+ const adapter = getDeliveryAdapter(platform);
329
+ if (!adapter) {
330
+ console.warn(`[subagent-delivery] no ${platform} adapter registered for ${info.name} — skipping delivery`);
331
+ return;
332
+ }
333
+ if (info.parentChatId === undefined)
334
+ return;
335
+ // Registry adapters accept string | number chatId directly.
336
+ const chatId = info.parentChatId;
337
+ const banner = buildBannerPlain(info, result);
338
+ const body = result.output?.trim() || `(empty output)`;
339
+ const NON_TG_CHUNK = 3800;
340
+ const FILE_THRESHOLD = 20_000;
341
+ try {
342
+ // Very long output → file upload if supported, else truncated text
343
+ if (body.length > FILE_THRESHOLD) {
344
+ await adapter.sendText(chatId, banner);
345
+ if (adapter.sendDocument) {
346
+ try {
347
+ await adapter.sendDocument(chatId, Buffer.from(body, "utf-8"), `${info.name}.md`);
348
+ return;
349
+ }
350
+ catch (err) {
351
+ console.error(`[subagent-delivery] ${platform} file upload failed:`, err);
352
+ }
353
+ }
354
+ // Fallback: chunked text if no file upload or upload failed
355
+ for (let i = 0; i < body.length; i += NON_TG_CHUNK) {
356
+ await adapter.sendText(chatId, body.slice(i, i + NON_TG_CHUNK));
357
+ }
358
+ return;
359
+ }
360
+ // Fits in one message → combined
361
+ if (body.length + banner.length + 2 <= NON_TG_CHUNK) {
362
+ await adapter.sendText(chatId, `${banner}\n\n${body}`);
363
+ return;
364
+ }
365
+ // Medium — banner first, then chunked body
366
+ await adapter.sendText(chatId, banner);
367
+ for (let i = 0; i < body.length; i += NON_TG_CHUNK) {
368
+ await adapter.sendText(chatId, body.slice(i, i + NON_TG_CHUNK));
369
+ }
370
+ }
371
+ catch (err) {
372
+ console.error(`[subagent-delivery] ${platform} send failed for ${info.name}:`, err);
373
+ }
374
+ }
375
+ /**
376
+ * v4.14 — Plain-text banner variant for non-Telegram platforms.
377
+ * No Markdown (some platforms render it inconsistently), just emoji +
378
+ * clean labels. Matches the info layout of buildBanner.
379
+ */
380
+ function buildBannerPlain(info, result) {
381
+ const truncated = result.status === "completed" &&
382
+ (!result.output || result.output.trim().length === 0);
383
+ const icon = truncated ? "⚠️" : statusIcon(result.status);
384
+ const statusLabel = truncated ? "completed · empty output" : result.status;
385
+ const dur = formatDuration(result.duration);
386
+ const ti = formatTokens(result.tokensUsed.input);
387
+ const to = formatTokens(result.tokensUsed.output);
388
+ return `${icon} ${info.name} — ${statusLabel} · ${dur} · ${ti} in / ${to} out`;
389
+ }
@@ -118,7 +118,7 @@ const PLATFORMS = [
118
118
  id: "slack",
119
119
  name: "Slack",
120
120
  icon: "💼",
121
- description: "Slack workspace integration via Socket Mode (no public URL needed). DMs and @mentions in channels.",
121
+ description: "Slack workspace integration via Socket Mode (no public URL needed). DMs, @mentions in channels, and the `/alvin` slash command for /status, /new, /effort, /help (v4.13.2+).",
122
122
  envVars: [
123
123
  { key: "SLACK_BOT_TOKEN", label: "Bot Token (xoxb-...)", placeholder: "xoxb-...", secret: true },
124
124
  { key: "SLACK_APP_TOKEN", label: "App Token (xapp-...)", placeholder: "xapp-...", secret: true },
@@ -126,13 +126,15 @@ const PLATFORMS = [
126
126
  npmPackages: ["@slack/bolt"],
127
127
  setupUrl: "https://api.slack.com/apps",
128
128
  setupSteps: [
129
- "Create a new App at api.slack.com/apps (From scratch)",
130
- "Enable Socket Mode (Settings Socket Mode Enable)",
131
- "Generate App-Level Token with 'connections:write' scope copy as SLACK_APP_TOKEN",
132
- "Go to OAuth & Permissionsadd Bot Token Scopes: chat:write, channels:history, groups:history, im:history, mpim:history, app_mentions:read, files:write, reactions:write",
133
- "Install App to Workspace → copy Bot User OAuth Token as SLACK_BOT_TOKEN",
134
- "Subscribe to Events: message.im, message.groups, message.channels, app_mention",
135
- "Invite the bot to channels with /invite @botname",
129
+ "Go to https://api.slack.com/apps and click 'Create New App' → 'From an app manifest'. Choose your workspace.",
130
+ "Paste the full manifest JSON below into the JSON tab (replaces the template). This sets scopes, events, Messages Tab, Socket Mode, and the /alvin slash command in one go:\n\n{\n \"display_information\": { \"name\": \"Alvin\" },\n \"features\": {\n \"app_home\": {\n \"home_tab_enabled\": false,\n \"messages_tab_enabled\": true,\n \"messages_tab_read_only_enabled\": false\n },\n \"bot_user\": { \"display_name\": \"Alvin\", \"always_online\": false },\n \"slash_commands\": [\n {\n \"command\": \"/alvin\",\n \"description\": \"Alvin bot commands\",\n \"usage_hint\": \"new | status | effort low|medium|high|max | help\",\n \"should_escape\": false\n }\n ]\n },\n \"oauth_config\": {\n \"scopes\": {\n \"bot\": [\n \"app_mentions:read\", \"mpim:read\", \"chat:write\",\n \"channels:history\", \"groups:history\", \"im:history\", \"mpim:history\",\n \"files:write\", \"reactions:write\", \"files:read\",\n \"commands\"\n ]\n },\n \"pkce_enabled\": true\n },\n \"settings\": {\n \"event_subscriptions\": {\n \"bot_events\": [\n \"app_mention\", \"message.channels\", \"message.groups\",\n \"message.im\", \"message.mpim\"\n ]\n },\n \"interactivity\": { \"is_enabled\": true },\n \"org_deploy_enabled\": false,\n \"socket_mode_enabled\": true,\n \"token_rotation_enabled\": false\n }\n}",
131
+ "Click Create. Slack creates the App with all the correct config pre-wired.",
132
+ "Go to Settings Basic Information App-Level Tokens → 'Generate Token and Scopes'. Name it 'socket', pick scope 'connections:write', click Generate. Copy the xapp-... token into SLACK_APP_TOKEN below.",
133
+ "Go to Settings → Install App → Install to Workspace → Allow. Copy the 'Bot User OAuth Token' (xoxb-...) into SLACK_BOT_TOKEN below.",
134
+ "Save both tokens here (click 'Save') and then click 'Test Connection' — you should see '@alvin on <Workspace>'.",
135
+ "Click 'Restart bot' in Maintenance the Slack adapter connects automatically (look for '💬 Slack connected' in the logs).",
136
+ "In Slack: open the app in the sidebar, click 'Messages' tab, send a DM. Or use the slash command: /alvin status, /alvin new, /alvin effort high, /alvin help.",
137
+ "To use in channels: invite the bot with /invite @alvin and then @mention it (e.g. '@alvin what's the weather in Berlin?').",
136
138
  ],
137
139
  },
138
140
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.13.1",
3
+ "version": "4.14.0",
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,71 @@
1
+ /**
2
+ * v4.14 — delivery-registry module tests.
3
+ *
4
+ * Registers platform adapters (slack/discord/whatsapp) so the sub-agent
5
+ * watcher can route delivery to the right one based on
6
+ * PendingAsyncAgent.platform. Telegram does NOT go through this registry
7
+ * — it continues to use the existing grammy-bot path via attachBotApi.
8
+ */
9
+ import { describe, it, expect, beforeEach, vi } from "vitest";
10
+
11
+ beforeEach(() => vi.resetModules());
12
+
13
+ describe("delivery-registry (v4.14)", () => {
14
+ it("register + get roundtrip", async () => {
15
+ const { registerDeliveryAdapter, getDeliveryAdapter, __resetForTest } =
16
+ await import("../src/services/delivery-registry.js");
17
+ __resetForTest();
18
+
19
+ const fake = {
20
+ platform: "slack" as const,
21
+ sendText: vi.fn(async () => {}),
22
+ };
23
+ registerDeliveryAdapter(fake);
24
+ expect(getDeliveryAdapter("slack")).toBe(fake);
25
+ });
26
+
27
+ it("returns null for unregistered platform", async () => {
28
+ const { getDeliveryAdapter, __resetForTest } = await import(
29
+ "../src/services/delivery-registry.js"
30
+ );
31
+ __resetForTest();
32
+ expect(getDeliveryAdapter("slack")).toBeNull();
33
+ expect(getDeliveryAdapter("discord")).toBeNull();
34
+ expect(getDeliveryAdapter("telegram")).toBeNull();
35
+ });
36
+
37
+ it("re-register replaces the existing adapter (handles platform reload)", async () => {
38
+ const {
39
+ registerDeliveryAdapter,
40
+ getDeliveryAdapter,
41
+ __resetForTest,
42
+ } = await import("../src/services/delivery-registry.js");
43
+ __resetForTest();
44
+
45
+ const first = { platform: "slack" as const, sendText: vi.fn(async () => {}) };
46
+ const second = { platform: "slack" as const, sendText: vi.fn(async () => {}) };
47
+ registerDeliveryAdapter(first);
48
+ registerDeliveryAdapter(second);
49
+ expect(getDeliveryAdapter("slack")).toBe(second);
50
+ });
51
+
52
+ it("adapters are isolated per platform", async () => {
53
+ const {
54
+ registerDeliveryAdapter,
55
+ getDeliveryAdapter,
56
+ __resetForTest,
57
+ } = await import("../src/services/delivery-registry.js");
58
+ __resetForTest();
59
+
60
+ const slack = { platform: "slack" as const, sendText: vi.fn(async () => {}) };
61
+ const discord = {
62
+ platform: "discord" as const,
63
+ sendText: vi.fn(async () => {}),
64
+ };
65
+ registerDeliveryAdapter(slack);
66
+ registerDeliveryAdapter(discord);
67
+ expect(getDeliveryAdapter("slack")).toBe(slack);
68
+ expect(getDeliveryAdapter("discord")).toBe(discord);
69
+ expect(getDeliveryAdapter("whatsapp")).toBeNull();
70
+ });
71
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * v4.13.2 — Slack slash command parser tests.
3
+ *
4
+ * Users on Slack type `/alvin <subcommand> [args...]` which Bolt
5
+ * delivers via app.command('/alvin') with `command.text` containing
6
+ * the part after `/alvin `. We parse it into a platform-agnostic
7
+ * "/subcommand [args]" text that handlePlatformCommand already knows
8
+ * how to route (/new, /status, /effort, /help).
9
+ *
10
+ * Empty text → `/help` (most helpful default).
11
+ * Pass-through for everything else — unknown subcommand falls through
12
+ * to normal LLM prompt handling.
13
+ */
14
+ import { describe, it, expect } from "vitest";
15
+ import { parseSlackSlashCommand } from "../src/platforms/slack-slash-parser.js";
16
+
17
+ describe("parseSlackSlashCommand (v4.13.2)", () => {
18
+ it("empty text maps to /help", () => {
19
+ expect(parseSlackSlashCommand("")).toBe("/help");
20
+ expect(parseSlackSlashCommand(" ")).toBe("/help");
21
+ });
22
+
23
+ it("single-word subcommand becomes /<subcommand>", () => {
24
+ expect(parseSlackSlashCommand("status")).toBe("/status");
25
+ expect(parseSlackSlashCommand("new")).toBe("/new");
26
+ expect(parseSlackSlashCommand("help")).toBe("/help");
27
+ });
28
+
29
+ it("subcommand with args preserves the args", () => {
30
+ expect(parseSlackSlashCommand("effort high")).toBe("/effort high");
31
+ expect(parseSlackSlashCommand("effort low")).toBe("/effort low");
32
+ });
33
+
34
+ it("multi-word args are preserved verbatim", () => {
35
+ expect(parseSlackSlashCommand("ask what is the weather in berlin")).toBe(
36
+ "/ask what is the weather in berlin",
37
+ );
38
+ });
39
+
40
+ it("collapses extra whitespace around subcommand", () => {
41
+ expect(parseSlackSlashCommand(" status ")).toBe("/status");
42
+ expect(parseSlackSlashCommand(" effort max ")).toBe("/effort max");
43
+ });
44
+
45
+ it("lowercases the subcommand for case-insensitive matching", () => {
46
+ expect(parseSlackSlashCommand("Status")).toBe("/status");
47
+ expect(parseSlackSlashCommand("HELP")).toBe("/help");
48
+ });
49
+
50
+ it("does NOT lowercase the args (preserve user intent)", () => {
51
+ expect(parseSlackSlashCommand("ask What is THIS")).toBe(
52
+ "/ask What is THIS",
53
+ );
54
+ });
55
+
56
+ it("handles leading slash defensively — strips duplicate", () => {
57
+ // If a user literally types `/alvin /status`, Slack delivers text="/status"
58
+ expect(parseSlackSlashCommand("/status")).toBe("/status");
59
+ expect(parseSlackSlashCommand("/effort max")).toBe("/effort max");
60
+ });
61
+ });
@@ -0,0 +1,232 @@
1
+ /**
2
+ * v4.14 — subagent-delivery platform routing tests.
3
+ *
4
+ * Covers the new v4.14 behavior: deliveries with `info.platform` other
5
+ * than "telegram" go through the delivery-registry adapter instead of
6
+ * the grammy bot API. Telegram path is unchanged and still uses the
7
+ * injected grammy-compatible API.
8
+ */
9
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
+
11
+ interface CapturedMsg {
12
+ chatId: string | number;
13
+ text: string;
14
+ }
15
+
16
+ beforeEach(() => vi.resetModules());
17
+
18
+ async function loadModules() {
19
+ const delivery = await import("../src/services/subagent-delivery.js");
20
+ const registry = await import("../src/services/delivery-registry.js");
21
+ registry.__resetForTest();
22
+ return { delivery, registry };
23
+ }
24
+
25
+ describe("subagent-delivery platform routing (v4.14)", () => {
26
+ afterEach(async () => {
27
+ const { delivery, registry } = await loadModules();
28
+ delivery.__setBotApiForTest(null);
29
+ registry.__resetForTest();
30
+ });
31
+
32
+ it("info.platform='slack' routes via delivery-registry (NOT grammy api)", async () => {
33
+ const { delivery, registry } = await loadModules();
34
+
35
+ // Register fake Slack adapter
36
+ const sent: CapturedMsg[] = [];
37
+ registry.registerDeliveryAdapter({
38
+ platform: "slack",
39
+ sendText: async (chatId, text) => {
40
+ sent.push({ chatId, text });
41
+ },
42
+ });
43
+
44
+ // Set a grammy api that SHOULD NOT be called
45
+ const grammyCalls: CapturedMsg[] = [];
46
+ delivery.__setBotApiForTest({
47
+ sendMessage: async (chatId: number, text: string) => {
48
+ grammyCalls.push({ chatId, text });
49
+ return { message_id: 1 };
50
+ },
51
+ sendDocument: async () => ({ message_id: 1 }),
52
+ });
53
+
54
+ await delivery.deliverSubAgentResult(
55
+ {
56
+ id: "a1",
57
+ name: "Research task",
58
+ status: "completed",
59
+ startedAt: Date.now() - 5000,
60
+ source: "cron",
61
+ depth: 0,
62
+ parentChatId: "C012SLACKCH",
63
+ platform: "slack",
64
+ },
65
+ {
66
+ id: "a1",
67
+ name: "Research task",
68
+ status: "completed",
69
+ output: "Result body",
70
+ tokensUsed: { input: 100, output: 50 },
71
+ duration: 5000,
72
+ },
73
+ );
74
+
75
+ expect(sent).toHaveLength(1);
76
+ expect(sent[0].chatId).toBe("C012SLACKCH");
77
+ expect(sent[0].text).toContain("Research task");
78
+ expect(sent[0].text).toContain("Result body");
79
+ // grammy must NOT have been touched
80
+ expect(grammyCalls).toHaveLength(0);
81
+ });
82
+
83
+ it("info.platform='telegram' (default) still uses grammy api — behavior unchanged", async () => {
84
+ const { delivery, registry } = await loadModules();
85
+
86
+ // Register Slack adapter that SHOULD NOT be called
87
+ const slackCalls: CapturedMsg[] = [];
88
+ registry.registerDeliveryAdapter({
89
+ platform: "slack",
90
+ sendText: async (chatId, text) => slackCalls.push({ chatId, text }),
91
+ });
92
+
93
+ const grammyCalls: CapturedMsg[] = [];
94
+ delivery.__setBotApiForTest({
95
+ sendMessage: async (chatId: number, text: string) => {
96
+ grammyCalls.push({ chatId, text });
97
+ return { message_id: 1 };
98
+ },
99
+ sendDocument: async () => ({ message_id: 1 }),
100
+ });
101
+
102
+ await delivery.deliverSubAgentResult(
103
+ {
104
+ id: "a2",
105
+ name: "Telegram task",
106
+ status: "completed",
107
+ startedAt: Date.now() - 3000,
108
+ source: "cron",
109
+ depth: 0,
110
+ parentChatId: 8425689727,
111
+ // platform undefined → defaults to telegram
112
+ },
113
+ {
114
+ id: "a2",
115
+ name: "Telegram task",
116
+ status: "completed",
117
+ output: "Telegram body",
118
+ tokensUsed: { input: 10, output: 5 },
119
+ duration: 3000,
120
+ },
121
+ );
122
+
123
+ expect(grammyCalls).toHaveLength(1);
124
+ expect(grammyCalls[0].chatId).toBe(8425689727);
125
+ expect(grammyCalls[0].text).toContain("Telegram body");
126
+ // Slack adapter must NOT have been touched
127
+ expect(slackCalls).toHaveLength(0);
128
+ });
129
+
130
+ it("info.platform='discord' routes to discord adapter", async () => {
131
+ const { delivery, registry } = await loadModules();
132
+
133
+ const discordCalls: CapturedMsg[] = [];
134
+ registry.registerDeliveryAdapter({
135
+ platform: "discord",
136
+ sendText: async (chatId, text) =>
137
+ discordCalls.push({ chatId, text }),
138
+ });
139
+
140
+ await delivery.deliverSubAgentResult(
141
+ {
142
+ id: "a3",
143
+ name: "Discord task",
144
+ status: "completed",
145
+ startedAt: Date.now() - 1000,
146
+ source: "cron",
147
+ depth: 0,
148
+ parentChatId: "1234567890123456",
149
+ platform: "discord",
150
+ },
151
+ {
152
+ id: "a3",
153
+ name: "Discord task",
154
+ status: "completed",
155
+ output: "Discord body",
156
+ tokensUsed: { input: 1, output: 1 },
157
+ duration: 1000,
158
+ },
159
+ );
160
+
161
+ expect(discordCalls).toHaveLength(1);
162
+ expect(discordCalls[0].chatId).toBe("1234567890123456");
163
+ });
164
+
165
+ it("non-telegram platform with NO registered adapter skips delivery (no crash)", async () => {
166
+ const { delivery } = await loadModules();
167
+
168
+ await expect(
169
+ delivery.deliverSubAgentResult(
170
+ {
171
+ id: "a4",
172
+ name: "Orphan",
173
+ status: "completed",
174
+ startedAt: Date.now(),
175
+ source: "cron",
176
+ depth: 0,
177
+ parentChatId: "C999",
178
+ platform: "slack",
179
+ },
180
+ {
181
+ id: "a4",
182
+ name: "Orphan",
183
+ status: "completed",
184
+ output: "x",
185
+ tokensUsed: { input: 1, output: 1 },
186
+ duration: 100,
187
+ },
188
+ ),
189
+ ).resolves.not.toThrow();
190
+ });
191
+
192
+ it("long output triggers chunking on non-Telegram adapter", async () => {
193
+ const { delivery, registry } = await loadModules();
194
+
195
+ const sent: string[] = [];
196
+ registry.registerDeliveryAdapter({
197
+ platform: "slack",
198
+ sendText: async (_chatId, text) => {
199
+ sent.push(text);
200
+ },
201
+ });
202
+
203
+ // Build ~8000 chars of output (forces chunking at 3800)
204
+ const longBody = "x".repeat(8000);
205
+
206
+ await delivery.deliverSubAgentResult(
207
+ {
208
+ id: "a5",
209
+ name: "Long task",
210
+ status: "completed",
211
+ startedAt: Date.now(),
212
+ source: "cron",
213
+ depth: 0,
214
+ parentChatId: "C1",
215
+ platform: "slack",
216
+ },
217
+ {
218
+ id: "a5",
219
+ name: "Long task",
220
+ status: "completed",
221
+ output: longBody,
222
+ tokensUsed: { input: 1, output: 1 },
223
+ duration: 100,
224
+ },
225
+ );
226
+
227
+ // Expect: 1 banner + multiple body chunks
228
+ expect(sent.length).toBeGreaterThan(1);
229
+ const bodyBytes = sent.slice(1).join("").length;
230
+ expect(bodyBytes).toBe(longBody.length);
231
+ });
232
+ });