triflux 9.3.0 → 9.5.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.
@@ -5,9 +5,46 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir, homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
 
8
- const PSMUX_BIN = process.env.PSMUX_BIN || "psmux";
8
+ const PSMUX_BIN = (() => {
9
+ if (process.env.PSMUX_BIN) return process.env.PSMUX_BIN;
10
+ // PATH에서 찾기
11
+ try {
12
+ childProcess.execFileSync("psmux", ["-V"], { stdio: "ignore", timeout: 2000, windowsHide: true });
13
+ return "psmux";
14
+ } catch { /* not in PATH */ }
15
+ // Windows 기본 설치 경로 탐색
16
+ if (process.platform === "win32") {
17
+ const candidates = [
18
+ join(process.env.LOCALAPPDATA || "", "psmux", "psmux.exe"),
19
+ join(process.env.APPDATA || "", "npm", "psmux.cmd"),
20
+ join(homedir(), "AppData", "Local", "psmux", "psmux.exe"),
21
+ join(homedir(), "scoop", "shims", "psmux.exe"),
22
+ ];
23
+ for (const p of candidates) {
24
+ if (existsSync(p)) return p;
25
+ }
26
+ }
27
+ return "psmux"; // 최종 fallback — 원래대로
28
+ })();
9
29
  const GIT_BASH = process.env.GIT_BASH_PATH || "C:\\Program Files\\Git\\bin\\bash.exe";
10
30
  const IS_WINDOWS = process.platform === "win32";
31
+
32
+ /** Windows psmux 세션의 기본 셸을 PowerShell로 강제한다 (pwsh7 우선, ps5 fallback). */
33
+ const PWSH_BIN = (() => {
34
+ if (!IS_WINDOWS) return "";
35
+ if (process.env.PSMUX_SHELL) return process.env.PSMUX_SHELL;
36
+ // pwsh 7 우선
37
+ try {
38
+ childProcess.execFileSync("pwsh", ["-NoLogo", "-NoProfile", "-Command", "exit 0"], { stdio: "ignore", timeout: 3000, windowsHide: true });
39
+ return "pwsh";
40
+ } catch { /* not found */ }
41
+ // powershell 5 fallback
42
+ try {
43
+ childProcess.execFileSync("powershell.exe", ["-NoLogo", "-NoProfile", "-Command", "exit 0"], { stdio: "ignore", timeout: 3000, windowsHide: true });
44
+ return "powershell.exe";
45
+ } catch { /* not found */ }
46
+ return ""; // 둘 다 없으면 psmux 기본 셸 사용
47
+ })();
11
48
  const PSMUX_TIMEOUT_MS = 10000;
12
49
  const COMPLETION_PREFIX = "__TRIFLUX_DONE__:";
13
50
  const CAPTURE_ROOT = process.env.PSMUX_CAPTURE_ROOT || join(tmpdir(), "psmux-steering");
@@ -125,7 +162,15 @@ function randomToken(prefix) {
125
162
 
126
163
  function ensurePsmuxInstalled() {
127
164
  if (!hasPsmux()) {
128
- throw new Error("psmux가 설치되어 있지 않습니다.");
165
+ throw new Error(
166
+ "psmux가 설치되어 있지 않습니다.\n\n" +
167
+ "psmux는 Codex/Gemini CLI를 병렬 세션으로 실행하는 터미널 멀티플렉서입니다.\n" +
168
+ "설치 방법 (택 1):\n" +
169
+ " winget install marlocarlo.psmux\n" +
170
+ " scoop install psmux\n" +
171
+ " npm install -g psmux\n\n" +
172
+ "설치 후 터미널을 재시작하세요."
173
+ );
129
174
  }
130
175
  }
131
176
 
@@ -389,7 +434,7 @@ export function createPsmuxSession(sessionName, opts = {}) {
389
434
  const limitedPaneCount = layout === "2x2" ? Math.min(paneCount, 4) : paneCount;
390
435
  const sessionTarget = `${sessionName}:0`;
391
436
 
392
- const leadPane = psmuxExec([
437
+ const newSessionArgs = [
393
438
  "new-session",
394
439
  "-d",
395
440
  "-P",
@@ -401,7 +446,15 @@ export function createPsmuxSession(sessionName, opts = {}) {
401
446
  "220",
402
447
  "-y",
403
448
  "55",
404
- ]);
449
+ ];
450
+ // Windows: psmux 기본 셸이 cmd.exe일 수 있으므로 PowerShell 강제
451
+ if (PWSH_BIN) newSessionArgs.push(PWSH_BIN, "-NoLogo", "-NoProfile");
452
+ const leadPane = psmuxExec(newSessionArgs);
453
+
454
+ // split-window로 생성되는 pane도 동일 셸 사용
455
+ if (PWSH_BIN) {
456
+ try { psmuxExec(["set-option", "-t", sessionName, "default-command", `${PWSH_BIN} -NoLogo -NoProfile`]); } catch { /* 미지원 시 무시 */ }
457
+ }
405
458
 
406
459
  if (layout === "2x2" && limitedPaneCount >= 3) {
407
460
  const rightPane = psmuxExec([
@@ -956,7 +1009,10 @@ export async function waitForCompletion(sessionName, paneNameOrTarget, token, ti
956
1009
  */
957
1010
  export function spawnWorker(sessionName, workerName, cmd) {
958
1011
  if (!hasPsmux()) {
959
- throw new Error("psmux가 설치되어 있지 않습니다. psmux를 먼저 설치하세요.");
1012
+ throw new Error(
1013
+ "psmux가 설치되어 있지 않습니다.\n" +
1014
+ "설치: winget install marlocarlo.psmux (또는 scoop install psmux / npm i -g psmux)"
1015
+ );
960
1016
  }
961
1017
 
962
1018
  // remain-on-exit: 종료된 pane이 즉시 사라지는 것 방지
@@ -997,7 +1053,7 @@ export function spawnWorker(sessionName, workerName, cmd) {
997
1053
  */
998
1054
  export function getWorkerStatus(sessionName, workerName) {
999
1055
  if (!hasPsmux()) {
1000
- throw new Error("psmux 설치되어 있지 않습니다.");
1056
+ throw new Error("psmux 미설치. 설치: winget install marlocarlo.psmux (또는 npm i -g psmux)");
1001
1057
  }
1002
1058
  try {
1003
1059
  const pane = resolvePane(sessionName, workerName);
@@ -1022,7 +1078,7 @@ export function getWorkerStatus(sessionName, workerName) {
1022
1078
  */
1023
1079
  export function killWorker(sessionName, workerName) {
1024
1080
  if (!hasPsmux()) {
1025
- throw new Error("psmux 설치되어 있지 않습니다.");
1081
+ throw new Error("psmux 미설치. 설치: winget install marlocarlo.psmux (또는 npm i -g psmux)");
1026
1082
  }
1027
1083
  try {
1028
1084
  const { paneId, status } = getWorkerStatus(sessionName, workerName);
@@ -1081,7 +1137,7 @@ export function killWorker(sessionName, workerName) {
1081
1137
  */
1082
1138
  export function captureWorkerOutput(sessionName, workerName, lines = 50) {
1083
1139
  if (!hasPsmux()) {
1084
- throw new Error("psmux 설치되어 있지 않습니다.");
1140
+ throw new Error("psmux 미설치. 설치: winget install marlocarlo.psmux (또는 npm i -g psmux)");
1085
1141
  }
1086
1142
  try {
1087
1143
  const { paneId } = getWorkerStatus(sessionName, workerName);
@@ -35,7 +35,7 @@ try {
35
35
  execFileSync("psmux", ["--version"], { encoding: "utf8", timeout: 2000 });
36
36
  } catch {
37
37
  process.stderr.write(
38
- "ERROR: psmux not found or not executable. Install psmux before running tui-viewer.\n",
38
+ "ERROR: psmux 미설치. 설치: winget install marlocarlo.psmux (또는 npm i -g psmux)\n",
39
39
  );
40
40
  process.exit(1);
41
41
  }
@@ -3,24 +3,7 @@
3
3
 
4
4
  import { spawn } from 'node:child_process';
5
5
  import readline from 'node:readline';
6
-
7
- const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
8
- const DEFAULT_KILL_GRACE_MS = 1000;
9
-
10
- function toStringList(value) {
11
- if (!Array.isArray(value)) return [];
12
- return value
13
- .map((item) => String(item ?? '').trim())
14
- .filter(Boolean);
15
- }
16
-
17
- function safeJsonParse(line) {
18
- try {
19
- return JSON.parse(line);
20
- } catch {
21
- return null;
22
- }
23
- }
6
+ import { toStringList, safeJsonParse, createWorkerError, DEFAULT_TIMEOUT_MS, DEFAULT_KILL_GRACE_MS } from './worker-utils.mjs';
24
7
 
25
8
  function appendTextFragments(value, parts) {
26
9
  if (value == null) return;
@@ -59,12 +42,6 @@ function findSessionId(event) {
59
42
  || null;
60
43
  }
61
44
 
62
- function createWorkerError(message, details = {}) {
63
- const error = new Error(message);
64
- Object.assign(error, details);
65
- return error;
66
- }
67
-
68
45
  function buildClaudeArgs(worker, options) {
69
46
  const args = [...worker.commandArgs];
70
47
 
@@ -292,10 +292,6 @@ export class CodexMcpWorker {
292
292
  }
293
293
  }
294
294
 
295
- export function createCodexMcpWorker(options = {}) {
296
- return new CodexMcpWorker(options);
297
- }
298
-
299
295
  function parseCliArgs(argv) {
300
296
  const options = {
301
297
  command: process.env.CODEX_BIN || 'codex',
@@ -2,25 +2,10 @@
2
2
  // ADR-006: --output-format stream-json 기반 단발 실행 워커.
3
3
 
4
4
  import { spawn } from 'node:child_process';
5
+ import { existsSync } from 'node:fs';
6
+ import { delimiter, extname, join } from 'node:path';
5
7
  import readline from 'node:readline';
6
-
7
- const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
8
- const DEFAULT_KILL_GRACE_MS = 1000;
9
-
10
- function toStringList(value) {
11
- if (!Array.isArray(value)) return [];
12
- return value
13
- .map((item) => String(item ?? '').trim())
14
- .filter(Boolean);
15
- }
16
-
17
- function safeJsonParse(line) {
18
- try {
19
- return JSON.parse(line);
20
- } catch {
21
- return null;
22
- }
23
- }
8
+ import { toStringList, safeJsonParse, createWorkerError, DEFAULT_TIMEOUT_MS, DEFAULT_KILL_GRACE_MS } from './worker-utils.mjs';
24
9
 
25
10
  function appendTextFragments(value, parts) {
26
11
  if (value == null) return;
@@ -84,10 +69,100 @@ function buildGeminiArgs(options) {
84
69
  return args;
85
70
  }
86
71
 
87
- function createWorkerError(message, details = {}) {
88
- const error = new Error(message);
89
- Object.assign(error, details);
90
- return error;
72
+ function resolveSpawnCommand(command, env = process.env) {
73
+ const raw = String(command ?? '').trim();
74
+ if (!raw || process.platform !== 'win32') return raw;
75
+
76
+ const pathExts = (env.PATHEXT || process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
77
+ .split(';')
78
+ .map((ext) => ext.trim().toLowerCase())
79
+ .filter(Boolean);
80
+ const extensions = extname(raw)
81
+ ? ['']
82
+ : [...new Set(['.cmd', '.exe', '.bat', ...pathExts, ''])];
83
+
84
+ const tryResolve = (base) => {
85
+ for (const ext of extensions) {
86
+ const candidate = `${base}${ext}`;
87
+ if (existsSync(candidate)) return candidate;
88
+ }
89
+ return null;
90
+ };
91
+
92
+ if (raw.includes('\\') || raw.includes('/')) {
93
+ return tryResolve(raw.replaceAll('/', '\\')) || raw;
94
+ }
95
+
96
+ const pathEntries = String(env.PATH || process.env.PATH || '')
97
+ .split(delimiter)
98
+ .map((entry) => entry.trim())
99
+ .filter(Boolean);
100
+
101
+ for (const entry of pathEntries) {
102
+ const resolved = tryResolve(join(entry, raw));
103
+ if (resolved) return resolved;
104
+ }
105
+
106
+ return raw;
107
+ }
108
+
109
+ function quoteWindowsCmdArg(value) {
110
+ const raw = String(value ?? '');
111
+ if (raw.length === 0) return '""';
112
+
113
+ const escaped = raw
114
+ .replace(/(\\*)"/g, '$1$1\\"')
115
+ .replace(/(\\+)$/g, '$1$1');
116
+
117
+ return /[\s"&()<>^|]/.test(raw)
118
+ ? `"${escaped}"`
119
+ : escaped;
120
+ }
121
+
122
+ function quotePosixShellArg(value) {
123
+ const raw = String(value ?? '');
124
+ return `'${raw.replaceAll("'", `'\"'\"'`)}'`;
125
+ }
126
+
127
+ function toBashPath(value) {
128
+ return String(value ?? '')
129
+ .replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`)
130
+ .replaceAll('\\', '/');
131
+ }
132
+
133
+ function buildSpawnSpec(command, args, env = process.env) {
134
+ const resolvedCommand = resolveSpawnCommand(command, env);
135
+
136
+ if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolvedCommand)) {
137
+ const commandLine = [resolvedCommand, ...args]
138
+ .map((part) => quoteWindowsCmdArg(part))
139
+ .join(' ');
140
+
141
+ return {
142
+ command: 'cmd.exe',
143
+ args: ['/d', '/s', '/c', commandLine],
144
+ resolvedCommand,
145
+ };
146
+ }
147
+
148
+ if (process.platform === 'win32' && !extname(resolvedCommand) && existsSync(resolvedCommand)) {
149
+ const bashCommand = env.TFX_BASH_BIN || env.BASH || 'bash';
150
+ const commandLine = [toBashPath(resolvedCommand), ...args]
151
+ .map((part) => quotePosixShellArg(part))
152
+ .join(' ');
153
+
154
+ return {
155
+ command: bashCommand,
156
+ args: ['-lc', commandLine],
157
+ resolvedCommand,
158
+ };
159
+ }
160
+
161
+ return {
162
+ command: resolvedCommand,
163
+ args,
164
+ resolvedCommand,
165
+ };
91
166
  }
92
167
 
93
168
  /**
@@ -187,10 +262,12 @@ export class GeminiWorker {
187
262
  promptArgument: options.promptArgument ?? '',
188
263
  }),
189
264
  ];
265
+ const env = { ...this.env, ...(options.env || {}) };
266
+ const spawnSpec = buildSpawnSpec(this.command, args, env);
190
267
 
191
- const child = spawn(this.command, args, {
268
+ const child = spawn(spawnSpec.command, spawnSpec.args, {
192
269
  cwd: options.cwd || this.cwd,
193
- env: { ...this.env, ...(options.env || {}) },
270
+ env,
194
271
  stdio: ['pipe', 'pipe', 'pipe'],
195
272
  windowsHide: true,
196
273
  });
@@ -269,10 +346,13 @@ export class GeminiWorker {
269
346
  const response = [
270
347
  extractText(resultEvent),
271
348
  ...events
272
- .filter((event) => event?.type === 'message' || event?.type === 'assistant')
349
+ .filter((event) => (
350
+ event?.type === 'assistant'
351
+ || (event?.type === 'message' && event?.role === 'assistant')
352
+ ))
273
353
  .map((event) => extractText(event))
274
354
  .filter(Boolean),
275
- ...stdoutLines,
355
+ ...stdoutLines.filter((line) => line.trim() !== '""'),
276
356
  ]
277
357
  .filter(Boolean)
278
358
  .join('\n')
@@ -280,8 +360,8 @@ export class GeminiWorker {
280
360
 
281
361
  const result = {
282
362
  type: 'gemini',
283
- command: this.command,
284
- args,
363
+ command: spawnSpec.resolvedCommand,
364
+ args: spawnSpec.args,
285
365
  response,
286
366
  events,
287
367
  resultEvent,
@@ -38,4 +38,3 @@
38
38
  * @property {string} type - 'codex' | 'gemini' | 'claude' | 'delegator'
39
39
  */
40
40
 
41
- export const WORKER_TYPES = Object.freeze(['codex', 'gemini', 'claude', 'delegator']);
@@ -0,0 +1,26 @@
1
+ // hub/workers/worker-utils.mjs — 워커 공통 유틸리티
2
+ // claude-worker, gemini-worker, pipe 등에서 공유하는 순수 유틸 함수 모음.
3
+
4
+ export const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
5
+ export const DEFAULT_KILL_GRACE_MS = 1000;
6
+
7
+ export function toStringList(value) {
8
+ if (!Array.isArray(value)) return [];
9
+ return value
10
+ .map((item) => String(item ?? '').trim())
11
+ .filter(Boolean);
12
+ }
13
+
14
+ export function safeJsonParse(line) {
15
+ try {
16
+ return JSON.parse(line);
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export function createWorkerError(message, details = {}) {
23
+ const error = new Error(message);
24
+ Object.assign(error, details);
25
+ return error;
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "9.3.0",
3
+ "version": "9.5.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": {
@@ -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 =