switchroom 0.14.89 → 0.14.91

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.
@@ -49815,8 +49815,8 @@ var {
49815
49815
  } = import__.default;
49816
49816
 
49817
49817
  // src/build-info.ts
49818
- var VERSION = "0.14.89";
49819
- var COMMIT_SHA = "125cbcc5";
49818
+ var VERSION = "0.14.91";
49819
+ var COMMIT_SHA = "e938daab";
49820
49820
 
49821
49821
  // src/cli/agent.ts
49822
49822
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.89",
3
+ "version": "0.14.91",
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": {
@@ -78,7 +78,7 @@ const mcp = new Server(
78
78
  '',
79
79
  'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text — delete is for retraction). Edits don\'t trigger push notifications — when a long task completes, send a new reply so the user\'s device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.',
80
80
  '',
81
- 'If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic — no need to pass message_thread_id manually unless you want to override. Each <channel> message is the current topic — answer ONLY this message\'s question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.',
81
+ 'If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from the framework owns the answer\'s topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic — answer ONLY this message\'s question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.',
82
82
  '',
83
83
  'The default format is "html" — write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
84
84
  '',
@@ -24571,7 +24571,7 @@ var mcp = new Server({ name: "telegram", version: "1.0.0" }, {
24571
24571
  "",
24572
24572
  `reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
24573
24573
  "",
24574
- "If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic \u2014 no need to pass message_thread_id manually unless you want to override. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
24574
+ "If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from \u2014 the framework owns the answer's topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
24575
24575
  "",
24576
24576
  'The default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
24577
24577
  "",
@@ -48000,12 +48000,23 @@ function decideFeedReopen(input) {
48000
48000
 
48001
48001
  // gateway/answer-thread-resolve.ts
48002
48002
  function resolveAnswerThreadId(input) {
48003
- if (input.explicitThreadId != null)
48004
- return input.explicitThreadId;
48003
+ if (input.frameworkTopicAuthority === false) {
48004
+ if (input.explicitThreadId != null)
48005
+ return input.explicitThreadId;
48006
+ if (input.originResolved)
48007
+ return input.originThreadId;
48008
+ if (input.liveThreadId != null)
48009
+ return input.liveThreadId;
48010
+ if (input.lastEndedResolvedForChat)
48011
+ return input.lastEndedThreadIdForChat;
48012
+ return input.liveThreadId;
48013
+ }
48005
48014
  if (input.originResolved)
48006
48015
  return input.originThreadId;
48007
- if (input.liveThreadId != null)
48016
+ if (input.liveTurnPresent)
48008
48017
  return input.liveThreadId;
48018
+ if (input.explicitThreadId != null)
48019
+ return input.explicitThreadId;
48009
48020
  if (input.lastEndedResolvedForChat)
48010
48021
  return input.lastEndedThreadIdForChat;
48011
48022
  return input.liveThreadId;
@@ -52889,11 +52900,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52889
52900
  }
52890
52901
 
52891
52902
  // ../src/build-info.ts
52892
- var VERSION = "0.14.89";
52893
- var COMMIT_SHA = "125cbcc5";
52894
- var COMMIT_DATE = "2026-06-08T07:42:50+10:00";
52895
- var LATEST_PR = null;
52896
- var COMMITS_AHEAD_OF_TAG = 2;
52903
+ var VERSION = "0.14.91";
52904
+ var COMMIT_SHA = "e938daab";
52905
+ var COMMIT_DATE = "2026-06-09T03:14:21Z";
52906
+ var LATEST_PR = 2241;
52907
+ var COMMITS_AHEAD_OF_TAG = 0;
52897
52908
 
52898
52909
  // gateway/boot-version.ts
52899
52910
  function formatRelativeAgo(iso) {
@@ -53534,6 +53545,21 @@ function resolveWorkerFeedDispatch(sub, watcherDescription) {
53534
53545
  };
53535
53546
  }
53536
53547
 
53548
+ // gateway/subagent-status-surface.ts
53549
+ function resolveSubagentStatusSurface(input) {
53550
+ if (!input.isBackground) {
53551
+ if (input.liveTurnPresent)
53552
+ return "nest";
53553
+ if (!input.orphanStatusEnabled)
53554
+ return "skip";
53555
+ return input.workerFeedEnabled ? "worker-feed" : "skip";
53556
+ }
53557
+ return input.workerFeedEnabled ? "worker-feed" : "legacy-relay";
53558
+ }
53559
+ function isOrphanSubagentStatusEnabled(envVal) {
53560
+ return envVal !== "0";
53561
+ }
53562
+
53537
53563
  // gateway/resolve-calling-subagent.ts
53538
53564
  function resolveCallingSubagent(opts) {
53539
53565
  if (opts.db == null)
@@ -54140,6 +54166,7 @@ var _noReplyDrainParsed = _noReplyDrainRaw != null && _noReplyDrainRaw !== "" ?
54140
54166
  var SERIALIZE_NOREPLY_DRAIN_MS = Number.isFinite(_noReplyDrainParsed) && _noReplyDrainParsed > 0 ? _noReplyDrainParsed : 2500;
54141
54167
  var TURN_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== "0";
54142
54168
  var FRAMEWORK_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== "0";
54169
+ var REPLY_TOPIC_AUTHORITY_ENABLED = process.env.SWITCHROOM_REPLY_TOPIC_AUTHORITY !== "0";
54143
54170
  var TOPIC_FRAMING_ENABLED = process.env.SWITCHROOM_TOPIC_FRAMING !== "0";
54144
54171
  var QUEUED_STATUS_UX_ENABLED = process.env.SWITCHROOM_QUEUED_STATUS_UX !== "0";
54145
54172
  var FEED_REOPEN_AFTER_ACK_ENABLED = process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== "0";
@@ -54233,15 +54260,18 @@ function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, origin
54233
54260
  originResolved: originTurn != null,
54234
54261
  originThreadId: originTurn?.sessionThreadId,
54235
54262
  liveThreadId: liveTurn?.sessionThreadId,
54263
+ liveTurnPresent: liveTurn != null,
54236
54264
  lastEndedResolvedForChat: recovered != null,
54237
- lastEndedThreadIdForChat: recovered?.sessionThreadId
54265
+ lastEndedThreadIdForChat: recovered?.sessionThreadId,
54266
+ frameworkTopicAuthority: REPLY_TOPIC_AUTHORITY_ENABLED
54238
54267
  });
54239
- const via = explicitThreadId != null ? "explicit" : originTurn != null ? originVia === "quoted" ? "quoted" : "origin" : liveTurn?.sessionThreadId != null ? "live" : recovered != null ? "recovered" : "none";
54268
+ const via = REPLY_TOPIC_AUTHORITY_ENABLED ? originTurn != null ? originVia === "quoted" ? "quoted" : "origin" : liveTurn != null ? "live" : explicitThreadId != null ? "explicit" : recovered != null ? "recovered" : "none" : explicitThreadId != null ? "explicit" : originTurn != null ? originVia === "quoted" ? "quoted" : "origin" : liveTurn?.sessionThreadId != null ? "live" : recovered != null ? "recovered" : "none";
54269
+ const explicitOverridden = REPLY_TOPIC_AUTHORITY_ENABLED && explicitThreadId != null && (originTurn != null || liveTurn != null) && threadId !== explicitThreadId;
54240
54270
  const ownerTurn = originTurn ?? recovered ?? liveTurn;
54241
54271
  const isSupergroup = chatId.startsWith("-100");
54242
54272
  const unrouted = isSupergroup && threadId == null && ownerTurn == null;
54243
54273
  const misrouteRisk = isSupergroup && via === "live" && hasDifferentThreadedRecentTurn(chatId, liveTurn?.sessionThreadId);
54244
- process.stderr.write(`telegram gateway: reply-route surface=${surface} chat=${chatId} resolved_thread=${threadId ?? "-"} via=${via} late=${liveTurn == null} originTurn=${ownerTurn?.turnId ?? "-"} origin_thread=${ownerTurn?.sessionThreadId ?? "-"}` + (via === "recovered" ? " RECOVERED" : "") + (via === "quoted" ? " QUOTED(framework-origin)" : "") + (unrouted ? " UNROUTED(supergroup\u2192no-topic)" : "") + (misrouteRisk ? " MISROUTE_RISK(no-echo\u2192live-successor)" : "") + `
54274
+ process.stderr.write(`telegram gateway: reply-route surface=${surface} chat=${chatId} resolved_thread=${threadId ?? "-"} via=${via} late=${liveTurn == null} originTurn=${ownerTurn?.turnId ?? "-"} origin_thread=${ownerTurn?.sessionThreadId ?? "-"}` + (via === "recovered" ? " RECOVERED" : "") + (via === "quoted" ? " QUOTED(framework-origin)" : "") + (unrouted ? " UNROUTED(supergroup\u2192no-topic)" : "") + (misrouteRisk ? " MISROUTE_RISK(no-echo\u2192live-successor)" : "") + (explicitOverridden ? ` EXPLICIT_OVERRIDDEN(model\u2192${explicitThreadId},routed\u2192${threadId ?? "-"})` : "") + `
54245
54275
  `);
54246
54276
  return threadId;
54247
54277
  }
@@ -64554,6 +64584,7 @@ var didOneTimeSetup = false;
64554
64584
  if (watcherAgentDir != null) {
64555
64585
  const workerFeedEnabled = isWorkerActivityFeedEnabled(process.env.SWITCHROOM_WORKER_ACTIVITY_FEED);
64556
64586
  const foregroundNestingEnabled = process.env.SWITCHROOM_FOREGROUND_SUBAGENT_NESTING !== "0";
64587
+ const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS);
64557
64588
  const workerActivityFeed = createWorkerActivityFeed({
64558
64589
  bot: {
64559
64590
  sendMessage: async (cid, text, sendOpts) => {
@@ -64635,6 +64666,22 @@ var didOneTimeSetup = false;
64635
64666
  }
64636
64667
  }
64637
64668
  }
64669
+ return;
64670
+ }
64671
+ if (resolveSubagentStatusSurface({
64672
+ isBackground: false,
64673
+ liveTurnPresent: false,
64674
+ workerFeedEnabled,
64675
+ orphanStatusEnabled
64676
+ }) === "worker-feed") {
64677
+ workerActivityFeed.finish(agentId, {
64678
+ description: dispatch.feedDescription,
64679
+ lastTool: null,
64680
+ toolCount,
64681
+ latestSummary: resultText,
64682
+ elapsedMs: durationMs,
64683
+ state: outcome === "failed" ? "failed" : "done"
64684
+ });
64638
64685
  }
64639
64686
  return;
64640
64687
  }
@@ -64701,6 +64748,26 @@ var didOneTimeSetup = false;
64701
64748
  }
64702
64749
  const isBackground = dispatch.isBackground;
64703
64750
  if (!isBackground) {
64751
+ const surface = resolveSubagentStatusSurface({
64752
+ isBackground: false,
64753
+ liveTurnPresent: currentTurn != null,
64754
+ workerFeedEnabled,
64755
+ orphanStatusEnabled
64756
+ });
64757
+ if (surface === "worker-feed") {
64758
+ const origin = resolveSubagentOriginChat(agentId);
64759
+ workerActivityFeed.update(agentId, origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ""), {
64760
+ description: dispatch.feedDescription,
64761
+ lastTool,
64762
+ toolCount,
64763
+ latestSummary,
64764
+ elapsedMs,
64765
+ state: "running"
64766
+ }, origin?.threadId);
64767
+ return;
64768
+ }
64769
+ if (surface !== "nest")
64770
+ return;
64704
64771
  const turn = currentTurn;
64705
64772
  if (turn == null)
64706
64773
  return;
@@ -24268,7 +24268,7 @@ var init_bridge = __esm(async () => {
24268
24268
  "",
24269
24269
  `reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
24270
24270
  "",
24271
- "If a message includes message_thread_id, it came from a forum topic. The reply tool will automatically route replies back to the same topic \u2014 no need to pass message_thread_id manually unless you want to override. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
24271
+ "If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from \u2014 the framework owns the answer's topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
24272
24272
  "",
24273
24273
  'The default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Use format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text.',
24274
24274
  "",
@@ -1,160 +1,190 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { resolveAnswerThreadId, type AnswerThreadInput } from './answer-thread-resolve.js'
3
3
 
4
- describe('resolveAnswerThreadId precedence', () => {
5
- it('(1) explicit model thread wins over everything', () => {
4
+ // Distinct symbolic thread ids so an output's provenance is unambiguous (no two
5
+ // tiers share a value): explicit=70, origin=50, live=30, lastEnded=90.
6
+ const T = 70 // explicit
7
+ const O = 50 // origin
8
+ const L = 30 // live
9
+ const E = 90 // lastEnded
10
+
11
+ // ── Framework-authority (DEFAULT) — human-readable map ──────────────────────
12
+ describe('resolveAnswerThreadId — framework authority (default)', () => {
13
+ it('origin turn wins over the model explicit (the General→CRM fix)', () => {
6
14
  expect(
7
15
  resolveAnswerThreadId({
8
- explicitThreadId: 7,
16
+ explicitThreadId: T,
9
17
  originResolved: true,
10
- originThreadId: 3,
11
- liveThreadId: 4,
12
- lastEndedResolvedForChat: true,
13
- lastEndedThreadIdForChat: 9,
18
+ originThreadId: O,
19
+ liveTurnPresent: true,
20
+ liveThreadId: L,
14
21
  }),
15
- ).toBe(7)
16
- })
17
-
18
- it('(2) origin turn thread wins over the live turn (the Brevo→Meta fix)', () => {
19
- expect(
20
- resolveAnswerThreadId({ originResolved: true, originThreadId: 3, liveThreadId: 4 }),
21
- ).toBe(3)
22
- })
23
-
24
- it('(2) a DM origin (resolved, thread undefined) pins to undefined, not the live thread', () => {
25
- expect(
26
- resolveAnswerThreadId({ originResolved: true, originThreadId: undefined, liveThreadId: 4 }),
27
- ).toBeUndefined()
22
+ ).toBe(O)
28
23
  })
29
24
 
30
- it('(3) no origin falls back to the live turn thread (legacy #1664)', () => {
25
+ it('THE marko case: a General-origin question (origin resolved, thread undefined) + model explicit General, NOT the model topic', () => {
31
26
  expect(
32
- resolveAnswerThreadId({ originResolved: false, liveThreadId: 4 }),
33
- ).toBe(4)
27
+ resolveAnswerThreadId({
28
+ explicitThreadId: 4, // model tried to send the answer to CRM (topic 4)
29
+ originResolved: true,
30
+ originThreadId: undefined, // General origin carries no thread
31
+ liveTurnPresent: true,
32
+ liveThreadId: undefined,
33
+ }),
34
+ ).toBeUndefined() // → General, where it was asked
34
35
  })
35
36
 
36
- // ── tier (4): late-reply topic recovery (2026-06-05) ──────────────────────
37
- it('(4) no explicit, no origin, NO live turn → recovers the most-recent ended turn thread', () => {
38
- // The marko bug: a reply that fired after the orphaned-reply backstop ended
39
- // its turn. Pre-fix this returned undefined (General); now it recovers topic 3.
37
+ it('a live in-flight turn wins over the model explicit even with no origin echo', () => {
40
38
  expect(
41
39
  resolveAnswerThreadId({
40
+ explicitThreadId: T,
42
41
  originResolved: false,
43
- liveThreadId: undefined,
44
- lastEndedResolvedForChat: true,
45
- lastEndedThreadIdForChat: 3,
42
+ liveTurnPresent: true,
43
+ liveThreadId: L,
46
44
  }),
47
- ).toBe(3)
45
+ ).toBe(L)
48
46
  })
49
47
 
50
- it('(4) a recovered DM turn (ended, thread undefined) stays threadless', () => {
48
+ it('a General live turn (present, thread undefined) still beats the model explicit', () => {
51
49
  expect(
52
50
  resolveAnswerThreadId({
51
+ explicitThreadId: T,
53
52
  originResolved: false,
53
+ liveTurnPresent: true,
54
54
  liveThreadId: undefined,
55
- lastEndedResolvedForChat: true,
56
- lastEndedThreadIdForChat: undefined,
57
55
  }),
58
56
  ).toBeUndefined()
59
57
  })
60
58
 
61
- it('(4) recovery does NOT override a live turn live thread still wins at tier 3', () => {
59
+ it('model explicit is honoured only when there is NO origin and NO live turn (orphaned/proactive)', () => {
62
60
  expect(
63
61
  resolveAnswerThreadId({
62
+ explicitThreadId: T,
64
63
  originResolved: false,
65
- liveThreadId: 4,
66
- lastEndedResolvedForChat: true,
67
- lastEndedThreadIdForChat: 3,
64
+ liveTurnPresent: false,
68
65
  }),
69
- ).toBe(4)
66
+ ).toBe(T)
70
67
  })
71
68
 
72
- it('(4) no recovery candidatelegacy result (undefined), unchanged', () => {
69
+ it('late reply, no anchor, no explicit recovers the last-ended topic', () => {
73
70
  expect(
74
71
  resolveAnswerThreadId({
75
72
  originResolved: false,
76
- liveThreadId: undefined,
77
- lastEndedResolvedForChat: false,
73
+ liveTurnPresent: false,
74
+ lastEndedResolvedForChat: true,
75
+ lastEndedThreadIdForChat: E,
78
76
  }),
79
- ).toBeUndefined()
77
+ ).toBe(E)
80
78
  })
81
79
 
82
- it('pure DM (every tier undefined) → undefined', () => {
80
+ it('pure DM (no anchors) → undefined', () => {
83
81
  expect(resolveAnswerThreadId({ originResolved: false })).toBeUndefined()
84
82
  })
85
83
  })
86
84
 
85
+ // ── Legacy precedence (kill switch SWITCHROOM_REPLY_TOPIC_AUTHORITY=0) ───────
86
+ describe('resolveAnswerThreadId — legacy (frameworkTopicAuthority:false)', () => {
87
+ it('explicit wins outright (the old behaviour)', () => {
88
+ expect(
89
+ resolveAnswerThreadId({
90
+ frameworkTopicAuthority: false,
91
+ explicitThreadId: T,
92
+ originResolved: true,
93
+ originThreadId: O,
94
+ liveThreadId: L,
95
+ }),
96
+ ).toBe(T)
97
+ })
98
+ it('origin beats live when no explicit (unchanged)', () => {
99
+ expect(
100
+ resolveAnswerThreadId({
101
+ frameworkTopicAuthority: false,
102
+ originResolved: true,
103
+ originThreadId: O,
104
+ liveThreadId: L,
105
+ }),
106
+ ).toBe(O)
107
+ })
108
+ })
109
+
87
110
  // ── TOTAL-ENUMERATION DETERMINISM PROOF ─────────────────────────────────────
88
111
  //
89
- // The operator standard (memory feedback_prove_finite_fsm_not_sample): a
90
- // passing sample is not a proof. `resolveAnswerThreadId` is a PURE decision
91
- // function over a FINITE input space — so we can prove its determinism by
92
- // CONSTRUCTION: enumerate every reachable input and assert totality,
93
- // determinism, no-fabrication, and the precedence the doc-comment promises.
94
- // Any future edit that perturbs the decision table on ANY of the 64 inputs
95
- // fails here this block is the regression guard, the 9 examples above are
96
- // the human-readable map.
97
- //
98
- // Distinct symbolic thread ids so an output's provenance is unambiguous (no
99
- // two tiers share a value): explicit=70, origin=50, live=30, lastEnded=90.
100
- const T = 70 // explicit (tier 1)
101
- const O = 50 // origin (tier 2)
102
- const L = 30 // live (tier 3)
103
- const E = 90 // lastEnded(tier 4)
104
-
105
- function allInputs(): AnswerThreadInput[] {
112
+ // The operator standard (memory feedback_prove_finite_fsm_not_sample): a passing
113
+ // sample is not a proof. `resolveAnswerThreadId` is a PURE decision function over
114
+ // a FINITE input space — so we prove its behaviour by CONSTRUCTION: enumerate
115
+ // every REACHABLE input (origin/live/ended each have 3 reachable states ×
116
+ // explicit present/absent) for BOTH precedence modes, and assert totality,
117
+ // determinism, no-fabrication, the documented precedence, and the load-bearing
118
+ // guarantee: under framework authority the model's explicit thread CANNOT
119
+ // redirect a reply that has a framework anchor.
120
+
121
+ // Reachable sub-states (a thread value is only present when its 'resolved' flag is).
122
+ const ORIGIN_STATES: Array<Pick<AnswerThreadInput, 'originResolved' | 'originThreadId'>> = [
123
+ { originResolved: false },
124
+ { originResolved: true, originThreadId: undefined }, // DM/General origin
125
+ { originResolved: true, originThreadId: O },
126
+ ]
127
+ const LIVE_STATES: Array<Pick<AnswerThreadInput, 'liveTurnPresent' | 'liveThreadId'>> = [
128
+ { liveTurnPresent: false },
129
+ { liveTurnPresent: true, liveThreadId: undefined }, // General live turn
130
+ { liveTurnPresent: true, liveThreadId: L },
131
+ ]
132
+ const ENDED_STATES: Array<
133
+ Pick<AnswerThreadInput, 'lastEndedResolvedForChat' | 'lastEndedThreadIdForChat'>
134
+ > = [
135
+ { lastEndedResolvedForChat: false },
136
+ { lastEndedResolvedForChat: true, lastEndedThreadIdForChat: undefined },
137
+ { lastEndedResolvedForChat: true, lastEndedThreadIdForChat: E },
138
+ ]
139
+
140
+ function reachableInputs(frameworkTopicAuthority: boolean): AnswerThreadInput[] {
106
141
  const rows: AnswerThreadInput[] = []
107
142
  for (const explicitThreadId of [undefined, T])
108
- for (const originResolved of [false, true])
109
- for (const originThreadId of [undefined, O])
110
- for (const liveThreadId of [undefined, L])
111
- for (const lastEndedResolvedForChat of [false, true])
112
- for (const lastEndedThreadIdForChat of [undefined, E])
113
- rows.push({
114
- explicitThreadId,
115
- originResolved,
116
- originThreadId,
117
- lastEndedResolvedForChat,
118
- lastEndedThreadIdForChat,
119
- liveThreadId,
120
- })
121
- return rows
143
+ for (const o of ORIGIN_STATES)
144
+ for (const lv of LIVE_STATES)
145
+ for (const en of ENDED_STATES)
146
+ rows.push({ explicitThreadId, ...o, ...lv, ...en, frameworkTopicAuthority })
147
+ return rows // 2 × 3 × 3 × 3 = 54
122
148
  }
123
149
 
124
- // Independent reference encoding the documented precedence (the SPEC), kept
125
- // deliberately separate from the implementation so a regression in either
126
- // surfaces as a divergence rather than a silently-shared bug.
127
- function specExpected(i: AnswerThreadInput): number | undefined {
128
- if (i.explicitThreadId != null) return i.explicitThreadId // tier 1
129
- if (i.originResolved) return i.originThreadId // tier 2 (may be undefined: DM origin)
130
- if (i.liveThreadId != null) return i.liveThreadId // tier 3
131
- if (i.lastEndedResolvedForChat) return i.lastEndedThreadIdForChat // tier 4
132
- return i.liveThreadId // catch-all (undefined here)
150
+ // Independent SPEC encodings, kept separate from the implementation so a
151
+ // regression in either surfaces as a divergence.
152
+ function specFramework(i: AnswerThreadInput): number | undefined {
153
+ if (i.originResolved) return i.originThreadId // (1)
154
+ if (i.liveTurnPresent) return i.liveThreadId // (2)
155
+ if (i.explicitThreadId != null) return i.explicitThreadId // (3)
156
+ if (i.lastEndedResolvedForChat) return i.lastEndedThreadIdForChat // (4)
157
+ return i.liveThreadId
158
+ }
159
+ function specLegacy(i: AnswerThreadInput): number | undefined {
160
+ if (i.explicitThreadId != null) return i.explicitThreadId
161
+ if (i.originResolved) return i.originThreadId
162
+ if (i.liveThreadId != null) return i.liveThreadId
163
+ if (i.lastEndedResolvedForChat) return i.lastEndedThreadIdForChat
164
+ return i.liveThreadId
133
165
  }
134
166
 
135
- describe('resolveAnswerThreadId — total-enumeration determinism proof (all 64 inputs)', () => {
136
- const ROWS = allInputs()
167
+ describe('resolveAnswerThreadId — total-enumeration proof (54 reachable inputs × 2 modes)', () => {
168
+ const FA = reachableInputs(true)
169
+ const LEGACY = reachableInputs(false)
137
170
 
138
- it('the input space is exactly 64 rows (2^6)', () => {
139
- expect(ROWS.length).toBe(64)
171
+ it('the reachable input space is exactly 54 rows per mode', () => {
172
+ expect(FA.length).toBe(54)
173
+ expect(LEGACY.length).toBe(54)
140
174
  })
141
175
 
142
- it('TOTAL: every input returns without throwing', () => {
143
- for (const i of ROWS) {
144
- expect(() => resolveAnswerThreadId(i)).not.toThrow()
145
- }
176
+ it('TOTAL: every input returns without throwing (both modes)', () => {
177
+ for (const i of [...FA, ...LEGACY]) expect(() => resolveAnswerThreadId(i)).not.toThrow()
146
178
  })
147
179
 
148
- it('DETERMINISTIC: each input maps to exactly one output (idempotent across repeated calls)', () => {
149
- for (const i of ROWS) {
150
- const a = resolveAnswerThreadId(i)
151
- const b = resolveAnswerThreadId({ ...i })
152
- expect(b).toBe(a)
180
+ it('DETERMINISTIC: each input maps to exactly one output across repeated calls', () => {
181
+ for (const i of [...FA, ...LEGACY]) {
182
+ expect(resolveAnswerThreadId({ ...i })).toBe(resolveAnswerThreadId(i))
153
183
  }
154
184
  })
155
185
 
156
186
  it('NO FABRICATION: every output is undefined or one of the four input thread fields', () => {
157
- for (const i of ROWS) {
187
+ for (const i of [...FA, ...LEGACY]) {
158
188
  const out = resolveAnswerThreadId(i)
159
189
  const provenance = new Set([
160
190
  undefined,
@@ -167,53 +197,45 @@ describe('resolveAnswerThreadId — total-enumeration determinism proof (all 64
167
197
  }
168
198
  })
169
199
 
170
- it('PRECEDENCE: matches the documented spec on all 64 inputs', () => {
171
- for (const i of ROWS) {
172
- expect(resolveAnswerThreadId(i)).toBe(specExpected(i))
173
- }
200
+ it('PRECEDENCE (framework): matches the documented spec on all 54 inputs', () => {
201
+ for (const i of FA) expect(resolveAnswerThreadId(i)).toBe(specFramework(i))
174
202
  })
175
203
 
176
- // ── By-construction invariants: the output depends ONLY on the highest
177
- // RESOLVED tier, so no lower-tier input (notably a flipped live turn)
178
- // can perturb a higher tier's decision. These are the routing guarantees
179
- // the resolver exists to provide. ─────────────────────────────────────
180
-
181
- it('INV-1 explicit DOMINANCE: explicit set ⇒ output === explicit, independent of all other fields', () => {
182
- for (const i of ROWS) {
183
- if (i.explicitThreadId != null) expect(resolveAnswerThreadId(i)).toBe(i.explicitThreadId)
184
- }
204
+ it('PRECEDENCE (legacy): matches the legacy spec on all 54 inputs', () => {
205
+ for (const i of LEGACY) expect(resolveAnswerThreadId(i)).toBe(specLegacy(i))
185
206
  })
186
207
 
187
- it('INV-2 origin FLIP-IMMUNITY: no explicit + originResolved ⇒ output === originThreadId, for EVERY liveThreadId/lastEnded combo (the Brevo→Meta fix: a currentTurn flip cannot steal a resolved origin)', () => {
188
- for (const i of ROWS) {
189
- if (i.explicitThreadId == null && i.originResolved) {
190
- expect(resolveAnswerThreadId(i)).toBe(i.originThreadId)
208
+ // ── The load-bearing guarantee ────────────────────────────────────────────
209
+ it('INV-ANCHOR (framework): the model explicit CANNOT redirect a reply that has a framework anchor — output is independent of explicitThreadId whenever originResolved OR liveTurnPresent', () => {
210
+ for (const i of FA) {
211
+ if (i.originResolved || i.liveTurnPresent) {
212
+ const withExplicit = resolveAnswerThreadId({ ...i, explicitThreadId: T })
213
+ const without = resolveAnswerThreadId({ ...i, explicitThreadId: undefined })
214
+ expect(withExplicit).toBe(without)
191
215
  }
192
216
  }
193
217
  })
194
218
 
195
- it('INV-3 recovery REACHABILITY: tier-4 (lastEnded) result occurs ONLY when no explicit, no origin, no live turn', () => {
196
- for (const i of ROWS) {
197
- const out = resolveAnswerThreadId(i)
198
- // If the result came from the lastEnded field (and that field is the
199
- // only one carrying that distinct value E), the three higher tiers must
200
- // all be absent.
201
- if (out === E) {
202
- expect(i.explicitThreadId).toBeUndefined()
203
- expect(i.originResolved).toBe(false)
204
- expect(i.liveThreadId).toBeUndefined()
205
- expect(i.lastEndedResolvedForChat).toBe(true)
206
- }
219
+ it('INV-ORIGIN (framework): originResolved output === originThreadId, for EVERY explicit/live/ended combo (flip-immunity + explicit-immunity)', () => {
220
+ for (const i of FA) if (i.originResolved) expect(resolveAnswerThreadId(i)).toBe(i.originThreadId)
221
+ })
222
+
223
+ it('INV-LIVE (framework): ¬originResolved liveTurnPresent output === liveThreadId, regardless of explicit', () => {
224
+ for (const i of FA) {
225
+ if (!i.originResolved && i.liveTurnPresent) expect(resolveAnswerThreadId(i)).toBe(i.liveThreadId)
207
226
  }
208
227
  })
209
228
 
210
- it('INV-4 live-tier REACHABILITY: tier-3 (live) result occurs ONLY when no explicit and no resolved origin', () => {
211
- for (const i of ROWS) {
212
- const out = resolveAnswerThreadId(i)
213
- if (out === L) {
214
- expect(i.explicitThreadId).toBeUndefined()
229
+ it('INV-EXPLICIT-LAST (framework): an explicit-tier result occurs ONLY when no origin AND no live turn', () => {
230
+ for (const i of FA) {
231
+ if (resolveAnswerThreadId(i) === T) {
215
232
  expect(i.originResolved).toBe(false)
233
+ expect(i.liveTurnPresent).toBe(false)
216
234
  }
217
235
  }
218
236
  })
237
+
238
+ it('INV-EXPLICIT-DOMINANCE (legacy): explicit set ⇒ output === explicit, independent of all other fields', () => {
239
+ for (const i of LEGACY) if (i.explicitThreadId != null) expect(resolveAnswerThreadId(i)).toBe(i.explicitThreadId)
240
+ })
219
241
  })
@@ -5,35 +5,48 @@
5
5
  * Pure decision: which forum-topic thread should an ANSWER reply
6
6
  * (`reply` / `stream_reply`) land in?
7
7
  *
8
- * ## The bug this closes
8
+ * ## The bugs this closes
9
9
  *
10
- * In a forum supergroup one sequential `claude` CLI owns every topic with
11
- * a singleton `currentTurn`. The Brevo turn's reply landed ~42s after its
12
- * turn-end event; by then `currentTurn` had flipped to the Meta turn.
13
- * `executeReply` captured `const turn = currentTurn` at execution and, when
14
- * the model omitted `message_thread_id`, resolved the thread from
15
- * `turn.sessionThreadId` (Meta's thread) — so Brevo's answer landed in
16
- * Meta. A successor turn stole a predecessor's late reply.
10
+ * **(a) currentTurn flip (2026-06-05).** In a forum supergroup one sequential
11
+ * `claude` CLI owns every topic with a singleton `currentTurn`. A late reply
12
+ * landed after `currentTurn` had flipped to a successor topic, so the answer
13
+ * went to the wrong topic. Fixed by pinning to the ORIGIN turn's thread (tier 1).
17
14
  *
18
- * ## The precedence (answer paths)
15
+ * **(b) model-chosen topic override (2026-06-08).** A General-topic question's
16
+ * answer landed in the CRM topic because the model passed an explicit
17
+ * `message_thread_id` and the resolver let that win OUTRIGHT over the
18
+ * framework's record of where the question came from. General is the trap: its
19
+ * messages carry no thread id, so nothing forces the reply back — and the
20
+ * model's explicit "post to CRM" overrode the General origin. The user, reading
21
+ * General, saw silence. This is a model-dependency: a reply's topic must not be
22
+ * the model's free choice.
19
23
  *
20
- * 1. An explicit `message_thread_id` the MODEL passed wins outright
21
- * the model is asserting the destination topic.
22
- * 2. Else the ORIGIN turn's thread: the turn matched by the reply's
23
- * `origin_turn_id` (the meta field the model echoes back). This is
24
- * authoritative even when `currentTurn` has flipped, because the
25
- * origin turn is looked up in a recently-ended registry.
26
- * 3. Else the LIVE turn's thread — but ONLY when the live turn IS the
27
- * origin turn (no flip happened) OR no origin turn could be resolved
28
- * at all (origin id absent/unknown; legacy / pre-stamp path).
29
- * 4. Else (no explicit, no origin echoed, no live turn) a LATE reply that
30
- * fired after its turn already ended (the orphaned-reply backstop case)
31
- * recover the origin topic from the most-recently-ended turn for this
32
- * chat. Without this, such a reply defaults to the main chat (General in a
33
- * supergroup) and its answer vanishes from the topic the user is reading
34
- * (the 2026-06-05 marko triage). Still NOT the `chatThreadMap` last-seen
35
- * heuristicthe recovered turn is the chat's own most-recent turn, not
36
- * whichever topic last received any message.
24
+ * ## The precedence (answer paths) FRAMEWORK AUTHORITY (default)
25
+ *
26
+ * The topic a reply lands in is owned by the TURN it answers, not the model's
27
+ * `message_thread_id`. The model's explicit thread is demoted to a last resort
28
+ * used only when the framework has NO turn anchor at all (a genuinely
29
+ * orphaned / proactive send). Precedence:
30
+ *
31
+ * 1. ORIGIN turn's thread the turn matched by the reply's `origin_turn_id`
32
+ * (echoed) or a quoted `reply_to` (framework reverse-index). Authoritative
33
+ * even when `currentTurn` flipped (closes bug a). A General/DM origin
34
+ * yields `undefined`, which correctly routes to the main chat / General.
35
+ * 2. LIVE in-flight turn's thread — keyed on the turn's PRESENCE, not its
36
+ * thread value, so a General live turn (thread `undefined`) still anchors
37
+ * the reply to the General conversation. The model's explicit cannot
38
+ * redirect a reply that belongs to an in-flight turn (closes bug b).
39
+ * 3. EXPLICIT model thread only now, when there is neither an origin nor a
40
+ * live turn (a late / proactive send with no framework anchor). Here the
41
+ * model's `message_thread_id` is the only signal, so honour it.
42
+ * 4. LATE-reply recovery — no explicit, no origin, no live turn: recover the
43
+ * origin topic from the most-recently-ended turn for this chat, so an
44
+ * orphaned-backstop reply lands in its topic instead of defaulting to the
45
+ * main chat (General). Not the `chatThreadMap` last-seen heuristic.
46
+ *
47
+ * Setting `frameworkTopicAuthority: false` (kill switch
48
+ * SWITCHROOM_REPLY_TOPIC_AUTHORITY=0) restores the legacy explicit-first
49
+ * precedence (the model's thread wins outright).
37
50
  *
38
51
  * The `chatThreadMap` last-seen fallback is preserved for NON-answer
39
52
  * surfaces (`send_typing`, `forward_message`, `progress_update`) by NOT
@@ -50,13 +63,17 @@ export interface AnswerThreadInput {
50
63
  * `originResolved` is true. */
51
64
  originThreadId?: number | undefined
52
65
  /** Whether an origin turn was resolved at all. Distinguishes
53
- * "origin turn exists and its thread is undefined (a DM origin)" from
66
+ * "origin turn exists and its thread is undefined (a DM/General origin)" from
54
67
  * "no origin turn" — both surface as `originThreadId === undefined`. */
55
68
  originResolved: boolean
56
69
  /** Thread of the LIVE `currentTurn` at execution time, or undefined
57
- * (no live turn, or a DM live turn). The legacy (#1664) fallback when
58
- * no origin turn is resolvable. */
70
+ * (no live turn, or a DM/General live turn). */
59
71
  liveThreadId?: number | undefined
72
+ /** Whether a LIVE in-flight turn exists at execution time. Distinguishes a
73
+ * General (thread-undefined) live turn — still a valid framework anchor —
74
+ * from no live turn at all. Mirrors `originResolved`. Absent in the legacy
75
+ * path (which keys off `liveThreadId != null`). */
76
+ liveTurnPresent?: boolean
60
77
  /**
61
78
  * Late-reply topic recovery (2026-06-05). Thread of the most-recently-ended
62
79
  * turn for THIS chat (from `recentTurnsById`), used as a deterministic
@@ -64,42 +81,52 @@ export interface AnswerThreadInput {
64
81
  * turn — the late-reply-after-turn-end case. Without it, a reply that fires
65
82
  * after the orphaned-reply backstop closed its turn defaults to the main chat
66
83
  * (General topic in a supergroup), so its answer vanishes from the topic the
67
- * user is reading. Only consulted at tier (4); a DM origin yields undefined,
68
- * which is correct.
84
+ * user is reading. A DM origin yields undefined, which is correct.
69
85
  */
70
86
  lastEndedThreadIdForChat?: number | undefined
71
87
  /** Whether a recently-ended turn exists for this chat — distinguishes
72
88
  * "ended turn exists, DM (thread undefined)" from "no ended turn at all". */
73
89
  lastEndedResolvedForChat?: boolean
90
+ /**
91
+ * When true (default), the framework's turn anchor (origin → live) owns the
92
+ * reply topic and the model's `explicitThreadId` is a last resort (consulted
93
+ * only when no anchor exists). When false, the legacy explicit-first
94
+ * precedence (the model's thread wins outright). Undefined is treated as
95
+ * true. Kill switch SWITCHROOM_REPLY_TOPIC_AUTHORITY=0.
96
+ */
97
+ frameworkTopicAuthority?: boolean
74
98
  }
75
99
 
76
100
  /**
77
101
  * Pure. Returns the thread id to send the answer to, or undefined for the
78
- * main chat (DM / no thread).
102
+ * main chat (DM / General / no thread).
79
103
  *
80
- * Precedence: explicit model thread origin turn's thread (authoritative
81
- * across a currentTurn flip; this is the wrong-topic fix) live turn's
82
- * thread (legacy #1664 fallback when origin can't be resolved). Crucially
83
- * the chat last-seen `chatThreadMap` heuristic is NOT in this chain — that
84
- * heuristic is what produced the Brevo→Meta wrong-topic bug, so answer
85
- * paths never reach it.
104
+ * Default (framework authority): origin turnlive in-flight turn explicit
105
+ * model thread late-ended recovery. The model's `explicitThreadId` cannot
106
+ * override a resolved origin or a live turn it is consulted only when neither
107
+ * exists. The chat last-seen `chatThreadMap` heuristic is NOT in this chain.
86
108
  */
87
109
  export function resolveAnswerThreadId(input: AnswerThreadInput): number | undefined {
88
- // (1) explicit model thread wins.
89
- if (input.explicitThreadId != null) return input.explicitThreadId
90
- // (2) origin turn resolved → pin to its thread (authoritative even when
91
- // currentTurn has flipped to a successor). A DM origin yields
92
- // undefined, which is correct.
110
+ if (input.frameworkTopicAuthority === false) {
111
+ // ── Legacy precedence (kill switch): the model's explicit thread wins. ──
112
+ if (input.explicitThreadId != null) return input.explicitThreadId
113
+ if (input.originResolved) return input.originThreadId
114
+ if (input.liveThreadId != null) return input.liveThreadId
115
+ if (input.lastEndedResolvedForChat) return input.lastEndedThreadIdForChat
116
+ return input.liveThreadId
117
+ }
118
+ // ── Framework-authority precedence (default) ───────────────────────────────
119
+ // (1) origin turn → its thread (authoritative across a currentTurn flip; a
120
+ // General/DM origin yields undefined → main chat / General).
93
121
  if (input.originResolved) return input.originThreadId
94
- // (3) no origin resolved (legacy / pre-stamp / evicted) fall back to
95
- // the live turn's thread, the existing turn-pinned behaviour (#1664).
96
- if (input.liveThreadId != null) return input.liveThreadId
97
- // (4) no explicit, no origin echoed, no live turn — a LATE reply that fired
98
- // after its turn already ended (the orphaned-reply backstop case).
99
- // Recover the origin topic from the most-recently-ended turn for this
100
- // chat so the answer lands in the topic it belongs to instead of
101
- // defaulting to the main chat (General). When no ended turn is known,
102
- // fall through to liveThreadId (undefined) — the legacy result.
122
+ // (2) a live in-flight turn its thread. Key off PRESENCE, not the thread
123
+ // value: a General live turn has an undefined thread but is still the
124
+ // anchor, so the model's explicit can't pull the reply out of it.
125
+ if (input.liveTurnPresent) return input.liveThreadId
126
+ // (3) no framework anchor (genuinely orphaned / proactive) → honour the
127
+ // model's explicit thread, its only signal here.
128
+ if (input.explicitThreadId != null) return input.explicitThreadId
129
+ // (4) late reply, no anchor, no explicit recover the chat's last-ended topic.
103
130
  if (input.lastEndedResolvedForChat) return input.lastEndedThreadIdForChat
104
131
  return input.liveThreadId
105
132
  }
@@ -471,6 +471,10 @@ import {
471
471
  } from './resume-inbound-builder.js'
472
472
  import { applySubagentsSchema, getSubagentByJsonlId } from '../registry/subagents-schema.js'
473
473
  import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
474
+ import {
475
+ resolveSubagentStatusSurface,
476
+ isOrphanSubagentStatusEnabled,
477
+ } from './subagent-status-surface.js'
474
478
  import { formatIdleFooter } from '../idle-footer.js'
475
479
  import { resolveCallingSubagent } from './resolve-calling-subagent.js'
476
480
 
@@ -1566,6 +1570,15 @@ const TURN_ORIGIN_ROUTING_ENABLED =
1566
1570
  // mask a misroute. Kill switch off (=0) → echo-only origin (today's behaviour).
1567
1571
  const FRAMEWORK_ORIGIN_ROUTING_ENABLED =
1568
1572
  process.env.SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== '0'
1573
+ // Reply-topic framework authority (2026-06-08 determinism pass). The topic an
1574
+ // ANSWER lands in is owned by the TURN it answers (origin → live), NOT the
1575
+ // model's `message_thread_id`. The model's explicit thread is demoted to a
1576
+ // last resort (consulted only when there is no origin AND no live turn). Closes
1577
+ // the model-chosen-topic override that sent a General-topic question's answer
1578
+ // into the CRM topic (marko) — the model could redirect any reply. Kill switch
1579
+ // off (=0) → legacy explicit-first precedence (the model's thread wins).
1580
+ const REPLY_TOPIC_AUTHORITY_ENABLED =
1581
+ process.env.SWITCHROOM_REPLY_TOPIC_AUTHORITY !== '0'
1569
1582
  // Component 4 (per-turn topic framing). Add a one-line directive to the
1570
1583
  // channel meta + bridge instructions telling the model to answer ONLY the
1571
1584
  // current message's topic. Kill switch off (=0) → no framing field.
@@ -2033,15 +2046,32 @@ function resolveAnswerThreadWithLog(
2033
2046
  originResolved: originTurn != null,
2034
2047
  originThreadId: originTurn?.sessionThreadId,
2035
2048
  liveThreadId: liveTurn?.sessionThreadId,
2049
+ liveTurnPresent: liveTurn != null,
2036
2050
  lastEndedResolvedForChat: recovered != null,
2037
2051
  lastEndedThreadIdForChat: recovered?.sessionThreadId,
2052
+ frameworkTopicAuthority: REPLY_TOPIC_AUTHORITY_ENABLED,
2038
2053
  })
2039
- const via =
2040
- explicitThreadId != null ? 'explicit'
2041
- : originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
2042
- : liveTurn?.sessionThreadId != null ? 'live'
2043
- : recovered != null ? 'recovered'
2044
- : 'none'
2054
+ // `via` reflects the ACTIVE precedence so telemetry matches routing.
2055
+ const via = REPLY_TOPIC_AUTHORITY_ENABLED
2056
+ ? (originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
2057
+ : liveTurn != null ? 'live'
2058
+ : explicitThreadId != null ? 'explicit'
2059
+ : recovered != null ? 'recovered'
2060
+ : 'none')
2061
+ : (explicitThreadId != null ? 'explicit'
2062
+ : originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
2063
+ : liveTurn?.sessionThreadId != null ? 'live'
2064
+ : recovered != null ? 'recovered'
2065
+ : 'none')
2066
+ // Observability: the model passed an explicit topic but a framework anchor
2067
+ // (origin/live) overrode it. This is the deterministic correction that fixes
2068
+ // the General→CRM misroute; surface it so the model's topic-grabbing is
2069
+ // visible rather than silent.
2070
+ const explicitOverridden =
2071
+ REPLY_TOPIC_AUTHORITY_ENABLED &&
2072
+ explicitThreadId != null &&
2073
+ (originTurn != null || liveTurn != null) &&
2074
+ threadId !== explicitThreadId
2045
2075
  const ownerTurn = originTurn ?? recovered ?? liveTurn
2046
2076
  const isSupergroup = chatId.startsWith('-100')
2047
2077
  // UNROUTED = a supergroup reply that resolved to NO topic with NO owner turn
@@ -2068,6 +2098,9 @@ function resolveAnswerThreadWithLog(
2068
2098
  (via === 'quoted' ? ' QUOTED(framework-origin)' : '') +
2069
2099
  (unrouted ? ' UNROUTED(supergroup→no-topic)' : '') +
2070
2100
  (misrouteRisk ? ' MISROUTE_RISK(no-echo→live-successor)' : '') +
2101
+ (explicitOverridden
2102
+ ? ` EXPLICIT_OVERRIDDEN(model→${explicitThreadId},routed→${threadId ?? '-'})`
2103
+ : '') +
2071
2104
  '\n',
2072
2105
  )
2073
2106
  return threadId
@@ -20381,6 +20414,11 @@ void (async () => {
20381
20414
  // compose draft, so no answer-stream contention). The kill-switch
20382
20415
  // disables only the nesting; the parent's own feed is unaffected.
20383
20416
  const foregroundNestingEnabled = process.env.SWITCHROOM_FOREGROUND_SUBAGENT_NESTING !== '0'
20417
+ // Orphaned-foreground status (2026-06-09): a FOREGROUND sub-agent
20418
+ // with no live parent turn to nest into (dispatched outside a turn,
20419
+ // or the turn ended while it kept running — extended autonomous
20420
+ // work) is surfaced via the worker feed instead of vanishing.
20421
+ const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS)
20384
20422
  const workerActivityFeed = createWorkerActivityFeed({
20385
20423
  bot: {
20386
20424
  sendMessage: async (cid, text, sendOpts) => {
@@ -20580,6 +20618,29 @@ void (async () => {
20580
20618
  }
20581
20619
  }
20582
20620
  }
20621
+ return
20622
+ }
20623
+ // Not nested → an orphaned foreground sub-agent that was
20624
+ // surfaced via the worker feed (no live turn to nest into):
20625
+ // finalize its message (no-op if none was posted). A
20626
+ // foreground result returns inline as the Task tool result, so
20627
+ // there is no handback to deliver — return after.
20628
+ if (
20629
+ resolveSubagentStatusSurface({
20630
+ isBackground: false,
20631
+ liveTurnPresent: false,
20632
+ workerFeedEnabled,
20633
+ orphanStatusEnabled,
20634
+ }) === 'worker-feed'
20635
+ ) {
20636
+ void workerActivityFeed.finish(agentId, {
20637
+ description: dispatch.feedDescription,
20638
+ lastTool: null,
20639
+ toolCount,
20640
+ latestSummary: resultText,
20641
+ elapsedMs: durationMs,
20642
+ state: outcome === 'failed' ? 'failed' : 'done',
20643
+ })
20583
20644
  }
20584
20645
  return
20585
20646
  }
@@ -20709,8 +20770,39 @@ void (async () => {
20709
20770
  // activity draft rather than a separate worker message. Pure
20710
20771
  // jsonl-tail → render (no model call), inside the
20711
20772
  // subscription-honest boundary.
20773
+ //
20774
+ // But a foreground sub-agent with NO live turn to nest into
20775
+ // (dispatched outside a turn, or the turn ended while it kept
20776
+ // running — extended autonomous work) has nowhere to nest, and
20777
+ // pre-fix it silently returned here → invisible. Route through
20778
+ // the proven decision: an orphaned foreground sub-agent goes to
20779
+ // the worker feed (owner-DM fallback), not into the void.
20780
+ const surface = resolveSubagentStatusSurface({
20781
+ isBackground: false,
20782
+ liveTurnPresent: currentTurn != null,
20783
+ workerFeedEnabled,
20784
+ orphanStatusEnabled,
20785
+ })
20786
+ if (surface === 'worker-feed') {
20787
+ const origin = resolveSubagentOriginChat(agentId)
20788
+ void workerActivityFeed.update(
20789
+ agentId,
20790
+ origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ''),
20791
+ {
20792
+ description: dispatch.feedDescription,
20793
+ lastTool,
20794
+ toolCount,
20795
+ latestSummary,
20796
+ elapsedMs,
20797
+ state: 'running',
20798
+ },
20799
+ origin?.threadId,
20800
+ )
20801
+ return
20802
+ }
20803
+ if (surface !== 'nest') return // 'skip' — orphan-status off
20712
20804
  const turn = currentTurn
20713
- if (turn == null) return
20805
+ if (turn == null) return // defensive: 'nest' implies a live turn
20714
20806
  // Render regardless of `replyCalled` — a foreground Task
20715
20807
  // blocks the parent, so any reply seen while it runs is an
20716
20808
  // interim ack, never the final answer. Gating on replyCalled
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ resolveSubagentStatusSurface,
4
+ isOrphanSubagentStatusEnabled,
5
+ type SubagentStatusSurface,
6
+ type SubagentStatusSurfaceInput,
7
+ } from './subagent-status-surface.js'
8
+
9
+ // ── Human-readable map ──────────────────────────────────────────────────────
10
+ describe('resolveSubagentStatusSurface', () => {
11
+ const base: SubagentStatusSurfaceInput = {
12
+ isBackground: false,
13
+ liveTurnPresent: true,
14
+ workerFeedEnabled: true,
15
+ orphanStatusEnabled: true,
16
+ }
17
+ it('foreground + live turn → nest (unchanged default)', () => {
18
+ expect(resolveSubagentStatusSurface(base)).toBe('nest')
19
+ })
20
+ it('THE fix: orphaned foreground (no live turn) → worker-feed', () => {
21
+ expect(resolveSubagentStatusSurface({ ...base, liveTurnPresent: false })).toBe('worker-feed')
22
+ })
23
+ it('kill switch: orphaned foreground with orphanStatus OFF → skip (pre-fix invisible)', () => {
24
+ expect(
25
+ resolveSubagentStatusSurface({ ...base, liveTurnPresent: false, orphanStatusEnabled: false }),
26
+ ).toBe('skip')
27
+ })
28
+ it('orphaned foreground but feed OFF → skip (nothing to surface through)', () => {
29
+ expect(
30
+ resolveSubagentStatusSurface({ ...base, liveTurnPresent: false, workerFeedEnabled: false }),
31
+ ).toBe('skip')
32
+ })
33
+ it('background + feed on → worker-feed', () => {
34
+ expect(resolveSubagentStatusSurface({ ...base, isBackground: true, liveTurnPresent: false })).toBe('worker-feed')
35
+ })
36
+ it('background + feed off → legacy-relay', () => {
37
+ expect(
38
+ resolveSubagentStatusSurface({ ...base, isBackground: true, workerFeedEnabled: false }),
39
+ ).toBe('legacy-relay')
40
+ })
41
+ })
42
+
43
+ describe('isOrphanSubagentStatusEnabled — default ON, =0 kill switch', () => {
44
+ it('undefined / "1" / "" → on; "0" → off', () => {
45
+ expect(isOrphanSubagentStatusEnabled(undefined)).toBe(true)
46
+ expect(isOrphanSubagentStatusEnabled('1')).toBe(true)
47
+ expect(isOrphanSubagentStatusEnabled('')).toBe(true)
48
+ expect(isOrphanSubagentStatusEnabled('0')).toBe(false)
49
+ })
50
+ })
51
+
52
+ // ── TOTAL-ENUMERATION DETERMINISM PROOF ─────────────────────────────────────
53
+ // 4 booleans = 16 reachable inputs. Enumerate all, assert totality, determinism,
54
+ // the documented table (independent spec), and the load-bearing invariants.
55
+ // (operator standard feedback_prove_finite_fsm_not_sample.)
56
+ function allInputs(): SubagentStatusSurfaceInput[] {
57
+ const rows: SubagentStatusSurfaceInput[] = []
58
+ for (const isBackground of [false, true])
59
+ for (const liveTurnPresent of [false, true])
60
+ for (const workerFeedEnabled of [false, true])
61
+ for (const orphanStatusEnabled of [false, true])
62
+ rows.push({ isBackground, liveTurnPresent, workerFeedEnabled, orphanStatusEnabled })
63
+ return rows
64
+ }
65
+
66
+ // Independent spec encoding (kept separate from the impl).
67
+ function spec(i: SubagentStatusSurfaceInput): SubagentStatusSurface {
68
+ if (i.isBackground) return i.workerFeedEnabled ? 'worker-feed' : 'legacy-relay'
69
+ if (i.liveTurnPresent) return 'nest'
70
+ if (!i.orphanStatusEnabled) return 'skip'
71
+ return i.workerFeedEnabled ? 'worker-feed' : 'skip'
72
+ }
73
+
74
+ describe('resolveSubagentStatusSurface — total enumeration (16 inputs)', () => {
75
+ const ROWS = allInputs()
76
+
77
+ it('exactly 16 reachable inputs (2^4)', () => {
78
+ expect(ROWS.length).toBe(16)
79
+ })
80
+ it('TOTAL + DETERMINISTIC: every input returns one of the four surfaces, idempotently', () => {
81
+ const surfaces = new Set<SubagentStatusSurface>(['nest', 'worker-feed', 'legacy-relay', 'skip'])
82
+ for (const i of ROWS) {
83
+ const a = resolveSubagentStatusSurface(i)
84
+ expect(surfaces.has(a)).toBe(true)
85
+ expect(resolveSubagentStatusSurface({ ...i })).toBe(a)
86
+ }
87
+ })
88
+ it('PRECEDENCE: matches the documented spec on all 16 inputs', () => {
89
+ for (const i of ROWS) expect(resolveSubagentStatusSurface(i)).toBe(spec(i))
90
+ })
91
+
92
+ it('INV-ORPHAN-VISIBLE: an orphaned foreground sub-agent is NEVER skip when orphanStatus + feed are on', () => {
93
+ for (const i of ROWS) {
94
+ if (!i.isBackground && !i.liveTurnPresent && i.orphanStatusEnabled && i.workerFeedEnabled) {
95
+ expect(resolveSubagentStatusSurface(i)).toBe('worker-feed')
96
+ }
97
+ }
98
+ })
99
+ it('INV-KILL-SWITCH: orphanStatus OFF ⇒ an orphaned foreground sub-agent is exactly the pre-fix behaviour (skip)', () => {
100
+ for (const i of ROWS) {
101
+ if (!i.isBackground && !i.liveTurnPresent && !i.orphanStatusEnabled) {
102
+ expect(resolveSubagentStatusSurface(i)).toBe('skip')
103
+ }
104
+ }
105
+ })
106
+ it('INV-NEST-UNCHANGED: a foreground sub-agent with a live turn is ALWAYS nest, independent of the other flags', () => {
107
+ for (const i of ROWS) {
108
+ if (!i.isBackground && i.liveTurnPresent) expect(resolveSubagentStatusSurface(i)).toBe('nest')
109
+ }
110
+ })
111
+ it('INV-BACKGROUND-UNCHANGED: background routing depends ONLY on the feed flag, never on liveTurn/orphanStatus', () => {
112
+ for (const i of ROWS) {
113
+ if (i.isBackground) {
114
+ expect(resolveSubagentStatusSurface(i)).toBe(i.workerFeedEnabled ? 'worker-feed' : 'legacy-relay')
115
+ }
116
+ }
117
+ })
118
+ })
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Where does a sub-agent's live status go?
3
+ *
4
+ * A sub-agent's progress is surfaced on one of four "surfaces". This pure
5
+ * decision picks which, so the routing is provable by total enumeration rather
6
+ * than buried in the gateway's imperative branches.
7
+ *
8
+ * - `nest` — a FOREGROUND sub-agent running inside a LIVE parent turn:
9
+ * its narrative nests under the parent's activity draft
10
+ * (the progress card). The default, unchanged.
11
+ * - `worker-feed` — a BACKGROUND worker (the `🛠 Worker` edit-in-place
12
+ * message), OR a FOREGROUND sub-agent that has NO live
13
+ * parent turn to nest into (dispatched outside a turn, or
14
+ * the turn ended while it kept running). The latter is the
15
+ * 2026-06-09 fix: extended autonomous work was invisible
16
+ * because foreground status was turn-scoped and a sub-agent
17
+ * with no turn silently returned. It now reuses the worker
18
+ * feed (with the same owner-DM fallback background workers
19
+ * already use), so post-turn work is always visible.
20
+ * - `legacy-relay` — a BACKGROUND worker when the worker feed is OFF: fall
21
+ * back to the legacy "still working" injected-inbound relay.
22
+ * - `skip` — nothing to surface (kill-switch off for an orphaned
23
+ * foreground, or no feed to surface it through).
24
+ *
25
+ * Determinism: the input space is 4 booleans = 16 rows, enumerated and proven
26
+ * in subagent-status-surface.test.ts (operator standard
27
+ * feedback_prove_finite_fsm_not_sample). The load-bearing invariant: an
28
+ * orphaned foreground sub-agent (no live turn) is `worker-feed`, never `skip`,
29
+ * whenever the orphan-status flag and the feed are both on.
30
+ */
31
+
32
+ export type SubagentStatusSurface = 'nest' | 'worker-feed' | 'legacy-relay' | 'skip'
33
+
34
+ export interface SubagentStatusSurfaceInput {
35
+ /** run_in_background dispatch (registry `subagents.background`). */
36
+ isBackground: boolean
37
+ /**
38
+ * A LIVE parent turn exists to nest this (foreground) sub-agent into.
39
+ * onProgress: `currentTurn != null`. onFinish: `currentTurn != null && it was
40
+ * actually nested` (so a foreground sub-agent that was surfaced via the worker
41
+ * feed — never nested — finalizes through the feed, not a turn collapse).
42
+ * Ignored for background sub-agents.
43
+ */
44
+ liveTurnPresent: boolean
45
+ /** SWITCHROOM_WORKER_ACTIVITY_FEED on. */
46
+ workerFeedEnabled: boolean
47
+ /** SWITCHROOM_ORPHAN_SUBAGENT_STATUS on — surface no-parent-turn foreground
48
+ * sub-agents via the worker feed. Off = pre-fix behaviour (invisible). */
49
+ orphanStatusEnabled: boolean
50
+ }
51
+
52
+ export function resolveSubagentStatusSurface(
53
+ input: SubagentStatusSurfaceInput,
54
+ ): SubagentStatusSurface {
55
+ if (!input.isBackground) {
56
+ // Foreground sub-agent.
57
+ if (input.liveTurnPresent) return 'nest'
58
+ // Orphaned foreground: no live turn to nest into — the invisible case.
59
+ if (!input.orphanStatusEnabled) return 'skip' // kill switch: pre-fix behaviour
60
+ return input.workerFeedEnabled ? 'worker-feed' : 'skip' // surfacing needs the feed
61
+ }
62
+ // Background worker.
63
+ return input.workerFeedEnabled ? 'worker-feed' : 'legacy-relay'
64
+ }
65
+
66
+ /** SWITCHROOM_ORPHAN_SUBAGENT_STATUS — default ON; `=0` restores pre-fix (invisible) behaviour. */
67
+ export function isOrphanSubagentStatusEnabled(envVal: string | undefined): boolean {
68
+ return envVal !== '0'
69
+ }
@@ -16,12 +16,25 @@ describe('resolveAnswerThreadId', () => {
16
16
  const BREVO = 4
17
17
  const META = 3
18
18
 
19
- it('explicit model thread wins outright', () => {
19
+ it('the model explicit no longer wins over a framework anchor (origin wins); kill switch restores legacy', () => {
20
+ // Default (framework authority): the resolved origin owns the topic — the
21
+ // model passing a different explicit thread cannot redirect the reply.
20
22
  expect(
21
23
  resolveAnswerThreadId({
24
+ explicitThreadId: BREVO, // model tried to send it to Brevo
25
+ originResolved: true,
26
+ originThreadId: META, // but the question came from Meta
27
+ liveTurnPresent: true,
28
+ liveThreadId: META,
29
+ }),
30
+ ).toBe(META)
31
+ // Kill switch (SWITCHROOM_REPLY_TOPIC_AUTHORITY=0): explicit wins outright.
32
+ expect(
33
+ resolveAnswerThreadId({
34
+ frameworkTopicAuthority: false,
22
35
  explicitThreadId: BREVO,
23
36
  originResolved: true,
24
- originThreadId: META, // even if origin says otherwise
37
+ originThreadId: META,
25
38
  liveThreadId: META,
26
39
  }),
27
40
  ).toBe(BREVO)
@@ -86,18 +99,20 @@ describe('resolveAnswerThreadId', () => {
86
99
  ).toBeUndefined()
87
100
  })
88
101
 
89
- it('precedence: explicit > origin > live (never chatThreadMap — not an input)', () => {
102
+ it('precedence (framework authority, default): origin > live > explicit (never chatThreadMap — not an input)', () => {
90
103
  // The chat last-seen thread is deliberately NOT a parameter: answer
91
104
  // paths can never reach it, which is what closes the wrong-topic bug.
92
- // explicit beats both:
105
+ // The model's explicit no longer beats a framework anchor — origin wins
106
+ // even when the model asserted a different topic (the General→CRM fix):
93
107
  expect(
94
108
  resolveAnswerThreadId({
95
109
  explicitThreadId: 9,
96
110
  originResolved: true,
97
111
  originThreadId: BREVO,
112
+ liveTurnPresent: true,
98
113
  liveThreadId: META,
99
114
  }),
100
- ).toBe(9)
115
+ ).toBe(BREVO)
101
116
  // no explicit, origin resolved → origin (not live):
102
117
  expect(
103
118
  resolveAnswerThreadId({
@@ -107,5 +122,15 @@ describe('resolveAnswerThreadId', () => {
107
122
  liveThreadId: META,
108
123
  }),
109
124
  ).toBe(BREVO)
125
+ // kill switch restores the legacy explicit-first precedence:
126
+ expect(
127
+ resolveAnswerThreadId({
128
+ frameworkTopicAuthority: false,
129
+ explicitThreadId: 9,
130
+ originResolved: true,
131
+ originThreadId: BREVO,
132
+ liveThreadId: META,
133
+ }),
134
+ ).toBe(9)
110
135
  })
111
136
  })