switchroom 0.14.41 → 0.14.43

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 (26) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  3. package/dist/cli/drive-write-pretool.mjs +10 -10
  4. package/dist/cli/notion-write-pretool.mjs +82 -82
  5. package/dist/cli/skill-validate-pretool.mjs +72 -72
  6. package/dist/cli/switchroom.js +357 -357
  7. package/dist/host-control/main.js +148 -148
  8. package/dist/vault/approvals/kernel-server.js +82 -82
  9. package/dist/vault/broker/server.js +83 -83
  10. package/package.json +1 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  12. package/telegram-plugin/dist/gateway/gateway.js +396 -212
  13. package/telegram-plugin/dist/server.js +160 -160
  14. package/telegram-plugin/gateway/gateway.ts +126 -29
  15. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +22 -0
  16. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +13 -0
  17. package/telegram-plugin/subagent-watcher.ts +44 -0
  18. package/telegram-plugin/tests/subagent-handback-decision.test.ts +32 -0
  19. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +35 -0
  20. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +56 -0
  21. package/telegram-plugin/tests/subagent-watcher.test.ts +42 -0
  22. package/telegram-plugin/uat/driver.ts +41 -0
  23. package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +17 -10
  24. package/telegram-plugin/uat/scenarios/fuzz-supergroup-channel.test.ts +136 -0
  25. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +9 -7
  26. package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +102 -0
@@ -251,7 +251,7 @@ import { handleInjectCommand } from './inject-handler.js'
251
251
  import { type BannerState } from '../slot-banner.js'
252
252
  import { refreshBanner } from '../slot-banner-driver.js'
253
253
  import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
254
- import { resolveOutboundTopic as resolveOutboundTopicHelper, type TopicRouterConfig as _OutboundRouterConfig } from '../../src/telegram/topic-router.js'
254
+ import { resolveOutboundTopic as resolveOutboundTopicHelper, topicForRecipient, type TopicRouterConfig as _OutboundRouterConfig } from '../../src/telegram/topic-router.js'
255
255
  import { readTurnUsages } from '../../src/agents/perf.js'
256
256
  import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
257
257
  import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
@@ -2273,14 +2273,20 @@ function postPermissionResumeMessage(opts: {
2273
2273
  const targets: Array<{ chatId: string; threadId: number | undefined }> =
2274
2274
  turn != null
2275
2275
  ? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }]
2276
- : loadAccess().allowFrom.map(chatId => ({
2277
- chatId,
2278
- threadId: resolveAgentOutboundTopic({
2276
+ : (() => {
2277
+ const sg = resolveAgentSupergroupChatId()
2278
+ const topic = resolveAgentOutboundTopic({
2279
2279
  kind: 'permission',
2280
2280
  turnInitiated: false,
2281
2281
  originThreadId: undefined,
2282
- }),
2283
- }))
2282
+ })
2283
+ // allowFrom is normally operator DMs — attach the topic only to a
2284
+ // recipient that owns it (the supergroup), never a DM (marko wedge).
2285
+ return loadAccess().allowFrom.map(chatId => ({
2286
+ chatId,
2287
+ threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg }),
2288
+ }))
2289
+ })()
2284
2290
  for (const { chatId, threadId } of targets) {
2285
2291
  // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send
2286
2292
  void swallowingApiCall(
@@ -4664,16 +4670,20 @@ const ipcServer: IpcServer = createIpcServer({
4664
4670
  turnInitiated: activeTurn != null,
4665
4671
  originThreadId: activeTurn?.sessionThreadId,
4666
4672
  })
4673
+ const permSupergroup = resolveAgentSupergroupChatId()
4667
4674
  for (const chat_id of access.allowFrom) {
4668
4675
  // parse_mode=HTML pairs with formatPermissionCardBody (#1790)
4669
4676
  // so the <b>/<i> tags render as formatting.
4670
- // PR4b emitter sweep opts now optionally carries
4671
- // message_thread_id when supergroup mode is on.
4677
+ // The resolved topic is valid only in the agent's supergroup — attach
4678
+ // it ONLY when this recipient IS that supergroup. allowFrom DMs get the
4679
+ // card thread-less; attaching a topic to a DM yields 400 "message thread
4680
+ // not found" → card never arrives → auto-deny → wedge (marko 2026-06-02).
4681
+ const permThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: permTopic, supergroupChatId: permSupergroup })
4672
4682
  // allow-raw-bot-api: permission-request keyboard fan-out; topic-aware opts
4673
4683
  void bot.api.sendMessage(chat_id, text, {
4674
4684
  parse_mode: 'HTML',
4675
4685
  reply_markup: keyboard,
4676
- ...(permTopic != null ? { message_thread_id: permTopic } : {}),
4686
+ ...(permThread != null ? { message_thread_id: permThread } : {}),
4677
4687
  }).catch(e => {
4678
4688
  process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
4679
4689
  })
@@ -4811,9 +4821,15 @@ const ipcServer: IpcServer = createIpcServer({
4811
4821
  // topic. Drive approval cards follow the originating turn
4812
4822
  // (operator-initiated tool call), admin alias fallback.
4813
4823
  const activeTurn = currentTurn
4814
- const driveTopic = resolveAgentOutboundTopic({
4815
- kind: 'hostd-approval',
4816
- originThreadId: activeTurn?.sessionThreadId,
4824
+ // Attach the topic only when `operator` IS the agent's supergroup —
4825
+ // operator DMs have no topics (marko brevo wedge, 2026-06-02).
4826
+ const driveTopic = topicForRecipient({
4827
+ recipientChatId: operator,
4828
+ resolvedTopic: resolveAgentOutboundTopic({
4829
+ kind: 'hostd-approval',
4830
+ originThreadId: activeTurn?.sessionThreadId,
4831
+ }),
4832
+ supergroupChatId: resolveAgentSupergroupChatId(),
4817
4833
  })
4818
4834
  return {
4819
4835
  chatId: operator,
@@ -4884,9 +4900,14 @@ const ipcServer: IpcServer = createIpcServer({
4884
4900
  // alias fallback for background cases. Same shape as hostd /
4885
4901
  // drive approvals below.
4886
4902
  const activeTurn = currentTurn
4887
- const ms365Topic = resolveAgentOutboundTopic({
4888
- kind: 'hostd-approval',
4889
- originThreadId: activeTurn?.sessionThreadId,
4903
+ // Topic valid only in the agent's supergroup — never on the operator DM.
4904
+ const ms365Topic = topicForRecipient({
4905
+ recipientChatId: operator,
4906
+ resolvedTopic: resolveAgentOutboundTopic({
4907
+ kind: 'hostd-approval',
4908
+ originThreadId: activeTurn?.sessionThreadId,
4909
+ }),
4910
+ supergroupChatId: resolveAgentSupergroupChatId(),
4890
4911
  })
4891
4912
  return {
4892
4913
  chatId: operator,
@@ -4963,9 +4984,14 @@ const ipcServer: IpcServer = createIpcServer({
4963
4984
  // returns undefined for non-supergroup agents → behavior
4964
4985
  // unchanged.
4965
4986
  const activeTurn = currentTurn
4966
- const cfgTopic = resolveAgentOutboundTopic({
4967
- kind: 'hostd-approval',
4968
- originThreadId: activeTurn?.sessionThreadId,
4987
+ // Topic valid only in the agent's supergroup — never on the operator DM.
4988
+ const cfgTopic = topicForRecipient({
4989
+ recipientChatId: operator,
4990
+ resolvedTopic: resolveAgentOutboundTopic({
4991
+ kind: 'hostd-approval',
4992
+ originThreadId: activeTurn?.sessionThreadId,
4993
+ }),
4994
+ supergroupChatId: resolveAgentSupergroupChatId(),
4969
4995
  })
4970
4996
  return {
4971
4997
  chatId: operator,
@@ -5577,7 +5603,21 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
5577
5603
 
5578
5604
  assertAllowedChat(chat_id)
5579
5605
 
5580
- let threadId = resolveThreadId(chat_id, args.message_thread_id as string | undefined)
5606
+ // Thread resolution precedence: (1) an explicit message_thread_id the
5607
+ // model passed, else (2) THIS turn's own originating topic
5608
+ // (turn-pinned, #1664), else (3) the chat's last-seen topic
5609
+ // (chatThreadMap). Preferring the turn's own thread over the chat
5610
+ // last-seen heuristic fixes synthetic turns (subagent handback/progress,
5611
+ // cron) — whose topic the model is never told and which never write
5612
+ // chatThreadMap — and is strictly more correct under multi-topic
5613
+ // concurrency (a reply lands in the topic the turn came from, not
5614
+ // whichever topic most recently received a message). DM: both are
5615
+ // undefined → unchanged.
5616
+ let threadId = resolveThreadId(
5617
+ chat_id,
5618
+ (args.message_thread_id as string | undefined) ??
5619
+ (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined),
5620
+ )
5581
5621
 
5582
5622
  if (reply_to == null && quoteOptIn && HISTORY_ENABLED) {
5583
5623
  try {
@@ -6176,6 +6216,16 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
6176
6216
  const turn = currentTurn
6177
6217
  if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
6178
6218
  if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
6219
+ // Thread precedence (matches executeReply): when the model passes no
6220
+ // explicit message_thread_id, fall back to THIS turn's originating
6221
+ // topic before handleStreamReply's chatThreadMap last-seen heuristic.
6222
+ // Injecting here threads every downstream consumer consistently — the
6223
+ // dedup key, the voice-scrub metric, the draft transport, and the send
6224
+ // — so a streamed handback/synthetic-turn reply lands in the right
6225
+ // supergroup topic. DM: sessionThreadId undefined → unchanged.
6226
+ if (args.message_thread_id == null && turn?.sessionThreadId != null) {
6227
+ args.message_thread_id = String(turn.sessionThreadId)
6228
+ }
6179
6229
 
6180
6230
  // Outbound secret scrub (#2044): mask before the dedup key, the draft
6181
6231
  // stream sends, and the history record. stream_reply carries the FULL
@@ -11067,6 +11117,30 @@ function resolveAgentOutboundTopic(
11067
11117
  }
11068
11118
  }
11069
11119
 
11120
+ /**
11121
+ * The agent's supergroup chat id (`channels.telegram.chat_id`) when it is in
11122
+ * supergroup-owned mode, else undefined. A forum topic id resolved by
11123
+ * {@link resolveAgentOutboundTopic} is valid ONLY in this chat — used by
11124
+ * {@link topicForRecipient} to decide whether an approval/permission card sent
11125
+ * to a given recipient (operator DMs vs the supergroup itself) may carry a
11126
+ * `message_thread_id`. Attaching a topic to a DM is the marko brevo wedge
11127
+ * (2026-06-02): the card fails with "message thread not found" and auto-denies.
11128
+ */
11129
+ function resolveAgentSupergroupChatId(): string | undefined {
11130
+ const agentName = process.env.SWITCHROOM_AGENT_NAME
11131
+ if (!agentName) return undefined
11132
+ try {
11133
+ const cfg = loadSwitchroomConfig()
11134
+ const rawAgent = cfg.agents?.[agentName]
11135
+ if (!rawAgent) return undefined
11136
+ const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
11137
+ const tg = resolved.channels?.telegram as { chat_id?: string | number } | undefined
11138
+ return tg?.chat_id != null ? String(tg.chat_id) : undefined
11139
+ } catch {
11140
+ return undefined
11141
+ }
11142
+ }
11143
+
11070
11144
  /**
11071
11145
  * Stamp a user-facing restart reason into the clean-shutdown marker
11072
11146
  * (same file the SIGTERM handler writes to and the next session greeting
@@ -18581,6 +18655,7 @@ void (async () => {
18581
18655
  })
18582
18656
  }
18583
18657
 
18658
+ const handbackOrigin = resolveSubagentOriginChat(agentId)
18584
18659
  const decision = decideSubagentHandback({
18585
18660
  handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
18586
18661
  outcome,
@@ -18589,11 +18664,18 @@ void (async () => {
18589
18664
  // turn) back to the conversation the Task was dispatched
18590
18665
  // from, so the result lands where the user asked — not the
18591
18666
  // agent's DM. Falls back to fleetChatId/ownerChatId.
18592
- fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
18667
+ fleetChatId: handbackOrigin?.chatId || fleetChatId,
18668
+ // Supergroup topic the Task was dispatched from. Plumbed
18669
+ // through so the handback turn (and the model's in-voice
18670
+ // "here's what the worker found" reply) land in the
18671
+ // originating topic — not the chat's last-seen topic.
18672
+ // Applied only when the origin chat resolved (DM fallback
18673
+ // is topic-less).
18674
+ ...(handbackOrigin?.threadId != null
18675
+ ? { originThreadId: handbackOrigin.threadId }
18676
+ : {}),
18593
18677
  // Owner-chat fallback: if the parent-turn chat can't be
18594
- // resolved, route to the owner chat. Every switchroom fleet
18595
- // agent is DM-shaped, so allowFrom[0] is the conversation
18596
- // that dispatched.
18678
+ // resolved, route to the owner chat.
18597
18679
  ownerChatId: loadAccess().allowFrom[0] ?? '',
18598
18680
  taskDescription: description,
18599
18681
  resultText,
@@ -18654,7 +18736,7 @@ void (async () => {
18654
18736
  // suppresses stale-after-restart delivery (a 4-h-old
18655
18737
  // "still working (5m)" would be a lie). Sweep on handback
18656
18738
  // lives in the `onFinish` block just above.
18657
- onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
18739
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine }) => {
18658
18740
  let fleetChatId = ''
18659
18741
  try {
18660
18742
  const fleets = progressDriver?.peekAllFleets() ?? []
@@ -18694,7 +18776,15 @@ void (async () => {
18694
18776
  nestingEnabled: foregroundNestingEnabled,
18695
18777
  replyCalled: turn.replyCalled,
18696
18778
  })) return
18697
- const child = latestSummary.trim().slice(0, 120)
18779
+ // Prefer the tick's own display line: `progressLine` (a
18780
+ // friendly tool-step label) on tool ticks, else the
18781
+ // worker's narrative (`latestSummary`) on text ticks. This
18782
+ // lets a foreground sub-agent that runs tools without
18783
+ // emitting prose still nest its steps under the parent
18784
+ // feed (the foreground blindspot) — mirroring the
18785
+ // main-turn activity feed, which surfaces both tool labels
18786
+ // and prose.
18787
+ const child = (progressLine ?? latestSummary).trim().slice(0, 120)
18698
18788
  if (child.length === 0) return
18699
18789
  let narrative = turn.foregroundSubAgents.get(agentId)
18700
18790
  if (narrative == null) {
@@ -18746,12 +18836,18 @@ void (async () => {
18746
18836
  return
18747
18837
  }
18748
18838
 
18839
+ const progressOrigin = resolveSubagentOriginChat(agentId)
18749
18840
  const decision = decideSubagentProgress({
18750
18841
  disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
18751
18842
  isBackground,
18752
18843
  // Prefer the conversation the Task was dispatched from over
18753
18844
  // the owner DM (see resolveSubagentOriginChat).
18754
- fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
18845
+ fleetChatId: progressOrigin?.chatId || fleetChatId,
18846
+ // Carry the dispatching topic so the progress wake lands in
18847
+ // it (applied only when the origin chat resolved).
18848
+ ...(progressOrigin?.threadId != null
18849
+ ? { originThreadId: progressOrigin.threadId }
18850
+ : {}),
18755
18851
  ownerChatId: loadAccess().allowFrom[0] ?? '',
18756
18852
  subagentJsonlId: agentId,
18757
18853
  taskDescription: description,
@@ -18769,10 +18865,11 @@ void (async () => {
18769
18865
  // model is about to compose an explicit in-voice
18770
18866
  // progress line — letting the "— still working (Nm)"
18771
18867
  // edit fire in parallel would double-surface the
18772
- // signal. Progress envelopes target the chat level
18773
- // (no thread id), matching how the inbound lands.
18868
+ // signal. Key the clear on the topic the envelope lands
18869
+ // in (origin thread) so the right lane is yielded in a
18870
+ // supergroup; chat-level for DM-shaped agents.
18774
18871
  pendingProgress.clearPending(
18775
- statusKey(decision.chatId, undefined),
18872
+ statusKey(decision.chatId, progressOrigin?.threadId),
18776
18873
  'progress',
18777
18874
  )
18778
18875
  process.stderr.write(
@@ -40,6 +40,12 @@ export interface SubagentHandbackContext {
40
40
  /** Telegram chat the work was dispatched from — the synthesized
41
41
  * handback turn lands here so it stays with the conversation. */
42
42
  chatId: string
43
+ /** Supergroup topic (message_thread_id) the work was dispatched from.
44
+ * Carried so the synthesized handback turn — and the model's
45
+ * in-voice "here's what the worker found" reply — land in the
46
+ * originating topic, not the chat's last-seen topic. Omitted for
47
+ * DM-shaped chats (no topics). See `gateway.ts:resolveSubagentOriginChat`. */
48
+ threadId?: number
43
49
  /** Dispatch-time task description (the sub-agent's `description`). */
44
50
  taskDescription: string
45
51
  /** The worker's final result text — its last narrative emission
@@ -98,6 +104,9 @@ export function buildSubagentHandbackInbound(opts: {
98
104
  return {
99
105
  type: 'inbound',
100
106
  chatId: opts.ctx.chatId,
107
+ // Top-level threadId → the enqueued turn's sessionThreadId, so the
108
+ // handback turn's live activity feed routes to the originating topic.
109
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
101
110
  messageId: ts, // synthetic — no Telegram message id exists
102
111
  user: 'subagent-watcher',
103
112
  userId: 0,
@@ -106,6 +115,10 @@ export function buildSubagentHandbackInbound(opts: {
106
115
  meta: {
107
116
  source: 'subagent_handback',
108
117
  outcome: opts.ctx.outcome,
118
+ // meta.message_thread_id is the model-visible channel attribute
119
+ // (mirrors the real-inbound shape) so the model's reply targets
120
+ // the dispatching topic. Mirrors gateway.ts:10557.
121
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
109
122
  ...(opts.ctx.jsonlAgentId ? { subagent_jsonl_id: opts.ctx.jsonlAgentId } : {}),
110
123
  },
111
124
  }
@@ -135,6 +148,10 @@ export interface SubagentHandbackDecisionInput {
135
148
  fleetChatId: string
136
149
  /** Owner chat fallback (access.json allowFrom[0]); '' if none. */
137
150
  ownerChatId: string
151
+ /** Supergroup topic the work was dispatched from (from the parent
152
+ * turn). Applied ONLY when `fleetChatId` resolved (the origin chat
153
+ * won) — the `ownerChatId` DM fallback has no topic. */
154
+ originThreadId?: number
138
155
  taskDescription: string
139
156
  resultText: string
140
157
  /** JSONL filename stem for this Claude Code spawn — forwarded into
@@ -185,9 +202,14 @@ export function decideSubagentHandback(
185
202
  if (!chatId) {
186
203
  return { deliver: false, reason: 'no-chat' }
187
204
  }
205
+ // Thread only when the origin chat (fleetChatId) won — the ownerChatId
206
+ // DM fallback is topic-less, so a stray thread id would mis-address it.
207
+ const threadId =
208
+ input.fleetChatId && input.originThreadId != null ? input.originThreadId : undefined
188
209
  const inbound = buildSubagentHandbackInbound({
189
210
  ctx: {
190
211
  chatId,
212
+ ...(threadId != null ? { threadId } : {}),
191
213
  taskDescription: input.taskDescription,
192
214
  resultText: input.resultText,
193
215
  outcome: input.outcome,
@@ -62,6 +62,10 @@ export const DEFAULT_PROGRESS_INTERVAL_MS = 5 * 60 * 1000
62
62
  export interface SubagentProgressContext {
63
63
  /** Telegram chat the work was dispatched from. */
64
64
  chatId: string
65
+ /** Supergroup topic (message_thread_id) the work was dispatched from,
66
+ * so the progress wake-up turn and the model's reply land in the
67
+ * originating topic. Omitted for DM-shaped chats. */
68
+ threadId?: number
65
69
  /** JSONL-derived sub-agent id (stable per Claude Code spawn). Pinned
66
70
  * into the spool id so envelopes for the same worker dedup across
67
71
  * buckets cleanly and survive gateway restarts. */
@@ -125,6 +129,7 @@ export function buildSubagentProgressInbound(opts: {
125
129
  return {
126
130
  type: 'inbound',
127
131
  chatId: opts.ctx.chatId,
132
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
128
133
  messageId: ts, // synthetic — no Telegram message id exists
129
134
  user: 'subagent-watcher',
130
135
  userId: 0,
@@ -132,6 +137,7 @@ export function buildSubagentProgressInbound(opts: {
132
137
  text,
133
138
  meta: {
134
139
  source: 'subagent_progress',
140
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
135
141
  subagent_jsonl_id: opts.ctx.subagentJsonlId,
136
142
  bucket_idx: String(opts.ctx.bucketIdx),
137
143
  expiresAt: String(expiresAt),
@@ -155,6 +161,10 @@ export interface SubagentProgressDecisionInput {
155
161
  fleetChatId: string
156
162
  /** Owner chat fallback (access.json allowFrom[0]); '' if none. */
157
163
  ownerChatId: string
164
+ /** Supergroup topic the work was dispatched from. Applied ONLY when
165
+ * `fleetChatId` resolved (the origin chat won); the DM fallback is
166
+ * topic-less. */
167
+ originThreadId?: number
158
168
  subagentJsonlId: string
159
169
  taskDescription: string
160
170
  latestSummary: string
@@ -240,9 +250,12 @@ export function decideSubagentProgress(
240
250
  if (input.lastBucketIdx != null && bucketIdx <= input.lastBucketIdx) {
241
251
  return { deliver: false, reason: 'bucket-already-fired' }
242
252
  }
253
+ const threadId =
254
+ input.fleetChatId && input.originThreadId != null ? input.originThreadId : undefined
243
255
  const inbound = buildSubagentProgressInbound({
244
256
  ctx: {
245
257
  chatId,
258
+ ...(threadId != null ? { threadId } : {}),
246
259
  subagentJsonlId: input.subagentJsonlId,
247
260
  taskDescription: input.taskDescription,
248
261
  latestSummary: input.latestSummary,
@@ -42,6 +42,7 @@ import { basename, join } from 'path'
42
42
  import { homedir } from 'os'
43
43
  import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptLine } from './session-tail.js'
44
44
  import { sanitiseToolArg } from './fleet-state.js'
45
+ import { describeToolUse } from './tool-activity-summary.js'
45
46
  import { escapeHtml, truncate } from './card-format.js'
46
47
  import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents } from './registry/subagents-schema.js'
47
48
  import { touchTurnActiveMarker } from './gateway/turn-active-marker.js'
@@ -348,6 +349,13 @@ export interface SubagentWatcherConfig {
348
349
  lastTool: { name: string; sanitisedArg: string } | null
349
350
  /** Tool-use count observed so far. */
350
351
  toolCount: number
352
+ /** Friendly display line for THIS tick. Set on `sub_agent_tool_use`
353
+ * events to a `describeToolUse` label ("Reading X", "Running a
354
+ * command") so a foreground sub-agent that runs tools without
355
+ * emitting prose still surfaces its steps in the parent's nested
356
+ * feed. Undefined on `sub_agent_text` ticks — the gateway falls back
357
+ * to `latestSummary` (the narrative line), preserving prior behavior. */
358
+ progressLine?: string
351
359
  }) => void
352
360
  /** `Date.now` override for tests. */
353
361
  now?: () => number
@@ -645,6 +653,9 @@ export function readSubTail(
645
653
  lastTool: { name: string; sanitisedArg: string } | null
646
654
  /** Tool-use count observed so far. */
647
655
  toolCount: number
656
+ /** Friendly display line for THIS tick (set on tool ticks; see the
657
+ * SubagentWatcherConfig.onProgress doc). */
658
+ progressLine?: string
648
659
  }) => void,
649
660
  ): void {
650
661
  try {
@@ -781,6 +792,39 @@ export function readSubTail(
781
792
  name: ev.toolName,
782
793
  sanitisedArg: sanitiseToolArg(ev.toolName, ev.input ?? {}),
783
794
  }
795
+ // Surface a tool-step progress cue. A foreground sub-agent that
796
+ // runs tools WITHOUT emitting prose (e.g. a researcher reading
797
+ // files) previously produced no onProgress tick at all — only
798
+ // `sub_agent_text` fired it — so its steps never nested under the
799
+ // parent's activity feed (the named foreground blindspot). Fire
800
+ // here too, carrying a friendly `describeToolUse` label as
801
+ // `progressLine` so the gateway can render "Reading X" / "Running
802
+ // a command" the same way the main-turn feed does. `latestSummary`
803
+ // stays the worker's narrative result (never polluted with tool
804
+ // labels — the handback payload depends on it). Pure jsonl-tail →
805
+ // render, no model call.
806
+ if (onProgress != null && entry.state === 'running' && !entry.historical) {
807
+ const toolLine = describeToolUse(ev.toolName, ev.input ?? {})
808
+ if (toolLine != null && toolLine.length > 0) {
809
+ try {
810
+ onProgress({
811
+ agentId: entry.agentId,
812
+ description: entry.description,
813
+ latestSummary: entry.lastResultText,
814
+ elapsedMs: now - entry.dispatchedAt,
815
+ prevBucketIdx: entry.lastProgressBucketIdx,
816
+ setBucketIdx: (b: number) => {
817
+ entry.lastProgressBucketIdx = b
818
+ },
819
+ lastTool: entry.lastTool,
820
+ toolCount: entry.toolCount,
821
+ progressLine: toolLine,
822
+ })
823
+ } catch (cbErr) {
824
+ log?.(`subagent-watcher: onProgress (tool) callback error ${entry.agentId}: ${(cbErr as Error).message}`)
825
+ }
826
+ }
827
+ }
784
828
  } else if (ev.kind === 'sub_agent_text') {
785
829
  // Do NOT overwrite description with narrative text — description is
786
830
  // set at dispatch time (from the parent Agent/Task tool_use input)
@@ -109,4 +109,36 @@ describe('decideSubagentHandback', () => {
109
109
  expect(d.inbound.text).toContain('Applied 3 migrations')
110
110
  }
111
111
  })
112
+
113
+ // Supergroup topic routing (#status-channel-routing).
114
+ it('threads the inbound to the origin topic when the origin (fleet) chat won', () => {
115
+ const d = decideSubagentHandback({ ...base, fleetChatId: '-100777', originThreadId: 42 })
116
+ expect(d.deliver).toBe(true)
117
+ if (d.deliver) {
118
+ expect(d.chatId).toBe('-100777')
119
+ expect(d.inbound.threadId).toBe(42)
120
+ expect(d.inbound.meta.message_thread_id).toBe('42')
121
+ }
122
+ })
123
+
124
+ it('does NOT thread when falling back to the owner DM (topic-less)', () => {
125
+ // fleetChatId empty → owner DM wins; a stray originThreadId must not
126
+ // be applied to a DM chat that has no topics.
127
+ const d = decideSubagentHandback({ ...base, fleetChatId: '', originThreadId: 42 })
128
+ expect(d.deliver).toBe(true)
129
+ if (d.deliver) {
130
+ expect(d.chatId).toBe('999')
131
+ expect(d.inbound.threadId).toBeUndefined()
132
+ expect(d.inbound.meta.message_thread_id).toBeUndefined()
133
+ }
134
+ })
135
+
136
+ it('omits thread carriers when no originThreadId is supplied (DM-shaped agent)', () => {
137
+ const d = decideSubagentHandback({ ...base, fleetChatId: '777' })
138
+ expect(d.deliver).toBe(true)
139
+ if (d.deliver) {
140
+ expect(d.inbound.threadId).toBeUndefined()
141
+ expect(d.inbound.meta.message_thread_id).toBeUndefined()
142
+ }
143
+ })
112
144
  })
@@ -124,4 +124,39 @@ describe('buildSubagentHandbackInbound', () => {
124
124
  })
125
125
  expect(inbound.text).toContain('(no description)')
126
126
  })
127
+
128
+ // Supergroup topic routing (#status-channel-routing). The handback turn
129
+ // and the model's in-voice reply must land in the topic the work was
130
+ // dispatched from — not the chat's last-seen topic. The carriers are the
131
+ // top-level threadId (→ turn.sessionThreadId, routes the activity feed)
132
+ // and meta.message_thread_id (the model-visible channel attribute,
133
+ // mirrors the real-inbound shape at gateway.ts:10557).
134
+ it('carries top-level threadId AND meta.message_thread_id when ctx.threadId is set', () => {
135
+ const inbound = buildSubagentHandbackInbound({
136
+ ctx: {
137
+ chatId: '-1001234567890',
138
+ threadId: 42,
139
+ taskDescription: 'Research competitors',
140
+ resultText: 'Found 3 relevant comps.',
141
+ outcome: 'completed',
142
+ },
143
+ nowMs: FIXED_NOW,
144
+ })
145
+ expect(inbound.threadId).toBe(42)
146
+ expect(inbound.meta.message_thread_id).toBe('42')
147
+ })
148
+
149
+ it('omits both thread carriers when ctx.threadId is absent (DM-shaped chat)', () => {
150
+ const inbound = buildSubagentHandbackInbound({
151
+ ctx: {
152
+ chatId: '12345',
153
+ taskDescription: 'x',
154
+ resultText: 'y',
155
+ outcome: 'completed',
156
+ },
157
+ nowMs: FIXED_NOW,
158
+ })
159
+ expect(inbound.threadId).toBeUndefined()
160
+ expect(inbound.meta.message_thread_id).toBeUndefined()
161
+ })
127
162
  })
@@ -158,6 +158,42 @@ describe('buildSubagentProgressInbound', () => {
158
158
  })
159
159
  expect(spoolId(bucket1)).not.toBe(spoolId(bucket2))
160
160
  })
161
+
162
+ // Supergroup topic routing (#status-channel-routing).
163
+ it('carries top-level threadId AND meta.message_thread_id when ctx.threadId is set', () => {
164
+ const inbound = buildSubagentProgressInbound({
165
+ ctx: {
166
+ chatId: '-100999',
167
+ threadId: 7,
168
+ subagentJsonlId: 'jsonl-abc',
169
+ taskDescription: 'x',
170
+ latestSummary: 'still going',
171
+ elapsedMs: 7 * 60 * 1000,
172
+ bucketIdx: 1,
173
+ progressIntervalMs: INTERVAL_MS,
174
+ },
175
+ nowMs: FIXED_NOW,
176
+ })
177
+ expect(inbound.threadId).toBe(7)
178
+ expect(inbound.meta.message_thread_id).toBe('7')
179
+ })
180
+
181
+ it('omits both thread carriers when ctx.threadId is absent (DM-shaped chat)', () => {
182
+ const inbound = buildSubagentProgressInbound({
183
+ ctx: {
184
+ chatId: '12345',
185
+ subagentJsonlId: 'jsonl-abc',
186
+ taskDescription: 'x',
187
+ latestSummary: 'y',
188
+ elapsedMs: 7 * 60 * 1000,
189
+ bucketIdx: 1,
190
+ progressIntervalMs: INTERVAL_MS,
191
+ },
192
+ nowMs: FIXED_NOW,
193
+ })
194
+ expect(inbound.threadId).toBeUndefined()
195
+ expect(inbound.meta.message_thread_id).toBeUndefined()
196
+ })
161
197
  })
162
198
 
163
199
  describe('isEnvFlagOn — bool env parser', () => {
@@ -266,4 +302,24 @@ describe('decideSubagentProgress', () => {
266
302
  expect(d.deliver).toBe(false)
267
303
  if (!d.deliver) expect(d.reason).toBe('missing-jsonl-id')
268
304
  })
305
+
306
+ // Supergroup topic routing (#status-channel-routing).
307
+ it('threads to the origin topic when the origin (fleet) chat won', () => {
308
+ const d = decideSubagentProgress(baseInput({ fleetChatId: '-100abc', originThreadId: 7 }))
309
+ expect(d.deliver).toBe(true)
310
+ if (d.deliver) {
311
+ expect(d.inbound.threadId).toBe(7)
312
+ expect(d.inbound.meta.message_thread_id).toBe('7')
313
+ }
314
+ })
315
+
316
+ it('does NOT thread when falling back to the owner DM', () => {
317
+ const d = decideSubagentProgress(baseInput({ fleetChatId: '', originThreadId: 7 }))
318
+ expect(d.deliver).toBe(true)
319
+ if (d.deliver) {
320
+ expect(d.chatId).toBe('999')
321
+ expect(d.inbound.threadId).toBeUndefined()
322
+ expect(d.inbound.meta.message_thread_id).toBeUndefined()
323
+ }
324
+ })
269
325
  })
@@ -373,6 +373,7 @@ describe('startSubagentWatcher', () => {
373
373
  function startWatcherSync(opts: {
374
374
  agentDir: string
375
375
  onFinish?: Parameters<typeof startSubagentWatcher>[0]['onFinish']
376
+ onProgress?: Parameters<typeof startSubagentWatcher>[0]['onProgress']
376
377
  }): {
377
378
  notifications: string[]
378
379
  poll: () => void
@@ -392,6 +393,7 @@ describe('startSubagentWatcher', () => {
392
393
  notifications.push(`✓ Worker done: ${info.description}`)
393
394
  opts.onFinish?.(info)
394
395
  },
396
+ ...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
395
397
  stallThresholdMs: 60_000,
396
398
  rescanMs: 500,
397
399
  now: () => Date.now(),
@@ -477,6 +479,46 @@ describe('startSubagentWatcher', () => {
477
479
  expect(entry?.toolCount).toBe(3)
478
480
  })
479
481
 
482
+ it('fires onProgress with a friendly tool-step progressLine on a tool_use tick (foreground visibility)', () => {
483
+ // A foreground sub-agent that runs tools WITHOUT emitting prose used
484
+ // to fire no onProgress cue at all — only `sub_agent_text` did — so
485
+ // its steps never nested under the parent's activity feed (the named
486
+ // foreground blindspot). The tool_use branch now fires onProgress
487
+ // carrying a `describeToolUse` label so the gateway can render
488
+ // "Reading X" the same way the main-turn feed does.
489
+ const progress: Array<{ progressLine?: string; toolCount: number; latestSummary: string }> = []
490
+ const agentDir = join(tmpRoot, 'agent')
491
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
492
+ mkdirSync(subagentsDir, { recursive: true })
493
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
494
+
495
+ const h = startWatcherSync({
496
+ agentDir,
497
+ onProgress: ({ progressLine, toolCount, latestSummary }) => {
498
+ progress.push({ progressLine, toolCount, latestSummary })
499
+ },
500
+ })
501
+ // Register running, post-boot (same pattern as the onFinish test).
502
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Research the competitors')))
503
+ h.poll()
504
+ expect(h.watcher.getRegistry().get('deadbeef')?.state).toBe('running')
505
+
506
+ // The sub-agent reads a file — a tool_use with no accompanying prose.
507
+ appendFileSync(jsonlPath, buildJSONL({
508
+ type: 'assistant',
509
+ message: { content: [{ type: 'tool_use', name: 'Read', id: 'r1', input: { file_path: '/x/CLAUDE.md' } }] },
510
+ }))
511
+ h.poll()
512
+
513
+ const toolTick = progress.find((p) => p.progressLine != null)
514
+ expect(toolTick).toBeDefined()
515
+ // Friendly label, matching the main-turn activity feed's renderer.
516
+ expect(toolTick?.progressLine).toBe('Reading CLAUDE.md')
517
+ // latestSummary stays the (empty) narrative result — never polluted
518
+ // with the tool label, so the handback payload is unaffected.
519
+ expect(toolTick?.latestSummary).toBe('')
520
+ })
521
+
480
522
  it('captures the full last narrative line into lastResultText (handback)', () => {
481
523
  // lastSummaryLine keeps only the first line, 120 chars — a progress
482
524
  // preview. lastResultText keeps the full last narrative emission: