triflux 7.1.4 → 7.2.1
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/marketplace.json +31 -31
- package/.claude-plugin/plugin.json +22 -23
- package/bin/triflux.mjs +18 -5
- package/hooks/keyword-rules.json +393 -361
- package/hub/bridge.mjs +799 -786
- package/hub/delegator/contracts.mjs +37 -38
- package/hub/delegator/schema/delegator-tools.schema.json +250 -250
- package/hub/delegator/service.mjs +307 -302
- package/hub/intent.mjs +108 -11
- package/hub/lib/process-utils.mjs +20 -0
- package/hub/pipe.mjs +589 -589
- package/hub/pipeline/gates/confidence.mjs +1 -1
- package/hub/pipeline/gates/selfcheck.mjs +2 -4
- package/hub/pipeline/state.mjs +191 -187
- package/hub/pipeline/transitions.mjs +124 -120
- package/hub/public/dashboard.html +355 -349
- package/hub/quality/deslop.mjs +5 -3
- package/hub/reflexion.mjs +5 -1
- package/hub/research.mjs +6 -1
- package/hub/router.mjs +791 -782
- package/hub/server.mjs +893 -822
- package/hub/store.mjs +807 -778
- package/hub/team/agent-map.json +10 -0
- package/hub/team/ansi.mjs +3 -4
- package/hub/team/cli/commands/control.mjs +43 -43
- package/hub/team/cli/commands/interrupt.mjs +36 -36
- package/hub/team/cli/commands/kill.mjs +3 -3
- package/hub/team/cli/commands/send.mjs +37 -37
- package/hub/team/cli/commands/start/index.mjs +18 -8
- package/hub/team/cli/commands/start/parse-args.mjs +3 -1
- package/hub/team/cli/commands/start/start-headless.mjs +4 -1
- package/hub/team/cli/commands/status.mjs +87 -87
- package/hub/team/cli/commands/stop.mjs +1 -1
- package/hub/team/cli/commands/task.mjs +1 -1
- package/hub/team/cli/index.mjs +41 -39
- package/hub/team/cli/manifest.mjs +29 -28
- package/hub/team/cli/services/hub-client.mjs +37 -0
- package/hub/team/cli/services/state-store.mjs +26 -12
- package/hub/team/dashboard.mjs +11 -4
- package/hub/team/handoff.mjs +12 -0
- package/hub/team/headless.mjs +202 -200
- package/hub/team/native-supervisor.mjs +386 -346
- package/hub/team/nativeProxy.mjs +680 -692
- package/hub/team/staleState.mjs +361 -369
- package/hub/team/tui-viewer.mjs +27 -3
- package/hub/team/tui.mjs +1 -0
- package/hub/token-mode.mjs +114 -24
- package/hub/workers/delegator-mcp.mjs +1059 -1057
- package/hud/colors.mjs +88 -0
- package/hud/constants.mjs +78 -0
- package/hud/hud-qos-status.mjs +206 -1872
- package/hud/providers/claude.mjs +309 -0
- package/hud/providers/codex.mjs +151 -0
- package/hud/providers/gemini.mjs +320 -0
- package/hud/renderers.mjs +424 -0
- package/hud/terminal.mjs +140 -0
- package/hud/utils.mjs +271 -0
- package/package.json +1 -2
- package/scripts/__tests__/keyword-detector.test.mjs +234 -234
- package/scripts/headless-guard-fast.sh +21 -0
- package/scripts/headless-guard.mjs +26 -6
- package/scripts/lib/keyword-rules.mjs +166 -168
- package/scripts/setup.mjs +720 -690
- package/scripts/tfx-route-post.mjs +424 -424
- package/scripts/tfx-route.sh +1663 -1650
- package/scripts/tmp-cleanup.mjs +74 -0
- package/skills/tfx-auto/SKILL.md +279 -278
- package/skills/tfx-auto-codex/SKILL.md +98 -77
- package/skills/tfx-codex/SKILL.md +65 -65
- package/skills/tfx-gemini/SKILL.md +83 -82
- package/skills/tfx-hub/SKILL.md +205 -136
- package/skills/tfx-multi/SKILL.md +11 -5
- package/.mcp.json +0 -8
package/hub/team/headless.mjs
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
// v6.0.0: Lead-direct 모드 (runHeadlessInteractive, autoAttachTerminal)
|
|
5
5
|
// 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
|
-
import { execSync,
|
|
9
|
+
import { execSync, spawn } from "node:child_process";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
10
12
|
import {
|
|
11
13
|
createPsmuxSession,
|
|
12
14
|
killPsmuxSession,
|
|
@@ -17,7 +19,7 @@ import {
|
|
|
17
19
|
startCapture,
|
|
18
20
|
psmuxExec,
|
|
19
21
|
} from "./psmux.mjs";
|
|
20
|
-
import {
|
|
22
|
+
import { HANDOFF_INSTRUCTION_SHORT, processHandoff } from "./handoff.mjs";
|
|
21
23
|
|
|
22
24
|
const RESULT_DIR = join(tmpdir(), "tfx-headless");
|
|
23
25
|
|
|
@@ -30,17 +32,9 @@ const CLI_BRAND = {
|
|
|
30
32
|
const ANSI_RESET = "\x1b[0m";
|
|
31
33
|
const ANSI_DIM = "\x1b[2m";
|
|
32
34
|
|
|
33
|
-
/** 에이전트 역할명 → CLI 타입 매핑 (
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
architect: "codex", planner: "codex", critic: "codex", analyst: "codex",
|
|
37
|
-
"code-reviewer": "codex", "security-reviewer": "codex", "quality-reviewer": "codex",
|
|
38
|
-
scientist: "codex", "scientist-deep": "codex", "document-specialist": "codex",
|
|
39
|
-
spark: "codex",
|
|
40
|
-
designer: "gemini", writer: "gemini",
|
|
41
|
-
explore: "claude", verifier: "claude", "test-engineer": "claude", "qa-tester": "claude",
|
|
42
|
-
codex: "codex", gemini: "gemini", claude: "claude",
|
|
43
|
-
};
|
|
35
|
+
/** 에이전트 역할명 → CLI 타입 매핑 (단일 소스: agent-map.json) */
|
|
36
|
+
const _require = createRequire(import.meta.url);
|
|
37
|
+
const AGENT_TO_CLI = _require("./agent-map.json");
|
|
44
38
|
|
|
45
39
|
/**
|
|
46
40
|
* 에이전트 역할명 또는 CLI 이름을 CLI 타입("codex"|"gemini"|"claude")으로 해석한다.
|
|
@@ -68,25 +62,44 @@ const MCP_PROFILE_HINTS = {
|
|
|
68
62
|
* @param {object} [opts]
|
|
69
63
|
* @param {boolean} [opts.handoff=true]
|
|
70
64
|
* @param {string} [opts.mcp] — MCP 프로필 ("implement"|"analyze"|"review"|"docs")
|
|
65
|
+
* @param {string} [opts.contextFile] — 컨텍스트 파일 경로 (최대 32KB, UTF-8 안전 절단)
|
|
71
66
|
* @returns {string} PowerShell 명령
|
|
72
67
|
*/
|
|
73
68
|
export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
74
|
-
const { handoff = true, mcp } = opts;
|
|
69
|
+
const { handoff = true, mcp, contextFile } = opts;
|
|
75
70
|
const resolvedCli = resolveCliType(cli);
|
|
71
|
+
|
|
72
|
+
// contextFile 처리: 32KB(32768 bytes) 초과 시 UTF-8 안전 절단
|
|
73
|
+
let contextPrefix = "";
|
|
74
|
+
if (contextFile && existsSync(contextFile)) {
|
|
75
|
+
let ctx = readFileSync(contextFile, "utf8");
|
|
76
|
+
if (Buffer.byteLength(ctx, "utf8") > 32768) {
|
|
77
|
+
ctx = Buffer.from(ctx).subarray(0, 32768).toString("utf8");
|
|
78
|
+
}
|
|
79
|
+
if (ctx.length > 0) {
|
|
80
|
+
contextPrefix = `<prior_context>\n${ctx}\n</prior_context>\n\n`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
76
84
|
const mcpHint = mcp && MCP_PROFILE_HINTS[mcp] ? ` [MCP: ${mcp}] ${MCP_PROFILE_HINTS[mcp]}` : "";
|
|
77
|
-
// HANDOFF
|
|
78
|
-
|
|
79
|
-
const fullPrompt = `${prompt}${mcpHint}`;
|
|
80
|
-
|
|
85
|
+
// P2: HANDOFF 지시를 프롬프트에 삽입 (워커가 구조화된 handoff 블록을 출력하도록)
|
|
86
|
+
const handoffHint = handoff ? `\n\n${HANDOFF_INSTRUCTION_SHORT}` : "";
|
|
87
|
+
const fullPrompt = `${contextPrefix}${prompt}${mcpHint}${handoffHint}`;
|
|
88
|
+
|
|
89
|
+
// 보안: 프롬프트를 임시 파일에 쓰고 파일 참조로 전달 (셸 주입 방지)
|
|
90
|
+
if (!existsSync(RESULT_DIR)) mkdirSync(RESULT_DIR, { recursive: true });
|
|
91
|
+
const promptFile = join(RESULT_DIR, "prompt-" + randomUUID().slice(0, 8) + ".txt").replace(/\\/g, "/");
|
|
92
|
+
writeFileSync(promptFile, fullPrompt, "utf8");
|
|
93
|
+
|
|
81
94
|
const cls = "Clear-Host; ";
|
|
82
95
|
|
|
83
96
|
switch (resolvedCli) {
|
|
84
97
|
case "codex":
|
|
85
|
-
return `${cls}codex exec '${
|
|
98
|
+
return `${cls}codex exec (Get-Content -Raw '${promptFile}') -o '${resultFile}' --color never`;
|
|
86
99
|
case "gemini":
|
|
87
|
-
return `${cls}gemini -p '${
|
|
100
|
+
return `${cls}gemini -p (Get-Content -Raw '${promptFile}') -o text > '${resultFile}' 2>'${resultFile}.err'`;
|
|
88
101
|
case "claude":
|
|
89
|
-
return `${cls}claude -p '${
|
|
102
|
+
return `${cls}claude -p (Get-Content -Raw '${promptFile}') --output-format text > '${resultFile}' 2>&1`;
|
|
90
103
|
default:
|
|
91
104
|
throw new Error(`지원하지 않는 CLI: ${resolvedCli} (원본: ${cli})`);
|
|
92
105
|
}
|
|
@@ -106,118 +119,101 @@ function readResult(resultFile, paneId) {
|
|
|
106
119
|
return capturePsmuxPane(paneId, 30);
|
|
107
120
|
}
|
|
108
121
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
let dispatches;
|
|
140
|
-
|
|
141
|
-
if (progressive) {
|
|
142
|
-
// ─── 실시간 스플릿 모드: lead pane만 생성 후, 워커를 하나씩 추가 ───
|
|
143
|
-
const session = createPsmuxSession(sessionName, { layout, paneCount: 1 });
|
|
144
|
-
applyTrifluxTheme(sessionName);
|
|
145
|
-
if (safeProgress) safeProgress({ type: "session_created", sessionName, panes: session.panes });
|
|
146
|
-
|
|
147
|
-
// dashboard: 워커 pane을 먼저 생성한 후 pane 0에 대시보드를 실행
|
|
148
|
-
// (listPanes로 워커 감지가 가능하려면 워커 pane이 먼저 존재해야 함)
|
|
149
|
-
|
|
150
|
-
dispatches = assignments.map((assignment, i) => {
|
|
151
|
-
const paneName = `worker-${i + 1}`;
|
|
152
|
-
const resolvedCli = resolveCliType(assignment.cli);
|
|
153
|
-
const brand = CLI_BRAND[resolvedCli] || { emoji: "\u{25CF}", label: resolvedCli, ansi: "" };
|
|
154
|
-
const paneTitle = assignment.role
|
|
155
|
-
? `${brand.emoji} ${resolvedCli} (${assignment.role})`
|
|
156
|
-
: `${brand.emoji} ${resolvedCli}-${i + 1}`;
|
|
157
|
-
|
|
158
|
-
let newPaneId;
|
|
159
|
-
if (i === 0) {
|
|
160
|
-
// 첫 번째 워커: 빈 lead pane 사용
|
|
161
|
-
newPaneId = `${sessionName}:0.0`;
|
|
162
|
-
} else {
|
|
163
|
-
// 2번째+: split-window로 추가
|
|
164
|
-
newPaneId = psmuxExec([
|
|
165
|
-
"split-window", "-t", sessionName, "-P", "-F",
|
|
166
|
-
"#{session_name}:#{window_index}.#{pane_index}",
|
|
167
|
-
]);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// 타이틀 설정 (이모지 포함)
|
|
171
|
-
try { psmuxExec(["select-pane", "-t", newPaneId, "-T", paneTitle]); } catch { /* 무시 */ }
|
|
172
|
-
|
|
173
|
-
if (safeProgress) safeProgress({ type: "worker_added", paneName, cli: assignment.cli, paneTitle });
|
|
122
|
+
/** progressive 스플릿 모드: lead pane만 생성 후, 워커를 하나씩 추가하며 dispatch */
|
|
123
|
+
async function dispatchProgressive(sessionName, assignments, layout, safeProgress) {
|
|
124
|
+
const session = createPsmuxSession(sessionName, { layout, paneCount: 1 });
|
|
125
|
+
applyTrifluxTheme(sessionName);
|
|
126
|
+
if (safeProgress) safeProgress({ type: "session_created", sessionName, panes: session.panes });
|
|
127
|
+
|
|
128
|
+
// dashboard: 워커 pane을 먼저 생성한 후 pane 0에 대시보드를 실행
|
|
129
|
+
// (listPanes로 워커 감지가 가능하려면 워커 pane이 먼저 존재해야 함)
|
|
130
|
+
|
|
131
|
+
const dispatches = [];
|
|
132
|
+
for (let i = 0; i < assignments.length; i++) {
|
|
133
|
+
const assignment = assignments[i];
|
|
134
|
+
const paneName = `worker-${i + 1}`;
|
|
135
|
+
const resolvedCli = resolveCliType(assignment.cli);
|
|
136
|
+
const brand = CLI_BRAND[resolvedCli] || { emoji: "\u{25CF}", label: resolvedCli, ansi: "" };
|
|
137
|
+
const paneTitle = assignment.role
|
|
138
|
+
? `${brand.emoji} ${resolvedCli} (${assignment.role})`
|
|
139
|
+
: `${brand.emoji} ${resolvedCli}-${i + 1}`;
|
|
140
|
+
|
|
141
|
+
let newPaneId;
|
|
142
|
+
if (i === 0) {
|
|
143
|
+
// 첫 번째 워커: 빈 lead pane 사용
|
|
144
|
+
newPaneId = `${sessionName}:0.0`;
|
|
145
|
+
} else {
|
|
146
|
+
// 2번째+: split-window로 추가
|
|
147
|
+
newPaneId = psmuxExec([
|
|
148
|
+
"split-window", "-t", sessionName, "-P", "-F",
|
|
149
|
+
"#{session_name}:#{window_index}.#{pane_index}",
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
174
152
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp });
|
|
178
|
-
startCapture(sessionName, newPaneId);
|
|
179
|
-
// pane 간 pipe-pane EBUSY 방지 — capture 스크립트 파일 잠금 해제 대기
|
|
180
|
-
if (i > 0) { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300); } catch {} }
|
|
181
|
-
const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
|
|
153
|
+
// 타이틀 설정 (이모지 포함)
|
|
154
|
+
try { psmuxExec(["select-pane", "-t", newPaneId, "-T", paneTitle]); } catch { /* 무시 */ }
|
|
182
155
|
|
|
183
|
-
|
|
156
|
+
if (safeProgress) safeProgress({ type: "worker_added", paneName, cli: assignment.cli, paneTitle });
|
|
184
157
|
|
|
185
|
-
|
|
186
|
-
});
|
|
158
|
+
// 캡처 시작 + 컬러 배너 + 명령 dispatch
|
|
159
|
+
const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
|
|
160
|
+
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp });
|
|
161
|
+
startCapture(sessionName, newPaneId);
|
|
162
|
+
// pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
|
|
163
|
+
if (i > 0) await new Promise(r => setTimeout(r, 300));
|
|
164
|
+
const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
|
|
187
165
|
|
|
188
|
-
|
|
189
|
-
try { psmuxExec(["select-layout", "-t", sessionName, "tiled"]); } catch { /* 무시 */ }
|
|
166
|
+
if (safeProgress) safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
|
|
190
167
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
} else {
|
|
194
|
-
// ─── 기존 모드: 모든 pane을 한 번에 생성 ───
|
|
195
|
-
const paneCount = assignments.length + 1;
|
|
196
|
-
// A2b fix: 2x2 레이아웃은 최대 4 pane — 초과 시 tiled로 자동 전환
|
|
197
|
-
const effectiveLayout = (layout === "2x2" && paneCount > 4) ? "tiled" : layout;
|
|
198
|
-
const session = createPsmuxSession(sessionName, { layout: effectiveLayout, paneCount });
|
|
199
|
-
applyTrifluxTheme(sessionName);
|
|
200
|
-
if (safeProgress) safeProgress({ type: "session_created", sessionName, panes: session.panes });
|
|
168
|
+
dispatches.push({ ...dispatch, paneId: newPaneId, paneName, resultFile, cli: assignment.cli, role: assignment.role });
|
|
169
|
+
}
|
|
201
170
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
|
|
205
|
-
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp });
|
|
206
|
-
const scriptDir = join(RESULT_DIR, sessionName);
|
|
207
|
-
const dispatch = dispatchCommand(sessionName, paneName, cmd, { scriptDir, scriptName: paneName });
|
|
171
|
+
// 모든 split 완료 후 레이아웃 한 번만 정렬 (깜빡임 방지)
|
|
172
|
+
try { psmuxExec(["select-layout", "-t", sessionName, "tiled"]); } catch { /* 무시 */ }
|
|
208
173
|
|
|
209
|
-
|
|
210
|
-
// 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
|
|
211
|
-
// progressive 모드에서는 split-window 시 새 pane에 바로 타이틀이 설정되므로 문제없음
|
|
174
|
+
// v7.1.3: psmux 내부 대시보드 pane 제거 — WT 스플릿에서 tui-viewer 직접 실행
|
|
212
175
|
|
|
213
|
-
|
|
176
|
+
return dispatches;
|
|
177
|
+
}
|
|
214
178
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
179
|
+
/** 기존 batch 모드: 모든 pane을 한 번에 생성하여 dispatch */
|
|
180
|
+
function dispatchBatch(sessionName, assignments, layout, safeProgress) {
|
|
181
|
+
const paneCount = assignments.length + 1;
|
|
182
|
+
// A2b fix: 2x2 레이아웃은 최대 4 pane — 초과 시 tiled로 자동 전환
|
|
183
|
+
const effectiveLayout = (layout === "2x2" && paneCount > 4) ? "tiled" : layout;
|
|
184
|
+
const session = createPsmuxSession(sessionName, { layout: effectiveLayout, paneCount });
|
|
185
|
+
applyTrifluxTheme(sessionName);
|
|
186
|
+
if (safeProgress) safeProgress({ type: "session_created", sessionName, panes: session.panes });
|
|
187
|
+
|
|
188
|
+
return assignments.map((assignment, i) => {
|
|
189
|
+
const paneName = `worker-${i + 1}`;
|
|
190
|
+
const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
|
|
191
|
+
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp });
|
|
192
|
+
const scriptDir = join(RESULT_DIR, sessionName);
|
|
193
|
+
const dispatch = dispatchCommand(sessionName, paneName, cmd, { scriptDir, scriptName: paneName });
|
|
194
|
+
|
|
195
|
+
// P1 fix: 비-progressive에서는 pane 리네임 금지 — 캡처 로그 경로가 타이틀 기반이므로
|
|
196
|
+
// 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
|
|
197
|
+
// progressive 모드에서는 split-window 시 새 pane에 바로 타이틀이 설정되므로 문제없음
|
|
198
|
+
|
|
199
|
+
if (safeProgress) safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
|
|
200
|
+
|
|
201
|
+
return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role };
|
|
202
|
+
});
|
|
203
|
+
}
|
|
218
204
|
|
|
205
|
+
/**
|
|
206
|
+
* 모든 dispatch를 병렬 대기하며 완료 결과를 수집한다.
|
|
207
|
+
* @param {string} sessionName
|
|
208
|
+
* @param {Array} dispatches
|
|
209
|
+
* @param {number} timeoutSec
|
|
210
|
+
* @param {Function|null} safeProgress
|
|
211
|
+
* @param {number} progressIntervalSec
|
|
212
|
+
* @returns {Promise<Array<{d, completion, output}>>}
|
|
213
|
+
*/
|
|
214
|
+
async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec) {
|
|
219
215
|
// 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
|
|
220
|
-
|
|
216
|
+
return Promise.all(dispatches.map(async (d) => {
|
|
221
217
|
// onPoll → onProgress 변환 (throttle by progressIntervalSec)
|
|
222
218
|
const pollOpts = {};
|
|
223
219
|
if (safeProgress && progressIntervalSec > 0) {
|
|
@@ -258,7 +254,14 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
258
254
|
|
|
259
255
|
return { d, completion, output };
|
|
260
256
|
}));
|
|
257
|
+
}
|
|
261
258
|
|
|
259
|
+
/**
|
|
260
|
+
* git diff + handoff 파이프라인을 적용하여 최종 결과 배열을 반환한다.
|
|
261
|
+
* @param {Array<{d, completion, output}>} results
|
|
262
|
+
* @returns {Array}
|
|
263
|
+
*/
|
|
264
|
+
function collectResults(results) {
|
|
262
265
|
// B3 fix: git diff를 루프 밖에서 1회만 실행 (워커 수만큼 중복 방지)
|
|
263
266
|
let gitDiffFiles;
|
|
264
267
|
try {
|
|
@@ -267,7 +270,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
267
270
|
} catch { /* git 미설치 또는 non-repo — 무시 */ }
|
|
268
271
|
|
|
269
272
|
// handoff 파이프라인: parse → validate → format (각 워커 결과에 적용)
|
|
270
|
-
|
|
273
|
+
return results.map(({ d, completion, output }) => {
|
|
271
274
|
const handoffResult = processHandoff(output, {
|
|
272
275
|
exitCode: completion.exitCode,
|
|
273
276
|
resultFile: d.resultFile,
|
|
@@ -291,8 +294,45 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
291
294
|
handoffFallback: handoffResult.fallback,
|
|
292
295
|
};
|
|
293
296
|
});
|
|
297
|
+
}
|
|
294
298
|
|
|
295
|
-
|
|
299
|
+
/**
|
|
300
|
+
* 헤드리스 CLI 오케스트레이션 실행
|
|
301
|
+
*
|
|
302
|
+
* @param {string} sessionName — psmux 세션 이름
|
|
303
|
+
* @param {Array<{cli: string, prompt: string, role?: string}>} assignments
|
|
304
|
+
* @param {object} [opts]
|
|
305
|
+
* @param {number} [opts.timeoutSec=300] — 각 워커 타임아웃
|
|
306
|
+
* @param {string} [opts.layout='2x2'] — pane 레이아웃
|
|
307
|
+
* @param {(event: object) => void} [opts.onProgress] — 진행 콜백
|
|
308
|
+
* @param {number} [opts.progressIntervalSec=0] — N초마다 progress 이벤트 발화 (0=비활성)
|
|
309
|
+
* @param {boolean} [opts.progressive=true] — true면 pane을 하나씩 split-window로 추가 (실시간 스플릿)
|
|
310
|
+
* @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}> }}
|
|
311
|
+
*/
|
|
312
|
+
export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
313
|
+
const {
|
|
314
|
+
timeoutSec = 300,
|
|
315
|
+
layout = "2x2",
|
|
316
|
+
onProgress,
|
|
317
|
+
progressIntervalSec = 0,
|
|
318
|
+
progressive = true,
|
|
319
|
+
dashboard = false,
|
|
320
|
+
} = opts;
|
|
321
|
+
|
|
322
|
+
mkdirSync(RESULT_DIR, { recursive: true });
|
|
323
|
+
|
|
324
|
+
// onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
|
|
325
|
+
const safeProgress = onProgress
|
|
326
|
+
? (event) => { try { onProgress(event); } catch { /* 콜백 예외 삼킴 */ } }
|
|
327
|
+
: null;
|
|
328
|
+
|
|
329
|
+
const dispatches = progressive
|
|
330
|
+
? await dispatchProgressive(sessionName, assignments, layout, safeProgress)
|
|
331
|
+
: dispatchBatch(sessionName, assignments, layout, safeProgress);
|
|
332
|
+
|
|
333
|
+
const results = await awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec);
|
|
334
|
+
|
|
335
|
+
return { sessionName, results: collectResults(results) };
|
|
296
336
|
}
|
|
297
337
|
|
|
298
338
|
/**
|
|
@@ -374,6 +414,23 @@ function getWtDefaultFontSize() {
|
|
|
374
414
|
return 12;
|
|
375
415
|
}
|
|
376
416
|
|
|
417
|
+
/**
|
|
418
|
+
* 파일을 원자적으로 쓴다 — 임시 파일에 먼저 기록 후 rename으로 교체.
|
|
419
|
+
* 프로세스가 쓰기 도중 충돌해도 원본 파일이 손상되지 않는다.
|
|
420
|
+
* @param {string} filePath — 대상 파일 경로
|
|
421
|
+
* @param {string} data — 쓸 내용
|
|
422
|
+
*/
|
|
423
|
+
function atomicWriteSync(filePath, data) {
|
|
424
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
425
|
+
try {
|
|
426
|
+
writeFileSync(tmpPath, data, "utf8");
|
|
427
|
+
renameSync(tmpPath, filePath);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
try { writeFileSync(tmpPath.replace(/\.tmp$/, ".tmp.del"), ""); } catch { /* 무시 */ }
|
|
430
|
+
throw err;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
377
434
|
export function ensureWtProfile(workerCount = 2) {
|
|
378
435
|
const settingsPaths = [
|
|
379
436
|
join(process.env.LOCALAPPDATA || "", "Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json"),
|
|
@@ -401,6 +458,7 @@ export function ensureWtProfile(workerCount = 2) {
|
|
|
401
458
|
unfocusedAppearance: { opacity: 20 },
|
|
402
459
|
colorScheme: "One Half Dark",
|
|
403
460
|
font: { size: Math.max(6, getWtDefaultFontSize() - 1 - Math.floor(workerCount / 2)) },
|
|
461
|
+
closeOnExit: "always",
|
|
404
462
|
hidden: true, // 프로필 목록에는 숨김 (triflux에서만 사용)
|
|
405
463
|
};
|
|
406
464
|
|
|
@@ -410,7 +468,7 @@ export function ensureWtProfile(workerCount = 2) {
|
|
|
410
468
|
settings.profiles.list.push(profile);
|
|
411
469
|
}
|
|
412
470
|
|
|
413
|
-
|
|
471
|
+
atomicWriteSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
414
472
|
return true;
|
|
415
473
|
} catch { /* 파싱 실패 — 다음 경로 */ }
|
|
416
474
|
}
|
|
@@ -420,87 +478,31 @@ export function ensureWtProfile(workerCount = 2) {
|
|
|
420
478
|
// ─── v6.0.0: Lead-Direct Interactive Mode ───
|
|
421
479
|
|
|
422
480
|
/**
|
|
423
|
-
* Windows Terminal에서 psmux 세션을 자동 attach한다.
|
|
424
|
-
*
|
|
481
|
+
* Windows Terminal에서 psmux 세션을 split-pane으로 자동 attach한다.
|
|
482
|
+
* WT_SESSION 안에서만 동작하며, 새 탭(nt)은 생성하지 않는다.
|
|
425
483
|
*
|
|
426
484
|
* @param {string} sessionName — attach할 psmux 세션 이름
|
|
427
|
-
* @param {object} [opts]
|
|
428
|
-
* @param {
|
|
485
|
+
* @param {object} [opts] — 예약 (현재 미사용)
|
|
486
|
+
* @param {number} [workerCount=2]
|
|
429
487
|
* @returns {boolean} 성공 여부
|
|
430
488
|
*/
|
|
431
489
|
export function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
|
|
490
|
+
// 보안: sessionName 셸 주입 방지 — 영숫자, 하이픈, 언더스코어만 허용
|
|
491
|
+
const safeName = String(sessionName).replace(/[^a-zA-Z0-9_\-]/g, "");
|
|
492
|
+
sessionName = safeName || "tfx-session";
|
|
493
|
+
if (!process.env.WT_SESSION) return false;
|
|
494
|
+
try { execSync("where wt.exe", { stdio: "ignore" }); } catch { return false; }
|
|
438
495
|
ensureWtProfile(workerCount);
|
|
439
|
-
|
|
440
|
-
const mode = opts.mode || "auto";
|
|
441
|
-
|
|
442
|
-
if (mode === "split" && process.env.WT_SESSION) {
|
|
443
|
-
// inner split — 같은 WT 창에서 가로 분할. psmux를 직접 실행 (pwsh 불필요).
|
|
444
|
-
try {
|
|
445
|
-
const child = spawn("wt.exe", [
|
|
446
|
-
"-w", "0", "sp", "-H", "-s", "0.50",
|
|
447
|
-
"--profile", "triflux", "--title", "triflux",
|
|
448
|
-
"--", "psmux", "attach", "-t", sessionName,
|
|
449
|
-
], { detached: true, stdio: "ignore" });
|
|
450
|
-
child.unref();
|
|
451
|
-
try { spawn("wt.exe", ["-w", "0", "mf", "up"], { detached: true, stdio: "ignore" }).unref(); } catch { /* 무시 */ }
|
|
452
|
-
return true;
|
|
453
|
-
} catch { /* fallthrough to window */ }
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// v6.1.0: 읽기전용 뷰어 + 포커스 복원 + 윈도우 배치
|
|
457
|
-
const logDir = join(tmpdir(), "psmux-steering", sessionName).replace(/\\/g, "/");
|
|
458
|
-
const cols = opts.cols || Math.max(100, 60 + workerCount * 15);
|
|
459
|
-
const rows = opts.rows || Math.max(25, 15 + workerCount * 4);
|
|
460
|
-
|
|
461
|
-
// 읽기전용 뷰어 스크립트 생성
|
|
462
|
-
const viewerScript = join(tmpdir(), "tfx-viewer-" + sessionName + ".ps1").replace(/\\/g, "/");
|
|
463
|
-
writeFileSync(viewerScript, [
|
|
464
|
-
"$Host.UI.RawUI.WindowTitle = '" + sessionName + "'",
|
|
465
|
-
"Write-Host \"`e[38;5;214m⬡ triflux viewer (read-only)`e[0m — " + sessionName + "\"",
|
|
466
|
-
"Write-Host 'Log: " + logDir + "'",
|
|
467
|
-
"Write-Host '---'",
|
|
468
|
-
"for ($i=0; $i -lt 30; $i++) { if ((Get-ChildItem '" + logDir + "' -Filter *.log -ErrorAction SilentlyContinue).Count -gt 0) { break }; Start-Sleep 1 }",
|
|
469
|
-
"if (-not (Get-ChildItem '" + logDir + "' -Filter *.log -ErrorAction SilentlyContinue)) { Write-Host 'No log files found after 30s'; exit 1 }",
|
|
470
|
-
"Get-Content -Path '" + logDir + "\\*.log' -Wait -Tail 50",
|
|
471
|
-
].join("\n"), "utf8");
|
|
472
|
-
|
|
473
|
-
// T1 fix: 현재 HWND를 먼저 저장 → 탭 추가 → 즉시 원래 탭으로 복귀
|
|
474
|
-
// 2단계 포커스 복원: (1) 탭 추가 전 HWND 캡처 (2) 탭 추가 후 150ms+SendKeys
|
|
475
|
-
const saveHwndScript = [
|
|
476
|
-
"Add-Type -AssemblyName System.Windows.Forms",
|
|
477
|
-
"$before = [System.Windows.Forms.Form]::ActiveForm",
|
|
478
|
-
// 150ms로 단축 (300ms → 150ms) — WT 탭 생성은 ~100ms
|
|
479
|
-
"Start-Sleep -Milliseconds 150",
|
|
480
|
-
"[System.Windows.Forms.SendKeys]::SendWait('^+{TAB}')",
|
|
481
|
-
].join("; ");
|
|
482
|
-
|
|
483
|
-
// WT new-tab은 --size 미지원 (window-level only). 기존 창(-w 0)에 탭 추가만.
|
|
484
|
-
const wtArgs = ["-w", "0", "nt", "--profile", "triflux", "--title", sessionName];
|
|
485
|
-
wtArgs.push("--", "pwsh.exe", "-NoProfile", "-NoLogo", "-File", viewerScript);
|
|
486
|
-
|
|
487
496
|
try {
|
|
488
|
-
const child = spawn("wt.exe",
|
|
497
|
+
const child = spawn("wt.exe", [
|
|
498
|
+
"-w", "0", "sp", "-H", "-s", "0.50",
|
|
499
|
+
"--profile", "triflux", "--title", "triflux",
|
|
500
|
+
"--", "psmux", "attach", "-t", sessionName,
|
|
501
|
+
], { detached: true, stdio: "ignore" });
|
|
489
502
|
child.unref();
|
|
490
|
-
|
|
491
|
-
return
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// T1: 포커스 복원 (150ms — 기존 300ms에서 단축)
|
|
495
|
-
for (const shell of ["pwsh.exe", "powershell.exe"]) {
|
|
496
|
-
try {
|
|
497
|
-
spawn(shell, ["-NoProfile", "-NonInteractive", "-Command", saveHwndScript], {
|
|
498
|
-
detached: true, stdio: "ignore",
|
|
499
|
-
}).unref();
|
|
500
|
-
break;
|
|
501
|
-
} catch { /* 다음 shell */ }
|
|
502
|
-
}
|
|
503
|
-
return true;
|
|
503
|
+
try { spawn("wt.exe", ["-w", "0", "mf", "up"], { detached: true, stdio: "ignore" }).unref(); } catch { /* 무시 */ }
|
|
504
|
+
return true;
|
|
505
|
+
} catch { return false; }
|
|
504
506
|
}
|
|
505
507
|
|
|
506
508
|
/**
|
|
@@ -598,7 +600,7 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
|
|
|
598
600
|
// v7.0: psmux attach로 대시보드+워커 전체 세션을 WT 탭에 표시
|
|
599
601
|
attachDashboardTab(sessionName, assignments.length);
|
|
600
602
|
} else {
|
|
601
|
-
autoAttachTerminal(sessionName, {
|
|
603
|
+
autoAttachTerminal(sessionName, {}, assignments.length);
|
|
602
604
|
}
|
|
603
605
|
}
|
|
604
606
|
if (userOnProgress) userOnProgress(event);
|