switchroom 0.18.32 → 0.19.0

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 (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -186,12 +186,20 @@ describe('coalesced worker feed — one message per chat under load', () => {
186
186
  // would shed ~ (N-1)/N of every second's cosmetic edits.
187
187
  expect(gate.stats().global.shed).toBeLessThanOrEqual(3)
188
188
 
189
- // Liveness: the LAST landed edit carries EVERY worker's latest step all
190
- // rows refreshed together in the one message within one edit cycle.
189
+ // Liveness + bounded card (#3349): under Ken's curve every SHOWN worker
190
+ // keeps depth 3, so a 15-way storm cannot render every row — the 24-line
191
+ // body ceiling collapses the newest rows into a `+N more working…` spill.
192
+ // The last landed edit still carries the VISIBLE workers' latest steps
193
+ // (refreshed together in the one message) and a spill marker for the rest.
191
194
  const lastBody = edits[edits.length - 1].text
192
- for (const id of ids) {
193
- expect(lastBody).toContain(latest[id])
195
+ // 6 rows fit the 24-line budget (6·(1 header + 3 depth) = 24); the oldest
196
+ // rows (w0…w5) stay visible, the 9 newest spill.
197
+ for (let i = 0; i < 6; i++) {
198
+ expect(lastBody).toContain(latest[`w${i}`])
194
199
  }
200
+ expect(lastBody).toContain('more working')
201
+ // Still exactly ONE message — the spill is a render detail, not a new send.
202
+ expect(sends.length).toBe(1)
195
203
  })
196
204
 
197
205
  it('admits a critical reply mid-storm (never shed or starved by the cosmetic feed)', async () => {
@@ -722,21 +730,23 @@ describe('renderCombinedWorkerFeed (pure)', () => {
722
730
  expect(body).toContain('b first')
723
731
  })
724
732
 
725
- it('degrades to ONE history line per worker at a large fan-out and stays within the body budget', () => {
733
+ it('holds the MIN_WORKER_DEPTH (3) floor per shown worker at a large fan-out and stays within the body budget (#3349)', () => {
734
+ // 6 workers each with a deep (5-line) history. Ken's curve pins depth=3 at
735
+ // 4+ workers, so every SHOWN worker paints its last 3 steps. 6·(1 header +
736
+ // 3 history) = 24 lines = MAX_COMBINED_BODY_LINES, so all 6 stay visible.
726
737
  const rows = Array.from({ length: 6 }, (_, i) =>
727
- rowH(i, [`w${i} oldest`, `w${i} middle`, `w${i} newest`]),
738
+ rowH(i, [`w${i} s1`, `w${i} s2`, `w${i} s3`, `w${i} s4`, `w${i} s5`]),
728
739
  )
729
740
  const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
730
- // Only the newest step of each worker survives — the earlier lines are
731
- // dropped by the per-worker depth clamp (floor((13-6)/6)=1).
732
741
  for (let i = 0; i < 6; i++) {
733
- expect(body).toContain(current(`w${i} newest`))
734
- expect(body).not.toContain(`w${i} oldest`)
735
- expect(body).not.toContain(`w${i} middle`)
742
+ // Last 3 steps shown; the two oldest dropped by the depth-3 window.
743
+ expect(body).toContain(current(`w${i} s5`))
744
+ expect(body).toContain(struck(`w${i} s4`))
745
+ expect(body).toContain(struck(`w${i} s3`))
746
+ expect(body).not.toContain(`w${i} s1`)
747
+ expect(body).not.toContain(`w${i} s2`)
736
748
  }
737
- // Total body lines (worker headers + history) stay within the budget: 6
738
- // header lines + 6 history lines = 12 ≤ MAX_COMBINED_BODY_LINES (13). Count
739
- // only the per-worker body lines (exclude the top count line + any spill).
749
+ // Body lines (worker headers + history) stay within the re-derived budget.
740
750
  const bodyLines = body
741
751
  .split('\n')
742
752
  .map((l) => l.trim())
@@ -744,15 +754,43 @@ describe('renderCombinedWorkerFeed (pure)', () => {
744
754
  const headerAndHistory = bodyLines.filter(
745
755
  (l) => !l.startsWith('🛠') && !l.includes('more working'),
746
756
  )
747
- expect(headerAndHistory.length).toBeLessThanOrEqual(13)
757
+ expect(headerAndHistory.length).toBeLessThanOrEqual(24)
748
758
  })
749
759
 
750
- it('exposes the deterministic depth formula (2→5, 3→3, 4→2, 6→1)', () => {
760
+ it('drops NEWEST (trailing) rows into the spill when the fan-out overflows the 24-line body budget, keeping per-worker depth at 3 (#3349)', () => {
761
+ // 8 workers × (1 header + 3 depth) = 32 > 24 → the backstop collapses the 2
762
+ // newest (trailing) visible rows into `+M more working…`, so 6 rows render at full
763
+ // depth-3 (24 lines) rather than every worker losing a step.
764
+ const rows = Array.from({ length: 8 }, (_, i) =>
765
+ rowH(i, [`w${i} s1`, `w${i} s2`, `w${i} s3`]),
766
+ )
767
+ const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
768
+ expect(body).toContain('more working')
769
+ const bodyLines = body
770
+ .split('\n')
771
+ .map((l) => l.trim())
772
+ .filter((l) => l.length > 0)
773
+ const headerAndHistory = bodyLines.filter(
774
+ (l) => !l.startsWith('🛠') && !l.includes('more working'),
775
+ )
776
+ expect(headerAndHistory.length).toBeLessThanOrEqual(24)
777
+ expect(body).toContain('+2 more working')
778
+ // The 6 kept rows render at full depth-3 (last row kept shows all 3 steps).
779
+ expect(body).toContain(current('w5 s3'))
780
+ expect(body).toContain(struck('w5 s2'))
781
+ expect(body).toContain(struck('w5 s1'))
782
+ // The 2 trailing rows spilled — none of worker 7's lines render.
783
+ expect(body).not.toContain('w7 s3')
784
+ })
785
+
786
+ it("exposes Ken's deterministic depth curve max(3, 7−w): 1→6, 2→5, 3→4, 4→3, 5+→3 (#3349)", () => {
787
+ expect(combinedHistoryDepth(1)).toBe(6)
751
788
  expect(combinedHistoryDepth(2)).toBe(5)
752
- expect(combinedHistoryDepth(3)).toBe(3)
753
- expect(combinedHistoryDepth(4)).toBe(2)
754
- expect(combinedHistoryDepth(6)).toBe(1)
755
- expect(combinedHistoryDepth(8)).toBe(1)
789
+ expect(combinedHistoryDepth(3)).toBe(4)
790
+ expect(combinedHistoryDepth(4)).toBe(3)
791
+ expect(combinedHistoryDepth(5)).toBe(3)
792
+ expect(combinedHistoryDepth(6)).toBe(3)
793
+ expect(combinedHistoryDepth(8)).toBe(3)
756
794
  })
757
795
 
758
796
  // ── Worker numbering (#3298): stable ordinal prefix at 2+ workers ──────────
@@ -798,6 +836,58 @@ describe('renderCombinedWorkerFeed (pure)', () => {
798
836
  })
799
837
  })
800
838
 
839
+ // ── #3349: Ken's per-worker depth curve 6/5/4/3, exact rendered depth ─────────
840
+ describe("Ken's per-worker step-trail depth curve (#3349)", () => {
841
+ // A worker with a DEEP history (8 lines) so the depth curve — not the buffer —
842
+ // is always the binding limit. Each history line is uniquely tokenised
843
+ // (`w{i}-s{n}`) so we can count exactly how many of a worker's steps rendered.
844
+ const deepRow = (i: number) => ({
845
+ description: `task w${i}`,
846
+ elapsedMs: 12_000 + i * 1000,
847
+ toolCount: 3,
848
+ ordinal: i + 1,
849
+ currentStep: `w${i}-s8`,
850
+ historyLines: Array.from({ length: 8 }, (_, n) => `w${i}-s${n + 1}`),
851
+ })
852
+ // Count how many of worker `i`'s step tokens appear in the rendered body.
853
+ const shownSteps = (body: string, i: number): number =>
854
+ (body.match(new RegExp(`w${i}-s\\d`, 'g')) ?? []).length
855
+
856
+ it.each([
857
+ [1, 6],
858
+ [2, 5],
859
+ [3, 4],
860
+ [4, 3],
861
+ [5, 3],
862
+ ])('%i worker(s) → exactly %i steps rendered per worker', (workers, expectedDepth) => {
863
+ const rows = Array.from({ length: workers }, (_, i) => deepRow(i))
864
+ const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
865
+ expect(combinedHistoryDepth(workers)).toBe(expectedDepth)
866
+ for (let i = 0; i < workers; i++) {
867
+ expect(shownSteps(body, i)).toBe(expectedDepth)
868
+ }
869
+ // Newest step last (bottom-anchored → bold), oldest rendered step struck.
870
+ const worker0 = `**→ w0-s8**`
871
+ expect(body).toContain(worker0)
872
+ })
873
+
874
+ it('a worker with FEWER steps than the depth shows all it has, no padding', () => {
875
+ // 3 workers → depth 4, but this worker only has 2 steps: render both, no more.
876
+ const rows = [
877
+ { description: 'shallow', elapsedMs: 12_000, toolCount: 1, ordinal: 1,
878
+ currentStep: 'only-b', historyLines: ['only-a', 'only-b'] },
879
+ deepRow(1),
880
+ deepRow(2),
881
+ ]
882
+ const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
883
+ expect(combinedHistoryDepth(3)).toBe(4)
884
+ expect(body).toContain('~~_✓ only-a_~~')
885
+ expect(body).toContain('**→ only-b**')
886
+ // Deep workers still get their full 4.
887
+ expect(shownSteps(body, 1)).toBe(4)
888
+ })
889
+ })
890
+
801
891
  /**
802
892
  * Worker-feed ghost-leak (immortal/unpinned/buried card) — outcome tests.
803
893
  *
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Issue #3373 follow-up (PR #3376 adversarial review) — guards for the
3
+ * SendMessage-resume → feed re-surface seam.
4
+ *
5
+ * The #3376 fix has three links: the watcher fires `onResume` on jsonl growth
6
+ * past a genuine terminal (pinned by subagent-watcher-resume-reregister.test.ts),
7
+ * the gateway's `onResume` handler delegates to `handleWorkerResume`, and
8
+ * `handleWorkerResume` calls `feed.resurrect(agentId)` to clear the terminal
9
+ * `finalized` latch. The review found the middle + last links untested: deleting
10
+ * the gateway handler (or gutting its body) left every test green. This file
11
+ * closes both:
12
+ *
13
+ * 1. UNIT — `handleWorkerResume` calls `feed.resurrect` with the agentId,
14
+ * never throws when resurrect throws or the feed is absent, and always
15
+ * emits the re-surface audit line.
16
+ * 2. SOURCE SCAN (mirrors permission-verdict-resume-guard.test.ts) — the
17
+ * gateway wires an `onResume:` callback into the watcher config AND that
18
+ * callback delegates to `handleWorkerResume`. Deleting the handler, or
19
+ * replacing the delegation with a no-op body, reds this suite.
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest'
23
+ import { readFileSync } from 'node:fs'
24
+ import { fileURLToPath } from 'node:url'
25
+ import { dirname, resolve } from 'node:path'
26
+ import { handleWorkerResume } from '../gateway/worker-feed-dispatch.js'
27
+
28
+ describe('handleWorkerResume (issue #3373 seam unit)', () => {
29
+ it('calls feed.resurrect with the agentId and logs the re-surface line', () => {
30
+ const calls: string[] = []
31
+ const logs: string[] = []
32
+ handleWorkerResume({ resurrect: (id) => calls.push(id) }, 'w1', (m) => logs.push(m))
33
+ expect(calls).toEqual(['w1'])
34
+ expect(logs.some((l) => l.includes('w1') && l.includes('RE-SURFACED'))).toBe(true)
35
+ })
36
+
37
+ it('a throwing resurrect is logged, never propagated (watcher poll loop safety)', () => {
38
+ const logs: string[] = []
39
+ expect(() =>
40
+ handleWorkerResume(
41
+ { resurrect: () => { throw new Error('boom') } },
42
+ 'w2',
43
+ (m) => logs.push(m),
44
+ ),
45
+ ).not.toThrow()
46
+ expect(logs.some((l) => l.includes('boom') && l.includes('w2'))).toBe(true)
47
+ // The audit line still lands after the failure.
48
+ expect(logs.some((l) => l.includes('RE-SURFACED'))).toBe(true)
49
+ })
50
+
51
+ it('a null/undefined feed (feed disabled) is a safe no-op that still audits', () => {
52
+ const logs: string[] = []
53
+ expect(() => handleWorkerResume(null, 'w3', (m) => logs.push(m))).not.toThrow()
54
+ expect(logs.some((l) => l.includes('w3') && l.includes('RE-SURFACED'))).toBe(true)
55
+ })
56
+ })
57
+
58
+ describe('gateway onResume wiring (issue #3373 source-scan guard)', () => {
59
+ const __dirname = dirname(fileURLToPath(import.meta.url))
60
+ const GATEWAY_SRC = readFileSync(
61
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
62
+ 'utf8',
63
+ )
64
+
65
+ it('the watcher config carries an onResume callback', () => {
66
+ expect(/\bonResume:\s*\(/.test(GATEWAY_SRC)).toBe(true)
67
+ })
68
+
69
+ it('the onResume callback delegates to handleWorkerResume (not an inline no-op)', () => {
70
+ // Match the `onResume: (…) => { … }` callback body and require the
71
+ // delegation call inside it. A gutted handler (empty body / dropped
72
+ // resurrect) fails here even though tsc stays green.
73
+ const m = GATEWAY_SRC.match(/\bonResume:\s*\([^)]*\)\s*=>\s*\{([\s\S]*?)\n\s{14}\},/)
74
+ expect(m, 'onResume callback not found in gateway.ts').not.toBeNull()
75
+ expect(m![1]).toMatch(/\bhandleWorkerResume\s*\(/)
76
+ expect(m![1]).toMatch(/\bworkerActivityFeed\b/)
77
+ })
78
+
79
+ it('handleWorkerResume is imported from worker-feed-dispatch (the tested seam)', () => {
80
+ expect(
81
+ /import\s*\{[^}]*\bhandleWorkerResume\b[^}]*\}\s*from\s*'\.\/worker-feed-dispatch\.js'/.test(
82
+ GATEWAY_SRC,
83
+ ),
84
+ ).toBe(true)
85
+ })
86
+ })
@@ -86,6 +86,7 @@ export function describeToolUse(
86
86
  import {
87
87
  STATUS_CARD_CHAR_BUDGET,
88
88
  STATUS_ROLLING_LINES,
89
+ WORKER_HISTORY_MAX,
89
90
  STATUS_LINE_MAX,
90
91
  NESTED_PREFIX,
91
92
  } from './status-no-truncate.js'
@@ -286,7 +287,8 @@ function escapeStepLine(raw: string): string {
286
287
 
287
288
  /**
288
289
  * Shared step-feed emitter. Appends `✓`/`→` bullet lines to `out` for the
289
- * given ALREADY-ESCAPED step strings, windowing to STATUS_ROLLING_LINES and
290
+ * given ALREADY-ESCAPED step strings, windowing to `window` (default
291
+ * STATUS_ROLLING_LINES; the worker surfaces pass a deeper window) and
290
292
  * prepending a `+N earlier…` header when the feed overflows the window (on
291
293
  * BOTH surfaces now). The worker feed imports this directly.
292
294
  *
@@ -301,9 +303,10 @@ export function renderStepFeed(
301
303
  steps: string[],
302
304
  allDone: boolean,
303
305
  liveSuffix = '',
306
+ window: number = STATUS_ROLLING_LINES,
304
307
  ): void {
305
308
  if (steps.length === 0) return
306
- const shown = steps.slice(-STATUS_ROLLING_LINES)
309
+ const shown = steps.slice(-Math.max(1, window))
307
310
  const hidden = steps.length - shown.length
308
311
  if (hidden > 0) out.push(`_✓ +${hidden} earlier…_`)
309
312
  const lastIdx = shown.length - 1
@@ -351,6 +354,13 @@ export interface StatusCardOpts {
351
354
  stepCount?: number
352
355
  /** Optional terminal result block (worker recap), already-cleaned text + emoji. */
353
356
  result?: { emoji: string; text: string }
357
+ /**
358
+ * How many trailing step/child lines the rolling window shows. Defaults to
359
+ * STATUS_ROLLING_LINES (the 🤖 agent card). The 🛠 single-worker card passes
360
+ * a deeper window (`workerHistoryDepth(1)` = 6) so a lone worker can show its
361
+ * full recent trail — see WORKER_HISTORY_MAX.
362
+ */
363
+ historyWindow?: number
354
364
  }
355
365
 
356
366
  /**
@@ -364,6 +374,7 @@ export interface StatusCardOpts {
364
374
  */
365
375
  export function renderStatusCard(opts: StatusCardOpts): string | null {
366
376
  const { header, final = false, liveSuffix = '', stepCount, result } = opts
377
+ const window = Math.max(1, opts.historyWindow ?? STATUS_ROLLING_LINES)
367
378
  const rawSteps = opts.steps.filter((s) => s != null)
368
379
  const rawChildren = (opts.childSteps ?? []).map((s) => s.trim()).filter((s) => s.length > 0)
369
380
  const hasChildren = rawChildren.length > 0
@@ -393,12 +404,12 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
393
404
 
394
405
  if (hasChildren) {
395
406
  // Parent lines all render done — the live → step lives in the nested block.
396
- const shownParent = steps.slice(-STATUS_ROLLING_LINES)
407
+ const shownParent = steps.slice(-window)
397
408
  const hiddenParent = steps.length - shownParent.length
398
409
  if (hiddenParent > 0) out.push(`_✓ +${hiddenParent} earlier…_`)
399
410
  for (const s of shownParent) out.push(`~~_✓ ${s}_~~`)
400
411
  // Child block.
401
- const shownChild = children.slice(-STATUS_ROLLING_LINES)
412
+ const shownChild = children.slice(-window)
402
413
  const hiddenChild = children.length - shownChild.length
403
414
  if (hiddenChild > 0) out.push(`${NESTED_PREFIX}_+${hiddenChild} earlier…_`)
404
415
  const lastChildIdx = shownChild.length - 1
@@ -410,7 +421,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
410
421
  )
411
422
  })
412
423
  } else {
413
- renderStepFeed(out, steps, final, liveSuffix)
424
+ renderStepFeed(out, steps, final, liveSuffix, window)
414
425
  }
415
426
 
416
427
  if (final && stepCount != null && stepCount > 0) {
@@ -659,38 +670,65 @@ export interface CombinedWorkerRow {
659
670
 
660
671
  export interface CombinedWorkerFeedOpts {
661
672
  /** Max worker rows rendered before the `+M more working…` spill line.
662
- * The overflow is ordered oldest-hidden-first (the newest/most-recently
663
- * active workers stay visible). */
673
+ * Rows are kept head-first (`rows.slice(0, visibleCount)`): the earliest
674
+ * supplied (oldest dispatch-order) workers stay visible and the
675
+ * newest/trailing rows spill. */
664
676
  maxRows: number
665
677
  }
666
678
 
679
+ /** Each visible worker costs one header line before any history. */
680
+ const PER_WORKER_HEADER_COST = 1
681
+
682
+ /**
683
+ * Floor of the per-worker depth curve — every SHOWN worker renders at least
684
+ * this many recent steps, no matter how large the fan-out (Ken's "4+ → 3 each").
685
+ */
686
+ const MIN_WORKER_DEPTH = 3
687
+
688
+ /**
689
+ * Design fan-out: the largest concurrent-worker count whose full Ken curve is
690
+ * allowed to render before the total-line backstop starts collapsing the
691
+ * NEWEST (trailing) rows into `+M more working…`. Chosen at 6 — beyond six live workers a
692
+ * per-worker trail is no longer a glanceable card, so extra rows spill rather
693
+ * than every shown worker losing depth. Every worker that IS shown keeps its
694
+ * full curve depth; the ceiling trims row COUNT, never per-worker depth.
695
+ */
696
+ const DESIGN_FANOUT = 6
697
+
667
698
  /**
668
699
  * Total per-worker BODY line budget for the combined feed — the sum, across all
669
700
  * visible workers, of (one header line + that worker's history lines). The top
670
701
  * `🛠 Workers · N running` line and the `+M more working…` spill are OUTSIDE
671
- * this budget (fixed chrome). 13 is chosen so the card stays a compact glance,
672
- * not a wall: at 2 workers it yields the full 5-line history each (2·1 header +
673
- * 2·5 history = 12 ≤ 13), and it degrades to a single history line each by ~6
674
- * workers matching the pre-adaptive one-line-per-worker floor while never
675
- * letting a 2–3 worker fan-out lose its narrative trail.
702
+ * this budget (fixed chrome).
703
+ *
704
+ * Re-derived (#3349) to fit Ken's per-worker curve `max(3, 7 w)` rather than
705
+ * to DRIVE the depth: at the flat tail (w 4, depth = MIN_WORKER_DEPTH) each
706
+ * worker costs `PER_WORKER_HEADER_COST + MIN_WORKER_DEPTH` = 4 lines, so a
707
+ * DESIGN_FANOUT of 6 fully-rendered workers needs 6 × 4 = 24 lines. That fully
708
+ * fits every fan-out through 6 workers (w1=7, w2=12, w3=15, w4=16, w5=20,
709
+ * w6=24); a 7th/8th concurrent worker overflows and the backstop drops the
710
+ * newest (trailing) visible rows to the spill — bounding the card at 24 body lines so a
711
+ * big swarm never explodes it. The per-worker curve wins on DEPTH; this ceiling
712
+ * wins on ROW COUNT.
676
713
  */
677
- const MAX_COMBINED_BODY_LINES = 13
678
- /** Each visible worker costs one header line before any history. */
679
- const PER_WORKER_HEADER_COST = 1
714
+ const MAX_COMBINED_BODY_LINES = DESIGN_FANOUT * (PER_WORKER_HEADER_COST + MIN_WORKER_DEPTH)
680
715
 
681
716
  /**
682
717
  * Deterministic per-worker history depth for `w` visible workers:
683
- * clamp( floor( (BUDGETheaderCost·w) / w ), 1, STATUS_ROLLING_LINES )
684
- * So 2 workers5 lines each, 3 → 3, 4 → 2, ≥61 (today's single-line floor
685
- * is the graceful-degradation floor, never below it). Pure function of the
686
- * visible worker count no model input, consistent with deterministic controls.
718
+ * clamp( max(MIN_WORKER_DEPTH, 7 − w), 1, WORKER_HISTORY_MAX )
719
+ * So 1 worker6, 2 → 5, 3 → 4, 4 → 3, ≥43 (Ken's 6/5/4/3 curve, #3349).
720
+ * Pure function of the visible worker count no model input, consistent with
721
+ * deterministic controls. Replaces the former budget-driven divide (which
722
+ * yielded 5/5/3/2/… and could never reach 6 for a lone worker).
687
723
  */
688
724
  export function combinedHistoryDepth(w: number): number {
689
725
  if (w <= 0) return 1
690
- const raw = Math.floor((MAX_COMBINED_BODY_LINES - PER_WORKER_HEADER_COST * w) / w)
691
- return Math.max(1, Math.min(STATUS_ROLLING_LINES, raw))
726
+ return Math.min(WORKER_HISTORY_MAX, Math.max(MIN_WORKER_DEPTH, 7 - w))
692
727
  }
693
728
 
729
+ /** Alias for readability at the single-worker call site — same curve. */
730
+ export const workerHistoryDepth = combinedHistoryDepth
731
+
694
732
  /**
695
733
  * Render N≥1 live workers into ONE combined feed body (ready Telegram
696
734
  * markdown; callers send verbatim — do NOT re-escape). Layout:
@@ -720,7 +758,7 @@ export function combinedHistoryDepth(w: number): number {
720
758
  * Pure. Rows are rendered in the order supplied (the manager passes them
721
759
  * dispatch-order, oldest first). `maxRows` caps the visible rows; the hidden
722
760
  * remainder collapses to a single `+M more working…` line. A total-budget
723
- * backstop drops the OLDEST visible rows one at a time (growing the spill)
761
+ * backstop drops the NEWEST (trailing) visible rows one at a time (growing the spill)
724
762
  * until the body fits STATUS_CARD_CHAR_BUDGET, so a burst of long descriptions
725
763
  * can never overflow the wire limit. Returns null only when `rows` is empty.
726
764
  */
@@ -758,37 +796,47 @@ export function renderCombinedWorkerFeed(
758
796
  return src.filter((s) => s != null && stripMarkdown(s).replace(/\s+/g, ' ').trim().length > 0)
759
797
  }
760
798
 
761
- const compose = (visibleCount: number): string => {
799
+ const compose = (visibleCount: number): { body: string; bodyLines: number } => {
762
800
  const shown = rows.slice(0, visibleCount)
763
801
  const hidden = rows.length - shown.length
764
- // Adaptive depth: split the fixed body-line budget across the VISIBLE
765
- // workers so the card stays bounded regardless of fan-out.
802
+ // Per-worker depth follows Ken's deterministic curve max(3, 7−w) (#3349):
803
+ // the curve drives DEPTH; the total-line budget below drives ROW COUNT.
766
804
  const depth = combinedHistoryDepth(shown.length)
767
- const out: string[] = [`🛠 **Workers** · _${rows.length} running_`]
805
+ const chrome: string[] = [`🛠 **Workers** · _${rows.length} running_`]
806
+ const bodyOut: string[] = []
768
807
  for (const r of shown) {
769
- out.push(rowHeader(r))
808
+ bodyOut.push(rowHeader(r))
770
809
  const hist = rowHistory(r)
771
810
  if (hist.length === 0) {
772
- out.push('→ _starting…_')
811
+ bodyOut.push('→ _starting…_')
773
812
  continue
774
813
  }
775
814
  // Paint the last-K history lines with the SAME `✓`/`→` idiom as the
776
815
  // single-worker card: escape each raw line through the shared per-line
777
816
  // pipeline (escapeStepLine), then renderStepFeed strikes the prior steps
778
- // and bolds the newest in-progress step.
817
+ // and bolds the newest in-progress step. The window equals the depth so a
818
+ // per-worker `+N earlier…` marker never appears inside the combined feed.
779
819
  const esc = hist.slice(-depth).map(escapeStepLine)
780
- renderStepFeed(out, esc, false)
820
+ renderStepFeed(bodyOut, esc, false, '', depth)
781
821
  }
822
+ const out = [...chrome, ...bodyOut]
782
823
  if (hidden > 0) out.push(`_+${hidden} more working…_`)
783
- return stackCardLines(out)
824
+ return { body: stackCardLines(out), bodyLines: bodyOut.length }
784
825
  }
785
826
 
786
- // Cap to maxRows first, then shrink further only if the char budget demands.
827
+ // Cap to maxRows first, then shrink the visible set while EITHER the total
828
+ // body-line budget (#3349: bounds a big swarm without stealing depth from the
829
+ // shown workers) OR the wire char budget is exceeded. Newest (trailing) rows
830
+ // collapse into the `+M more working…` spill (`rows.slice(0, visibleCount)`
831
+ // keeps the head of the list).
787
832
  let visible = Math.min(rows.length, maxRows)
788
- let body = compose(visible)
789
- while (body.length > STATUS_CARD_CHAR_BUDGET && visible > 1) {
833
+ let { body, bodyLines } = compose(visible)
834
+ while (
835
+ (bodyLines > MAX_COMBINED_BODY_LINES || body.length > STATUS_CARD_CHAR_BUDGET) &&
836
+ visible > 1
837
+ ) {
790
838
  visible -= 1
791
- body = compose(visible)
839
+ ;({ body, bodyLines } = compose(visible))
792
840
  }
793
841
  return body
794
842
  }
@@ -155,28 +155,80 @@ export const FLUSH_SUBSTANTIVE_MIN_CHARS = 200
155
155
  * `[verboseNarration(250), realAnswer(150)]` a reversed length scan returns the
156
156
  * 250-char narration and DROPS the 150-char real answer. Instead we take the
157
157
  * last non-empty block as the answer and strip only the EARLIER blocks — and
158
- * only when they look like intent-narration (short, or the classic "Let me…" /
159
- * "I'll…" openers). If the earlier blocks are themselves substantial (a genuine
160
- * multi-paragraph answer written as several blocks) we keep the whole thing
161
- * joined, so we never truncate a real long answer down to its last paragraph.
158
+ * only when they look like intent-narration. If the earlier blocks are
159
+ * themselves substantial (a genuine multi-paragraph answer written as several
160
+ * blocks) we keep the whole thing joined, so we never truncate a real long
161
+ * answer down to its last paragraph.
162
+ *
163
+ * #3237 — the STRUCTURAL discriminator. The opener heuristic below
164
+ * (`isNarrationBlock`) cannot tell a narration preamble ("Let me pull the
165
+ * numbers…" followed by a separate reply) from a real answer paragraph that
166
+ * merely OPENS with "Let me explain…": both match the same regex, and length
167
+ * cannot separate them (the observed narration was itself ≥200 chars). The one
168
+ * signal that DOES separate them is structural — did a `tool_use` follow this
169
+ * block in the model's actual message? A narration preamble is drafted, then
170
+ * the model ACTS (a tool call follows it); a terminal answer paragraph is not
171
+ * followed by any tool call. That per-block flag (`lastInMessage`) is computed
172
+ * upstream by `projectAssistantTextBlocks` (session-tail.ts) and, when the
173
+ * caller plumbs it through as `followedByToolUse`, we consult STRUCTURE, but
174
+ * asymmetrically:
175
+ * - present-and-FALSE (no tool_use followed) is purely additive — it can only
176
+ * RESCUE a block the opener regex would have mis-stripped (a terminal answer
177
+ * opening "Let me explain…"); it never drops a block the heuristic kept.
178
+ * - present-and-TRUE (a tool_use followed) is NOT taken as narration on its
179
+ * own: a substantial real-content paragraph can precede a tool call, so the
180
+ * strip is GATED by the substance check (narration only if it also matches
181
+ * the opener/trailer heuristic OR falls below the substantive floor). This
182
+ * is deliberately not "strictly additive" over the opener-only strip — it
183
+ * both rescues real content the old flag-alone path would have dropped and
184
+ * stays truncation-free.
185
+ * Where the structural flag is ABSENT for a block (legacy `string[]` caller, or
186
+ * the accumulator lost provenance) we fall back to the opener/trailer heuristic
187
+ * for that block.
188
+ *
189
+ * `followedByToolUse` is a parallel array aligned to `blocks` (index `i` ⇒
190
+ * `blocks[i]`); it is zipped BEFORE the empty-block filter so alignment holds
191
+ * even if the caller passes empty/whitespace blocks. `undefined` at an index
192
+ * (or a missing/short array) means "no reliable structural signal for this
193
+ * block" → opener-heuristic fallback.
162
194
  *
163
195
  * `blocks` are already trimmed/non-empty candidates (silent markers removed by
164
196
  * the caller's guards). Returns the chosen delivery text.
165
197
  */
166
- export function selectFlushDeliveryText(blocks: string[]): string {
198
+ export function selectFlushDeliveryText(
199
+ blocks: string[],
200
+ followedByToolUse?: ReadonlyArray<boolean | undefined>,
201
+ ): string {
167
202
  const candidates = blocks
168
- .map(b => b.trim())
169
- .filter(b => b.length > 0)
203
+ .map((b, i) => ({ text: b.trim(), followedByToolUse: followedByToolUse?.[i] }))
204
+ .filter(c => c.text.length > 0)
170
205
  if (candidates.length === 0) return ''
171
- if (candidates.length === 1) return candidates[0]
172
- const answer = candidates[candidates.length - 1]
206
+ if (candidates.length === 1) return candidates[0].text
207
+ const answer = candidates[candidates.length - 1].text
173
208
  const preceding = candidates.slice(0, -1)
174
209
  // Deliver only the terminal answer when every earlier block is
175
- // intent-narration (a short block, or a "Let me…/I'll…/I'm going to…" opener).
210
+ // intent-narration. Per block:
211
+ // - structural flag TRUE ⇒ a tool_use followed this block, but that alone
212
+ // is NOT sufficient to drop it: a substantial real-content paragraph can
213
+ // legitimately precede a tool call (the model writes a real answer, then
214
+ // calls a memory/verify tool, then a short wrap-up). So gate the
215
+ // structural strip with the substantive floor — narration only if the
216
+ // block ALSO looks like narration OR is below the substantive floor.
217
+ // - structural flag FALSE ⇒ present-and-false definitively overrides the
218
+ // opener regex: no tool_use followed, so it is a terminal-style block,
219
+ // never narration (#3237).
220
+ // - structural flag ABSENT ⇒ fall back to the opener/trailer heuristic.
176
221
  // Otherwise the earlier blocks carry real content — keep the full joined text
177
- // so a legitimate multi-block answer is never truncated to its last paragraph.
178
- const allNarration = preceding.every(isNarrationBlock)
179
- return allNarration ? answer : candidates.join('\n\n')
222
+ // so a legitimate multi-block answer is never truncated to its last
223
+ // paragraph (#3237).
224
+ const allNarration = preceding.every(c =>
225
+ c.followedByToolUse === true
226
+ ? isNarrationBlock(c.text) || c.text.trim().length < FLUSH_SUBSTANTIVE_MIN_CHARS
227
+ : c.followedByToolUse === false
228
+ ? false
229
+ : isNarrationBlock(c.text),
230
+ )
231
+ return allNarration ? answer : candidates.map(c => c.text).join('\n\n')
180
232
  }
181
233
 
182
234
  /**
@@ -244,6 +296,17 @@ export interface FlushDecisionInput {
244
296
  * is false — once the model has called reply / stream_reply the turn is
245
297
  * served and trailing terminal text is dropped (see `decideTurnFlush`). */
246
298
  capturedText: string[]
299
+ /** Optional per-block structural provenance, aligned to `capturedText`
300
+ * (index `i` describes `capturedText[i]`). `true` ⇒ a `tool_use` followed
301
+ * this text block in its assistant message (the draft-then-send narration
302
+ * signal — the negation of `projectAssistantTextBlocks`' `lastInMessage`).
303
+ * Consumed by `selectFlushDeliveryText` to separate a narration preamble from
304
+ * a real answer paragraph that merely opens with a narration phrase (#3237).
305
+ * When absent (legacy caller / lost provenance) the strip falls back to the
306
+ * opener/trailer heuristic. Present-and-false is additive (only rescues a
307
+ * mis-stripped terminal answer); present-and-true is gated by the substantive
308
+ * floor rather than trusted alone. */
309
+ capturedBlockMeta?: boolean[]
247
310
  /** Feature flag — defaults to true. Pass `false` to force skip everywhere. */
248
311
  flushEnabled?: boolean
249
312
  }
@@ -316,7 +379,10 @@ export function decideTurnFlush(input: FlushDecisionInput): FlushDecision {
316
379
  // blob (see `selectFlushDeliveryText`). The silent-marker / empty guards above
317
380
  // still run on the full `joined` string so a partly-silent turn is classified
318
381
  // correctly; only the DELIVERED text is narrowed to the answer.
319
- return { kind: 'flush', text: selectFlushDeliveryText(input.capturedText) }
382
+ return {
383
+ kind: 'flush',
384
+ text: selectFlushDeliveryText(input.capturedText, input.capturedBlockMeta),
385
+ }
320
386
  }
321
387
 
322
388
  /**