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 +26 -0
- package/hooks/claude-cwd-projection-refresh.mjs +83 -0
- package/hooks/codex-session-hook.mjs +47 -18
- package/hooks/hook-orchestrator.mjs +22 -0
- package/hooks/hook-registry.json +13 -0
- package/hooks/hooks.json +12 -0
- package/hub/bridge.mjs +3 -1
- package/hub/diagnostics/claude-runtime-flags.mjs +64 -0
- package/hub/mac-tray.swift +1 -1
- package/hub/public/tray.html +90 -58
- package/hub/server.mjs +22 -6
- package/hub/team/claude-agent-session-normalizer.mjs +53 -0
- package/hub/team/claude-daemon-control.mjs +17 -13
- package/hub/team/claude-session-projection.mjs +57 -0
- package/hub/team/cli/services/hub-client.mjs +12 -3
- package/package.json +1 -1
- package/scripts/lib/env-probe.mjs +4 -1
- package/scripts/pack.mjs +1 -0
- package/scripts/release/check-packages-mirror.mjs +80 -2
- package/skills/tfx-setup/SKILL.md +2 -2
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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);
|
package/hooks/hook-registry.json
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/hub/mac-tray.swift
CHANGED
|
@@ -63,7 +63,7 @@ class WebViewController: NSViewController, WKNavigationDelegate, WKScriptMessage
|
|
|
63
63
|
override func loadView() {
|
|
64
64
|
let webConfiguration = WKWebViewConfiguration()
|
|
65
65
|
webConfiguration.userContentController.add(self, name: "tray")
|
|
66
|
-
let initialFrame = NSRect(x: 0, y: 0, width:
|
|
66
|
+
let initialFrame = NSRect(x: 0, y: 0, width: 460, height: 720)
|
|
67
67
|
webView = WKWebView(frame: initialFrame, configuration: webConfiguration)
|
|
68
68
|
|
|
69
69
|
// Transparent background for Glassmorphism
|
package/hub/public/tray.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<title>Triflux CTO Tray</title>
|
|
6
6
|
<style>
|
|
7
7
|
:root {
|
|
8
|
-
--bg-color: rgba(30, 30, 30, 0.
|
|
8
|
+
--bg-color: rgba(30, 30, 30, 0.76);
|
|
9
9
|
--border-color: rgba(255,255,255,0.15);
|
|
10
10
|
--text-main: #ffffff;
|
|
11
11
|
--text-muted: rgba(255,255,255,0.58);
|
|
@@ -44,6 +44,12 @@
|
|
|
44
44
|
.stat-label { font-size: 10px; color: var(--text-muted); margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
45
45
|
.stat-value { font-size: 18px; font-weight: 650; font-variant-numeric: tabular-nums; display: flex; align-items: baseline; gap: 4px; }
|
|
46
46
|
.stat-unit { font-size: 10px; font-weight: 400; color: var(--text-muted); }
|
|
47
|
+
.session-overview { display: grid; grid-template-columns: 1.25fr repeat(3, minmax(0, 0.78fr)); gap: 8px; }
|
|
48
|
+
.summary-card { min-width: 0; border-radius: 10px; padding: 9px 10px; background: rgba(0,0,0,0.18); border: 1px solid rgba(255,255,255,0.07); }
|
|
49
|
+
.summary-card.primary { background: rgba(10,132,255,0.14); border-color: rgba(10,132,255,0.24); }
|
|
50
|
+
.summary-kpi { font-size: 18px; line-height: 1; font-weight: 760; font-variant-numeric: tabular-nums; letter-spacing: -0.3px; }
|
|
51
|
+
.summary-label { margin-top: 4px; font-size: 10px; font-weight: 700; color: rgba(255,255,255,0.76); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
52
|
+
.summary-sub { margin-top: 2px; font-size: 9px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
47
53
|
.models-list, .server-list, .session-list { display: flex; flex-direction: column; gap: 8px; }
|
|
48
54
|
.model-row { display: grid; grid-template-columns: minmax(76px, auto) 1fr auto; gap: 10px; align-items: center; font-size: 12px; min-width: 0; }
|
|
49
55
|
.model-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
@@ -60,25 +66,25 @@
|
|
|
60
66
|
.focus-btn:hover { background: var(--accent); color: #fff; }
|
|
61
67
|
.agent-view { display: flex; flex-direction: column; gap: 8px; }
|
|
62
68
|
.project-group { min-width: 0; border: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.13); border-radius: 9px; overflow: hidden; }
|
|
63
|
-
.project-group > summary, .
|
|
64
|
-
.project-group > summary::-webkit-details-marker, .
|
|
65
|
-
.project-summary { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; gap:
|
|
69
|
+
.project-group > summary, .session-card > summary, .bubble-wrap > summary { list-style: none; }
|
|
70
|
+
.project-group > summary::-webkit-details-marker, .session-card > summary::-webkit-details-marker, .bubble-wrap > summary::-webkit-details-marker { display: none; }
|
|
71
|
+
.project-summary { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 9px 10px; cursor: pointer; }
|
|
66
72
|
.project-chevron { color: rgba(255,255,255,0.44); font-size: 11px; transform: rotate(-90deg); transition: transform 120ms ease; }
|
|
67
73
|
.project-group[open] .project-chevron { transform: rotate(0deg); }
|
|
68
74
|
.project-name { color: #b6b1e8; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; font-weight: 750; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
69
|
-
.project-count { color:
|
|
75
|
+
.project-count { display: inline-flex; align-items: center; justify-content: center; min-height: 20px; border-radius: 999px; padding: 2px 7px; color: rgba(255,255,255,0.72); background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.07); font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
70
76
|
.project-path { color: var(--text-muted); font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 10px; padding: 0 9px 8px 32px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
71
77
|
.project-body { padding: 0 8px 9px; display: flex; flex-direction: column; gap: 7px; }
|
|
72
|
-
.
|
|
73
|
-
.
|
|
74
|
-
.
|
|
75
|
-
.
|
|
76
|
-
.
|
|
77
|
-
.
|
|
78
|
-
.
|
|
79
|
-
.
|
|
78
|
+
.session-card { border: 1px solid rgba(10,132,255,0.13); background: rgba(255,255,255,0.035); border-radius: 9px; overflow: hidden; }
|
|
79
|
+
.session-summary { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 8px 9px; cursor: pointer; }
|
|
80
|
+
.session-summary-title { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 11px; font-weight: 740; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
81
|
+
.session-summary-sub { color: var(--text-muted); font-size: 10px; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
82
|
+
.session-detail { display: flex; flex-direction: column; gap: 9px; padding: 9px; border-top: 1px solid rgba(255,255,255,0.05); }
|
|
83
|
+
.detail-section { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
|
84
|
+
.detail-label { color: var(--text-muted); font-size: 9px; font-weight: 800; letter-spacing: 0.5px; text-transform: uppercase; }
|
|
85
|
+
.detail-empty { color: var(--text-muted); border: 1px dashed rgba(255,255,255,0.10); border-radius: 8px; padding: 8px; font-size: 10px; }
|
|
80
86
|
.runtime-list { display: flex; flex-direction: column; gap: 7px; }
|
|
81
|
-
.runtime-card { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap:
|
|
87
|
+
.runtime-card { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 8px; border-radius: 8px; background: rgba(255,255,255,0.045); border: 1px solid rgba(255,255,255,0.07); }
|
|
82
88
|
.runtime-main { min-width: 0; }
|
|
83
89
|
.runtime-title { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
84
90
|
.runtime-command { color: rgba(255,255,255,0.90); font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 11px; font-weight: 750; }
|
|
@@ -89,8 +95,8 @@
|
|
|
89
95
|
.bubble-card { min-width: 0; }
|
|
90
96
|
.bubble-wrap { min-width: 0; }
|
|
91
97
|
.bubble-preview { display: block; cursor: pointer; border-radius: 12px; padding: 8px 9px; font-size: 11px; line-height: 1.35; color: rgba(255,255,255,0.88); background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.08); max-height: 48px; overflow: hidden; }
|
|
92
|
-
.worker
|
|
93
|
-
.cto
|
|
98
|
+
.bubble-card.worker .bubble-preview { border-top-left-radius: 4px; }
|
|
99
|
+
.bubble-card.cto .bubble-preview { background: rgba(10,132,255,0.14); border-color: rgba(10,132,255,0.18); border-top-right-radius: 4px; }
|
|
94
100
|
.bubble-full { margin: 6px 0 0; white-space: pre-wrap; word-break: break-word; max-height: 190px; overflow: auto; padding: 8px; border-radius: 8px; background: rgba(0,0,0,0.28); color: rgba(255,255,255,0.82); font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 10px; line-height: 1.35; }
|
|
95
101
|
.agent-line { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 5px; align-items: start; min-width: 0; padding: 3px 0; }
|
|
96
102
|
.agent-line.worker { padding-left: 10px; }
|
|
@@ -112,6 +118,12 @@
|
|
|
112
118
|
.chip.on { color: var(--success); border-color: rgba(50,215,75,0.25); background: rgba(50,215,75,0.08); }
|
|
113
119
|
.chip.partial { color: var(--warning); border-color: rgba(255,214,10,0.25); background: rgba(255,214,10,0.08); }
|
|
114
120
|
.chip.off { color: var(--danger); border-color: rgba(255,69,58,0.25); background: rgba(255,69,58,0.08); }
|
|
121
|
+
.client-chip-row { display: flex; gap: 7px; flex-wrap: wrap; }
|
|
122
|
+
.client-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; border-radius: 999px; padding: 4px 8px; background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.08); color: rgba(255,255,255,0.82); font-size: 11px; }
|
|
123
|
+
.client-chip strong { font-variant-numeric: tabular-nums; color: var(--text-main); }
|
|
124
|
+
.client-chip.on { border-color: rgba(50,215,75,0.24); }
|
|
125
|
+
.client-chip.partial { border-color: rgba(255,214,10,0.24); }
|
|
126
|
+
.client-chip.off { border-color: rgba(255,69,58,0.24); }
|
|
115
127
|
.agent-badge { width: 20px; height: 20px; display: inline-grid; place-items: center; flex: 0 0 auto; border-radius: 6px; font-size: 11px; font-weight: 850; font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; border: 1px solid rgba(255,255,255,0.12); }
|
|
116
128
|
.agent-tag { justify-content: center; }
|
|
117
129
|
.agent-tag.claude { color: #d19a66; background: rgba(209,154,102,0.12); border-color: rgba(209,154,102,0.32); }
|
|
@@ -202,14 +214,14 @@
|
|
|
202
214
|
|
|
203
215
|
function renderRuntimeClients(runtime) {
|
|
204
216
|
const clients = Array.isArray(runtime?.summary?.clients) ? runtime.summary.clients : [];
|
|
205
|
-
|
|
206
|
-
return clients.map(client => `
|
|
207
|
-
<
|
|
208
|
-
<
|
|
209
|
-
<
|
|
210
|
-
<
|
|
211
|
-
</
|
|
212
|
-
`).join('')
|
|
217
|
+
if (clients.length === 0) return '<div class="mini-card muted">No runtime clients reported</div>';
|
|
218
|
+
return `<div class="client-chip-row">${clients.map(client => `
|
|
219
|
+
<span class="client-chip ${escapeHtml(client.status)}">
|
|
220
|
+
<span class="dot ${escapeHtml(client.id)}" style="background:${escapeHtml(client.color)};box-shadow:0 0 6px ${escapeHtml(client.color)}"></span>
|
|
221
|
+
<span>${escapeHtml(client.label)}</span>
|
|
222
|
+
<strong>${escapeHtml(client.count || 0)}</strong>
|
|
223
|
+
</span>
|
|
224
|
+
`).join('')}</div>`;
|
|
213
225
|
}
|
|
214
226
|
|
|
215
227
|
function liveFallbackRows(live) {
|
|
@@ -246,21 +258,28 @@
|
|
|
246
258
|
return path.replace(/^\/Users\/[^/]+/, '~');
|
|
247
259
|
}
|
|
248
260
|
|
|
261
|
+
function normalizeProjectPath(value) {
|
|
262
|
+
const path = String(value || '').trim();
|
|
263
|
+
if (!path) return '';
|
|
264
|
+
if (path === '/' || /^\/+$/u.test(path) || /^[A-Za-z]:[\\/]$/u.test(path)) return path;
|
|
265
|
+
return path.replace(/[\\/]+$/u, '') || path;
|
|
266
|
+
}
|
|
267
|
+
|
|
249
268
|
function projectName(value) {
|
|
250
|
-
const path =
|
|
269
|
+
const path = normalizeProjectPath(value);
|
|
251
270
|
if (!path) return 'local';
|
|
252
|
-
return path.split(
|
|
271
|
+
return path.split(/[\\/]/u).filter(Boolean).pop() || path;
|
|
253
272
|
}
|
|
254
273
|
|
|
255
274
|
function projectPathFor(row, hub, fallback = '') {
|
|
256
275
|
const cwd = String(row?.cwd || row?.worktreePath || '').trim();
|
|
257
|
-
if (cwd && cwd !== 'local' && cwd !== 'local runtime') return cwd;
|
|
276
|
+
if (cwd && cwd !== 'local' && cwd !== 'local runtime') return normalizeProjectPath(cwd);
|
|
258
277
|
const root = String(hub?.projectRoot || fallback || '').trim();
|
|
259
|
-
return root || cwd || 'local';
|
|
278
|
+
return normalizeProjectPath(root || cwd || 'local');
|
|
260
279
|
}
|
|
261
280
|
|
|
262
281
|
function getProjectGroup(groups, projectPath) {
|
|
263
|
-
const key = projectPath || 'local';
|
|
282
|
+
const key = normalizeProjectPath(projectPath) || 'local';
|
|
264
283
|
if (!groups.has(key)) groups.set(key, { projectPath: key, ctos: [], workers: [] });
|
|
265
284
|
return groups.get(key);
|
|
266
285
|
}
|
|
@@ -356,9 +375,9 @@
|
|
|
356
375
|
const focusDisabled = row.synthetic || !sid;
|
|
357
376
|
const badge = side === 'cto'
|
|
358
377
|
? '<span class="agent-badge">T</span>'
|
|
359
|
-
:
|
|
378
|
+
: renderAgentTag(row.agent) || '<span class="agent-badge">A</span>';
|
|
360
379
|
return `
|
|
361
|
-
<div class="bubble-card">
|
|
380
|
+
<div class="bubble-card ${escapeHtml(side)}">
|
|
362
381
|
<div class="chat-session-title">
|
|
363
382
|
<span style="display:flex;align-items:center;gap:6px;min-width:0;">${badge}<span class="chat-session-id">${escapeHtml(sid || side)}</span></span>
|
|
364
383
|
${focusDisabled ? '' : `<button class="focus-btn" data-focus-session="1" data-session-id="${dataAttr(sid)}" data-pid="${dataAttr(pid)}" data-agent-id="${dataAttr(agentId)}">Focus</button>`}
|
|
@@ -383,7 +402,6 @@
|
|
|
383
402
|
<div class="runtime-main">
|
|
384
403
|
<div class="runtime-title"><span class="runtime-command">${escapeHtml(commandLabel(row))}</span><span class="agent-state ${escapeHtml(status)}">${escapeHtml(status)}</span></div>
|
|
385
404
|
<div class="runtime-sub">${escapeHtml(displayPath(row.cwd || row.host || 'local runtime'))}${row.elapsed ? ` · ${escapeHtml(row.elapsed)}` : ''}</div>
|
|
386
|
-
<div class="runtime-meta">${pid ? `<span class="chip">PID ${escapeHtml(pid)}</span>` : ''}${renderCopyChip('ID', sid)}</div>
|
|
387
405
|
</div>
|
|
388
406
|
${focusDisabled ? '' : `<button class="focus-btn" data-focus-session="1" data-session-id="${dataAttr(sid)}" data-pid="${dataAttr(pid)}" data-agent-id="${dataAttr(agentId)}">Focus</button>`}
|
|
389
407
|
</div>
|
|
@@ -392,42 +410,53 @@
|
|
|
392
410
|
|
|
393
411
|
function renderCtoChat(ctoRow, workers) {
|
|
394
412
|
const status = ctoRow.status || ctoRow.phase || 'active';
|
|
395
|
-
const title = compactText(ctoRow.sessionId || ctoRow.taskSummary || '
|
|
396
|
-
const subtitle = compactText(hasPromptText(ctoRow) ? ctoRow.taskSummary || ctoRow.sessionKind || '' : '', '');
|
|
413
|
+
const title = compactText(ctoRow.sessionId || ctoRow.taskSummary || 'Session', 'Session');
|
|
414
|
+
const subtitle = compactText(hasPromptText(ctoRow) ? ctoRow.taskSummary || ctoRow.sessionKind || '' : displayPath(ctoRow.cwd || ctoRow.worktreePath || ''), '');
|
|
415
|
+
const promptHtml = hasPromptText(ctoRow)
|
|
416
|
+
? `<div class="detail-section"><div class="detail-label">Prompt</div>${renderChatBubble(ctoRow, { side: 'cto' })}</div>`
|
|
417
|
+
: '';
|
|
418
|
+
const agentRows = workers.map(worker => hasPromptText(worker) ? renderChatBubble(worker, { side: 'worker' }) : renderRuntimeCard(worker)).join('');
|
|
419
|
+
const agentsHtml = workers.length
|
|
420
|
+
? `<div class="detail-section"><div class="detail-label">Agents</div><div class="runtime-list">${agentRows}</div></div>`
|
|
421
|
+
: '';
|
|
422
|
+
const emptyHtml = promptHtml || agentsHtml ? '' : '<div class="detail-empty">No session details yet</div>';
|
|
397
423
|
return `
|
|
398
|
-
<details class="
|
|
399
|
-
<summary class="
|
|
400
|
-
<
|
|
424
|
+
<details class="session-card" data-open-key="cto:${dataAttr(ctoRow.sessionId || title)}">
|
|
425
|
+
<summary class="session-summary">
|
|
426
|
+
<span class="agent-badge">T</span>
|
|
401
427
|
<div style="min-width:0;">
|
|
402
|
-
<div class="
|
|
403
|
-
${subtitle ? `<div class="
|
|
428
|
+
<div class="session-summary-title">${escapeHtml(title)}</div>
|
|
429
|
+
${subtitle ? `<div class="session-summary-sub">${escapeHtml(subtitle)}</div>` : ''}
|
|
404
430
|
</div>
|
|
405
431
|
<div class="agent-state ${escapeHtml(status)}">${escapeHtml(status)}</div>
|
|
406
432
|
</summary>
|
|
407
|
-
<div class="
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
</div>
|
|
412
|
-
<div class="chat-column cto-column">
|
|
413
|
-
<div class="chat-column-title">CTO</div>
|
|
414
|
-
${hasPromptText(ctoRow) ? renderChatBubble(ctoRow, { side: 'cto' }) : '<div class="chat-empty">No prompt handoff events captured yet</div>'}
|
|
415
|
-
</div>
|
|
433
|
+
<div class="session-detail">
|
|
434
|
+
${promptHtml}
|
|
435
|
+
${agentsHtml}
|
|
436
|
+
${emptyHtml}
|
|
416
437
|
</div>
|
|
417
438
|
</details>
|
|
418
439
|
`;
|
|
419
440
|
}
|
|
420
441
|
|
|
442
|
+
function pluralize(count, one, many = `${one}s`) {
|
|
443
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
421
446
|
function renderProjectGroup(group) {
|
|
422
447
|
const projectDisplayPath = displayPath(group.projectPath);
|
|
423
448
|
const projectLabel = projectName(group.projectPath);
|
|
424
|
-
const
|
|
449
|
+
const sessionCount = group.ctos.filter(row => !isSyntheticCto(row)).length;
|
|
450
|
+
const agentCount = group.workers.length;
|
|
451
|
+
const projectCount = [sessionCount ? pluralize(sessionCount, 'session') : '', agentCount ? pluralize(agentCount, 'agent') : '']
|
|
452
|
+
.filter(Boolean)
|
|
453
|
+
.join(' · ') || 'empty';
|
|
425
454
|
let html = `
|
|
426
455
|
<details class="project-group" data-open-key="project:${dataAttr(group.projectPath)}">
|
|
427
456
|
<summary class="project-summary">
|
|
428
457
|
<span class="project-chevron">▾</span>
|
|
429
458
|
<span class="project-name">${escapeHtml(projectLabel)}</span>
|
|
430
|
-
<span class="project-count">${escapeHtml(
|
|
459
|
+
<span class="project-count">${escapeHtml(projectCount)}</span>
|
|
431
460
|
</summary>
|
|
432
461
|
<div class="project-path">${escapeHtml(projectDisplayPath)}</div>
|
|
433
462
|
<div class="project-body">
|
|
@@ -452,23 +481,26 @@
|
|
|
452
481
|
const activeSessions = sessions.filter(s => s.status !== 'stale');
|
|
453
482
|
const liveRows = liveFallbackRows(live);
|
|
454
483
|
const projectGroups = buildProjectGroups(activeSessions.slice(0, 10), liveRows, hub);
|
|
484
|
+
const activeCount = activeSessions.filter(s => s.status === 'active').length;
|
|
485
|
+
const idleCount = activeSessions.filter(s => s.status === 'idle').length;
|
|
455
486
|
const staleCount = sessions.filter(s => s.status === 'stale').length;
|
|
487
|
+
const runtimeTotal = runtime?.summary?.total || liveRows.length || 0;
|
|
456
488
|
let html = `
|
|
457
489
|
<div class="section">
|
|
458
|
-
<div class="
|
|
459
|
-
<div class="
|
|
460
|
-
<div class="
|
|
461
|
-
<div class="
|
|
462
|
-
<div class="
|
|
490
|
+
<div class="session-overview">
|
|
491
|
+
<div class="summary-card primary"><div class="summary-kpi">${live.length}</div><div class="summary-label">Live agents</div><div class="summary-sub">${runtimeTotal} runtime CLIs</div></div>
|
|
492
|
+
<div class="summary-card"><div class="summary-kpi">${activeCount}</div><div class="summary-label">Active</div></div>
|
|
493
|
+
<div class="summary-card"><div class="summary-kpi">${idleCount}</div><div class="summary-label">Idle</div></div>
|
|
494
|
+
<div class="summary-card"><div class="summary-kpi">${staleCount}</div><div class="summary-label">Stale</div></div>
|
|
463
495
|
</div>
|
|
464
|
-
|
|
496
|
+
<div class="path-text">${projectGroups.length} ${projectGroups.length === 1 ? 'workspace' : 'workspaces'} · ${cto.active_shards?.length || 0} shards · ${renderCopyChip('Hub', hub.id || String(hub.pid || ''))}</div>
|
|
465
497
|
</div>
|
|
466
498
|
<div class="section">
|
|
467
|
-
<div class="section-title">
|
|
499
|
+
<div class="section-title">Agent Mix</div>
|
|
468
500
|
<div class="models-list">${renderRuntimeClients(runtime)}</div>
|
|
469
501
|
</div>
|
|
470
502
|
<div class="section">
|
|
471
|
-
<div class="section-title">
|
|
503
|
+
<div class="section-title">Workspaces</div>
|
|
472
504
|
<div class="agent-view">
|
|
473
505
|
`;
|
|
474
506
|
for (const group of projectGroups) html += renderProjectGroup(group);
|
package/hub/server.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
1059
|
-
|
|
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
|
-
|
|
1064
|
-
const expected = String(sessionId || "");
|
|
1072
|
+
const expected = String(sessionId || "").trim();
|
|
1065
1073
|
if (!expected) return null;
|
|
1066
1074
|
return (
|
|
1067
|
-
listResponse.
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
job?.session_id ??
|
|
1071
|
-
job?.dispatch?.sessionId ??
|
|
1072
|
-
job?.d?.sessionId ??
|
|
1073
|
-
"";
|
|
1074
|
-
return String(candidate) === expected;
|
|
1075
|
-
}) || null
|
|
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 =
|
|
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
|
@@ -38,7 +38,10 @@ function fetchHubStatus({
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function resolveDefaultStatusUrl(
|
|
41
|
+
export function resolveDefaultStatusUrl(
|
|
42
|
+
env = process.env,
|
|
43
|
+
cwd = process.cwd(),
|
|
44
|
+
) {
|
|
42
45
|
const port = resolveHubPortForContext({
|
|
43
46
|
env,
|
|
44
47
|
cwd,
|
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
|
|
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
|
|
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` 포함 |
|
|
252
|
+
| `CLAUDE.md` 포함 | Codex prompt 혼입 위험 | AskUserQuestion 으로 제거 제안 |
|
|
253
253
|
| 순서 불일치 (예: AGENTS 먼저) | 우선순위 의도 미반영 | AskUserQuestion 으로 교정 제안 |
|
|
254
254
|
|
|
255
255
|
**비표준 감지 시 AskUserQuestion:**
|