alvin-bot 5.1.8 → 5.2.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,37 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [5.2.0] — 2026-05-17
6
+
7
+ ### Stop now actually means stop — instantly
8
+
9
+ You could always type `/cancel`, but it rarely felt like anything
10
+ happened: the bot kept "thinking" for a while and the answer often
11
+ still arrived. That's fixed. When you stop a task, the bot now bails
12
+ out immediately instead of quietly trying its backup brains one after
13
+ another in the background. Press stop — it stops.
14
+
15
+ ### A one-tap ⛔ Stop button on every running task
16
+
17
+ No more remembering or typing a command mid-thought. While Alvin is
18
+ working you'll see a ⛔ Stop button right there on the status message.
19
+ One tap, the task ends, the button flips to "⛔ Gestoppt". Mistyped
20
+ your request or changed your mind? It's one thumb away — and it works
21
+ the same in Telegram, Slack, Discord and WhatsApp.
22
+
23
+ ### New `/stopall` — pull the plug on everything
24
+
25
+ `/cancel` (and the button) stop the task you're watching but let
26
+ already-running background helpers finish and report back later. When
27
+ you want a hard reset — *"forget all of it"* — use the new `/stopall`:
28
+ it stops the current task, terminates the background sub-agents it
29
+ spawned, and clears anything queued behind it. Nothing comes back to
30
+ surprise you afterwards.
31
+
32
+ Under the hood this was a careful, well-tested hardening of the whole
33
+ stop path — verified end-to-end on a clean second machine before
34
+ shipping. No setup or config needed; it just works.
35
+
5
36
  ## [5.1.8] — 2026-05-17
6
37
 
7
38
  ### Interrupted jobs auto-resume after a controlled restart
@@ -2,7 +2,7 @@ import { InlineKeyboard, InputFile } from "grammy";
2
2
  import fs from "fs";
3
3
  import path, { resolve } from "path";
4
4
  import os from "os";
5
- import { getSession, resetSession, markSessionDirty, getTelegramWorkspace, setTelegramWorkspace } from "../services/session.js";
5
+ import { getSession, buildSessionKey, resetSession, markSessionDirty, getTelegramWorkspace, setTelegramWorkspace } from "../services/session.js";
6
6
  import { listWorkspaces, getWorkspace } from "../services/workspaces.js";
7
7
  import { getRegistry } from "../engine.js";
8
8
  import { reloadSoul } from "../services/personality.js";
@@ -32,6 +32,8 @@ import { getReleaseHighlights } from "../services/release-highlights.js";
32
32
  import { runCleanup, getCleanupPolicy } from "../services/disk-cleanup.js";
33
33
  import { getHealthStatus, isFailedOver } from "../services/heartbeat.js";
34
34
  import { t, LOCALE_NAMES, LOCALE_FLAGS } from "../i18n.js";
35
+ import { requestStop, interruptQuery } from "../services/stop-controller.js";
36
+ import { killSessionDetachedAgents, cancelPendingForSession } from "../services/async-agent-watcher.js";
35
37
  // Kick off auto-update loop on module load if the persistent flag is set.
36
38
  // Doing this as a module side-effect avoids touching the bot entry point.
37
39
  if (getAutoUpdate()) {
@@ -1896,18 +1898,66 @@ export function registerCommands(bot) {
1896
1898
  }
1897
1899
  await ctx.answerCallbackQuery();
1898
1900
  });
1901
+ /** Build StopDeps for a session — wires the three stop side-effects. */
1902
+ function buildStopDeps(session) {
1903
+ return {
1904
+ interruptQuery: () => interruptQuery(session),
1905
+ killDetachedAgents: () => killSessionDetachedAgents(session),
1906
+ clearPendingForSession: (k) => cancelPendingForSession(k),
1907
+ };
1908
+ }
1899
1909
  bot.command("cancel", async (ctx) => {
1900
1910
  const userId = ctx.from.id;
1901
- const session = getSession(userId);
1911
+ // Use buildSessionKey so the resolved session is always the SAME object
1912
+ // that message.ts created (and that registered pending agents under this key).
1913
+ // In per-user mode this equals String(userId); in per-channel modes it
1914
+ // incorporates chatId — matching the key used for the ⛔ button payload.
1915
+ const sessionKey = buildSessionKey("telegram", ctx.chat.id, userId);
1916
+ const session = getSession(sessionKey);
1902
1917
  const lang = session.language;
1903
- if (session.isProcessing && session.abortController) {
1904
- session.abortController.abort();
1918
+ if (session.isProcessing) {
1919
+ requestStop(session, "soft", buildStopDeps(session));
1905
1920
  await ctx.reply(t("bot.cancel.cancelling", lang));
1906
1921
  }
1907
1922
  else {
1908
1923
  await ctx.reply(t("bot.cancel.noRunning", lang));
1909
1924
  }
1910
1925
  });
1926
+ // /stopall — hard stop: interrupt query + kill detached agents + clear queue + cancel pending.
1927
+ bot.command("stopall", async (ctx) => {
1928
+ const userId = ctx.from.id;
1929
+ // Same canonical-key resolution as /cancel above — must match message.ts.
1930
+ const sessionKey = buildSessionKey("telegram", ctx.chat.id, userId);
1931
+ const session = getSession(sessionKey);
1932
+ const lang = session.language;
1933
+ if (session.isProcessing) {
1934
+ requestStop(session, "hard", buildStopDeps(session));
1935
+ await ctx.reply(t("bot.cancel.stoppedAll", lang));
1936
+ }
1937
+ else {
1938
+ await ctx.reply(t("bot.cancel.noRunning", lang));
1939
+ }
1940
+ });
1941
+ // stop: inline keyboard callback — soft stop triggered from the ⛔ button
1942
+ // sent alongside the processing indicator in the message handler.
1943
+ // Payload format: "stop:<sessionKey>" where sessionKey = String(userId) in
1944
+ // default per-user mode (mirrors buildSessionKey used in message.ts).
1945
+ bot.callbackQuery(/^stop:(.+)$/, async (ctx) => {
1946
+ const sessionKey = ctx.match[1];
1947
+ const session = getSession(sessionKey);
1948
+ const lang = session.language;
1949
+ if (session.isProcessing) {
1950
+ requestStop(session, "soft", buildStopDeps(session));
1951
+ }
1952
+ try {
1953
+ await ctx.answerCallbackQuery({ text: t("bot.cancel.stoppedToast", lang) });
1954
+ }
1955
+ catch { /* harmless grammy race */ }
1956
+ try {
1957
+ await ctx.editMessageReplyMarkup({});
1958
+ }
1959
+ catch { /* harmless grammy race — message may already be gone */ }
1960
+ });
1911
1961
  // /restart — trigger a PM2-managed restart by exiting the process.
1912
1962
  // The PM2 supervisor picks up the exit and respawns with --update-env.
1913
1963
  bot.command("restart", async (ctx) => {
@@ -1,4 +1,4 @@
1
- import { InputFile } from "grammy";
1
+ import { InputFile, InlineKeyboard } from "grammy";
2
2
  import fs from "fs";
3
3
  import { getSession, addToHistory, trackProviderUsage, buildSessionKey, getTelegramWorkspace, markSessionDirty } from "../services/session.js";
4
4
  import { resolveWorkspaceOrDefault, getWorkspace } from "../services/workspaces.js";
@@ -229,6 +229,18 @@ export async function handleMessage(ctx) {
229
229
  // for the rest of this function (it's mutated from the bypass path in
230
230
  // another handler invocation, so the type stays `boolean | undefined`).
231
231
  delete session._bypassAbortFired;
232
+ // v5.1 — Send a lightweight control message with an inline ⛔ Stop button.
233
+ // Payload "stop:<sessionKey>" matches the callbackQuery handler in commands.ts.
234
+ // Cleaned up (deleted) in the finally block regardless of how the turn ends.
235
+ // One message only — do NOT send if the chat can't receive replies.
236
+ let stopMsgId = null;
237
+ try {
238
+ const stopMsg = await ctx.reply("⏳", {
239
+ reply_markup: new InlineKeyboard().text("⛔ Stop", `stop:${sessionKey}`),
240
+ });
241
+ stopMsgId = stopMsg.message_id;
242
+ }
243
+ catch { /* harmless — typing indicator remains, button is best-effort */ }
232
244
  const streamer = new TelegramStreamer(ctx.chat.id, ctx.api, ctx.message?.message_id);
233
245
  let finalText = "";
234
246
  let timedOut = false;
@@ -444,6 +456,8 @@ export async function handleMessage(ctx) {
444
456
  userId,
445
457
  sessionKey,
446
458
  } : undefined,
459
+ // v5.1 — Store the SDK query handle so requestStop() can interrupt it.
460
+ onQueryHandle: (q) => { session._qHandle = q; },
447
461
  };
448
462
  // Stream response from provider (with fallback)
449
463
  let lastBroadcastLen = 0;
@@ -459,6 +473,11 @@ export async function handleMessage(ctx) {
459
473
  // This is the second half of the empty-stream-loop fix.
460
474
  let sessionResetInStream = false;
461
475
  for await (const chunk of registry.queryWithFallback(queryOpts, workspace.provider)) {
476
+ // v5.1 — Bail as soon as requestStop() marks the session. The registry's
477
+ // outer loop already guards against new provider attempts; this guard
478
+ // drains the current generator's remaining chunks immediately.
479
+ if (session._stopRequested)
480
+ break;
462
481
  // v4.12.1 — Update pending-sync-task state FIRST so the timer's
463
482
  // next reset picks up the new state. This ordering is load-bearing:
464
483
  // reversing it means the timer rearms with stale state. A sync
@@ -625,6 +644,14 @@ export async function handleMessage(ctx) {
625
644
  break;
626
645
  }
627
646
  }
647
+ // v5.1 stop: user stopped this query — do NOT finalize partial output
648
+ // as a successful answer, no 👍, no history commit. The stop trigger
649
+ // (/cancel | /stopall | ⛔ button) already acknowledged to the user.
650
+ // The `finally` still runs (clears isProcessing/_qHandle/_stopRequested
651
+ // + typing indicator).
652
+ if (session._stopRequested) {
653
+ return;
654
+ }
628
655
  if (bypassAborted) {
629
656
  // v4.12.3 — Bypass path took over; don't finalize, don't react 👍.
630
657
  // Just clean up and return. The finally block still fires.
@@ -699,6 +726,17 @@ export async function handleMessage(ctx) {
699
726
  clearInterval(typingInterval);
700
727
  session.isProcessing = false;
701
728
  session.abortController = null;
729
+ // v5.1 — Clear stop-hardening state so the next turn starts clean.
730
+ session._qHandle = null;
731
+ session._stopRequested = null;
732
+ // v5.1 — Remove the ⛔ Stop control message (sent at processing start).
733
+ // Best-effort: if it was already deleted or the bot lacks permission, ignore.
734
+ if (stopMsgId !== null) {
735
+ try {
736
+ await ctx.api.deleteMessage(ctx.chat.id, stopMsgId);
737
+ }
738
+ catch { /* harmless grammy race */ }
739
+ }
702
740
  // Check for queued messages — they'll be prepended to the next real message
703
741
  // Queue stays in session and gets consumed on next handleMessage call
704
742
  }
package/dist/i18n.js CHANGED
@@ -344,6 +344,18 @@ const strings = {
344
344
  es: "No hay ninguna solicitud en curso.",
345
345
  fr: "Aucune requête en cours.",
346
346
  },
347
+ "bot.cancel.stoppedAll": {
348
+ en: "⛔ Stopped everything — running task, sub-agents and queue cancelled.",
349
+ de: "⛔ Alles gestoppt — laufende Aufgabe, Sub-Agents und Warteschlange abgebrochen.",
350
+ es: "⛔ Todo detenido — tarea en curso, sub-agentes y cola cancelados.",
351
+ fr: "⛔ Tout arrêté — tâche en cours, sous-agents et file annulés.",
352
+ },
353
+ "bot.cancel.stoppedToast": {
354
+ en: "⛔ Stopped",
355
+ de: "⛔ Gestoppt",
356
+ es: "⛔ Detenido",
357
+ fr: "⛔ Arrêté",
358
+ },
347
359
  // /model
348
360
  "bot.model.chooseHeader": {
349
361
  en: "🤖 *Choose model:*",
@@ -206,10 +206,24 @@ export class ClaudeSDKProvider {
206
206
  ...(fallbackModel ? { fallbackModel } : {}),
207
207
  },
208
208
  });
209
+ // v5.1 — Expose the SDK query handle to the caller so requestStop()
210
+ // can interrupt it via q.interrupt() + transport.process.kill().
211
+ try {
212
+ options.onQueryHandle?.(q);
213
+ }
214
+ catch { /* non-fatal */ }
209
215
  let accumulatedText = "";
210
216
  let capturedSessionId = options.sessionId || "";
211
217
  let localToolUseCount = 0;
212
218
  for await (const message of q) {
219
+ // v5.1 — Bail immediately if the caller set abortSignal while iterating.
220
+ if (options.abortSignal?.aborted) {
221
+ try {
222
+ q.return?.();
223
+ }
224
+ catch { /* ignore */ }
225
+ break;
226
+ }
213
227
  // System init — capture session ID
214
228
  if (message.type === "system" && "subtype" in message && message.subtype === "init") {
215
229
  const sysMsg = message;
@@ -123,6 +123,12 @@ export class ProviderRegistry {
123
123
  }
124
124
  const errors = [];
125
125
  for (const key of chain) {
126
+ // v5.1 PRIMARY FIX — bail before attempting any provider if the caller
127
+ // has already aborted. Without this guard, the outer fallback loop kept
128
+ // trying successive providers (e.g. Ollama) even after abort(), leaving
129
+ // the typing indicator on and wasting resources.
130
+ if (options.abortSignal?.aborted)
131
+ break;
126
132
  const provider = this.providers.get(key);
127
133
  if (!provider)
128
134
  continue;
@@ -234,6 +240,12 @@ export class ProviderRegistry {
234
240
  // If we got here without done or error, something's off
235
241
  return;
236
242
  }
243
+ // v5.1 stop: clean user cancellation — the outer loop broke on an
244
+ // aborted signal with nothing attempted. Don't surface a misleading
245
+ // "no provider" error; end the generator quietly (the consumer's
246
+ // _stopRequested handling owns the user-facing acknowledgement).
247
+ if (options.abortSignal?.aborted)
248
+ return;
237
249
  // All providers failed — show specific errors
238
250
  const errorDetail = errors.map(e => ` ${e.key}: ${e.error}`).join("\n");
239
251
  yield {
@@ -99,6 +99,9 @@ export function dispatchDetachedAgent(input) {
99
99
  // Detach from parent Node's event loop so parent exit doesn't wait.
100
100
  child.unref();
101
101
  // Register with watcher so it polls the output file and delivers.
102
+ // child.pid is captured here (before unref) so killSessionDetachedAgents
103
+ // can send SIGTERM to the process. child.pid may be undefined if the OS
104
+ // failed to assign a PID (extremely rare); registerPendingAgent tolerates it.
102
105
  registerPendingAgent({
103
106
  agentId,
104
107
  outputFile,
@@ -109,6 +112,7 @@ export function dispatchDetachedAgent(input) {
109
112
  toolUseId: null,
110
113
  sessionKey: input.sessionKey,
111
114
  platform: input.platform,
115
+ pid: child.pid,
112
116
  });
113
117
  // Increment the session's pendingBackgroundCount so the main handler
114
118
  // knows a background task is in flight (same signal path as SDK's
@@ -131,6 +131,7 @@ export function registerPendingAgent(input) {
131
131
  toolUseId: input.toolUseId,
132
132
  sessionKey: input.sessionKey,
133
133
  platform: input.platform,
134
+ pid: input.pid,
134
135
  };
135
136
  enforcePendingCap();
136
137
  pending.set(input.agentId, entry);
@@ -281,6 +282,66 @@ async function deliverAsFailure(entry, status, error) {
281
282
  decrementPendingCount(entry.sessionKey);
282
283
  }
283
284
  // ── Test helpers ──────────────────────────────────────────────────
285
+ /**
286
+ * v5.1 — Kill detached sub-agent processes scoped to a session.
287
+ *
288
+ * Reads the canonical session key from `session.sessionKey` (stamped by
289
+ * getSession() / loadPersistedSessions() — always a real string, never null
290
+ * after the v5.1.x fix). Iterates watcher entries whose `sessionKey` matches
291
+ * and calls `killFn(entry.pid)` for entries that have a pid.
292
+ *
293
+ * The function accepts an injectable `killFn` for testability. The default
294
+ * implementation sends SIGTERM; callers in tests pass a spy.
295
+ *
296
+ * Never throws — all per-entry errors are swallowed.
297
+ */
298
+ export function killSessionDetachedAgents(session, killFn = (p) => {
299
+ try {
300
+ process.kill(p, "SIGTERM");
301
+ }
302
+ catch { /* already gone */ }
303
+ }) {
304
+ // Use session.sessionKey — the real canonical key stamped by getSession().
305
+ // Before v5.1.x this field did not exist on UserSession, causing a silent
306
+ // no-op; the fix in session.ts guarantees it is always a non-empty string.
307
+ const key = session.sessionKey ?? null;
308
+ if (key === null)
309
+ return;
310
+ for (const entry of pending.values()) {
311
+ if (entry.sessionKey !== key)
312
+ continue;
313
+ if (typeof entry.pid === "number") {
314
+ try {
315
+ killFn(entry.pid);
316
+ }
317
+ catch { /* best-effort */ }
318
+ }
319
+ }
320
+ }
321
+ /**
322
+ * v5.1 — Mark/remove all pending watcher entries for a session so the
323
+ * watcher does NOT deliver them after a hard stop.
324
+ *
325
+ * Mirrors the pattern used in pollOnce(): remove from the in-memory map
326
+ * and persist immediately. This is a remove-not-flag approach because
327
+ * there is no `cancelled` flag on PendingAsyncAgent and adding one would
328
+ * require touching all poll-cycle delivery paths.
329
+ *
330
+ * Scoped strictly to entries whose `sessionKey` matches — never global,
331
+ * never name-based. Never throws.
332
+ */
333
+ export function cancelPendingForSession(sessionKey) {
334
+ let changed = false;
335
+ for (const [id, entry] of pending.entries()) {
336
+ if (entry.sessionKey === sessionKey) {
337
+ pending.delete(id);
338
+ changed = true;
339
+ }
340
+ }
341
+ if (changed) {
342
+ saveToDisk();
343
+ }
344
+ }
284
345
  /** Test-only: drop in-memory state. Doesn't touch disk. */
285
346
  export function __resetForTest() {
286
347
  pending.clear();
@@ -171,11 +171,17 @@ export function loadPersistedSessions() {
171
171
  // Build a UserSession from the persisted shape, filling defaults for any
172
172
  // fields added in newer schema versions.
173
173
  const restored = {
174
+ // v5.1.x — Canonical registry key stamped so stop-controller and watcher
175
+ // helpers can read session.sessionKey regardless of how the session was
176
+ // created (direct access or rehydrated from disk).
177
+ sessionKey: key,
174
178
  sessionId: persisted.sessionId ?? null,
175
179
  workingDir: persisted.workingDir ?? process.cwd(),
176
180
  workspaceName: persisted.workspaceName ?? null,
177
181
  isProcessing: false,
178
182
  abortController: null,
183
+ _stopRequested: null,
184
+ _qHandle: null,
179
185
  lastActivity: persisted.lastActivity ?? Date.now(),
180
186
  startedAt: persisted.startedAt ?? Date.now(),
181
187
  totalCost: persisted.totalCost ?? 0,
@@ -74,11 +74,14 @@ export function getSession(key) {
74
74
  let session = sessions.get(k);
75
75
  if (!session) {
76
76
  session = {
77
+ sessionKey: k,
77
78
  sessionId: null,
78
79
  workingDir: config.defaultWorkingDir,
79
80
  workspaceName: null,
80
81
  isProcessing: false,
81
82
  abortController: null,
83
+ _stopRequested: null,
84
+ _qHandle: null,
82
85
  lastActivity: Date.now(),
83
86
  startedAt: Date.now(),
84
87
  totalCost: 0,
@@ -106,6 +109,11 @@ export function getSession(key) {
106
109
  // Touch lastActivity on every access so the cleanup interval
107
110
  // never kills a session that's still being interacted with.
108
111
  session.lastActivity = Date.now();
112
+ // Idempotent: stamp sessionKey on existing sessions that predate this field
113
+ // (e.g. sessions loaded from persistence before the field existed).
114
+ if (!session.sessionKey) {
115
+ session.sessionKey = k;
116
+ }
109
117
  }
110
118
  return session;
111
119
  }
@@ -0,0 +1,52 @@
1
+ /** Teeth: interrupt the running SDK query. SDK hides the pid, so we use
2
+ * its own interrupt() control-message + transport process kill fallback.
3
+ * Both best-effort; never throws. */
4
+ export function interruptQuery(session) {
5
+ try {
6
+ session._qHandle?.interrupt?.();
7
+ }
8
+ catch { /* already finished */ }
9
+ try {
10
+ session._qHandle?.transport?.process?.kill?.();
11
+ }
12
+ catch { /* n/a */ }
13
+ }
14
+ /**
15
+ * Single shared stop primitive. Synchronous, idempotent, NEVER throws into
16
+ * the caller. soft = abort + interrupt running query + reset JS state.
17
+ * hard = soft + kill detached sub-agents + clear queue + cancel pending.
18
+ *
19
+ * Post Task-1: the SDK hides the subprocess PID, so the teeth are
20
+ * q.interrupt() (via deps.interruptQuery), NOT a process-group SIGKILL.
21
+ */
22
+ export function requestStop(session, tier, deps) {
23
+ // 1. Signal abort (best-effort; already fast per Task-1 evidence).
24
+ try {
25
+ session.abortController?.abort();
26
+ }
27
+ catch { /* idempotent */ }
28
+ // 2. Teeth — interrupt the running SDK query.
29
+ try {
30
+ deps.interruptQuery();
31
+ }
32
+ catch { /* already finished / never throw */ }
33
+ // 3. Mark stop so consumer loop + provider + registry fallback bail.
34
+ session._stopRequested = tier;
35
+ // 4. Hard tier extras.
36
+ if (tier === "hard") {
37
+ try {
38
+ deps.killDetachedAgents();
39
+ }
40
+ catch { /* best-effort */ }
41
+ if (Array.isArray(session.messageQueue))
42
+ session.messageQueue.length = 0;
43
+ try {
44
+ deps.clearPendingForSession(session.sessionKey);
45
+ }
46
+ catch { /* best-effort */ }
47
+ }
48
+ // 5. Deterministic JS-state teardown.
49
+ session.isProcessing = false;
50
+ session.abortController = null;
51
+ session._qHandle = null;
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "5.1.8",
3
+ "version": "5.2.0",
4
4
  "description": "Alvin Bot — Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",