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.
- package/README.md +236 -156
- package/hub/bridge.mjs +638 -290
- package/hub/codex-compat.mjs +1 -1
- package/hub/fullcycle.mjs +1 -1
- package/hub/intent.mjs +1 -0
- package/hub/lib/mcp-response-cache.mjs +205 -0
- package/hub/pipe.mjs +228 -119
- package/hub/reflexion.mjs +87 -13
- package/hub/research.mjs +1 -0
- package/hub/server.mjs +997 -611
- package/hub/team/ansi.mjs +1 -1
- package/hub/team/conductor-registry.mjs +121 -0
- package/hub/team/conductor.mjs +256 -125
- package/hub/team/execution-mode.mjs +105 -0
- package/hub/team/headless.mjs +686 -252
- package/hub/team/lead-control.mjs +91 -4
- package/hub/team/mcp-selector.mjs +145 -0
- package/hub/team/session-sync.mjs +153 -6
- package/hub/team/swarm-hypervisor.mjs +208 -86
- package/hub/team/tui-lite.mjs +18 -2
- package/hub/token-mode.mjs +1 -0
- package/hub/tools.mjs +474 -252
- package/package.json +5 -5
- package/scripts/codex-gateway-preflight.mjs +133 -0
- package/scripts/codex-mcp-gateway-sync.mjs +199 -0
- package/skills/star-prompt/SKILL.md +169 -69
- package/skills/tfx-setup/SKILL.md +124 -0
- package/skills/tfx-swarm/SKILL.md +124 -72
package/hub/team/headless.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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: {
|
|
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:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 =
|
|
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(
|
|
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, {
|
|
101
|
-
|
|
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(
|
|
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(
|
|
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 {
|
|
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 {
|
|
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(
|
|
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 {
|
|
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) {
|
|
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(
|
|
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 {
|
|
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 {
|
|
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",
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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] || {
|
|
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",
|
|
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 {
|
|
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)
|
|
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(
|
|
391
|
-
|
|
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)
|
|
398
|
-
|
|
399
|
-
|
|
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 {
|
|
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(
|
|
587
|
+
const resolvedDashboardLayout = resolveDashboardLayout(
|
|
588
|
+
dashboardLayout,
|
|
589
|
+
assignments.length,
|
|
590
|
+
);
|
|
419
591
|
// A2b fix: 2x2 레이아웃은 최대 4 pane — 초과 시 tiled로 자동 전환
|
|
420
|
-
const effectiveLayout =
|
|
421
|
-
const session = createPsmuxSession(sessionName, {
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
lastProgressAt
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
}
|
|
518
|
-
|
|
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:
|
|
521
|
-
exitCode:
|
|
522
|
-
|
|
523
|
-
restarts:
|
|
723
|
+
matched: stallResult.matched,
|
|
724
|
+
exitCode: stallResult.exitCode,
|
|
725
|
+
stallDetected: stallResult.stallDetected,
|
|
726
|
+
restarts: stallResult.restarts,
|
|
524
727
|
};
|
|
525
|
-
}
|
|
526
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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", {
|
|
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 {
|
|
789
|
+
} catch {
|
|
790
|
+
/* git 미설치 또는 non-repo — 무시 */
|
|
791
|
+
}
|
|
568
792
|
|
|
569
793
|
// handoff 파이프라인: parse → validate → format (각 워커 결과에 적용)
|
|
570
|
-
return
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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(
|
|
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) {
|
|
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, {
|
|
687
|
-
|
|
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(
|
|
690
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
[
|
|
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 {
|
|
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(
|
|
778
|
-
|
|
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(
|
|
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 =
|
|
788
|
-
|
|
789
|
-
|
|
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 {
|
|
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 || "")
|
|
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",
|
|
824
|
-
"
|
|
825
|
-
"--",
|
|
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) =>
|
|
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(
|
|
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(
|
|
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",
|
|
846
|
-
"
|
|
847
|
-
"--",
|
|
848
|
-
"
|
|
849
|
-
"--
|
|
850
|
-
"
|
|
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)
|
|
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 = [
|
|
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 = [
|
|
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 = [
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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",
|
|
871
|
-
|
|
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(
|
|
887
|
-
|
|
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(
|
|
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: {
|
|
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] = {
|
|
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 {
|
|
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 =
|
|
945
|
-
|
|
946
|
-
|
|
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 {
|
|
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(
|
|
962
|
-
|
|
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(
|
|
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 {
|
|
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(
|
|
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.
|
|
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 ||
|
|
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(
|
|
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(
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
},
|