switchroom 0.13.19 → 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 +201 -31
- package/telegram-plugin/gateway/disconnect-flush.ts +37 -0
- package/telegram-plugin/gateway/gateway.ts +138 -8
- 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 +22 -0
- 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/tests/text-voice-scrub.test.ts +174 -0
- package/telegram-plugin/text-voice-scrub.ts +199 -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
|
}
|
|
@@ -40355,6 +40359,90 @@ function getOpenTags(html) {
|
|
|
40355
40359
|
return tagStack;
|
|
40356
40360
|
}
|
|
40357
40361
|
|
|
40362
|
+
// text-voice-scrub.ts
|
|
40363
|
+
var NULL = "\x00";
|
|
40364
|
+
var FENCE_PH = `${NULL}VS_FENCE`;
|
|
40365
|
+
var INLINE_PH = `${NULL}VS_INLINE`;
|
|
40366
|
+
var HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
|
|
40367
|
+
var HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
|
|
40368
|
+
var URL_PH = `${NULL}VS_URL`;
|
|
40369
|
+
var URL_RE = /https?:\/\/\S+/g;
|
|
40370
|
+
function enabled4() {
|
|
40371
|
+
const v = process.env.SWITCHROOM_DISABLE_VOICE_SCRUB;
|
|
40372
|
+
return !(v === "1" || v === "true");
|
|
40373
|
+
}
|
|
40374
|
+
function park(text) {
|
|
40375
|
+
const parts = [];
|
|
40376
|
+
let parked = text;
|
|
40377
|
+
parked = parked.replace(/```[\s\S]*?```/g, (m) => {
|
|
40378
|
+
const idx = parts.length;
|
|
40379
|
+
parts.push({ prefix: FENCE_PH, idx, raw: m });
|
|
40380
|
+
return `${FENCE_PH}${idx}${NULL}`;
|
|
40381
|
+
});
|
|
40382
|
+
parked = parked.replace(/<pre>[\s\S]*?<\/pre>/gi, (m) => {
|
|
40383
|
+
const idx = parts.length;
|
|
40384
|
+
parts.push({ prefix: HTML_PRE_PH, idx, raw: m });
|
|
40385
|
+
return `${HTML_PRE_PH}${idx}${NULL}`;
|
|
40386
|
+
});
|
|
40387
|
+
parked = parked.replace(/<code[^>]*>[\s\S]*?<\/code>/gi, (m) => {
|
|
40388
|
+
const idx = parts.length;
|
|
40389
|
+
parts.push({ prefix: HTML_CODE_PH, idx, raw: m });
|
|
40390
|
+
return `${HTML_CODE_PH}${idx}${NULL}`;
|
|
40391
|
+
});
|
|
40392
|
+
parked = parked.replace(/`[^`\n]+`/g, (m) => {
|
|
40393
|
+
const idx = parts.length;
|
|
40394
|
+
parts.push({ prefix: INLINE_PH, idx, raw: m });
|
|
40395
|
+
return `${INLINE_PH}${idx}${NULL}`;
|
|
40396
|
+
});
|
|
40397
|
+
parked = parked.replace(URL_RE, (m) => {
|
|
40398
|
+
const idx = parts.length;
|
|
40399
|
+
parts.push({ prefix: URL_PH, idx, raw: m });
|
|
40400
|
+
return `${URL_PH}${idx}${NULL}`;
|
|
40401
|
+
});
|
|
40402
|
+
return { parked, parts };
|
|
40403
|
+
}
|
|
40404
|
+
function restore(text, parts) {
|
|
40405
|
+
let restored = text;
|
|
40406
|
+
for (let i = parts.length - 1;i >= 0; i--) {
|
|
40407
|
+
const p = parts[i];
|
|
40408
|
+
restored = restored.replace(`${p.prefix}${p.idx}${NULL}`, () => p.raw);
|
|
40409
|
+
}
|
|
40410
|
+
return restored;
|
|
40411
|
+
}
|
|
40412
|
+
function replaceDashes(text) {
|
|
40413
|
+
let replaced = 0;
|
|
40414
|
+
let out = text;
|
|
40415
|
+
out = out.replace(/(\S) [\u2014\u2013] (\S)/g, (_m, before, after) => {
|
|
40416
|
+
replaced++;
|
|
40417
|
+
const sentenceStart = /[A-Z]/.test(after);
|
|
40418
|
+
return sentenceStart ? `${before}. ${after}` : `${before}, ${after}`;
|
|
40419
|
+
});
|
|
40420
|
+
out = out.replace(/ [\u2014\u2013](\s*\n)/g, (_m, ws) => {
|
|
40421
|
+
replaced++;
|
|
40422
|
+
return `.${ws}`;
|
|
40423
|
+
});
|
|
40424
|
+
out = out.replace(/(\w)[\u2014\u2013](\w)/g, (_m, before, after) => {
|
|
40425
|
+
replaced++;
|
|
40426
|
+
return `${before}, ${after}`;
|
|
40427
|
+
});
|
|
40428
|
+
out = out.replace(/[\u2014\u2013]/g, () => {
|
|
40429
|
+
replaced++;
|
|
40430
|
+
return "-";
|
|
40431
|
+
});
|
|
40432
|
+
return { out, replaced };
|
|
40433
|
+
}
|
|
40434
|
+
function scrubVoice(text) {
|
|
40435
|
+
if (!enabled4() || text.length === 0) {
|
|
40436
|
+
return { scrubbed: text, replaced: 0 };
|
|
40437
|
+
}
|
|
40438
|
+
const { parked, parts } = park(text);
|
|
40439
|
+
const { out, replaced } = replaceDashes(parked);
|
|
40440
|
+
if (replaced === 0) {
|
|
40441
|
+
return { scrubbed: text, replaced: 0 };
|
|
40442
|
+
}
|
|
40443
|
+
return { scrubbed: restore(out, parts), replaced };
|
|
40444
|
+
}
|
|
40445
|
+
|
|
40358
40446
|
// telegram-button-constraints.ts
|
|
40359
40447
|
var TELEGRAM_BUTTON_LIMITS = {
|
|
40360
40448
|
TEXT_MAX: 64,
|
|
@@ -40903,7 +40991,7 @@ function shouldShowHandoffLine() {
|
|
|
40903
40991
|
return v.toLowerCase() !== "false";
|
|
40904
40992
|
}
|
|
40905
40993
|
function formatHandoffLine(topic, format) {
|
|
40906
|
-
const prefix = "\u21a9\ufe0f Picked up where we left off
|
|
40994
|
+
const prefix = "\u21a9\ufe0f Picked up where we left off, ";
|
|
40907
40995
|
if (format === "html") {
|
|
40908
40996
|
return `<i>${prefix}${escapeHtml6(topic)}</i>
|
|
40909
40997
|
|
|
@@ -41072,6 +41160,7 @@ function flushOnAgentDisconnect(deps) {
|
|
|
41072
41160
|
activeDraftParseModes,
|
|
41073
41161
|
clearActiveReactions: clearActiveReactions3,
|
|
41074
41162
|
disposeProgressDriver,
|
|
41163
|
+
onDanglingTurnsSwept,
|
|
41075
41164
|
log
|
|
41076
41165
|
} = deps;
|
|
41077
41166
|
if (agentName3 == null) {
|
|
@@ -41085,6 +41174,15 @@ function flushOnAgentDisconnect(deps) {
|
|
|
41085
41174
|
activeTurnStartedAt.delete(key);
|
|
41086
41175
|
}
|
|
41087
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
|
+
}
|
|
41088
41186
|
disposeProgressDriver();
|
|
41089
41187
|
for (const [key, stream] of activeDraftStreams.entries()) {
|
|
41090
41188
|
if (!stream.isFinal())
|
|
@@ -44380,7 +44478,11 @@ function purgeStaleTurnsForChat(chatId, keys, purger) {
|
|
|
44380
44478
|
|
|
44381
44479
|
// gateway/inbound-delivery-gate.ts
|
|
44382
44480
|
function decideInboundDelivery(input) {
|
|
44383
|
-
if (input.
|
|
44481
|
+
if (input.isSteering)
|
|
44482
|
+
return "deliver";
|
|
44483
|
+
if (input.isInterrupt === true)
|
|
44484
|
+
return "deliver";
|
|
44485
|
+
if (input.turnInFlight)
|
|
44384
44486
|
return "buffer-until-idle";
|
|
44385
44487
|
return "deliver";
|
|
44386
44488
|
}
|
|
@@ -44639,9 +44741,9 @@ function transition(state3, event) {
|
|
|
44639
44741
|
|
|
44640
44742
|
// gateway/inbound-delivery-machine-shadow.ts
|
|
44641
44743
|
var state3 = initialState();
|
|
44642
|
-
var
|
|
44744
|
+
var enabled5 = process.env.SWITCHROOM_DELIVERY_MACHINE_SHADOW !== "0";
|
|
44643
44745
|
function shadowEmit(event) {
|
|
44644
|
-
if (!
|
|
44746
|
+
if (!enabled5)
|
|
44645
44747
|
return [];
|
|
44646
44748
|
try {
|
|
44647
44749
|
const result = transition(state3, event);
|
|
@@ -44699,12 +44801,12 @@ function redeliverBufferedInbound2(buffer, agent, send, spool) {
|
|
|
44699
44801
|
}
|
|
44700
44802
|
|
|
44701
44803
|
// gateway/inbound-delivery-machine-dispatch.ts
|
|
44702
|
-
var
|
|
44804
|
+
var enabled6 = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== "0";
|
|
44703
44805
|
function isDispatchEnabled() {
|
|
44704
|
-
return
|
|
44806
|
+
return enabled6;
|
|
44705
44807
|
}
|
|
44706
44808
|
function dispatchEffects(effects, ctx) {
|
|
44707
|
-
if (!
|
|
44809
|
+
if (!enabled6)
|
|
44708
44810
|
return;
|
|
44709
44811
|
for (const effect of effects) {
|
|
44710
44812
|
dispatchOne(effect, ctx);
|
|
@@ -46618,7 +46720,7 @@ function backfillJsonlAgentId(db2, jsonlPath, agentId, log) {
|
|
|
46618
46720
|
db2.prepare("UPDATE subagents SET jsonl_agent_id = ? WHERE id = ?").run(agentId, candidate.id);
|
|
46619
46721
|
log?.(`subagent-watcher: backfill linked ${agentId} \u2192 ${candidate.id}`);
|
|
46620
46722
|
}
|
|
46621
|
-
function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, parentStateDir, onUnstall) {
|
|
46723
|
+
function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, parentStateDir, onUnstall, onFileVanished) {
|
|
46622
46724
|
try {
|
|
46623
46725
|
const stat = fs2.statSync(entry.filePath);
|
|
46624
46726
|
if (stat.size < tail.cursor) {
|
|
@@ -46723,6 +46825,12 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
|
|
|
46723
46825
|
}
|
|
46724
46826
|
tail.hasEmittedStart = startState.hasEmittedStart;
|
|
46725
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
|
+
}
|
|
46726
46834
|
log?.(`subagent-watcher: read error ${entry.agentId}: ${err.message}`);
|
|
46727
46835
|
}
|
|
46728
46836
|
}
|
|
@@ -46829,7 +46937,7 @@ function startSubagentWatcher(config) {
|
|
|
46829
46937
|
return;
|
|
46830
46938
|
readSubTail(entry2, t, nowFn(), (desc) => {
|
|
46831
46939
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
|
|
46832
|
-
}, fs2, log, db2, parentStateDir, config.onUnstall);
|
|
46940
|
+
}, fs2, log, db2, parentStateDir, config.onUnstall, cleanupTerminalAgent);
|
|
46833
46941
|
maybySendStateTransition(agentId);
|
|
46834
46942
|
});
|
|
46835
46943
|
} catch (err) {
|
|
@@ -47075,7 +47183,7 @@ function startSubagentWatcher(config) {
|
|
|
47075
47183
|
continue;
|
|
47076
47184
|
readSubTail(entry, tail, n, (desc) => {
|
|
47077
47185
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`);
|
|
47078
|
-
}, fs2, log, db2, parentStateDir, config.onUnstall);
|
|
47186
|
+
}, fs2, log, db2, parentStateDir, config.onUnstall, cleanupTerminalAgent);
|
|
47079
47187
|
maybySendStateTransition(agentId);
|
|
47080
47188
|
}
|
|
47081
47189
|
checkStalls();
|
|
@@ -48207,10 +48315,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
48207
48315
|
}
|
|
48208
48316
|
|
|
48209
48317
|
// ../src/build-info.ts
|
|
48210
|
-
var VERSION = "0.13.
|
|
48211
|
-
var COMMIT_SHA = "
|
|
48212
|
-
var COMMIT_DATE = "2026-05-
|
|
48213
|
-
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;
|
|
48214
48322
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
48215
48323
|
|
|
48216
48324
|
// gateway/boot-version.ts
|
|
@@ -49994,6 +50102,12 @@ startTimer({
|
|
|
49994
50102
|
emitRuntimeMetric(event);
|
|
49995
50103
|
},
|
|
49996
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
|
+
}
|
|
49997
50111
|
let text = null;
|
|
49998
50112
|
const upd = inFlightUpdate;
|
|
49999
50113
|
if (upd != null) {
|
|
@@ -50260,6 +50374,13 @@ var ipcServer = createIpcServer({
|
|
|
50260
50374
|
disposeProgressDriver: () => {
|
|
50261
50375
|
progressDriver?.dispose?.({ preservePending: true });
|
|
50262
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
|
+
},
|
|
50263
50384
|
log: (msg) => process.stderr.write(`${msg}
|
|
50264
50385
|
`)
|
|
50265
50386
|
});
|
|
@@ -50650,11 +50771,23 @@ async function executeReply(args) {
|
|
|
50650
50771
|
if (rawText == null || rawText === "")
|
|
50651
50772
|
throw new Error("reply: text is required and cannot be empty");
|
|
50652
50773
|
let text = repairEscapedWhitespace(rawText);
|
|
50774
|
+
{
|
|
50775
|
+
const scrub = scrubVoice(text);
|
|
50776
|
+
if (scrub.replaced > 0) {
|
|
50777
|
+
text = scrub.scrubbed;
|
|
50778
|
+
emitRuntimeMetric({
|
|
50779
|
+
kind: "voice_scrub_applied",
|
|
50780
|
+
chatKey: statusKey(chat_id, args.message_thread_id != null ? Number(args.message_thread_id) : undefined),
|
|
50781
|
+
replaced: scrub.replaced,
|
|
50782
|
+
site: "reply"
|
|
50783
|
+
});
|
|
50784
|
+
}
|
|
50785
|
+
}
|
|
50653
50786
|
process.stderr.write(`telegram channel: reply: invoked chatId=${chat_id} charCount=${text.length} preview=${JSON.stringify(text.slice(0, 80))}
|
|
50654
50787
|
`);
|
|
50655
50788
|
{
|
|
50656
50789
|
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
50657
|
-
const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now());
|
|
50790
|
+
const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now(), currentTurn?.registryKey ?? null);
|
|
50658
50791
|
if (dup != null) {
|
|
50659
50792
|
process.stderr.write(`telegram gateway: reply: deduped (#546) chatId=${chat_id} ageMs=${dup.ageMs} preview=${JSON.stringify(dup.preview)}
|
|
50660
50793
|
`);
|
|
@@ -50845,7 +50978,7 @@ ${url}`;
|
|
|
50845
50978
|
})) {
|
|
50846
50979
|
turn2.finalAnswerDelivered = true;
|
|
50847
50980
|
}
|
|
50848
|
-
outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now());
|
|
50981
|
+
outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now(), turn2?.registryKey ?? null);
|
|
50849
50982
|
silentAnchorEditDone = true;
|
|
50850
50983
|
} catch (err) {
|
|
50851
50984
|
process.stderr.write(`telegram gateway: silent-reply auto-edit failed, falling back to fresh send: ${err instanceof Error ? err.message : String(err)}
|
|
@@ -51052,7 +51185,7 @@ ${url}`;
|
|
|
51052
51185
|
process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(",")}] chunks=${chunks.length}
|
|
51053
51186
|
`);
|
|
51054
51187
|
if (sentIds.length > 0) {
|
|
51055
|
-
outboundDedup.record(chat_id, threadId, text, Date.now());
|
|
51188
|
+
outboundDedup.record(chat_id, threadId, text, Date.now(), currentTurn?.registryKey ?? null);
|
|
51056
51189
|
}
|
|
51057
51190
|
return { content: [{ type: "text", text: result }] };
|
|
51058
51191
|
}
|
|
@@ -51062,11 +51195,23 @@ async function executeStreamReply(args) {
|
|
|
51062
51195
|
throw new Error("stream_reply: chat_id is required");
|
|
51063
51196
|
if (args.text == null || args.text === "")
|
|
51064
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
|
+
}
|
|
51065
51210
|
if (args.done === true) {
|
|
51066
51211
|
const sChatId = args.chat_id;
|
|
51067
51212
|
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
51068
51213
|
const sText = args.text;
|
|
51069
|
-
const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now());
|
|
51214
|
+
const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now(), currentTurn?.registryKey ?? null);
|
|
51070
51215
|
if (dup != null) {
|
|
51071
51216
|
process.stderr.write(`telegram gateway: stream_reply: deduped (#546) chatId=${sChatId} ageMs=${dup.ageMs} preview=${JSON.stringify(dup.preview)}
|
|
51072
51217
|
`);
|
|
@@ -51158,7 +51303,7 @@ async function executeStreamReply(args) {
|
|
|
51158
51303
|
if (args.done === true && result.messageId != null) {
|
|
51159
51304
|
const sChatId = args.chat_id;
|
|
51160
51305
|
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
51161
|
-
outboundDedup.record(sChatId, sThreadId, args.text, Date.now());
|
|
51306
|
+
outboundDedup.record(sChatId, sThreadId, args.text, Date.now(), currentTurn?.registryKey ?? null);
|
|
51162
51307
|
noteOutbound3(statusKey(sChatId, sThreadId), {
|
|
51163
51308
|
messageId: result.messageId,
|
|
51164
51309
|
text: args.text
|
|
@@ -51710,7 +51855,19 @@ async function executeEditMessage(args) {
|
|
|
51710
51855
|
const editAccess = loadAccess();
|
|
51711
51856
|
const editConfigMode = editAccess.parseMode ?? "html";
|
|
51712
51857
|
const editFormat = args.format ?? editConfigMode;
|
|
51713
|
-
|
|
51858
|
+
let editRawText = repairEscapedWhitespace(args.text);
|
|
51859
|
+
{
|
|
51860
|
+
const scrub = scrubVoice(editRawText);
|
|
51861
|
+
if (scrub.replaced > 0) {
|
|
51862
|
+
editRawText = scrub.scrubbed;
|
|
51863
|
+
emitRuntimeMetric({
|
|
51864
|
+
kind: "voice_scrub_applied",
|
|
51865
|
+
chatKey: statusKey(args.chat_id, undefined),
|
|
51866
|
+
replaced: scrub.replaced,
|
|
51867
|
+
site: "edit_message"
|
|
51868
|
+
});
|
|
51869
|
+
}
|
|
51870
|
+
}
|
|
51714
51871
|
let editParseMode;
|
|
51715
51872
|
let editText;
|
|
51716
51873
|
if (editFormat === "html") {
|
|
@@ -52058,10 +52215,10 @@ function handleSessionEvent(ev) {
|
|
|
52058
52215
|
}
|
|
52059
52216
|
},
|
|
52060
52217
|
checkDedup: (text) => {
|
|
52061
|
-
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;
|
|
52062
52219
|
},
|
|
52063
52220
|
recordDedup: (text) => {
|
|
52064
|
-
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now());
|
|
52221
|
+
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, text, Date.now(), turn.registryKey ?? null);
|
|
52065
52222
|
},
|
|
52066
52223
|
recordOutbound: ({ messageId, text }) => {
|
|
52067
52224
|
if (!HISTORY_ENABLED)
|
|
@@ -52152,7 +52309,7 @@ function handleSessionEvent(ev) {
|
|
|
52152
52309
|
streamFinalizedAsAnswer = true;
|
|
52153
52310
|
turn.finalAnswerDelivered = true;
|
|
52154
52311
|
try {
|
|
52155
|
-
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, streamedFinalText, Date.now());
|
|
52312
|
+
outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, streamedFinalText, Date.now(), turn.registryKey ?? null);
|
|
52156
52313
|
} catch {}
|
|
52157
52314
|
if (HISTORY_ENABLED) {
|
|
52158
52315
|
try {
|
|
@@ -52252,10 +52409,22 @@ function handleSessionEvent(ev) {
|
|
|
52252
52409
|
return;
|
|
52253
52410
|
}
|
|
52254
52411
|
if (flushDecision.kind === "flush") {
|
|
52255
|
-
|
|
52412
|
+
let capturedText = flushDecision.text;
|
|
52256
52413
|
const backstopChatId = chatId;
|
|
52257
52414
|
const backstopThreadId = threadId;
|
|
52258
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
|
+
}
|
|
52259
52428
|
turn.finalAnswerDelivered = true;
|
|
52260
52429
|
const cardTakeover = progressDriver?.takeOverCard({
|
|
52261
52430
|
chatId: backstopChatId,
|
|
@@ -52347,7 +52516,7 @@ function handleSessionEvent(ev) {
|
|
|
52347
52516
|
});
|
|
52348
52517
|
} catch {}
|
|
52349
52518
|
}
|
|
52350
|
-
outboundDedup.record(backstopChatId, backstopThreadId, capturedText, Date.now());
|
|
52519
|
+
outboundDedup.record(backstopChatId, backstopThreadId, capturedText, Date.now(), currentTurn?.registryKey ?? null);
|
|
52351
52520
|
if (backstopCtrl)
|
|
52352
52521
|
backstopCtrl.setDone();
|
|
52353
52522
|
if (backstopCardTurnKey != null) {
|
|
@@ -53237,7 +53406,8 @@ ${preBlock(write.output)}`;
|
|
|
53237
53406
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
53238
53407
|
if (decideInboundDelivery({
|
|
53239
53408
|
turnInFlight: turnInFlightAtReceipt,
|
|
53240
|
-
isSteering
|
|
53409
|
+
isSteering,
|
|
53410
|
+
isInterrupt: interrupt.isInterrupt
|
|
53241
53411
|
}) === "buffer-until-idle") {
|
|
53242
53412
|
pendingInboundBuffer.push(selfAgent, inboundMsg);
|
|
53243
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
|