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 +64 -0
- package/dist/config.js +8 -0
- package/dist/handlers/commands.js +54 -4
- package/dist/handlers/message.js +114 -3
- package/dist/i18n.js +19 -0
- package/dist/providers/claude-sdk-provider.js +17 -1
- package/dist/providers/registry.js +12 -0
- package/dist/services/alvin-dispatch.js +4 -0
- package/dist/services/async-agent-watcher.js +61 -0
- package/dist/services/session-persistence.js +8 -0
- package/dist/services/session.js +14 -0
- package/dist/services/steer-channel.js +41 -0
- package/dist/services/stop-controller.js +52 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
1904
|
-
session
|
|
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) => {
|
package/dist/handlers/message.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/services/session.js
CHANGED
|
@@ -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
|
+
}
|