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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/triflux.mjs +171 -231
- package/hub/pipe.mjs +1 -8
- package/hub/team/psmux.mjs +64 -8
- package/hub/team/tui-viewer.mjs +1 -1
- package/hub/workers/claude-worker.mjs +1 -24
- package/hub/workers/codex-mcp.mjs +0 -4
- package/hub/workers/gemini-worker.mjs +108 -28
- package/hub/workers/interface.mjs +0 -1
- package/hub/workers/worker-utils.mjs +26 -0
- package/package.json +1 -1
- package/scripts/__tests__/remote-spawn.test.mjs +17 -3
- package/scripts/cross-review-gate.mjs +11 -65
- package/scripts/cross-review-tracker.mjs +10 -51
- package/scripts/headless-guard.mjs +5 -17
- package/scripts/lib/cross-review-utils.mjs +51 -0
- package/scripts/lib/hook-utils.mjs +14 -0
- package/scripts/lib/mcp-filter.mjs +10 -1
- package/scripts/psmux-safety-guard.mjs +64 -0
- package/scripts/remote-spawn.mjs +77 -23
- package/scripts/session-spawn-helper.mjs +3 -2
- package/scripts/setup.mjs +129 -9
- package/scripts/tfx-route.sh +93 -10
- package/skills/tfx-psmux-rules/SKILL.md +100 -13
- package/skills/tfx-research/SKILL.md +1 -1
- package/skills/tfx-setup/SKILL.md +17 -1
package/hub/team/psmux.mjs
CHANGED
|
@@ -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 =
|
|
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(
|
|
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
|
|
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(
|
|
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);
|
package/hub/team/tui-viewer.mjs
CHANGED
|
@@ -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
|
|
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
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
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(
|
|
268
|
+
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
192
269
|
cwd: options.cwd || this.cwd,
|
|
193
|
-
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) =>
|
|
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:
|
|
284
|
-
args,
|
|
363
|
+
command: spawnSpec.resolvedCommand,
|
|
364
|
+
args: spawnSpec.args,
|
|
285
365
|
response,
|
|
286
366
|
events,
|
|
287
367
|
resultEvent,
|
|
@@ -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,7 +1,9 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
|
|
4
|
-
import {
|
|
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를
|
|
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, [
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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 =
|