alvin-bot 5.2.0 → 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,39 @@
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
+
5
38
  ## [5.2.0] — 2026-05-17
6
39
 
7
40
  ### Stop now actually means stop — instantly
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
+ }
@@ -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
@@ -459,6 +512,19 @@ export async function handleMessage(ctx) {
459
512
  // v5.1 — Store the SDK query handle so requestStop() can interrupt it.
460
513
  onQueryHandle: (q) => { session._qHandle = q; },
461
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
+ }
462
528
  // Stream response from provider (with fallback)
463
529
  let lastBroadcastLen = 0;
464
530
  // Captured during tool_use chunks; consumed by tool_result chunks so
@@ -729,6 +795,13 @@ export async function handleMessage(ctx) {
729
795
  // v5.1 — Clear stop-hardening state so the next turn starts clean.
730
796
  session._qHandle = null;
731
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;
732
805
  // v5.1 — Remove the ⛔ Stop control message (sent at processing start).
733
806
  // Best-effort: if it was already deleted or the bot lacks permission, ignore.
734
807
  if (stopMsgId !== null) {
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…",
@@ -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,
@@ -182,6 +182,8 @@ export function loadPersistedSessions() {
182
182
  abortController: null,
183
183
  _stopRequested: null,
184
184
  _qHandle: null,
185
+ _steerChannel: null,
186
+ _steerAckSentThisTurn: false,
185
187
  lastActivity: persisted.lastActivity ?? Date.now(),
186
188
  startedAt: persisted.startedAt ?? Date.now(),
187
189
  totalCost: persisted.totalCost ?? 0,
@@ -82,6 +82,8 @@ export function getSession(key) {
82
82
  abortController: null,
83
83
  _stopRequested: null,
84
84
  _qHandle: null,
85
+ _steerChannel: null,
86
+ _steerAckSentThisTurn: false,
85
87
  lastActivity: Date.now(),
86
88
  startedAt: Date.now(),
87
89
  totalCost: 0,
@@ -114,6 +116,10 @@ export function getSession(key) {
114
116
  if (!session.sessionKey) {
115
117
  session.sessionKey = k;
116
118
  }
119
+ if (session._steerChannel === undefined)
120
+ session._steerChannel = null;
121
+ if (session._steerAckSentThisTurn === undefined)
122
+ session._steerAckSentThisTurn = false;
117
123
  }
118
124
  return session;
119
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "5.2.0",
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",