triflux 10.33.0 → 10.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/triflux.mjs +26 -0
- package/config/mcp-registry.json +0 -9
- package/config/mcp-registry.json.bak-pre-serena-removal +89 -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/hooks/session-start-fast.mjs +51 -1
- package/hub/bridge.mjs +3 -1
- package/hub/diagnostics/claude-runtime-flags.mjs +64 -0
- package/hub/hub-lifecycle.mjs +18 -12
- package/hub/mac-tray.swift +1 -1
- package/hub/public/tray.html +90 -58
- package/hub/server.mjs +23 -6
- package/hub/team/claude-agent-session-normalizer.mjs +53 -0
- package/hub/team/claude-daemon-control.mjs +51 -15
- package/hub/team/claude-session-projection.mjs +57 -0
- package/hub/team/cli/services/hub-client.mjs +12 -3
- 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/lib/env-probe.mjs +4 -1
- package/scripts/pack.mjs +1 -0
- package/scripts/release/check-packages-mirror.mjs +81 -3
- 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 +2 -4
- package/skills/tfx-ship/SKILL.md +0 -9
- package/skills/tfx-wt/SKILL.md +0 -6
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 (터미널 멀티플렉서)");
|
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
|
+
}
|
|
@@ -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": "*",
|
|
@@ -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/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/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/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
|