switchroom 0.18.6 → 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 (71) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +269 -56
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  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/telegram-plugin/dist/bridge/bridge.js +22 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +1937 -580
  15. package/telegram-plugin/dist/server.js +24 -0
  16. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  17. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  18. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  19. package/telegram-plugin/gateway/gateway.ts +1071 -130
  20. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  21. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  22. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  23. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  24. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  25. package/telegram-plugin/gateway/model-command.ts +212 -51
  26. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  27. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  28. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  29. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  30. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  31. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  33. package/telegram-plugin/model-label.ts +69 -0
  34. package/telegram-plugin/operator-events.ts +24 -0
  35. package/telegram-plugin/permission-diff.ts +128 -0
  36. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  37. package/telegram-plugin/registry/subagents.test.ts +90 -0
  38. package/telegram-plugin/session-tail.ts +28 -0
  39. package/telegram-plugin/silent-end.ts +49 -4
  40. package/telegram-plugin/subagent-watcher.ts +222 -37
  41. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  42. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  43. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  44. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  45. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  46. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  47. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  48. package/telegram-plugin/tests/model-command.test.ts +202 -42
  49. package/telegram-plugin/tests/model-label.test.ts +64 -0
  50. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  51. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  52. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  53. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  54. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  55. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  56. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  57. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  58. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  59. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  60. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  61. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  62. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  63. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  64. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  66. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  67. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  68. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  69. package/telegram-plugin/tool-activity-summary.ts +22 -2
  70. package/telegram-plugin/typing-wrap.ts +72 -25
  71. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -37,14 +37,19 @@ describe('boot promotion — in-flight worker replays its activity through onPro
37
37
  let currentTime = 100_000
38
38
  // The file EXISTS AT BOOT with real in-flight activity: a prompt and two
39
39
  // tool steps, still running (no turn_end). mtime is fresh (30s ago) so
40
- // the freshness gate promotes it.
41
- const content = Buffer.from(buildJSONL(
40
+ // the freshness gate makes it ELIGIBLE — but fix #5 also requires actual
41
+ // post-boot growth before promoting (mtime freshness alone can't
42
+ // distinguish this from a worker killed moments before the restart).
43
+ // `content` grows below via the captured fs.watch callback to supply
44
+ // that confirmation.
45
+ let content = Buffer.from(buildJSONL(
42
46
  { type: 'user', message: { content: [{ type: 'text', text: 'long research task' }] } },
43
47
  { type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/repo/a.ts' } }] } },
44
48
  { type: 'assistant', message: { content: [{ type: 'tool_use', id: 't2', name: 'Bash', input: { command: 'npm test' } }] } },
45
49
  ), 'utf-8')
46
50
 
47
51
  let lastOpened: string | null = null
52
+ let watchCb: (() => void) | null = null
48
53
  const mockFs = {
49
54
  existsSync: ((p: fs.PathLike) => {
50
55
  const ps = String(p)
@@ -69,7 +74,10 @@ describe('boot promotion — in-flight worker replays its activity through onPro
69
74
  src.copy(buf as Buffer, offset)
70
75
  return src.length
71
76
  }) as unknown as typeof fs.readSync,
72
- watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
77
+ watch: ((_p: fs.PathLike, cb?: () => void) => {
78
+ watchCb = cb ?? null
79
+ return { close: vi.fn() } as unknown as fs.FSWatcher
80
+ }) as unknown as typeof fs.watch,
73
81
  }
74
82
 
75
83
  const progressCalls: Array<{ agentId: string; progressLine?: string; toolCount: number }> = []
@@ -86,10 +94,25 @@ describe('boot promotion — in-flight worker replays its activity through onPro
86
94
  },
87
95
  })
88
96
 
97
+ // Fix #5: registration alone (mtime-fresh, no observed growth yet) must
98
+ // NOT promote — it stays historical/pending until growth confirms it.
99
+ const preGrowthEntry = watcher.getRegistry().get(agentId)
100
+ expect(preGrowthEntry?.historical).toBe(true)
101
+ expect(preGrowthEntry?.bootPromotionPending).toBeDefined()
102
+
103
+ // Simulate the worker taking one more real step post-boot: the JSONL
104
+ // grows, and Claude Code's fs notification fires.
105
+ content = Buffer.concat([content, Buffer.from(buildJSONL(
106
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'still going' }] } },
107
+ ), 'utf-8')])
108
+ currentTime += 1000
109
+ watchCb?.()
110
+
89
111
  const entry = watcher.getRegistry().get(agentId)
90
112
  expect(entry).toBeDefined()
91
- // Promoted live (fresh mtime, still running).
113
+ // Promoted live now that post-boot growth was actually observed.
92
114
  expect(entry?.historical).toBe(false)
115
+ expect(entry?.bootPromotionPending).toBeUndefined()
93
116
  // Replay rebuilt the entry WITHOUT double-counting the initial read.
94
117
  expect(entry?.toolCount).toBe(2)
95
118
  expect(entry?.lastTool?.name).toBe('Bash')
@@ -102,6 +125,86 @@ describe('boot promotion — in-flight worker replays its activity through onPro
102
125
  watcher.stop()
103
126
  })
104
127
 
128
+ it('fix #5: a running-at-boot file with fresh mtime but NO post-boot growth is never promoted (avoids a stale completed handback)', () => {
129
+ const agentId = 'inflight-no-growth-01'
130
+ const agentDir = '/home/user/.switchroom/agents/myagent'
131
+ const projectsRoot = `${agentDir}/.claude/projects`
132
+ const projectDir = `${projectsRoot}/mock-cwd`
133
+ const sessionDir = `${projectDir}/sess`
134
+ const subagentsDir = `${sessionDir}/subagents`
135
+ const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
136
+
137
+ let currentTime = 100_000
138
+ // Fresh mtime (30s old) but the file NEVER grows again — indistinguishable
139
+ // from a worker that was killed moments before this restart.
140
+ const content = Buffer.from(buildJSONL(
141
+ { type: 'user', message: { content: [{ type: 'text', text: 'task that got killed' }] } },
142
+ { type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/repo/a.ts' } }] } },
143
+ ), 'utf-8')
144
+
145
+ let lastOpened: string | null = null
146
+ const intervals: Array<{ fn: () => void; ms: number; fireAt: number }> = []
147
+ const mockFs = {
148
+ existsSync: ((p: fs.PathLike) => {
149
+ const ps = String(p)
150
+ return ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir || ps === jsonlPath
151
+ }) as typeof fs.existsSync,
152
+ readdirSync: ((p: fs.PathLike) => {
153
+ const ps = String(p)
154
+ if (ps === projectsRoot) return ['mock-cwd']
155
+ if (ps === projectDir) return ['sess']
156
+ if (ps === sessionDir) return ['subagents']
157
+ if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
158
+ return []
159
+ }) as unknown as typeof fs.readdirSync,
160
+ statSync: (() => ({ size: content.length, mtimeMs: 100_000 - 30_000 }) as fs.Stats) as typeof fs.statSync,
161
+ openSync: ((p: fs.PathLike) => { lastOpened = String(p); return 7 }) as unknown as typeof fs.openSync,
162
+ closeSync: (() => { lastOpened = null }) as typeof fs.closeSync,
163
+ readSync: ((
164
+ _fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
165
+ ): number => {
166
+ if (lastOpened !== jsonlPath) return 0
167
+ const src = content.slice(position ?? 0, (position ?? 0) + length)
168
+ src.copy(buf as Buffer, offset)
169
+ return src.length
170
+ }) as unknown as typeof fs.readSync,
171
+ watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
172
+ }
173
+
174
+ const finishCalls: Array<{ agentId: string; outcome: string }> = []
175
+ const watcher = startSubagentWatcher({
176
+ agentDir,
177
+ fs: mockFs,
178
+ inflightPromoteMaxAgeMs: 60_000, // small bound so the test can cross it
179
+ now: () => currentTime,
180
+ setInterval: (fn, ms) => {
181
+ intervals.push({ fn, ms, fireAt: currentTime + ms })
182
+ return { ref: 0 }
183
+ },
184
+ clearInterval: () => {},
185
+ setTimeout: () => ({ ref: 0 }),
186
+ clearTimeout: () => {},
187
+ onFinish: ({ agentId: id, outcome }) => finishCalls.push({ agentId: id, outcome }),
188
+ })
189
+
190
+ const entryAtBoot = watcher.getRegistry().get(agentId)
191
+ expect(entryAtBoot?.historical).toBe(true)
192
+ expect(entryAtBoot?.bootPromotionPending).toBeDefined()
193
+
194
+ // Advance well past the promotion window WITHOUT the file ever growing.
195
+ currentTime += 120_000
196
+ for (const iv of intervals) if (iv.fireAt <= currentTime) iv.fn()
197
+
198
+ const entry = watcher.getRegistry().get(agentId)
199
+ // Never promoted: still historical, no completed/failed handback ever
200
+ // synthesised from these pre-restart bytes.
201
+ expect(entry?.historical).toBe(true)
202
+ expect(entry?.bootPromotionPending).toBeUndefined() // gave up after the deadline
203
+ expect(finishCalls).toHaveLength(0)
204
+
205
+ watcher.stop()
206
+ })
207
+
105
208
  it('a STALE running-at-boot file stays historical and fires no onProgress (no replay spam)', () => {
106
209
  const agentId = 'stale-at-boot-01'
107
210
  const agentDir = '/home/user/.switchroom/agents/myagent'
@@ -224,14 +224,22 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
224
224
  expect(h.finishCalls[0].agentId).toBe('gap1-complete')
225
225
  expect(h.finishCalls[0].outcome).toBe('completed') // pre-fix: 'orphan' → dropped
226
226
  expect(h.finishCalls[0].resultText).toContain('root cause')
227
- // The promotion is logged so the path is observable in prod.
228
- expect(h.logs.some((l) => l.includes('in-flight at bootpromoting to live'))).toBe(true)
227
+ // Fix #5: promotion now requires an observed post-boot growth event
228
+ // (mtime freshness alone is refused)the `append` above supplied it,
229
+ // and the promotion is logged so the path is observable in prod.
230
+ expect(h.logs.some((l) => l.includes('confirmed live') && l.includes('post-boot JSONL growth'))).toBe(true)
229
231
  })
230
232
 
231
- it('an in-flight-at-boot worker that dies silently is rescued by stall synthesis', () => {
233
+ it('an in-flight-at-boot worker that dies silently (after proving liveness) is rescued by stall synthesis', () => {
232
234
  // Pre-fix, historical entries were skipped by stall detection, so a
233
235
  // worker that crossed a restart and then went silent sat running
234
236
  // forever — no handback ever. After promotion it gets the safety net.
237
+ //
238
+ // Fix #5: promotion additionally requires proof of post-boot liveness —
239
+ // a single real step (e.g. a narrative line) grows the JSONL past its
240
+ // boot-time snapshot, confirming this is NOT a worker killed moments
241
+ // before the restart. Only THEN does it get the stall-synthesis safety
242
+ // net when it subsequently goes silent for good.
235
243
  const h = makeHarness({
236
244
  agentId: 'gap1-silent',
237
245
  bootLines: [subAgentUserMsg('bg task')],
@@ -239,6 +247,11 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
239
247
  silentStallTerminalMs: 120_000,
240
248
  })
241
249
 
250
+ h.append(subAgentText('still investigating'))
251
+ h.advance(600) // one poll observes the post-boot growth → promotes
252
+
253
+ expect(h.watcher.getRegistry().get('gap1-silent')?.historical).toBe(false)
254
+
242
255
  h.advance(62_000) // stall threshold crossed
243
256
  expect(h.stallTerminalCalls).toHaveLength(0)
244
257
  h.advance(121_000) // silent-stall terminal window elapses → synthesis
@@ -247,6 +260,30 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
247
260
  expect(h.finishCalls[0].outcome).toBe('completed')
248
261
  })
249
262
 
263
+ it('fix #5: an in-flight-at-boot worker that NEVER shows post-boot growth is left historical (no stale completed handback)', () => {
264
+ // The bug this closes: a worker killed <15min before a restart is
265
+ // byte-for-byte indistinguishable from a live one by mtime alone. Absent
266
+ // any actual post-boot growth, it must NOT be promoted — so it can never
267
+ // synthesise a false 'completed' handback from the pre-restart bytes.
268
+ const h = makeHarness({
269
+ agentId: 'gap1-killed-before-restart',
270
+ bootLines: [subAgentUserMsg('bg task')],
271
+ stallThresholdMs: 60_000,
272
+ silentStallTerminalMs: 120_000,
273
+ inflightPromoteMaxAgeMs: 60_000,
274
+ })
275
+
276
+ // No append — the file never grows again after boot.
277
+ h.advance(62_000)
278
+ h.advance(121_000)
279
+ h.advance(600_000) // well past the promotion window too
280
+
281
+ expect(h.watcher.getRegistry().get('gap1-killed-before-restart')?.historical).toBe(true)
282
+ expect(h.stallTerminalCalls).toHaveLength(0)
283
+ expect(h.finishCalls).toHaveLength(0)
284
+ expect(h.logs.some((l) => l.includes('never observed post-boot JSONL growth'))).toBe(true)
285
+ })
286
+
250
287
  it('a worker already DONE at boot stays suppressed (no spurious replay)', () => {
251
288
  // The legitimate use of `historical`: a worker that finished in a prior
252
289
  // session must NOT re-fire a handback on every restart. This is the
@@ -298,7 +335,8 @@ describe('Gap 1 freshness gate — v0.14.24 stale-replay regression', () => {
298
335
 
299
336
  expect(h.finishCalls).toHaveLength(1)
300
337
  expect(h.finishCalls[0].outcome).toBe('completed')
301
- expect(h.logs.some((l) => l.includes('promoting to live'))).toBe(true)
338
+ // Fix #5: the append above supplies the required post-boot growth proof.
339
+ expect(h.logs.some((l) => l.includes('confirmed live'))).toBe(true)
302
340
  })
303
341
 
304
342
  it('kill-switch (bootPromoteEnabled=false) suppresses even a fresh running-at-boot worker', () => {
@@ -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
@@ -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)
@@ -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**')