triflux 10.33.1 → 10.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/mcp-registry.json +0 -9
- package/config/mcp-registry.json.bak-pre-serena-removal +89 -0
- package/hooks/session-start-fast.mjs +51 -1
- package/hub/hub-lifecycle.mjs +18 -12
- package/hub/server.mjs +1 -0
- package/hub/team/claude-daemon-control.mjs +34 -2
- package/hub/team/synapse-registry.mjs +32 -5
- package/hub/tray-lifecycle.mjs +7 -1
- package/hub/tray.mjs +13 -8
- package/package.json +1 -1
- package/scripts/__tests__/ensure-codex-hooks.test.mjs +183 -0
- package/scripts/ensure-codex-hooks.mjs +71 -27
- package/scripts/release/check-packages-mirror.mjs +1 -1
- package/scripts/test-lock.mjs +39 -9
- package/scripts/tfx-route.sh +10 -2
- package/skills/star-prompt/SKILL.md +0 -2
- package/skills/tfx-analysis/SKILL.md +1 -9
- package/skills/tfx-auto/SKILL.md +4 -36
- package/skills/tfx-doctor/SKILL.md +0 -2
- package/skills/tfx-find/SKILL.md +0 -15
- package/skills/tfx-forge/SKILL.md +0 -10
- package/skills/tfx-goal-clarify/SKILL.md +0 -9
- package/skills/tfx-hooks/SKILL.md +0 -2
- package/skills/tfx-hub/SKILL.md +0 -2
- package/skills/tfx-index/SKILL.md +0 -12
- package/skills/tfx-interview/SKILL.md +0 -11
- package/skills/tfx-live/SKILL.md +0 -4
- package/skills/tfx-plan/SKILL.md +1 -11
- package/skills/tfx-profile/SKILL.md +0 -2
- package/skills/tfx-prune/SKILL.md +0 -6
- package/skills/tfx-qa/SKILL.md +1 -10
- package/skills/tfx-remote/SKILL.md +0 -2
- package/skills/tfx-research/SKILL.md +1 -15
- package/skills/tfx-review/SKILL.md +1 -16
- package/skills/tfx-setup/SKILL.md +0 -2
- package/skills/tfx-ship/SKILL.md +0 -9
- package/skills/tfx-wt/SKILL.md +0 -6
package/config/mcp-registry.json
CHANGED
|
@@ -41,15 +41,6 @@
|
|
|
41
41
|
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
42
42
|
"description": "Exa neural/semantic web search — 학술/기술 깊이. Key 발급: https://exa.ai/dashboard → secrets.env의 EXA_API_KEY"
|
|
43
43
|
},
|
|
44
|
-
"serena": {
|
|
45
|
-
"policy": "gateway-http",
|
|
46
|
-
"transport": "http",
|
|
47
|
-
"gateway_port": 8105,
|
|
48
|
-
"gateway_path": "/mcp",
|
|
49
|
-
"safe": true,
|
|
50
|
-
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
51
|
-
"description": "Serena MCP — local supergateway stateful Streamable HTTP endpoint (:8105/mcp)"
|
|
52
|
-
},
|
|
53
44
|
"brave-search": {
|
|
54
45
|
"policy": "gateway-http",
|
|
55
46
|
"transport": "http",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "mcp-registry-schema",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"description": "MCP 서버 중앙 레지스트리 — 진실의 원천",
|
|
5
|
+
"defaults": {
|
|
6
|
+
"transport": "hub-url",
|
|
7
|
+
"hub_base": "http://127.0.0.1:27888"
|
|
8
|
+
},
|
|
9
|
+
"policy_notes": {
|
|
10
|
+
"policy": "Server policy is the SSOT for client sync shape: hosted writes url+headers, gateway-sse writes http://127.0.0.1:81XX/sse with SSE metadata, gateway-http writes http://127.0.0.1:81XX/mcp with HTTP metadata, and stdio writes command/args/env.",
|
|
11
|
+
"transport": "Server transport accepts \"hub-url\" for triflux Hub URL flow, \"http\" for direct Streamable HTTP MCP endpoints, or \"stdio\" for upstream-stdio-only MCP servers. Gateway-backed stdio upstreams should use policy:\"gateway-http\" plus gateway_port/gateway_path so clients receive reconnect-safe HTTP MCP config; legacy policy:\"gateway-sse\" remains supported only for backward compatibility.",
|
|
12
|
+
"headers": "Optional headers are allowed only for HTTP-compatible transports. Each header value must be a descriptor: {\"value\":\"literal\"} for non-secret static values, {\"env\":\"ENV_VAR_NAME\"} for secrets resolved at sync/runtime, or {\"env\":\"ENV_VAR_NAME\",\"prefix\":\"Bearer \"} for common authorization formats.",
|
|
13
|
+
"secret_safety": "Resolved secret values must not be written back to this registry file. Missing env vars warn during sync and do not emit empty secret headers.",
|
|
14
|
+
"sync_denylist": "Array of client:server strings skipped by proactive registry sync, for example gemini:tfx-hub."
|
|
15
|
+
},
|
|
16
|
+
"servers": {
|
|
17
|
+
"tfx-hub": {
|
|
18
|
+
"policy": "hosted",
|
|
19
|
+
"transport": "hub-url",
|
|
20
|
+
"url": "http://127.0.0.1:27888/mcp",
|
|
21
|
+
"safe": true,
|
|
22
|
+
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
23
|
+
"description": "triflux Hub MCP 서버"
|
|
24
|
+
},
|
|
25
|
+
"context7": {
|
|
26
|
+
"policy": "hosted",
|
|
27
|
+
"transport": "http",
|
|
28
|
+
"url": "https://mcp.context7.com/mcp",
|
|
29
|
+
"safe": true,
|
|
30
|
+
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
31
|
+
"description": "Upstash Context7 — 라이브러리 문서/코드 컨텍스트 (HTTP MCP, API key 불필요)"
|
|
32
|
+
},
|
|
33
|
+
"exa": {
|
|
34
|
+
"policy": "hosted",
|
|
35
|
+
"transport": "http",
|
|
36
|
+
"url": "https://mcp.exa.ai/mcp",
|
|
37
|
+
"headers": {
|
|
38
|
+
"Authorization": { "env": "EXA_API_KEY", "prefix": "Bearer " }
|
|
39
|
+
},
|
|
40
|
+
"safe": true,
|
|
41
|
+
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
42
|
+
"description": "Exa neural/semantic web search — 학술/기술 깊이. Key 발급: https://exa.ai/dashboard → secrets.env의 EXA_API_KEY"
|
|
43
|
+
},
|
|
44
|
+
"serena": {
|
|
45
|
+
"policy": "gateway-http",
|
|
46
|
+
"transport": "http",
|
|
47
|
+
"gateway_port": 8105,
|
|
48
|
+
"gateway_path": "/mcp",
|
|
49
|
+
"safe": true,
|
|
50
|
+
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
51
|
+
"description": "Serena MCP — local supergateway stateful Streamable HTTP endpoint (:8105/mcp)"
|
|
52
|
+
},
|
|
53
|
+
"brave-search": {
|
|
54
|
+
"policy": "gateway-http",
|
|
55
|
+
"transport": "http",
|
|
56
|
+
"gateway_port": 8101,
|
|
57
|
+
"gateway_path": "/mcp",
|
|
58
|
+
"safe": true,
|
|
59
|
+
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
60
|
+
"description": "Brave Search MCP — local supergateway stateful Streamable HTTP endpoint (:8101/mcp). BRAVE_API_KEY 환경변수 필요 (https://brave.com/search/api/). secrets.env 의 BRAVE_API_KEY 참조."
|
|
61
|
+
},
|
|
62
|
+
"tavily": {
|
|
63
|
+
"policy": "hosted",
|
|
64
|
+
"transport": "http",
|
|
65
|
+
"url": "https://mcp.tavily.com/mcp",
|
|
66
|
+
"headers": {
|
|
67
|
+
"Authorization": { "env": "TAVILY_API_KEY", "prefix": "Bearer " }
|
|
68
|
+
},
|
|
69
|
+
"safe": true,
|
|
70
|
+
"targets": ["claude", "gemini", "codex", "antigravity"],
|
|
71
|
+
"description": "Tavily research — 비용/운영/DX/일반 웹. Key 발급: https://app.tavily.com/home → secrets.env의 TAVILY_API_KEY"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"policies": {
|
|
75
|
+
"stdio_action": "replace-with-hub",
|
|
76
|
+
"unknown_server_action": "warn",
|
|
77
|
+
"sync_denylist": [],
|
|
78
|
+
"watched_paths": [
|
|
79
|
+
"~/.gemini/settings.json",
|
|
80
|
+
"~/.codex/config.toml",
|
|
81
|
+
"~/.claude.json",
|
|
82
|
+
"~/.claude/settings.json",
|
|
83
|
+
"~/.claude/settings.local.json",
|
|
84
|
+
".claude/mcp.json",
|
|
85
|
+
".mcp.json",
|
|
86
|
+
"~/.gemini/config/mcp_config.json"
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
//
|
|
11
11
|
// external source 훅 (session-vault 등)은 여전히 execFile로 실행된다.
|
|
12
12
|
|
|
13
|
-
import { execFile } from "node:child_process";
|
|
13
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
15
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
16
|
import {
|
|
@@ -40,6 +40,55 @@ function parseStartPayload(stdinData) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
function readAncestorCommands(pid = process.ppid, maxDepth = 6) {
|
|
44
|
+
if (process.platform === "win32") return [];
|
|
45
|
+
const commands = [];
|
|
46
|
+
let currentPid = Number(pid);
|
|
47
|
+
for (let depth = 0; depth < maxDepth; depth++) {
|
|
48
|
+
if (!Number.isInteger(currentPid) || currentPid <= 1) break;
|
|
49
|
+
try {
|
|
50
|
+
const output = execFileSync(
|
|
51
|
+
"ps",
|
|
52
|
+
["-o", "ppid=", "-o", "command=", "-p", String(currentPid)],
|
|
53
|
+
{
|
|
54
|
+
encoding: "utf8",
|
|
55
|
+
timeout: 200,
|
|
56
|
+
windowsHide: true,
|
|
57
|
+
},
|
|
58
|
+
).trim();
|
|
59
|
+
const match = output.match(/^(\d+)\s+([\s\S]+)$/);
|
|
60
|
+
if (!match) break;
|
|
61
|
+
commands.push(match[2]);
|
|
62
|
+
currentPid = Number(match[1]);
|
|
63
|
+
} catch {
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return commands;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function commandUsesClaudePrintMode(command) {
|
|
71
|
+
return /\bclaude(?:\s+\S+)*\s+(?:--print|-p)(?:\s|=|$)/u.test(
|
|
72
|
+
String(command || ""),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function shouldSkipInteractiveRegistration(payload, seams = {}) {
|
|
77
|
+
const declaredKind = String(
|
|
78
|
+
payload?.sessionKind || payload?.session_kind || "",
|
|
79
|
+
)
|
|
80
|
+
.trim()
|
|
81
|
+
.toLowerCase();
|
|
82
|
+
if (declaredKind === "headless") return true;
|
|
83
|
+
|
|
84
|
+
const ancestorCommands = Array.isArray(seams.ancestorCommands)
|
|
85
|
+
? seams.ancestorCommands
|
|
86
|
+
: readAncestorCommands(seams.parentPid);
|
|
87
|
+
return ancestorCommands.some((command) =>
|
|
88
|
+
commandUsesClaudePrintMode(command),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
43
92
|
/**
|
|
44
93
|
* cwd 기준 git 컨텍스트(worktree root / branch)를 best-effort, 비동기로 수집.
|
|
45
94
|
* execFileSync 와 달리 호출자(BACKGROUND)를 블로킹하지 않는다. 각 git 호출은
|
|
@@ -107,6 +156,7 @@ export function registerInteractiveSession(stdinData, seams = {}) {
|
|
|
107
156
|
const payload = parseStartPayload(stdinData);
|
|
108
157
|
const sessionId = String(payload?.session_id || "").trim();
|
|
109
158
|
if (!sessionId) return;
|
|
159
|
+
if (shouldSkipInteractiveRegistration(payload, seams)) return;
|
|
110
160
|
const cwd = typeof payload?.cwd === "string" ? payload.cwd : process.cwd();
|
|
111
161
|
|
|
112
162
|
// 1) cwd 만으로 즉시 minimal register (블로킹 git 없음, latency 0).
|
package/hub/hub-lifecycle.mjs
CHANGED
|
@@ -15,15 +15,21 @@ const EPHEMERAL_ENV_KEYS = [
|
|
|
15
15
|
"TFX_TEAM_AGENT_NAME",
|
|
16
16
|
"TFX_EPHEMERAL",
|
|
17
17
|
];
|
|
18
|
+
const WORKTREE_CWD_PATTERNS = [
|
|
19
|
+
/\/\.claude\/worktrees\//u,
|
|
20
|
+
/\/\.worktrees\//u,
|
|
21
|
+
/\/\.codex-swarm\/wt-[^/]+(?:\/|$)/u,
|
|
22
|
+
/(^|\/)wt-[^/]+(?:\/|$)/u,
|
|
23
|
+
];
|
|
18
24
|
|
|
19
|
-
function
|
|
25
|
+
function parsePositivePort(value) {
|
|
20
26
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
21
27
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
|
-
function
|
|
30
|
+
function parsePortFromHubUrl(value) {
|
|
25
31
|
try {
|
|
26
|
-
return
|
|
32
|
+
return parsePositivePort(new URL(String(value)).port);
|
|
27
33
|
} catch {
|
|
28
34
|
return null;
|
|
29
35
|
}
|
|
@@ -40,12 +46,7 @@ export function isWorktreeOrEphemeralHubContext({
|
|
|
40
46
|
env = process.env,
|
|
41
47
|
} = {}) {
|
|
42
48
|
const normalizedCwd = String(cwd || "").replace(/\\/g, "/");
|
|
43
|
-
if (
|
|
44
|
-
normalizedCwd.includes("/.claude/worktrees/") ||
|
|
45
|
-
normalizedCwd.includes("/.worktrees/") ||
|
|
46
|
-
normalizedCwd.includes("/.codex-swarm/wt-") ||
|
|
47
|
-
/(^|\/)wt-[^/]+(?:\/|$)/u.test(normalizedCwd)
|
|
48
|
-
) {
|
|
49
|
+
if (WORKTREE_CWD_PATTERNS.some((pattern) => pattern.test(normalizedCwd))) {
|
|
49
50
|
return true;
|
|
50
51
|
}
|
|
51
52
|
return EPHEMERAL_ENV_KEYS.some((key) => String(env?.[key] || "").length > 0);
|
|
@@ -57,11 +58,16 @@ export function resolveHubPortForContext({
|
|
|
57
58
|
cwd = process.cwd(),
|
|
58
59
|
defaultPort = HUB_DEFAULT_PORT,
|
|
59
60
|
} = {}) {
|
|
60
|
-
const envPort =
|
|
61
|
-
|
|
61
|
+
const envPort =
|
|
62
|
+
parsePositivePort(port) ?? parsePositivePort(env?.TFX_HUB_PORT);
|
|
63
|
+
const urlPort = parsePortFromHubUrl(env?.TFX_HUB_URL);
|
|
62
64
|
const resolvedPort = envPort ?? urlPort ?? defaultPort;
|
|
63
65
|
if (
|
|
64
66
|
resolvedPort !== defaultPort &&
|
|
67
|
+
// Test-only opt-in seam: TFX_HUB_ALLOW_EPHEMERAL_PORT=1 lets an ephemeral
|
|
68
|
+
// context (worktree cwd / TFX_TEAM_* env) honor the resolved port instead
|
|
69
|
+
// of clamping to the canonical default. Default-off — production unchanged.
|
|
70
|
+
String(env?.TFX_HUB_ALLOW_EPHEMERAL_PORT ?? "") !== "1" &&
|
|
65
71
|
isWorktreeOrEphemeralHubContext({ cwd, env })
|
|
66
72
|
) {
|
|
67
73
|
return defaultPort;
|
|
@@ -181,7 +187,7 @@ export async function reapExistingHubProcesses({
|
|
|
181
187
|
typeof readPidFileFn === "function"
|
|
182
188
|
? readPidFileFn()
|
|
183
189
|
: { pid: readPidFilePid({ pidFilePath }) };
|
|
184
|
-
const pidFilePid =
|
|
190
|
+
const pidFilePid = parsePositivePort(pidFileInfo?.pid);
|
|
185
191
|
const defaultPortPids = [
|
|
186
192
|
...new Set(
|
|
187
193
|
(typeof findListeningPidsForPortFn === "function"
|
package/hub/server.mjs
CHANGED
|
@@ -113,6 +113,29 @@ export function sendClaudeControlRequest(
|
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// claude daemon 은 control.sock 의 mutating op(dispatch 등)에 control-key
|
|
117
|
+
// 인증을 강제한다 (미제시 시 EAUTH "didn't present the daemon control key").
|
|
118
|
+
// key 는 <configDir>/daemon/control.key (configDir 스코프별). 파일이 없으면
|
|
119
|
+
// 인증 미강제 daemon 으로 보고 auth 필드를 생략한다 (fail-open — 구버전 호환).
|
|
120
|
+
export async function readDaemonControlKey(
|
|
121
|
+
configDir = resolveClaudeConfigDir(),
|
|
122
|
+
) {
|
|
123
|
+
try {
|
|
124
|
+
const key = await fs.readFile(
|
|
125
|
+
path.join(configDir, "daemon", "control.key"),
|
|
126
|
+
"utf8",
|
|
127
|
+
);
|
|
128
|
+
return key.trim() || undefined;
|
|
129
|
+
} catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function buildDaemonControlAuth(configDir) {
|
|
135
|
+
const auth = await readDaemonControlKey(configDir);
|
|
136
|
+
return auth ? { auth } : {};
|
|
137
|
+
}
|
|
138
|
+
|
|
116
139
|
export function buildDaemonAttachRequest({
|
|
117
140
|
short,
|
|
118
141
|
cols = DEFAULT_DAEMON_ATTACH_COLS,
|
|
@@ -1146,11 +1169,12 @@ export async function resolveDaemonBridgeSessionId({
|
|
|
1146
1169
|
}
|
|
1147
1170
|
}
|
|
1148
1171
|
|
|
1149
|
-
export async function killDaemonJob(controlSock, short) {
|
|
1172
|
+
export async function killDaemonJob(controlSock, short, { auth } = {}) {
|
|
1150
1173
|
return await sendClaudeControlRequest(controlSock, {
|
|
1151
1174
|
proto: 1,
|
|
1152
1175
|
op: "kill",
|
|
1153
1176
|
short,
|
|
1177
|
+
...(auth ? { auth } : {}),
|
|
1154
1178
|
});
|
|
1155
1179
|
}
|
|
1156
1180
|
|
|
@@ -1237,6 +1261,7 @@ export async function dispatchClaudeDaemonJob({
|
|
|
1237
1261
|
const writeProjection =
|
|
1238
1262
|
_deps.writeClaudeSessionProjection || writeClaudeSessionProjection;
|
|
1239
1263
|
const readProcStart = _deps.getProcStart || getProcStart;
|
|
1264
|
+
const buildAuth = _deps.buildDaemonControlAuth || buildDaemonControlAuth;
|
|
1240
1265
|
|
|
1241
1266
|
const short = payload.short;
|
|
1242
1267
|
const sessionsDir =
|
|
@@ -1246,6 +1271,7 @@ export async function dispatchClaudeDaemonJob({
|
|
|
1246
1271
|
// native-bridge 는 control.sock 존재를 미리 점검한다 (없으면 fast-fail).
|
|
1247
1272
|
if (accessControlSock) await accessControlSock(resolvedControlSock);
|
|
1248
1273
|
|
|
1274
|
+
const controlAuth = await buildAuth(paths?.configDir);
|
|
1249
1275
|
const dispatch = await sendControl(
|
|
1250
1276
|
resolvedControlSock,
|
|
1251
1277
|
{
|
|
@@ -1253,11 +1279,17 @@ export async function dispatchClaudeDaemonJob({
|
|
|
1253
1279
|
op: "dispatch",
|
|
1254
1280
|
d: payload,
|
|
1255
1281
|
timeoutMs: dispatchTimeoutMs,
|
|
1282
|
+
...controlAuth,
|
|
1256
1283
|
},
|
|
1257
1284
|
{ timeoutMs: dispatchTimeoutMs },
|
|
1258
1285
|
);
|
|
1259
1286
|
if (dispatch?.ok !== true) {
|
|
1260
|
-
|
|
1287
|
+
// daemon 거부 사유를 보존한다 — generic 메시지만 던지면 EAUTH 같은
|
|
1288
|
+
// 실원인이 묻혀 진단이 어려워진다.
|
|
1289
|
+
const reason = dispatch?.error ? `: ${dispatch.error}` : "";
|
|
1290
|
+
throw new Error(
|
|
1291
|
+
`Claude daemon dispatch failed for ${name || short}${reason}`,
|
|
1292
|
+
);
|
|
1261
1293
|
}
|
|
1262
1294
|
|
|
1263
1295
|
const pidOpts =
|
|
@@ -6,6 +6,7 @@ const DEFAULT_LOCAL_TIMEOUT_MS = 30_000;
|
|
|
6
6
|
const DEFAULT_REMOTE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
7
7
|
const DEFAULT_REMOTE_TIMEOUT_MS = 90_000;
|
|
8
8
|
const DEFAULT_EXPIRE_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const DEFAULT_CLEAN_EXPIRE_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
9
10
|
// Interactive (Claude/Codex) sessions idle for long stretches; a 30s local
|
|
10
11
|
// timeout produces stale false-positives. 5-minute TTL + an `idle` state
|
|
11
12
|
// distinguishes "alive but inactive" from "presumed dead".
|
|
@@ -23,6 +24,27 @@ function normalizeSessionKind(raw) {
|
|
|
23
24
|
return VALID_SESSION_KINDS.has(raw) ? raw : "headless";
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
function normalizeDurationMs(raw, fallback) {
|
|
28
|
+
if (raw == null) return fallback;
|
|
29
|
+
const str = String(raw).trim();
|
|
30
|
+
if (!str) return fallback;
|
|
31
|
+
const parsed = Number(str);
|
|
32
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function defaultCleanExpireTimeoutMs() {
|
|
36
|
+
return normalizeDurationMs(
|
|
37
|
+
process.env.TFX_SYNAPSE_CLEAN_EXPIRE_MS,
|
|
38
|
+
DEFAULT_CLEAN_EXPIRE_TIMEOUT_MS,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasDirtyFiles(session) {
|
|
43
|
+
return Array.isArray(session?.dirtyFiles)
|
|
44
|
+
? session.dirtyFiles.some((file) => typeof file === "string" && file)
|
|
45
|
+
: false;
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
// A session is "live" while active OR idle. Idle is an interactive session that
|
|
27
49
|
// missed its heartbeat interval but is still under the TTL — alive but inactive,
|
|
28
50
|
// not presumed dead. getActive() and querySessions() share this single predicate
|
|
@@ -135,6 +157,7 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
135
157
|
interactiveHeartbeatIntervalMs = DEFAULT_INTERACTIVE_HEARTBEAT_INTERVAL_MS,
|
|
136
158
|
interactiveTimeoutMs = DEFAULT_INTERACTIVE_TIMEOUT_MS,
|
|
137
159
|
expireTimeoutMs = DEFAULT_EXPIRE_TIMEOUT_MS,
|
|
160
|
+
cleanExpireTimeoutMs = defaultCleanExpireTimeoutMs(),
|
|
138
161
|
} = opts;
|
|
139
162
|
|
|
140
163
|
const sessions = new Map();
|
|
@@ -357,19 +380,23 @@ export function createSynapseRegistry(opts = {}) {
|
|
|
357
380
|
return true;
|
|
358
381
|
}
|
|
359
382
|
|
|
360
|
-
// stale/expired 세션이
|
|
383
|
+
// stale/expired 세션이 cutoff 넘게 누적되면 Map에서 제거.
|
|
361
384
|
// live(active/idle)는 lastHeartbeat가 오래돼도 보존 — git-preflight dirty-file 가드가 의존.
|
|
385
|
+
// Dirty stale rows keep the historical 24h window because same-id resume
|
|
386
|
+
// revives their dirty-file guard; clean stale rows expire quickly to avoid
|
|
387
|
+
// daily dummy rows after overnight operator gaps.
|
|
362
388
|
function pruneExpired(opts2 = {}) {
|
|
363
389
|
if (destroyed) return { removed: [], count: 0 };
|
|
364
390
|
|
|
365
|
-
const
|
|
366
|
-
typeof opts2.olderThanMs === "number"
|
|
367
|
-
? opts2.olderThanMs
|
|
368
|
-
: expireTimeoutMs;
|
|
391
|
+
const explicitCutoff =
|
|
392
|
+
typeof opts2.olderThanMs === "number" ? opts2.olderThanMs : null;
|
|
369
393
|
const removed = [];
|
|
370
394
|
const currentTime = now();
|
|
371
395
|
|
|
372
396
|
for (const [sessionId, session] of sessions) {
|
|
397
|
+
const cutoff =
|
|
398
|
+
explicitCutoff ??
|
|
399
|
+
(hasDirtyFiles(session) ? expireTimeoutMs : cleanExpireTimeoutMs);
|
|
373
400
|
if (
|
|
374
401
|
isLiveStatus(session.status) ||
|
|
375
402
|
currentTime - session.lastHeartbeat <= cutoff
|
package/hub/tray-lifecycle.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// hub/tray-lifecycle.mjs — Hub/Tray bidirectional auto-start helpers
|
|
2
2
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isWorktreeOrEphemeralHubContext,
|
|
6
|
+
resolveHubPortForContext,
|
|
7
|
+
} from "./hub-lifecycle.mjs";
|
|
5
8
|
|
|
6
9
|
const DEFAULT_HUB_PORT = "27888";
|
|
7
10
|
const DEFAULT_HEALTH_TIMEOUT_MS = 1_000;
|
|
@@ -117,6 +120,9 @@ export function spawnTrayForHub({
|
|
|
117
120
|
spawnFn = spawn,
|
|
118
121
|
} = {}) {
|
|
119
122
|
if (env?.TFX_HUB_AUTO_TRAY === "0") return { status: "disabled" };
|
|
123
|
+
if (isWorktreeOrEphemeralHubContext({ env })) {
|
|
124
|
+
return { status: "disabled", reason: "ephemeral-or-worktree-context" };
|
|
125
|
+
}
|
|
120
126
|
if (platform !== "darwin") return { status: "unsupported-platform" };
|
|
121
127
|
if (!trayPath) return { status: "missing-tray-path" };
|
|
122
128
|
|
package/hub/tray.mjs
CHANGED
|
@@ -19,15 +19,20 @@ function sleep(ms) {
|
|
|
19
19
|
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function isNodeCommand(command) {
|
|
23
|
+
return /\bnode(?:\s|$)|[/\\]node(?:\s|$)/u.test(command);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasMacTrayScriptArg(command) {
|
|
27
|
+
return /(?:^|[\s"'])[^"'<>|]*[/\\]hub[/\\]tray\.mjs(?:$|[\s"'])/u.test(
|
|
28
|
+
command,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
export function collectMacTrayProcesses(
|
|
23
33
|
psOutput = "",
|
|
24
|
-
{
|
|
25
|
-
scriptPath = fileURLToPath(import.meta.url),
|
|
26
|
-
currentPid = process.pid,
|
|
27
|
-
} = {},
|
|
34
|
+
{ currentPid = process.pid } = {},
|
|
28
35
|
) {
|
|
29
|
-
const target = String(scriptPath || "");
|
|
30
|
-
if (!target) return [];
|
|
31
36
|
const ownPid = Number(currentPid);
|
|
32
37
|
return String(psOutput)
|
|
33
38
|
.split(/\r?\n/u)
|
|
@@ -38,8 +43,8 @@ export function collectMacTrayProcesses(
|
|
|
38
43
|
const ppid = Number.parseInt(match[2], 10);
|
|
39
44
|
const command = match[3].trim();
|
|
40
45
|
if (!Number.isFinite(pid) || pid === ownPid) return [];
|
|
41
|
-
if (!command
|
|
42
|
-
if (
|
|
46
|
+
if (!isNodeCommand(command)) return [];
|
|
47
|
+
if (!hasMacTrayScriptArg(command)) return [];
|
|
43
48
|
return [{ pid, ppid, command }];
|
|
44
49
|
});
|
|
45
50
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// ensure-codex-hooks 직렬화/멱등 회귀 테스트 (issue #394 배경)
|
|
2
|
+
//
|
|
3
|
+
// 핵심 계약:
|
|
4
|
+
// 1. 기록은 codex CLI 0.137+ canonical 인 table-header 형식만 사용한다.
|
|
5
|
+
// 2. strip/수렴 판정은 inline dotted-key / table-header 양 형식을 모두 인식한다.
|
|
6
|
+
// 3. 이미 수렴 상태면 config 를 재기록하지 않는다 (SessionStart 마다 호출되므로).
|
|
7
|
+
// 4. 어떤 입력 조합에서도 같은 키가 2회 직렬화되지 않는다 (TOML duplicate key
|
|
8
|
+
// = codex 전 호출 즉사).
|
|
9
|
+
//
|
|
10
|
+
// 전부 mkdtemp 격리 — 실 ~/.codex 무접촉.
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { afterEach, describe, it } from "node:test";
|
|
17
|
+
import { ensureCodexHooks } from "../ensure-codex-hooks.mjs";
|
|
18
|
+
|
|
19
|
+
const TEMP_DIRS = [];
|
|
20
|
+
|
|
21
|
+
function makeCodexHome() {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "tfx-ensure-codex-hooks-"));
|
|
23
|
+
TEMP_DIRS.push(dir);
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
while (TEMP_DIRS.length > 0) {
|
|
29
|
+
rmSync(TEMP_DIRS.pop(), { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function countOccurrences(content, needle) {
|
|
34
|
+
return content.split(needle).length - 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function managedKeyCounts(content, hooksPath) {
|
|
38
|
+
const counts = {};
|
|
39
|
+
for (const label of ["session_start", "user_prompt_submit"]) {
|
|
40
|
+
const key = `${hooksPath}:${label}:0:0`;
|
|
41
|
+
const inline = countOccurrences(content, `"${key}" = {`);
|
|
42
|
+
const table = countOccurrences(content, `[hooks.state."${key}"]`);
|
|
43
|
+
counts[label] = { inline, table, total: inline + table };
|
|
44
|
+
}
|
|
45
|
+
return counts;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("ensureCodexHooks — table-header 직렬화 + 멱등", () => {
|
|
49
|
+
it("fresh install: table-header 형식으로 각 키 1회 기록", () => {
|
|
50
|
+
const codexHome = makeCodexHome();
|
|
51
|
+
const result = ensureCodexHooks({ codexHome, backupTimestamp: "t0" });
|
|
52
|
+
|
|
53
|
+
assert.equal(result.skipped, false);
|
|
54
|
+
assert.equal(result.changedHooks, true);
|
|
55
|
+
assert.equal(result.changedConfig, true);
|
|
56
|
+
|
|
57
|
+
const config = readFileSync(result.configPath, "utf8");
|
|
58
|
+
const counts = managedKeyCounts(config, result.hooksPath);
|
|
59
|
+
for (const label of ["session_start", "user_prompt_submit"]) {
|
|
60
|
+
assert.equal(counts[label].table, 1, `${label} table 형식 1회`);
|
|
61
|
+
assert.equal(counts[label].inline, 0, `${label} inline 형식 0회`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("멱등: 2회 실행 시 changedConfig=false, 내용 불변", () => {
|
|
66
|
+
const codexHome = makeCodexHome();
|
|
67
|
+
const first = ensureCodexHooks({ codexHome, backupTimestamp: "t0" });
|
|
68
|
+
const before = readFileSync(first.configPath, "utf8");
|
|
69
|
+
|
|
70
|
+
const second = ensureCodexHooks({ codexHome, backupTimestamp: "t1" });
|
|
71
|
+
assert.equal(second.changedConfig, false, "2회차 config 무변경");
|
|
72
|
+
assert.equal(second.changedHooks, false, "2회차 hooks 무변경");
|
|
73
|
+
assert.equal(readFileSync(first.configPath, "utf8"), before);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("재발 시나리오: inline+table 이중화 config 를 단일 table 로 dedupe", () => {
|
|
77
|
+
const codexHome = makeCodexHome();
|
|
78
|
+
const first = ensureCodexHooks({ codexHome, backupTimestamp: "t0" });
|
|
79
|
+
const config = readFileSync(first.configPath, "utf8");
|
|
80
|
+
|
|
81
|
+
// 과거 설치기(inline)와 codex 재직렬화(table)가 공존하는 오염 상태 재현:
|
|
82
|
+
// 현재 table 기록 위에, 같은 키/해시의 inline 라인을 [hooks.state] 헤더와
|
|
83
|
+
// 함께 prepend 한다 — 2026-06-10 실측 config.toml:151-156 구조.
|
|
84
|
+
const counts0 = managedKeyCounts(config, first.hooksPath);
|
|
85
|
+
assert.equal(counts0.session_start.table, 1);
|
|
86
|
+
const tableBlocks = config
|
|
87
|
+
.split("\n")
|
|
88
|
+
.filter((l) => l.includes("trusted_hash"));
|
|
89
|
+
assert.ok(tableBlocks.length >= 2);
|
|
90
|
+
const hashOf = (label) => {
|
|
91
|
+
const idx = config.indexOf(
|
|
92
|
+
`[hooks.state."${first.hooksPath}:${label}:0:0"]`,
|
|
93
|
+
);
|
|
94
|
+
const m = config.slice(idx).match(/trusted_hash = "([^"]+)"/);
|
|
95
|
+
return m[1];
|
|
96
|
+
};
|
|
97
|
+
const polluted = [
|
|
98
|
+
"[hooks.state]",
|
|
99
|
+
`"${first.hooksPath}:session_start:0:0" = { trusted_hash = "${hashOf("session_start")}" }`,
|
|
100
|
+
`"${first.hooksPath}:user_prompt_submit:0:0" = { trusted_hash = "${hashOf("user_prompt_submit")}" }`,
|
|
101
|
+
config,
|
|
102
|
+
].join("\n");
|
|
103
|
+
writeFileSync(first.configPath, polluted, "utf8");
|
|
104
|
+
|
|
105
|
+
const second = ensureCodexHooks({ codexHome, backupTimestamp: "t1" });
|
|
106
|
+
assert.equal(second.changedConfig, true, "오염 감지 시 재기록");
|
|
107
|
+
const cleaned = readFileSync(first.configPath, "utf8");
|
|
108
|
+
const counts = managedKeyCounts(cleaned, first.hooksPath);
|
|
109
|
+
for (const label of ["session_start", "user_prompt_submit"]) {
|
|
110
|
+
assert.equal(counts[label].total, 1, `${label} 총 1회 (duplicate 해소)`);
|
|
111
|
+
assert.equal(counts[label].table, 1, `${label} table 형식으로 수렴`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("codex 가 table 로만 재직렬화한 config 는 수렴 상태로 보고 무변경", () => {
|
|
116
|
+
const codexHome = makeCodexHome();
|
|
117
|
+
const first = ensureCodexHooks({ codexHome, backupTimestamp: "t0" });
|
|
118
|
+
const config = readFileSync(first.configPath, "utf8");
|
|
119
|
+
|
|
120
|
+
// codex 재직렬화 시뮬레이션: 동일 키/해시가 table 형식으로만 존재
|
|
121
|
+
// (이미 우리 기록이 table 이므로 그대로가 그 상태다). 위에 무관 설정만 추가.
|
|
122
|
+
writeFileSync(first.configPath, `model = "gpt-5.5"\n\n${config}`, "utf8");
|
|
123
|
+
|
|
124
|
+
const second = ensureCodexHooks({ codexHome, backupTimestamp: "t1" });
|
|
125
|
+
assert.equal(second.changedConfig, false, "수렴 상태 무변경");
|
|
126
|
+
const after = readFileSync(first.configPath, "utf8");
|
|
127
|
+
assert.ok(after.startsWith('model = "gpt-5.5"'), "비관리 설정 보존");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("비관리 hooks.state 키(oh-my-codex 등)는 보존", () => {
|
|
131
|
+
const codexHome = makeCodexHome();
|
|
132
|
+
const configPath = join(codexHome, "config.toml");
|
|
133
|
+
const foreign =
|
|
134
|
+
'[hooks.state."oh-my-codex@local:hooks/hooks.json:stop:0:0"]\ntrusted_hash = "sha256:aaaa"\n';
|
|
135
|
+
writeFileSync(configPath, foreign, "utf8");
|
|
136
|
+
|
|
137
|
+
const result = ensureCodexHooks({ codexHome, backupTimestamp: "t0" });
|
|
138
|
+
const config = readFileSync(result.configPath, "utf8");
|
|
139
|
+
assert.ok(
|
|
140
|
+
config.includes("oh-my-codex@local:hooks/hooks.json:stop:0:0"),
|
|
141
|
+
"외부 키 보존",
|
|
142
|
+
);
|
|
143
|
+
assert.equal(
|
|
144
|
+
countOccurrences(config, "oh-my-codex@local"),
|
|
145
|
+
1,
|
|
146
|
+
"외부 키 중복 없음",
|
|
147
|
+
);
|
|
148
|
+
const counts = managedKeyCounts(config, result.hooksPath);
|
|
149
|
+
assert.equal(counts.session_start.total, 1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("hash 불일치(stale trust) 시 양 형식 strip 후 table 1회 재기록", () => {
|
|
153
|
+
const codexHome = makeCodexHome();
|
|
154
|
+
const first = ensureCodexHooks({ codexHome, backupTimestamp: "t0" });
|
|
155
|
+
const config = readFileSync(first.configPath, "utf8");
|
|
156
|
+
// 해시를 임의 값으로 바꿔 stale trust 상태 재현
|
|
157
|
+
writeFileSync(
|
|
158
|
+
first.configPath,
|
|
159
|
+
config.replace(
|
|
160
|
+
/trusted_hash = "sha256:[0-9a-f]+"/g,
|
|
161
|
+
'trusted_hash = "sha256:stale"',
|
|
162
|
+
),
|
|
163
|
+
"utf8",
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const second = ensureCodexHooks({ codexHome, backupTimestamp: "t1" });
|
|
167
|
+
assert.equal(second.changedConfig, true);
|
|
168
|
+
const after = readFileSync(first.configPath, "utf8");
|
|
169
|
+
const counts = managedKeyCounts(after, first.hooksPath);
|
|
170
|
+
for (const label of ["session_start", "user_prompt_submit"]) {
|
|
171
|
+
assert.equal(counts[label].total, 1);
|
|
172
|
+
assert.equal(counts[label].table, 1);
|
|
173
|
+
}
|
|
174
|
+
// 관리 키 영역은 stale 해시가 아닌 유효한 sha256 으로 갱신됐다
|
|
175
|
+
assert.equal(
|
|
176
|
+
countOccurrences(after, "sha256:stale"),
|
|
177
|
+
0,
|
|
178
|
+
"stale 해시 잔존 없음",
|
|
179
|
+
);
|
|
180
|
+
const m = after.match(/trusted_hash = "(sha256:[0-9a-f]{64})"/);
|
|
181
|
+
assert.ok(m, "유효한 sha256 해시로 재기록");
|
|
182
|
+
});
|
|
183
|
+
});
|