switchroom 0.18.12 → 0.18.13

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 (49) hide show
  1. package/dist/agent-scheduler/index.js +8 -0
  2. package/dist/auth-broker/index.js +63 -65
  3. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  4. package/dist/cli/notion-write-pretool.mjs +9 -1
  5. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  6. package/dist/cli/switchroom.js +952 -3126
  7. package/dist/host-control/main.js +216 -2862
  8. package/dist/vault/approvals/kernel-server.js +67 -0
  9. package/dist/vault/broker/server.js +98 -44
  10. package/package.json +1 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +49 -3
  12. package/telegram-plugin/dist/gateway/gateway.js +656 -2326
  13. package/telegram-plugin/dist/server.js +65 -3
  14. package/telegram-plugin/format.ts +19 -0
  15. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  16. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  17. package/telegram-plugin/gateway/gateway.ts +221 -73
  18. package/telegram-plugin/history.ts +51 -0
  19. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  20. package/telegram-plugin/model-unavailable.ts +41 -11
  21. package/telegram-plugin/outbound-field-redact.ts +69 -0
  22. package/telegram-plugin/render/render.ts +32 -14
  23. package/telegram-plugin/scoped-approval.ts +11 -2
  24. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  25. package/telegram-plugin/secret-detect/index.ts +12 -56
  26. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  27. package/telegram-plugin/send-gate.test.ts +25 -6
  28. package/telegram-plugin/send-gate.ts +82 -8
  29. package/telegram-plugin/session-tail.ts +82 -7
  30. package/telegram-plugin/subagent-watcher.ts +71 -16
  31. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  32. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  33. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  34. package/telegram-plugin/tests/history.test.ts +115 -0
  35. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  36. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  37. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  38. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  39. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  40. package/telegram-plugin/tests/render/render.test.ts +88 -0
  41. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  42. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  43. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  44. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  45. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  46. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  47. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  48. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  49. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -0,0 +1,275 @@
1
+ /**
2
+ * FD-leak regression tests for the subagent-watcher (review findings H1 + H2).
3
+ *
4
+ * H1 — directory FSWatchers were only ever closed in the global stop(). When a
5
+ * Claude session's `subagents/` dir was reaped, the rescan loop skipped
6
+ * the vanished path without closing its watcher, so every session leaked
7
+ * one inotify FD for the gateway's lifetime.
8
+ *
9
+ * H2 — every boot-scanned file opened a per-file FSWatcher unconditionally,
10
+ * including stale historical `running` entries that `checkStalls` skips
11
+ * and that therefore never reach terminal cleanup — leaking their FD until
12
+ * the file happened to vanish.
13
+ *
14
+ * Each test FAILS on pre-fix code (an unclosed / never-opened-guarded watcher)
15
+ * and PASSES with the runtime-close / open-gating fix.
16
+ */
17
+
18
+ import { describe, it, expect, vi } from 'vitest'
19
+ import type * as fs from 'fs'
20
+ import { startSubagentWatcher } from '../subagent-watcher.js'
21
+
22
+ interface FakeWatcher {
23
+ path: string
24
+ close: ReturnType<typeof vi.fn>
25
+ closed: boolean
26
+ }
27
+
28
+ /**
29
+ * Minimal watcher harness with a MUTABLE fake filesystem (so a test can make a
30
+ * directory or file vanish mid-run) and per-watcher path tracking (so we can
31
+ * assert exactly which watchers were opened / closed).
32
+ */
33
+ function makeHarness(opts: {
34
+ agentDir?: string
35
+ dirs: Record<string, string[]>
36
+ fileSizes?: Record<string, number>
37
+ rescanMs?: number
38
+ }) {
39
+ const agentDir = opts.agentDir ?? '/home/user/.switchroom/agents/myagent'
40
+ const rescanMs = opts.rescanMs ?? 500
41
+ const dirs = new Map<string, string[]>(Object.entries(opts.dirs))
42
+ const fileSizes = new Map<string, number>(Object.entries(opts.fileSizes ?? {}))
43
+ const logs: string[] = []
44
+ const watchers: FakeWatcher[] = []
45
+ let currentTime = 1_000_000
46
+
47
+ const existsSync = ((p: fs.PathLike) => {
48
+ const ps = String(p)
49
+ return dirs.has(ps) || fileSizes.has(ps)
50
+ }) as typeof fs.existsSync
51
+
52
+ const mockFs = {
53
+ existsSync,
54
+ readdirSync: ((p: fs.PathLike) => dirs.get(String(p)) ?? []) as unknown as typeof fs.readdirSync,
55
+ // No mtimeMs → boot-promotion freshness gate treats a running file as
56
+ // stale (dead prior-session worker), so it stays historical + unpromoted.
57
+ statSync: ((p: fs.PathLike) => ({ size: fileSizes.get(String(p)) ?? 0 }) as fs.Stats) as typeof fs.statSync,
58
+ openSync: (() => 42) as unknown as typeof fs.openSync,
59
+ closeSync: (() => undefined) as typeof fs.closeSync,
60
+ readSync: (() => 0) as unknown as typeof fs.readSync,
61
+ watch: ((p: fs.PathLike) => {
62
+ const w: FakeWatcher = {
63
+ path: String(p),
64
+ closed: false,
65
+ close: vi.fn(() => { w.closed = true }),
66
+ }
67
+ watchers.push(w)
68
+ return w as unknown as fs.FSWatcher
69
+ }) as unknown as typeof fs.watch,
70
+ }
71
+
72
+ const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
73
+ const timeouts: Array<{ fn: () => void; ref: number; fireAt: number }> = []
74
+ let nextRef = 1
75
+
76
+ const watcher = startSubagentWatcher({
77
+ agentDir,
78
+ onFinish: () => {},
79
+ stallThresholdMs: 60_000,
80
+ silentSynthesisStallThresholdMs: 60_000,
81
+ rescanMs,
82
+ now: () => currentTime,
83
+ setInterval: (fn, ms) => {
84
+ const ref = nextRef++
85
+ intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
86
+ return { ref }
87
+ },
88
+ clearInterval: (handle) => {
89
+ const { ref } = handle as { ref: number }
90
+ const idx = intervals.findIndex((i) => i.ref === ref)
91
+ if (idx !== -1) intervals.splice(idx, 1)
92
+ },
93
+ setTimeout: (fn, ms) => {
94
+ const ref = nextRef++
95
+ timeouts.push({ fn, ref, fireAt: currentTime + ms })
96
+ return { ref }
97
+ },
98
+ clearTimeout: (handle) => {
99
+ const { ref } = handle as { ref: number }
100
+ const idx = timeouts.findIndex((t) => t.ref === ref)
101
+ if (idx !== -1) timeouts.splice(idx, 1)
102
+ },
103
+ fs: mockFs,
104
+ log: (msg: string) => { logs.push(msg) },
105
+ })
106
+
107
+ const poll = (): void => {
108
+ // intervals[0] is the poll loop (registered first — see startSubagentWatcher).
109
+ intervals[0]?.fn()
110
+ }
111
+
112
+ return {
113
+ watcher,
114
+ watchers,
115
+ logs,
116
+ dirs,
117
+ fileSizes,
118
+ poll,
119
+ fileWatchers: () => watchers.filter((w) => w.path.endsWith('.jsonl')),
120
+ dirWatchersFor: (p: string) => watchers.filter((w) => w.path === p),
121
+ }
122
+ }
123
+
124
+ const PROJECTS = '/home/user/.switchroom/agents/myagent/.claude/projects'
125
+
126
+ describe('subagent-watcher FD-leak (H1): dir watchers close when their session dir vanishes', () => {
127
+ it('closes and forgets the subagents-dir FSWatcher after the session dir is reaped', () => {
128
+ const projectDir = `${PROJECTS}/myproject`
129
+ const sessionDir = `${projectDir}/session-A`
130
+ const subagentsDir = `${sessionDir}/subagents`
131
+
132
+ const h = makeHarness({
133
+ dirs: {
134
+ [PROJECTS]: ['myproject'],
135
+ [projectDir]: ['session-A'],
136
+ [sessionDir]: ['subagents'],
137
+ [subagentsDir]: [], // empty subagents dir — still gets a dir watcher
138
+ },
139
+ })
140
+
141
+ // Boot scan already ran in the constructor; poll once to be certain the
142
+ // dir watcher for the subagents dir has been opened.
143
+ h.poll()
144
+ const dw = h.dirWatchersFor(subagentsDir)
145
+ expect(dw).toHaveLength(1)
146
+ expect(dw[0].closed).toBe(false)
147
+
148
+ // Claude Code reaps the whole session directory (session rotation).
149
+ h.dirs.delete(sessionDir)
150
+ h.dirs.delete(subagentsDir)
151
+ h.dirs.set(projectDir, []) // session-A gone from the project listing
152
+
153
+ // Next rescan tick must release the now-dangling dir watcher.
154
+ h.poll()
155
+
156
+ expect(dw[0].close).toHaveBeenCalledTimes(1)
157
+ expect(dw[0].closed).toBe(true)
158
+
159
+ h.watcher.stop()
160
+ })
161
+ })
162
+
163
+ describe('subagent-watcher FD-leak (H2): no per-file watcher for stale historical running entries', () => {
164
+ it('does not open an FSWatcher for a boot-discovered stale running JSONL', () => {
165
+ const projectDir = `${PROJECTS}/myproject`
166
+ const sessionDir = `${projectDir}/session-A`
167
+ const subagentsDir = `${sessionDir}/subagents`
168
+ const staleFile = `${subagentsDir}/agent-deadbeef.jsonl`
169
+
170
+ const h = makeHarness({
171
+ dirs: {
172
+ [PROJECTS]: ['myproject'],
173
+ [projectDir]: ['session-A'],
174
+ [sessionDir]: ['subagents'],
175
+ [subagentsDir]: ['agent-deadbeef.jsonl'],
176
+ },
177
+ // size 0 + no mtimeMs → registers as a stale historical `running` entry
178
+ // that will never be promoted and never reach terminal cleanup.
179
+ fileSizes: { [staleFile]: 0 },
180
+ })
181
+
182
+ h.poll()
183
+
184
+ // A dir watcher for the subagents dir is fine. What must NOT happen is a
185
+ // per-file (.jsonl) watcher for a stale historical running entry that
186
+ // would leak forever.
187
+ const fileWatchersForStale = h.fileWatchers().filter((w) => w.path === staleFile)
188
+ expect(fileWatchersForStale).toHaveLength(0)
189
+
190
+ h.watcher.stop()
191
+ })
192
+
193
+ // Positive direction of the H2 gate: the open-guard is `!entry.historical ||
194
+ // entry.bootPromotionPending != null`, so the negative test above (no watcher
195
+ // for a stale boot entry) must be balanced by proof the guard does NOT
196
+ // over-prune a genuinely LIVE worker. A file that first appears AFTER the boot
197
+ // scan is non-historical (`bootScanInProgress` is already false), so it is a
198
+ // live worker the user is awaiting and MUST get its own per-file FSWatcher —
199
+ // otherwise its tool-call / turn_end transitions would be invisible until the
200
+ // 1s defensive poll happened to catch them. Reviewer verified this by code-
201
+ // reading only; this test locks it in.
202
+ it('opens a per-file FSWatcher for a live (post-boot, non-historical) worker JSONL', () => {
203
+ const projectDir = `${PROJECTS}/myproject`
204
+ const sessionDir = `${projectDir}/session-A`
205
+ const subagentsDir = `${sessionDir}/subagents`
206
+ const liveFile = `${subagentsDir}/agent-live01.jsonl`
207
+
208
+ const h = makeHarness({
209
+ dirs: {
210
+ [PROJECTS]: ['myproject'],
211
+ [projectDir]: ['session-A'],
212
+ [sessionDir]: ['subagents'],
213
+ [subagentsDir]: [], // empty at boot → nothing marked historical
214
+ },
215
+ })
216
+
217
+ // Boot scan already ran (constructor) over the empty dir; poll once so the
218
+ // dir watcher for the subagents dir is definitely established.
219
+ h.poll()
220
+ expect(h.fileWatchers()).toHaveLength(0) // nothing to watch yet
221
+
222
+ // A brand-new worker dispatches AFTER boot: its JSONL appears now. Because
223
+ // bootScanInProgress is already false, scanSubagentsDir does NOT mark it
224
+ // historical → it registers as a live running entry.
225
+ h.dirs.set(subagentsDir, ['agent-live01.jsonl'])
226
+ h.fileSizes.set(liveFile, 24)
227
+
228
+ h.poll()
229
+
230
+ const fileWatchersForLive = h.fileWatchers().filter((w) => w.path === liveFile)
231
+ expect(fileWatchersForLive).toHaveLength(1)
232
+ expect(fileWatchersForLive[0].closed).toBe(false)
233
+
234
+ h.watcher.stop()
235
+ // stop() must close the live watcher it opened (no leak on shutdown).
236
+ expect(fileWatchersForLive[0].closed).toBe(true)
237
+ })
238
+ })
239
+
240
+ describe('subagent-watcher FD-leak (H1): a still-present session dir keeps its watcher across rescans', () => {
241
+ it('does NOT prune the dir watcher for a directory that is still present', () => {
242
+ const projectDir = `${PROJECTS}/myproject`
243
+ const sessionDir = `${projectDir}/session-A`
244
+ const subagentsDir = `${sessionDir}/subagents`
245
+
246
+ const h = makeHarness({
247
+ dirs: {
248
+ [PROJECTS]: ['myproject'],
249
+ [projectDir]: ['session-A'],
250
+ [sessionDir]: ['subagents'],
251
+ [subagentsDir]: [], // present + empty — gets a dir watcher, stays present
252
+ },
253
+ })
254
+
255
+ h.poll()
256
+ const dw = h.dirWatchersFor(subagentsDir)
257
+ expect(dw).toHaveLength(1)
258
+ expect(dw[0].closed).toBe(false)
259
+
260
+ // The session dir never vanishes. Several rescan ticks pass. The complement
261
+ // of the H1 close-on-vanish test: pruneVanishedDirWatchers must leave a
262
+ // still-existing dir's watcher untouched, and the `if (!dirWatchers.has(...))`
263
+ // guard must NOT open a duplicate watcher for the same dir on each rescan.
264
+ h.poll()
265
+ h.poll()
266
+ h.poll()
267
+
268
+ expect(dw[0].close).not.toHaveBeenCalled()
269
+ expect(dw[0].closed).toBe(false)
270
+ // Exactly one dir watcher for this path across all the rescans — no churn.
271
+ expect(h.dirWatchersFor(subagentsDir)).toHaveLength(1)
272
+
273
+ h.watcher.stop()
274
+ })
275
+ })
@@ -9,12 +9,26 @@
9
9
  * Run with:
10
10
  * bun test telegram-plugin/tests/worktree-watch-cwds.test.ts
11
11
  */
12
- import { describe, it, expect, beforeEach } from "vitest";
12
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
13
+ import { mkdtempSync, rmSync, readFileSync } from "node:fs";
14
+ import { join, dirname } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+ import { fileURLToPath } from "node:url";
13
17
  import {
14
18
  ownedWorktreeCwds,
19
+ refreshOwnedWorktreeHeartbeats,
20
+ makeWorktreeWatchProvider,
15
21
  __resetIdentityEscalationForTests,
16
22
  type WorktreeOwnershipRecord,
23
+ type WorktreeHeartbeatRecord,
17
24
  } from "../worktree-watch-cwds.js";
25
+ import {
26
+ writeRecord,
27
+ readRecord,
28
+ listRecords as registryListRecords,
29
+ touchHeartbeat as registryTouchHeartbeat,
30
+ } from "../../src/worktree/registry.js";
31
+ import type { WorktreeRecord } from "../../src/worktree/types.js";
18
32
 
19
33
  const idPath = (p: string) => p; // identity realpath for deterministic tests
20
34
 
@@ -196,3 +210,203 @@ describe("ownedWorktreeCwds", () => {
196
210
  expect(provider()).toEqual(["/wt/b"]);
197
211
  });
198
212
  });
213
+
214
+ describe("refreshOwnedWorktreeHeartbeats", () => {
215
+ const records: WorktreeHeartbeatRecord[] = [
216
+ { id: "mine-1", ownerAgent: "klanker" },
217
+ { id: "mine-2", ownerAgent: "klanker" },
218
+ { id: "theirs", ownerAgent: "reggie" },
219
+ { id: "ownerless" }, // ownerAgent undefined
220
+ ];
221
+
222
+ it("touches ONLY this agent's records, never ownerless or foreign ones", () => {
223
+ const touched: string[] = [];
224
+ const n = refreshOwnedWorktreeHeartbeats({
225
+ self: "klanker",
226
+ listRecords: () => records,
227
+ touchHeartbeat: (id) => touched.push(id),
228
+ minRefreshIntervalMs: 0, // always touch
229
+ });
230
+ expect(touched.sort()).toEqual(["mine-1", "mine-2"]);
231
+ expect(touched).not.toContain("theirs");
232
+ expect(touched).not.toContain("ownerless");
233
+ expect(n).toBe(2);
234
+ });
235
+
236
+ it("fail-closed: unresolved identity touches NOTHING (no #1116 leak)", () => {
237
+ const touched: string[] = [];
238
+ const n = refreshOwnedWorktreeHeartbeats({
239
+ self: undefined,
240
+ listRecords: () => records,
241
+ touchHeartbeat: (id) => touched.push(id),
242
+ minRefreshIntervalMs: 0,
243
+ });
244
+ expect(touched).toEqual([]);
245
+ expect(n).toBe(0);
246
+ });
247
+
248
+ it("resolves identity from agentDir when env is unset (durable fallback)", () => {
249
+ const touched: string[] = [];
250
+ refreshOwnedWorktreeHeartbeats({
251
+ self: undefined,
252
+ agentDir: "/home/x/.switchroom/agents/klanker",
253
+ listRecords: () => records,
254
+ touchHeartbeat: (id) => touched.push(id),
255
+ minRefreshIntervalMs: 0,
256
+ });
257
+ expect(touched.sort()).toEqual(["mine-1", "mine-2"]);
258
+ });
259
+
260
+ it("throttles: skips a record whose heartbeat is younger than the interval", () => {
261
+ const now = 1_000_000;
262
+ const fresh = new Date(now - 30_000).toISOString(); // 30s ago
263
+ const aged = new Date(now - 5 * 60_000).toISOString(); // 5min ago
264
+ const touched: string[] = [];
265
+ refreshOwnedWorktreeHeartbeats({
266
+ self: "klanker",
267
+ listRecords: () => [
268
+ { id: "fresh", ownerAgent: "klanker", heartbeatAt: fresh },
269
+ { id: "aged", ownerAgent: "klanker", heartbeatAt: aged },
270
+ ],
271
+ touchHeartbeat: (id) => touched.push(id),
272
+ minRefreshIntervalMs: 2 * 60_000, // 2 min
273
+ now: () => now,
274
+ });
275
+ // Only the aged one crosses the throttle window.
276
+ expect(touched).toEqual(["aged"]);
277
+ });
278
+
279
+ it("a registry read failure is swallowed (never throws on the hot loop)", () => {
280
+ expect(() =>
281
+ refreshOwnedWorktreeHeartbeats({
282
+ self: "klanker",
283
+ listRecords: () => {
284
+ throw new Error("registry gone");
285
+ },
286
+ touchHeartbeat: () => {},
287
+ }),
288
+ ).not.toThrow();
289
+ });
290
+
291
+ it("a per-record touch failure does not abort the remaining records", () => {
292
+ const touched: string[] = [];
293
+ const n = refreshOwnedWorktreeHeartbeats({
294
+ self: "klanker",
295
+ listRecords: () => records,
296
+ touchHeartbeat: (id) => {
297
+ if (id === "mine-1") throw new Error("write failed");
298
+ touched.push(id);
299
+ },
300
+ minRefreshIntervalMs: 0,
301
+ });
302
+ // mine-2 still touched despite mine-1 throwing.
303
+ expect(touched).toEqual(["mine-2"]);
304
+ expect(n).toBe(1);
305
+ });
306
+ });
307
+
308
+ // ─── M1: the gateway's extraWatchCwds provider ──────────────────────────────
309
+ //
310
+ // The gateway wires a single closure (`makeWorktreeWatchProvider`) as the
311
+ // subagent-watcher's extraWatchCwdsProvider. The whole point of this PR is
312
+ // that that closure ALSO advances heartbeats on every tick — deleting the
313
+ // refresh call would leave the cwd behaviour (and every ownership test above)
314
+ // green while silently reintroducing the zero-caller `touchHeartbeat` bug.
315
+ // These tests are the deterministic guard: they assert the provider returns
316
+ // owned cwds AND advances a real registry record's heartbeatAt on the same
317
+ // call, and that the gateway actually installs it.
318
+ describe("makeWorktreeWatchProvider (gateway wiring)", () => {
319
+ const idPathLocal = (p: string) => p; // deterministic realpath
320
+
321
+ it("both returns owned cwds AND advances the claim's heartbeat (in-memory)", () => {
322
+ const now = 10_000_000;
323
+ const before = new Date(now - 60 * 60_000).toISOString(); // 1h ago (stale)
324
+ const store: (WorktreeOwnershipRecord & WorktreeHeartbeatRecord)[] = [
325
+ { id: "mine", path: "/wt/mine", ownerAgent: "klanker", heartbeatAt: before },
326
+ { id: "theirs", path: "/wt/theirs", ownerAgent: "reggie", heartbeatAt: before },
327
+ ];
328
+ const touched: string[] = [];
329
+ const provider = makeWorktreeWatchProvider({
330
+ self: "klanker",
331
+ listRecords: () => store,
332
+ touchHeartbeat: (id) => touched.push(id),
333
+ realpath: idPathLocal,
334
+ minRefreshIntervalMs: 0,
335
+ now: () => now,
336
+ });
337
+
338
+ const cwds = provider();
339
+
340
+ // (1) returns exactly this agent's owned cwds
341
+ expect(cwds).toEqual(["/wt/mine"]);
342
+ // (2) AND advanced the heartbeat of the owned record only — never foreign
343
+ expect(touched).toEqual(["mine"]);
344
+ });
345
+
346
+ describe("against a real temp registry", () => {
347
+ let tmpDir: string;
348
+ const origEnv = process.env.SWITCHROOM_WORKTREE_DIR;
349
+
350
+ beforeEach(() => {
351
+ tmpDir = mkdtempSync(join(tmpdir(), "sw-provider-test-"));
352
+ process.env.SWITCHROOM_WORKTREE_DIR = tmpDir;
353
+ });
354
+
355
+ afterEach(() => {
356
+ rmSync(tmpDir, { recursive: true, force: true });
357
+ if (origEnv === undefined) delete process.env.SWITCHROOM_WORKTREE_DIR;
358
+ else process.env.SWITCHROOM_WORKTREE_DIR = origEnv;
359
+ });
360
+
361
+ function makeRecord(overrides: Partial<WorktreeRecord> = {}): WorktreeRecord {
362
+ const iso = new Date().toISOString();
363
+ return {
364
+ id: "prov001",
365
+ repo: "/fake/repo",
366
+ repoName: "fake",
367
+ branch: "task/prov001",
368
+ path: "/wt/prov001",
369
+ createdAt: iso,
370
+ heartbeatAt: iso,
371
+ ownerAgent: "klanker",
372
+ ...overrides,
373
+ };
374
+ }
375
+
376
+ it("provider drives the REAL registry: returns cwd AND advances heartbeatAt on disk", async () => {
377
+ const past = new Date(Date.now() - 5 * 60_000).toISOString(); // 5min ago
378
+ writeRecord(makeRecord({ heartbeatAt: past }));
379
+ const before = new Date(readRecord("prov001")!.heartbeatAt).getTime();
380
+
381
+ const provider = makeWorktreeWatchProvider({
382
+ self: "klanker",
383
+ listRecords: registryListRecords,
384
+ touchHeartbeat: registryTouchHeartbeat,
385
+ realpath: idPathLocal,
386
+ minRefreshIntervalMs: 0, // always touch
387
+ });
388
+
389
+ await new Promise((r) => setTimeout(r, 5)); // ensure a strictly newer ts
390
+ const cwds = provider();
391
+
392
+ // (1) returns the owned cwd from the real registry
393
+ expect(cwds).toEqual(["/wt/prov001"]);
394
+ // (2) AND the on-disk heartbeat actually advanced (the zero-caller guard)
395
+ const after = new Date(readRecord("prov001")!.heartbeatAt).getTime();
396
+ expect(after).toBeGreaterThan(before);
397
+ });
398
+ });
399
+
400
+ it("gateway.ts installs makeWorktreeWatchProvider as extraWatchCwdsProvider", () => {
401
+ // Grep-pin (anchor MUST resolve so it can never pass vacuously — repo test
402
+ // convention, PR #3126): proves the gateway actually wires the extracted
403
+ // provider, so a behaviour test on the provider is not testing dead code.
404
+ const here = dirname(fileURLToPath(import.meta.url));
405
+ const gatewaySrc = readFileSync(
406
+ join(here, "..", "gateway", "gateway.ts"),
407
+ "utf8",
408
+ );
409
+ const anchor = "extraWatchCwdsProvider: makeWorktreeWatchProvider(";
410
+ expect(gatewaySrc.indexOf(anchor)).toBeGreaterThan(-1);
411
+ });
412
+ });