triflux 9.2.4 → 9.4.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 (38) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/triflux.mjs +48 -217
  3. package/hub/pipe.mjs +1 -8
  4. package/hub/team/psmux.mjs +21 -1
  5. package/hub/workers/claude-worker.mjs +1 -24
  6. package/hub/workers/codex-mcp.mjs +0 -4
  7. package/hub/workers/gemini-worker.mjs +108 -28
  8. package/hub/workers/interface.mjs +0 -1
  9. package/hub/workers/worker-utils.mjs +26 -0
  10. package/package.json +1 -1
  11. package/scripts/__tests__/remote-spawn.test.mjs +17 -3
  12. package/scripts/cross-review-gate.mjs +11 -65
  13. package/scripts/cross-review-tracker.mjs +10 -51
  14. package/scripts/headless-guard.mjs +5 -17
  15. package/scripts/lib/cross-review-utils.mjs +51 -0
  16. package/scripts/lib/hook-utils.mjs +14 -0
  17. package/scripts/lib/mcp-filter.mjs +10 -1
  18. package/scripts/psmux-safety-guard.mjs +64 -0
  19. package/scripts/remote-spawn.mjs +77 -23
  20. package/scripts/session-spawn-helper.mjs +2 -1
  21. package/scripts/setup.mjs +36 -6
  22. package/scripts/tfx-route.sh +93 -10
  23. package/skills/tfx-auto/SKILL.md +4 -1
  24. package/skills/tfx-auto-codex/SKILL.md +3 -1
  25. package/skills/tfx-autopilot/SKILL.md +1 -1
  26. package/skills/tfx-autoresearch/SKILL.md +2 -0
  27. package/skills/tfx-codex/SKILL.md +3 -1
  28. package/skills/tfx-consensus/SKILL.md +5 -3
  29. package/skills/tfx-fullcycle/SKILL.md +1 -1
  30. package/skills/tfx-gemini/SKILL.md +3 -1
  31. package/skills/tfx-hooks/SKILL.md +216 -0
  32. package/skills/tfx-multi/SKILL.md +4 -1
  33. package/skills/tfx-plan/SKILL.md +1 -1
  34. package/skills/tfx-psmux-rules/SKILL.md +100 -13
  35. package/skills/tfx-ralph/SKILL.md +11 -66
  36. package/skills/tfx-research/SKILL.md +1 -1
  37. package/skills/tfx-review/SKILL.md +1 -1
  38. package/skills/tfx-setup/SKILL.md +17 -1
@@ -1,7 +1,9 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
 
4
- import { startSpawnExitWatcher, watchSpawnSessionExit } from "../remote-spawn.mjs";
4
+ import { __remoteSpawnTest } from "../remote-spawn.mjs";
5
+
6
+ const { startSpawnExitWatcher, watchSpawnSessionExit } = __remoteSpawnTest;
5
7
 
6
8
  describe("remote-spawn watcher", () => {
7
9
  it("lead pane dead 상태가 grace 기간 유지되면 세션을 정리한다", async () => {
@@ -51,7 +53,7 @@ describe("remote-spawn watcher", () => {
51
53
  assert.equal(killCount, 0);
52
54
  });
53
55
 
54
- it("detached watcher를 --watch-exit 인자로 실행한다", () => {
56
+ it("detached watcher를 현재 cleanup watcher 인자로 실행한다", () => {
55
57
  const calls = [];
56
58
  const started = startSpawnExitWatcher("tfx-spawn-detached", {
57
59
  force: true,
@@ -70,7 +72,19 @@ describe("remote-spawn watcher", () => {
70
72
  assert.equal(started, true);
71
73
  assert.equal(calls.length, 1);
72
74
  assert.equal(calls[0].file, "node-test");
73
- assert.deepEqual(calls[0].args, ["C:/tmp/remote-spawn.mjs", "--watch-exit", "tfx-spawn-detached"]);
75
+ assert.deepEqual(calls[0].args, [
76
+ "C:/tmp/remote-spawn.mjs",
77
+ "--watch-cleanup",
78
+ "tfx-spawn-detached",
79
+ "--pane",
80
+ "tfx-spawn-detached:0.0",
81
+ "--poll-ms",
82
+ "1000",
83
+ "--grace-ms",
84
+ "1500",
85
+ "--max-ms",
86
+ "3600000",
87
+ ]);
74
88
  assert.equal(calls[0].options.detached, true);
75
89
  assert.equal(calls[0].options.stdio, "ignore");
76
90
  assert.equal(calls[0].unrefCalled, true);
@@ -2,56 +2,17 @@
2
2
 
3
3
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
4
4
  import { join } from "node:path";
5
-
6
- const SESSION_TTL_SEC = 30 * 60;
7
- const STATE_REL_PATH = join(".omc", "state", "cross-review.json");
8
-
9
- function readStdin() {
10
- return new Promise((resolve) => {
11
- let raw = "";
12
- process.stdin.setEncoding("utf8");
13
- process.stdin.on("data", (chunk) => {
14
- raw += chunk;
15
- });
16
- process.stdin.on("end", () => resolve(raw));
17
- process.stdin.on("error", () => resolve(""));
18
- });
19
- }
20
-
21
- function parseJson(raw) {
22
- try {
23
- return JSON.parse(raw);
24
- } catch {
25
- return null;
26
- }
27
- }
28
-
29
- function nowSec() {
30
- return Math.floor(Date.now() / 1000);
31
- }
32
-
33
- function resolveBaseDir(payload) {
34
- if (typeof payload?.cwd === "string" && payload.cwd.trim()) return payload.cwd;
35
- if (typeof payload?.directory === "string" && payload.directory.trim()) return payload.directory;
36
- return process.cwd();
37
- }
38
-
39
- function expectedReviewer(author) {
40
- if (author === "claude") return "codex";
41
- if (author === "codex") return "claude";
42
- if (author === "gemini") return "claude";
43
- return "";
44
- }
45
-
46
- function shouldTrackPath(filePath) {
47
- if (typeof filePath !== "string" || !filePath.trim()) return false;
48
-
49
- const lower = filePath.toLowerCase();
50
- if (lower.startsWith(".omc/") || lower.startsWith(".claude/")) return false;
51
- if (lower === "package-lock.json" || lower.endsWith("/package-lock.json")) return false;
52
- if (/\.(md|lock|yml|yaml)$/i.test(lower)) return false;
53
- return true;
54
- }
5
+ import { nudge, deny } from "./lib/hook-utils.mjs";
6
+ import {
7
+ readStdin,
8
+ parseJson,
9
+ nowSec,
10
+ resolveBaseDir,
11
+ shouldTrackPath,
12
+ expectedReviewer,
13
+ SESSION_TTL_SEC,
14
+ STATE_REL_PATH,
15
+ } from "./lib/cross-review-utils.mjs";
55
16
 
56
17
  function loadState(statePath) {
57
18
  if (!existsSync(statePath)) return null;
@@ -77,21 +38,6 @@ function isGitCommitCommand(command) {
77
38
  return /\bgit\s+commit\b/i.test(command);
78
39
  }
79
40
 
80
- function nudge(message) {
81
- process.stdout.write(JSON.stringify({
82
- hookSpecificOutput: {
83
- hookEventName: "PreToolUse",
84
- additionalContext: message,
85
- },
86
- }));
87
- process.exit(0);
88
- }
89
-
90
- function deny(message) {
91
- process.stderr.write(message);
92
- process.exit(2);
93
- }
94
-
95
41
  function summarizePending(entries) {
96
42
  return entries
97
43
  .map((item) => {
@@ -2,40 +2,16 @@
2
2
 
3
3
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { dirname, isAbsolute, join, relative } from "node:path";
5
-
6
- const SESSION_TTL_SEC = 30 * 60;
7
- const STATE_REL_PATH = join(".omc", "state", "cross-review.json");
8
- const EXCLUDED_FILE_PATTERN = /\.(md|lock|yml|yaml)$/i;
9
-
10
- function nowSec() {
11
- return Math.floor(Date.now() / 1000);
12
- }
13
-
14
- function readStdin() {
15
- return new Promise((resolve) => {
16
- let raw = "";
17
- process.stdin.setEncoding("utf8");
18
- process.stdin.on("data", (chunk) => {
19
- raw += chunk;
20
- });
21
- process.stdin.on("end", () => resolve(raw));
22
- process.stdin.on("error", () => resolve(""));
23
- });
24
- }
25
-
26
- function parseJson(raw) {
27
- try {
28
- return JSON.parse(raw);
29
- } catch {
30
- return null;
31
- }
32
- }
33
-
34
- function resolveBaseDir(payload) {
35
- if (typeof payload?.cwd === "string" && payload.cwd.trim()) return payload.cwd;
36
- if (typeof payload?.directory === "string" && payload.directory.trim()) return payload.directory;
37
- return process.cwd();
38
- }
5
+ import {
6
+ readStdin,
7
+ parseJson,
8
+ nowSec,
9
+ resolveBaseDir,
10
+ shouldTrackPath,
11
+ expectedReviewer,
12
+ SESSION_TTL_SEC,
13
+ STATE_REL_PATH,
14
+ } from "./lib/cross-review-utils.mjs";
39
15
 
40
16
  function resolveStatePath(baseDir) {
41
17
  return join(baseDir, STATE_REL_PATH);
@@ -91,16 +67,6 @@ function normalizePath(filePath, baseDir) {
91
67
  return normalized.replace(/\\/g, "/").replace(/^\.\//, "");
92
68
  }
93
69
 
94
- function shouldTrackPath(filePath) {
95
- if (!filePath) return false;
96
- const lower = filePath.toLowerCase();
97
-
98
- if (lower.startsWith(".omc/") || lower.startsWith(".claude/")) return false;
99
- if (lower === "package-lock.json" || lower.endsWith("/package-lock.json")) return false;
100
- if (EXCLUDED_FILE_PATTERN.test(lower)) return false;
101
- return true;
102
- }
103
-
104
70
  function extractFilePath(toolInput) {
105
71
  if (!toolInput || typeof toolInput !== "object") return "";
106
72
  const candidate = toolInput.file_path ?? toolInput.path ?? toolInput.filePath ?? "";
@@ -185,13 +151,6 @@ function detectAuthor(payload) {
185
151
  return "claude";
186
152
  }
187
153
 
188
- function expectedReviewer(author) {
189
- if (author === "claude") return "codex";
190
- if (author === "codex") return "claude";
191
- if (author === "gemini") return "claude";
192
- return "";
193
- }
194
-
195
154
  function applyReviewer(state, reviewer, ts) {
196
155
  for (const [filePath, meta] of Object.entries(state.files)) {
197
156
  if (!meta || typeof meta !== "object") continue;
@@ -28,6 +28,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
28
28
  import { execFileSync } from "node:child_process";
29
29
  import { tmpdir } from "node:os";
30
30
  import { join } from "node:path";
31
+ import { nudge, deny } from "./lib/hook-utils.mjs";
31
32
 
32
33
  const CACHE_FILE = join(tmpdir(), "tfx-psmux-check.json");
33
34
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5분
@@ -58,22 +59,14 @@ function writeMultiState(state) {
58
59
  } catch { /* ignore */ }
59
60
  }
60
61
 
61
- function nudge(message) {
62
- process.stdout.write(JSON.stringify({
63
- hookSpecificOutput: {
64
- hookEventName: "PreToolUse",
65
- additionalContext: message,
66
- },
67
- }));
68
- process.exit(0);
69
- }
70
-
71
62
  function isPsmuxInstalled() {
72
- // 캐시 확인
63
+ // 캐시 확인 (미래 타임스탬프 오염 방어)
73
64
  try {
74
65
  if (existsSync(CACHE_FILE)) {
75
66
  const cache = JSON.parse(readFileSync(CACHE_FILE, "utf8"));
76
- if (Date.now() - cache.ts < CACHE_TTL_MS) return cache.ok;
67
+ const age = Date.now() - cache.ts;
68
+ if (age >= 0 && age < CACHE_TTL_MS) return cache.ok;
69
+ // age < 0 → 미래 ts (오염) → 캐시 무시하고 재검사
77
70
  }
78
71
  } catch { /* cache miss */ }
79
72
 
@@ -147,11 +140,6 @@ function autoRoute(updatedCommand, reason) {
147
140
  process.exit(0);
148
141
  }
149
142
 
150
- function deny(reason) {
151
- process.stderr.write(reason);
152
- process.exit(2);
153
- }
154
-
155
143
  const HEADLESS_FALLBACK_COMMAND =
156
144
  'Bash("tfx multi --teammate-mode headless --assign \'codex:prompt:role\' ...")';
157
145
  const DIRECT_CLI_BYPASS_HINT =
@@ -0,0 +1,51 @@
1
+ import { join } from "node:path";
2
+
3
+ export const SESSION_TTL_SEC = 30 * 60;
4
+ export const STATE_REL_PATH = join(".omc", "state", "cross-review.json");
5
+
6
+ export function readStdin() {
7
+ return new Promise((resolve) => {
8
+ let raw = "";
9
+ process.stdin.setEncoding("utf8");
10
+ process.stdin.on("data", (chunk) => {
11
+ raw += chunk;
12
+ });
13
+ process.stdin.on("end", () => resolve(raw));
14
+ process.stdin.on("error", () => resolve(""));
15
+ });
16
+ }
17
+
18
+ export function parseJson(raw) {
19
+ try {
20
+ return JSON.parse(raw);
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ export function nowSec() {
27
+ return Math.floor(Date.now() / 1000);
28
+ }
29
+
30
+ export function resolveBaseDir(payload) {
31
+ if (typeof payload?.cwd === "string" && payload.cwd.trim()) return payload.cwd;
32
+ if (typeof payload?.directory === "string" && payload.directory.trim()) return payload.directory;
33
+ return process.cwd();
34
+ }
35
+
36
+ export function shouldTrackPath(filePath) {
37
+ if (typeof filePath !== "string" || !filePath.trim()) return false;
38
+
39
+ const lower = filePath.toLowerCase();
40
+ if (lower.startsWith(".omc/") || lower.startsWith(".claude/")) return false;
41
+ if (lower === "package-lock.json" || lower.endsWith("/package-lock.json")) return false;
42
+ if (/\.(md|lock|yml|yaml)$/i.test(lower)) return false;
43
+ return true;
44
+ }
45
+
46
+ export function expectedReviewer(author) {
47
+ if (author === "claude") return "codex";
48
+ if (author === "codex") return "claude";
49
+ if (author === "gemini") return "claude";
50
+ return "";
51
+ }
@@ -0,0 +1,14 @@
1
+ export function nudge(message) {
2
+ process.stdout.write(JSON.stringify({
3
+ hookSpecificOutput: {
4
+ hookEventName: "PreToolUse",
5
+ additionalContext: message,
6
+ },
7
+ }));
8
+ process.exit(0);
9
+ }
10
+
11
+ export function deny(reason) {
12
+ process.stderr.write(reason);
13
+ process.exit(2);
14
+ }
@@ -369,6 +369,13 @@ function selectContextualServers(baseServers, profile, options = {}) {
369
369
  const selected = new Set(
370
370
  (profile.alwaysOnServers || []).filter((server) => baseServers.includes(server)),
371
371
  );
372
+ const wantsBrowserObservation = (
373
+ baseServers.includes('playwright')
374
+ && /(?:browser|screenshot|layout|responsive|visual|screen|page|ui|ux|regression|캡처|스크린샷|레이아웃|반응형|화면|브라우저)/iu.test(taskText)
375
+ );
376
+ if (wantsBrowserObservation) {
377
+ selected.add('playwright');
378
+ }
372
379
  const requestedSearchTool = typeof options.searchTool === 'string' ? options.searchTool : '';
373
380
 
374
381
  const rankedServers = rankServers(
@@ -477,7 +484,9 @@ export function resolveAllowedServers(options = {}) {
477
484
  const baseServers = availableServers.length
478
485
  ? profile.allowedServers.filter((server) => availableServers.includes(server))
479
486
  : [...profile.allowedServers];
480
- const manifestFiltered = applyManifestFilter(baseServers);
487
+ const manifestFiltered = availableServers.length
488
+ ? baseServers
489
+ : applyManifestFilter(baseServers);
481
490
  return selectContextualServers(manifestFiltered, profile, { ...options, inventory, inventoryIndex });
482
491
  }
483
492
 
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * psmux-safety-guard.mjs — PreToolUse 훅
4
+ *
5
+ * psmux kill-session 직접 호출을 차단하고,
6
+ * psmux/wt 명령 사용 시 안전 규칙을 강제 주입한다.
7
+ *
8
+ * 동작:
9
+ * - kill-session 직접 호출 → deny (exit 2)
10
+ * - psmux/wt 명령 감지 → additionalContext에 안전 규칙 주입
11
+ * - 그 외 → 통과 (exit 0)
12
+ */
13
+
14
+ import { nudge, deny } from "./lib/hook-utils.mjs";
15
+
16
+ const KILL_PATTERN = /\bpsmux\s+kill-session\b/;
17
+ const GRACEFUL_PATTERN = /send-keys.*exit.*Enter[\s\S]*sleep\s+\d/;
18
+ const PSMUX_OR_WT = /\b(psmux|wt\.exe|wt\s)/;
19
+
20
+ const SAFETY_RULES = `[psmux-safety] WT 프리징 방지 필수 규칙:
21
+ 1) exit → sleep 2 → kill 순서 필수. 바로 kill 절대 금지.
22
+ 2) psmux send-keys는 PowerShell 구문만 (bash 문법 직접 전달 금지).
23
+ 3) 경로는 Windows 형식 (C:\\...). /c/... 금지.
24
+ 4) wt.exe는 sp(split-pane)만 사용. nt(new-tab) 금지.
25
+ 5) -p triflux 프로파일 필수.`;
26
+
27
+ async function main() {
28
+ let raw = "";
29
+ for await (const chunk of process.stdin) raw += chunk;
30
+ if (!raw.trim()) process.exit(0);
31
+
32
+ let input;
33
+ try { input = JSON.parse(raw); } catch { process.exit(0); }
34
+
35
+ if (input.tool_name !== "Bash") process.exit(0);
36
+
37
+ const cmd = input.tool_input?.command || "";
38
+
39
+ // kill-session 직접 호출 감지
40
+ if (KILL_PATTERN.test(cmd)) {
41
+ // 같은 명령 블록 안에 graceful exit 패턴이 있으면 허용
42
+ if (GRACEFUL_PATTERN.test(cmd)) {
43
+ nudge(SAFETY_RULES);
44
+ }
45
+ deny(
46
+ "[psmux-safety] psmux kill-session 직접 호출 차단.\n" +
47
+ "WT 프리징을 방지하려면 반드시 exit → sleep 2 → kill 순서를 따르세요.\n\n" +
48
+ "올바른 패턴:\n" +
49
+ " psmux send-keys -t SESSION \"exit\" Enter\n" +
50
+ " sleep 2\n" +
51
+ " psmux kill-session -t SESSION\n\n" +
52
+ "이 3줄을 하나의 명령 블록으로 실행하세요."
53
+ );
54
+ }
55
+
56
+ // psmux/wt 명령 감지 → 안전 규칙 주입
57
+ if (PSMUX_OR_WT.test(cmd)) {
58
+ nudge(SAFETY_RULES);
59
+ }
60
+
61
+ process.exit(0);
62
+ }
63
+
64
+ main().catch(() => process.exit(0));
@@ -915,48 +915,100 @@ function isPrimaryPaneDead(paneId) {
915
915
  }
916
916
  }
917
917
 
918
- async function runSpawnCleanupWatcher(sessionName, paneId, timingOptions = {}) {
919
- const timings = resolveCleanupWatcherTimingOptions(timingOptions);
920
- const startedAt = Date.now();
918
+ async function watchSpawnSessionExit(sessionName, options = {}) {
919
+ const paneId = options.paneId || `${sessionName}:0.0`;
920
+ const pollMs = parsePositiveInt(options.pollMs, DEFAULT_CLEANUP_WATCH_POLL_MS);
921
+ const graceMs = parsePositiveInt(options.graceMs, DEFAULT_CLEANUP_WATCH_GRACE_MS);
922
+ const maxWaitMs = parsePositiveInt(options.maxWaitMs, DEFAULT_CLEANUP_WATCH_MAX_MS);
923
+ const sessionExists = typeof options.sessionExists === "function"
924
+ ? options.sessionExists
925
+ : psmuxSessionExists;
926
+ const getPaneStatus = typeof options.getPaneStatus === "function"
927
+ ? options.getPaneStatus
928
+ : (targetPaneId) => ({ isDead: isPrimaryPaneDead(targetPaneId), exitCode: null });
929
+ const killSession = typeof options.killSession === "function"
930
+ ? options.killSession
931
+ : killPsmuxSession;
932
+ const now = typeof options.now === "function" ? options.now : Date.now;
933
+ const sleep = typeof options.sleep === "function" ? options.sleep : sleepMsAsync;
934
+ const startedAt = now();
921
935
  let consecutiveErrors = 0;
922
936
 
923
- while (Date.now() - startedAt <= timings.maxMs) {
924
- if (!psmuxSessionExists(sessionName)) {
925
- return;
937
+ while (now() - startedAt <= maxWaitMs) {
938
+ if (!sessionExists(sessionName)) {
939
+ return { cleaned: false, reason: "session-missing" };
926
940
  }
927
941
 
928
- const dead = isPrimaryPaneDead(paneId);
929
- if (dead === null) {
942
+ const paneStatus = getPaneStatus(paneId) || {};
943
+ if (paneStatus.isDead == null) {
930
944
  consecutiveErrors += 1;
931
- if (consecutiveErrors >= 10) return; // psmux 반복 실패 시 조기 종료
945
+ if (consecutiveErrors >= 10) {
946
+ return { cleaned: false, reason: "probe-failed" };
947
+ }
932
948
  } else {
933
949
  consecutiveErrors = 0;
934
950
  }
935
- if (dead === true) {
936
- await sleepMsAsync(timings.graceMs);
937
951
 
938
- if (!psmuxSessionExists(sessionName)) {
939
- return;
952
+ if (paneStatus.isDead === true) {
953
+ await sleep(graceMs);
954
+
955
+ if (!sessionExists(sessionName)) {
956
+ return { cleaned: false, reason: "session-missing" };
940
957
  }
941
958
 
942
- if (isPrimaryPaneDead(paneId) === true) {
943
- try { killPsmuxSession(sessionName); } catch {}
944
- return;
959
+ const afterGrace = getPaneStatus(paneId) || {};
960
+ if (afterGrace.isDead === true) {
961
+ killSession(sessionName);
962
+ return {
963
+ cleaned: true,
964
+ reason: "pane-dead",
965
+ exitCode: afterGrace.exitCode ?? paneStatus.exitCode ?? null,
966
+ };
945
967
  }
946
968
  }
947
969
 
948
- await sleepMsAsync(timings.pollMs);
970
+ await sleep(pollMs);
971
+ }
972
+
973
+ return { cleaned: false, reason: "timeout" };
974
+ }
975
+
976
+ async function runSpawnCleanupWatcher(sessionName, paneId, timingOptions = {}) {
977
+ await watchSpawnSessionExit(sessionName, {
978
+ paneId,
979
+ pollMs: timingOptions.pollMs,
980
+ graceMs: timingOptions.graceMs,
981
+ maxWaitMs: timingOptions.maxMs,
982
+ });
983
+ }
984
+
985
+ function startSpawnExitWatcher(sessionName, options = {}) {
986
+ if (!options.force && !shouldUsePsmux()) {
987
+ return false;
988
+ }
989
+
990
+ const paneId = options.paneId || `${sessionName}:0.0`;
991
+ const args = buildSpawnCleanupWatcherArgs(sessionName, paneId, {
992
+ graceMs: options.graceMs,
993
+ maxMs: options.maxMs,
994
+ pollMs: options.pollMs,
995
+ });
996
+ args[0] = options.scriptPath || args[0];
997
+ const spawnFn = typeof options.spawnFn === "function" ? options.spawnFn : spawn;
998
+ const child = spawnFn(options.execPath || process.execPath, args, {
999
+ detached: true,
1000
+ stdio: "ignore",
1001
+ windowsHide: true,
1002
+ });
1003
+ if (child && typeof child.unref === "function") {
1004
+ child.unref();
949
1005
  }
1006
+ return true;
950
1007
  }
951
1008
 
952
1009
  function startSpawnSessionCleanupWatcher(sessionName, paneId, timingOptions = {}) {
953
- const args = buildSpawnCleanupWatcherArgs(sessionName, paneId, timingOptions);
954
1010
  try {
955
- spawn(process.execPath, args, {
956
- detached: true,
957
- stdio: "ignore",
958
- windowsHide: true,
959
- }).unref();
1011
+ startSpawnExitWatcher(sessionName, { ...timingOptions, force: true, paneId });
960
1012
  } catch {
961
1013
  // watcher 시작 실패는 spawn 자체 실패로 보지 않는다.
962
1014
  }
@@ -1230,6 +1282,8 @@ export const __remoteSpawnTest = {
1230
1282
  buildPromptContext,
1231
1283
  parseArgs,
1232
1284
  rewritePromptPaths,
1285
+ startSpawnExitWatcher,
1233
1286
  stageRemotePromptFiles,
1234
1287
  validateTransferCandidate,
1288
+ watchSpawnSessionExit,
1235
1289
  };
@@ -69,10 +69,11 @@ export function createIsolatedSession(options = {}) {
69
69
  export function attachWithWindowsTerminal(sessionName, options = {}) {
70
70
  const profile = options.profile || DEFAULT_ATTACH_PROFILE;
71
71
  const title = options.title || sessionName;
72
+ const spawnFn = options.spawnFn || spawn;
72
73
 
73
74
  // sp (split-pane), not new-tab
74
75
  const wtArgs = ["sp", "-p", profile, "--title", title, "--", "psmux", "attach-session", "-t", sessionName];
75
- const child = spawn("wt.exe", wtArgs, { detached: true, stdio: "ignore", windowsHide: false });
76
+ const child = spawnFn("wt.exe", wtArgs, { detached: true, stdio: "ignore", windowsHide: false });
76
77
  child.unref();
77
78
  return wtArgs;
78
79
  }
package/scripts/setup.mjs CHANGED
@@ -141,6 +141,11 @@ const SYNC_MAP = [
141
141
  dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "claude-worker.mjs"),
142
142
  label: "hub/workers/claude-worker.mjs",
143
143
  },
144
+ {
145
+ src: join(PLUGIN_ROOT, "hub", "workers", "worker-utils.mjs"),
146
+ dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "worker-utils.mjs"),
147
+ label: "hub/workers/worker-utils.mjs",
148
+ },
144
149
  {
145
150
  src: join(PLUGIN_ROOT, "hub", "workers", "factory.mjs"),
146
151
  dst: join(CLAUDE_DIR, "scripts", "hub", "workers", "factory.mjs"),
@@ -216,6 +221,26 @@ const SYNC_MAP = [
216
221
  dst: join(CLAUDE_DIR, "scripts", "lib", "mcp-server-catalog.mjs"),
217
222
  label: "lib/mcp-server-catalog.mjs",
218
223
  },
224
+ {
225
+ src: join(PLUGIN_ROOT, "scripts", "lib", "mcp-manifest.mjs"),
226
+ dst: join(CLAUDE_DIR, "scripts", "lib", "mcp-manifest.mjs"),
227
+ label: "lib/mcp-manifest.mjs",
228
+ },
229
+ {
230
+ src: join(PLUGIN_ROOT, "scripts", "lib", "hook-utils.mjs"),
231
+ dst: join(CLAUDE_DIR, "scripts", "lib", "hook-utils.mjs"),
232
+ label: "lib/hook-utils.mjs",
233
+ },
234
+ {
235
+ src: join(PLUGIN_ROOT, "scripts", "psmux-safety-guard.mjs"),
236
+ dst: join(CLAUDE_DIR, "scripts", "psmux-safety-guard.mjs"),
237
+ label: "psmux-safety-guard.mjs",
238
+ },
239
+ {
240
+ src: join(PLUGIN_ROOT, "scripts", "lib", "cross-review-utils.mjs"),
241
+ dst: join(CLAUDE_DIR, "scripts", "lib", "cross-review-utils.mjs"),
242
+ label: "lib/cross-review-utils.mjs",
243
+ },
219
244
  {
220
245
  src: join(PLUGIN_ROOT, "scripts", "lib", "keyword-rules.mjs"),
221
246
  dst: join(CLAUDE_DIR, "scripts", "lib", "keyword-rules.mjs"),
@@ -331,13 +356,18 @@ function ensureCodexProfiles() {
331
356
  writeFileSync(CODEX_CONFIG_PATH, updated, "utf8");
332
357
  }
333
358
 
334
- return changed;
335
- } catch {
336
- return 0;
359
+ return { ok: true, changed };
360
+ } catch (error) {
361
+ return { ok: false, changed: 0, message: error.message };
337
362
  }
338
363
  }
339
364
 
340
- export { replaceProfileSection, hasProfileSection, detectDevMode, SYNC_MAP, BREADCRUMB_PATH, PLUGIN_ROOT, CLAUDE_DIR, SKILL_ALIASES };
365
+ export {
366
+ replaceProfileSection, hasProfileSection, escapeRegExp, detectDevMode,
367
+ SYNC_MAP, BREADCRUMB_PATH, PLUGIN_ROOT, CLAUDE_DIR,
368
+ SKILL_ALIASES, REQUIRED_CODEX_PROFILES,
369
+ buildAliasedSkillContent, syncAliasedSkillDir, getVersion, ensureCodexProfiles,
370
+ };
341
371
 
342
372
  async function main() {
343
373
  const isSync = process.argv.includes("--sync");
@@ -814,8 +844,8 @@ if (process.platform === "win32") {
814
844
 
815
845
  // ── Codex 프로필 자동 보정 ──
816
846
 
817
- const codexProfilesAdded = ensureCodexProfiles();
818
- if (codexProfilesAdded > 0) {
847
+ const codexProfileResult = ensureCodexProfiles();
848
+ if (codexProfileResult.changed > 0) {
819
849
  synced++;
820
850
  }
821
851