switchroom 0.16.46 → 0.17.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 (109) hide show
  1. package/dist/agent-scheduler/index.js +83 -81
  2. package/dist/auth-broker/index.js +104 -88
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -15
  5. package/dist/cli/notion-write-pretool.mjs +85 -83
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +1720 -1392
  8. package/dist/cli/ui/index.html +84 -12
  9. package/dist/host-control/main.js +209 -173
  10. package/dist/vault/approvals/kernel-server.js +86 -83
  11. package/dist/vault/broker/server.js +284 -139
  12. package/package.json +3 -3
  13. package/profiles/_base/cron-session.sh.hbs +1 -1
  14. package/profiles/_base/start.sh.hbs +54 -3
  15. package/skills/switchroom-architecture/telegram.md +8 -15
  16. package/skills/switchroom-cli/SKILL.md +4 -5
  17. package/skills/telegram-test-harness/SKILL.md +1 -1
  18. package/telegram-plugin/README.md +18 -29
  19. package/telegram-plugin/bridge/bridge.ts +1 -41
  20. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  21. package/telegram-plugin/dist/bridge/bridge.js +120 -155
  22. package/telegram-plugin/dist/gateway/gateway.js +1127 -1029
  23. package/telegram-plugin/dist/server.js +168 -203
  24. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  25. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  26. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  27. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  28. package/telegram-plugin/gateway/gateway.ts +566 -631
  29. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  30. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  31. package/telegram-plugin/gateway/model-command.ts +51 -3
  32. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  33. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  34. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  36. package/telegram-plugin/history.ts +5 -0
  37. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  38. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  39. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  40. package/telegram-plugin/registry/turns-schema.ts +65 -1
  41. package/telegram-plugin/session-tail.ts +26 -4
  42. package/telegram-plugin/slot-banner-driver.ts +42 -2
  43. package/telegram-plugin/status-query-telemetry.ts +100 -0
  44. package/telegram-plugin/stream-reply-handler.ts +15 -16
  45. package/telegram-plugin/subagent-watcher.ts +182 -30
  46. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  47. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  48. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  49. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  50. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  51. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  52. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  53. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  54. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  55. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  56. package/telegram-plugin/tests/model-command.test.ts +54 -1
  57. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  58. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  59. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  60. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  61. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  62. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  63. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  64. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  65. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  66. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  67. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  68. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  69. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  70. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  71. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  72. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  73. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  75. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  76. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  77. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  78. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
  79. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  80. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  81. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  82. package/telegram-plugin/tool-activity-summary.ts +19 -0
  83. package/telegram-plugin/turn-flush-safety.ts +16 -1
  84. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  85. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  86. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  87. package/telegram-plugin/worker-activity-feed.ts +75 -15
  88. package/vendor/hindsight-memory/CHANGELOG.md +24 -0
  89. package/vendor/hindsight-memory/README.md +5 -0
  90. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  91. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  92. package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
  93. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  94. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  95. package/vendor/hindsight-memory/scripts/retain.py +8 -1
  96. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  97. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  98. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  99. package/vendor/hindsight-memory/settings.json +4 -0
  100. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  101. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  102. package/vendor/hindsight-memory/tests/test_content.py +18 -0
  103. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  104. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  105. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  106. package/telegram-plugin/silent-reply.ts +0 -58
  107. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  108. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  109. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Nested (depth-2+) sub-agent keying — registry primitives.
3
+ *
4
+ * Pins the three mechanisms that fix the "nested worker card frozen on
5
+ * starting…" class:
6
+ * 1. `recordNestedSubagentDispatch` — the watcher records/repairs a nested
7
+ * dispatch row from the PARENT worker's JSONL (the authoritative
8
+ * dispatch record), because the PreToolUse hook cannot attribute it
9
+ * (turn-active.json is gone once the main turn ends) and can lose the
10
+ * write entirely under concurrent nested dispatch.
11
+ * 2. `resolveSubagentOriginTurnKey` — transitive origin resolution via the
12
+ * `parent_agent_id` chain, so a nested worker's card + handback route to
13
+ * the originating chat/topic instead of the owner DM.
14
+ * 3. `backfillJsonlAgentId` — inherits `parent_turn_key` from the nested-
15
+ * parent chain BEFORE falling back to the (never-correct-for-nested)
16
+ * turns time-window match.
17
+ *
18
+ * bun:sqlite — run under Bun:
19
+ * bun test telegram-plugin/tests/subagent-nested-dispatch.test.ts
20
+ */
21
+
22
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
23
+ import { mkdtempSync, rmSync, writeFileSync } from 'fs'
24
+ import { tmpdir } from 'os'
25
+ import { join } from 'path'
26
+ import { openTurnsDbInMemory } from '../registry/turns-schema.js'
27
+ import {
28
+ applySubagentsSchema,
29
+ recordNestedSubagentDispatch,
30
+ resolveSubagentOriginTurnKey,
31
+ getSubagent,
32
+ } from '../registry/subagents-schema.js'
33
+ import { backfillJsonlAgentId } from '../subagent-watcher.js'
34
+
35
+ type Db = ReturnType<typeof openTurnsDbInMemory>
36
+
37
+ let tempDir: string
38
+ let db: Db
39
+
40
+ beforeEach(() => {
41
+ tempDir = mkdtempSync(join(tmpdir(), 'sub-nested-'))
42
+ db = openTurnsDbInMemory()
43
+ applySubagentsSchema(db)
44
+ })
45
+
46
+ afterEach(() => {
47
+ try { db.close() } catch { /* ignore */ }
48
+ try { rmSync(tempDir, { recursive: true, force: true }) } catch { /* ignore */ }
49
+ })
50
+
51
+ function insertTurn(turnKey: string, chatId: string, startedAt: number, endedAt: number | null) {
52
+ db.prepare(`
53
+ INSERT INTO turns (turn_key, chat_id, thread_id, started_at, ended_at, created_at, updated_at)
54
+ VALUES (?, ?, NULL, ?, ?, ?, ?)
55
+ `).run(turnKey, chatId, startedAt, endedAt, startedAt, startedAt)
56
+ }
57
+
58
+ function insertSub(args: {
59
+ id: string
60
+ parentTurnKey?: string | null
61
+ jsonlAgentId?: string | null
62
+ parentAgentId?: string | null
63
+ background?: boolean
64
+ description?: string | null
65
+ startedAt?: number
66
+ }) {
67
+ db.prepare(`
68
+ INSERT INTO subagents
69
+ (id, parent_session_id, parent_turn_key, agent_type, description,
70
+ background, started_at, last_activity_at, status, jsonl_agent_id, parent_agent_id)
71
+ VALUES (?, 'sess-1', ?, 'worker', ?, ?, ?, ?, 'running', ?, ?)
72
+ `).run(
73
+ args.id,
74
+ args.parentTurnKey ?? null,
75
+ args.description ?? null,
76
+ args.background ? 1 : 0,
77
+ args.startedAt ?? 1000,
78
+ args.startedAt ?? 1000,
79
+ args.jsonlAgentId ?? null,
80
+ args.parentAgentId ?? null,
81
+ )
82
+ }
83
+
84
+ describe('recordNestedSubagentDispatch', () => {
85
+ it('creates the row when the hook lost the write (missing-row nested worker)', () => {
86
+ // Depth-1 parent: background worker, attributed at main-turn dispatch.
87
+ insertSub({ id: 'toolu_parent', parentTurnKey: '8248:_:1', jsonlAgentId: 'parent01', background: true })
88
+
89
+ recordNestedSubagentDispatch(db, {
90
+ toolUseId: 'toolu_child',
91
+ parentJsonlAgentId: 'parent01',
92
+ agentType: 'general-purpose',
93
+ description: 'nested probe',
94
+ background: false,
95
+ now: 5000,
96
+ })
97
+
98
+ const child = getSubagent(db, 'toolu_child')
99
+ expect(child).not.toBeNull()
100
+ expect(child?.parent_agent_id).toBe('parent01')
101
+ // Transitively inherited from the depth-1 row at record time.
102
+ expect(child?.parent_turn_key).toBe('8248:_:1')
103
+ expect(child?.description).toBe('nested probe')
104
+ expect(child?.status).toBe('running')
105
+ })
106
+
107
+ it('repairs a hook-inserted row: stamps parent_agent_id + inherits NULL parent_turn_key', () => {
108
+ insertSub({ id: 'toolu_parent', parentTurnKey: 'turn-A', jsonlAgentId: 'parent01', background: true })
109
+ // The hook DID land the row, but with parent_turn_key NULL (marker gone)
110
+ // — the exact live-incident shape (sibling a18c6003… had an empty key).
111
+ insertSub({ id: 'toolu_child', parentTurnKey: null, description: 'hook desc' })
112
+
113
+ recordNestedSubagentDispatch(db, {
114
+ toolUseId: 'toolu_child',
115
+ parentJsonlAgentId: 'parent01',
116
+ description: 'watcher desc',
117
+ background: true,
118
+ now: 6000,
119
+ })
120
+
121
+ const child = getSubagent(db, 'toolu_child')
122
+ expect(child?.parent_agent_id).toBe('parent01')
123
+ expect(child?.parent_turn_key).toBe('turn-A')
124
+ // INSERT OR IGNORE — the hook row's own fields are preserved.
125
+ expect(child?.description).toBe('hook desc')
126
+ })
127
+
128
+ it('is idempotent and never overwrites an existing parent_turn_key', () => {
129
+ insertSub({ id: 'toolu_parent', parentTurnKey: 'turn-A', jsonlAgentId: 'parent01', background: true })
130
+ insertSub({ id: 'toolu_child', parentTurnKey: 'turn-REAL' })
131
+
132
+ recordNestedSubagentDispatch(db, {
133
+ toolUseId: 'toolu_child', parentJsonlAgentId: 'parent01', background: false, now: 1,
134
+ })
135
+ recordNestedSubagentDispatch(db, {
136
+ toolUseId: 'toolu_child', parentJsonlAgentId: 'parent01', background: false, now: 2,
137
+ })
138
+
139
+ const child = getSubagent(db, 'toolu_child')
140
+ expect(child?.parent_turn_key).toBe('turn-REAL')
141
+ expect(child?.parent_agent_id).toBe('parent01')
142
+ })
143
+ })
144
+
145
+ describe('resolveSubagentOriginTurnKey — transitive origin walk', () => {
146
+ it('depth-1: returns the row own parent_turn_key', () => {
147
+ insertSub({ id: 't1', parentTurnKey: 'turn-A', jsonlAgentId: 'a1' })
148
+ expect(resolveSubagentOriginTurnKey(db, 'a1')).toBe('turn-A')
149
+ })
150
+
151
+ it('depth-2: NULL own key resolves via the parent chain', () => {
152
+ insertSub({ id: 't1', parentTurnKey: 'turn-A', jsonlAgentId: 'a1' })
153
+ insertSub({ id: 't2', parentTurnKey: null, jsonlAgentId: 'a2', parentAgentId: 'a1' })
154
+ expect(resolveSubagentOriginTurnKey(db, 'a2')).toBe('turn-A')
155
+ })
156
+
157
+ it('depth-3: walks multiple hops', () => {
158
+ insertSub({ id: 't1', parentTurnKey: 'turn-A', jsonlAgentId: 'a1' })
159
+ insertSub({ id: 't2', parentTurnKey: null, jsonlAgentId: 'a2', parentAgentId: 'a1' })
160
+ insertSub({ id: 't3', parentTurnKey: null, jsonlAgentId: 'a3', parentAgentId: 'a2' })
161
+ expect(resolveSubagentOriginTurnKey(db, 'a3')).toBe('turn-A')
162
+ })
163
+
164
+ it('cycle-safe: a corrupt self/loop chain returns null instead of hanging', () => {
165
+ insertSub({ id: 't1', parentTurnKey: null, jsonlAgentId: 'a1', parentAgentId: 'a2' })
166
+ insertSub({ id: 't2', parentTurnKey: null, jsonlAgentId: 'a2', parentAgentId: 'a1' })
167
+ expect(resolveSubagentOriginTurnKey(db, 'a1')).toBeNull()
168
+ })
169
+
170
+ it('missing row / unattributed chain returns null (caller keeps DM fallback)', () => {
171
+ expect(resolveSubagentOriginTurnKey(db, 'nope')).toBeNull()
172
+ insertSub({ id: 't1', parentTurnKey: null, jsonlAgentId: 'a1' })
173
+ expect(resolveSubagentOriginTurnKey(db, 'a1')).toBeNull()
174
+ })
175
+ })
176
+
177
+ describe('backfillJsonlAgentId — nested-parent inheritance beats the window match', () => {
178
+ function writeMeta(stem: string, toolUseId: string): string {
179
+ const jsonlPath = join(tempDir, `agent-${stem}.jsonl`)
180
+ writeFileSync(join(tempDir, `agent-${stem}.meta.json`), JSON.stringify({
181
+ agentType: 'general-purpose', description: 'nested probe', toolUseId,
182
+ }))
183
+ return jsonlPath
184
+ }
185
+
186
+ it('inherits the ancestor turn key when the dispatch happened AFTER the main turn ended', () => {
187
+ // The originating turn ended at t=2000; an unrelated turn is open at
188
+ // child-dispatch time (t=5000) — the window match would MIS-attribute.
189
+ insertTurn('turn-ORIGIN', '8248', 1000, 2000)
190
+ insertTurn('turn-OTHER', '9999', 4000, null)
191
+ insertSub({ id: 'toolu_parent', parentTurnKey: 'turn-ORIGIN', jsonlAgentId: 'parent01', background: true })
192
+ // Watcher-recorded nested row (parent_agent_id set, key inherited=NULL
193
+ // here to force the backfill path to resolve it).
194
+ db.prepare(`
195
+ INSERT INTO subagents
196
+ (id, parent_session_id, parent_turn_key, agent_type, description,
197
+ background, started_at, last_activity_at, status, jsonl_agent_id, parent_agent_id)
198
+ VALUES ('toolu_child', NULL, NULL, 'general-purpose', 'nested probe', 0, 5000, 5000, 'running', NULL, 'parent01')
199
+ `).run()
200
+
201
+ const jsonlPath = writeMeta('child01', 'toolu_child')
202
+ backfillJsonlAgentId(db, jsonlPath, 'child01')
203
+
204
+ const child = getSubagent(db, 'toolu_child')
205
+ expect(child?.jsonl_agent_id).toBe('child01')
206
+ // Inherited from the parent chain — NOT 'turn-OTHER' from the open window.
207
+ expect(child?.parent_turn_key).toBe('turn-ORIGIN')
208
+ })
209
+ })
@@ -563,3 +563,40 @@ describe('agent-dir resolution (RFC §Bug 2)', () => {
563
563
  }
564
564
  })
565
565
  })
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // Concurrent nested dispatch — row-creation reliability (unified cards)
569
+ // ---------------------------------------------------------------------------
570
+
571
+ describe('pretool — concurrent dispatch reliability', () => {
572
+ it('N hook processes writing the same registry.db concurrently all land their rows', async () => {
573
+ // The live-incident failure mode: several sub-agents dispatch at once
574
+ // (a depth-1 worker fanning out nested workers), the hook processes
575
+ // contend on registry.db, and a loser's INSERT vanished with
576
+ // SQLITE_BUSY → 0 rows → the worker's card froze on "starting…".
577
+ // The busy_timeout arming + this pin keep that from regressing.
578
+ const N = 8
579
+ const { spawn } = require('child_process') as typeof import('child_process')
580
+ const runs = Array.from({ length: N }, (_, i) => new Promise<number>((resolve) => {
581
+ const child = spawn(process.execPath, [PRETOOL_SCRIPT], {
582
+ env: { ...process.env, SWITCHROOM_AGENT_DIR: agentDir },
583
+ stdio: ['pipe', 'ignore', 'inherit'],
584
+ })
585
+ child.on('close', (code: number | null) => resolve(code ?? -1))
586
+ child.stdin!.end(JSON.stringify({
587
+ session_id: 'sess-conc',
588
+ tool_name: 'Task',
589
+ tool_use_id: `toolu_conc_${i}`,
590
+ tool_input: { description: `concurrent worker ${i}`, run_in_background: true },
591
+ }))
592
+ }))
593
+ const codes = await Promise.all(runs)
594
+ expect(codes.every((c) => c === 0)).toBe(true)
595
+
596
+ const db = openDb()
597
+ const rows = db.prepare(
598
+ "SELECT id FROM subagents WHERE id LIKE 'toolu_conc_%' ORDER BY id",
599
+ ).all() as Array<{ id: string }>
600
+ expect(rows.length).toBe(N)
601
+ }, 30_000)
602
+ })
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Boot-promotion / onProgress gate-ordering regression (unified progress
3
+ * cards — the frozen-"starting…" contributor in registerAgent).
4
+ *
5
+ * Pre-fix sequence for a worker file that is IN-FLIGHT at boot:
6
+ * 1. registerAgent creates the entry with `historical = true`,
7
+ * 2. the initial readSubTail runs — every onProgress cue is suppressed by
8
+ * the `!entry.historical` guard — and leaves the cursor at EOF,
9
+ * 3. THEN the promotion block flips `historical = false`.
10
+ * Result: the promoted live worker's existing activity never fires
11
+ * onProgress, so its card (painted later by the heartbeat) shows a bare
12
+ * "starting…" stub, and for a quiet worker no later tick ever repairs it.
13
+ *
14
+ * The fix re-reads the tail from byte 0 AFTER promotion (resetting
15
+ * toolCount/lastTool so nothing double-counts), so the replayed events both
16
+ * rebuild the entry and fire onProgress. This test pins that.
17
+ */
18
+
19
+ import { describe, it, expect, vi } from 'vitest'
20
+ import { startSubagentWatcher } from '../subagent-watcher.js'
21
+ import * as fs from 'fs'
22
+
23
+ function buildJSONL(...lines: object[]): string {
24
+ return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
25
+ }
26
+
27
+ describe('boot promotion — in-flight worker replays its activity through onProgress', () => {
28
+ it('fires onProgress for pre-existing tool activity after promotion (not suppressed)', () => {
29
+ const agentId = 'inflight-at-boot-01'
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
+ const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
36
+
37
+ let currentTime = 100_000
38
+ // The file EXISTS AT BOOT with real in-flight activity: a prompt and two
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(
42
+ { type: 'user', message: { content: [{ type: 'text', text: 'long research task' }] } },
43
+ { type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/repo/a.ts' } }] } },
44
+ { type: 'assistant', message: { content: [{ type: 'tool_use', id: 't2', name: 'Bash', input: { command: 'npm test' } }] } },
45
+ ), 'utf-8')
46
+
47
+ let lastOpened: string | null = null
48
+ const mockFs = {
49
+ existsSync: ((p: fs.PathLike) => {
50
+ const ps = String(p)
51
+ return ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir || ps === jsonlPath
52
+ }) as typeof fs.existsSync,
53
+ readdirSync: ((p: fs.PathLike) => {
54
+ const ps = String(p)
55
+ if (ps === projectsRoot) return ['mock-cwd']
56
+ if (ps === projectDir) return ['sess']
57
+ if (ps === sessionDir) return ['subagents']
58
+ if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
59
+ return []
60
+ }) as unknown as typeof fs.readdirSync,
61
+ statSync: (() => ({ size: content.length, mtimeMs: currentTime - 30_000 }) as fs.Stats) as typeof fs.statSync,
62
+ openSync: ((p: fs.PathLike) => { lastOpened = String(p); return 7 }) as unknown as typeof fs.openSync,
63
+ closeSync: (() => { lastOpened = null }) as typeof fs.closeSync,
64
+ readSync: ((
65
+ _fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
66
+ ): number => {
67
+ if (lastOpened !== jsonlPath) return 0
68
+ const src = content.slice(position ?? 0, (position ?? 0) + length)
69
+ src.copy(buf as Buffer, offset)
70
+ return src.length
71
+ }) as unknown as typeof fs.readSync,
72
+ watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
73
+ }
74
+
75
+ const progressCalls: Array<{ agentId: string; progressLine?: string; toolCount: number }> = []
76
+ const watcher = startSubagentWatcher({
77
+ agentDir,
78
+ fs: mockFs,
79
+ now: () => currentTime,
80
+ setInterval: () => ({ ref: 0 }),
81
+ clearInterval: () => {},
82
+ setTimeout: () => ({ ref: 0 }),
83
+ clearTimeout: () => {},
84
+ onProgress: ({ agentId: id, progressLine, toolCount }) => {
85
+ progressCalls.push({ agentId: id, progressLine, toolCount })
86
+ },
87
+ })
88
+
89
+ const entry = watcher.getRegistry().get(agentId)
90
+ expect(entry).toBeDefined()
91
+ // Promoted live (fresh mtime, still running).
92
+ expect(entry?.historical).toBe(false)
93
+ // Replay rebuilt the entry WITHOUT double-counting the initial read.
94
+ expect(entry?.toolCount).toBe(2)
95
+ expect(entry?.lastTool?.name).toBe('Bash')
96
+ // THE regression pin: the replayed tool steps fired onProgress — pre-fix
97
+ // this was zero (suppressed by the historical gate, cursor left at EOF).
98
+ const mine = progressCalls.filter((c) => c.agentId === agentId)
99
+ expect(mine.length).toBeGreaterThanOrEqual(2)
100
+ expect(mine.some((c) => c.progressLine != null && c.progressLine.length > 0)).toBe(true)
101
+
102
+ watcher.stop()
103
+ })
104
+
105
+ it('a STALE running-at-boot file stays historical and fires no onProgress (no replay spam)', () => {
106
+ const agentId = 'stale-at-boot-01'
107
+ const agentDir = '/home/user/.switchroom/agents/myagent'
108
+ const projectsRoot = `${agentDir}/.claude/projects`
109
+ const projectDir = `${projectsRoot}/mock-cwd`
110
+ const sessionDir = `${projectDir}/sess`
111
+ const subagentsDir = `${sessionDir}/subagents`
112
+ const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
113
+
114
+ const currentTime = 100_000_000
115
+ const content = Buffer.from(buildJSONL(
116
+ { type: 'user', message: { content: [{ type: 'text', text: 'dead prior-session task' }] } },
117
+ { type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x' } }] } },
118
+ ), 'utf-8')
119
+
120
+ let lastOpened: string | null = null
121
+ const mockFs = {
122
+ existsSync: ((p: fs.PathLike) => {
123
+ const ps = String(p)
124
+ return ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir || ps === jsonlPath
125
+ }) as typeof fs.existsSync,
126
+ readdirSync: ((p: fs.PathLike) => {
127
+ const ps = String(p)
128
+ if (ps === projectsRoot) return ['mock-cwd']
129
+ if (ps === projectDir) return ['sess']
130
+ if (ps === sessionDir) return ['subagents']
131
+ if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
132
+ return []
133
+ }) as unknown as typeof fs.readdirSync,
134
+ // Last write DAYS ago — far past the 15-min freshness window.
135
+ statSync: (() => ({ size: content.length, mtimeMs: currentTime - 3 * 24 * 3600_000 }) as fs.Stats) as typeof fs.statSync,
136
+ openSync: ((p: fs.PathLike) => { lastOpened = String(p); return 7 }) as unknown as typeof fs.openSync,
137
+ closeSync: (() => { lastOpened = null }) as typeof fs.closeSync,
138
+ readSync: ((
139
+ _fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
140
+ ): number => {
141
+ if (lastOpened !== jsonlPath) return 0
142
+ const src = content.slice(position ?? 0, (position ?? 0) + length)
143
+ src.copy(buf as Buffer, offset)
144
+ return src.length
145
+ }) as unknown as typeof fs.readSync,
146
+ watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
147
+ }
148
+
149
+ const progressCalls: string[] = []
150
+ const watcher = startSubagentWatcher({
151
+ agentDir,
152
+ fs: mockFs,
153
+ now: () => currentTime,
154
+ setInterval: () => ({ ref: 0 }),
155
+ clearInterval: () => {},
156
+ setTimeout: () => ({ ref: 0 }),
157
+ clearTimeout: () => {},
158
+ onProgress: ({ agentId: id }) => { progressCalls.push(id) },
159
+ })
160
+
161
+ const entry = watcher.getRegistry().get(agentId)
162
+ expect(entry?.historical).toBe(true)
163
+ expect(progressCalls.filter((id) => id === agentId)).toHaveLength(0)
164
+
165
+ watcher.stop()
166
+ })
167
+ })
@@ -26,9 +26,17 @@ function subAgentUserMsg(promptText: string) {
26
26
  function makeHarness(opts: {
27
27
  agentId?: string
28
28
  configStallThresholdMs?: number
29
+ configSilentSynthStallThresholdMs?: number
29
30
  configSilentStallTerminalMs?: number
31
+ initialContent?: string
30
32
  } = {}) {
31
- const { agentId = 'env-thresh-agent', configStallThresholdMs, configSilentStallTerminalMs } = opts
33
+ const {
34
+ agentId = 'env-thresh-agent',
35
+ configStallThresholdMs,
36
+ configSilentSynthStallThresholdMs,
37
+ configSilentStallTerminalMs,
38
+ initialContent,
39
+ } = opts
32
40
 
33
41
  let currentTime = 1000
34
42
  const stallCalls: Array<{ idleMs: number }> = []
@@ -43,7 +51,7 @@ function makeHarness(opts: {
43
51
  const subagentsDir = `${sessionDir}/subagents`
44
52
  const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
45
53
  const fileContents = new Map<string, Buffer>()
46
- fileContents.set(jsonlPath, Buffer.from(buildJSONL(subAgentUserMsg('bg task')), 'utf-8'))
54
+ fileContents.set(jsonlPath, Buffer.from(initialContent ?? buildJSONL(subAgentUserMsg('bg task')), 'utf-8'))
47
55
 
48
56
  let lastOpenedPath: string | null = null
49
57
  const mockFs = {
@@ -81,7 +89,7 @@ function makeHarness(opts: {
81
89
  const watcher = startSubagentWatcher({
82
90
  agentDir,
83
91
  stallThresholdMs: configStallThresholdMs,
84
- silentSynthesisStallThresholdMs: configStallThresholdMs,
92
+ silentSynthesisStallThresholdMs: configSilentSynthStallThresholdMs ?? configStallThresholdMs,
85
93
  silentStallTerminalMs: configSilentStallTerminalMs,
86
94
  rescanMs: 500,
87
95
  onStall: (_id, idleMs) => stallCalls.push({ idleMs }),
@@ -211,6 +219,41 @@ describe('subagent-watcher env-var threshold overrides', () => {
211
219
  expect(h.stallTerminalCalls).toHaveLength(0)
212
220
  })
213
221
 
222
+ it('a Bash-in-flight worker skips the tight stall threshold but still releases the terminal gate', () => {
223
+ // End-to-end of the long-runner suppression under compressed env-tuned
224
+ // windows: a worker mid-`Bash` (a) is NOT flagged at the tight active-loop
225
+ // threshold, (b) IS eventually flagged at the widened silent-synthesis
226
+ // window, and (c) the pass-2 terminal synthesis STILL fires the fixed
227
+ // window after that — the load-bearing completion-gate release is intact.
228
+ const h = makeHarness({
229
+ agentId: 'env-thresh-bash',
230
+ configStallThresholdMs: 1000, // tight active-loop threshold
231
+ configSilentSynthStallThresholdMs: 10_000, // widened long-runner window
232
+ configSilentStallTerminalMs: 5000, // compressed terminal window
233
+ initialContent: buildJSONL(
234
+ subAgentUserMsg('bg task'),
235
+ { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tool-B', name: 'Bash', input: { command: 'npm test' } }] } },
236
+ ),
237
+ })
238
+ h.advance(500) // register + tail → toolCount=1, lastTool=Bash
239
+ h.unmarkHistorical()
240
+
241
+ // 5s idle — well past the 1s active-loop threshold, but a Bash worker uses
242
+ // the 10s silent-synthesis window, so NO false stall.
243
+ h.advance(5_000)
244
+ expect(h.stallCalls).toHaveLength(0)
245
+
246
+ // Cross the 10s window → the stall IS flagged (pass-2 becomes reachable).
247
+ h.advance(6_000)
248
+ expect(h.stallCalls).toHaveLength(1)
249
+ expect(h.stallTerminalCalls).toHaveLength(0)
250
+
251
+ // 5s post-stall → terminal synthesis releases the deferred-completion gate.
252
+ h.advance(6_000)
253
+ expect(h.stallTerminalCalls).toHaveLength(1)
254
+ expect(h.finishCalls).toHaveLength(1)
255
+ })
256
+
214
257
  it('zero env value falls through to default (zero is not "fire immediately")', () => {
215
258
  process.env.SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS = '0'
216
259
  const h = makeHarness({ configStallThresholdMs: 1000 })
@@ -311,6 +311,76 @@ describe('subagent-watcher onStall callback (Option C, issue #393)', () => {
311
311
  expect(stallCalls).toHaveLength(1)
312
312
  })
313
313
 
314
+ // Bash-in-flight suppression: a worker whose LAST observed tool is a known
315
+ // long-runner (`Bash` — a build, `npm test`, a long curl) legitimately goes
316
+ // quiet in the JSONL for minutes while the command runs. Pre-fix the tight
317
+ // 60s active-loop threshold misfired on exactly this population, producing
318
+ // the false "stall detected (idle 60s)" the operator hit while a background
319
+ // worker was mid-Bash. The watcher now widens the threshold for such workers
320
+ // to the silent-synthesis window.
321
+ it('does NOT trip stall at 60s when the last tool is Bash (long-runner suppression)', () => {
322
+ const agentId = 'stall-bash-01'
323
+ const { stallCalls, advance, watcher } = makeStallHarness({
324
+ agentId,
325
+ stallThresholdMs: 60_000,
326
+ silentSynthesisStallThresholdMs: 300_000, // 5min
327
+ rescanMs: 500,
328
+ initialContent: buildJSONL(
329
+ subAgentUserMsg('background task'),
330
+ { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tool-B', name: 'Bash', input: { command: 'npm test' } }] } },
331
+ ),
332
+ })
333
+ advance(500) // register + tail read → toolCount=1, lastTool=Bash
334
+ const entry = watcher.getRegistry().get(agentId)
335
+ if (entry) entry.historical = false
336
+ expect(entry?.lastTool?.name).toBe('Bash')
337
+ advance(120_000) // 2min idle — far past 60s but under the silent-synth window
338
+ expect(stallCalls).toHaveLength(0) // NOT falsely stalled mid-Bash
339
+ })
340
+
341
+ it('DOES trip stall for a Bash worker once past the silent-synthesis window (pass-2 gate still reachable)', () => {
342
+ const agentId = 'stall-bash-02'
343
+ const { stallCalls, advance, watcher } = makeStallHarness({
344
+ agentId,
345
+ stallThresholdMs: 60_000,
346
+ silentSynthesisStallThresholdMs: 300_000, // 5min
347
+ rescanMs: 500,
348
+ initialContent: buildJSONL(
349
+ subAgentUserMsg('background task'),
350
+ { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tool-B', name: 'Bash', input: { command: 'sleep 999' } }] } },
351
+ ),
352
+ })
353
+ advance(500)
354
+ const entry = watcher.getRegistry().get(agentId)
355
+ if (entry) entry.historical = false
356
+ advance(120_000) // under the window — no stall yet
357
+ expect(stallCalls).toHaveLength(0)
358
+ advance(200_000) // total ~5min20s — past the silent-synth window → stall fires
359
+ expect(stallCalls).toHaveLength(1)
360
+ expect(stallCalls[0].agentId).toBe(agentId)
361
+ })
362
+
363
+ it('a non-long-runner last tool (Read) still trips at the tight 60s threshold', () => {
364
+ // Guard the suppression is scoped to long-runners only — a Read that goes
365
+ // quiet for 60s is genuinely stalled and must still flag.
366
+ const agentId = 'stall-read-01'
367
+ const { stallCalls, advance, watcher } = makeStallHarness({
368
+ agentId,
369
+ stallThresholdMs: 60_000,
370
+ silentSynthesisStallThresholdMs: 600_000, // way out — proves the 60s path is taken
371
+ rescanMs: 500,
372
+ initialContent: buildJSONL(
373
+ subAgentUserMsg('background task'),
374
+ { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tool-R', name: 'Read', input: { path: '/x' } }] } },
375
+ ),
376
+ })
377
+ advance(500)
378
+ const entry = watcher.getRegistry().get(agentId)
379
+ if (entry) entry.historical = false
380
+ advance(65_000) // 65s quiet after a Read → genuinely stalled
381
+ expect(stallCalls).toHaveLength(1)
382
+ })
383
+
314
384
  // Test 10: onStall is NOT called for sub-agents already done/failed
315
385
  it('does not call onStall for sub-agents in done/failed state', () => {
316
386
  const agentId = 'stall-test-10-done'
@@ -8,6 +8,8 @@ import {
8
8
  renderActivityFeedWithNested,
9
9
  renderActivityHeader,
10
10
  formatFeedElapsed,
11
+ formatStepSuffix,
12
+ STEP_TIMER_MIN_MS,
11
13
  type SessionActivityHeader,
12
14
  } from "../tool-activity-summary.js";
13
15
  import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from "../status-no-truncate.js";
@@ -782,6 +784,20 @@ describe("formatFeedElapsed — elapsed formatter", () => {
782
784
  });
783
785
  });
784
786
 
787
+ describe("formatStepSuffix — per-step suffix with 10s gate", () => {
788
+ it("returns empty string while the step is younger than STEP_TIMER_MIN_MS", () => {
789
+ expect(formatStepSuffix(0)).toBe("");
790
+ expect(formatStepSuffix(9_999)).toBe("");
791
+ expect(formatStepSuffix(STEP_TIMER_MIN_MS - 1)).toBe("");
792
+ });
793
+
794
+ it("renders ` · <elapsed>` once the step reaches the gate", () => {
795
+ expect(formatStepSuffix(STEP_TIMER_MIN_MS)).toBe(" · 10s");
796
+ expect(formatStepSuffix(13_000)).toBe(" · 13s");
797
+ expect(formatStepSuffix(65_000)).toBe(" · 1m05s");
798
+ });
799
+ });
800
+
785
801
  describe("renderActivityFeed — header param (main-session card fix)", () => {
786
802
  it("prepends the two-line header when header is supplied (running)", () => {
787
803
  const header: SessionActivityHeader = {
@@ -15,7 +15,6 @@ import {
15
15
  // A representative slice mirroring the real TOOL_SCHEMAS names.
16
16
  const SAMPLE: NamedTool[] = [
17
17
  { name: 'reply', description: 'r' },
18
- { name: 'stream_reply', description: 's' },
19
18
  { name: 'get_recent_messages', description: 'g' },
20
19
  { name: 'react', description: 'k' },
21
20
  { name: 'edit_message', description: 'e' },
@@ -58,10 +57,9 @@ describe('buildEffectiveToolSchemas — per-tool deferral pins (B)', () => {
58
57
  }
59
58
  })
60
59
 
61
- it('the reply path (reply/stream_reply) is ALWAYS pinned — never defers', () => {
60
+ it('the reply path (reply) is ALWAYS pinned — never defers', () => {
62
61
  const out = buildEffectiveToolSchemas(SAMPLE, { linearEnabled: false })
63
62
  expect(metaOf(out, 'reply')).toEqual({ 'anthropic/alwaysLoad': true })
64
- expect(metaOf(out, 'stream_reply')).toEqual({ 'anthropic/alwaysLoad': true })
65
63
  })
66
64
 
67
65
  it('cold tools carry NO _meta (so they defer under tool-search)', () => {
@@ -41,18 +41,15 @@ describe('computeLabel — never-null built-in fallthrough', () => {
41
41
  })
42
42
 
43
43
  describe('computeLabel — surface-tool suppression is key-agnostic', () => {
44
- it('returns null for telegram reply/stream_reply under any registration key', () => {
44
+ it('returns null for telegram reply under any registration key', () => {
45
45
  // Standard switchroom-telegram key.
46
46
  expect(computeLabel('mcp__switchroom-telegram__reply', {})).toBeNull()
47
- expect(computeLabel('mcp__switchroom-telegram__stream_reply', {})).toBeNull()
48
47
 
49
48
  // Legacy clerk-telegram key — same plugin, different registration name.
50
49
  expect(computeLabel('mcp__clerk-telegram__reply', {})).toBeNull()
51
- expect(computeLabel('mcp__clerk-telegram__stream_reply', {})).toBeNull()
52
50
 
53
51
  // Hypothetical custom fork.
54
52
  expect(computeLabel('mcp__my-custom-telegram__reply', {})).toBeNull()
55
- expect(computeLabel('mcp__my-custom-telegram__stream_reply', {})).toBeNull()
56
53
  })
57
54
 
58
55
  it('returns null for telegram react under any registration key', () => {