switchroom 0.14.42 → 0.14.44

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.
Files changed (25) hide show
  1. package/dist/cli/switchroom.js +2 -2
  2. package/package.json +1 -1
  3. package/telegram-plugin/dist/gateway/gateway.js +183 -17
  4. package/telegram-plugin/gateway/gateway.ts +100 -29
  5. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +22 -0
  6. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +13 -0
  7. package/telegram-plugin/gateway/turn-state-purge.ts +14 -0
  8. package/telegram-plugin/silence-poke.ts +26 -0
  9. package/telegram-plugin/status-reactions.ts +14 -0
  10. package/telegram-plugin/subagent-watcher.ts +44 -0
  11. package/telegram-plugin/tests/silence-poke.test.ts +36 -0
  12. package/telegram-plugin/tests/status-reactions.test.ts +16 -0
  13. package/telegram-plugin/tests/subagent-handback-decision.test.ts +32 -0
  14. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +35 -0
  15. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +56 -0
  16. package/telegram-plugin/tests/subagent-watcher.test.ts +42 -0
  17. package/telegram-plugin/tests/turn-state-purge.test.ts +28 -0
  18. package/telegram-plugin/uat/driver.ts +41 -0
  19. package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +17 -10
  20. package/telegram-plugin/uat/scenarios/fuzz-supergroup-channel.test.ts +141 -0
  21. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-channel.test.ts +104 -0
  22. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +9 -7
  23. package/telegram-plugin/uat/scenarios/jtbd-supergroup-handback-channel.test.ts +77 -0
  24. package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +102 -0
  25. package/telegram-plugin/uat/scenarios/jtbd-worker-activity-feed-channel.test.ts +114 -0
@@ -49462,8 +49462,8 @@ var {
49462
49462
  } = import__.default;
49463
49463
 
49464
49464
  // src/build-info.ts
49465
- var VERSION = "0.14.42";
49466
- var COMMIT_SHA = "6da4313d";
49465
+ var VERSION = "0.14.44";
49466
+ var COMMIT_SHA = "90836113";
49467
49467
 
49468
49468
  // src/cli/agent.ts
49469
49469
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.42",
3
+ "version": "0.14.44",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32244,6 +32244,7 @@ class StatusReactionController {
32244
32244
  stallHardTimer = null;
32245
32245
  finished = false;
32246
32246
  held = false;
32247
+ awaitingApproval = false;
32247
32248
  debounceMs;
32248
32249
  stallSoftMs;
32249
32250
  stallHardMs;
@@ -32305,9 +32306,13 @@ class StatusReactionController {
32305
32306
  this.enqueue(working);
32306
32307
  }
32307
32308
  }
32309
+ isAwaiting() {
32310
+ return this.awaitingApproval && !this.finished;
32311
+ }
32308
32312
  scheduleState(state, opts = {}) {
32309
32313
  if (this.finished)
32310
32314
  return;
32315
+ this.awaitingApproval = state === "awaiting";
32311
32316
  const emoji = this.resolveEmoji(state);
32312
32317
  if (emoji == null) {
32313
32318
  if (!opts.skipStallReset)
@@ -38957,8 +38962,17 @@ function noteToolEnd(key, toolUseId, _now) {
38957
38962
  function endTurn(key) {
38958
38963
  state2.delete(key);
38959
38964
  }
38960
- function formatFrameworkFallbackText(fallbackKind, silenceMs, inFlightTools = []) {
38965
+ function silenceMsForKey(key, now) {
38966
+ const s = state2.get(key);
38967
+ if (s == null)
38968
+ return null;
38969
+ return now - (s.lastOutboundAt ?? s.turnStartedAt);
38970
+ }
38971
+ function formatFrameworkFallbackText(fallbackKind, silenceMs, inFlightTools = [], blockedOnApproval = false) {
38961
38972
  const minutes = Math.max(1, Math.round(silenceMs / 60000));
38973
+ if (blockedOnApproval) {
38974
+ return `waiting for your approval \u2014 tap Approve or Deny on the card above (${minutes} min)`;
38975
+ }
38962
38976
  const suffix = `(no update from agent in ${minutes} min)`;
38963
38977
  if (inFlightTools.length > 0) {
38964
38978
  const longest = inFlightTools[0];
@@ -47234,7 +47248,7 @@ function createInboundSpool(opts) {
47234
47248
  }
47235
47249
 
47236
47250
  // gateway/turn-state-purge.ts
47237
- function purgeStaleTurnsForChat(chatId, keys, purger) {
47251
+ function purgeStaleTurnsForChat(chatId, keys, purger, isStale = () => true) {
47238
47252
  if (!chatId)
47239
47253
  return { purged: [] };
47240
47254
  const purged = [];
@@ -47248,6 +47262,8 @@ function purgeStaleTurnsForChat(chatId, keys, purger) {
47248
47262
  const keyChat = key.slice(0, sep3);
47249
47263
  if (keyChat !== chatId)
47250
47264
  continue;
47265
+ if (!isStale(key))
47266
+ continue;
47251
47267
  purger(key);
47252
47268
  purged.push(key);
47253
47269
  }
@@ -47942,6 +47958,7 @@ ${result}
47942
47958
  return {
47943
47959
  type: "inbound",
47944
47960
  chatId: opts.ctx.chatId,
47961
+ ...opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {},
47945
47962
  messageId: ts,
47946
47963
  user: "subagent-watcher",
47947
47964
  userId: 0,
@@ -47950,6 +47967,7 @@ ${result}
47950
47967
  meta: {
47951
47968
  source: "subagent_handback",
47952
47969
  outcome: opts.ctx.outcome,
47970
+ ...opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {},
47953
47971
  ...opts.ctx.jsonlAgentId ? { subagent_jsonl_id: opts.ctx.jsonlAgentId } : {}
47954
47972
  }
47955
47973
  };
@@ -47968,9 +47986,11 @@ function decideSubagentHandback(input) {
47968
47986
  if (!chatId) {
47969
47987
  return { deliver: false, reason: "no-chat" };
47970
47988
  }
47989
+ const threadId = input.fleetChatId && input.originThreadId != null ? input.originThreadId : undefined;
47971
47990
  const inbound = buildSubagentHandbackInbound({
47972
47991
  ctx: {
47973
47992
  chatId,
47993
+ ...threadId != null ? { threadId } : {},
47974
47994
  taskDescription: input.taskDescription,
47975
47995
  resultText: input.resultText,
47976
47996
  outcome: input.outcome,
@@ -48017,6 +48037,7 @@ ${summary}
48017
48037
  return {
48018
48038
  type: "inbound",
48019
48039
  chatId: opts.ctx.chatId,
48040
+ ...opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {},
48020
48041
  messageId: ts,
48021
48042
  user: "subagent-watcher",
48022
48043
  userId: 0,
@@ -48024,6 +48045,7 @@ ${summary}
48024
48045
  text,
48025
48046
  meta: {
48026
48047
  source: "subagent_progress",
48048
+ ...opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {},
48027
48049
  subagent_jsonl_id: opts.ctx.subagentJsonlId,
48028
48050
  bucket_idx: String(opts.ctx.bucketIdx),
48029
48051
  expiresAt: String(expiresAt),
@@ -48062,9 +48084,11 @@ function decideSubagentProgress(input) {
48062
48084
  if (input.lastBucketIdx != null && bucketIdx <= input.lastBucketIdx) {
48063
48085
  return { deliver: false, reason: "bucket-already-fired" };
48064
48086
  }
48087
+ const threadId = input.fleetChatId && input.originThreadId != null ? input.originThreadId : undefined;
48065
48088
  const inbound = buildSubagentProgressInbound({
48066
48089
  ctx: {
48067
48090
  chatId,
48091
+ ...threadId != null ? { threadId } : {},
48068
48092
  subagentJsonlId: input.subagentJsonlId,
48069
48093
  taskDescription: input.taskDescription,
48070
48094
  latestSummary: input.latestSummary,
@@ -49498,6 +49522,113 @@ function redactSecrets(text) {
49498
49522
  out = out.replace(re, "[redacted]");
49499
49523
  return out;
49500
49524
  }
49525
+
49526
+ // tool-activity-summary.ts
49527
+ function baseName(p) {
49528
+ if (typeof p !== "string" || p.length === 0)
49529
+ return null;
49530
+ const parts = p.split("/").filter(Boolean);
49531
+ return parts.length > 0 ? parts[parts.length - 1] : p;
49532
+ }
49533
+ function hostName(u) {
49534
+ if (typeof u !== "string" || u.length === 0)
49535
+ return null;
49536
+ try {
49537
+ return new URL(u).hostname.replace(/^www\./, "");
49538
+ } catch {
49539
+ return u.replace(/^https?:\/\//, "").split("/")[0] || null;
49540
+ }
49541
+ }
49542
+ function clip(s, n) {
49543
+ if (typeof s !== "string")
49544
+ return null;
49545
+ const t = s.trim();
49546
+ if (t.length === 0)
49547
+ return null;
49548
+ return t.length > n ? t.slice(0, n - 1) + "\u2026" : t;
49549
+ }
49550
+ function describeToolUse(toolName, input) {
49551
+ if (!toolName)
49552
+ return null;
49553
+ const inp = input ?? {};
49554
+ const mcpMatch = /^mcp__(.+?)__(.+)$/.exec(toolName);
49555
+ if (mcpMatch) {
49556
+ const server = mcpMatch[1].toLowerCase();
49557
+ const tool = mcpMatch[2].toLowerCase();
49558
+ if (server === "switchroom-telegram")
49559
+ return null;
49560
+ if (server === "hindsight") {
49561
+ if (tool === "recall" || tool === "reflect")
49562
+ return "Searching memory";
49563
+ if (tool === "retain" || tool === "update_memory" || tool === "sync_retain")
49564
+ return "Saving to memory";
49565
+ return "Working with memory";
49566
+ }
49567
+ if (server === "google-workspace" || server === "claude_ai_google_calendar") {
49568
+ return "Checking your calendar";
49569
+ }
49570
+ if (server === "claude_ai_gmail")
49571
+ return "Checking your email";
49572
+ if (server === "claude_ai_google_drive")
49573
+ return "Looking through your files";
49574
+ if (server === "notion" || server === "claude_ai_notion") {
49575
+ return "Checking your notes";
49576
+ }
49577
+ const desc = clip(inp.description, 60) ?? clip(inp.query, 50) ?? clip(inp.title, 50);
49578
+ if (desc)
49579
+ return desc;
49580
+ return "Using " + tool.replace(/[-_]+/g, " ");
49581
+ }
49582
+ switch (toolName) {
49583
+ case "Bash": {
49584
+ return clip(inp.description, 70) ?? "Running a command";
49585
+ }
49586
+ case "BashOutput":
49587
+ case "KillShell":
49588
+ return "Managing a background command";
49589
+ case "Read": {
49590
+ const f = baseName(inp.file_path);
49591
+ return f ? `Reading ${f}` : "Reading a file";
49592
+ }
49593
+ case "Edit":
49594
+ case "MultiEdit":
49595
+ case "NotebookEdit": {
49596
+ const f = baseName(inp.file_path) ?? baseName(inp.notebook_path);
49597
+ return f ? `Editing ${f}` : "Editing a file";
49598
+ }
49599
+ case "Write": {
49600
+ const f = baseName(inp.file_path);
49601
+ return f ? `Writing ${f}` : "Writing a file";
49602
+ }
49603
+ case "Grep":
49604
+ case "Glob": {
49605
+ const p = clip(inp.pattern, 40);
49606
+ return p ? `Searching for ${p}` : "Searching files";
49607
+ }
49608
+ case "WebFetch": {
49609
+ const h = hostName(inp.url);
49610
+ return h ? `Reading ${h}` : "Reading a web page";
49611
+ }
49612
+ case "WebSearch": {
49613
+ const q = clip(inp.query, 50);
49614
+ return q ? `Searching the web for ${q}` : "Searching the web";
49615
+ }
49616
+ case "Task":
49617
+ case "Agent": {
49618
+ const d = clip(inp.description, 60);
49619
+ return d ? `Delegating: ${d}` : "Delegating to a sub-agent";
49620
+ }
49621
+ case "TodoWrite":
49622
+ case "TaskCreate":
49623
+ case "TaskUpdate":
49624
+ case "TaskList":
49625
+ return "Updating the plan";
49626
+ case "ToolSearch":
49627
+ return "Finding the right tool";
49628
+ default:
49629
+ return "Working\u2026";
49630
+ }
49631
+ }
49501
49632
  // registry/subagents-schema.ts
49502
49633
  function countRunningBackgroundSubagents(db2) {
49503
49634
  const row = db2.prepare("SELECT count(*) AS n FROM subagents WHERE background = 1 AND status = 'running'").get();
@@ -49745,6 +49876,28 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
49745
49876
  name: ev.toolName,
49746
49877
  sanitisedArg: sanitiseToolArg(ev.toolName, ev.input ?? {})
49747
49878
  };
49879
+ if (onProgress != null && entry.state === "running" && !entry.historical) {
49880
+ const toolLine = describeToolUse(ev.toolName, ev.input ?? {});
49881
+ if (toolLine != null && toolLine.length > 0) {
49882
+ try {
49883
+ onProgress({
49884
+ agentId: entry.agentId,
49885
+ description: entry.description,
49886
+ latestSummary: entry.lastResultText,
49887
+ elapsedMs: now - entry.dispatchedAt,
49888
+ prevBucketIdx: entry.lastProgressBucketIdx,
49889
+ setBucketIdx: (b) => {
49890
+ entry.lastProgressBucketIdx = b;
49891
+ },
49892
+ lastTool: entry.lastTool,
49893
+ toolCount: entry.toolCount,
49894
+ progressLine: toolLine
49895
+ });
49896
+ } catch (cbErr) {
49897
+ log?.(`subagent-watcher: onProgress (tool) callback error ${entry.agentId}: ${cbErr.message}`);
49898
+ }
49899
+ }
49900
+ }
49748
49901
  } else if (ev.kind === "sub_agent_text") {
49749
49902
  entry.lastSummaryLine = ev.text.split(`
49750
49903
  `)[0].trim().slice(0, 120);
@@ -51871,10 +52024,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51871
52024
  }
51872
52025
 
51873
52026
  // ../src/build-info.ts
51874
- var VERSION = "0.14.42";
51875
- var COMMIT_SHA = "6da4313d";
51876
- var COMMIT_DATE = "2026-06-02T22:14:58Z";
51877
- var LATEST_PR = 2097;
52027
+ var VERSION = "0.14.44";
52028
+ var COMMIT_SHA = "90836113";
52029
+ var COMMIT_DATE = "2026-06-03T02:30:59Z";
52030
+ var LATEST_PR = 2110;
51878
52031
  var COMMITS_AHEAD_OF_TAG = 0;
51879
52032
 
51880
52033
  // gateway/boot-version.ts
@@ -54096,7 +54249,8 @@ startTimer({
54096
54249
  } catch {}
54097
54250
  }
54098
54251
  if (text == null) {
54099
- text = formatFrameworkFallbackText(ctx.fallbackKind, ctx.silenceMs, ctx.inFlightTools);
54252
+ const blockedOnApproval = activeStatusReactions.get(statusKey(ctx.chatId, ctx.threadId))?.isAwaiting() ?? false;
54253
+ text = formatFrameworkFallbackText(ctx.fallbackKind, ctx.silenceMs, ctx.inFlightTools, blockedOnApproval);
54100
54254
  }
54101
54255
  try {
54102
54256
  await robustApiCall(() => bot.api.sendMessage(ctx.chatId, text, {
@@ -54155,7 +54309,13 @@ startTimer({
54155
54309
  endTurn(fbKey);
54156
54310
  noteTurnEnd(fbKey);
54157
54311
  purgeReactionTracking(fbKey);
54158
- const fbExtraPurge = purgeStaleTurnsForChat(fbChatId, activeTurnStartedAt.keys(), purgeReactionTracking);
54312
+ const fbNow = Date.now();
54313
+ const fbExtraPurge = purgeStaleTurnsForChat(fbChatId, activeTurnStartedAt.keys(), purgeReactionTracking, (siblingKey) => {
54314
+ if (siblingKey === fbKey)
54315
+ return true;
54316
+ const sib = silenceMsForKey(siblingKey, fbNow);
54317
+ return sib == null || sib >= DEFAULT_THRESHOLDS.fallback;
54318
+ });
54159
54319
  if (turnMatchesFallback && currentTurn === wedgedTurn)
54160
54320
  currentTurn = null;
54161
54321
  try {
@@ -55075,7 +55235,7 @@ ${url}`;
55075
55235
  effectiveText = text;
55076
55236
  }
55077
55237
  assertAllowedChat(chat_id);
55078
- let threadId = resolveThreadId(chat_id, args.message_thread_id);
55238
+ let threadId = resolveThreadId(chat_id, args.message_thread_id ?? (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined));
55079
55239
  if (reply_to == null && quoteOptIn && HISTORY_ENABLED) {
55080
55240
  try {
55081
55241
  const latest = getLatestInboundMessageId(chat_id, threadId ?? null);
@@ -55414,6 +55574,9 @@ async function executeStreamReply(args) {
55414
55574
  throw new Error("stream_reply: chat_id is required");
55415
55575
  if (args.text == null || args.text === "")
55416
55576
  throw new Error("stream_reply: text is required and cannot be empty");
55577
+ if (args.message_thread_id == null && turn?.sessionThreadId != null) {
55578
+ args.message_thread_id = String(turn.sessionThreadId);
55579
+ }
55417
55580
  args.text = redactOutboundText(args.text, "stream_reply");
55418
55581
  {
55419
55582
  const scrub = scrubVoice(args.text);
@@ -56676,13 +56839,12 @@ function handleSessionEvent(ev) {
56676
56839
  const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId));
56677
56840
  const name = ev.toolName;
56678
56841
  if (isTelegramReplyTool(name)) {
56679
- const wasFirstReply = !turn.replyCalled;
56680
56842
  turn.replyCalled = true;
56681
56843
  if (turn.orphanedReplyTimeoutId != null) {
56682
56844
  clearTimeout(turn.orphanedReplyTimeoutId);
56683
56845
  turn.orphanedReplyTimeoutId = null;
56684
56846
  }
56685
- if (wasFirstReply) {
56847
+ if (turn.finalAnswerDelivered) {
56686
56848
  clearActivitySummary(turn);
56687
56849
  }
56688
56850
  }
@@ -56702,7 +56864,7 @@ function handleSessionEvent(ev) {
56702
56864
  return;
56703
56865
  if (isTelegramSurfaceTool(ev.toolName))
56704
56866
  return;
56705
- if (turn.replyCalled)
56867
+ if (turn.finalAnswerDelivered)
56706
56868
  return;
56707
56869
  const rendered = appendActivityLabel(turn.mirrorLines, ev.label);
56708
56870
  if (rendered != null) {
@@ -62863,11 +63025,13 @@ var didOneTimeSetup = false;
62863
63025
  state: outcome === "failed" ? "failed" : "done"
62864
63026
  });
62865
63027
  }
63028
+ const handbackOrigin = resolveSubagentOriginChat(agentId);
62866
63029
  const decision = decideSubagentHandback({
62867
63030
  handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
62868
63031
  outcome,
62869
63032
  isBackground,
62870
- fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
63033
+ fleetChatId: handbackOrigin?.chatId || fleetChatId,
63034
+ ...handbackOrigin?.threadId != null ? { originThreadId: handbackOrigin.threadId } : {},
62871
63035
  ownerChatId: loadAccess().allowFrom[0] ?? "",
62872
63036
  taskDescription: description,
62873
63037
  resultText,
@@ -62895,7 +63059,7 @@ var didOneTimeSetup = false;
62895
63059
  process.stderr.write(`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}
62896
63060
  `);
62897
63061
  },
62898
- onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
63062
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine }) => {
62899
63063
  let fleetChatId = "";
62900
63064
  try {
62901
63065
  const fleets = progressDriver?.peekAllFleets() ?? [];
@@ -62922,7 +63086,7 @@ var didOneTimeSetup = false;
62922
63086
  replyCalled: turn.replyCalled
62923
63087
  }))
62924
63088
  return;
62925
- const child = latestSummary.trim().slice(0, 120);
63089
+ const child = (progressLine ?? latestSummary).trim().slice(0, 120);
62926
63090
  if (child.length === 0)
62927
63091
  return;
62928
63092
  let narrative = turn.foregroundSubAgents.get(agentId);
@@ -62957,10 +63121,12 @@ var didOneTimeSetup = false;
62957
63121
  }, origin?.threadId);
62958
63122
  return;
62959
63123
  }
63124
+ const progressOrigin = resolveSubagentOriginChat(agentId);
62960
63125
  const decision = decideSubagentProgress({
62961
63126
  disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
62962
63127
  isBackground,
62963
- fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
63128
+ fleetChatId: progressOrigin?.chatId || fleetChatId,
63129
+ ...progressOrigin?.threadId != null ? { originThreadId: progressOrigin.threadId } : {},
62964
63130
  ownerChatId: loadAccess().allowFrom[0] ?? "",
62965
63131
  subagentJsonlId: agentId,
62966
63132
  taskDescription: description,
@@ -62973,7 +63139,7 @@ var didOneTimeSetup = false;
62973
63139
  return;
62974
63140
  setBucketIdx(decision.bucketIdx);
62975
63141
  pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? "", decision.inbound);
62976
- clearPending(statusKey(decision.chatId, undefined), "progress");
63142
+ clearPending(statusKey(decision.chatId, progressOrigin?.threadId), "progress");
62977
63143
  process.stderr.write(`telegram gateway: subagent-progress queued agent=${agentId} bucket=${decision.bucketIdx} elapsed_ms=${elapsedMs} chat=${decision.chatId}
62978
63144
  `);
62979
63145
  }
@@ -3912,10 +3912,18 @@ silencePoke.startTimer({
3912
3912
  // (CC-4 in `docs/status-ask-cause-classes.md`). Derives "N min" suffix
3913
3913
  // from `ctx.silenceMs` so the wording stays honest if the 300s
3914
3914
  // threshold is tuned.
3915
+ // Honesty: if the turn is parked on an approval card (the dominant
3916
+ // benign "wedge" class — claude is alive, waiting on the operator's
3917
+ // tap), say so instead of "still working…". The reaction controller
3918
+ // already tracks this (setAwaiting on the permission-request park).
3919
+ const blockedOnApproval = activeStatusReactions
3920
+ .get(statusKey(ctx.chatId, ctx.threadId))
3921
+ ?.isAwaiting() ?? false
3915
3922
  text = silencePoke.formatFrameworkFallbackText(
3916
3923
  ctx.fallbackKind,
3917
3924
  ctx.silenceMs,
3918
3925
  ctx.inFlightTools,
3926
+ blockedOnApproval,
3919
3927
  )
3920
3928
  }
3921
3929
  try {
@@ -4033,16 +4041,29 @@ silencePoke.startTimer({
4033
4041
  // SAME chat (different threads, or a `null` vs `undefined`-thread
4034
4042
  // variant left over from a normal turn-end path that nulled
4035
4043
  // currentTurn without invoking purgeReactionTracking — the
4036
- // gymbro/klanker held-mid-turn symptom, 2026-05-20). Any sibling
4037
- // for fbChatId is by definition stale when THIS fallback fires
4038
- // (the chat has been silent ≥5 min); sweep them via the same
4039
- // purger. Multi-chat-safe — only touches keys for fbChatId, so
4044
+ // gymbro/klanker held-mid-turn symptom, 2026-05-20); sweep them via
4045
+ // the same purger. Multi-chat-safe only touches keys for fbChatId, so
4040
4046
  // #1546's intentional cross-chat safety guard is preserved.
4047
+ //
4048
+ // BUT a sibling is NOT "by definition stale": in one-agent-owns-supergroup
4049
+ // every forum topic shares fbChatId, so a chatId-only sweep would purge a
4050
+ // LIVE sibling topic's reaction controller + typing loop when THIS topic's
4051
+ // poke fires. Gate each sibling on its OWN silence clock — purge only those
4052
+ // also silent ≥ the fallback threshold (their own poke would fire too),
4053
+ // sparing topics that are actively mid-turn. Use silence, not turn-start
4054
+ // age, so a long-but-narrating turn isn't mistaken for stale.
4041
4055
  // See turn-state-purge.ts.
4056
+ const fbNow = Date.now()
4042
4057
  const fbExtraPurge = purgeStaleTurnsForChat(
4043
4058
  fbChatId,
4044
4059
  activeTurnStartedAt.keys(),
4045
4060
  purgeReactionTracking,
4061
+ (siblingKey) => {
4062
+ if (siblingKey === fbKey) return true // the firing key is genuinely stale
4063
+ const sib = silencePoke.silenceMsForKey(siblingKey, fbNow)
4064
+ // No silence-poke state → dangling (turn ended, key not purged) → stale.
4065
+ return sib == null || sib >= silencePoke.DEFAULT_THRESHOLDS.fallback
4066
+ },
4046
4067
  )
4047
4068
  // Null `currentTurn` if it's still pointing at the wedged turn —
4048
4069
  // when claude eventually fires a late `turn_end` for this session
@@ -5603,7 +5624,21 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
5603
5624
 
5604
5625
  assertAllowedChat(chat_id)
5605
5626
 
5606
- let threadId = resolveThreadId(chat_id, args.message_thread_id as string | undefined)
5627
+ // Thread resolution precedence: (1) an explicit message_thread_id the
5628
+ // model passed, else (2) THIS turn's own originating topic
5629
+ // (turn-pinned, #1664), else (3) the chat's last-seen topic
5630
+ // (chatThreadMap). Preferring the turn's own thread over the chat
5631
+ // last-seen heuristic fixes synthetic turns (subagent handback/progress,
5632
+ // cron) — whose topic the model is never told and which never write
5633
+ // chatThreadMap — and is strictly more correct under multi-topic
5634
+ // concurrency (a reply lands in the topic the turn came from, not
5635
+ // whichever topic most recently received a message). DM: both are
5636
+ // undefined → unchanged.
5637
+ let threadId = resolveThreadId(
5638
+ chat_id,
5639
+ (args.message_thread_id as string | undefined) ??
5640
+ (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined),
5641
+ )
5607
5642
 
5608
5643
  if (reply_to == null && quoteOptIn && HISTORY_ENABLED) {
5609
5644
  try {
@@ -6202,6 +6237,16 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
6202
6237
  const turn = currentTurn
6203
6238
  if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
6204
6239
  if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
6240
+ // Thread precedence (matches executeReply): when the model passes no
6241
+ // explicit message_thread_id, fall back to THIS turn's originating
6242
+ // topic before handleStreamReply's chatThreadMap last-seen heuristic.
6243
+ // Injecting here threads every downstream consumer consistently — the
6244
+ // dedup key, the voice-scrub metric, the draft transport, and the send
6245
+ // — so a streamed handback/synthetic-turn reply lands in the right
6246
+ // supergroup topic. DM: sessionThreadId undefined → unchanged.
6247
+ if (args.message_thread_id == null && turn?.sessionThreadId != null) {
6248
+ args.message_thread_id = String(turn.sessionThreadId)
6249
+ }
6205
6250
 
6206
6251
  // Outbound secret scrub (#2044): mask before the dedup key, the draft
6207
6252
  // stream sends, and the history record. stream_reply carries the FULL
@@ -8130,17 +8175,19 @@ function handleSessionEvent(ev: SessionEvent): void {
8130
8175
  // Phase tracking removed in #553 PR 5 — phases only fed the
8131
8176
  // placeholder-heartbeat label, which has been retired.
8132
8177
  if (isTelegramReplyTool(name)) {
8133
- const wasFirstReply = !turn.replyCalled
8134
8178
  turn.replyCalled = true
8135
8179
  if (turn.orphanedReplyTimeoutId != null) {
8136
8180
  clearTimeout(turn.orphanedReplyTimeoutId)
8137
8181
  turn.orphanedReplyTimeoutId = null
8138
8182
  }
8139
- // The model's real reply takes over as the authoritative
8140
- // surface, so delete the activity feed message the user
8141
- // sees the real reply land in the same beat the feed
8142
- // disappears. turn_end is the no-reply safety net.
8143
- if (wasFirstReply) {
8183
+ // Delete the activity feed only when the FINAL answer has landed —
8184
+ // NOT on an ack-first interim reply ("On it"). Gating on the first
8185
+ // reply deleted the feed on the ack, so the post-ack work
8186
+ // (sub-agents/tools) rendered into nothing — the "agent went silent
8187
+ // after On it" gap. `finalAnswerDelivered` is set by executeReply
8188
+ // (isFinalAnswerReply) before this tool_use event fires; turn_end
8189
+ // (below) clears unconditionally as the idempotent no-reply / race net.
8190
+ if (turn.finalAnswerDelivered) {
8144
8191
  clearActivitySummary(turn)
8145
8192
  }
8146
8193
  }
@@ -8171,15 +8218,16 @@ function handleSessionEvent(ev: SessionEvent): void {
8171
8218
  // Surface tools (reply/stream_reply/react) are the conversation, not
8172
8219
  // activity — the hook labels them ("Replying"), so filter by name.
8173
8220
  if (isTelegramSurfaceTool(ev.toolName)) return
8174
- // Stop feeding once the reply has landed. The first reply is the
8175
- // hand-off: `clearActivitySummary` deletes the feed so the answer is
8176
- // the authoritative surface (the validated clean hand-off). Without
8177
- // this gate a tool called after the reply would re-`sendMessage` a
8178
- // fresh feed message below the answer a delete-then-resend flicker.
8221
+ // Stop feeding once the FINAL answer has landed the hand-off where
8222
+ // `clearActivitySummary` deletes the feed so the answer is the
8223
+ // authoritative surface. Gating on `replyCalled` (any reply) killed the
8224
+ // feed on an ack-first interim "On it", so the post-ack work had no live
8225
+ // surface; gate on `finalAnswerDelivered` so the feed keeps narrating
8226
+ // between the ack and the real answer. Without this a tool called after
8227
+ // the FINAL answer would re-`sendMessage` a fresh feed below it (flicker).
8179
8228
  // Safe ordering: `tool_label` is real-time (PreToolUse, ~250ms) while
8180
- // `replyCalled` is set from the lagged reply tool_use, so a genuinely
8181
- // pre-reply label virtually always arrives before the flag flips.
8182
- if (turn.replyCalled) return
8229
+ // `finalAnswerDelivered` is set from executeReply on the final answer.
8230
+ if (turn.finalAnswerDelivered) return
8183
8231
  const rendered = appendActivityLabel(turn.mirrorLines, ev.label)
8184
8232
  if (rendered != null) {
8185
8233
  // Recompose so any active foreground sub-agent's nested block (Model A)
@@ -18631,6 +18679,7 @@ void (async () => {
18631
18679
  })
18632
18680
  }
18633
18681
 
18682
+ const handbackOrigin = resolveSubagentOriginChat(agentId)
18634
18683
  const decision = decideSubagentHandback({
18635
18684
  handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
18636
18685
  outcome,
@@ -18639,11 +18688,18 @@ void (async () => {
18639
18688
  // turn) back to the conversation the Task was dispatched
18640
18689
  // from, so the result lands where the user asked — not the
18641
18690
  // agent's DM. Falls back to fleetChatId/ownerChatId.
18642
- fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
18691
+ fleetChatId: handbackOrigin?.chatId || fleetChatId,
18692
+ // Supergroup topic the Task was dispatched from. Plumbed
18693
+ // through so the handback turn (and the model's in-voice
18694
+ // "here's what the worker found" reply) land in the
18695
+ // originating topic — not the chat's last-seen topic.
18696
+ // Applied only when the origin chat resolved (DM fallback
18697
+ // is topic-less).
18698
+ ...(handbackOrigin?.threadId != null
18699
+ ? { originThreadId: handbackOrigin.threadId }
18700
+ : {}),
18643
18701
  // Owner-chat fallback: if the parent-turn chat can't be
18644
- // resolved, route to the owner chat. Every switchroom fleet
18645
- // agent is DM-shaped, so allowFrom[0] is the conversation
18646
- // that dispatched.
18702
+ // resolved, route to the owner chat.
18647
18703
  ownerChatId: loadAccess().allowFrom[0] ?? '',
18648
18704
  taskDescription: description,
18649
18705
  resultText,
@@ -18704,7 +18760,7 @@ void (async () => {
18704
18760
  // suppresses stale-after-restart delivery (a 4-h-old
18705
18761
  // "still working (5m)" would be a lie). Sweep on handback
18706
18762
  // lives in the `onFinish` block just above.
18707
- onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
18763
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine }) => {
18708
18764
  let fleetChatId = ''
18709
18765
  try {
18710
18766
  const fleets = progressDriver?.peekAllFleets() ?? []
@@ -18744,7 +18800,15 @@ void (async () => {
18744
18800
  nestingEnabled: foregroundNestingEnabled,
18745
18801
  replyCalled: turn.replyCalled,
18746
18802
  })) return
18747
- const child = latestSummary.trim().slice(0, 120)
18803
+ // Prefer the tick's own display line: `progressLine` (a
18804
+ // friendly tool-step label) on tool ticks, else the
18805
+ // worker's narrative (`latestSummary`) on text ticks. This
18806
+ // lets a foreground sub-agent that runs tools without
18807
+ // emitting prose still nest its steps under the parent
18808
+ // feed (the foreground blindspot) — mirroring the
18809
+ // main-turn activity feed, which surfaces both tool labels
18810
+ // and prose.
18811
+ const child = (progressLine ?? latestSummary).trim().slice(0, 120)
18748
18812
  if (child.length === 0) return
18749
18813
  let narrative = turn.foregroundSubAgents.get(agentId)
18750
18814
  if (narrative == null) {
@@ -18796,12 +18860,18 @@ void (async () => {
18796
18860
  return
18797
18861
  }
18798
18862
 
18863
+ const progressOrigin = resolveSubagentOriginChat(agentId)
18799
18864
  const decision = decideSubagentProgress({
18800
18865
  disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
18801
18866
  isBackground,
18802
18867
  // Prefer the conversation the Task was dispatched from over
18803
18868
  // the owner DM (see resolveSubagentOriginChat).
18804
- fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
18869
+ fleetChatId: progressOrigin?.chatId || fleetChatId,
18870
+ // Carry the dispatching topic so the progress wake lands in
18871
+ // it (applied only when the origin chat resolved).
18872
+ ...(progressOrigin?.threadId != null
18873
+ ? { originThreadId: progressOrigin.threadId }
18874
+ : {}),
18805
18875
  ownerChatId: loadAccess().allowFrom[0] ?? '',
18806
18876
  subagentJsonlId: agentId,
18807
18877
  taskDescription: description,
@@ -18819,10 +18889,11 @@ void (async () => {
18819
18889
  // model is about to compose an explicit in-voice
18820
18890
  // progress line — letting the "— still working (Nm)"
18821
18891
  // edit fire in parallel would double-surface the
18822
- // signal. Progress envelopes target the chat level
18823
- // (no thread id), matching how the inbound lands.
18892
+ // signal. Key the clear on the topic the envelope lands
18893
+ // in (origin thread) so the right lane is yielded in a
18894
+ // supergroup; chat-level for DM-shaped agents.
18824
18895
  pendingProgress.clearPending(
18825
- statusKey(decision.chatId, undefined),
18896
+ statusKey(decision.chatId, progressOrigin?.threadId),
18826
18897
  'progress',
18827
18898
  )
18828
18899
  process.stderr.write(