@triflux/remote 10.50.2 → 10.52.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 -20
- package/cto/collect.mjs +1 -200
- package/cto/current.schema.json +2 -23
- package/cto/hygiene.mjs +2 -61
- package/cto/lake-root.mjs +0 -56
- package/cto/status.mjs +5 -187
- package/hub/index.mjs +0 -17
- package/hub/team/agent-map.json +0 -1
- package/hub/team/agents-row-attach.py +120 -0
- package/hub/team/agents-row.mjs +200 -0
- package/hub/team/ansi.mjs +1 -35
- package/hub/team/backend.mjs +9 -145
- package/hub/team/claude-daemon-control.mjs +1 -2
- package/hub/team/claude-session-projection.mjs +5 -73
- 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 +2 -39
- 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 +42 -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 +2 -7
- 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/runtime-mode.mjs +27 -98
- package/hub/team/cli/services/state-store.mjs +5 -20
- package/hub/team/handoff.mjs +3 -34
- package/hub/team/headless.mjs +108 -1296
- package/hub/team/orchestrator.mjs +4 -30
- package/hub/team/pane.mjs +6 -28
- package/hub/team/psmux.mjs +3 -34
- package/hub/team/remote-session.mjs +1 -2
- package/hub/team/session-context.mjs +1 -1
- package/hub/team/session.mjs +7 -91
- package/hub/team/tui-core.mjs +1 -1
- package/hub/team/tui-viewer.mjs +11 -58
- package/hub/team/tui.mjs +8 -306
- package/hub/team/uds-orchestrator.mjs +2 -580
- package/hub/team/wt-manager.mjs +2 -2
- package/hub/workers/factory.mjs +6 -84
- package/hub/workers/interface.mjs +1 -7
- package/hub/workers/lib/jsonrpc-core.mjs +11 -0
- package/hub/workers/lib/jsonrpc-ws-uds.mjs +5 -8
- package/package.json +3 -8
- package/scripts/lib/agent-route-policy.mjs +143 -62
- package/scripts/lib/cli-claude.mjs +0 -14
- package/scripts/lib/env-probe.mjs +2 -131
- package/scripts/lib/gemini-profiles.mjs +2 -69
- package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
- package/scripts/lib/machine-profile.mjs +3 -17
- package/scripts/lib/mcp-filter.mjs +109 -53
- package/scripts/lib/mcp-guard-engine.mjs +40 -98
- 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/hub-lifecycle.mjs +0 -248
- package/hub/pipe.mjs +0 -825
- package/hub/promote-penalties.mjs +0 -138
- package/hub/public/dashboard.html +0 -416
- package/hub/schema.sql +0 -209
- package/hub/server.mjs +0 -3048
- package/hub/store-adapter.mjs +0 -182
- package/hub/store.mjs +0 -1736
- 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/hub-client.mjs +0 -264
- 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-registry.mjs +0 -121
- package/hub/team/conductor.mjs +0 -1529
- package/hub/team/consensus-meta.mjs +0 -176
- package/hub/team/cto-auto-collect.mjs +0 -115
- 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/lead-control.mjs +0 -191
- 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/session-sync.mjs +0 -316
- package/hub/team/swarm-cli.mjs +0 -390
- package/hub/team/swarm-hypervisor.mjs +0 -2206
- package/hub/team/swarm-intent.mjs +0 -216
- 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/synapse-cli.mjs +0 -458
- package/hub/team/synapse-http.mjs +0 -311
- package/hub/team/synapse-registry.mjs +0 -618
- package/hub/team/tui-remote-adapter.mjs +0 -377
- package/hub/team/tui-synapse.mjs +0 -223
- package/hub/team/worker-completion-validator.mjs +0 -79
- package/hub/team/worker-sandbox.mjs +0 -125
- package/hub/team/worktree-lifecycle.mjs +0 -639
- package/hub/team/wt-templates.json +0 -43
- package/hub/tools.mjs +0 -900
- package/hub/workers/antigravity-route-worker.mjs +0 -179
- package/hub/workers/codex-app-server-worker.mjs +0 -1067
- package/hub/workers/delegator-mcp.mjs +0 -1098
- package/hub/workers/lib/jsonrpc-stdio.mjs +0 -204
- package/scripts/lib/claudemd-scanner.mjs +0 -223
- package/scripts/lib/context.mjs +0 -67
- package/scripts/lib/dynamic-route-cli.mjs +0 -107
- package/scripts/lib/handoff.mjs +0 -187
- package/scripts/lib/logger.mjs +0 -122
- package/scripts/lib/stealth-fetch.mjs +0 -176
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,20 +7,16 @@ import {
|
|
|
10
7
|
existsSync,
|
|
11
8
|
mkdirSync,
|
|
12
9
|
readFileSync,
|
|
13
|
-
renameSync,
|
|
14
10
|
rmSync,
|
|
15
11
|
statSync,
|
|
16
12
|
writeFileSync,
|
|
17
13
|
} from "node:fs";
|
|
18
14
|
import { createRequire } from "node:module";
|
|
19
|
-
import net from "node:net";
|
|
20
15
|
import { tmpdir } from "node:os";
|
|
21
16
|
import { dirname, join, resolve } from "node:path";
|
|
22
17
|
import { fileURLToPath } from "node:url";
|
|
23
18
|
import { resolveNestedCodexAgentProfile } from "../../scripts/lib/agent-route-policy.mjs";
|
|
24
|
-
import { requestJson } from "@triflux/core/hub/bridge.mjs";
|
|
25
19
|
import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
|
|
26
|
-
import { getMaxSpawnPerSec } from "@triflux/core/hub/lib/spawn-trace.mjs";
|
|
27
20
|
import {
|
|
28
21
|
createActivityLifecycle,
|
|
29
22
|
isActivityLifecycleEnabled,
|
|
@@ -31,27 +24,15 @@ import {
|
|
|
31
24
|
resolveStallInterventionMs,
|
|
32
25
|
} from "@triflux/core/hub/lib/worker-lifecycle.mjs";
|
|
33
26
|
import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
|
|
27
|
+
import { createAgentsRowOwnership, openAgentsRow } from "./agents-row.mjs";
|
|
34
28
|
import { getBackend } from "./backend.mjs";
|
|
35
|
-
import {
|
|
36
|
-
buildDaemonControlAuth,
|
|
37
|
-
buildDaemonExecDispatchPayload,
|
|
38
|
-
deriveClaudeDaemonPaths as deriveClaudeControlPaths,
|
|
39
|
-
dispatchClaudeDaemonJob,
|
|
40
|
-
killDaemonJob,
|
|
41
|
-
sendKillBySessionId,
|
|
42
|
-
} from "./claude-daemon-control.mjs";
|
|
43
|
-
import { startClaudeNativeBridge } from "./claude-native-bridge.mjs";
|
|
44
|
-
import { removeClaudeSessionProjection } from "./claude-session-projection.mjs";
|
|
45
29
|
import { resolveDashboardLayout } from "./dashboard-layout.mjs";
|
|
46
30
|
import {
|
|
47
31
|
formatHandoffForLead,
|
|
48
32
|
HANDOFF_INSTRUCTION_SHORT,
|
|
49
33
|
processHandoff,
|
|
50
34
|
} from "./handoff.mjs";
|
|
51
|
-
import {
|
|
52
|
-
createFileActivitySource,
|
|
53
|
-
createInterventionLadder,
|
|
54
|
-
} from "./intervention.mjs";
|
|
35
|
+
import { createInterventionLadder } from "./intervention.mjs";
|
|
55
36
|
import {
|
|
56
37
|
capturePsmuxPane,
|
|
57
38
|
createPsmuxSession,
|
|
@@ -59,26 +40,18 @@ import {
|
|
|
59
40
|
killPsmuxSession,
|
|
60
41
|
psmuxExec,
|
|
61
42
|
psmuxSessionExists,
|
|
62
|
-
sendKeysToPane,
|
|
63
43
|
startCapture,
|
|
64
44
|
waitForCompletion,
|
|
65
45
|
} from "./psmux.mjs";
|
|
66
|
-
import {
|
|
67
|
-
buildSynapseTaskSummary,
|
|
68
|
-
registerSynapseSession,
|
|
69
|
-
unregisterSynapseSession,
|
|
70
|
-
} from "./synapse-http.mjs";
|
|
71
46
|
import { createLogDashboard } from "./tui.mjs";
|
|
72
47
|
import { createWtManager } from "./wt-manager.mjs";
|
|
73
48
|
|
|
74
49
|
const RESULT_DIR = join(tmpdir(), "tfx-headless");
|
|
75
|
-
const DAEMON_COMPLETION_PREFIX = "__TFX_HEADLESS_DONE__:";
|
|
76
50
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
77
51
|
|
|
78
52
|
/** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
|
|
79
53
|
const CLI_BRAND = {
|
|
80
54
|
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
55
|
antigravity: {
|
|
83
56
|
emoji: "\u{1F535}",
|
|
84
57
|
label: "Antigravity",
|
|
@@ -90,8 +63,6 @@ const CLI_BRAND = {
|
|
|
90
63
|
ansi: "\x1b[38;2;232;112;64m",
|
|
91
64
|
}, // 🟠 claudeOrange
|
|
92
65
|
};
|
|
93
|
-
const _ANSI_RESET = "\x1b[0m";
|
|
94
|
-
const _ANSI_DIM = "\x1b[2m";
|
|
95
66
|
|
|
96
67
|
/** 에이전트 역할명 → CLI 타입 매핑 (단일 소스: agent-map.json) */
|
|
97
68
|
const _require = createRequire(import.meta.url);
|
|
@@ -109,11 +80,6 @@ export function resolveCliType(agentOrCli) {
|
|
|
109
80
|
return AGENT_TO_CLI[agentOrCli] || agentOrCli;
|
|
110
81
|
}
|
|
111
82
|
|
|
112
|
-
function resolveHeadlessCliType(agentOrCli) {
|
|
113
|
-
const resolved = resolveCliType(agentOrCli);
|
|
114
|
-
return resolved === "gemini" ? "antigravity" : resolved;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
83
|
function shellQuote(value) {
|
|
118
84
|
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
119
85
|
}
|
|
@@ -146,7 +112,7 @@ function resolveRouteAgentForHeadless(resolvedCli, opts = {}) {
|
|
|
146
112
|
if (
|
|
147
113
|
role &&
|
|
148
114
|
VALID_ROUTE_AGENTS.has(role) &&
|
|
149
|
-
!["
|
|
115
|
+
!["antigravity", "agy"].includes(role)
|
|
150
116
|
) {
|
|
151
117
|
return role;
|
|
152
118
|
}
|
|
@@ -215,95 +181,6 @@ export function getHeadlessWorkerAgentId(sessionName, index) {
|
|
|
215
181
|
return `headless-${sessionName}-${index}`;
|
|
216
182
|
}
|
|
217
183
|
|
|
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
184
|
/** MCP 프로필별 프롬프트 힌트 (tfx-route.sh resolve_mcp_policy의 경량 미러) */
|
|
308
185
|
const MCP_PROFILE_HINTS = {
|
|
309
186
|
implement:
|
|
@@ -314,19 +191,6 @@ const MCP_PROFILE_HINTS = {
|
|
|
314
191
|
docs: "Focus on documentation and explanation tasks.",
|
|
315
192
|
};
|
|
316
193
|
|
|
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
194
|
export function buildDashboardAttachArgs(
|
|
331
195
|
sessionName,
|
|
332
196
|
layout,
|
|
@@ -348,7 +212,7 @@ export function buildDashboardAttachArgs(
|
|
|
348
212
|
|
|
349
213
|
export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
350
214
|
const { handoff = true, mcp, contextFile, model, cwd } = opts;
|
|
351
|
-
const resolvedCli =
|
|
215
|
+
const resolvedCli = resolveCliType(cli);
|
|
352
216
|
|
|
353
217
|
// contextFile 처리: 32KB(32768 bytes) 초과 시 UTF-8 안전 절단
|
|
354
218
|
let contextPrefix = "";
|
|
@@ -357,7 +221,11 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
|
357
221
|
let ctx = readFileSync(contextFile, "utf8");
|
|
358
222
|
let truncated = false;
|
|
359
223
|
if (Buffer.byteLength(ctx, "utf8") > 32768) {
|
|
360
|
-
|
|
224
|
+
// 바이트 경계에서 잘린 다바이트 글자는 U+FFFD 로 디코딩되므로 떼어 낸다.
|
|
225
|
+
ctx = Buffer.from(ctx)
|
|
226
|
+
.subarray(0, 32768)
|
|
227
|
+
.toString("utf8")
|
|
228
|
+
.replace(/�+$/u, "");
|
|
361
229
|
truncated = true;
|
|
362
230
|
}
|
|
363
231
|
if (truncated) {
|
|
@@ -391,7 +259,6 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
|
391
259
|
"prompt-" + randomUUID().slice(0, 8) + ".txt",
|
|
392
260
|
).replace(/\\/g, "/");
|
|
393
261
|
writeFileSync(promptFile, fullPrompt, "utf8");
|
|
394
|
-
void IS_WINDOWS; // referenced for diagnostic guard chain below
|
|
395
262
|
|
|
396
263
|
// Codex와 Antigravity는 tfx-route.sh를 통해서만 headless 실행한다. 이 경로가
|
|
397
264
|
// disable/fallback/fail-loud 정책의 유일한 판정원이다. 여기서 같은 정책을
|
|
@@ -432,48 +299,6 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
|
432
299
|
return `cd '${safeCwd.replace(/'/g, "'\\''")}' && ${backendCommand}`;
|
|
433
300
|
}
|
|
434
301
|
|
|
435
|
-
function escapeRegExp(value) {
|
|
436
|
-
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
export function buildDaemonWrappedCommand(command, token) {
|
|
440
|
-
return `{ ${command}; __ec=$?; echo "${DAEMON_COMPLETION_PREFIX}${token}:$__ec"; }`;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
export function waitForDaemonCompletionFromMessages(
|
|
444
|
-
messages,
|
|
445
|
-
{ token, resultFile } = {},
|
|
446
|
-
) {
|
|
447
|
-
const completionRegex = new RegExp(
|
|
448
|
-
`${escapeRegExp(DAEMON_COMPLETION_PREFIX)}${escapeRegExp(token)}:(\\d+)`,
|
|
449
|
-
);
|
|
450
|
-
const stream = [];
|
|
451
|
-
let matched = false;
|
|
452
|
-
let exitCode = null;
|
|
453
|
-
|
|
454
|
-
for (const message of messages) {
|
|
455
|
-
if (Array.isArray(message?.streamTail)) {
|
|
456
|
-
stream.push(...message.streamTail.map((line) => `${line}\n`));
|
|
457
|
-
}
|
|
458
|
-
if (typeof message?.line !== "string") continue;
|
|
459
|
-
const match = completionRegex.exec(message.line);
|
|
460
|
-
if (match) {
|
|
461
|
-
const beforeCompletion = message.line.slice(0, match.index);
|
|
462
|
-
if (beforeCompletion) stream.push(beforeCompletion);
|
|
463
|
-
matched = true;
|
|
464
|
-
exitCode = Number.parseInt(match[1], 10);
|
|
465
|
-
continue;
|
|
466
|
-
}
|
|
467
|
-
stream.push(message.line);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
if (resultFile) {
|
|
471
|
-
writeFileSync(`${resultFile}.partial`, stream.join(""), "utf8");
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
return { matched, exitCode };
|
|
475
|
-
}
|
|
476
|
-
|
|
477
302
|
/**
|
|
478
303
|
* 이전 run 의 stale .txt / .partial / .err 를 제거한다.
|
|
479
304
|
* Issue #118 Codex review R1 HIGH: resultFile 경로 재사용 시 (워커 restart 또는
|
|
@@ -531,125 +356,33 @@ export function readResult(resultFile, paneId) {
|
|
|
531
356
|
return capturePsmuxPane(paneId, 30);
|
|
532
357
|
}
|
|
533
358
|
|
|
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
359
|
function createHeadlessIntervention(dispatch, fallback) {
|
|
612
360
|
return async (context) => {
|
|
613
361
|
if (typeof fallback === "function")
|
|
614
362
|
return (await fallback(context)) === true;
|
|
615
363
|
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
}
|
|
626
|
-
return `${capturePsmuxPane(context.paneId || dispatch.paneId, 50)}\0${mtime}`;
|
|
627
|
-
};
|
|
364
|
+
const readActivitySignature = () => {
|
|
365
|
+
let mtime = 0;
|
|
366
|
+
try {
|
|
367
|
+
mtime = statSync(dispatch.resultFile).mtimeMs;
|
|
368
|
+
} catch {
|
|
369
|
+
/* result file has not been written yet */
|
|
370
|
+
}
|
|
371
|
+
return `${capturePsmuxPane(context.paneId || dispatch.paneId, 50)}\0${mtime}`;
|
|
372
|
+
};
|
|
628
373
|
const ladder = createInterventionLadder({
|
|
629
|
-
target:
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
controlSock: dispatch.controlSock,
|
|
636
|
-
short: dispatch.daemonShort,
|
|
637
|
-
sessionId: dispatch.sessionId,
|
|
638
|
-
configDir: dispatch.daemonPaths?.configDir,
|
|
639
|
-
},
|
|
640
|
-
}
|
|
641
|
-
: {
|
|
642
|
-
channel: "tmux-pane",
|
|
643
|
-
paneId: context.paneId || dispatch.paneId,
|
|
644
|
-
cli: dispatch.cli,
|
|
645
|
-
sessionId: dispatch.sessionId,
|
|
646
|
-
},
|
|
374
|
+
target: {
|
|
375
|
+
channel: "tmux-pane",
|
|
376
|
+
paneId: context.paneId || dispatch.paneId,
|
|
377
|
+
cli: dispatch.cli,
|
|
378
|
+
sessionId: dispatch.sessionId,
|
|
379
|
+
},
|
|
647
380
|
readActivitySignature,
|
|
648
381
|
deps: {
|
|
649
382
|
// 이 트랙이 재실행 소유자다. pane 채널은 기존 completion wrapper를 다시
|
|
650
383
|
// dispatch하여 token/result 계약을 보존한다.
|
|
651
384
|
resumeHandler: async () => {
|
|
652
|
-
if (
|
|
385
|
+
if (!dispatch.command) return { ok: false };
|
|
653
386
|
const restarted = dispatchCommand(
|
|
654
387
|
context.sessionName,
|
|
655
388
|
context.paneId || dispatch.paneId,
|
|
@@ -706,254 +439,6 @@ export function createActivityPollGuard({
|
|
|
706
439
|
});
|
|
707
440
|
}
|
|
708
441
|
|
|
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
442
|
function createHeadlessSessionOwnership(killSession) {
|
|
958
443
|
let owned = false;
|
|
959
444
|
let ownedSessionName = "";
|
|
@@ -1021,7 +506,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
|
1021
506
|
const paneName = `worker-${i + 1}`;
|
|
1022
507
|
const displayName = resolveHeadlessDisplayName(assignment, paneName);
|
|
1023
508
|
const workerId = getHeadlessWorkerAgentId(sessionName, i);
|
|
1024
|
-
const resolvedCli =
|
|
509
|
+
const resolvedCli = resolveCliType(assignment.cli);
|
|
1025
510
|
const brand = CLI_BRAND[resolvedCli] || {
|
|
1026
511
|
emoji: "\u{25CF}",
|
|
1027
512
|
label: resolvedCli,
|
|
@@ -1047,7 +532,6 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
|
1047
532
|
} catch {
|
|
1048
533
|
/* 무시 */
|
|
1049
534
|
}
|
|
1050
|
-
await registerHeadlessWorker(sessionName, i, resolvedCli);
|
|
1051
535
|
|
|
1052
536
|
if (safeProgress)
|
|
1053
537
|
safeProgress({
|
|
@@ -1081,7 +565,6 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
|
1081
565
|
// pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
|
|
1082
566
|
if (i > 0) await new Promise((r) => setTimeout(r, 300));
|
|
1083
567
|
const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
|
|
1084
|
-
registerHeadlessSynapseWorker(workerId, assignment.prompt);
|
|
1085
568
|
|
|
1086
569
|
if (safeProgress)
|
|
1087
570
|
safeProgress({ type: "dispatched", paneName, cli: resolvedCli });
|
|
@@ -1150,7 +633,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
1150
633
|
const paneName = `worker-${i + 1}`;
|
|
1151
634
|
const displayName = resolveHeadlessDisplayName(assignment, paneName);
|
|
1152
635
|
const workerId = getHeadlessWorkerAgentId(sessionName, i);
|
|
1153
|
-
const resolvedCli =
|
|
636
|
+
const resolvedCli = resolveCliType(assignment.cli);
|
|
1154
637
|
const resultFile = join(
|
|
1155
638
|
RESULT_DIR,
|
|
1156
639
|
`${sessionName}-${paneName}.txt`,
|
|
@@ -1170,12 +653,10 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
1170
653
|
},
|
|
1171
654
|
);
|
|
1172
655
|
const scriptDir = join(RESULT_DIR, sessionName);
|
|
1173
|
-
await registerHeadlessWorker(sessionName, i, resolvedCli);
|
|
1174
656
|
const dispatch = dispatchCommand(sessionName, paneName, cmd, {
|
|
1175
657
|
scriptDir,
|
|
1176
658
|
scriptName: paneName,
|
|
1177
659
|
});
|
|
1178
|
-
registerHeadlessSynapseWorker(workerId, assignment.prompt);
|
|
1179
660
|
|
|
1180
661
|
// P1 fix: 비-progressive에서는 pane 리네임 금지 — 캡처 로그 경로가 타이틀 기반이므로
|
|
1181
662
|
// 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
|
|
@@ -1204,135 +685,6 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
1204
685
|
);
|
|
1205
686
|
}
|
|
1206
687
|
|
|
1207
|
-
async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
|
|
1208
|
-
const { safeProgress, configDir } = opts;
|
|
1209
|
-
const paths = deriveClaudeControlPaths({ configDir });
|
|
1210
|
-
const dispatches = [];
|
|
1211
|
-
|
|
1212
|
-
try {
|
|
1213
|
-
for (let i = 0; i < assignments.length; i += 1) {
|
|
1214
|
-
const assignment = assignments[i];
|
|
1215
|
-
const paneName = `worker-${i + 1}`;
|
|
1216
|
-
const displayName = resolveHeadlessDisplayName(assignment, paneName);
|
|
1217
|
-
const workerId = getHeadlessWorkerAgentId(sessionName, i);
|
|
1218
|
-
const resolvedCli = resolveHeadlessCliType(assignment.cli);
|
|
1219
|
-
const resultFile = join(
|
|
1220
|
-
RESULT_DIR,
|
|
1221
|
-
`${sessionName}-${paneName}.txt`,
|
|
1222
|
-
).replace(/\\/g, "/");
|
|
1223
|
-
cleanStaleResultArtifacts(resultFile);
|
|
1224
|
-
const command = buildHeadlessCommand(
|
|
1225
|
-
assignment.cli,
|
|
1226
|
-
assignment.prompt,
|
|
1227
|
-
resultFile,
|
|
1228
|
-
{
|
|
1229
|
-
mcp: assignment.mcp,
|
|
1230
|
-
role: assignment.role,
|
|
1231
|
-
model: assignment.model,
|
|
1232
|
-
profile: assignment.profile,
|
|
1233
|
-
cwd: assignment.cwd || assignment.workdir,
|
|
1234
|
-
},
|
|
1235
|
-
);
|
|
1236
|
-
const token = randomUUID().slice(0, 10);
|
|
1237
|
-
const short = randomUUID().replace(/-/g, "").slice(0, 8);
|
|
1238
|
-
const name = `Triflux ${resolvedCli || "worker"} ${displayName}`;
|
|
1239
|
-
const record = {
|
|
1240
|
-
paneId: `daemon:${short}`,
|
|
1241
|
-
paneName,
|
|
1242
|
-
displayName,
|
|
1243
|
-
resultFile,
|
|
1244
|
-
cli: resolvedCli,
|
|
1245
|
-
role: assignment.role,
|
|
1246
|
-
command,
|
|
1247
|
-
token,
|
|
1248
|
-
workerId,
|
|
1249
|
-
cwd: assignment.cwd || assignment.workdir,
|
|
1250
|
-
daemonShort: short,
|
|
1251
|
-
daemonPaths: paths,
|
|
1252
|
-
controlSock: paths.controlSock,
|
|
1253
|
-
sessionId: "",
|
|
1254
|
-
sessionProjectionPath: "",
|
|
1255
|
-
daemonCompletionMatched: false,
|
|
1256
|
-
};
|
|
1257
|
-
dispatches.push(record);
|
|
1258
|
-
|
|
1259
|
-
const payload = buildDaemonExecDispatchPayload({
|
|
1260
|
-
short,
|
|
1261
|
-
cwd: assignment.cwd || assignment.workdir || process.cwd(),
|
|
1262
|
-
command: buildDaemonWrappedCommand(command, token),
|
|
1263
|
-
name,
|
|
1264
|
-
});
|
|
1265
|
-
record.sessionId = payload.sessionId;
|
|
1266
|
-
// buildDaemonWrappedCommand + token completion 시맨틱은 헬퍼 밖(headless 전용)에
|
|
1267
|
-
// 남기고, dispatch→pid→bridge→projection 시퀀스만 공유 헬퍼로 위임한다.
|
|
1268
|
-
// CRITICAL: waitForDaemonCompletion 이 나중에 record.daemonCompletionMatched 를
|
|
1269
|
-
// 변경하고 cleanupDaemonDispatches 가 같은 record 를 읽으므로, 헬퍼가 새 객체를
|
|
1270
|
-
// 반환해도 record 를 교체하지 말고 plain 필드를 Object.assign 한다.
|
|
1271
|
-
const dispatched = await dispatchClaudeDaemonJob({
|
|
1272
|
-
paths,
|
|
1273
|
-
controlSock: paths.controlSock,
|
|
1274
|
-
payload,
|
|
1275
|
-
agent: resolvedCli || "codex",
|
|
1276
|
-
name,
|
|
1277
|
-
cwd: assignment.cwd || assignment.workdir || process.cwd(),
|
|
1278
|
-
dispatchTimeoutMs: 5000,
|
|
1279
|
-
}).catch((error) => {
|
|
1280
|
-
throw new Error(
|
|
1281
|
-
`[headless] Claude daemon dispatch failed for ${paneName}: ${
|
|
1282
|
-
error?.message || error
|
|
1283
|
-
}`,
|
|
1284
|
-
);
|
|
1285
|
-
});
|
|
1286
|
-
Object.assign(record, {
|
|
1287
|
-
sessionId: dispatched.sessionId,
|
|
1288
|
-
sessionProjectionPath: dispatched.sessionProjectionPath,
|
|
1289
|
-
});
|
|
1290
|
-
await registerHeadlessWorker(sessionName, i, resolvedCli);
|
|
1291
|
-
registerHeadlessSynapseWorker(workerId, assignment.prompt);
|
|
1292
|
-
if (safeProgress) {
|
|
1293
|
-
safeProgress({ type: "dispatched", paneName, cli: resolvedCli });
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
} catch (error) {
|
|
1297
|
-
await cleanupDaemonDispatches(dispatches);
|
|
1298
|
-
throw error;
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
return dispatches;
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
export async function cleanupDaemonDispatches(dispatches) {
|
|
1305
|
-
await Promise.all(
|
|
1306
|
-
dispatches.map(async (dispatch) => {
|
|
1307
|
-
if (dispatch.sessionProjectionPath) {
|
|
1308
|
-
await removeClaudeSessionProjection(
|
|
1309
|
-
dispatch.sessionProjectionPath,
|
|
1310
|
-
).catch(() => {});
|
|
1311
|
-
}
|
|
1312
|
-
if (
|
|
1313
|
-
dispatch.daemonCompletionMatched === true &&
|
|
1314
|
-
dispatch.daemonPaths &&
|
|
1315
|
-
dispatch.sessionId
|
|
1316
|
-
) {
|
|
1317
|
-
await sendKillBySessionId({
|
|
1318
|
-
daemonPaths: dispatch.daemonPaths,
|
|
1319
|
-
sessionId: dispatch.sessionId,
|
|
1320
|
-
}).catch(() => {});
|
|
1321
|
-
}
|
|
1322
|
-
if (dispatch.controlSock && dispatch.daemonShort) {
|
|
1323
|
-
const controlAuth = await buildDaemonControlAuth(
|
|
1324
|
-
dispatch.daemonPaths?.configDir,
|
|
1325
|
-
).catch(() => ({}));
|
|
1326
|
-
await killDaemonJob(
|
|
1327
|
-
dispatch.controlSock,
|
|
1328
|
-
dispatch.daemonShort,
|
|
1329
|
-
controlAuth,
|
|
1330
|
-
).catch(() => {});
|
|
1331
|
-
}
|
|
1332
|
-
}),
|
|
1333
|
-
);
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
688
|
/**
|
|
1337
689
|
* 모든 dispatch를 병렬 대기하며 완료 결과를 수집한다.
|
|
1338
690
|
* @param {string} sessionName
|
|
@@ -1348,7 +700,6 @@ async function awaitAll(
|
|
|
1348
700
|
timeoutSec,
|
|
1349
701
|
safeProgress,
|
|
1350
702
|
progressIntervalSec,
|
|
1351
|
-
stallOpts,
|
|
1352
703
|
lifecycle,
|
|
1353
704
|
) {
|
|
1354
705
|
// 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
|
|
@@ -1375,73 +726,7 @@ async function awaitAll(
|
|
|
1375
726
|
}
|
|
1376
727
|
|
|
1377
728
|
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) {
|
|
729
|
+
if (lifecycle?.enabled) {
|
|
1445
730
|
if (d.logPath) pollOpts.logPath = d.logPath;
|
|
1446
731
|
const activity = createActivityLifecycle({
|
|
1447
732
|
interventionMs: lifecycle.interventionMs,
|
|
@@ -1501,7 +786,6 @@ async function awaitAll(
|
|
|
1501
786
|
}
|
|
1502
787
|
|
|
1503
788
|
const output = readResult(d.resultFile, d.paneId);
|
|
1504
|
-
unregisterHeadlessSynapseWorker(d.workerId);
|
|
1505
789
|
|
|
1506
790
|
if (safeProgress) {
|
|
1507
791
|
safeProgress({
|
|
@@ -1512,174 +796,6 @@ async function awaitAll(
|
|
|
1512
796
|
matched: completion.matched,
|
|
1513
797
|
exitCode: completion.exitCode,
|
|
1514
798
|
sessionDead: completion.sessionDead || false,
|
|
1515
|
-
stallDetected: completion.stallDetected || false,
|
|
1516
|
-
stallExhausted: completion.stallExhausted || false,
|
|
1517
|
-
});
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
return { d, completion, output };
|
|
1521
|
-
}),
|
|
1522
|
-
);
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
export async function waitForDaemonCompletion(
|
|
1526
|
-
dispatch,
|
|
1527
|
-
timeoutSec,
|
|
1528
|
-
safeProgress,
|
|
1529
|
-
progressIntervalSec,
|
|
1530
|
-
lifecycle = null,
|
|
1531
|
-
) {
|
|
1532
|
-
const messages = [];
|
|
1533
|
-
const socket = net.connect(dispatch.controlSock);
|
|
1534
|
-
let buffer = "";
|
|
1535
|
-
let settled = false;
|
|
1536
|
-
let lastProgressAt = 0;
|
|
1537
|
-
const activity = lifecycle?.enabled
|
|
1538
|
-
? createActivityLifecycle({
|
|
1539
|
-
interventionMs: lifecycle.interventionMs,
|
|
1540
|
-
hardCeilingMs: lifecycle.hardCeilingMs,
|
|
1541
|
-
onIntervene: createHeadlessIntervention(
|
|
1542
|
-
dispatch,
|
|
1543
|
-
lifecycle.onIntervene,
|
|
1544
|
-
),
|
|
1545
|
-
})
|
|
1546
|
-
: null;
|
|
1547
|
-
|
|
1548
|
-
return await new Promise((resolve) => {
|
|
1549
|
-
const finish = (completion, timeoutReason = "") => {
|
|
1550
|
-
if (settled) return;
|
|
1551
|
-
settled = true;
|
|
1552
|
-
clearTimeout(timer);
|
|
1553
|
-
clearInterval(timer);
|
|
1554
|
-
socket.destroy();
|
|
1555
|
-
const finalCompletion =
|
|
1556
|
-
completion ||
|
|
1557
|
-
waitForDaemonCompletionFromMessages(messages, {
|
|
1558
|
-
token: dispatch.token,
|
|
1559
|
-
resultFile: dispatch.resultFile,
|
|
1560
|
-
});
|
|
1561
|
-
if (!finalCompletion.matched && timeoutReason) {
|
|
1562
|
-
finalCompletion.timedOut = true;
|
|
1563
|
-
finalCompletion.timeoutReason = timeoutReason;
|
|
1564
|
-
}
|
|
1565
|
-
if (finalCompletion.matched) {
|
|
1566
|
-
dispatch.daemonCompletionMatched = true;
|
|
1567
|
-
cleanupDaemonDispatches([dispatch]).catch(() => {});
|
|
1568
|
-
} else {
|
|
1569
|
-
buildDaemonControlAuth(dispatch.daemonPaths?.configDir)
|
|
1570
|
-
.catch(() => ({}))
|
|
1571
|
-
.then((controlAuth) =>
|
|
1572
|
-
killDaemonJob(
|
|
1573
|
-
dispatch.controlSock,
|
|
1574
|
-
dispatch.daemonShort,
|
|
1575
|
-
controlAuth,
|
|
1576
|
-
),
|
|
1577
|
-
)
|
|
1578
|
-
.catch(() => {});
|
|
1579
|
-
}
|
|
1580
|
-
resolve(finalCompletion);
|
|
1581
|
-
};
|
|
1582
|
-
|
|
1583
|
-
const timer = activity
|
|
1584
|
-
? setInterval(() => {
|
|
1585
|
-
void activity
|
|
1586
|
-
.check({
|
|
1587
|
-
channel: "daemon",
|
|
1588
|
-
controlSock: dispatch.controlSock,
|
|
1589
|
-
daemonShort: dispatch.daemonShort,
|
|
1590
|
-
resultFile: dispatch.resultFile,
|
|
1591
|
-
})
|
|
1592
|
-
.then((reason) => {
|
|
1593
|
-
if (reason) finish(null, reason);
|
|
1594
|
-
});
|
|
1595
|
-
}, lifecycle.checkIntervalMs ?? 5_000)
|
|
1596
|
-
: setTimeout(
|
|
1597
|
-
() => finish(null, "wall_clock"),
|
|
1598
|
-
Math.max(0, timeoutSec * 1000),
|
|
1599
|
-
);
|
|
1600
|
-
if (typeof timer.unref === "function") timer.unref();
|
|
1601
|
-
|
|
1602
|
-
socket.on("error", () => finish(null, "socket_error"));
|
|
1603
|
-
socket.on("close", () => {
|
|
1604
|
-
if (!settled) finish(null, "socket_closed");
|
|
1605
|
-
});
|
|
1606
|
-
socket.on("connect", () => {
|
|
1607
|
-
socket.write(
|
|
1608
|
-
`${JSON.stringify({
|
|
1609
|
-
proto: 1,
|
|
1610
|
-
op: "subscribe",
|
|
1611
|
-
short: dispatch.daemonShort,
|
|
1612
|
-
tail: 20,
|
|
1613
|
-
})}\n`,
|
|
1614
|
-
);
|
|
1615
|
-
});
|
|
1616
|
-
socket.on("data", (chunk) => {
|
|
1617
|
-
buffer += chunk.toString("utf8");
|
|
1618
|
-
while (buffer.includes("\n")) {
|
|
1619
|
-
const index = buffer.indexOf("\n");
|
|
1620
|
-
const line = buffer.slice(0, index);
|
|
1621
|
-
buffer = buffer.slice(index + 1);
|
|
1622
|
-
if (!line.trim()) continue;
|
|
1623
|
-
const message = JSON.parse(line);
|
|
1624
|
-
messages.push(message);
|
|
1625
|
-
activity?.observe();
|
|
1626
|
-
if (
|
|
1627
|
-
safeProgress &&
|
|
1628
|
-
progressIntervalSec > 0 &&
|
|
1629
|
-
typeof message.line === "string"
|
|
1630
|
-
) {
|
|
1631
|
-
const now = Date.now();
|
|
1632
|
-
if (now - lastProgressAt >= progressIntervalSec * 1000) {
|
|
1633
|
-
lastProgressAt = now;
|
|
1634
|
-
safeProgress({
|
|
1635
|
-
type: "progress",
|
|
1636
|
-
paneName: dispatch.paneName,
|
|
1637
|
-
displayName: dispatch.displayName,
|
|
1638
|
-
cli: dispatch.cli,
|
|
1639
|
-
snapshot: message.line.split("\n").slice(-15).join("\n"),
|
|
1640
|
-
});
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
const completion = waitForDaemonCompletionFromMessages(messages, {
|
|
1644
|
-
token: dispatch.token,
|
|
1645
|
-
resultFile: dispatch.resultFile,
|
|
1646
|
-
});
|
|
1647
|
-
if (completion.matched) finish(completion);
|
|
1648
|
-
}
|
|
1649
|
-
});
|
|
1650
|
-
});
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
async function awaitAllDaemon(
|
|
1654
|
-
dispatches,
|
|
1655
|
-
timeoutSec,
|
|
1656
|
-
safeProgress,
|
|
1657
|
-
progressIntervalSec,
|
|
1658
|
-
lifecycle,
|
|
1659
|
-
) {
|
|
1660
|
-
return Promise.all(
|
|
1661
|
-
dispatches.map(async (d) => {
|
|
1662
|
-
const completion = await waitForDaemonCompletion(
|
|
1663
|
-
d,
|
|
1664
|
-
timeoutSec,
|
|
1665
|
-
safeProgress,
|
|
1666
|
-
progressIntervalSec,
|
|
1667
|
-
lifecycle,
|
|
1668
|
-
);
|
|
1669
|
-
const output = readResult(d.resultFile, d.paneId);
|
|
1670
|
-
unregisterHeadlessSynapseWorker(d.workerId);
|
|
1671
|
-
|
|
1672
|
-
if (safeProgress) {
|
|
1673
|
-
safeProgress({
|
|
1674
|
-
type: "completed",
|
|
1675
|
-
paneName: d.paneName,
|
|
1676
|
-
displayName: d.displayName,
|
|
1677
|
-
cli: d.cli,
|
|
1678
|
-
matched: completion.matched,
|
|
1679
|
-
exitCode: completion.exitCode,
|
|
1680
|
-
sessionDead: false,
|
|
1681
|
-
stallDetected: false,
|
|
1682
|
-
stallExhausted: false,
|
|
1683
799
|
});
|
|
1684
800
|
}
|
|
1685
801
|
|
|
@@ -1693,7 +809,7 @@ async function awaitAllDaemon(
|
|
|
1693
809
|
* @param {Array<{d, completion, output}>} results
|
|
1694
810
|
* @returns {Array}
|
|
1695
811
|
*/
|
|
1696
|
-
async function collectResults(
|
|
812
|
+
async function collectResults(results) {
|
|
1697
813
|
// handoff 파이프라인: parse → validate → format (각 워커 결과에 적용)
|
|
1698
814
|
return await Promise.all(
|
|
1699
815
|
results.map(async ({ d, completion, output }) => {
|
|
@@ -1715,18 +831,6 @@ async function collectResults(sessionName, results) {
|
|
|
1715
831
|
};
|
|
1716
832
|
handoffResult.formatted = formatHandoffForLead(handoffResult.handoff);
|
|
1717
833
|
}
|
|
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
834
|
return {
|
|
1731
835
|
cli: d.cli,
|
|
1732
836
|
paneName: d.paneName,
|
|
@@ -1782,21 +886,18 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1782
886
|
const sessionOwnership =
|
|
1783
887
|
opts._sessionOwnership ||
|
|
1784
888
|
createHeadlessSessionOwnership(deps.killPsmuxSession || killPsmuxSession);
|
|
889
|
+
const agentsRows = opts._agentsRows || createAgentsRowOwnership();
|
|
1785
890
|
let {
|
|
1786
891
|
timeoutSec = 900,
|
|
1787
892
|
layout = "2x2",
|
|
1788
893
|
onProgress,
|
|
1789
894
|
progressIntervalSec = 0,
|
|
1790
895
|
progressive = true,
|
|
1791
|
-
autoAttach = false,
|
|
1792
896
|
dashboard = false,
|
|
1793
897
|
dashboardLayout = "single",
|
|
1794
|
-
stallDetect,
|
|
1795
898
|
nativeBridge = false,
|
|
1796
|
-
nativeBridgeMode = "
|
|
899
|
+
nativeBridgeMode = "agents",
|
|
1797
900
|
onIntervene,
|
|
1798
|
-
leadPane = null,
|
|
1799
|
-
leadTmux = null,
|
|
1800
901
|
} = opts;
|
|
1801
902
|
if (!Number.isFinite(Number(timeoutSec)) || Number(timeoutSec) <= 0)
|
|
1802
903
|
timeoutSec = 900;
|
|
@@ -1814,70 +915,13 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1814
915
|
);
|
|
1815
916
|
|
|
1816
917
|
if (normalizedAssignments.length === 0) {
|
|
1817
|
-
return { sessionName, results: [], sessionOwnership };
|
|
918
|
+
return { sessionName, results: [], sessionOwnership, agentsRows };
|
|
1818
919
|
}
|
|
1819
920
|
|
|
1820
|
-
let nativeBridgeHandle = null;
|
|
1821
|
-
let daemonDispatches = [];
|
|
1822
921
|
let runCompleted = false;
|
|
1823
922
|
let completedResults = [];
|
|
1824
923
|
let runFailed = false;
|
|
1825
924
|
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
925
|
// in-process TUI: dashboard=true이고 stdout이 TTY일 때 직접 구동
|
|
1882
926
|
let tui = null;
|
|
1883
927
|
const resolvedLayout = resolveDashboardLayout(
|
|
@@ -1897,7 +941,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1897
941
|
const a = normalizedAssignments[i];
|
|
1898
942
|
const paneName = `worker-${i + 1}`;
|
|
1899
943
|
tui.updateWorker(paneName, {
|
|
1900
|
-
cli:
|
|
944
|
+
cli: resolveCliType(a.cli || "codex"),
|
|
1901
945
|
displayName: resolveHeadlessDisplayName(a, paneName),
|
|
1902
946
|
role: a.role || "",
|
|
1903
947
|
status: "pending",
|
|
@@ -1905,11 +949,10 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1905
949
|
});
|
|
1906
950
|
}
|
|
1907
951
|
} else if (dashboard) {
|
|
1908
|
-
//
|
|
1909
|
-
// stderr 로만 안내 (stdout 은 readHeadlessResult 파싱 대상이라 건드리지 않는다).
|
|
952
|
+
// stdout은 결과 파싱 대상이므로 안내는 stderr로 보낸다.
|
|
1910
953
|
const logDir = join(tmpdir(), "tfx-headless");
|
|
1911
954
|
process.stderr.write(
|
|
1912
|
-
`\n⚠
|
|
955
|
+
`\n⚠ stdout is not a TTY; dashboard is skipped.\n` +
|
|
1913
956
|
` Session is running in background. Worker logs:\n` +
|
|
1914
957
|
` ${logDir}${IS_WINDOWS ? "\\" : "/"}${sessionName}-worker-N.txt\n` +
|
|
1915
958
|
` ${logDir}${IS_WINDOWS ? "\\" : "/"}${sessionName}-worker-N.txt.err\n\n`,
|
|
@@ -1951,62 +994,9 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1951
994
|
}
|
|
1952
995
|
}
|
|
1953
996
|
|
|
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
997
|
// onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
|
|
2005
998
|
const combinedProgress = (event) => {
|
|
2006
|
-
nativeBridgeHandle?.handleProgress(event);
|
|
2007
999
|
feedTui(event);
|
|
2008
|
-
feedSynapse(event);
|
|
2009
|
-
feedHubActivity(event);
|
|
2010
1000
|
if (onProgress) {
|
|
2011
1001
|
try {
|
|
2012
1002
|
onProgress(event);
|
|
@@ -2026,82 +1016,37 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
2026
1016
|
try {
|
|
2027
1017
|
const progressiveDispatch = deps.dispatchProgressive || dispatchProgressive;
|
|
2028
1018
|
const batchDispatch = deps.dispatchBatch || dispatchBatch;
|
|
2029
|
-
const dispatches =
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
})
|
|
2034
|
-
: progressive
|
|
2035
|
-
? await progressiveDispatch(sessionName, normalizedAssignments, {
|
|
2036
|
-
layout,
|
|
2037
|
-
safeProgress,
|
|
2038
|
-
dashboardLayout,
|
|
2039
|
-
sessionOwnership,
|
|
2040
|
-
_deps: deps,
|
|
2041
|
-
})
|
|
2042
|
-
: await batchDispatch(sessionName, normalizedAssignments, {
|
|
2043
|
-
layout,
|
|
2044
|
-
safeProgress,
|
|
2045
|
-
dashboardLayout,
|
|
2046
|
-
sessionOwnership,
|
|
2047
|
-
_deps: deps,
|
|
2048
|
-
});
|
|
2049
|
-
if (nativeBridge && nativeBridgeMode === "agents") {
|
|
2050
|
-
daemonDispatches = dispatches;
|
|
2051
|
-
const createObservationSession =
|
|
2052
|
-
deps.createDaemonObservationSession || createDaemonObservationSession;
|
|
2053
|
-
let observation = null;
|
|
2054
|
-
try {
|
|
2055
|
-
observation = createObservationSession(sessionName, dispatches, {
|
|
2056
|
-
autoAttach,
|
|
2057
|
-
dashboard,
|
|
1019
|
+
const dispatches = progressive
|
|
1020
|
+
? await progressiveDispatch(sessionName, normalizedAssignments, {
|
|
1021
|
+
layout,
|
|
1022
|
+
safeProgress,
|
|
2058
1023
|
dashboardLayout,
|
|
2059
|
-
|
|
2060
|
-
leadTmux,
|
|
2061
|
-
onProgress: safeProgress,
|
|
1024
|
+
sessionOwnership,
|
|
2062
1025
|
_deps: deps,
|
|
2063
|
-
})
|
|
2064
|
-
|
|
2065
|
-
|
|
1026
|
+
})
|
|
1027
|
+
: await batchDispatch(sessionName, normalizedAssignments, {
|
|
1028
|
+
layout,
|
|
2066
1029
|
safeProgress,
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
"observer setup failed",
|
|
2071
|
-
);
|
|
2072
|
-
}
|
|
2073
|
-
if (observation) {
|
|
2074
|
-
sessionOwnership.acquire(observation.sessionName);
|
|
2075
|
-
safeProgress({
|
|
2076
|
-
type: "session_created",
|
|
2077
|
-
sessionName: observation.sessionName,
|
|
2078
|
-
panes: observation.panes,
|
|
2079
|
-
dashboardLayout: observation.dashboardLayout,
|
|
2080
|
-
observationSource: "daemon-files",
|
|
1030
|
+
dashboardLayout,
|
|
1031
|
+
sessionOwnership,
|
|
1032
|
+
_deps: deps,
|
|
2081
1033
|
});
|
|
2082
|
-
|
|
1034
|
+
if (nativeBridge && nativeBridgeMode === "agents") {
|
|
1035
|
+
await openHeadlessAgentsRows(sessionName, dispatches, agentsRows, {
|
|
1036
|
+
safeProgress,
|
|
1037
|
+
_deps: deps,
|
|
1038
|
+
});
|
|
2083
1039
|
}
|
|
2084
1040
|
|
|
2085
|
-
const results =
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
: await awaitAll(
|
|
2095
|
-
sessionName,
|
|
2096
|
-
dispatches,
|
|
2097
|
-
timeoutSec,
|
|
2098
|
-
safeProgress,
|
|
2099
|
-
progressIntervalSec,
|
|
2100
|
-
stallDetect,
|
|
2101
|
-
lifecycle,
|
|
2102
|
-
);
|
|
2103
|
-
const collected = await collectResults(sessionName, results);
|
|
2104
|
-
for (const result of collected) nativeBridgeHandle?.completeWorker(result);
|
|
1041
|
+
const results = await (deps.awaitAll || awaitAll)(
|
|
1042
|
+
sessionName,
|
|
1043
|
+
dispatches,
|
|
1044
|
+
timeoutSec,
|
|
1045
|
+
safeProgress,
|
|
1046
|
+
progressIntervalSec,
|
|
1047
|
+
lifecycle,
|
|
1048
|
+
);
|
|
1049
|
+
const collected = await collectResults(results);
|
|
2105
1050
|
|
|
2106
1051
|
// 완료 시 TUI에 최종 상태 반영 후 닫기
|
|
2107
1052
|
if (tui) {
|
|
@@ -2133,30 +1078,17 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
2133
1078
|
runError = error;
|
|
2134
1079
|
}
|
|
2135
1080
|
|
|
2136
|
-
if (!runCompleted)
|
|
2137
|
-
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
|
-
if (daemonDispatches.length > 0) {
|
|
2148
|
-
await cleanupDaemonDispatches(daemonDispatches);
|
|
2149
|
-
}
|
|
2150
|
-
} catch (error) {
|
|
1081
|
+
if (!runCompleted) {
|
|
2151
1082
|
sessionOwnership.release();
|
|
2152
|
-
|
|
2153
|
-
runFailed = true;
|
|
2154
|
-
runError = error;
|
|
2155
|
-
}
|
|
1083
|
+
await agentsRows.release();
|
|
2156
1084
|
}
|
|
2157
|
-
|
|
2158
1085
|
if (runFailed) throw runError;
|
|
2159
|
-
return {
|
|
1086
|
+
return {
|
|
1087
|
+
sessionName,
|
|
1088
|
+
results: completedResults,
|
|
1089
|
+
sessionOwnership,
|
|
1090
|
+
agentsRows,
|
|
1091
|
+
};
|
|
2160
1092
|
}
|
|
2161
1093
|
|
|
2162
1094
|
/**
|
|
@@ -2175,23 +1107,17 @@ export async function runHeadlessWithCleanup(assignments, opts = {}) {
|
|
|
2175
1107
|
const sessionOwnership = createHeadlessSessionOwnership(
|
|
2176
1108
|
deps.killPsmuxSession || killPsmuxSession,
|
|
2177
1109
|
);
|
|
1110
|
+
const agentsRows = createAgentsRowOwnership();
|
|
2178
1111
|
|
|
2179
1112
|
try {
|
|
2180
1113
|
return await run(sessionName, assignments, {
|
|
2181
1114
|
...runOpts,
|
|
2182
1115
|
_sessionOwnership: sessionOwnership,
|
|
1116
|
+
_agentsRows: agentsRows,
|
|
2183
1117
|
});
|
|
2184
1118
|
} finally {
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
unregisterHeadlessSynapseWorker(
|
|
2188
|
-
getHeadlessWorkerAgentId(sessionName, index),
|
|
2189
|
-
);
|
|
2190
|
-
}
|
|
2191
|
-
await deregisterHeadlessWorkers(sessionName, assignments.length);
|
|
2192
|
-
} finally {
|
|
2193
|
-
sessionOwnership.release();
|
|
2194
|
-
}
|
|
1119
|
+
await agentsRows.release();
|
|
1120
|
+
sessionOwnership.release();
|
|
2195
1121
|
// WT split pane은 psmux 종료 시 셸이 끝나면서 자동으로 닫힘
|
|
2196
1122
|
// 수동 close-pane 불필요 (레이스 컨디션으로 WT 에러 발생)
|
|
2197
1123
|
}
|
|
@@ -2233,71 +1159,6 @@ export function applyTrifluxTheme(sessionName) {
|
|
|
2233
1159
|
}
|
|
2234
1160
|
}
|
|
2235
1161
|
|
|
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
1162
|
function sanitizeSessionName(value) {
|
|
2302
1163
|
return String(value || "").replace(/[^a-zA-Z0-9_-]/g, "") || "tfx-session";
|
|
2303
1164
|
}
|
|
@@ -2338,98 +1199,52 @@ function emitObserverWarning(
|
|
|
2338
1199
|
});
|
|
2339
1200
|
}
|
|
2340
1201
|
|
|
2341
|
-
// ─── v6.0.0: Lead-Direct Interactive Mode ───
|
|
2342
|
-
|
|
2343
1202
|
/**
|
|
2344
|
-
*
|
|
2345
|
-
*
|
|
2346
|
-
*
|
|
2347
|
-
* @param {string} sessionName
|
|
2348
|
-
* @param {Array<{resultFile:string}>} dispatches
|
|
2349
|
-
* @param {object} [opts]
|
|
2350
|
-
* @returns {{sessionName:string, panes:string[], dashboardLayout:string}|null}
|
|
1203
|
+
* 워커 pane 마다 `claude agents` 행을 연다. 행은 그 pane 을 읽기 전용으로 보여 준다.
|
|
1204
|
+
* 행을 못 열어도 워커는 tmux 에서 계속 돈다. 실패는 observer_warning 으로 알린다.
|
|
2351
1205
|
*/
|
|
2352
|
-
|
|
1206
|
+
async function openHeadlessAgentsRows(
|
|
2353
1207
|
sessionName,
|
|
2354
1208
|
dispatches,
|
|
1209
|
+
rows,
|
|
2355
1210
|
opts = {},
|
|
2356
1211
|
) {
|
|
2357
1212
|
const deps = opts._deps || {};
|
|
2358
1213
|
const platform = deps.platform ?? process.platform;
|
|
2359
|
-
|
|
2360
|
-
const leadTmux = String(opts.leadTmux || "");
|
|
2361
|
-
if (!opts.autoAttach) return null;
|
|
2362
|
-
if (platform !== "darwin" && platform !== "linux") return null;
|
|
2363
|
-
if (!leadTmux.trim()) return null;
|
|
2364
|
-
if (!/^%\d+$/u.test(leadPane)) return null;
|
|
2365
|
-
if (!Array.isArray(dispatches) || dispatches.length === 0) return null;
|
|
2366
|
-
|
|
2367
|
-
const safeSessionName = sanitizeSessionName(sessionName);
|
|
2368
|
-
const resolvedLayout = opts.dashboard
|
|
2369
|
-
? resolveDashboardLayout(opts.dashboardLayout, dispatches.length)
|
|
2370
|
-
: "lite";
|
|
2371
|
-
const createSession = deps.createPsmuxSession || createPsmuxSession;
|
|
2372
|
-
const applyTheme = deps.applyTrifluxTheme || applyTrifluxTheme;
|
|
2373
|
-
const sendKeys = deps.sendKeysToPane || sendKeysToPane;
|
|
2374
|
-
const killSession = deps.killPsmuxSession || killPsmuxSession;
|
|
2375
|
-
let created = false;
|
|
2376
|
-
let stage = "observer_session_create";
|
|
2377
|
-
|
|
2378
|
-
try {
|
|
2379
|
-
const session = createSession(safeSessionName, {
|
|
2380
|
-
layout: "2x2",
|
|
2381
|
-
paneCount: 1,
|
|
2382
|
-
});
|
|
2383
|
-
created = true;
|
|
2384
|
-
stage = "observer_theme";
|
|
2385
|
-
applyTheme(safeSessionName);
|
|
2386
|
-
stage = "observer_viewer_start";
|
|
2387
|
-
const targetPane = session.panes?.[0];
|
|
2388
|
-
if (!targetPane) throw new Error("observer pane was not created");
|
|
2389
|
-
|
|
2390
|
-
const viewerCommand = [
|
|
2391
|
-
"exec",
|
|
2392
|
-
shellQuote(process.execPath),
|
|
2393
|
-
shellQuote(join(SCRIPT_DIR, "tui-viewer.mjs")),
|
|
2394
|
-
"--session",
|
|
2395
|
-
shellQuote(safeSessionName),
|
|
2396
|
-
"--result-dir",
|
|
2397
|
-
shellQuote(RESULT_DIR),
|
|
2398
|
-
"--layout",
|
|
2399
|
-
shellQuote(resolvedLayout),
|
|
2400
|
-
"--source",
|
|
2401
|
-
"files",
|
|
2402
|
-
"--workers",
|
|
2403
|
-
String(dispatches.length),
|
|
2404
|
-
"--lead-pane",
|
|
2405
|
-
shellQuote(leadPane),
|
|
2406
|
-
].join(" ");
|
|
2407
|
-
sendKeys(targetPane, viewerCommand, true);
|
|
2408
|
-
|
|
2409
|
-
return {
|
|
2410
|
-
sessionName: safeSessionName,
|
|
2411
|
-
panes: session.panes,
|
|
2412
|
-
dashboardLayout: resolvedLayout,
|
|
2413
|
-
};
|
|
2414
|
-
} catch (error) {
|
|
2415
|
-
if (created) {
|
|
2416
|
-
try {
|
|
2417
|
-
killSession(safeSessionName);
|
|
2418
|
-
} catch {
|
|
2419
|
-
/* fail-open: observer failure must not stop daemon workers */
|
|
2420
|
-
}
|
|
2421
|
-
}
|
|
1214
|
+
if (platform !== "darwin" && platform !== "linux") {
|
|
2422
1215
|
emitObserverWarning(
|
|
2423
|
-
opts.
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
1216
|
+
opts.safeProgress,
|
|
1217
|
+
sessionName,
|
|
1218
|
+
"agents_row",
|
|
1219
|
+
null,
|
|
1220
|
+
`claude agents rows need tmux on macOS/Linux (platform: ${platform})`,
|
|
2428
1221
|
);
|
|
2429
|
-
return
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
const open = deps.openAgentsRow || openAgentsRow;
|
|
1225
|
+
for (const d of dispatches) {
|
|
1226
|
+
try {
|
|
1227
|
+
const row = await open({
|
|
1228
|
+
name: `${d.cli} ${d.displayName || d.paneName}`,
|
|
1229
|
+
target: d.paneId,
|
|
1230
|
+
cwd: d.cwd || process.cwd(),
|
|
1231
|
+
});
|
|
1232
|
+
rows.add(row.short);
|
|
1233
|
+
d.agentsRowShort = row.short;
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
emitObserverWarning(
|
|
1236
|
+
opts.safeProgress,
|
|
1237
|
+
sessionName,
|
|
1238
|
+
"agents_row",
|
|
1239
|
+
error,
|
|
1240
|
+
`claude agents row failed for ${d.paneName}`,
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
2430
1243
|
}
|
|
2431
1244
|
}
|
|
2432
1245
|
|
|
1246
|
+
// ─── v6.0.0: Lead-Direct Interactive Mode ───
|
|
1247
|
+
|
|
2433
1248
|
/**
|
|
2434
1249
|
* attached tmux의 현재 leader pane 옆에 headless session view를 연다.
|
|
2435
1250
|
* 새 pane의 attach client는 headless session 종료와 함께 자연스럽게 종료된다.
|
|
@@ -2789,11 +1604,13 @@ export async function runHeadlessInteractive(
|
|
|
2789
1604
|
let sessionOwnership = createHeadlessSessionOwnership(
|
|
2790
1605
|
deps.killPsmuxSession || killPsmuxSession,
|
|
2791
1606
|
);
|
|
1607
|
+
const agentsRows = createAgentsRowOwnership();
|
|
2792
1608
|
|
|
2793
1609
|
// Phase 1: 세션 생성 → 즉시 관찰 pane/tab attach → dispatch → 대기 → 결과 수집
|
|
2794
1610
|
const runResult = await run(sessionName, assignments, {
|
|
2795
1611
|
...interactiveRunOpts,
|
|
2796
1612
|
_sessionOwnership: sessionOwnership,
|
|
1613
|
+
_agentsRows: agentsRows,
|
|
2797
1614
|
});
|
|
2798
1615
|
const { results } = runResult;
|
|
2799
1616
|
sessionOwnership = runResult.sessionOwnership || sessionOwnership;
|
|
@@ -2876,12 +1693,7 @@ export async function runHeadlessInteractive(
|
|
|
2876
1693
|
kill() {
|
|
2877
1694
|
if (this._killed) return;
|
|
2878
1695
|
this._killed = true;
|
|
2879
|
-
|
|
2880
|
-
unregisterHeadlessSynapseWorker(
|
|
2881
|
-
getHeadlessWorkerAgentId(sessionName, index),
|
|
2882
|
-
);
|
|
2883
|
-
}
|
|
2884
|
-
void deregisterHeadlessWorkers(sessionName, assignments.length);
|
|
1696
|
+
void agentsRows.release();
|
|
2885
1697
|
sessionOwnership.release();
|
|
2886
1698
|
// attach pane/tab은 psmux 종료 → attach client 종료 → 자동 닫힘
|
|
2887
1699
|
// 수동 close-pane 불필요 (레이스 컨디션으로 WT 0x80070002 에러 발생)
|