alvin-bot 5.1.8 → 5.3.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,70 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [5.3.0] — 2026-05-18
6
+
7
+ ### Talk to Alvin while it's working — no more interrupting yourself
8
+
9
+ Until now, a message you sent while Alvin was busy had only two
10
+ outcomes: it waited in line until the current task finished, or it
11
+ threw the task away and started over. Now there's a third, much
12
+ better one. Drop a quick *"btw, also check the other folder"* or
13
+ *"actually, use the live data not the test data"* mid-task and Alvin
14
+ takes it in **while keeping everything it has already done** — exactly
15
+ like leaning over to a colleague who's already working and adding one
16
+ more thing. No restart, no lost progress.
17
+
18
+ ### A quiet 📨 so you know it landed
19
+
20
+ When your mid-task note is picked up, Alvin reacts with a 📨 on your
21
+ message and, the first time per task, adds one short line so you know
22
+ it was taken on board without derailing what it's doing. After that
23
+ it's just the reaction — no chatter, no spam while it works.
24
+
25
+ ### Stop still always wins
26
+
27
+ Steering never overrides stopping. The ⛔ Stop button, `/cancel` and
28
+ `/stopall` behave exactly as before and always take precedence — a
29
+ mid-task note can never bring back a task you've stopped. If you'd
30
+ rather keep the old "queue it until done" behaviour, you can switch
31
+ steering off with a single setting; it's on by default for everyone.
32
+
33
+ Live steering works with the Claude engine; with other AI providers
34
+ your message safely falls back to the previous queue behaviour, so
35
+ nothing breaks. As always, this shipped only after a full end-to-end
36
+ verification and a stress test on a clean separate machine.
37
+
38
+ ## [5.2.0] — 2026-05-17
39
+
40
+ ### Stop now actually means stop — instantly
41
+
42
+ You could always type `/cancel`, but it rarely felt like anything
43
+ happened: the bot kept "thinking" for a while and the answer often
44
+ still arrived. That's fixed. When you stop a task, the bot now bails
45
+ out immediately instead of quietly trying its backup brains one after
46
+ another in the background. Press stop — it stops.
47
+
48
+ ### A one-tap ⛔ Stop button on every running task
49
+
50
+ No more remembering or typing a command mid-thought. While Alvin is
51
+ working you'll see a ⛔ Stop button right there on the status message.
52
+ One tap, the task ends, the button flips to "⛔ Gestoppt". Mistyped
53
+ your request or changed your mind? It's one thumb away — and it works
54
+ the same in Telegram, Slack, Discord and WhatsApp.
55
+
56
+ ### New `/stopall` — pull the plug on everything
57
+
58
+ `/cancel` (and the button) stop the task you're watching but let
59
+ already-running background helpers finish and report back later. When
60
+ you want a hard reset — *"forget all of it"* — use the new `/stopall`:
61
+ it stops the current task, terminates the background sub-agents it
62
+ spawned, and clears anything queued behind it. Nothing comes back to
63
+ surprise you afterwards.
64
+
65
+ Under the hood this was a careful, well-tested hardening of the whole
66
+ stop path — verified end-to-end on a clean second machine before
67
+ shipping. No setup or config needed; it just works.
68
+
5
69
  ## [5.1.8] — 2026-05-17
6
70
 
7
71
  ### Interrupted jobs auto-resume after a controlled restart
package/dist/config.js CHANGED
@@ -83,3 +83,11 @@ export const config = {
83
83
  // Exec Security
84
84
  execSecurity: (process.env.EXEC_SECURITY || "full"),
85
85
  };
86
+ /**
87
+ * Feature flag: btw live-steering. Default ON — only "false" or "0" disables.
88
+ * Re-reads process.env each call so tests can override without module reloads.
89
+ */
90
+ export function isSteeringEnabled() {
91
+ const v = process.env.STEERING_ENABLED;
92
+ return v !== "false" && v !== "0";
93
+ }
@@ -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";
@@ -19,6 +19,8 @@ import { isHarmlessTelegramError } from "../util/telegram-error-filter.js";
19
19
  import { handleToolResultChunk } from "./async-agent-chunk-handler.js";
20
20
  import { createStuckTimer } from "./stuck-timer.js";
21
21
  import { shouldBypassQueue, shouldBypassSdkResume, waitUntilProcessingFalse, } from "./background-bypass.js";
22
+ import { SteerChannel } from "../services/steer-channel.js";
23
+ import { isSteeringEnabled } from "../config.js";
22
24
  /**
23
25
  * Stuck-only timeout — NO absolute cap.
24
26
  *
@@ -119,6 +121,29 @@ const TOOL_ICONS = {
119
121
  WebFetch: "📡",
120
122
  Task: "🤖",
121
123
  };
124
+ // ── v5.2 live steering — pure routing helper ─────────────────────────────────
125
+ /**
126
+ * Decide how a mid-task message (arriving while `session.isProcessing`) should
127
+ * be handled. Evaluated in the `if (session.isProcessing)` guard before any
128
+ * side-effects, so the caller can branch cleanly.
129
+ *
130
+ * Decision priority:
131
+ * 1. "bypass" — background-agent bypass path (pre-existing Cycle-1 logic)
132
+ * 2. "steer" — push into live SteerChannel (claude-sdk + steering on + channel open)
133
+ * 3. "queue" — normal queue behavior (all other cases)
134
+ *
135
+ * Defensive: if `isProcessing` is false the helper is being called incorrectly;
136
+ * it returns "queue" so the caller falls through to existing behavior.
137
+ */
138
+ export function decideMidTaskRouting(args) {
139
+ if (!args.isProcessing)
140
+ return "queue";
141
+ if (args.shouldBypass)
142
+ return "bypass";
143
+ if (args.providerIsClaudeSdk && args.steeringEnabled && args.hasSteerChannel)
144
+ return "steer";
145
+ return "queue";
146
+ }
122
147
  /** React to a message with an emoji. Silently fails if reactions aren't supported. */
123
148
  async function react(ctx, emoji) {
124
149
  try {
@@ -172,11 +197,22 @@ export async function handleMessage(ctx) {
172
197
  // the new message gets processed immediately. The background task
173
198
  // itself continues in its detached subprocess; the async-agent watcher
174
199
  // delivers the result via subagent-delivery.ts when ready.
175
- if (shouldBypassQueue({
200
+ //
201
+ // v5.2 — decideMidTaskRouting unifies bypass / steer / queue in one place.
202
+ const _midTaskBypass = shouldBypassQueue({
176
203
  isProcessing: session.isProcessing,
177
204
  pendingBackgroundCount: session.pendingBackgroundCount,
178
205
  abortController: session.abortController,
179
- })) {
206
+ });
207
+ const _midTaskProviderIsSdk = getRegistry().getActive().config.type === "claude-sdk";
208
+ const _midTaskRoute = decideMidTaskRouting({
209
+ isProcessing: true,
210
+ providerIsClaudeSdk: _midTaskProviderIsSdk,
211
+ steeringEnabled: isSteeringEnabled(),
212
+ hasSteerChannel: !!session._steerChannel,
213
+ shouldBypass: _midTaskBypass,
214
+ });
215
+ if (_midTaskRoute === "bypass") {
180
216
  console.log(`[v4.12.3 bypass] aborting blocked query for ${sessionKey} — ` +
181
217
  `${session.pendingBackgroundCount} background agent(s) pending`);
182
218
  // Mark the abort as a bypass so the old handler's error branch
@@ -194,6 +230,23 @@ export async function handleMessage(ctx) {
194
230
  await waitUntilProcessingFalse(session, 5000);
195
231
  // Fall through to start a fresh query below.
196
232
  }
233
+ else if (_midTaskRoute === "steer") {
234
+ // v5.2 — btw live steering: push mid-task message into the open
235
+ // SteerChannel so the running claude-sdk query picks it up as a
236
+ // streaming-input user message. No abort, no queue.
237
+ session._steerChannel.push(text);
238
+ await react(ctx, "📨");
239
+ if (!session._steerAckSentThisTurn) {
240
+ try {
241
+ await ctx.reply(t("bot.steer.ack", session.language));
242
+ }
243
+ catch {
244
+ /* harmless grammy race */
245
+ }
246
+ session._steerAckSentThisTurn = true;
247
+ }
248
+ return;
249
+ }
197
250
  else {
198
251
  // Normal queue behavior. v4.12.3 — emit a text reply in addition
199
252
  // to the reaction so the user actually sees that their message
@@ -229,6 +282,18 @@ export async function handleMessage(ctx) {
229
282
  // for the rest of this function (it's mutated from the bypass path in
230
283
  // another handler invocation, so the type stays `boolean | undefined`).
231
284
  delete session._bypassAbortFired;
285
+ // v5.1 — Send a lightweight control message with an inline ⛔ Stop button.
286
+ // Payload "stop:<sessionKey>" matches the callbackQuery handler in commands.ts.
287
+ // Cleaned up (deleted) in the finally block regardless of how the turn ends.
288
+ // One message only — do NOT send if the chat can't receive replies.
289
+ let stopMsgId = null;
290
+ try {
291
+ const stopMsg = await ctx.reply("⏳", {
292
+ reply_markup: new InlineKeyboard().text("⛔ Stop", `stop:${sessionKey}`),
293
+ });
294
+ stopMsgId = stopMsg.message_id;
295
+ }
296
+ catch { /* harmless — typing indicator remains, button is best-effort */ }
232
297
  const streamer = new TelegramStreamer(ctx.chat.id, ctx.api, ctx.message?.message_id);
233
298
  let finalText = "";
234
299
  let timedOut = false;
@@ -444,7 +509,22 @@ export async function handleMessage(ctx) {
444
509
  userId,
445
510
  sessionKey,
446
511
  } : undefined,
512
+ // v5.1 — Store the SDK query handle so requestStop() can interrupt it.
513
+ onQueryHandle: (q) => { session._qHandle = q; },
447
514
  };
515
+ // v5.2 — btw live steering: seed SteerChannel at turn start so mid-task
516
+ // user messages can be pushed in while this query is running. Only for
517
+ // claude-sdk (the only provider that supports streaming-input prompts).
518
+ // The initial bridged prompt is pushed first so the channel sequence is:
519
+ // [bridgedPrompt, <any mid-task messages>, <close on finally>]
520
+ // queryOpts.steerChannel is set so the provider uses the channel as the
521
+ // prompt source. queryOpts.prompt is kept as-is for non-SDK fallback paths
522
+ // (providers that don't support steerChannel ignore it and use prompt).
523
+ if (isSDK && isSteeringEnabled()) {
524
+ session._steerChannel = new SteerChannel();
525
+ session._steerChannel.push(bridgedPrompt);
526
+ queryOpts.steerChannel = session._steerChannel;
527
+ }
448
528
  // Stream response from provider (with fallback)
449
529
  let lastBroadcastLen = 0;
450
530
  // Captured during tool_use chunks; consumed by tool_result chunks so
@@ -459,6 +539,11 @@ export async function handleMessage(ctx) {
459
539
  // This is the second half of the empty-stream-loop fix.
460
540
  let sessionResetInStream = false;
461
541
  for await (const chunk of registry.queryWithFallback(queryOpts, workspace.provider)) {
542
+ // v5.1 — Bail as soon as requestStop() marks the session. The registry's
543
+ // outer loop already guards against new provider attempts; this guard
544
+ // drains the current generator's remaining chunks immediately.
545
+ if (session._stopRequested)
546
+ break;
462
547
  // v4.12.1 — Update pending-sync-task state FIRST so the timer's
463
548
  // next reset picks up the new state. This ordering is load-bearing:
464
549
  // reversing it means the timer rearms with stale state. A sync
@@ -625,6 +710,14 @@ export async function handleMessage(ctx) {
625
710
  break;
626
711
  }
627
712
  }
713
+ // v5.1 stop: user stopped this query — do NOT finalize partial output
714
+ // as a successful answer, no 👍, no history commit. The stop trigger
715
+ // (/cancel | /stopall | ⛔ button) already acknowledged to the user.
716
+ // The `finally` still runs (clears isProcessing/_qHandle/_stopRequested
717
+ // + typing indicator).
718
+ if (session._stopRequested) {
719
+ return;
720
+ }
628
721
  if (bypassAborted) {
629
722
  // v4.12.3 — Bypass path took over; don't finalize, don't react 👍.
630
723
  // Just clean up and return. The finally block still fires.
@@ -699,6 +792,24 @@ export async function handleMessage(ctx) {
699
792
  clearInterval(typingInterval);
700
793
  session.isProcessing = false;
701
794
  session.abortController = null;
795
+ // v5.1 — Clear stop-hardening state so the next turn starts clean.
796
+ session._qHandle = null;
797
+ session._stopRequested = null;
798
+ // v5.2 — Close and clear the SteerChannel; reset per-turn ack flag.
799
+ try {
800
+ session._steerChannel?.close();
801
+ }
802
+ catch { /* ignore */ }
803
+ session._steerChannel = null;
804
+ session._steerAckSentThisTurn = false;
805
+ // v5.1 — Remove the ⛔ Stop control message (sent at processing start).
806
+ // Best-effort: if it was already deleted or the bot lacks permission, ignore.
807
+ if (stopMsgId !== null) {
808
+ try {
809
+ await ctx.api.deleteMessage(ctx.chat.id, stopMsgId);
810
+ }
811
+ catch { /* harmless grammy race */ }
812
+ }
702
813
  // Check for queued messages — they'll be prepended to the next real message
703
814
  // Queue stays in session and gets consumed on next handleMessage call
704
815
  }
package/dist/i18n.js CHANGED
@@ -331,6 +331,13 @@ const strings = {
331
331
  es: "(externo, activo)",
332
332
  fr: "(externe, en cours)",
333
333
  },
334
+ // live steering ack (Task 4 — btw feature)
335
+ "bot.steer.ack": {
336
+ en: "📨 Noted — Alvin will factor that in without restarting.",
337
+ de: "📨 Mitgenommen — Alvin berücksichtigt das, ohne abzubrechen.",
338
+ es: "📨 Anotado — Alvin lo tendrá en cuenta sin reiniciar.",
339
+ fr: "📨 Noté — Alvin en tiendra compte sans redémarrer.",
340
+ },
334
341
  // /cancel
335
342
  "bot.cancel.cancelling": {
336
343
  en: "Cancelling request…",
@@ -344,6 +351,18 @@ const strings = {
344
351
  es: "No hay ninguna solicitud en curso.",
345
352
  fr: "Aucune requête en cours.",
346
353
  },
354
+ "bot.cancel.stoppedAll": {
355
+ en: "⛔ Stopped everything — running task, sub-agents and queue cancelled.",
356
+ de: "⛔ Alles gestoppt — laufende Aufgabe, Sub-Agents und Warteschlange abgebrochen.",
357
+ es: "⛔ Todo detenido — tarea en curso, sub-agentes y cola cancelados.",
358
+ fr: "⛔ Tout arrêté — tâche en cours, sous-agents et file annulés.",
359
+ },
360
+ "bot.cancel.stoppedToast": {
361
+ en: "⛔ Stopped",
362
+ de: "⛔ Gestoppt",
363
+ es: "⛔ Detenido",
364
+ fr: "⛔ Arrêté",
365
+ },
347
366
  // /model
348
367
  "bot.model.chooseHeader": {
349
368
  en: "🤖 *Choose model:*",
@@ -174,7 +174,9 @@ export class ClaudeSDKProvider {
174
174
  const primaryIsHaiku = (modelOverride ?? "").toLowerCase().includes("haiku");
175
175
  const fallbackModel = primaryIsHaiku ? undefined : "haiku";
176
176
  const q = query({
177
- prompt,
177
+ prompt: options.steerChannel
178
+ ? options.steerChannel
179
+ : prompt,
178
180
  options: {
179
181
  cwd: options.workingDir || process.cwd(),
180
182
  abortController: internalAbortController,
@@ -206,10 +208,24 @@ export class ClaudeSDKProvider {
206
208
  ...(fallbackModel ? { fallbackModel } : {}),
207
209
  },
208
210
  });
211
+ // v5.1 — Expose the SDK query handle to the caller so requestStop()
212
+ // can interrupt it via q.interrupt() + transport.process.kill().
213
+ try {
214
+ options.onQueryHandle?.(q);
215
+ }
216
+ catch { /* non-fatal */ }
209
217
  let accumulatedText = "";
210
218
  let capturedSessionId = options.sessionId || "";
211
219
  let localToolUseCount = 0;
212
220
  for await (const message of q) {
221
+ // v5.1 — Bail immediately if the caller set abortSignal while iterating.
222
+ if (options.abortSignal?.aborted) {
223
+ try {
224
+ q.return?.();
225
+ }
226
+ catch { /* ignore */ }
227
+ break;
228
+ }
213
229
  // System init — capture session ID
214
230
  if (message.type === "system" && "subtype" in message && message.subtype === "init") {
215
231
  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,19 @@ 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,
185
+ _steerChannel: null,
186
+ _steerAckSentThisTurn: false,
179
187
  lastActivity: persisted.lastActivity ?? Date.now(),
180
188
  startedAt: persisted.startedAt ?? Date.now(),
181
189
  totalCost: persisted.totalCost ?? 0,
@@ -74,11 +74,16 @@ 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,
85
+ _steerChannel: null,
86
+ _steerAckSentThisTurn: false,
82
87
  lastActivity: Date.now(),
83
88
  startedAt: Date.now(),
84
89
  totalCost: 0,
@@ -106,6 +111,15 @@ export function getSession(key) {
106
111
  // Touch lastActivity on every access so the cleanup interval
107
112
  // never kills a session that's still being interacted with.
108
113
  session.lastActivity = Date.now();
114
+ // Idempotent: stamp sessionKey on existing sessions that predate this field
115
+ // (e.g. sessions loaded from persistence before the field existed).
116
+ if (!session.sessionKey) {
117
+ session.sessionKey = k;
118
+ }
119
+ if (session._steerChannel === undefined)
120
+ session._steerChannel = null;
121
+ if (session._steerAckSentThisTurn === undefined)
122
+ session._steerAckSentThisTurn = false;
109
123
  }
110
124
  return session;
111
125
  }
@@ -0,0 +1,41 @@
1
+ const DEFAULT_CAP = 20;
2
+ export class SteerChannel {
3
+ cap;
4
+ buf = [];
5
+ closed = false;
6
+ resolveNext = null;
7
+ constructor(cap = DEFAULT_CAP) {
8
+ this.cap = cap;
9
+ }
10
+ push(text) {
11
+ if (this.closed)
12
+ return;
13
+ if (this.buf.length >= this.cap) {
14
+ console.warn(`[steer-channel] cap ${this.cap} reached — dropping steer message`);
15
+ return;
16
+ }
17
+ this.buf.push({ type: "user", message: { role: "user", content: text }, parent_tool_use_id: null });
18
+ const r = this.resolveNext;
19
+ this.resolveNext = null;
20
+ r?.();
21
+ }
22
+ close() {
23
+ if (this.closed)
24
+ return;
25
+ this.closed = true;
26
+ const r = this.resolveNext;
27
+ this.resolveNext = null;
28
+ r?.();
29
+ }
30
+ async *[Symbol.asyncIterator]() {
31
+ while (true) {
32
+ if (this.buf.length > 0) {
33
+ yield this.buf.shift();
34
+ continue;
35
+ }
36
+ if (this.closed)
37
+ return;
38
+ await new Promise((res) => { this.resolveNext = res; });
39
+ }
40
+ }
41
+ }
@@ -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.3.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",