@triflux/remote 10.1.0 → 10.26.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.
Files changed (162) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +61 -0
  3. package/hub/server.mjs +2179 -709
  4. package/hub/store-adapter.mjs +59 -26
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +78 -17
  9. package/hub/team/build-worker-prompt.mjs +73 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +1130 -0
  12. package/hub/team/claude-native-bridge.mjs +1011 -0
  13. package/hub/team/claude-session-projection.mjs +74 -0
  14. package/hub/team/cli/commands/attach.mjs +57 -37
  15. package/hub/team/cli/commands/control.mjs +29 -8
  16. package/hub/team/cli/commands/debug.mjs +95 -74
  17. package/hub/team/cli/commands/focus.mjs +80 -53
  18. package/hub/team/cli/commands/interrupt.mjs +18 -6
  19. package/hub/team/cli/commands/kill.mjs +16 -5
  20. package/hub/team/cli/commands/list.mjs +31 -24
  21. package/hub/team/cli/commands/send.mjs +19 -6
  22. package/hub/team/cli/commands/start/index.mjs +151 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +97 -13
  24. package/hub/team/cli/commands/start/start-headless.mjs +155 -37
  25. package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
  26. package/hub/team/cli/commands/start/start-mux.mjs +122 -73
  27. package/hub/team/cli/commands/start/start-wt.mjs +86 -69
  28. package/hub/team/cli/commands/status.mjs +43 -14
  29. package/hub/team/cli/commands/stop.mjs +11 -4
  30. package/hub/team/cli/commands/task.mjs +8 -3
  31. package/hub/team/cli/commands/tasks.mjs +13 -13
  32. package/hub/team/cli/help.mjs +1 -1
  33. package/hub/team/cli/index.mjs +2 -2
  34. package/hub/team/cli/manifest.mjs +38 -8
  35. package/hub/team/cli/render.mjs +52 -30
  36. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  37. package/hub/team/cli/services/hub-client.mjs +44 -16
  38. package/hub/team/cli/services/member-selector.mjs +37 -30
  39. package/hub/team/cli/services/native-control.mjs +144 -116
  40. package/hub/team/cli/services/runtime-mode.mjs +61 -4
  41. package/hub/team/cli/services/state-store.mjs +66 -9
  42. package/hub/team/cli/services/task-model.mjs +40 -30
  43. package/hub/team/codex-review.mjs +521 -0
  44. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  45. package/hub/team/conductor-registry.mjs +121 -0
  46. package/hub/team/conductor.mjs +1379 -671
  47. package/hub/team/consensus-meta.mjs +176 -0
  48. package/hub/team/dashboard-anchor.mjs +4 -5
  49. package/hub/team/dashboard-layout.mjs +3 -1
  50. package/hub/team/dashboard-open.mjs +20 -118
  51. package/hub/team/dashboard.mjs +76 -28
  52. package/hub/team/event-log.mjs +18 -10
  53. package/hub/team/execution-mode.mjs +256 -0
  54. package/hub/team/extract-completion-payload.mjs +110 -0
  55. package/hub/team/git-preflight.mjs +286 -0
  56. package/hub/team/handoff.mjs +49 -18
  57. package/hub/team/headless-bridge-session.mjs +17 -0
  58. package/hub/team/headless.mjs +2213 -1156
  59. package/hub/team/health-probe.mjs +232 -69
  60. package/hub/team/interactive-native-launcher.mjs +106 -0
  61. package/hub/team/interactive-native-worker.mjs +80 -0
  62. package/hub/team/interactive-tui-transport.mjs +465 -0
  63. package/hub/team/launcher-template.mjs +49 -17
  64. package/hub/team/lead-control.mjs +91 -4
  65. package/hub/team/mcp-selector.mjs +145 -0
  66. package/hub/team/native-supervisor.mjs +90 -26
  67. package/hub/team/native.mjs +120 -42
  68. package/hub/team/nativeProxy.mjs +184 -113
  69. package/hub/team/notify.mjs +147 -78
  70. package/hub/team/orchestrator.mjs +173 -161
  71. package/hub/team/pane.mjs +43 -20
  72. package/hub/team/process-cleanup.mjs +69 -25
  73. package/hub/team/psmux.mjs +1022 -281
  74. package/hub/team/recovery-store.mjs +149 -0
  75. package/hub/team/remote-probe.mjs +68 -52
  76. package/hub/team/remote-session.mjs +131 -59
  77. package/hub/team/remote-watcher.mjs +61 -33
  78. package/hub/team/retry-state-machine.mjs +336 -0
  79. package/hub/team/routing.mjs +51 -25
  80. package/hub/team/runtime-strategy.mjs +200 -0
  81. package/hub/team/schemas/worker-completion.json +39 -0
  82. package/hub/team/sentinel-capture.mjs +127 -0
  83. package/hub/team/session-sync.mjs +153 -6
  84. package/hub/team/session.mjs +574 -611
  85. package/hub/team/shared.mjs +13 -13
  86. package/hub/team/staleState.mjs +72 -30
  87. package/hub/team/swarm-cli.mjs +390 -0
  88. package/hub/team/swarm-hypervisor.mjs +1544 -193
  89. package/hub/team/swarm-intent.mjs +216 -0
  90. package/hub/team/swarm-locks.mjs +140 -25
  91. package/hub/team/swarm-planner.mjs +121 -21
  92. package/hub/team/swarm-preflight.mjs +319 -0
  93. package/hub/team/swarm-reconciler.mjs +128 -23
  94. package/hub/team/synapse-cli.mjs +458 -0
  95. package/hub/team/synapse-http.mjs +60 -0
  96. package/hub/team/synapse-registry.mjs +329 -0
  97. package/hub/team/terminal-opener.mjs +216 -0
  98. package/hub/team/tui-core.mjs +292 -0
  99. package/hub/team/tui-lite.mjs +237 -148
  100. package/hub/team/tui-remote-adapter.mjs +18 -34
  101. package/hub/team/tui-synapse.mjs +223 -0
  102. package/hub/team/tui-viewer.mjs +108 -48
  103. package/hub/team/tui-widgets.mjs +262 -0
  104. package/hub/team/tui.mjs +931 -439
  105. package/hub/team/worker-completion-validator.mjs +84 -0
  106. package/hub/team/worker-sandbox.mjs +97 -0
  107. package/hub/team/worker-signal.mjs +263 -0
  108. package/hub/team/worker-signal.types.d.ts +52 -0
  109. package/hub/team/worktree-lifecycle.mjs +517 -63
  110. package/hub/team/wt-manager.mjs +297 -26
  111. package/hub/tools.mjs +477 -252
  112. package/hub/tray.mjs +411 -368
  113. package/hub/workers/antigravity-route-worker.mjs +169 -0
  114. package/hub/workers/claude-worker.mjs +162 -118
  115. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  116. package/hub/workers/codex-mcp.mjs +724 -507
  117. package/hub/workers/delegator-mcp.mjs +570 -499
  118. package/hub/workers/factory.mjs +103 -21
  119. package/hub/workers/gemini-worker.mjs +126 -284
  120. package/hub/workers/interface.mjs +24 -2
  121. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  122. package/hub/workers/worker-utils.mjs +23 -17
  123. package/package.json +4 -3
  124. package/scripts/lib/agent-json.mjs +27 -0
  125. package/scripts/lib/async.mjs +174 -0
  126. package/scripts/lib/claudemd-scanner.mjs +223 -0
  127. package/scripts/lib/cli-agy.mjs +62 -0
  128. package/scripts/lib/cli-claude.mjs +78 -0
  129. package/scripts/lib/cli-codex.mjs +199 -0
  130. package/scripts/lib/cli-gemini.mjs +37 -0
  131. package/scripts/lib/codex-recovery.sh +50 -0
  132. package/scripts/lib/context.mjs +67 -0
  133. package/scripts/lib/cross-review-utils.mjs +59 -0
  134. package/scripts/lib/doctor-env-checks.mjs +121 -0
  135. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  136. package/scripts/lib/env-probe.mjs +269 -0
  137. package/scripts/lib/env.mjs +14 -0
  138. package/scripts/lib/gemini-profiles.mjs +119 -0
  139. package/scripts/lib/handoff.mjs +187 -0
  140. package/scripts/lib/hook-utils.mjs +16 -0
  141. package/scripts/lib/hub.mjs +48 -0
  142. package/scripts/lib/keyword-rules.mjs +195 -0
  143. package/scripts/lib/logger.mjs +105 -0
  144. package/scripts/lib/mcp-filter.mjs +883 -0
  145. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  146. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  147. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  148. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  149. package/scripts/lib/mcp-health.mjs +773 -0
  150. package/scripts/lib/mcp-manifest.mjs +89 -0
  151. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  152. package/scripts/lib/pid.mjs +63 -0
  153. package/scripts/lib/process-utils.mjs +26 -0
  154. package/scripts/lib/psmux-info.mjs +224 -0
  155. package/scripts/lib/quota.mjs +47 -0
  156. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  157. package/scripts/lib/skill-state.mjs +220 -0
  158. package/scripts/lib/skill-template.mjs +375 -0
  159. package/scripts/lib/team.mjs +75 -0
  160. package/scripts/lib/timeout.mjs +66 -0
  161. package/scripts/lib/tmp.mjs +26 -0
  162. package/scripts/lib/toml.mjs +113 -0
@@ -1,1156 +1,2213 @@
1
- // hub/team/headless.mjs — 헤드리스 CLI 오케스트레이션
2
- // psmux pane에서 CLI를 헤드리스 모드로 실행하고 결과를 수집한다.
3
- // v5.2.0: 기본 headless 엔진 (runHeadless, runHeadlessWithCleanup)
4
- // v6.0.0: Lead-direct 모드 (runHeadlessInteractive, autoAttachTerminal)
5
- // 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
6
- import { join } from "node:path";
7
- import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, statSync } from "node:fs";
8
- import { tmpdir } from "node:os";
9
- import { execSync, spawn } from "node:child_process";
10
- import { createRequire } from "node:module";
11
- import { randomUUID } from "node:crypto";
12
- import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
13
- import {
14
- createPsmuxSession,
15
- killPsmuxSession,
16
- psmuxSessionExists,
17
- dispatchCommand,
18
- waitForCompletion,
19
- capturePsmuxPane,
20
- startCapture,
21
- psmuxExec,
22
- } from "./psmux.mjs";
23
- import { HANDOFF_INSTRUCTION_SHORT, processHandoff } from "./handoff.mjs";
24
- import { getBackend } from "./backend.mjs";
25
- import { resolveDashboardLayout } from "./dashboard-layout.mjs";
26
- import { createLogDashboard } from "./tui.mjs";
27
-
28
- const RESULT_DIR = join(tmpdir(), "tfx-headless");
29
-
30
- /** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
31
- const CLI_BRAND = {
32
- codex: { emoji: "\u{26AA}", label: "Codex", ansi: "\x1b[97m" }, // ⚪ bright white (codexWhite)
33
- gemini: { emoji: "\u{1F535}", label: "Gemini", ansi: "\x1b[38;5;39m" }, // 🔵 geminiBlue
34
- claude: { emoji: "\u{1F7E0}", label: "Claude", ansi: "\x1b[38;2;232;112;64m" }, // 🟠 claudeOrange
35
- };
36
- const _ANSI_RESET = "\x1b[0m";
37
- const _ANSI_DIM = "\x1b[2m";
38
-
39
- /** 에이전트 역할명 → CLI 타입 매핑 (단일 소스: agent-map.json) */
40
- const _require = createRequire(import.meta.url);
41
- const AGENT_TO_CLI = _require("./agent-map.json");
42
-
43
- /**
44
- * 에이전트 역할명 또는 CLI 이름을 CLI 타입("codex"|"gemini"|"claude")으로 해석한다.
45
- * route_agent()가 적용되지 않는 headless 경로에서 사용.
46
- * @param {string} agentOrCli — "executor", "codex", "designer" 등
47
- * @returns {'codex'|'gemini'|'claude'} CLI 타입
48
- */
49
- export function resolveCliType(agentOrCli) {
50
- return AGENT_TO_CLI[agentOrCli] || agentOrCli;
51
- }
52
-
53
- /** MCP 프로필별 프롬프트 힌트 (tfx-route.sh resolve_mcp_policy의 경량 미러) */
54
- const MCP_PROFILE_HINTS = {
55
- implement: "You have full filesystem read/write access. Implement changes directly.",
56
- analyze: "Focus on reading and analyzing the codebase. Prefer analysis over modification.",
57
- review: "Review the code for quality, security, and correctness.",
58
- docs: "Focus on documentation and explanation tasks.",
59
- };
60
-
61
- /**
62
- * CLI별 헤드리스 명령 빌더
63
- * @param {'codex'|'gemini'|'claude'} cli
64
- * @param {string} prompt — 실행할 프롬프트
65
- * @param {string} resultFile — 결과 저장 파일 경로
66
- * @param {object} [opts]
67
- * @param {boolean} [opts.handoff=true]
68
- * @param {string} [opts.mcp] — MCP 프로필 ("implement"|"analyze"|"review"|"docs")
69
- * @param {string} [opts.contextFile] — 컨텍스트 파일 경로 (최대 32KB, UTF-8 안전 절단)
70
- * @returns {string} PowerShell 명령
71
- */
72
- export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
73
- const { handoff = true, mcp, contextFile, model, cwd } = opts;
74
- const resolvedCli = resolveCliType(cli);
75
-
76
- // contextFile 처리: 32KB(32768 bytes) 초과 시 UTF-8 안전 절단
77
- let contextPrefix = "";
78
- if (contextFile && existsSync(contextFile)) {
79
- let ctx = readFileSync(contextFile, "utf8");
80
- if (Buffer.byteLength(ctx, "utf8") > 32768) {
81
- ctx = Buffer.from(ctx).subarray(0, 32768).toString("utf8");
82
- }
83
- if (ctx.length > 0) {
84
- contextPrefix = `<prior_context>\n${ctx}\n</prior_context>\n\n`;
85
- }
86
- }
87
-
88
- const mcpHint = mcp && MCP_PROFILE_HINTS[mcp] ? ` [MCP: ${mcp}] ${MCP_PROFILE_HINTS[mcp]}` : "";
89
- // P2: HANDOFF 지시를 프롬프트에 삽입 (워커가 구조화된 handoff 블록을 출력하도록)
90
- const handoffHint = handoff ? `\n\n${HANDOFF_INSTRUCTION_SHORT}` : "";
91
- const fullPrompt = `${contextPrefix}${prompt}${mcpHint}${handoffHint}`;
92
-
93
- // 보안: 프롬프트를 임시 파일에 쓰고 파일 참조로 전달 (셸 주입 방지)
94
- if (!existsSync(RESULT_DIR)) mkdirSync(RESULT_DIR, { recursive: true });
95
- const promptFile = join(RESULT_DIR, "prompt-" + randomUUID().slice(0, 8) + ".txt").replace(/\\/g, "/");
96
- writeFileSync(promptFile, fullPrompt, "utf8");
97
-
98
- const backend = getBackend(resolvedCli);
99
- const promptExpr = `(Get-Content -Raw '${promptFile}')`;
100
- const backendCommand = backend.buildArgs(promptExpr, resultFile, { ...opts, model });
101
- const safeCwd = typeof cwd === "string" ? cwd.trim().replace(/[\r\n\x00-\x1f]/g, "") : "";
102
- if (safeCwd && (safeCwd.startsWith("\\\\") || safeCwd.startsWith("//"))) {
103
- throw new Error("[headless] UNC 경로는 cwd로 사용할 수 없습니다: " + safeCwd);
104
- }
105
- if (!safeCwd) return backendCommand;
106
-
107
- return `Set-Location -LiteralPath '${escapePwshSingleQuoted(safeCwd)}'; ${backendCommand}`;
108
- }
109
-
110
- /**
111
- * 결과 파일 읽기 (없으면 capture-pane fallback)
112
- * @param {string} resultFile
113
- * @param {string} paneId
114
- * @returns {string}
115
- */
116
- function readResult(resultFile, paneId) {
117
- if (existsSync(resultFile)) {
118
- return readFileSync(resultFile, "utf8").trim();
119
- }
120
- // fallback 1: stderr 파일 (codex 실패 시 원인 추적)
121
- const errFile = `${resultFile}.err`;
122
- if (existsSync(errFile)) {
123
- const stderr = readFileSync(errFile, "utf8").trim();
124
- if (stderr) return `[stderr] ${stderr}`;
125
- }
126
- // fallback 2: capture-pane (paneId = "tfx:0.1" 형태)
127
- return capturePsmuxPane(paneId, 30);
128
- }
129
-
130
- // ─── Stall Detection ───
131
-
132
- /** Stall detection 기본값 (immutable) */
133
- export const STALL_DEFAULTS = Object.freeze({
134
- pollInterval: 5_000,
135
- stallTimeout: 120_000,
136
- completionTimeout: 900_000,
137
- maxRestarts: 2,
138
- });
139
-
140
- /** CLI pane stall 감지 에러 (STALL_EXHAUSTED | COMPLETION_TIMEOUT) */
141
- export class StallError extends Error {
142
- constructor(message, { code = "STALL_DETECTED", category = "transient", recovery = "" } = {}) {
143
- super(message);
144
- this.name = "StallError";
145
- this.code = code;
146
- this.category = category;
147
- this.recovery = recovery;
148
- }
149
- }
150
-
151
- /**
152
- * Stall 모니터 팩토리 — output + resultFile mtime 하이브리드 감지
153
- * @param {string} paneId
154
- * @param {string} resultFile
155
- * @param {{ stallTimeout: number }} config
156
- * @param {{ capturePsmuxPane?: Function, statSync?: Function }} [deps]
157
- * @returns {{ poll: () => { snapshot: string, mtimeChanged: boolean, stalled: boolean, elapsed: number } }}
158
- */
159
- export function createStallMonitor(paneId, resultFile, config, deps = {}) {
160
- const capture = deps.capturePsmuxPane || capturePsmuxPane;
161
- const stat = deps.statSync || statSync;
162
- let lastSnapshot = "";
163
- let lastMtime = 0;
164
- let lastChangeAt = Date.now();
165
-
166
- try { lastMtime = stat(resultFile).mtimeMs; } catch { /* not created yet */ }
167
-
168
- return Object.freeze({
169
- poll() {
170
- const snapshot = capture(paneId, 50);
171
- let currentMtime = 0;
172
- try { currentMtime = stat(resultFile).mtimeMs; } catch { /* ignore */ }
173
-
174
- const outputChanged = snapshot !== lastSnapshot;
175
- const mtimeChanged = currentMtime > 0 && currentMtime !== lastMtime;
176
-
177
- if (outputChanged || mtimeChanged) {
178
- lastChangeAt = Date.now();
179
- lastSnapshot = snapshot;
180
- if (mtimeChanged) lastMtime = currentMtime;
181
- }
182
-
183
- const elapsed = Date.now() - lastChangeAt;
184
- return Object.freeze({
185
- snapshot,
186
- mtimeChanged,
187
- stalled: elapsed >= config.stallTimeout,
188
- elapsed,
189
- });
190
- },
191
- });
192
- }
193
-
194
- /**
195
- * 하이브리드 stall 감지 대기 — output 변화 + resultFile mtime 모니터링.
196
- * 2분 무변화 시 pane kill → re-dispatch (최대 2회 재시작).
197
- *
198
- * @param {string} sessionName
199
- * @param {string} paneId — 현재 pane 타겟 (예: "tfx:0.1")
200
- * @param {string} resultFile — 결과 저장 파일 경로
201
- * @param {object} [opts]
202
- * @param {number} [opts.pollInterval=5000] — 폴링 간격 ms
203
- * @param {number} [opts.stallTimeout=120000] — 무변화 stall 판정 ms
204
- * @param {number} [opts.completionTimeout=900000] — 전체 타임아웃 ms
205
- * @param {number} [opts.maxRestarts=2] — 최대 재시작 횟수
206
- * @param {string} [opts.command] — re-dispatch용 원본 명령
207
- * @param {string} [opts.token] — completion token
208
- * @param {(snapshot: string) => void} [opts.onPoll] — 폴링 콜백
209
- * @returns {Promise<{ matched: boolean, exitCode: number|null, restarts: number, stallDetected: boolean }>}
210
- */
211
- export async function waitForCompletionWithStallDetect(sessionName, paneId, resultFile, opts = {}) {
212
- const {
213
- pollInterval = 5000,
214
- stallTimeout = 120000,
215
- completionTimeout = 900000,
216
- maxRestarts = 2,
217
- command,
218
- token,
219
- onPoll,
220
- _deps,
221
- } = opts;
222
-
223
- // 의존성 (테스트 시 _deps로 주입 가능)
224
- const deps = _deps || {};
225
- const _capture = deps.capturePsmuxPane || capturePsmuxPane;
226
- const _exists = deps.existsSync || existsSync;
227
- const _stat = deps.statSync || statSync;
228
- const _readFile = deps.readFileSync || readFileSync;
229
- const _exec = deps.psmuxExec || psmuxExec;
230
- const _dispatch = deps.dispatchCommand || dispatchCommand;
231
- const _startCapture = deps.startCapture || startCapture;
232
-
233
- const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
234
- const completionPatterns = [
235
- token
236
- ? `${esc("__TRIFLUX_DONE__:")}${esc(token)}:(\\d+)`
237
- : `${esc("__TRIFLUX_DONE__:")}\\S+:(\\d+)`,
238
- token
239
- ? `${esc("TFX_DONE_")}${esc(token)}:(\\d+)`
240
- : `${esc("TFX_DONE_")}\\S+:(\\d+)`,
241
- ];
242
- const completionRe = new RegExp(completionPatterns.join("|"), "m");
243
-
244
- let restarts = 0;
245
- let currentPaneId = paneId;
246
- let stallDetected = false;
247
-
248
- while (true) {
249
- let lastOutput = "";
250
- let lastMtime = 0;
251
- let lastChangeAt = Date.now();
252
- const startedAt = Date.now();
253
-
254
- // 초기 resultFile mtime
255
- try {
256
- if (_exists(resultFile)) lastMtime = _stat(resultFile).mtimeMs;
257
- } catch { /* 무시 */ }
258
-
259
- while (true) {
260
- await new Promise((r) => setTimeout(r, pollInterval));
261
- const now = Date.now();
262
-
263
- // 전체 타임아웃
264
- if (now - startedAt > completionTimeout) {
265
- return { matched: false, exitCode: null, restarts, stallDetected, timedOut: true };
266
- }
267
-
268
- // 1) capture-pane 출력 확인
269
- const currentOutput = _capture(currentPaneId, 50);
270
- if (onPoll) { try { onPoll(currentOutput); } catch { /* 삼킴 */ } }
271
-
272
- // 2) completion 토큰 감지
273
- const completionMatch = completionRe.exec(currentOutput);
274
- if (completionMatch) {
275
- return {
276
- matched: true,
277
- exitCode: Number.parseInt((completionMatch.slice(1).find(Boolean) || '0'), 10),
278
- restarts,
279
- stallDetected,
280
- timedOut: false,
281
- };
282
- }
283
-
284
- // 3) resultFile 존재 + mtime 변화 확인
285
- let currentMtime = 0;
286
- try {
287
- if (_exists(resultFile)) currentMtime = _stat(resultFile).mtimeMs;
288
- } catch { /* 무시 */ }
289
-
290
- // 4) 변화 감지 → stallTimer 리셋
291
- const outputChanged = currentOutput !== lastOutput;
292
- const mtimeChanged = currentMtime > 0 && currentMtime !== lastMtime;
293
-
294
- if (outputChanged || mtimeChanged) {
295
- lastChangeAt = now;
296
- lastOutput = currentOutput;
297
- if (mtimeChanged) lastMtime = currentMtime;
298
- }
299
-
300
- // resultFile이 갱신되고 내용이 있으면 완료로 간주
301
- if (mtimeChanged && currentMtime > 0 && _exists(resultFile)) {
302
- try {
303
- const content = _readFile(resultFile, "utf8").trim();
304
- if (content.length > 0) {
305
- return { matched: true, exitCode: 0, restarts, stallDetected, timedOut: false };
306
- }
307
- } catch { /* 무시 */ }
308
- }
309
-
310
- // 5) stall 판정
311
- if (now - lastChangeAt >= stallTimeout) {
312
- stallDetected = true;
313
-
314
- if (restarts >= maxRestarts) {
315
- const err = new Error("CLI가 반복적으로 멈춤. 수동 확인 필요.");
316
- err.code = "STALL_EXHAUSTED";
317
- err.category = "transient";
318
- err.recovery = "CLI가 반복적으로 멈춤. 수동 확인 필요.";
319
- err.restarts = restarts;
320
- throw err;
321
- }
322
-
323
- // kill pane → re-dispatch
324
- try { _exec(["kill-pane", "-t", currentPaneId]); } catch { /* 이미 종료 */ }
325
-
326
- if (command) {
327
- // 새 pane split + 동일 command re-dispatch
328
- const newPaneId = _exec([
329
- "split-window", "-t", sessionName, "-P", "-F",
330
- "#{session_name}:#{window_index}.#{pane_index}",
331
- ]);
332
- _startCapture(sessionName, newPaneId);
333
- _dispatch(sessionName, newPaneId, command);
334
- currentPaneId = newPaneId;
335
- }
336
-
337
- restarts++;
338
- break; // inner loop 재시작 (stallTimer 리셋)
339
- }
340
- }
341
- }
342
- }
343
-
344
- /** progressive 스플릿 모드: lead pane만 생성 후, 워커를 하나씩 추가하며 dispatch */
345
- async function dispatchProgressive(sessionName, assignments, opts = {}) {
346
- const {
347
- layout,
348
- safeProgress,
349
- dashboardLayout = "single",
350
- } = opts;
351
- const resolvedDashboardLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
352
- const session = createPsmuxSession(sessionName, { layout, paneCount: 1 });
353
- applyTrifluxTheme(sessionName);
354
- if (safeProgress) {
355
- safeProgress({
356
- type: "session_created",
357
- sessionName,
358
- panes: session.panes,
359
- dashboardLayout: resolvedDashboardLayout,
360
- });
361
- }
362
-
363
- // dashboard: 워커 pane을 먼저 생성한 후 pane 0에 대시보드를 실행
364
- // (listPanes로 워커 감지가 가능하려면 워커 pane이 먼저 존재해야 함)
365
-
366
- const dispatches = [];
367
- for (let i = 0; i < assignments.length; i++) {
368
- const assignment = assignments[i];
369
- const paneName = `worker-${i + 1}`;
370
- const resolvedCli = resolveCliType(assignment.cli);
371
- const brand = CLI_BRAND[resolvedCli] || { emoji: "\u{25CF}", label: resolvedCli, ansi: "" };
372
- const paneTitle = assignment.role
373
- ? `${brand.emoji} ${resolvedCli} (${assignment.role})`
374
- : `${brand.emoji} ${resolvedCli}-${i + 1}`;
375
-
376
- let newPaneId;
377
- // 모든 워커를 split-window로 생성 (lead pane index 0은 비워둠)
378
- // tui-viewer가 index 0을 건너뛰므로, 워커는 항상 index >= 1에 배치
379
- newPaneId = psmuxExec([
380
- "split-window", "-t", sessionName, "-P", "-F",
381
- "#{session_name}:#{window_index}.#{pane_index}",
382
- ]);
383
-
384
- // 타이틀 설정 (이모지 포함)
385
- try { psmuxExec(["select-pane", "-t", newPaneId, "-T", paneTitle]); } catch { /* 무시 */ }
386
-
387
- if (safeProgress) safeProgress({ type: "worker_added", paneName, cli: assignment.cli, paneTitle });
388
-
389
- // 캡처 시작 + 컬러 배너 + 명령 dispatch
390
- const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
391
- const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp, model: assignment.model });
392
- startCapture(sessionName, newPaneId);
393
- // pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
394
- if (i > 0) await new Promise(r => setTimeout(r, 300));
395
- const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
396
-
397
- if (safeProgress) safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
398
-
399
- dispatches.push({ ...dispatch, paneId: newPaneId, paneName, resultFile, cli: assignment.cli, role: assignment.role, command: cmd });
400
- }
401
-
402
- // 모든 split 완료 후 레이아웃 한 번만 정렬 (깜빡임 방지)
403
- try { psmuxExec(["select-layout", "-t", sessionName, "tiled"]); } catch { /* 무시 */ }
404
-
405
- // v7.1.3: psmux 내부 대시보드 pane 제거 — WT 스플릿에서 tui-viewer 직접 실행
406
-
407
- return dispatches;
408
- }
409
-
410
- /** 기존 batch 모드: 모든 pane을 한 번에 생성하여 dispatch */
411
- function dispatchBatch(sessionName, assignments, opts = {}) {
412
- const {
413
- layout,
414
- safeProgress,
415
- dashboardLayout = "single",
416
- } = opts;
417
- const paneCount = assignments.length + 1;
418
- const resolvedDashboardLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
419
- // A2b fix: 2x2 레이아웃은 최대 4 pane — 초과 시 tiled로 자동 전환
420
- const effectiveLayout = (layout === "2x2" && paneCount > 4) ? "tiled" : layout;
421
- const session = createPsmuxSession(sessionName, { layout: effectiveLayout, paneCount });
422
- applyTrifluxTheme(sessionName);
423
- if (safeProgress) {
424
- safeProgress({
425
- type: "session_created",
426
- sessionName,
427
- panes: session.panes,
428
- dashboardLayout: resolvedDashboardLayout,
429
- });
430
- }
431
-
432
- return assignments.map((assignment, i) => {
433
- const paneName = `worker-${i + 1}`;
434
- const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
435
- const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp, model: assignment.model });
436
- const scriptDir = join(RESULT_DIR, sessionName);
437
- const dispatch = dispatchCommand(sessionName, paneName, cmd, { scriptDir, scriptName: paneName });
438
-
439
- // P1 fix: 비-progressive에서는 pane 리네임 금지 — 캡처 로그 경로가 타이틀 기반이므로
440
- // 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
441
- // progressive 모드에서는 split-window 시 새 pane에 바로 타이틀이 설정되므로 문제없음
442
-
443
- if (safeProgress) safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
444
-
445
- return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role, command: cmd };
446
- });
447
- }
448
-
449
- /**
450
- * 모든 dispatch를 병렬 대기하며 완료 결과를 수집한다.
451
- * @param {string} sessionName
452
- * @param {Array} dispatches
453
- * @param {number} timeoutSec
454
- * @param {Function|null} safeProgress
455
- * @param {number} progressIntervalSec
456
- * @returns {Promise<Array<{d, completion, output}>>}
457
- */
458
- async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec, stallOpts) {
459
- // 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
460
- return Promise.all(dispatches.map(async (d) => {
461
- // onPoll → onProgress 변환 (throttle by progressIntervalSec)
462
- const pollOpts = {};
463
- if (safeProgress && progressIntervalSec > 0) {
464
- let lastProgressAt = 0;
465
- const intervalMs = progressIntervalSec * 1000;
466
- pollOpts.onPoll = ({ content }) => {
467
- const now = Date.now();
468
- if (now - lastProgressAt >= intervalMs) {
469
- lastProgressAt = now;
470
- safeProgress({
471
- type: "progress",
472
- paneName: d.paneName,
473
- cli: d.cli,
474
- snapshot: content.split("\n").slice(-15).join("\n"), // 마지막 15줄
475
- });
476
- }
477
- };
478
- }
479
-
480
- let completion;
481
- if (stallOpts?.enabled) {
482
- // 하이브리드 stall detection 모드
483
- try {
484
- const stallPollCb = safeProgress && progressIntervalSec > 0
485
- ? (snapshot) => {
486
- try {
487
- safeProgress({
488
- type: "progress",
489
- paneName: d.paneName,
490
- cli: d.cli,
491
- snapshot: snapshot.split("\n").slice(-15).join("\n"),
492
- });
493
- } catch { /* 삼킴 */ }
494
- }
495
- : undefined;
496
-
497
- const stallResult = await waitForCompletionWithStallDetect(
498
- sessionName,
499
- d.paneId || d.paneName,
500
- d.resultFile,
501
- {
502
- pollInterval: stallOpts.pollInterval,
503
- stallTimeout: stallOpts.stallTimeout,
504
- completionTimeout: stallOpts.completionTimeout ?? timeoutSec * 1000,
505
- maxRestarts: stallOpts.maxRestarts,
506
- command: d.command,
507
- token: d.token,
508
- onPoll: stallPollCb,
509
- },
510
- );
511
- completion = {
512
- matched: stallResult.matched,
513
- exitCode: stallResult.exitCode,
514
- stallDetected: stallResult.stallDetected,
515
- restarts: stallResult.restarts,
516
- };
517
- } catch (stallErr) {
518
- if (stallErr.code === "STALL_EXHAUSTED") {
519
- completion = {
520
- matched: false,
521
- exitCode: null,
522
- stallExhausted: true,
523
- restarts: stallErr.restarts,
524
- };
525
- } else {
526
- throw stallErr;
527
- }
528
- }
529
- } else {
530
- // 기존 waitForCompletion 경로
531
- if (d.logPath) pollOpts.logPath = d.logPath;
532
- completion = await waitForCompletion(sessionName, d.paneId || d.paneName, d.token, timeoutSec, pollOpts);
533
- }
534
-
535
- const output = completion.matched
536
- ? readResult(d.resultFile, d.paneId)
537
- : "";
538
-
539
- if (safeProgress) {
540
- safeProgress({
541
- type: "completed",
542
- paneName: d.paneName,
543
- cli: d.cli,
544
- matched: completion.matched,
545
- exitCode: completion.exitCode,
546
- sessionDead: completion.sessionDead || false,
547
- stallDetected: completion.stallDetected || false,
548
- stallExhausted: completion.stallExhausted || false,
549
- });
550
- }
551
-
552
- return { d, completion, output };
553
- }));
554
- }
555
-
556
- /**
557
- * git diff + handoff 파이프라인을 적용하여 최종 결과 배열을 반환한다.
558
- * @param {Array<{d, completion, output}>} results
559
- * @returns {Array}
560
- */
561
- function collectResults(results) {
562
- // B3 fix: git diff를 루프 밖에서 1회만 실행 (워커 수만큼 중복 방지)
563
- let gitDiffFiles;
564
- try {
565
- const diffOut = execSync("git diff --name-only HEAD", { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
566
- gitDiffFiles = diffOut.trim().split("\n").filter(Boolean);
567
- } catch { /* git 미설치 또는 non-repo — 무시 */ }
568
-
569
- // handoff 파이프라인: parse → validate → format (각 워커 결과에 적용)
570
- return results.map(({ d, completion, output }) => {
571
- const handoffResult = processHandoff(output, {
572
- exitCode: completion.exitCode,
573
- resultFile: d.resultFile,
574
- cli: d.cli,
575
- gitDiffFiles,
576
- });
577
-
578
- return {
579
- cli: d.cli,
580
- paneName: d.paneName,
581
- paneId: d.paneId,
582
- role: d.role,
583
- matched: completion.matched,
584
- exitCode: completion.exitCode,
585
- output,
586
- resultFile: d.resultFile,
587
- sessionDead: completion.sessionDead || false,
588
- handoff: handoffResult.handoff,
589
- handoffFormatted: handoffResult.formatted,
590
- handoffValid: handoffResult.valid,
591
- handoffFallback: handoffResult.fallback,
592
- };
593
- });
594
- }
595
-
596
- /**
597
- * 헤드리스 CLI 오케스트레이션 실행
598
- *
599
- * @param {string} sessionName — psmux 세션 이름
600
- * @param {Array<{cli: string, prompt: string, role?: string}>} assignments
601
- * @param {object} [opts]
602
- * @param {number} [opts.timeoutSec=300] — 각 워커 타임아웃
603
- * @param {string} [opts.layout='2x2'] — pane 레이아웃
604
- * @param {(event: object) => void} [opts.onProgress] — 진행 콜백
605
- * @param {number} [opts.progressIntervalSec=0] — N초마다 progress 이벤트 발화 (0=비활성)
606
- * @param {boolean} [opts.progressive=true] — true면 pane을 하나씩 split-window로 추가 (실시간 스플릿)
607
- * @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
608
- * @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}> }}
609
- */
610
- export async function runHeadless(sessionName, assignments, opts = {}) {
611
- const {
612
- timeoutSec = 300,
613
- layout = "2x2",
614
- onProgress,
615
- progressIntervalSec = 0,
616
- progressive = true,
617
- dashboard = false,
618
- dashboardLayout = "single",
619
- stallDetect,
620
- } = opts;
621
-
622
- mkdirSync(RESULT_DIR, { recursive: true });
623
-
624
- // in-process TUI: dashboard=true이고 stdout이 TTY일 때 직접 구동
625
- let tui = null;
626
- const resolvedLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
627
- if (dashboard && process.stdout.isTTY) {
628
- tui = createLogDashboard({
629
- stream: process.stdout,
630
- input: process.stdin,
631
- refreshMs: 200,
632
- layout: resolvedLayout,
633
- });
634
- tui.setStartTime(Date.now());
635
- // 초기 워커 상태 등록
636
- for (let i = 0; i < assignments.length; i++) {
637
- const a = assignments[i];
638
- tui.updateWorker(`worker-${i + 1}`, {
639
- cli: a.cli || "codex",
640
- role: a.role || "",
641
- status: "pending",
642
- progress: 0,
643
- });
644
- }
645
- }
646
-
647
- // per-worker state feed: onProgress 이벤트 → tui.updateWorker()
648
- function feedTui(event) {
649
- if (!tui) return;
650
- const { type, paneName, cli, snapshot, matched, exitCode } = event;
651
- if (!paneName) return;
652
-
653
- if (type === "progress" && snapshot) {
654
- tui.updateWorker(paneName, {
655
- cli: cli || "codex",
656
- status: "running",
657
- snapshot: snapshot.split("\n").at(-1) || "",
658
- summary: snapshot.split("\n").at(-1) || "",
659
- detail: snapshot,
660
- progress: 0.5,
661
- });
662
- } else if (type === "completed") {
663
- const status = matched && exitCode === 0 ? "completed" : "failed";
664
- tui.updateWorker(paneName, {
665
- cli: cli || "codex",
666
- status,
667
- progress: 1,
668
- });
669
- } else if (type === "worker_added") {
670
- tui.updateWorker(paneName, {
671
- cli: cli || "codex",
672
- status: "running",
673
- progress: 0.05,
674
- });
675
- }
676
- }
677
-
678
- // onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
679
- const combinedProgress = (event) => {
680
- feedTui(event);
681
- if (onProgress) { try { onProgress(event); } catch { /* 콜백 예외 삼킴 */ } }
682
- };
683
- const safeProgress = (event) => { try { combinedProgress(event); } catch { /* 삼킴 */ } };
684
-
685
- const dispatches = progressive
686
- ? await dispatchProgressive(sessionName, assignments, { layout, safeProgress, dashboardLayout })
687
- : dispatchBatch(sessionName, assignments, { layout, safeProgress, dashboardLayout });
688
-
689
- const results = await awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec, stallDetect);
690
- const collected = collectResults(results);
691
-
692
- // 완료 시 TUI에 최종 상태 반영 후 닫기
693
- if (tui) {
694
- for (const r of collected) {
695
- tui.updateWorker(r.paneName, {
696
- cli: r.cli,
697
- role: r.role || "",
698
- status: r.handoff?.status === "failed" ? "failed" : "completed",
699
- handoff: r.handoff,
700
- summary: r.handoff?.verdict || (r.matched ? "completed" : "failed"),
701
- detail: r.output,
702
- progress: 1,
703
- elapsed: Math.round((Date.now() - (tui._startedAt || Date.now())) / 1000),
704
- });
705
- }
706
- tui.render();
707
- // 최종 화면을 잠깐 유지 후 닫기
708
- await new Promise((r) => setTimeout(r, 1500));
709
- tui.close();
710
- }
711
-
712
- return { sessionName, results: collected };
713
- }
714
-
715
- /**
716
- * 헤드리스 실행 + 자동 정리
717
- * 성공/실패에 관계없이 세션을 정리한다.
718
- *
719
- * @param {Array<{cli: string, prompt: string, role?: string}>} assignments
720
- * @param {object} [opts] — runHeadless opts + sessionPrefix
721
- * @returns {{ results: Array, sessionName: string }}
722
- */
723
- export async function runHeadlessWithCleanup(assignments, opts = {}) {
724
- const { sessionPrefix = "tfx-hl", ...runOpts } = opts;
725
- const sessionName = `${sessionPrefix}-${Date.now().toString(36).slice(-6)}`;
726
-
727
- try {
728
- return await runHeadless(sessionName, assignments, runOpts);
729
- } finally {
730
- try { killPsmuxSession(sessionName); } catch { /* 무시 */ }
731
- // WT split pane은 psmux 종료 시 셸이 끝나면서 자동으로 닫힘
732
- // 수동 close-pane 불필요 (레이스 컨디션으로 WT 에러 발생)
733
- }
734
- }
735
-
736
- // ─── v6.0.0: Theme + Visual ───
737
-
738
- /**
739
- * psmux 세션에 triflux 테마를 적용한다.
740
- * status bar + pane border 색상 + 브랜딩.
741
- *
742
- * @param {string} sessionName
743
- */
744
- export function applyTrifluxTheme(sessionName) {
745
- const opts = [
746
- // Status bar — Catppuccin Mocha 기반
747
- ["status-style", "bg=#1e1e2e,fg=#cdd6f4"],
748
- ["status-left", " #[fg=#89b4fa,bold]▲ triflux#[default] "],
749
- ["status-left-length", "20"],
750
- ["status-right", " #[fg=#a6adc8]#{pane_title}#[default] │ #[fg=#f9e2af]%H:%M#[default] "],
751
- ["status-right-length", "40"],
752
- // Pane border — active/inactive 구분
753
- ["pane-active-border-style", "fg=#89b4fa"],
754
- ["pane-border-style", "fg=#45475a"],
755
- // Status bar 위치
756
- ["status-position", "bottom"],
757
- // 셸이 pane 타이틀을 변경하는 것 방지 (캡처 로그 경로 안정성)
758
- ["allow-rename", "off"],
759
- ];
760
- for (const [key, value] of opts) {
761
- try { psmuxExec(["set-option", "-t", sessionName, key, value]); } catch { /* 무시 */ }
762
- }
763
- }
764
-
765
- /**
766
- * Windows Terminal에 triflux 프로필을 자동 생성/갱신한다.
767
- * 반투명 + 비포커스 시 더 투명 + Catppuccin 테마.
768
- * @returns {boolean} 성공 여부
769
- */
770
-
771
- /**
772
- * WT 기본 프로필의 폰트 크기를 읽는다.
773
- * @returns {number} 기본 폰트 크기 (못 읽으면 12)
774
- */
775
- function getWtDefaultFontSize() {
776
- const settingsPaths = [
777
- join(process.env.LOCALAPPDATA || "", "Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json"),
778
- join(process.env.LOCALAPPDATA || "", "Microsoft/Windows Terminal/settings.json"),
779
- ];
780
- for (const p of settingsPaths) {
781
- if (!existsSync(p)) continue;
782
- try {
783
- const settings = JSON.parse(readFileSync(p, "utf8").replace(/^\s*\/\/.*$/gm, ""));
784
- // 기본 프로필 or 첫 프로필의 폰트
785
- const defaultGuid = settings.defaultProfile;
786
- const profiles = settings.profiles?.list || [];
787
- const defaultProfile = profiles.find(pr => pr.guid === defaultGuid) || profiles[0];
788
- return defaultProfile?.font?.size || settings.profiles?.defaults?.font?.size || 12;
789
- } catch { /* 다음 */ }
790
- }
791
- return 12;
792
- }
793
-
794
- /**
795
- * 파일을 원자적으로 쓴다 — 임시 파일에 먼저 기록 후 rename으로 교체.
796
- * 프로세스가 쓰기 도중 충돌해도 원본 파일이 손상되지 않는다.
797
- * @param {string} filePath — 대상 파일 경로
798
- * @param {string} data — 쓸 내용
799
- */
800
- function atomicWriteSync(filePath, data) {
801
- const tmpPath = `${filePath}.${process.pid}.tmp`;
802
- try {
803
- writeFileSync(tmpPath, data, "utf8");
804
- renameSync(tmpPath, filePath);
805
- } catch (err) {
806
- try { writeFileSync(tmpPath.replace(/\.tmp$/, ".tmp.del"), ""); } catch { /* 무시 */ }
807
- throw err;
808
- }
809
- }
810
-
811
- function sanitizeSessionName(value) {
812
- return String(value || "").replace(/[^a-zA-Z0-9_-]/g, "") || "tfx-session";
813
- }
814
-
815
- function sanitizeWindowTitle(value, fallback = "triflux") {
816
- const text = String(value || "").replace(/[\r\n]+/g, " ").trim();
817
- return text || fallback;
818
- }
819
-
820
- function buildWtAttachPaneArgs(sessionName, title) {
821
- const safeSession = sanitizeSessionName(sessionName);
822
- return [
823
- "--profile", "triflux",
824
- "--title", sanitizeWindowTitle(title, `▲ ${safeSession}`),
825
- "--", "psmux", "attach-session", "-t", safeSession,
826
- ];
827
- }
828
-
829
- function joinWtCommands(commands) {
830
- return commands.flatMap((command, index) => (index === 0 ? command : [";", ...command]));
831
- }
832
-
833
- function buildAttachTitle(sessionName, suffix = "") {
834
- const base = `▲ ${sanitizeSessionName(sessionName)}`;
835
- return suffix ? `${base} ${suffix}` : base;
836
- }
837
-
838
- export function buildDashboardAttachArgs(sessionName, dashboardLayout = "single", workerCount = 2, anchor = "window") {
839
- const safeSession = sanitizeSessionName(sessionName);
840
- const resolvedLayout = resolveDashboardLayout(dashboardLayout, workerCount);
841
- const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
842
- const prefix = anchor === "tab" ? ["-w", "0", "nt"] : ["-w", "new"];
843
- return [
844
- ...prefix,
845
- "--profile", "triflux",
846
- "--title", buildAttachTitle(safeSession, "dashboard"),
847
- "--", "node", viewerPath,
848
- "--session", safeSession,
849
- "--result-dir", RESULT_DIR,
850
- "--layout", resolvedLayout,
851
- ];
852
- }
853
-
854
- export function buildWtAttachArgs(sessionName, workerCount = 1) {
855
- const safeSession = sanitizeSessionName(sessionName);
856
- const count = Number.isFinite(workerCount) ? Math.max(1, Math.trunc(workerCount)) : 1;
857
- if (count >= 5) return buildDashboardAttachArgs(safeSession, "single", count);
858
-
859
- const pane1 = ["nt", ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession))];
860
- if (count === 1) return ["-w", "0", ...pane1];
861
-
862
- const pane2 = ["sp", count >= 3 ? "-V" : "-H", ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession, "2"))];
863
- if (count === 2) return ["-w", "0", ...joinWtCommands([pane1, pane2])];
864
-
865
- const pane3 = ["sp", "-H", ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession, "3"))];
866
- if (count === 3) return ["-w", "0", ...joinWtCommands([pane1, pane2, ["move-focus", "left"], pane3])];
867
-
868
- const pane4 = ["sp", "-H", ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession, "4"))];
869
- return [
870
- "-w", "0",
871
- ...joinWtCommands([pane1, pane2, ["move-focus", "left"], pane3, ["move-focus", "right"], pane4]),
872
- ];
873
- }
874
-
875
- function spawnDetachedWt(args) {
876
- const child = spawn("wt.exe", args, {
877
- detached: true,
878
- stdio: "ignore",
879
- windowsHide: false,
880
- });
881
- child.unref();
882
- }
883
-
884
- export function ensureWtProfile(workerCount = 2) {
885
- const settingsPaths = [
886
- join(process.env.LOCALAPPDATA || "", "Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json"),
887
- join(process.env.LOCALAPPDATA || "", "Microsoft/Windows Terminal/settings.json"),
888
- ];
889
-
890
- for (const settingsPath of settingsPaths) {
891
- if (!existsSync(settingsPath)) continue;
892
- try {
893
- const raw = readFileSync(settingsPath, "utf8");
894
- // JSON with comments — 간단한 strip (// 주석만)
895
- const cleaned = raw.replace(/^\s*\/\/.*$/gm, "");
896
- const settings = JSON.parse(cleaned);
897
- if (!settings.profiles?.list) continue;
898
-
899
- const existing = settings.profiles.list.findIndex(p => p.name === "triflux");
900
- const profile = {
901
- name: "triflux",
902
- commandline: "psmux",
903
- icon: "\u{1F53A}", // 🔺
904
- tabTitle: "triflux",
905
- suppressApplicationTitle: true,
906
- opacity: 40,
907
- useAcrylic: true,
908
- unfocusedAppearance: { opacity: 20 },
909
- colorScheme: "One Half Dark",
910
- font: { size: Math.max(6, getWtDefaultFontSize() - 1 - Math.floor(workerCount / 2)) },
911
- closeOnExit: "always",
912
- hidden: true, // 프로필 목록에는 숨김 (triflux에서만 사용)
913
- };
914
-
915
- if (existing >= 0) {
916
- settings.profiles.list[existing] = { ...settings.profiles.list[existing], ...profile };
917
- } else {
918
- settings.profiles.list.push(profile);
919
- }
920
-
921
- atomicWriteSync(settingsPath, JSON.stringify(settings, null, 2));
922
- return true;
923
- } catch { /* 파싱 실패 — 다음 경로 */ }
924
- }
925
- return false;
926
- }
927
-
928
- // ─── v6.0.0: Lead-Direct Interactive Mode ───
929
-
930
- /**
931
- * Windows Terminal에서 psmux 세션을 자동 attach한다.
932
- * 1명은 새 탭 단일 attach, 2/3/4명은 새 탭 내 split-pane, 5명 이상은 dashboard로 전환한다.
933
- *
934
- * @param {string} sessionName — attach할 psmux 세션 이름
935
- * @param {object} [opts]
936
- * @param {number} [workerCount=2]
937
- * @returns {boolean} 성공 여부
938
- */
939
- export function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
940
- if (!process.env.WT_SESSION) return false;
941
- try { execSync("where wt.exe", { stdio: "ignore" }); } catch { return false; }
942
- ensureWtProfile(workerCount);
943
- try {
944
- const args = workerCount >= 5
945
- ? buildDashboardAttachArgs(sessionName, opts.dashboardLayout, workerCount, opts.dashboardAnchor)
946
- : buildWtAttachArgs(sessionName, workerCount);
947
- spawnDetachedWt(args);
948
- return true;
949
- } catch { return false; }
950
- }
951
-
952
- /**
953
- * v7.0: psmux 세션을 WT 탭에 attach (대시보드 + 워커 전체 뷰)
954
- * @param {string} sessionName
955
- * @param {number} workerCount
956
- * @param {string} [dashboardLayout='single']
957
- * @param {number} [dashboardSize=0.50] — 하위 호환용 인자 (현재는 anchor 기반 attach만 사용)
958
- * @param {string} [dashboardAnchor='window'] — window | tab
959
- * @returns {boolean}
960
- */
961
- export function attachDashboardTab(sessionName, workerCount = 2, dashboardLayout = "single", dashboardSize = 0.40, dashboardAnchor = "window") {
962
- try { execSync("where wt.exe", { stdio: "ignore" }); } catch { return false; }
963
- ensureWtProfile(workerCount);
964
- try {
965
- const args = buildDashboardAttachArgs(sessionName, dashboardLayout, workerCount, dashboardAnchor);
966
- void dashboardSize;
967
- spawnDetachedWt(args);
968
- return true;
969
- } catch { return false; }
970
- }
971
-
972
- /**
973
- * 모든 워커 pane의 현재 스냅샷을 수집한다.
974
- *
975
- * @param {string} sessionName
976
- * @param {Array<{paneId: string, paneName: string, cli: string}>} dispatches
977
- * @param {number} [lines=15] — 각 pane에서 캡처할 줄 수
978
- * @returns {Array<{paneName: string, cli: string, snapshot: string}>}
979
- */
980
- export function getProgressSnapshots(sessionName, dispatches, lines = 15) {
981
- if (!psmuxSessionExists(sessionName)) return [];
982
- return dispatches.map((d) => {
983
- try {
984
- const snapshot = capturePsmuxPane(d.paneId, lines);
985
- return { paneName: d.paneName, cli: d.cli, snapshot };
986
- } catch {
987
- return { paneName: d.paneName, cli: d.cli, snapshot: "(캡처 실패)" };
988
- }
989
- });
990
- }
991
-
992
- /**
993
- * Lead-Direct Interactive 헤드리스 실행.
994
- * 세션을 유지하면서 결과 수집 후에도 추가 명령을 dispatch할 수 있다.
995
- * 반환된 handle의 kill()을 반드시 호출하여 세션을 정리해야 한다.
996
- *
997
- * @param {string} sessionName — psmux 세션 이름
998
- * @param {Array<{cli: string, prompt: string, role?: string}>} assignments
999
- * @param {object} [opts]
1000
- * @param {number} [opts.timeoutSec=300]
1001
- * @param {string} [opts.layout='2x2']
1002
- * @param {(event: object) => void} [opts.onProgress]
1003
- * @param {number} [opts.progressIntervalSec=0]
1004
- * @param {boolean} [opts.autoAttach=false] — Windows Terminal 자동 attach
1005
- * @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
1006
- * @param {AbortSignal} [opts.signal] — abort 시 자동 세션 정리
1007
- * @param {number} [opts.maxIdleSec=0] — 유휴 시 자동 정리 (0=비활성)
1008
- * @returns {Promise<{
1009
- * sessionName: string,
1010
- * results: Array,
1011
- * dispatches: Array,
1012
- * dispatch: (paneName: string, command: string) => {paneId: string, paneName: string, token: string},
1013
- * capture: (paneName: string, lines?: number) => string,
1014
- * snapshots: (lines?: number) => Array,
1015
- * waitFor: (paneName: string, token: string, timeoutSec?: number, opts?: object) => Promise,
1016
- * alive: () => boolean,
1017
- * kill: () => void,
1018
- * }>}
1019
- */
1020
- export async function runHeadlessInteractive(sessionName, assignments, opts = {}) {
1021
- const {
1022
- autoAttach = false,
1023
- dashboard = false,
1024
- dashboardSize = 0.40,
1025
- dashboardAnchor = "window",
1026
- signal,
1027
- maxIdleSec = 0,
1028
- ...runOpts
1029
- } = opts;
1030
- const headlessOpts = dashboard
1031
- ? { ...runOpts, dashboard: true }
1032
- : { ...runOpts };
1033
-
1034
- // autoAttach를 session_created 시점에 트리거 (CLI 실행 전에 터미널 열림)
1035
- const userOnProgress = headlessOpts.onProgress;
1036
- let terminalAttached = false;
1037
- const onProgress = (event) => {
1038
- if (autoAttach && event.type === "session_created" && !terminalAttached) {
1039
- terminalAttached = true;
1040
- if (dashboard) {
1041
- // v7.0: psmux attach로 대시보드+워커 전체 세션을 WT 탭에 표시
1042
- attachDashboardTab(
1043
- sessionName,
1044
- assignments.length,
1045
- event.dashboardLayout || resolveDashboardLayout(headlessOpts.dashboardLayout, assignments.length),
1046
- dashboardSize,
1047
- dashboardAnchor,
1048
- );
1049
- } else {
1050
- autoAttachTerminal(sessionName, { dashboardLayout: headlessOpts.dashboardLayout, dashboardAnchor }, assignments.length);
1051
- }
1052
- }
1053
- if (userOnProgress) userOnProgress(event);
1054
- };
1055
- const interactiveRunOpts = { ...headlessOpts, onProgress };
1056
-
1057
- // Phase 1: 세션 생성 → 즉시 터미널 팝업 → dispatch → 대기 → 결과 수집
1058
- const { results } = await runHeadless(sessionName, assignments, interactiveRunOpts);
1059
-
1060
- // Phase 2: 세션을 유지하고 interactive handle 반환
1061
- // Fix P2: paneId를 dispatches에 포함 (snapshots에서 필요)
1062
- const dispatches = results.map((r, i) => ({
1063
- paneName: r.paneName,
1064
- paneId: r.paneId || "",
1065
- cli: r.cli,
1066
- role: r.role,
1067
- }));
1068
-
1069
- // Fix P2: maxIdleSec 리셋을 위한 타이머 관리
1070
- let idleTimer = null;
1071
- function resetIdleTimer() {
1072
- if (idleTimer) clearTimeout(idleTimer);
1073
- if (maxIdleSec > 0) {
1074
- idleTimer = setTimeout(() => handle.kill(), maxIdleSec * 1000);
1075
- if (idleTimer.unref) idleTimer.unref();
1076
- }
1077
- }
1078
-
1079
- const handle = {
1080
- sessionName,
1081
- results,
1082
- dispatches,
1083
- _killed: false,
1084
-
1085
- /** 특정 pane에 후속 명령 dispatch (캡처 자동 재시작) */
1086
- dispatch(paneName, command) {
1087
- if (this._killed) throw new Error("세션이 이미 종료되었습니다.");
1088
- try { startCapture(sessionName, paneName); } catch { /* 이미 활성 — 무시 */ }
1089
- resetIdleTimer();
1090
- return dispatchCommand(sessionName, paneName, command);
1091
- },
1092
-
1093
- /** 특정 pane의 현재 출력 캡처 */
1094
- capture(paneName, lines = 30) {
1095
- if (this._killed) return "";
1096
- try {
1097
- // Fix P2: paneName으로 resolvePane을 경유하여 정확한 paneId 획득
1098
- return capturePsmuxPane(paneName, lines);
1099
- } catch {
1100
- return "(캡처 실패)";
1101
- }
1102
- },
1103
-
1104
- /** 모든 pane 스냅샷 */
1105
- snapshots(lines = 15) {
1106
- if (this._killed) return [];
1107
- return getProgressSnapshots(sessionName, dispatches, lines);
1108
- },
1109
-
1110
- /** 특정 pane에서 완료 대기 */
1111
- async waitFor(paneName, token, timeoutSec = 300, waitOpts = {}) {
1112
- if (this._killed) return { matched: false, sessionDead: true };
1113
- resetIdleTimer();
1114
- return waitForCompletion(sessionName, paneName, token, timeoutSec, waitOpts);
1115
- },
1116
-
1117
- /** 세션 생존 확인 */
1118
- alive() {
1119
- if (this._killed) return false;
1120
- return psmuxSessionExists(sessionName);
1121
- },
1122
-
1123
- /** 세션 종료 — WT pane은 psmux 종료 시 자동으로 닫힘 */
1124
- kill() {
1125
- if (this._killed) return;
1126
- this._killed = true;
1127
- try { killPsmuxSession(sessionName); } catch { /* 무시 */ }
1128
- // WT split pane은 psmux 종료 → 셸 종료 → 자동 닫힘
1129
- // 수동 close-pane 불필요 (레이스 컨디션으로 WT 0x80070002 에러 발생)
1130
- },
1131
- };
1132
-
1133
- // AbortController signal로 자동 정리
1134
- if (signal) {
1135
- if (signal.aborted) {
1136
- handle.kill();
1137
- } else {
1138
- signal.addEventListener("abort", () => handle.kill(), { once: true });
1139
- }
1140
- }
1141
-
1142
- // 유휴 타임아웃 자동 정리 (resetIdleTimer 단일 경로 사용)
1143
- resetIdleTimer();
1144
-
1145
- // 프로세스 종료 시 safety net
1146
- const exitHandler = () => handle.kill();
1147
- process.on("exit", exitHandler);
1148
- // kill() 후 리스너 제거를 위해 참조 보관
1149
- const originalKill = handle.kill.bind(handle);
1150
- handle.kill = function () {
1151
- originalKill();
1152
- process.removeListener("exit", exitHandler);
1153
- };
1154
-
1155
- return handle;
1156
- }
1
+ // hub/team/headless.mjs — 헤드리스 CLI 오케스트레이션
2
+ // psmux pane에서 CLI를 헤드리스 모드로 실행하고 결과를 수집한다.
3
+ // v5.2.0: 기본 headless 엔진 (runHeadless, runHeadlessWithCleanup)
4
+ // v6.0.0: Lead-direct 모드 (runHeadlessInteractive, autoAttachTerminal)
5
+ // 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
6
+
7
+ import { execSync } from "node:child_process";
8
+ import { randomUUID } from "node:crypto";
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ renameSync,
14
+ rmSync,
15
+ statSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { createRequire } from "node:module";
19
+ import net from "node:net";
20
+ import { tmpdir } from "node:os";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { requestJson } from "@triflux/core/hub/bridge.mjs";
24
+ import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
25
+ import { getMaxSpawnPerSec } from "@triflux/core/hub/lib/spawn-trace.mjs";
26
+ import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
27
+ import { getBackend } from "./backend.mjs";
28
+ import {
29
+ buildDaemonExecDispatchPayload,
30
+ deriveClaudeDaemonPaths as deriveClaudeControlPaths,
31
+ killDaemonJob,
32
+ resolveDaemonBridgeSessionId,
33
+ sendClaudeControlRequest,
34
+ sendKillBySessionId,
35
+ waitForDaemonJobPid,
36
+ } from "./claude-daemon-control.mjs";
37
+ import {
38
+ getProcStart,
39
+ startClaudeNativeBridge,
40
+ } from "./claude-native-bridge.mjs";
41
+ import {
42
+ buildClaudeSessionProjection,
43
+ removeClaudeSessionProjection,
44
+ writeClaudeSessionProjection,
45
+ } from "./claude-session-projection.mjs";
46
+ import { resolveDashboardLayout } from "./dashboard-layout.mjs";
47
+ import {
48
+ formatHandoffForLead,
49
+ HANDOFF_INSTRUCTION_SHORT,
50
+ processHandoff,
51
+ } from "./handoff.mjs";
52
+ import {
53
+ capturePsmuxPane,
54
+ createPsmuxSession,
55
+ dispatchCommand,
56
+ killPsmuxSession,
57
+ psmuxExec,
58
+ psmuxSessionExists,
59
+ startCapture,
60
+ waitForCompletion,
61
+ } from "./psmux.mjs";
62
+ import {
63
+ buildSynapseTaskSummary,
64
+ registerSynapseSession,
65
+ unregisterSynapseSession,
66
+ } from "./synapse-http.mjs";
67
+ import { createLogDashboard } from "./tui.mjs";
68
+ import { createWtManager } from "./wt-manager.mjs";
69
+
70
+ const RESULT_DIR = join(tmpdir(), "tfx-headless");
71
+ const DAEMON_COMPLETION_PREFIX = "__TFX_HEADLESS_DONE__:";
72
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
73
+
74
+ /** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
75
+ const CLI_BRAND = {
76
+ codex: { emoji: "\u{26AA}", label: "Codex", ansi: "\x1b[97m" }, // ⚪ bright white (codexWhite)
77
+ gemini: { emoji: "\u{1F535}", label: "Gemini", ansi: "\x1b[38;5;39m" }, // 🔵 geminiBlue
78
+ antigravity: {
79
+ emoji: "\u{1F535}",
80
+ label: "Antigravity",
81
+ ansi: "\x1b[38;5;39m",
82
+ },
83
+ claude: {
84
+ emoji: "\u{1F7E0}",
85
+ label: "Claude",
86
+ ansi: "\x1b[38;2;232;112;64m",
87
+ }, // 🟠 claudeOrange
88
+ };
89
+ const _ANSI_RESET = "\x1b[0m";
90
+ const _ANSI_DIM = "\x1b[2m";
91
+
92
+ /** 에이전트 역할명 → CLI 타입 매핑 (단일 소스: agent-map.json) */
93
+ const _require = createRequire(import.meta.url);
94
+ const AGENT_TO_CLI = _require("./agent-map.json");
95
+ // scripts/tfx-route.sh also validates route agents from agent-map.json.
96
+ const VALID_ROUTE_AGENTS = new Set(Object.keys(AGENT_TO_CLI));
97
+
98
+ /**
99
+ * 에이전트 역할명 또는 CLI 이름을 CLI 타입("codex"|"antigravity"|"claude")으로 해석한다.
100
+ * route_agent()가 적용되지 않는 headless 경로에서 사용.
101
+ * @param {string} agentOrCli — "executor", "codex", "designer" 등
102
+ * @returns {'codex'|'antigravity'|'claude'} CLI 타입
103
+ */
104
+ export function resolveCliType(agentOrCli) {
105
+ return AGENT_TO_CLI[agentOrCli] || agentOrCli;
106
+ }
107
+
108
+ function resolveHeadlessCliType(agentOrCli) {
109
+ const resolved = resolveCliType(agentOrCli);
110
+ return resolved === "gemini" ? "antigravity" : resolved;
111
+ }
112
+
113
+ function shellQuote(value) {
114
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
115
+ }
116
+
117
+ function resolveHeadlessRouteScript(opts = {}) {
118
+ const candidates = [
119
+ opts.routeScript,
120
+ process.env.TFX_ROUTE_SCRIPT,
121
+ process.env.TFX_DELEGATOR_ROUTE_SCRIPT,
122
+ join(process.cwd(), "scripts", "tfx-route.sh"),
123
+ join(SCRIPT_DIR, "..", "..", "scripts", "tfx-route.sh"),
124
+ process.env.HOME
125
+ ? join(process.env.HOME, ".claude", "scripts", "tfx-route.sh")
126
+ : "",
127
+ "tfx-route.sh",
128
+ ];
129
+
130
+ for (const candidate of candidates) {
131
+ if (!candidate) continue;
132
+ if (candidate === "tfx-route.sh" || existsSync(candidate)) {
133
+ return candidate;
134
+ }
135
+ }
136
+
137
+ return "tfx-route.sh";
138
+ }
139
+
140
+ function resolveRouteAgentForHeadless(resolvedCli, opts = {}) {
141
+ const role = String(opts.role || "").trim();
142
+ if (
143
+ role &&
144
+ VALID_ROUTE_AGENTS.has(role) &&
145
+ !["gemini", "antigravity", "agy"].includes(role)
146
+ ) {
147
+ return role;
148
+ }
149
+ return resolvedCli === "antigravity" ? "antigravity" : resolvedCli;
150
+ }
151
+
152
+ export function normalizeHeadlessRole(role) {
153
+ const normalized = String(role || "")
154
+ .trim()
155
+ .toLowerCase();
156
+ return normalized && VALID_ROUTE_AGENTS.has(normalized) ? normalized : "";
157
+ }
158
+
159
+ export function resolveHeadlessDisplayName(assignment = {}, paneName = "") {
160
+ const normalized = String(assignment.displayName || "")
161
+ .trim()
162
+ .replace(/[\r\n\x00-\x1f]/g, " ");
163
+ return normalized || paneName || "";
164
+ }
165
+
166
+ export function normalizeHeadlessAssignment(assignment = {}, index = 0) {
167
+ const paneName = `worker-${index + 1}`;
168
+ return {
169
+ ...assignment,
170
+ role: normalizeHeadlessRole(assignment.role),
171
+ displayName: resolveHeadlessDisplayName(assignment, paneName),
172
+ };
173
+ }
174
+
175
+ function buildRouteBackedHeadlessCommand(
176
+ resolvedCli,
177
+ promptFile,
178
+ resultFile,
179
+ opts = {},
180
+ ) {
181
+ const routeScript = resolveHeadlessRouteScript(opts);
182
+ const routeAgent = resolveRouteAgentForHeadless(resolvedCli, opts);
183
+ const mcp = String(opts.mcp || "auto");
184
+ const timeout =
185
+ Number.isFinite(Number(opts.timeoutSec)) && Number(opts.timeoutSec) > 0
186
+ ? String(Math.trunc(Number(opts.timeoutSec)))
187
+ : "";
188
+ const timeoutArg = timeout ? ` ${shellQuote(timeout)}` : "";
189
+ const routeMode = resolvedCli === "antigravity" ? "antigravity" : resolvedCli;
190
+ const script =
191
+ `__tfx_prompt=$(cat ${shellQuote(promptFile)}); ` +
192
+ `TFX_CLI_MODE=${shellQuote(routeMode)} ` +
193
+ `bash ${shellQuote(routeScript)} ${shellQuote(routeAgent)} "$__tfx_prompt" ${shellQuote(mcp)}${timeoutArg} ` +
194
+ `> ${shellQuote(resultFile)} 2>${shellQuote(`${resultFile}.err`)} < /dev/null`;
195
+ return `bash -lc ${shellQuote(script)}`;
196
+ }
197
+
198
+ export function getHeadlessWorkerAgentId(sessionName, index) {
199
+ return `headless-${sessionName}-${index}`;
200
+ }
201
+
202
+ export function getHeadlessLeadAgentId(sessionName) {
203
+ return `headless-${sessionName}-lead`;
204
+ }
205
+
206
+ export async function registerHeadlessWorker(
207
+ sessionName,
208
+ index,
209
+ cli,
210
+ requestJsonFn = requestJson,
211
+ ) {
212
+ await requestJsonFn("/bridge/register", {
213
+ body: {
214
+ agentId: getHeadlessWorkerAgentId(sessionName, index),
215
+ topics: ["headless.worker"],
216
+ capabilities: [cli],
217
+ },
218
+ }).catch(() => {});
219
+ }
220
+
221
+ export async function publishHeadlessResult(
222
+ sessionName,
223
+ workerId,
224
+ status,
225
+ handoff,
226
+ requestJsonFn = requestJson,
227
+ ) {
228
+ await requestJsonFn("/bridge/publish", {
229
+ body: {
230
+ from: getHeadlessLeadAgentId(sessionName),
231
+ to: "topic:headless.results",
232
+ type: "event",
233
+ payload: { workerId, status, handoff },
234
+ },
235
+ }).catch(() => {});
236
+ }
237
+
238
+ export async function deregisterHeadlessWorkers(
239
+ sessionName,
240
+ workerCount,
241
+ requestJsonFn = requestJson,
242
+ ) {
243
+ await Promise.all(
244
+ Array.from({ length: workerCount }, (_, index) =>
245
+ requestJsonFn("/bridge/deregister", {
246
+ body: { agentId: getHeadlessWorkerAgentId(sessionName, index) },
247
+ }).catch(() => {}),
248
+ ),
249
+ );
250
+ }
251
+
252
+ function registerHeadlessSynapseWorker(workerId, prompt) {
253
+ registerSynapseSession({
254
+ sessionId: workerId,
255
+ host: "local",
256
+ taskSummary: buildSynapseTaskSummary(prompt),
257
+ });
258
+ }
259
+
260
+ function unregisterHeadlessSynapseWorker(workerId) {
261
+ unregisterSynapseSession(workerId);
262
+ }
263
+
264
+ /** MCP 프로필별 프롬프트 힌트 (tfx-route.sh resolve_mcp_policy의 경량 미러) */
265
+ const MCP_PROFILE_HINTS = {
266
+ implement:
267
+ "You have full filesystem read/write access. Implement changes directly.",
268
+ analyze:
269
+ "Focus on reading and analyzing the codebase. Prefer analysis over modification.",
270
+ review: "Review the code for quality, security, and correctness.",
271
+ docs: "Focus on documentation and explanation tasks.",
272
+ };
273
+
274
+ /**
275
+ * CLI별 헤드리스 명령 빌더
276
+ * @param {'codex'|'antigravity'|'claude'} cli
277
+ * @param {string} prompt — 실행할 프롬프트
278
+ * @param {string} resultFile — 결과 저장 파일 경로
279
+ * @param {object} [opts]
280
+ * @param {boolean} [opts.handoff=true]
281
+ * @param {string} [opts.mcp] — MCP 프로필 ("implement"|"analyze"|"review"|"docs")
282
+ * @param {string} [opts.contextFile] — 컨텍스트 파일 경로 (최대 32KB, UTF-8 안전 절단)
283
+ * @returns {string} PowerShell 명령
284
+ */
285
+ // ── Dashboard attach args for WT ────────────────────────────────
286
+
287
+ export function buildDashboardAttachArgs(
288
+ sessionName,
289
+ layout,
290
+ workerCount,
291
+ anchor = "window",
292
+ ) {
293
+ const safeName = String(sessionName).replace(/[^a-zA-Z0-9_-]/g, "");
294
+ const base = anchor === "tab" ? ["-w", "0", "nt"] : ["-w", "new"];
295
+ return [
296
+ ...base,
297
+ "--session",
298
+ safeName,
299
+ "--layout",
300
+ layout,
301
+ "--workers",
302
+ String(workerCount),
303
+ ];
304
+ }
305
+
306
+ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
307
+ const { handoff = true, mcp, contextFile, model, cwd } = opts;
308
+ const resolvedCli = resolveHeadlessCliType(cli);
309
+
310
+ // contextFile 처리: 32KB(32768 bytes) 초과 시 UTF-8 안전 절단
311
+ let contextPrefix = "";
312
+ if (contextFile && existsSync(contextFile)) {
313
+ let ctx = readFileSync(contextFile, "utf8");
314
+ if (Buffer.byteLength(ctx, "utf8") > 32768) {
315
+ ctx = Buffer.from(ctx).subarray(0, 32768).toString("utf8");
316
+ }
317
+ if (ctx.length > 0) {
318
+ contextPrefix = `<prior_context>\n${ctx}\n</prior_context>\n\n`;
319
+ }
320
+ }
321
+
322
+ const mcpHint =
323
+ mcp && MCP_PROFILE_HINTS[mcp]
324
+ ? ` [MCP: ${mcp}] ${MCP_PROFILE_HINTS[mcp]}`
325
+ : "";
326
+ // P2: HANDOFF 지시를 프롬프트에 삽입 (워커가 구조화된 handoff 블록을 출력하도록)
327
+ const handoffHint = handoff ? `\n\n${HANDOFF_INSTRUCTION_SHORT}` : "";
328
+ const fullPrompt = `${contextPrefix}${prompt}${mcpHint}${handoffHint}`;
329
+
330
+ // 보안: 프롬프트를 임시 파일에 쓰고 파일 참조로 전달 (셸 주입 방지).
331
+ // 이전 구현은 `"$(cat 'PATH')"` shell-expansion expression 을 만들어
332
+ // backend.buildArgs 에 전달했는데, 새 stdin-prompt 모드 (PR #252) 가
333
+ // 그 literal 을 user message 로 해석해서 codex 가 prompt 가 아닌
334
+ // shell expression 자체를 받는 회귀가 있었다. 이제 fullPrompt 를 그대로
335
+ // 전달하고 buildExecCommand 가 자체적으로 tmp file 작성 + stdin redirect.
336
+ if (!existsSync(RESULT_DIR)) mkdirSync(RESULT_DIR, { recursive: true });
337
+ const promptFile = join(
338
+ RESULT_DIR,
339
+ "prompt-" + randomUUID().slice(0, 8) + ".txt",
340
+ ).replace(/\\/g, "/");
341
+ writeFileSync(promptFile, fullPrompt, "utf8");
342
+ void IS_WINDOWS; // referenced for diagnostic guard chain below
343
+
344
+ const backendCommand =
345
+ resolvedCli === "antigravity"
346
+ ? buildRouteBackedHeadlessCommand(resolvedCli, promptFile, resultFile, {
347
+ ...opts,
348
+ mcp,
349
+ })
350
+ : getBackend(resolvedCli).buildArgs(fullPrompt, resultFile, {
351
+ ...opts,
352
+ model,
353
+ promptFile,
354
+ });
355
+ const safeCwd =
356
+ typeof cwd === "string" ? cwd.trim().replace(/[\r\n\x00-\x1f]/g, "") : "";
357
+ if (safeCwd && (safeCwd.startsWith("\\\\") || safeCwd.startsWith("//"))) {
358
+ throw new Error(
359
+ "[headless] UNC 경로는 cwd로 사용할 수 없습니다: " + safeCwd,
360
+ );
361
+ }
362
+ if (!safeCwd) return backendCommand;
363
+
364
+ // 플랫폼 분기: PowerShell은 Set-Location, bash/zsh는 cd
365
+ if (IS_WINDOWS) {
366
+ return `Set-Location -LiteralPath '${escapePwshSingleQuoted(safeCwd)}'; ${backendCommand}`;
367
+ }
368
+ return `cd '${safeCwd.replace(/'/g, "'\\''")}' && ${backendCommand}`;
369
+ }
370
+
371
+ function escapeRegExp(value) {
372
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
373
+ }
374
+
375
+ export function buildDaemonWrappedCommand(command, token) {
376
+ return `{ ${command}; __ec=$?; echo "${DAEMON_COMPLETION_PREFIX}${token}:$__ec"; }`;
377
+ }
378
+
379
+ export function waitForDaemonCompletionFromMessages(
380
+ messages,
381
+ { token, resultFile } = {},
382
+ ) {
383
+ const completionRegex = new RegExp(
384
+ `${escapeRegExp(DAEMON_COMPLETION_PREFIX)}${escapeRegExp(token)}:(\\d+)`,
385
+ );
386
+ const stream = [];
387
+ let matched = false;
388
+ let exitCode = null;
389
+
390
+ for (const message of messages) {
391
+ if (Array.isArray(message?.streamTail)) {
392
+ stream.push(...message.streamTail.map((line) => `${line}\n`));
393
+ }
394
+ if (typeof message?.line !== "string") continue;
395
+ const match = completionRegex.exec(message.line);
396
+ if (match) {
397
+ const beforeCompletion = message.line.slice(0, match.index);
398
+ if (beforeCompletion) stream.push(beforeCompletion);
399
+ matched = true;
400
+ exitCode = Number.parseInt(match[1], 10);
401
+ continue;
402
+ }
403
+ stream.push(message.line);
404
+ }
405
+
406
+ if (resultFile) {
407
+ writeFileSync(`${resultFile}.partial`, stream.join(""), "utf8");
408
+ }
409
+
410
+ return { matched, exitCode };
411
+ }
412
+
413
+ /**
414
+ * 이전 run 의 stale .txt / .partial / .err 를 제거한다.
415
+ * Issue #118 Codex review R1 HIGH: resultFile 경로 재사용 시 (워커 restart 또는
416
+ * 동일 세션명 재실행) 이전 run 의 HANDOFF 가 새 run 결과로 오인될 수 있다.
417
+ *
418
+ * Issue #118 R2 MEDIUM: Windows 에서 locked/open 파일로 인한 삭제 실패를
419
+ * silent swallow 하면 stale artifact 가 살아남아 bug 가 non-deterministic 하게
420
+ * 재발한다. ENOENT 외 에러는 경고 로그 + 재시도(1회) 후 계속.
421
+ * @param {string} resultFile
422
+ */
423
+ export function cleanStaleResultArtifacts(resultFile) {
424
+ for (const suffix of ["", ".partial", ".err"]) {
425
+ const path = `${resultFile}${suffix}`;
426
+ for (let attempt = 0; attempt < 2; attempt++) {
427
+ try {
428
+ rmSync(path);
429
+ break;
430
+ } catch (err) {
431
+ if (err?.code === "ENOENT") break; // 이미 없음 — 정상
432
+ if (attempt === 0) continue; // 1회 재시도
433
+ // 재시도도 실패: locked 파일일 수 있음. 경고 로그 후 계속 진행
434
+ // (dispatch 중단 시 워커 자체가 시작 못하므로 best-effort 유지)
435
+ console.warn(
436
+ `[headless] cleanStaleResultArtifacts: ${path} 삭제 실패 (${err?.code || "unknown"}). stale leak 가능.`,
437
+ );
438
+ }
439
+ }
440
+ }
441
+ }
442
+
443
+ /**
444
+ * 결과 파일 읽기 (없으면 capture-pane fallback)
445
+ * Issue #118 fallback chain: .txt → .partial ([partial] prefix) → .err → capture-pane
446
+ * @param {string} resultFile
447
+ * @param {string} paneId
448
+ * @returns {string}
449
+ */
450
+ export function readResult(resultFile, paneId) {
451
+ if (existsSync(resultFile)) {
452
+ return readFileSync(resultFile, "utf8").trim();
453
+ }
454
+ // Issue #118 fallback 0: timeout 직전 persist 된 부분 출력 (.partial)
455
+ const partialFile = `${resultFile}.partial`;
456
+ if (existsSync(partialFile)) {
457
+ const partial = readFileSync(partialFile, "utf8").trim();
458
+ if (partial) return `[partial] ${partial}`;
459
+ }
460
+ // fallback 1: stderr 파일 (codex 실패 시 원인 추적)
461
+ const errFile = `${resultFile}.err`;
462
+ if (existsSync(errFile)) {
463
+ const stderr = readFileSync(errFile, "utf8").trim();
464
+ if (stderr) return `[stderr] ${stderr}`;
465
+ }
466
+ // fallback 2: capture-pane (paneId = "tfx:0.1" 형태)
467
+ return capturePsmuxPane(paneId, 30);
468
+ }
469
+
470
+ // ─── Stall Detection ───
471
+
472
+ /** Stall detection 기본값 (immutable) */
473
+ export const STALL_DEFAULTS = Object.freeze({
474
+ pollInterval: 5_000,
475
+ stallTimeout: 120_000,
476
+ completionTimeout: 900_000,
477
+ maxRestarts: 2,
478
+ });
479
+
480
+ /** CLI pane stall 감지 에러 (STALL_EXHAUSTED | COMPLETION_TIMEOUT) */
481
+ export class StallError extends Error {
482
+ constructor(
483
+ message,
484
+ { code = "STALL_DETECTED", category = "transient", recovery = "" } = {},
485
+ ) {
486
+ super(message);
487
+ this.name = "StallError";
488
+ this.code = code;
489
+ this.category = category;
490
+ this.recovery = recovery;
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Stall 모니터 팩토리 — output + resultFile mtime 하이브리드 감지
496
+ * @param {string} paneId
497
+ * @param {string} resultFile
498
+ * @param {{ stallTimeout: number }} config
499
+ * @param {{ capturePsmuxPane?: Function, statSync?: Function }} [deps]
500
+ * @returns {{ poll: () => { snapshot: string, mtimeChanged: boolean, stalled: boolean, elapsed: number } }}
501
+ */
502
+ export function createStallMonitor(paneId, resultFile, config, deps = {}) {
503
+ const capture = deps.capturePsmuxPane || capturePsmuxPane;
504
+ const stat = deps.statSync || statSync;
505
+ let lastSnapshot = "";
506
+ let lastMtime = 0;
507
+ let lastChangeAt = Date.now();
508
+
509
+ try {
510
+ lastMtime = stat(resultFile).mtimeMs;
511
+ } catch {
512
+ /* not created yet */
513
+ }
514
+
515
+ return Object.freeze({
516
+ poll() {
517
+ const snapshot = capture(paneId, 50);
518
+ let currentMtime = 0;
519
+ try {
520
+ currentMtime = stat(resultFile).mtimeMs;
521
+ } catch {
522
+ /* ignore */
523
+ }
524
+
525
+ const outputChanged = snapshot !== lastSnapshot;
526
+ const mtimeChanged = currentMtime > 0 && currentMtime !== lastMtime;
527
+
528
+ if (outputChanged || mtimeChanged) {
529
+ lastChangeAt = Date.now();
530
+ lastSnapshot = snapshot;
531
+ if (mtimeChanged) lastMtime = currentMtime;
532
+ }
533
+
534
+ const elapsed = Date.now() - lastChangeAt;
535
+ return Object.freeze({
536
+ snapshot,
537
+ mtimeChanged,
538
+ stalled: elapsed >= config.stallTimeout,
539
+ elapsed,
540
+ });
541
+ },
542
+ });
543
+ }
544
+
545
+ /**
546
+ * 하이브리드 stall 감지 대기 — output 변화 + resultFile mtime 모니터링.
547
+ * 2분 무변화 시 pane kill → re-dispatch (최대 2회 재시작).
548
+ *
549
+ * @param {string} sessionName
550
+ * @param {string} paneId — 현재 pane 타겟 (예: "tfx:0.1")
551
+ * @param {string} resultFile — 결과 저장 파일 경로
552
+ * @param {object} [opts]
553
+ * @param {number} [opts.pollInterval=5000] — 폴링 간격 ms
554
+ * @param {number} [opts.stallTimeout=120000] — 무변화 stall 판정 ms
555
+ * @param {number} [opts.completionTimeout=900000] — 전체 타임아웃 ms
556
+ * @param {number} [opts.maxRestarts=2] — 최대 재시작 횟수
557
+ * @param {string} [opts.command] — re-dispatch용 원본 명령
558
+ * @param {string} [opts.token] — completion token
559
+ * @param {(snapshot: string) => void} [opts.onPoll] — 폴링 콜백
560
+ * @returns {Promise<{ matched: boolean, exitCode: number|null, restarts: number, stallDetected: boolean, paneId: string, token?: string, logPath?: string|null }>}
561
+ */
562
+ export async function waitForCompletionWithStallDetect(
563
+ sessionName,
564
+ paneId,
565
+ resultFile,
566
+ opts = {},
567
+ ) {
568
+ const {
569
+ pollInterval = 5000,
570
+ stallTimeout = 120000,
571
+ completionTimeout = 900000,
572
+ maxRestarts = 2,
573
+ command,
574
+ token,
575
+ onPoll,
576
+ _deps,
577
+ } = opts;
578
+
579
+ // 의존성 (테스트 시 _deps로 주입 가능)
580
+ const deps = _deps || {};
581
+ const _capture = deps.capturePsmuxPane || capturePsmuxPane;
582
+ const _exists = deps.existsSync || existsSync;
583
+ const _stat = deps.statSync || statSync;
584
+ const _readFile = deps.readFileSync || readFileSync;
585
+ const _exec = deps.psmuxExec || psmuxExec;
586
+ const _dispatch = deps.dispatchCommand || dispatchCommand;
587
+ const _startCapture = deps.startCapture || startCapture;
588
+
589
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
590
+ const buildCompletionRegex = (activeToken) => {
591
+ const completionPatterns = [
592
+ activeToken
593
+ ? `${esc("__TRIFLUX_DONE__:")}${esc(activeToken)}:(\\d+)`
594
+ : `${esc("__TRIFLUX_DONE__:")}\\S+:(\\d+)`,
595
+ activeToken
596
+ ? `${esc("TFX_DONE_")}${esc(activeToken)}:(\\d+)`
597
+ : `${esc("TFX_DONE_")}\\S+:(\\d+)`,
598
+ ];
599
+ return new RegExp(completionPatterns.join("|"), "m");
600
+ };
601
+
602
+ let restarts = 0;
603
+ let currentPaneId = paneId;
604
+ let stallDetected = false;
605
+ let currentToken = token;
606
+ let currentLogPath = opts.logPath || null;
607
+
608
+ while (true) {
609
+ let lastOutput = "";
610
+ let lastMtime = 0;
611
+ let lastChangeAt = Date.now();
612
+ const startedAt = Date.now();
613
+
614
+ // 초기 resultFile mtime
615
+ try {
616
+ if (_exists(resultFile)) lastMtime = _stat(resultFile).mtimeMs;
617
+ } catch {
618
+ /* 무시 */
619
+ }
620
+
621
+ while (true) {
622
+ await new Promise((r) => setTimeout(r, pollInterval));
623
+ const now = Date.now();
624
+
625
+ // 전체 타임아웃
626
+ if (now - startedAt > completionTimeout) {
627
+ // Issue #118: timeout kill 전에 capture-pane 출력을 .partial 파일로 persist.
628
+ // resultFile(`.txt`)이 아직 생성되지 않았을 수 있으므로 readResult 가 fallback 으로 읽는다.
629
+ try {
630
+ const partialSnapshot = _capture(currentPaneId, 200);
631
+ if (partialSnapshot && partialSnapshot.trim().length > 0) {
632
+ const writer = deps.writeFileSync || writeFileSync;
633
+ writer(`${resultFile}.partial`, partialSnapshot, "utf8");
634
+ }
635
+ } catch {
636
+ /* best-effort, timeout 은 이미 확정 */
637
+ }
638
+ return {
639
+ matched: false,
640
+ exitCode: null,
641
+ restarts,
642
+ stallDetected,
643
+ timedOut: true,
644
+ paneId: currentPaneId,
645
+ token: currentToken,
646
+ logPath: currentLogPath,
647
+ };
648
+ }
649
+
650
+ // 1) capture-pane 출력 확인
651
+ const currentOutput = _capture(currentPaneId, 50);
652
+ if (onPoll) {
653
+ try {
654
+ onPoll(currentOutput);
655
+ } catch {
656
+ /* 삼킴 */
657
+ }
658
+ }
659
+
660
+ // 2) completion 토큰 감지
661
+ const completionRe = buildCompletionRegex(currentToken);
662
+ const completionMatch = completionRe.exec(currentOutput);
663
+ if (completionMatch) {
664
+ return {
665
+ matched: true,
666
+ exitCode: Number.parseInt(
667
+ completionMatch.slice(1).find(Boolean) || "0",
668
+ 10,
669
+ ),
670
+ restarts,
671
+ stallDetected,
672
+ timedOut: false,
673
+ paneId: currentPaneId,
674
+ token: currentToken,
675
+ logPath: currentLogPath,
676
+ };
677
+ }
678
+
679
+ // 3) resultFile 존재 + mtime 변화 확인
680
+ let currentMtime = 0;
681
+ try {
682
+ if (_exists(resultFile)) currentMtime = _stat(resultFile).mtimeMs;
683
+ } catch {
684
+ /* 무시 */
685
+ }
686
+
687
+ // 4) 변화 감지 → stallTimer 리셋
688
+ const outputChanged = currentOutput !== lastOutput;
689
+ const mtimeChanged = currentMtime > 0 && currentMtime !== lastMtime;
690
+
691
+ if (outputChanged || mtimeChanged) {
692
+ lastChangeAt = now;
693
+ lastOutput = currentOutput;
694
+ if (mtimeChanged) lastMtime = currentMtime;
695
+ }
696
+
697
+ // resultFile이 갱신되고 내용이 있으면 완료로 간주
698
+ if (mtimeChanged && currentMtime > 0 && _exists(resultFile)) {
699
+ try {
700
+ const content = _readFile(resultFile, "utf8").trim();
701
+ if (content.length > 0) {
702
+ return {
703
+ matched: true,
704
+ exitCode: 0,
705
+ restarts,
706
+ stallDetected,
707
+ timedOut: false,
708
+ paneId: currentPaneId,
709
+ token: currentToken,
710
+ logPath: currentLogPath,
711
+ };
712
+ }
713
+ } catch {
714
+ /* 무시 */
715
+ }
716
+ }
717
+
718
+ // 5) stall 판정
719
+ if (now - lastChangeAt >= stallTimeout) {
720
+ stallDetected = true;
721
+
722
+ if (restarts >= maxRestarts) {
723
+ const err = new Error("CLI가 반복적으로 멈춤. 수동 확인 필요.");
724
+ err.code = "STALL_EXHAUSTED";
725
+ err.category = "transient";
726
+ err.recovery = "CLI가 반복적으로 멈춤. 수동 확인 필요.";
727
+ err.restarts = restarts;
728
+ throw err;
729
+ }
730
+
731
+ // kill pane → re-dispatch
732
+ try {
733
+ _exec(["kill-pane", "-t", currentPaneId]);
734
+ } catch {
735
+ /* 이미 종료 */
736
+ }
737
+
738
+ if (command) {
739
+ // 새 pane split + 동일 command re-dispatch
740
+ const newPaneId = _exec([
741
+ "split-window",
742
+ "-t",
743
+ sessionName,
744
+ "-P",
745
+ "-F",
746
+ "#{session_name}:#{window_index}.#{pane_index}",
747
+ ]);
748
+ _startCapture(sessionName, newPaneId);
749
+ const redispatch = _dispatch(sessionName, newPaneId, command);
750
+ currentPaneId = redispatch?.paneId || newPaneId;
751
+ if (redispatch?.token) currentToken = redispatch.token;
752
+ if (redispatch?.logPath) currentLogPath = redispatch.logPath;
753
+ }
754
+
755
+ restarts++;
756
+ break; // inner loop 재시작 (stallTimer 리셋)
757
+ }
758
+ }
759
+ }
760
+ }
761
+
762
+ /** progressive 스플릿 모드: lead pane만 생성 후, 워커를 하나씩 추가하며 dispatch */
763
+ async function dispatchProgressive(sessionName, assignments, opts = {}) {
764
+ const { layout, safeProgress, dashboardLayout = "single" } = opts;
765
+ const resolvedDashboardLayout = resolveDashboardLayout(
766
+ dashboardLayout,
767
+ assignments.length,
768
+ );
769
+ const session = createPsmuxSession(sessionName, { layout, paneCount: 1 });
770
+ applyTrifluxTheme(sessionName);
771
+ if (safeProgress) {
772
+ safeProgress({
773
+ type: "session_created",
774
+ sessionName,
775
+ panes: session.panes,
776
+ dashboardLayout: resolvedDashboardLayout,
777
+ });
778
+ }
779
+
780
+ // dashboard: 워커 pane을 먼저 생성한 후 pane 0에 대시보드를 실행
781
+ // (listPanes로 워커 감지가 가능하려면 워커 pane이 먼저 존재해야 함)
782
+
783
+ const dispatches = [];
784
+ for (let i = 0; i < assignments.length; i++) {
785
+ const assignment = assignments[i];
786
+ const paneName = `worker-${i + 1}`;
787
+ const displayName = resolveHeadlessDisplayName(assignment, paneName);
788
+ const workerId = getHeadlessWorkerAgentId(sessionName, i);
789
+ const resolvedCli = resolveHeadlessCliType(assignment.cli);
790
+ const brand = CLI_BRAND[resolvedCli] || {
791
+ emoji: "\u{25CF}",
792
+ label: resolvedCli,
793
+ ansi: "",
794
+ };
795
+ const paneTitle = `${brand.emoji} ${resolvedCli} (${displayName})`;
796
+
797
+ let newPaneId;
798
+ // 모든 워커를 split-window로 생성 (lead pane index 0은 비워둠)
799
+ // tui-viewer가 index 0을 건너뛰므로, 워커는 항상 index >= 1에 배치
800
+ newPaneId = psmuxExec([
801
+ "split-window",
802
+ "-t",
803
+ sessionName,
804
+ "-P",
805
+ "-F",
806
+ "#{session_name}:#{window_index}.#{pane_index}",
807
+ ]);
808
+
809
+ // 타이틀 설정 (이모지 포함)
810
+ try {
811
+ psmuxExec(["select-pane", "-t", newPaneId, "-T", paneTitle]);
812
+ } catch {
813
+ /* 무시 */
814
+ }
815
+ await registerHeadlessWorker(sessionName, i, resolvedCli);
816
+
817
+ if (safeProgress)
818
+ safeProgress({
819
+ type: "worker_added",
820
+ paneName,
821
+ displayName,
822
+ cli: resolvedCli,
823
+ paneTitle,
824
+ });
825
+
826
+ // 캡처 시작 + 컬러 배너 + 명령 dispatch
827
+ const resultFile = join(
828
+ RESULT_DIR,
829
+ `${sessionName}-${paneName}.txt`,
830
+ ).replace(/\\/g, "/");
831
+ // Issue #118 review R1 HIGH: stale artifact 제거 (이전 run / restart 잔재)
832
+ cleanStaleResultArtifacts(resultFile);
833
+ const cmd = buildHeadlessCommand(
834
+ assignment.cli,
835
+ assignment.prompt,
836
+ resultFile,
837
+ {
838
+ mcp: assignment.mcp,
839
+ role: assignment.role,
840
+ model: assignment.model,
841
+ cwd: assignment.cwd || assignment.workdir,
842
+ },
843
+ );
844
+ startCapture(sessionName, newPaneId);
845
+ // pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
846
+ if (i > 0) await new Promise((r) => setTimeout(r, 300));
847
+ const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
848
+ registerHeadlessSynapseWorker(workerId, assignment.prompt);
849
+
850
+ if (safeProgress)
851
+ safeProgress({ type: "dispatched", paneName, cli: resolvedCli });
852
+
853
+ dispatches.push({
854
+ ...dispatch,
855
+ paneId: newPaneId,
856
+ paneName,
857
+ displayName,
858
+ resultFile,
859
+ cli: resolvedCli,
860
+ role: assignment.role,
861
+ command: cmd,
862
+ workerId,
863
+ cwd: assignment.cwd || assignment.workdir,
864
+ });
865
+ }
866
+
867
+ // 모든 split 완료 후 레이아웃 한 번만 정렬 (깜빡임 방지)
868
+ try {
869
+ psmuxExec(["select-layout", "-t", sessionName, "tiled"]);
870
+ } catch {
871
+ /* 무시 */
872
+ }
873
+
874
+ // v7.1.3: psmux 내부 대시보드 pane 제거 — WT 스플릿에서 tui-viewer 직접 실행
875
+
876
+ return dispatches;
877
+ }
878
+
879
+ /** 기존 batch 모드: 모든 pane을 한 번에 생성하여 dispatch */
880
+ async function dispatchBatch(sessionName, assignments, opts = {}) {
881
+ const { layout, safeProgress, dashboardLayout = "single" } = opts;
882
+ const paneCount = assignments.length + 1;
883
+ const resolvedDashboardLayout = resolveDashboardLayout(
884
+ dashboardLayout,
885
+ assignments.length,
886
+ );
887
+ // A2b fix: 2x2 레이아웃은 최대 4 pane — 초과 시 tiled로 자동 전환
888
+ const effectiveLayout = layout === "2x2" && paneCount > 4 ? "tiled" : layout;
889
+ const session = createPsmuxSession(sessionName, {
890
+ layout: effectiveLayout,
891
+ paneCount,
892
+ });
893
+ applyTrifluxTheme(sessionName);
894
+ if (safeProgress) {
895
+ safeProgress({
896
+ type: "session_created",
897
+ sessionName,
898
+ panes: session.panes,
899
+ dashboardLayout: resolvedDashboardLayout,
900
+ });
901
+ }
902
+
903
+ return await Promise.all(
904
+ assignments.map(async (assignment, i) => {
905
+ const paneName = `worker-${i + 1}`;
906
+ const displayName = resolveHeadlessDisplayName(assignment, paneName);
907
+ const workerId = getHeadlessWorkerAgentId(sessionName, i);
908
+ const resolvedCli = resolveHeadlessCliType(assignment.cli);
909
+ const resultFile = join(
910
+ RESULT_DIR,
911
+ `${sessionName}-${paneName}.txt`,
912
+ ).replace(/\\/g, "/");
913
+ // Issue #118 review R1 HIGH: stale artifact 제거 (이전 run / restart 잔재)
914
+ cleanStaleResultArtifacts(resultFile);
915
+ const cmd = buildHeadlessCommand(
916
+ assignment.cli,
917
+ assignment.prompt,
918
+ resultFile,
919
+ {
920
+ mcp: assignment.mcp,
921
+ role: assignment.role,
922
+ model: assignment.model,
923
+ cwd: assignment.cwd || assignment.workdir,
924
+ },
925
+ );
926
+ const scriptDir = join(RESULT_DIR, sessionName);
927
+ await registerHeadlessWorker(sessionName, i, resolvedCli);
928
+ const dispatch = dispatchCommand(sessionName, paneName, cmd, {
929
+ scriptDir,
930
+ scriptName: paneName,
931
+ });
932
+ registerHeadlessSynapseWorker(workerId, assignment.prompt);
933
+
934
+ // P1 fix: 비-progressive에서는 pane 리네임 금지 — 캡처 로그 경로가 타이틀 기반이므로
935
+ // 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
936
+ // progressive 모드에서는 split-window 시 새 pane에 바로 타이틀이 설정되므로 문제없음
937
+
938
+ if (safeProgress)
939
+ safeProgress({
940
+ type: "dispatched",
941
+ paneName,
942
+ displayName,
943
+ cli: resolvedCli,
944
+ });
945
+
946
+ return {
947
+ ...dispatch,
948
+ paneName,
949
+ displayName,
950
+ resultFile,
951
+ cli: resolvedCli,
952
+ role: assignment.role,
953
+ command: cmd,
954
+ workerId,
955
+ cwd: assignment.cwd || assignment.workdir,
956
+ };
957
+ }),
958
+ );
959
+ }
960
+
961
+ async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
962
+ const { safeProgress, configDir } = opts;
963
+ const paths = deriveClaudeControlPaths({ configDir });
964
+ const dispatches = [];
965
+
966
+ try {
967
+ for (let i = 0; i < assignments.length; i += 1) {
968
+ const assignment = assignments[i];
969
+ const paneName = `worker-${i + 1}`;
970
+ const displayName = resolveHeadlessDisplayName(assignment, paneName);
971
+ const workerId = getHeadlessWorkerAgentId(sessionName, i);
972
+ const resolvedCli = resolveHeadlessCliType(assignment.cli);
973
+ const resultFile = join(
974
+ RESULT_DIR,
975
+ `${sessionName}-${paneName}.txt`,
976
+ ).replace(/\\/g, "/");
977
+ cleanStaleResultArtifacts(resultFile);
978
+ const command = buildHeadlessCommand(
979
+ assignment.cli,
980
+ assignment.prompt,
981
+ resultFile,
982
+ {
983
+ mcp: assignment.mcp,
984
+ role: assignment.role,
985
+ model: assignment.model,
986
+ cwd: assignment.cwd || assignment.workdir,
987
+ },
988
+ );
989
+ const token = randomUUID().slice(0, 10);
990
+ const short = randomUUID().replace(/-/g, "").slice(0, 8);
991
+ const name = `Triflux ${resolvedCli || "worker"} ${displayName}`;
992
+ const record = {
993
+ paneId: `daemon:${short}`,
994
+ paneName,
995
+ displayName,
996
+ resultFile,
997
+ cli: resolvedCli,
998
+ role: assignment.role,
999
+ command,
1000
+ token,
1001
+ workerId,
1002
+ cwd: assignment.cwd || assignment.workdir,
1003
+ daemonShort: short,
1004
+ daemonPaths: paths,
1005
+ controlSock: paths.controlSock,
1006
+ sessionId: "",
1007
+ sessionProjectionPath: "",
1008
+ daemonCompletionMatched: false,
1009
+ };
1010
+ dispatches.push(record);
1011
+
1012
+ const payload = buildDaemonExecDispatchPayload({
1013
+ short,
1014
+ cwd: assignment.cwd || assignment.workdir || process.cwd(),
1015
+ command: buildDaemonWrappedCommand(command, token),
1016
+ name,
1017
+ });
1018
+ record.sessionId = payload.sessionId;
1019
+ const dispatch = await sendClaudeControlRequest(paths.controlSock, {
1020
+ proto: 1,
1021
+ op: "dispatch",
1022
+ d: payload,
1023
+ timeoutMs: 5000,
1024
+ });
1025
+ if (dispatch?.ok !== true) {
1026
+ throw new Error(
1027
+ `[headless] Claude daemon dispatch failed for ${paneName}`,
1028
+ );
1029
+ }
1030
+
1031
+ const job = await waitForDaemonJobPid(paths.controlSock, short);
1032
+ const bridgeSessionId = await resolveDaemonBridgeSessionId({
1033
+ daemonPaths: paths,
1034
+ short,
1035
+ job,
1036
+ });
1037
+ const projection = buildClaudeSessionProjection({
1038
+ pid: job.pid,
1039
+ procStart: getProcStart(job.pid),
1040
+ sessionId: payload.sessionId,
1041
+ short,
1042
+ cwd: assignment.cwd || assignment.workdir || process.cwd(),
1043
+ name,
1044
+ agent: resolvedCli || "codex",
1045
+ startedAt: job.startedAt || Date.now(),
1046
+ updatedAt: Date.now(),
1047
+ bridgeSessionId,
1048
+ });
1049
+ record.sessionProjectionPath = await writeClaudeSessionProjection(
1050
+ paths.sessionsDir,
1051
+ projection,
1052
+ );
1053
+ await registerHeadlessWorker(sessionName, i, resolvedCli);
1054
+ registerHeadlessSynapseWorker(workerId, assignment.prompt);
1055
+ if (safeProgress) {
1056
+ safeProgress({ type: "dispatched", paneName, cli: resolvedCli });
1057
+ }
1058
+ }
1059
+ } catch (error) {
1060
+ await cleanupDaemonDispatches(dispatches);
1061
+ throw error;
1062
+ }
1063
+
1064
+ return dispatches;
1065
+ }
1066
+
1067
+ export async function cleanupDaemonDispatches(dispatches) {
1068
+ await Promise.all(
1069
+ dispatches.map(async (dispatch) => {
1070
+ if (dispatch.sessionProjectionPath) {
1071
+ await removeClaudeSessionProjection(
1072
+ dispatch.sessionProjectionPath,
1073
+ ).catch(() => {});
1074
+ }
1075
+ if (
1076
+ dispatch.daemonCompletionMatched === true &&
1077
+ dispatch.daemonPaths &&
1078
+ dispatch.sessionId
1079
+ ) {
1080
+ await sendKillBySessionId({
1081
+ daemonPaths: dispatch.daemonPaths,
1082
+ sessionId: dispatch.sessionId,
1083
+ }).catch(() => {});
1084
+ }
1085
+ if (dispatch.controlSock && dispatch.daemonShort) {
1086
+ await killDaemonJob(dispatch.controlSock, dispatch.daemonShort).catch(
1087
+ () => {},
1088
+ );
1089
+ }
1090
+ }),
1091
+ );
1092
+ }
1093
+
1094
+ /**
1095
+ * 모든 dispatch를 병렬 대기하며 완료 결과를 수집한다.
1096
+ * @param {string} sessionName
1097
+ * @param {Array} dispatches
1098
+ * @param {number} timeoutSec
1099
+ * @param {Function|null} safeProgress
1100
+ * @param {number} progressIntervalSec
1101
+ * @returns {Promise<Array<{d, completion, output}>>}
1102
+ */
1103
+ async function awaitAll(
1104
+ sessionName,
1105
+ dispatches,
1106
+ timeoutSec,
1107
+ safeProgress,
1108
+ progressIntervalSec,
1109
+ stallOpts,
1110
+ ) {
1111
+ // 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
1112
+ return Promise.all(
1113
+ dispatches.map(async (d) => {
1114
+ // onPoll → onProgress 변환 (throttle by progressIntervalSec)
1115
+ const pollOpts = {};
1116
+ if (safeProgress && progressIntervalSec > 0) {
1117
+ let lastProgressAt = 0;
1118
+ const intervalMs = progressIntervalSec * 1000;
1119
+ pollOpts.onPoll = ({ content }) => {
1120
+ const now = Date.now();
1121
+ if (now - lastProgressAt >= intervalMs) {
1122
+ lastProgressAt = now;
1123
+ safeProgress({
1124
+ type: "progress",
1125
+ paneName: d.paneName,
1126
+ displayName: d.displayName,
1127
+ cli: d.cli,
1128
+ snapshot: content.split("\n").slice(-15).join("\n"), // 마지막 15줄
1129
+ });
1130
+ }
1131
+ };
1132
+ }
1133
+
1134
+ let completion;
1135
+ if (stallOpts?.enabled) {
1136
+ // 하이브리드 stall detection 모드
1137
+ try {
1138
+ const stallPollCb =
1139
+ safeProgress && progressIntervalSec > 0
1140
+ ? (snapshot) => {
1141
+ try {
1142
+ safeProgress({
1143
+ type: "progress",
1144
+ paneName: d.paneName,
1145
+ displayName: d.displayName,
1146
+ cli: d.cli,
1147
+ snapshot: snapshot.split("\n").slice(-15).join("\n"),
1148
+ });
1149
+ } catch {
1150
+ /* 삼킴 */
1151
+ }
1152
+ }
1153
+ : undefined;
1154
+
1155
+ const stallResult = await waitForCompletionWithStallDetect(
1156
+ sessionName,
1157
+ d.paneId || d.paneName,
1158
+ d.resultFile,
1159
+ {
1160
+ pollInterval: stallOpts.pollInterval,
1161
+ stallTimeout: stallOpts.stallTimeout,
1162
+ completionTimeout:
1163
+ stallOpts.completionTimeout ?? timeoutSec * 1000,
1164
+ maxRestarts: stallOpts.maxRestarts,
1165
+ command: d.command,
1166
+ token: d.token,
1167
+ onPoll: stallPollCb,
1168
+ },
1169
+ );
1170
+ if (stallResult.paneId) d.paneId = stallResult.paneId;
1171
+ if (stallResult.token) d.token = stallResult.token;
1172
+ if (stallResult.logPath) d.logPath = stallResult.logPath;
1173
+ completion = {
1174
+ matched: stallResult.matched,
1175
+ exitCode: stallResult.exitCode,
1176
+ stallDetected: stallResult.stallDetected,
1177
+ restarts: stallResult.restarts,
1178
+ };
1179
+ } catch (stallErr) {
1180
+ if (stallErr.code === "STALL_EXHAUSTED") {
1181
+ completion = {
1182
+ matched: false,
1183
+ exitCode: null,
1184
+ stallExhausted: true,
1185
+ restarts: stallErr.restarts,
1186
+ };
1187
+ } else {
1188
+ throw stallErr;
1189
+ }
1190
+ }
1191
+ } else {
1192
+ // 기존 waitForCompletion 경로
1193
+ if (d.logPath) pollOpts.logPath = d.logPath;
1194
+ completion = await waitForCompletion(
1195
+ sessionName,
1196
+ d.paneId || d.paneName,
1197
+ d.token,
1198
+ timeoutSec,
1199
+ pollOpts,
1200
+ );
1201
+ }
1202
+
1203
+ // Issue #118: matched=false(timeout kill) 에도 capture-pane 스냅샷을
1204
+ // .partial 로 persist 하여 readResult fallback chain 이 복구할 수 있도록 한다.
1205
+ if (!completion.matched) {
1206
+ try {
1207
+ const snap = capturePsmuxPane(d.paneId || d.paneName, 200);
1208
+ if (snap && snap.trim().length > 0) {
1209
+ writeFileSync(`${d.resultFile}.partial`, snap, "utf8");
1210
+ }
1211
+ } catch {
1212
+ /* best-effort */
1213
+ }
1214
+ }
1215
+
1216
+ const output = readResult(d.resultFile, d.paneId);
1217
+ unregisterHeadlessSynapseWorker(d.workerId);
1218
+
1219
+ if (safeProgress) {
1220
+ safeProgress({
1221
+ type: "completed",
1222
+ paneName: d.paneName,
1223
+ displayName: d.displayName,
1224
+ cli: d.cli,
1225
+ matched: completion.matched,
1226
+ exitCode: completion.exitCode,
1227
+ sessionDead: completion.sessionDead || false,
1228
+ stallDetected: completion.stallDetected || false,
1229
+ stallExhausted: completion.stallExhausted || false,
1230
+ });
1231
+ }
1232
+
1233
+ return { d, completion, output };
1234
+ }),
1235
+ );
1236
+ }
1237
+
1238
+ async function waitForDaemonCompletion(
1239
+ dispatch,
1240
+ timeoutSec,
1241
+ safeProgress,
1242
+ progressIntervalSec,
1243
+ ) {
1244
+ const messages = [];
1245
+ const socket = net.connect(dispatch.controlSock);
1246
+ let buffer = "";
1247
+ let settled = false;
1248
+ let lastProgressAt = 0;
1249
+
1250
+ return await new Promise((resolve) => {
1251
+ const finish = (completion) => {
1252
+ if (settled) return;
1253
+ settled = true;
1254
+ clearTimeout(timer);
1255
+ socket.destroy();
1256
+ const finalCompletion =
1257
+ completion ||
1258
+ waitForDaemonCompletionFromMessages(messages, {
1259
+ token: dispatch.token,
1260
+ resultFile: dispatch.resultFile,
1261
+ });
1262
+ if (finalCompletion.matched) {
1263
+ dispatch.daemonCompletionMatched = true;
1264
+ cleanupDaemonDispatches([dispatch]).catch(() => {});
1265
+ } else {
1266
+ killDaemonJob(dispatch.controlSock, dispatch.daemonShort).catch(
1267
+ () => {},
1268
+ );
1269
+ }
1270
+ resolve(finalCompletion);
1271
+ };
1272
+
1273
+ const timer = setTimeout(
1274
+ () => {
1275
+ finish({ matched: false, exitCode: null });
1276
+ },
1277
+ Math.max(0, timeoutSec * 1000),
1278
+ );
1279
+
1280
+ socket.on("error", () => {
1281
+ finish({ matched: false, exitCode: null });
1282
+ });
1283
+ socket.on("close", () => {
1284
+ if (!settled) finish({ matched: false, exitCode: null });
1285
+ });
1286
+ socket.on("connect", () => {
1287
+ socket.write(
1288
+ `${JSON.stringify({
1289
+ proto: 1,
1290
+ op: "subscribe",
1291
+ short: dispatch.daemonShort,
1292
+ tail: 20,
1293
+ })}\n`,
1294
+ );
1295
+ });
1296
+ socket.on("data", (chunk) => {
1297
+ buffer += chunk.toString("utf8");
1298
+ while (buffer.includes("\n")) {
1299
+ const index = buffer.indexOf("\n");
1300
+ const line = buffer.slice(0, index);
1301
+ buffer = buffer.slice(index + 1);
1302
+ if (!line.trim()) continue;
1303
+ const message = JSON.parse(line);
1304
+ messages.push(message);
1305
+ if (
1306
+ safeProgress &&
1307
+ progressIntervalSec > 0 &&
1308
+ typeof message.line === "string"
1309
+ ) {
1310
+ const now = Date.now();
1311
+ if (now - lastProgressAt >= progressIntervalSec * 1000) {
1312
+ lastProgressAt = now;
1313
+ safeProgress({
1314
+ type: "progress",
1315
+ paneName: dispatch.paneName,
1316
+ displayName: dispatch.displayName,
1317
+ cli: dispatch.cli,
1318
+ snapshot: message.line.split("\n").slice(-15).join("\n"),
1319
+ });
1320
+ }
1321
+ }
1322
+ const completion = waitForDaemonCompletionFromMessages(messages, {
1323
+ token: dispatch.token,
1324
+ resultFile: dispatch.resultFile,
1325
+ });
1326
+ if (completion.matched) finish(completion);
1327
+ }
1328
+ });
1329
+ });
1330
+ }
1331
+
1332
+ async function awaitAllDaemon(
1333
+ dispatches,
1334
+ timeoutSec,
1335
+ safeProgress,
1336
+ progressIntervalSec,
1337
+ ) {
1338
+ return Promise.all(
1339
+ dispatches.map(async (d) => {
1340
+ const completion = await waitForDaemonCompletion(
1341
+ d,
1342
+ timeoutSec,
1343
+ safeProgress,
1344
+ progressIntervalSec,
1345
+ );
1346
+ const output = readResult(d.resultFile, d.paneId);
1347
+ unregisterHeadlessSynapseWorker(d.workerId);
1348
+
1349
+ if (safeProgress) {
1350
+ safeProgress({
1351
+ type: "completed",
1352
+ paneName: d.paneName,
1353
+ displayName: d.displayName,
1354
+ cli: d.cli,
1355
+ matched: completion.matched,
1356
+ exitCode: completion.exitCode,
1357
+ sessionDead: false,
1358
+ stallDetected: false,
1359
+ stallExhausted: false,
1360
+ });
1361
+ }
1362
+
1363
+ return { d, completion, output };
1364
+ }),
1365
+ );
1366
+ }
1367
+
1368
+ /**
1369
+ * git diff + handoff 파이프라인을 적용하여 최종 결과 배열을 반환한다.
1370
+ * @param {Array<{d, completion, output}>} results
1371
+ * @returns {Array}
1372
+ */
1373
+ async function collectResults(sessionName, results) {
1374
+ // handoff 파이프라인: parse → validate → format (각 워커 결과에 적용)
1375
+ return await Promise.all(
1376
+ results.map(async ({ d, completion, output }) => {
1377
+ const workerGitDiffFiles = collectGitDiffFiles(d.cwd);
1378
+ const handoffResult = processHandoff(output, {
1379
+ exitCode: completion.exitCode,
1380
+ resultFile: d.resultFile,
1381
+ cli: d.cli,
1382
+ gitDiffFiles: workerGitDiffFiles,
1383
+ });
1384
+ if (
1385
+ completion.exitCode === 0 &&
1386
+ workerGitDiffFiles.length === 0 &&
1387
+ handoffResult.handoff?.lead_action === "accept"
1388
+ ) {
1389
+ handoffResult.handoff = {
1390
+ ...handoffResult.handoff,
1391
+ lead_action: "needs_read",
1392
+ };
1393
+ handoffResult.formatted = formatHandoffForLead(handoffResult.handoff);
1394
+ }
1395
+ const status =
1396
+ handoffResult.handoff?.status ||
1397
+ (completion.matched && completion.exitCode === 0
1398
+ ? "completed"
1399
+ : "failed");
1400
+ await publishHeadlessResult(
1401
+ sessionName,
1402
+ d.workerId,
1403
+ status,
1404
+ handoffResult.handoff,
1405
+ );
1406
+
1407
+ return {
1408
+ cli: d.cli,
1409
+ paneName: d.paneName,
1410
+ displayName: d.displayName,
1411
+ paneId: d.paneId,
1412
+ workerId: d.workerId,
1413
+ role: d.role,
1414
+ matched: completion.matched,
1415
+ exitCode: completion.exitCode,
1416
+ output,
1417
+ resultFile: d.resultFile,
1418
+ workerGitDiffFiles,
1419
+ sessionDead: completion.sessionDead || false,
1420
+ handoff: handoffResult.handoff,
1421
+ handoffFormatted: handoffResult.formatted,
1422
+ handoffValid: handoffResult.valid,
1423
+ handoffFallback: handoffResult.fallback,
1424
+ };
1425
+ }),
1426
+ );
1427
+ }
1428
+
1429
+ function collectGitDiffFiles(cwd) {
1430
+ try {
1431
+ const diffOut = execSync("git diff --name-only HEAD", {
1432
+ cwd,
1433
+ encoding: "utf8",
1434
+ timeout: 5000,
1435
+ stdio: ["pipe", "pipe", "pipe"],
1436
+ });
1437
+ return diffOut.trim().split("\n").filter(Boolean);
1438
+ } catch {
1439
+ return [];
1440
+ }
1441
+ }
1442
+
1443
+ /**
1444
+ * 헤드리스 CLI 오케스트레이션 실행
1445
+ *
1446
+ * @param {string} sessionName — psmux 세션 이름
1447
+ * @param {Array<{cli: string, prompt: string, role?: string}>} assignments
1448
+ * @param {object} [opts]
1449
+ * @param {number} [opts.timeoutSec=300] — 각 워커 타임아웃
1450
+ * @param {string} [opts.layout='2x2'] — pane 레이아웃
1451
+ * @param {(event: object) => void} [opts.onProgress] — 진행 콜백
1452
+ * @param {number} [opts.progressIntervalSec=0] — N초마다 progress 이벤트 발화 (0=비활성)
1453
+ * @param {boolean} [opts.progressive=true] — true면 pane을 하나씩 split-window로 추가 (실시간 스플릿)
1454
+ * @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
1455
+ * @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}> }}
1456
+ */
1457
+ export async function runHeadless(sessionName, assignments, opts = {}) {
1458
+ const {
1459
+ timeoutSec = 900,
1460
+ layout = "2x2",
1461
+ onProgress,
1462
+ progressIntervalSec = 0,
1463
+ progressive = true,
1464
+ dashboard = false,
1465
+ dashboardLayout = "single",
1466
+ stallDetect,
1467
+ nativeBridge = false,
1468
+ nativeBridgeMode = "roster",
1469
+ } = opts;
1470
+
1471
+ mkdirSync(RESULT_DIR, { recursive: true });
1472
+ const normalizedAssignments = (assignments || []).map((assignment, index) =>
1473
+ normalizeHeadlessAssignment(assignment, index),
1474
+ );
1475
+
1476
+ if (normalizedAssignments.length === 0) {
1477
+ return { sessionName, results: [] };
1478
+ }
1479
+
1480
+ let nativeBridgeHandle = null;
1481
+ let daemonDispatches = [];
1482
+ if (nativeBridge && nativeBridgeMode === "claude-wrapper") {
1483
+ throw new Error(
1484
+ "[headless] --native-bridge-mode claude-wrapper is reserved and not implemented yet",
1485
+ );
1486
+ }
1487
+ if (
1488
+ nativeBridge &&
1489
+ (nativeBridgeMode === "roster" || nativeBridgeMode === "interactive-attach")
1490
+ ) {
1491
+ nativeBridgeHandle = await startClaudeNativeBridge({
1492
+ sessionName,
1493
+ assignments: normalizedAssignments,
1494
+ cwd: process.cwd(),
1495
+ workerType:
1496
+ nativeBridgeMode === "interactive-attach" ? "interactive" : "headless",
1497
+ onKill() {
1498
+ try {
1499
+ killPsmuxSession(sessionName);
1500
+ } catch {
1501
+ /* session may not exist yet */
1502
+ }
1503
+ },
1504
+ });
1505
+ process.stderr.write(
1506
+ `[headless] Claude native bridge roster written: ${nativeBridgeHandle.rosterPath}\n`,
1507
+ );
1508
+ }
1509
+
1510
+ // Hub version skew pre-flight (fail-open, best-effort)
1511
+ requestJson("/status", { method: "GET", timeoutMs: 500 })
1512
+ .then((status) => {
1513
+ const hubRate = status?.spawn_trace?.max_per_sec;
1514
+ const localRate = getMaxSpawnPerSec();
1515
+ if (typeof hubRate === "number" && hubRate !== localRate) {
1516
+ console.warn(
1517
+ `[headless] Hub version skew detected: hub spawn rate=${hubRate}/s, local=${localRate}/s. Restart hub to sync.`,
1518
+ );
1519
+ }
1520
+ })
1521
+ .catch(() => {});
1522
+
1523
+ // Synapse: 세션 registration (fire-and-forget, hub 미응답 시 무시)
1524
+ const synapseIds = normalizedAssignments.map(
1525
+ (_, i) => `${sessionName}-worker-${i + 1}`,
1526
+ );
1527
+ for (let i = 0; i < normalizedAssignments.length; i++) {
1528
+ const a = normalizedAssignments[i];
1529
+ requestJson("/synapse/register", {
1530
+ method: "POST",
1531
+ body: {
1532
+ sessionId: synapseIds[i],
1533
+ host: "local",
1534
+ taskSummary: String(a.prompt || "").slice(0, 100),
1535
+ isRemote: false,
1536
+ },
1537
+ timeoutMs: 1000,
1538
+ }).catch(() => {});
1539
+ }
1540
+
1541
+ // in-process TUI: dashboard=true이고 stdout이 TTY일 때 직접 구동
1542
+ let tui = null;
1543
+ const resolvedLayout = resolveDashboardLayout(
1544
+ dashboardLayout,
1545
+ normalizedAssignments.length,
1546
+ );
1547
+ if (dashboard && process.stdout.isTTY) {
1548
+ tui = createLogDashboard({
1549
+ stream: process.stdout,
1550
+ input: process.stdin,
1551
+ refreshMs: 200,
1552
+ layout: resolvedLayout,
1553
+ });
1554
+ tui.setStartTime(Date.now());
1555
+ // 초기 워커 상태 등록
1556
+ for (let i = 0; i < normalizedAssignments.length; i++) {
1557
+ const a = normalizedAssignments[i];
1558
+ const paneName = `worker-${i + 1}`;
1559
+ tui.updateWorker(paneName, {
1560
+ cli: resolveHeadlessCliType(a.cli || "codex"),
1561
+ displayName: resolveHeadlessDisplayName(a, paneName),
1562
+ role: a.role || "",
1563
+ status: "pending",
1564
+ progress: 0,
1565
+ });
1566
+ }
1567
+ } else if (dashboard) {
1568
+ // Issue #116-F: --dashboard 요청이 non-TTY 환경에서 silent skip 되던 혼란 해소.
1569
+ // stderr 로만 안내 (stdout 은 readHeadlessResult 파싱 대상이라 건드리지 않는다).
1570
+ const logDir = join(tmpdir(), "tfx-headless");
1571
+ process.stderr.write(
1572
+ `\n⚠ --dashboard requested but stdout is not a TTY; dashboard is skipped.\n` +
1573
+ ` Session is running in background. Worker logs:\n` +
1574
+ ` ${logDir}${IS_WINDOWS ? "\\" : "/"}${sessionName}-worker-N.txt\n` +
1575
+ ` ${logDir}${IS_WINDOWS ? "\\" : "/"}${sessionName}-worker-N.txt.err\n\n`,
1576
+ );
1577
+ }
1578
+
1579
+ // per-worker state feed: onProgress 이벤트 → tui.updateWorker()
1580
+ function feedTui(event) {
1581
+ if (!tui) return;
1582
+ const { type, paneName, displayName, cli, snapshot, matched, exitCode } =
1583
+ event;
1584
+ if (!paneName) return;
1585
+
1586
+ if (type === "progress" && snapshot) {
1587
+ tui.updateWorker(paneName, {
1588
+ cli: cli || "codex",
1589
+ displayName,
1590
+ status: "running",
1591
+ snapshot: snapshot.split("\n").at(-1) || "",
1592
+ summary: snapshot.split("\n").at(-1) || "",
1593
+ detail: snapshot,
1594
+ progress: 0.5,
1595
+ });
1596
+ } else if (type === "completed") {
1597
+ const status = matched && exitCode === 0 ? "completed" : "failed";
1598
+ tui.updateWorker(paneName, {
1599
+ cli: cli || "codex",
1600
+ displayName,
1601
+ status,
1602
+ progress: 1,
1603
+ });
1604
+ } else if (type === "worker_added") {
1605
+ tui.updateWorker(paneName, {
1606
+ cli: cli || "codex",
1607
+ displayName,
1608
+ status: "running",
1609
+ progress: 0.05,
1610
+ });
1611
+ }
1612
+ }
1613
+
1614
+ // Synapse heartbeat: progress 이벤트마다 해당 워커의 세션 갱신
1615
+ const feedSynapse = (event) => {
1616
+ if (!event?.paneName) return;
1617
+ const match = event.paneName.match(/worker-(\d+)/);
1618
+ if (!match) return;
1619
+ const idx = parseInt(match[1], 10) - 1;
1620
+ const sid = synapseIds[idx];
1621
+ if (!sid) return;
1622
+ requestJson("/synapse/heartbeat", {
1623
+ method: "POST",
1624
+ body: {
1625
+ sessionId: sid,
1626
+ partial: { taskSummary: (event.snapshot || "").slice(0, 100) },
1627
+ },
1628
+ timeoutMs: 500,
1629
+ }).catch(() => {});
1630
+ };
1631
+
1632
+ // onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
1633
+ const combinedProgress = (event) => {
1634
+ nativeBridgeHandle?.handleProgress(event);
1635
+ feedTui(event);
1636
+ feedSynapse(event);
1637
+ if (onProgress) {
1638
+ try {
1639
+ onProgress(event);
1640
+ } catch {
1641
+ /* 콜백 예외 삼킴 */
1642
+ }
1643
+ }
1644
+ };
1645
+ const safeProgress = (event) => {
1646
+ try {
1647
+ combinedProgress(event);
1648
+ } catch {
1649
+ /* 삼킴 */
1650
+ }
1651
+ };
1652
+
1653
+ try {
1654
+ const dispatches =
1655
+ nativeBridge && nativeBridgeMode === "agents"
1656
+ ? await dispatchDaemonBatch(sessionName, normalizedAssignments, {
1657
+ safeProgress,
1658
+ })
1659
+ : progressive
1660
+ ? await dispatchProgressive(sessionName, normalizedAssignments, {
1661
+ layout,
1662
+ safeProgress,
1663
+ dashboardLayout,
1664
+ })
1665
+ : await dispatchBatch(sessionName, normalizedAssignments, {
1666
+ layout,
1667
+ safeProgress,
1668
+ dashboardLayout,
1669
+ });
1670
+ if (nativeBridge && nativeBridgeMode === "agents") {
1671
+ daemonDispatches = dispatches;
1672
+ }
1673
+
1674
+ const results =
1675
+ nativeBridge && nativeBridgeMode === "agents"
1676
+ ? await awaitAllDaemon(
1677
+ dispatches,
1678
+ timeoutSec,
1679
+ safeProgress,
1680
+ progressIntervalSec,
1681
+ )
1682
+ : await awaitAll(
1683
+ sessionName,
1684
+ dispatches,
1685
+ timeoutSec,
1686
+ safeProgress,
1687
+ progressIntervalSec,
1688
+ stallDetect,
1689
+ );
1690
+ const collected = await collectResults(sessionName, results);
1691
+ for (const result of collected) nativeBridgeHandle?.completeWorker(result);
1692
+
1693
+ // 완료 시 TUI에 최종 상태 반영 후 닫기
1694
+ if (tui) {
1695
+ for (const r of collected) {
1696
+ tui.updateWorker(r.paneName, {
1697
+ cli: r.cli,
1698
+ displayName: r.displayName,
1699
+ role: r.role || "",
1700
+ status: r.handoff?.status === "failed" ? "failed" : "completed",
1701
+ handoff: r.handoff,
1702
+ summary: r.handoff?.verdict || (r.matched ? "completed" : "failed"),
1703
+ detail: r.output,
1704
+ progress: 1,
1705
+ elapsed: Math.round(
1706
+ (Date.now() - (tui._startedAt || Date.now())) / 1000,
1707
+ ),
1708
+ });
1709
+ }
1710
+ tui.render();
1711
+ // 최종 화면을 잠깐 유지 후 닫기
1712
+ await new Promise((r) => setTimeout(r, 1500));
1713
+ tui.close();
1714
+ }
1715
+
1716
+ return { sessionName, results: collected };
1717
+ } finally {
1718
+ // Synapse: 세션 unregister (fire-and-forget)
1719
+ for (const sid of synapseIds) {
1720
+ requestJson("/synapse/unregister", {
1721
+ method: "POST",
1722
+ body: { sessionId: sid },
1723
+ timeoutMs: 1000,
1724
+ }).catch(() => {});
1725
+ }
1726
+ if (nativeBridgeHandle) await nativeBridgeHandle.close();
1727
+ if (daemonDispatches.length > 0) {
1728
+ await cleanupDaemonDispatches(daemonDispatches);
1729
+ }
1730
+ }
1731
+ }
1732
+
1733
+ /**
1734
+ * 헤드리스 실행 + 자동 정리
1735
+ * 성공/실패에 관계없이 세션을 정리한다.
1736
+ *
1737
+ * @param {Array<{cli: string, prompt: string, role?: string}>} assignments
1738
+ * @param {object} [opts] — runHeadless opts + sessionPrefix
1739
+ * @returns {{ results: Array, sessionName: string }}
1740
+ */
1741
+ export async function runHeadlessWithCleanup(assignments, opts = {}) {
1742
+ const { sessionPrefix = "tfx-hl", ...runOpts } = opts;
1743
+ const sessionName = `${sessionPrefix}-${Date.now().toString(36).slice(-6)}`;
1744
+
1745
+ try {
1746
+ return await runHeadless(sessionName, assignments, runOpts);
1747
+ } finally {
1748
+ for (let index = 0; index < assignments.length; index++) {
1749
+ unregisterHeadlessSynapseWorker(
1750
+ getHeadlessWorkerAgentId(sessionName, index),
1751
+ );
1752
+ }
1753
+ await deregisterHeadlessWorkers(sessionName, assignments.length);
1754
+ try {
1755
+ killPsmuxSession(sessionName);
1756
+ } catch {
1757
+ /* 무시 */
1758
+ }
1759
+ // WT split pane은 psmux 종료 시 셸이 끝나면서 자동으로 닫힘
1760
+ // 수동 close-pane 불필요 (레이스 컨디션으로 WT 에러 발생)
1761
+ }
1762
+ }
1763
+
1764
+ // ─── v6.0.0: Theme + Visual ───
1765
+
1766
+ /**
1767
+ * psmux 세션에 triflux 테마를 적용한다.
1768
+ * status bar + pane border 색상 + 브랜딩.
1769
+ *
1770
+ * @param {string} sessionName
1771
+ */
1772
+ export function applyTrifluxTheme(sessionName) {
1773
+ const opts = [
1774
+ // Status bar — Catppuccin Mocha 기반
1775
+ ["status-style", "bg=#1e1e2e,fg=#cdd6f4"],
1776
+ ["status-left", " #[fg=#89b4fa,bold]▲ triflux#[default] "],
1777
+ ["status-left-length", "20"],
1778
+ [
1779
+ "status-right",
1780
+ " #[fg=#a6adc8]#{pane_title}#[default] │ #[fg=#f9e2af]%H:%M#[default] ",
1781
+ ],
1782
+ ["status-right-length", "40"],
1783
+ // Pane border — active/inactive 구분
1784
+ ["pane-active-border-style", "fg=#89b4fa"],
1785
+ ["pane-border-style", "fg=#45475a"],
1786
+ // Status bar 위치
1787
+ ["status-position", "bottom"],
1788
+ // 셸이 pane 타이틀을 변경하는 것 방지 (캡처 로그 경로 안정성)
1789
+ ["allow-rename", "off"],
1790
+ ];
1791
+ for (const [key, value] of opts) {
1792
+ try {
1793
+ psmuxExec(["set-option", "-t", sessionName, key, value]);
1794
+ } catch {
1795
+ /* 무시 */
1796
+ }
1797
+ }
1798
+ }
1799
+
1800
+ /**
1801
+ * Windows Terminal에 triflux 프로필을 자동 생성/갱신한다.
1802
+ * 반투명 + 비포커스 시 더 투명 + Catppuccin 테마.
1803
+ * @returns {boolean} 성공 여부
1804
+ */
1805
+
1806
+ /**
1807
+ * WT 기본 프로필의 폰트 크기를 읽는다.
1808
+ * @returns {number} 기본 폰트 크기 (못 읽으면 12)
1809
+ */
1810
+ function _getWtDefaultFontSize() {
1811
+ const settingsPaths = [
1812
+ join(
1813
+ process.env.LOCALAPPDATA || "",
1814
+ "Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json",
1815
+ ),
1816
+ join(
1817
+ process.env.LOCALAPPDATA || "",
1818
+ "Microsoft/Windows Terminal/settings.json",
1819
+ ),
1820
+ ];
1821
+ for (const p of settingsPaths) {
1822
+ if (!existsSync(p)) continue;
1823
+ try {
1824
+ const settings = JSON.parse(
1825
+ readFileSync(p, "utf8").replace(/^\s*\/\/.*$/gm, ""),
1826
+ );
1827
+ // 기본 프로필 or 첫 프로필의 폰트
1828
+ const defaultGuid = settings.defaultProfile;
1829
+ const profiles = settings.profiles?.list || [];
1830
+ const defaultProfile =
1831
+ profiles.find((pr) => pr.guid === defaultGuid) || profiles[0];
1832
+ return (
1833
+ defaultProfile?.font?.size ||
1834
+ settings.profiles?.defaults?.font?.size ||
1835
+ 12
1836
+ );
1837
+ } catch {
1838
+ /* 다음 */
1839
+ }
1840
+ }
1841
+ return 12;
1842
+ }
1843
+
1844
+ /**
1845
+ * 파일을 원자적으로 쓴다 — 임시 파일에 먼저 기록 후 rename으로 교체.
1846
+ * 프로세스가 쓰기 도중 충돌해도 원본 파일이 손상되지 않는다.
1847
+ * @param {string} filePath — 대상 파일 경로
1848
+ * @param {string} data — 쓸 내용
1849
+ */
1850
+ function _atomicWriteSync(filePath, data) {
1851
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
1852
+ try {
1853
+ writeFileSync(tmpPath, data, "utf8");
1854
+ renameSync(tmpPath, filePath);
1855
+ } catch (err) {
1856
+ try {
1857
+ writeFileSync(tmpPath.replace(/\.tmp$/, ".tmp.del"), "");
1858
+ } catch {
1859
+ /* 무시 */
1860
+ }
1861
+ throw err;
1862
+ }
1863
+ }
1864
+
1865
+ function sanitizeSessionName(value) {
1866
+ return String(value || "").replace(/[^a-zA-Z0-9_-]/g, "") || "tfx-session";
1867
+ }
1868
+
1869
+ function buildAttachTitle(sessionName, suffix = "") {
1870
+ const base = `▲ ${sanitizeSessionName(sessionName)}`;
1871
+ return suffix ? `${base} ${suffix}` : base;
1872
+ }
1873
+
1874
+ // ─── v6.0.0: Lead-Direct Interactive Mode ───
1875
+
1876
+ /**
1877
+ * Windows Terminal에서 psmux 세션을 자동 attach한다.
1878
+ * 1명은 새 탭 단일 attach, 2/3/4명은 새 탭 내 split-pane, 5명 이상은 dashboard로 전환한다.
1879
+ *
1880
+ * @param {string} sessionName — attach할 psmux 세션 이름
1881
+ * @param {object} [opts]
1882
+ * @param {number} [workerCount=2]
1883
+ * @returns {Promise<boolean>} 성공 여부
1884
+ */
1885
+ export async function autoAttachTerminal(
1886
+ sessionName,
1887
+ opts = {},
1888
+ workerCount = 2,
1889
+ ) {
1890
+ if (process.platform !== "win32") return false;
1891
+ if (!process.env.WT_SESSION) return false;
1892
+
1893
+ const wt = createWtManager();
1894
+ wt.ensureWtProfile(workerCount);
1895
+
1896
+ try {
1897
+ const safeSession = sanitizeSessionName(sessionName);
1898
+ if (workerCount >= 5) {
1899
+ const resolvedLayout = resolveDashboardLayout(
1900
+ opts.dashboardLayout || "single",
1901
+ workerCount,
1902
+ );
1903
+ const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(
1904
+ /\\/g,
1905
+ "/",
1906
+ );
1907
+ await wt.createTab({
1908
+ title: buildAttachTitle(safeSession, "dashboard"),
1909
+ profile: "triflux",
1910
+ command: `node "${viewerPath}" --session ${safeSession} --result-dir "${RESULT_DIR}" --layout ${resolvedLayout}`,
1911
+ });
1912
+ } else {
1913
+ const panes = [];
1914
+ panes.push({
1915
+ title: buildAttachTitle(safeSession),
1916
+ profile: "triflux",
1917
+ command: `psmux attach-session -t ${safeSession}`,
1918
+ });
1919
+
1920
+ if (workerCount >= 2) {
1921
+ panes.push({
1922
+ direction: workerCount >= 3 ? "V" : "H",
1923
+ title: buildAttachTitle(safeSession, "2"),
1924
+ profile: "triflux",
1925
+ command: `psmux attach-session -t ${safeSession}`,
1926
+ });
1927
+ }
1928
+ if (workerCount >= 3) {
1929
+ panes.push({
1930
+ direction: "H",
1931
+ title: buildAttachTitle(safeSession, "3"),
1932
+ profile: "triflux",
1933
+ command: `psmux attach-session -t ${safeSession}`,
1934
+ });
1935
+ }
1936
+ if (workerCount >= 4) {
1937
+ panes.push({
1938
+ direction: "H",
1939
+ title: buildAttachTitle(safeSession, "4"),
1940
+ profile: "triflux",
1941
+ command: `psmux attach-session -t ${safeSession}`,
1942
+ });
1943
+ }
1944
+ await wt.applySplitLayout(panes);
1945
+ }
1946
+ return true;
1947
+ } catch {
1948
+ return false;
1949
+ }
1950
+ }
1951
+
1952
+ /**
1953
+ * v7.0: psmux 세션을 WT 탭에 attach (대시보드 + 워커 전체 뷰)
1954
+ * @param {string} sessionName
1955
+ * @param {number} workerCount
1956
+ * @param {string} [dashboardLayout='single']
1957
+ * @param {number} [dashboardSize=0.4]
1958
+ * @param {string} [dashboardAnchor='window'] — window | tab
1959
+ * @returns {Promise<boolean>}
1960
+ */
1961
+ export async function attachDashboardTab(
1962
+ sessionName,
1963
+ workerCount = 2,
1964
+ dashboardLayout = "single",
1965
+ dashboardSize = 0.4,
1966
+ dashboardAnchor = "window",
1967
+ ) {
1968
+ const wt = createWtManager();
1969
+ const envInfo = wt.getEnvironmentInfo?.() || {};
1970
+ if (!envInfo.hasWindowsTerminal) return false;
1971
+ wt.ensureWtProfile(workerCount);
1972
+
1973
+ try {
1974
+ const safeSession = sanitizeSessionName(sessionName);
1975
+ const resolvedLayout = resolveDashboardLayout(dashboardLayout, workerCount);
1976
+ const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(
1977
+ /\\/g,
1978
+ "/",
1979
+ );
1980
+
1981
+ await wt.createTab({
1982
+ title: buildAttachTitle(safeSession, "dashboard"),
1983
+ profile: "triflux",
1984
+ command: `node "${viewerPath}" --session ${safeSession} --result-dir "${RESULT_DIR}" --layout ${resolvedLayout}`,
1985
+ });
1986
+ return true;
1987
+ } catch {
1988
+ return false;
1989
+ }
1990
+ }
1991
+
1992
+ /**
1993
+ * 모든 워커 pane의 현재 스냅샷을 수집한다.
1994
+ *
1995
+ * @param {string} sessionName
1996
+ * @param {Array<{paneId: string, paneName: string, cli: string}>} dispatches
1997
+ * @param {number} [lines=15] — 각 pane에서 캡처할 줄 수
1998
+ * @returns {Array<{paneName: string, cli: string, snapshot: string}>}
1999
+ */
2000
+ export function getProgressSnapshots(sessionName, dispatches, lines = 15) {
2001
+ if (!psmuxSessionExists(sessionName)) return [];
2002
+ return dispatches.map((d) => {
2003
+ try {
2004
+ const snapshot = capturePsmuxPane(d.paneId, lines);
2005
+ return { paneName: d.paneName, cli: d.cli, snapshot };
2006
+ } catch {
2007
+ return { paneName: d.paneName, cli: d.cli, snapshot: "(캡처 실패)" };
2008
+ }
2009
+ });
2010
+ }
2011
+
2012
+ /**
2013
+ * Lead-Direct Interactive 헤드리스 실행.
2014
+ * 세션을 유지하면서 결과 수집 후에도 추가 명령을 dispatch할 수 있다.
2015
+ * 반환된 handle의 kill()을 반드시 호출하여 세션을 정리해야 한다.
2016
+ *
2017
+ * @param {string} sessionName — psmux 세션 이름
2018
+ * @param {Array<{cli: string, prompt: string, role?: string}>} assignments
2019
+ * @param {object} [opts]
2020
+ * @param {number} [opts.timeoutSec=300]
2021
+ * @param {string} [opts.layout='2x2']
2022
+ * @param {(event: object) => void} [opts.onProgress]
2023
+ * @param {number} [opts.progressIntervalSec=0]
2024
+ * @param {boolean} [opts.autoAttach=false] — Windows Terminal 자동 attach
2025
+ * @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
2026
+ * @param {AbortSignal} [opts.signal] — abort 시 자동 세션 정리
2027
+ * @param {number} [opts.maxIdleSec=0] — 유휴 시 자동 정리 (0=비활성)
2028
+ * @returns {Promise<{
2029
+ * sessionName: string,
2030
+ * results: Array,
2031
+ * dispatches: Array,
2032
+ * dispatch: (paneName: string, command: string) => {paneId: string, paneName: string, token: string},
2033
+ * capture: (paneName: string, lines?: number) => string,
2034
+ * snapshots: (lines?: number) => Array,
2035
+ * waitFor: (paneName: string, token: string, timeoutSec?: number, opts?: object) => Promise,
2036
+ * alive: () => boolean,
2037
+ * kill: () => void,
2038
+ * }>}
2039
+ */
2040
+ export async function runHeadlessInteractive(
2041
+ sessionName,
2042
+ assignments,
2043
+ opts = {},
2044
+ ) {
2045
+ const {
2046
+ autoAttach = false,
2047
+ dashboard = false,
2048
+ dashboardSize = 0.4,
2049
+ dashboardAnchor = "window",
2050
+ signal,
2051
+ maxIdleSec = 0,
2052
+ ...runOpts
2053
+ } = opts;
2054
+ const headlessOpts = dashboard
2055
+ ? { ...runOpts, dashboard: true }
2056
+ : { ...runOpts };
2057
+
2058
+ // autoAttach를 session_created 시점에 트리거 (CLI 실행 전에 터미널 열림)
2059
+ const userOnProgress = headlessOpts.onProgress;
2060
+ let terminalAttached = false;
2061
+ const onProgress = (event) => {
2062
+ if (autoAttach && event.type === "session_created" && !terminalAttached) {
2063
+ terminalAttached = true;
2064
+ if (dashboard) {
2065
+ // v7.0: psmux attach로 대시보드+워커 전체 세션을 WT 탭에 표시
2066
+ attachDashboardTab(
2067
+ sessionName,
2068
+ assignments.length,
2069
+ event.dashboardLayout ||
2070
+ resolveDashboardLayout(
2071
+ headlessOpts.dashboardLayout,
2072
+ assignments.length,
2073
+ ),
2074
+ dashboardSize,
2075
+ dashboardAnchor,
2076
+ );
2077
+ } else {
2078
+ autoAttachTerminal(
2079
+ sessionName,
2080
+ { dashboardLayout: headlessOpts.dashboardLayout, dashboardAnchor },
2081
+ assignments.length,
2082
+ );
2083
+ }
2084
+ }
2085
+ if (userOnProgress) userOnProgress(event);
2086
+ };
2087
+ const interactiveRunOpts = { ...headlessOpts, onProgress };
2088
+
2089
+ // Phase 1: 세션 생성 → 즉시 터미널 팝업 → dispatch → 대기 → 결과 수집
2090
+ const { results } = await runHeadless(
2091
+ sessionName,
2092
+ assignments,
2093
+ interactiveRunOpts,
2094
+ );
2095
+
2096
+ // Phase 2: 세션을 유지하고 interactive handle 반환
2097
+ // Fix P2: paneId를 dispatches에 포함 (snapshots에서 필요)
2098
+ const dispatches = results.map((r, i) => ({
2099
+ paneName: r.paneName,
2100
+ displayName: r.displayName,
2101
+ paneId: r.paneId || "",
2102
+ cli: r.cli,
2103
+ role: r.role,
2104
+ }));
2105
+
2106
+ // Fix P2: maxIdleSec 리셋을 위한 타이머 관리
2107
+ let idleTimer = null;
2108
+ function resetIdleTimer() {
2109
+ if (idleTimer) clearTimeout(idleTimer);
2110
+ if (maxIdleSec > 0) {
2111
+ idleTimer = setTimeout(() => handle.kill(), maxIdleSec * 1000);
2112
+ if (idleTimer.unref) idleTimer.unref();
2113
+ }
2114
+ }
2115
+
2116
+ const handle = {
2117
+ sessionName,
2118
+ results,
2119
+ dispatches,
2120
+ _killed: false,
2121
+
2122
+ /** 특정 pane에 후속 명령 dispatch (캡처 자동 재시작) */
2123
+ dispatch(paneName, command) {
2124
+ if (this._killed) throw new Error("세션이 이미 종료되었습니다.");
2125
+ try {
2126
+ startCapture(sessionName, paneName);
2127
+ } catch {
2128
+ /* 이미 활성 — 무시 */
2129
+ }
2130
+ resetIdleTimer();
2131
+ return dispatchCommand(sessionName, paneName, command);
2132
+ },
2133
+
2134
+ /** 특정 pane의 현재 출력 캡처 */
2135
+ capture(paneName, lines = 30) {
2136
+ if (this._killed) return "";
2137
+ try {
2138
+ // Fix P2: paneName으로 resolvePane을 경유하여 정확한 paneId 획득
2139
+ return capturePsmuxPane(paneName, lines);
2140
+ } catch {
2141
+ return "(캡처 실패)";
2142
+ }
2143
+ },
2144
+
2145
+ /** 모든 pane 스냅샷 */
2146
+ snapshots(lines = 15) {
2147
+ if (this._killed) return [];
2148
+ return getProgressSnapshots(sessionName, dispatches, lines);
2149
+ },
2150
+
2151
+ /** 특정 pane에서 완료 대기 */
2152
+ async waitFor(paneName, token, timeoutSec = 300, waitOpts = {}) {
2153
+ if (this._killed) return { matched: false, sessionDead: true };
2154
+ resetIdleTimer();
2155
+ return waitForCompletion(
2156
+ sessionName,
2157
+ paneName,
2158
+ token,
2159
+ timeoutSec,
2160
+ waitOpts,
2161
+ );
2162
+ },
2163
+
2164
+ /** 세션 생존 확인 */
2165
+ alive() {
2166
+ if (this._killed) return false;
2167
+ return psmuxSessionExists(sessionName);
2168
+ },
2169
+
2170
+ /** 세션 종료 — WT pane은 psmux 종료 시 자동으로 닫힘 */
2171
+ kill() {
2172
+ if (this._killed) return;
2173
+ this._killed = true;
2174
+ for (let index = 0; index < assignments.length; index++) {
2175
+ unregisterHeadlessSynapseWorker(
2176
+ getHeadlessWorkerAgentId(sessionName, index),
2177
+ );
2178
+ }
2179
+ void deregisterHeadlessWorkers(sessionName, assignments.length);
2180
+ try {
2181
+ killPsmuxSession(sessionName);
2182
+ } catch {
2183
+ /* 무시 */
2184
+ }
2185
+ // WT split pane은 psmux 종료 → 셸 종료 → 자동 닫힘
2186
+ // 수동 close-pane 불필요 (레이스 컨디션으로 WT 0x80070002 에러 발생)
2187
+ },
2188
+ };
2189
+
2190
+ // AbortController signal로 자동 정리
2191
+ if (signal) {
2192
+ if (signal.aborted) {
2193
+ handle.kill();
2194
+ } else {
2195
+ signal.addEventListener("abort", () => handle.kill(), { once: true });
2196
+ }
2197
+ }
2198
+
2199
+ // 유휴 타임아웃 자동 정리 (resetIdleTimer 단일 경로 사용)
2200
+ resetIdleTimer();
2201
+
2202
+ // 프로세스 종료 시 safety net
2203
+ const exitHandler = () => handle.kill();
2204
+ process.on("exit", exitHandler);
2205
+ // kill() 후 리스너 제거를 위해 참조 보관
2206
+ const originalKill = handle.kill.bind(handle);
2207
+ handle.kill = function () {
2208
+ originalKill();
2209
+ process.removeListener("exit", exitHandler);
2210
+ };
2211
+
2212
+ return handle;
2213
+ }