switchroom 0.16.47 → 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 (106) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +24 -8
  3. package/dist/cli/drive-write-pretool.mjs +0 -5
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +1358 -1030
  6. package/dist/cli/ui/index.html +84 -12
  7. package/dist/host-control/main.js +53 -17
  8. package/dist/vault/approvals/kernel-server.js +4 -1
  9. package/dist/vault/broker/server.js +201 -56
  10. package/package.json +3 -3
  11. package/profiles/_base/cron-session.sh.hbs +1 -1
  12. package/profiles/_base/start.sh.hbs +54 -3
  13. package/skills/switchroom-architecture/telegram.md +8 -15
  14. package/skills/switchroom-cli/SKILL.md +4 -5
  15. package/skills/telegram-test-harness/SKILL.md +1 -1
  16. package/telegram-plugin/README.md +18 -29
  17. package/telegram-plugin/bridge/bridge.ts +1 -41
  18. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  19. package/telegram-plugin/dist/bridge/bridge.js +8 -43
  20. package/telegram-plugin/dist/gateway/gateway.js +682 -773
  21. package/telegram-plugin/dist/server.js +8 -43
  22. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  23. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  24. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  25. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  26. package/telegram-plugin/gateway/gateway.ts +518 -624
  27. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  28. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  29. package/telegram-plugin/gateway/model-command.ts +51 -3
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  31. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  32. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  33. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  34. package/telegram-plugin/history.ts +5 -0
  35. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  36. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  37. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  38. package/telegram-plugin/registry/turns-schema.ts +65 -1
  39. package/telegram-plugin/session-tail.ts +26 -4
  40. package/telegram-plugin/slot-banner-driver.ts +42 -2
  41. package/telegram-plugin/status-query-telemetry.ts +100 -0
  42. package/telegram-plugin/stream-reply-handler.ts +15 -16
  43. package/telegram-plugin/subagent-watcher.ts +182 -30
  44. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  45. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  46. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  47. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  48. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  49. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  50. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  51. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  52. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  53. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  54. package/telegram-plugin/tests/model-command.test.ts +54 -1
  55. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  56. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  57. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  58. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  59. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  60. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  61. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  62. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  63. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  64. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  65. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  66. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  67. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  68. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  69. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  70. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  71. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  72. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  73. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  74. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  75. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  76. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  77. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  78. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  79. package/telegram-plugin/tool-activity-summary.ts +19 -0
  80. package/telegram-plugin/turn-flush-safety.ts +16 -1
  81. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  82. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  83. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  84. package/telegram-plugin/worker-activity-feed.ts +75 -15
  85. package/vendor/hindsight-memory/CHANGELOG.md +24 -0
  86. package/vendor/hindsight-memory/README.md +5 -0
  87. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  88. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  89. package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
  90. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  91. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  92. package/vendor/hindsight-memory/scripts/retain.py +8 -1
  93. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  94. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  95. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  96. package/vendor/hindsight-memory/settings.json +4 -0
  97. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  98. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  99. package/vendor/hindsight-memory/tests/test_content.py +18 -0
  100. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  101. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  102. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  103. package/telegram-plugin/silent-reply.ts +0 -58
  104. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  105. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  106. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Flagship harness for the prose-silent background-worker visibility fix
3
+ * (PR 2 of 2). This is the automated proxy for the live-gateway test the
4
+ * operator demanded: it wires the REAL `startSubagentWatcher` to the REAL
5
+ * `createWorkerActivityFeed` on ONE shared virtual clock, mirroring the
6
+ * gateway's `onProgress → workerActivityFeed.update` and
7
+ * `onFinish → workerActivityFeed.finish` wiring, and drives a sub-agent JSONL
8
+ * that contains ONLY tool_use events (a long `Bash` run) — NO `sub_agent_text`.
9
+ *
10
+ * It asserts the four outcomes the fix must deliver:
11
+ * (a) the `🛠 Worker` feed PAINTS a message (gets a messageId) within the
12
+ * expected window even though the worker never emitted prose,
13
+ * (b) the message keeps UPDATING via the heartbeat while the worker is alive,
14
+ * (c) NO false stall is flagged during the Bash window (the worker mid-`Bash`
15
+ * is not "stalled" just because its JSONL went quiet), and
16
+ * (d) the 300s-style silent-stall TERMINAL synthesis still releases the
17
+ * deferred-completion gate at the end (onStallTerminal + onFinish fire,
18
+ * and the feed finalizes) — the load-bearing completion path is intact.
19
+ *
20
+ * The pre-fix behaviour this locks against: a background worker that dives
21
+ * straight into a long `Bash` fired one tool tick (before firstPaintMin, so the
22
+ * paint was held), then no further JSONL lines — and the heartbeat skipped
23
+ * `messageId == null` handles, so the worker showed NOTHING for its entire run
24
+ * while ALSO being falsely flagged "stall detected (idle 60s)".
25
+ *
26
+ * Uses vitest (no bun:sqlite): no registry DB is wired, so the gate-release
27
+ * proxy is the `onStallTerminal` + `onFinish` callbacks — the exact signals the
28
+ * gateway's handback delivery and deferred-completion release consume.
29
+ */
30
+
31
+ import { describe, it, expect, vi } from 'vitest'
32
+ import { startSubagentWatcher } from '../subagent-watcher.js'
33
+ import {
34
+ createWorkerActivityFeed,
35
+ type WorkerActivityView,
36
+ } from '../worker-activity-feed.js'
37
+ import * as fs from 'fs'
38
+
39
+ function buildJSONL(...lines: object[]): string {
40
+ return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
41
+ }
42
+ function subAgentUserMsg(promptText: string) {
43
+ return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
44
+ }
45
+ function bashToolUse(id: string, command: string) {
46
+ return { type: 'assistant', message: { content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] } }
47
+ }
48
+
49
+ interface FakeBot {
50
+ sent: Array<{ chatId: string; text: string }>
51
+ edits: Array<{ messageId: number; text: string }>
52
+ sendMessage: (chatId: string, text: string) => Promise<{ message_id: number }>
53
+ editMessageText: (chatId: string, messageId: number, text: string) => Promise<unknown>
54
+ }
55
+ function makeFakeBot(): FakeBot {
56
+ let nextId = 5000
57
+ const fb: FakeBot = {
58
+ sent: [],
59
+ edits: [],
60
+ sendMessage: async (chatId, text) => {
61
+ fb.sent.push({ chatId, text })
62
+ return { message_id: nextId++ }
63
+ },
64
+ editMessageText: async (_chatId, messageId, text) => {
65
+ fb.edits.push({ messageId, text })
66
+ return {}
67
+ },
68
+ }
69
+ return fb
70
+ }
71
+
72
+ /** Flush the feed's async chains (microtasks + the awaited fake bot). */
73
+ const flush = async (): Promise<void> => {
74
+ for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0))
75
+ }
76
+
77
+ function makeHarness() {
78
+ const agentId = 'prose-silent-bash-01'
79
+ const chatId = 'chat-42'
80
+ let currentTime = 1000
81
+
82
+ const stallCalls: Array<{ agentId: string; idleMs: number }> = []
83
+ const stallTerminalCalls: Array<{ agentId: string }> = []
84
+ const finishCalls: Array<{ agentId: string; outcome: string }> = []
85
+
86
+ const agentDir = '/home/user/.switchroom/agents/myagent'
87
+ const sessionId = 'mock-session'
88
+ const projectsRoot = `${agentDir}/.claude/projects`
89
+ const projectDir = `${projectsRoot}/mock-cwd`
90
+ const sessionDir = `${projectDir}/${sessionId}`
91
+ const subagentsDir = `${sessionDir}/subagents`
92
+ const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
93
+ const fileContents = new Map<string, Buffer>()
94
+ // Start with ONLY the dispatch user message — no tool, no prose. The Bash
95
+ // tool_use is appended post-boot so it fires onProgress on a live (non-
96
+ // historical) entry, exactly like a worker that starts then dives into Bash.
97
+ fileContents.set(jsonlPath, Buffer.from(buildJSONL(subAgentUserMsg('run the integration suite')), 'utf-8'))
98
+
99
+ let lastOpenedPath: string | null = null
100
+ const mockFs = {
101
+ existsSync: ((p: fs.PathLike) => {
102
+ const ps = String(p)
103
+ if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
104
+ return fileContents.has(ps)
105
+ }) as typeof fs.existsSync,
106
+ readdirSync: ((p: fs.PathLike) => {
107
+ const ps = String(p)
108
+ if (ps === projectsRoot) return ['mock-cwd']
109
+ if (ps === projectDir) return [sessionId]
110
+ if (ps === sessionDir) return ['subagents']
111
+ if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
112
+ return []
113
+ }) as unknown as typeof fs.readdirSync,
114
+ statSync: ((p: fs.PathLike) => ({ size: fileContents.get(String(p))?.length ?? 0 }) as fs.Stats) as typeof fs.statSync,
115
+ openSync: ((p: fs.PathLike) => { lastOpenedPath = String(p); return 42 }) as unknown as typeof fs.openSync,
116
+ closeSync: (() => { lastOpenedPath = null }) as typeof fs.closeSync,
117
+ readSync: ((
118
+ _fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
119
+ ): number => {
120
+ const content = lastOpenedPath != null ? fileContents.get(lastOpenedPath) : undefined
121
+ if (!content) return 0
122
+ const pos = position ?? 0
123
+ const src = content.slice(pos, pos + length)
124
+ ;(src as Buffer).copy(buf as Buffer, offset)
125
+ return src.length
126
+ }) as unknown as typeof fs.readSync,
127
+ watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
128
+ }
129
+
130
+ // One shared interval list drives BOTH the watcher (rescan + checkStalls)
131
+ // and the feed (heartbeat) off the same virtual clock.
132
+ const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
133
+ let nextRef = 1
134
+ const sharedSetInterval = (fn: () => void, ms: number): unknown => {
135
+ const ref = nextRef++
136
+ intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
137
+ return { ref }
138
+ }
139
+ const sharedClearInterval = (h: unknown): void => {
140
+ const { ref } = h as { ref: number }
141
+ const idx = intervals.findIndex((i) => i.ref === ref)
142
+ if (idx !== -1) intervals.splice(idx, 1)
143
+ }
144
+
145
+ const bot = makeFakeBot()
146
+ const feed = createWorkerActivityFeed({
147
+ bot,
148
+ now: () => currentTime,
149
+ firstPaintMinMs: 8000,
150
+ heartbeatTickMs: 6000,
151
+ minEditIntervalMs: 2500,
152
+ setInterval: sharedSetInterval,
153
+ clearInterval: sharedClearInterval,
154
+ })
155
+
156
+ const watcher = startSubagentWatcher({
157
+ agentDir,
158
+ stallThresholdMs: 60_000, // tight active-loop threshold
159
+ silentSynthesisStallThresholdMs: 300_000, // widened window (also long-runner window)
160
+ silentStallTerminalMs: 300_000, // post-stall terminal-synthesis window (unchanged)
161
+ rescanMs: 500,
162
+ now: () => currentTime,
163
+ setInterval: sharedSetInterval,
164
+ clearInterval: sharedClearInterval,
165
+ fs: mockFs,
166
+ onStall: (id, idleMs) => stallCalls.push({ agentId: id, idleMs }),
167
+ onStallTerminal: (id) => stallTerminalCalls.push({ agentId: id }),
168
+ onFinish: ({ agentId: id, outcome, description, resultText, toolCount, durationMs }) => {
169
+ finishCalls.push({ agentId: id, outcome })
170
+ // Mirror the gateway's terminal wiring: force the worker feed's recap.
171
+ void feed.finish(id, {
172
+ description,
173
+ lastTool: null,
174
+ toolCount,
175
+ latestSummary: resultText,
176
+ elapsedMs: durationMs,
177
+ state: outcome === 'failed' ? 'failed' : 'done',
178
+ })
179
+ },
180
+ // Mirror the gateway's onProgress → workerActivityFeed.update wiring for a
181
+ // background worker (worker-feed owns the progress beat).
182
+ onProgress: ({ agentId: id, description, latestSummary, elapsedMs, lastTool, toolCount, progressLine }) => {
183
+ const view: WorkerActivityView = {
184
+ description,
185
+ lastTool,
186
+ toolCount,
187
+ // Mirror the gateway's step-line precedence: the friendly tool label
188
+ // on tool ticks, else the narrative (the unified-cards fix — a
189
+ // tools-only worker must grow real steps, not freeze on "starting…").
190
+ latestSummary: progressLine != null && progressLine.length > 0 ? progressLine : latestSummary,
191
+ elapsedMs,
192
+ state: 'running',
193
+ }
194
+ void feed.update(id, chatId, view)
195
+ },
196
+ })
197
+
198
+ const advance = (ms: number): void => {
199
+ currentTime += ms
200
+ for (;;) {
201
+ intervals.sort((a, b) => a.fireAt - b.fireAt)
202
+ const next = intervals[0]
203
+ if (!next || next.fireAt > currentTime) break
204
+ next.fireAt += next.ms
205
+ next.fn()
206
+ }
207
+ }
208
+
209
+ const appendBash = (): void => {
210
+ const existing = fileContents.get(jsonlPath) ?? Buffer.from('')
211
+ const line = JSON.stringify(bashToolUse('tool-bash-1', 'npm run test:integration')) + '\n'
212
+ fileContents.set(jsonlPath, Buffer.concat([existing, Buffer.from(line, 'utf-8')]))
213
+ }
214
+
215
+ return {
216
+ agentId, bot, feed, watcher, advance, appendBash, flush,
217
+ stallCalls, stallTerminalCalls, finishCalls,
218
+ unmarkHistorical: () => {
219
+ const e = watcher.getRegistry().get(agentId)
220
+ if (e) e.historical = false
221
+ },
222
+ registry: () => watcher.getRegistry(),
223
+ }
224
+ }
225
+
226
+ describe('prose-silent background worker — end-to-end visibility harness (PR 2)', () => {
227
+ it('paints, keeps updating, does not falsely stall, and still releases the terminal gate', async () => {
228
+ const h = makeHarness()
229
+
230
+ // Boot: register the entry (historical=true at boot). Flip it live to model
231
+ // an entry discovered post-boot (the only case progress/stall fire).
232
+ h.advance(500)
233
+ h.unmarkHistorical()
234
+
235
+ // The worker dives into a long Bash. The single tool_use fires onProgress —
236
+ // BEFORE firstPaintMin — so the feed HOLDS the paint (this is the trap).
237
+ h.appendBash()
238
+ h.advance(500) // poll reads the Bash line → onProgress(tool) → feed.update
239
+ await h.flush()
240
+ const entry = h.registry().get(h.agentId)
241
+ expect(entry?.lastTool?.name).toBe('Bash')
242
+ expect(entry?.toolCount).toBe(1)
243
+ // Held: elapsed (~1s) < firstPaintMin (8s), no paint yet.
244
+ expect(h.bot.sent).toHaveLength(0)
245
+ expect(h.feed.messageIdOf(h.agentId)).toBeNull()
246
+
247
+ // (a) PAINT: with NO further JSONL events, the heartbeat performs the first
248
+ // paint once the held handle is past firstPaintMin. Pre-fix this never
249
+ // happened (heartbeat skipped messageId==null) → the worker was invisible.
250
+ h.advance(12_000) // ~12.5s since dispatch — past firstPaintMin, heartbeats fire
251
+ await h.flush()
252
+ expect(h.bot.sent).toHaveLength(1)
253
+ expect(h.feed.messageIdOf(h.agentId)).not.toBeNull()
254
+ expect(h.bot.sent[0].chatId).toBe('chat-42')
255
+ expect(h.bot.sent[0].text).toContain('🛠 **Worker**')
256
+
257
+ // (b) KEEPS UPDATING: later heartbeats edit the message with a climbing
258
+ // `· Ns` suffix so the still-alive worker visibly advances.
259
+ h.advance(30_000)
260
+ await h.flush()
261
+ expect(h.bot.edits.length).toBeGreaterThanOrEqual(1)
262
+ expect(h.bot.edits[h.bot.edits.length - 1].text).toMatch(/· \d+/)
263
+
264
+ // (c) NO FALSE STALL during the Bash window: the worker's last tool is a
265
+ // long-runner, so the tight 60s active-loop threshold does NOT apply. We
266
+ // are now well past 60s of JSONL silence with zero stall flagged.
267
+ h.advance(60_000) // total idle ~102s — far past the old 60s misfire point
268
+ await h.flush()
269
+ expect(h.stallCalls).toHaveLength(0)
270
+ // The feed is still live and updating during this window.
271
+ expect(h.bot.edits.length).toBeGreaterThanOrEqual(2)
272
+
273
+ // The stall IS eventually flagged once past the widened silent-synthesis
274
+ // window (the worker genuinely never wrote another line / turn_end).
275
+ h.advance(260_000) // total idle > 300s → stall flagged
276
+ await h.flush()
277
+ expect(h.stallCalls).toHaveLength(1)
278
+ expect(h.stallCalls[0].agentId).toBe(h.agentId)
279
+ expect(h.stallTerminalCalls).toHaveLength(0)
280
+
281
+ // (d) TERMINAL GATE RELEASE: 300s past the stall, the silent-stall terminal
282
+ // synthesis fires — onStallTerminal + onFinish — and the feed finalizes.
283
+ // This is the untouched, load-bearing completion path.
284
+ h.advance(310_000)
285
+ await h.flush()
286
+ expect(h.stallTerminalCalls).toHaveLength(1)
287
+ expect(h.finishCalls).toHaveLength(1)
288
+ expect(h.finishCalls[0].agentId).toBe(h.agentId)
289
+ // The feed's terminal recap edited the existing worker message to done.
290
+ const lastEdit = h.bot.edits[h.bot.edits.length - 1]
291
+ expect(lastEdit.text).toContain('done')
292
+ // Handle dropped after finish (gateway unpins independently).
293
+ expect(h.feed.messageIdOf(h.agentId)).toBeNull()
294
+ })
295
+ })
@@ -192,6 +192,25 @@ export function formatFeedElapsed(ms: number): string {
192
192
  return `${m}m${(s % 60).toString().padStart(2, '0')}s`
193
193
  }
194
194
 
195
+ /**
196
+ * Minimum time the CURRENT step must have been running before its own
197
+ * `· <elapsed>` suffix appears on the `→` line. Under this, no suffix — a
198
+ * fresh step reads cleaner without a timer, and the header total already
199
+ * carries the turn/worker elapsed.
200
+ */
201
+ export const STEP_TIMER_MIN_MS = 10_000
202
+
203
+ /**
204
+ * Live-suffix for the in-progress step line: the STEP's OWN elapsed (time
205
+ * since that step started — NOT the turn/worker total, which lives in the
206
+ * header). Empty string until the step has run ≥ STEP_TIMER_MIN_MS, so the
207
+ * suffix never duplicates the header total on a young step.
208
+ */
209
+ export function formatStepSuffix(stepElapsedMs: number): string {
210
+ if (stepElapsedMs < STEP_TIMER_MIN_MS) return ''
211
+ return ` · ${formatFeedElapsed(stepElapsedMs)}`
212
+ }
213
+
195
214
  // ─── Truncation pipeline (the single correctness-critical primitive) ────────
196
215
  //
197
216
  // Per RAW line, in this EXACT order:
@@ -190,7 +190,22 @@ export function decideTurnFlush(input: FlushDecisionInput): FlushDecision {
190
190
  if (input.replyCalled) return { kind: 'skip', reason: 'reply-called' }
191
191
 
192
192
  if (input.chatId == null) return { kind: 'skip', reason: 'no-inbound-chat' }
193
- const joined = input.capturedText.join('\n').trim()
193
+ // #2798 join whole authored assistant text blocks with a PARAGRAPH break,
194
+ // not a single newline. Each `capturedText` element is one complete
195
+ // `content[i].text` block (session-tail.ts `projectAssistantTextBlocks`), so
196
+ // the boundary between two elements is a paragraph boundary. Joining with a
197
+ // lone `\n` collapses adjacent blocks into one run — on the Bot API 10.1
198
+ // rich-markdown path (#2669) a single newline is a soft break, so the blocks
199
+ // render as an undifferentiated wall-of-text. `\n\n` is the GFM paragraph
200
+ // separator; the gateway send path then wedges visible spacers into those
201
+ // gaps via addParagraphSpacers (mirroring the reply path, #2692) so the
202
+ // paragraphs render with real separation.
203
+ //
204
+ // The silent-marker guards below are unaffected by this change:
205
+ // isSilentFlushMarker length-guards the whole joined string; the composite /
206
+ // trailing-marker guards split on '\n' and filter empty lines, so the extra
207
+ // blank line an '\n\n' join introduces is discarded before matching.
208
+ const joined = input.capturedText.join('\n\n').trim()
194
209
  if (joined.length === 0) return { kind: 'skip', reason: 'empty-text' }
195
210
  if (isSilentFlushMarker(joined)) return { kind: 'skip', reason: 'silent-marker' }
196
211
  // Composite silent noise — e.g. "Sent.\nNO_REPLY\nNO_REPLY" accumulated
@@ -17,15 +17,14 @@
17
17
  * Runs under CI `uat-gate`; the full live MTProto run needs the test-harness
18
18
  * agent + a vault session, so locally this self-skips green (no driver).
19
19
  *
20
- * Scope caveat: this end-to-end scenario only exercises PR-2's upgrade code
21
- * path when the harness model delivers its final answer via the `reply` tool.
22
- * If the model answers via `stream_reply` instead, that path bypasses the
23
- * over-ping safety net entirely (it never reaches `decideOverPing`), so the
24
- * upgrade-over-ack logic is never touched. The model's tool choice isn't
25
- * forceable here, which makes this scenario a WEAKER backstop than the unit
26
- * matrix the real proof of the upgrade behaviour lives in the deterministic
27
- * unit tests in `over-ping-final-answer-decoupling.test.ts`. Treat this as a
28
- * live smoke-test of the happy path, not the source of truth.
20
+ * Scope caveat: this end-to-end scenario exercises PR-2's upgrade code path
21
+ * when the harness model delivers its final answer via the `reply` tool
22
+ * which is now the single final-answer tool (the redundant `stream_reply`
23
+ * tool was retired). The model's exact phrasing isn't forceable here, which
24
+ * makes this scenario a WEAKER backstop than the unit matrix — the real proof
25
+ * of the upgrade behaviour lives in the deterministic unit tests in
26
+ * `over-ping-final-answer-decoupling.test.ts`. Treat this as a live
27
+ * smoke-test of the happy path, not the source of truth.
29
28
  */
30
29
  import { describe, it, expect, beforeAll } from "vitest";
31
30
  import { spinUp, type Scenario } from "../harness.js";
@@ -8,7 +8,7 @@
8
8
  * (`SILENCE_FALLBACK_MS`, default 300 000 ms) even while it is visibly
9
9
  * progressing. The silence clock is reset by:
10
10
  *
11
- * - A fresh `reply` or `stream_reply` first-emit (any real user-visible send).
11
+ * - A fresh `reply` first-emit (any real user-visible send).
12
12
  * - `SILENCE_LIVENESS_PRODUCTION` ON (the default): a new tool-activity label
13
13
  * appearing on the feed, or an answer-stream draft update.
14
14
  *
@@ -100,7 +100,7 @@ describe("uat-jtbd: narration intent before a silent tool stretch", () => {
100
100
  const toolCount = events.filter((e) => e.kind === "tool_use").length;
101
101
  expect(toolCount).toBeGreaterThan(1);
102
102
  const replyToolFired = events.some(
103
- (e) => e.kind === "tool_use" && /^(reply|stream_reply)$/.test(e.toolName),
103
+ (e) => e.kind === "tool_use" && /^reply$/.test(e.toolName),
104
104
  );
105
105
  expect(replyToolFired).toBe(false);
106
106
  });
@@ -38,7 +38,7 @@ import {
38
38
  truncate,
39
39
  } from './card-format.js'
40
40
  import { STATUS_ROLLING_LINES } from './status-no-truncate.js'
41
- import { renderStatusCard, formatFeedElapsed } from './tool-activity-summary.js'
41
+ import { renderStatusCard, formatStepSuffix } from './tool-activity-summary.js'
42
42
 
43
43
  /** Worker-activity feed is ON by default; an operator opts out with
44
44
  * SWITCHROOM_WORKER_ACTIVITY_FEED=0. */
@@ -226,6 +226,13 @@ interface WorkerHandle {
226
226
  * `· Ns` suffix climbs even when no fresh view arrives.
227
227
  */
228
228
  dispatchAtMs: number | null
229
+ /**
230
+ * Wall-clock ms the CURRENT step started — stamped whenever a NEW narrative
231
+ * line lands (the `→` line changes). The heartbeat's step suffix shows the
232
+ * step's OWN elapsed from this anchor (not the worker total, which the
233
+ * header already carries), and only once past STEP_TIMER_MIN_MS.
234
+ */
235
+ stepStartedAtMs: number | null
229
236
  }
230
237
 
231
238
  const COOLDOWN_JITTER_MS = 500
@@ -311,10 +318,19 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
311
318
  function accumulateNarrative(h: WorkerHandle, view: WorkerActivityView): void {
312
319
  const line = view.latestSummary.trim()
313
320
  if (line.length === 0) return
314
- // Dedup against the immediately-preceding line the watcher re-emits the
315
- // same narrative across ticks while a tool runs; we only grow on change.
316
- if (h.narrative[h.narrative.length - 1] === line) return
321
+ // Dedup within the whole rolling window, not just the immediately-
322
+ // preceding line. The watcher re-emits the same narrative across ticks
323
+ // while a tool runs (adjacent repeats), AND one logical step can surface
324
+ // twice non-adjacently — e.g. a "Look for X" preamble followed later by
325
+ // the Task tool whose describeToolUse label is the same "Look for X"
326
+ // description, interleaved with another step (the A,B,A duplication the
327
+ // operator observed on live cards). A legitimate later re-visit of the
328
+ // same step re-appears once the earlier copy scrolls out of the window.
329
+ if (h.narrative.includes(line)) return
317
330
  h.narrative.push(line)
331
+ // The `→` current-step line just CHANGED — reset the per-step timer so the
332
+ // heartbeat's `· Ns` suffix measures THIS step, not the whole worker run.
333
+ h.stepStartedAtMs = nowFn()
318
334
  // Rolling window — keep only the last STATUS_ROLLING_LINES in memory. The
319
335
  // render shows exactly those lines (clipped per-line by the unified pipeline);
320
336
  // fitCardToBudget is the wire-limit backstop.
@@ -405,13 +421,27 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
405
421
  }
406
422
 
407
423
  /**
408
- * Heartbeat — option (a), suffix-only, NEVER opens a new message. For each
409
- * handle with a posted message, enqueue a re-render through the existing
410
- * chain → doUpdate path (never editMessageText directly). Skips:
411
- * - handles with no posted message (messageId == null),
424
+ * Heartbeat — keeps a running worker's message alive AND performs the
425
+ * FIRST paint for a prose-silent worker whose only tick arrived before
426
+ * `firstPaintMin`.
427
+ *
428
+ * Why the first-paint branch exists: a background worker that dives
429
+ * straight into quiet work (e.g. a long `Bash` / `npm test`) emits a
430
+ * single `sub_agent_tool_use` event when the command is invoked, then no
431
+ * further JSONL lines for the whole run. That one tick drives `update`
432
+ * once — but if it lands before `firstPaintMin` the paint is held, and
433
+ * with no subsequent tick nothing ever re-drives it, so the worker shows
434
+ * NOTHING for its entire run (the "I can't see the worker" gap). The
435
+ * heartbeat closes it: once such a handle is past `firstPaintMin`, drive a
436
+ * paint here through the same chain → doUpdate path. After first paint the
437
+ * suffix-only maintenance branch keeps it advancing.
438
+ *
439
+ * For handles that already have a posted message, this is the original
440
+ * option-(a), suffix-only re-render (never editMessageText directly). Skips:
412
441
  * - handles inside a 429 cooldown,
413
- * - handles edited within minEditInterval (no stampede),
414
- * - non-running handles (terminal/deleted).
442
+ * - handles with no `lastView` (no update ever arrived) or non-running,
443
+ * - for the maintenance branch: handles edited within minEditInterval
444
+ * (no stampede) or whose current step isn't yet stale.
415
445
  * The `· Ns` liveSuffix is applied ONLY when the worker's current step is
416
446
  * stale (now - lastEditAt >= heartbeatTickMs) so a normally-ticking worker is
417
447
  * untouched and its body stays byte-stable for the dedup.
@@ -419,19 +449,48 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
419
449
  function heartbeatTick(): void {
420
450
  const now = nowFn()
421
451
  for (const h of handles.values()) {
422
- if (h.messageId == null) continue
452
+ // Orphan-paint guard: `finish()` deletes the handle in a `.finally` that
453
+ // may not have drained if a tick fires in the same synchronous stretch.
454
+ // Skip any handle no longer in the map so the first-paint branch below
455
+ // can never send a fresh `running` message on an already-finished worker
456
+ // (which would orphan a card that never finalizes). Restores the
457
+ // structural safety the pre-first-paint `messageId == null` skip gave.
458
+ if (!handles.has(h.agentId)) continue
423
459
  if (h.lastView == null) continue
424
460
  if (h.lastView.state !== 'running') continue
425
461
  if (now < h.cooldownUntil) continue
462
+
463
+ const liveElapsed = h.dispatchAtMs != null ? now - h.dispatchAtMs : h.lastView.elapsedMs
464
+
465
+ // First-paint path: a prose-silent worker's single early tick was held
466
+ // (elapsed < firstPaintMin) and no further tick re-drove it. Once it is
467
+ // past firstPaintMin, drive the paint. doUpdate's send branch re-checks
468
+ // firstPaintMin against the refreshed elapsed, so this is exact.
469
+ if (h.messageId == null) {
470
+ if (liveElapsed < firstPaintMin) continue
471
+ const view = { ...h.lastView, elapsedMs: Math.max(h.lastView.elapsedMs, liveElapsed) }
472
+ h.chain = h.chain
473
+ .then(() => doUpdate(h, view))
474
+ .catch((err) => {
475
+ log(`worker-feed: heartbeat first-paint chain error ${h.agentId}: ${(err as Error).message}`)
476
+ })
477
+ continue
478
+ }
479
+
426
480
  if (now - h.lastEditAt < minEditInterval) continue
427
481
  const stale = now - h.lastEditAt >= heartbeatTickMs
428
482
  if (!stale) continue
429
- const liveElapsed = h.dispatchAtMs != null ? now - h.dispatchAtMs : h.lastView.elapsedMs
430
- const liveSuffix = ' · ' + formatFeedElapsed(liveElapsed)
483
+ // Per-step suffix: the CURRENT step's own elapsed (since the `→` line
484
+ // last changed), never the worker total — the header already shows the
485
+ // total, and repeating it on the step line was the Ken-observed dupe.
486
+ // Under STEP_TIMER_MIN_MS formatStepSuffix returns '' (no timer yet);
487
+ // the header elapsed still climbs via the refreshed view below.
488
+ const stepElapsed = h.stepStartedAtMs != null ? now - h.stepStartedAtMs : liveElapsed
489
+ const liveSuffix = formatStepSuffix(stepElapsed)
431
490
  // Re-render THROUGH the chain + doUpdate path — never editMessageText directly.
432
491
  //
433
- // CLOCK-ANCHOR PARITY: refresh the view's elapsedMs to the SAME
434
- // `liveElapsed` the step suffix shows. The header renders
492
+ // CLOCK-ANCHOR PARITY: refresh the view's elapsedMs to the same `now`
493
+ // anchor the step suffix uses. The header renders
435
494
  // `view.elapsedMs`; passing the stale lastView froze the header at the
436
495
  // last watcher event while the `· Ns` suffix kept ticking, so the
437
496
  // current step's timer could read MORE than the card's master elapsed
@@ -478,6 +537,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
478
537
  chain: Promise.resolve(),
479
538
  lastView: null,
480
539
  dispatchAtMs: null,
540
+ stepStartedAtMs: null,
481
541
  }
482
542
  handles.set(agentId, h)
483
543
  }
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Ported from upstream (vectorize-io/hindsight, `hindsight-integrations/claude-code/`)
6
+
7
+ - `c5a61db2b` — raise `_check_health` default timeout 2s→10s in
8
+ `scripts/lib/daemon.py` to stop the busy-daemon restart/kill loop
9
+ (applied clean; codex-integration hunk not applicable).
10
+ - `3d6c2ba8b` — label "Current time" as UTC in the recall context block
11
+ (`lib/content.py:format_current_time`), so client LLMs in non-UTC
12
+ timezones don't misread the timestamp as local time.
13
+ - `962140eef` — recall tag filters: `recallTags`, `recallTagsMatch`,
14
+ `recallTagGroups`, plus per-additional-bank overrides via
15
+ `recallAdditionalBankFilters`. Hand-ported into the switchroom recall.py
16
+ rewrite: filters compose with sender-bank routing (per-bank overrides
17
+ apply to sender banks too) and are part of the recall cache key
18
+ (`_tag_filter_sig`) so a filter change can't serve stale cached results.
19
+ Note: because the key now joins an extra part (empty string when filters
20
+ are unused), every cache key rotates ONCE across this upgrade boundary —
21
+ the first recall per session after upgrading is a cache miss. Within a
22
+ version, keys are unchanged as long as filters stay unused.
23
+ - `55ef70679` — optional `requestTimeoutSeconds` /
24
+ `HINDSIGHT_REQUEST_TIMEOUT_SECONDS` global request-timeout override in
25
+ `HindsightClient` (adapted to our `_request`). Wired into retain.py only;
26
+ recall.py deliberately keeps its own 8s hook-budget timeout. Upstream's
27
+ mcp_server.py hunks skipped (not vendored).
28
+
5
29
  ### Added
6
30
 
7
31
  - `{user_id}` template variable for `retainTags` and `retainMetadata`, resolved
@@ -186,6 +186,11 @@ Auto-recall runs on every user prompt. It queries Hindsight for relevant memorie
186
186
  | `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `1` | How many prior conversation turns to include when composing the recall query. `1` = only the latest user message; higher values give more context but may dilute the query. |
187
187
  | `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Maximum character length of the query sent to Hindsight. Longer queries are truncated. |
188
188
  | `recallRoles` | — | `["user", "assistant"]` | Which message roles to include when building the recall query from prior turns. |
189
+ | `recallTags` | `HINDSIGHT_RECALL_TAGS` | `[]` | Optional tags to pass to the recall API, such as `["memory_type:rule"]`. The env var accepts JSON or a comma-separated list. |
190
+ | `recallTagsMatch` | `HINDSIGHT_RECALL_TAGS_MATCH` | `"any"` | Tag matching mode used with `recallTags` or `recallTagGroups`: `"any"`, `"all"`, `"any_strict"`, or `"all_strict"`. |
191
+ | `recallTagGroups` | `HINDSIGHT_RECALL_TAG_GROUPS` | `null` | Optional compound tag filter passed through to the recall API. The env var must be JSON. |
192
+ | `recallAdditionalBankFilters` | `HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS` | `{}` | Optional per-bank tag filter overrides for banks listed in `recallAdditionalBanks`, keyed by bank ID. Each value may set `recallTags`, `recallTagsMatch`, and `recallTagGroups`. The env var must be JSON. |
193
+ | `requestTimeoutSeconds` | `HINDSIGHT_REQUEST_TIMEOUT_SECONDS` | unset | Optional global HTTP request timeout override (seconds) for retain and API calls. The recall hook keeps its own 8s hook-budget timeout regardless. |
189
194
  | `recallPromptPreamble` | — | built-in string | Text placed above the recalled memories in the injected context block. Customize this to change how Claude interprets the memories. |
190
195
 
191
196
  ---
@@ -44,9 +44,29 @@ def _validate_api_url(url: str) -> str:
44
44
  class HindsightClient:
45
45
  """HTTP client for the Hindsight API."""
46
46
 
47
- def __init__(self, api_url: str, api_token: Optional[str] = None):
47
+ def __init__(
48
+ self,
49
+ api_url: str,
50
+ api_token: Optional[str] = None,
51
+ request_timeout_override: Optional[int] = None,
52
+ ):
48
53
  self.api_url = _validate_api_url(api_url)
49
54
  self.api_token = api_token
55
+ self.request_timeout_override = request_timeout_override
56
+
57
+ def _resolve_timeout(self, timeout: int) -> int:
58
+ """Return the override if configured, otherwise the caller's timeout.
59
+
60
+ Upstream 55ef70679. NOTE: recall.py deliberately does not pass the
61
+ override — its 8s timeout is a hook-budget invariant.
62
+
63
+ The override is clamped to >= 1: a zero/negative env value would
64
+ otherwise reach urlopen as a nonsensical timeout (0 fails every
65
+ request immediately), turning a config typo into a dead client.
66
+ """
67
+ if self.request_timeout_override is None:
68
+ return timeout
69
+ return max(1, self.request_timeout_override)
50
70
 
51
71
  def _headers(self) -> dict:
52
72
  headers = {
@@ -58,6 +78,7 @@ class HindsightClient:
58
78
  return headers
59
79
 
60
80
  def _request(self, method: str, path: str, body: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> dict:
81
+ timeout = self._resolve_timeout(timeout)
61
82
  url = f"{self.api_url}{path}"
62
83
  data = json.dumps(body).encode() if body else None
63
84
  req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
@@ -100,6 +121,9 @@ class HindsightClient:
100
121
  max_tokens: int = 1024,
101
122
  budget: str = "mid",
102
123
  types: Optional[list] = None,
124
+ tags: Optional[list] = None,
125
+ tags_match: Optional[str] = None,
126
+ tag_groups: Optional[object] = None,
103
127
  timeout: int = 10,
104
128
  ) -> dict:
105
129
  """Recall memories from a bank.
@@ -115,6 +139,12 @@ class HindsightClient:
115
139
  body["budget"] = budget
116
140
  if types:
117
141
  body["types"] = types
142
+ if tags:
143
+ body["tags"] = tags
144
+ if tags_match:
145
+ body["tags_match"] = tags_match
146
+ if tag_groups:
147
+ body["tag_groups"] = tag_groups
118
148
  return self._request("POST", path, body, timeout=timeout)
119
149
 
120
150
  def retain(