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 +33 -0
- package/dist/config.js +8 -0
- package/dist/handlers/message.js +75 -2
- package/dist/i18n.js +7 -0
- package/dist/providers/claude-sdk-provider.js +3 -1
- package/dist/services/session-persistence.js +2 -0
- package/dist/services/session.js +6 -0
- package/dist/services/steer-channel.js +41 -0
- package/package.json +1 -1
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
|
+
}
|
package/dist/handlers/message.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/services/session.js
CHANGED
|
@@ -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
|
+
}
|