triflux 10.33.0 → 10.33.1

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/bin/triflux.mjs CHANGED
@@ -23,6 +23,7 @@ import { homedir, tmpdir } from "os";
23
23
  import { basename, dirname, join, resolve } from "path";
24
24
  import { fileURLToPath } from "url";
25
25
  import { loadDelegatorSchemaBundle } from "../hub/delegator/tool-definitions.mjs";
26
+ import { inspectClaudeRuntimeFlags } from "../hub/diagnostics/claude-runtime-flags.mjs";
26
27
  import {
27
28
  checkNetworkAvailability,
28
29
  validateRuntimeCachePaths,
@@ -2524,6 +2525,15 @@ function addDoctorCheck(report, entry) {
2524
2525
  report.checks.push(entry);
2525
2526
  }
2526
2527
 
2528
+ function readJsonIfExists(filePath) {
2529
+ if (!existsSync(filePath)) return {};
2530
+ try {
2531
+ return JSON.parse(readFileSync(filePath, "utf8"));
2532
+ } catch {
2533
+ return {};
2534
+ }
2535
+ }
2536
+
2527
2537
  function toHookCoverageName(fileName, fallbackId = "") {
2528
2538
  if (typeof fileName === "string" && fileName.trim()) {
2529
2539
  return basename(fileName).replace(/\.mjs$/i, "");
@@ -3453,6 +3463,22 @@ async function cmdDoctor(options = {}) {
3453
3463
  issues++;
3454
3464
  }
3455
3465
 
3466
+ const claudeSettings = readJsonIfExists(join(CLAUDE_DIR, "settings.json"));
3467
+ const runtimeFlags = inspectClaudeRuntimeFlags({
3468
+ env: process.env,
3469
+ settings: claudeSettings,
3470
+ });
3471
+ addDoctorCheck(report, {
3472
+ name: "claude-runtime-flags",
3473
+ status: runtimeFlags.status,
3474
+ safe_mode: runtimeFlags.safeMode,
3475
+ disable_bundled_skills: runtimeFlags.disableBundledSkills,
3476
+ managed_mcp_policy: runtimeFlags.managedMcpPolicy,
3477
+ summary: runtimeFlags.summary,
3478
+ ...(runtimeFlags.fix ? { fix: runtimeFlags.fix } : {}),
3479
+ });
3480
+ if (runtimeFlags.status === "warning") warn(runtimeFlags.summary);
3481
+
3456
3482
  // 7. psmux (Windows only)
3457
3483
  if (process.platform === "win32") {
3458
3484
  section("psmux (터미널 멀티플렉서)");
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { refreshClaudeSessionProjectionCwd } from "../hub/team/claude-session-projection.mjs";
8
+
9
+ function readStdin() {
10
+ try {
11
+ return fs.readFile(0, "utf8");
12
+ } catch {
13
+ return Promise.resolve("");
14
+ }
15
+ }
16
+
17
+ export function resolveCwdChangedPayload(input = {}, env = process.env) {
18
+ const sessionId = String(
19
+ input.session_id ??
20
+ input.sessionId ??
21
+ input.session?.id ??
22
+ env.CLAUDE_CODE_SESSION_ID ??
23
+ env.CLAUDE_SESSION_ID ??
24
+ "",
25
+ ).trim();
26
+ const cwd = String(
27
+ input.cwd ??
28
+ input.new_cwd ??
29
+ input.newCwd ??
30
+ input.current_cwd ??
31
+ input.workspace?.cwd ??
32
+ input.source?.cwd ??
33
+ "",
34
+ ).trim();
35
+ const configDir = path.resolve(
36
+ env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"),
37
+ );
38
+ return {
39
+ eventName: String(input.hook_event_name || ""),
40
+ sessionId,
41
+ cwd,
42
+ sessionsDir: path.join(configDir, "sessions"),
43
+ };
44
+ }
45
+
46
+ export async function handleCwdChangedHook(stdinText, env = process.env) {
47
+ let input = {};
48
+ try {
49
+ input = stdinText.trim() ? JSON.parse(stdinText) : {};
50
+ } catch {
51
+ return { code: 0, stdout: "" };
52
+ }
53
+ const payload = resolveCwdChangedPayload(input, env);
54
+ if (payload.eventName && payload.eventName !== "CwdChanged") {
55
+ return { code: 0, stdout: "" };
56
+ }
57
+ if (!payload.sessionId || !payload.cwd) return { code: 0, stdout: "" };
58
+
59
+ const result = await refreshClaudeSessionProjectionCwd({
60
+ sessionsDir: payload.sessionsDir,
61
+ sessionId: payload.sessionId,
62
+ cwd: payload.cwd,
63
+ });
64
+ if (!result.updated) return { code: 0, stdout: "" };
65
+ return {
66
+ code: 0,
67
+ stdout: JSON.stringify({
68
+ hookSpecificOutput: {
69
+ hookEventName: "CwdChanged",
70
+ additionalContext: `Triflux projection cwd refreshed: ${payload.cwd}`,
71
+ },
72
+ }),
73
+ };
74
+ }
75
+
76
+ if (
77
+ process.argv[1] &&
78
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
79
+ ) {
80
+ const result = await handleCwdChangedHook(await readStdin());
81
+ if (result.stdout) process.stdout.write(result.stdout);
82
+ process.exit(result.code);
83
+ }
@@ -51,6 +51,33 @@ function normalizeMode(mode, payload) {
51
51
  return "";
52
52
  }
53
53
 
54
+ function swallowStdoutWrite(_chunk, encodingOrCallback, callback) {
55
+ const done =
56
+ typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
57
+ if (typeof done === "function") done();
58
+ return true;
59
+ }
60
+
61
+ async function runHookSideEffectsWithStdoutSuppressed(fn) {
62
+ const originalStdoutWrite = stdout.write;
63
+ const originalConsoleDebug = console.debug;
64
+ const originalConsoleInfo = console.info;
65
+ const originalConsoleLog = console.log;
66
+
67
+ stdout.write = swallowStdoutWrite;
68
+ console.debug = () => {};
69
+ console.info = () => {};
70
+ console.log = () => {};
71
+ try {
72
+ return await fn();
73
+ } finally {
74
+ stdout.write = originalStdoutWrite;
75
+ console.debug = originalConsoleDebug;
76
+ console.info = originalConsoleInfo;
77
+ console.log = originalConsoleLog;
78
+ }
79
+ }
80
+
54
81
  export async function runCodexSessionHook(stdinData, opts = {}) {
55
82
  const output = "{}\n";
56
83
  const parsed = parsePayload(stdinData);
@@ -66,24 +93,26 @@ export async function runCodexSessionHook(stdinData, opts = {}) {
66
93
  opts.drainPendingSynapse || defaultDrainPendingSynapse;
67
94
 
68
95
  try {
69
- if (mode === "register") {
70
- try {
71
- await hubEnsureRun(stdinData);
72
- } catch {}
73
- try {
74
- registerInteractiveSession(stdinData);
75
- } catch {}
76
- try {
77
- await drainPendingSynapse(1000);
78
- } catch {}
79
- } else if (mode === "heartbeat") {
80
- try {
81
- heartbeatInteractiveSession(stdinData);
82
- } catch {}
83
- try {
84
- await drainPendingSynapse(500);
85
- } catch {}
86
- }
96
+ await runHookSideEffectsWithStdoutSuppressed(async () => {
97
+ if (mode === "register") {
98
+ try {
99
+ await hubEnsureRun(stdinData);
100
+ } catch {}
101
+ try {
102
+ registerInteractiveSession(stdinData);
103
+ } catch {}
104
+ try {
105
+ await drainPendingSynapse(1000);
106
+ } catch {}
107
+ } else if (mode === "heartbeat") {
108
+ try {
109
+ heartbeatInteractiveSession(stdinData);
110
+ } catch {}
111
+ try {
112
+ await drainPendingSynapse(500);
113
+ } catch {}
114
+ }
115
+ });
87
116
  } catch {
88
117
  // Codex session hooks are observational and must never block the session.
89
118
  }
@@ -465,6 +465,28 @@ async function main() {
465
465
  }
466
466
  }
467
467
 
468
+ // ── Stop: interactive 세션 liveness heartbeat (턴 종료 시) ──
469
+ // UserPromptSubmit 은 턴 *시작* 에, Stop 은 매 어시스턴트 턴 *종료* 에 heartbeat
470
+ // 한다. 둘이 함께 "대화 중이지만 매 분 새 프롬프트를 보내지는 않는" 세션을 5분
471
+ // interactive TTL 위로 유지해, 대화 도중 stale 로 떨어져 `cto status`
472
+ // live_sessions / tray 에서 사라지는 것을 막는다. 진짜 완전 idle(턴 자체가 없음)
473
+ // 은 여전히 TTL 후 stale 로 가며 — 그게 의도된 dead-session 신호다. UserPromptSubmit
474
+ // 과 동일하게 fire-and-forget POST 직후 짧은 상한으로 drain 한다.
475
+ if (eventName === "Stop") {
476
+ try {
477
+ const { heartbeatInteractiveSession } = await import(
478
+ "./session-start-fast.mjs"
479
+ );
480
+ heartbeatInteractiveSession(stdinRaw);
481
+ const { drainPendingSynapse } = await import(
482
+ "../hub/team/synapse-http.mjs"
483
+ );
484
+ await drainPendingSynapse(500);
485
+ } catch {
486
+ /* best-effort — heartbeat 실패가 Stop 을 막지 않는다 */
487
+ }
488
+ }
489
+
468
490
  // 이벤트에 해당하는 훅 목록
469
491
  const hooks = registry.events[eventName];
470
492
  if (!hooks || hooks.length === 0) process.exit(0);
@@ -8,6 +8,19 @@
8
8
  "external_priority": 100
9
9
  },
10
10
  "events": {
11
+ "CwdChanged": [
12
+ {
13
+ "id": "tfx-claude-cwd-projection-refresh",
14
+ "source": "triflux",
15
+ "matcher": "*",
16
+ "command": "node \"${PLUGIN_ROOT}/hooks/claude-cwd-projection-refresh.mjs\"",
17
+ "priority": 0,
18
+ "enabled": true,
19
+ "timeout": 2,
20
+ "blocking": false,
21
+ "description": "Claude /cd CwdChanged 이벤트를 Triflux native-bridge projection cwd에 반영"
22
+ }
23
+ ],
11
24
  "PermissionRequest": [
12
25
  {
13
26
  "id": "tfx-permission-safe-allow",
package/hooks/hooks.json CHANGED
@@ -13,6 +13,18 @@
13
13
  ]
14
14
  }
15
15
  ],
16
+ "CwdChanged": [
17
+ {
18
+ "matcher": "*",
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
23
+ "timeout": 5
24
+ }
25
+ ]
26
+ }
27
+ ],
16
28
  "UserPromptSubmit": [
17
29
  {
18
30
  "matcher": "*",
package/hub/bridge.mjs CHANGED
@@ -1207,6 +1207,7 @@ async function cmdDaemonProbe(args) {
1207
1207
  const payload = readBridgePayload(args);
1208
1208
  const {
1209
1209
  deriveClaudeDaemonPaths,
1210
+ extractClaudeAgentSessions,
1210
1211
  findDaemonJobBySessionId,
1211
1212
  findDaemonJobByShort,
1212
1213
  sendClaudeControlRequest,
@@ -1224,11 +1225,12 @@ async function cmdDaemonProbe(args) {
1224
1225
  : payload.short
1225
1226
  ? findDaemonJobByShort(list, payload.short)
1226
1227
  : null;
1228
+ const sessions = extractClaudeAgentSessions(list);
1227
1229
 
1228
1230
  return emitJson({
1229
1231
  ok: list?.ok !== false,
1230
1232
  controlSock: daemonPaths.controlSock,
1231
- sessions: list?.jobs || list?.sessions || [],
1233
+ sessions,
1232
1234
  target: target || undefined,
1233
1235
  error: list?.ok === false ? list?.error : undefined,
1234
1236
  });
@@ -0,0 +1,64 @@
1
+ export function readBooleanRuntimeFlag(value) {
2
+ const text = String(value ?? "")
3
+ .trim()
4
+ .toLowerCase();
5
+ return text === "1" || text === "true" || text === "yes" || text === "on";
6
+ }
7
+
8
+ function countStringArray(value) {
9
+ return Array.isArray(value)
10
+ ? value.filter((entry) => String(entry ?? "").trim()).length
11
+ : 0;
12
+ }
13
+
14
+ export function inspectClaudeRuntimeFlags({
15
+ env = process.env,
16
+ settings = {},
17
+ } = {}) {
18
+ const safeMode = readBooleanRuntimeFlag(env.CLAUDE_CODE_SAFE_MODE);
19
+ const disableBundledSkills =
20
+ readBooleanRuntimeFlag(env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS) ||
21
+ settings?.disableBundledSkills === true;
22
+ const allowedCount = countStringArray(settings?.allowedMcpServers);
23
+ const deniedCount = countStringArray(settings?.deniedMcpServers);
24
+ const managedMcpPolicy = {
25
+ active: allowedCount > 0 || deniedCount > 0,
26
+ allowedCount,
27
+ deniedCount,
28
+ };
29
+
30
+ const messages = [];
31
+ if (safeMode) {
32
+ messages.push(
33
+ "Claude Code safe mode is enabled; CLAUDE.md, plugins, skills, hooks, and MCP servers may be disabled.",
34
+ );
35
+ }
36
+ if (disableBundledSkills) {
37
+ messages.push(
38
+ "Claude bundled skills/workflows are disabled; slash-command and skill discovery may differ from normal sessions.",
39
+ );
40
+ }
41
+ if (managedMcpPolicy.active) {
42
+ messages.push(
43
+ `Claude managed MCP policy detected; allowed=${allowedCount}, denied=${deniedCount}.`,
44
+ );
45
+ }
46
+
47
+ return {
48
+ status: messages.length > 0 ? "warning" : "ok",
49
+ safeMode,
50
+ disableBundledSkills,
51
+ managedMcpPolicy,
52
+ summary:
53
+ messages.length > 0
54
+ ? messages.join(" ")
55
+ : "Claude Code runtime flags look normal.",
56
+ fix: safeMode
57
+ ? "Unset CLAUDE_CODE_SAFE_MODE or restart Claude Code without --safe-mode for normal Triflux hooks/skills/MCP behavior."
58
+ : disableBundledSkills
59
+ ? "Unset CLAUDE_CODE_DISABLE_BUNDLED_SKILLS or set disableBundledSkills=false if bundled skills are expected."
60
+ : managedMcpPolicy.active
61
+ ? "Review allowedMcpServers/deniedMcpServers in Claude managed settings if MCP tools are missing."
62
+ : undefined,
63
+ };
64
+ }
@@ -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
@@ -33,15 +33,15 @@ import { createAdaptiveEngine } from "./adaptive.mjs";
33
33
  import { createAssignCallbackServer } from "./assign-callbacks.mjs";
34
34
  import { DelegatorService } from "./delegator/index.mjs";
35
35
  import { createHitlManager } from "./hitl.mjs";
36
+ import {
37
+ reapExistingHubProcesses,
38
+ resolveHubPortForContext,
39
+ } from "./hub-lifecycle.mjs";
36
40
  import {
37
41
  cleanupOrphanNodeProcesses,
38
42
  cleanupOrphanRuntimeProcesses,
39
43
  cleanupStaleFsmonitorDaemons,
40
44
  } from "./lib/process-utils.mjs";
41
- import {
42
- reapExistingHubProcesses,
43
- resolveHubPortForContext,
44
- } from "./hub-lifecycle.mjs";
45
45
  import * as spawnTrace from "./lib/spawn-trace.mjs";
46
46
  import {
47
47
  recordRequest,
@@ -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
  }
@@ -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,19 @@ 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
+ processes: failed,
2465
+ caller: "startup",
2466
+ },
2467
+ "hub.startup_reaper_failed",
2468
+ );
2469
+ }
2454
2470
  },
2455
2471
  );
2456
2472
  }
@@ -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,
@@ -1055,27 +1059,27 @@ export function buildClaudePromptDispatchPayload({
1055
1059
  }
1056
1060
 
1057
1061
  export function findDaemonJobByShort(listResponse, short) {
1058
- if (!Array.isArray(listResponse?.jobs)) return null;
1059
- return listResponse.jobs.find((job) => job?.short === short) || null;
1062
+ const expected = String(short || "").trim();
1063
+ if (!expected) return null;
1064
+ return (
1065
+ extractClaudeAgentSessions(listResponse).find(
1066
+ (job) => job.short === expected || job.id === expected,
1067
+ ) || null
1068
+ );
1060
1069
  }
1061
1070
 
1062
1071
  export function findDaemonJobBySessionId(listResponse, sessionId) {
1063
- if (!Array.isArray(listResponse?.jobs)) return null;
1064
- const expected = String(sessionId || "");
1072
+ const expected = String(sessionId || "").trim();
1065
1073
  if (!expected) return null;
1066
1074
  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
1075
+ extractClaudeAgentSessions(listResponse).find(
1076
+ (job) => String(job.sessionId || job.session_id || "") === expected,
1077
+ ) || null
1076
1078
  );
1077
1079
  }
1078
1080
 
1081
+ export { extractClaudeAgentSessions, normalizeClaudeAgentSession };
1082
+
1079
1083
  export async function waitForDaemonJobPid(
1080
1084
  controlSock,
1081
1085
  short,
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.33.0",
3
+ "version": "10.33.1",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Antigravity, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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,
package/scripts/pack.mjs CHANGED
@@ -61,6 +61,7 @@ const CORE_DIRS = [
61
61
  "hub/lib",
62
62
  "hub/middleware",
63
63
  "hub/team/retry-state-machine.mjs",
64
+ "hub/team/claude-agent-session-normalizer.mjs",
64
65
  "hub/team/claude-daemon-control.mjs",
65
66
  "hub/team/claude-session-projection.mjs",
66
67
  // dep-free helper imported by core's session-start/end hooks (peer-discovery)
@@ -42,6 +42,10 @@ const CORE_FILE_MIRRORS = [
42
42
  source: "hub/team/retry-state-machine.mjs",
43
43
  target: "packages/core/hub/team/retry-state-machine.mjs",
44
44
  },
45
+ {
46
+ source: "hub/team/claude-agent-session-normalizer.mjs",
47
+ target: "packages/core/hub/team/claude-agent-session-normalizer.mjs",
48
+ },
45
49
  {
46
50
  source: "hub/team/claude-daemon-control.mjs",
47
51
  target: "packages/core/hub/team/claude-daemon-control.mjs",
@@ -80,6 +84,77 @@ function walkRelFiles(root, skipRels = new Set()) {
80
84
  return out;
81
85
  }
82
86
 
87
+ // packages/remote is NOT a byte-identical mirror: pack:remote rewrites relative
88
+ // imports of core-owned modules to @triflux/core/* specifiers. So validate it
89
+ // structurally instead — every .mjs import must resolve (relative -> exists
90
+ // under packages/remote; @triflux/core/X -> exists under packages/core), and
91
+ // every non-.mjs file mirrored from root must stay byte-identical. This catches
92
+ // the two drift classes that shipped silently in v10.33.0: a broken remote
93
+ // import (relative specifier to a core-only module) and a stale non-JS asset
94
+ // (the tray UI), neither of which the triflux/core byte-mirror check can see.
95
+ const REMOTE_NON_MIRROR = new Set([
96
+ "package.json",
97
+ "package-lock.json",
98
+ "README.md",
99
+ "README.ko.md",
100
+ "LICENSE",
101
+ "hub/index.mjs", // pack-generated barrel (REMOTE_INDEX), not a root mirror
102
+ ]);
103
+
104
+ function extractImportSpecifiers(content) {
105
+ // Strip comments first so JSDoc usage examples (`* import x from './y'`) and
106
+ // commented-out imports are not mistaken for real specifiers.
107
+ const code = content
108
+ .replace(/\/\*[\s\S]*?\*\//g, "")
109
+ .replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
110
+ const specs = [];
111
+ const re = /(?:from\s+|import\(\s*)["']([^"']+)["']/g;
112
+ let match;
113
+ while ((match = re.exec(code)) !== null) specs.push(match[1]);
114
+ return specs;
115
+ }
116
+
117
+ function checkRemoteMirror(repoRoot) {
118
+ const issues = [];
119
+ const remoteRoot = join(repoRoot, "packages", "remote");
120
+ const coreRoot = join(repoRoot, "packages", "core");
121
+ if (!existsSync(remoteRoot)) return issues;
122
+
123
+ for (const rel of walkRelFiles(remoteRoot)) {
124
+ if (REMOTE_NON_MIRROR.has(rel)) continue;
125
+ const remotePath = join(remoteRoot, rel);
126
+ const displayPath = `packages/remote/${rel}`;
127
+
128
+ if (rel.endsWith(".mjs")) {
129
+ const content = readFileSync(remotePath, "utf8");
130
+ for (const spec of extractImportSpecifiers(content)) {
131
+ let resolvedPath = null;
132
+ if (spec.startsWith("@triflux/core/")) {
133
+ resolvedPath = join(coreRoot, spec.slice("@triflux/core/".length));
134
+ } else if (spec.startsWith(".")) {
135
+ resolvedPath = join(dirname(remotePath), spec);
136
+ } else {
137
+ continue; // bare specifier (node: builtin or npm dependency)
138
+ }
139
+ if (!existsSync(resolvedPath)) {
140
+ issues.push({
141
+ path: displayPath,
142
+ kind: `remote-unresolvable-import (${spec})`,
143
+ });
144
+ }
145
+ }
146
+ } else {
147
+ // Non-.mjs mirrored from root: no import rewrite -> must be byte-identical.
148
+ const rootPath = join(repoRoot, rel);
149
+ if (!existsSync(rootPath)) continue; // remote-only asset, nothing to mirror
150
+ if (!readFileSync(rootPath).equals(readFileSync(remotePath))) {
151
+ issues.push({ path: displayPath, kind: "remote-content-diff" });
152
+ }
153
+ }
154
+ }
155
+ return issues;
156
+ }
157
+
83
158
  function compareMirror({
84
159
  fix = false,
85
160
  repoRoot = DEFAULT_REPO_ROOT,
@@ -211,6 +286,9 @@ function compareMirror({
211
286
  }
212
287
  }
213
288
 
289
+ // packages/remote structural validation (not a byte-mirror — see above).
290
+ for (const issue of checkRemoteMirror(repoRoot)) issues.push(issue);
291
+
214
292
  return { ok: issues.length === 0, issues, fixed };
215
293
  }
216
294
 
@@ -231,7 +309,7 @@ function main() {
231
309
  }
232
310
  } else {
233
311
  console.log(
234
- "Mirror OK — packages/triflux and packages/core file mirrors match root",
312
+ "Mirror OK — packages/triflux + packages/core (byte) and packages/remote (imports + assets) match root",
235
313
  );
236
314
  }
237
315
  } else {
@@ -247,7 +325,7 @@ function main() {
247
325
  }
248
326
  console.log("");
249
327
  console.log(
250
- "Run with --fix to copy root → packages/triflux and packages/core file mirrors. Orphans must be removed manually.",
328
+ "Run with --fix to copy root → packages/triflux and packages/core file mirrors. Orphans must be removed manually. packages/remote drift (remote-*) is regenerated by `npm run pack:remote`, not --fix.",
251
329
  );
252
330
  }
253
331
 
@@ -241,7 +241,7 @@ project_doc_fallback_filenames = ["CODEX.md", "AGENTS.md"]
241
241
  **이유:**
242
242
  - Codex 전용 진입점 `CODEX.md` 우선 → 프로젝트가 의도한 Codex 컨텍스트 적용
243
243
  - `AGENTS.md` 폴백 → 공용 에이전트 지시서 지원
244
- - **`CLAUDE.md` 포함 금지** — Claude 전용 hint(XML 태그, 한국어 응답 지시 등)가 Codex 누설되어 컨텍스트 노이즈 증가 (실측: 약 84% 노이즈 감소)
244
+ - **`CLAUDE.md`는 fallback에 넣지 않기** — Claude 전용 hint Codex prompt에 섞일 있다. 실제 영향은 `codex debug prompt-input`으로 확인하며, 근거 없는 정량치(예: N% 감소)는 쓰지 않는다.
245
245
 
246
246
  **감지 규칙:**
247
247
 
@@ -249,7 +249,7 @@ project_doc_fallback_filenames = ["CODEX.md", "AGENTS.md"]
249
249
  |------|------|------|
250
250
  | 키 누락 | 기본값 `["AGENTS.md"]` 로 동작 → `CODEX.md` 무시됨 | AskUserQuestion 으로 추가 제안 |
251
251
  | 값 `["CODEX.md", "AGENTS.md"]` | ✅ 표준 | 변경 없음 |
252
- | `CLAUDE.md` 포함 | 컨텍스트 노이즈 위험 | AskUserQuestion 으로 제거 제안 |
252
+ | `CLAUDE.md` 포함 | Codex prompt 혼입 위험 | AskUserQuestion 으로 제거 제안 |
253
253
  | 순서 불일치 (예: AGENTS 먼저) | 우선순위 의도 미반영 | AskUserQuestion 으로 교정 제안 |
254
254
 
255
255
  **비표준 감지 시 AskUserQuestion:**