switchroom 0.13.10 → 0.13.12

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/bridge/bridge.js +23 -4
  4. package/telegram-plugin/dist/gateway/gateway.js +51 -74
  5. package/telegram-plugin/dist/server.js +23 -4
  6. package/telegram-plugin/gateway/gateway.ts +44 -78
  7. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +82 -0
  8. package/telegram-plugin/model-unavailable.ts +11 -1
  9. package/telegram-plugin/operator-events.fixtures.json +14 -24
  10. package/telegram-plugin/operator-events.ts +11 -2
  11. package/telegram-plugin/session-tail.ts +71 -4
  12. package/telegram-plugin/subagent-watcher.ts +13 -20
  13. package/telegram-plugin/tests/fleet-state-watcher.test.ts +0 -1
  14. package/telegram-plugin/tests/model-unavailable.test.ts +15 -2
  15. package/telegram-plugin/tests/operator-events-session-tail.test.ts +53 -2
  16. package/telegram-plugin/tests/operator-events.test.ts +14 -7
  17. package/telegram-plugin/tests/subagent-handback-decision.test.ts +112 -0
  18. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +1 -3
  19. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +0 -1
  20. package/telegram-plugin/tests/subagent-watcher-parent-marker.test.ts +0 -1
  21. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +1 -4
  22. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +0 -1
  23. package/telegram-plugin/tests/subagent-watcher.test.ts +15 -5
  24. package/telegram-plugin/tests/turn-flush-safety.test.ts +29 -81
  25. package/telegram-plugin/turn-flush-safety.ts +23 -53
@@ -47314,8 +47314,8 @@ var {
47314
47314
  } = import__.default;
47315
47315
 
47316
47316
  // src/build-info.ts
47317
- var VERSION = "0.13.10";
47318
- var COMMIT_SHA = "e0fd6617";
47317
+ var VERSION = "0.13.12";
47318
+ var COMMIT_SHA = "18363dfb";
47319
47319
 
47320
47320
  // src/cli/agent.ts
47321
47321
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.10",
3
+ "version": "0.13.12",
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": {
@@ -23004,7 +23004,7 @@ function classifyInner(raw) {
23004
23004
  return "rate-limited";
23005
23005
  }
23006
23006
  if (errorType === "overloaded_error" || errorCode === "overloaded_error" || sdkCode === "overloaded_error" || message.toLowerCase().includes("overloaded_error") || message.toLowerCase().includes("overloaded")) {
23007
- return "quota-exhausted";
23007
+ return "rate-limited";
23008
23008
  }
23009
23009
  if (errorType === "agent-crashed" || errorCode === "agent-crashed") {
23010
23010
  return "agent-crashed";
@@ -23349,6 +23349,12 @@ function projectSubagentLine(line, agentId, state) {
23349
23349
  }
23350
23350
  return [];
23351
23351
  }
23352
+ function extractRetryState(obj) {
23353
+ return {
23354
+ retryAttempt: typeof obj.retryAttempt === "number" ? obj.retryAttempt : null,
23355
+ maxRetries: typeof obj.maxRetries === "number" ? obj.maxRetries : null
23356
+ };
23357
+ }
23352
23358
  function detectErrorInTranscriptLine(line) {
23353
23359
  if (!line || line.length > 2 * 1024 * 1024)
23354
23360
  return null;
@@ -23366,7 +23372,13 @@ function detectErrorInTranscriptLine(line) {
23366
23372
  const errStr = typeof obj.error === "string" ? obj.error : "";
23367
23373
  const text = extractAssistantText(obj);
23368
23374
  const kind2 = status === 429 ? "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
23369
- return { kind: kind2, raw: obj, detail: text || errStr || "api error" };
23375
+ return {
23376
+ kind: kind2,
23377
+ raw: obj,
23378
+ detail: text || errStr || "api error",
23379
+ transient: kind2 === "rate-limited",
23380
+ terminal: true
23381
+ };
23370
23382
  }
23371
23383
  const isErrorLine = type === "api_error" || type === "error";
23372
23384
  const embeddedError = typeof obj.error === "object" && obj.error != null ? obj.error : null;
@@ -23375,7 +23387,10 @@ function detectErrorInTranscriptLine(line) {
23375
23387
  const raw = embeddedError ?? obj;
23376
23388
  const kind = classifyClaudeError(embeddedError ?? obj);
23377
23389
  const detail = extractDetailMessage(embeddedError) ?? extractDetailMessage(obj) ?? String(type ?? "");
23378
- return { kind, raw, detail };
23390
+ const transient = kind === "rate-limited";
23391
+ const retry = extractRetryState(obj);
23392
+ const terminal = !transient ? true : retry.retryAttempt != null && retry.maxRetries != null ? retry.retryAttempt >= retry.maxRetries : isErrorLine;
23393
+ return { kind, raw, detail, transient, terminal };
23379
23394
  }
23380
23395
  function extractDetailMessage(obj) {
23381
23396
  if (!obj)
@@ -23497,7 +23512,11 @@ function startSessionTail(config2) {
23497
23512
  try {
23498
23513
  const errEvent = detectErrorInTranscriptLine(line);
23499
23514
  if (errEvent) {
23500
- onOperatorEvent(errEvent);
23515
+ if (errEvent.terminal || !errEvent.transient) {
23516
+ onOperatorEvent(errEvent);
23517
+ } else {
23518
+ log?.(`session-tail: transient overload suppressed (in-flight retry) kind=${errEvent.kind}`);
23519
+ }
23501
23520
  }
23502
23521
  } catch (err) {
23503
23522
  log?.(`session-tail: onOperatorEvent threw: ${err.message}`);
@@ -39632,7 +39632,8 @@ function resolveModelUnavailableFromOperatorEvent(ev) {
39632
39632
  return detectModelUnavailable(detail) ?? { kind: "quota_exhausted", raw: detail };
39633
39633
  }
39634
39634
  if (ev.kind === "rate-limited") {
39635
- return detectModelUnavailable(detail) ?? { kind: "overload", raw: detail };
39635
+ const detected = detectModelUnavailable(detail);
39636
+ return detected?.kind === "quota_exhausted" ? detected : null;
39636
39637
  }
39637
39638
  if (ev.kind === "unknown-5xx") {
39638
39639
  return detectModelUnavailable(detail) ?? { kind: "overload", raw: detail };
@@ -40592,28 +40593,12 @@ function isSilentFlushMarker2(text) {
40592
40593
  }
40593
40594
  return SILENT_MARKERS2.has(trimmed.toUpperCase());
40594
40595
  }
40595
- var REPLY_CALLED_TAIL_MIN_CHARS = 40;
40596
40596
  function decideTurnFlush(input) {
40597
40597
  const flushEnabled = input.flushEnabled !== false;
40598
40598
  if (!flushEnabled)
40599
40599
  return { kind: "skip", reason: "flag-disabled" };
40600
- if (input.replyCalled) {
40601
- const tailIdx = input.capturedTextLenAtLastReply ?? input.capturedText.length;
40602
- const tail = input.capturedText.slice(tailIdx).join(`
40603
- `).trim();
40604
- const minChars = input.replyCalledTailMinChars ?? REPLY_CALLED_TAIL_MIN_CHARS;
40605
- if (tail.length === 0) {
40606
- return { kind: "skip", reason: "reply-called" };
40607
- }
40608
- if (tail.length < minChars) {
40609
- return { kind: "skip", reason: "reply-called-no-new-text" };
40610
- }
40611
- if (input.chatId == null)
40612
- return { kind: "skip", reason: "no-inbound-chat" };
40613
- if (isSilentFlushMarker2(tail))
40614
- return { kind: "skip", reason: "silent-marker" };
40615
- return { kind: "flush", text: tail };
40616
- }
40600
+ if (input.replyCalled)
40601
+ return { kind: "skip", reason: "reply-called" };
40617
40602
  if (input.chatId == null)
40618
40603
  return { kind: "skip", reason: "no-inbound-chat" };
40619
40604
  const joined = input.capturedText.join(`
@@ -44782,6 +44767,31 @@ ${result}
44782
44767
  }
44783
44768
  };
44784
44769
  }
44770
+ function decideSubagentHandback(input) {
44771
+ if (input.handbackEnvValue === "0") {
44772
+ return { deliver: false, reason: "env-disabled" };
44773
+ }
44774
+ if (input.outcome !== "completed" && input.outcome !== "failed") {
44775
+ return { deliver: false, reason: "outcome-not-terminal" };
44776
+ }
44777
+ if (!input.isBackground) {
44778
+ return { deliver: false, reason: "foreground" };
44779
+ }
44780
+ const chatId = input.fleetChatId || input.ownerChatId;
44781
+ if (!chatId) {
44782
+ return { deliver: false, reason: "no-chat" };
44783
+ }
44784
+ const inbound = buildSubagentHandbackInbound({
44785
+ ctx: {
44786
+ chatId,
44787
+ taskDescription: input.taskDescription,
44788
+ resultText: input.resultText,
44789
+ outcome: input.outcome
44790
+ },
44791
+ ...input.nowMs !== undefined ? { nowMs: input.nowMs } : {}
44792
+ });
44793
+ return { deliver: true, chatId, inbound };
44794
+ }
44785
44795
 
44786
44796
  // gateway/poll-health.ts
44787
44797
  var DEFAULT_LOG = (msg) => {
@@ -46628,14 +46638,6 @@ function startSubagentWatcher(config) {
46628
46638
  return;
46629
46639
  if (entry.state === "done" && !entry.completionNotified) {
46630
46640
  entry.completionNotified = true;
46631
- const desc = escapeHtml8(truncate3(entry.description, 80));
46632
- const summary = entry.lastSummaryLine ? ` \u2014 ${escapeHtml8(truncate3(entry.lastSummaryLine, 120))}` : "";
46633
- const tools = entry.toolCount > 0 ? ` (${entry.toolCount} tools)` : "";
46634
- try {
46635
- config.sendNotification(`\u2713 Worker done: ${desc}${tools}${summary}`);
46636
- } catch (err) {
46637
- log?.(`subagent-watcher: completion notification error: ${err.message}`);
46638
- }
46639
46641
  if (config.onFinish) {
46640
46642
  try {
46641
46643
  config.onFinish({
@@ -48001,11 +48003,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48001
48003
  }
48002
48004
 
48003
48005
  // ../src/build-info.ts
48004
- var VERSION = "0.13.10";
48005
- var COMMIT_SHA = "e0fd6617";
48006
- var COMMIT_DATE = "2026-05-22T12:01:29+10:00";
48006
+ var VERSION = "0.13.12";
48007
+ var COMMIT_SHA = "18363dfb";
48008
+ var COMMIT_DATE = "2026-05-22T19:32:19+10:00";
48007
48009
  var LATEST_PR = null;
48008
- var COMMITS_AHEAD_OF_TAG = 6;
48010
+ var COMMITS_AHEAD_OF_TAG = 5;
48009
48011
 
48010
48012
  // gateway/boot-version.ts
48011
48013
  function formatRelativeAgo(iso) {
@@ -49628,7 +49630,7 @@ function emitGatewayOperatorEvent(event) {
49628
49630
  let renderedText;
49629
49631
  let renderedKeyboard;
49630
49632
  if (modelUnavailable) {
49631
- const isAutoKind = modelUnavailable.kind === "quota_exhausted" || modelUnavailable.kind === "overload";
49633
+ const isAutoKind = modelUnavailable.kind === "quota_exhausted";
49632
49634
  const willActuallyFire = isAutoKind && wouldFireFleetAutoFallback();
49633
49635
  process.stderr.write(`telegram gateway: operator-event suppressing-raw-stderr-for-model-unavailable agent=${agent} kind=${kind} detected=${modelUnavailable.kind} autoKind=${isAutoKind} willFire=${willActuallyFire}
49634
49636
  `);
@@ -51568,7 +51570,6 @@ function handleSessionEvent(ev) {
51568
51570
  gatewayReceiveAt: startedAt,
51569
51571
  replyCalled: false,
51570
51572
  capturedText: [],
51571
- capturedTextLenAtLastReply: 0,
51572
51573
  orphanedReplyTimeoutId: null,
51573
51574
  registryKey: null,
51574
51575
  lastAssistantMsgId: null,
@@ -51633,7 +51634,6 @@ function handleSessionEvent(ev) {
51633
51634
  const name = ev.toolName;
51634
51635
  if (isTelegramReplyTool(name)) {
51635
51636
  turn.replyCalled = true;
51636
- turn.capturedTextLenAtLastReply = turn.capturedText.length;
51637
51637
  if (turn.orphanedReplyTimeoutId != null) {
51638
51638
  clearTimeout(turn.orphanedReplyTimeoutId);
51639
51639
  turn.orphanedReplyTimeoutId = null;
@@ -51796,13 +51796,8 @@ function handleSessionEvent(ev) {
51796
51796
  chatId: turn.sessionChatId,
51797
51797
  replyCalled: turn.replyCalled,
51798
51798
  capturedText: turn.capturedText,
51799
- capturedTextLenAtLastReply: turn.capturedTextLenAtLastReply,
51800
51799
  flushEnabled: TURN_FLUSH_SAFETY_ENABLED
51801
51800
  });
51802
- if (flushDecision.kind === "flush" && turn.replyCalled) {
51803
- process.stderr.write(`telegram gateway: WARN post-reply-tail flush (#1291) \u2014 model emitted ${flushDecision.text.length} chars after a prior reply call without a follow-up reply tool chat=${chatId} turnStartedAt=${turn.startedAt}
51804
- `);
51805
- }
51806
51801
  if (flushDecision.kind === "skip" && flushDecision.reason !== "reply-called") {
51807
51802
  process.stderr.write(`telegram gateway: turn-flush skipped \u2014 reason=${flushDecision.reason}
51808
51803
  `);
@@ -57282,20 +57277,6 @@ var didOneTimeSetup = false;
57282
57277
  agentCwd: watcherAgentDir,
57283
57278
  db: turnsDb,
57284
57279
  parentStateDir: STATE_DIR,
57285
- sendNotification: (text) => {
57286
- const ownerChatId = loadAccess().allowFrom[0];
57287
- if (!ownerChatId)
57288
- return;
57289
- swallowingApiCall(() => lockedBot.api.sendMessage(ownerChatId, text, {
57290
- parse_mode: "HTML",
57291
- link_preview_options: { is_disabled: true },
57292
- ...TOPIC_ID != null ? { message_thread_id: TOPIC_ID } : {}
57293
- }), {
57294
- chat_id: ownerChatId,
57295
- verb: "subagent-watcher-notification",
57296
- ...TOPIC_ID != null ? { threadId: TOPIC_ID } : {}
57297
- });
57298
- },
57299
57280
  log: (msg) => process.stderr.write(`telegram gateway: ${msg}
57300
57281
  `),
57301
57282
  onStall: (agentId, idleMs, description) => {
@@ -57321,17 +57302,13 @@ var didOneTimeSetup = false;
57321
57302
  }
57322
57303
  },
57323
57304
  onFinish: ({ agentId, outcome, description, resultText }) => {
57324
- if (process.env.SWITCHROOM_SUBAGENT_HANDBACK === "0")
57325
- return;
57326
- if (outcome !== "completed" && outcome !== "failed")
57327
- return;
57328
- let chatId = "";
57305
+ let fleetChatId = "";
57329
57306
  let isBackground = false;
57330
57307
  try {
57331
57308
  const fleets = progressDriver?.peekAllFleets() ?? [];
57332
57309
  for (const f of fleets) {
57333
57310
  if (f.fleet.has(agentId)) {
57334
- chatId = f.chatId ?? "";
57311
+ fleetChatId = f.chatId ?? "";
57335
57312
  break;
57336
57313
  }
57337
57314
  }
@@ -57343,24 +57320,24 @@ var didOneTimeSetup = false;
57343
57320
  isBackground = row.background === 1;
57344
57321
  } catch {}
57345
57322
  }
57346
- if (!isBackground)
57347
- return;
57348
- const handbackChatId = chatId || (loadAccess().allowFrom[0] ?? "");
57349
- if (!handbackChatId) {
57350
- process.stderr.write(`telegram gateway: subagent-handback ${agentId} \u2014 no chat to deliver to; skipped
57323
+ const decision = decideSubagentHandback({
57324
+ handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
57325
+ outcome,
57326
+ isBackground,
57327
+ fleetChatId,
57328
+ ownerChatId: loadAccess().allowFrom[0] ?? "",
57329
+ taskDescription: description,
57330
+ resultText
57331
+ });
57332
+ if (!decision.deliver) {
57333
+ if (decision.reason === "no-chat") {
57334
+ process.stderr.write(`telegram gateway: subagent-handback ${agentId} \u2014 no chat to deliver to; skipped
57351
57335
  `);
57336
+ }
57352
57337
  return;
57353
57338
  }
57354
- const inbound = buildSubagentHandbackInbound({
57355
- ctx: {
57356
- chatId: String(handbackChatId),
57357
- taskDescription: description,
57358
- resultText,
57359
- outcome
57360
- }
57361
- });
57362
- pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? "", inbound);
57363
- process.stderr.write(`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${handbackChatId} resultChars=${resultText.length}
57339
+ pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? "", decision.inbound);
57340
+ process.stderr.write(`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}
57364
57341
  `);
57365
57342
  }
57366
57343
  });
@@ -17029,7 +17029,7 @@ function classifyInner(raw) {
17029
17029
  return "rate-limited";
17030
17030
  }
17031
17031
  if (errorType === "overloaded_error" || errorCode === "overloaded_error" || sdkCode === "overloaded_error" || message.toLowerCase().includes("overloaded_error") || message.toLowerCase().includes("overloaded")) {
17032
- return "quota-exhausted";
17032
+ return "rate-limited";
17033
17033
  }
17034
17034
  if (errorType === "agent-crashed" || errorCode === "agent-crashed") {
17035
17035
  return "agent-crashed";
@@ -17387,6 +17387,12 @@ function projectSubagentLine(line, agentId, state) {
17387
17387
  }
17388
17388
  return [];
17389
17389
  }
17390
+ function extractRetryState(obj) {
17391
+ return {
17392
+ retryAttempt: typeof obj.retryAttempt === "number" ? obj.retryAttempt : null,
17393
+ maxRetries: typeof obj.maxRetries === "number" ? obj.maxRetries : null
17394
+ };
17395
+ }
17390
17396
  function detectErrorInTranscriptLine(line) {
17391
17397
  if (!line || line.length > 2 * 1024 * 1024)
17392
17398
  return null;
@@ -17404,7 +17410,13 @@ function detectErrorInTranscriptLine(line) {
17404
17410
  const errStr = typeof obj.error === "string" ? obj.error : "";
17405
17411
  const text = extractAssistantText(obj);
17406
17412
  const kind2 = status === 429 ? "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
17407
- return { kind: kind2, raw: obj, detail: text || errStr || "api error" };
17413
+ return {
17414
+ kind: kind2,
17415
+ raw: obj,
17416
+ detail: text || errStr || "api error",
17417
+ transient: kind2 === "rate-limited",
17418
+ terminal: true
17419
+ };
17408
17420
  }
17409
17421
  const isErrorLine = type === "api_error" || type === "error";
17410
17422
  const embeddedError = typeof obj.error === "object" && obj.error != null ? obj.error : null;
@@ -17413,7 +17425,10 @@ function detectErrorInTranscriptLine(line) {
17413
17425
  const raw = embeddedError ?? obj;
17414
17426
  const kind = classifyClaudeError(embeddedError ?? obj);
17415
17427
  const detail = extractDetailMessage(embeddedError) ?? extractDetailMessage(obj) ?? String(type ?? "");
17416
- return { kind, raw, detail };
17428
+ const transient = kind === "rate-limited";
17429
+ const retry = extractRetryState(obj);
17430
+ const terminal = !transient ? true : retry.retryAttempt != null && retry.maxRetries != null ? retry.retryAttempt >= retry.maxRetries : isErrorLine;
17431
+ return { kind, raw, detail, transient, terminal };
17417
17432
  }
17418
17433
  function extractDetailMessage(obj) {
17419
17434
  if (!obj)
@@ -17535,7 +17550,11 @@ function startSessionTail(config2) {
17535
17550
  try {
17536
17551
  const errEvent = detectErrorInTranscriptLine(line);
17537
17552
  if (errEvent) {
17538
- onOperatorEvent(errEvent);
17553
+ if (errEvent.terminal || !errEvent.transient) {
17554
+ onOperatorEvent(errEvent);
17555
+ } else {
17556
+ log?.(`session-tail: transient overload suppressed (in-flight retry) kind=${errEvent.kind}`);
17557
+ }
17539
17558
  }
17540
17559
  } catch (err) {
17541
17560
  log?.(`session-tail: onOperatorEvent threw: ${err.message}`);
@@ -281,7 +281,7 @@ import {
281
281
  buildVaultSaveFailedInbound,
282
282
  buildVaultSaveDiscardedInbound,
283
283
  } from './vault-grant-inbound-builders.js'
284
- import { buildSubagentHandbackInbound } from './subagent-handback-inbound-builder.js'
284
+ import { decideSubagentHandback } from './subagent-handback-inbound-builder.js'
285
285
  import { createPollHealthCheck, type PollHealthCheckHandle } from './poll-health.js'
286
286
  import type {
287
287
  ToolCallMessage,
@@ -1192,14 +1192,6 @@ type CurrentTurn = {
1192
1192
  gatewayReceiveAt: number
1193
1193
  replyCalled: boolean
1194
1194
  capturedText: string[]
1195
- // #1291: snapshot of capturedText.length at the moment of the most
1196
- // recent reply / stream_reply tool call. Used by decideTurnFlush to
1197
- // isolate the post-reply tail (e.g. a soft-commit reply followed by
1198
- // the real substantive answer in terminal text only) and flush it as
1199
- // a follow-up message. Pre-#1291 the existence of ANY reply call
1200
- // suppressed flush entirely — that lost long terminal-only answers
1201
- // after a "let me check" interim reply.
1202
- capturedTextLenAtLastReply: number
1203
1195
  orphanedReplyTimeoutId: ReturnType<typeof setTimeout> | null
1204
1196
  registryKey: string | null
1205
1197
  // Last assistant outbound message id for the current turn — populated
@@ -2712,8 +2704,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
2712
2704
  // Card text branches on the AND. wouldFireFleetAutoFallback is a
2713
2705
  // pure read of the dedup state; calling fireFleetAutoFallback only
2714
2706
  // when both are true keeps the card honest.
2715
- const isAutoKind =
2716
- modelUnavailable.kind === 'quota_exhausted' || modelUnavailable.kind === 'overload'
2707
+ // Only a genuine quota / usage-limit hit is addressable by fleet
2708
+ // auto-fallback (swap to an account that still has runway). An
2709
+ // `overload` is transient Anthropic SERVER-side capacity pressure —
2710
+ // every account is equally affected, so failing over does nothing;
2711
+ // it just produces a self-cancelling "probed healthy / Stale event?"
2712
+ // loop on every 529. Overload is handled by Claude Code's own
2713
+ // internal retry, not by switching accounts.
2714
+ const isAutoKind = modelUnavailable.kind === 'quota_exhausted'
2717
2715
  const willActuallyFire = isAutoKind && wouldFireFleetAutoFallback()
2718
2716
  process.stderr.write(
2719
2717
  `telegram gateway: operator-event suppressing-raw-stderr-for-model-unavailable agent=${agent} kind=${kind} detected=${modelUnavailable.kind} autoKind=${isAutoKind} willFire=${willActuallyFire}\n`,
@@ -5700,7 +5698,6 @@ function handleSessionEvent(ev: SessionEvent): void {
5700
5698
  gatewayReceiveAt: startedAt,
5701
5699
  replyCalled: false,
5702
5700
  capturedText: [],
5703
- capturedTextLenAtLastReply: 0,
5704
5701
  orphanedReplyTimeoutId: null,
5705
5702
  registryKey: null,
5706
5703
  lastAssistantMsgId: null,
@@ -5801,12 +5798,6 @@ function handleSessionEvent(ev: SessionEvent): void {
5801
5798
  // placeholder-heartbeat label, which has been retired.
5802
5799
  if (isTelegramReplyTool(name)) {
5803
5800
  turn.replyCalled = true
5804
- // #1291: pin the captured-text index at the moment of this reply
5805
- // tool call. Anything pushed into capturedText after this point
5806
- // is the post-reply tail (e.g. the substantive answer composed
5807
- // in terminal text after a soft-commit "on it, back in a few").
5808
- // decideTurnFlush slices from this index to flush the tail.
5809
- turn.capturedTextLenAtLastReply = turn.capturedText.length
5810
5801
  if (turn.orphanedReplyTimeoutId != null) {
5811
5802
  clearTimeout(turn.orphanedReplyTimeoutId)
5812
5803
  turn.orphanedReplyTimeoutId = null
@@ -6066,20 +6057,8 @@ function handleSessionEvent(ev: SessionEvent): void {
6066
6057
  chatId: turn.sessionChatId,
6067
6058
  replyCalled: turn.replyCalled,
6068
6059
  capturedText: turn.capturedText,
6069
- capturedTextLenAtLastReply: turn.capturedTextLenAtLastReply,
6070
6060
  flushEnabled: TURN_FLUSH_SAFETY_ENABLED,
6071
6061
  })
6072
- // #1291: when the model emitted a soft-commit reply followed by a
6073
- // substantive terminal-only answer, decideTurnFlush returns
6074
- // kind:'flush' with the post-reply tail. Log WARN so this case is
6075
- // auditable — the model SHOULD have called reply for the tail, but
6076
- // didn't, and the framework is covering for it.
6077
- if (flushDecision.kind === 'flush' && turn.replyCalled) {
6078
- process.stderr.write(
6079
- `telegram gateway: WARN post-reply-tail flush (#1291) — model emitted ${flushDecision.text.length} chars after a prior reply call without a follow-up reply tool` +
6080
- ` chat=${chatId} turnStartedAt=${turn.startedAt}\n`,
6081
- )
6082
- }
6083
6062
  if (flushDecision.kind === 'skip' && flushDecision.reason !== 'reply-called') {
6084
6063
  process.stderr.write(
6085
6064
  `telegram gateway: turn-flush skipped — reason=${flushDecision.reason}\n`,
@@ -14977,26 +14956,11 @@ void (async () => {
14977
14956
  // inside the sub-agent. Belt-and-braces with PR #557's
14978
14957
  // multi-signal progress gate.
14979
14958
  parentStateDir: STATE_DIR,
14980
- sendNotification: (text: string) => {
14981
- const ownerChatId = loadAccess().allowFrom[0]
14982
- if (!ownerChatId) return
14983
- // #1075: thread-id-bearing route through swallowingApiCall
14984
- // so a deleted TOPIC_ID forum thread doesn't crash the
14985
- // gateway. Notifications are best-effort.
14986
- void swallowingApiCall(
14987
- () =>
14988
- lockedBot.api.sendMessage(ownerChatId, text, {
14989
- parse_mode: 'HTML',
14990
- link_preview_options: { is_disabled: true },
14991
- ...(TOPIC_ID != null ? { message_thread_id: TOPIC_ID } : {}),
14992
- }),
14993
- {
14994
- chat_id: ownerChatId,
14995
- verb: 'subagent-watcher-notification',
14996
- ...(TOPIC_ID != null ? { threadId: TOPIC_ID } : {}),
14997
- },
14998
- )
14999
- },
14959
+ // No user-facing notification callback: the card-era
14960
+ // "✓ Worker done" message was retired with the progress
14961
+ // card (#1122). Sub-agent completion reaches the user as
14962
+ // the model's own beat-4 handback reply; the watcher's
14963
+ // role here is registry liveness + the `onFinish` cue.
15000
14964
  log: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
15001
14965
  // Option C (#393): route stall detections into the progress-card
15002
14966
  // driver so the pinned card re-renders with a ⚠️ indicator even
@@ -15063,22 +15027,24 @@ void (async () => {
15063
15027
  // need nothing here, and 'orphan' is a stale historical-at-
15064
15028
  // boot row, not a fresh completion the user is waiting on.
15065
15029
  onFinish: ({ agentId, outcome, description, resultText }) => {
15066
- if (process.env.SWITCHROOM_SUBAGENT_HANDBACK === '0') return
15067
- if (outcome !== 'completed' && outcome !== 'failed') return
15068
-
15069
- let chatId = ''
15030
+ // IO: resolve the fleet chat id and the background flag.
15031
+ // The DECISION (gating + inbound build) is delegated to
15032
+ // the pure `decideSubagentHandback` so it is unit-tested
15033
+ // independent of the gateway — see
15034
+ // `subagent-handback-decision.test.ts`.
15035
+ let fleetChatId = ''
15070
15036
  let isBackground = false
15071
15037
  try {
15072
15038
  const fleets = progressDriver?.peekAllFleets() ?? []
15073
15039
  for (const f of fleets) {
15074
15040
  if (f.fleet.has(agentId)) {
15075
- chatId = f.chatId ?? ''
15041
+ fleetChatId = f.chatId ?? ''
15076
15042
  break
15077
15043
  }
15078
15044
  }
15079
15045
  } catch {
15080
15046
  // peek failures are non-fatal — fall through to the
15081
- // owner-chat fallback below.
15047
+ // owner-chat fallback inside decideSubagentHandback.
15082
15048
  }
15083
15049
  if (turnsDb != null) {
15084
15050
  try {
@@ -15088,36 +15054,36 @@ void (async () => {
15088
15054
  if (row != null) isBackground = row.background === 1
15089
15055
  } catch { /* best-effort */ }
15090
15056
  }
15091
- if (!isBackground) return
15092
-
15093
- // chatId fallback: if the progress-driver fleet entry was
15094
- // already cleaned up by the time onFinish fires, route to
15095
- // the owner chat. Every switchroom fleet agent is
15096
- // DM-shaped, so allowFrom[0] is the conversation that
15097
- // dispatched the work.
15098
- const handbackChatId = chatId || (loadAccess().allowFrom[0] ?? '')
15099
- if (!handbackChatId) {
15100
- process.stderr.write(
15101
- `telegram gateway: subagent-handback ${agentId} — no chat to deliver to; skipped\n`,
15102
- )
15057
+
15058
+ const decision = decideSubagentHandback({
15059
+ handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
15060
+ outcome,
15061
+ isBackground,
15062
+ fleetChatId,
15063
+ // Owner-chat fallback: if the progress-driver fleet
15064
+ // entry was already cleaned up, route to the owner
15065
+ // chat. Every switchroom fleet agent is DM-shaped, so
15066
+ // allowFrom[0] is the conversation that dispatched.
15067
+ ownerChatId: loadAccess().allowFrom[0] ?? '',
15068
+ taskDescription: description,
15069
+ resultText,
15070
+ })
15071
+ if (!decision.deliver) {
15072
+ if (decision.reason === 'no-chat') {
15073
+ process.stderr.write(
15074
+ `telegram gateway: subagent-handback ${agentId} — no chat to deliver to; skipped\n`,
15075
+ )
15076
+ }
15103
15077
  return
15104
15078
  }
15105
15079
 
15106
- const inbound = buildSubagentHandbackInbound({
15107
- ctx: {
15108
- chatId: String(handbackChatId),
15109
- taskDescription: description,
15110
- resultText,
15111
- outcome,
15112
- },
15113
- })
15114
15080
  // Deliver via pendingInboundBuffer + the idle-drain tick.
15115
15081
  // The drain only releases at an idle prompt (no active
15116
15082
  // turn), so the handback always lands as a clean fresh
15117
15083
  // turn and never races a turn-in-flight composer (#1556).
15118
- pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', inbound)
15084
+ pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', decision.inbound)
15119
15085
  process.stderr.write(
15120
- `telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${handbackChatId} resultChars=${resultText.length}\n`,
15086
+ `telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}\n`,
15121
15087
  )
15122
15088
  },
15123
15089
  })
@@ -101,3 +101,85 @@ export function buildSubagentHandbackInbound(opts: {
101
101
  },
102
102
  }
103
103
  }
104
+
105
+ // ───────────────────────────────────────────────────────────────────────────
106
+ // Handback decision (pure — unit-testable gate for the gateway onFinish path)
107
+ // ───────────────────────────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Inputs to the handback decision. The gateway's `subagent-watcher`
111
+ * `onFinish` callback does the IO — resolves `isBackground` from the
112
+ * registry DB, `fleetChatId` from the progress-driver fleet, and
113
+ * `ownerChatId` from access.json — then hands the resolved values here.
114
+ * Keeping the *decision* pure makes the gate (which injects turns)
115
+ * testable without standing up a gateway.
116
+ */
117
+ export interface SubagentHandbackDecisionInput {
118
+ /** `SWITCHROOM_SUBAGENT_HANDBACK` env var value (any non-'0' = enabled). */
119
+ handbackEnvValue: string | undefined
120
+ /** Terminal outcome the watcher reported. */
121
+ outcome: 'completed' | 'failed' | 'orphan'
122
+ /** Whether the sub-agent was a background dispatch (registry DB flag).
123
+ * Foreground sub-agents hand back natively in the parent's turn. */
124
+ isBackground: boolean
125
+ /** Chat id from the progress-driver fleet entry; '' if not found. */
126
+ fleetChatId: string
127
+ /** Owner chat fallback (access.json allowFrom[0]); '' if none. */
128
+ ownerChatId: string
129
+ taskDescription: string
130
+ resultText: string
131
+ /** Deterministic clock for tests. */
132
+ nowMs?: number
133
+ }
134
+
135
+ /** Why a handback was NOT delivered — one of these, or `delivered`. */
136
+ export type SubagentHandbackSkipReason =
137
+ | 'env-disabled'
138
+ | 'outcome-not-terminal'
139
+ | 'foreground'
140
+ | 'no-chat'
141
+
142
+ export type SubagentHandbackDecision =
143
+ | { deliver: false; reason: SubagentHandbackSkipReason }
144
+ | { deliver: true; chatId: string; inbound: InboundMessage }
145
+
146
+ /**
147
+ * Decide whether a finished sub-agent warrants a handback turn, and if
148
+ * so build the inbound. Pure: all IO is the caller's job.
149
+ *
150
+ * Gates, in order:
151
+ * 1. kill-switch — `SWITCHROOM_SUBAGENT_HANDBACK=0` disables entirely.
152
+ * 2. outcome — only `completed`/`failed` hand back; `orphan` is a
153
+ * stale historical-at-boot row, not a fresh completion.
154
+ * 3. foreground — a foreground sub-agent already handed its result
155
+ * back as the Task tool result in the parent's own turn.
156
+ * 4. no-chat — neither the fleet entry nor the owner chat resolved,
157
+ * so there is nowhere to deliver.
158
+ */
159
+ export function decideSubagentHandback(
160
+ input: SubagentHandbackDecisionInput,
161
+ ): SubagentHandbackDecision {
162
+ if (input.handbackEnvValue === '0') {
163
+ return { deliver: false, reason: 'env-disabled' }
164
+ }
165
+ if (input.outcome !== 'completed' && input.outcome !== 'failed') {
166
+ return { deliver: false, reason: 'outcome-not-terminal' }
167
+ }
168
+ if (!input.isBackground) {
169
+ return { deliver: false, reason: 'foreground' }
170
+ }
171
+ const chatId = input.fleetChatId || input.ownerChatId
172
+ if (!chatId) {
173
+ return { deliver: false, reason: 'no-chat' }
174
+ }
175
+ const inbound = buildSubagentHandbackInbound({
176
+ ctx: {
177
+ chatId,
178
+ taskDescription: input.taskDescription,
179
+ resultText: input.resultText,
180
+ outcome: input.outcome,
181
+ },
182
+ ...(input.nowMs !== undefined ? { nowMs: input.nowMs } : {}),
183
+ })
184
+ return { deliver: true, chatId, inbound }
185
+ }