switchroom 0.15.45 → 0.16.4

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 (149) hide show
  1. package/dist/agent-scheduler/index.js +122 -88
  2. package/dist/auth-broker/index.js +463 -177
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +17 -14
  5. package/dist/cli/notion-write-pretool.mjs +117 -86
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
  7. package/dist/cli/self-improve-stop.mjs +428 -0
  8. package/dist/cli/skill-validate-pretool.mjs +72 -72
  9. package/dist/cli/switchroom.js +3158 -1178
  10. package/dist/host-control/main.js +2833 -355
  11. package/dist/vault/approvals/kernel-server.js +7479 -7439
  12. package/dist/vault/broker/server.js +11312 -11272
  13. package/examples/minimal.yaml +1 -0
  14. package/examples/switchroom.yaml +1 -0
  15. package/package.json +3 -3
  16. package/profiles/_base/start.sh.hbs +88 -1
  17. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  18. package/profiles/default/CLAUDE.md.hbs +0 -19
  19. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  20. package/telegram-plugin/answer-stream-flag.ts +12 -49
  21. package/telegram-plugin/answer-stream.ts +5 -150
  22. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  23. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  24. package/telegram-plugin/context-exhaustion.ts +12 -0
  25. package/telegram-plugin/demo-mask.ts +154 -0
  26. package/telegram-plugin/dist/bridge/bridge.js +167 -124
  27. package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
  28. package/telegram-plugin/dist/server.js +215 -172
  29. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  30. package/telegram-plugin/draft-stream.ts +47 -410
  31. package/telegram-plugin/final-answer-detect.ts +17 -12
  32. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  33. package/telegram-plugin/format.ts +56 -19
  34. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  35. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  36. package/telegram-plugin/gateway/auth-command.ts +70 -14
  37. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  38. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  39. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  40. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  41. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  42. package/telegram-plugin/gateway/effort-command.ts +8 -3
  43. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  44. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  45. package/telegram-plugin/gateway/gateway.ts +1837 -291
  46. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  47. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  48. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  49. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  50. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  51. package/telegram-plugin/history.ts +33 -11
  52. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  53. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  54. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  55. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  56. package/telegram-plugin/issues-card.ts +4 -0
  57. package/telegram-plugin/model-unavailable.ts +124 -0
  58. package/telegram-plugin/narrative-dedup.ts +69 -0
  59. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  60. package/telegram-plugin/package.json +3 -3
  61. package/telegram-plugin/pending-work-progress.ts +12 -0
  62. package/telegram-plugin/permission-rule.ts +32 -5
  63. package/telegram-plugin/permission-title.ts +152 -9
  64. package/telegram-plugin/quota-check.ts +13 -0
  65. package/telegram-plugin/quota-watch.ts +135 -7
  66. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  67. package/telegram-plugin/registry/turns-schema.ts +9 -0
  68. package/telegram-plugin/runtime-metrics.ts +13 -0
  69. package/telegram-plugin/session-tail.ts +96 -11
  70. package/telegram-plugin/silence-poke.ts +170 -24
  71. package/telegram-plugin/slot-banner-driver.ts +3 -0
  72. package/telegram-plugin/status-no-truncate.ts +44 -0
  73. package/telegram-plugin/status-reactions.ts +20 -3
  74. package/telegram-plugin/stream-controller.ts +4 -23
  75. package/telegram-plugin/stream-reply-handler.ts +6 -24
  76. package/telegram-plugin/streaming-metrics.ts +91 -0
  77. package/telegram-plugin/subagent-watcher.ts +212 -66
  78. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  79. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  80. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  82. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  83. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  84. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  87. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  88. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  89. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  90. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  91. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  92. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  93. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  94. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  95. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  96. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  97. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  98. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  99. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  100. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  101. package/telegram-plugin/tests/history.test.ts +60 -0
  102. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  103. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  104. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  105. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  106. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  107. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  108. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  109. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  110. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  111. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  112. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  113. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  114. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  115. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  116. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  117. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  118. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  119. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  120. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  121. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  122. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  123. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  124. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  125. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  126. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  127. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  128. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  129. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  130. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  131. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  132. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  133. package/telegram-plugin/tool-activity-summary.ts +375 -58
  134. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  135. package/telegram-plugin/uat/assertions.ts +115 -0
  136. package/telegram-plugin/uat/driver.ts +68 -0
  137. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  138. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  139. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  145. package/telegram-plugin/welcome-text.ts +13 -1
  146. package/telegram-plugin/worker-activity-feed.ts +157 -82
  147. package/telegram-plugin/draft-transport.ts +0 -122
  148. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  149. package/telegram-plugin/tests/draft-transport.test.ts +0 -211
@@ -16,7 +16,7 @@
16
16
  * entire server.ts top-level initialization.
17
17
  */
18
18
 
19
- import { createDraftStream, type DraftStreamHandle, type StreamDraftFn } from './draft-stream.js'
19
+ import { createDraftStream, type DraftStreamHandle } from './draft-stream.js'
20
20
  import { htmlToPlainText } from './html-sanitize.js'
21
21
 
22
22
  /**
@@ -152,30 +152,15 @@ export interface StreamControllerConfig {
152
152
  */
153
153
  log?: (msg: string) => void
154
154
  /**
155
- * Optional warning logger. Used for transport fallback notices.
155
+ * Optional warning logger. Used for fallback notices.
156
156
  */
157
157
  warn?: (msg: string) => void
158
- /**
159
- * Transport selector passed to createDraftStream.
160
- * - "auto" (default): use draft transport for DMs only
161
- * - "draft": always prefer draft (if sendMessageDraft is available)
162
- * - "message": always use sendMessage/editMessageText
163
- *
164
- * The gateway forces "message" for forum topics (threads), since
165
- * sendMessageDraft does not support threaded chats.
166
- */
167
- previewTransport?: 'auto' | 'message' | 'draft'
168
158
  /**
169
159
  * True when the chat is a private DM. Passed to createDraftStream so
170
- * "auto" transport knows whether to activate draft.
160
+ * the throttle default (400 ms for DMs vs 1000 ms for groups) is applied
161
+ * correctly when no explicit throttleMs is set.
171
162
  */
172
163
  isPrivateChat?: boolean
173
- /**
174
- * sendMessageDraft callback. When provided (and transport allows it),
175
- * intermediate stream updates use the draft API. On finalize(), a real
176
- * sendMessage is posted for push notification and the draft is cleared.
177
- */
178
- sendMessageDraft?: StreamDraftFn
179
164
  /**
180
165
  * If set, the controller is initialized as if a previous send had
181
166
  * landed with this `message_id`. The first `update()` invokes
@@ -214,9 +199,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
214
199
  quoteText,
215
200
  protectContent,
216
201
  replyMarkup,
217
- previewTransport,
218
202
  isPrivateChat,
219
- sendMessageDraft,
220
203
  initialMessageId,
221
204
  } = cfg
222
205
 
@@ -314,9 +297,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
314
297
  ...(idleMs != null ? { idleMs } : {}),
315
298
  ...(log != null ? { log } : {}),
316
299
  ...(warn != null ? { warn } : {}),
317
- ...(previewTransport != null ? { previewTransport } : {}),
318
300
  ...(isPrivateChat != null ? { isPrivateChat } : {}),
319
- ...(sendMessageDraft != null ? { sendMessageDraft } : {}),
320
301
  ...(initialMessageId != null ? { initialMessageId } : {}),
321
302
  chatId,
322
303
  },
@@ -16,7 +16,7 @@
16
16
  * wraps into an MCP content response.
17
17
  */
18
18
 
19
- import type { DraftStreamHandle, StreamDraftFn } from './draft-stream.js'
19
+ import type { DraftStreamHandle } from './draft-stream.js'
20
20
  import {
21
21
  createStreamController,
22
22
  type StreamBotApi,
@@ -240,13 +240,6 @@ export interface StreamReplyDeps {
240
240
  /** Error-path stderr. */
241
241
  writeError: (line: string) => void
242
242
  throttleMs?: number
243
- /**
244
- * sendMessageDraft callback. When provided, stream_reply uses the draft
245
- * API for intermediate updates (DM transport). On done=true, a real
246
- * sendMessage fires for push notification, then the draft is cleared.
247
- * Optional — omit to keep the existing sendMessage/editMessageText path.
248
- */
249
- sendMessageDraft?: StreamDraftFn
250
243
  /**
251
244
  * Idempotency hook for the duplicate-message class (issue #626).
252
245
  *
@@ -275,12 +268,12 @@ export interface StreamReplyDeps {
275
268
  }) => number | null | undefined
276
269
  /**
277
270
  * True when the current chat is a private DM. Passed to the stream
278
- * controller so "auto" transport activates draft in DMs only.
271
+ * controller so the DM throttle default (400 ms) is applied instead of
272
+ * the group default (1000 ms) when no explicit throttleMs is set.
279
273
  */
280
274
  isPrivateChat?: boolean
281
275
  /**
282
- * True when the current chat is a forum topic. Forum topics do not
283
- * support sendMessageDraft — this forces message transport.
276
+ * True when the current chat is a forum topic.
284
277
  */
285
278
  isForumTopic?: boolean
286
279
  /**
@@ -464,14 +457,6 @@ export async function handleStreamReply(
464
457
  }
465
458
  }
466
459
 
467
- // Resolve draft-transport options. Forum topics force message transport
468
- // because sendMessageDraft does not support threads.
469
- const isForumTopic = deps.isForumTopic === true
470
- const resolvedTransport: 'auto' | 'message' | 'draft' =
471
- isForumTopic || deps.sendMessageDraft == null
472
- ? 'message'
473
- : 'auto'
474
-
475
460
  // Idempotency hook (#626): if an external authority (e.g. the
476
461
  // gateway's pin manager) already knows the anchor message id for
477
462
  // this lane+turn, initialize the stream with it so the next update
@@ -504,8 +489,8 @@ export async function handleStreamReply(
504
489
  threadId,
505
490
  parseMode,
506
491
  disableLinkPreview: deps.disableLinkPreview,
507
- // PR B: pass undefined when caller didn't override, so draft-stream's
508
- // transport-aware default (300 ms draft / 1000 ms message) wins.
492
+ // Pass undefined when caller didn't override, so draft-stream's
493
+ // DM/group throttle defaults apply (400 ms DMs, 1000 ms groups).
509
494
  ...(deps.throttleMs != null ? { throttleMs: deps.throttleMs } : {}),
510
495
  retry: deps.retry,
511
496
  ...(replyToMessageId != null ? { replyToMessageId } : {}),
@@ -513,9 +498,7 @@ export async function handleStreamReply(
513
498
  ...(args.protect_content === true ? { protectContent: true } : {}),
514
499
  ...(args.disable_notification === true ? { disableNotification: true } : {}),
515
500
  ...(args.reply_markup != null ? { replyMarkup: args.reply_markup } : {}),
516
- previewTransport: resolvedTransport,
517
501
  isPrivateChat: deps.isPrivateChat === true,
518
- ...(deps.sendMessageDraft != null ? { sendMessageDraft: deps.sendMessageDraft } : {}),
519
502
  ...(initialMessageId != null ? { initialMessageId } : {}),
520
503
  onSend: (messageId, charCount) =>
521
504
  deps.logStreamingEvent({ kind: 'draft_send', chatId: chat_id, messageId, charCount }),
@@ -539,7 +522,6 @@ export async function handleStreamReply(
539
522
  || msg.startsWith('stream → edited')
540
523
  || msg.startsWith('stream → not modified')
541
524
  || msg.startsWith('stream finalized')
542
- || msg.startsWith('stream → draft')
543
525
  || msg.startsWith('stream → materialized')
544
526
  ) return
545
527
  deps.writeError(`telegram channel: stream_reply ${msg}\n`)
@@ -93,6 +93,97 @@ export type StreamingEvent =
93
93
  chatId: string
94
94
  messageId: number | undefined
95
95
  }
96
+ /**
97
+ * Emitted when maybeEarlyAckReaction fires the 👀 pre-coalesce reaction
98
+ * for a private-chat inbound. Lets operators see how often the fast-ack
99
+ * path triggers vs. the regular StatusReactionController path (#553 F2).
100
+ */
101
+ | {
102
+ kind: 'early_ack_reaction'
103
+ chatId: string
104
+ messageId: number
105
+ emoji: string
106
+ }
107
+ /**
108
+ * Emitted when a fresh StatusReactionController is installed for a new turn
109
+ * (group / non-DM path where the controller manages the whole reaction lifecycle).
110
+ */
111
+ | {
112
+ kind: 'status_reaction_install'
113
+ chatId: string
114
+ turnId: string
115
+ messageId: number
116
+ }
117
+ /**
118
+ * Emitted on every emoji transition inside a StatusReactionController.
119
+ * Lets operators trace the full queued→thinking→tool→done lifecycle and
120
+ * see how many state changes occur in a silent turn.
121
+ */
122
+ | {
123
+ kind: 'status_reaction_transition'
124
+ chatId: string
125
+ turnId: string
126
+ emoji: string
127
+ }
128
+ /**
129
+ * Emitted when StatusReactionController.finalize() / setDone() runs
130
+ * (controller disposed at turn_end or disconnect-flush). Terminal event.
131
+ */
132
+ | {
133
+ kind: 'status_reaction_dispose'
134
+ chatId: string
135
+ turnId: string
136
+ reason: 'done' | 'error' | 'disconnect' | 'undelivered'
137
+ }
138
+ /**
139
+ * Emitted when the FIRST text reply (reply or stream_reply) of a turn is
140
+ * sent to the user. `timeToFirstTextReplyMs` is the wall-clock delta from
141
+ * the inbound-received timestamp to the moment this reply tool fires.
142
+ * Issue #2527 instrumentation: reveals when a turn is reaction-only.
143
+ */
144
+ | {
145
+ kind: 'turn_reply_timing'
146
+ chatId: string
147
+ threadId: number | undefined
148
+ turnId: string
149
+ timeToFirstTextReplyMs: number
150
+ }
151
+ /**
152
+ * Emitted at turn_end when the turn produced ZERO text replies (only
153
+ * reaction-emoji transitions). This is the primary observable for the
154
+ * #2527 failure mode — the user sees only an emoji and the turn is done.
155
+ */
156
+ | {
157
+ kind: 'turn_no_reply_warn'
158
+ chatId: string
159
+ threadId: number | undefined
160
+ turnId: string
161
+ turnDurationMs: number
162
+ reactionCount: number
163
+ }
164
+ /**
165
+ * Emitted when the silence-poke framework-fallback fires and sends its
166
+ * "still working…" ping. Records the silence duration so operators can
167
+ * correlate with reaction-only turns.
168
+ */
169
+ | {
170
+ kind: 'silence_poke_fire'
171
+ chatId: string
172
+ threadId: number | undefined
173
+ silenceMs: number
174
+ fallbackKind: string
175
+ }
176
+ /**
177
+ * Emitted when the silence-poke handler short-circuits because the turn
178
+ * already ended cleanly during the silence window (the late-fire race).
179
+ */
180
+ | {
181
+ kind: 'silence_poke_skip'
182
+ chatId: string
183
+ threadId: number | undefined
184
+ silenceMs: number
185
+ skipReason: string
186
+ }
96
187
 
97
188
  /**
98
189
  * True iff the env gate is on. Re-read on every call so tests can toggle
@@ -42,7 +42,8 @@ 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
+ import { clipNarrative, describeToolUse } from './tool-activity-summary.js'
46
+ import { REPLY_TOOLS, isDraftOfReply } from './narrative-dedup.js'
46
47
  import { escapeHtml, truncate } from './card-format.js'
47
48
  import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents } from './registry/subagents-schema.js'
48
49
  import { touchTurnActiveMarker } from './gateway/turn-active-marker.js'
@@ -158,6 +159,27 @@ export interface WorkerEntry {
158
159
  * failed handback's "what it reported before failing" slot when the
159
160
  * worker left no narrative result of its own. */
160
161
  errorDetail?: string
162
+ /**
163
+ * Narrative-dedup gate state (JSONL-text-narrative primitive). A
164
+ * `sub_agent_text` block is held here for ONE lookahead step so the next
165
+ * `sub_agent_tool_use` / `sub_agent_turn_end` can decide draft-then-send
166
+ * (SUPPRESS — it duplicates the worker's reply) vs working-narration (SHOW
167
+ * — fire `onProgress({latestSummary})`). Null when nothing is pending. The
168
+ * pure decision lives in narrative-dedup.ts; this slot is the per-entry
169
+ * cursor. Mirrors the gateway's `turn.pendingNarrative`.
170
+ */
171
+ pendingNarrative?: { text: string } | null
172
+ /**
173
+ * NIT 3 (sub-agent turn_end symmetry). Most-recently-seen
174
+ * reply/stream_reply `input.text` for this sub-agent — the actual answer a
175
+ * FOREGROUND sub-agent delivered. `sub_agent_turn_end` resolves a trailing
176
+ * `sub_agent_text` block against THIS so a draft of the just-delivered
177
+ * answer is suppressed the same way main-agent step 3 does (conservative
178
+ * dedup). Undefined for background workers that never call a reply tool —
179
+ * their trailing narration still SHOWs, unchanged. Mirrors the gateway's
180
+ * `turn.lastReplyText`.
181
+ */
182
+ lastReplyText?: string
161
183
  }
162
184
 
163
185
  export interface SubagentWatcherConfig {
@@ -503,14 +525,20 @@ interface FsLike {
503
525
  * Backfill `jsonl_agent_id` for a sub-agent row that was inserted by the
504
526
  * PreToolUse hook (keyed on tool_use_id) but didn't yet know the JSONL stem.
505
527
  *
506
- * Strategy: read the `agent-<id>.meta.json` sibling Claude Code writes next
507
- * to each sub-agent JSONL. It carries the same `{ agentType, description }`
508
- * pair the parent passed to the Agent() tool. We match that pair to the
509
- * most-recent row in `subagents` where `jsonl_agent_id IS NULL` and link them.
528
+ * Strategy: read the `agent-<id>.meta.json` sibling that the Claude Code
529
+ * binary writes next to each sub-agent JSONL. It carries `{ agentType,
530
+ * description, toolUseId }` where `toolUseId` is the primary key of the
531
+ * `subagents` row the same `event.tool_use_id` value the pretool hook
532
+ * (`subagent-tracker-pretool.mjs`) uses when it inserts the DB row. We use
533
+ * the direct `toolUseId` lookup first (exact PK match, race-safe); fall back
534
+ * to the fuzzy `(agentType, description)` match only when `toolUseId` is
535
+ * absent (older Claude Code versions that pre-date this field in the meta).
510
536
  *
511
537
  * Edge cases:
512
538
  * - meta.json missing or unreadable: no-op (the row stays unlinked; liveness
513
539
  * writes from this agent's JSONL won't land, but the system stays correct).
540
+ * - `toolUseId` present but no matching row (hook crashed / race): fall
541
+ * through to the fuzzy match so the link is still attempted.
514
542
  * - Multiple in-flight rows with identical (agent_type, description): the
515
543
  * most recently started one wins (FIFO matches dispatch order in practice).
516
544
  * - Row already linked to a different agentId: SQL `WHERE jsonl_agent_id IS
@@ -526,7 +554,7 @@ export function backfillJsonlAgentId(
526
554
  log?: (msg: string) => void,
527
555
  ): void {
528
556
  const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json')
529
- let meta: { agentType?: string; description?: string }
557
+ let meta: { agentType?: string; description?: string; toolUseId?: string } | null
530
558
  try {
531
559
  const raw = readFileSync(metaPath, 'utf8')
532
560
  meta = JSON.parse(raw)
@@ -534,8 +562,8 @@ export function backfillJsonlAgentId(
534
562
  log?.(`subagent-watcher: backfill skip ${agentId} — meta.json not readable at ${metaPath}`)
535
563
  return
536
564
  }
537
- if (!meta.agentType && !meta.description) {
538
- log?.(`subagent-watcher: backfill skip ${agentId} — meta.json has no agentType/description`)
565
+ if (!meta || (!meta.agentType && !meta.description && !meta.toolUseId)) {
566
+ log?.(`subagent-watcher: backfill skip ${agentId} — meta.json has no agentType/description/toolUseId`)
539
567
  return
540
568
  }
541
569
 
@@ -545,27 +573,51 @@ export function backfillJsonlAgentId(
545
573
  .get(agentId)
546
574
  if (already != null) return
547
575
 
548
- // Find the most-recent matching unmatched row.
549
- const candidate = db
550
- .prepare(`
551
- SELECT id FROM subagents
552
- WHERE jsonl_agent_id IS NULL
553
- AND agent_type IS ?
554
- AND description IS ?
555
- ORDER BY started_at DESC
556
- LIMIT 1
557
- `)
558
- .get(meta.agentType ?? null, meta.description ?? null) as { id: string } | null
559
-
560
- if (candidate == null) {
561
- log?.(`subagent-watcher: backfill no candidate for ${agentId} (type=${meta.agentType} desc=${meta.description})`)
576
+ // Primary path (Bug 1 fix): direct PK lookup via the toolUseId Claude Code
577
+ // writes to meta.json. The pretool hook inserts the row with `id =
578
+ // event.tool_use_id`, so this is an exact match with no ambiguity — no
579
+ // race, no description-collision, no fuzzy-match false-negative.
580
+ let candidateId: string | null = null
581
+ if (meta.toolUseId) {
582
+ const direct = db
583
+ .prepare('SELECT id FROM subagents WHERE id = ? AND jsonl_agent_id IS NULL LIMIT 1')
584
+ .get(meta.toolUseId) as { id: string } | null
585
+ if (direct != null) {
586
+ candidateId = direct.id
587
+ log?.(`subagent-watcher: backfill direct-key match ${agentId} → ${candidateId} (toolUseId=${meta.toolUseId})`)
588
+ } else {
589
+ log?.(`subagent-watcher: backfill direct-key miss ${agentId} toolUseId=${meta.toolUseId} — falling back to fuzzy match`)
590
+ }
591
+ }
592
+
593
+ // Fallback path: fuzzy (agentType, description) match for older Claude Code
594
+ // versions whose meta.json predates the toolUseId field.
595
+ if (candidateId == null && (meta.agentType || meta.description)) {
596
+ const fuzzy = db
597
+ .prepare(`
598
+ SELECT id FROM subagents
599
+ WHERE jsonl_agent_id IS NULL
600
+ AND agent_type IS ?
601
+ AND description IS ?
602
+ ORDER BY started_at DESC
603
+ LIMIT 1
604
+ `)
605
+ .get(meta.agentType ?? null, meta.description ?? null) as { id: string } | null
606
+ if (fuzzy != null) {
607
+ candidateId = fuzzy.id
608
+ log?.(`subagent-watcher: backfill fuzzy match ${agentId} → ${candidateId} (type=${meta.agentType} desc=${meta.description})`)
609
+ }
610
+ }
611
+
612
+ if (candidateId == null) {
613
+ log?.(`subagent-watcher: backfill no candidate for ${agentId} (toolUseId=${meta.toolUseId} type=${meta.agentType} desc=${meta.description})`)
562
614
  return
563
615
  }
564
616
 
565
617
  db
566
618
  .prepare('UPDATE subagents SET jsonl_agent_id = ? WHERE id = ?')
567
- .run(agentId, candidate.id)
568
- log?.(`subagent-watcher: backfill linked ${agentId} → ${candidate.id}`)
619
+ .run(agentId, candidateId)
620
+ log?.(`subagent-watcher: backfill linked ${agentId} → ${candidateId}`)
569
621
 
570
622
  // Backfill parent_turn_key (gateway-side). The PreToolUse hook can't know
571
623
  // the gateway-minted Telegram turn_key (a chat+topic+turn key) — it only
@@ -588,7 +640,7 @@ export function backfillJsonlAgentId(
588
640
  try {
589
641
  const linkedRow = db
590
642
  .prepare('SELECT started_at, parent_turn_key FROM subagents WHERE id = ?')
591
- .get(candidate.id) as { started_at: number; parent_turn_key: string | null } | null
643
+ .get(candidateId) as { started_at: number; parent_turn_key: string | null } | null
592
644
  if (linkedRow != null && linkedRow.parent_turn_key == null) {
593
645
  const turn = db
594
646
  .prepare(
@@ -600,12 +652,12 @@ export function backfillJsonlAgentId(
600
652
  if (turn?.turn_key != null) {
601
653
  db
602
654
  .prepare('UPDATE subagents SET parent_turn_key = ? WHERE id = ?')
603
- .run(turn.turn_key, candidate.id)
604
- log?.(`subagent-watcher: backfill parent_turn_key ${candidate.id} → ${turn.turn_key}`)
655
+ .run(turn.turn_key, candidateId)
656
+ log?.(`subagent-watcher: backfill parent_turn_key ${candidateId} → ${turn.turn_key}`)
605
657
  }
606
658
  }
607
659
  } catch (err) {
608
- log?.(`subagent-watcher: parent_turn_key backfill skipped for ${candidate.id} — ${(err as Error).message}`)
660
+ log?.(`subagent-watcher: parent_turn_key backfill skipped for ${candidateId} — ${(err as Error).message}`)
609
661
  }
610
662
  }
611
663
 
@@ -743,6 +795,62 @@ export function readSubTail(
743
795
  if (errInfo.detail) entry.errorDetail = errInfo.detail.slice(0, SUBAGENT_RESULT_TEXT_MAX)
744
796
  }
745
797
  const events = projectSubagentLine(line, entry.agentId, startState)
798
+ // Narrative-dedup gate (JSONL-text-narrative primitive) — fire the
799
+ // narrative progress cue for a SHOWN sub_agent_text block. Identical
800
+ // shape to the inline #1720 onProgress below; factored out so the gate
801
+ // (stage-on-text, resolve-on-tool/turn_end) can replay a previously
802
+ // pending block exactly once. `latestSummary` carries the worker's
803
+ // narrative result (entry.lastResultText), never tool labels.
804
+ const fireNarrativeProgress = (): void => {
805
+ if (onProgress == null || entry.state !== 'running' || entry.historical) return
806
+ try {
807
+ onProgress({
808
+ agentId: entry.agentId,
809
+ description: entry.description,
810
+ latestSummary: entry.lastResultText,
811
+ elapsedMs: now - entry.dispatchedAt,
812
+ prevBucketIdx: entry.lastProgressBucketIdx,
813
+ setBucketIdx: (b: number) => {
814
+ entry.lastProgressBucketIdx = b
815
+ },
816
+ lastTool: entry.lastTool,
817
+ toolCount: entry.toolCount,
818
+ })
819
+ } catch (cbErr) {
820
+ log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${(cbErr as Error).message}`)
821
+ }
822
+ }
823
+ // Resolve a pending sub-agent narrative against a lookahead event.
824
+ // SUPPRESS only when the pending block drafts a reply/stream_reply
825
+ // tool's text; otherwise SHOW (fire the cue). See narrative-dedup.ts §2b.
826
+ //
827
+ // Two lookahead shapes:
828
+ // - sub_agent_tool_use: `toolName`/`toolInput` are the tool — suppress
829
+ // a draft of THIS tool's reply text.
830
+ // - sub_agent_turn_end: `toolName` is null. NIT 3 (turn_end symmetry):
831
+ // a FOREGROUND sub-agent that called stream_reply/reply as its final
832
+ // tool then emitted a trailing text block would, under the old
833
+ // unconditional SHOW, surface a draft of the delivered answer. So at
834
+ // turn_end we apply the SAME conservative dedup as main-agent step 3:
835
+ // compare the trailing block against the worker's last reply text
836
+ // (`entry.lastReplyText`) and suppress a draft. Background workers
837
+ // never set lastReplyText, so their trailing narration still SHOWs.
838
+ const resolvePendingSubNarrative = (
839
+ toolName: string | null,
840
+ toolInput: Record<string, unknown> | undefined,
841
+ ): void => {
842
+ if (entry.pendingNarrative == null) return
843
+ const pending = entry.pendingNarrative
844
+ entry.pendingNarrative = null
845
+ if (toolName != null && REPLY_TOOLS.has(toolName)) {
846
+ const replyText = typeof toolInput?.text === 'string' ? (toolInput.text as string) : ''
847
+ if (isDraftOfReply(pending.text, replyText)) return // draft of the reply → SUPPRESS
848
+ } else if (toolName == null && entry.lastReplyText != null && entry.lastReplyText.length > 0) {
849
+ // turn_end path: suppress a trailing draft of the delivered answer.
850
+ if (isDraftOfReply(pending.text, entry.lastReplyText)) return
851
+ }
852
+ fireNarrativeProgress()
853
+ }
746
854
  for (const ev of events) {
747
855
  const idleSecBeforeBump = Math.round((now - entry.lastActivityAt) / 1000)
748
856
  entry.lastActivityAt = now
@@ -783,6 +891,17 @@ export function readSubTail(
783
891
  log?.(`subagent-watcher: stall cleared for ${entry.agentId} (activity resumed after ${idleSecBeforeBump}s — re-arming detection)`)
784
892
  }
785
893
  if (ev.kind === 'sub_agent_tool_use') {
894
+ // Narrative-dedup gate step 2: a sub_agent_text block was pending;
895
+ // this tool is the lookahead that decides it (SHOW unless it drafts
896
+ // a reply tool's text). Runs before the tool's own progress cue so
897
+ // a working preamble surfaces just ahead of its tool step.
898
+ resolvePendingSubNarrative(ev.toolName, ev.input)
899
+ // NIT 3: capture a foreground sub-agent's actual reply text so the
900
+ // turn_end path can suppress a trailing draft of it (see
901
+ // resolvePendingSubNarrative). Only REPLY_TOOLS carry the answer.
902
+ if (REPLY_TOOLS.has(ev.toolName) && typeof ev.input?.text === 'string') {
903
+ entry.lastReplyText = ev.input.text as string
904
+ }
786
905
  entry.toolCount++
787
906
  // P0 of #662: surface the most recent tool name + sanitised
788
907
  // arg so the driver's fleet-state shadow can render the
@@ -830,7 +949,7 @@ export function readSubTail(
830
949
  // set at dispatch time (from the parent Agent/Task tool_use input)
831
950
  // and must remain stable. Overwriting it with the sub-agent's first
832
951
  // narrative line caused a race-condition-dependent display (issue #352).
833
- entry.lastSummaryLine = ev.text.split('\n')[0].trim().slice(0, 120)
952
+ entry.lastSummaryLine = clipNarrative(ev.text)
834
953
  // Retain the full text of the most recent narrative emission —
835
954
  // for a worker the final such line before turn_end IS its
836
955
  // result summary (the worker prompt asks it to "return a
@@ -841,29 +960,28 @@ export function readSubTail(
841
960
  // args or file content — consistent with the watcher's
842
961
  // "descriptions only" privacy posture.
843
962
  entry.lastResultText = ev.text.trim().slice(0, SUBAGENT_RESULT_TEXT_MAX)
844
- // #1720: surface a progress cue for the gateway. Only fire
845
- // while the entry is still running and not historical — a
846
- // terminal entry's last narrative line is the handback
847
- // payload, not a mid-flight progress nudge.
848
- if (onProgress != null && entry.state === 'running' && !entry.historical) {
849
- try {
850
- onProgress({
851
- agentId: entry.agentId,
852
- description: entry.description,
853
- latestSummary: entry.lastResultText,
854
- elapsedMs: now - entry.dispatchedAt,
855
- prevBucketIdx: entry.lastProgressBucketIdx,
856
- setBucketIdx: (b: number) => {
857
- entry.lastProgressBucketIdx = b
858
- },
859
- lastTool: entry.lastTool,
860
- toolCount: entry.toolCount,
861
- })
862
- } catch (cbErr) {
863
- log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${(cbErr as Error).message}`)
864
- }
963
+ // #1720 + JSONL-text-narrative gate step 1: stage this block for
964
+ // one lookahead step instead of firing the progress cue
965
+ // immediately. A previously-pending block had nothing reply-shaped
966
+ // after it (pure narration) flush it as SHOWN now; then stage
967
+ // THIS block. Its eventual SHOW/SUPPRESS is decided by the next
968
+ // sub_agent_tool_use / sub_agent_turn_end. `lastResultText` /
969
+ // `lastSummaryLine` above already updated unconditionally — the
970
+ // handback payload is independent of the progress-cue decision.
971
+ if (entry.pendingNarrative != null) {
972
+ fireNarrativeProgress() // prior pending was pure narration → SHOW
865
973
  }
974
+ entry.pendingNarrative = { text: ev.text }
866
975
  } else if (ev.kind === 'sub_agent_turn_end') {
976
+ // Narrative-dedup gate step 3: a trailing sub_agent_text block with
977
+ // nothing after it. SUPPRESS only when it drafts the foreground
978
+ // sub-agent's delivered reply (entry.lastReplyText, set above on a
979
+ // REPLY_TOOL tool_use) — symmetric with main-agent step 3; otherwise
980
+ // SHOW. Background workers never set lastReplyText, so their trailing
981
+ // narration still SHOWs. The worker's result is carried separately
982
+ // via lastResultText/onFinish, so a SHOWN trailing cue here is purely
983
+ // the transient liveness beat.
984
+ resolvePendingSubNarrative(null, undefined)
867
985
  if (entry.state === 'running') {
868
986
  entry.state = 'done'
869
987
  // Bug 2 fix (#333): mark the DB row completed via watcher's turn_end
@@ -1456,25 +1574,53 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1456
1574
  const subagentsPath = join(projectPath, sDir, 'subagents')
1457
1575
  if (!fs.existsSync(subagentsPath)) continue
1458
1576
 
1459
- // Watch the subagents dir for new files if not already watching
1460
- if (!dirWatchers.has(subagentsPath)) {
1461
- try {
1462
- const w = fs.watch(subagentsPath, (_event, filename) => {
1463
- if (!filename || !filename.toString().startsWith('agent-') || !filename.toString().endsWith('.jsonl')) return
1464
- const filePath = join(subagentsPath, filename.toString())
1465
- if (!knownFiles.has(filePath)) {
1466
- scanSubagentsDir(subagentsPath)
1467
- }
1468
- })
1469
- dirWatchers.set(subagentsPath, w)
1470
- log?.(`subagent-watcher: watching dir ${subagentsPath}`)
1471
- } catch (err) {
1472
- log?.(`subagent-watcher: dir watch failed ${subagentsPath}: ${(err as Error).message}`)
1577
+ // Watch a single flat subagents dir and scan its agent-*.jsonl files.
1578
+ // Reused for both the base subagents/ dir and each workflow sub-dir.
1579
+ const watchAndScan = (dirPath: string): void => {
1580
+ if (!dirWatchers.has(dirPath)) {
1581
+ try {
1582
+ const w = fs.watch(dirPath, (_event, filename) => {
1583
+ if (!filename || !filename.toString().startsWith('agent-') || !filename.toString().endsWith('.jsonl')) return
1584
+ const filePath = join(dirPath, filename.toString())
1585
+ if (!knownFiles.has(filePath)) {
1586
+ scanSubagentsDir(dirPath)
1587
+ }
1588
+ })
1589
+ dirWatchers.set(dirPath, w)
1590
+ log?.(`subagent-watcher: watching dir ${dirPath}`)
1591
+ } catch (err) {
1592
+ log?.(`subagent-watcher: dir watch failed ${dirPath}: ${(err as Error).message}`)
1593
+ }
1473
1594
  }
1595
+ scanSubagentsDir(dirPath)
1474
1596
  }
1475
1597
 
1476
- // Scan existing files
1477
- scanSubagentsDir(subagentsPath)
1598
+ // Register the base subagents dir
1599
+ watchAndScan(subagentsPath)
1600
+
1601
+ // Workflow sub-agents (spawned by the Workflow tool) write to:
1602
+ // subagents/workflows/wf_<id>/agent-<id>.jsonl
1603
+ // The flat readdir above misses these because it only sees the
1604
+ // "workflows" directory entry (not matching agent-*.jsonl). Descend
1605
+ // one level so each wf_*/ dir gets the same watch+scan treatment.
1606
+ const workflowsPath = join(subagentsPath, 'workflows')
1607
+ if (fs.existsSync(workflowsPath)) {
1608
+ let wfDirs: string[]
1609
+ try {
1610
+ wfDirs = fs.readdirSync(workflowsPath) as string[]
1611
+ } catch { continue }
1612
+ for (const wfDir of wfDirs) {
1613
+ try {
1614
+ const wfPath = join(workflowsPath, wfDir)
1615
+ // Only descend into actual directories. statSync succeeds on
1616
+ // regular files too (e.g. a stray journal.jsonl or lock file
1617
+ // sitting directly in workflows/), so check isDirectory()
1618
+ // explicitly rather than relying on a throw that never comes.
1619
+ if (!fs.statSync(wfPath).isDirectory()) continue
1620
+ watchAndScan(wfPath)
1621
+ } catch { /* skip entries we can't stat or watch */ }
1622
+ }
1623
+ }
1478
1624
  }
1479
1625
  }
1480
1626
  }