switchroom 0.18.18 → 0.18.20

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 (45) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +36 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/answer-ready-flush.ts +187 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1131 -285
  7. package/telegram-plugin/dist/server.js +6 -0
  8. package/telegram-plugin/format.ts +208 -125
  9. package/telegram-plugin/gateway/cron-session.ts +32 -0
  10. package/telegram-plugin/gateway/gateway.ts +800 -107
  11. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  12. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  13. package/telegram-plugin/gateway/outbound-send-path.ts +5 -3
  14. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  15. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  16. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  17. package/telegram-plugin/llm-error-present.ts +68 -30
  18. package/telegram-plugin/narrative-flush.ts +181 -0
  19. package/telegram-plugin/pending-work-progress.ts +65 -1
  20. package/telegram-plugin/session-tail.ts +6 -1
  21. package/telegram-plugin/silent-end.ts +182 -0
  22. package/telegram-plugin/subagent-watcher.ts +244 -81
  23. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  24. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  25. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  26. package/telegram-plugin/tests/format-consistency.test.ts +39 -4
  27. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
  28. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  29. package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
  30. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  31. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  32. package/telegram-plugin/tests/outbound-send-path.test.ts +2 -0
  33. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  34. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  35. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  36. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  37. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
  38. package/telegram-plugin/tests/telegram-format.test.ts +72 -4
  39. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  40. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  41. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  42. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
  43. package/telegram-plugin/tool-activity-summary.ts +78 -16
  44. package/telegram-plugin/turn-flush-safety.ts +2 -1
  45. package/telegram-plugin/worker-activity-feed.ts +181 -30
@@ -46,7 +46,8 @@ import { homedir } from 'os'
46
46
  import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptLine } from './session-tail.js'
47
47
  import { sanitiseToolArg } from './fleet-state.js'
48
48
  import { clipNarrative, describeToolUse } from './tool-activity-summary.js'
49
- import { REPLY_TOOLS, isDraftOfReply } from './narrative-dedup.js'
49
+ import { REPLY_TOOLS } from './narrative-dedup.js'
50
+ import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from './narrative-flush.js'
50
51
  import { truncate } from './card-format.js'
51
52
  import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents, recordNestedSubagentDispatch, recordSubagentModel } from './registry/subagents-schema.js'
52
53
  import { touchTurnActiveMarker } from './gateway/turn-active-marker.js'
@@ -73,6 +74,38 @@ export interface SubagentLivenessDb {
73
74
 
74
75
  export type WorkerState = 'running' | 'done' | 'failed'
75
76
 
77
+ /**
78
+ * Per-entry narrative gate: the SAME `NarrativeFlushController` kernel the
79
+ * main-agent gateway path uses, driven by a POLL-driven scheduler instead of a
80
+ * real `setTimeout`. Constructed lazily on the first `sub_agent_text` block.
81
+ *
82
+ * Time-box parity with the main path (Residual A): the kernel's injected
83
+ * scheduler stamps `deadline = nowFn()+PENDING_NARRATIVE_FLUSH_MS` when a block
84
+ * is parked; `tick(now)` — called at the top of every `readSubTail` (which runs
85
+ * every ~1s poll for a running entry, regardless of file growth) — fires the
86
+ * parked block EARLY once `now >= deadline`, WITHOUT waiting for the worker's
87
+ * first tool. So a worker's opening narration surfaces ~the next poll tick
88
+ * after the flush window rather than gating on its first jsonl tool event.
89
+ *
90
+ * Depth-generic: one gate per `WorkerEntry`, and every sub-agent / worker /
91
+ * nested sub-worker at any depth is a `WorkerEntry` — so the early-paint
92
+ * applies uniformly at every nesting level.
93
+ */
94
+ export interface WorkerNarrativeGate {
95
+ /** Fire the early-paint if a parked block's flush window has elapsed.
96
+ * Also refreshes the injected clock to this poll's `now`, so a separate
97
+ * clock-refresh entrypoint is unnecessary. */
98
+ tick(now: number): void
99
+ /** Gate step 1: park a new `sub_agent_text` block (SHOW any prior pending). */
100
+ stage(text: string): void
101
+ /** Gate step 2: a tool_use lookahead. Returns whether a narrative cue fired. */
102
+ resolveOnTool(toolName: string | null, input: Record<string, unknown> | undefined): boolean
103
+ /** Gate step 3: turn_end lookahead. Returns whether a narrative cue fired. */
104
+ resolveAtTurnEnd(lastReplyText: string): boolean
105
+ /** Cancel the timer + drop pending state (resurrection / teardown). */
106
+ reset(): void
107
+ }
108
+
76
109
  export interface WorkerEntry {
77
110
  /** Sub-agent JSONL file stem, e.g. "a75d4757a81e7b1f8". */
78
111
  readonly agentId: string
@@ -197,15 +230,18 @@ export interface WorkerEntry {
197
230
  * worker left no narrative result of its own. */
198
231
  errorDetail?: string
199
232
  /**
200
- * Narrative-dedup gate state (JSONL-text-narrative primitive). A
201
- * `sub_agent_text` block is held here for ONE lookahead step so the next
202
- * `sub_agent_tool_use` / `sub_agent_turn_end` can decide draft-then-send
203
- * (SUPPRESS — it duplicates the worker's reply) vs working-narration (SHOW
204
- * — fire `onProgress({latestSummary})`). Null when nothing is pending. The
205
- * pure decision lives in narrative-dedup.ts; this slot is the per-entry
206
- * cursor. Mirrors the gateway's `turn.pendingNarrative`.
233
+ * Narrative-dedup + early-paint gate (JSONL-text-narrative primitive). A
234
+ * `sub_agent_text` block is parked in the kernel for ONE lookahead step so
235
+ * the next `sub_agent_tool_use` / `sub_agent_turn_end` can decide
236
+ * draft-then-send (SUPPRESS — it duplicates the worker's reply) vs
237
+ * working-narration (SHOW — fire `onProgress({latestSummary})`), AND
238
+ * time-boxed so an opening narration paints EARLY if no lookahead arrives
239
+ * within `PENDING_NARRATIVE_FLUSH_MS`. Reuses the SAME
240
+ * `NarrativeFlushController` kernel as the main-agent gateway path
241
+ * (`makeNarrativeGate`), driven by a poll scheduler. Constructed lazily on
242
+ * the first `sub_agent_text`; null before then. Reset on resurrection.
207
243
  */
208
- pendingNarrative?: { text: string } | null
244
+ narrativeGate?: WorkerNarrativeGate | null
209
245
  /**
210
246
  * NIT 3 (sub-agent turn_end symmetry). Most-recently-seen
211
247
  * reply/stream_reply `input.text` for this sub-agent — the actual answer a
@@ -538,6 +574,26 @@ export interface SubagentWatcherConfig {
538
574
  */
539
575
  background: boolean | undefined
540
576
  }) => void
577
+ /**
578
+ * Fires EXACTLY when a sub-agent is swept out of the watcher's registry by
579
+ * `cleanupTerminalAgent` — the single, authoritative "this agent is terminal
580
+ * and being forgotten" signal that EVERY terminal path funnels through
581
+ * (real `turn_end`, silent-stall synthesis, failed, boot done-at-boot orphan,
582
+ * AND the JSONL-vanished path — where Claude Code reaped the parent session's
583
+ * `subagents/` dir, `onFileVanished` → `cleanupTerminalAgent` runs DIRECTLY,
584
+ * bypassing `onFinish`).
585
+ *
586
+ * Why this exists (worker-feed ghost leak): the live worker-activity feed
587
+ * removes a worker's row ONLY from the gateway's `onFinish` handler. The
588
+ * JSONL-vanished and boot-orphan terminal paths never fire `onFinish`, so a
589
+ * worker that was live in the feed leaks there forever — the shared card never
590
+ * empties, so it never collapses/unpins and heartbeat-edits indefinitely
591
+ * while buried up-chat. Wiring feed removal to THIS callback (the same sweep
592
+ * the watcher already performs) makes cleanup and feed-remove impossible to
593
+ * diverge. Best-effort; idempotent on the feed side (a no-op once the worker
594
+ * was already removed by `onFinish`).
595
+ */
596
+ onTerminalCleanup?: (agentId: string) => void
541
597
  /**
542
598
  * #1720: fires on every `sub_agent_text` event for a running
543
599
  * sub-agent. The gateway decides whether to materialise a
@@ -635,6 +691,21 @@ const DEFAULT_SILENT_STALL_TERMINAL_MS = 300_000
635
691
  // reaper TTL, so the watcher — not the reaper — still owns the terminal
636
692
  // transition for a worker that died mid-tool.
637
693
  const DEFAULT_INFLIGHT_TERMINAL_CAP_MS = 45 * 60_000
694
+ /**
695
+ * Resolve the effective in-flight terminal-synthesis cap — the maximum wall-
696
+ * clock a genuinely-live worker can go with ZERO JSONL writes before the
697
+ * watcher declares it terminal (a worker mid-very-long `Bash`). This is the
698
+ * SAME resolution `startSubagentWatcher` uses internally (explicit config →
699
+ * `SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS` env → default), exported so
700
+ * downstream liveness surfaces (the worker-activity feed's backstop TTL) can
701
+ * DERIVE their bound from it in code rather than hardcoding an assumption about
702
+ * its value. Keeps the "TTL must exceed the watcher's terminal-transition
703
+ * latency" invariant code-enforced: if an operator raises the cap via env, any
704
+ * derived TTL moves with it instead of falsely reaping a still-live worker.
705
+ */
706
+ export function resolveInflightTerminalCapMs(configVal?: number): number {
707
+ return configVal ?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS') ?? DEFAULT_INFLIGHT_TERMINAL_CAP_MS
708
+ }
638
709
  // Minimum wall-clock gap between two "terminal synthesis deferred" log lines
639
710
  // for the SAME worker (#3092). `checkStalls` runs on the ~1s rescan tick, and
640
711
  // the deferral is re-evaluated (and, before this gate, re-logged) on every one
@@ -1020,6 +1091,13 @@ export function readSubTail(
1020
1091
  }) => void,
1021
1092
  ): void {
1022
1093
  try {
1094
+ // Early-paint tick (Residual A): fire a parked opening narration whose
1095
+ // flush window has elapsed, WITHOUT waiting for a tool. Runs BEFORE the
1096
+ // `size === cursor` no-growth early-return below, so it fires on a quiet
1097
+ // poll (the "narrate, then think" gap) — the whole point of the time-box.
1098
+ // This function is the per-poll defensive read for every running entry, so
1099
+ // the deadline is observed within one poll tick of PENDING_NARRATIVE_FLUSH_MS.
1100
+ if (entry.narrativeGate != null) entry.narrativeGate.tick(now)
1023
1101
  const stat = fs.statSync(entry.filePath)
1024
1102
  if (stat.size < tail.cursor) {
1025
1103
  tail.cursor = 0
@@ -1126,70 +1204,142 @@ export function readSubTail(
1126
1204
  if (errInfo.detail) entry.errorDetail = errInfo.detail.slice(0, SUBAGENT_RESULT_TEXT_MAX)
1127
1205
  }
1128
1206
  const events = projectSubagentLine(line, entry.agentId, startState)
1129
- // Narrative-dedup gate (JSONL-text-narrative primitive) — fire the
1130
- // narrative progress cue for a SHOWN sub_agent_text block. Identical
1131
- // shape to the inline #1720 onProgress below; factored out so the gate
1132
- // (stage-on-text, resolve-on-tool/turn_end) can replay a previously
1133
- // pending block exactly once. `latestSummary` carries the worker's
1134
- // narrative result (entry.lastResultText), never tool labels.
1135
- const fireNarrativeProgress = (): boolean => {
1136
- if (onProgress == null || entry.state !== 'running' || entry.historical) return false
1137
- try {
1138
- onProgress({
1139
- agentId: entry.agentId,
1140
- description: entry.description,
1141
- latestSummary: entry.lastResultText,
1142
- elapsedMs: now - entry.dispatchedAt,
1143
- prevBucketIdx: entry.lastProgressBucketIdx,
1144
- setBucketIdx: (b: number) => {
1145
- entry.lastProgressBucketIdx = b
1207
+ // Narrative gate (JSONL-text-narrative primitive) — the SAME
1208
+ // `NarrativeFlushController` kernel the main-agent gateway path uses,
1209
+ // driven here by a POLL-driven scheduler so a worker's opening narration
1210
+ // paints EARLY (~the next poll after PENDING_NARRATIVE_FLUSH_MS) instead
1211
+ // of gating on its first tool. `show` fires the narrative onProgress cue
1212
+ // for a SHOWN block (`latestSummary` = entry.lastResultText, never tool
1213
+ // labels matching the historical wire shape); the kernel owns the
1214
+ // SHOW/SUPPRESS/early-paint/retract decisions (dedup lives in
1215
+ // narrative-dedup.ts, timer + retract in narrative-flush.ts).
1216
+ const buildNarrativeGate = (): WorkerNarrativeGate => {
1217
+ // Injected clock the kernel's scheduler reads. Refreshed each poll via
1218
+ // tick(now) so `deadline = nowRef.value + flushMs` uses the CURRENT
1219
+ // poll's `now` even though the kernel persists across polls.
1220
+ const nowRef = { value: now }
1221
+ // Whether the LAST kernel effect painted a narrative cue — read back by
1222
+ // resolveOnTool/resolveAtTurnEnd for the tool-label clobber guard.
1223
+ let cueFired = false
1224
+ const fireCue = (): boolean => {
1225
+ if (onProgress == null || entry.state !== 'running' || entry.historical) return false
1226
+ try {
1227
+ onProgress({
1228
+ agentId: entry.agentId,
1229
+ description: entry.description,
1230
+ latestSummary: entry.lastResultText,
1231
+ elapsedMs: nowRef.value - entry.dispatchedAt,
1232
+ prevBucketIdx: entry.lastProgressBucketIdx,
1233
+ setBucketIdx: (b: number) => {
1234
+ entry.lastProgressBucketIdx = b
1235
+ },
1236
+ lastTool: entry.lastTool,
1237
+ toolCount: entry.toolCount,
1238
+ model: entry.currentModel,
1239
+ })
1240
+ return true
1241
+ } catch (cbErr) {
1242
+ log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${(cbErr as Error).message}`)
1243
+ return false
1244
+ }
1245
+ }
1246
+ // Poll-driven scheduler: `arm` stamps a deadline off the injected clock;
1247
+ // the watcher's `tick` fires it once the poll clock reaches it — the
1248
+ // deterministic, clock-injected equivalent of the gateway's setTimeout.
1249
+ const scheduler = {
1250
+ armedFn: null as (() => void) | null,
1251
+ deadline: 0,
1252
+ }
1253
+ const controller = new NarrativeFlushController(
1254
+ {
1255
+ show: () => {
1256
+ cueFired = fireCue()
1146
1257
  },
1147
- lastTool: entry.lastTool,
1148
- toolCount: entry.toolCount,
1149
- model: entry.currentModel,
1150
- })
1151
- return true
1152
- } catch (cbErr) {
1153
- log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${(cbErr as Error).message}`)
1154
- return false
1258
+ // Retract on the worker path is a documented near-no-op: unlike the
1259
+ // gateway (which splices a persisted narration mirror), the worker
1260
+ // card is replace-on-write and the worker's reply is a
1261
+ // Telegram-surface tool that `describeToolUse` never renders — so a
1262
+ // timer-painted block can never be DOUBLE-printed as both a card
1263
+ // step and the reply (the gateway's catastrophe is structurally
1264
+ // impossible here). The transient trail line self-heals via the
1265
+ // rolling window; the worker's true result rides
1266
+ // lastResultText/onFinish regardless of the gate. See DESIGN.md
1267
+ // "COVERAGE LIMIT" for the narrow >250ms-gap draft edge.
1268
+ retractShown: () => {
1269
+ log?.(
1270
+ `subagent-watcher: narrative early-paint retract (no-op on replace-on-write worker card) ${entry.agentId}`,
1271
+ )
1272
+ },
1273
+ },
1274
+ {
1275
+ arm: (fn, ms) => {
1276
+ scheduler.armedFn = fn
1277
+ scheduler.deadline = nowRef.value + ms
1278
+ },
1279
+ disarm: () => {
1280
+ scheduler.armedFn = null
1281
+ },
1282
+ },
1283
+ PENDING_NARRATIVE_FLUSH_MS,
1284
+ )
1285
+ return {
1286
+ tick: (n) => {
1287
+ nowRef.value = n
1288
+ if (scheduler.armedFn != null && n >= scheduler.deadline) {
1289
+ const fn = scheduler.armedFn
1290
+ scheduler.armedFn = null
1291
+ fn() // → onTimerFire → show → fireCue (EARLY paint, no tool needed)
1292
+ }
1293
+ },
1294
+ // stage/resolve run synchronously within a readSubTail call whose
1295
+ // clock was already refreshed onto nowRef by the top-of-function
1296
+ // `tick(now)` (or by buildNarrativeGate on the first block), so they
1297
+ // must NOT re-stamp nowRef with a captured (stale) `now`.
1298
+ stage: (text) => {
1299
+ controller.stage(text)
1300
+ },
1301
+ resolveOnTool: (toolName, input) => {
1302
+ cueFired = false
1303
+ // NIT 3 (turn_end symmetry) lives inside the kernel: it compares the
1304
+ // pending block against a REPLY_TOOL's input.text (tool path) and,
1305
+ // at turn_end, against the delivered reply text passed by
1306
+ // resolveAtTurnEnd. Here we only forward the tool lookahead.
1307
+ controller.resolveOnTool(toolName ?? '', input)
1308
+ return cueFired
1309
+ },
1310
+ resolveAtTurnEnd: (lastReplyText) => {
1311
+ cueFired = false
1312
+ controller.flushAtTurnEnd(lastReplyText)
1313
+ return cueFired
1314
+ },
1315
+ reset: () => {
1316
+ controller.teardown()
1317
+ scheduler.armedFn = null
1318
+ },
1155
1319
  }
1156
1320
  }
1157
1321
  // Resolve a pending sub-agent narrative against a lookahead event.
1158
- // SUPPRESS only when the pending block drafts a reply/stream_reply
1159
- // tool's text; otherwise SHOW (fire the cue). See narrative-dedup.ts §2b.
1160
- //
1161
- // Two lookahead shapes:
1162
- // - sub_agent_tool_use: `toolName`/`toolInput` are the tool suppress
1163
- // a draft of THIS tool's reply text.
1164
- // - sub_agent_turn_end: `toolName` is null. NIT 3 (turn_end symmetry):
1165
- // a FOREGROUND sub-agent that called stream_reply/reply as its final
1166
- // tool then emitted a trailing text block would, under the old
1167
- // unconditional SHOW, surface a draft of the delivered answer. So at
1168
- // turn_end we apply the SAME conservative dedup as main-agent step 3:
1169
- // compare the trailing block against the worker's last reply text
1170
- // (`entry.lastReplyText`) and suppress a draft. Background workers
1171
- // never set lastReplyText, so their trailing narration still SHOWs.
1172
- // Returns true iff a narrative onProgress cue actually fired this
1173
- // call — callers use this to skip a redundant/clobbering tool-label
1174
- // onProgress cue for the SAME tick (see #1042 below: without this,
1175
- // the tool-description onProgress unconditionally fires right after
1176
- // and its replace-on-write onProgress always wins, so the narration
1177
- // shown here is never actually visible on the pinned card).
1322
+ // SUPPRESS only when the pending block drafts a reply/stream_reply tool's
1323
+ // text; otherwise SHOW (fire the cue). Two lookahead shapes:
1324
+ // - sub_agent_tool_use: forward the tool — the kernel suppresses a draft
1325
+ // of THIS tool's reply text (REPLY_TOOLS only).
1326
+ // - sub_agent_turn_end: `toolName` is null resolveAtTurnEnd with
1327
+ // entry.lastReplyText, so a FOREGROUND sub-agent's trailing draft of
1328
+ // its delivered answer is suppressed (background workers never set
1329
+ // lastReplyText, so their trailing narration still SHOWs).
1330
+ // Returns true iff a narrative onProgress cue actually fired callers use
1331
+ // this to skip a redundant/clobbering tool-label onProgress cue for the
1332
+ // SAME tick (the tool-description onProgress's replace-on-write always
1333
+ // wins, so firing both back-to-back hides the narration).
1178
1334
  const resolvePendingSubNarrative = (
1179
1335
  toolName: string | null,
1180
1336
  toolInput: Record<string, unknown> | undefined,
1181
1337
  ): boolean => {
1182
- if (entry.pendingNarrative == null) return false
1183
- const pending = entry.pendingNarrative
1184
- entry.pendingNarrative = null
1185
- if (toolName != null && REPLY_TOOLS.has(toolName)) {
1186
- const replyText = typeof toolInput?.text === 'string' ? (toolInput.text as string) : ''
1187
- if (isDraftOfReply(pending.text, replyText)) return false // draft of the reply → SUPPRESS
1188
- } else if (toolName == null && entry.lastReplyText != null && entry.lastReplyText.length > 0) {
1189
- // turn_end path: suppress a trailing draft of the delivered answer.
1190
- if (isDraftOfReply(pending.text, entry.lastReplyText)) return false
1338
+ if (entry.narrativeGate == null) return false
1339
+ if (toolName == null) {
1340
+ return entry.narrativeGate.resolveAtTurnEnd(entry.lastReplyText ?? '')
1191
1341
  }
1192
- return fireNarrativeProgress()
1342
+ return entry.narrativeGate.resolveOnTool(toolName, toolInput)
1193
1343
  }
1194
1344
  for (const ev of events) {
1195
1345
  const idleSecBeforeBump = Math.round((now - entry.lastActivityAt) / 1000)
@@ -1396,18 +1546,18 @@ export function readSubTail(
1396
1546
  // args or file content — consistent with the watcher's
1397
1547
  // "descriptions only" privacy posture.
1398
1548
  entry.lastResultText = ev.text.trim().slice(0, SUBAGENT_RESULT_TEXT_MAX)
1399
- // #1720 + JSONL-text-narrative gate step 1: stage this block for
1400
- // one lookahead step instead of firing the progress cue
1401
- // immediately. A previously-pending block had nothing reply-shaped
1402
- // after it (pure narration) flush it as SHOWN now; then stage
1403
- // THIS block. Its eventual SHOW/SUPPRESS is decided by the next
1404
- // sub_agent_tool_use / sub_agent_turn_end. `lastResultText` /
1405
- // `lastSummaryLine` above already updated unconditionally the
1406
- // handback payload is independent of the progress-cue decision.
1407
- if (entry.pendingNarrative != null) {
1408
- fireNarrativeProgress() // prior pending was pure narration SHOW
1409
- }
1410
- entry.pendingNarrative = { text: ev.text }
1549
+ // #1720 + JSONL-text-narrative gate step 1: stage this block for one
1550
+ // lookahead step (AND arm the early-paint timer) instead of firing
1551
+ // the progress cue immediately. The kernel's `stage` SHOWs any prior
1552
+ // pending block (it had nothing reply-shaped after it pure
1553
+ // narration) then parks THIS one; its eventual SHOW/SUPPRESS is
1554
+ // decided by the next sub_agent_tool_use / sub_agent_turn_end, or by
1555
+ // the early-paint timer if neither arrives within the flush window.
1556
+ // `lastResultText` / `lastSummaryLine` above already updated
1557
+ // unconditionally the handback payload is independent of the
1558
+ // progress-cue decision. Gate is built lazily on the first block.
1559
+ if (entry.narrativeGate == null) entry.narrativeGate = buildNarrativeGate()
1560
+ entry.narrativeGate.stage(ev.text)
1411
1561
  } else if (ev.kind === 'sub_agent_tool_result') {
1412
1562
  // The tool call completed — clear it from the in-flight set so
1413
1563
  // the terminal-synthesis gate re-opens. Idempotent: a result
@@ -1510,10 +1660,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1510
1660
  config.silentStallTerminalMs
1511
1661
  ?? parseEnvMs('SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS')
1512
1662
  ?? DEFAULT_SILENT_STALL_TERMINAL_MS
1513
- const inflightTerminalCapMs =
1514
- config.inflightTerminalCapMs
1515
- ?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS')
1516
- ?? DEFAULT_INFLIGHT_TERMINAL_CAP_MS
1663
+ const inflightTerminalCapMs = resolveInflightTerminalCapMs(config.inflightTerminalCapMs)
1517
1664
  const deferralLogIntervalMs =
1518
1665
  config.deferralLogIntervalMs
1519
1666
  ?? parseEnvMs('SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS')
@@ -1862,7 +2009,11 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1862
2009
  // worker's card reaches real activity instead of a frozen stub.
1863
2010
  entry.toolCount = 0
1864
2011
  entry.lastTool = null
1865
- entry.pendingNarrative = null
2012
+ // Cancel any armed early-paint timer + drop pending state before the
2013
+ // from-scratch replay so a stale parked block can't fire against the
2014
+ // rebuilt cursor. Rebuilt lazily on the first replayed sub_agent_text.
2015
+ entry.narrativeGate?.reset()
2016
+ entry.narrativeGate = null
1866
2017
  tail.cursor = 0
1867
2018
  tail.pendingPartial = ''
1868
2019
  tail.hasEmittedStart = false
@@ -2012,6 +2163,18 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
2012
2163
  }
2013
2164
  terminatedAgentIds.add(agentId)
2014
2165
  log?.(`subagent-watcher: cleaned up terminal agent ${agentId}`)
2166
+ // Authoritative terminal sweep → notify the worker-activity feed so its row
2167
+ // for this agent is removed even on the paths that never fire `onFinish`
2168
+ // (JSONL vanished, boot done-at-boot orphan). Without this the feed row
2169
+ // leaks and the shared card goes immortal/unpinned (worker-feed ghost leak).
2170
+ // Best-effort: a callback throw must never wedge the watcher's cleanup.
2171
+ if (config.onTerminalCleanup) {
2172
+ try {
2173
+ config.onTerminalCleanup(agentId)
2174
+ } catch (cbErr) {
2175
+ log?.(`subagent-watcher: onTerminalCleanup callback error ${agentId}: ${(cbErr as Error).message}`)
2176
+ }
2177
+ }
2015
2178
  }
2016
2179
 
2017
2180
  // ─── Card resurrection (issue #3023) ─────────────────────────────────────