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.
Files changed (37) hide show
  1. package/config/mcp-registry.json +0 -9
  2. package/config/mcp-registry.json.bak-pre-serena-removal +89 -0
  3. package/hooks/session-start-fast.mjs +51 -1
  4. package/hub/hub-lifecycle.mjs +18 -12
  5. package/hub/server.mjs +1 -0
  6. package/hub/team/claude-daemon-control.mjs +34 -2
  7. package/hub/team/synapse-registry.mjs +32 -5
  8. package/hub/tray-lifecycle.mjs +7 -1
  9. package/hub/tray.mjs +13 -8
  10. package/package.json +1 -1
  11. package/scripts/__tests__/ensure-codex-hooks.test.mjs +183 -0
  12. package/scripts/ensure-codex-hooks.mjs +71 -27
  13. package/scripts/release/check-packages-mirror.mjs +1 -1
  14. package/scripts/test-lock.mjs +39 -9
  15. package/scripts/tfx-route.sh +10 -2
  16. package/skills/star-prompt/SKILL.md +0 -2
  17. package/skills/tfx-analysis/SKILL.md +1 -9
  18. package/skills/tfx-auto/SKILL.md +4 -36
  19. package/skills/tfx-doctor/SKILL.md +0 -2
  20. package/skills/tfx-find/SKILL.md +0 -15
  21. package/skills/tfx-forge/SKILL.md +0 -10
  22. package/skills/tfx-goal-clarify/SKILL.md +0 -9
  23. package/skills/tfx-hooks/SKILL.md +0 -2
  24. package/skills/tfx-hub/SKILL.md +0 -2
  25. package/skills/tfx-index/SKILL.md +0 -12
  26. package/skills/tfx-interview/SKILL.md +0 -11
  27. package/skills/tfx-live/SKILL.md +0 -4
  28. package/skills/tfx-plan/SKILL.md +1 -11
  29. package/skills/tfx-profile/SKILL.md +0 -2
  30. package/skills/tfx-prune/SKILL.md +0 -6
  31. package/skills/tfx-qa/SKILL.md +1 -10
  32. package/skills/tfx-remote/SKILL.md +0 -2
  33. package/skills/tfx-research/SKILL.md +1 -15
  34. package/skills/tfx-review/SKILL.md +1 -16
  35. package/skills/tfx-setup/SKILL.md +0 -2
  36. package/skills/tfx-ship/SKILL.md +0 -9
  37. package/skills/tfx-wt/SKILL.md +0 -6
@@ -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).
@@ -15,15 +15,21 @@ const EPHEMERAL_ENV_KEYS = [
15
15
  "TFX_TEAM_AGENT_NAME",
16
16
  "TFX_EPHEMERAL",
17
17
  ];
18
+ const WORKTREE_CWD_PATTERNS = [
19
+ /\/\.claude\/worktrees\//u,
20
+ /\/\.worktrees\//u,
21
+ /\/\.codex-swarm\/wt-[^/]+(?:\/|$)/u,
22
+ /(^|\/)wt-[^/]+(?:\/|$)/u,
23
+ ];
18
24
 
19
- function parsePositiveInt(value) {
25
+ function parsePositivePort(value) {
20
26
  const parsed = Number.parseInt(String(value ?? ""), 10);
21
27
  return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
22
28
  }
23
29
 
24
- function parsePortFromUrl(value) {
30
+ function parsePortFromHubUrl(value) {
25
31
  try {
26
- return parsePositiveInt(new URL(String(value)).port);
32
+ return parsePositivePort(new URL(String(value)).port);
27
33
  } catch {
28
34
  return null;
29
35
  }
@@ -40,12 +46,7 @@ export function isWorktreeOrEphemeralHubContext({
40
46
  env = process.env,
41
47
  } = {}) {
42
48
  const normalizedCwd = String(cwd || "").replace(/\\/g, "/");
43
- if (
44
- normalizedCwd.includes("/.claude/worktrees/") ||
45
- normalizedCwd.includes("/.worktrees/") ||
46
- normalizedCwd.includes("/.codex-swarm/wt-") ||
47
- /(^|\/)wt-[^/]+(?:\/|$)/u.test(normalizedCwd)
48
- ) {
49
+ if (WORKTREE_CWD_PATTERNS.some((pattern) => pattern.test(normalizedCwd))) {
49
50
  return true;
50
51
  }
51
52
  return EPHEMERAL_ENV_KEYS.some((key) => String(env?.[key] || "").length > 0);
@@ -57,11 +58,16 @@ export function resolveHubPortForContext({
57
58
  cwd = process.cwd(),
58
59
  defaultPort = HUB_DEFAULT_PORT,
59
60
  } = {}) {
60
- const envPort = parsePositiveInt(port) ?? parsePositiveInt(env?.TFX_HUB_PORT);
61
- const urlPort = parsePortFromUrl(env?.TFX_HUB_URL);
61
+ const envPort =
62
+ parsePositivePort(port) ?? parsePositivePort(env?.TFX_HUB_PORT);
63
+ const urlPort = parsePortFromHubUrl(env?.TFX_HUB_URL);
62
64
  const resolvedPort = envPort ?? urlPort ?? defaultPort;
63
65
  if (
64
66
  resolvedPort !== defaultPort &&
67
+ // Test-only opt-in seam: TFX_HUB_ALLOW_EPHEMERAL_PORT=1 lets an ephemeral
68
+ // context (worktree cwd / TFX_TEAM_* env) honor the resolved port instead
69
+ // of clamping to the canonical default. Default-off — production unchanged.
70
+ String(env?.TFX_HUB_ALLOW_EPHEMERAL_PORT ?? "") !== "1" &&
65
71
  isWorktreeOrEphemeralHubContext({ cwd, env })
66
72
  ) {
67
73
  return defaultPort;
@@ -181,7 +187,7 @@ export async function reapExistingHubProcesses({
181
187
  typeof readPidFileFn === "function"
182
188
  ? readPidFileFn()
183
189
  : { pid: readPidFilePid({ pidFilePath }) };
184
- const pidFilePid = parsePositiveInt(pidFileInfo?.pid);
190
+ const pidFilePid = parsePositivePort(pidFileInfo?.pid);
185
191
  const defaultPortPids = [
186
192
  ...new Set(
187
193
  (typeof findListeningPidsForPortFn === "function"
package/hub/server.mjs CHANGED
@@ -2461,6 +2461,7 @@ export async function startHub({
2461
2461
  hubLog.warn(
2462
2462
  {
2463
2463
  failed: failed.length,
2464
+ failedCount: failed.length,
2464
2465
  processes: failed,
2465
2466
  caller: "startup",
2466
2467
  },
@@ -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
- throw new Error(`Claude daemon dispatch failed for ${name || short}`);
1287
+ // daemon 거부 사유를 보존한다 generic 메시지만 던지면 EAUTH 같은
1288
+ // 실원인이 묻혀 진단이 어려워진다.
1289
+ const reason = dispatch?.error ? `: ${dispatch.error}` : "";
1290
+ throw new Error(
1291
+ `Claude daemon dispatch failed for ${name || short}${reason}`,
1292
+ );
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 세션이 expireTimeoutMs 넘게 누적되면 Map에서 제거.
383
+ // stale/expired 세션이 cutoff 넘게 누적되면 Map에서 제거.
361
384
  // live(active/idle)는 lastHeartbeat가 오래돼도 보존 — git-preflight dirty-file 가드가 의존.
385
+ // Dirty stale rows keep the historical 24h window because same-id resume
386
+ // revives their dirty-file guard; clean stale rows expire quickly to avoid
387
+ // daily dummy rows after overnight operator gaps.
362
388
  function pruneExpired(opts2 = {}) {
363
389
  if (destroyed) return { removed: [], count: 0 };
364
390
 
365
- const cutoff =
366
- typeof opts2.olderThanMs === "number"
367
- ? opts2.olderThanMs
368
- : expireTimeoutMs;
391
+ const explicitCutoff =
392
+ typeof opts2.olderThanMs === "number" ? opts2.olderThanMs : null;
369
393
  const removed = [];
370
394
  const currentTime = now();
371
395
 
372
396
  for (const [sessionId, session] of sessions) {
397
+ const cutoff =
398
+ explicitCutoff ??
399
+ (hasDirtyFiles(session) ? expireTimeoutMs : cleanExpireTimeoutMs);
373
400
  if (
374
401
  isLiveStatus(session.status) ||
375
402
  currentTime - session.lastHeartbeat <= cutoff
@@ -1,7 +1,10 @@
1
1
  // hub/tray-lifecycle.mjs — Hub/Tray bidirectional auto-start helpers
2
2
 
3
3
  import { spawn } from "node:child_process";
4
- import { resolveHubPortForContext } from "./hub-lifecycle.mjs";
4
+ import {
5
+ isWorktreeOrEphemeralHubContext,
6
+ resolveHubPortForContext,
7
+ } from "./hub-lifecycle.mjs";
5
8
 
6
9
  const DEFAULT_HUB_PORT = "27888";
7
10
  const DEFAULT_HEALTH_TIMEOUT_MS = 1_000;
@@ -117,6 +120,9 @@ export function spawnTrayForHub({
117
120
  spawnFn = spawn,
118
121
  } = {}) {
119
122
  if (env?.TFX_HUB_AUTO_TRAY === "0") return { status: "disabled" };
123
+ if (isWorktreeOrEphemeralHubContext({ env })) {
124
+ return { status: "disabled", reason: "ephemeral-or-worktree-context" };
125
+ }
120
126
  if (platform !== "darwin") return { status: "unsupported-platform" };
121
127
  if (!trayPath) return { status: "missing-tray-path" };
122
128
 
package/hub/tray.mjs CHANGED
@@ -19,15 +19,20 @@ function sleep(ms) {
19
19
  return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
20
20
  }
21
21
 
22
+ function isNodeCommand(command) {
23
+ return /\bnode(?:\s|$)|[/\\]node(?:\s|$)/u.test(command);
24
+ }
25
+
26
+ function hasMacTrayScriptArg(command) {
27
+ return /(?:^|[\s"'])[^"'<>|]*[/\\]hub[/\\]tray\.mjs(?:$|[\s"'])/u.test(
28
+ command,
29
+ );
30
+ }
31
+
22
32
  export function collectMacTrayProcesses(
23
33
  psOutput = "",
24
- {
25
- scriptPath = fileURLToPath(import.meta.url),
26
- currentPid = process.pid,
27
- } = {},
34
+ { currentPid = process.pid } = {},
28
35
  ) {
29
- const target = String(scriptPath || "");
30
- if (!target) return [];
31
36
  const ownPid = Number(currentPid);
32
37
  return String(psOutput)
33
38
  .split(/\r?\n/u)
@@ -38,8 +43,8 @@ export function collectMacTrayProcesses(
38
43
  const ppid = Number.parseInt(match[2], 10);
39
44
  const command = match[3].trim();
40
45
  if (!Number.isFinite(pid) || pid === ownPid) return [];
41
- if (!command.includes(target)) return [];
42
- if (!/\bnode(?:\s|$)|\/node(?:\s|$)/u.test(command)) return [];
46
+ if (!isNodeCommand(command)) return [];
47
+ if (!hasMacTrayScriptArg(command)) return [];
43
48
  return [{ pid, ppid, command }];
44
49
  });
45
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.33.1",
3
+ "version": "10.34.0",
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": {
@@ -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
+ });