triflux 9.8.2 → 9.8.3
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/bin/triflux.mjs +5 -0
- package/hooks/safety-guard.mjs +1 -1
- package/hub/assign-callbacks.mjs +13 -16
- package/hub/hitl.mjs +6 -3
- package/hub/intent.mjs +2 -7
- package/hub/lib/process-utils.mjs +5 -4
- package/hub/pipe.mjs +4 -7
- package/hub/platform.mjs +186 -0
- package/hub/router.mjs +791 -791
- package/hub/server.mjs +1112 -1000
- package/hub/state.mjs +245 -0
- package/hub/store-adapter.mjs +614 -0
- package/hub/store.mjs +820 -807
- package/hub/team/headless.mjs +298 -66
- package/hub/team/nativeProxy.mjs +2 -1
- package/hub/team/psmux.mjs +3 -3
- package/hub/tray.mjs +8 -7
- package/hub/workers/claude-worker.mjs +89 -37
- package/hub/workers/codex-mcp.mjs +123 -29
- package/hub/workers/gemini-worker.mjs +81 -137
- package/hub/workers/interface.mjs +12 -0
- package/hub/workers/worker-utils.mjs +78 -0
- package/package.json +7 -1
- package/scripts/headless-guard.mjs +7 -1
- package/scripts/setup.mjs +56 -745
- package/scripts/tfx-route.sh +2 -2
- package/scripts/tmp-cleanup.mjs +34 -5
package/hub/team/headless.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// v6.0.0: Lead-direct 모드 (runHeadlessInteractive, autoAttachTerminal)
|
|
5
5
|
// 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, statSync } from "node:fs";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { execSync, spawn } from "node:child_process";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
@@ -22,12 +22,15 @@ import {
|
|
|
22
22
|
import { HANDOFF_INSTRUCTION_SHORT, processHandoff } from "./handoff.mjs";
|
|
23
23
|
import { getBackend } from "./backend.mjs";
|
|
24
24
|
import { resolveDashboardLayout } from "./dashboard-layout.mjs";
|
|
25
|
-
import { normalizeDashboardAnchor } from "./dashboard-anchor.mjs";
|
|
26
25
|
import { createLogDashboard } from "./tui.mjs";
|
|
27
|
-
import { createLiteDashboard } from "./tui-lite.mjs";
|
|
28
26
|
|
|
29
27
|
const RESULT_DIR = join(tmpdir(), "tfx-headless");
|
|
30
28
|
|
|
29
|
+
// remote-spawn.mjs의 escapePwshSingleQuoted와 동일 — 순환 의존 방지를 위해 인라인
|
|
30
|
+
function escapePwshSingleQuoted(value) {
|
|
31
|
+
return String(value).replace(/'/g, "''");
|
|
32
|
+
}
|
|
33
|
+
|
|
31
34
|
/** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
|
|
32
35
|
const CLI_BRAND = {
|
|
33
36
|
codex: { emoji: "\u{26AA}", label: "Codex", ansi: "\x1b[97m" }, // ⚪ bright white (codexWhite)
|
|
@@ -51,11 +54,6 @@ export function resolveCliType(agentOrCli) {
|
|
|
51
54
|
return AGENT_TO_CLI[agentOrCli] || agentOrCli;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
// remote-spawn.mjs의 escapePwshSingleQuoted와 동일 — 순환 의존 방지를 위해 인라인
|
|
55
|
-
function escapePwshSingleQuoted(value) {
|
|
56
|
-
return String(value).replace(/'/g, "''");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
57
|
/** MCP 프로필별 프롬프트 힌트 (tfx-route.sh resolve_mcp_policy의 경량 미러) */
|
|
60
58
|
const MCP_PROFILE_HINTS = {
|
|
61
59
|
implement: "You have full filesystem read/write access. Implement changes directly.",
|
|
@@ -73,7 +71,6 @@ const MCP_PROFILE_HINTS = {
|
|
|
73
71
|
* @param {boolean} [opts.handoff=true]
|
|
74
72
|
* @param {string} [opts.mcp] — MCP 프로필 ("implement"|"analyze"|"review"|"docs")
|
|
75
73
|
* @param {string} [opts.contextFile] — 컨텍스트 파일 경로 (최대 32KB, UTF-8 안전 절단)
|
|
76
|
-
* @param {string} [opts.cwd] — 워커 실행 작업 디렉터리
|
|
77
74
|
* @returns {string} PowerShell 명령
|
|
78
75
|
*/
|
|
79
76
|
export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
@@ -128,6 +125,215 @@ function readResult(resultFile, paneId) {
|
|
|
128
125
|
return capturePsmuxPane(paneId, 30);
|
|
129
126
|
}
|
|
130
127
|
|
|
128
|
+
// ─── Stall Detection ───
|
|
129
|
+
|
|
130
|
+
/** Stall detection 기본값 (immutable) */
|
|
131
|
+
export const STALL_DEFAULTS = Object.freeze({
|
|
132
|
+
pollInterval: 5_000,
|
|
133
|
+
stallTimeout: 120_000,
|
|
134
|
+
completionTimeout: 900_000,
|
|
135
|
+
maxRestarts: 2,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** CLI pane stall 감지 에러 (STALL_EXHAUSTED | COMPLETION_TIMEOUT) */
|
|
139
|
+
export class StallError extends Error {
|
|
140
|
+
constructor(message, { code = "STALL_DETECTED", category = "transient", recovery = "" } = {}) {
|
|
141
|
+
super(message);
|
|
142
|
+
this.name = "StallError";
|
|
143
|
+
this.code = code;
|
|
144
|
+
this.category = category;
|
|
145
|
+
this.recovery = recovery;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Stall 모니터 팩토리 — output + resultFile mtime 하이브리드 감지
|
|
151
|
+
* @param {string} paneId
|
|
152
|
+
* @param {string} resultFile
|
|
153
|
+
* @param {{ stallTimeout: number }} config
|
|
154
|
+
* @param {{ capturePsmuxPane?: Function, statSync?: Function }} [deps]
|
|
155
|
+
* @returns {{ poll: () => { snapshot: string, mtimeChanged: boolean, stalled: boolean, elapsed: number } }}
|
|
156
|
+
*/
|
|
157
|
+
export function createStallMonitor(paneId, resultFile, config, deps = {}) {
|
|
158
|
+
const capture = deps.capturePsmuxPane || capturePsmuxPane;
|
|
159
|
+
const stat = deps.statSync || statSync;
|
|
160
|
+
let lastSnapshot = "";
|
|
161
|
+
let lastMtime = 0;
|
|
162
|
+
let lastChangeAt = Date.now();
|
|
163
|
+
|
|
164
|
+
try { lastMtime = stat(resultFile).mtimeMs; } catch { /* not created yet */ }
|
|
165
|
+
|
|
166
|
+
return Object.freeze({
|
|
167
|
+
poll() {
|
|
168
|
+
const snapshot = capture(paneId, 50);
|
|
169
|
+
let currentMtime = 0;
|
|
170
|
+
try { currentMtime = stat(resultFile).mtimeMs; } catch { /* ignore */ }
|
|
171
|
+
|
|
172
|
+
const outputChanged = snapshot !== lastSnapshot;
|
|
173
|
+
const mtimeChanged = currentMtime > 0 && currentMtime !== lastMtime;
|
|
174
|
+
|
|
175
|
+
if (outputChanged || mtimeChanged) {
|
|
176
|
+
lastChangeAt = Date.now();
|
|
177
|
+
lastSnapshot = snapshot;
|
|
178
|
+
if (mtimeChanged) lastMtime = currentMtime;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const elapsed = Date.now() - lastChangeAt;
|
|
182
|
+
return Object.freeze({
|
|
183
|
+
snapshot,
|
|
184
|
+
mtimeChanged,
|
|
185
|
+
stalled: elapsed >= config.stallTimeout,
|
|
186
|
+
elapsed,
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 하이브리드 stall 감지 대기 — output 변화 + resultFile mtime 모니터링.
|
|
194
|
+
* 2분 무변화 시 pane kill → re-dispatch (최대 2회 재시작).
|
|
195
|
+
*
|
|
196
|
+
* @param {string} sessionName
|
|
197
|
+
* @param {string} paneId — 현재 pane 타겟 (예: "tfx:0.1")
|
|
198
|
+
* @param {string} resultFile — 결과 저장 파일 경로
|
|
199
|
+
* @param {object} [opts]
|
|
200
|
+
* @param {number} [opts.pollInterval=5000] — 폴링 간격 ms
|
|
201
|
+
* @param {number} [opts.stallTimeout=120000] — 무변화 stall 판정 ms
|
|
202
|
+
* @param {number} [opts.completionTimeout=900000] — 전체 타임아웃 ms
|
|
203
|
+
* @param {number} [opts.maxRestarts=2] — 최대 재시작 횟수
|
|
204
|
+
* @param {string} [opts.command] — re-dispatch용 원본 명령
|
|
205
|
+
* @param {string} [opts.token] — completion token
|
|
206
|
+
* @param {(snapshot: string) => void} [opts.onPoll] — 폴링 콜백
|
|
207
|
+
* @returns {Promise<{ matched: boolean, exitCode: number|null, restarts: number, stallDetected: boolean }>}
|
|
208
|
+
*/
|
|
209
|
+
export async function waitForCompletionWithStallDetect(sessionName, paneId, resultFile, opts = {}) {
|
|
210
|
+
const {
|
|
211
|
+
pollInterval = 5000,
|
|
212
|
+
stallTimeout = 120000,
|
|
213
|
+
completionTimeout = 900000,
|
|
214
|
+
maxRestarts = 2,
|
|
215
|
+
command,
|
|
216
|
+
token,
|
|
217
|
+
onPoll,
|
|
218
|
+
_deps,
|
|
219
|
+
} = opts;
|
|
220
|
+
|
|
221
|
+
// 의존성 (테스트 시 _deps로 주입 가능)
|
|
222
|
+
const deps = _deps || {};
|
|
223
|
+
const _capture = deps.capturePsmuxPane || capturePsmuxPane;
|
|
224
|
+
const _exists = deps.existsSync || existsSync;
|
|
225
|
+
const _stat = deps.statSync || statSync;
|
|
226
|
+
const _readFile = deps.readFileSync || readFileSync;
|
|
227
|
+
const _exec = deps.psmuxExec || psmuxExec;
|
|
228
|
+
const _dispatch = deps.dispatchCommand || dispatchCommand;
|
|
229
|
+
const _startCapture = deps.startCapture || startCapture;
|
|
230
|
+
|
|
231
|
+
const _PREFIX = "__TRIFLUX_DONE__:";
|
|
232
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
233
|
+
const completionRe = token
|
|
234
|
+
? new RegExp(`${esc(_PREFIX)}${esc(token)}:(\\d+)`, "m")
|
|
235
|
+
: new RegExp(`${esc(_PREFIX)}\\S+:(\\d+)`, "m");
|
|
236
|
+
|
|
237
|
+
let restarts = 0;
|
|
238
|
+
let currentPaneId = paneId;
|
|
239
|
+
let stallDetected = false;
|
|
240
|
+
|
|
241
|
+
while (true) {
|
|
242
|
+
let lastOutput = "";
|
|
243
|
+
let lastMtime = 0;
|
|
244
|
+
let lastChangeAt = Date.now();
|
|
245
|
+
const startedAt = Date.now();
|
|
246
|
+
|
|
247
|
+
// 초기 resultFile mtime
|
|
248
|
+
try {
|
|
249
|
+
if (_exists(resultFile)) lastMtime = _stat(resultFile).mtimeMs;
|
|
250
|
+
} catch { /* 무시 */ }
|
|
251
|
+
|
|
252
|
+
while (true) {
|
|
253
|
+
await new Promise((r) => setTimeout(r, pollInterval));
|
|
254
|
+
const now = Date.now();
|
|
255
|
+
|
|
256
|
+
// 전체 타임아웃
|
|
257
|
+
if (now - startedAt > completionTimeout) {
|
|
258
|
+
return { matched: false, exitCode: null, restarts, stallDetected, timedOut: true };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 1) capture-pane 출력 확인
|
|
262
|
+
const currentOutput = _capture(currentPaneId, 50);
|
|
263
|
+
if (onPoll) { try { onPoll(currentOutput); } catch { /* 삼킴 */ } }
|
|
264
|
+
|
|
265
|
+
// 2) completion 토큰 감지
|
|
266
|
+
const completionMatch = completionRe.exec(currentOutput);
|
|
267
|
+
if (completionMatch) {
|
|
268
|
+
return {
|
|
269
|
+
matched: true,
|
|
270
|
+
exitCode: Number.parseInt(completionMatch[1], 10),
|
|
271
|
+
restarts,
|
|
272
|
+
stallDetected,
|
|
273
|
+
timedOut: false,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 3) resultFile 존재 + mtime 변화 확인
|
|
278
|
+
let currentMtime = 0;
|
|
279
|
+
try {
|
|
280
|
+
if (_exists(resultFile)) currentMtime = _stat(resultFile).mtimeMs;
|
|
281
|
+
} catch { /* 무시 */ }
|
|
282
|
+
|
|
283
|
+
// 4) 변화 감지 → stallTimer 리셋
|
|
284
|
+
const outputChanged = currentOutput !== lastOutput;
|
|
285
|
+
const mtimeChanged = currentMtime > 0 && currentMtime !== lastMtime;
|
|
286
|
+
|
|
287
|
+
if (outputChanged || mtimeChanged) {
|
|
288
|
+
lastChangeAt = now;
|
|
289
|
+
lastOutput = currentOutput;
|
|
290
|
+
if (mtimeChanged) lastMtime = currentMtime;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// resultFile이 갱신되고 내용이 있으면 완료로 간주
|
|
294
|
+
if (mtimeChanged && currentMtime > 0 && _exists(resultFile)) {
|
|
295
|
+
try {
|
|
296
|
+
const content = _readFile(resultFile, "utf8").trim();
|
|
297
|
+
if (content.length > 0) {
|
|
298
|
+
return { matched: true, exitCode: 0, restarts, stallDetected, timedOut: false };
|
|
299
|
+
}
|
|
300
|
+
} catch { /* 무시 */ }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 5) stall 판정
|
|
304
|
+
if (now - lastChangeAt >= stallTimeout) {
|
|
305
|
+
stallDetected = true;
|
|
306
|
+
|
|
307
|
+
if (restarts >= maxRestarts) {
|
|
308
|
+
const err = new Error("CLI가 반복적으로 멈춤. 수동 확인 필요.");
|
|
309
|
+
err.code = "STALL_EXHAUSTED";
|
|
310
|
+
err.category = "transient";
|
|
311
|
+
err.recovery = "CLI가 반복적으로 멈춤. 수동 확인 필요.";
|
|
312
|
+
err.restarts = restarts;
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// kill pane → re-dispatch
|
|
317
|
+
try { _exec(["kill-pane", "-t", currentPaneId]); } catch { /* 이미 종료 */ }
|
|
318
|
+
|
|
319
|
+
if (command) {
|
|
320
|
+
// 새 pane split + 동일 command re-dispatch
|
|
321
|
+
const newPaneId = _exec([
|
|
322
|
+
"split-window", "-t", sessionName, "-P", "-F",
|
|
323
|
+
"#{session_name}:#{window_index}.#{pane_index}",
|
|
324
|
+
]);
|
|
325
|
+
_startCapture(sessionName, newPaneId);
|
|
326
|
+
_dispatch(sessionName, newPaneId, command);
|
|
327
|
+
currentPaneId = newPaneId;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
restarts++;
|
|
331
|
+
break; // inner loop 재시작 (stallTimer 리셋)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
131
337
|
/** progressive 스플릿 모드: lead pane만 생성 후, 워커를 하나씩 추가하며 dispatch */
|
|
132
338
|
async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
133
339
|
const {
|
|
@@ -175,7 +381,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
|
175
381
|
|
|
176
382
|
// 캡처 시작 + 컬러 배너 + 명령 dispatch
|
|
177
383
|
const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
|
|
178
|
-
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp, model: assignment.model
|
|
384
|
+
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp, model: assignment.model });
|
|
179
385
|
startCapture(sessionName, newPaneId);
|
|
180
386
|
// pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
|
|
181
387
|
if (i > 0) await new Promise(r => setTimeout(r, 300));
|
|
@@ -183,7 +389,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
|
183
389
|
|
|
184
390
|
if (safeProgress) safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
|
|
185
391
|
|
|
186
|
-
dispatches.push({ ...dispatch, paneId: newPaneId, paneName, resultFile, cli: assignment.cli, role: assignment.role });
|
|
392
|
+
dispatches.push({ ...dispatch, paneId: newPaneId, paneName, resultFile, cli: assignment.cli, role: assignment.role, command: cmd });
|
|
187
393
|
}
|
|
188
394
|
|
|
189
395
|
// 모든 split 완료 후 레이아웃 한 번만 정렬 (깜빡임 방지)
|
|
@@ -219,7 +425,7 @@ function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
219
425
|
return assignments.map((assignment, i) => {
|
|
220
426
|
const paneName = `worker-${i + 1}`;
|
|
221
427
|
const resultFile = join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
|
|
222
|
-
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp, model: assignment.model
|
|
428
|
+
const cmd = buildHeadlessCommand(assignment.cli, assignment.prompt, resultFile, { mcp: assignment.mcp, model: assignment.model });
|
|
223
429
|
const scriptDir = join(RESULT_DIR, sessionName);
|
|
224
430
|
const dispatch = dispatchCommand(sessionName, paneName, cmd, { scriptDir, scriptName: paneName });
|
|
225
431
|
|
|
@@ -229,7 +435,7 @@ function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
229
435
|
|
|
230
436
|
if (safeProgress) safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
|
|
231
437
|
|
|
232
|
-
return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role };
|
|
438
|
+
return { ...dispatch, paneName, resultFile, cli: assignment.cli, role: assignment.role, command: cmd };
|
|
233
439
|
});
|
|
234
440
|
}
|
|
235
441
|
|
|
@@ -242,13 +448,11 @@ function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
242
448
|
* @param {number} progressIntervalSec
|
|
243
449
|
* @returns {Promise<Array<{d, completion, output}>>}
|
|
244
450
|
*/
|
|
245
|
-
async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec) {
|
|
246
|
-
const ac = new AbortController();
|
|
247
|
-
|
|
451
|
+
async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec, stallOpts) {
|
|
248
452
|
// 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
|
|
249
453
|
return Promise.all(dispatches.map(async (d) => {
|
|
250
454
|
// onPoll → onProgress 변환 (throttle by progressIntervalSec)
|
|
251
|
-
const pollOpts = {
|
|
455
|
+
const pollOpts = {};
|
|
252
456
|
if (safeProgress && progressIntervalSec > 0) {
|
|
253
457
|
let lastProgressAt = 0;
|
|
254
458
|
const intervalMs = progressIntervalSec * 1000;
|
|
@@ -266,13 +470,59 @@ async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progr
|
|
|
266
470
|
};
|
|
267
471
|
}
|
|
268
472
|
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
473
|
+
let completion;
|
|
474
|
+
if (stallOpts && stallOpts.enabled) {
|
|
475
|
+
// 하이브리드 stall detection 모드
|
|
476
|
+
try {
|
|
477
|
+
const stallPollCb = safeProgress && progressIntervalSec > 0
|
|
478
|
+
? (snapshot) => {
|
|
479
|
+
try {
|
|
480
|
+
safeProgress({
|
|
481
|
+
type: "progress",
|
|
482
|
+
paneName: d.paneName,
|
|
483
|
+
cli: d.cli,
|
|
484
|
+
snapshot: snapshot.split("\n").slice(-15).join("\n"),
|
|
485
|
+
});
|
|
486
|
+
} catch { /* 삼킴 */ }
|
|
487
|
+
}
|
|
488
|
+
: undefined;
|
|
489
|
+
|
|
490
|
+
const stallResult = await waitForCompletionWithStallDetect(
|
|
491
|
+
sessionName,
|
|
492
|
+
d.paneId || d.paneName,
|
|
493
|
+
d.resultFile,
|
|
494
|
+
{
|
|
495
|
+
pollInterval: stallOpts.pollInterval,
|
|
496
|
+
stallTimeout: stallOpts.stallTimeout,
|
|
497
|
+
completionTimeout: stallOpts.completionTimeout ?? timeoutSec * 1000,
|
|
498
|
+
maxRestarts: stallOpts.maxRestarts,
|
|
499
|
+
command: d.command,
|
|
500
|
+
token: d.token,
|
|
501
|
+
onPoll: stallPollCb,
|
|
502
|
+
},
|
|
503
|
+
);
|
|
504
|
+
completion = {
|
|
505
|
+
matched: stallResult.matched,
|
|
506
|
+
exitCode: stallResult.exitCode,
|
|
507
|
+
stallDetected: stallResult.stallDetected,
|
|
508
|
+
restarts: stallResult.restarts,
|
|
509
|
+
};
|
|
510
|
+
} catch (stallErr) {
|
|
511
|
+
if (stallErr.code === "STALL_EXHAUSTED") {
|
|
512
|
+
completion = {
|
|
513
|
+
matched: false,
|
|
514
|
+
exitCode: null,
|
|
515
|
+
stallExhausted: true,
|
|
516
|
+
restarts: stallErr.restarts,
|
|
517
|
+
};
|
|
518
|
+
} else {
|
|
519
|
+
throw stallErr;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
// 기존 waitForCompletion 경로
|
|
524
|
+
if (d.logPath) pollOpts.logPath = d.logPath;
|
|
525
|
+
completion = await waitForCompletion(sessionName, d.paneId || d.paneName, d.token, timeoutSec, pollOpts);
|
|
276
526
|
}
|
|
277
527
|
|
|
278
528
|
const output = completion.matched
|
|
@@ -287,6 +537,8 @@ async function awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progr
|
|
|
287
537
|
matched: completion.matched,
|
|
288
538
|
exitCode: completion.exitCode,
|
|
289
539
|
sessionDead: completion.sessionDead || false,
|
|
540
|
+
stallDetected: completion.stallDetected || false,
|
|
541
|
+
stallExhausted: completion.stallExhausted || false,
|
|
290
542
|
});
|
|
291
543
|
}
|
|
292
544
|
|
|
@@ -357,6 +609,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
357
609
|
progressive = true,
|
|
358
610
|
dashboard = false,
|
|
359
611
|
dashboardLayout = "single",
|
|
612
|
+
stallDetect,
|
|
360
613
|
} = opts;
|
|
361
614
|
|
|
362
615
|
mkdirSync(RESULT_DIR, { recursive: true });
|
|
@@ -365,15 +618,12 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
365
618
|
let tui = null;
|
|
366
619
|
const resolvedLayout = resolveDashboardLayout(dashboardLayout, assignments.length);
|
|
367
620
|
if (dashboard && process.stdout.isTTY) {
|
|
368
|
-
|
|
621
|
+
tui = createLogDashboard({
|
|
369
622
|
stream: process.stdout,
|
|
370
623
|
input: process.stdin,
|
|
371
624
|
refreshMs: 200,
|
|
372
625
|
layout: resolvedLayout,
|
|
373
|
-
};
|
|
374
|
-
tui = resolvedLayout === "lite"
|
|
375
|
-
? createLiteDashboard(dashOpts)
|
|
376
|
-
: createLogDashboard(dashOpts);
|
|
626
|
+
});
|
|
377
627
|
tui.setStartTime(Date.now());
|
|
378
628
|
// 초기 워커 상태 등록
|
|
379
629
|
for (let i = 0; i < assignments.length; i++) {
|
|
@@ -429,7 +679,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
429
679
|
? await dispatchProgressive(sessionName, assignments, { layout, safeProgress, dashboardLayout })
|
|
430
680
|
: dispatchBatch(sessionName, assignments, { layout, safeProgress, dashboardLayout });
|
|
431
681
|
|
|
432
|
-
const results = await awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec);
|
|
682
|
+
const results = await awaitAll(sessionName, dispatches, timeoutSec, safeProgress, progressIntervalSec, stallDetect);
|
|
433
683
|
const collected = collectResults(results);
|
|
434
684
|
|
|
435
685
|
// 완료 시 TUI에 최종 상태 반영 후 닫기
|
|
@@ -606,19 +856,12 @@ export function ensureWtProfile(workerCount = 2) {
|
|
|
606
856
|
* @param {number} [workerCount=2]
|
|
607
857
|
* @returns {boolean} 성공 여부
|
|
608
858
|
*/
|
|
609
|
-
let _wtAvailable = null;
|
|
610
|
-
function isWtAvailable() {
|
|
611
|
-
if (_wtAvailable !== null) return _wtAvailable;
|
|
612
|
-
if (!process.env.WT_SESSION) { _wtAvailable = false; return false; }
|
|
613
|
-
try { execSync("where wt.exe", { stdio: "ignore" }); _wtAvailable = true; } catch { _wtAvailable = false; }
|
|
614
|
-
return _wtAvailable;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
859
|
export function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
|
|
618
860
|
// 보안: sessionName 셸 주입 방지 — 영숫자, 하이픈, 언더스코어만 허용
|
|
619
861
|
const safeName = String(sessionName).replace(/[^a-zA-Z0-9_\-]/g, "");
|
|
620
862
|
sessionName = safeName || "tfx-session";
|
|
621
|
-
if (!
|
|
863
|
+
if (!process.env.WT_SESSION) return false;
|
|
864
|
+
try { execSync("where wt.exe", { stdio: "ignore" }); } catch { return false; }
|
|
622
865
|
ensureWtProfile(workerCount);
|
|
623
866
|
try {
|
|
624
867
|
const child = spawn("wt.exe", [
|
|
@@ -627,45 +870,37 @@ export function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
|
|
|
627
870
|
"--", "psmux", "attach", "-t", sessionName,
|
|
628
871
|
], { detached: true, stdio: "ignore" });
|
|
629
872
|
child.unref();
|
|
630
|
-
|
|
873
|
+
try { spawn("wt.exe", ["-w", "0", "mf", "up"], { detached: true, stdio: "ignore" }).unref(); } catch { /* 무시 */ }
|
|
631
874
|
return true;
|
|
632
875
|
} catch { return false; }
|
|
633
876
|
}
|
|
634
877
|
|
|
635
|
-
export function buildDashboardAttachArgs(sessionName, dashboardLayout = "single", workerCount = 2, dashboardAnchor = "window") {
|
|
636
|
-
const safeName = String(sessionName).replace(/[^a-zA-Z0-9_\-]/g, "") || "tfx-session";
|
|
637
|
-
const resolvedDashboardLayout = resolveDashboardLayout(dashboardLayout, workerCount);
|
|
638
|
-
const resolvedDashboardAnchor = normalizeDashboardAnchor(dashboardAnchor);
|
|
639
|
-
const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
|
|
640
|
-
const viewerArgs = [
|
|
641
|
-
"--profile", "triflux",
|
|
642
|
-
"--title", `▲ ${safeName}`,
|
|
643
|
-
"--", "node", viewerPath, "--session", safeName, "--result-dir", RESULT_DIR, "--layout", resolvedDashboardLayout,
|
|
644
|
-
];
|
|
645
|
-
|
|
646
|
-
if (resolvedDashboardAnchor === "tab") {
|
|
647
|
-
return ["-w", "0", "nt", ...viewerArgs];
|
|
648
|
-
}
|
|
649
|
-
return ["-w", "new", ...viewerArgs];
|
|
650
|
-
}
|
|
651
|
-
|
|
652
878
|
/**
|
|
653
879
|
* v7.0: psmux 세션을 WT 탭에 attach (대시보드 + 워커 전체 뷰)
|
|
654
880
|
* @param {string} sessionName
|
|
655
881
|
* @param {number} workerCount
|
|
656
882
|
* @param {string} [dashboardLayout='single']
|
|
657
883
|
* @param {number} [dashboardSize=0.50] — 대시보드 분할 비율 (0.2~0.8)
|
|
658
|
-
* @deprecated dashboardSize — anchor=window|tab 모드에서는 무시됨
|
|
659
|
-
* @param {string} [dashboardAnchor='window'] — dashboard anchor 정책(window|tab)
|
|
660
884
|
* @returns {boolean}
|
|
661
885
|
*/
|
|
662
|
-
export function attachDashboardTab(sessionName, workerCount = 2, dashboardLayout = "single", dashboardSize = 0.40
|
|
663
|
-
|
|
886
|
+
export function attachDashboardTab(sessionName, workerCount = 2, dashboardLayout = "single", dashboardSize = 0.40) {
|
|
887
|
+
try { execSync("where wt.exe", { stdio: "ignore" }); } catch { return false; }
|
|
664
888
|
ensureWtProfile(workerCount);
|
|
889
|
+
const resolvedDashboardLayout = resolveDashboardLayout(dashboardLayout, workerCount);
|
|
890
|
+
|
|
891
|
+
// v7.1.3: 대시보드만 스플릿 (psmux attach 대신 tui-viewer 직접 실행)
|
|
892
|
+
// raw CLI 출력은 사용자에게 불필요 — 대시보드 로그만 표시
|
|
893
|
+
const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
|
|
894
|
+
const sizeStr = String(Math.round(dashboardSize * 100) / 100);
|
|
665
895
|
try {
|
|
666
|
-
const
|
|
667
|
-
|
|
896
|
+
const child = spawn("wt.exe", [
|
|
897
|
+
"-w", "0", "sp", "-H", "-s", sizeStr,
|
|
898
|
+
"--profile", "triflux",
|
|
899
|
+
"--title", `▲ ${sessionName}`,
|
|
900
|
+
"--", "node", viewerPath, "--session", sessionName, "--result-dir", RESULT_DIR, "--layout", resolvedDashboardLayout,
|
|
901
|
+
], { detached: true, stdio: "ignore" });
|
|
668
902
|
child.unref();
|
|
903
|
+
try { spawn("wt.exe", ["-w", "0", "mf", "up"], { detached: true, stdio: "ignore" }).unref(); } catch {}
|
|
669
904
|
return true;
|
|
670
905
|
} catch { return false; }
|
|
671
906
|
}
|
|
@@ -704,7 +939,6 @@ export function getProgressSnapshots(sessionName, dispatches, lines = 15) {
|
|
|
704
939
|
* @param {number} [opts.progressIntervalSec=0]
|
|
705
940
|
* @param {boolean} [opts.autoAttach=false] — Windows Terminal 자동 attach
|
|
706
941
|
* @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
|
|
707
|
-
* @param {string} [opts.dashboardAnchor='window'] — dashboard anchor 정책(window|tab)
|
|
708
942
|
* @param {AbortSignal} [opts.signal] — abort 시 자동 세션 정리
|
|
709
943
|
* @param {number} [opts.maxIdleSec=0] — 유휴 시 자동 정리 (0=비활성)
|
|
710
944
|
* @returns {Promise<{
|
|
@@ -724,7 +958,6 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
|
|
|
724
958
|
autoAttach = false,
|
|
725
959
|
dashboard = false,
|
|
726
960
|
dashboardSize = 0.40,
|
|
727
|
-
dashboardAnchor = "window",
|
|
728
961
|
signal,
|
|
729
962
|
maxIdleSec = 0,
|
|
730
963
|
...runOpts
|
|
@@ -746,7 +979,6 @@ export async function runHeadlessInteractive(sessionName, assignments, opts = {}
|
|
|
746
979
|
assignments.length,
|
|
747
980
|
event.dashboardLayout || resolveDashboardLayout(headlessOpts.dashboardLayout, assignments.length),
|
|
748
981
|
dashboardSize,
|
|
749
|
-
dashboardAnchor,
|
|
750
982
|
);
|
|
751
983
|
} else {
|
|
752
984
|
autoAttachTerminal(sessionName, {}, assignments.length);
|
package/hub/team/nativeProxy.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { basename, dirname, join } from 'node:path';
|
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { randomUUID } from 'node:crypto';
|
|
22
22
|
import { isPidAlive } from '../lib/process-utils.mjs';
|
|
23
|
+
import { IS_WINDOWS } from '../platform.mjs';
|
|
23
24
|
|
|
24
25
|
const TEAM_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
25
26
|
const CLAUDE_HOME = join(homedir(), '.claude');
|
|
@@ -62,7 +63,7 @@ function atomicWriteJson(path, value) {
|
|
|
62
63
|
renameSync(tmp, path);
|
|
63
64
|
} catch (e) {
|
|
64
65
|
// Windows NTFS: 대상 파일 존재 시 rename 실패 가능 → 삭제 후 재시도
|
|
65
|
-
if (
|
|
66
|
+
if (IS_WINDOWS && (e.code === 'EPERM' || e.code === 'EEXIST')) {
|
|
66
67
|
try { unlinkSync(path); } catch {}
|
|
67
68
|
renameSync(tmp, path);
|
|
68
69
|
} else {
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
5
5
|
import { tmpdir, homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { formatPsmuxInstallGuidance } from "../../scripts/lib/psmux-info.mjs";
|
|
8
|
+
import { IS_WINDOWS } from "../platform.mjs";
|
|
8
9
|
|
|
9
10
|
const PSMUX_BIN = (() => {
|
|
10
11
|
if (process.env.PSMUX_BIN) return process.env.PSMUX_BIN;
|
|
@@ -14,7 +15,7 @@ const PSMUX_BIN = (() => {
|
|
|
14
15
|
return "psmux";
|
|
15
16
|
} catch { /* not in PATH */ }
|
|
16
17
|
// Windows 기본 설치 경로 탐색
|
|
17
|
-
if (
|
|
18
|
+
if (IS_WINDOWS) {
|
|
18
19
|
const candidates = [
|
|
19
20
|
join(process.env.LOCALAPPDATA || "", "psmux", "psmux.exe"),
|
|
20
21
|
join(process.env.APPDATA || "", "npm", "psmux.cmd"),
|
|
@@ -28,7 +29,6 @@ const PSMUX_BIN = (() => {
|
|
|
28
29
|
return "psmux"; // 최종 fallback — 원래대로
|
|
29
30
|
})();
|
|
30
31
|
const GIT_BASH = process.env.GIT_BASH_PATH || "C:\\Program Files\\Git\\bin\\bash.exe";
|
|
31
|
-
const IS_WINDOWS = process.platform === "win32";
|
|
32
32
|
|
|
33
33
|
/** Windows psmux 세션의 기본 셸을 PowerShell로 강제한다 (pwsh7 우선, ps5 fallback). */
|
|
34
34
|
const PWSH_BIN = (() => {
|
|
@@ -881,7 +881,7 @@ export function dispatchCommand(sessionName, paneNameOrTarget, commandText) {
|
|
|
881
881
|
const token = randomToken(paneName);
|
|
882
882
|
const safeCommand = wrapCliForBash(commandText);
|
|
883
883
|
// CP949 등 non-UTF-8 codepage 환경에서 CLI stdout이 깨지는 문제 방지 (belt-and-suspenders)
|
|
884
|
-
const chcpPrefix =
|
|
884
|
+
const chcpPrefix = IS_WINDOWS ? "chcp 65001 > $null; " : "";
|
|
885
885
|
const wrapped = `${chcpPrefix}try { ${safeCommand} } finally { $trifluxExit = if ($null -ne $LASTEXITCODE) { [int]$LASTEXITCODE } else { 0 }; Write-Output "${COMPLETION_PREFIX}${token}:$trifluxExit" }`;
|
|
886
886
|
|
|
887
887
|
sendLiteralToPane(pane.paneId, wrapped, true);
|
package/hub/tray.mjs
CHANGED
|
@@ -4,9 +4,10 @@ import _SysTrayModule from "systray2";
|
|
|
4
4
|
const SysTray = _SysTrayModule.default || _SysTrayModule;
|
|
5
5
|
import { exec } from "node:child_process";
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
-
import { homedir } from "node:os";
|
|
8
|
-
import { join, resolve } from "node:path";
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { IS_WINDOWS } from "./platform.mjs";
|
|
10
11
|
|
|
11
12
|
const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
|
|
12
13
|
const DEFAULT_HUB_PORT = "27888";
|
|
@@ -311,10 +312,10 @@ async function shutdown(reason = "shutdown") {
|
|
|
311
312
|
}
|
|
312
313
|
}
|
|
313
314
|
|
|
314
|
-
export async function startTray() {
|
|
315
|
-
if (
|
|
316
|
-
throw new Error("tray command is only supported on Windows.");
|
|
317
|
-
}
|
|
315
|
+
export async function startTray() {
|
|
316
|
+
if (!IS_WINDOWS) {
|
|
317
|
+
throw new Error("tray command is only supported on Windows.");
|
|
318
|
+
}
|
|
318
319
|
|
|
319
320
|
systray = new SysTray({
|
|
320
321
|
menu,
|