@triflux/remote 10.50.2 → 10.51.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/cto/brief.mjs +0 -12
- package/cto/collect.mjs +0 -73
- package/cto/current.schema.json +0 -4
- package/cto/status.mjs +2 -50
- package/hub/index.mjs +0 -8
- package/hub/team/agent-map.json +0 -1
- package/hub/team/ansi.mjs +1 -35
- package/hub/team/backend.mjs +9 -145
- package/hub/team/cli/commands/attach.mjs +1 -17
- package/hub/team/cli/commands/control.mjs +12 -38
- package/hub/team/cli/commands/debug.mjs +1 -37
- package/hub/team/cli/commands/focus.mjs +5 -31
- package/hub/team/cli/commands/interrupt.mjs +6 -25
- package/hub/team/cli/commands/kill.mjs +1 -30
- package/hub/team/cli/commands/list.mjs +0 -16
- package/hub/team/cli/commands/send.mjs +6 -26
- package/hub/team/cli/commands/start/index.mjs +41 -133
- package/hub/team/cli/commands/start/parse-args.mjs +15 -40
- package/hub/team/cli/commands/start/start-headless.mjs +138 -105
- package/hub/team/cli/commands/start/start-mux.mjs +0 -3
- package/hub/team/cli/commands/status.mjs +2 -57
- package/hub/team/cli/commands/stop.mjs +14 -19
- package/hub/team/cli/help.mjs +4 -5
- package/hub/team/cli/manifest.mjs +5 -1
- package/hub/team/cli/render.mjs +0 -12
- package/hub/team/cli/services/hub-client.mjs +2 -88
- package/hub/team/cli/services/runtime-mode.mjs +27 -98
- package/hub/team/handoff.mjs +3 -34
- package/hub/team/headless.mjs +13 -754
- package/hub/team/orchestrator.mjs +4 -30
- package/hub/team/pane.mjs +6 -28
- package/hub/team/psmux.mjs +0 -1
- package/hub/team/remote-session.mjs +1 -2
- package/hub/team/session.mjs +7 -91
- package/hub/team/swarm-intent.mjs +0 -198
- package/hub/team/tui-core.mjs +1 -1
- package/hub/team/tui-viewer.mjs +2 -2
- package/hub/team/tui.mjs +8 -269
- package/hub/team/wt-manager.mjs +2 -2
- package/hub/workers/factory.mjs +4 -37
- package/hub/workers/lib/jsonrpc-ws-uds.mjs +0 -2
- package/package.json +1 -1
- package/scripts/lib/agent-route-policy.mjs +162 -14
- package/scripts/lib/claudemd-scanner.mjs +2 -199
- package/scripts/lib/cli-claude.mjs +0 -2
- package/scripts/lib/context.mjs +3 -19
- package/scripts/lib/env-probe.mjs +5 -64
- package/scripts/lib/gemini-profiles.mjs +2 -69
- package/scripts/lib/machine-profile.mjs +3 -17
- package/scripts/lib/mcp-filter.mjs +109 -53
- package/scripts/lib/mcp-health.mjs +2 -2
- package/scripts/lib/mcp-server-catalog.mjs +2 -2
- package/scripts/lib/psmux-info.mjs +11 -59
- package/scripts/lib/skill-template.mjs +0 -245
- package/hub/team/build-worker-prompt.mjs +0 -92
- package/hub/team/check-mcp-hub.mjs +0 -49
- package/hub/team/claude-native-bridge.mjs +0 -987
- package/hub/team/cli/commands/start/start-in-process.mjs +0 -50
- package/hub/team/cli/commands/start/start-wt.mjs +0 -86
- package/hub/team/cli/services/native-control.mjs +0 -145
- package/hub/team/codex-review.mjs +0 -520
- package/hub/team/conductor-mesh-bridge.mjs +0 -122
- package/hub/team/conductor.mjs +0 -1529
- package/hub/team/consensus-meta.mjs +0 -176
- package/hub/team/cto-pull.mjs +0 -31
- package/hub/team/daemon-pty-tmux-bridge.mjs +0 -146
- package/hub/team/dashboard.mjs +0 -322
- package/hub/team/event-log.mjs +0 -84
- package/hub/team/execution-mode.mjs +0 -324
- package/hub/team/extract-completion-payload.mjs +0 -110
- package/hub/team/headless-bridge-session.mjs +0 -17
- package/hub/team/health-probe.mjs +0 -435
- package/hub/team/interactive-native-launcher.mjs +0 -119
- package/hub/team/interactive-native-worker.mjs +0 -80
- package/hub/team/interactive-tui-transport.mjs +0 -479
- package/hub/team/launcher-template.mjs +0 -144
- package/hub/team/mcp-selector.mjs +0 -145
- package/hub/team/native-supervisor.mjs +0 -460
- package/hub/team/native.mjs +0 -727
- package/hub/team/recovery-store.mjs +0 -149
- package/hub/team/remote-probe.mjs +0 -292
- package/hub/team/remote-watcher.mjs +0 -506
- package/hub/team/runtime-strategy.mjs +0 -200
- package/hub/team/schemas/worker-completion.json +0 -39
- package/hub/team/sentinel-capture.mjs +0 -127
- package/hub/team/swarm-cli.mjs +0 -390
- package/hub/team/swarm-hypervisor.mjs +0 -2206
- package/hub/team/swarm-locks.mjs +0 -335
- package/hub/team/swarm-planner.mjs +0 -371
- package/hub/team/swarm-preflight.mjs +0 -366
- package/hub/team/swarm-reconciler.mjs +0 -242
- package/hub/team/tui-remote-adapter.mjs +0 -377
- package/hub/team/worker-completion-validator.mjs +0 -79
- package/hub/team/worktree-lifecycle.mjs +0 -639
- package/hub/team/wt-templates.json +0 -43
- package/hub/workers/antigravity-route-worker.mjs +0 -179
- package/scripts/lib/dynamic-route-cli.mjs +0 -107
package/hub/team/headless.mjs
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
// hub/team/headless.mjs — 헤드리스 CLI 오케스트레이션
|
|
2
2
|
// psmux pane에서 CLI를 헤드리스 모드로 실행하고 결과를 수집한다.
|
|
3
|
-
// v5.2.0: 기본 headless 엔진 (runHeadless, runHeadlessWithCleanup)
|
|
4
|
-
// v6.0.0: Lead-direct 모드 (runHeadlessInteractive, autoAttachTerminal)
|
|
5
|
-
// 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
|
|
6
3
|
|
|
7
4
|
import { execSync } from "node:child_process";
|
|
8
5
|
import { randomUUID } from "node:crypto";
|
|
@@ -10,7 +7,6 @@ import {
|
|
|
10
7
|
existsSync,
|
|
11
8
|
mkdirSync,
|
|
12
9
|
readFileSync,
|
|
13
|
-
renameSync,
|
|
14
10
|
rmSync,
|
|
15
11
|
statSync,
|
|
16
12
|
writeFileSync,
|
|
@@ -21,9 +17,7 @@ import { tmpdir } from "node:os";
|
|
|
21
17
|
import { dirname, join, resolve } from "node:path";
|
|
22
18
|
import { fileURLToPath } from "node:url";
|
|
23
19
|
import { resolveNestedCodexAgentProfile } from "../../scripts/lib/agent-route-policy.mjs";
|
|
24
|
-
import { requestJson } from "@triflux/core/hub/bridge.mjs";
|
|
25
20
|
import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
|
|
26
|
-
import { getMaxSpawnPerSec } from "@triflux/core/hub/lib/spawn-trace.mjs";
|
|
27
21
|
import {
|
|
28
22
|
createActivityLifecycle,
|
|
29
23
|
isActivityLifecycleEnabled,
|
|
@@ -40,7 +34,6 @@ import {
|
|
|
40
34
|
killDaemonJob,
|
|
41
35
|
sendKillBySessionId,
|
|
42
36
|
} from "./claude-daemon-control.mjs";
|
|
43
|
-
import { startClaudeNativeBridge } from "./claude-native-bridge.mjs";
|
|
44
37
|
import { removeClaudeSessionProjection } from "./claude-session-projection.mjs";
|
|
45
38
|
import { resolveDashboardLayout } from "./dashboard-layout.mjs";
|
|
46
39
|
import {
|
|
@@ -63,11 +56,6 @@ import {
|
|
|
63
56
|
startCapture,
|
|
64
57
|
waitForCompletion,
|
|
65
58
|
} from "./psmux.mjs";
|
|
66
|
-
import {
|
|
67
|
-
buildSynapseTaskSummary,
|
|
68
|
-
registerSynapseSession,
|
|
69
|
-
unregisterSynapseSession,
|
|
70
|
-
} from "./synapse-http.mjs";
|
|
71
59
|
import { createLogDashboard } from "./tui.mjs";
|
|
72
60
|
import { createWtManager } from "./wt-manager.mjs";
|
|
73
61
|
|
|
@@ -78,7 +66,6 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
|
78
66
|
/** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
|
|
79
67
|
const CLI_BRAND = {
|
|
80
68
|
codex: { emoji: "\u{26AA}", label: "Codex", ansi: "\x1b[97m" }, // ⚪ bright white (codexWhite)
|
|
81
|
-
gemini: { emoji: "\u{1F535}", label: "Gemini", ansi: "\x1b[38;5;39m" }, // 🔵 geminiBlue
|
|
82
69
|
antigravity: {
|
|
83
70
|
emoji: "\u{1F535}",
|
|
84
71
|
label: "Antigravity",
|
|
@@ -90,8 +77,6 @@ const CLI_BRAND = {
|
|
|
90
77
|
ansi: "\x1b[38;2;232;112;64m",
|
|
91
78
|
}, // 🟠 claudeOrange
|
|
92
79
|
};
|
|
93
|
-
const _ANSI_RESET = "\x1b[0m";
|
|
94
|
-
const _ANSI_DIM = "\x1b[2m";
|
|
95
80
|
|
|
96
81
|
/** 에이전트 역할명 → CLI 타입 매핑 (단일 소스: agent-map.json) */
|
|
97
82
|
const _require = createRequire(import.meta.url);
|
|
@@ -109,11 +94,6 @@ export function resolveCliType(agentOrCli) {
|
|
|
109
94
|
return AGENT_TO_CLI[agentOrCli] || agentOrCli;
|
|
110
95
|
}
|
|
111
96
|
|
|
112
|
-
function resolveHeadlessCliType(agentOrCli) {
|
|
113
|
-
const resolved = resolveCliType(agentOrCli);
|
|
114
|
-
return resolved === "gemini" ? "antigravity" : resolved;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
97
|
function shellQuote(value) {
|
|
118
98
|
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
119
99
|
}
|
|
@@ -146,7 +126,7 @@ function resolveRouteAgentForHeadless(resolvedCli, opts = {}) {
|
|
|
146
126
|
if (
|
|
147
127
|
role &&
|
|
148
128
|
VALID_ROUTE_AGENTS.has(role) &&
|
|
149
|
-
!["
|
|
129
|
+
!["antigravity", "agy"].includes(role)
|
|
150
130
|
) {
|
|
151
131
|
return role;
|
|
152
132
|
}
|
|
@@ -215,95 +195,6 @@ export function getHeadlessWorkerAgentId(sessionName, index) {
|
|
|
215
195
|
return `headless-${sessionName}-${index}`;
|
|
216
196
|
}
|
|
217
197
|
|
|
218
|
-
export function getHeadlessLeadAgentId(sessionName) {
|
|
219
|
-
return `headless-${sessionName}-lead`;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const HUB_CLI_VALUES = new Set(["codex", "gemini", "claude", "other"]);
|
|
223
|
-
const presenceOwnerByAgent = new Map();
|
|
224
|
-
function normalizeCliForHub(cli) {
|
|
225
|
-
return HUB_CLI_VALUES.has(cli) ? cli : "other";
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export async function registerHeadlessWorker(
|
|
229
|
-
sessionName,
|
|
230
|
-
index,
|
|
231
|
-
cli,
|
|
232
|
-
requestJsonFn = requestJson,
|
|
233
|
-
) {
|
|
234
|
-
const agentId = getHeadlessWorkerAgentId(sessionName, index);
|
|
235
|
-
const result = await requestJsonFn("/bridge/register", {
|
|
236
|
-
body: {
|
|
237
|
-
agent_id: agentId,
|
|
238
|
-
cli: normalizeCliForHub(cli),
|
|
239
|
-
topics: ["headless.worker"],
|
|
240
|
-
capabilities: [cli],
|
|
241
|
-
},
|
|
242
|
-
}).catch(() => null);
|
|
243
|
-
if (result?.ok && result.data?.presence_generation != null) {
|
|
244
|
-
presenceOwnerByAgent.set(agentId, {
|
|
245
|
-
presence_generation: result.data.presence_generation,
|
|
246
|
-
hub_instance_id: result.data.hub_instance_id,
|
|
247
|
-
store_fingerprint: result.data.store_fingerprint,
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
return result;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
export async function publishHeadlessResult(
|
|
254
|
-
sessionName,
|
|
255
|
-
workerId,
|
|
256
|
-
status,
|
|
257
|
-
handoff,
|
|
258
|
-
requestJsonFn = requestJson,
|
|
259
|
-
) {
|
|
260
|
-
await requestJsonFn("/bridge/publish", {
|
|
261
|
-
body: {
|
|
262
|
-
from: getHeadlessLeadAgentId(sessionName),
|
|
263
|
-
to: "topic:headless.results",
|
|
264
|
-
type: "event",
|
|
265
|
-
payload: { workerId, status, handoff },
|
|
266
|
-
},
|
|
267
|
-
}).catch(() => {});
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
export async function deregisterHeadlessWorkers(
|
|
271
|
-
sessionName,
|
|
272
|
-
workerCount,
|
|
273
|
-
requestJsonFn = requestJson,
|
|
274
|
-
) {
|
|
275
|
-
await Promise.all(
|
|
276
|
-
Array.from({ length: workerCount }, (_, index) =>
|
|
277
|
-
requestJsonFn("/bridge/deregister", {
|
|
278
|
-
body: {
|
|
279
|
-
agent_id: getHeadlessWorkerAgentId(sessionName, index),
|
|
280
|
-
...(presenceOwnerByAgent.get(
|
|
281
|
-
getHeadlessWorkerAgentId(sessionName, index),
|
|
282
|
-
) || {}),
|
|
283
|
-
},
|
|
284
|
-
})
|
|
285
|
-
.catch(() => {})
|
|
286
|
-
.finally(() =>
|
|
287
|
-
presenceOwnerByAgent.delete(
|
|
288
|
-
getHeadlessWorkerAgentId(sessionName, index),
|
|
289
|
-
),
|
|
290
|
-
),
|
|
291
|
-
),
|
|
292
|
-
);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function registerHeadlessSynapseWorker(workerId, prompt) {
|
|
296
|
-
registerSynapseSession({
|
|
297
|
-
sessionId: workerId,
|
|
298
|
-
host: "local",
|
|
299
|
-
taskSummary: buildSynapseTaskSummary(prompt),
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function unregisterHeadlessSynapseWorker(workerId) {
|
|
304
|
-
unregisterSynapseSession(workerId);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
198
|
/** MCP 프로필별 프롬프트 힌트 (tfx-route.sh resolve_mcp_policy의 경량 미러) */
|
|
308
199
|
const MCP_PROFILE_HINTS = {
|
|
309
200
|
implement:
|
|
@@ -314,19 +205,6 @@ const MCP_PROFILE_HINTS = {
|
|
|
314
205
|
docs: "Focus on documentation and explanation tasks.",
|
|
315
206
|
};
|
|
316
207
|
|
|
317
|
-
/**
|
|
318
|
-
* CLI별 헤드리스 명령 빌더
|
|
319
|
-
* @param {'codex'|'antigravity'|'claude'} cli
|
|
320
|
-
* @param {string} prompt — 실행할 프롬프트
|
|
321
|
-
* @param {string} resultFile — 결과 저장 파일 경로
|
|
322
|
-
* @param {object} [opts]
|
|
323
|
-
* @param {boolean} [opts.handoff=true]
|
|
324
|
-
* @param {string} [opts.mcp] — MCP 프로필 ("implement"|"analyze"|"review"|"docs")
|
|
325
|
-
* @param {string} [opts.contextFile] — 컨텍스트 파일 경로 (최대 32KB, UTF-8 안전 절단)
|
|
326
|
-
* @returns {string} PowerShell 명령
|
|
327
|
-
*/
|
|
328
|
-
// ── Dashboard attach args for WT ────────────────────────────────
|
|
329
|
-
|
|
330
208
|
export function buildDashboardAttachArgs(
|
|
331
209
|
sessionName,
|
|
332
210
|
layout,
|
|
@@ -348,7 +226,7 @@ export function buildDashboardAttachArgs(
|
|
|
348
226
|
|
|
349
227
|
export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
350
228
|
const { handoff = true, mcp, contextFile, model, cwd } = opts;
|
|
351
|
-
const resolvedCli =
|
|
229
|
+
const resolvedCli = resolveCliType(cli);
|
|
352
230
|
|
|
353
231
|
// contextFile 처리: 32KB(32768 bytes) 초과 시 UTF-8 안전 절단
|
|
354
232
|
let contextPrefix = "";
|
|
@@ -391,7 +269,6 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
|
391
269
|
"prompt-" + randomUUID().slice(0, 8) + ".txt",
|
|
392
270
|
).replace(/\\/g, "/");
|
|
393
271
|
writeFileSync(promptFile, fullPrompt, "utf8");
|
|
394
|
-
void IS_WINDOWS; // referenced for diagnostic guard chain below
|
|
395
272
|
|
|
396
273
|
// Codex와 Antigravity는 tfx-route.sh를 통해서만 headless 실행한다. 이 경로가
|
|
397
274
|
// disable/fallback/fail-loud 정책의 유일한 판정원이다. 여기서 같은 정책을
|
|
@@ -531,83 +408,6 @@ export function readResult(resultFile, paneId) {
|
|
|
531
408
|
return capturePsmuxPane(paneId, 30);
|
|
532
409
|
}
|
|
533
410
|
|
|
534
|
-
// ─── Stall Detection ───
|
|
535
|
-
|
|
536
|
-
/** Stall detection 기본값 (immutable) */
|
|
537
|
-
export const STALL_DEFAULTS = Object.freeze({
|
|
538
|
-
pollInterval: 5_000,
|
|
539
|
-
stallTimeout: 120_000,
|
|
540
|
-
interventionTimeout: resolveStallInterventionMs(),
|
|
541
|
-
hardCeiling: resolveHardCeilingMs(),
|
|
542
|
-
maxRestarts: 2,
|
|
543
|
-
maxInterventions: 1,
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
/** CLI pane stall 감지 에러 (STALL_EXHAUSTED | COMPLETION_TIMEOUT) */
|
|
547
|
-
export class StallError extends Error {
|
|
548
|
-
constructor(
|
|
549
|
-
message,
|
|
550
|
-
{ code = "STALL_DETECTED", category = "transient", recovery = "" } = {},
|
|
551
|
-
) {
|
|
552
|
-
super(message);
|
|
553
|
-
this.name = "StallError";
|
|
554
|
-
this.code = code;
|
|
555
|
-
this.category = category;
|
|
556
|
-
this.recovery = recovery;
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
/**
|
|
561
|
-
* Stall 모니터 팩토리 — output + resultFile mtime 하이브리드 감지
|
|
562
|
-
* @param {string} paneId
|
|
563
|
-
* @param {string} resultFile
|
|
564
|
-
* @param {{ stallTimeout: number }} config
|
|
565
|
-
* @param {{ capturePsmuxPane?: Function, statSync?: Function }} [deps]
|
|
566
|
-
* @returns {{ poll: () => { snapshot: string, mtimeChanged: boolean, stalled: boolean, elapsed: number } }}
|
|
567
|
-
*/
|
|
568
|
-
export function createStallMonitor(paneId, resultFile, config, deps = {}) {
|
|
569
|
-
const capture = deps.capturePsmuxPane || capturePsmuxPane;
|
|
570
|
-
const stat = deps.statSync || statSync;
|
|
571
|
-
let lastSnapshot = "";
|
|
572
|
-
let lastMtime = 0;
|
|
573
|
-
let lastChangeAt = Date.now();
|
|
574
|
-
|
|
575
|
-
try {
|
|
576
|
-
lastMtime = stat(resultFile).mtimeMs;
|
|
577
|
-
} catch {
|
|
578
|
-
/* not created yet */
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
return Object.freeze({
|
|
582
|
-
poll() {
|
|
583
|
-
const snapshot = capture(paneId, 50);
|
|
584
|
-
let currentMtime = 0;
|
|
585
|
-
try {
|
|
586
|
-
currentMtime = stat(resultFile).mtimeMs;
|
|
587
|
-
} catch {
|
|
588
|
-
/* ignore */
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
const outputChanged = snapshot !== lastSnapshot;
|
|
592
|
-
const mtimeChanged = currentMtime > 0 && currentMtime !== lastMtime;
|
|
593
|
-
|
|
594
|
-
if (outputChanged || mtimeChanged) {
|
|
595
|
-
lastChangeAt = Date.now();
|
|
596
|
-
lastSnapshot = snapshot;
|
|
597
|
-
if (mtimeChanged) lastMtime = currentMtime;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
const elapsed = Date.now() - lastChangeAt;
|
|
601
|
-
return Object.freeze({
|
|
602
|
-
snapshot,
|
|
603
|
-
mtimeChanged,
|
|
604
|
-
stalled: elapsed >= config.stallTimeout,
|
|
605
|
-
elapsed,
|
|
606
|
-
});
|
|
607
|
-
},
|
|
608
|
-
});
|
|
609
|
-
}
|
|
610
|
-
|
|
611
411
|
function createHeadlessIntervention(dispatch, fallback) {
|
|
612
412
|
return async (context) => {
|
|
613
413
|
if (typeof fallback === "function")
|
|
@@ -706,254 +506,6 @@ export function createActivityPollGuard({
|
|
|
706
506
|
});
|
|
707
507
|
}
|
|
708
508
|
|
|
709
|
-
/**
|
|
710
|
-
* 하이브리드 stall 감지 대기 — output 변화 + resultFile mtime 모니터링.
|
|
711
|
-
* 2분 무변화 시 pane kill → re-dispatch (최대 2회 재시작).
|
|
712
|
-
*
|
|
713
|
-
* @param {string} sessionName
|
|
714
|
-
* @param {string} paneId — 현재 pane 타겟 (예: "tfx:0.1")
|
|
715
|
-
* @param {string} resultFile — 결과 저장 파일 경로
|
|
716
|
-
* @param {object} [opts]
|
|
717
|
-
* @param {number} [opts.pollInterval=5000] — 폴링 간격 ms
|
|
718
|
-
* @param {number} [opts.stallTimeout=120000] — 무변화 stall 판정 ms
|
|
719
|
-
* @param {number} [opts.hardCeiling] — 활동과 무관한 절대 상한 ms
|
|
720
|
-
* @param {number} [opts.completionTimeout] — hardCeiling의 하위호환 alias
|
|
721
|
-
* @param {number} [opts.maxRestarts=2] — 최대 재시작 횟수
|
|
722
|
-
* @param {string} [opts.command] — re-dispatch용 원본 명령
|
|
723
|
-
* @param {string} [opts.token] — completion token
|
|
724
|
-
* @param {(snapshot: string) => void} [opts.onPoll] — 폴링 콜백
|
|
725
|
-
* @returns {Promise<{ matched: boolean, exitCode: number|null, restarts: number, stallDetected: boolean, paneId: string, token?: string, logPath?: string|null }>}
|
|
726
|
-
*/
|
|
727
|
-
export async function waitForCompletionWithStallDetect(
|
|
728
|
-
sessionName,
|
|
729
|
-
paneId,
|
|
730
|
-
resultFile,
|
|
731
|
-
opts = {},
|
|
732
|
-
) {
|
|
733
|
-
const {
|
|
734
|
-
pollInterval = 5000,
|
|
735
|
-
stallTimeout = 120000,
|
|
736
|
-
maxRestarts = 2,
|
|
737
|
-
maxInterventions = 1,
|
|
738
|
-
command,
|
|
739
|
-
token,
|
|
740
|
-
onPoll,
|
|
741
|
-
onIntervene,
|
|
742
|
-
_deps,
|
|
743
|
-
} = opts;
|
|
744
|
-
const hardCeiling =
|
|
745
|
-
opts.hardCeiling ??
|
|
746
|
-
opts.completionTimeout ??
|
|
747
|
-
(isActivityLifecycleEnabled() ? resolveHardCeilingMs() : 900_000);
|
|
748
|
-
|
|
749
|
-
// 의존성 (테스트 시 _deps로 주입 가능)
|
|
750
|
-
const deps = _deps || {};
|
|
751
|
-
const _capture = deps.capturePsmuxPane || capturePsmuxPane;
|
|
752
|
-
const _exists = deps.existsSync || existsSync;
|
|
753
|
-
const _stat = deps.statSync || statSync;
|
|
754
|
-
const _readFile = deps.readFileSync || readFileSync;
|
|
755
|
-
const _exec = deps.psmuxExec || psmuxExec;
|
|
756
|
-
const _dispatch = deps.dispatchCommand || dispatchCommand;
|
|
757
|
-
const _startCapture = deps.startCapture || startCapture;
|
|
758
|
-
|
|
759
|
-
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
760
|
-
const buildCompletionRegex = (activeToken) => {
|
|
761
|
-
const completionPatterns = [
|
|
762
|
-
activeToken
|
|
763
|
-
? `${esc("__TRIFLUX_DONE__:")}${esc(activeToken)}:(\\d+)`
|
|
764
|
-
: `${esc("__TRIFLUX_DONE__:")}\\S+:(\\d+)`,
|
|
765
|
-
activeToken
|
|
766
|
-
? `${esc("TFX_DONE_")}${esc(activeToken)}:(\\d+)`
|
|
767
|
-
: `${esc("TFX_DONE_")}\\S+:(\\d+)`,
|
|
768
|
-
];
|
|
769
|
-
return new RegExp(completionPatterns.join("|"), "m");
|
|
770
|
-
};
|
|
771
|
-
|
|
772
|
-
let restarts = 0;
|
|
773
|
-
let interventions = 0;
|
|
774
|
-
const runStartedAt = Date.now();
|
|
775
|
-
let currentPaneId = paneId;
|
|
776
|
-
let stallDetected = false;
|
|
777
|
-
let currentToken = token;
|
|
778
|
-
let currentLogPath = opts.logPath || null;
|
|
779
|
-
|
|
780
|
-
while (true) {
|
|
781
|
-
let lastOutput = "";
|
|
782
|
-
let lastMtime = 0;
|
|
783
|
-
let lastChangeAt = Date.now();
|
|
784
|
-
|
|
785
|
-
// 초기 resultFile mtime
|
|
786
|
-
try {
|
|
787
|
-
if (_exists(resultFile)) lastMtime = _stat(resultFile).mtimeMs;
|
|
788
|
-
} catch {
|
|
789
|
-
/* 무시 */
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
while (true) {
|
|
793
|
-
await new Promise((r) => setTimeout(r, pollInterval));
|
|
794
|
-
const now = Date.now();
|
|
795
|
-
|
|
796
|
-
// 절대 상한은 재시작과 활동 여부를 관통하는 유일한 wall-clock kill이다.
|
|
797
|
-
if (now - runStartedAt > hardCeiling) {
|
|
798
|
-
// Issue #118: timeout kill 전에 capture-pane 출력을 .partial 파일로 persist.
|
|
799
|
-
// resultFile(`.txt`)이 아직 생성되지 않았을 수 있으므로 readResult 가 fallback 으로 읽는다.
|
|
800
|
-
try {
|
|
801
|
-
const partialSnapshot = _capture(currentPaneId, 200);
|
|
802
|
-
if (partialSnapshot && partialSnapshot.trim().length > 0) {
|
|
803
|
-
const writer = deps.writeFileSync || writeFileSync;
|
|
804
|
-
writer(`${resultFile}.partial`, partialSnapshot, "utf8");
|
|
805
|
-
}
|
|
806
|
-
} catch {
|
|
807
|
-
/* best-effort, timeout 은 이미 확정 */
|
|
808
|
-
}
|
|
809
|
-
return {
|
|
810
|
-
matched: false,
|
|
811
|
-
exitCode: null,
|
|
812
|
-
restarts,
|
|
813
|
-
stallDetected,
|
|
814
|
-
timedOut: true,
|
|
815
|
-
timeoutReason: "hard_ceiling",
|
|
816
|
-
paneId: currentPaneId,
|
|
817
|
-
token: currentToken,
|
|
818
|
-
logPath: currentLogPath,
|
|
819
|
-
};
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// 1) capture-pane 출력 확인
|
|
823
|
-
const currentOutput = _capture(currentPaneId, 50);
|
|
824
|
-
if (onPoll) {
|
|
825
|
-
try {
|
|
826
|
-
onPoll(currentOutput);
|
|
827
|
-
} catch {
|
|
828
|
-
/* 삼킴 */
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// 2) completion 토큰 감지
|
|
833
|
-
const completionRe = buildCompletionRegex(currentToken);
|
|
834
|
-
const completionMatch = completionRe.exec(currentOutput);
|
|
835
|
-
if (completionMatch) {
|
|
836
|
-
return {
|
|
837
|
-
matched: true,
|
|
838
|
-
exitCode: Number.parseInt(
|
|
839
|
-
completionMatch.slice(1).find(Boolean) || "0",
|
|
840
|
-
10,
|
|
841
|
-
),
|
|
842
|
-
restarts,
|
|
843
|
-
stallDetected,
|
|
844
|
-
timedOut: false,
|
|
845
|
-
paneId: currentPaneId,
|
|
846
|
-
token: currentToken,
|
|
847
|
-
logPath: currentLogPath,
|
|
848
|
-
};
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
// 3) resultFile 존재 + mtime 변화 확인
|
|
852
|
-
let currentMtime = 0;
|
|
853
|
-
try {
|
|
854
|
-
if (_exists(resultFile)) currentMtime = _stat(resultFile).mtimeMs;
|
|
855
|
-
} catch {
|
|
856
|
-
/* 무시 */
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// 4) 변화 감지 → stallTimer 리셋
|
|
860
|
-
const outputChanged = currentOutput !== lastOutput;
|
|
861
|
-
const mtimeChanged = currentMtime > 0 && currentMtime !== lastMtime;
|
|
862
|
-
|
|
863
|
-
if (outputChanged || mtimeChanged) {
|
|
864
|
-
lastChangeAt = now;
|
|
865
|
-
lastOutput = currentOutput;
|
|
866
|
-
if (mtimeChanged) lastMtime = currentMtime;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
// resultFile이 갱신되고 내용이 있으면 완료로 간주
|
|
870
|
-
if (mtimeChanged && currentMtime > 0 && _exists(resultFile)) {
|
|
871
|
-
try {
|
|
872
|
-
const content = _readFile(resultFile, "utf8").trim();
|
|
873
|
-
if (content.length > 0) {
|
|
874
|
-
return {
|
|
875
|
-
matched: true,
|
|
876
|
-
exitCode: 0,
|
|
877
|
-
restarts,
|
|
878
|
-
stallDetected,
|
|
879
|
-
timedOut: false,
|
|
880
|
-
paneId: currentPaneId,
|
|
881
|
-
token: currentToken,
|
|
882
|
-
logPath: currentLogPath,
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
} catch {
|
|
886
|
-
/* 무시 */
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// 5) stall 판정
|
|
891
|
-
if (now - lastChangeAt >= stallTimeout) {
|
|
892
|
-
stallDetected = true;
|
|
893
|
-
|
|
894
|
-
if (restarts >= maxRestarts) {
|
|
895
|
-
if (onIntervene && interventions < maxInterventions) {
|
|
896
|
-
interventions += 1;
|
|
897
|
-
let handled = false;
|
|
898
|
-
try {
|
|
899
|
-
handled =
|
|
900
|
-
(await onIntervene({
|
|
901
|
-
channel: "pane",
|
|
902
|
-
sessionName,
|
|
903
|
-
paneId: currentPaneId,
|
|
904
|
-
resultFile,
|
|
905
|
-
inactiveMs: now - lastChangeAt,
|
|
906
|
-
restarts,
|
|
907
|
-
interventions,
|
|
908
|
-
})) === true;
|
|
909
|
-
} catch {
|
|
910
|
-
handled = false;
|
|
911
|
-
}
|
|
912
|
-
if (handled) {
|
|
913
|
-
lastChangeAt = Date.now();
|
|
914
|
-
continue;
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
const err = new Error("CLI가 반복적으로 멈춤. 수동 확인 필요.");
|
|
918
|
-
err.code = "STALL_EXHAUSTED";
|
|
919
|
-
err.category = "transient";
|
|
920
|
-
err.recovery = "CLI가 반복적으로 멈춤. 수동 확인 필요.";
|
|
921
|
-
err.restarts = restarts;
|
|
922
|
-
err.interventions = interventions;
|
|
923
|
-
throw err;
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
// kill pane → re-dispatch
|
|
927
|
-
try {
|
|
928
|
-
_exec(["kill-pane", "-t", currentPaneId]);
|
|
929
|
-
} catch {
|
|
930
|
-
/* 이미 종료 */
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
if (command) {
|
|
934
|
-
// 새 pane split + 동일 command re-dispatch
|
|
935
|
-
const newPaneId = _exec([
|
|
936
|
-
"split-window",
|
|
937
|
-
"-t",
|
|
938
|
-
sessionName,
|
|
939
|
-
"-P",
|
|
940
|
-
"-F",
|
|
941
|
-
"#{session_name}:#{window_index}.#{pane_index}",
|
|
942
|
-
]);
|
|
943
|
-
_startCapture(sessionName, newPaneId);
|
|
944
|
-
const redispatch = _dispatch(sessionName, newPaneId, command);
|
|
945
|
-
currentPaneId = redispatch?.paneId || newPaneId;
|
|
946
|
-
if (redispatch?.token) currentToken = redispatch.token;
|
|
947
|
-
if (redispatch?.logPath) currentLogPath = redispatch.logPath;
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
restarts++;
|
|
951
|
-
break; // inner loop 재시작 (stallTimer 리셋)
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
509
|
function createHeadlessSessionOwnership(killSession) {
|
|
958
510
|
let owned = false;
|
|
959
511
|
let ownedSessionName = "";
|
|
@@ -1021,7 +573,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
|
1021
573
|
const paneName = `worker-${i + 1}`;
|
|
1022
574
|
const displayName = resolveHeadlessDisplayName(assignment, paneName);
|
|
1023
575
|
const workerId = getHeadlessWorkerAgentId(sessionName, i);
|
|
1024
|
-
const resolvedCli =
|
|
576
|
+
const resolvedCli = resolveCliType(assignment.cli);
|
|
1025
577
|
const brand = CLI_BRAND[resolvedCli] || {
|
|
1026
578
|
emoji: "\u{25CF}",
|
|
1027
579
|
label: resolvedCli,
|
|
@@ -1047,7 +599,6 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
|
1047
599
|
} catch {
|
|
1048
600
|
/* 무시 */
|
|
1049
601
|
}
|
|
1050
|
-
await registerHeadlessWorker(sessionName, i, resolvedCli);
|
|
1051
602
|
|
|
1052
603
|
if (safeProgress)
|
|
1053
604
|
safeProgress({
|
|
@@ -1081,7 +632,6 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
|
1081
632
|
// pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
|
|
1082
633
|
if (i > 0) await new Promise((r) => setTimeout(r, 300));
|
|
1083
634
|
const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
|
|
1084
|
-
registerHeadlessSynapseWorker(workerId, assignment.prompt);
|
|
1085
635
|
|
|
1086
636
|
if (safeProgress)
|
|
1087
637
|
safeProgress({ type: "dispatched", paneName, cli: resolvedCli });
|
|
@@ -1150,7 +700,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
1150
700
|
const paneName = `worker-${i + 1}`;
|
|
1151
701
|
const displayName = resolveHeadlessDisplayName(assignment, paneName);
|
|
1152
702
|
const workerId = getHeadlessWorkerAgentId(sessionName, i);
|
|
1153
|
-
const resolvedCli =
|
|
703
|
+
const resolvedCli = resolveCliType(assignment.cli);
|
|
1154
704
|
const resultFile = join(
|
|
1155
705
|
RESULT_DIR,
|
|
1156
706
|
`${sessionName}-${paneName}.txt`,
|
|
@@ -1170,12 +720,10 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
1170
720
|
},
|
|
1171
721
|
);
|
|
1172
722
|
const scriptDir = join(RESULT_DIR, sessionName);
|
|
1173
|
-
await registerHeadlessWorker(sessionName, i, resolvedCli);
|
|
1174
723
|
const dispatch = dispatchCommand(sessionName, paneName, cmd, {
|
|
1175
724
|
scriptDir,
|
|
1176
725
|
scriptName: paneName,
|
|
1177
726
|
});
|
|
1178
|
-
registerHeadlessSynapseWorker(workerId, assignment.prompt);
|
|
1179
727
|
|
|
1180
728
|
// P1 fix: 비-progressive에서는 pane 리네임 금지 — 캡처 로그 경로가 타이틀 기반이므로
|
|
1181
729
|
// 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
|
|
@@ -1215,7 +763,7 @@ async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
|
|
|
1215
763
|
const paneName = `worker-${i + 1}`;
|
|
1216
764
|
const displayName = resolveHeadlessDisplayName(assignment, paneName);
|
|
1217
765
|
const workerId = getHeadlessWorkerAgentId(sessionName, i);
|
|
1218
|
-
const resolvedCli =
|
|
766
|
+
const resolvedCli = resolveCliType(assignment.cli);
|
|
1219
767
|
const resultFile = join(
|
|
1220
768
|
RESULT_DIR,
|
|
1221
769
|
`${sessionName}-${paneName}.txt`,
|
|
@@ -1287,8 +835,6 @@ async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
|
|
|
1287
835
|
sessionId: dispatched.sessionId,
|
|
1288
836
|
sessionProjectionPath: dispatched.sessionProjectionPath,
|
|
1289
837
|
});
|
|
1290
|
-
await registerHeadlessWorker(sessionName, i, resolvedCli);
|
|
1291
|
-
registerHeadlessSynapseWorker(workerId, assignment.prompt);
|
|
1292
838
|
if (safeProgress) {
|
|
1293
839
|
safeProgress({ type: "dispatched", paneName, cli: resolvedCli });
|
|
1294
840
|
}
|
|
@@ -1348,7 +894,6 @@ async function awaitAll(
|
|
|
1348
894
|
timeoutSec,
|
|
1349
895
|
safeProgress,
|
|
1350
896
|
progressIntervalSec,
|
|
1351
|
-
stallOpts,
|
|
1352
897
|
lifecycle,
|
|
1353
898
|
) {
|
|
1354
899
|
// 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
|
|
@@ -1375,73 +920,7 @@ async function awaitAll(
|
|
|
1375
920
|
}
|
|
1376
921
|
|
|
1377
922
|
let completion;
|
|
1378
|
-
if (
|
|
1379
|
-
// 하이브리드 stall detection 모드
|
|
1380
|
-
try {
|
|
1381
|
-
const stallPollCb =
|
|
1382
|
-
safeProgress && progressIntervalSec > 0
|
|
1383
|
-
? (snapshot) => {
|
|
1384
|
-
try {
|
|
1385
|
-
safeProgress({
|
|
1386
|
-
type: "progress",
|
|
1387
|
-
paneName: d.paneName,
|
|
1388
|
-
displayName: d.displayName,
|
|
1389
|
-
cli: d.cli,
|
|
1390
|
-
snapshot: snapshot.split("\n").slice(-15).join("\n"),
|
|
1391
|
-
});
|
|
1392
|
-
} catch {
|
|
1393
|
-
/* 삼킴 */
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
: undefined;
|
|
1397
|
-
|
|
1398
|
-
const stallResult = await waitForCompletionWithStallDetect(
|
|
1399
|
-
sessionName,
|
|
1400
|
-
d.paneId || d.paneName,
|
|
1401
|
-
d.resultFile,
|
|
1402
|
-
{
|
|
1403
|
-
pollInterval: stallOpts.pollInterval,
|
|
1404
|
-
stallTimeout: stallOpts.stallTimeout,
|
|
1405
|
-
hardCeiling: lifecycle?.enabled
|
|
1406
|
-
? (stallOpts.hardCeiling ??
|
|
1407
|
-
stallOpts.completionTimeout ??
|
|
1408
|
-
lifecycle.hardCeilingMs)
|
|
1409
|
-
: (stallOpts.completionTimeout ?? timeoutSec * 1000),
|
|
1410
|
-
maxRestarts: stallOpts.maxRestarts,
|
|
1411
|
-
maxInterventions: stallOpts.maxInterventions,
|
|
1412
|
-
command: d.command,
|
|
1413
|
-
token: d.token,
|
|
1414
|
-
onPoll: stallPollCb,
|
|
1415
|
-
onIntervene: lifecycle?.enabled
|
|
1416
|
-
? createHeadlessIntervention(
|
|
1417
|
-
d,
|
|
1418
|
-
stallOpts.onIntervene ?? lifecycle.onIntervene,
|
|
1419
|
-
)
|
|
1420
|
-
: undefined,
|
|
1421
|
-
},
|
|
1422
|
-
);
|
|
1423
|
-
if (stallResult.paneId) d.paneId = stallResult.paneId;
|
|
1424
|
-
if (stallResult.token) d.token = stallResult.token;
|
|
1425
|
-
if (stallResult.logPath) d.logPath = stallResult.logPath;
|
|
1426
|
-
completion = {
|
|
1427
|
-
matched: stallResult.matched,
|
|
1428
|
-
exitCode: stallResult.exitCode,
|
|
1429
|
-
stallDetected: stallResult.stallDetected,
|
|
1430
|
-
restarts: stallResult.restarts,
|
|
1431
|
-
};
|
|
1432
|
-
} catch (stallErr) {
|
|
1433
|
-
if (stallErr.code === "STALL_EXHAUSTED") {
|
|
1434
|
-
completion = {
|
|
1435
|
-
matched: false,
|
|
1436
|
-
exitCode: null,
|
|
1437
|
-
stallExhausted: true,
|
|
1438
|
-
restarts: stallErr.restarts,
|
|
1439
|
-
};
|
|
1440
|
-
} else {
|
|
1441
|
-
throw stallErr;
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
|
-
} else if (lifecycle?.enabled) {
|
|
923
|
+
if (lifecycle?.enabled) {
|
|
1445
924
|
if (d.logPath) pollOpts.logPath = d.logPath;
|
|
1446
925
|
const activity = createActivityLifecycle({
|
|
1447
926
|
interventionMs: lifecycle.interventionMs,
|
|
@@ -1501,7 +980,6 @@ async function awaitAll(
|
|
|
1501
980
|
}
|
|
1502
981
|
|
|
1503
982
|
const output = readResult(d.resultFile, d.paneId);
|
|
1504
|
-
unregisterHeadlessSynapseWorker(d.workerId);
|
|
1505
983
|
|
|
1506
984
|
if (safeProgress) {
|
|
1507
985
|
safeProgress({
|
|
@@ -1512,8 +990,6 @@ async function awaitAll(
|
|
|
1512
990
|
matched: completion.matched,
|
|
1513
991
|
exitCode: completion.exitCode,
|
|
1514
992
|
sessionDead: completion.sessionDead || false,
|
|
1515
|
-
stallDetected: completion.stallDetected || false,
|
|
1516
|
-
stallExhausted: completion.stallExhausted || false,
|
|
1517
993
|
});
|
|
1518
994
|
}
|
|
1519
995
|
|
|
@@ -1667,7 +1143,6 @@ async function awaitAllDaemon(
|
|
|
1667
1143
|
lifecycle,
|
|
1668
1144
|
);
|
|
1669
1145
|
const output = readResult(d.resultFile, d.paneId);
|
|
1670
|
-
unregisterHeadlessSynapseWorker(d.workerId);
|
|
1671
1146
|
|
|
1672
1147
|
if (safeProgress) {
|
|
1673
1148
|
safeProgress({
|
|
@@ -1678,8 +1153,6 @@ async function awaitAllDaemon(
|
|
|
1678
1153
|
matched: completion.matched,
|
|
1679
1154
|
exitCode: completion.exitCode,
|
|
1680
1155
|
sessionDead: false,
|
|
1681
|
-
stallDetected: false,
|
|
1682
|
-
stallExhausted: false,
|
|
1683
1156
|
});
|
|
1684
1157
|
}
|
|
1685
1158
|
|
|
@@ -1693,7 +1166,7 @@ async function awaitAllDaemon(
|
|
|
1693
1166
|
* @param {Array<{d, completion, output}>} results
|
|
1694
1167
|
* @returns {Array}
|
|
1695
1168
|
*/
|
|
1696
|
-
async function collectResults(
|
|
1169
|
+
async function collectResults(results) {
|
|
1697
1170
|
// handoff 파이프라인: parse → validate → format (각 워커 결과에 적용)
|
|
1698
1171
|
return await Promise.all(
|
|
1699
1172
|
results.map(async ({ d, completion, output }) => {
|
|
@@ -1715,18 +1188,6 @@ async function collectResults(sessionName, results) {
|
|
|
1715
1188
|
};
|
|
1716
1189
|
handoffResult.formatted = formatHandoffForLead(handoffResult.handoff);
|
|
1717
1190
|
}
|
|
1718
|
-
const status =
|
|
1719
|
-
handoffResult.handoff?.status ||
|
|
1720
|
-
(completion.matched && completion.exitCode === 0
|
|
1721
|
-
? "completed"
|
|
1722
|
-
: "failed");
|
|
1723
|
-
await publishHeadlessResult(
|
|
1724
|
-
sessionName,
|
|
1725
|
-
d.workerId,
|
|
1726
|
-
status,
|
|
1727
|
-
handoffResult.handoff,
|
|
1728
|
-
);
|
|
1729
|
-
|
|
1730
1191
|
return {
|
|
1731
1192
|
cli: d.cli,
|
|
1732
1193
|
paneName: d.paneName,
|
|
@@ -1791,9 +1252,8 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1791
1252
|
autoAttach = false,
|
|
1792
1253
|
dashboard = false,
|
|
1793
1254
|
dashboardLayout = "single",
|
|
1794
|
-
stallDetect,
|
|
1795
1255
|
nativeBridge = false,
|
|
1796
|
-
nativeBridgeMode = "
|
|
1256
|
+
nativeBridgeMode = "agents",
|
|
1797
1257
|
onIntervene,
|
|
1798
1258
|
leadPane = null,
|
|
1799
1259
|
leadTmux = null,
|
|
@@ -1817,67 +1277,11 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1817
1277
|
return { sessionName, results: [], sessionOwnership };
|
|
1818
1278
|
}
|
|
1819
1279
|
|
|
1820
|
-
let nativeBridgeHandle = null;
|
|
1821
1280
|
let daemonDispatches = [];
|
|
1822
1281
|
let runCompleted = false;
|
|
1823
1282
|
let completedResults = [];
|
|
1824
1283
|
let runFailed = false;
|
|
1825
1284
|
let runError;
|
|
1826
|
-
if (nativeBridge && nativeBridgeMode === "claude-wrapper") {
|
|
1827
|
-
throw new Error(
|
|
1828
|
-
"[headless] --native-bridge-mode claude-wrapper is reserved and not implemented yet",
|
|
1829
|
-
);
|
|
1830
|
-
}
|
|
1831
|
-
if (
|
|
1832
|
-
nativeBridge &&
|
|
1833
|
-
(nativeBridgeMode === "roster" || nativeBridgeMode === "interactive-attach")
|
|
1834
|
-
) {
|
|
1835
|
-
nativeBridgeHandle = await startClaudeNativeBridge({
|
|
1836
|
-
sessionName,
|
|
1837
|
-
assignments: normalizedAssignments,
|
|
1838
|
-
cwd: process.cwd(),
|
|
1839
|
-
workerType:
|
|
1840
|
-
nativeBridgeMode === "interactive-attach" ? "interactive" : "headless",
|
|
1841
|
-
onKill() {
|
|
1842
|
-
sessionOwnership.release();
|
|
1843
|
-
},
|
|
1844
|
-
});
|
|
1845
|
-
process.stderr.write(
|
|
1846
|
-
`[headless] Claude native bridge roster written: ${nativeBridgeHandle.rosterPath}\n`,
|
|
1847
|
-
);
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
// Hub version skew pre-flight (fail-open, best-effort)
|
|
1851
|
-
requestJson("/status", { method: "GET", timeoutMs: 500 })
|
|
1852
|
-
.then((status) => {
|
|
1853
|
-
const hubRate = status?.spawn_trace?.max_per_sec;
|
|
1854
|
-
const localRate = getMaxSpawnPerSec();
|
|
1855
|
-
if (typeof hubRate === "number" && hubRate !== localRate) {
|
|
1856
|
-
console.warn(
|
|
1857
|
-
`[headless] Hub version skew detected: hub spawn rate=${hubRate}/s, local=${localRate}/s. Restart hub to sync.`,
|
|
1858
|
-
);
|
|
1859
|
-
}
|
|
1860
|
-
})
|
|
1861
|
-
.catch(() => {});
|
|
1862
|
-
|
|
1863
|
-
// Synapse: 세션 registration (fire-and-forget, hub 미응답 시 무시)
|
|
1864
|
-
const synapseIds = normalizedAssignments.map(
|
|
1865
|
-
(_, i) => `${sessionName}-worker-${i + 1}`,
|
|
1866
|
-
);
|
|
1867
|
-
for (let i = 0; i < normalizedAssignments.length; i++) {
|
|
1868
|
-
const a = normalizedAssignments[i];
|
|
1869
|
-
requestJson("/synapse/register", {
|
|
1870
|
-
method: "POST",
|
|
1871
|
-
body: {
|
|
1872
|
-
sessionId: synapseIds[i],
|
|
1873
|
-
host: "local",
|
|
1874
|
-
taskSummary: String(a.prompt || "").slice(0, 100),
|
|
1875
|
-
isRemote: false,
|
|
1876
|
-
},
|
|
1877
|
-
timeoutMs: 1000,
|
|
1878
|
-
}).catch(() => {});
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
1285
|
// in-process TUI: dashboard=true이고 stdout이 TTY일 때 직접 구동
|
|
1882
1286
|
let tui = null;
|
|
1883
1287
|
const resolvedLayout = resolveDashboardLayout(
|
|
@@ -1897,7 +1301,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1897
1301
|
const a = normalizedAssignments[i];
|
|
1898
1302
|
const paneName = `worker-${i + 1}`;
|
|
1899
1303
|
tui.updateWorker(paneName, {
|
|
1900
|
-
cli:
|
|
1304
|
+
cli: resolveCliType(a.cli || "codex"),
|
|
1901
1305
|
displayName: resolveHeadlessDisplayName(a, paneName),
|
|
1902
1306
|
role: a.role || "",
|
|
1903
1307
|
status: "pending",
|
|
@@ -1905,11 +1309,10 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1905
1309
|
});
|
|
1906
1310
|
}
|
|
1907
1311
|
} else if (dashboard) {
|
|
1908
|
-
//
|
|
1909
|
-
// stderr 로만 안내 (stdout 은 readHeadlessResult 파싱 대상이라 건드리지 않는다).
|
|
1312
|
+
// stdout은 결과 파싱 대상이므로 안내는 stderr로 보낸다.
|
|
1910
1313
|
const logDir = join(tmpdir(), "tfx-headless");
|
|
1911
1314
|
process.stderr.write(
|
|
1912
|
-
`\n⚠
|
|
1315
|
+
`\n⚠ stdout is not a TTY; dashboard is skipped.\n` +
|
|
1913
1316
|
` Session is running in background. Worker logs:\n` +
|
|
1914
1317
|
` ${logDir}${IS_WINDOWS ? "\\" : "/"}${sessionName}-worker-N.txt\n` +
|
|
1915
1318
|
` ${logDir}${IS_WINDOWS ? "\\" : "/"}${sessionName}-worker-N.txt.err\n\n`,
|
|
@@ -1951,62 +1354,9 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1951
1354
|
}
|
|
1952
1355
|
}
|
|
1953
1356
|
|
|
1954
|
-
// Synapse heartbeat: progress 이벤트마다 해당 워커의 세션 갱신
|
|
1955
|
-
const feedSynapse = (event) => {
|
|
1956
|
-
if (!event?.paneName) return;
|
|
1957
|
-
const match = event.paneName.match(/worker-(\d+)/);
|
|
1958
|
-
if (!match) return;
|
|
1959
|
-
const idx = parseInt(match[1], 10) - 1;
|
|
1960
|
-
const sid = synapseIds[idx];
|
|
1961
|
-
if (!sid) return;
|
|
1962
|
-
requestJson("/synapse/heartbeat", {
|
|
1963
|
-
method: "POST",
|
|
1964
|
-
body: {
|
|
1965
|
-
sessionId: sid,
|
|
1966
|
-
partial: { taskSummary: (event.snapshot || "").slice(0, 100) },
|
|
1967
|
-
},
|
|
1968
|
-
timeoutMs: 500,
|
|
1969
|
-
}).catch(() => {});
|
|
1970
|
-
};
|
|
1971
|
-
|
|
1972
|
-
const HUB_ACTIVITY_MIN_INTERVAL_MS = 30_000;
|
|
1973
|
-
const lastHubBeatByWorker = new Map();
|
|
1974
|
-
const feedHubActivity = (event) => {
|
|
1975
|
-
if (event?.type !== "progress" || !event.paneName) return;
|
|
1976
|
-
const match = event.paneName.match(/worker-(\d+)/);
|
|
1977
|
-
if (!match) return;
|
|
1978
|
-
const idx = parseInt(match[1], 10) - 1;
|
|
1979
|
-
const agentId = getHeadlessWorkerAgentId(sessionName, idx);
|
|
1980
|
-
const nowMs = Date.now();
|
|
1981
|
-
if (
|
|
1982
|
-
(lastHubBeatByWorker.get(agentId) || 0) + HUB_ACTIVITY_MIN_INTERVAL_MS >
|
|
1983
|
-
nowMs
|
|
1984
|
-
)
|
|
1985
|
-
return;
|
|
1986
|
-
lastHubBeatByWorker.set(agentId, nowMs);
|
|
1987
|
-
requestJson("/bridge/heartbeat", {
|
|
1988
|
-
method: "POST",
|
|
1989
|
-
body: {
|
|
1990
|
-
agent_id: agentId,
|
|
1991
|
-
...(presenceOwnerByAgent.get(agentId) || {}),
|
|
1992
|
-
},
|
|
1993
|
-
timeoutMs: 500,
|
|
1994
|
-
}).catch(() => {});
|
|
1995
|
-
const assignJobId = normalizedAssignments[idx]?.assignJobId;
|
|
1996
|
-
if (assignJobId)
|
|
1997
|
-
requestJson("/bridge/assign/result", {
|
|
1998
|
-
method: "POST",
|
|
1999
|
-
body: { job_id: assignJobId, worker_agent: agentId, status: "running" },
|
|
2000
|
-
timeoutMs: 500,
|
|
2001
|
-
}).catch(() => {});
|
|
2002
|
-
};
|
|
2003
|
-
|
|
2004
1357
|
// onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
|
|
2005
1358
|
const combinedProgress = (event) => {
|
|
2006
|
-
nativeBridgeHandle?.handleProgress(event);
|
|
2007
1359
|
feedTui(event);
|
|
2008
|
-
feedSynapse(event);
|
|
2009
|
-
feedHubActivity(event);
|
|
2010
1360
|
if (onProgress) {
|
|
2011
1361
|
try {
|
|
2012
1362
|
onProgress(event);
|
|
@@ -2097,11 +1447,9 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
2097
1447
|
timeoutSec,
|
|
2098
1448
|
safeProgress,
|
|
2099
1449
|
progressIntervalSec,
|
|
2100
|
-
stallDetect,
|
|
2101
1450
|
lifecycle,
|
|
2102
1451
|
);
|
|
2103
|
-
const collected = await collectResults(
|
|
2104
|
-
for (const result of collected) nativeBridgeHandle?.completeWorker(result);
|
|
1452
|
+
const collected = await collectResults(results);
|
|
2105
1453
|
|
|
2106
1454
|
// 완료 시 TUI에 최종 상태 반영 후 닫기
|
|
2107
1455
|
if (tui) {
|
|
@@ -2135,15 +1483,6 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
2135
1483
|
|
|
2136
1484
|
if (!runCompleted) sessionOwnership.release();
|
|
2137
1485
|
try {
|
|
2138
|
-
// Synapse: 세션 unregister (fire-and-forget)
|
|
2139
|
-
for (const sid of synapseIds) {
|
|
2140
|
-
requestJson("/synapse/unregister", {
|
|
2141
|
-
method: "POST",
|
|
2142
|
-
body: { sessionId: sid },
|
|
2143
|
-
timeoutMs: 1000,
|
|
2144
|
-
}).catch(() => {});
|
|
2145
|
-
}
|
|
2146
|
-
if (nativeBridgeHandle) await nativeBridgeHandle.close();
|
|
2147
1486
|
if (daemonDispatches.length > 0) {
|
|
2148
1487
|
await cleanupDaemonDispatches(daemonDispatches);
|
|
2149
1488
|
}
|
|
@@ -2182,16 +1521,7 @@ export async function runHeadlessWithCleanup(assignments, opts = {}) {
|
|
|
2182
1521
|
_sessionOwnership: sessionOwnership,
|
|
2183
1522
|
});
|
|
2184
1523
|
} finally {
|
|
2185
|
-
|
|
2186
|
-
for (let index = 0; index < assignments.length; index++) {
|
|
2187
|
-
unregisterHeadlessSynapseWorker(
|
|
2188
|
-
getHeadlessWorkerAgentId(sessionName, index),
|
|
2189
|
-
);
|
|
2190
|
-
}
|
|
2191
|
-
await deregisterHeadlessWorkers(sessionName, assignments.length);
|
|
2192
|
-
} finally {
|
|
2193
|
-
sessionOwnership.release();
|
|
2194
|
-
}
|
|
1524
|
+
sessionOwnership.release();
|
|
2195
1525
|
// WT split pane은 psmux 종료 시 셸이 끝나면서 자동으로 닫힘
|
|
2196
1526
|
// 수동 close-pane 불필요 (레이스 컨디션으로 WT 에러 발생)
|
|
2197
1527
|
}
|
|
@@ -2233,71 +1563,6 @@ export function applyTrifluxTheme(sessionName) {
|
|
|
2233
1563
|
}
|
|
2234
1564
|
}
|
|
2235
1565
|
|
|
2236
|
-
/**
|
|
2237
|
-
* Windows Terminal에 triflux 프로필을 자동 생성/갱신한다.
|
|
2238
|
-
* 반투명 + 비포커스 시 더 투명 + Catppuccin 테마.
|
|
2239
|
-
* @returns {boolean} 성공 여부
|
|
2240
|
-
*/
|
|
2241
|
-
|
|
2242
|
-
/**
|
|
2243
|
-
* WT 기본 프로필의 폰트 크기를 읽는다.
|
|
2244
|
-
* @returns {number} 기본 폰트 크기 (못 읽으면 12)
|
|
2245
|
-
*/
|
|
2246
|
-
function _getWtDefaultFontSize() {
|
|
2247
|
-
const settingsPaths = [
|
|
2248
|
-
join(
|
|
2249
|
-
process.env.LOCALAPPDATA || "",
|
|
2250
|
-
"Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json",
|
|
2251
|
-
),
|
|
2252
|
-
join(
|
|
2253
|
-
process.env.LOCALAPPDATA || "",
|
|
2254
|
-
"Microsoft/Windows Terminal/settings.json",
|
|
2255
|
-
),
|
|
2256
|
-
];
|
|
2257
|
-
for (const p of settingsPaths) {
|
|
2258
|
-
if (!existsSync(p)) continue;
|
|
2259
|
-
try {
|
|
2260
|
-
const settings = JSON.parse(
|
|
2261
|
-
readFileSync(p, "utf8").replace(/^\s*\/\/.*$/gm, ""),
|
|
2262
|
-
);
|
|
2263
|
-
// 기본 프로필 or 첫 프로필의 폰트
|
|
2264
|
-
const defaultGuid = settings.defaultProfile;
|
|
2265
|
-
const profiles = settings.profiles?.list || [];
|
|
2266
|
-
const defaultProfile =
|
|
2267
|
-
profiles.find((pr) => pr.guid === defaultGuid) || profiles[0];
|
|
2268
|
-
return (
|
|
2269
|
-
defaultProfile?.font?.size ||
|
|
2270
|
-
settings.profiles?.defaults?.font?.size ||
|
|
2271
|
-
12
|
|
2272
|
-
);
|
|
2273
|
-
} catch {
|
|
2274
|
-
/* 다음 */
|
|
2275
|
-
}
|
|
2276
|
-
}
|
|
2277
|
-
return 12;
|
|
2278
|
-
}
|
|
2279
|
-
|
|
2280
|
-
/**
|
|
2281
|
-
* 파일을 원자적으로 쓴다 — 임시 파일에 먼저 기록 후 rename으로 교체.
|
|
2282
|
-
* 프로세스가 쓰기 도중 충돌해도 원본 파일이 손상되지 않는다.
|
|
2283
|
-
* @param {string} filePath — 대상 파일 경로
|
|
2284
|
-
* @param {string} data — 쓸 내용
|
|
2285
|
-
*/
|
|
2286
|
-
function _atomicWriteSync(filePath, data) {
|
|
2287
|
-
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
2288
|
-
try {
|
|
2289
|
-
writeFileSync(tmpPath, data, "utf8");
|
|
2290
|
-
renameSync(tmpPath, filePath);
|
|
2291
|
-
} catch (err) {
|
|
2292
|
-
try {
|
|
2293
|
-
writeFileSync(tmpPath.replace(/\.tmp$/, ".tmp.del"), "");
|
|
2294
|
-
} catch {
|
|
2295
|
-
/* 무시 */
|
|
2296
|
-
}
|
|
2297
|
-
throw err;
|
|
2298
|
-
}
|
|
2299
|
-
}
|
|
2300
|
-
|
|
2301
1566
|
function sanitizeSessionName(value) {
|
|
2302
1567
|
return String(value || "").replace(/[^a-zA-Z0-9_-]/g, "") || "tfx-session";
|
|
2303
1568
|
}
|
|
@@ -2876,12 +2141,6 @@ export async function runHeadlessInteractive(
|
|
|
2876
2141
|
kill() {
|
|
2877
2142
|
if (this._killed) return;
|
|
2878
2143
|
this._killed = true;
|
|
2879
|
-
for (let index = 0; index < assignments.length; index++) {
|
|
2880
|
-
unregisterHeadlessSynapseWorker(
|
|
2881
|
-
getHeadlessWorkerAgentId(sessionName, index),
|
|
2882
|
-
);
|
|
2883
|
-
}
|
|
2884
|
-
void deregisterHeadlessWorkers(sessionName, assignments.length);
|
|
2885
2144
|
sessionOwnership.release();
|
|
2886
2145
|
// attach pane/tab은 psmux 종료 → attach client 종료 → 자동 닫힘
|
|
2887
2146
|
// 수동 close-pane 불필요 (레이스 컨디션으로 WT 0x80070002 에러 발생)
|