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.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +2 -0
  4. package/README.md +2 -0
  5. package/bin/triflux.mjs +297 -47
  6. package/hooks/hook-registry.json +4 -4
  7. package/hub/fullcycle.mjs +96 -0
  8. package/hub/paths.mjs +30 -28
  9. package/hub/pipeline/index.mjs +318 -318
  10. package/hub/schema.sql +146 -146
  11. package/hub/team/cli/commands/kill.mjs +37 -37
  12. package/hub/team/cli/commands/stop.mjs +31 -31
  13. package/hub/team/cli/commands/task.mjs +30 -30
  14. package/hub/team/cli/services/hub-client.mjs +208 -208
  15. package/hub/team/cli/services/native-control.mjs +118 -118
  16. package/hub/team/cli/services/runtime-mode.mjs +62 -62
  17. package/hub/team/cli/services/state-store.mjs +48 -48
  18. package/hub/team/dashboard.mjs +274 -274
  19. package/hub/team/native.mjs +649 -649
  20. package/hub/team/psmux.mjs +68 -13
  21. package/hub/tools.mjs +554 -554
  22. package/hub/workers/claude-worker.mjs +423 -423
  23. package/hub/workers/codex-mcp.mjs +410 -410
  24. package/hub/workers/gemini-worker.mjs +429 -429
  25. package/hub/workers/interface.mjs +40 -40
  26. package/package.json +1 -1
  27. package/scripts/__tests__/remote-spawn-transfer.test.mjs +1 -1
  28. package/scripts/cache-warmup.mjs +1 -0
  29. package/scripts/claude-logged.ps1 +54 -0
  30. package/scripts/demo-tui.mjs +59 -0
  31. package/scripts/headless-guard.mjs +4 -7
  32. package/scripts/hub-ensure.mjs +120 -120
  33. package/scripts/lib/psmux-info.mjs +119 -0
  34. package/scripts/lib/remote-spawn-transfer.mjs +1 -1
  35. package/scripts/setup.mjs +150 -6
  36. package/scripts/tfx-route-post.mjs +90 -13
  37. package/scripts/token-snapshot.mjs +575 -575
  38. package/skills/.omc/state/agent-replay-8f0e10a9-9693-4410-96f5-a6b07e8ed995.jsonl +1 -0
  39. package/skills/.omc/state/idle-notif-cooldown.json +3 -0
  40. package/skills/.omc/state/last-tool-error.json +7 -0
  41. package/skills/.omc/state/subagent-tracking.json +7 -0
  42. package/skills/tfx-codex-swarm/SKILL.md +40 -5
  43. package/skills/tfx-codex-swarm/mcp-daemon/register-autostart.ps1 +32 -0
  44. package/skills/tfx-doctor/SKILL.md +3 -0
  45. package/skills/tfx-fullcycle/SKILL.md +79 -4
  46. package/skills/tfx-hub/SKILL.md +3 -1
  47. package/skills/tfx-psmux-rules/SKILL.md +53 -31
  48. package/skills/tfx-remote-spawn/references/hosts.json +16 -16
  49. package/skills/tfx-setup/SKILL.md +9 -0
  50. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "9.7.13",
3
+ "version": "9.8.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -114,4 +114,4 @@ describe("remote-spawn transfer plan", () => {
114
114
  assert.deepEqual(plan.transfers, []);
115
115
  assert.equal(plan.stagedHandoffPath, null);
116
116
  });
117
- });
117
+ });
@@ -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
- // psmux -V 실행
74
- let ok = false;
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;
@@ -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
+ }
@@ -193,4 +193,4 @@ export function buildRemoteTransferPlan(options = {}) {
193
193
  stagedHandoffPath,
194
194
  transfers,
195
195
  };
196
- }
196
+ }