triflux 7.0.5 → 7.1.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/hub/team/headless.mjs +13 -10
- package/hub/team/tui-viewer.mjs +36 -23
- package/hub/team/tui.mjs +80 -98
- package/package.json +1 -1
- package/scripts/headless-guard.mjs +5 -0
package/hub/team/headless.mjs
CHANGED
|
@@ -144,16 +144,8 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
144
144
|
applyTrifluxTheme(sessionName);
|
|
145
145
|
if (safeProgress) safeProgress({ type: "session_created", sessionName, panes: session.panes });
|
|
146
146
|
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
|
|
150
|
-
const resultDir = RESULT_DIR.replace(/\\/g, "/");
|
|
151
|
-
const viewerCmd = `node ${viewerPath} --session ${sessionName} --result-dir ${resultDir}`;
|
|
152
|
-
try {
|
|
153
|
-
psmuxExec(["send-keys", "-t", `${sessionName}:0.0`, viewerCmd, "Enter"]);
|
|
154
|
-
psmuxExec(["select-pane", "-t", `${sessionName}:0.0`, "-T", "▲ dashboard"]);
|
|
155
|
-
} catch { /* 무시 */ }
|
|
156
|
-
}
|
|
147
|
+
// dashboard: 워커 pane을 먼저 생성한 후 pane 0에 대시보드를 실행
|
|
148
|
+
// (listPanes로 워커 감지가 가능하려면 워커 pane이 먼저 존재해야 함)
|
|
157
149
|
|
|
158
150
|
dispatches = assignments.map((assignment, i) => {
|
|
159
151
|
const paneName = `worker-${i + 1}`;
|
|
@@ -196,6 +188,17 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
196
188
|
// 모든 split 완료 후 레이아웃 한 번만 정렬 (깜빡임 방지)
|
|
197
189
|
try { psmuxExec(["select-layout", "-t", sessionName, "tiled"]); } catch { /* 무시 */ }
|
|
198
190
|
|
|
191
|
+
// v7.0.6: 워커 pane 생성 후 대시보드 시작 (워커가 먼저 존재해야 listPanes 감지 가능)
|
|
192
|
+
if (dashboard) {
|
|
193
|
+
const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
|
|
194
|
+
const resultDir = RESULT_DIR.replace(/\\/g, "/");
|
|
195
|
+
const viewerCmd = `node ${viewerPath} --session ${sessionName}`;
|
|
196
|
+
try {
|
|
197
|
+
psmuxExec(["send-keys", "-t", `${sessionName}:0.0`, viewerCmd, "Enter"]);
|
|
198
|
+
psmuxExec(["select-pane", "-t", `${sessionName}:0.0`, "-T", "▲ dashboard"]);
|
|
199
|
+
} catch { /* 무시 */ }
|
|
200
|
+
}
|
|
201
|
+
|
|
199
202
|
} else {
|
|
200
203
|
// ─── 기존 모드: 모든 pane을 한 번에 생성 ───
|
|
201
204
|
const paneCount = assignments.length + 1;
|
package/hub/team/tui-viewer.mjs
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hub/team/tui-viewer.mjs — psmux pane용
|
|
3
|
-
// 같은 psmux 세션의 워커 pane을 capture-pane으로
|
|
2
|
+
// hub/team/tui-viewer.mjs — psmux pane용 append-only 로그 뷰어 v3
|
|
3
|
+
// 같은 psmux 세션의 워커 pane을 capture-pane으로 모니터링한다.
|
|
4
4
|
|
|
5
5
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
|
-
import {
|
|
9
|
+
import { createLogDashboard } from "./tui.mjs";
|
|
10
10
|
import { processHandoff } from "./handoff.mjs";
|
|
11
11
|
|
|
12
12
|
const args = process.argv.slice(2);
|
|
13
13
|
const sessionIdx = args.indexOf("--session");
|
|
14
|
+
const resultDirIdx = args.indexOf("--result-dir");
|
|
14
15
|
const SESSION = sessionIdx >= 0 ? args[sessionIdx + 1] : null;
|
|
15
|
-
const RESULT_DIR =
|
|
16
|
+
const RESULT_DIR = resultDirIdx >= 0
|
|
17
|
+
? args[resultDirIdx + 1]
|
|
18
|
+
: join(tmpdir(), "tfx-headless");
|
|
16
19
|
|
|
17
20
|
if (!SESSION) {
|
|
18
21
|
process.stderr.write("Usage: node tui-viewer.mjs --session <name>\n");
|
|
19
22
|
process.exit(1);
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
const tui =
|
|
25
|
+
const tui = createLogDashboard({ refreshMs: 0 });
|
|
23
26
|
const startTime = Date.now();
|
|
24
27
|
tui.setStartTime(startTime);
|
|
25
28
|
|
|
@@ -34,7 +37,9 @@ function listPanes() {
|
|
|
34
37
|
const [index, title, pid] = line.split(":");
|
|
35
38
|
return { index: parseInt(index, 10), title: title || "", pid };
|
|
36
39
|
});
|
|
37
|
-
} catch {
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
// ── pane 캡처 ──
|
|
@@ -43,7 +48,9 @@ function capturePane(paneIdx, lines = 5) {
|
|
|
43
48
|
return execFileSync("psmux", [
|
|
44
49
|
"capture-pane", "-t", `${SESSION}:0.${paneIdx}`, "-p",
|
|
45
50
|
], { encoding: "utf8", timeout: 2000 }).trim().split("\n").slice(-lines).join("\n");
|
|
46
|
-
} catch {
|
|
51
|
+
} catch {
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
// ── result 파일에서 handoff 파싱 ──
|
|
@@ -54,7 +61,9 @@ function checkResultFile(paneName) {
|
|
|
54
61
|
const content = readFileSync(resultFile, "utf8");
|
|
55
62
|
if (content.trim().length === 0) return null;
|
|
56
63
|
return processHandoff(content, { exitCode: 0, resultFile });
|
|
57
|
-
} catch {
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
// ── 메인 폴링 ──
|
|
@@ -63,6 +72,9 @@ const workerState = new Map(); // paneName → { paneIdx, done }
|
|
|
63
72
|
|
|
64
73
|
function poll() {
|
|
65
74
|
const panes = listPanes();
|
|
75
|
+
if (panes.length <= 1 && workerState.size === 0) {
|
|
76
|
+
process.stderr.write(`[poll] no workers found. panes=${JSON.stringify(panes)} session=${SESSION}\n`);
|
|
77
|
+
}
|
|
66
78
|
// pane 0 = 대시보드 (자기 자신), pane 1+ = 워커
|
|
67
79
|
for (const pane of panes) {
|
|
68
80
|
if (pane.index === 0) continue; // 자기 자신 건너뜀
|
|
@@ -91,37 +103,38 @@ function poll() {
|
|
|
91
103
|
}
|
|
92
104
|
|
|
93
105
|
// 진행 중 — pane 캡처로 스냅샷
|
|
94
|
-
const snapshot = capturePane(pane.index,
|
|
95
|
-
const
|
|
106
|
+
const snapshot = capturePane(pane.index, 5);
|
|
107
|
+
const lines = snapshot.split("\n").filter(l => l.trim());
|
|
108
|
+
const lastLine = lines.pop() || "";
|
|
96
109
|
|
|
97
110
|
if (!existing) {
|
|
98
111
|
workerState.set(paneName, { paneIdx: pane.index, done: false });
|
|
99
112
|
}
|
|
100
113
|
|
|
101
|
-
// result
|
|
114
|
+
// 완료 감지: (1) result 파일 존재, (2) 셸 프롬프트 복귀, (3) "tokens used" 텍스트
|
|
102
115
|
const resultFile = join(RESULT_DIR, `${SESSION}-${paneName}.txt`);
|
|
103
116
|
let resultSize = 0;
|
|
104
117
|
try { resultSize = statSync(resultFile).size; } catch {}
|
|
105
118
|
|
|
106
|
-
|
|
107
|
-
|
|
119
|
+
const shellReturned = /^(PS\s|>|\$)\s*/.test(lastLine) && lines.length > 2;
|
|
120
|
+
const tokensLine = lines.find(l => /tokens?\s+used/i.test(l));
|
|
121
|
+
|
|
122
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
123
|
+
if (resultSize > 10 || shellReturned) {
|
|
108
124
|
workerState.set(paneName, { paneIdx: pane.index, done: true });
|
|
109
|
-
const
|
|
125
|
+
const meaningful = lines.filter((l) => !(/^(PS\s|>|\$)/.test(l)) && !(/tokens?\s+used/i.test(l)));
|
|
126
|
+
const verdict = meaningful.pop()?.slice(0, 80) || "completed";
|
|
110
127
|
tui.updateWorker(paneName, {
|
|
111
128
|
cli,
|
|
112
129
|
role: pane.title,
|
|
113
130
|
status: "completed",
|
|
114
|
-
handoff:
|
|
115
|
-
elapsed
|
|
131
|
+
handoff: { verdict, confidence: tokensLine ? "high" : "low" },
|
|
132
|
+
elapsed,
|
|
116
133
|
});
|
|
117
134
|
} else {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
status: "running",
|
|
122
|
-
snapshot: lastLine.slice(0, 60),
|
|
123
|
-
elapsed: Math.round((Date.now() - startTime) / 1000),
|
|
124
|
-
});
|
|
135
|
+
const meaningful = lines.filter(l => !(/^(PS\s|>|\$)/.test(l)));
|
|
136
|
+
const snap = meaningful.pop()?.slice(0, 60) || lastLine.slice(0, 60);
|
|
137
|
+
tui.updateWorker(paneName, { cli, role: pane.title, status: "running", snapshot: snap, elapsed });
|
|
125
138
|
}
|
|
126
139
|
}
|
|
127
140
|
|
package/hub/team/tui.mjs
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
|
-
// hub/team/tui.mjs —
|
|
2
|
-
//
|
|
3
|
-
// 외부 의존성 0 — Node.js 내장 + ansi.mjs만 사용
|
|
1
|
+
// hub/team/tui.mjs — Append-only 로그 대시보드 (v8)
|
|
2
|
+
// ANSI 색상은 유지하되 커서 이동/화면 덮어쓰기는 사용하지 않는다.
|
|
4
3
|
|
|
5
|
-
import {
|
|
6
|
-
cursorHome, clearLine, cursorHide, cursorShow,
|
|
7
|
-
RESET, BOLD, DIM, FG, BG,
|
|
8
|
-
color, dim, truncate, stripAnsi,
|
|
9
|
-
STATUS_ICON, CLI_ICON,
|
|
10
|
-
} from "./ansi.mjs";
|
|
4
|
+
import { RESET, FG, color, dim, STATUS_ICON } from "./ansi.mjs";
|
|
11
5
|
|
|
12
6
|
// package.json에서 동적 로드 (실패 시 fallback)
|
|
13
7
|
let VERSION = "7.x";
|
|
@@ -18,18 +12,16 @@ try {
|
|
|
18
12
|
} catch { /* fallback */ }
|
|
19
13
|
|
|
20
14
|
/**
|
|
21
|
-
*
|
|
15
|
+
* 로그 스트림 대시보드 생성 (append-only)
|
|
22
16
|
* @param {object} [opts]
|
|
23
17
|
* @param {NodeJS.WriteStream} [opts.stream=process.stdout]
|
|
24
|
-
* @param {number} [opts.refreshMs=1000] — 자동
|
|
25
|
-
* @
|
|
26
|
-
* @returns {TuiHandle}
|
|
18
|
+
* @param {number} [opts.refreshMs=1000] — 자동 렌더 주기 (0=수동만)
|
|
19
|
+
* @returns {LogDashboardHandle}
|
|
27
20
|
*/
|
|
28
|
-
export function
|
|
21
|
+
export function createLogDashboard(opts = {}) {
|
|
29
22
|
const {
|
|
30
23
|
stream = process.stdout,
|
|
31
24
|
refreshMs = 1000,
|
|
32
|
-
width: fixedWidth = 0,
|
|
33
25
|
} = opts;
|
|
34
26
|
|
|
35
27
|
const workers = new Map();
|
|
@@ -38,105 +30,75 @@ export function createTui(opts = {}) {
|
|
|
38
30
|
let timer = null;
|
|
39
31
|
let closed = false;
|
|
40
32
|
let frameCount = 0;
|
|
33
|
+
const lastLineByWorker = new Map();
|
|
41
34
|
|
|
42
|
-
function
|
|
43
|
-
function out(text) { if (!closed) stream.write(text); }
|
|
35
|
+
function out(text) { if (!closed) stream.write(`${text}\n`); }
|
|
44
36
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
function header() {
|
|
48
|
-
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
|
49
|
-
const vals = [...workers.values()];
|
|
50
|
-
const done = vals.filter((s) => s.status === "completed").length;
|
|
51
|
-
const fail = vals.filter((s) => s.status === "failed").length;
|
|
52
|
-
const run = vals.filter((s) => s.status === "running").length;
|
|
53
|
-
|
|
54
|
-
const left = ` ${color("▲ triflux", FG.triflux)} ${dim(`v${VERSION}`)}`;
|
|
55
|
-
const right = `${color(`${done}✓`, FG.green)} ${color(`${fail}✗`, FG.red)} ${run}⏳ ${dim(`${vals.length}총`)} ${dim(`${elapsed}s`)}`;
|
|
56
|
-
const gap = Math.max(1, w() - stripAnsi(left).length - stripAnsi(right).length - 1);
|
|
57
|
-
return `${BG.header}${left}${" ".repeat(gap)}${right}${RESET}`;
|
|
37
|
+
function nowElapsedSec() {
|
|
38
|
+
return Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
|
58
39
|
}
|
|
59
40
|
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const el = st.elapsed != null ? `${st.elapsed}s` : "";
|
|
64
|
-
const role = st.role ? dim(`(${st.role})`) : "";
|
|
65
|
-
const conf = st.handoff?.confidence ? dim(` ${st.handoff.confidence}`) : "";
|
|
66
|
-
|
|
67
|
-
const lines = [];
|
|
68
|
-
lines.push(` ${icon} ${st.cli} ${role} ${sIcon} ${el}${conf}`);
|
|
69
|
-
|
|
70
|
-
if (st.handoff?.verdict) {
|
|
71
|
-
lines.push(` ${truncate(st.handoff.verdict, w() - 8)}`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (st.handoff?.files_changed?.length) {
|
|
75
|
-
const f = st.handoff.files_changed.slice(0, 3).join(", ");
|
|
76
|
-
const more = st.handoff.files_changed.length > 3 ? ` +${st.handoff.files_changed.length - 3}` : "";
|
|
77
|
-
lines.push(` ${dim(`files: ${f}${more}`)}`);
|
|
78
|
-
}
|
|
41
|
+
function elapsedLabel(sec) {
|
|
42
|
+
return dim(`[${sec}s]`);
|
|
43
|
+
}
|
|
79
44
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
45
|
+
function oneLine(text, fallback = "n/a") {
|
|
46
|
+
const normalized = String(text || "").replace(/\s+/g, " ").trim();
|
|
47
|
+
if (!normalized) return fallback;
|
|
48
|
+
return normalized.length > 160 ? `${normalized.slice(0, 157)}...` : normalized;
|
|
49
|
+
}
|
|
86
50
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
51
|
+
function cliColor(cli) {
|
|
52
|
+
if (cli === "gemini") return FG.gemini;
|
|
53
|
+
if (cli === "claude") return FG.claude;
|
|
54
|
+
if (cli === "codex") return FG.codex;
|
|
55
|
+
return FG.white;
|
|
56
|
+
}
|
|
90
57
|
|
|
91
|
-
|
|
58
|
+
function statusLabel(status) {
|
|
59
|
+
if (status === "completed") return color("completed", FG.green);
|
|
60
|
+
if (status === "failed") return color("failed", FG.red);
|
|
61
|
+
if (status === "running") return color("running", FG.blue);
|
|
62
|
+
return dim(status || "pending");
|
|
92
63
|
}
|
|
93
64
|
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (pi < ci) return `${FG.green}${p}${RESET}`;
|
|
102
|
-
return dim(p);
|
|
103
|
-
});
|
|
104
|
-
return ` ${parts.join(dim("→"))}`;
|
|
65
|
+
function messageLabel(st) {
|
|
66
|
+
if (st.handoff?.verdict) return oneLine(st.handoff.verdict, "completed");
|
|
67
|
+
if (st.snapshot) return oneLine(st.snapshot, st.status || "running");
|
|
68
|
+
if (st.status === "failed" && st.handoff?.lead_action) {
|
|
69
|
+
return oneLine(`action=${st.handoff.lead_action}`, "failed");
|
|
70
|
+
}
|
|
71
|
+
return st.status || "pending";
|
|
105
72
|
}
|
|
106
73
|
|
|
107
|
-
function
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
|
|
74
|
+
function workerLine(name, st) {
|
|
75
|
+
const status = st.status || "pending";
|
|
76
|
+
const icon = STATUS_ICON[status] || STATUS_ICON.pending;
|
|
77
|
+
const cli = st.cli || "codex";
|
|
78
|
+
const cliLabel = `${cliColor(cli)}${cli}${RESET}`;
|
|
79
|
+
const workerLabel = color(name, FG.triflux);
|
|
80
|
+
const statusText = statusLabel(status);
|
|
81
|
+
const message = messageLabel(st);
|
|
82
|
+
const sec = Number.isFinite(st._logSec) ? st._logSec : nowElapsedSec();
|
|
83
|
+
return `${elapsedLabel(sec)} ${icon} ${workerLabel} (${cliLabel}) ${statusText} ${dim("—")} ${message}`;
|
|
112
84
|
}
|
|
113
85
|
|
|
86
|
+
// 현재 상태와 마지막으로 출력한 라인을 비교해 변경분만 append
|
|
114
87
|
function render() {
|
|
115
88
|
if (closed) return;
|
|
116
89
|
frameCount++;
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
} else {
|
|
126
|
-
for (const [name, st] of workers) {
|
|
127
|
-
for (const l of workerCard(name, st)) buf.push(l + clearLine + "\n");
|
|
128
|
-
buf.push("\n");
|
|
90
|
+
|
|
91
|
+
const names = [...workers.keys()].sort();
|
|
92
|
+
for (const name of names) {
|
|
93
|
+
const st = workers.get(name);
|
|
94
|
+
const line = workerLine(name, st);
|
|
95
|
+
if (line !== lastLineByWorker.get(name)) {
|
|
96
|
+
lastLineByWorker.set(name, line);
|
|
97
|
+
out(line);
|
|
129
98
|
}
|
|
130
99
|
}
|
|
131
|
-
|
|
132
|
-
buf.push(sep + "\n");
|
|
133
|
-
buf.push(pipelineBar() + clearLine + "\n");
|
|
134
|
-
buf.push(footer() + clearLine + "\n");
|
|
135
|
-
|
|
136
|
-
out(buf.join(""));
|
|
137
100
|
}
|
|
138
101
|
|
|
139
|
-
// 자동 갱신
|
|
140
102
|
if (refreshMs > 0) {
|
|
141
103
|
timer = setInterval(render, refreshMs);
|
|
142
104
|
if (timer.unref) timer.unref();
|
|
@@ -145,17 +107,37 @@ export function createTui(opts = {}) {
|
|
|
145
107
|
return {
|
|
146
108
|
updateWorker(paneName, state) {
|
|
147
109
|
const existing = workers.get(paneName) || { cli: "codex", status: "pending" };
|
|
148
|
-
|
|
110
|
+
const merged = { ...existing, ...state };
|
|
111
|
+
const nextSig = [
|
|
112
|
+
merged.cli || "",
|
|
113
|
+
merged.status || "",
|
|
114
|
+
merged.snapshot || "",
|
|
115
|
+
merged.handoff?.verdict || "",
|
|
116
|
+
merged.handoff?.lead_action || "",
|
|
117
|
+
merged.handoff?.confidence || "",
|
|
118
|
+
merged.handoff?.risk || "",
|
|
119
|
+
].join("|");
|
|
120
|
+
const sigChanged = nextSig !== existing._sig;
|
|
121
|
+
const explicitElapsed = Number.isFinite(state.elapsed) ? Math.max(0, Math.round(state.elapsed)) : null;
|
|
122
|
+
merged._sig = nextSig;
|
|
123
|
+
merged._logSec = sigChanged
|
|
124
|
+
? (explicitElapsed ?? nowElapsedSec())
|
|
125
|
+
: (Number.isFinite(existing._logSec) ? existing._logSec : (explicitElapsed ?? nowElapsedSec()));
|
|
126
|
+
workers.set(paneName, merged);
|
|
127
|
+
},
|
|
128
|
+
updatePipeline(state) {
|
|
129
|
+
pipeline = { ...pipeline, ...state };
|
|
130
|
+
},
|
|
131
|
+
setStartTime(ms) {
|
|
132
|
+
startedAt = ms;
|
|
149
133
|
},
|
|
150
|
-
updatePipeline(state) { pipeline = { ...pipeline, ...state }; },
|
|
151
|
-
setStartTime(ms) { startedAt = ms; },
|
|
152
134
|
render,
|
|
153
135
|
getWorkers() { return new Map(workers); },
|
|
154
136
|
getFrameCount() { return frameCount; },
|
|
155
137
|
close() {
|
|
156
138
|
if (closed) return;
|
|
157
|
-
closed = true;
|
|
158
139
|
if (timer) clearInterval(timer);
|
|
140
|
+
closed = true;
|
|
159
141
|
},
|
|
160
142
|
};
|
|
161
143
|
}
|
package/package.json
CHANGED
|
@@ -135,6 +135,11 @@ async function main() {
|
|
|
135
135
|
process.exit(0);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// psmux send-keys / capture-pane은 통과 (pane 내 간접 실행)
|
|
139
|
+
if (/psmux\s+(send-keys|capture-pane|list-panes|split-window|select-pane)/.test(cmd)) {
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
|
|
138
143
|
// codex/gemini 직접 CLI 호출 → deny
|
|
139
144
|
if (/\bcodex\s+exec\b/.test(cmd) || /\bgemini\s+(-p|--prompt)\b/.test(cmd)) {
|
|
140
145
|
deny(
|