@triflux/remote 10.28.1 → 10.30.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.
package/hub/server.mjs CHANGED
@@ -56,7 +56,10 @@ import { createStoreAdapter } from "./store-adapter.mjs";
56
56
  import { createGitPreflight } from "./team/git-preflight.mjs";
57
57
  import { nativeProxy } from "./team/nativeProxy.mjs";
58
58
  import { createSwarmLocks } from "./team/swarm-locks.mjs";
59
- import { createSynapseRegistry } from "./team/synapse-registry.mjs";
59
+ import {
60
+ createSynapseRegistry,
61
+ projectPeer,
62
+ } from "./team/synapse-registry.mjs";
60
63
  import { registerTeamBridge } from "@triflux/core/hub/team-bridge.mjs";
61
64
  import { createTools } from "./tools.mjs";
62
65
  import { createDelegatorMcpWorker } from "./workers/delegator-mcp.mjs";
@@ -1299,7 +1302,20 @@ export async function startHub({
1299
1302
  }
1300
1303
 
1301
1304
  // ── Synapse Layer 5: session registry + locks + preflight routes ──
1305
+ // Admin/raw snapshot (loopback-only). Returns raw cwd/pid for local
1306
+ // admin/HUD use; the redacted peer surface is GET /synapse/peers.
1302
1307
  if (path === "/synapse/sessions" && req.method === "GET") {
1308
+ // Enforce the loopback boundary independent of token mode: the global
1309
+ // token gate admits any token-bearing remote client, but this raw
1310
+ // snapshot leaks absolute cwd/pid/worktreePath/dirtyFiles for every
1311
+ // session, so it must never be reachable off 127.0.0.1 (LOCKED #3: raw
1312
+ // cwd is admin-only via loopback). Off-loopback peers use /synapse/peers.
1313
+ if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
1314
+ return writeJson(res, 403, {
1315
+ ok: false,
1316
+ error: "Forbidden: /synapse/sessions is loopback-only",
1317
+ });
1318
+ }
1303
1319
  return writeJson(res, 200, {
1304
1320
  ok: true,
1305
1321
  ...synapseRegistry.snapshot(),
@@ -1307,6 +1323,33 @@ export async function startHub({
1307
1323
  });
1308
1324
  }
1309
1325
 
1326
+ // Redacted peer-discovery surface. Returns co-located live peers (same
1327
+ // cwd / worktree) with raw cwd/pid stripped — only label/hash + booleans.
1328
+ if (path === "/synapse/peers" && req.method === "GET") {
1329
+ // formatHostForUrl wraps IPv6 hosts in [...]; a bare `::1` would make
1330
+ // `new URL(req.url, "http://::1")` throw (invalid authority).
1331
+ const query = new URL(req.url, `http://${formatHostForUrl(host)}`)
1332
+ .searchParams;
1333
+ const cwd = query.get("cwd") || "";
1334
+ const worktree = query.get("worktree") || "";
1335
+ const excludeSessionId = query.get("excludeSessionId") || "";
1336
+ // Require at least one non-empty locator. Without it querySessions
1337
+ // already returns [], but short-circuiting keeps the contract explicit
1338
+ // and avoids ever enumerating the registry over the redacted surface.
1339
+ if (!cwd && !worktree) {
1340
+ return writeJson(res, 200, { ok: true, peers: [], ts: Date.now() });
1341
+ }
1342
+ const matches = synapseRegistry.querySessions({
1343
+ cwd,
1344
+ worktree,
1345
+ excludeSessionId,
1346
+ });
1347
+ const peers = matches.map((session) =>
1348
+ projectPeer(session, { cwd, worktree }),
1349
+ );
1350
+ return writeJson(res, 200, { ok: true, peers, ts: Date.now() });
1351
+ }
1352
+
1310
1353
  if (path === "/synapse/register" && req.method === "POST") {
1311
1354
  try {
1312
1355
  const body = await parseBody(req);
@@ -1,4 +1,56 @@
1
- const DEFAULT_SYNAPSE_BASE_URL = "http://127.0.0.1:27888";
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const HUB_DEFAULT_PORT = 27888;
6
+ // Same token file as hub/bridge.mjs (HUB_TOKEN_FILE). Kept inline rather than
7
+ // imported so this helper stays dependency-free and byte-identical across the
8
+ // packages/core mirror (which cannot carry relative cross-package imports).
9
+ const HUB_TOKEN_FILE = join(homedir(), ".claude", ".tfx-hub-token");
10
+
11
+ // In-flight fire-and-forget POSTs. A short-lived caller (the SessionStart hook,
12
+ // which process.exit(0)s immediately after firing register) must be able to
13
+ // flush these before the process dies: Node's process.exit() abandons pending
14
+ // socket I/O, so an un-awaited loopback POST is created but never written.
15
+ // Callers on an exit budget drain this set via drainPendingSynapse() first.
16
+ const inFlightSynapse = new Set();
17
+
18
+ function normalizeToken(raw) {
19
+ if (raw == null) return null;
20
+ const token = String(raw).trim();
21
+ return token || null;
22
+ }
23
+
24
+ // Mirrors hub/bridge.mjs:readHubToken — env override first, then the token file.
25
+ // Missing token → null (backward-compatible with token-less hubs).
26
+ function readHubToken() {
27
+ const envToken = normalizeToken(process.env.TFX_HUB_TOKEN);
28
+ if (envToken) return envToken;
29
+ try {
30
+ return normalizeToken(readFileSync(HUB_TOKEN_FILE, "utf8"));
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ // Wrap IPv6 hosts in [...] so they form a valid URL authority (cf. bridge.mjs).
37
+ function formatHostForUrl(host) {
38
+ return String(host).includes(":") ? `[${host}]` : host;
39
+ }
40
+
41
+ // Resolve the hub base URL from env (TFX_HUB_URL / TFX_HUB_PORT) before falling
42
+ // back to the loopback default, so a cascaded / non-default hub port is hit
43
+ // instead of silently posting to 127.0.0.1:27888 and missing the live hub.
44
+ function resolveSynapseBaseUrl() {
45
+ const envUrl = normalizeToken(process.env.TFX_HUB_URL);
46
+ if (envUrl) return envUrl.replace(/\/mcp$/, "");
47
+
48
+ const envPort = Number.parseInt(String(process.env.TFX_HUB_PORT ?? ""), 10);
49
+ if (Number.isFinite(envPort) && envPort > 0) {
50
+ return `http://${formatHostForUrl("127.0.0.1")}:${envPort}`;
51
+ }
52
+ return `http://${formatHostForUrl("127.0.0.1")}:${HUB_DEFAULT_PORT}`;
53
+ }
2
54
 
3
55
  function resolveSynapseFetch(fetchImpl) {
4
56
  if (fetchImpl === null) return null;
@@ -19,23 +71,73 @@ export function fireAndForgetSynapse(path, payload, opts = {}) {
19
71
  if (!fetchImpl) return false;
20
72
 
21
73
  try {
22
- const url = new URL(
23
- path,
24
- opts.baseUrl || DEFAULT_SYNAPSE_BASE_URL,
25
- ).toString();
26
- Promise.resolve(
27
- fetchImpl(url, {
28
- method: "POST",
29
- headers: { "content-type": "application/json" },
30
- body: JSON.stringify(payload),
31
- }),
32
- ).catch(() => {});
74
+ const baseUrl = opts.baseUrl || resolveSynapseBaseUrl();
75
+ const url = new URL(path, baseUrl).toString();
76
+ const headers = { "content-type": "application/json" };
77
+ // token: false explicitly skips auth; otherwise reuse the hub token so a
78
+ // token-required hub does not 401 (the failure would be swallowed silently).
79
+ if (opts.token !== false) {
80
+ const token =
81
+ typeof opts.token === "string" && opts.token.trim()
82
+ ? opts.token.trim()
83
+ : readHubToken();
84
+ if (token) headers.Authorization = `Bearer ${token}`;
85
+ }
86
+ const init = {
87
+ method: "POST",
88
+ headers,
89
+ body: JSON.stringify(payload),
90
+ };
91
+ // Optional bounded timeout so callers on a teardown budget (e.g. SessionEnd)
92
+ // cannot hang the host process if the hub stalls. The timer is unref'd so it
93
+ // never keeps the event loop (and thus the hook process) alive on its own.
94
+ let timer = null;
95
+ const timeoutMs = Number(opts.timeoutMs);
96
+ if (
97
+ Number.isFinite(timeoutMs) &&
98
+ timeoutMs > 0 &&
99
+ typeof AbortController === "function"
100
+ ) {
101
+ const controller = new AbortController();
102
+ init.signal = controller.signal;
103
+ timer = setTimeout(() => controller.abort(), timeoutMs);
104
+ if (typeof timer.unref === "function") timer.unref();
105
+ }
106
+ const tracked = Promise.resolve(fetchImpl(url, init))
107
+ .catch(() => {})
108
+ .finally(() => {
109
+ if (timer) clearTimeout(timer);
110
+ inFlightSynapse.delete(tracked);
111
+ });
112
+ inFlightSynapse.add(tracked);
33
113
  return true;
34
114
  } catch {
35
115
  return false;
36
116
  }
37
117
  }
38
118
 
119
+ // Await any in-flight fire-and-forget Synapse POSTs, bounded by timeoutMs so a
120
+ // stalled hub can never hang the caller. The SessionStart hook calls this right
121
+ // before process.exit(0) so the register POST actually flushes to the socket.
122
+ export function drainPendingSynapse(timeoutMs = 1000) {
123
+ if (inFlightSynapse.size === 0) return Promise.resolve();
124
+ const settled = Promise.allSettled([...inFlightSynapse]);
125
+ const ms = Number(timeoutMs);
126
+ if (!Number.isFinite(ms) || ms <= 0) return settled;
127
+ let timeout = null;
128
+ const timeoutPromise = new Promise((resolve) => {
129
+ // Keep this timer ref'ed: drainPendingSynapse() is awaited specifically
130
+ // to hold the process open until either in-flight POSTs settle or the
131
+ // bounded drain budget expires. An unref'ed timer can let Node's test
132
+ // runner/process exit before the Promise resolves when no other handles
133
+ // remain.
134
+ timeout = setTimeout(resolve, ms);
135
+ });
136
+ return Promise.race([settled, timeoutPromise]).finally(() => {
137
+ if (timeout) clearTimeout(timeout);
138
+ });
139
+ }
140
+
39
141
  export function registerSynapseSession(meta, opts = {}) {
40
142
  return fireAndForgetSynapse("/synapse/register", meta, opts);
41
143
  }
@@ -45,11 +147,15 @@ export function heartbeatSynapseSession(
45
147
  partialMeta = {},
46
148
  opts = {},
47
149
  ) {
150
+ // The live route reads `{ sessionId, partial }` (hub/server.mjs
151
+ // /synapse/heartbeat → synapseRegistry.heartbeat(sessionId, partial)), so the
152
+ // helper must nest the meta under `partial` rather than spreading it top-level.
48
153
  return fireAndForgetSynapse(
49
154
  "/synapse/heartbeat",
50
155
  {
51
156
  sessionId,
52
- ...(partialMeta && typeof partialMeta === "object" ? partialMeta : {}),
157
+ partial:
158
+ partialMeta && typeof partialMeta === "object" ? partialMeta : {},
53
159
  },
54
160
  opts,
55
161
  );
@@ -5,12 +5,59 @@ const DEFAULT_LOCAL_HEARTBEAT_INTERVAL_MS = 5_000;
5
5
  const DEFAULT_LOCAL_TIMEOUT_MS = 30_000;
6
6
  const DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS = 15_000;
7
7
  const DEFAULT_REMOTE_TIMEOUT_MS = 90_000;
8
+ // Interactive (Claude/Codex) sessions idle for long stretches; a 30s local
9
+ // timeout produces stale false-positives. 5-minute TTL + an `idle` state
10
+ // distinguishes "alive but inactive" from "presumed dead".
11
+ const DEFAULT_INTERACTIVE_HEARTBEAT_INTERVAL_MS = 60_000;
12
+ const DEFAULT_INTERACTIVE_TIMEOUT_MS = 300_000;
13
+
14
+ const VALID_SESSION_KINDS = new Set(["interactive", "headless"]);
8
15
 
9
16
  function normalizeSessionId(sessionId) {
10
17
  if (sessionId == null) return "";
11
18
  return String(sessionId).trim();
12
19
  }
13
20
 
21
+ function normalizeSessionKind(raw) {
22
+ return VALID_SESSION_KINDS.has(raw) ? raw : "headless";
23
+ }
24
+
25
+ // A session is "live" while active OR idle. Idle is an interactive session that
26
+ // missed its heartbeat interval but is still under the TTL — alive but inactive,
27
+ // not presumed dead. getActive() and querySessions() share this single predicate
28
+ // so the liveness contract can't drift: getActive() feeds git-preflight's
29
+ // dirty-file conflict guard, so dropping idle there would hide a still-live
30
+ // interactive session's claimed paths from that safety check.
31
+ function isLiveStatus(status) {
32
+ return status === "active" || status === "idle";
33
+ }
34
+
35
+ // Non-secret co-location fingerprint: a short, stable, non-reversible hash of a
36
+ // path so peers can correlate co-located sessions without leaking the raw
37
+ // filesystem path (cf. AccountBroker redaction). This is NOT a security boundary
38
+ // — the boundary is `cwdLabel` (basename only). Treat the hash as an opaque
39
+ // correlation token, not as a secret.
40
+ function shortHash(value) {
41
+ const str = String(value ?? "");
42
+ let h = 5381;
43
+ for (let i = 0; i < str.length; i++) {
44
+ h = (h * 33) ^ str.charCodeAt(i);
45
+ }
46
+ return (h >>> 0).toString(36);
47
+ }
48
+
49
+ // Last path segment of a directory, used as a human-readable label in redacted
50
+ // peer projections. Separator-agnostic on purpose: `node:path`.basename() is
51
+ // platform-dependent, so on POSIX a Windows path like `C:\Users\Alice\secret`
52
+ // would NOT be split and the full path would leak as the label. Splitting on
53
+ // either separator redacts both path styles regardless of host platform.
54
+ function pathLabel(value) {
55
+ const str = String(value ?? "").replace(/[/\\]+$/, "");
56
+ if (!str) return "";
57
+ const segments = str.split(/[/\\]+/);
58
+ return segments[segments.length - 1] || "";
59
+ }
60
+
14
61
  function cloneSession(session) {
15
62
  return {
16
63
  ...session,
@@ -24,6 +71,13 @@ function sanitizeSession(raw, fallbackSessionId = "") {
24
71
  const sessionId = normalizeSessionId(raw?.sessionId ?? fallbackSessionId);
25
72
  if (!sessionId) return null;
26
73
 
74
+ const status =
75
+ raw?.status === "stale" ||
76
+ raw?.status === "expired" ||
77
+ raw?.status === "idle"
78
+ ? raw.status
79
+ : "active";
80
+
27
81
  return {
28
82
  sessionId,
29
83
  host: typeof raw?.host === "string" ? raw.host : "local",
@@ -33,11 +87,39 @@ function sanitizeSession(raw, fallbackSessionId = "") {
33
87
  taskSummary: typeof raw?.taskSummary === "string" ? raw.taskSummary : "",
34
88
  lastHeartbeat:
35
89
  typeof raw?.lastHeartbeat === "number" ? raw.lastHeartbeat : Date.now(),
36
- status:
37
- raw?.status === "stale" || raw?.status === "expired"
38
- ? raw.status
39
- : "active",
90
+ status,
40
91
  isRemote: Boolean(raw?.isRemote),
92
+ // Additive fields (peer-discovery). Defaults keep pre-existing persist rows
93
+ // (no cwd/pid/sessionKind) loading cleanly — no persist version bump needed.
94
+ cwd: typeof raw?.cwd === "string" ? raw.cwd : "",
95
+ pid: typeof raw?.pid === "number" ? raw.pid : null,
96
+ sessionKind: normalizeSessionKind(raw?.sessionKind),
97
+ };
98
+ }
99
+
100
+ // Pure redacted projection of a session for the peer-discovery route. Raw
101
+ // `cwd`/`pid` and `dirtyFiles` are NEVER exposed; only label/hash + booleans
102
+ // computed against the caller's query args (cf. AccountBroker.publicSnapshot).
103
+ export function projectPeer(session, query = {}) {
104
+ const cwd = typeof session?.cwd === "string" ? session.cwd : "";
105
+ const worktreePath =
106
+ typeof session?.worktreePath === "string" ? session.worktreePath : "";
107
+ const queryCwd = typeof query?.cwd === "string" ? query.cwd : "";
108
+ const queryWorktree =
109
+ typeof query?.worktree === "string" ? query.worktree : "";
110
+
111
+ return {
112
+ sessionId: typeof session?.sessionId === "string" ? session.sessionId : "",
113
+ branch: typeof session?.branch === "string" ? session.branch : "",
114
+ sessionKind: normalizeSessionKind(session?.sessionKind),
115
+ status: typeof session?.status === "string" ? session.status : "active",
116
+ host: typeof session?.host === "string" ? session.host : "local",
117
+ isRemote: Boolean(session?.isRemote),
118
+ sameCwd: Boolean(queryCwd) && cwd === queryCwd,
119
+ sameWorktree: Boolean(queryWorktree) && worktreePath === queryWorktree,
120
+ cwdLabel: pathLabel(cwd),
121
+ cwdHash: cwd ? shortHash(cwd) : "",
122
+ worktreeLabel: pathLabel(worktreePath),
41
123
  };
42
124
  }
43
125
 
@@ -49,11 +131,14 @@ export function createSynapseRegistry(opts = {}) {
49
131
  localTimeoutMs = DEFAULT_LOCAL_TIMEOUT_MS,
50
132
  remoteHeartbeatIntervalMs = DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS,
51
133
  remoteTimeoutMs = DEFAULT_REMOTE_TIMEOUT_MS,
134
+ interactiveHeartbeatIntervalMs = DEFAULT_INTERACTIVE_HEARTBEAT_INTERVAL_MS,
135
+ interactiveTimeoutMs = DEFAULT_INTERACTIVE_TIMEOUT_MS,
52
136
  } = opts;
53
137
 
54
138
  const sessions = new Map();
55
139
  const monitors = new Map();
56
140
  const staleCallbacks = new Set();
141
+ const idleCallbacks = new Set();
57
142
  const removedCallbacks = new Set();
58
143
 
59
144
  function now() {
@@ -61,12 +146,16 @@ export function createSynapseRegistry(opts = {}) {
61
146
  }
62
147
 
63
148
  function intervalFor(session) {
149
+ if (session.sessionKind === "interactive") {
150
+ return interactiveHeartbeatIntervalMs;
151
+ }
64
152
  return session.isRemote
65
153
  ? remoteHeartbeatIntervalMs
66
154
  : localHeartbeatIntervalMs;
67
155
  }
68
156
 
69
157
  function timeoutFor(session) {
158
+ if (session.sessionKind === "interactive") return interactiveTimeoutMs;
70
159
  return session.isRemote ? remoteTimeoutMs : localTimeoutMs;
71
160
  }
72
161
 
@@ -130,6 +219,21 @@ export function createSynapseRegistry(opts = {}) {
130
219
  }
131
220
  }
132
221
 
222
+ function notifyIdle(session) {
223
+ const clone = cloneSession(session);
224
+ emitter?.emit("synapse.session.idle", {
225
+ sessionId: session.sessionId,
226
+ session: clone,
227
+ });
228
+ for (const callback of idleCallbacks) {
229
+ try {
230
+ callback(clone);
231
+ } catch {
232
+ /* no-op */
233
+ }
234
+ }
235
+ }
236
+
133
237
  function notifyRemoved(session) {
134
238
  const clone = cloneSession(session);
135
239
  emitter?.emit("synapse.session.removed", {
@@ -156,12 +260,30 @@ export function createSynapseRegistry(opts = {}) {
156
260
  if (!current) return;
157
261
 
158
262
  const elapsedMs = now() - current.lastHeartbeat;
159
- if (elapsedMs > timeoutFor(current) && current.status !== "stale") {
160
- const staled = { ...current, status: "stale" };
161
- sessions.set(sessionId, staled);
263
+ if (elapsedMs > timeoutFor(current)) {
264
+ if (current.status !== "stale") {
265
+ const staled = { ...current, status: "stale" };
266
+ sessions.set(sessionId, staled);
267
+ schedulePersist();
268
+ setImmediate(() => {
269
+ if (!destroyed) notifyStale(staled);
270
+ });
271
+ }
272
+ return;
273
+ }
274
+
275
+ // Interactive sessions that miss the heartbeat interval but are still
276
+ // under the TTL are "alive but inactive" (idle), not "presumed dead".
277
+ if (
278
+ current.sessionKind === "interactive" &&
279
+ current.status === "active" &&
280
+ elapsedMs > intervalFor(current)
281
+ ) {
282
+ const idled = { ...current, status: "idle" };
283
+ sessions.set(sessionId, idled);
162
284
  schedulePersist();
163
285
  setImmediate(() => {
164
- if (!destroyed) notifyStale(staled);
286
+ if (!destroyed) notifyIdle(idled);
165
287
  });
166
288
  }
167
289
  }, intervalFor(session));
@@ -185,7 +307,14 @@ export function createSynapseRegistry(opts = {}) {
185
307
  return { ok: false, sessionId, reason: "invalid_id" };
186
308
  }
187
309
 
188
- if (sessions.has(sessionId)) {
310
+ // A live row (active/idle) means a concurrent session already holds this id
311
+ // — reject (LOCKED #5). A stale/expired row is a dead remnant of the SAME
312
+ // session resuming: Claude Code re-fires SessionStart with the same
313
+ // session_id on resume/clear/compact, so fall through and re-register
314
+ // (revive) it. Otherwise the resumed-but-live session stays stale forever
315
+ // and vanishes from peer-discovery AND git-preflight's dirty-file guard.
316
+ const existing = sessions.get(sessionId);
317
+ if (existing && isLiveStatus(existing.status)) {
189
318
  console.warn(
190
319
  "[synapse-registry] duplicate registration rejected:",
191
320
  sessionId,
@@ -252,6 +381,9 @@ export function createSynapseRegistry(opts = {}) {
252
381
  if (typeof partialMeta.isRemote === "boolean") {
253
382
  updated.isRemote = partialMeta.isRemote;
254
383
  }
384
+ if (typeof partialMeta.cwd === "string") updated.cwd = partialMeta.cwd;
385
+ if (typeof partialMeta.pid === "number") updated.pid = partialMeta.pid;
386
+ // sessionKind is set at register time and immutable on heartbeat.
255
387
  }
256
388
 
257
389
  sessions.set(normalized, updated);
@@ -270,8 +402,11 @@ export function createSynapseRegistry(opts = {}) {
270
402
  }
271
403
 
272
404
  function getActive() {
405
+ // Live = active or idle. git-preflight uses this to detect other live
406
+ // sessions whose dirty files would conflict; an idle interactive session is
407
+ // still live (process alive, dirty files on disk) and must stay visible.
273
408
  return [...sessions.values()]
274
- .filter((session) => session.status === "active")
409
+ .filter((session) => isLiveStatus(session.status))
275
410
  .map((session) => cloneSession(session));
276
411
  }
277
412
 
@@ -286,11 +421,51 @@ export function createSynapseRegistry(opts = {}) {
286
421
  return session ? cloneSession(session) : null;
287
422
  }
288
423
 
424
+ // Peer-discovery primitive: returns sessions sharing the caller's cwd OR
425
+ // worktree, excluding the caller's own session. Only live peers (active/idle)
426
+ // are returned — stale/expired rows are presumed dead and omitted.
427
+ //
428
+ // SECURITY: at least one non-empty locator (cwd or worktree) is REQUIRED. An
429
+ // empty filter would otherwise return every active/idle session, turning the
430
+ // redacted peer surface into a full-registry enumeration (session ids/branches/
431
+ // labels/hashes). With no usable locator we return [] rather than leak.
432
+ //
433
+ // Matching is disjunctive (cwd OR worktreePath): a peer in the same worktree
434
+ // but a different subdirectory shares the worktree locator even though its cwd
435
+ // differs, and a conjunctive (AND) match would wrongly drop it.
436
+ function querySessions(filter = {}) {
437
+ const wantCwd = typeof filter?.cwd === "string" ? filter.cwd : "";
438
+ const wantWorktree =
439
+ typeof filter?.worktree === "string" ? filter.worktree : "";
440
+ const excludeId = normalizeSessionId(filter?.excludeSessionId);
441
+
442
+ // Require at least one non-empty locator — never enumerate the whole registry.
443
+ if (!wantCwd && !wantWorktree) return [];
444
+
445
+ return [...sessions.values()]
446
+ .filter((session) => {
447
+ if (!isLiveStatus(session.status)) {
448
+ return false;
449
+ }
450
+ if (excludeId && session.sessionId === excludeId) return false;
451
+ const cwdMatch = Boolean(wantCwd) && session.cwd === wantCwd;
452
+ const worktreeMatch =
453
+ Boolean(wantWorktree) && session.worktreePath === wantWorktree;
454
+ return cwdMatch || worktreeMatch;
455
+ })
456
+ .map((session) => cloneSession(session));
457
+ }
458
+
289
459
  function onStale(callback) {
290
460
  if (typeof callback !== "function") return;
291
461
  staleCallbacks.add(callback);
292
462
  }
293
463
 
464
+ function onIdle(callback) {
465
+ if (typeof callback !== "function") return;
466
+ idleCallbacks.add(callback);
467
+ }
468
+
294
469
  function onRemoved(callback) {
295
470
  if (typeof callback !== "function") return;
296
471
  removedCallbacks.add(callback);
@@ -321,7 +496,9 @@ export function createSynapseRegistry(opts = {}) {
321
496
  getActive,
322
497
  getAll,
323
498
  getSession,
499
+ querySessions,
324
500
  onStale,
501
+ onIdle,
325
502
  onRemoved,
326
503
  snapshot,
327
504
  destroy,