switchroom 0.13.20 → 0.13.21
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/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +3 -3
- package/telegram-plugin/dist/gateway/gateway.js +87 -25
- package/telegram-plugin/gateway/disconnect-flush.ts +37 -0
- package/telegram-plugin/gateway/gateway.ts +100 -7
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +37 -4
- package/telegram-plugin/handoff-continuity.ts +8 -2
- package/telegram-plugin/recent-outbound-dedup.ts +51 -5
- package/telegram-plugin/runtime-metrics.ts +5 -1
- package/telegram-plugin/subagent-watcher.ts +25 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +114 -0
- package/telegram-plugin/tests/handoff-continuity.test.ts +15 -2
- package/telegram-plugin/tests/inbound-delivery-gate.test.ts +77 -4
- package/telegram-plugin/tests/recent-outbound-dedup.test.ts +72 -0
- package/telegram-plugin/tests/subagent-watcher-enoent-deregister.test.ts +152 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +72 -45
package/dist/cli/switchroom.js
CHANGED
|
@@ -47331,8 +47331,8 @@ var {
|
|
|
47331
47331
|
} = import__.default;
|
|
47332
47332
|
|
|
47333
47333
|
// src/build-info.ts
|
|
47334
|
-
var VERSION = "0.13.
|
|
47335
|
-
var COMMIT_SHA = "
|
|
47334
|
+
var VERSION = "0.13.21";
|
|
47335
|
+
var COMMIT_SHA = "4183c5df";
|
|
47336
47336
|
|
|
47337
47337
|
// src/cli/agent.ts
|
|
47338
47338
|
init_source();
|
package/package.json
CHANGED
|
@@ -23,11 +23,11 @@ The 👀→🤔→🔥→👍 status reaction and the typing indicator are *ambi
|
|
|
23
23
|
|
|
24
24
|
**Follow-ups while a turn is in flight.** Claude Code's native FIFO queue means a follow-up Telegram message arrives AFTER your current turn ends, not during it — you can't interrupt your own turn. Every follow-up becomes the next prompt you see. The plugin enriches the `<channel>` meta so you can classify correctly:
|
|
25
25
|
|
|
26
|
-
- `
|
|
27
|
-
- `
|
|
26
|
+
- `queued="true"` — DEFAULT for mid-turn follow-ups (no prefix). Treat as a new, independent task. Do NOT reference the in-flight work — start fresh. Also fires when the user typed `/queue ` or `/q ` (legacy alias; the prefix is stripped from the body you see).
|
|
27
|
+
- `steering="true"` — the user typed `/steer ` or `/s ` (the prefix is stripped from the body you see). Treat as a course-correction or addendum on the in-flight work. Continue the original task, incorporating the new guidance.
|
|
28
28
|
- `prior_turn_in_progress="true"`, `seconds_since_turn_start="N"`, `prior_assistant_preview="..."` — auxiliary context on the prior turn so you can decide which of the above applies when ambiguous. `prior_assistant_preview` is the first ~200 chars of your most recent reply in this chat, HTML tags stripped.
|
|
29
29
|
|
|
30
|
-
If both `queued` and `steering` are somehow present, `
|
|
30
|
+
If both `queued` and `steering` are somehow present, `steering` wins (explicit opt-in beats default). If `prior_turn_in_progress="true"` is set without either flag (shouldn't happen but defensive), treat the message as a follow-up related to your last reply.
|
|
31
31
|
|
|
32
32
|
**Self-narrate the classification.** At the top of your reply for any `steering` or `queued` message, include a brief italic one-liner so the user can correct you — e.g. `_↪️ treating as steer on the prior task_` or `_📥 queued as a new task_`.
|
|
33
33
|
|
|
@@ -30563,7 +30563,7 @@ class OutboundDedupCache {
|
|
|
30563
30563
|
constructor(opts = {}) {
|
|
30564
30564
|
this.ttlMs = opts.ttlMs ?? DEFAULT_DEDUP_TTL_MS;
|
|
30565
30565
|
}
|
|
30566
|
-
record(chatId, threadId, text, now) {
|
|
30566
|
+
record(chatId, threadId, text, now, turnKey = null) {
|
|
30567
30567
|
if (text.length < DEDUP_MIN_CONTENT_LEN)
|
|
30568
30568
|
return;
|
|
30569
30569
|
const key = makeKey(chatId, threadId);
|
|
@@ -30572,11 +30572,12 @@ class OutboundDedupCache {
|
|
|
30572
30572
|
list.push({
|
|
30573
30573
|
hash: normalizeForDedup(text),
|
|
30574
30574
|
ts: now,
|
|
30575
|
-
preview: text.slice(0, 80)
|
|
30575
|
+
preview: text.slice(0, 80),
|
|
30576
|
+
turnKey
|
|
30576
30577
|
});
|
|
30577
30578
|
this.entries.set(key, list);
|
|
30578
30579
|
}
|
|
30579
|
-
check(chatId, threadId, text, now) {
|
|
30580
|
+
check(chatId, threadId, text, now, turnKey = null) {
|
|
30580
30581
|
if (text.length < DEDUP_MIN_CONTENT_LEN)
|
|
30581
30582
|
return null;
|
|
30582
30583
|
const key = makeKey(chatId, threadId);
|
|
@@ -30586,9 +30587,12 @@ class OutboundDedupCache {
|
|
|
30586
30587
|
this.evict(list, now);
|
|
30587
30588
|
const candidateHash = normalizeForDedup(text);
|
|
30588
30589
|
for (const entry of list) {
|
|
30589
|
-
if (entry.hash
|
|
30590
|
-
|
|
30590
|
+
if (entry.hash !== candidateHash)
|
|
30591
|
+
continue;
|
|
30592
|
+
if (turnKey != null && entry.turnKey != null && entry.turnKey !== turnKey) {
|
|
30593
|
+
continue;
|
|
30591
30594
|
}
|
|
30595
|
+
return { matched: true, preview: entry.preview, ageMs: now - entry.ts };
|
|
30592
30596
|
}
|
|
30593
30597
|
return null;
|
|
30594
30598
|
}
|
|
@@ -40987,7 +40991,7 @@ function shouldShowHandoffLine() {
|
|
|
40987
40991
|
return v.toLowerCase() !== "false";
|
|
40988
40992
|
}
|
|
40989
40993
|
function formatHandoffLine(topic, format) {
|
|
40990
|
-
const prefix = "\u21a9\ufe0f Picked up where we left off
|
|
40994
|
+
const prefix = "\u21a9\ufe0f Picked up where we left off, ";
|
|
40991
40995
|
if (format === "html") {
|
|
40992
40996
|
return `<i>${prefix}${escapeHtml6(topic)}</i>
|
|
40993
40997
|
|
|
@@ -41156,6 +41160,7 @@ function flushOnAgentDisconnect(deps) {
|
|
|
41156
41160
|
activeDraftParseModes,
|
|
41157
41161
|
clearActiveReactions: clearActiveReactions3,
|
|
41158
41162
|
disposeProgressDriver,
|
|
41163
|
+
onDanglingTurnsSwept,
|
|
41159
41164
|
log
|
|
41160
41165
|
} = deps;
|
|
41161
41166
|
if (agentName3 == null) {
|
|
@@ -41169,6 +41174,15 @@ function flushOnAgentDisconnect(deps) {
|
|
|
41169
41174
|
activeTurnStartedAt.delete(key);
|
|
41170
41175
|
}
|
|
41171
41176
|
clearActiveReactions3();
|
|
41177
|
+
const danglingKeys = [...activeTurnStartedAt.keys()];
|
|
41178
|
+
if (danglingKeys.length > 0) {
|
|
41179
|
+
for (const k of danglingKeys) {
|
|
41180
|
+
activeTurnStartedAt.delete(k);
|
|
41181
|
+
activeReactionMsgIds.delete(k);
|
|
41182
|
+
}
|
|
41183
|
+
log(`telegram gateway: disconnect-flush swept ${danglingKeys.length} dangling turn key(s) ` + `post-bridge-death (controller loop missed \u2014 setDone raced disconnect)`);
|
|
41184
|
+
onDanglingTurnsSwept?.(danglingKeys);
|
|
41185
|
+
}
|
|
41172
41186
|
disposeProgressDriver();
|
|
41173
41187
|
for (const [key, stream] of activeDraftStreams.entries()) {
|
|
41174
41188
|
if (!stream.isFinal())
|
|
@@ -44464,7 +44478,11 @@ function purgeStaleTurnsForChat(chatId, keys, purger) {
|
|
|
44464
44478
|
|
|
44465
44479
|
// gateway/inbound-delivery-gate.ts
|
|
44466
44480
|
function decideInboundDelivery(input) {
|
|
44467
|
-
if (input.
|
|
44481
|
+
if (input.isSteering)
|
|
44482
|
+
return "deliver";
|
|
44483
|
+
if (input.isInterrupt === true)
|
|
44484
|
+
return "deliver";
|
|
44485
|
+
if (input.turnInFlight)
|
|
44468
44486
|
return "buffer-until-idle";
|
|
44469
44487
|
return "deliver";
|
|
44470
44488
|
}
|
|
@@ -46702,7 +46720,7 @@ function backfillJsonlAgentId(db2, jsonlPath, agentId, log) {
|
|
|
46702
46720
|
db2.prepare("UPDATE subagents SET jsonl_agent_id = ? WHERE id = ?").run(agentId, candidate.id);
|
|
46703
46721
|
log?.(`subagent-watcher: backfill linked ${agentId} \u2192 ${candidate.id}`);
|
|
46704
46722
|
}
|
|
46705
|
-
function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, parentStateDir, onUnstall) {
|
|
46723
|
+
function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, parentStateDir, onUnstall, onFileVanished) {
|
|
46706
46724
|
try {
|
|
46707
46725
|
const stat = fs2.statSync(entry.filePath);
|
|
46708
46726
|
if (stat.size < tail.cursor) {
|
|
@@ -46807,6 +46825,12 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
|
|
|
46807
46825
|
}
|
|
46808
46826
|
tail.hasEmittedStart = startState.hasEmittedStart;
|
|
46809
46827
|
} catch (err) {
|
|
46828
|
+
const code = err.code;
|
|
46829
|
+
if (code === "ENOENT" || code === "EACCES") {
|
|
46830
|
+
log?.(`subagent-watcher: JSONL vanished for ${entry.agentId} (${code}) \u2014 deregistering`);
|
|
46831
|
+
onFileVanished?.(entry.agentId, code);
|
|
46832
|
+
return;
|
|
46833
|
+
}
|
|
46810
46834
|
log?.(`subagent-watcher: read error ${entry.agentId}: ${err.message}`);
|
|
46811
46835
|
}
|
|
46812
46836
|
}
|
|
@@ -46913,7 +46937,7 @@ function startSubagentWatcher(config) {
|
|
|
46913
46937
|
return;
|
|
46914
46938
|
readSubTail(entry2, t, nowFn(), (desc) => {
|
|
46915
46939
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
|
|
46916
|
-
}, fs2, log, db2, parentStateDir, config.onUnstall);
|
|
46940
|
+
}, fs2, log, db2, parentStateDir, config.onUnstall, cleanupTerminalAgent);
|
|
46917
46941
|
maybySendStateTransition(agentId);
|
|
46918
46942
|
});
|
|
46919
46943
|
} catch (err) {
|
|
@@ -47159,7 +47183,7 @@ function startSubagentWatcher(config) {
|
|
|
47159
47183
|
continue;
|
|
47160
47184
|
readSubTail(entry, tail, n, (desc) => {
|
|
47161
47185
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
|
|
47162
|
-
}, fs2, log, db2, parentStateDir, config.onUnstall);
|
|
47186
|
+
}, fs2, log, db2, parentStateDir, config.onUnstall, cleanupTerminalAgent);
|
|
47163
47187
|
maybySendStateTransition(agentId);
|
|
47164
47188
|
}
|
|
47165
47189
|
checkStalls();
|
|
@@ -48291,10 +48315,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
48291
48315
|
}
|
|
48292
48316
|
|
|
48293
48317
|
// ../src/build-info.ts
|
|
48294
|
-
var VERSION = "0.13.
|
|
48295
|
-
var COMMIT_SHA = "
|
|
48296
|
-
var COMMIT_DATE = "2026-05-
|
|
48297
|
-
var LATEST_PR =
|
|
48318
|
+
var VERSION = "0.13.21";
|
|
48319
|
+
var COMMIT_SHA = "4183c5df";
|
|
48320
|
+
var COMMIT_DATE = "2026-05-23T21:59:16Z";
|
|
48321
|
+
var LATEST_PR = 1692;
|
|
48298
48322
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
48299
48323
|
|
|
48300
48324
|
// gateway/boot-version.ts
|
|
@@ -50078,6 +50102,12 @@ startTimer({
|
|
|
50078
50102
|
emitRuntimeMetric(event);
|
|
50079
50103
|
},
|
|
50080
50104
|
onFrameworkFallback: async (ctx) => {
|
|
50105
|
+
if (activeTurnStartedAt.get(ctx.key) == null && currentTurn == null) {
|
|
50106
|
+
process.stderr.write(`telegram gateway: silence-poke framework-fallback late-fire skipped \u2014 ` + `turn ended cleanly during silence window chat=${ctx.chatId} thread=${ctx.threadId ?? "-"} silence_ms=${ctx.silenceMs}
|
|
50107
|
+
`);
|
|
50108
|
+
endTurn(ctx.key);
|
|
50109
|
+
return;
|
|
50110
|
+
}
|
|
50081
50111
|
let text = null;
|
|
50082
50112
|
const upd = inFlightUpdate;
|
|
50083
50113
|
if (upd != null) {
|
|
@@ -50344,6 +50374,13 @@ var ipcServer = createIpcServer({
|
|
|
50344
50374
|
disposeProgressDriver: () => {
|
|
50345
50375
|
progressDriver?.dispose?.({ preservePending: true });
|
|
50346
50376
|
},
|
|
50377
|
+
onDanglingTurnsSwept: () => {
|
|
50378
|
+
if (currentTurn != null) {
|
|
50379
|
+
process.stderr.write(`telegram gateway: disconnect-flush nulled currentTurn (bridge died with turn in flight)
|
|
50380
|
+
`);
|
|
50381
|
+
currentTurn = null;
|
|
50382
|
+
}
|
|
50383
|
+
},
|
|
50347
50384
|
log: (msg) => process.stderr.write(`${msg}
|
|
50348
50385
|
`)
|
|
50349
50386
|
});
|
|
@@ -50750,7 +50787,7 @@ async function executeReply(args) {
|
|
|
50750
50787
|
`);
|
|
50751
50788
|
{
|
|
50752
50789
|
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
50753
|
-
const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now());
|
|
50790
|
+
const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now(), currentTurn?.registryKey ?? null);
|
|
50754
50791
|
if (dup != null) {
|
|
50755
50792
|
process.stderr.write(`telegram gateway: reply: deduped (#546) chatId=${chat_id} ageMs=${dup.ageMs} preview=${JSON.stringify(dup.preview)}
|
|
50756
50793
|
`);
|
|
@@ -50941,7 +50978,7 @@ ${url}`;
|
|
|
50941
50978
|
})) {
|
|
50942
50979
|
turn2.finalAnswerDelivered = true;
|
|
50943
50980
|
}
|
|
50944
|
-
outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now());
|
|
50981
|
+
outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now(), turn2?.registryKey ?? null);
|
|
50945
50982
|
silentAnchorEditDone = true;
|
|
50946
50983
|
} catch (err) {
|
|
50947
50984
|
process.stderr.write(`telegram gateway: silent-reply auto-edit failed, falling back to fresh send: ${err instanceof Error ? err.message : String(err)}
|
|
@@ -51148,7 +51185,7 @@ ${url}`;
|
|
|
51148
51185
|
process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(",")}] chunks=${chunks.length}
|
|
51149
51186
|
`);
|
|
51150
51187
|
if (sentIds.length > 0) {
|
|
51151
|
-
outboundDedup.record(chat_id, threadId, text, Date.now());
|
|
51188
|
+
outboundDedup.record(chat_id, threadId, text, Date.now(), currentTurn?.registryKey ?? null);
|
|
51152
51189
|
}
|
|
51153
51190
|
return { content: [{ type: "text", text: result }] };
|
|
51154
51191
|
}
|
|
@@ -51158,11 +51195,23 @@ async function executeStreamReply(args) {
|
|
|
51158
51195
|
throw new Error("stream_reply: chat_id is required");
|
|
51159
51196
|
if (args.text == null || args.text === "")
|
|
51160
51197
|
throw new Error("stream_reply: text is required and cannot be empty");
|
|
51198
|
+
{
|
|
51199
|
+
const scrub = scrubVoice(args.text);
|
|
51200
|
+
if (scrub.replaced > 0) {
|
|
51201
|
+
args.text = scrub.scrubbed;
|
|
51202
|
+
emitRuntimeMetric({
|
|
51203
|
+
kind: "voice_scrub_applied",
|
|
51204
|
+
chatKey: statusKey(args.chat_id, args.message_thread_id != null ? Number(args.message_thread_id) : undefined),
|
|
51205
|
+
replaced: scrub.replaced,
|
|
51206
|
+
site: "stream_reply"
|
|
51207
|
+
});
|
|
51208
|
+
}
|
|
51209
|
+
}
|
|
51161
51210
|
if (args.done === true) {
|
|
51162
51211
|
const sChatId = args.chat_id;
|
|
51163
51212
|
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
51164
51213
|
const sText = args.text;
|
|
51165
|
-
const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now());
|
|
51214
|
+
const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now(), currentTurn?.registryKey ?? null);
|
|
51166
51215
|
if (dup != null) {
|
|
51167
51216
|
process.stderr.write(`telegram gateway: stream_reply: deduped (#546) chatId=${sChatId} ageMs=${dup.ageMs} preview=${JSON.stringify(dup.preview)}
|
|
51168
51217
|
`);
|
|
@@ -51254,7 +51303,7 @@ async function executeStreamReply(args) {
|
|
|
51254
51303
|
if (args.done === true && result.messageId != null) {
|
|
51255
51304
|
const sChatId = args.chat_id;
|
|
51256
51305
|
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
51257
|
-
outboundDedup.record(sChatId, sThreadId, args.text, Date.now());
|
|
51306
|
+
outboundDedup.record(sChatId, sThreadId, args.text, Date.now(), currentTurn?.registryKey ?? null);
|
|
51258
51307
|
noteOutbound3(statusKey(sChatId, sThreadId), {
|
|
51259
51308
|
messageId: result.messageId,
|
|
51260
51309
|
text: args.text
|
|
@@ -52166,10 +52215,10 @@ function handleSessionEvent(ev) {
|
|
|
52166
52215
|
}
|
|
52167
52216
|
},
|
|
52168
52217
|
checkDedup: (text) => {
|
|
52169
|
-
return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now()) != null;
|
|
52218
|
+
return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null) != null;
|
|
52170
52219
|
},
|
|
52171
52220
|
recordDedup: (text) => {
|
|
52172
|
-
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now());
|
|
52221
|
+
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null);
|
|
52173
52222
|
},
|
|
52174
52223
|
recordOutbound: ({ messageId, text }) => {
|
|
52175
52224
|
if (!HISTORY_ENABLED)
|
|
@@ -52260,7 +52309,7 @@ function handleSessionEvent(ev) {
|
|
|
52260
52309
|
streamFinalizedAsAnswer = true;
|
|
52261
52310
|
turn.finalAnswerDelivered = true;
|
|
52262
52311
|
try {
|
|
52263
|
-
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, streamedFinalText, Date.now());
|
|
52312
|
+
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, streamedFinalText, Date.now(), turn.registryKey ?? null);
|
|
52264
52313
|
} catch {}
|
|
52265
52314
|
if (HISTORY_ENABLED) {
|
|
52266
52315
|
try {
|
|
@@ -52360,10 +52409,22 @@ function handleSessionEvent(ev) {
|
|
|
52360
52409
|
return;
|
|
52361
52410
|
}
|
|
52362
52411
|
if (flushDecision.kind === "flush") {
|
|
52363
|
-
|
|
52412
|
+
let capturedText = flushDecision.text;
|
|
52364
52413
|
const backstopChatId = chatId;
|
|
52365
52414
|
const backstopThreadId = threadId;
|
|
52366
52415
|
const backstopCtrl = ctrl;
|
|
52416
|
+
{
|
|
52417
|
+
const scrub = scrubVoice(capturedText);
|
|
52418
|
+
if (scrub.replaced > 0) {
|
|
52419
|
+
capturedText = scrub.scrubbed;
|
|
52420
|
+
emitRuntimeMetric({
|
|
52421
|
+
kind: "voice_scrub_applied",
|
|
52422
|
+
chatKey: statusKey(backstopChatId, backstopThreadId),
|
|
52423
|
+
replaced: scrub.replaced,
|
|
52424
|
+
site: "turn_flush"
|
|
52425
|
+
});
|
|
52426
|
+
}
|
|
52427
|
+
}
|
|
52367
52428
|
turn.finalAnswerDelivered = true;
|
|
52368
52429
|
const cardTakeover = progressDriver?.takeOverCard({
|
|
52369
52430
|
chatId: backstopChatId,
|
|
@@ -52455,7 +52516,7 @@ function handleSessionEvent(ev) {
|
|
|
52455
52516
|
});
|
|
52456
52517
|
} catch {}
|
|
52457
52518
|
}
|
|
52458
|
-
outboundDedup.record(backstopChatId, backstopThreadId, capturedText, Date.now());
|
|
52519
|
+
outboundDedup.record(backstopChatId, backstopThreadId, capturedText, Date.now(), currentTurn?.registryKey ?? null);
|
|
52459
52520
|
if (backstopCtrl)
|
|
52460
52521
|
backstopCtrl.setDone();
|
|
52461
52522
|
if (backstopCardTurnKey != null) {
|
|
@@ -53345,7 +53406,8 @@ ${preBlock(write.output)}`;
|
|
|
53345
53406
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
53346
53407
|
if (decideInboundDelivery({
|
|
53347
53408
|
turnInFlight: turnInFlightAtReceipt,
|
|
53348
|
-
isSteering
|
|
53409
|
+
isSteering,
|
|
53410
|
+
isInterrupt: interrupt.isInterrupt
|
|
53349
53411
|
}) === "buffer-until-idle") {
|
|
53350
53412
|
pendingInboundBuffer.push(selfAgent, inboundMsg);
|
|
53351
53413
|
process.stderr.write(`telegram gateway: inbound held mid-turn agent=${selfAgent} chat=${chat_id} msg=${msgId ?? "-"} \u2014 will flush on turn-complete
|
|
@@ -48,6 +48,18 @@ export interface DisconnectFlushDeps<Ctrl extends { setDone: () => void }, Strea
|
|
|
48
48
|
/** Progress driver — disposed with `preservePending: true` for sub-agent JTBDs (#393). */
|
|
49
49
|
disposeProgressDriver: () => void
|
|
50
50
|
|
|
51
|
+
/** Optional: called when the registered-agent disconnect found dangling
|
|
52
|
+
* `activeTurnStartedAt` entries the controller loop did not clear (i.e.
|
|
53
|
+
* `setDone()` already ran on the canonical reply path, leaving
|
|
54
|
+
* `activeStatusReactions` empty but `activeTurnStartedAt` populated).
|
|
55
|
+
* The gateway uses this to null its module-scope `currentTurn` — the
|
|
56
|
+
* bridge that owned that turn just died. Without this, the next
|
|
57
|
+
* inbound is "held mid-turn" against a ghost (the 2026-05-23 audit
|
|
58
|
+
* found ~14 such events / 3 days / 9 agents).
|
|
59
|
+
*
|
|
60
|
+
* No-op if no dangling keys are found. */
|
|
61
|
+
onDanglingTurnsSwept?: (purgedKeys: string[]) => void
|
|
62
|
+
|
|
51
63
|
/** Logger — receives the one-line decision trace. */
|
|
52
64
|
log: (msg: string) => void
|
|
53
65
|
}
|
|
@@ -70,6 +82,7 @@ export function flushOnAgentDisconnect<
|
|
|
70
82
|
activeDraftParseModes,
|
|
71
83
|
clearActiveReactions,
|
|
72
84
|
disposeProgressDriver,
|
|
85
|
+
onDanglingTurnsSwept,
|
|
73
86
|
log,
|
|
74
87
|
} = deps
|
|
75
88
|
|
|
@@ -91,6 +104,30 @@ export function flushOnAgentDisconnect<
|
|
|
91
104
|
}
|
|
92
105
|
clearActiveReactions()
|
|
93
106
|
|
|
107
|
+
// Defense-in-depth — sweep any `activeTurnStartedAt` keys the controller
|
|
108
|
+
// loop above did not touch. The bridge has crashed; any turn it owned is
|
|
109
|
+
// dead by definition, regardless of whether `activeStatusReactions`
|
|
110
|
+
// still tracks it. The race that motivates this: `setDone()` already
|
|
111
|
+
// fired on the canonical reply path (clearing the reaction controller)
|
|
112
|
+
// BUT the disconnect arrived BEFORE `purgeReactionTracking` ran the
|
|
113
|
+
// `activeTurnStartedAt.delete` line for that key. Without this sweep,
|
|
114
|
+
// the key orphans, and the next inbound is "held mid-turn" against
|
|
115
|
+
// a ghost — surfacing as the held-mid-turn / `currentTurn_nulled=true`
|
|
116
|
+
// wedge symptom documented in feedback_5min_restart_wedge memo and
|
|
117
|
+
// measured at ~14 events / 3 days / 9 agents (2026-05-23 audit).
|
|
118
|
+
const danglingKeys = [...activeTurnStartedAt.keys()]
|
|
119
|
+
if (danglingKeys.length > 0) {
|
|
120
|
+
for (const k of danglingKeys) {
|
|
121
|
+
activeTurnStartedAt.delete(k)
|
|
122
|
+
activeReactionMsgIds.delete(k)
|
|
123
|
+
}
|
|
124
|
+
log(
|
|
125
|
+
`telegram gateway: disconnect-flush swept ${danglingKeys.length} dangling turn key(s) ` +
|
|
126
|
+
`post-bridge-death (controller loop missed — setDone raced disconnect)`,
|
|
127
|
+
)
|
|
128
|
+
onDanglingTurnsSwept?.(danglingKeys)
|
|
129
|
+
}
|
|
130
|
+
|
|
94
131
|
// Stop coalesce timers that could emit into a finalized draft stream, but
|
|
95
132
|
// preserve chats with pendingCompletion=true — those have background
|
|
96
133
|
// sub-agents that legitimately outlive the parent bridge disconnect. The
|
|
@@ -3084,6 +3084,30 @@ silencePoke.startTimer({
|
|
|
3084
3084
|
emitRuntimeMetric(event)
|
|
3085
3085
|
},
|
|
3086
3086
|
onFrameworkFallback: async (ctx) => {
|
|
3087
|
+
// Late-fire short-circuit (2026-05-23 audit finding). The fallback
|
|
3088
|
+
// can race a clean turn-end: the model's actual reply lands inside
|
|
3089
|
+
// the silence window's final ~50ms, the canonical turn-end path
|
|
3090
|
+
// clears `activeTurnStartedAt` and nulls `currentTurn`, and then
|
|
3091
|
+
// this handler fires anyway. Without this check we emit a noisy
|
|
3092
|
+
// "still working…" ping to the user (right after they got their
|
|
3093
|
+
// real reply) AND a misleading "ended wedged turn ... currentTurn_
|
|
3094
|
+
// nulled=false drained_buffered=0/0" log line. The 7-day audit
|
|
3095
|
+
// showed this race accounts for ~90% of all framework_fallback log
|
|
3096
|
+
// events (124 of 138 `currentTurn_nulled=false` cases). Distinct
|
|
3097
|
+
// log line so observability still tracks the fact that the silence
|
|
3098
|
+
// crossed threshold; the wedge counter is no longer polluted.
|
|
3099
|
+
if (activeTurnStartedAt.get(ctx.key) == null && currentTurn == null) {
|
|
3100
|
+
process.stderr.write(
|
|
3101
|
+
`telegram gateway: silence-poke framework-fallback late-fire skipped — ` +
|
|
3102
|
+
`turn ended cleanly during silence window ` +
|
|
3103
|
+
`chat=${ctx.chatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs}\n`,
|
|
3104
|
+
)
|
|
3105
|
+
// Tell silence-poke this chat-thread is finished so the next
|
|
3106
|
+
// arming doesn't carry stale state.
|
|
3107
|
+
silencePoke.endTurn(ctx.key)
|
|
3108
|
+
return
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3087
3111
|
// Deterministic in-flight update status (klanker incident). If this
|
|
3088
3112
|
// gateway dispatched an update_apply that's still running, the
|
|
3089
3113
|
// recurring framework fallback carries hostd's REAL phase + elapsed
|
|
@@ -3579,6 +3603,18 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3579
3603
|
// scripts/check-plugin-references.mjs (TS2722).
|
|
3580
3604
|
progressDriver?.dispose?.({ preservePending: true })
|
|
3581
3605
|
},
|
|
3606
|
+
// When dangling activeTurnStartedAt keys were swept (setDone raced
|
|
3607
|
+
// disconnect), the module-scope `currentTurn` may also point at the
|
|
3608
|
+
// dead bridge's turn. Null it so the next inbound starts a fresh
|
|
3609
|
+
// turn instead of inheriting a ghost.
|
|
3610
|
+
onDanglingTurnsSwept: () => {
|
|
3611
|
+
if (currentTurn != null) {
|
|
3612
|
+
process.stderr.write(
|
|
3613
|
+
`telegram gateway: disconnect-flush nulled currentTurn (bridge died with turn in flight)\n`,
|
|
3614
|
+
)
|
|
3615
|
+
currentTurn = null
|
|
3616
|
+
}
|
|
3617
|
+
},
|
|
3582
3618
|
log: (msg) => process.stderr.write(`${msg}\n`),
|
|
3583
3619
|
})
|
|
3584
3620
|
},
|
|
@@ -4227,7 +4263,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4227
4263
|
// late-replies with different content sail through.
|
|
4228
4264
|
{
|
|
4229
4265
|
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
4230
|
-
const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now())
|
|
4266
|
+
const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now(), currentTurn?.registryKey ?? null)
|
|
4231
4267
|
if (dup != null) {
|
|
4232
4268
|
process.stderr.write(
|
|
4233
4269
|
`telegram gateway: reply: deduped (#546) chatId=${chat_id} ` +
|
|
@@ -4561,6 +4597,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4561
4597
|
threadId,
|
|
4562
4598
|
decision.mergedText,
|
|
4563
4599
|
Date.now(),
|
|
4600
|
+
turn?.registryKey ?? null,
|
|
4564
4601
|
)
|
|
4565
4602
|
|
|
4566
4603
|
silentAnchorEditDone = true
|
|
@@ -4885,7 +4922,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4885
4922
|
// calls with this same content within DEFAULT_DEDUP_TTL_MS will
|
|
4886
4923
|
// be suppressed.
|
|
4887
4924
|
if (sentIds.length > 0) {
|
|
4888
|
-
outboundDedup.record(chat_id, threadId, text, Date.now())
|
|
4925
|
+
outboundDedup.record(chat_id, threadId, text, Date.now(), currentTurn?.registryKey ?? null)
|
|
4889
4926
|
}
|
|
4890
4927
|
return { content: [{ type: 'text', text: result }] }
|
|
4891
4928
|
}
|
|
@@ -4896,6 +4933,31 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
4896
4933
|
if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
|
|
4897
4934
|
if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
|
|
4898
4935
|
|
|
4936
|
+
// Voice scrub (PR #1683 follow-up). Modern Claude on the fleet
|
|
4937
|
+
// uses the answer-stream / draft-stream path for multi-paragraph
|
|
4938
|
+
// replies — the model emits via stream_reply and the original
|
|
4939
|
+
// PR #1683 scrub site (executeReply) never sees the text. klanker's
|
|
4940
|
+
// 2026-05-24 log showed model output with em-dashes routed via
|
|
4941
|
+
// stream_reply done=true, materializing as sendMessage with no
|
|
4942
|
+
// scrub. Mirror the executeReply pattern here: scrub BEFORE the
|
|
4943
|
+
// outbound-dedup check (so retries see the scrubbed key) and
|
|
4944
|
+
// mutate args.text so all downstream consumers (the stream-
|
|
4945
|
+
// controller, dedup record, history record) see the scrubbed
|
|
4946
|
+
// version. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
|
|
4947
|
+
{
|
|
4948
|
+
const scrub = scrubVoice(args.text as string)
|
|
4949
|
+
if (scrub.replaced > 0) {
|
|
4950
|
+
args.text = scrub.scrubbed
|
|
4951
|
+
emitRuntimeMetric({
|
|
4952
|
+
kind: 'voice_scrub_applied',
|
|
4953
|
+
chatKey: statusKey(args.chat_id as string, args.message_thread_id != null
|
|
4954
|
+
? Number(args.message_thread_id) : undefined),
|
|
4955
|
+
replaced: scrub.replaced,
|
|
4956
|
+
site: 'stream_reply',
|
|
4957
|
+
})
|
|
4958
|
+
}
|
|
4959
|
+
}
|
|
4960
|
+
|
|
4899
4961
|
// #546 dedup check: stream_reply done=true is the most-common
|
|
4900
4962
|
// retry shape — claude-code re-emits the final-text call when
|
|
4901
4963
|
// the previous bridge missed the ack. If turn-flush already sent
|
|
@@ -4906,7 +4968,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
4906
4968
|
const sChatId = args.chat_id as string
|
|
4907
4969
|
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
4908
4970
|
const sText = args.text as string
|
|
4909
|
-
const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now())
|
|
4971
|
+
const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now(), currentTurn?.registryKey ?? null)
|
|
4910
4972
|
if (dup != null) {
|
|
4911
4973
|
process.stderr.write(
|
|
4912
4974
|
`telegram gateway: stream_reply: deduped (#546) chatId=${sChatId} ` +
|
|
@@ -5070,7 +5132,7 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
5070
5132
|
if (args.done === true && result.messageId != null) {
|
|
5071
5133
|
const sChatId = args.chat_id as string
|
|
5072
5134
|
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
5073
|
-
outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now())
|
|
5135
|
+
outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now(), currentTurn?.registryKey ?? null)
|
|
5074
5136
|
// #1445 cross-turn pending-async ambient. The terminal stream_reply
|
|
5075
5137
|
// (done=true) is the user-visible anchor for any cross-turn wait
|
|
5076
5138
|
// that follows. Capture it so if this turn ends with a pending
|
|
@@ -6382,10 +6444,10 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6382
6444
|
// threadId come from the captured `turn` snapshot, stable for
|
|
6383
6445
|
// the lifetime of the stream.
|
|
6384
6446
|
checkDedup: (text: string) => {
|
|
6385
|
-
return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now()) != null
|
|
6447
|
+
return outboundDedup.check(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null) != null
|
|
6386
6448
|
},
|
|
6387
6449
|
recordDedup: (text: string) => {
|
|
6388
|
-
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now())
|
|
6450
|
+
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null)
|
|
6389
6451
|
},
|
|
6390
6452
|
// #648 — write answer-stream materializations into the SQLite
|
|
6391
6453
|
// history buffer so get_recent_messages can surface them. Guard
|
|
@@ -6546,6 +6608,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6546
6608
|
turn.sessionThreadId,
|
|
6547
6609
|
streamedFinalText,
|
|
6548
6610
|
Date.now(),
|
|
6611
|
+
turn.registryKey ?? null,
|
|
6549
6612
|
)
|
|
6550
6613
|
} catch { /* best-effort */ }
|
|
6551
6614
|
if (HISTORY_ENABLED) {
|
|
@@ -6715,11 +6778,31 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6715
6778
|
}
|
|
6716
6779
|
|
|
6717
6780
|
if (flushDecision.kind === 'flush') {
|
|
6718
|
-
|
|
6781
|
+
let capturedText = flushDecision.text
|
|
6719
6782
|
const backstopChatId = chatId
|
|
6720
6783
|
const backstopThreadId = threadId
|
|
6721
6784
|
const backstopCtrl = ctrl
|
|
6722
6785
|
|
|
6786
|
+
// Voice scrub (PR #1683 follow-up). Turn-flush is the path
|
|
6787
|
+
// that fires when the model emits raw transcript text WITHOUT
|
|
6788
|
+
// calling reply / stream_reply. That captured text bypasses
|
|
6789
|
+
// PR #1683's executeReply scrub site entirely and is delivered
|
|
6790
|
+
// via sendMessage / editMessageText directly. Scrub the
|
|
6791
|
+
// capturedText before markdownToHtml so em-dashes never reach
|
|
6792
|
+
// the wire. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
|
|
6793
|
+
{
|
|
6794
|
+
const scrub = scrubVoice(capturedText)
|
|
6795
|
+
if (scrub.replaced > 0) {
|
|
6796
|
+
capturedText = scrub.scrubbed
|
|
6797
|
+
emitRuntimeMetric({
|
|
6798
|
+
kind: 'voice_scrub_applied',
|
|
6799
|
+
chatKey: statusKey(backstopChatId, backstopThreadId),
|
|
6800
|
+
replaced: scrub.replaced,
|
|
6801
|
+
site: 'turn_flush',
|
|
6802
|
+
})
|
|
6803
|
+
}
|
|
6804
|
+
}
|
|
6805
|
+
|
|
6723
6806
|
// #1664 — turn-flush only fires when !replyCalled (decideTurnFlush
|
|
6724
6807
|
// returns 'reply-called' otherwise). It legitimately delivers the
|
|
6725
6808
|
// model's terminal text as the answer, so the turn IS answered.
|
|
@@ -6911,6 +6994,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6911
6994
|
backstopThreadId,
|
|
6912
6995
|
capturedText,
|
|
6913
6996
|
Date.now(),
|
|
6997
|
+
currentTurn?.registryKey ?? null,
|
|
6914
6998
|
)
|
|
6915
6999
|
if (backstopCtrl) backstopCtrl.setDone()
|
|
6916
7000
|
// Unpin the card. completeTurn cleans up pinMgr's per-turn
|
|
@@ -8455,6 +8539,15 @@ async function handleInbound(
|
|
|
8455
8539
|
decideInboundDelivery({
|
|
8456
8540
|
turnInFlight: turnInFlightAtReceipt,
|
|
8457
8541
|
isSteering,
|
|
8542
|
+
// Interrupt-marker carve-out (2026-05-24): the `!`-prefixed body
|
|
8543
|
+
// must bypass the "buffer-until-turn-complete" gate because the
|
|
8544
|
+
// SIGINT'd turn often doesn't emit turn_complete, leaving the
|
|
8545
|
+
// body stranded in pendingInboundBuffer indefinitely. The
|
|
8546
|
+
// `interrupt` const is computed at the start of handleInbound
|
|
8547
|
+
// (line ~7606) and remains in scope here. When the user fires
|
|
8548
|
+
// `!`-with-body, this delivers the body as a fresh inbound to
|
|
8549
|
+
// the freshly-killed bridge.
|
|
8550
|
+
isInterrupt: interrupt.isInterrupt,
|
|
8458
8551
|
}) === 'buffer-until-idle'
|
|
8459
8552
|
) {
|
|
8460
8553
|
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
@@ -53,6 +53,27 @@
|
|
|
53
53
|
* mid-turn — that is the whole point of the steering feature (redirect
|
|
54
54
|
* the agent while it works). Steering messages keep immediate delivery.
|
|
55
55
|
* The wedge only ever affected the queued-mid-turn default path.
|
|
56
|
+
*
|
|
57
|
+
* ## Interrupt-marker is also exempt (2026-05-24 fix)
|
|
58
|
+
*
|
|
59
|
+
* An inbound prefixed with `!` invokes the interrupt path
|
|
60
|
+
* (`gateway.ts:handleInbound` parse + `tmux send-keys C-c` to the
|
|
61
|
+
* bridge). The SIGINT kills the in-flight turn at the SDK level — but
|
|
62
|
+
* the killed turn does NOT always emit `turn_complete`. Without that
|
|
63
|
+
* event, the turn-complete buffer-flush never fires, and the
|
|
64
|
+
* post-SIGINT inbound body (the `!` replacement instruction) rots in
|
|
65
|
+
* `pendingInboundBuffer` indefinitely.
|
|
66
|
+
*
|
|
67
|
+
* 2026-05-24 live UAT trace: user fires `! actually reply hello`,
|
|
68
|
+
* SIGINT delivered, killed turn never emits `turn_complete`, buffer
|
|
69
|
+
* stays full, user sees no response. The Phase-3 audit had this UAT
|
|
70
|
+
* `describe.skip`'d as "real interrupt-marker wedge or prompt-shape
|
|
71
|
+
* issue" — confirmed real.
|
|
72
|
+
*
|
|
73
|
+
* Resolution: bypass the gate for interrupt inbounds. The interrupt
|
|
74
|
+
* carve-out is a peer of `isSteering` — both are "intentional
|
|
75
|
+
* mid-turn delivery" cases. Caller passes the interrupt flag from the
|
|
76
|
+
* inbound parse; the gate returns `'deliver'` immediately.
|
|
56
77
|
*/
|
|
57
78
|
|
|
58
79
|
export interface InboundDeliveryGateInput {
|
|
@@ -63,6 +84,14 @@ export interface InboundDeliveryGateInput {
|
|
|
63
84
|
/** This inbound carried an explicit `/steer` (`/s`) prefix and is an
|
|
64
85
|
* intentional mid-turn redirect. */
|
|
65
86
|
isSteering: boolean
|
|
87
|
+
/** This inbound was parsed by `parseInterruptMarker` as a `!`-prefixed
|
|
88
|
+
* interrupt request. The gateway has already (or is about to) deliver
|
|
89
|
+
* the SIGINT to claude via tmux send-keys; the body of the message
|
|
90
|
+
* (post-`!`) is the user's replacement instruction. Without this
|
|
91
|
+
* carve-out, the body rots in pendingInboundBuffer because the
|
|
92
|
+
* SIGINT'd turn doesn't reliably emit turn_complete to drain the
|
|
93
|
+
* buffer. Optional + defaults false for backward compat. */
|
|
94
|
+
isInterrupt?: boolean
|
|
66
95
|
}
|
|
67
96
|
|
|
68
97
|
export type InboundDeliveryDecision =
|
|
@@ -73,13 +102,17 @@ export type InboundDeliveryDecision =
|
|
|
73
102
|
| 'buffer-until-idle'
|
|
74
103
|
|
|
75
104
|
/**
|
|
76
|
-
* Pure.
|
|
77
|
-
*
|
|
78
|
-
*
|
|
105
|
+
* Pure. Defers delivery ONLY when a turn is in flight AND this inbound
|
|
106
|
+
* is neither steering nor an interrupt. Idle → deliver. Steering → deliver
|
|
107
|
+
* (intentional mid-turn redirect). Interrupt → deliver (the `!`
|
|
108
|
+
* carve-out — see header doc; the killed turn may never drain the
|
|
109
|
+
* buffer, so we must not buffer in the first place).
|
|
79
110
|
*/
|
|
80
111
|
export function decideInboundDelivery(
|
|
81
112
|
input: InboundDeliveryGateInput,
|
|
82
113
|
): InboundDeliveryDecision {
|
|
83
|
-
if (input.
|
|
114
|
+
if (input.isSteering) return 'deliver'
|
|
115
|
+
if (input.isInterrupt === true) return 'deliver'
|
|
116
|
+
if (input.turnInFlight) return 'buffer-until-idle'
|
|
84
117
|
return 'deliver'
|
|
85
118
|
}
|