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.
Files changed (73) hide show
  1. package/.claude-plugin/marketplace.json +31 -31
  2. package/.claude-plugin/plugin.json +22 -23
  3. package/bin/triflux.mjs +18 -5
  4. package/hooks/keyword-rules.json +393 -361
  5. package/hub/bridge.mjs +799 -786
  6. package/hub/delegator/contracts.mjs +37 -38
  7. package/hub/delegator/schema/delegator-tools.schema.json +250 -250
  8. package/hub/delegator/service.mjs +307 -302
  9. package/hub/intent.mjs +108 -11
  10. package/hub/lib/process-utils.mjs +20 -0
  11. package/hub/pipe.mjs +589 -589
  12. package/hub/pipeline/gates/confidence.mjs +1 -1
  13. package/hub/pipeline/gates/selfcheck.mjs +2 -4
  14. package/hub/pipeline/state.mjs +191 -187
  15. package/hub/pipeline/transitions.mjs +124 -120
  16. package/hub/public/dashboard.html +355 -349
  17. package/hub/quality/deslop.mjs +5 -3
  18. package/hub/reflexion.mjs +5 -1
  19. package/hub/research.mjs +6 -1
  20. package/hub/router.mjs +791 -782
  21. package/hub/server.mjs +893 -822
  22. package/hub/store.mjs +807 -778
  23. package/hub/team/agent-map.json +10 -0
  24. package/hub/team/ansi.mjs +3 -4
  25. package/hub/team/cli/commands/control.mjs +43 -43
  26. package/hub/team/cli/commands/interrupt.mjs +36 -36
  27. package/hub/team/cli/commands/kill.mjs +3 -3
  28. package/hub/team/cli/commands/send.mjs +37 -37
  29. package/hub/team/cli/commands/start/index.mjs +18 -8
  30. package/hub/team/cli/commands/start/parse-args.mjs +3 -1
  31. package/hub/team/cli/commands/start/start-headless.mjs +4 -1
  32. package/hub/team/cli/commands/status.mjs +87 -87
  33. package/hub/team/cli/commands/stop.mjs +1 -1
  34. package/hub/team/cli/commands/task.mjs +1 -1
  35. package/hub/team/cli/index.mjs +41 -39
  36. package/hub/team/cli/manifest.mjs +29 -28
  37. package/hub/team/cli/services/hub-client.mjs +37 -0
  38. package/hub/team/cli/services/state-store.mjs +26 -12
  39. package/hub/team/dashboard.mjs +11 -4
  40. package/hub/team/handoff.mjs +12 -0
  41. package/hub/team/headless.mjs +202 -200
  42. package/hub/team/native-supervisor.mjs +386 -346
  43. package/hub/team/nativeProxy.mjs +680 -692
  44. package/hub/team/staleState.mjs +361 -369
  45. package/hub/team/tui-viewer.mjs +27 -3
  46. package/hub/team/tui.mjs +1 -0
  47. package/hub/token-mode.mjs +114 -24
  48. package/hub/workers/delegator-mcp.mjs +1059 -1057
  49. package/hud/colors.mjs +88 -0
  50. package/hud/constants.mjs +78 -0
  51. package/hud/hud-qos-status.mjs +206 -1872
  52. package/hud/providers/claude.mjs +309 -0
  53. package/hud/providers/codex.mjs +151 -0
  54. package/hud/providers/gemini.mjs +320 -0
  55. package/hud/renderers.mjs +424 -0
  56. package/hud/terminal.mjs +140 -0
  57. package/hud/utils.mjs +271 -0
  58. package/package.json +1 -2
  59. package/scripts/__tests__/keyword-detector.test.mjs +234 -234
  60. package/scripts/headless-guard-fast.sh +21 -0
  61. package/scripts/headless-guard.mjs +26 -6
  62. package/scripts/lib/keyword-rules.mjs +166 -168
  63. package/scripts/setup.mjs +720 -690
  64. package/scripts/tfx-route-post.mjs +424 -424
  65. package/scripts/tfx-route.sh +1663 -1650
  66. package/scripts/tmp-cleanup.mjs +74 -0
  67. package/skills/tfx-auto/SKILL.md +279 -278
  68. package/skills/tfx-auto-codex/SKILL.md +98 -77
  69. package/skills/tfx-codex/SKILL.md +65 -65
  70. package/skills/tfx-gemini/SKILL.md +83 -82
  71. package/skills/tfx-hub/SKILL.md +205 -136
  72. package/skills/tfx-multi/SKILL.md +11 -5
  73. package/.mcp.json +0 -8
@@ -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, execFileSync, spawn } from "node:child_process";
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 { HANDOFF_INSTRUCTION, processHandoff } from "./handoff.mjs";
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 타입 매핑 (route_agent() 미러) */
34
- const AGENT_TO_CLI = {
35
- executor: "codex", "build-fixer": "codex", debugger: "codex", "deep-executor": "codex",
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 지시는 프롬프트에 삽입하지 않음 headless 후처리(processHandoff)에서 처리
78
- // psmux send-keys 줄바꿈 문제 + codex exec "---" 인자 충돌 방지
79
- const fullPrompt = `${prompt}${mcpHint}`;
80
- const escaped = fullPrompt.replace(/'/g, "''");
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 '${escaped}' -o '${resultFile}' --color never`;
98
+ return `${cls}codex exec (Get-Content -Raw '${promptFile}') -o '${resultFile}' --color never`;
86
99
  case "gemini":
87
- return `${cls}gemini -p '${escaped}' -o text > '${resultFile}' 2>'${resultFile}.err'`;
100
+ return `${cls}gemini -p (Get-Content -Raw '${promptFile}') -o text > '${resultFile}' 2>'${resultFile}.err'`;
88
101
  case "claude":
89
- return `${cls}claude -p '${escaped}' --output-format text > '${resultFile}' 2>&1`;
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
- * 헤드리스 CLI 오케스트레이션 실행
111
- *
112
- * @param {string} sessionName — psmux 세션 이름
113
- * @param {Array<{cli: string, prompt: string, role?: string}>} assignments
114
- * @param {object} [opts]
115
- * @param {number} [opts.timeoutSec=300] 워커 타임아웃
116
- * @param {string} [opts.layout='2x2'] pane 레이아웃
117
- * @param {(event: object) => void} [opts.onProgress] — 진행 콜백
118
- * @param {number} [opts.progressIntervalSec=0] — N초마다 progress 이벤트 발화 (0=비활성)
119
- * @param {boolean} [opts.progressive=true] true면 pane을 하나씩 split-window로 추가 (실시간 스플릿)
120
- * @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}> }}
121
- */
122
- export async function runHeadless(sessionName, assignments, opts = {}) {
123
- const {
124
- timeoutSec = 300,
125
- layout = "2x2",
126
- onProgress,
127
- progressIntervalSec = 0,
128
- progressive = true,
129
- dashboard = false,
130
- } = opts;
131
-
132
- mkdirSync(RESULT_DIR, { recursive: true });
133
-
134
- // onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
135
- const safeProgress = onProgress
136
- ? (event) => { try { onProgress(event); } catch { /* 콜백 예외 삼킴 */ } }
137
- : null;
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
- // 캡처 시작 + 컬러 배너 + 명령 dispatch
176
- const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
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
- if (safeProgress) safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
156
+ if (safeProgress) safeProgress({ type: "worker_added", paneName, cli: assignment.cli, paneTitle });
184
157
 
185
- return { ...dispatch, paneId: newPaneId, paneName, resultFile, cli: assignment.cli, role: assignment.role };
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
- // 모든 split 완료 레이아웃 번만 정렬 (깜빡임 방지)
189
- try { psmuxExec(["select-layout", "-t", sessionName, "tiled"]); } catch { /* 무시 */ }
166
+ if (safeProgress) safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
190
167
 
191
- // v7.1.3: psmux 내부 대시보드 pane 제거 WT 스플릿에서 tui-viewer 직접 실행
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
- dispatches = assignments.map((assignment, i) => {
203
- const paneName = `worker-${i + 1}`;
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
- // P1 fix: 비-progressive에서는 pane 리네임 금지 캡처 로그 경로가 타이틀 기반이므로
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
- if (safeProgress) safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
176
+ return dispatches;
177
+ }
214
178
 
215
- return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role };
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
- const results = await Promise.all(dispatches.map(async (d) => {
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
- const finalResults = results.map(({ d, completion, output }) => {
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
- return { sessionName, results: finalResults };
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
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
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
- * 별도 창이 열리며 사용자가 실시간으로 CLI 출력을 볼 수 있다.
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 {string} [opts.position] — "right" | "left" | 없으면 기본 위치
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
- try {
433
- execSync("where wt.exe", { stdio: "ignore" });
434
- } catch {
435
- return false;
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", wtArgs, { detached: true, stdio: "ignore" });
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
- } catch {
491
- return false;
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, { mode: "window" }, assignments.length);
603
+ autoAttachTerminal(sessionName, {}, assignments.length);
602
604
  }
603
605
  }
604
606
  if (userOnProgress) userOnProgress(event);