@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.
@@ -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 parsePositiveInt(value) {
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 parsePortFromUrl(value) {
30
+ function parsePortFromHubUrl(value) {
25
31
  try {
26
- return parsePositiveInt(new URL(String(value)).port);
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 = parsePositiveInt(port) ?? parsePositiveInt(env?.TFX_HUB_PORT);
61
- const urlPort = parsePortFromUrl(env?.TFX_HUB_URL);
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 = parsePositiveInt(pidFileInfo?.pid);
190
+ const pidFilePid = parsePositivePort(pidFileInfo?.pid);
185
191
  const defaultPortPids = [
186
192
  ...new Set(
187
193
  (typeof findListeningPidsForPortFn === "function"
@@ -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: 320, height: 520)
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
@@ -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.68);
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, .cto-chat > summary, .bubble-wrap > summary { list-style: none; }
64
- .project-group > summary::-webkit-details-marker, .cto-chat > summary::-webkit-details-marker, .bubble-wrap > summary::-webkit-details-marker { display: none; }
65
- .project-summary { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; gap: 7px; align-items: center; padding: 8px 9px; cursor: pointer; }
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: var(--text-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
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
- .cto-chat { border: 1px solid rgba(10,132,255,0.13); background: rgba(255,255,255,0.035); border-radius: 8px; overflow: hidden; }
73
- .cto-summary { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 7px; align-items: center; padding: 7px 8px; cursor: pointer; }
74
- .cto-summary-title { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 11px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
75
- .cto-summary-sub { color: var(--text-muted); font-size: 10px; margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
76
- .chat-panel { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; padding: 8px; border-top: 1px solid rgba(255,255,255,0.05); }
77
- .chat-column { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
78
- .chat-column-title { color: var(--text-muted); font-size: 9px; font-weight: 800; letter-spacing: 0.5px; text-transform: uppercase; }
79
- .chat-empty { color: var(--text-muted); border: 1px dashed rgba(255,255,255,0.10); border-radius: 8px; padding: 8px; font-size: 10px; }
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: 7px; align-items: center; padding: 7px; border-radius: 8px; background: rgba(255,255,255,0.045); border: 1px solid rgba(255,255,255,0.07); }
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-column .bubble-preview { border-top-left-radius: 4px; }
93
- .cto-column .bubble-preview { background: rgba(10,132,255,0.14); border-color: rgba(10,132,255,0.18); border-top-right-radius: 4px; }
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
- const max = Math.max(1, ...clients.map(client => client.count || 0));
206
- return clients.map(client => `
207
- <div class="model-row">
208
- <div class="model-name"><div class="model-icon" style="color:${escapeHtml(client.color)}">${escapeHtml(client.icon)}</div><span class="model-label">${escapeHtml(client.label)}</span></div>
209
- <div class="progress-bar-bg"><div class="progress-bar-fill" style="width:${pct(client.count || 0, max)}%; background:${escapeHtml(client.color)}"></div></div>
210
- <div class="value-pill status-text ${escapeHtml(client.status)}">${escapeHtml(client.count || 0)}</div>
211
- </div>
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 = String(value || '').replace(/\/+$/u, '').trim();
269
+ const path = normalizeProjectPath(value);
251
270
  if (!path) return 'local';
252
- return path.split('/').filter(Boolean).pop() || path;
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
- : `<span class="agent-marker">└</span>${renderAgentTag(row.agent)}`;
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 || 'CTO', 'CTO');
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="cto-chat" data-open-key="cto:${dataAttr(ctoRow.sessionId || title)}">
399
- <summary class="cto-summary">
400
- <div class="agent-marker">•</div>
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="cto-summary-title">${escapeHtml(title)}</div>
403
- ${subtitle ? `<div class="cto-summary-sub">${escapeHtml(subtitle)}</div>` : ''}
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="chat-panel">
408
- <div class="chat-column worker-column">
409
- <div class="chat-column-title">Workers</div>
410
- ${workers.length ? `<div class="runtime-list">${workers.map(worker => hasPromptText(worker) ? renderChatBubble(worker, { side: 'worker' }) : renderRuntimeCard(worker)).join('')}</div>` : '<div class="chat-empty">No worker sessions detected</div>'}
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 totalChildren = group.ctos.length + group.workers.length;
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(String(totalChildren))}</span>
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="stats-grid">
459
- <div class="stat-box"><div class="stat-label">Live Sessions</div><div class="stat-value">${live.length}<span class="stat-unit">nodes</span></div></div>
460
- <div class="stat-box"><div class="stat-label">Active Shards</div><div class="stat-value">${cto.active_shards?.length || 0}<span class="stat-unit">shards</span></div></div>
461
- <div class="stat-box"><div class="stat-label">Runtime CLIs</div><div class="stat-value">${runtime?.summary?.total || 0}<span class="stat-unit">proc</span></div></div>
462
- <div class="stat-box"><div class="stat-label">Hub ID</div><div class="badge-row" style="margin-top:0;">${renderCopyChip('Hub', hub.id || String(hub.pid || ''))}</div></div>
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
- ${staleCount ? `<div class="path-text">${staleCount} stale sessions hidden from active cards</div>` : ''}
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">Runtime Clients</div>
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">Projects / CTO / Workers</div>
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 "./account-broker.mjs";
32
- import { createAdaptiveEngine } from "./adaptive.mjs";
33
- import { createAssignCallbackServer } from "./assign-callbacks.mjs";
34
- import { DelegatorService } from "./delegator/index.mjs";
35
- import { createHitlManager } from "./hitl.mjs";
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 * as spawnTrace from "./lib/spawn-trace.mjs";
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 "./lib/trace-recorder.mjs";
50
+ } from "@triflux/core/hub/lib/trace-recorder.mjs";
51
51
  import { focusSessionOnMac } from "./mac-focus.mjs";
52
- import { logQuotaRefreshFailures } from "./middleware/quota-middleware.mjs";
53
- import { wrapRequestHandler } from "./middleware/request-logger.mjs";
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 "./router.mjs";
56
- import { createAdaptiveFingerprintService } from "./session-fingerprint.mjs";
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 "./state.mjs";
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 "./team-bridge.mjs";
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(String(env?.TFX_HUB_IDLE_TIMEOUT_MS ?? ""), 10);
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("./reflexion.mjs");
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
- if (!Array.isArray(listResponse?.jobs)) return null;
1059
- return listResponse.jobs.find((job) => job?.short === short) || null;
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
- if (!Array.isArray(listResponse?.jobs)) return null;
1064
- const expected = String(sessionId || "");
1095
+ const expected = String(sessionId || "").trim();
1065
1096
  if (!expected) return null;
1066
1097
  return (
1067
- listResponse.jobs.find((job) => {
1068
- const candidate =
1069
- job?.sessionId ??
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
- throw new Error(`Claude daemon dispatch failed for ${name || short}`);
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 = getDefaultHubPort();
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 세션이 expireTimeoutMs 넘게 누적되면 Map에서 제거.
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 cutoff =
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
@@ -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 { resolveHubPortForContext } from "./hub-lifecycle.mjs";
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.includes(target)) return [];
42
- if (!/\bnode(?:\s|$)|\/node(?:\s|$)/u.test(command)) return [];
46
+ if (!isNodeCommand(command)) return [];
47
+ if (!hasMacTrayScriptArg(command)) return [];
43
48
  return [{ pid, ppid, command }];
44
49
  });
45
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.33.0",
3
+ "version": "10.34.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers, SQLite store.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -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 "../../hub/platform.mjs";
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(env = process.env, cwd = process.cwd()) {
41
+ export function resolveDefaultStatusUrl(
42
+ env = process.env,
43
+ cwd = process.cwd(),
44
+ ) {
42
45
  const port = resolveHubPortForContext({
43
46
  env,
44
47
  cwd,