@triflux/remote 10.51.0 → 10.53.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/cto/brief.mjs +0 -8
- package/cto/collect.mjs +1 -127
- package/cto/current.schema.json +2 -19
- package/cto/hygiene.mjs +2 -61
- package/cto/lake-root.mjs +0 -56
- package/cto/status.mjs +5 -139
- package/hub/index.mjs +0 -9
- package/hub/team/agents-row-attach.py +120 -0
- package/hub/team/agents-row.mjs +200 -0
- package/hub/team/claude-daemon-control.mjs +1 -2
- package/hub/team/claude-session-projection.mjs +5 -73
- package/hub/team/claude-transcript.mjs +19 -0
- package/hub/team/cli/commands/debug.mjs +1 -2
- package/hub/team/cli/commands/start/index.mjs +2 -1
- package/hub/team/cli/commands/start/start-headless.mjs +2 -0
- package/hub/team/cli/commands/start/start-mux.mjs +2 -4
- package/hub/team/cli/services/state-store.mjs +5 -20
- package/hub/team/headless.mjs +132 -551
- package/hub/team/psmux.mjs +3 -33
- package/hub/team/results-index.mjs +92 -0
- package/hub/team/session-context.mjs +1 -1
- package/hub/team/tui-viewer.mjs +9 -56
- package/hub/team/tui.mjs +0 -37
- package/hub/team/uds-orchestrator.mjs +20 -599
- package/hub/workers/factory.mjs +6 -51
- package/hub/workers/interface.mjs +1 -7
- package/hub/workers/lib/jsonrpc-core.mjs +11 -0
- package/hub/workers/lib/jsonrpc-ws-uds.mjs +5 -6
- package/package.json +3 -8
- package/scripts/lib/agent-route-policy.mjs +0 -67
- package/scripts/lib/cli-claude.mjs +0 -12
- package/scripts/lib/env-probe.mjs +0 -70
- package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
- package/scripts/lib/mcp-guard-engine.mjs +42 -101
- package/hub/hub-lifecycle.mjs +0 -248
- package/hub/pipe.mjs +0 -825
- package/hub/promote-penalties.mjs +0 -138
- package/hub/public/dashboard.html +0 -416
- package/hub/schema.sql +0 -209
- package/hub/server.mjs +0 -3048
- package/hub/store-adapter.mjs +0 -182
- package/hub/store.mjs +0 -1736
- package/hub/team/cli/services/hub-client.mjs +0 -178
- package/hub/team/conductor-registry.mjs +0 -121
- package/hub/team/cto-auto-collect.mjs +0 -115
- package/hub/team/lead-control.mjs +0 -191
- package/hub/team/session-sync.mjs +0 -316
- package/hub/team/swarm-intent.mjs +0 -18
- package/hub/team/synapse-cli.mjs +0 -458
- package/hub/team/synapse-http.mjs +0 -311
- package/hub/team/synapse-registry.mjs +0 -618
- package/hub/team/tui-synapse.mjs +0 -223
- package/hub/team/worker-sandbox.mjs +0 -125
- package/hub/tools.mjs +0 -900
- package/hub/workers/codex-app-server-worker.mjs +0 -1067
- package/hub/workers/delegator-mcp.mjs +0 -1098
- package/hub/workers/lib/jsonrpc-stdio.mjs +0 -204
- package/scripts/lib/claudemd-scanner.mjs +0 -26
- package/scripts/lib/context.mjs +0 -51
- package/scripts/lib/handoff.mjs +0 -187
- package/scripts/lib/logger.mjs +0 -122
- package/scripts/lib/stealth-fetch.mjs +0 -176
|
@@ -1,618 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname } from "node:path";
|
|
3
|
-
|
|
4
|
-
const DEFAULT_LOCAL_HEARTBEAT_INTERVAL_MS = 5_000;
|
|
5
|
-
const DEFAULT_LOCAL_TIMEOUT_MS = 30_000;
|
|
6
|
-
const DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
7
|
-
const DEFAULT_REMOTE_TIMEOUT_MS = 90_000;
|
|
8
|
-
const DEFAULT_EXPIRE_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
|
9
|
-
const DEFAULT_CLEAN_EXPIRE_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
10
|
-
// Interactive (Claude/Codex) sessions idle for long stretches; a 30s local
|
|
11
|
-
// timeout produces stale false-positives. 5-minute TTL + an `idle` state
|
|
12
|
-
// distinguishes "alive but inactive" from "presumed dead".
|
|
13
|
-
const DEFAULT_INTERACTIVE_HEARTBEAT_INTERVAL_MS = 60_000;
|
|
14
|
-
const DEFAULT_INTERACTIVE_TIMEOUT_MS = 300_000;
|
|
15
|
-
|
|
16
|
-
const VALID_SESSION_KINDS = new Set(["interactive", "headless"]);
|
|
17
|
-
|
|
18
|
-
function normalizeSessionId(sessionId) {
|
|
19
|
-
if (sessionId == null) return "";
|
|
20
|
-
return String(sessionId).trim();
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function normalizeSessionKind(raw) {
|
|
24
|
-
return VALID_SESSION_KINDS.has(raw) ? raw : "headless";
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function normalizeDurationMs(raw, fallback) {
|
|
28
|
-
if (raw == null) return fallback;
|
|
29
|
-
const str = String(raw).trim();
|
|
30
|
-
if (!str) return fallback;
|
|
31
|
-
const parsed = Number(str);
|
|
32
|
-
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function defaultCleanExpireTimeoutMs() {
|
|
36
|
-
return normalizeDurationMs(
|
|
37
|
-
process.env.TFX_SYNAPSE_CLEAN_EXPIRE_MS,
|
|
38
|
-
DEFAULT_CLEAN_EXPIRE_TIMEOUT_MS,
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function hasDirtyFiles(session) {
|
|
43
|
-
return Array.isArray(session?.dirtyFiles)
|
|
44
|
-
? session.dirtyFiles.some((file) => typeof file === "string" && file)
|
|
45
|
-
: false;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// A session is "live" while active OR idle. Idle is an interactive session that
|
|
49
|
-
// missed its heartbeat interval but is still under the TTL — alive but inactive,
|
|
50
|
-
// not presumed dead. getActive() and querySessions() share this single predicate
|
|
51
|
-
// so the liveness contract can't drift: getActive() feeds peer discovery,
|
|
52
|
-
// CTO status/hygiene and log retention, so dropping idle there would hide a
|
|
53
|
-
// still-live interactive session from every one of those consumers.
|
|
54
|
-
function isLiveStatus(status) {
|
|
55
|
-
return status === "active" || status === "idle";
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Non-secret co-location fingerprint: a short, stable, non-reversible hash of a
|
|
59
|
-
// path so peers can correlate co-located sessions without leaking the raw
|
|
60
|
-
// filesystem path (cf. AccountBroker redaction). This is NOT a security boundary
|
|
61
|
-
// — the boundary is `cwdLabel` (basename only). Treat the hash as an opaque
|
|
62
|
-
// correlation token, not as a secret.
|
|
63
|
-
function shortHash(value) {
|
|
64
|
-
const str = String(value ?? "");
|
|
65
|
-
let h = 5381;
|
|
66
|
-
for (let i = 0; i < str.length; i++) {
|
|
67
|
-
h = (h * 33) ^ str.charCodeAt(i);
|
|
68
|
-
}
|
|
69
|
-
return (h >>> 0).toString(36);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Last path segment of a directory, used as a human-readable label in redacted
|
|
73
|
-
// peer projections. Separator-agnostic on purpose: `node:path`.basename() is
|
|
74
|
-
// platform-dependent, so on POSIX a Windows path like `C:\Users\Alice\secret`
|
|
75
|
-
// would NOT be split and the full path would leak as the label. Splitting on
|
|
76
|
-
// either separator redacts both path styles regardless of host platform.
|
|
77
|
-
function pathLabel(value) {
|
|
78
|
-
const str = String(value ?? "").replace(/[/\\]+$/, "");
|
|
79
|
-
if (!str) return "";
|
|
80
|
-
const segments = str.split(/[/\\]+/);
|
|
81
|
-
return segments[segments.length - 1] || "";
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function cloneSession(session) {
|
|
85
|
-
return {
|
|
86
|
-
...session,
|
|
87
|
-
dirtyFiles: Array.isArray(session.dirtyFiles)
|
|
88
|
-
? [...session.dirtyFiles]
|
|
89
|
-
: [],
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function sanitizeSession(raw, fallbackSessionId = "") {
|
|
94
|
-
const sessionId = normalizeSessionId(raw?.sessionId ?? fallbackSessionId);
|
|
95
|
-
if (!sessionId) return null;
|
|
96
|
-
|
|
97
|
-
const status =
|
|
98
|
-
raw?.status === "stale" ||
|
|
99
|
-
raw?.status === "expired" ||
|
|
100
|
-
raw?.status === "idle"
|
|
101
|
-
? raw.status
|
|
102
|
-
: "active";
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
sessionId,
|
|
106
|
-
host: typeof raw?.host === "string" ? raw.host : "local",
|
|
107
|
-
worktreePath: typeof raw?.worktreePath === "string" ? raw.worktreePath : "",
|
|
108
|
-
branch: typeof raw?.branch === "string" ? raw.branch : "",
|
|
109
|
-
dirtyFiles: Array.isArray(raw?.dirtyFiles) ? [...raw.dirtyFiles] : [],
|
|
110
|
-
taskSummary: typeof raw?.taskSummary === "string" ? raw.taskSummary : "",
|
|
111
|
-
lastHeartbeat:
|
|
112
|
-
typeof raw?.lastHeartbeat === "number" ? raw.lastHeartbeat : Date.now(),
|
|
113
|
-
status,
|
|
114
|
-
isRemote: Boolean(raw?.isRemote),
|
|
115
|
-
// Additive fields (peer-discovery). Defaults keep pre-existing persist rows
|
|
116
|
-
// (no cwd/pid/sessionKind) loading cleanly — no persist version bump needed.
|
|
117
|
-
cwd: typeof raw?.cwd === "string" ? raw.cwd : "",
|
|
118
|
-
pid: typeof raw?.pid === "number" ? raw.pid : null,
|
|
119
|
-
sessionKind: normalizeSessionKind(raw?.sessionKind),
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Pure redacted projection of a session for the peer-discovery route. Raw
|
|
124
|
-
// `cwd`/`pid` and `dirtyFiles` are NEVER exposed; only label/hash + booleans
|
|
125
|
-
// computed against the caller's query args (cf. AccountBroker.publicSnapshot).
|
|
126
|
-
export function projectPeer(session, query = {}) {
|
|
127
|
-
const cwd = typeof session?.cwd === "string" ? session.cwd : "";
|
|
128
|
-
const worktreePath =
|
|
129
|
-
typeof session?.worktreePath === "string" ? session.worktreePath : "";
|
|
130
|
-
const queryCwd = typeof query?.cwd === "string" ? query.cwd : "";
|
|
131
|
-
const queryWorktree =
|
|
132
|
-
typeof query?.worktree === "string" ? query.worktree : "";
|
|
133
|
-
|
|
134
|
-
return {
|
|
135
|
-
sessionId: typeof session?.sessionId === "string" ? session.sessionId : "",
|
|
136
|
-
branch: typeof session?.branch === "string" ? session.branch : "",
|
|
137
|
-
sessionKind: normalizeSessionKind(session?.sessionKind),
|
|
138
|
-
status: typeof session?.status === "string" ? session.status : "active",
|
|
139
|
-
host: typeof session?.host === "string" ? session.host : "local",
|
|
140
|
-
isRemote: Boolean(session?.isRemote),
|
|
141
|
-
sameCwd: Boolean(queryCwd) && cwd === queryCwd,
|
|
142
|
-
sameWorktree: Boolean(queryWorktree) && worktreePath === queryWorktree,
|
|
143
|
-
cwdLabel: pathLabel(cwd),
|
|
144
|
-
cwdHash: cwd ? shortHash(cwd) : "",
|
|
145
|
-
worktreeLabel: pathLabel(worktreePath),
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function createSynapseRegistry(opts = {}) {
|
|
150
|
-
const {
|
|
151
|
-
persistPath,
|
|
152
|
-
emitter = null,
|
|
153
|
-
localHeartbeatIntervalMs = DEFAULT_LOCAL_HEARTBEAT_INTERVAL_MS,
|
|
154
|
-
localTimeoutMs = DEFAULT_LOCAL_TIMEOUT_MS,
|
|
155
|
-
remoteHeartbeatIntervalMs = DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS,
|
|
156
|
-
remoteTimeoutMs = DEFAULT_REMOTE_TIMEOUT_MS,
|
|
157
|
-
interactiveHeartbeatIntervalMs = DEFAULT_INTERACTIVE_HEARTBEAT_INTERVAL_MS,
|
|
158
|
-
interactiveTimeoutMs = DEFAULT_INTERACTIVE_TIMEOUT_MS,
|
|
159
|
-
expireTimeoutMs = DEFAULT_EXPIRE_TIMEOUT_MS,
|
|
160
|
-
cleanExpireTimeoutMs = defaultCleanExpireTimeoutMs(),
|
|
161
|
-
} = opts;
|
|
162
|
-
|
|
163
|
-
const sessions = new Map();
|
|
164
|
-
const monitors = new Map();
|
|
165
|
-
const staleCallbacks = new Set();
|
|
166
|
-
const idleCallbacks = new Set();
|
|
167
|
-
const removedCallbacks = new Set();
|
|
168
|
-
|
|
169
|
-
function now() {
|
|
170
|
-
return Date.now();
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function intervalFor(session) {
|
|
174
|
-
if (session.sessionKind === "interactive") {
|
|
175
|
-
return interactiveHeartbeatIntervalMs;
|
|
176
|
-
}
|
|
177
|
-
return session.isRemote
|
|
178
|
-
? remoteHeartbeatIntervalMs
|
|
179
|
-
: localHeartbeatIntervalMs;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function timeoutFor(session) {
|
|
183
|
-
if (session.sessionKind === "interactive") return interactiveTimeoutMs;
|
|
184
|
-
return session.isRemote ? remoteTimeoutMs : localTimeoutMs;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
let persistTimer = null;
|
|
188
|
-
let destroyed = false;
|
|
189
|
-
|
|
190
|
-
function persist() {
|
|
191
|
-
if (!persistPath) return;
|
|
192
|
-
try {
|
|
193
|
-
mkdirSync(dirname(persistPath), { recursive: true });
|
|
194
|
-
const data = Object.fromEntries(
|
|
195
|
-
[...sessions].map(([k, v]) => [k, cloneSession(v)]),
|
|
196
|
-
);
|
|
197
|
-
writeFileSync(persistPath, JSON.stringify(data, null, 2), "utf8");
|
|
198
|
-
} catch {
|
|
199
|
-
/* best-effort */
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function schedulePersist() {
|
|
204
|
-
if (destroyed || persistTimer) return;
|
|
205
|
-
persistTimer = setTimeout(() => {
|
|
206
|
-
persistTimer = null;
|
|
207
|
-
if (!destroyed) persist();
|
|
208
|
-
}, 200);
|
|
209
|
-
if (typeof persistTimer.unref === "function") persistTimer.unref();
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function restore() {
|
|
213
|
-
if (!persistPath || !existsSync(persistPath)) return;
|
|
214
|
-
try {
|
|
215
|
-
const data = JSON.parse(readFileSync(persistPath, "utf8"));
|
|
216
|
-
for (const [sessionId, session] of Object.entries(data)) {
|
|
217
|
-
const sanitized = sanitizeSession(session, sessionId);
|
|
218
|
-
if (sanitized) sessions.set(sanitized.sessionId, sanitized);
|
|
219
|
-
}
|
|
220
|
-
} catch {
|
|
221
|
-
/* corrupted file — start fresh */
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function stopMonitor(sessionId) {
|
|
226
|
-
const timer = monitors.get(sessionId);
|
|
227
|
-
if (!timer) return;
|
|
228
|
-
clearInterval(timer);
|
|
229
|
-
monitors.delete(sessionId);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function notifyStale(session) {
|
|
233
|
-
const clone = cloneSession(session);
|
|
234
|
-
emitter?.emit("synapse.session.stale", {
|
|
235
|
-
sessionId: session.sessionId,
|
|
236
|
-
session: clone,
|
|
237
|
-
});
|
|
238
|
-
for (const callback of staleCallbacks) {
|
|
239
|
-
try {
|
|
240
|
-
callback(clone);
|
|
241
|
-
} catch {
|
|
242
|
-
/* no-op */
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function notifyIdle(session) {
|
|
248
|
-
const clone = cloneSession(session);
|
|
249
|
-
emitter?.emit("synapse.session.idle", {
|
|
250
|
-
sessionId: session.sessionId,
|
|
251
|
-
session: clone,
|
|
252
|
-
});
|
|
253
|
-
for (const callback of idleCallbacks) {
|
|
254
|
-
try {
|
|
255
|
-
callback(clone);
|
|
256
|
-
} catch {
|
|
257
|
-
/* no-op */
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function notifyRemoved(session) {
|
|
263
|
-
const clone = cloneSession(session);
|
|
264
|
-
emitter?.emit("synapse.session.removed", {
|
|
265
|
-
sessionId: session.sessionId,
|
|
266
|
-
session: clone,
|
|
267
|
-
});
|
|
268
|
-
for (const callback of removedCallbacks) {
|
|
269
|
-
try {
|
|
270
|
-
callback(clone);
|
|
271
|
-
} catch {
|
|
272
|
-
/* no-op */
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function startMonitor(sessionId) {
|
|
278
|
-
stopMonitor(sessionId);
|
|
279
|
-
|
|
280
|
-
const session = sessions.get(sessionId);
|
|
281
|
-
if (!session) return;
|
|
282
|
-
|
|
283
|
-
const timer = setInterval(() => {
|
|
284
|
-
const current = sessions.get(sessionId);
|
|
285
|
-
if (!current) return;
|
|
286
|
-
|
|
287
|
-
const elapsedMs = now() - current.lastHeartbeat;
|
|
288
|
-
if (elapsedMs > timeoutFor(current)) {
|
|
289
|
-
if (current.status !== "stale") {
|
|
290
|
-
const staled = { ...current, status: "stale" };
|
|
291
|
-
sessions.set(sessionId, staled);
|
|
292
|
-
schedulePersist();
|
|
293
|
-
setImmediate(() => {
|
|
294
|
-
if (!destroyed) notifyStale(staled);
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Interactive sessions that miss the heartbeat interval but are still
|
|
301
|
-
// under the TTL are "alive but inactive" (idle), not "presumed dead".
|
|
302
|
-
if (
|
|
303
|
-
current.sessionKind === "interactive" &&
|
|
304
|
-
current.status === "active" &&
|
|
305
|
-
elapsedMs > intervalFor(current)
|
|
306
|
-
) {
|
|
307
|
-
const idled = { ...current, status: "idle" };
|
|
308
|
-
sessions.set(sessionId, idled);
|
|
309
|
-
schedulePersist();
|
|
310
|
-
setImmediate(() => {
|
|
311
|
-
if (!destroyed) notifyIdle(idled);
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
}, intervalFor(session));
|
|
315
|
-
|
|
316
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
317
|
-
monitors.set(sessionId, timer);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
restore();
|
|
321
|
-
for (const sessionId of sessions.keys()) {
|
|
322
|
-
startMonitor(sessionId);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function register(sessionIdOrMeta, meta = null) {
|
|
326
|
-
const normalizedMeta =
|
|
327
|
-
meta && typeof meta === "object"
|
|
328
|
-
? { ...meta, sessionId: sessionIdOrMeta }
|
|
329
|
-
: sessionIdOrMeta;
|
|
330
|
-
const sessionId = normalizeSessionId(normalizedMeta?.sessionId);
|
|
331
|
-
if (!sessionId) {
|
|
332
|
-
return { ok: false, sessionId, reason: "invalid_id" };
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// A live row (active/idle) means a concurrent session already holds this id
|
|
336
|
-
// — reject (LOCKED #5). A stale/expired row is a dead remnant of the SAME
|
|
337
|
-
// session resuming: Claude Code re-fires SessionStart with the same
|
|
338
|
-
// session_id on resume/clear/compact, so fall through and re-register
|
|
339
|
-
// (revive) it. Otherwise the resumed-but-live session stays stale forever
|
|
340
|
-
// and vanishes from peer-discovery.
|
|
341
|
-
const existing = sessions.get(sessionId);
|
|
342
|
-
if (existing && isLiveStatus(existing.status)) {
|
|
343
|
-
console.warn(
|
|
344
|
-
"[synapse-registry] duplicate registration rejected:",
|
|
345
|
-
sessionId,
|
|
346
|
-
);
|
|
347
|
-
return { ok: false, sessionId, reason: "duplicate" };
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const session = sanitizeSession(
|
|
351
|
-
{
|
|
352
|
-
...normalizedMeta,
|
|
353
|
-
sessionId,
|
|
354
|
-
status: "active",
|
|
355
|
-
lastHeartbeat: now(),
|
|
356
|
-
},
|
|
357
|
-
sessionId,
|
|
358
|
-
);
|
|
359
|
-
|
|
360
|
-
sessions.set(sessionId, session);
|
|
361
|
-
startMonitor(sessionId);
|
|
362
|
-
persist();
|
|
363
|
-
|
|
364
|
-
emitter?.emit("synapse.session.started", {
|
|
365
|
-
sessionId,
|
|
366
|
-
session: cloneSession(session),
|
|
367
|
-
});
|
|
368
|
-
return { ok: true, sessionId };
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function unregister(sessionId) {
|
|
372
|
-
const normalized = normalizeSessionId(sessionId);
|
|
373
|
-
const session = sessions.get(normalized);
|
|
374
|
-
if (!session) return false;
|
|
375
|
-
|
|
376
|
-
stopMonitor(normalized);
|
|
377
|
-
sessions.delete(normalized);
|
|
378
|
-
persist();
|
|
379
|
-
notifyRemoved(session);
|
|
380
|
-
return true;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// stale/expired 세션이 cutoff 넘게 누적되면 Map에서 제거.
|
|
384
|
-
// live(active/idle) rows survive a long-stale lastHeartbeat because peer
|
|
385
|
-
// discovery and CTO status/hygiene still read them.
|
|
386
|
-
// Dirty stale rows keep the historical 24h window. Its original consumer,
|
|
387
|
-
// the git-preflight dirty-file guard, is gone and nothing populates
|
|
388
|
-
// dirtyFiles today, so this split is a candidate for collapsing into one
|
|
389
|
-
// window. Clean stale rows expire quickly to avoid daily dummy rows after
|
|
390
|
-
// overnight operator gaps.
|
|
391
|
-
function pruneExpired(opts2 = {}) {
|
|
392
|
-
if (destroyed) return { removed: [], count: 0 };
|
|
393
|
-
|
|
394
|
-
const explicitCutoff =
|
|
395
|
-
typeof opts2.olderThanMs === "number" ? opts2.olderThanMs : null;
|
|
396
|
-
const removed = [];
|
|
397
|
-
const currentTime = now();
|
|
398
|
-
|
|
399
|
-
for (const [sessionId, session] of sessions) {
|
|
400
|
-
const cutoff =
|
|
401
|
-
explicitCutoff ??
|
|
402
|
-
(hasDirtyFiles(session) ? expireTimeoutMs : cleanExpireTimeoutMs);
|
|
403
|
-
if (
|
|
404
|
-
isLiveStatus(session.status) ||
|
|
405
|
-
currentTime - session.lastHeartbeat <= cutoff
|
|
406
|
-
) {
|
|
407
|
-
continue;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
stopMonitor(sessionId);
|
|
411
|
-
sessions.delete(sessionId);
|
|
412
|
-
removed.push(sessionId);
|
|
413
|
-
notifyRemoved(session);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (removed.length > 0) persist();
|
|
417
|
-
return { removed, count: removed.length };
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function heartbeat(sessionId, partialMeta = null) {
|
|
421
|
-
const normalized = normalizeSessionId(sessionId);
|
|
422
|
-
const session = sessions.get(normalized);
|
|
423
|
-
if (!session) {
|
|
424
|
-
// self-heal은 위치 정보(worktreePath 또는 cwd)가 있는 heartbeat에서만
|
|
425
|
-
// 발동한다. UserPromptSubmit heartbeat는 항상 worktreePath를 보내므로
|
|
426
|
-
// SessionStart register POST drop 복구 경로는 커버되고, taskSummary만 담은
|
|
427
|
-
// 빈약한 heartbeat로 위치 없는 가비지 세션이 register 되는 것은 막는다
|
|
428
|
-
// (그 경우는 기존대로 false → 400/heartbeat failed).
|
|
429
|
-
const hasLocator =
|
|
430
|
-
partialMeta &&
|
|
431
|
-
typeof partialMeta === "object" &&
|
|
432
|
-
!Array.isArray(partialMeta) &&
|
|
433
|
-
((typeof partialMeta.worktreePath === "string" &&
|
|
434
|
-
partialMeta.worktreePath.length > 0) ||
|
|
435
|
-
(typeof partialMeta.cwd === "string" && partialMeta.cwd.length > 0));
|
|
436
|
-
const canSelfHeal = Boolean(normalized) && hasLocator;
|
|
437
|
-
if (!canSelfHeal) return false;
|
|
438
|
-
|
|
439
|
-
// 미등록 세션 heartbeat = SessionStart register POST가 drop된 경우.
|
|
440
|
-
// self-heal로 register 복구(영구 미등록 방지).
|
|
441
|
-
//
|
|
442
|
-
// sessionKind는 명시값이 없으면 "interactive"로 본다. 실제 복구 경로인
|
|
443
|
-
// heartbeatInteractiveSession은 worktreePath/host/taskSummary만 보내고
|
|
444
|
-
// sessionKind는 생략하는데, sanitizeSession 기본값("headless")으로 떨어지면
|
|
445
|
-
// 복구된 Claude 세션이 30초 local timeout을 받아 idle/5분 TTL을 잃고 곧장
|
|
446
|
-
// stale로 전이돼 getActive()/peer discovery에서 사라진다(이 PRD가 고치려던
|
|
447
|
-
// 원래 증상의 재현). heartbeat 기반 liveness로 살아나는 세션은 interactive다.
|
|
448
|
-
const sanitizedKind =
|
|
449
|
-
typeof partialMeta.sessionKind === "string"
|
|
450
|
-
? partialMeta.sessionKind
|
|
451
|
-
: "interactive";
|
|
452
|
-
const healed = sanitizeSession(
|
|
453
|
-
{
|
|
454
|
-
...partialMeta,
|
|
455
|
-
sessionId: normalized,
|
|
456
|
-
sessionKind: sanitizedKind,
|
|
457
|
-
status: "active",
|
|
458
|
-
lastHeartbeat: now(),
|
|
459
|
-
},
|
|
460
|
-
normalized,
|
|
461
|
-
);
|
|
462
|
-
if (!healed) return false;
|
|
463
|
-
|
|
464
|
-
sessions.set(normalized, healed);
|
|
465
|
-
startMonitor(normalized);
|
|
466
|
-
persist();
|
|
467
|
-
emitter?.emit("synapse.session.started", {
|
|
468
|
-
sessionId: normalized,
|
|
469
|
-
session: cloneSession(healed),
|
|
470
|
-
});
|
|
471
|
-
return true;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
const wasRemote = session.isRemote;
|
|
475
|
-
const updated = { ...session, lastHeartbeat: now(), status: "active" };
|
|
476
|
-
|
|
477
|
-
if (partialMeta && typeof partialMeta === "object") {
|
|
478
|
-
if (typeof partialMeta.host === "string") updated.host = partialMeta.host;
|
|
479
|
-
if (typeof partialMeta.worktreePath === "string") {
|
|
480
|
-
updated.worktreePath = partialMeta.worktreePath;
|
|
481
|
-
}
|
|
482
|
-
if (typeof partialMeta.branch === "string")
|
|
483
|
-
updated.branch = partialMeta.branch;
|
|
484
|
-
if (Array.isArray(partialMeta.dirtyFiles)) {
|
|
485
|
-
updated.dirtyFiles = partialMeta.dirtyFiles.filter(
|
|
486
|
-
(f) => typeof f === "string" && f.length > 0,
|
|
487
|
-
);
|
|
488
|
-
}
|
|
489
|
-
if (typeof partialMeta.taskSummary === "string") {
|
|
490
|
-
updated.taskSummary = partialMeta.taskSummary;
|
|
491
|
-
}
|
|
492
|
-
if (typeof partialMeta.isRemote === "boolean") {
|
|
493
|
-
updated.isRemote = partialMeta.isRemote;
|
|
494
|
-
}
|
|
495
|
-
if (typeof partialMeta.cwd === "string") updated.cwd = partialMeta.cwd;
|
|
496
|
-
if (typeof partialMeta.pid === "number") updated.pid = partialMeta.pid;
|
|
497
|
-
// sessionKind is set at register time and immutable on heartbeat.
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
sessions.set(normalized, updated);
|
|
501
|
-
|
|
502
|
-
if (updated.isRemote !== wasRemote) {
|
|
503
|
-
startMonitor(normalized);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
schedulePersist();
|
|
507
|
-
emitter?.emit("synapse.session.heartbeat", {
|
|
508
|
-
sessionId: normalized,
|
|
509
|
-
session: cloneSession(updated),
|
|
510
|
-
partial: partialMeta,
|
|
511
|
-
});
|
|
512
|
-
return true;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
function getActive() {
|
|
516
|
-
// Live = active or idle. Peer discovery, CTO status/hygiene and log
|
|
517
|
-
// retention read this list; an idle interactive session is still live
|
|
518
|
-
// (process alive) and must stay visible to them.
|
|
519
|
-
return [...sessions.values()]
|
|
520
|
-
.filter((session) => isLiveStatus(session.status))
|
|
521
|
-
.map((session) => cloneSession(session));
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
function getAll() {
|
|
525
|
-
return [...sessions.values()].map((session) => cloneSession(session));
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
function getSession(sessionId) {
|
|
529
|
-
const normalized = normalizeSessionId(sessionId);
|
|
530
|
-
if (!normalized) return null;
|
|
531
|
-
const session = sessions.get(normalized);
|
|
532
|
-
return session ? cloneSession(session) : null;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// Peer-discovery primitive: returns sessions sharing the caller's cwd OR
|
|
536
|
-
// worktree, excluding the caller's own session. Only live peers (active/idle)
|
|
537
|
-
// are returned — stale/expired rows are presumed dead and omitted.
|
|
538
|
-
//
|
|
539
|
-
// SECURITY: at least one non-empty locator (cwd or worktree) is REQUIRED. An
|
|
540
|
-
// empty filter would otherwise return every active/idle session, turning the
|
|
541
|
-
// redacted peer surface into a full-registry enumeration (session ids/branches/
|
|
542
|
-
// labels/hashes). With no usable locator we return [] rather than leak.
|
|
543
|
-
//
|
|
544
|
-
// Matching is disjunctive (cwd OR worktreePath): a peer in the same worktree
|
|
545
|
-
// but a different subdirectory shares the worktree locator even though its cwd
|
|
546
|
-
// differs, and a conjunctive (AND) match would wrongly drop it.
|
|
547
|
-
function querySessions(filter = {}) {
|
|
548
|
-
const wantCwd = typeof filter?.cwd === "string" ? filter.cwd : "";
|
|
549
|
-
const wantWorktree =
|
|
550
|
-
typeof filter?.worktree === "string" ? filter.worktree : "";
|
|
551
|
-
const excludeId = normalizeSessionId(filter?.excludeSessionId);
|
|
552
|
-
|
|
553
|
-
// Require at least one non-empty locator — never enumerate the whole registry.
|
|
554
|
-
if (!wantCwd && !wantWorktree) return [];
|
|
555
|
-
|
|
556
|
-
return [...sessions.values()]
|
|
557
|
-
.filter((session) => {
|
|
558
|
-
if (!isLiveStatus(session.status)) {
|
|
559
|
-
return false;
|
|
560
|
-
}
|
|
561
|
-
if (excludeId && session.sessionId === excludeId) return false;
|
|
562
|
-
const cwdMatch = Boolean(wantCwd) && session.cwd === wantCwd;
|
|
563
|
-
const worktreeMatch =
|
|
564
|
-
Boolean(wantWorktree) && session.worktreePath === wantWorktree;
|
|
565
|
-
return cwdMatch || worktreeMatch;
|
|
566
|
-
})
|
|
567
|
-
.map((session) => cloneSession(session));
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
function onStale(callback) {
|
|
571
|
-
if (typeof callback !== "function") return;
|
|
572
|
-
staleCallbacks.add(callback);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
function onIdle(callback) {
|
|
576
|
-
if (typeof callback !== "function") return;
|
|
577
|
-
idleCallbacks.add(callback);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function onRemoved(callback) {
|
|
581
|
-
if (typeof callback !== "function") return;
|
|
582
|
-
removedCallbacks.add(callback);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function snapshot() {
|
|
586
|
-
return {
|
|
587
|
-
sessions: getAll(),
|
|
588
|
-
};
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
function destroy() {
|
|
592
|
-
destroyed = true;
|
|
593
|
-
for (const sessionId of monitors.keys()) {
|
|
594
|
-
stopMonitor(sessionId);
|
|
595
|
-
}
|
|
596
|
-
if (persistTimer) {
|
|
597
|
-
clearTimeout(persistTimer);
|
|
598
|
-
persistTimer = null;
|
|
599
|
-
}
|
|
600
|
-
persist();
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
return Object.freeze({
|
|
604
|
-
register,
|
|
605
|
-
unregister,
|
|
606
|
-
pruneExpired,
|
|
607
|
-
heartbeat,
|
|
608
|
-
getActive,
|
|
609
|
-
getAll,
|
|
610
|
-
getSession,
|
|
611
|
-
querySessions,
|
|
612
|
-
onStale,
|
|
613
|
-
onIdle,
|
|
614
|
-
onRemoved,
|
|
615
|
-
snapshot,
|
|
616
|
-
destroy,
|
|
617
|
-
});
|
|
618
|
-
}
|