switchroom 0.18.19 → 0.18.21

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 (57) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +59 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
  6. package/profiles/_shared/dev-protocol.md.hbs +2 -0
  7. package/profiles/_shared/execution-discipline.md.hbs +2 -2
  8. package/profiles/coding/CLAUDE.md.hbs +1 -1
  9. package/telegram-plugin/answer-ready-flush.ts +187 -0
  10. package/telegram-plugin/dist/gateway/gateway.js +1114 -184
  11. package/telegram-plugin/format.ts +179 -20
  12. package/telegram-plugin/gateway/cron-session.ts +32 -0
  13. package/telegram-plugin/gateway/gateway.ts +794 -106
  14. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  15. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  16. package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
  17. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
  18. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  19. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  20. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  21. package/telegram-plugin/narrative-flush.ts +181 -0
  22. package/telegram-plugin/pending-work-progress.ts +65 -1
  23. package/telegram-plugin/registry/subagents-schema.ts +6 -0
  24. package/telegram-plugin/session-tail.ts +6 -1
  25. package/telegram-plugin/silent-end.ts +182 -0
  26. package/telegram-plugin/stream-reply-handler.ts +14 -5
  27. package/telegram-plugin/subagent-watcher.ts +330 -82
  28. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  29. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  30. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  31. package/telegram-plugin/tests/format-consistency.test.ts +54 -34
  32. package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
  33. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  34. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  35. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  36. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  37. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
  38. package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
  39. package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
  40. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  41. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  42. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  43. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  44. package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
  45. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
  46. package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
  47. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
  48. package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
  49. package/telegram-plugin/tests/telegram-format.test.ts +36 -23
  50. package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
  51. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  52. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  53. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  54. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
  55. package/telegram-plugin/tool-activity-summary.ts +78 -16
  56. package/telegram-plugin/turn-flush-safety.ts +4 -4
  57. 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
@@ -572,6 +628,12 @@ export interface SubagentWatcherConfig {
572
628
  * assistant line — the gateway then falls back to the registry's
573
629
  * dispatch-time model. */
574
630
  model?: string
631
+ /** True for a growth-INDEPENDENT skeleton liveness cue (#3231): fired on a
632
+ * no-growth poll for a running entry so the card can first-paint / stay
633
+ * alive without waiting for JSONL growth. Carries the entry's real state
634
+ * but an EMPTY `latestSummary`/`progressLine` — never fabricated content.
635
+ * Consumers that count real narrative/tool cues must exclude it. */
636
+ skeleton?: boolean
575
637
  }) => void
576
638
  /** `Date.now` override for tests. */
577
639
  now?: () => number
@@ -635,6 +697,21 @@ const DEFAULT_SILENT_STALL_TERMINAL_MS = 300_000
635
697
  // reaper TTL, so the watcher — not the reaper — still owns the terminal
636
698
  // transition for a worker that died mid-tool.
637
699
  const DEFAULT_INFLIGHT_TERMINAL_CAP_MS = 45 * 60_000
700
+ /**
701
+ * Resolve the effective in-flight terminal-synthesis cap — the maximum wall-
702
+ * clock a genuinely-live worker can go with ZERO JSONL writes before the
703
+ * watcher declares it terminal (a worker mid-very-long `Bash`). This is the
704
+ * SAME resolution `startSubagentWatcher` uses internally (explicit config →
705
+ * `SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS` env → default), exported so
706
+ * downstream liveness surfaces (the worker-activity feed's backstop TTL) can
707
+ * DERIVE their bound from it in code rather than hardcoding an assumption about
708
+ * its value. Keeps the "TTL must exceed the watcher's terminal-transition
709
+ * latency" invariant code-enforced: if an operator raises the cap via env, any
710
+ * derived TTL moves with it instead of falsely reaping a still-live worker.
711
+ */
712
+ export function resolveInflightTerminalCapMs(configVal?: number): number {
713
+ return configVal ?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS') ?? DEFAULT_INFLIGHT_TERMINAL_CAP_MS
714
+ }
638
715
  // Minimum wall-clock gap between two "terminal synthesis deferred" log lines
639
716
  // for the SAME worker (#3092). `checkStalls` runs on the ~1s rescan tick, and
640
717
  // the deferral is re-evaluated (and, before this gate, re-logged) on every one
@@ -1017,15 +1094,101 @@ export function readSubTail(
1017
1094
  progressLine?: string
1018
1095
  /** Live model this worker is running (see SubagentWatcherConfig.onProgress). */
1019
1096
  model?: string
1097
+ /** Growth-independent skeleton liveness cue (#3231). See the identically
1098
+ * named field on SubagentWatcherConfig.onProgress. */
1099
+ skeleton?: boolean
1020
1100
  }) => void,
1021
1101
  ): void {
1022
1102
  try {
1103
+ // Early-paint tick (Residual A): fire a parked opening narration whose
1104
+ // flush window has elapsed, WITHOUT waiting for a tool. Runs BEFORE the
1105
+ // `size === cursor` no-growth early-return below, so it fires on a quiet
1106
+ // poll (the "narrate, then think" gap) — the whole point of the time-box.
1107
+ // This function is the per-poll defensive read for every running entry, so
1108
+ // the deadline is observed within one poll tick of PENDING_NARRATIVE_FLUSH_MS.
1109
+ if (entry.narrativeGate != null) entry.narrativeGate.tick(now)
1023
1110
  const stat = fs.statSync(entry.filePath)
1024
1111
  if (stat.size < tail.cursor) {
1025
1112
  tail.cursor = 0
1026
1113
  tail.pendingPartial = ''
1027
1114
  }
1028
- if (stat.size === tail.cursor) return
1115
+ if (stat.size === tail.cursor) {
1116
+ // First-paint independence (#3231): the worker card is otherwise driven
1117
+ // ONLY by growth-triggered progress cues below, so a running worker whose
1118
+ // JSONL is not currently growing surfaces NOTHING. That is the ~90-205s
1119
+ // invisible-card bug observed live (a57fbf, 2026-07-13): an async
1120
+ // foreground sub-agent did two Bash calls, then its first tool BLOCKED for
1121
+ // ~99s (no JSONL growth → no cue), and — because its spawning turn had
1122
+ // already ended — no nest and no worker-feed row existed to paint. Its
1123
+ // card did not appear until 205s after registration, on the next growth
1124
+ // event that happened to be classified to the feed. Fire a growth-INDEPENDENT
1125
+ // skeleton liveness cue on every no-growth poll for a live entry so the
1126
+ // gateway can paint (and keep alive) the card from registration onward,
1127
+ // uniformly across ALL spawn origins/nesting levels. The cue carries the
1128
+ // entry's REAL current state (lastTool/toolCount/model) but an EMPTY step
1129
+ // line — no fabricated content: it is inert on the foreground-nest path
1130
+ // (empty child → no-op, the parent's own card owns the live turn) and
1131
+ // creates/refreshes the orphan/background worker-feed row (→ "starting…",
1132
+ // whose first paint the feed's own firstPaintMin + heartbeat then owns).
1133
+ if (onProgress != null && entry.state === 'running' && !entry.historical) {
1134
+ // Child-aware suppression (#3233): the skeleton cue exists to paint a
1135
+ // LEAF worker whose card would otherwise be invisible (the 205s
1136
+ // blackout). A pure-ORCHESTRATOR parent — one that has dispatched a
1137
+ // descendant of its own — must NOT earn a redundant "starting…"
1138
+ // liveness row: the child surfaces its own live row in the same worker
1139
+ // feed, so an extra skeleton row for the parent is pure feed clutter
1140
+ // (fails the no-noise / never-storm bar). The discriminator is
1141
+ // deliberately NOT "0 own tools" — a leaf that registers and BLOCKS on
1142
+ // its very first tool has 0 completed tools and MUST still paint.
1143
+ // Instead, suppress when THIS entry has EVER dispatched a child (any
1144
+ // child registry row keyed by parent_agent_id = this entry's jsonl
1145
+ // agentId; recordNestedSubagentDispatch stamps it). "Ever", not "a
1146
+ // currently-running child": the skeleton cue is only the NO-GROWTH
1147
+ // fallback, so suppressing it for an orchestrator never hides real
1148
+ // work — if the parent does its own tools, those fire real growth
1149
+ // cues and paint the row; if it only orchestrates, its children carry
1150
+ // the liveness. Using "currently running" instead would re-paint a
1151
+ // spurious orchestrator "starting…" the moment its child finished. A
1152
+ // genuine leaf has no child row at all, so it keeps firing the
1153
+ // skeleton cue and paints promptly — the 205s-blackout class is intact.
1154
+ let hasChild = false
1155
+ if (db != null) {
1156
+ try {
1157
+ const kid = db
1158
+ .prepare(
1159
+ 'SELECT 1 FROM subagents WHERE parent_agent_id = ? LIMIT 1',
1160
+ )
1161
+ .get(entry.agentId)
1162
+ hasChild = kid != null
1163
+ } catch (kidErr) {
1164
+ // Best-effort: an absent/failed linkage read is treated as "leaf"
1165
+ // so we never suppress a genuine blackout paint on a DB hiccup.
1166
+ log?.(`subagent-watcher: skeleton child-check error ${entry.agentId}: ${(kidErr as Error).message}`)
1167
+ }
1168
+ }
1169
+ if (!hasChild) {
1170
+ try {
1171
+ onProgress({
1172
+ agentId: entry.agentId,
1173
+ description: entry.description,
1174
+ latestSummary: '',
1175
+ elapsedMs: now - entry.dispatchedAt,
1176
+ prevBucketIdx: entry.lastProgressBucketIdx,
1177
+ setBucketIdx: (b: number) => {
1178
+ entry.lastProgressBucketIdx = b
1179
+ },
1180
+ lastTool: entry.lastTool,
1181
+ toolCount: entry.toolCount,
1182
+ model: entry.currentModel,
1183
+ skeleton: true,
1184
+ })
1185
+ } catch (cbErr) {
1186
+ log?.(`subagent-watcher: onProgress (skeleton) callback error ${entry.agentId}: ${(cbErr as Error).message}`)
1187
+ }
1188
+ }
1189
+ }
1190
+ return
1191
+ }
1029
1192
 
1030
1193
  const buf = Buffer.alloc(stat.size - tail.cursor)
1031
1194
  const fd = fs.openSync(entry.filePath, 'r')
@@ -1126,70 +1289,142 @@ export function readSubTail(
1126
1289
  if (errInfo.detail) entry.errorDetail = errInfo.detail.slice(0, SUBAGENT_RESULT_TEXT_MAX)
1127
1290
  }
1128
1291
  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
1292
+ // Narrative gate (JSONL-text-narrative primitive) — the SAME
1293
+ // `NarrativeFlushController` kernel the main-agent gateway path uses,
1294
+ // driven here by a POLL-driven scheduler so a worker's opening narration
1295
+ // paints EARLY (~the next poll after PENDING_NARRATIVE_FLUSH_MS) instead
1296
+ // of gating on its first tool. `show` fires the narrative onProgress cue
1297
+ // for a SHOWN block (`latestSummary` = entry.lastResultText, never tool
1298
+ // labels matching the historical wire shape); the kernel owns the
1299
+ // SHOW/SUPPRESS/early-paint/retract decisions (dedup lives in
1300
+ // narrative-dedup.ts, timer + retract in narrative-flush.ts).
1301
+ const buildNarrativeGate = (): WorkerNarrativeGate => {
1302
+ // Injected clock the kernel's scheduler reads. Refreshed each poll via
1303
+ // tick(now) so `deadline = nowRef.value + flushMs` uses the CURRENT
1304
+ // poll's `now` even though the kernel persists across polls.
1305
+ const nowRef = { value: now }
1306
+ // Whether the LAST kernel effect painted a narrative cue — read back by
1307
+ // resolveOnTool/resolveAtTurnEnd for the tool-label clobber guard.
1308
+ let cueFired = false
1309
+ const fireCue = (): boolean => {
1310
+ if (onProgress == null || entry.state !== 'running' || entry.historical) return false
1311
+ try {
1312
+ onProgress({
1313
+ agentId: entry.agentId,
1314
+ description: entry.description,
1315
+ latestSummary: entry.lastResultText,
1316
+ elapsedMs: nowRef.value - entry.dispatchedAt,
1317
+ prevBucketIdx: entry.lastProgressBucketIdx,
1318
+ setBucketIdx: (b: number) => {
1319
+ entry.lastProgressBucketIdx = b
1320
+ },
1321
+ lastTool: entry.lastTool,
1322
+ toolCount: entry.toolCount,
1323
+ model: entry.currentModel,
1324
+ })
1325
+ return true
1326
+ } catch (cbErr) {
1327
+ log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${(cbErr as Error).message}`)
1328
+ return false
1329
+ }
1330
+ }
1331
+ // Poll-driven scheduler: `arm` stamps a deadline off the injected clock;
1332
+ // the watcher's `tick` fires it once the poll clock reaches it — the
1333
+ // deterministic, clock-injected equivalent of the gateway's setTimeout.
1334
+ const scheduler = {
1335
+ armedFn: null as (() => void) | null,
1336
+ deadline: 0,
1337
+ }
1338
+ const controller = new NarrativeFlushController(
1339
+ {
1340
+ show: () => {
1341
+ cueFired = fireCue()
1146
1342
  },
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
1343
+ // Retract on the worker path is a documented near-no-op: unlike the
1344
+ // gateway (which splices a persisted narration mirror), the worker
1345
+ // card is replace-on-write and the worker's reply is a
1346
+ // Telegram-surface tool that `describeToolUse` never renders — so a
1347
+ // timer-painted block can never be DOUBLE-printed as both a card
1348
+ // step and the reply (the gateway's catastrophe is structurally
1349
+ // impossible here). The transient trail line self-heals via the
1350
+ // rolling window; the worker's true result rides
1351
+ // lastResultText/onFinish regardless of the gate. See DESIGN.md
1352
+ // "COVERAGE LIMIT" for the narrow >250ms-gap draft edge.
1353
+ retractShown: () => {
1354
+ log?.(
1355
+ `subagent-watcher: narrative early-paint retract (no-op on replace-on-write worker card) ${entry.agentId}`,
1356
+ )
1357
+ },
1358
+ },
1359
+ {
1360
+ arm: (fn, ms) => {
1361
+ scheduler.armedFn = fn
1362
+ scheduler.deadline = nowRef.value + ms
1363
+ },
1364
+ disarm: () => {
1365
+ scheduler.armedFn = null
1366
+ },
1367
+ },
1368
+ PENDING_NARRATIVE_FLUSH_MS,
1369
+ )
1370
+ return {
1371
+ tick: (n) => {
1372
+ nowRef.value = n
1373
+ if (scheduler.armedFn != null && n >= scheduler.deadline) {
1374
+ const fn = scheduler.armedFn
1375
+ scheduler.armedFn = null
1376
+ fn() // → onTimerFire → show → fireCue (EARLY paint, no tool needed)
1377
+ }
1378
+ },
1379
+ // stage/resolve run synchronously within a readSubTail call whose
1380
+ // clock was already refreshed onto nowRef by the top-of-function
1381
+ // `tick(now)` (or by buildNarrativeGate on the first block), so they
1382
+ // must NOT re-stamp nowRef with a captured (stale) `now`.
1383
+ stage: (text) => {
1384
+ controller.stage(text)
1385
+ },
1386
+ resolveOnTool: (toolName, input) => {
1387
+ cueFired = false
1388
+ // NIT 3 (turn_end symmetry) lives inside the kernel: it compares the
1389
+ // pending block against a REPLY_TOOL's input.text (tool path) and,
1390
+ // at turn_end, against the delivered reply text passed by
1391
+ // resolveAtTurnEnd. Here we only forward the tool lookahead.
1392
+ controller.resolveOnTool(toolName ?? '', input)
1393
+ return cueFired
1394
+ },
1395
+ resolveAtTurnEnd: (lastReplyText) => {
1396
+ cueFired = false
1397
+ controller.flushAtTurnEnd(lastReplyText)
1398
+ return cueFired
1399
+ },
1400
+ reset: () => {
1401
+ controller.teardown()
1402
+ scheduler.armedFn = null
1403
+ },
1155
1404
  }
1156
1405
  }
1157
1406
  // 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).
1407
+ // SUPPRESS only when the pending block drafts a reply/stream_reply tool's
1408
+ // text; otherwise SHOW (fire the cue). Two lookahead shapes:
1409
+ // - sub_agent_tool_use: forward the tool — the kernel suppresses a draft
1410
+ // of THIS tool's reply text (REPLY_TOOLS only).
1411
+ // - sub_agent_turn_end: `toolName` is null resolveAtTurnEnd with
1412
+ // entry.lastReplyText, so a FOREGROUND sub-agent's trailing draft of
1413
+ // its delivered answer is suppressed (background workers never set
1414
+ // lastReplyText, so their trailing narration still SHOWs).
1415
+ // Returns true iff a narrative onProgress cue actually fired callers use
1416
+ // this to skip a redundant/clobbering tool-label onProgress cue for the
1417
+ // SAME tick (the tool-description onProgress's replace-on-write always
1418
+ // wins, so firing both back-to-back hides the narration).
1178
1419
  const resolvePendingSubNarrative = (
1179
1420
  toolName: string | null,
1180
1421
  toolInput: Record<string, unknown> | undefined,
1181
1422
  ): 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
1423
+ if (entry.narrativeGate == null) return false
1424
+ if (toolName == null) {
1425
+ return entry.narrativeGate.resolveAtTurnEnd(entry.lastReplyText ?? '')
1191
1426
  }
1192
- return fireNarrativeProgress()
1427
+ return entry.narrativeGate.resolveOnTool(toolName, toolInput)
1193
1428
  }
1194
1429
  for (const ev of events) {
1195
1430
  const idleSecBeforeBump = Math.round((now - entry.lastActivityAt) / 1000)
@@ -1396,18 +1631,18 @@ export function readSubTail(
1396
1631
  // args or file content — consistent with the watcher's
1397
1632
  // "descriptions only" privacy posture.
1398
1633
  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 }
1634
+ // #1720 + JSONL-text-narrative gate step 1: stage this block for one
1635
+ // lookahead step (AND arm the early-paint timer) instead of firing
1636
+ // the progress cue immediately. The kernel's `stage` SHOWs any prior
1637
+ // pending block (it had nothing reply-shaped after it pure
1638
+ // narration) then parks THIS one; its eventual SHOW/SUPPRESS is
1639
+ // decided by the next sub_agent_tool_use / sub_agent_turn_end, or by
1640
+ // the early-paint timer if neither arrives within the flush window.
1641
+ // `lastResultText` / `lastSummaryLine` above already updated
1642
+ // unconditionally the handback payload is independent of the
1643
+ // progress-cue decision. Gate is built lazily on the first block.
1644
+ if (entry.narrativeGate == null) entry.narrativeGate = buildNarrativeGate()
1645
+ entry.narrativeGate.stage(ev.text)
1411
1646
  } else if (ev.kind === 'sub_agent_tool_result') {
1412
1647
  // The tool call completed — clear it from the in-flight set so
1413
1648
  // the terminal-synthesis gate re-opens. Idempotent: a result
@@ -1510,10 +1745,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1510
1745
  config.silentStallTerminalMs
1511
1746
  ?? parseEnvMs('SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS')
1512
1747
  ?? 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
1748
+ const inflightTerminalCapMs = resolveInflightTerminalCapMs(config.inflightTerminalCapMs)
1517
1749
  const deferralLogIntervalMs =
1518
1750
  config.deferralLogIntervalMs
1519
1751
  ?? parseEnvMs('SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS')
@@ -1862,7 +2094,11 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1862
2094
  // worker's card reaches real activity instead of a frozen stub.
1863
2095
  entry.toolCount = 0
1864
2096
  entry.lastTool = null
1865
- entry.pendingNarrative = null
2097
+ // Cancel any armed early-paint timer + drop pending state before the
2098
+ // from-scratch replay so a stale parked block can't fire against the
2099
+ // rebuilt cursor. Rebuilt lazily on the first replayed sub_agent_text.
2100
+ entry.narrativeGate?.reset()
2101
+ entry.narrativeGate = null
1866
2102
  tail.cursor = 0
1867
2103
  tail.pendingPartial = ''
1868
2104
  tail.hasEmittedStart = false
@@ -2012,6 +2248,18 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
2012
2248
  }
2013
2249
  terminatedAgentIds.add(agentId)
2014
2250
  log?.(`subagent-watcher: cleaned up terminal agent ${agentId}`)
2251
+ // Authoritative terminal sweep → notify the worker-activity feed so its row
2252
+ // for this agent is removed even on the paths that never fire `onFinish`
2253
+ // (JSONL vanished, boot done-at-boot orphan). Without this the feed row
2254
+ // leaks and the shared card goes immortal/unpinned (worker-feed ghost leak).
2255
+ // Best-effort: a callback throw must never wedge the watcher's cleanup.
2256
+ if (config.onTerminalCleanup) {
2257
+ try {
2258
+ config.onTerminalCleanup(agentId)
2259
+ } catch (cbErr) {
2260
+ log?.(`subagent-watcher: onTerminalCleanup callback error ${agentId}: ${(cbErr as Error).message}`)
2261
+ }
2262
+ }
2015
2263
  }
2016
2264
 
2017
2265
  // ─── Card resurrection (issue #3023) ─────────────────────────────────────