@triflux/remote 10.43.0 → 10.45.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/hygiene.mjs +1 -1
- package/hub/lib/cto-env.mjs +1 -0
- package/hub/pipe.mjs +10 -9
- package/hub/schema.sql +62 -2
- package/hub/server.mjs +276 -132
- package/hub/store.mjs +699 -41
- package/hub/team/backend.mjs +4 -2
- package/hub/team/claude-daemon-control.mjs +2 -0
- package/hub/team/cli/commands/start/start-headless.mjs +2 -0
- package/hub/team/cli/commands/start/start-mux.mjs +24 -3
- package/hub/team/cli/services/runtime-mode.mjs +23 -9
- package/hub/team/conductor.mjs +101 -24
- package/hub/team/cto-auto-collect.mjs +2 -6
- package/hub/team/cto-pull.mjs +4 -0
- package/hub/team/dashboard-open.mjs +1 -0
- package/hub/team/execution-mode.mjs +50 -9
- package/hub/team/headless.mjs +539 -92
- package/hub/team/interactive-tui-transport.mjs +15 -1
- package/hub/team/intervention.mjs +1 -1
- package/hub/team/launcher-template.mjs +17 -1
- package/hub/team/notify.mjs +31 -11
- package/hub/team/orchestrator.mjs +36 -9
- package/hub/team/pane.mjs +176 -14
- package/hub/team/psmux.mjs +79 -70
- package/hub/team/retry-state-machine.mjs +41 -13
- package/hub/team/session.mjs +57 -0
- package/hub/team/swarm-hypervisor.mjs +4 -2
- package/hub/team/swarm-preflight.mjs +47 -0
- package/hub/team/synapse-http.mjs +149 -4
- package/hub/team/terminal-opener.mjs +68 -13
- package/hub/team/tui-viewer.mjs +65 -14
- package/hub/team/worktree-lifecycle.mjs +56 -59
- package/hub/tools.mjs +22 -1
- package/hub/workers/codex-mcp.mjs +26 -5
- package/hub/workers/delegator-mcp.mjs +3 -49
- package/package.json +1 -1
- package/scripts/lib/agent-route-policy.mjs +367 -0
- package/scripts/lib/cli-agy.mjs +24 -4
- package/scripts/lib/cli-codex.mjs +22 -160
- package/scripts/lib/codex-profile-config.mjs +48 -4
- package/scripts/lib/codex-recovery.sh +19 -0
- package/scripts/lib/gemini-profiles.mjs +113 -29
- package/scripts/lib/keyword-rules.mjs +24 -0
- package/scripts/lib/logger.mjs +60 -43
- package/scripts/lib/machine-profile.mjs +293 -0
- package/scripts/lib/mcp-health.mjs +44 -18
- package/cto/steward.mjs +0 -362
- package/hub/log-retention.mjs +0 -726
package/hub/team/headless.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import net from "node:net";
|
|
|
20
20
|
import { tmpdir } from "node:os";
|
|
21
21
|
import { dirname, join, resolve } from "node:path";
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { resolveNestedCodexAgentProfile } from "../../scripts/lib/cli-codex.mjs";
|
|
23
24
|
import { requestJson } from "@triflux/core/hub/bridge.mjs";
|
|
24
25
|
import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
|
|
25
26
|
import { getMaxSpawnPerSec } from "@triflux/core/hub/lib/spawn-trace.mjs";
|
|
@@ -42,15 +43,15 @@ import {
|
|
|
42
43
|
import { startClaudeNativeBridge } from "./claude-native-bridge.mjs";
|
|
43
44
|
import { removeClaudeSessionProjection } from "./claude-session-projection.mjs";
|
|
44
45
|
import { resolveDashboardLayout } from "./dashboard-layout.mjs";
|
|
45
|
-
import {
|
|
46
|
-
createFileActivitySource,
|
|
47
|
-
createInterventionLadder,
|
|
48
|
-
} from "./intervention.mjs";
|
|
49
46
|
import {
|
|
50
47
|
formatHandoffForLead,
|
|
51
48
|
HANDOFF_INSTRUCTION_SHORT,
|
|
52
49
|
processHandoff,
|
|
53
50
|
} from "./handoff.mjs";
|
|
51
|
+
import {
|
|
52
|
+
createFileActivitySource,
|
|
53
|
+
createInterventionLadder,
|
|
54
|
+
} from "./intervention.mjs";
|
|
54
55
|
import {
|
|
55
56
|
capturePsmuxPane,
|
|
56
57
|
createPsmuxSession,
|
|
@@ -58,6 +59,7 @@ import {
|
|
|
58
59
|
killPsmuxSession,
|
|
59
60
|
psmuxExec,
|
|
60
61
|
psmuxSessionExists,
|
|
62
|
+
sendKeysToPane,
|
|
61
63
|
startCapture,
|
|
62
64
|
waitForCompletion,
|
|
63
65
|
} from "./psmux.mjs";
|
|
@@ -189,6 +191,10 @@ function buildRouteBackedHeadlessCommand(
|
|
|
189
191
|
: "";
|
|
190
192
|
const timeoutArg = timeout ? ` ${shellQuote(timeout)}` : "";
|
|
191
193
|
const routeMode = resolvedCli === "antigravity" ? "antigravity" : resolvedCli;
|
|
194
|
+
const codexProfileEnv =
|
|
195
|
+
resolvedCli === "codex" && opts.codexProfile
|
|
196
|
+
? `TFX_CODEX_PROFILE=${shellQuote(opts.codexProfile)} `
|
|
197
|
+
: "";
|
|
192
198
|
const lifecycleEnv = [
|
|
193
199
|
"TFX_HARD_CEILING_SEC",
|
|
194
200
|
"TFX_STALL_THRESHOLD",
|
|
@@ -199,7 +205,7 @@ function buildRouteBackedHeadlessCommand(
|
|
|
199
205
|
.join("");
|
|
200
206
|
const script =
|
|
201
207
|
`__tfx_prompt=$(cat ${shellQuote(promptFile)}); ` +
|
|
202
|
-
`${lifecycleEnv}TFX_CLI_MODE=${shellQuote(routeMode)} ` +
|
|
208
|
+
`${lifecycleEnv}${codexProfileEnv}TFX_CLI_MODE=${shellQuote(routeMode)} ` +
|
|
203
209
|
`bash ${shellQuote(routeScript)} ${shellQuote(routeAgent)} "$__tfx_prompt" ${shellQuote(mcp)}${timeoutArg} ` +
|
|
204
210
|
`> ${shellQuote(resultFile)} 2>${shellQuote(`${resultFile}.err`)} < /dev/null`;
|
|
205
211
|
return `bash -lc ${shellQuote(script)}`;
|
|
@@ -214,6 +220,7 @@ export function getHeadlessLeadAgentId(sessionName) {
|
|
|
214
220
|
}
|
|
215
221
|
|
|
216
222
|
const HUB_CLI_VALUES = new Set(["codex", "gemini", "claude", "other"]);
|
|
223
|
+
const presenceOwnerByAgent = new Map();
|
|
217
224
|
function normalizeCliForHub(cli) {
|
|
218
225
|
return HUB_CLI_VALUES.has(cli) ? cli : "other";
|
|
219
226
|
}
|
|
@@ -224,14 +231,23 @@ export async function registerHeadlessWorker(
|
|
|
224
231
|
cli,
|
|
225
232
|
requestJsonFn = requestJson,
|
|
226
233
|
) {
|
|
227
|
-
|
|
234
|
+
const agentId = getHeadlessWorkerAgentId(sessionName, index);
|
|
235
|
+
const result = await requestJsonFn("/bridge/register", {
|
|
228
236
|
body: {
|
|
229
|
-
agent_id:
|
|
237
|
+
agent_id: agentId,
|
|
230
238
|
cli: normalizeCliForHub(cli),
|
|
231
239
|
topics: ["headless.worker"],
|
|
232
240
|
capabilities: [cli],
|
|
233
241
|
},
|
|
234
|
-
}).catch(() =>
|
|
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;
|
|
235
251
|
}
|
|
236
252
|
|
|
237
253
|
export async function publishHeadlessResult(
|
|
@@ -259,8 +275,19 @@ export async function deregisterHeadlessWorkers(
|
|
|
259
275
|
await Promise.all(
|
|
260
276
|
Array.from({ length: workerCount }, (_, index) =>
|
|
261
277
|
requestJsonFn("/bridge/deregister", {
|
|
262
|
-
body: {
|
|
263
|
-
|
|
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
|
+
),
|
|
264
291
|
),
|
|
265
292
|
);
|
|
266
293
|
}
|
|
@@ -366,10 +393,22 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
|
366
393
|
writeFileSync(promptFile, fullPrompt, "utf8");
|
|
367
394
|
void IS_WINDOWS; // referenced for diagnostic guard chain below
|
|
368
395
|
|
|
396
|
+
// Codex와 Antigravity는 tfx-route.sh를 통해서만 headless 실행한다. 이 경로가
|
|
397
|
+
// disable/fallback/fail-loud 정책의 유일한 판정원이다. 여기서 같은 정책을
|
|
398
|
+
// 재구현하면 shell route와 headless backend가 서로 다른 결론을 낼 수 있다.
|
|
399
|
+
const codexProfile =
|
|
400
|
+
resolvedCli === "codex"
|
|
401
|
+
? resolveNestedCodexAgentProfile(opts.role || cli, {
|
|
402
|
+
profileOverride: opts.profile ?? "auto",
|
|
403
|
+
globalProfile: process.env.TFX_CODEX_PROFILE,
|
|
404
|
+
codexHome: process.env.CODEX_HOME,
|
|
405
|
+
})
|
|
406
|
+
: undefined;
|
|
369
407
|
const backendCommand =
|
|
370
|
-
resolvedCli === "antigravity"
|
|
408
|
+
resolvedCli === "codex" || resolvedCli === "antigravity"
|
|
371
409
|
? buildRouteBackedHeadlessCommand(resolvedCli, promptFile, resultFile, {
|
|
372
410
|
...opts,
|
|
411
|
+
codexProfile,
|
|
373
412
|
mcp,
|
|
374
413
|
})
|
|
375
414
|
: getBackend(resolvedCli).buildArgs(fullPrompt, resultFile, {
|
|
@@ -571,7 +610,8 @@ export function createStallMonitor(paneId, resultFile, config, deps = {}) {
|
|
|
571
610
|
|
|
572
611
|
function createHeadlessIntervention(dispatch, fallback) {
|
|
573
612
|
return async (context) => {
|
|
574
|
-
if (typeof fallback === "function")
|
|
613
|
+
if (typeof fallback === "function")
|
|
614
|
+
return (await fallback(context)) === true;
|
|
575
615
|
|
|
576
616
|
const isDaemon = context.channel === "daemon";
|
|
577
617
|
const readActivitySignature = isDaemon
|
|
@@ -606,6 +646,8 @@ function createHeadlessIntervention(dispatch, fallback) {
|
|
|
606
646
|
},
|
|
607
647
|
readActivitySignature,
|
|
608
648
|
deps: {
|
|
649
|
+
// 이 트랙이 재실행 소유자다. pane 채널은 기존 completion wrapper를 다시
|
|
650
|
+
// dispatch하여 token/result 계약을 보존한다.
|
|
609
651
|
resumeHandler: async () => {
|
|
610
652
|
if (isDaemon || !dispatch.command) return { ok: false };
|
|
611
653
|
const restarted = dispatchCommand(
|
|
@@ -631,6 +673,7 @@ export function createActivityPollGuard({
|
|
|
631
673
|
const controller = new AbortController();
|
|
632
674
|
let lastMtime = 0;
|
|
633
675
|
let checking = false;
|
|
676
|
+
|
|
634
677
|
const finishIfTimedOut = async () => {
|
|
635
678
|
if (checking || controller.signal.aborted) return;
|
|
636
679
|
checking = true;
|
|
@@ -640,6 +683,7 @@ export function createActivityPollGuard({
|
|
|
640
683
|
checking = false;
|
|
641
684
|
}
|
|
642
685
|
};
|
|
686
|
+
|
|
643
687
|
return Object.freeze({
|
|
644
688
|
signal: controller.signal,
|
|
645
689
|
observe(content) {
|
|
@@ -910,15 +954,55 @@ export async function waitForCompletionWithStallDetect(
|
|
|
910
954
|
}
|
|
911
955
|
}
|
|
912
956
|
|
|
957
|
+
function createHeadlessSessionOwnership(killSession) {
|
|
958
|
+
let owned = false;
|
|
959
|
+
let ownedSessionName = "";
|
|
960
|
+
|
|
961
|
+
return {
|
|
962
|
+
get owned() {
|
|
963
|
+
return owned;
|
|
964
|
+
},
|
|
965
|
+
|
|
966
|
+
acquire(sessionName) {
|
|
967
|
+
if (owned) return false;
|
|
968
|
+
ownedSessionName = String(sessionName);
|
|
969
|
+
owned = true;
|
|
970
|
+
return true;
|
|
971
|
+
},
|
|
972
|
+
|
|
973
|
+
release() {
|
|
974
|
+
if (!owned) return false;
|
|
975
|
+
const sessionName = ownedSessionName;
|
|
976
|
+
owned = false;
|
|
977
|
+
ownedSessionName = "";
|
|
978
|
+
try {
|
|
979
|
+
killSession(sessionName);
|
|
980
|
+
} catch {
|
|
981
|
+
/* cleanup is best-effort and ownership release is idempotent */
|
|
982
|
+
}
|
|
983
|
+
return true;
|
|
984
|
+
},
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
913
988
|
/** progressive 스플릿 모드: lead pane만 생성 후, 워커를 하나씩 추가하며 dispatch */
|
|
914
989
|
async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
915
|
-
const {
|
|
990
|
+
const {
|
|
991
|
+
layout,
|
|
992
|
+
safeProgress,
|
|
993
|
+
dashboardLayout = "single",
|
|
994
|
+
sessionOwnership,
|
|
995
|
+
_deps: deps = {},
|
|
996
|
+
} = opts;
|
|
916
997
|
const resolvedDashboardLayout = resolveDashboardLayout(
|
|
917
998
|
dashboardLayout,
|
|
918
999
|
assignments.length,
|
|
919
1000
|
);
|
|
920
|
-
const
|
|
921
|
-
applyTrifluxTheme
|
|
1001
|
+
const createSession = deps.createPsmuxSession || createPsmuxSession;
|
|
1002
|
+
const applyTheme = deps.applyTrifluxTheme || applyTrifluxTheme;
|
|
1003
|
+
const session = createSession(sessionName, { layout, paneCount: 1 });
|
|
1004
|
+
sessionOwnership.acquire(session.sessionName || sessionName);
|
|
1005
|
+
applyTheme(sessionName);
|
|
922
1006
|
if (safeProgress) {
|
|
923
1007
|
safeProgress({
|
|
924
1008
|
type: "session_created",
|
|
@@ -989,6 +1073,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
|
989
1073
|
mcp: assignment.mcp,
|
|
990
1074
|
role: assignment.role,
|
|
991
1075
|
model: assignment.model,
|
|
1076
|
+
profile: assignment.profile,
|
|
992
1077
|
cwd: assignment.cwd || assignment.workdir,
|
|
993
1078
|
},
|
|
994
1079
|
);
|
|
@@ -1029,7 +1114,13 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
|
1029
1114
|
|
|
1030
1115
|
/** 기존 batch 모드: 모든 pane을 한 번에 생성하여 dispatch */
|
|
1031
1116
|
async function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
1032
|
-
const {
|
|
1117
|
+
const {
|
|
1118
|
+
layout,
|
|
1119
|
+
safeProgress,
|
|
1120
|
+
dashboardLayout = "single",
|
|
1121
|
+
sessionOwnership,
|
|
1122
|
+
_deps: deps = {},
|
|
1123
|
+
} = opts;
|
|
1033
1124
|
const paneCount = assignments.length + 1;
|
|
1034
1125
|
const resolvedDashboardLayout = resolveDashboardLayout(
|
|
1035
1126
|
dashboardLayout,
|
|
@@ -1037,11 +1128,14 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
1037
1128
|
);
|
|
1038
1129
|
// A2b fix: 2x2 레이아웃은 최대 4 pane — 초과 시 tiled로 자동 전환
|
|
1039
1130
|
const effectiveLayout = layout === "2x2" && paneCount > 4 ? "tiled" : layout;
|
|
1040
|
-
const
|
|
1131
|
+
const createSession = deps.createPsmuxSession || createPsmuxSession;
|
|
1132
|
+
const applyTheme = deps.applyTrifluxTheme || applyTrifluxTheme;
|
|
1133
|
+
const session = createSession(sessionName, {
|
|
1041
1134
|
layout: effectiveLayout,
|
|
1042
1135
|
paneCount,
|
|
1043
1136
|
});
|
|
1044
|
-
|
|
1137
|
+
sessionOwnership.acquire(session.sessionName || sessionName);
|
|
1138
|
+
applyTheme(sessionName);
|
|
1045
1139
|
if (safeProgress) {
|
|
1046
1140
|
safeProgress({
|
|
1047
1141
|
type: "session_created",
|
|
@@ -1071,6 +1165,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
1071
1165
|
mcp: assignment.mcp,
|
|
1072
1166
|
role: assignment.role,
|
|
1073
1167
|
model: assignment.model,
|
|
1168
|
+
profile: assignment.profile,
|
|
1074
1169
|
cwd: assignment.cwd || assignment.workdir,
|
|
1075
1170
|
},
|
|
1076
1171
|
);
|
|
@@ -1134,6 +1229,7 @@ async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
|
|
|
1134
1229
|
mcp: assignment.mcp,
|
|
1135
1230
|
role: assignment.role,
|
|
1136
1231
|
model: assignment.model,
|
|
1232
|
+
profile: assignment.profile,
|
|
1137
1233
|
cwd: assignment.cwd || assignment.workdir,
|
|
1138
1234
|
},
|
|
1139
1235
|
);
|
|
@@ -1306,12 +1402,11 @@ async function awaitAll(
|
|
|
1306
1402
|
{
|
|
1307
1403
|
pollInterval: stallOpts.pollInterval,
|
|
1308
1404
|
stallTimeout: stallOpts.stallTimeout,
|
|
1309
|
-
hardCeiling:
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
: (stallOpts.completionTimeout ?? timeoutSec * 1000),
|
|
1405
|
+
hardCeiling: lifecycle?.enabled
|
|
1406
|
+
? (stallOpts.hardCeiling ??
|
|
1407
|
+
stallOpts.completionTimeout ??
|
|
1408
|
+
lifecycle.hardCeilingMs)
|
|
1409
|
+
: (stallOpts.completionTimeout ?? timeoutSec * 1000),
|
|
1315
1410
|
maxRestarts: stallOpts.maxRestarts,
|
|
1316
1411
|
maxInterventions: stallOpts.maxInterventions,
|
|
1317
1412
|
command: d.command,
|
|
@@ -1443,7 +1538,10 @@ export async function waitForDaemonCompletion(
|
|
|
1443
1538
|
? createActivityLifecycle({
|
|
1444
1539
|
interventionMs: lifecycle.interventionMs,
|
|
1445
1540
|
hardCeilingMs: lifecycle.hardCeilingMs,
|
|
1446
|
-
onIntervene: createHeadlessIntervention(
|
|
1541
|
+
onIntervene: createHeadlessIntervention(
|
|
1542
|
+
dispatch,
|
|
1543
|
+
lifecycle.onIntervene,
|
|
1544
|
+
),
|
|
1447
1545
|
})
|
|
1448
1546
|
: null;
|
|
1449
1547
|
|
|
@@ -1677,21 +1775,28 @@ function collectGitDiffFiles(cwd) {
|
|
|
1677
1775
|
* @param {number} [opts.progressIntervalSec=0] — N초마다 progress 이벤트 발화 (0=비활성)
|
|
1678
1776
|
* @param {boolean} [opts.progressive=true] — true면 pane을 하나씩 split-window로 추가 (실시간 스플릿)
|
|
1679
1777
|
* @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
|
|
1680
|
-
* @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}
|
|
1778
|
+
* @returns {{ sessionName: string, results: Array<{cli: string, paneName: string, matched: boolean, exitCode: number|null, output: string, sessionDead?: boolean}>, sessionOwnership: {owned: boolean, acquire: (sessionName: string) => boolean, release: () => boolean} }}
|
|
1681
1779
|
*/
|
|
1682
1780
|
export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
1781
|
+
const deps = opts._deps || {};
|
|
1782
|
+
const sessionOwnership =
|
|
1783
|
+
opts._sessionOwnership ||
|
|
1784
|
+
createHeadlessSessionOwnership(deps.killPsmuxSession || killPsmuxSession);
|
|
1683
1785
|
let {
|
|
1684
1786
|
timeoutSec = 900,
|
|
1685
1787
|
layout = "2x2",
|
|
1686
1788
|
onProgress,
|
|
1687
1789
|
progressIntervalSec = 0,
|
|
1688
1790
|
progressive = true,
|
|
1791
|
+
autoAttach = false,
|
|
1689
1792
|
dashboard = false,
|
|
1690
1793
|
dashboardLayout = "single",
|
|
1691
1794
|
stallDetect,
|
|
1692
1795
|
nativeBridge = false,
|
|
1693
1796
|
nativeBridgeMode = "roster",
|
|
1694
1797
|
onIntervene,
|
|
1798
|
+
leadPane = null,
|
|
1799
|
+
leadTmux = null,
|
|
1695
1800
|
} = opts;
|
|
1696
1801
|
if (!Number.isFinite(Number(timeoutSec)) || Number(timeoutSec) <= 0)
|
|
1697
1802
|
timeoutSec = 900;
|
|
@@ -1709,11 +1814,15 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1709
1814
|
);
|
|
1710
1815
|
|
|
1711
1816
|
if (normalizedAssignments.length === 0) {
|
|
1712
|
-
return { sessionName, results: [] };
|
|
1817
|
+
return { sessionName, results: [], sessionOwnership };
|
|
1713
1818
|
}
|
|
1714
1819
|
|
|
1715
1820
|
let nativeBridgeHandle = null;
|
|
1716
1821
|
let daemonDispatches = [];
|
|
1822
|
+
let runCompleted = false;
|
|
1823
|
+
let completedResults = [];
|
|
1824
|
+
let runFailed = false;
|
|
1825
|
+
let runError;
|
|
1717
1826
|
if (nativeBridge && nativeBridgeMode === "claude-wrapper") {
|
|
1718
1827
|
throw new Error(
|
|
1719
1828
|
"[headless] --native-bridge-mode claude-wrapper is reserved and not implemented yet",
|
|
@@ -1730,11 +1839,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1730
1839
|
workerType:
|
|
1731
1840
|
nativeBridgeMode === "interactive-attach" ? "interactive" : "headless",
|
|
1732
1841
|
onKill() {
|
|
1733
|
-
|
|
1734
|
-
killPsmuxSession(sessionName);
|
|
1735
|
-
} catch {
|
|
1736
|
-
/* session may not exist yet */
|
|
1737
|
-
}
|
|
1842
|
+
sessionOwnership.release();
|
|
1738
1843
|
},
|
|
1739
1844
|
});
|
|
1740
1845
|
process.stderr.write(
|
|
@@ -1873,11 +1978,27 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1873
1978
|
const idx = parseInt(match[1], 10) - 1;
|
|
1874
1979
|
const agentId = getHeadlessWorkerAgentId(sessionName, idx);
|
|
1875
1980
|
const nowMs = Date.now();
|
|
1876
|
-
if (
|
|
1981
|
+
if (
|
|
1982
|
+
(lastHubBeatByWorker.get(agentId) || 0) + HUB_ACTIVITY_MIN_INTERVAL_MS >
|
|
1983
|
+
nowMs
|
|
1984
|
+
)
|
|
1985
|
+
return;
|
|
1877
1986
|
lastHubBeatByWorker.set(agentId, nowMs);
|
|
1878
|
-
requestJson("/bridge/heartbeat", {
|
|
1987
|
+
requestJson("/bridge/heartbeat", {
|
|
1988
|
+
method: "POST",
|
|
1989
|
+
body: {
|
|
1990
|
+
agent_id: agentId,
|
|
1991
|
+
...(presenceOwnerByAgent.get(agentId) || {}),
|
|
1992
|
+
},
|
|
1993
|
+
timeoutMs: 500,
|
|
1994
|
+
}).catch(() => {});
|
|
1879
1995
|
const assignJobId = normalizedAssignments[idx]?.assignJobId;
|
|
1880
|
-
if (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(() => {});
|
|
1881
2002
|
};
|
|
1882
2003
|
|
|
1883
2004
|
// onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
|
|
@@ -1903,24 +2024,62 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1903
2024
|
};
|
|
1904
2025
|
|
|
1905
2026
|
try {
|
|
2027
|
+
const progressiveDispatch = deps.dispatchProgressive || dispatchProgressive;
|
|
2028
|
+
const batchDispatch = deps.dispatchBatch || dispatchBatch;
|
|
1906
2029
|
const dispatches =
|
|
1907
2030
|
nativeBridge && nativeBridgeMode === "agents"
|
|
1908
2031
|
? await dispatchDaemonBatch(sessionName, normalizedAssignments, {
|
|
1909
2032
|
safeProgress,
|
|
1910
2033
|
})
|
|
1911
2034
|
: progressive
|
|
1912
|
-
? await
|
|
2035
|
+
? await progressiveDispatch(sessionName, normalizedAssignments, {
|
|
1913
2036
|
layout,
|
|
1914
2037
|
safeProgress,
|
|
1915
2038
|
dashboardLayout,
|
|
2039
|
+
sessionOwnership,
|
|
2040
|
+
_deps: deps,
|
|
1916
2041
|
})
|
|
1917
|
-
: await
|
|
2042
|
+
: await batchDispatch(sessionName, normalizedAssignments, {
|
|
1918
2043
|
layout,
|
|
1919
2044
|
safeProgress,
|
|
1920
2045
|
dashboardLayout,
|
|
2046
|
+
sessionOwnership,
|
|
2047
|
+
_deps: deps,
|
|
1921
2048
|
});
|
|
1922
2049
|
if (nativeBridge && nativeBridgeMode === "agents") {
|
|
1923
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,
|
|
2058
|
+
dashboardLayout,
|
|
2059
|
+
leadPane,
|
|
2060
|
+
leadTmux,
|
|
2061
|
+
onProgress: safeProgress,
|
|
2062
|
+
_deps: deps,
|
|
2063
|
+
});
|
|
2064
|
+
} catch (error) {
|
|
2065
|
+
emitObserverWarning(
|
|
2066
|
+
safeProgress,
|
|
2067
|
+
sessionName,
|
|
2068
|
+
"observer_session_create",
|
|
2069
|
+
error,
|
|
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",
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
1924
2083
|
}
|
|
1925
2084
|
|
|
1926
2085
|
const results =
|
|
@@ -1967,8 +2126,15 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1967
2126
|
tui.close();
|
|
1968
2127
|
}
|
|
1969
2128
|
|
|
1970
|
-
|
|
1971
|
-
|
|
2129
|
+
runCompleted = true;
|
|
2130
|
+
completedResults = collected;
|
|
2131
|
+
} catch (error) {
|
|
2132
|
+
runFailed = true;
|
|
2133
|
+
runError = error;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
if (!runCompleted) sessionOwnership.release();
|
|
2137
|
+
try {
|
|
1972
2138
|
// Synapse: 세션 unregister (fire-and-forget)
|
|
1973
2139
|
for (const sid of synapseIds) {
|
|
1974
2140
|
requestJson("/synapse/unregister", {
|
|
@@ -1981,7 +2147,16 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1981
2147
|
if (daemonDispatches.length > 0) {
|
|
1982
2148
|
await cleanupDaemonDispatches(daemonDispatches);
|
|
1983
2149
|
}
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
sessionOwnership.release();
|
|
2152
|
+
if (!runFailed) {
|
|
2153
|
+
runFailed = true;
|
|
2154
|
+
runError = error;
|
|
2155
|
+
}
|
|
1984
2156
|
}
|
|
2157
|
+
|
|
2158
|
+
if (runFailed) throw runError;
|
|
2159
|
+
return { sessionName, results: completedResults, sessionOwnership };
|
|
1985
2160
|
}
|
|
1986
2161
|
|
|
1987
2162
|
/**
|
|
@@ -1995,20 +2170,27 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1995
2170
|
export async function runHeadlessWithCleanup(assignments, opts = {}) {
|
|
1996
2171
|
const { sessionPrefix = "tfx-hl", ...runOpts } = opts;
|
|
1997
2172
|
const sessionName = `${sessionPrefix}-${Date.now().toString(36).slice(-6)}`;
|
|
2173
|
+
const deps = opts._deps || {};
|
|
2174
|
+
const run = deps.runHeadless || runHeadless;
|
|
2175
|
+
const sessionOwnership = createHeadlessSessionOwnership(
|
|
2176
|
+
deps.killPsmuxSession || killPsmuxSession,
|
|
2177
|
+
);
|
|
1998
2178
|
|
|
1999
2179
|
try {
|
|
2000
|
-
return await
|
|
2180
|
+
return await run(sessionName, assignments, {
|
|
2181
|
+
...runOpts,
|
|
2182
|
+
_sessionOwnership: sessionOwnership,
|
|
2183
|
+
});
|
|
2001
2184
|
} finally {
|
|
2002
|
-
for (let index = 0; index < assignments.length; index++) {
|
|
2003
|
-
unregisterHeadlessSynapseWorker(
|
|
2004
|
-
getHeadlessWorkerAgentId(sessionName, index),
|
|
2005
|
-
);
|
|
2006
|
-
}
|
|
2007
|
-
await deregisterHeadlessWorkers(sessionName, assignments.length);
|
|
2008
2185
|
try {
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
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();
|
|
2012
2194
|
}
|
|
2013
2195
|
// WT split pane은 psmux 종료 시 셸이 끝나면서 자동으로 닫힘
|
|
2014
2196
|
// 수동 close-pane 불필요 (레이스 컨디션으로 WT 에러 발생)
|
|
@@ -2125,8 +2307,276 @@ function buildAttachTitle(sessionName, suffix = "") {
|
|
|
2125
2307
|
return suffix ? `${base} ${suffix}` : base;
|
|
2126
2308
|
}
|
|
2127
2309
|
|
|
2310
|
+
function observerWarningMessage(cause, fallbackMessage) {
|
|
2311
|
+
if (cause instanceof Error && cause.message) return cause.message;
|
|
2312
|
+
const message = String(cause ?? "").trim();
|
|
2313
|
+
return message || fallbackMessage;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
function safelyDeliverProgress(onProgress, event) {
|
|
2317
|
+
if (typeof onProgress !== "function") return;
|
|
2318
|
+
try {
|
|
2319
|
+
const result = onProgress(event);
|
|
2320
|
+
if (result?.catch) void result.catch(() => {});
|
|
2321
|
+
} catch {
|
|
2322
|
+
/* observer diagnostics are best-effort and must remain fail-open */
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function emitObserverWarning(
|
|
2327
|
+
onProgress,
|
|
2328
|
+
sessionName,
|
|
2329
|
+
stage,
|
|
2330
|
+
cause,
|
|
2331
|
+
fallbackMessage,
|
|
2332
|
+
) {
|
|
2333
|
+
safelyDeliverProgress(onProgress, {
|
|
2334
|
+
type: "observer_warning",
|
|
2335
|
+
stage,
|
|
2336
|
+
message: observerWarningMessage(cause, fallbackMessage),
|
|
2337
|
+
sessionName: sanitizeSessionName(sessionName),
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2128
2341
|
// ─── v6.0.0: Lead-Direct Interactive Mode ───
|
|
2129
2342
|
|
|
2343
|
+
/**
|
|
2344
|
+
* daemon/native-bridge worker를 read-only로 관찰하는 tmux session을 만든다.
|
|
2345
|
+
* 실제 worker process는 daemon에 남고, viewer는 result/partial/stderr 파일만 읽는다.
|
|
2346
|
+
*
|
|
2347
|
+
* @param {string} sessionName
|
|
2348
|
+
* @param {Array<{resultFile:string}>} dispatches
|
|
2349
|
+
* @param {object} [opts]
|
|
2350
|
+
* @returns {{sessionName:string, panes:string[], dashboardLayout:string}|null}
|
|
2351
|
+
*/
|
|
2352
|
+
export function createDaemonObservationSession(
|
|
2353
|
+
sessionName,
|
|
2354
|
+
dispatches,
|
|
2355
|
+
opts = {},
|
|
2356
|
+
) {
|
|
2357
|
+
const deps = opts._deps || {};
|
|
2358
|
+
const platform = deps.platform ?? process.platform;
|
|
2359
|
+
const leadPane = String(opts.leadPane || "");
|
|
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
|
+
}
|
|
2422
|
+
emitObserverWarning(
|
|
2423
|
+
opts.onProgress,
|
|
2424
|
+
safeSessionName,
|
|
2425
|
+
stage,
|
|
2426
|
+
error,
|
|
2427
|
+
"observer setup failed",
|
|
2428
|
+
);
|
|
2429
|
+
return null;
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
/**
|
|
2434
|
+
* attached tmux의 현재 leader pane 옆에 headless session view를 연다.
|
|
2435
|
+
* 새 pane의 attach client는 headless session 종료와 함께 자연스럽게 종료된다.
|
|
2436
|
+
*
|
|
2437
|
+
* @param {string} sessionName
|
|
2438
|
+
* @param {object} [opts]
|
|
2439
|
+
* @returns {boolean} split 요청 성공 여부
|
|
2440
|
+
*/
|
|
2441
|
+
export function attachHeadlessTmuxPane(sessionName, opts = {}) {
|
|
2442
|
+
const deps = opts._deps || {};
|
|
2443
|
+
const platform = deps.platform ?? process.platform;
|
|
2444
|
+
if (platform !== "darwin" && platform !== "linux") return false;
|
|
2445
|
+
const tmuxEnv = String(opts.tmuxEnv || "");
|
|
2446
|
+
if (!tmuxEnv.trim()) return false;
|
|
2447
|
+
|
|
2448
|
+
const socketPath = tmuxEnv.split(",", 1)[0];
|
|
2449
|
+
if (!socketPath.trim()) return false;
|
|
2450
|
+
|
|
2451
|
+
const targetPane = String(opts.targetPane || "");
|
|
2452
|
+
if (!/^%\d+$/u.test(targetPane)) return false;
|
|
2453
|
+
|
|
2454
|
+
const exec = deps.psmuxExec || psmuxExec;
|
|
2455
|
+
try {
|
|
2456
|
+
exec([
|
|
2457
|
+
"split-window",
|
|
2458
|
+
"-v",
|
|
2459
|
+
"-l",
|
|
2460
|
+
"30%",
|
|
2461
|
+
"-t",
|
|
2462
|
+
targetPane,
|
|
2463
|
+
"env",
|
|
2464
|
+
"-u",
|
|
2465
|
+
"TMUX",
|
|
2466
|
+
"tmux",
|
|
2467
|
+
"-S",
|
|
2468
|
+
socketPath,
|
|
2469
|
+
"attach-session",
|
|
2470
|
+
"-t",
|
|
2471
|
+
sanitizeSessionName(sessionName),
|
|
2472
|
+
]);
|
|
2473
|
+
return true;
|
|
2474
|
+
} catch (error) {
|
|
2475
|
+
emitObserverWarning(
|
|
2476
|
+
opts.onProgress,
|
|
2477
|
+
sessionName,
|
|
2478
|
+
"tmux_split",
|
|
2479
|
+
error,
|
|
2480
|
+
"tmux split failed",
|
|
2481
|
+
);
|
|
2482
|
+
return false;
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/**
|
|
2487
|
+
* session_created auto-attach를 정확히 한 번 라우팅하는 progress handler.
|
|
2488
|
+
* Windows는 기존 WT helper를, macOS/Linux는 현재 tmux window split을 쓴다.
|
|
2489
|
+
*
|
|
2490
|
+
* @param {string} sessionName
|
|
2491
|
+
* @param {number} workerCount
|
|
2492
|
+
* @param {object} [opts]
|
|
2493
|
+
* @returns {(event: object) => void}
|
|
2494
|
+
*/
|
|
2495
|
+
export function createHeadlessAutoAttachHandler(
|
|
2496
|
+
sessionName,
|
|
2497
|
+
workerCount,
|
|
2498
|
+
opts = {},
|
|
2499
|
+
) {
|
|
2500
|
+
const {
|
|
2501
|
+
autoAttach = false,
|
|
2502
|
+
dashboard = false,
|
|
2503
|
+
dashboardLayout = "single",
|
|
2504
|
+
dashboardSize = 0.4,
|
|
2505
|
+
dashboardAnchor = "window",
|
|
2506
|
+
leadPane = null,
|
|
2507
|
+
leadTmux = null,
|
|
2508
|
+
onProgress,
|
|
2509
|
+
} = opts;
|
|
2510
|
+
const deps = opts._deps || {};
|
|
2511
|
+
let terminalAttached = false;
|
|
2512
|
+
|
|
2513
|
+
return (event) => {
|
|
2514
|
+
if (autoAttach && event.type === "session_created" && !terminalAttached) {
|
|
2515
|
+
terminalAttached = true;
|
|
2516
|
+
let warningReported = false;
|
|
2517
|
+
const reportAttachWarning = (warningEvent) => {
|
|
2518
|
+
warningReported = true;
|
|
2519
|
+
safelyDeliverProgress(onProgress, warningEvent);
|
|
2520
|
+
};
|
|
2521
|
+
const reportAttachFailure = (cause, fallbackMessage) => {
|
|
2522
|
+
warningReported = true;
|
|
2523
|
+
emitObserverWarning(
|
|
2524
|
+
onProgress,
|
|
2525
|
+
sessionName,
|
|
2526
|
+
"auto_attach",
|
|
2527
|
+
cause,
|
|
2528
|
+
fallbackMessage,
|
|
2529
|
+
);
|
|
2530
|
+
};
|
|
2531
|
+
try {
|
|
2532
|
+
const platform = deps.platform ?? process.platform;
|
|
2533
|
+
let attachResult;
|
|
2534
|
+
if (platform === "win32" && dashboard) {
|
|
2535
|
+
const attach = deps.attachDashboardTab || attachDashboardTab;
|
|
2536
|
+
attachResult = attach(
|
|
2537
|
+
sessionName,
|
|
2538
|
+
workerCount,
|
|
2539
|
+
event.dashboardLayout ||
|
|
2540
|
+
resolveDashboardLayout(dashboardLayout, workerCount),
|
|
2541
|
+
dashboardSize,
|
|
2542
|
+
dashboardAnchor,
|
|
2543
|
+
);
|
|
2544
|
+
} else if (platform === "win32") {
|
|
2545
|
+
const attach = deps.autoAttachTerminal || autoAttachTerminal;
|
|
2546
|
+
attachResult = attach(
|
|
2547
|
+
sessionName,
|
|
2548
|
+
{ dashboardLayout, dashboardAnchor },
|
|
2549
|
+
workerCount,
|
|
2550
|
+
);
|
|
2551
|
+
} else {
|
|
2552
|
+
const attach = deps.attachHeadlessTmuxPane || attachHeadlessTmuxPane;
|
|
2553
|
+
attachResult = attach(sessionName, {
|
|
2554
|
+
targetPane: leadPane,
|
|
2555
|
+
tmuxEnv: leadTmux,
|
|
2556
|
+
_deps: deps,
|
|
2557
|
+
onProgress: reportAttachWarning,
|
|
2558
|
+
});
|
|
2559
|
+
}
|
|
2560
|
+
if (attachResult === false && !warningReported) {
|
|
2561
|
+
reportAttachFailure(null, "auto-attach returned false");
|
|
2562
|
+
} else if (attachResult?.then) {
|
|
2563
|
+
void Promise.resolve(attachResult).then(
|
|
2564
|
+
(result) => {
|
|
2565
|
+
if (result === false && !warningReported) {
|
|
2566
|
+
reportAttachFailure(null, "auto-attach returned false");
|
|
2567
|
+
}
|
|
2568
|
+
},
|
|
2569
|
+
(error) => reportAttachFailure(error, "auto-attach rejected"),
|
|
2570
|
+
);
|
|
2571
|
+
}
|
|
2572
|
+
} catch (error) {
|
|
2573
|
+
reportAttachFailure(error, "auto-attach failed");
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
if (onProgress) onProgress(event);
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2130
2580
|
/**
|
|
2131
2581
|
* Windows Terminal에서 psmux 세션을 자동 attach한다.
|
|
2132
2582
|
* 1명은 새 탭 단일 attach, 2/3/4명은 새 탭 내 split-pane, 5명 이상은 dashboard로 전환한다.
|
|
@@ -2275,7 +2725,7 @@ export function getProgressSnapshots(sessionName, dispatches, lines = 15) {
|
|
|
2275
2725
|
* @param {string} [opts.layout='2x2']
|
|
2276
2726
|
* @param {(event: object) => void} [opts.onProgress]
|
|
2277
2727
|
* @param {number} [opts.progressIntervalSec=0]
|
|
2278
|
-
* @param {boolean} [opts.autoAttach=false] —
|
|
2728
|
+
* @param {boolean} [opts.autoAttach=false] — 현재 platform의 관찰 pane/tab 자동 attach
|
|
2279
2729
|
* @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
|
|
2280
2730
|
* @param {AbortSignal} [opts.signal] — abort 시 자동 세션 정리
|
|
2281
2731
|
* @param {number} [opts.maxIdleSec=0] — 유휴 시 자동 정리 (0=비활성)
|
|
@@ -2301,52 +2751,53 @@ export async function runHeadlessInteractive(
|
|
|
2301
2751
|
dashboard = false,
|
|
2302
2752
|
dashboardSize = 0.4,
|
|
2303
2753
|
dashboardAnchor = "window",
|
|
2754
|
+
leadPane = null,
|
|
2755
|
+
leadTmux = null,
|
|
2304
2756
|
signal,
|
|
2305
2757
|
maxIdleSec = 0,
|
|
2306
2758
|
...runOpts
|
|
2307
2759
|
} = opts;
|
|
2308
2760
|
const headlessOpts = dashboard
|
|
2309
|
-
? { ...runOpts, dashboard: true }
|
|
2310
|
-
: { ...runOpts };
|
|
2761
|
+
? { ...runOpts, dashboard: true, leadPane, leadTmux }
|
|
2762
|
+
: { ...runOpts, leadPane, leadTmux };
|
|
2311
2763
|
|
|
2312
2764
|
// autoAttach를 session_created 시점에 트리거 (CLI 실행 전에 터미널 열림)
|
|
2313
2765
|
const userOnProgress = headlessOpts.onProgress;
|
|
2314
|
-
|
|
2315
|
-
const onProgress = (event) => {
|
|
2316
|
-
if (autoAttach && event.type === "session_created" && !terminalAttached) {
|
|
2317
|
-
terminalAttached = true;
|
|
2318
|
-
if (dashboard) {
|
|
2319
|
-
// v7.0: psmux attach로 대시보드+워커 전체 세션을 WT 탭에 표시
|
|
2320
|
-
attachDashboardTab(
|
|
2321
|
-
sessionName,
|
|
2322
|
-
assignments.length,
|
|
2323
|
-
event.dashboardLayout ||
|
|
2324
|
-
resolveDashboardLayout(
|
|
2325
|
-
headlessOpts.dashboardLayout,
|
|
2326
|
-
assignments.length,
|
|
2327
|
-
),
|
|
2328
|
-
dashboardSize,
|
|
2329
|
-
dashboardAnchor,
|
|
2330
|
-
);
|
|
2331
|
-
} else {
|
|
2332
|
-
autoAttachTerminal(
|
|
2333
|
-
sessionName,
|
|
2334
|
-
{ dashboardLayout: headlessOpts.dashboardLayout, dashboardAnchor },
|
|
2335
|
-
assignments.length,
|
|
2336
|
-
);
|
|
2337
|
-
}
|
|
2338
|
-
}
|
|
2339
|
-
if (userOnProgress) userOnProgress(event);
|
|
2340
|
-
};
|
|
2341
|
-
const interactiveRunOpts = { ...headlessOpts, onProgress };
|
|
2342
|
-
|
|
2343
|
-
// Phase 1: 세션 생성 → 즉시 터미널 팝업 → dispatch → 대기 → 결과 수집
|
|
2344
|
-
const { results } = await runHeadless(
|
|
2766
|
+
const onProgress = createHeadlessAutoAttachHandler(
|
|
2345
2767
|
sessionName,
|
|
2346
|
-
assignments,
|
|
2347
|
-
|
|
2768
|
+
assignments.length,
|
|
2769
|
+
{
|
|
2770
|
+
autoAttach,
|
|
2771
|
+
dashboard,
|
|
2772
|
+
dashboardLayout: headlessOpts.dashboardLayout,
|
|
2773
|
+
dashboardSize,
|
|
2774
|
+
dashboardAnchor,
|
|
2775
|
+
leadPane,
|
|
2776
|
+
leadTmux,
|
|
2777
|
+
onProgress: userOnProgress,
|
|
2778
|
+
_deps: headlessOpts._deps,
|
|
2779
|
+
},
|
|
2780
|
+
);
|
|
2781
|
+
const interactiveRunOpts = {
|
|
2782
|
+
...headlessOpts,
|
|
2783
|
+
autoAttach,
|
|
2784
|
+
dashboard,
|
|
2785
|
+
onProgress,
|
|
2786
|
+
};
|
|
2787
|
+
const deps = headlessOpts._deps || {};
|
|
2788
|
+
const run = deps.runHeadless || runHeadless;
|
|
2789
|
+
let sessionOwnership = createHeadlessSessionOwnership(
|
|
2790
|
+
deps.killPsmuxSession || killPsmuxSession,
|
|
2348
2791
|
);
|
|
2349
2792
|
|
|
2793
|
+
// Phase 1: 세션 생성 → 즉시 관찰 pane/tab attach → dispatch → 대기 → 결과 수집
|
|
2794
|
+
const runResult = await run(sessionName, assignments, {
|
|
2795
|
+
...interactiveRunOpts,
|
|
2796
|
+
_sessionOwnership: sessionOwnership,
|
|
2797
|
+
});
|
|
2798
|
+
const { results } = runResult;
|
|
2799
|
+
sessionOwnership = runResult.sessionOwnership || sessionOwnership;
|
|
2800
|
+
|
|
2350
2801
|
// Phase 2: 세션을 유지하고 interactive handle 반환
|
|
2351
2802
|
// Fix P2: paneId를 dispatches에 포함 (snapshots에서 필요)
|
|
2352
2803
|
const dispatches = results.map((r, i) => ({
|
|
@@ -2421,7 +2872,7 @@ export async function runHeadlessInteractive(
|
|
|
2421
2872
|
return psmuxSessionExists(sessionName);
|
|
2422
2873
|
},
|
|
2423
2874
|
|
|
2424
|
-
/** 세션 종료 —
|
|
2875
|
+
/** 세션 종료 — attach pane/tab은 psmux 종료 시 자동으로 닫힘 */
|
|
2425
2876
|
kill() {
|
|
2426
2877
|
if (this._killed) return;
|
|
2427
2878
|
this._killed = true;
|
|
@@ -2431,12 +2882,8 @@ export async function runHeadlessInteractive(
|
|
|
2431
2882
|
);
|
|
2432
2883
|
}
|
|
2433
2884
|
void deregisterHeadlessWorkers(sessionName, assignments.length);
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
} catch {
|
|
2437
|
-
/* 무시 */
|
|
2438
|
-
}
|
|
2439
|
-
// WT split pane은 psmux 종료 → 셸 종료 → 자동 닫힘
|
|
2885
|
+
sessionOwnership.release();
|
|
2886
|
+
// attach pane/tab은 psmux 종료 → attach client 종료 → 자동 닫힘
|
|
2440
2887
|
// 수동 close-pane 불필요 (레이스 컨디션으로 WT 0x80070002 에러 발생)
|
|
2441
2888
|
},
|
|
2442
2889
|
};
|