triflux 10.2.0 → 10.3.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.
@@ -3,35 +3,48 @@
3
3
  // v5.2.0: 기본 headless 엔진 (runHeadless, runHeadlessWithCleanup)
4
4
  // v6.0.0: Lead-direct 모드 (runHeadlessInteractive, autoAttachTerminal)
5
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";
6
+
9
7
  import { execSync, spawn } from "node:child_process";
10
- import { createRequire } from "node:module";
11
8
  import { randomUUID } from "node:crypto";
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ renameSync,
14
+ statSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { createRequire } from "node:module";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { requestJson } from "../bridge.mjs";
12
21
  import { escapePwshSingleQuoted } from "../cli-adapter-base.mjs";
22
+ import { getBackend } from "./backend.mjs";
23
+ import { resolveDashboardLayout } from "./dashboard-layout.mjs";
24
+ import { HANDOFF_INSTRUCTION_SHORT, processHandoff } from "./handoff.mjs";
13
25
  import {
26
+ capturePsmuxPane,
14
27
  createPsmuxSession,
28
+ dispatchCommand,
15
29
  killPsmuxSession,
30
+ psmuxExec,
16
31
  psmuxSessionExists,
17
- dispatchCommand,
18
- waitForCompletion,
19
- capturePsmuxPane,
20
32
  startCapture,
21
- psmuxExec,
33
+ waitForCompletion,
22
34
  } 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
35
  import { createLogDashboard } from "./tui.mjs";
27
36
 
28
37
  const RESULT_DIR = join(tmpdir(), "tfx-headless");
29
38
 
30
39
  /** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
31
40
  const CLI_BRAND = {
32
- codex: { emoji: "\u{26AA}", label: "Codex", ansi: "\x1b[97m" }, // ⚪ bright white (codexWhite)
41
+ codex: { emoji: "\u{26AA}", label: "Codex", ansi: "\x1b[97m" }, // ⚪ bright white (codexWhite)
33
42
  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
43
+ claude: {
44
+ emoji: "\u{1F7E0}",
45
+ label: "Claude",
46
+ ansi: "\x1b[38;2;232;112;64m",
47
+ }, // 🟠 claudeOrange
35
48
  };
36
49
  const _ANSI_RESET = "\x1b[0m";
37
50
  const _ANSI_DIM = "\x1b[2m";
@@ -50,12 +63,68 @@ export function resolveCliType(agentOrCli) {
50
63
  return AGENT_TO_CLI[agentOrCli] || agentOrCli;
51
64
  }
52
65
 
66
+ export function getHeadlessWorkerAgentId(sessionName, index) {
67
+ return `headless-${sessionName}-${index}`;
68
+ }
69
+
70
+ export function getHeadlessLeadAgentId(sessionName) {
71
+ return `headless-${sessionName}-lead`;
72
+ }
73
+
74
+ export async function registerHeadlessWorker(
75
+ sessionName,
76
+ index,
77
+ cli,
78
+ requestJsonFn = requestJson,
79
+ ) {
80
+ await requestJsonFn("/bridge/register", {
81
+ body: {
82
+ agentId: getHeadlessWorkerAgentId(sessionName, index),
83
+ topics: ["headless.worker"],
84
+ capabilities: [cli],
85
+ },
86
+ }).catch(() => {});
87
+ }
88
+
89
+ export async function publishHeadlessResult(
90
+ sessionName,
91
+ workerId,
92
+ status,
93
+ handoff,
94
+ requestJsonFn = requestJson,
95
+ ) {
96
+ await requestJsonFn("/bridge/publish", {
97
+ body: {
98
+ from: getHeadlessLeadAgentId(sessionName),
99
+ to: "topic:headless.results",
100
+ type: "event",
101
+ payload: { workerId, status, handoff },
102
+ },
103
+ }).catch(() => {});
104
+ }
105
+
106
+ export async function deregisterHeadlessWorkers(
107
+ sessionName,
108
+ workerCount,
109
+ requestJsonFn = requestJson,
110
+ ) {
111
+ await Promise.all(
112
+ Array.from({ length: workerCount }, (_, index) =>
113
+ requestJsonFn("/bridge/deregister", {
114
+ body: { agentId: getHeadlessWorkerAgentId(sessionName, index) },
115
+ }).catch(() => {}),
116
+ ),
117
+ );
118
+ }
119
+
53
120
  /** MCP 프로필별 프롬프트 힌트 (tfx-route.sh resolve_mcp_policy의 경량 미러) */
54
121
  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.",
122
+ implement:
123
+ "You have full filesystem read/write access. Implement changes directly.",
124
+ analyze:
125
+ "Focus on reading and analyzing the codebase. Prefer analysis over modification.",
126
+ review: "Review the code for quality, security, and correctness.",
127
+ docs: "Focus on documentation and explanation tasks.",
59
128
  };
60
129
 
61
130
  /**
@@ -85,22 +154,34 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
85
154
  }
86
155
  }
87
156
 
88
- const mcpHint = mcp && MCP_PROFILE_HINTS[mcp] ? ` [MCP: ${mcp}] ${MCP_PROFILE_HINTS[mcp]}` : "";
157
+ const mcpHint =
158
+ mcp && MCP_PROFILE_HINTS[mcp]
159
+ ? ` [MCP: ${mcp}] ${MCP_PROFILE_HINTS[mcp]}`
160
+ : "";
89
161
  // P2: HANDOFF 지시를 프롬프트에 삽입 (워커가 구조화된 handoff 블록을 출력하도록)
90
162
  const handoffHint = handoff ? `\n\n${HANDOFF_INSTRUCTION_SHORT}` : "";
91
163
  const fullPrompt = `${contextPrefix}${prompt}${mcpHint}${handoffHint}`;
92
164
 
93
165
  // 보안: 프롬프트를 임시 파일에 쓰고 파일 참조로 전달 (셸 주입 방지)
94
166
  if (!existsSync(RESULT_DIR)) mkdirSync(RESULT_DIR, { recursive: true });
95
- const promptFile = join(RESULT_DIR, "prompt-" + randomUUID().slice(0, 8) + ".txt").replace(/\\/g, "/");
167
+ const promptFile = join(
168
+ RESULT_DIR,
169
+ "prompt-" + randomUUID().slice(0, 8) + ".txt",
170
+ ).replace(/\\/g, "/");
96
171
  writeFileSync(promptFile, fullPrompt, "utf8");
97
172
 
98
173
  const backend = getBackend(resolvedCli);
99
174
  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, "") : "";
175
+ const backendCommand = backend.buildArgs(promptExpr, resultFile, {
176
+ ...opts,
177
+ model,
178
+ });
179
+ const safeCwd =
180
+ typeof cwd === "string" ? cwd.trim().replace(/[\r\n\x00-\x1f]/g, "") : "";
102
181
  if (safeCwd && (safeCwd.startsWith("\\\\") || safeCwd.startsWith("//"))) {
103
- throw new Error("[headless] UNC 경로는 cwd로 사용할 수 없습니다: " + safeCwd);
182
+ throw new Error(
183
+ "[headless] UNC 경로는 cwd로 사용할 수 없습니다: " + safeCwd,
184
+ );
104
185
  }
105
186
  if (!safeCwd) return backendCommand;
106
187
 
@@ -139,7 +220,10 @@ export const STALL_DEFAULTS = Object.freeze({
139
220
 
140
221
  /** CLI pane stall 감지 에러 (STALL_EXHAUSTED | COMPLETION_TIMEOUT) */
141
222
  export class StallError extends Error {
142
- constructor(message, { code = "STALL_DETECTED", category = "transient", recovery = "" } = {}) {
223
+ constructor(
224
+ message,
225
+ { code = "STALL_DETECTED", category = "transient", recovery = "" } = {},
226
+ ) {
143
227
  super(message);
144
228
  this.name = "StallError";
145
229
  this.code = code;
@@ -163,13 +247,21 @@ export function createStallMonitor(paneId, resultFile, config, deps = {}) {
163
247
  let lastMtime = 0;
164
248
  let lastChangeAt = Date.now();
165
249
 
166
- try { lastMtime = stat(resultFile).mtimeMs; } catch { /* not created yet */ }
250
+ try {
251
+ lastMtime = stat(resultFile).mtimeMs;
252
+ } catch {
253
+ /* not created yet */
254
+ }
167
255
 
168
256
  return Object.freeze({
169
257
  poll() {
170
258
  const snapshot = capture(paneId, 50);
171
259
  let currentMtime = 0;
172
- try { currentMtime = stat(resultFile).mtimeMs; } catch { /* ignore */ }
260
+ try {
261
+ currentMtime = stat(resultFile).mtimeMs;
262
+ } catch {
263
+ /* ignore */
264
+ }
173
265
 
174
266
  const outputChanged = snapshot !== lastSnapshot;
175
267
  const mtimeChanged = currentMtime > 0 && currentMtime !== lastMtime;
@@ -208,7 +300,12 @@ export function createStallMonitor(paneId, resultFile, config, deps = {}) {
208
300
  * @param {(snapshot: string) => void} [opts.onPoll] — 폴링 콜백
209
301
  * @returns {Promise<{ matched: boolean, exitCode: number|null, restarts: number, stallDetected: boolean }>}
210
302
  */
211
- export async function waitForCompletionWithStallDetect(sessionName, paneId, resultFile, opts = {}) {
303
+ export async function waitForCompletionWithStallDetect(
304
+ sessionName,
305
+ paneId,
306
+ resultFile,
307
+ opts = {},
308
+ ) {
212
309
  const {
213
310
  pollInterval = 5000,
214
311
  stallTimeout = 120000,
@@ -254,7 +351,9 @@ export async function waitForCompletionWithStallDetect(sessionName, paneId, resu
254
351
  // 초기 resultFile mtime
255
352
  try {
256
353
  if (_exists(resultFile)) lastMtime = _stat(resultFile).mtimeMs;
257
- } catch { /* 무시 */ }
354
+ } catch {
355
+ /* 무시 */
356
+ }
258
357
 
259
358
  while (true) {
260
359
  await new Promise((r) => setTimeout(r, pollInterval));
@@ -262,19 +361,34 @@ export async function waitForCompletionWithStallDetect(sessionName, paneId, resu
262
361
 
263
362
  // 전체 타임아웃
264
363
  if (now - startedAt > completionTimeout) {
265
- return { matched: false, exitCode: null, restarts, stallDetected, timedOut: true };
364
+ return {
365
+ matched: false,
366
+ exitCode: null,
367
+ restarts,
368
+ stallDetected,
369
+ timedOut: true,
370
+ };
266
371
  }
267
372
 
268
373
  // 1) capture-pane 출력 확인
269
374
  const currentOutput = _capture(currentPaneId, 50);
270
- if (onPoll) { try { onPoll(currentOutput); } catch { /* 삼킴 */ } }
375
+ if (onPoll) {
376
+ try {
377
+ onPoll(currentOutput);
378
+ } catch {
379
+ /* 삼킴 */
380
+ }
381
+ }
271
382
 
272
383
  // 2) completion 토큰 감지
273
384
  const completionMatch = completionRe.exec(currentOutput);
274
385
  if (completionMatch) {
275
386
  return {
276
387
  matched: true,
277
- exitCode: Number.parseInt((completionMatch.slice(1).find(Boolean) || '0'), 10),
388
+ exitCode: Number.parseInt(
389
+ completionMatch.slice(1).find(Boolean) || "0",
390
+ 10,
391
+ ),
278
392
  restarts,
279
393
  stallDetected,
280
394
  timedOut: false,
@@ -285,7 +399,9 @@ export async function waitForCompletionWithStallDetect(sessionName, paneId, resu
285
399
  let currentMtime = 0;
286
400
  try {
287
401
  if (_exists(resultFile)) currentMtime = _stat(resultFile).mtimeMs;
288
- } catch { /* 무시 */ }
402
+ } catch {
403
+ /* 무시 */
404
+ }
289
405
 
290
406
  // 4) 변화 감지 → stallTimer 리셋
291
407
  const outputChanged = currentOutput !== lastOutput;
@@ -302,9 +418,17 @@ export async function waitForCompletionWithStallDetect(sessionName, paneId, resu
302
418
  try {
303
419
  const content = _readFile(resultFile, "utf8").trim();
304
420
  if (content.length > 0) {
305
- return { matched: true, exitCode: 0, restarts, stallDetected, timedOut: false };
421
+ return {
422
+ matched: true,
423
+ exitCode: 0,
424
+ restarts,
425
+ stallDetected,
426
+ timedOut: false,
427
+ };
306
428
  }
307
- } catch { /* 무시 */ }
429
+ } catch {
430
+ /* 무시 */
431
+ }
308
432
  }
309
433
 
310
434
  // 5) stall 판정
@@ -321,12 +445,20 @@ export async function waitForCompletionWithStallDetect(sessionName, paneId, resu
321
445
  }
322
446
 
323
447
  // kill pane → re-dispatch
324
- try { _exec(["kill-pane", "-t", currentPaneId]); } catch { /* 이미 종료 */ }
448
+ try {
449
+ _exec(["kill-pane", "-t", currentPaneId]);
450
+ } catch {
451
+ /* 이미 종료 */
452
+ }
325
453
 
326
454
  if (command) {
327
455
  // 새 pane split + 동일 command re-dispatch
328
456
  const newPaneId = _exec([
329
- "split-window", "-t", sessionName, "-P", "-F",
457
+ "split-window",
458
+ "-t",
459
+ sessionName,
460
+ "-P",
461
+ "-F",
330
462
  "#{session_name}:#{window_index}.#{pane_index}",
331
463
  ]);
332
464
  _startCapture(sessionName, newPaneId);
@@ -343,12 +475,11 @@ export async function waitForCompletionWithStallDetect(sessionName, paneId, resu
343
475
 
344
476
  /** progressive 스플릿 모드: lead pane만 생성 후, 워커를 하나씩 추가하며 dispatch */
345
477
  async function dispatchProgressive(sessionName, assignments, opts = {}) {
346
- const {
347
- layout,
348
- safeProgress,
349
- dashboardLayout = "single",
350
- } = opts;
351
- const resolvedDashboardLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
478
+ const { layout, safeProgress, dashboardLayout = "single" } = opts;
479
+ const resolvedDashboardLayout = resolveDashboardLayout(
480
+ dashboardLayout,
481
+ assignments.length,
482
+ );
352
483
  const session = createPsmuxSession(sessionName, { layout, paneCount: 1 });
353
484
  applyTrifluxTheme(sessionName);
354
485
  if (safeProgress) {
@@ -367,8 +498,13 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
367
498
  for (let i = 0; i < assignments.length; i++) {
368
499
  const assignment = assignments[i];
369
500
  const paneName = `worker-${i + 1}`;
501
+ const workerId = getHeadlessWorkerAgentId(sessionName, i);
370
502
  const resolvedCli = resolveCliType(assignment.cli);
371
- const brand = CLI_BRAND[resolvedCli] || { emoji: "\u{25CF}", label: resolvedCli, ansi: "" };
503
+ const brand = CLI_BRAND[resolvedCli] || {
504
+ emoji: "\u{25CF}",
505
+ label: resolvedCli,
506
+ ansi: "",
507
+ };
372
508
  const paneTitle = assignment.role
373
509
  ? `${brand.emoji} ${resolvedCli} (${assignment.role})`
374
510
  : `${brand.emoji} ${resolvedCli}-${i + 1}`;
@@ -377,30 +513,67 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
377
513
  // 모든 워커를 split-window로 생성 (lead pane index 0은 비워둠)
378
514
  // tui-viewer가 index 0을 건너뛰므로, 워커는 항상 index >= 1에 배치
379
515
  newPaneId = psmuxExec([
380
- "split-window", "-t", sessionName, "-P", "-F",
516
+ "split-window",
517
+ "-t",
518
+ sessionName,
519
+ "-P",
520
+ "-F",
381
521
  "#{session_name}:#{window_index}.#{pane_index}",
382
522
  ]);
383
523
 
384
524
  // 타이틀 설정 (이모지 포함)
385
- try { psmuxExec(["select-pane", "-t", newPaneId, "-T", paneTitle]); } catch { /* 무시 */ }
525
+ try {
526
+ psmuxExec(["select-pane", "-t", newPaneId, "-T", paneTitle]);
527
+ } catch {
528
+ /* 무시 */
529
+ }
530
+ await registerHeadlessWorker(sessionName, i, assignment.cli);
386
531
 
387
- if (safeProgress) safeProgress({ type: "worker_added", paneName, cli: assignment.cli, paneTitle });
532
+ if (safeProgress)
533
+ safeProgress({
534
+ type: "worker_added",
535
+ paneName,
536
+ cli: assignment.cli,
537
+ paneTitle,
538
+ });
388
539
 
389
540
  // 캡처 시작 + 컬러 배너 + 명령 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 });
541
+ const resultFile = join(
542
+ RESULT_DIR,
543
+ `${sessionName}-${paneName}.txt`,
544
+ ).replace(/\\/g, "/");
545
+ const cmd = buildHeadlessCommand(
546
+ assignment.cli,
547
+ assignment.prompt,
548
+ resultFile,
549
+ { mcp: assignment.mcp, model: assignment.model },
550
+ );
392
551
  startCapture(sessionName, newPaneId);
393
552
  // pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
394
- if (i > 0) await new Promise(r => setTimeout(r, 300));
553
+ if (i > 0) await new Promise((r) => setTimeout(r, 300));
395
554
  const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
396
555
 
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 });
556
+ if (safeProgress)
557
+ safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
558
+
559
+ dispatches.push({
560
+ ...dispatch,
561
+ paneId: newPaneId,
562
+ paneName,
563
+ resultFile,
564
+ cli: assignment.cli,
565
+ role: assignment.role,
566
+ command: cmd,
567
+ workerId,
568
+ });
400
569
  }
401
570
 
402
571
  // 모든 split 완료 후 레이아웃 한 번만 정렬 (깜빡임 방지)
403
- try { psmuxExec(["select-layout", "-t", sessionName, "tiled"]); } catch { /* 무시 */ }
572
+ try {
573
+ psmuxExec(["select-layout", "-t", sessionName, "tiled"]);
574
+ } catch {
575
+ /* 무시 */
576
+ }
404
577
 
405
578
  // v7.1.3: psmux 내부 대시보드 pane 제거 — WT 스플릿에서 tui-viewer 직접 실행
406
579
 
@@ -408,17 +581,19 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
408
581
  }
409
582
 
410
583
  /** 기존 batch 모드: 모든 pane을 한 번에 생성하여 dispatch */
411
- function dispatchBatch(sessionName, assignments, opts = {}) {
412
- const {
413
- layout,
414
- safeProgress,
415
- dashboardLayout = "single",
416
- } = opts;
584
+ async function dispatchBatch(sessionName, assignments, opts = {}) {
585
+ const { layout, safeProgress, dashboardLayout = "single" } = opts;
417
586
  const paneCount = assignments.length + 1;
418
- const resolvedDashboardLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
587
+ const resolvedDashboardLayout = resolveDashboardLayout(
588
+ dashboardLayout,
589
+ assignments.length,
590
+ );
419
591
  // A2b fix: 2x2 레이아웃은 최대 4 pane — 초과 시 tiled로 자동 전환
420
- const effectiveLayout = (layout === "2x2" && paneCount > 4) ? "tiled" : layout;
421
- const session = createPsmuxSession(sessionName, { layout: effectiveLayout, paneCount });
592
+ const effectiveLayout = layout === "2x2" && paneCount > 4 ? "tiled" : layout;
593
+ const session = createPsmuxSession(sessionName, {
594
+ layout: effectiveLayout,
595
+ paneCount,
596
+ });
422
597
  applyTrifluxTheme(sessionName);
423
598
  if (safeProgress) {
424
599
  safeProgress({
@@ -429,21 +604,45 @@ function dispatchBatch(sessionName, assignments, opts = {}) {
429
604
  });
430
605
  }
431
606
 
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 });
607
+ return await Promise.all(
608
+ assignments.map(async (assignment, i) => {
609
+ const paneName = `worker-${i + 1}`;
610
+ const workerId = getHeadlessWorkerAgentId(sessionName, i);
611
+ const resultFile = join(
612
+ RESULT_DIR,
613
+ `${sessionName}-${paneName}.txt`,
614
+ ).replace(/\\/g, "/");
615
+ const cmd = buildHeadlessCommand(
616
+ assignment.cli,
617
+ assignment.prompt,
618
+ resultFile,
619
+ { mcp: assignment.mcp, model: assignment.model },
620
+ );
621
+ const scriptDir = join(RESULT_DIR, sessionName);
622
+ await registerHeadlessWorker(sessionName, i, assignment.cli);
623
+ const dispatch = dispatchCommand(sessionName, paneName, cmd, {
624
+ scriptDir,
625
+ scriptName: paneName,
626
+ });
444
627
 
445
- return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role, command: cmd };
446
- });
628
+ // P1 fix: 비-progressive에서는 pane 리네임 금지 캡처 로그 경로가 타이틀 기반이므로
629
+ // 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
630
+ // progressive 모드에서는 split-window 시 새 pane에 바로 타이틀이 설정되므로 문제없음
631
+
632
+ if (safeProgress)
633
+ safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
634
+
635
+ return {
636
+ ...dispatch,
637
+ paneName,
638
+ resultFile,
639
+ cli: assignment.cli,
640
+ role: assignment.role,
641
+ command: cmd,
642
+ workerId,
643
+ };
644
+ }),
645
+ );
447
646
  }
448
647
 
449
648
  /**
@@ -455,102 +654,121 @@ function dispatchBatch(sessionName, assignments, opts = {}) {
455
654
  * @param {number} progressIntervalSec
456
655
  * @returns {Promise<Array<{d, completion, output}>>}
457
656
  */
458
- async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec, stallOpts) {
657
+ async function awaitAll(
658
+ sessionName,
659
+ dispatches,
660
+ timeoutSec,
661
+ safeProgress,
662
+ progressIntervalSec,
663
+ stallOpts,
664
+ ) {
459
665
  // 병렬 대기 (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,
666
+ return Promise.all(
667
+ dispatches.map(async (d) => {
668
+ // onPoll onProgress 변환 (throttle by progressIntervalSec)
669
+ const pollOpts = {};
670
+ if (safeProgress && progressIntervalSec > 0) {
671
+ let lastProgressAt = 0;
672
+ const intervalMs = progressIntervalSec * 1000;
673
+ pollOpts.onPoll = ({ content }) => {
674
+ const now = Date.now();
675
+ if (now - lastProgressAt >= intervalMs) {
676
+ lastProgressAt = now;
677
+ safeProgress({
678
+ type: "progress",
679
+ paneName: d.paneName,
680
+ cli: d.cli,
681
+ snapshot: content.split("\n").slice(-15).join("\n"), // 마지막 15줄
682
+ });
683
+ }
516
684
  };
517
- } catch (stallErr) {
518
- if (stallErr.code === "STALL_EXHAUSTED") {
685
+ }
686
+
687
+ let completion;
688
+ if (stallOpts?.enabled) {
689
+ // 하이브리드 stall detection 모드
690
+ try {
691
+ const stallPollCb =
692
+ safeProgress && progressIntervalSec > 0
693
+ ? (snapshot) => {
694
+ try {
695
+ safeProgress({
696
+ type: "progress",
697
+ paneName: d.paneName,
698
+ cli: d.cli,
699
+ snapshot: snapshot.split("\n").slice(-15).join("\n"),
700
+ });
701
+ } catch {
702
+ /* 삼킴 */
703
+ }
704
+ }
705
+ : undefined;
706
+
707
+ const stallResult = await waitForCompletionWithStallDetect(
708
+ sessionName,
709
+ d.paneId || d.paneName,
710
+ d.resultFile,
711
+ {
712
+ pollInterval: stallOpts.pollInterval,
713
+ stallTimeout: stallOpts.stallTimeout,
714
+ completionTimeout:
715
+ stallOpts.completionTimeout ?? timeoutSec * 1000,
716
+ maxRestarts: stallOpts.maxRestarts,
717
+ command: d.command,
718
+ token: d.token,
719
+ onPoll: stallPollCb,
720
+ },
721
+ );
519
722
  completion = {
520
- matched: false,
521
- exitCode: null,
522
- stallExhausted: true,
523
- restarts: stallErr.restarts,
723
+ matched: stallResult.matched,
724
+ exitCode: stallResult.exitCode,
725
+ stallDetected: stallResult.stallDetected,
726
+ restarts: stallResult.restarts,
524
727
  };
525
- } else {
526
- throw stallErr;
728
+ } catch (stallErr) {
729
+ if (stallErr.code === "STALL_EXHAUSTED") {
730
+ completion = {
731
+ matched: false,
732
+ exitCode: null,
733
+ stallExhausted: true,
734
+ restarts: stallErr.restarts,
735
+ };
736
+ } else {
737
+ throw stallErr;
738
+ }
527
739
  }
740
+ } else {
741
+ // 기존 waitForCompletion 경로
742
+ if (d.logPath) pollOpts.logPath = d.logPath;
743
+ completion = await waitForCompletion(
744
+ sessionName,
745
+ d.paneId || d.paneName,
746
+ d.token,
747
+ timeoutSec,
748
+ pollOpts,
749
+ );
528
750
  }
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
751
 
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
- }
752
+ const output = completion.matched
753
+ ? readResult(d.resultFile, d.paneId)
754
+ : "";
755
+
756
+ if (safeProgress) {
757
+ safeProgress({
758
+ type: "completed",
759
+ paneName: d.paneName,
760
+ cli: d.cli,
761
+ matched: completion.matched,
762
+ exitCode: completion.exitCode,
763
+ sessionDead: completion.sessionDead || false,
764
+ stallDetected: completion.stallDetected || false,
765
+ stallExhausted: completion.stallExhausted || false,
766
+ });
767
+ }
551
768
 
552
- return { d, completion, output };
553
- }));
769
+ return { d, completion, output };
770
+ }),
771
+ );
554
772
  }
555
773
 
556
774
  /**
@@ -558,39 +776,59 @@ async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progr
558
776
  * @param {Array<{d, completion, output}>} results
559
777
  * @returns {Array}
560
778
  */
561
- function collectResults(results) {
779
+ async function collectResults(sessionName, results) {
562
780
  // B3 fix: git diff를 루프 밖에서 1회만 실행 (워커 수만큼 중복 방지)
563
781
  let gitDiffFiles;
564
782
  try {
565
- const diffOut = execSync("git diff --name-only HEAD", { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
783
+ const diffOut = execSync("git diff --name-only HEAD", {
784
+ encoding: "utf8",
785
+ timeout: 5000,
786
+ stdio: ["pipe", "pipe", "pipe"],
787
+ });
566
788
  gitDiffFiles = diffOut.trim().split("\n").filter(Boolean);
567
- } catch { /* git 미설치 또는 non-repo — 무시 */ }
789
+ } catch {
790
+ /* git 미설치 또는 non-repo — 무시 */
791
+ }
568
792
 
569
793
  // 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
- });
794
+ return await Promise.all(
795
+ results.map(async ({ d, completion, output }) => {
796
+ const handoffResult = processHandoff(output, {
797
+ exitCode: completion.exitCode,
798
+ resultFile: d.resultFile,
799
+ cli: d.cli,
800
+ gitDiffFiles,
801
+ });
802
+ const status =
803
+ handoffResult.handoff?.status ||
804
+ (completion.matched && completion.exitCode === 0
805
+ ? "completed"
806
+ : "failed");
807
+ await publishHeadlessResult(
808
+ sessionName,
809
+ d.workerId,
810
+ status,
811
+ handoffResult.handoff,
812
+ );
577
813
 
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
- });
814
+ return {
815
+ cli: d.cli,
816
+ paneName: d.paneName,
817
+ paneId: d.paneId,
818
+ workerId: d.workerId,
819
+ role: d.role,
820
+ matched: completion.matched,
821
+ exitCode: completion.exitCode,
822
+ output,
823
+ resultFile: d.resultFile,
824
+ sessionDead: completion.sessionDead || false,
825
+ handoff: handoffResult.handoff,
826
+ handoffFormatted: handoffResult.formatted,
827
+ handoffValid: handoffResult.valid,
828
+ handoffFallback: handoffResult.fallback,
829
+ };
830
+ }),
831
+ );
594
832
  }
595
833
 
596
834
  /**
@@ -623,7 +861,10 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
623
861
 
624
862
  // in-process TUI: dashboard=true이고 stdout이 TTY일 때 직접 구동
625
863
  let tui = null;
626
- const resolvedLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
864
+ const resolvedLayout = resolveDashboardLayout(
865
+ dashboardLayout,
866
+ assignments.length,
867
+ );
627
868
  if (dashboard && process.stdout.isTTY) {
628
869
  tui = createLogDashboard({
629
870
  stream: process.stdout,
@@ -678,16 +919,43 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
678
919
  // onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
679
920
  const combinedProgress = (event) => {
680
921
  feedTui(event);
681
- if (onProgress) { try { onProgress(event); } catch { /* 콜백 예외 삼킴 */ } }
922
+ if (onProgress) {
923
+ try {
924
+ onProgress(event);
925
+ } catch {
926
+ /* 콜백 예외 삼킴 */
927
+ }
928
+ }
929
+ };
930
+ const safeProgress = (event) => {
931
+ try {
932
+ combinedProgress(event);
933
+ } catch {
934
+ /* 삼킴 */
935
+ }
682
936
  };
683
- const safeProgress = (event) => { try { combinedProgress(event); } catch { /* 삼킴 */ } };
684
937
 
685
938
  const dispatches = progressive
686
- ? await dispatchProgressive(sessionName, assignments, { layout, safeProgress, dashboardLayout })
687
- : dispatchBatch(sessionName, assignments, { layout, safeProgress, dashboardLayout });
939
+ ? await dispatchProgressive(sessionName, assignments, {
940
+ layout,
941
+ safeProgress,
942
+ dashboardLayout,
943
+ })
944
+ : await dispatchBatch(sessionName, assignments, {
945
+ layout,
946
+ safeProgress,
947
+ dashboardLayout,
948
+ });
688
949
 
689
- const results = await awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec, stallDetect);
690
- const collected = collectResults(results);
950
+ const results = await awaitAll(
951
+ sessionName,
952
+ dispatches,
953
+ timeoutSec,
954
+ safeProgress,
955
+ progressIntervalSec,
956
+ stallDetect,
957
+ );
958
+ const collected = await collectResults(sessionName, results);
691
959
 
692
960
  // 완료 시 TUI에 최종 상태 반영 후 닫기
693
961
  if (tui) {
@@ -700,7 +968,9 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
700
968
  summary: r.handoff?.verdict || (r.matched ? "completed" : "failed"),
701
969
  detail: r.output,
702
970
  progress: 1,
703
- elapsed: Math.round((Date.now() - (tui._startedAt || Date.now())) / 1000),
971
+ elapsed: Math.round(
972
+ (Date.now() - (tui._startedAt || Date.now())) / 1000,
973
+ ),
704
974
  });
705
975
  }
706
976
  tui.render();
@@ -727,7 +997,12 @@ export async function runHeadlessWithCleanup(assignments, opts = {}) {
727
997
  try {
728
998
  return await runHeadless(sessionName, assignments, runOpts);
729
999
  } finally {
730
- try { killPsmuxSession(sessionName); } catch { /* 무시 */ }
1000
+ await deregisterHeadlessWorkers(sessionName, assignments.length);
1001
+ try {
1002
+ killPsmuxSession(sessionName);
1003
+ } catch {
1004
+ /* 무시 */
1005
+ }
731
1006
  // WT split pane은 psmux 종료 시 셸이 끝나면서 자동으로 닫힘
732
1007
  // 수동 close-pane 불필요 (레이스 컨디션으로 WT 에러 발생)
733
1008
  }
@@ -747,7 +1022,10 @@ export function applyTrifluxTheme(sessionName) {
747
1022
  ["status-style", "bg=#1e1e2e,fg=#cdd6f4"],
748
1023
  ["status-left", " #[fg=#89b4fa,bold]▲ triflux#[default] "],
749
1024
  ["status-left-length", "20"],
750
- ["status-right", " #[fg=#a6adc8]#{pane_title}#[default] │ #[fg=#f9e2af]%H:%M#[default] "],
1025
+ [
1026
+ "status-right",
1027
+ " #[fg=#a6adc8]#{pane_title}#[default] │ #[fg=#f9e2af]%H:%M#[default] ",
1028
+ ],
751
1029
  ["status-right-length", "40"],
752
1030
  // Pane border — active/inactive 구분
753
1031
  ["pane-active-border-style", "fg=#89b4fa"],
@@ -758,7 +1036,11 @@ export function applyTrifluxTheme(sessionName) {
758
1036
  ["allow-rename", "off"],
759
1037
  ];
760
1038
  for (const [key, value] of opts) {
761
- try { psmuxExec(["set-option", "-t", sessionName, key, value]); } catch { /* 무시 */ }
1039
+ try {
1040
+ psmuxExec(["set-option", "-t", sessionName, key, value]);
1041
+ } catch {
1042
+ /* 무시 */
1043
+ }
762
1044
  }
763
1045
  }
764
1046
 
@@ -774,19 +1056,34 @@ export function applyTrifluxTheme(sessionName) {
774
1056
  */
775
1057
  function getWtDefaultFontSize() {
776
1058
  const settingsPaths = [
777
- join(process.env.LOCALAPPDATA || "", "Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json"),
778
- join(process.env.LOCALAPPDATA || "", "Microsoft/Windows Terminal/settings.json"),
1059
+ join(
1060
+ process.env.LOCALAPPDATA || "",
1061
+ "Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json",
1062
+ ),
1063
+ join(
1064
+ process.env.LOCALAPPDATA || "",
1065
+ "Microsoft/Windows Terminal/settings.json",
1066
+ ),
779
1067
  ];
780
1068
  for (const p of settingsPaths) {
781
1069
  if (!existsSync(p)) continue;
782
1070
  try {
783
- const settings = JSON.parse(readFileSync(p, "utf8").replace(/^\s*\/\/.*$/gm, ""));
1071
+ const settings = JSON.parse(
1072
+ readFileSync(p, "utf8").replace(/^\s*\/\/.*$/gm, ""),
1073
+ );
784
1074
  // 기본 프로필 or 첫 프로필의 폰트
785
1075
  const defaultGuid = settings.defaultProfile;
786
1076
  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 { /* 다음 */ }
1077
+ const defaultProfile =
1078
+ profiles.find((pr) => pr.guid === defaultGuid) || profiles[0];
1079
+ return (
1080
+ defaultProfile?.font?.size ||
1081
+ settings.profiles?.defaults?.font?.size ||
1082
+ 12
1083
+ );
1084
+ } catch {
1085
+ /* 다음 */
1086
+ }
790
1087
  }
791
1088
  return 12;
792
1089
  }
@@ -803,7 +1100,11 @@ function atomicWriteSync(filePath, data) {
803
1100
  writeFileSync(tmpPath, data, "utf8");
804
1101
  renameSync(tmpPath, filePath);
805
1102
  } catch (err) {
806
- try { writeFileSync(tmpPath.replace(/\.tmp$/, ".tmp.del"), ""); } catch { /* 무시 */ }
1103
+ try {
1104
+ writeFileSync(tmpPath.replace(/\.tmp$/, ".tmp.del"), "");
1105
+ } catch {
1106
+ /* 무시 */
1107
+ }
807
1108
  throw err;
808
1109
  }
809
1110
  }
@@ -813,21 +1114,31 @@ function sanitizeSessionName(value) {
813
1114
  }
814
1115
 
815
1116
  function sanitizeWindowTitle(value, fallback = "triflux") {
816
- const text = String(value || "").replace(/[\r\n]+/g, " ").trim();
1117
+ const text = String(value || "")
1118
+ .replace(/[\r\n]+/g, " ")
1119
+ .trim();
817
1120
  return text || fallback;
818
1121
  }
819
1122
 
820
1123
  function buildWtAttachPaneArgs(sessionName, title) {
821
1124
  const safeSession = sanitizeSessionName(sessionName);
822
1125
  return [
823
- "--profile", "triflux",
824
- "--title", sanitizeWindowTitle(title, `▲ ${safeSession}`),
825
- "--", "psmux", "attach-session", "-t", safeSession,
1126
+ "--profile",
1127
+ "triflux",
1128
+ "--title",
1129
+ sanitizeWindowTitle(title, `▲ ${safeSession}`),
1130
+ "--",
1131
+ "psmux",
1132
+ "attach-session",
1133
+ "-t",
1134
+ safeSession,
826
1135
  ];
827
1136
  }
828
1137
 
829
1138
  function joinWtCommands(commands) {
830
- return commands.flatMap((command, index) => (index === 0 ? command : [";", ...command]));
1139
+ return commands.flatMap((command, index) =>
1140
+ index === 0 ? command : [";", ...command],
1141
+ );
831
1142
  }
832
1143
 
833
1144
  function buildAttachTitle(sessionName, suffix = "") {
@@ -835,40 +1146,85 @@ function buildAttachTitle(sessionName, suffix = "") {
835
1146
  return suffix ? `${base} ${suffix}` : base;
836
1147
  }
837
1148
 
838
- export function buildDashboardAttachArgs(sessionName, dashboardLayout = "single", workerCount = 2, anchor = "window") {
1149
+ export function buildDashboardAttachArgs(
1150
+ sessionName,
1151
+ dashboardLayout = "single",
1152
+ workerCount = 2,
1153
+ anchor = "window",
1154
+ ) {
839
1155
  const safeSession = sanitizeSessionName(sessionName);
840
1156
  const resolvedLayout = resolveDashboardLayout(dashboardLayout, workerCount);
841
- const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
1157
+ const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(
1158
+ /\\/g,
1159
+ "/",
1160
+ );
842
1161
  const prefix = anchor === "tab" ? ["-w", "0", "nt"] : ["-w", "new"];
843
1162
  return [
844
1163
  ...prefix,
845
- "--profile", "triflux",
846
- "--title", buildAttachTitle(safeSession, "dashboard"),
847
- "--", "node", viewerPath,
848
- "--session", safeSession,
849
- "--result-dir", RESULT_DIR,
850
- "--layout", resolvedLayout,
1164
+ "--profile",
1165
+ "triflux",
1166
+ "--title",
1167
+ buildAttachTitle(safeSession, "dashboard"),
1168
+ "--",
1169
+ "node",
1170
+ viewerPath,
1171
+ "--session",
1172
+ safeSession,
1173
+ "--result-dir",
1174
+ RESULT_DIR,
1175
+ "--layout",
1176
+ resolvedLayout,
851
1177
  ];
852
1178
  }
853
1179
 
854
1180
  export function buildWtAttachArgs(sessionName, workerCount = 1) {
855
1181
  const safeSession = sanitizeSessionName(sessionName);
856
- const count = Number.isFinite(workerCount) ? Math.max(1, Math.trunc(workerCount)) : 1;
1182
+ const count = Number.isFinite(workerCount)
1183
+ ? Math.max(1, Math.trunc(workerCount))
1184
+ : 1;
857
1185
  if (count >= 5) return buildDashboardAttachArgs(safeSession, "single", count);
858
1186
 
859
- const pane1 = ["nt", ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession))];
1187
+ const pane1 = [
1188
+ "nt",
1189
+ ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession)),
1190
+ ];
860
1191
  if (count === 1) return ["-w", "0", ...pane1];
861
1192
 
862
- const pane2 = ["sp", count >= 3 ? "-V" : "-H", ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession, "2"))];
1193
+ const pane2 = [
1194
+ "sp",
1195
+ count >= 3 ? "-V" : "-H",
1196
+ ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession, "2")),
1197
+ ];
863
1198
  if (count === 2) return ["-w", "0", ...joinWtCommands([pane1, pane2])];
864
1199
 
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"))];
1200
+ const pane3 = [
1201
+ "sp",
1202
+ "-H",
1203
+ ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession, "3")),
1204
+ ];
1205
+ if (count === 3)
1206
+ return [
1207
+ "-w",
1208
+ "0",
1209
+ ...joinWtCommands([pane1, pane2, ["move-focus", "left"], pane3]),
1210
+ ];
1211
+
1212
+ const pane4 = [
1213
+ "sp",
1214
+ "-H",
1215
+ ...buildWtAttachPaneArgs(safeSession, buildAttachTitle(safeSession, "4")),
1216
+ ];
869
1217
  return [
870
- "-w", "0",
871
- ...joinWtCommands([pane1, pane2, ["move-focus", "left"], pane3, ["move-focus", "right"], pane4]),
1218
+ "-w",
1219
+ "0",
1220
+ ...joinWtCommands([
1221
+ pane1,
1222
+ pane2,
1223
+ ["move-focus", "left"],
1224
+ pane3,
1225
+ ["move-focus", "right"],
1226
+ pane4,
1227
+ ]),
872
1228
  ];
873
1229
  }
874
1230
 
@@ -883,8 +1239,14 @@ function spawnDetachedWt(args) {
883
1239
 
884
1240
  export function ensureWtProfile(workerCount = 2) {
885
1241
  const settingsPaths = [
886
- join(process.env.LOCALAPPDATA || "", "Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json"),
887
- join(process.env.LOCALAPPDATA || "", "Microsoft/Windows Terminal/settings.json"),
1242
+ join(
1243
+ process.env.LOCALAPPDATA || "",
1244
+ "Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json",
1245
+ ),
1246
+ join(
1247
+ process.env.LOCALAPPDATA || "",
1248
+ "Microsoft/Windows Terminal/settings.json",
1249
+ ),
888
1250
  ];
889
1251
 
890
1252
  for (const settingsPath of settingsPaths) {
@@ -896,7 +1258,9 @@ export function ensureWtProfile(workerCount = 2) {
896
1258
  const settings = JSON.parse(cleaned);
897
1259
  if (!settings.profiles?.list) continue;
898
1260
 
899
- const existing = settings.profiles.list.findIndex(p => p.name === "triflux");
1261
+ const existing = settings.profiles.list.findIndex(
1262
+ (p) => p.name === "triflux",
1263
+ );
900
1264
  const profile = {
901
1265
  name: "triflux",
902
1266
  commandline: "psmux",
@@ -907,20 +1271,30 @@ export function ensureWtProfile(workerCount = 2) {
907
1271
  useAcrylic: true,
908
1272
  unfocusedAppearance: { opacity: 20 },
909
1273
  colorScheme: "One Half Dark",
910
- font: { size: Math.max(6, getWtDefaultFontSize() - 1 - Math.floor(workerCount / 2)) },
1274
+ font: {
1275
+ size: Math.max(
1276
+ 6,
1277
+ getWtDefaultFontSize() - 1 - Math.floor(workerCount / 2),
1278
+ ),
1279
+ },
911
1280
  closeOnExit: "always",
912
1281
  hidden: true, // 프로필 목록에는 숨김 (triflux에서만 사용)
913
1282
  };
914
1283
 
915
1284
  if (existing >= 0) {
916
- settings.profiles.list[existing] = { ...settings.profiles.list[existing], ...profile };
1285
+ settings.profiles.list[existing] = {
1286
+ ...settings.profiles.list[existing],
1287
+ ...profile,
1288
+ };
917
1289
  } else {
918
1290
  settings.profiles.list.push(profile);
919
1291
  }
920
1292
 
921
1293
  atomicWriteSync(settingsPath, JSON.stringify(settings, null, 2));
922
1294
  return true;
923
- } catch { /* 파싱 실패 — 다음 경로 */ }
1295
+ } catch {
1296
+ /* 파싱 실패 — 다음 경로 */
1297
+ }
924
1298
  }
925
1299
  return false;
926
1300
  }
@@ -938,15 +1312,27 @@ export function ensureWtProfile(workerCount = 2) {
938
1312
  */
939
1313
  export function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
940
1314
  if (!process.env.WT_SESSION) return false;
941
- try { execSync("where wt.exe", { stdio: "ignore" }); } catch { return false; }
1315
+ try {
1316
+ execSync("where wt.exe", { stdio: "ignore" });
1317
+ } catch {
1318
+ return false;
1319
+ }
942
1320
  ensureWtProfile(workerCount);
943
1321
  try {
944
- const args = workerCount >= 5
945
- ? buildDashboardAttachArgs(sessionName, opts.dashboardLayout, workerCount, opts.dashboardAnchor)
946
- : buildWtAttachArgs(sessionName, workerCount);
1322
+ const args =
1323
+ workerCount >= 5
1324
+ ? buildDashboardAttachArgs(
1325
+ sessionName,
1326
+ opts.dashboardLayout,
1327
+ workerCount,
1328
+ opts.dashboardAnchor,
1329
+ )
1330
+ : buildWtAttachArgs(sessionName, workerCount);
947
1331
  spawnDetachedWt(args);
948
1332
  return true;
949
- } catch { return false; }
1333
+ } catch {
1334
+ return false;
1335
+ }
950
1336
  }
951
1337
 
952
1338
  /**
@@ -958,15 +1344,32 @@ export function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
958
1344
  * @param {string} [dashboardAnchor='window'] — window | tab
959
1345
  * @returns {boolean}
960
1346
  */
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; }
1347
+ export function attachDashboardTab(
1348
+ sessionName,
1349
+ workerCount = 2,
1350
+ dashboardLayout = "single",
1351
+ dashboardSize = 0.4,
1352
+ dashboardAnchor = "window",
1353
+ ) {
1354
+ try {
1355
+ execSync("where wt.exe", { stdio: "ignore" });
1356
+ } catch {
1357
+ return false;
1358
+ }
963
1359
  ensureWtProfile(workerCount);
964
1360
  try {
965
- const args = buildDashboardAttachArgs(sessionName, dashboardLayout, workerCount, dashboardAnchor);
1361
+ const args = buildDashboardAttachArgs(
1362
+ sessionName,
1363
+ dashboardLayout,
1364
+ workerCount,
1365
+ dashboardAnchor,
1366
+ );
966
1367
  void dashboardSize;
967
1368
  spawnDetachedWt(args);
968
1369
  return true;
969
- } catch { return false; }
1370
+ } catch {
1371
+ return false;
1372
+ }
970
1373
  }
971
1374
 
972
1375
  /**
@@ -1017,11 +1420,15 @@ export function getProgressSnapshots(sessionName, dispatches, lines = 15) {
1017
1420
  * kill: () => void,
1018
1421
  * }>}
1019
1422
  */
1020
- export async function runHeadlessInteractive(sessionName, assignments, opts = {}) {
1423
+ export async function runHeadlessInteractive(
1424
+ sessionName,
1425
+ assignments,
1426
+ opts = {},
1427
+ ) {
1021
1428
  const {
1022
1429
  autoAttach = false,
1023
1430
  dashboard = false,
1024
- dashboardSize = 0.40,
1431
+ dashboardSize = 0.4,
1025
1432
  dashboardAnchor = "window",
1026
1433
  signal,
1027
1434
  maxIdleSec = 0,
@@ -1042,12 +1449,20 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
1042
1449
  attachDashboardTab(
1043
1450
  sessionName,
1044
1451
  assignments.length,
1045
- event.dashboardLayout || resolveDashboardLayout(headlessOpts.dashboardLayout, assignments.length),
1452
+ event.dashboardLayout ||
1453
+ resolveDashboardLayout(
1454
+ headlessOpts.dashboardLayout,
1455
+ assignments.length,
1456
+ ),
1046
1457
  dashboardSize,
1047
1458
  dashboardAnchor,
1048
1459
  );
1049
1460
  } else {
1050
- autoAttachTerminal(sessionName, { dashboardLayout: headlessOpts.dashboardLayout, dashboardAnchor }, assignments.length);
1461
+ autoAttachTerminal(
1462
+ sessionName,
1463
+ { dashboardLayout: headlessOpts.dashboardLayout, dashboardAnchor },
1464
+ assignments.length,
1465
+ );
1051
1466
  }
1052
1467
  }
1053
1468
  if (userOnProgress) userOnProgress(event);
@@ -1055,7 +1470,11 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
1055
1470
  const interactiveRunOpts = { ...headlessOpts, onProgress };
1056
1471
 
1057
1472
  // Phase 1: 세션 생성 → 즉시 터미널 팝업 → dispatch → 대기 → 결과 수집
1058
- const { results } = await runHeadless(sessionName, assignments, interactiveRunOpts);
1473
+ const { results } = await runHeadless(
1474
+ sessionName,
1475
+ assignments,
1476
+ interactiveRunOpts,
1477
+ );
1059
1478
 
1060
1479
  // Phase 2: 세션을 유지하고 interactive handle 반환
1061
1480
  // Fix P2: paneId를 dispatches에 포함 (snapshots에서 필요)
@@ -1085,7 +1504,11 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
1085
1504
  /** 특정 pane에 후속 명령 dispatch (캡처 자동 재시작) */
1086
1505
  dispatch(paneName, command) {
1087
1506
  if (this._killed) throw new Error("세션이 이미 종료되었습니다.");
1088
- try { startCapture(sessionName, paneName); } catch { /* 이미 활성 — 무시 */ }
1507
+ try {
1508
+ startCapture(sessionName, paneName);
1509
+ } catch {
1510
+ /* 이미 활성 — 무시 */
1511
+ }
1089
1512
  resetIdleTimer();
1090
1513
  return dispatchCommand(sessionName, paneName, command);
1091
1514
  },
@@ -1111,7 +1534,13 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
1111
1534
  async waitFor(paneName, token, timeoutSec = 300, waitOpts = {}) {
1112
1535
  if (this._killed) return { matched: false, sessionDead: true };
1113
1536
  resetIdleTimer();
1114
- return waitForCompletion(sessionName, paneName, token, timeoutSec, waitOpts);
1537
+ return waitForCompletion(
1538
+ sessionName,
1539
+ paneName,
1540
+ token,
1541
+ timeoutSec,
1542
+ waitOpts,
1543
+ );
1115
1544
  },
1116
1545
 
1117
1546
  /** 세션 생존 확인 */
@@ -1124,7 +1553,12 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
1124
1553
  kill() {
1125
1554
  if (this._killed) return;
1126
1555
  this._killed = true;
1127
- try { killPsmuxSession(sessionName); } catch { /* 무시 */ }
1556
+ void deregisterHeadlessWorkers(sessionName, assignments.length);
1557
+ try {
1558
+ killPsmuxSession(sessionName);
1559
+ } catch {
1560
+ /* 무시 */
1561
+ }
1128
1562
  // WT split pane은 psmux 종료 → 셸 종료 → 자동 닫힘
1129
1563
  // 수동 close-pane 불필요 (레이스 컨디션으로 WT 0x80070002 에러 발생)
1130
1564
  },