@triflux/remote 10.33.0 → 10.34.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/hub-lifecycle.mjs +18 -12
- package/hub/mac-tray.swift +1 -1
- package/hub/public/tray.html +90 -58
- package/hub/server.mjs +38 -21
- package/hub/team/claude-agent-session-normalizer.mjs +53 -0
- package/hub/team/claude-daemon-control.mjs +51 -15
- package/hub/team/claude-session-projection.mjs +57 -0
- package/hub/team/cli/services/hub-client.mjs +12 -3
- package/hub/team/synapse-registry.mjs +32 -5
- package/hub/tray-lifecycle.mjs +7 -1
- package/hub/tray.mjs +13 -8
- package/package.json +1 -1
- package/scripts/lib/env-probe.mjs +5 -2
package/hub/hub-lifecycle.mjs
CHANGED
|
@@ -15,15 +15,21 @@ const EPHEMERAL_ENV_KEYS = [
|
|
|
15
15
|
"TFX_TEAM_AGENT_NAME",
|
|
16
16
|
"TFX_EPHEMERAL",
|
|
17
17
|
];
|
|
18
|
+
const WORKTREE_CWD_PATTERNS = [
|
|
19
|
+
/\/\.claude\/worktrees\//u,
|
|
20
|
+
/\/\.worktrees\//u,
|
|
21
|
+
/\/\.codex-swarm\/wt-[^/]+(?:\/|$)/u,
|
|
22
|
+
/(^|\/)wt-[^/]+(?:\/|$)/u,
|
|
23
|
+
];
|
|
18
24
|
|
|
19
|
-
function
|
|
25
|
+
function parsePositivePort(value) {
|
|
20
26
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
21
27
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
function
|
|
30
|
+
function parsePortFromHubUrl(value) {
|
|
25
31
|
try {
|
|
26
|
-
return
|
|
32
|
+
return parsePositivePort(new URL(String(value)).port);
|
|
27
33
|
} catch {
|
|
28
34
|
return null;
|
|
29
35
|
}
|
|
@@ -40,12 +46,7 @@ export function isWorktreeOrEphemeralHubContext({
|
|
|
40
46
|
env = process.env,
|
|
41
47
|
} = {}) {
|
|
42
48
|
const normalizedCwd = String(cwd || "").replace(/\\/g, "/");
|
|
43
|
-
if (
|
|
44
|
-
normalizedCwd.includes("/.claude/worktrees/") ||
|
|
45
|
-
normalizedCwd.includes("/.worktrees/") ||
|
|
46
|
-
normalizedCwd.includes("/.codex-swarm/wt-") ||
|
|
47
|
-
/(^|\/)wt-[^/]+(?:\/|$)/u.test(normalizedCwd)
|
|
48
|
-
) {
|
|
49
|
+
if (WORKTREE_CWD_PATTERNS.some((pattern) => pattern.test(normalizedCwd))) {
|
|
49
50
|
return true;
|
|
50
51
|
}
|
|
51
52
|
return EPHEMERAL_ENV_KEYS.some((key) => String(env?.[key] || "").length > 0);
|
|
@@ -57,11 +58,16 @@ export function resolveHubPortForContext({
|
|
|
57
58
|
cwd = process.cwd(),
|
|
58
59
|
defaultPort = HUB_DEFAULT_PORT,
|
|
59
60
|
} = {}) {
|
|
60
|
-
const envPort =
|
|
61
|
-
|
|
61
|
+
const envPort =
|
|
62
|
+
parsePositivePort(port) ?? parsePositivePort(env?.TFX_HUB_PORT);
|
|
63
|
+
const urlPort = parsePortFromHubUrl(env?.TFX_HUB_URL);
|
|
62
64
|
const resolvedPort = envPort ?? urlPort ?? defaultPort;
|
|
63
65
|
if (
|
|
64
66
|
resolvedPort !== defaultPort &&
|
|
67
|
+
// Test-only opt-in seam: TFX_HUB_ALLOW_EPHEMERAL_PORT=1 lets an ephemeral
|
|
68
|
+
// context (worktree cwd / TFX_TEAM_* env) honor the resolved port instead
|
|
69
|
+
// of clamping to the canonical default. Default-off — production unchanged.
|
|
70
|
+
String(env?.TFX_HUB_ALLOW_EPHEMERAL_PORT ?? "") !== "1" &&
|
|
65
71
|
isWorktreeOrEphemeralHubContext({ cwd, env })
|
|
66
72
|
) {
|
|
67
73
|
return defaultPort;
|
|
@@ -181,7 +187,7 @@ export async function reapExistingHubProcesses({
|
|
|
181
187
|
typeof readPidFileFn === "function"
|
|
182
188
|
? readPidFileFn()
|
|
183
189
|
: { pid: readPidFilePid({ pidFilePath }) };
|
|
184
|
-
const pidFilePid =
|
|
190
|
+
const pidFilePid = parsePositivePort(pidFileInfo?.pid);
|
|
185
191
|
const defaultPortPids = [
|
|
186
192
|
...new Set(
|
|
187
193
|
(typeof findListeningPidsForPortFn === "function"
|
package/hub/mac-tray.swift
CHANGED
|
@@ -63,7 +63,7 @@ class WebViewController: NSViewController, WKNavigationDelegate, WKScriptMessage
|
|
|
63
63
|
override func loadView() {
|
|
64
64
|
let webConfiguration = WKWebViewConfiguration()
|
|
65
65
|
webConfiguration.userContentController.add(self, name: "tray")
|
|
66
|
-
let initialFrame = NSRect(x: 0, y: 0, width:
|
|
66
|
+
let initialFrame = NSRect(x: 0, y: 0, width: 460, height: 720)
|
|
67
67
|
webView = WKWebView(frame: initialFrame, configuration: webConfiguration)
|
|
68
68
|
|
|
69
69
|
// Transparent background for Glassmorphism
|
package/hub/public/tray.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<title>Triflux CTO Tray</title>
|
|
6
6
|
<style>
|
|
7
7
|
:root {
|
|
8
|
-
--bg-color: rgba(30, 30, 30, 0.
|
|
8
|
+
--bg-color: rgba(30, 30, 30, 0.76);
|
|
9
9
|
--border-color: rgba(255,255,255,0.15);
|
|
10
10
|
--text-main: #ffffff;
|
|
11
11
|
--text-muted: rgba(255,255,255,0.58);
|
|
@@ -44,6 +44,12 @@
|
|
|
44
44
|
.stat-label { font-size: 10px; color: var(--text-muted); margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
45
45
|
.stat-value { font-size: 18px; font-weight: 650; font-variant-numeric: tabular-nums; display: flex; align-items: baseline; gap: 4px; }
|
|
46
46
|
.stat-unit { font-size: 10px; font-weight: 400; color: var(--text-muted); }
|
|
47
|
+
.session-overview { display: grid; grid-template-columns: 1.25fr repeat(3, minmax(0, 0.78fr)); gap: 8px; }
|
|
48
|
+
.summary-card { min-width: 0; border-radius: 10px; padding: 9px 10px; background: rgba(0,0,0,0.18); border: 1px solid rgba(255,255,255,0.07); }
|
|
49
|
+
.summary-card.primary { background: rgba(10,132,255,0.14); border-color: rgba(10,132,255,0.24); }
|
|
50
|
+
.summary-kpi { font-size: 18px; line-height: 1; font-weight: 760; font-variant-numeric: tabular-nums; letter-spacing: -0.3px; }
|
|
51
|
+
.summary-label { margin-top: 4px; font-size: 10px; font-weight: 700; color: rgba(255,255,255,0.76); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
52
|
+
.summary-sub { margin-top: 2px; font-size: 9px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
47
53
|
.models-list, .server-list, .session-list { display: flex; flex-direction: column; gap: 8px; }
|
|
48
54
|
.model-row { display: grid; grid-template-columns: minmax(76px, auto) 1fr auto; gap: 10px; align-items: center; font-size: 12px; min-width: 0; }
|
|
49
55
|
.model-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
@@ -60,25 +66,25 @@
|
|
|
60
66
|
.focus-btn:hover { background: var(--accent); color: #fff; }
|
|
61
67
|
.agent-view { display: flex; flex-direction: column; gap: 8px; }
|
|
62
68
|
.project-group { min-width: 0; border: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.13); border-radius: 9px; overflow: hidden; }
|
|
63
|
-
.project-group > summary, .
|
|
64
|
-
.project-group > summary::-webkit-details-marker, .
|
|
65
|
-
.project-summary { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; gap:
|
|
69
|
+
.project-group > summary, .session-card > summary, .bubble-wrap > summary { list-style: none; }
|
|
70
|
+
.project-group > summary::-webkit-details-marker, .session-card > summary::-webkit-details-marker, .bubble-wrap > summary::-webkit-details-marker { display: none; }
|
|
71
|
+
.project-summary { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 9px 10px; cursor: pointer; }
|
|
66
72
|
.project-chevron { color: rgba(255,255,255,0.44); font-size: 11px; transform: rotate(-90deg); transition: transform 120ms ease; }
|
|
67
73
|
.project-group[open] .project-chevron { transform: rotate(0deg); }
|
|
68
74
|
.project-name { color: #b6b1e8; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; font-weight: 750; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
69
|
-
.project-count { color:
|
|
75
|
+
.project-count { display: inline-flex; align-items: center; justify-content: center; min-height: 20px; border-radius: 999px; padding: 2px 7px; color: rgba(255,255,255,0.72); background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.07); font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
70
76
|
.project-path { color: var(--text-muted); font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 10px; padding: 0 9px 8px 32px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
71
77
|
.project-body { padding: 0 8px 9px; display: flex; flex-direction: column; gap: 7px; }
|
|
72
|
-
.
|
|
73
|
-
.
|
|
74
|
-
.
|
|
75
|
-
.
|
|
76
|
-
.
|
|
77
|
-
.
|
|
78
|
-
.
|
|
79
|
-
.
|
|
78
|
+
.session-card { border: 1px solid rgba(10,132,255,0.13); background: rgba(255,255,255,0.035); border-radius: 9px; overflow: hidden; }
|
|
79
|
+
.session-summary { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 8px 9px; cursor: pointer; }
|
|
80
|
+
.session-summary-title { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 11px; font-weight: 740; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
81
|
+
.session-summary-sub { color: var(--text-muted); font-size: 10px; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
82
|
+
.session-detail { display: flex; flex-direction: column; gap: 9px; padding: 9px; border-top: 1px solid rgba(255,255,255,0.05); }
|
|
83
|
+
.detail-section { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
|
84
|
+
.detail-label { color: var(--text-muted); font-size: 9px; font-weight: 800; letter-spacing: 0.5px; text-transform: uppercase; }
|
|
85
|
+
.detail-empty { color: var(--text-muted); border: 1px dashed rgba(255,255,255,0.10); border-radius: 8px; padding: 8px; font-size: 10px; }
|
|
80
86
|
.runtime-list { display: flex; flex-direction: column; gap: 7px; }
|
|
81
|
-
.runtime-card { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap:
|
|
87
|
+
.runtime-card { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 8px; border-radius: 8px; background: rgba(255,255,255,0.045); border: 1px solid rgba(255,255,255,0.07); }
|
|
82
88
|
.runtime-main { min-width: 0; }
|
|
83
89
|
.runtime-title { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
84
90
|
.runtime-command { color: rgba(255,255,255,0.90); font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 11px; font-weight: 750; }
|
|
@@ -89,8 +95,8 @@
|
|
|
89
95
|
.bubble-card { min-width: 0; }
|
|
90
96
|
.bubble-wrap { min-width: 0; }
|
|
91
97
|
.bubble-preview { display: block; cursor: pointer; border-radius: 12px; padding: 8px 9px; font-size: 11px; line-height: 1.35; color: rgba(255,255,255,0.88); background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.08); max-height: 48px; overflow: hidden; }
|
|
92
|
-
.worker
|
|
93
|
-
.cto
|
|
98
|
+
.bubble-card.worker .bubble-preview { border-top-left-radius: 4px; }
|
|
99
|
+
.bubble-card.cto .bubble-preview { background: rgba(10,132,255,0.14); border-color: rgba(10,132,255,0.18); border-top-right-radius: 4px; }
|
|
94
100
|
.bubble-full { margin: 6px 0 0; white-space: pre-wrap; word-break: break-word; max-height: 190px; overflow: auto; padding: 8px; border-radius: 8px; background: rgba(0,0,0,0.28); color: rgba(255,255,255,0.82); font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 10px; line-height: 1.35; }
|
|
95
101
|
.agent-line { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 5px; align-items: start; min-width: 0; padding: 3px 0; }
|
|
96
102
|
.agent-line.worker { padding-left: 10px; }
|
|
@@ -112,6 +118,12 @@
|
|
|
112
118
|
.chip.on { color: var(--success); border-color: rgba(50,215,75,0.25); background: rgba(50,215,75,0.08); }
|
|
113
119
|
.chip.partial { color: var(--warning); border-color: rgba(255,214,10,0.25); background: rgba(255,214,10,0.08); }
|
|
114
120
|
.chip.off { color: var(--danger); border-color: rgba(255,69,58,0.25); background: rgba(255,69,58,0.08); }
|
|
121
|
+
.client-chip-row { display: flex; gap: 7px; flex-wrap: wrap; }
|
|
122
|
+
.client-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; border-radius: 999px; padding: 4px 8px; background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.08); color: rgba(255,255,255,0.82); font-size: 11px; }
|
|
123
|
+
.client-chip strong { font-variant-numeric: tabular-nums; color: var(--text-main); }
|
|
124
|
+
.client-chip.on { border-color: rgba(50,215,75,0.24); }
|
|
125
|
+
.client-chip.partial { border-color: rgba(255,214,10,0.24); }
|
|
126
|
+
.client-chip.off { border-color: rgba(255,69,58,0.24); }
|
|
115
127
|
.agent-badge { width: 20px; height: 20px; display: inline-grid; place-items: center; flex: 0 0 auto; border-radius: 6px; font-size: 11px; font-weight: 850; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; border: 1px solid rgba(255,255,255,0.12); }
|
|
116
128
|
.agent-tag { justify-content: center; }
|
|
117
129
|
.agent-tag.claude { color: #d19a66; background: rgba(209,154,102,0.12); border-color: rgba(209,154,102,0.32); }
|
|
@@ -202,14 +214,14 @@
|
|
|
202
214
|
|
|
203
215
|
function renderRuntimeClients(runtime) {
|
|
204
216
|
const clients = Array.isArray(runtime?.summary?.clients) ? runtime.summary.clients : [];
|
|
205
|
-
|
|
206
|
-
return clients.map(client => `
|
|
207
|
-
<
|
|
208
|
-
<
|
|
209
|
-
<
|
|
210
|
-
<
|
|
211
|
-
</
|
|
212
|
-
`).join('')
|
|
217
|
+
if (clients.length === 0) return '<div class="mini-card muted">No runtime clients reported</div>';
|
|
218
|
+
return `<div class="client-chip-row">${clients.map(client => `
|
|
219
|
+
<span class="client-chip ${escapeHtml(client.status)}">
|
|
220
|
+
<span class="dot ${escapeHtml(client.id)}" style="background:${escapeHtml(client.color)};box-shadow:0 0 6px ${escapeHtml(client.color)}"></span>
|
|
221
|
+
<span>${escapeHtml(client.label)}</span>
|
|
222
|
+
<strong>${escapeHtml(client.count || 0)}</strong>
|
|
223
|
+
</span>
|
|
224
|
+
`).join('')}</div>`;
|
|
213
225
|
}
|
|
214
226
|
|
|
215
227
|
function liveFallbackRows(live) {
|
|
@@ -246,21 +258,28 @@
|
|
|
246
258
|
return path.replace(/^\/Users\/[^/]+/, '~');
|
|
247
259
|
}
|
|
248
260
|
|
|
261
|
+
function normalizeProjectPath(value) {
|
|
262
|
+
const path = String(value || '').trim();
|
|
263
|
+
if (!path) return '';
|
|
264
|
+
if (path === '/' || /^\/+$/u.test(path) || /^[A-Za-z]:[\\/]$/u.test(path)) return path;
|
|
265
|
+
return path.replace(/[\\/]+$/u, '') || path;
|
|
266
|
+
}
|
|
267
|
+
|
|
249
268
|
function projectName(value) {
|
|
250
|
-
const path =
|
|
269
|
+
const path = normalizeProjectPath(value);
|
|
251
270
|
if (!path) return 'local';
|
|
252
|
-
return path.split(
|
|
271
|
+
return path.split(/[\\/]/u).filter(Boolean).pop() || path;
|
|
253
272
|
}
|
|
254
273
|
|
|
255
274
|
function projectPathFor(row, hub, fallback = '') {
|
|
256
275
|
const cwd = String(row?.cwd || row?.worktreePath || '').trim();
|
|
257
|
-
if (cwd && cwd !== 'local' && cwd !== 'local runtime') return cwd;
|
|
276
|
+
if (cwd && cwd !== 'local' && cwd !== 'local runtime') return normalizeProjectPath(cwd);
|
|
258
277
|
const root = String(hub?.projectRoot || fallback || '').trim();
|
|
259
|
-
return root || cwd || 'local';
|
|
278
|
+
return normalizeProjectPath(root || cwd || 'local');
|
|
260
279
|
}
|
|
261
280
|
|
|
262
281
|
function getProjectGroup(groups, projectPath) {
|
|
263
|
-
const key = projectPath || 'local';
|
|
282
|
+
const key = normalizeProjectPath(projectPath) || 'local';
|
|
264
283
|
if (!groups.has(key)) groups.set(key, { projectPath: key, ctos: [], workers: [] });
|
|
265
284
|
return groups.get(key);
|
|
266
285
|
}
|
|
@@ -356,9 +375,9 @@
|
|
|
356
375
|
const focusDisabled = row.synthetic || !sid;
|
|
357
376
|
const badge = side === 'cto'
|
|
358
377
|
? '<span class="agent-badge">T</span>'
|
|
359
|
-
:
|
|
378
|
+
: renderAgentTag(row.agent) || '<span class="agent-badge">A</span>';
|
|
360
379
|
return `
|
|
361
|
-
<div class="bubble-card">
|
|
380
|
+
<div class="bubble-card ${escapeHtml(side)}">
|
|
362
381
|
<div class="chat-session-title">
|
|
363
382
|
<span style="display:flex;align-items:center;gap:6px;min-width:0;">${badge}<span class="chat-session-id">${escapeHtml(sid || side)}</span></span>
|
|
364
383
|
${focusDisabled ? '' : `<button class="focus-btn" data-focus-session="1" data-session-id="${dataAttr(sid)}" data-pid="${dataAttr(pid)}" data-agent-id="${dataAttr(agentId)}">Focus</button>`}
|
|
@@ -383,7 +402,6 @@
|
|
|
383
402
|
<div class="runtime-main">
|
|
384
403
|
<div class="runtime-title"><span class="runtime-command">${escapeHtml(commandLabel(row))}</span><span class="agent-state ${escapeHtml(status)}">${escapeHtml(status)}</span></div>
|
|
385
404
|
<div class="runtime-sub">${escapeHtml(displayPath(row.cwd || row.host || 'local runtime'))}${row.elapsed ? ` · ${escapeHtml(row.elapsed)}` : ''}</div>
|
|
386
|
-
<div class="runtime-meta">${pid ? `<span class="chip">PID ${escapeHtml(pid)}</span>` : ''}${renderCopyChip('ID', sid)}</div>
|
|
387
405
|
</div>
|
|
388
406
|
${focusDisabled ? '' : `<button class="focus-btn" data-focus-session="1" data-session-id="${dataAttr(sid)}" data-pid="${dataAttr(pid)}" data-agent-id="${dataAttr(agentId)}">Focus</button>`}
|
|
389
407
|
</div>
|
|
@@ -392,42 +410,53 @@
|
|
|
392
410
|
|
|
393
411
|
function renderCtoChat(ctoRow, workers) {
|
|
394
412
|
const status = ctoRow.status || ctoRow.phase || 'active';
|
|
395
|
-
const title = compactText(ctoRow.sessionId || ctoRow.taskSummary || '
|
|
396
|
-
const subtitle = compactText(hasPromptText(ctoRow) ? ctoRow.taskSummary || ctoRow.sessionKind || '' : '', '');
|
|
413
|
+
const title = compactText(ctoRow.sessionId || ctoRow.taskSummary || 'Session', 'Session');
|
|
414
|
+
const subtitle = compactText(hasPromptText(ctoRow) ? ctoRow.taskSummary || ctoRow.sessionKind || '' : displayPath(ctoRow.cwd || ctoRow.worktreePath || ''), '');
|
|
415
|
+
const promptHtml = hasPromptText(ctoRow)
|
|
416
|
+
? `<div class="detail-section"><div class="detail-label">Prompt</div>${renderChatBubble(ctoRow, { side: 'cto' })}</div>`
|
|
417
|
+
: '';
|
|
418
|
+
const agentRows = workers.map(worker => hasPromptText(worker) ? renderChatBubble(worker, { side: 'worker' }) : renderRuntimeCard(worker)).join('');
|
|
419
|
+
const agentsHtml = workers.length
|
|
420
|
+
? `<div class="detail-section"><div class="detail-label">Agents</div><div class="runtime-list">${agentRows}</div></div>`
|
|
421
|
+
: '';
|
|
422
|
+
const emptyHtml = promptHtml || agentsHtml ? '' : '<div class="detail-empty">No session details yet</div>';
|
|
397
423
|
return `
|
|
398
|
-
<details class="
|
|
399
|
-
<summary class="
|
|
400
|
-
<
|
|
424
|
+
<details class="session-card" data-open-key="cto:${dataAttr(ctoRow.sessionId || title)}">
|
|
425
|
+
<summary class="session-summary">
|
|
426
|
+
<span class="agent-badge">T</span>
|
|
401
427
|
<div style="min-width:0;">
|
|
402
|
-
<div class="
|
|
403
|
-
${subtitle ? `<div class="
|
|
428
|
+
<div class="session-summary-title">${escapeHtml(title)}</div>
|
|
429
|
+
${subtitle ? `<div class="session-summary-sub">${escapeHtml(subtitle)}</div>` : ''}
|
|
404
430
|
</div>
|
|
405
431
|
<div class="agent-state ${escapeHtml(status)}">${escapeHtml(status)}</div>
|
|
406
432
|
</summary>
|
|
407
|
-
<div class="
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
</div>
|
|
412
|
-
<div class="chat-column cto-column">
|
|
413
|
-
<div class="chat-column-title">CTO</div>
|
|
414
|
-
${hasPromptText(ctoRow) ? renderChatBubble(ctoRow, { side: 'cto' }) : '<div class="chat-empty">No prompt handoff events captured yet</div>'}
|
|
415
|
-
</div>
|
|
433
|
+
<div class="session-detail">
|
|
434
|
+
${promptHtml}
|
|
435
|
+
${agentsHtml}
|
|
436
|
+
${emptyHtml}
|
|
416
437
|
</div>
|
|
417
438
|
</details>
|
|
418
439
|
`;
|
|
419
440
|
}
|
|
420
441
|
|
|
442
|
+
function pluralize(count, one, many = `${one}s`) {
|
|
443
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
421
446
|
function renderProjectGroup(group) {
|
|
422
447
|
const projectDisplayPath = displayPath(group.projectPath);
|
|
423
448
|
const projectLabel = projectName(group.projectPath);
|
|
424
|
-
const
|
|
449
|
+
const sessionCount = group.ctos.filter(row => !isSyntheticCto(row)).length;
|
|
450
|
+
const agentCount = group.workers.length;
|
|
451
|
+
const projectCount = [sessionCount ? pluralize(sessionCount, 'session') : '', agentCount ? pluralize(agentCount, 'agent') : '']
|
|
452
|
+
.filter(Boolean)
|
|
453
|
+
.join(' · ') || 'empty';
|
|
425
454
|
let html = `
|
|
426
455
|
<details class="project-group" data-open-key="project:${dataAttr(group.projectPath)}">
|
|
427
456
|
<summary class="project-summary">
|
|
428
457
|
<span class="project-chevron">▾</span>
|
|
429
458
|
<span class="project-name">${escapeHtml(projectLabel)}</span>
|
|
430
|
-
<span class="project-count">${escapeHtml(
|
|
459
|
+
<span class="project-count">${escapeHtml(projectCount)}</span>
|
|
431
460
|
</summary>
|
|
432
461
|
<div class="project-path">${escapeHtml(projectDisplayPath)}</div>
|
|
433
462
|
<div class="project-body">
|
|
@@ -452,23 +481,26 @@
|
|
|
452
481
|
const activeSessions = sessions.filter(s => s.status !== 'stale');
|
|
453
482
|
const liveRows = liveFallbackRows(live);
|
|
454
483
|
const projectGroups = buildProjectGroups(activeSessions.slice(0, 10), liveRows, hub);
|
|
484
|
+
const activeCount = activeSessions.filter(s => s.status === 'active').length;
|
|
485
|
+
const idleCount = activeSessions.filter(s => s.status === 'idle').length;
|
|
455
486
|
const staleCount = sessions.filter(s => s.status === 'stale').length;
|
|
487
|
+
const runtimeTotal = runtime?.summary?.total || liveRows.length || 0;
|
|
456
488
|
let html = `
|
|
457
489
|
<div class="section">
|
|
458
|
-
<div class="
|
|
459
|
-
<div class="
|
|
460
|
-
<div class="
|
|
461
|
-
<div class="
|
|
462
|
-
<div class="
|
|
490
|
+
<div class="session-overview">
|
|
491
|
+
<div class="summary-card primary"><div class="summary-kpi">${live.length}</div><div class="summary-label">Live agents</div><div class="summary-sub">${runtimeTotal} runtime CLIs</div></div>
|
|
492
|
+
<div class="summary-card"><div class="summary-kpi">${activeCount}</div><div class="summary-label">Active</div></div>
|
|
493
|
+
<div class="summary-card"><div class="summary-kpi">${idleCount}</div><div class="summary-label">Idle</div></div>
|
|
494
|
+
<div class="summary-card"><div class="summary-kpi">${staleCount}</div><div class="summary-label">Stale</div></div>
|
|
463
495
|
</div>
|
|
464
|
-
|
|
496
|
+
<div class="path-text">${projectGroups.length} ${projectGroups.length === 1 ? 'workspace' : 'workspaces'} · ${cto.active_shards?.length || 0} shards · ${renderCopyChip('Hub', hub.id || String(hub.pid || ''))}</div>
|
|
465
497
|
</div>
|
|
466
498
|
<div class="section">
|
|
467
|
-
<div class="section-title">
|
|
499
|
+
<div class="section-title">Agent Mix</div>
|
|
468
500
|
<div class="models-list">${renderRuntimeClients(runtime)}</div>
|
|
469
501
|
</div>
|
|
470
502
|
<div class="section">
|
|
471
|
-
<div class="section-title">
|
|
503
|
+
<div class="section-title">Workspaces</div>
|
|
472
504
|
<div class="agent-view">
|
|
473
505
|
`;
|
|
474
506
|
for (const group of projectGroups) html += renderProjectGroup(group);
|
package/hub/server.mjs
CHANGED
|
@@ -28,32 +28,32 @@ import {
|
|
|
28
28
|
inspectRegistry,
|
|
29
29
|
inspectRegistryStatus,
|
|
30
30
|
} from "../scripts/lib/mcp-guard-engine.mjs";
|
|
31
|
-
import { broker as brokerInstance, reloadBroker } from "
|
|
32
|
-
import { createAdaptiveEngine } from "
|
|
33
|
-
import { createAssignCallbackServer } from "
|
|
34
|
-
import { DelegatorService } from "
|
|
35
|
-
import { createHitlManager } from "
|
|
36
|
-
import {
|
|
37
|
-
cleanupOrphanNodeProcesses,
|
|
38
|
-
cleanupOrphanRuntimeProcesses,
|
|
39
|
-
cleanupStaleFsmonitorDaemons,
|
|
40
|
-
} from "./lib/process-utils.mjs";
|
|
31
|
+
import { broker as brokerInstance, reloadBroker } from "@triflux/core/hub/account-broker.mjs";
|
|
32
|
+
import { createAdaptiveEngine } from "@triflux/core/hub/adaptive.mjs";
|
|
33
|
+
import { createAssignCallbackServer } from "@triflux/core/hub/assign-callbacks.mjs";
|
|
34
|
+
import { DelegatorService } from "@triflux/core/hub/delegator/index.mjs";
|
|
35
|
+
import { createHitlManager } from "@triflux/core/hub/hitl.mjs";
|
|
41
36
|
import {
|
|
42
37
|
reapExistingHubProcesses,
|
|
43
38
|
resolveHubPortForContext,
|
|
44
39
|
} from "./hub-lifecycle.mjs";
|
|
45
|
-
import
|
|
40
|
+
import {
|
|
41
|
+
cleanupOrphanNodeProcesses,
|
|
42
|
+
cleanupOrphanRuntimeProcesses,
|
|
43
|
+
cleanupStaleFsmonitorDaemons,
|
|
44
|
+
} from "@triflux/core/hub/lib/process-utils.mjs";
|
|
45
|
+
import * as spawnTrace from "@triflux/core/hub/lib/spawn-trace.mjs";
|
|
46
46
|
import {
|
|
47
47
|
recordRequest,
|
|
48
48
|
recordWorker,
|
|
49
49
|
snapshot as traceSnapshot,
|
|
50
|
-
} from "
|
|
50
|
+
} from "@triflux/core/hub/lib/trace-recorder.mjs";
|
|
51
51
|
import { focusSessionOnMac } from "./mac-focus.mjs";
|
|
52
|
-
import { logQuotaRefreshFailures } from "
|
|
53
|
-
import { wrapRequestHandler } from "
|
|
52
|
+
import { logQuotaRefreshFailures } from "@triflux/core/hub/middleware/quota-middleware.mjs";
|
|
53
|
+
import { wrapRequestHandler } from "@triflux/core/hub/middleware/request-logger.mjs";
|
|
54
54
|
import { createPipeServer } from "./pipe.mjs";
|
|
55
|
-
import { createRouter } from "
|
|
56
|
-
import { createAdaptiveFingerprintService } from "
|
|
55
|
+
import { createRouter } from "@triflux/core/hub/router.mjs";
|
|
56
|
+
import { createAdaptiveFingerprintService } from "@triflux/core/hub/session-fingerprint.mjs";
|
|
57
57
|
import {
|
|
58
58
|
acquireLock,
|
|
59
59
|
getVersionHash,
|
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
readState,
|
|
62
62
|
releaseLock,
|
|
63
63
|
writeState,
|
|
64
|
-
} from "
|
|
64
|
+
} from "@triflux/core/hub/state.mjs";
|
|
65
65
|
import { createStoreAdapter } from "./store-adapter.mjs";
|
|
66
66
|
import { createCtoAutoCollector } from "./team/cto-auto-collect.mjs";
|
|
67
67
|
import { createGitPreflight } from "./team/git-preflight.mjs";
|
|
@@ -71,7 +71,7 @@ import {
|
|
|
71
71
|
createSynapseRegistry,
|
|
72
72
|
projectPeer,
|
|
73
73
|
} from "./team/synapse-registry.mjs";
|
|
74
|
-
import { registerTeamBridge } from "
|
|
74
|
+
import { registerTeamBridge } from "@triflux/core/hub/team-bridge.mjs";
|
|
75
75
|
import { createTools } from "./tools.mjs";
|
|
76
76
|
import { spawnTrayForHub } from "./tray-lifecycle.mjs";
|
|
77
77
|
import { getRuntimeStatus } from "./tray-runtime.mjs";
|
|
@@ -659,7 +659,10 @@ export function resolveHubIdleTimeoutMs({
|
|
|
659
659
|
env = process.env,
|
|
660
660
|
defaultPort = HUB_DEFAULT_PORT,
|
|
661
661
|
} = {}) {
|
|
662
|
-
const parsed = Number.parseInt(
|
|
662
|
+
const parsed = Number.parseInt(
|
|
663
|
+
String(env?.TFX_HUB_IDLE_TIMEOUT_MS ?? ""),
|
|
664
|
+
10,
|
|
665
|
+
);
|
|
663
666
|
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
|
664
667
|
return Number(port) === defaultPort ? 0 : HUB_IDLE_TIMEOUT_DEFAULT_MS;
|
|
665
668
|
}
|
|
@@ -1073,7 +1076,7 @@ export async function startHub({
|
|
|
1073
1076
|
|
|
1074
1077
|
// adaptive rule confidence decay (7일 이상 미관측 규칙 -0.1 감소)
|
|
1075
1078
|
try {
|
|
1076
|
-
const { decayRules } = await import("
|
|
1079
|
+
const { decayRules } = await import("@triflux/core/hub/reflexion.mjs");
|
|
1077
1080
|
const decay = decayRules(
|
|
1078
1081
|
store,
|
|
1079
1082
|
adaptiveEngine.sessionCount?.() || 1,
|
|
@@ -2440,7 +2443,7 @@ export async function startHub({
|
|
|
2440
2443
|
);
|
|
2441
2444
|
if (port === HUB_DEFAULT_PORT) {
|
|
2442
2445
|
void reapExistingHubProcesses({ currentPid: process.pid }).then(
|
|
2443
|
-
({ reaped }) => {
|
|
2446
|
+
({ reaped, failed }) => {
|
|
2444
2447
|
if (reaped.length > 0) {
|
|
2445
2448
|
hubLog.info(
|
|
2446
2449
|
{
|
|
@@ -2451,6 +2454,20 @@ export async function startHub({
|
|
|
2451
2454
|
"hub.startup_reaper",
|
|
2452
2455
|
);
|
|
2453
2456
|
}
|
|
2457
|
+
// Surface kill-unable orphans (EPERM/defunct). Without this the
|
|
2458
|
+
// reaper's failed[] is dropped and an unreapable hub survives
|
|
2459
|
+
// with zero log signal (FU3 observability gap).
|
|
2460
|
+
if (failed && failed.length > 0) {
|
|
2461
|
+
hubLog.warn(
|
|
2462
|
+
{
|
|
2463
|
+
failed: failed.length,
|
|
2464
|
+
failedCount: failed.length,
|
|
2465
|
+
processes: failed,
|
|
2466
|
+
caller: "startup",
|
|
2467
|
+
},
|
|
2468
|
+
"hub.startup_reaper_failed",
|
|
2469
|
+
);
|
|
2470
|
+
}
|
|
2454
2471
|
},
|
|
2455
2472
|
);
|
|
2456
2473
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
function firstString(...values) {
|
|
2
|
+
for (const value of values) {
|
|
3
|
+
const text = String(value ?? "").trim();
|
|
4
|
+
if (text) return text;
|
|
5
|
+
}
|
|
6
|
+
return "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function maybeAssign(target, key, value) {
|
|
10
|
+
if (value !== "" && value != null) target[key] = value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeClaudeAgentSession(row = {}) {
|
|
14
|
+
const short = firstString(row.short, row.id, row.jobId, row.job_id);
|
|
15
|
+
const sessionId = firstString(
|
|
16
|
+
row.sessionId,
|
|
17
|
+
row.session_id,
|
|
18
|
+
row.dispatch?.sessionId,
|
|
19
|
+
row.dispatch?.session_id,
|
|
20
|
+
row.d?.sessionId,
|
|
21
|
+
row.d?.session_id,
|
|
22
|
+
row.session?.id,
|
|
23
|
+
);
|
|
24
|
+
const state = firstString(row.state, row.status, row.tempo, "unknown");
|
|
25
|
+
const status = firstString(row.status, row.state, row.tempo, "unknown");
|
|
26
|
+
|
|
27
|
+
const normalized = {
|
|
28
|
+
...row,
|
|
29
|
+
short,
|
|
30
|
+
id: firstString(row.id, short),
|
|
31
|
+
sessionId,
|
|
32
|
+
session_id: sessionId,
|
|
33
|
+
state,
|
|
34
|
+
status,
|
|
35
|
+
};
|
|
36
|
+
maybeAssign(normalized, "cwd", row.cwd);
|
|
37
|
+
maybeAssign(normalized, "name", row.name);
|
|
38
|
+
maybeAssign(normalized, "kind", row.kind);
|
|
39
|
+
maybeAssign(normalized, "waitingFor", row.waitingFor ?? row.waiting_for);
|
|
40
|
+
maybeAssign(normalized, "startedAt", row.startedAt ?? row.started_at);
|
|
41
|
+
maybeAssign(normalized, "updatedAt", row.updatedAt ?? row.updated_at);
|
|
42
|
+
maybeAssign(normalized, "pid", row.pid);
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function extractClaudeAgentSessions(listResponse = {}) {
|
|
47
|
+
const rows = Array.isArray(listResponse.jobs)
|
|
48
|
+
? listResponse.jobs
|
|
49
|
+
: Array.isArray(listResponse.sessions)
|
|
50
|
+
? listResponse.sessions
|
|
51
|
+
: [];
|
|
52
|
+
return rows.map((row) => normalizeClaudeAgentSession(row));
|
|
53
|
+
}
|
|
@@ -4,6 +4,10 @@ import fs from "node:fs/promises";
|
|
|
4
4
|
import net from "node:net";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
extractClaudeAgentSessions,
|
|
9
|
+
normalizeClaudeAgentSession,
|
|
10
|
+
} from "./claude-agent-session-normalizer.mjs";
|
|
7
11
|
import {
|
|
8
12
|
buildClaudeSessionProjection,
|
|
9
13
|
removeClaudeSessionProjection,
|
|
@@ -109,6 +113,29 @@ export function sendClaudeControlRequest(
|
|
|
109
113
|
});
|
|
110
114
|
}
|
|
111
115
|
|
|
116
|
+
// claude daemon 은 control.sock 의 mutating op(dispatch 등)에 control-key
|
|
117
|
+
// 인증을 강제한다 (미제시 시 EAUTH "didn't present the daemon control key").
|
|
118
|
+
// key 는 <configDir>/daemon/control.key (configDir 스코프별). 파일이 없으면
|
|
119
|
+
// 인증 미강제 daemon 으로 보고 auth 필드를 생략한다 (fail-open — 구버전 호환).
|
|
120
|
+
export async function readDaemonControlKey(
|
|
121
|
+
configDir = resolveClaudeConfigDir(),
|
|
122
|
+
) {
|
|
123
|
+
try {
|
|
124
|
+
const key = await fs.readFile(
|
|
125
|
+
path.join(configDir, "daemon", "control.key"),
|
|
126
|
+
"utf8",
|
|
127
|
+
);
|
|
128
|
+
return key.trim() || undefined;
|
|
129
|
+
} catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function buildDaemonControlAuth(configDir) {
|
|
135
|
+
const auth = await readDaemonControlKey(configDir);
|
|
136
|
+
return auth ? { auth } : {};
|
|
137
|
+
}
|
|
138
|
+
|
|
112
139
|
export function buildDaemonAttachRequest({
|
|
113
140
|
short,
|
|
114
141
|
cols = DEFAULT_DAEMON_ATTACH_COLS,
|
|
@@ -1055,27 +1082,27 @@ export function buildClaudePromptDispatchPayload({
|
|
|
1055
1082
|
}
|
|
1056
1083
|
|
|
1057
1084
|
export function findDaemonJobByShort(listResponse, short) {
|
|
1058
|
-
|
|
1059
|
-
|
|
1085
|
+
const expected = String(short || "").trim();
|
|
1086
|
+
if (!expected) return null;
|
|
1087
|
+
return (
|
|
1088
|
+
extractClaudeAgentSessions(listResponse).find(
|
|
1089
|
+
(job) => job.short === expected || job.id === expected,
|
|
1090
|
+
) || null
|
|
1091
|
+
);
|
|
1060
1092
|
}
|
|
1061
1093
|
|
|
1062
1094
|
export function findDaemonJobBySessionId(listResponse, sessionId) {
|
|
1063
|
-
|
|
1064
|
-
const expected = String(sessionId || "");
|
|
1095
|
+
const expected = String(sessionId || "").trim();
|
|
1065
1096
|
if (!expected) return null;
|
|
1066
1097
|
return (
|
|
1067
|
-
listResponse.
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
job?.session_id ??
|
|
1071
|
-
job?.dispatch?.sessionId ??
|
|
1072
|
-
job?.d?.sessionId ??
|
|
1073
|
-
"";
|
|
1074
|
-
return String(candidate) === expected;
|
|
1075
|
-
}) || null
|
|
1098
|
+
extractClaudeAgentSessions(listResponse).find(
|
|
1099
|
+
(job) => String(job.sessionId || job.session_id || "") === expected,
|
|
1100
|
+
) || null
|
|
1076
1101
|
);
|
|
1077
1102
|
}
|
|
1078
1103
|
|
|
1104
|
+
export { extractClaudeAgentSessions, normalizeClaudeAgentSession };
|
|
1105
|
+
|
|
1079
1106
|
export async function waitForDaemonJobPid(
|
|
1080
1107
|
controlSock,
|
|
1081
1108
|
short,
|
|
@@ -1142,11 +1169,12 @@ export async function resolveDaemonBridgeSessionId({
|
|
|
1142
1169
|
}
|
|
1143
1170
|
}
|
|
1144
1171
|
|
|
1145
|
-
export async function killDaemonJob(controlSock, short) {
|
|
1172
|
+
export async function killDaemonJob(controlSock, short, { auth } = {}) {
|
|
1146
1173
|
return await sendClaudeControlRequest(controlSock, {
|
|
1147
1174
|
proto: 1,
|
|
1148
1175
|
op: "kill",
|
|
1149
1176
|
short,
|
|
1177
|
+
...(auth ? { auth } : {}),
|
|
1150
1178
|
});
|
|
1151
1179
|
}
|
|
1152
1180
|
|
|
@@ -1233,6 +1261,7 @@ export async function dispatchClaudeDaemonJob({
|
|
|
1233
1261
|
const writeProjection =
|
|
1234
1262
|
_deps.writeClaudeSessionProjection || writeClaudeSessionProjection;
|
|
1235
1263
|
const readProcStart = _deps.getProcStart || getProcStart;
|
|
1264
|
+
const buildAuth = _deps.buildDaemonControlAuth || buildDaemonControlAuth;
|
|
1236
1265
|
|
|
1237
1266
|
const short = payload.short;
|
|
1238
1267
|
const sessionsDir =
|
|
@@ -1242,6 +1271,7 @@ export async function dispatchClaudeDaemonJob({
|
|
|
1242
1271
|
// native-bridge 는 control.sock 존재를 미리 점검한다 (없으면 fast-fail).
|
|
1243
1272
|
if (accessControlSock) await accessControlSock(resolvedControlSock);
|
|
1244
1273
|
|
|
1274
|
+
const controlAuth = await buildAuth(paths?.configDir);
|
|
1245
1275
|
const dispatch = await sendControl(
|
|
1246
1276
|
resolvedControlSock,
|
|
1247
1277
|
{
|
|
@@ -1249,11 +1279,17 @@ export async function dispatchClaudeDaemonJob({
|
|
|
1249
1279
|
op: "dispatch",
|
|
1250
1280
|
d: payload,
|
|
1251
1281
|
timeoutMs: dispatchTimeoutMs,
|
|
1282
|
+
...controlAuth,
|
|
1252
1283
|
},
|
|
1253
1284
|
{ timeoutMs: dispatchTimeoutMs },
|
|
1254
1285
|
);
|
|
1255
1286
|
if (dispatch?.ok !== true) {
|
|
1256
|
-
|
|
1287
|
+
// daemon 거부 사유를 보존한다 — generic 메시지만 던지면 EAUTH 같은
|
|
1288
|
+
// 실원인이 묻혀 진단이 어려워진다.
|
|
1289
|
+
const reason = dispatch?.error ? `: ${dispatch.error}` : "";
|
|
1290
|
+
throw new Error(
|
|
1291
|
+
`Claude daemon dispatch failed for ${name || short}${reason}`,
|
|
1292
|
+
);
|
|
1257
1293
|
}
|
|
1258
1294
|
|
|
1259
1295
|
const pidOpts =
|
|
@@ -69,6 +69,63 @@ export async function updateClaudeSessionProjection(sessionPath, patch) {
|
|
|
69
69
|
return next;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
function projectionSessionId(projection) {
|
|
73
|
+
return String(
|
|
74
|
+
projection?.sessionId ?? projection?.session_id ?? projection?.id ?? "",
|
|
75
|
+
).trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function findClaudeSessionProjectionBySessionId(
|
|
79
|
+
sessionsDir,
|
|
80
|
+
sessionId,
|
|
81
|
+
) {
|
|
82
|
+
const expected = String(sessionId || "").trim();
|
|
83
|
+
if (!sessionsDir || !expected) return null;
|
|
84
|
+
let entries;
|
|
85
|
+
try {
|
|
86
|
+
entries = await fs.readdir(sessionsDir, { withFileTypes: true });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error?.code === "ENOENT") return null;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
94
|
+
const filePath = path.join(sessionsDir, entry.name);
|
|
95
|
+
try {
|
|
96
|
+
const projection = JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
97
|
+
if (projectionSessionId(projection) === expected) {
|
|
98
|
+
return { path: filePath, projection };
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof SyntaxError) continue;
|
|
102
|
+
if (error?.code === "ENOENT") continue;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function refreshClaudeSessionProjectionCwd({
|
|
110
|
+
sessionsDir,
|
|
111
|
+
sessionId,
|
|
112
|
+
cwd,
|
|
113
|
+
updatedAt = Date.now(),
|
|
114
|
+
} = {}) {
|
|
115
|
+
const nextCwd = String(cwd || "").trim();
|
|
116
|
+
if (!nextCwd) return { updated: false, reason: "missing_cwd" };
|
|
117
|
+
const found = await findClaudeSessionProjectionBySessionId(
|
|
118
|
+
sessionsDir,
|
|
119
|
+
sessionId,
|
|
120
|
+
);
|
|
121
|
+
if (!found) return { updated: false, reason: "projection_not_found" };
|
|
122
|
+
const projection = await updateClaudeSessionProjection(found.path, {
|
|
123
|
+
cwd: nextCwd,
|
|
124
|
+
updatedAt,
|
|
125
|
+
});
|
|
126
|
+
return { updated: true, path: found.path, projection };
|
|
127
|
+
}
|
|
128
|
+
|
|
72
129
|
export async function removeClaudeSessionProjection(sessionPath) {
|
|
73
130
|
await fs.rm(sessionPath, { force: true });
|
|
74
131
|
}
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
writeFileSync,
|
|
8
8
|
} from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
|
|
10
|
+
import { resolveHubPortForContext } from "../../../hub-lifecycle.mjs";
|
|
11
11
|
import { publishLeadControl as publishLeadControlBridge } from "../../lead-control.mjs";
|
|
12
12
|
import {
|
|
13
13
|
getTeamStatus as fetchTeamStatus,
|
|
@@ -117,15 +117,24 @@ export async function startHubDaemon() {
|
|
|
117
117
|
throw error;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// Resolve the canonical port. server.mjs forces 27888 in worktree/ephemeral
|
|
121
|
+
// contexts via resolveHubPortForContext; mirror that here so a polluted
|
|
122
|
+
// TFX_HUB_PORT does not make us spawn with / probe a port the daemon never
|
|
123
|
+
// binds (defense-in-depth — do not rely solely on server.mjs's boundary guard).
|
|
124
|
+
const resolvedPort = resolveHubPortForContext({
|
|
125
|
+
env: process.env,
|
|
126
|
+
cwd: process.cwd(),
|
|
127
|
+
});
|
|
128
|
+
|
|
120
129
|
const child = spawn(process.execPath, [serverPath], {
|
|
121
|
-
env: { ...process.env },
|
|
130
|
+
env: { ...process.env, TFX_HUB_PORT: String(resolvedPort) },
|
|
122
131
|
stdio: "ignore",
|
|
123
132
|
detached: true,
|
|
124
133
|
windowsHide: true,
|
|
125
134
|
});
|
|
126
135
|
child.unref();
|
|
127
136
|
|
|
128
|
-
const expectedPort =
|
|
137
|
+
const expectedPort = resolvedPort;
|
|
129
138
|
const deadline = Date.now() + 3000;
|
|
130
139
|
while (Date.now() < deadline) {
|
|
131
140
|
const status = await probeHubStatus("127.0.0.1", expectedPort, 500);
|
|
@@ -6,6 +6,7 @@ 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
8
|
const DEFAULT_EXPIRE_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const DEFAULT_CLEAN_EXPIRE_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
9
10
|
// Interactive (Claude/Codex) sessions idle for long stretches; a 30s local
|
|
10
11
|
// timeout produces stale false-positives. 5-minute TTL + an `idle` state
|
|
11
12
|
// distinguishes "alive but inactive" from "presumed dead".
|
|
@@ -23,6 +24,27 @@ function normalizeSessionKind(raw) {
|
|
|
23
24
|
return VALID_SESSION_KINDS.has(raw) ? raw : "headless";
|
|
24
25
|
}
|
|
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
|
+
|
|
26
48
|
// A session is "live" while active OR idle. Idle is an interactive session that
|
|
27
49
|
// missed its heartbeat interval but is still under the TTL — alive but inactive,
|
|
28
50
|
// not presumed dead. getActive() and querySessions() share this single predicate
|
|
@@ -135,6 +157,7 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
135
157
|
interactiveHeartbeatIntervalMs = DEFAULT_INTERACTIVE_HEARTBEAT_INTERVAL_MS,
|
|
136
158
|
interactiveTimeoutMs = DEFAULT_INTERACTIVE_TIMEOUT_MS,
|
|
137
159
|
expireTimeoutMs = DEFAULT_EXPIRE_TIMEOUT_MS,
|
|
160
|
+
cleanExpireTimeoutMs = defaultCleanExpireTimeoutMs(),
|
|
138
161
|
} = opts;
|
|
139
162
|
|
|
140
163
|
const sessions = new Map();
|
|
@@ -357,19 +380,23 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
357
380
|
return true;
|
|
358
381
|
}
|
|
359
382
|
|
|
360
|
-
// stale/expired 세션이
|
|
383
|
+
// stale/expired 세션이 cutoff 넘게 누적되면 Map에서 제거.
|
|
361
384
|
// live(active/idle)는 lastHeartbeat가 오래돼도 보존 — git-preflight dirty-file 가드가 의존.
|
|
385
|
+
// Dirty stale rows keep the historical 24h window because same-id resume
|
|
386
|
+
// revives their dirty-file guard; clean stale rows expire quickly to avoid
|
|
387
|
+
// daily dummy rows after overnight operator gaps.
|
|
362
388
|
function pruneExpired(opts2 = {}) {
|
|
363
389
|
if (destroyed) return { removed: [], count: 0 };
|
|
364
390
|
|
|
365
|
-
const
|
|
366
|
-
typeof opts2.olderThanMs === "number"
|
|
367
|
-
? opts2.olderThanMs
|
|
368
|
-
: expireTimeoutMs;
|
|
391
|
+
const explicitCutoff =
|
|
392
|
+
typeof opts2.olderThanMs === "number" ? opts2.olderThanMs : null;
|
|
369
393
|
const removed = [];
|
|
370
394
|
const currentTime = now();
|
|
371
395
|
|
|
372
396
|
for (const [sessionId, session] of sessions) {
|
|
397
|
+
const cutoff =
|
|
398
|
+
explicitCutoff ??
|
|
399
|
+
(hasDirtyFiles(session) ? expireTimeoutMs : cleanExpireTimeoutMs);
|
|
373
400
|
if (
|
|
374
401
|
isLiveStatus(session.status) ||
|
|
375
402
|
currentTime - session.lastHeartbeat <= cutoff
|
package/hub/tray-lifecycle.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// hub/tray-lifecycle.mjs — Hub/Tray bidirectional auto-start helpers
|
|
2
2
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isWorktreeOrEphemeralHubContext,
|
|
6
|
+
resolveHubPortForContext,
|
|
7
|
+
} from "./hub-lifecycle.mjs";
|
|
5
8
|
|
|
6
9
|
const DEFAULT_HUB_PORT = "27888";
|
|
7
10
|
const DEFAULT_HEALTH_TIMEOUT_MS = 1_000;
|
|
@@ -117,6 +120,9 @@ export function spawnTrayForHub({
|
|
|
117
120
|
spawnFn = spawn,
|
|
118
121
|
} = {}) {
|
|
119
122
|
if (env?.TFX_HUB_AUTO_TRAY === "0") return { status: "disabled" };
|
|
123
|
+
if (isWorktreeOrEphemeralHubContext({ env })) {
|
|
124
|
+
return { status: "disabled", reason: "ephemeral-or-worktree-context" };
|
|
125
|
+
}
|
|
120
126
|
if (platform !== "darwin") return { status: "unsupported-platform" };
|
|
121
127
|
if (!trayPath) return { status: "missing-tray-path" };
|
|
122
128
|
|
package/hub/tray.mjs
CHANGED
|
@@ -19,15 +19,20 @@ function sleep(ms) {
|
|
|
19
19
|
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function isNodeCommand(command) {
|
|
23
|
+
return /\bnode(?:\s|$)|[/\\]node(?:\s|$)/u.test(command);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasMacTrayScriptArg(command) {
|
|
27
|
+
return /(?:^|[\s"'])[^"'<>|]*[/\\]hub[/\\]tray\.mjs(?:$|[\s"'])/u.test(
|
|
28
|
+
command,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
export function collectMacTrayProcesses(
|
|
23
33
|
psOutput = "",
|
|
24
|
-
{
|
|
25
|
-
scriptPath = fileURLToPath(import.meta.url),
|
|
26
|
-
currentPid = process.pid,
|
|
27
|
-
} = {},
|
|
34
|
+
{ currentPid = process.pid } = {},
|
|
28
35
|
) {
|
|
29
|
-
const target = String(scriptPath || "");
|
|
30
|
-
if (!target) return [];
|
|
31
36
|
const ownPid = Number(currentPid);
|
|
32
37
|
return String(psOutput)
|
|
33
38
|
.split(/\r?\n/u)
|
|
@@ -38,8 +43,8 @@ export function collectMacTrayProcesses(
|
|
|
38
43
|
const ppid = Number.parseInt(match[2], 10);
|
|
39
44
|
const command = match[3].trim();
|
|
40
45
|
if (!Number.isFinite(pid) || pid === ownPid) return [];
|
|
41
|
-
if (!command
|
|
42
|
-
if (
|
|
46
|
+
if (!isNodeCommand(command)) return [];
|
|
47
|
+
if (!hasMacTrayScriptArg(command)) return [];
|
|
43
48
|
return [{ pid, ppid, command }];
|
|
44
49
|
});
|
|
45
50
|
}
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { resolveHubPortForContext } from "../../hub/hub-lifecycle.mjs";
|
|
8
|
-
import { whichCommand, whichCommandAsync } from "
|
|
8
|
+
import { whichCommand, whichCommandAsync } from "@triflux/core/hub/platform.mjs";
|
|
9
9
|
|
|
10
10
|
const HUB_DEFAULT_PORT = 27888;
|
|
11
11
|
const DEFAULT_STATUS_URL = "http://127.0.0.1:27888/status";
|
|
@@ -38,7 +38,10 @@ function fetchHubStatus({
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function resolveDefaultStatusUrl(
|
|
41
|
+
export function resolveDefaultStatusUrl(
|
|
42
|
+
env = process.env,
|
|
43
|
+
cwd = process.cwd(),
|
|
44
|
+
) {
|
|
42
45
|
const port = resolveHubPortForContext({
|
|
43
46
|
env,
|
|
44
47
|
cwd,
|