switchroom 0.18.3 → 0.18.7

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 (156) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +386 -128
  6. package/dist/host-control/main.js +4 -2
  7. package/dist/vault/approvals/kernel-server.js +3 -1
  8. package/dist/vault/broker/server.js +38 -8
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-release/SKILL.md +78 -0
  14. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
  17. package/telegram-plugin/dist/server.js +24 -0
  18. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  19. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  20. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  21. package/telegram-plugin/gateway/gateway.ts +1331 -151
  22. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  23. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  24. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  25. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  27. package/telegram-plugin/gateway/model-command.ts +212 -51
  28. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  29. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  31. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  32. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  33. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  34. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  36. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  37. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  38. package/telegram-plugin/model-label.ts +69 -0
  39. package/telegram-plugin/operator-events.ts +45 -0
  40. package/telegram-plugin/pending-work-progress.ts +42 -7
  41. package/telegram-plugin/permission-diff.ts +128 -0
  42. package/telegram-plugin/quota-bar-format.ts +360 -0
  43. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  44. package/telegram-plugin/registry/subagents.test.ts +90 -0
  45. package/telegram-plugin/session-tail.ts +28 -0
  46. package/telegram-plugin/silent-end.ts +49 -4
  47. package/telegram-plugin/subagent-watcher.ts +249 -46
  48. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  49. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  51. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  52. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  53. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  54. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  55. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  56. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  57. package/telegram-plugin/tests/model-command.test.ts +202 -42
  58. package/telegram-plugin/tests/model-label.test.ts +64 -0
  59. package/telegram-plugin/tests/operator-events.test.ts +17 -0
  60. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  61. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  62. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  63. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  64. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  65. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  66. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  68. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  69. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  70. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  71. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  72. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  73. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  74. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  75. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  76. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  77. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  78. package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
  79. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  80. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  81. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  82. package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
  83. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  84. package/telegram-plugin/tool-activity-summary.ts +22 -2
  85. package/telegram-plugin/typing-wrap.ts +72 -25
  86. package/telegram-plugin/worker-activity-feed.ts +229 -15
  87. package/profiles/default/CLAUDE.md +0 -116
  88. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  89. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -265,6 +265,53 @@ describe('backfillJsonlAgentId — overlapping windows / hook precedence (#2081)
265
265
  })
266
266
  })
267
267
 
268
+ // ─── Fix #3: ambiguous fuzzy backfill refuses to link ────────────────────────
269
+ // Legacy meta.json (no toolUseId) falls back to the fuzzy (agentType,
270
+ // description) match. With N unlinked rows sharing that key, a bare
271
+ // `ORDER BY started_at DESC LIMIT 1` assigned by start-order, not true
272
+ // correspondence — a genuine mislink for a cross-topic identical dispatch.
273
+ // The fix: link only when the candidate is UNAMBIGUOUS (exactly one match);
274
+ // otherwise leave jsonl_agent_id NULL and let the marker/window path
275
+ // attribute it.
276
+ describe('backfillJsonlAgentId — fix #3: ambiguous fuzzy match refused', () => {
277
+ it('two unlinked rows sharing (agent_type, description) → fuzzy link refused, row stays NULL', () => {
278
+ insertSub({ id: 'toolu_amb_1', agentType: 'general-purpose', description: 'Run the tests', startedAt: 1000 })
279
+ insertSub({ id: 'toolu_amb_2', agentType: 'general-purpose', description: 'Run the tests', startedAt: 2000 })
280
+
281
+ // Legacy meta.json — no toolUseId, forces the fuzzy fallback path.
282
+ const jsonlPath = writeMeta('general-purpose', 'Run the tests')
283
+ const logs: string[] = []
284
+ backfillJsonlAgentId(db, jsonlPath, 'agentstem_ambiguous', (m) => logs.push(m))
285
+
286
+ // Neither candidate gets mis-assigned — both remain unlinked.
287
+ expect(readSub('toolu_amb_1')?.jsonl_agent_id ?? null).toBeNull()
288
+ expect(readSub('toolu_amb_2')?.jsonl_agent_id ?? null).toBeNull()
289
+ expect(logs.some((l) => l.includes('ambiguous') && l.includes('agentstem_ambiguous'))).toBe(true)
290
+ })
291
+
292
+ it('a SINGLE unambiguous candidate still links normally (no regression)', () => {
293
+ insertSub({ id: 'toolu_unamb', agentType: 'general-purpose', description: 'Unique task', startedAt: 1000 })
294
+
295
+ const jsonlPath = writeMeta('general-purpose', 'Unique task')
296
+ backfillJsonlAgentId(db, jsonlPath, 'agentstem_unambiguous')
297
+
298
+ expect(readSub('toolu_unamb')?.jsonl_agent_id).toBe('agentstem_unambiguous')
299
+ })
300
+
301
+ it('three unlinked rows sharing the same key → still refused (not just a 2-row edge case)', () => {
302
+ insertSub({ id: 'toolu_amb3_1', agentType: 'researcher', description: 'Investigate', startedAt: 1000 })
303
+ insertSub({ id: 'toolu_amb3_2', agentType: 'researcher', description: 'Investigate', startedAt: 2000 })
304
+ insertSub({ id: 'toolu_amb3_3', agentType: 'researcher', description: 'Investigate', startedAt: 3000 })
305
+
306
+ const jsonlPath = writeMeta('researcher', 'Investigate')
307
+ backfillJsonlAgentId(db, jsonlPath, 'agentstem_amb3')
308
+
309
+ expect(readSub('toolu_amb3_1')?.jsonl_agent_id ?? null).toBeNull()
310
+ expect(readSub('toolu_amb3_2')?.jsonl_agent_id ?? null).toBeNull()
311
+ expect(readSub('toolu_amb3_3')?.jsonl_agent_id ?? null).toBeNull()
312
+ })
313
+ })
314
+
268
315
  // ─── #2506: null meta.json guard ─────────────────────────────────────────────
269
316
  // JSON.parse('null') succeeds and returns null. Before the fix, the enclosing
270
317
  // try/catch only covered the read+parse, so execution fell through to
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Fix #9a — `terminatedAgentIds` (the re-discovery dedup guard added on
3
+ * every `cleanupTerminalAgent`, read in `scanSubagentsDir`'s skip-check)
4
+ * grew unboundedly: only ever cleared wholesale in `stop()`. This pins the
5
+ * bound: once the cap is hit, the OLDEST id is evicted on each new insert
6
+ * (Set preserves insertion order), while the dedup guarantee for recently-
7
+ * terminated ids still holds.
8
+ *
9
+ * `terminatedAgentIdsCap` is a config override so the test can exercise
10
+ * eviction with a handful of entries instead of the production cap
11
+ * (a few thousand).
12
+ */
13
+
14
+ import { describe, it, expect, vi } from 'vitest'
15
+ import { startSubagentWatcher } from '../subagent-watcher.js'
16
+ import * as fs from 'fs'
17
+
18
+ function buildJSONL(...lines: object[]): string {
19
+ return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
20
+ }
21
+ function subAgentUserMsg(promptText: string) {
22
+ return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
23
+ }
24
+ function subAgentTurnEnd() {
25
+ return { type: 'system', subtype: 'turn_duration', duration_ms: 100 }
26
+ }
27
+
28
+ describe('terminatedAgentIds cap (fix #9a)', () => {
29
+ it('evicts the oldest terminated id once the cap is hit, but keeps dedup for recent ids', () => {
30
+ const agentDir = '/home/user/.switchroom/agents/myagent'
31
+ const projectsRoot = `${agentDir}/.claude/projects`
32
+ const projectDir = `${projectsRoot}/mock-cwd`
33
+ const sessionDir = `${projectDir}/sess`
34
+ const subagentsDir = `${sessionDir}/subagents`
35
+
36
+ // 4 agents, already fully done (turn_end present) at "boot" — each will
37
+ // schedule a terminal cleanup immediately.
38
+ const agentIds = ['agent-a', 'agent-b', 'agent-c', 'agent-d']
39
+ const fileNames = agentIds.map((id) => `agent-${id}.jsonl`)
40
+ const filePaths = new Map(agentIds.map((id) => [id, `${subagentsDir}/agent-${id}.jsonl`]))
41
+ // Files visible to a directory scan — stay present throughout (a real
42
+ // gateway's JSONL files don't disappear on cleanup either; only the
43
+ // in-memory `terminatedAgentIds` dedup guard decides re-registration).
44
+ const visibleFiles = [...fileNames]
45
+
46
+ const content = Buffer.from(buildJSONL(subAgentUserMsg('done task'), subAgentTurnEnd()), 'utf-8')
47
+
48
+ let lastOpened: string | null = null
49
+ const mockFs = {
50
+ existsSync: ((p: fs.PathLike) => {
51
+ const ps = String(p)
52
+ if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
53
+ return [...filePaths.values()].includes(ps)
54
+ }) as typeof fs.existsSync,
55
+ readdirSync: ((p: fs.PathLike) => {
56
+ const ps = String(p)
57
+ if (ps === projectsRoot) return ['mock-cwd']
58
+ if (ps === projectDir) return ['sess']
59
+ if (ps === sessionDir) return ['subagents']
60
+ if (ps === subagentsDir) return visibleFiles
61
+ return []
62
+ }) as unknown as typeof fs.readdirSync,
63
+ statSync: (() => ({ size: content.length, mtimeMs: 0 }) as fs.Stats) as typeof fs.statSync,
64
+ openSync: ((p: fs.PathLike) => { lastOpened = String(p); return 7 }) as unknown as typeof fs.openSync,
65
+ closeSync: (() => { lastOpened = null }) as typeof fs.closeSync,
66
+ readSync: ((
67
+ _fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
68
+ ): number => {
69
+ if (lastOpened == null) return 0
70
+ const src = content.slice(position ?? 0, (position ?? 0) + length)
71
+ src.copy(buf as Buffer, offset)
72
+ return src.length
73
+ }) as unknown as typeof fs.readSync,
74
+ watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
75
+ }
76
+
77
+ let currentTime = 1_000_000
78
+ const intervals: Array<{ fn: () => void; ms: number; fireAt: number }> = []
79
+ const timeouts: Array<{ fn: () => void; ms: number; fireAt: number; ref: number }> = []
80
+ let nextTimeoutRef = 1
81
+
82
+ const advance = (ms: number): void => {
83
+ currentTime += ms
84
+ for (;;) {
85
+ timeouts.sort((a, b) => a.fireAt - b.fireAt)
86
+ const next = timeouts[0]
87
+ if (!next || next.fireAt > currentTime) break
88
+ timeouts.shift()
89
+ next.fn()
90
+ }
91
+ for (const iv of intervals) {
92
+ while (iv.fireAt <= currentTime) {
93
+ iv.fn()
94
+ iv.fireAt += iv.ms
95
+ }
96
+ }
97
+ }
98
+
99
+ const watcher = startSubagentWatcher({
100
+ agentDir,
101
+ fs: mockFs,
102
+ terminatedAgentIdsCap: 3,
103
+ now: () => currentTime,
104
+ setInterval: (fn, ms) => {
105
+ intervals.push({ fn, ms, fireAt: currentTime + ms })
106
+ return { ref: 0 }
107
+ },
108
+ clearInterval: () => {},
109
+ setTimeout: (fn, ms) => {
110
+ const ref = nextTimeoutRef++
111
+ timeouts.push({ fn, ms, fireAt: currentTime + ms, ref })
112
+ return { ref }
113
+ },
114
+ clearTimeout: (handle) => {
115
+ const { ref } = handle as { ref: number }
116
+ const idx = timeouts.findIndex((t) => t.ref === ref)
117
+ if (idx !== -1) timeouts.splice(idx, 1)
118
+ },
119
+ })
120
+
121
+ // All 4 discovered at boot, already `done` → each schedules a terminal
122
+ // cleanup TERMINAL_CLEANUP_GRACE_MS (30s) out, in discovery order.
123
+ for (const id of agentIds) {
124
+ expect(watcher.getRegistry().has(id)).toBe(true)
125
+ }
126
+
127
+ // Fire all 4 scheduled cleanups (in order a, b, c, d). With cap=3, the
128
+ // 4th cleanup (d) evicts the OLDEST tracked id (a) before inserting d.
129
+ // `advance()` also drives the rescan interval (default 1s) forward as
130
+ // part of the same clock jump, so by the time it returns the poll
131
+ // loop has ALSO already rediscovered the just-evicted agent-a (its
132
+ // JSONL file is still on disk throughout — nothing "disappears" for a
133
+ // real gateway either) — that's the observable proof the eviction
134
+ // took effect, not a separate step.
135
+ advance(30_000)
136
+
137
+ // agent-a was evicted from `terminatedAgentIds` (cap=3, it was the
138
+ // oldest insert) → the dedup guard no longer suppresses it, so the
139
+ // rescan re-discovered and re-registered it.
140
+ expect(watcher.getRegistry().has('agent-a')).toBe(true)
141
+ // agent-b, agent-c, agent-d are still within the cap window → the
142
+ // dedup guard still suppresses their re-discovery even though their
143
+ // JSONL files are also still sitting on disk.
144
+ expect(watcher.getRegistry().has('agent-b')).toBe(false)
145
+ expect(watcher.getRegistry().has('agent-c')).toBe(false)
146
+ expect(watcher.getRegistry().has('agent-d')).toBe(false)
147
+
148
+ watcher.stop()
149
+ })
150
+ })
@@ -519,6 +519,60 @@ describe('startSubagentWatcher', () => {
519
519
  expect(toolTick?.latestSummary).toBe('')
520
520
  })
521
521
 
522
+ it('captures message.model into entry.currentModel and threads it onto onProgress', () => {
523
+ const progress: Array<{ model?: string }> = []
524
+ const agentDir = join(tmpRoot, 'agent')
525
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
526
+ mkdirSync(subagentsDir, { recursive: true })
527
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
528
+
529
+ const h = startWatcherSync({
530
+ agentDir,
531
+ onProgress: ({ model }) => { progress.push({ model }) },
532
+ })
533
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Research the competitors')))
534
+ h.poll()
535
+
536
+ // Assistant line carrying a resolved model + a tool_use (drives an
537
+ // onProgress tick). The model event is projected first, so the entry's
538
+ // currentModel is set before the tool tick fires.
539
+ appendFileSync(jsonlPath, buildJSONL({
540
+ type: 'assistant',
541
+ message: {
542
+ model: 'claude-opus-4-8',
543
+ content: [{ type: 'tool_use', name: 'Read', id: 'r1', input: { file_path: '/x/CLAUDE.md' } }],
544
+ },
545
+ }))
546
+ h.poll()
547
+
548
+ expect(h.watcher.getRegistry().get('deadbeef')?.currentModel).toBe('claude-opus-4-8')
549
+ const modelled = progress.find((p) => p.model != null)
550
+ expect(modelled?.model).toBe('claude-opus-4-8')
551
+ })
552
+
553
+ it('ignores a synthetic model sentinel, keeping the last real model', () => {
554
+ const agentDir = join(tmpRoot, 'agent')
555
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
556
+ mkdirSync(subagentsDir, { recursive: true })
557
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
558
+
559
+ const h = startWatcherSync({ agentDir })
560
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Research')))
561
+ h.poll()
562
+ appendFileSync(jsonlPath, buildJSONL({
563
+ type: 'assistant',
564
+ message: { model: 'claude-opus-4-8', content: [{ type: 'tool_use', name: 'Read', id: 'r1', input: {} }] },
565
+ }))
566
+ h.poll()
567
+ // A compaction/synthetic line — must NOT clobber the last real model.
568
+ appendFileSync(jsonlPath, buildJSONL({
569
+ type: 'assistant',
570
+ message: { model: '<synthetic>', content: [{ type: 'tool_use', name: 'Bash', id: 'b1', input: {} }] },
571
+ }))
572
+ h.poll()
573
+ expect(h.watcher.getRegistry().get('deadbeef')?.currentModel).toBe('claude-opus-4-8')
574
+ })
575
+
522
576
  it('narrative gate: a draft-then-reply sub_agent_text is SUPPRESSED (no progress cue)', () => {
523
577
  // The worker composes its answer as a text block, then calls
524
578
  // stream_reply with near-identical text. The narrative cue must be
@@ -575,6 +629,67 @@ describe('startSubagentWatcher', () => {
575
629
  expect(narrativeCues[0]).toContain('find the repo')
576
630
  })
577
631
 
632
+ it('narration-clobber regression: a narration cue immediately followed by its resolving tool_use in the same poll does NOT get overwritten by the tool-label cue', () => {
633
+ // Reproduces the bug: sub_agent_text ("On it...") + sub_agent_tool_use
634
+ // (Bash) land in the SAME jsonl-tail read, so both the narrative
635
+ // resolution (fireNarrativeProgress) and the tool-description
636
+ // onProgress fire within one loop iteration over `events`. Since the
637
+ // card renders replace-on-write, the tool-label call previously always
638
+ // clobbered the narration call that fired moments earlier — narration
639
+ // was staged and "SHOWN" per the dedup gate, but never actually
640
+ // visible on the pinned card. Assert only ONE onProgress cue fires for
641
+ // this tick, and it's the narration (progressLine == null), not the
642
+ // tool label.
643
+ const allCues: Array<{ progressLine?: string; latestSummary: string }> = []
644
+ const agentDir = join(tmpRoot, 'agent')
645
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
646
+ mkdirSync(subagentsDir, { recursive: true })
647
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
648
+ const h = startWatcherSync({
649
+ agentDir,
650
+ onProgress: ({ progressLine, latestSummary }) => {
651
+ allCues.push({ progressLine, latestSummary })
652
+ },
653
+ })
654
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
655
+ h.poll()
656
+ // Narration text + its resolving tool_use appended and tailed together
657
+ // in a single poll — this is the "same tick" race.
658
+ appendFileSync(
659
+ jsonlPath,
660
+ buildJSONL(subAgentAssistantText('On it. Let me find the repo.'), subAgentToolUse('Bash', 'b1')),
661
+ )
662
+ h.poll()
663
+ expect(allCues.length).toBe(1)
664
+ expect(allCues[0].progressLine).toBeUndefined()
665
+ expect(allCues[0].latestSummary).toContain('find the repo')
666
+ })
667
+
668
+ it('unregressed: two sub_agent_tool_use events with no narration between them both still show tool labels (named foreground blindspot)', () => {
669
+ // Guards against the clobber-guard fix over-suppressing: narrativeJustFired
670
+ // must be false for a tool_use that has no preceding pending narrative,
671
+ // so back-to-back tool calls (a researcher reading files with no prose)
672
+ // must both still surface a progressLine.
673
+ const toolCues: Array<string | undefined> = []
674
+ const agentDir = join(tmpRoot, 'agent')
675
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
676
+ mkdirSync(subagentsDir, { recursive: true })
677
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
678
+ const h = startWatcherSync({
679
+ agentDir,
680
+ onProgress: ({ progressLine }) => {
681
+ if (progressLine != null) toolCues.push(progressLine)
682
+ },
683
+ })
684
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
685
+ h.poll()
686
+ appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b1')))
687
+ h.poll()
688
+ appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b2')))
689
+ h.poll()
690
+ expect(toolCues.length).toBe(2)
691
+ })
692
+
578
693
  it('narrative gate: trailing narration at turn_end is SHOWN', () => {
579
694
  const narrativeCues: string[] = []
580
695
  const agentDir = join(tmpRoot, 'agent')
@@ -723,6 +723,28 @@ describe("renderActivityHeader — two-line header builder", () => {
723
723
  const [h1] = renderActivityHeader("🤖", "Agent", "run a_b & c*d", 5_000, 1, "running");
724
724
  expect(h1).toContain("run a\\_b & c\\*d");
725
725
  });
726
+
727
+ it("appends the friendly live model to the running metrics line", () => {
728
+ const [, h2] = renderActivityHeader("🤖", "Agent", "", 120_000, 14, "running", "claude-opus-4-8");
729
+ expect(h2).toBe("_2m00s · 14 tools · opus 4.8_");
730
+ });
731
+
732
+ it("appends the friendly live model to the done metrics line", () => {
733
+ const [, h2] = renderActivityHeader("🤖", "Agent", "", 65_000, 3, "done", "claude-sonnet-5");
734
+ expect(h2).toBe("_done · 3 tools · 1m05s · sonnet 5_");
735
+ });
736
+
737
+ it("shows an sr-* model id verbatim on the metrics line", () => {
738
+ const [, h2] = renderActivityHeader("🛠", "Worker", "run tests", 10_000, 2, "running", "sr-glm-5");
739
+ expect(h2).toBe("_10s · 2 tools · sr-glm-5_");
740
+ });
741
+
742
+ it("omits the model tag for a sentinel / absent value", () => {
743
+ const [, h2none] = renderActivityHeader("🤖", "Agent", "", 15_000, 7, "running");
744
+ expect(h2none).toBe("_15s · 7 tools_");
745
+ const [, h2synth] = renderActivityHeader("🤖", "Agent", "", 15_000, 7, "running", "<synthetic>");
746
+ expect(h2synth).toBe("_15s · 7 tools_");
747
+ });
726
748
  });
727
749
 
728
750
  describe("agent flat path routes through the shared step-feed primitive", () => {
@@ -815,6 +837,21 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
815
837
  expect(out).toContain("**→ Searching memory**");
816
838
  });
817
839
 
840
+ it("threads the header model through the flat and nested feed paths", () => {
841
+ const header: SessionActivityHeader = {
842
+ label: "Agent",
843
+ elapsedMs: 120_000,
844
+ toolCount: 14,
845
+ state: "running",
846
+ model: "claude-opus-4-8",
847
+ };
848
+ const flat = renderActivityFeed(["Searching memory"], false, "", undefined, header)!;
849
+ expect(flat).toContain("_2m00s · 14 tools · opus 4.8_");
850
+ // Nested path (with a child line) carries the same model tag.
851
+ const nested = renderActivityFeedWithNested(["Reading"], ["nested step"], false, "", undefined, header)!;
852
+ expect(nested).toContain("· opus 4.8_");
853
+ });
854
+
818
855
  it("prepends the done header when final=true", () => {
819
856
  const header: SessionActivityHeader = {
820
857
  label: "Agent",
@@ -64,6 +64,29 @@ describe('createTypingWrapper', () => {
64
64
  expect(deps.startTypingLoop).toHaveBeenCalledTimes(1)
65
65
  })
66
66
 
67
+ it('fix #7: does NOT stop typing when the first of two parallel tools resolves while the second is still running', () => {
68
+ const deps = makeDeps()
69
+ const w = createTypingWrapper(deps)
70
+ // Two overlapping tool_use blocks on the same lane. The first fires the
71
+ // loop immediately; the second (still in-flight) uses the debounce.
72
+ w.onToolUse('t1', 'chat-A', 'Bash')
73
+ w.onToolUse('t2', 'chat-A', 'Read')
74
+ expect(deps.startTypingLoop).toHaveBeenCalledTimes(1)
75
+ // Let t2's debounce fire so it's a live, started entry on the lane too.
76
+ vi.advanceTimersByTime(500)
77
+ expect(deps.startTypingLoop).toHaveBeenCalledTimes(2)
78
+
79
+ // t1 resolves first — with the pre-fix boolean Set, this deleted the
80
+ // lane and stopped the loop even though t2 is still running.
81
+ w.onToolResult('t1')
82
+ expect(deps.stopTypingLoop).not.toHaveBeenCalled()
83
+
84
+ // t2 resolves too — now the lane's ref-count hits zero and the loop stops.
85
+ w.onToolResult('t2')
86
+ expect(deps.stopTypingLoop).toHaveBeenCalledTimes(1)
87
+ expect(deps.stopTypingLoop).toHaveBeenCalledWith('chat-A', null)
88
+ })
89
+
67
90
  it('starts then stops typing when a single slow tool completes', () => {
68
91
  const deps = makeDeps()
69
92
  const w = createTypingWrapper(deps)
@@ -96,6 +96,25 @@ describe('classifyRejection — benign Telegram 400s', () => {
96
96
  )
97
97
  expect(classifyRejection(err)).toBe('log_only')
98
98
  })
99
+ it('returns "log_only" for "group chat was upgraded to a supergroup chat" (marko 2026-07-09, recurring 2026-06-07/2026-06-09)', () => {
100
+ // A send targeted a basic-group chat_id that Telegram had since
101
+ // migrated to a supergroup (new -100xxxxxxxxxx id). This crashed the
102
+ // ENTIRE gateway process (every agent/chat) over a single stale
103
+ // cached chat_id — the same root cause recurred three times across a
104
+ // month because the crash-on-leak behavior masked the underlying
105
+ // stale-id bug instead of just logging it. A migrated/invalid
106
+ // destination chat must never take down the whole gateway.
107
+ const err = grammyError(
108
+ 400,
109
+ 'Bad Request: group chat was upgraded to a supergroup chat',
110
+ )
111
+ expect(classifyRejection(err)).toBe('log_only')
112
+ })
113
+
114
+ it('returns "log_only" for "group chat was deactivated"', () => {
115
+ const err = grammyError(400, 'Bad Request: group chat was deactivated')
116
+ expect(classifyRejection(err)).toBe('log_only')
117
+ })
99
118
  })
100
119
 
101
120
  describe('classifyRejection — genuine errors still crash', () => {
@@ -86,6 +86,17 @@ describe('renderWorkerActivity', () => {
86
86
  expect(out).not.toContain('<code>')
87
87
  })
88
88
 
89
+ it('renders the friendly live model on the worker metrics line', () => {
90
+ const out = renderWorkerActivity(view({ model: 'claude-sonnet-5' }))
91
+ expect(out).toContain('_10s · 3 tools · sonnet 5_')
92
+ })
93
+
94
+ it('omits the model tag when the worker model is unknown', () => {
95
+ const out = renderWorkerActivity(view())
96
+ expect(out).toContain('_10s · 3 tools_')
97
+ expect(out).not.toContain('· sonnet')
98
+ })
99
+
89
100
  it('shows a "starting…" line when no step has run yet', () => {
90
101
  const out = renderWorkerActivity(view({ lastTool: null, latestSummary: '' }))
91
102
  expect(out).toContain('🛠 **Worker**')
@@ -945,6 +956,103 @@ describe('createWorkerActivityFeed — heartbeat', () => {
945
956
  })
946
957
  })
947
958
 
959
+ // ─── Resurrection guard + deferred finalize (review findings #2 & #3) ─────────
960
+ // #3: a late watcher onProgress tick arriving after `finish()` queued its
961
+ // chain must NOT resurrect the handle and paint a fresh running message.
962
+ // #2: a terminal edit that hit a 429 cooldown stages on `pendingFinish` and
963
+ // is re-driven by the heartbeat after cooldown — the card can't get stuck
964
+ // on its last running render. "not modified" / message-gone are success/drop.
965
+ describe('createWorkerActivityFeed — resurrection guard + deferred finalize', () => {
966
+ const drain = () => new Promise((r) => setTimeout(r, 0))
967
+
968
+ it('#3: a late update tick after finish does not resurrect a running card', async () => {
969
+ const bot = makeFakeBot()
970
+ let clock = 10_000
971
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
972
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'step one' }))
973
+ expect(bot.sent).toHaveLength(1)
974
+
975
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'done result' }))
976
+ // finish landed a terminal edit and dropped the handle.
977
+ expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(true)
978
+ expect(feed.has('w1')).toBe(false)
979
+
980
+ // Late watcher tick arrives AFTER finish. Pre-fix this would create a
981
+ // fresh handle and paint a new running message on a finalized worker.
982
+ clock = 12_000
983
+ await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'step two' }))
984
+ expect(bot.sent).toHaveLength(1) // no new running message
985
+ expect(feed.has('w1')).toBe(false)
986
+ expect(feed.size).toBe(0)
987
+ })
988
+
989
+ it('#2: a 429 on the finish edit stages pendingFinish; the heartbeat re-drives it after cooldown', async () => {
990
+ const bot = makeFakeBot()
991
+ let clock = 10_000
992
+ const feed = createWorkerActivityFeed({
993
+ bot,
994
+ now: () => clock,
995
+ minEditIntervalMs: 0,
996
+ heartbeatTickMs: 6000,
997
+ setInterval: () => 1,
998
+ clearInterval: () => {},
999
+ })
1000
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'running step' }))
1001
+ expect(bot.sent).toHaveLength(1)
1002
+
1003
+ // The terminal edit hits a 429 with a 2s retry_after.
1004
+ bot.failNextEditWith = { error_code: 429, parameters: { retry_after: 2 } }
1005
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'final result' }))
1006
+ await drain()
1007
+ // No terminal edit landed yet — the last edit is still the running render.
1008
+ expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(false)
1009
+
1010
+ // Inside the cooldown — a heartbeat tick must NOT retry (would re-429).
1011
+ clock = 11_000
1012
+ feed.heartbeatTick()
1013
+ await drain()
1014
+ expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(false)
1015
+
1016
+ // Past the cooldown (10_000 + 2000 + 500 jitter = 12_500) — the heartbeat
1017
+ // re-drives the deferred finalize and the terminal edit lands.
1018
+ clock = 13_000
1019
+ feed.heartbeatTick()
1020
+ await drain()
1021
+ expect(bot.edits.some((e) => e.text.includes('_done ·'))).toBe(true)
1022
+ expect(feed.has('w1')).toBe(false) // handle dropped after the terminal edit landed
1023
+ })
1024
+
1025
+ it('#2: "message is not modified" on finish is treated as success (card already correct)', async () => {
1026
+ const bot = makeFakeBot()
1027
+ let clock = 10_000
1028
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
1029
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'x' }))
1030
+ expect(bot.sent).toHaveLength(1)
1031
+
1032
+ bot.failNextEditWith = new Error('Bad Request: message is not modified')
1033
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'x' }))
1034
+ await drain()
1035
+ // The handle is dropped — the not-modified outcome is success, no retry.
1036
+ expect(feed.has('w1')).toBe(false)
1037
+ })
1038
+
1039
+ it('#2: a gone message on finish drops the handle silently (no card to finalize)', async () => {
1040
+ const bot = makeFakeBot()
1041
+ const logs: string[] = []
1042
+ let clock = 10_000
1043
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0, log: (m) => logs.push(m) })
1044
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'x' }))
1045
+ expect(bot.sent).toHaveLength(1)
1046
+
1047
+ bot.failNextEditWith = new Error('Bad Request: message to edit not found')
1048
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'done' }))
1049
+ await drain()
1050
+ expect(feed.has('w1')).toBe(false)
1051
+ // No scary "finish edit failed" warning for a transport-gone outcome.
1052
+ expect(logs.some((l) => l.includes('finish edit failed'))).toBe(false)
1053
+ })
1054
+ })
1055
+
948
1056
  // ─── Extreme-edge: single oversized narrative line (no-truncate ON) ──────────
949
1057
  // Reproduces the bug where accumulateNarrative's char-budget splice would push
950
1058
  // the oversized line then immediately splice it out, making the narrative empty