switchroom 0.15.45 → 0.16.5

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +56 -15
  2. package/dist/auth-broker/index.js +383 -97
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +7 -4
  5. package/dist/cli/notion-write-pretool.mjs +35 -4
  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/switchroom.js +2894 -841
  9. package/dist/host-control/main.js +2685 -207
  10. package/dist/vault/approvals/kernel-server.js +7453 -7413
  11. package/dist/vault/broker/server.js +11428 -11388
  12. package/examples/minimal.yaml +1 -0
  13. package/examples/switchroom.yaml +1 -0
  14. package/package.json +3 -3
  15. package/profiles/_base/start.sh.hbs +97 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  17. package/profiles/default/CLAUDE.md.hbs +0 -19
  18. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  19. package/telegram-plugin/answer-stream-flag.ts +12 -49
  20. package/telegram-plugin/answer-stream.ts +5 -150
  21. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  22. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  23. package/telegram-plugin/context-exhaustion.ts +12 -0
  24. package/telegram-plugin/demo-mask.ts +154 -0
  25. package/telegram-plugin/dist/bridge/bridge.js +55 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +2938 -977
  27. package/telegram-plugin/dist/server.js +55 -12
  28. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  29. package/telegram-plugin/draft-stream.ts +47 -410
  30. package/telegram-plugin/final-answer-detect.ts +17 -12
  31. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  32. package/telegram-plugin/format.ts +56 -19
  33. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  34. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  35. package/telegram-plugin/gateway/auth-command.ts +70 -14
  36. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  37. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  38. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  39. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  40. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  41. package/telegram-plugin/gateway/effort-command.ts +8 -3
  42. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  43. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  44. package/telegram-plugin/gateway/gateway.ts +1857 -292
  45. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  46. package/telegram-plugin/gateway/model-command.ts +115 -4
  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-command.test.ts +134 -0
  103. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  104. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  105. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  106. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  107. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  108. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  109. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  110. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  112. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  113. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  114. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  115. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  116. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  117. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  118. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  119. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  120. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  121. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  122. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  123. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  124. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  125. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  126. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  127. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  128. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  129. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  130. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  131. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  132. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  133. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  134. package/telegram-plugin/tool-activity-summary.ts +375 -58
  135. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  136. package/telegram-plugin/uat/assertions.ts +115 -0
  137. package/telegram-plugin/uat/driver.ts +68 -0
  138. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  139. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  145. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  146. package/telegram-plugin/welcome-text.ts +13 -1
  147. package/telegram-plugin/worker-activity-feed.ts +157 -82
  148. package/telegram-plugin/draft-transport.ts +0 -122
  149. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  150. package/telegram-plugin/tests/draft-transport.test.ts +0 -211
@@ -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
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * M-2: `activityEverOpened` sticky-true invariant — structural assertion.
3
+ *
4
+ * `activityEverOpened` is set to `true` exactly once, when the activity feed
5
+ * posts its first message (the `sendMessage` path in `drainActivitySummary`).
6
+ * It must NEVER be reset to false or cleared — unlike `activityMessageId`, which
7
+ * is nulled by `clearActivitySummary` to indicate that the persistent message was
8
+ * finalized/deleted. The sticky-true invariant lets the turn-end DEGRADED check
9
+ * (`detectStatusSurfaceDegraded`) distinguish "feed never opened" (the
10
+ * resume-400 signature) from "feed opened + finalized".
11
+ *
12
+ * Load-bearing constraints:
13
+ * 1. `activityEverOpened = true` is set exactly ONCE in gateway.ts (at the
14
+ * send-message success site in drainActivitySummary).
15
+ * 2. `turn.activityEverOpened = false` NEVER appears in gateway.ts (it is only
16
+ * initialised to `false` in the turn-initialiser object literal, never reset
17
+ * via a standalone assignment).
18
+ *
19
+ * These are STRUCTURAL (source-read) assertions. Pattern: silence-liveness-wiring.test.ts.
20
+ */
21
+ import { describe, it, expect } from 'vitest'
22
+ import { readFileSync } from 'node:fs'
23
+ import { resolve } from 'node:path'
24
+
25
+ const gatewaySrc = readFileSync(
26
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
27
+ 'utf-8',
28
+ )
29
+
30
+ describe('M-2: activityEverOpened sticky-true invariant', () => {
31
+ it('activityEverOpened = true appears exactly once (set at send-message success)', () => {
32
+ const setTrueMatches = [...gatewaySrc.matchAll(/activityEverOpened\s*=\s*true/g)]
33
+ expect(setTrueMatches).toHaveLength(1)
34
+ })
35
+
36
+ it('turn.activityEverOpened = false never appears (no standalone reset)', () => {
37
+ // The only `false` value must be in the object literal initialiser
38
+ // (e.g. `activityEverOpened: false`), never a standalone reassignment.
39
+ const resetMatches = [...gatewaySrc.matchAll(/turn\.activityEverOpened\s*=\s*false/g)]
40
+ expect(resetMatches).toHaveLength(0)
41
+ })
42
+
43
+ it('activityEverOpened is initialised false in the turn object literal (per-turn reset)', () => {
44
+ // The object literal form `activityEverOpened: false` must exist (per-turn init).
45
+ expect(gatewaySrc).toMatch(/activityEverOpened:\s*false/)
46
+ })
47
+ })
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
27
- import { createAnswerStream, __resetDraftIdForTests } from '../answer-stream.js'
27
+ import { createAnswerStream } from '../answer-stream.js'
28
28
 
29
29
  // ─── Helpers ──────────────────────────────────────────────────────────────────
30
30
 
@@ -47,7 +47,6 @@ function makeEditMessageText() {
47
47
  }
48
48
 
49
49
  beforeEach(() => {
50
- __resetDraftIdForTests()
51
50
  nextMessageId = 2000
52
51
  vi.useFakeTimers()
53
52
  })
@@ -70,9 +69,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
70
69
  const onMetric = vi.fn()
71
70
 
72
71
  const stream = createAnswerStream({
73
- chatId: 'chat646',
74
- isPrivateChat: false,
75
- minInitialChars: 0,
72
+ chatId: 'chat646', minInitialChars: 0,
76
73
  throttleMs: 250,
77
74
  sendMessage,
78
75
  editMessageText,
@@ -112,9 +109,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
112
109
  const log = vi.fn()
113
110
 
114
111
  const stream = createAnswerStream({
115
- chatId: 'chat646',
116
- isPrivateChat: false,
117
- minInitialChars: 0,
112
+ chatId: 'chat646', minInitialChars: 0,
118
113
  throttleMs: 250,
119
114
  sendMessage,
120
115
  editMessageText,
@@ -158,9 +153,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
158
153
  const editMessageText = makeEditMessageText()
159
154
 
160
155
  const stream = createAnswerStream({
161
- chatId: 'chat646',
162
- isPrivateChat: false,
163
- minInitialChars: 0,
156
+ chatId: 'chat646', minInitialChars: 0,
164
157
  throttleMs: 250,
165
158
  sendMessage,
166
159
  editMessageText,
@@ -185,9 +178,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
185
178
  const recordDedup = vi.fn()
186
179
 
187
180
  const stream = createAnswerStream({
188
- chatId: 'chat646',
189
- isPrivateChat: false,
190
- minInitialChars: 0,
181
+ chatId: 'chat646', minInitialChars: 0,
191
182
  throttleMs: 250,
192
183
  sendMessage: sendMessage as never,
193
184
  editMessageText,
@@ -229,9 +220,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
229
220
  const editMessageText = makeEditMessageText()
230
221
 
231
222
  const stream = createAnswerStream({
232
- chatId: 'chat646',
233
- isPrivateChat: false,
234
- minInitialChars: 0,
223
+ chatId: 'chat646', minInitialChars: 0,
235
224
  throttleMs: 250,
236
225
  sendMessage,
237
226
  editMessageText,
@@ -276,9 +265,7 @@ describe('answer-stream materialize() — recordOutbound callback (#648)', () =>
276
265
  const recordOutbound = vi.fn()
277
266
 
278
267
  const stream = createAnswerStream({
279
- chatId: 'chat648',
280
- isPrivateChat: false,
281
- minInitialChars: 0,
268
+ chatId: 'chat648', minInitialChars: 0,
282
269
  throttleMs: 250,
283
270
  sendMessage,
284
271
  editMessageText,
@@ -303,9 +290,7 @@ describe('answer-stream materialize() — recordOutbound callback (#648)', () =>
303
290
  const recordOutbound = vi.fn()
304
291
 
305
292
  const stream = createAnswerStream({
306
- chatId: 'chat648',
307
- isPrivateChat: false,
308
- minInitialChars: 0,
293
+ chatId: 'chat648', minInitialChars: 0,
309
294
  throttleMs: 250,
310
295
  sendMessage,
311
296
  editMessageText,
@@ -331,9 +316,7 @@ describe('answer-stream materialize() — recordOutbound callback (#648)', () =>
331
316
  const recordOutbound = vi.fn()
332
317
 
333
318
  const stream = createAnswerStream({
334
- chatId: 'chat648',
335
- isPrivateChat: false,
336
- minInitialChars: 0,
319
+ chatId: 'chat648', minInitialChars: 0,
337
320
  throttleMs: 250,
338
321
  sendMessage: sendMessage as never,
339
322
  editMessageText,