triflux 9.7.13 → 9.8.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/bin/triflux.mjs +297 -47
- package/hooks/hook-registry.json +4 -4
- package/hub/fullcycle.mjs +96 -0
- package/hub/paths.mjs +30 -28
- package/hub/pipeline/index.mjs +318 -318
- package/hub/schema.sql +146 -146
- package/hub/team/cli/commands/kill.mjs +37 -37
- package/hub/team/cli/commands/stop.mjs +31 -31
- package/hub/team/cli/commands/task.mjs +30 -30
- package/hub/team/cli/services/hub-client.mjs +208 -208
- package/hub/team/cli/services/native-control.mjs +118 -118
- package/hub/team/cli/services/runtime-mode.mjs +62 -62
- package/hub/team/cli/services/state-store.mjs +48 -48
- package/hub/team/dashboard.mjs +274 -274
- package/hub/team/native.mjs +649 -649
- package/hub/team/psmux.mjs +68 -13
- package/hub/tools.mjs +554 -554
- package/hub/workers/claude-worker.mjs +423 -423
- package/hub/workers/codex-mcp.mjs +410 -410
- package/hub/workers/gemini-worker.mjs +429 -429
- package/hub/workers/interface.mjs +40 -40
- package/package.json +1 -1
- package/scripts/__tests__/remote-spawn-transfer.test.mjs +1 -1
- package/scripts/cache-warmup.mjs +1 -0
- package/scripts/claude-logged.ps1 +54 -0
- package/scripts/demo-tui.mjs +59 -0
- package/scripts/headless-guard.mjs +4 -7
- package/scripts/hub-ensure.mjs +120 -120
- package/scripts/lib/psmux-info.mjs +119 -0
- package/scripts/lib/remote-spawn-transfer.mjs +1 -1
- package/scripts/setup.mjs +150 -6
- package/scripts/tfx-route-post.mjs +90 -13
- package/scripts/token-snapshot.mjs +575 -575
- package/skills/.omc/state/agent-replay-8f0e10a9-9693-4410-96f5-a6b07e8ed995.jsonl +1 -0
- package/skills/.omc/state/idle-notif-cooldown.json +3 -0
- package/skills/.omc/state/last-tool-error.json +7 -0
- package/skills/.omc/state/subagent-tracking.json +7 -0
- package/skills/tfx-codex-swarm/SKILL.md +40 -5
- package/skills/tfx-codex-swarm/mcp-daemon/register-autostart.ps1 +32 -0
- package/skills/tfx-doctor/SKILL.md +3 -0
- package/skills/tfx-fullcycle/SKILL.md +79 -4
- package/skills/tfx-hub/SKILL.md +3 -1
- package/skills/tfx-psmux-rules/SKILL.md +53 -31
- package/skills/tfx-remote-spawn/references/hosts.json +16 -16
- package/skills/tfx-setup/SKILL.md +9 -0
- package/tui/doctor.mjs +1 -0
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
// hub/workers/interface.mjs — Worker 공통 인터페이스 정의
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 워커 실행 옵션
|
|
5
|
-
* @typedef {object} WorkerExecuteOptions
|
|
6
|
-
* @property {string} [cwd] - 워커 작업 디렉터리
|
|
7
|
-
* @property {string} [sessionKey] - 내부 세션 키
|
|
8
|
-
* @property {string} [threadId] - 외부에서 지정한 Codex threadId
|
|
9
|
-
* @property {boolean} [resetSession] - 기존 세션을 무시하고 새 세션 시작 여부
|
|
10
|
-
* @property {string} [model] - Codex 모델 이름
|
|
11
|
-
* @property {string} [profile] - Codex 프로필 이름
|
|
12
|
-
* @property {'untrusted'|'on-failure'|'on-request'|'never'} [approvalPolicy] - 승인 정책
|
|
13
|
-
* @property {'read-only'|'workspace-write'|'danger-full-access'} [sandbox] - 샌드박스 정책
|
|
14
|
-
* @property {Record<string, unknown>} [config] - 추가 Codex 설정
|
|
15
|
-
* @property {string} [baseInstructions] - 기본 시스템 지침
|
|
16
|
-
* @property {string} [developerInstructions] - 개발자 지침
|
|
17
|
-
* @property {string} [compactPrompt] - 컴팩션 프롬프트
|
|
18
|
-
* @property {number} [timeoutMs] - MCP 요청 타임아웃(ms)
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* 워커 실행 결과
|
|
23
|
-
* @typedef {object} WorkerResult
|
|
24
|
-
* @property {string} output - 최종 텍스트 출력
|
|
25
|
-
* @property {number} exitCode - 종료 코드(0=성공)
|
|
26
|
-
* @property {string | null} [threadId] - Codex 세션 threadId
|
|
27
|
-
* @property {string | null} [sessionKey] - 내부 세션 키
|
|
28
|
-
* @property {unknown} [raw] - 원본 tool call 결과
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* 공통 워커 인터페이스
|
|
33
|
-
* @typedef {object} IWorker
|
|
34
|
-
* @property {(prompt: string, opts?: WorkerExecuteOptions) => Promise<WorkerResult>} execute
|
|
35
|
-
* @property {() => Promise<void>} start
|
|
36
|
-
* @property {() => Promise<void>} stop
|
|
37
|
-
* @property {() => boolean} isReady
|
|
38
|
-
* @property {string} type - 'codex' | 'gemini' | 'claude' | 'delegator'
|
|
39
|
-
*/
|
|
40
|
-
|
|
1
|
+
// hub/workers/interface.mjs — Worker 공통 인터페이스 정의
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 워커 실행 옵션
|
|
5
|
+
* @typedef {object} WorkerExecuteOptions
|
|
6
|
+
* @property {string} [cwd] - 워커 작업 디렉터리
|
|
7
|
+
* @property {string} [sessionKey] - 내부 세션 키
|
|
8
|
+
* @property {string} [threadId] - 외부에서 지정한 Codex threadId
|
|
9
|
+
* @property {boolean} [resetSession] - 기존 세션을 무시하고 새 세션 시작 여부
|
|
10
|
+
* @property {string} [model] - Codex 모델 이름
|
|
11
|
+
* @property {string} [profile] - Codex 프로필 이름
|
|
12
|
+
* @property {'untrusted'|'on-failure'|'on-request'|'never'} [approvalPolicy] - 승인 정책
|
|
13
|
+
* @property {'read-only'|'workspace-write'|'danger-full-access'} [sandbox] - 샌드박스 정책
|
|
14
|
+
* @property {Record<string, unknown>} [config] - 추가 Codex 설정
|
|
15
|
+
* @property {string} [baseInstructions] - 기본 시스템 지침
|
|
16
|
+
* @property {string} [developerInstructions] - 개발자 지침
|
|
17
|
+
* @property {string} [compactPrompt] - 컴팩션 프롬프트
|
|
18
|
+
* @property {number} [timeoutMs] - MCP 요청 타임아웃(ms)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 워커 실행 결과
|
|
23
|
+
* @typedef {object} WorkerResult
|
|
24
|
+
* @property {string} output - 최종 텍스트 출력
|
|
25
|
+
* @property {number} exitCode - 종료 코드(0=성공)
|
|
26
|
+
* @property {string | null} [threadId] - Codex 세션 threadId
|
|
27
|
+
* @property {string | null} [sessionKey] - 내부 세션 키
|
|
28
|
+
* @property {unknown} [raw] - 원본 tool call 결과
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 공통 워커 인터페이스
|
|
33
|
+
* @typedef {object} IWorker
|
|
34
|
+
* @property {(prompt: string, opts?: WorkerExecuteOptions) => Promise<WorkerResult>} execute
|
|
35
|
+
* @property {() => Promise<void>} start
|
|
36
|
+
* @property {() => Promise<void>} stop
|
|
37
|
+
* @property {() => boolean} isReady
|
|
38
|
+
* @property {string} type - 'codex' | 'gemini' | 'claude' | 'delegator'
|
|
39
|
+
*/
|
|
40
|
+
|
package/package.json
CHANGED
package/scripts/cache-warmup.mjs
CHANGED
|
@@ -309,6 +309,7 @@ export function extractProjectMeta(options = {}) {
|
|
|
309
309
|
if (existsSync(packageJsonPath)) {
|
|
310
310
|
try {
|
|
311
311
|
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
312
|
+
name = pkg.name || name;
|
|
312
313
|
description = pkg.description || "";
|
|
313
314
|
testCmd = pkg.scripts?.test || null;
|
|
314
315
|
lang = "JavaScript/ESM (Node.js)";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(ValueFromRemainingArguments=$true)]
|
|
3
|
+
[string[]]$ClaudeArgs
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
$LogDir = "$HOME\Desktop\Projects\triflux\logs"
|
|
7
|
+
$LogFile = "$LogDir\claude-sessions.log"
|
|
8
|
+
|
|
9
|
+
if (-not (Test-Path $LogDir)) {
|
|
10
|
+
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function Write-Log {
|
|
14
|
+
param([string]$Level, [string]$Message)
|
|
15
|
+
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
|
16
|
+
$line = "[$ts] [$Level] $Message"
|
|
17
|
+
Write-Host $line
|
|
18
|
+
Add-Content -Path $LogFile -Value $line -Encoding UTF8
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
$SessionId = "{0}-{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), $PID
|
|
22
|
+
|
|
23
|
+
Write-Log "INFO" "=== Claude Code 세션 시작 [ID: $SessionId] | 인자: $($ClaudeArgs -join " ") ==="
|
|
24
|
+
|
|
25
|
+
$StartTime = Get-Date
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
if ($ClaudeArgs -and $ClaudeArgs.Count -gt 0) {
|
|
29
|
+
& claude @ClaudeArgs
|
|
30
|
+
} else {
|
|
31
|
+
& claude
|
|
32
|
+
}
|
|
33
|
+
$ExitCode = $LASTEXITCODE
|
|
34
|
+
} catch {
|
|
35
|
+
$ExitCode = -1
|
|
36
|
+
Write-Log "ERROR" "Claude 실행 실패: $_"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
$EndTime = Get-Date
|
|
40
|
+
$Elapsed = [math]::Round(($EndTime - $StartTime).TotalSeconds, 1)
|
|
41
|
+
|
|
42
|
+
$Reason = switch ($ExitCode) {
|
|
43
|
+
0 { "정상 종료" }
|
|
44
|
+
1 { "일반 오류" }
|
|
45
|
+
-1 { "실행 실패 (명령 없음)" }
|
|
46
|
+
default { "비정상 종료" }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if ($ExitCode -eq -1073741510) { $Reason = "사용자 인터럽트 (Ctrl+C)" }
|
|
50
|
+
|
|
51
|
+
Write-Log "INFO" "세션 종료 | exit=$ExitCode | 실행시간=${Elapsed}s | 원인=$Reason"
|
|
52
|
+
Write-Log "INFO" "=== Claude Code 세션 종료 [ID: $SessionId] ==="
|
|
53
|
+
|
|
54
|
+
exit $ExitCode
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// TUI 대시보드 데모 — 5초간 시뮬레이션 후 자동 종료
|
|
3
|
+
import { createLogDashboard } from "../hub/team/tui.mjs";
|
|
4
|
+
|
|
5
|
+
const tui = createLogDashboard({ refreshMs: 200, forceTTY: true });
|
|
6
|
+
|
|
7
|
+
// Phase 1: 워커 시작
|
|
8
|
+
tui.updateWorker("worker-1", {
|
|
9
|
+
cli: "codex", role: "executor", status: "running",
|
|
10
|
+
progress: 0.1, confidence: "low", tokens: "0.3k",
|
|
11
|
+
snapshot: "analyzing codebase...",
|
|
12
|
+
detail: "mcp: serena/activate_project started",
|
|
13
|
+
});
|
|
14
|
+
tui.updateWorker("worker-2", {
|
|
15
|
+
cli: "gemini", role: "writer", status: "running",
|
|
16
|
+
progress: 0.05, confidence: "low",
|
|
17
|
+
snapshot: "loading context...",
|
|
18
|
+
detail: "initializing gemini session",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Phase 2: 진행
|
|
22
|
+
setTimeout(() => {
|
|
23
|
+
tui.updateWorker("worker-1", {
|
|
24
|
+
progress: 0.45, tokens: "1.2k", confidence: "medium",
|
|
25
|
+
snapshot: "mcp: serena/read_file (completed)",
|
|
26
|
+
detail: "mcp: serena/activate_project (completed)\nmcp: serena/read_file started\nmcp: serena/read_file (completed)\nmcp: serena/find_symbol started",
|
|
27
|
+
});
|
|
28
|
+
tui.updateWorker("worker-2", {
|
|
29
|
+
progress: 0.3, tokens: "0.8k",
|
|
30
|
+
snapshot: "writing README section...",
|
|
31
|
+
detail: "initializing gemini session\nreading existing docs\nwriting README section...",
|
|
32
|
+
});
|
|
33
|
+
}, 1500);
|
|
34
|
+
|
|
35
|
+
// Phase 3: worker-2 완료
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
tui.updateWorker("worker-1", {
|
|
38
|
+
progress: 0.78, tokens: "2.1k", confidence: "high",
|
|
39
|
+
snapshot: "applying fix to tui.mjs",
|
|
40
|
+
detail: "mcp: serena/activate_project (completed)\nmcp: serena/read_file (completed)\nmcp: serena/find_symbol (completed)\nmcp: serena/replace_symbol_body started\napplying fix to tui.mjs",
|
|
41
|
+
handoff: { files_changed: ["hub/team/tui.mjs"] },
|
|
42
|
+
});
|
|
43
|
+
tui.updateWorker("worker-2", {
|
|
44
|
+
status: "completed", progress: 1, tokens: "1.5k", confidence: "high",
|
|
45
|
+
handoff: { status: "ok", verdict: "documentation updated", confidence: "high", files_changed: ["README.md"] },
|
|
46
|
+
});
|
|
47
|
+
}, 3000);
|
|
48
|
+
|
|
49
|
+
// Phase 4: 전부 완료
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
tui.updateWorker("worker-1", {
|
|
52
|
+
status: "completed", progress: 1, tokens: "3.4k", confidence: "high",
|
|
53
|
+
handoff: { status: "ok", verdict: "fix applied and verified", confidence: "high", files_changed: ["hub/team/tui.mjs", "tests/unit/tui.test.mjs"] },
|
|
54
|
+
});
|
|
55
|
+
tui.updatePipeline({ phase: "verify" });
|
|
56
|
+
}, 4500);
|
|
57
|
+
|
|
58
|
+
// 종료
|
|
59
|
+
setTimeout(() => { tui.close(); process.exit(0); }, 6000);
|
|
@@ -29,6 +29,7 @@ import { execFileSync } from "node:child_process";
|
|
|
29
29
|
import { tmpdir } from "node:os";
|
|
30
30
|
import { join } from "node:path";
|
|
31
31
|
import { nudge, deny } from "./lib/hook-utils.mjs";
|
|
32
|
+
import { probePsmuxSupport } from "./lib/psmux-info.mjs";
|
|
32
33
|
|
|
33
34
|
const CACHE_FILE = join(tmpdir(), "tfx-psmux-check.json");
|
|
34
35
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5분
|
|
@@ -70,16 +71,12 @@ function isPsmuxInstalled() {
|
|
|
70
71
|
}
|
|
71
72
|
} catch { /* cache miss */ }
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
try {
|
|
76
|
-
execFileSync("psmux", ["-V"], { timeout: 2000, stdio: "ignore" });
|
|
77
|
-
ok = true;
|
|
78
|
-
} catch { /* not installed */ }
|
|
74
|
+
const probe = probePsmuxSupport({ execFileSyncFn: execFileSync });
|
|
75
|
+
const ok = probe.ok;
|
|
79
76
|
|
|
80
77
|
// 캐시 저장
|
|
81
78
|
try {
|
|
82
|
-
writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), ok }));
|
|
79
|
+
writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), ok, version: probe.version, missingCommands: probe.missingCommands }));
|
|
83
80
|
} catch { /* ignore */ }
|
|
84
81
|
|
|
85
82
|
return ok;
|
package/scripts/hub-ensure.mjs
CHANGED
|
@@ -1,120 +1,120 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// SessionStart 훅에서 호출되는 Hub 보장 스크립트.
|
|
3
|
-
// - /status 기반 헬스체크
|
|
4
|
-
// - 비정상 시 Hub를 detached로 기동
|
|
5
|
-
|
|
6
|
-
import { existsSync, readFileSync } from "fs";
|
|
7
|
-
import { join, dirname } from "path";
|
|
8
|
-
import { homedir } from "os";
|
|
9
|
-
import { spawn } from "child_process";
|
|
10
|
-
import { fileURLToPath } from "url";
|
|
11
|
-
|
|
12
|
-
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
13
|
-
const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
14
|
-
const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
|
|
15
|
-
|
|
16
|
-
function formatHostForUrl(host) {
|
|
17
|
-
return host.includes(":") ? `[${host}]` : host;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function buildHubBaseUrl(host, port) {
|
|
21
|
-
return `http://${formatHostForUrl(host)}:${port}`;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function resolveHubTarget() {
|
|
25
|
-
const envPortRaw = Number(process.env.TFX_HUB_PORT || "");
|
|
26
|
-
const envPort = Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : null;
|
|
27
|
-
const target = {
|
|
28
|
-
host: "127.0.0.1",
|
|
29
|
-
port: envPort || 27888,
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
if (existsSync(HUB_PID_FILE)) {
|
|
33
|
-
try {
|
|
34
|
-
const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
35
|
-
if (!envPort) {
|
|
36
|
-
const pidPort = Number(info?.port);
|
|
37
|
-
if (Number.isFinite(pidPort) && pidPort > 0) target.port = pidPort;
|
|
38
|
-
}
|
|
39
|
-
if (typeof info?.host === "string") {
|
|
40
|
-
const host = info.host.trim();
|
|
41
|
-
if (LOOPBACK_HOSTS.has(host)) target.host = host;
|
|
42
|
-
}
|
|
43
|
-
} catch {
|
|
44
|
-
// ignore parse errors and use env/default
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return target;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function isHubHealthy(host, port) {
|
|
52
|
-
try {
|
|
53
|
-
const res = await fetch(`${buildHubBaseUrl(host, port)}/status`, {
|
|
54
|
-
signal: AbortSignal.timeout(1000),
|
|
55
|
-
});
|
|
56
|
-
if (!res.ok) return false;
|
|
57
|
-
const data = await res.json();
|
|
58
|
-
return data?.hub?.state === "healthy";
|
|
59
|
-
} catch {
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function startHubDetached(port) {
|
|
65
|
-
const serverPath = join(PLUGIN_ROOT, "hub", "server.mjs");
|
|
66
|
-
if (!existsSync(serverPath)) return false;
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
const env = { ...process.env, TFX_HUB_PORT: String(port) };
|
|
70
|
-
if (process.platform === "win32") {
|
|
71
|
-
// Windows: cmd.exe /c start /b → 완전 독립 프로세스 트리 생성
|
|
72
|
-
// hook timeout 시 프로세스 트리 킬에서 살아남음
|
|
73
|
-
const child = spawn("cmd.exe", ["/c", "start", "/b", "", process.execPath, serverPath], {
|
|
74
|
-
env,
|
|
75
|
-
stdio: "ignore",
|
|
76
|
-
windowsHide: true,
|
|
77
|
-
});
|
|
78
|
-
child.unref();
|
|
79
|
-
} else {
|
|
80
|
-
const child = spawn(process.execPath, [serverPath], {
|
|
81
|
-
env,
|
|
82
|
-
detached: true,
|
|
83
|
-
stdio: "ignore",
|
|
84
|
-
});
|
|
85
|
-
child.unref();
|
|
86
|
-
}
|
|
87
|
-
return true;
|
|
88
|
-
} catch {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Hub 기동 후 ready 상태까지 대기 (최대 maxWaitMs) */
|
|
94
|
-
async function waitForHubReady(host, port, maxWaitMs = 5000) {
|
|
95
|
-
const interval = 250;
|
|
96
|
-
const deadline = Date.now() + maxWaitMs;
|
|
97
|
-
while (Date.now() < deadline) {
|
|
98
|
-
if (await isHubHealthy(host, port)) return true;
|
|
99
|
-
await new Promise((r) => setTimeout(r, interval));
|
|
100
|
-
}
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const { host, port } = resolveHubTarget();
|
|
105
|
-
if (!(await isHubHealthy(host, port))) {
|
|
106
|
-
const started = startHubDetached(port);
|
|
107
|
-
if (started) {
|
|
108
|
-
const ready = await waitForHubReady(host, port, 3000);
|
|
109
|
-
if (ready) {
|
|
110
|
-
process.stdout.write("hub: ok");
|
|
111
|
-
} else {
|
|
112
|
-
// fire-and-forget: hub이 아직 기동 중일 수 있음 — 에러가 아닌 경고
|
|
113
|
-
process.stdout.write("hub: starting");
|
|
114
|
-
}
|
|
115
|
-
} else {
|
|
116
|
-
process.stderr.write("[hub-ensure] hub 시작 실패");
|
|
117
|
-
}
|
|
118
|
-
} else {
|
|
119
|
-
process.stdout.write("hub: ok");
|
|
120
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SessionStart 훅에서 호출되는 Hub 보장 스크립트.
|
|
3
|
+
// - /status 기반 헬스체크
|
|
4
|
+
// - 비정상 시 Hub를 detached로 기동
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from "fs";
|
|
7
|
+
import { join, dirname } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { spawn } from "child_process";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
|
|
12
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
13
|
+
const PLUGIN_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
14
|
+
const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
|
|
15
|
+
|
|
16
|
+
function formatHostForUrl(host) {
|
|
17
|
+
return host.includes(":") ? `[${host}]` : host;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildHubBaseUrl(host, port) {
|
|
21
|
+
return `http://${formatHostForUrl(host)}:${port}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveHubTarget() {
|
|
25
|
+
const envPortRaw = Number(process.env.TFX_HUB_PORT || "");
|
|
26
|
+
const envPort = Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : null;
|
|
27
|
+
const target = {
|
|
28
|
+
host: "127.0.0.1",
|
|
29
|
+
port: envPort || 27888,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (existsSync(HUB_PID_FILE)) {
|
|
33
|
+
try {
|
|
34
|
+
const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
|
|
35
|
+
if (!envPort) {
|
|
36
|
+
const pidPort = Number(info?.port);
|
|
37
|
+
if (Number.isFinite(pidPort) && pidPort > 0) target.port = pidPort;
|
|
38
|
+
}
|
|
39
|
+
if (typeof info?.host === "string") {
|
|
40
|
+
const host = info.host.trim();
|
|
41
|
+
if (LOOPBACK_HOSTS.has(host)) target.host = host;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore parse errors and use env/default
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return target;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function isHubHealthy(host, port) {
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`${buildHubBaseUrl(host, port)}/status`, {
|
|
54
|
+
signal: AbortSignal.timeout(1000),
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) return false;
|
|
57
|
+
const data = await res.json();
|
|
58
|
+
return data?.hub?.state === "healthy";
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function startHubDetached(port) {
|
|
65
|
+
const serverPath = join(PLUGIN_ROOT, "hub", "server.mjs");
|
|
66
|
+
if (!existsSync(serverPath)) return false;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const env = { ...process.env, TFX_HUB_PORT: String(port) };
|
|
70
|
+
if (process.platform === "win32") {
|
|
71
|
+
// Windows: cmd.exe /c start /b → 완전 독립 프로세스 트리 생성
|
|
72
|
+
// hook timeout 시 프로세스 트리 킬에서 살아남음
|
|
73
|
+
const child = spawn("cmd.exe", ["/c", "start", "/b", "", process.execPath, serverPath], {
|
|
74
|
+
env,
|
|
75
|
+
stdio: "ignore",
|
|
76
|
+
windowsHide: true,
|
|
77
|
+
});
|
|
78
|
+
child.unref();
|
|
79
|
+
} else {
|
|
80
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
81
|
+
env,
|
|
82
|
+
detached: true,
|
|
83
|
+
stdio: "ignore",
|
|
84
|
+
});
|
|
85
|
+
child.unref();
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Hub 기동 후 ready 상태까지 대기 (최대 maxWaitMs) */
|
|
94
|
+
async function waitForHubReady(host, port, maxWaitMs = 5000) {
|
|
95
|
+
const interval = 250;
|
|
96
|
+
const deadline = Date.now() + maxWaitMs;
|
|
97
|
+
while (Date.now() < deadline) {
|
|
98
|
+
if (await isHubHealthy(host, port)) return true;
|
|
99
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { host, port } = resolveHubTarget();
|
|
105
|
+
if (!(await isHubHealthy(host, port))) {
|
|
106
|
+
const started = startHubDetached(port);
|
|
107
|
+
if (started) {
|
|
108
|
+
const ready = await waitForHubReady(host, port, 3000);
|
|
109
|
+
if (ready) {
|
|
110
|
+
process.stdout.write("hub: ok");
|
|
111
|
+
} else {
|
|
112
|
+
// fire-and-forget: hub이 아직 기동 중일 수 있음 — 에러가 아닌 경고
|
|
113
|
+
process.stdout.write("hub: starting");
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
process.stderr.write("[hub-ensure] hub 시작 실패");
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
process.stdout.write("hub: ok");
|
|
120
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export const PSMUX_RECOMMENDED_VERSION = "3.3.1";
|
|
4
|
+
export const PSMUX_REQUIRED_COMMANDS = [
|
|
5
|
+
"new-session",
|
|
6
|
+
"attach-session",
|
|
7
|
+
"kill-session",
|
|
8
|
+
"capture-pane",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export const PSMUX_OPTIONAL_COMMANDS = [
|
|
12
|
+
"detach-client",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const PSMUX_INSTALL_COMMANDS = [
|
|
16
|
+
"winget install marlocarlo.psmux",
|
|
17
|
+
"scoop install psmux",
|
|
18
|
+
"choco install psmux",
|
|
19
|
+
"cargo install psmux",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const PSMUX_UPDATE_COMMANDS = [
|
|
23
|
+
"winget upgrade marlocarlo.psmux",
|
|
24
|
+
"scoop update psmux",
|
|
25
|
+
"choco upgrade psmux",
|
|
26
|
+
"cargo install psmux --force",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function formatPsmuxCommandList(commands = PSMUX_INSTALL_COMMANDS, indent = "") {
|
|
30
|
+
return commands.map((command) => `${indent}${command}`).join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatPsmuxInstallGuidance(indent = "") {
|
|
34
|
+
return formatPsmuxCommandList(PSMUX_INSTALL_COMMANDS, indent);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatPsmuxUpdateGuidance(indent = "") {
|
|
38
|
+
return formatPsmuxCommandList(PSMUX_UPDATE_COMMANDS, indent);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parsePsmuxVersion(output = "") {
|
|
42
|
+
const match = String(output).match(/psmux\s+v?(\d+\.\d+\.\d+)/i);
|
|
43
|
+
return match?.[1] || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function compareSemver(a, b) {
|
|
47
|
+
const left = String(a || "").split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
48
|
+
const right = String(b || "").split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
49
|
+
for (let index = 0; index < 3; index += 1) {
|
|
50
|
+
if (left[index] > right[index]) return 1;
|
|
51
|
+
if (left[index] < right[index]) return -1;
|
|
52
|
+
}
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isRecommendedPsmuxVersion(version) {
|
|
57
|
+
if (!version) return false;
|
|
58
|
+
return compareSemver(version, PSMUX_RECOMMENDED_VERSION) >= 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function probePsmuxSupport(options = {}) {
|
|
62
|
+
const execFileSyncFn = options.execFileSyncFn || execFileSync;
|
|
63
|
+
const bin = options.bin || "psmux";
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const versionOutput = execFileSyncFn(bin, ["-V"], {
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
timeout: 2000,
|
|
69
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
70
|
+
windowsHide: true,
|
|
71
|
+
});
|
|
72
|
+
const version = parsePsmuxVersion(versionOutput);
|
|
73
|
+
|
|
74
|
+
let helpOutput = "";
|
|
75
|
+
try {
|
|
76
|
+
helpOutput = execFileSyncFn(bin, ["--help"], {
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
timeout: 2000,
|
|
79
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
80
|
+
windowsHide: true,
|
|
81
|
+
});
|
|
82
|
+
} catch {
|
|
83
|
+
helpOutput = "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const missingCommands = PSMUX_REQUIRED_COMMANDS.filter(
|
|
87
|
+
(command) => !helpOutput || !helpOutput.includes(command),
|
|
88
|
+
);
|
|
89
|
+
const missingOptionalCommands = PSMUX_OPTIONAL_COMMANDS.filter(
|
|
90
|
+
(command) => !helpOutput || !helpOutput.includes(command),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
ok: missingCommands.length === 0,
|
|
95
|
+
installed: true,
|
|
96
|
+
version,
|
|
97
|
+
recommendedVersion: PSMUX_RECOMMENDED_VERSION,
|
|
98
|
+
recommended: isRecommendedPsmuxVersion(version),
|
|
99
|
+
missingCommands,
|
|
100
|
+
missingOptionalCommands,
|
|
101
|
+
hasHelp: helpOutput.length > 0,
|
|
102
|
+
installHint: formatPsmuxInstallGuidance(" "),
|
|
103
|
+
updateHint: formatPsmuxUpdateGuidance(" "),
|
|
104
|
+
};
|
|
105
|
+
} catch {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
installed: false,
|
|
109
|
+
version: null,
|
|
110
|
+
recommendedVersion: PSMUX_RECOMMENDED_VERSION,
|
|
111
|
+
recommended: false,
|
|
112
|
+
missingCommands: [...PSMUX_REQUIRED_COMMANDS],
|
|
113
|
+
missingOptionalCommands: [...PSMUX_OPTIONAL_COMMANDS],
|
|
114
|
+
hasHelp: false,
|
|
115
|
+
installHint: formatPsmuxInstallGuidance(" "),
|
|
116
|
+
updateHint: formatPsmuxUpdateGuidance(" "),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|