@triflux/remote 10.44.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/hub/pipe.mjs +10 -15
- package/hub/schema.sql +1 -0
- package/hub/server.mjs +172 -93
- package/hub/store.mjs +127 -61
- 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 +80 -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 +38 -2
- package/hub/team/headless.mjs +493 -82
- package/hub/team/interactive-tui-transport.mjs +15 -1
- package/hub/team/launcher-template.mjs +17 -1
- 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 +39 -16
- package/hub/team/session.mjs +57 -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/tools.mjs +10 -1
- package/hub/workers/codex-mcp.mjs +8 -3
- package/hub/workers/delegator-mcp.mjs +1 -1
- package/package.json +1 -1
- package/scripts/lib/agent-route-policy.mjs +84 -24
- package/scripts/lib/cli-agy.mjs +24 -4
- package/scripts/lib/cli-codex.mjs +2 -0
- package/scripts/lib/codex-profile-config.mjs +26 -8
- package/scripts/lib/codex-recovery.sh +19 -0
- package/scripts/lib/gemini-profiles.mjs +113 -29
- package/scripts/lib/keyword-rules.mjs +12 -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/hub/team/headless.mjs
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
killPsmuxSession,
|
|
60
60
|
psmuxExec,
|
|
61
61
|
psmuxSessionExists,
|
|
62
|
+
sendKeysToPane,
|
|
62
63
|
startCapture,
|
|
63
64
|
waitForCompletion,
|
|
64
65
|
} from "./psmux.mjs";
|
|
@@ -190,6 +191,10 @@ function buildRouteBackedHeadlessCommand(
|
|
|
190
191
|
: "";
|
|
191
192
|
const timeoutArg = timeout ? ` ${shellQuote(timeout)}` : "";
|
|
192
193
|
const routeMode = resolvedCli === "antigravity" ? "antigravity" : resolvedCli;
|
|
194
|
+
const codexProfileEnv =
|
|
195
|
+
resolvedCli === "codex" && opts.codexProfile
|
|
196
|
+
? `TFX_CODEX_PROFILE=${shellQuote(opts.codexProfile)} `
|
|
197
|
+
: "";
|
|
193
198
|
const lifecycleEnv = [
|
|
194
199
|
"TFX_HARD_CEILING_SEC",
|
|
195
200
|
"TFX_STALL_THRESHOLD",
|
|
@@ -200,7 +205,7 @@ function buildRouteBackedHeadlessCommand(
|
|
|
200
205
|
.join("");
|
|
201
206
|
const script =
|
|
202
207
|
`__tfx_prompt=$(cat ${shellQuote(promptFile)}); ` +
|
|
203
|
-
`${lifecycleEnv}TFX_CLI_MODE=${shellQuote(routeMode)} ` +
|
|
208
|
+
`${lifecycleEnv}${codexProfileEnv}TFX_CLI_MODE=${shellQuote(routeMode)} ` +
|
|
204
209
|
`bash ${shellQuote(routeScript)} ${shellQuote(routeAgent)} "$__tfx_prompt" ${shellQuote(mcp)}${timeoutArg} ` +
|
|
205
210
|
`> ${shellQuote(resultFile)} 2>${shellQuote(`${resultFile}.err`)} < /dev/null`;
|
|
206
211
|
return `bash -lc ${shellQuote(script)}`;
|
|
@@ -215,6 +220,7 @@ export function getHeadlessLeadAgentId(sessionName) {
|
|
|
215
220
|
}
|
|
216
221
|
|
|
217
222
|
const HUB_CLI_VALUES = new Set(["codex", "gemini", "claude", "other"]);
|
|
223
|
+
const presenceOwnerByAgent = new Map();
|
|
218
224
|
function normalizeCliForHub(cli) {
|
|
219
225
|
return HUB_CLI_VALUES.has(cli) ? cli : "other";
|
|
220
226
|
}
|
|
@@ -225,14 +231,23 @@ export async function registerHeadlessWorker(
|
|
|
225
231
|
cli,
|
|
226
232
|
requestJsonFn = requestJson,
|
|
227
233
|
) {
|
|
228
|
-
|
|
234
|
+
const agentId = getHeadlessWorkerAgentId(sessionName, index);
|
|
235
|
+
const result = await requestJsonFn("/bridge/register", {
|
|
229
236
|
body: {
|
|
230
|
-
agent_id:
|
|
237
|
+
agent_id: agentId,
|
|
231
238
|
cli: normalizeCliForHub(cli),
|
|
232
239
|
topics: ["headless.worker"],
|
|
233
240
|
capabilities: [cli],
|
|
234
241
|
},
|
|
235
|
-
}).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;
|
|
236
251
|
}
|
|
237
252
|
|
|
238
253
|
export async function publishHeadlessResult(
|
|
@@ -260,8 +275,19 @@ export async function deregisterHeadlessWorkers(
|
|
|
260
275
|
await Promise.all(
|
|
261
276
|
Array.from({ length: workerCount }, (_, index) =>
|
|
262
277
|
requestJsonFn("/bridge/deregister", {
|
|
263
|
-
body: {
|
|
264
|
-
|
|
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
|
+
),
|
|
265
291
|
),
|
|
266
292
|
);
|
|
267
293
|
}
|
|
@@ -367,6 +393,9 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
|
367
393
|
writeFileSync(promptFile, fullPrompt, "utf8");
|
|
368
394
|
void IS_WINDOWS; // referenced for diagnostic guard chain below
|
|
369
395
|
|
|
396
|
+
// Codex와 Antigravity는 tfx-route.sh를 통해서만 headless 실행한다. 이 경로가
|
|
397
|
+
// disable/fallback/fail-loud 정책의 유일한 판정원이다. 여기서 같은 정책을
|
|
398
|
+
// 재구현하면 shell route와 headless backend가 서로 다른 결론을 낼 수 있다.
|
|
370
399
|
const codexProfile =
|
|
371
400
|
resolvedCli === "codex"
|
|
372
401
|
? resolveNestedCodexAgentProfile(opts.role || cli, {
|
|
@@ -376,18 +405,15 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
|
376
405
|
})
|
|
377
406
|
: undefined;
|
|
378
407
|
const backendCommand =
|
|
379
|
-
resolvedCli === "antigravity"
|
|
408
|
+
resolvedCli === "codex" || resolvedCli === "antigravity"
|
|
380
409
|
? buildRouteBackedHeadlessCommand(resolvedCli, promptFile, resultFile, {
|
|
381
410
|
...opts,
|
|
411
|
+
codexProfile,
|
|
382
412
|
mcp,
|
|
383
413
|
})
|
|
384
414
|
: getBackend(resolvedCli).buildArgs(fullPrompt, resultFile, {
|
|
385
415
|
...opts,
|
|
386
416
|
model,
|
|
387
|
-
profile: codexProfile,
|
|
388
|
-
codexHome: process.env.CODEX_HOME,
|
|
389
|
-
disallowUltra: true,
|
|
390
|
-
enforceCanonicalProfile: true,
|
|
391
417
|
promptFile,
|
|
392
418
|
});
|
|
393
419
|
const safeCwd =
|
|
@@ -928,15 +954,55 @@ export async function waitForCompletionWithStallDetect(
|
|
|
928
954
|
}
|
|
929
955
|
}
|
|
930
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
|
+
|
|
931
988
|
/** progressive 스플릿 모드: lead pane만 생성 후, 워커를 하나씩 추가하며 dispatch */
|
|
932
989
|
async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
933
|
-
const {
|
|
990
|
+
const {
|
|
991
|
+
layout,
|
|
992
|
+
safeProgress,
|
|
993
|
+
dashboardLayout = "single",
|
|
994
|
+
sessionOwnership,
|
|
995
|
+
_deps: deps = {},
|
|
996
|
+
} = opts;
|
|
934
997
|
const resolvedDashboardLayout = resolveDashboardLayout(
|
|
935
998
|
dashboardLayout,
|
|
936
999
|
assignments.length,
|
|
937
1000
|
);
|
|
938
|
-
const
|
|
939
|
-
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);
|
|
940
1006
|
if (safeProgress) {
|
|
941
1007
|
safeProgress({
|
|
942
1008
|
type: "session_created",
|
|
@@ -1048,7 +1114,13 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
|
|
|
1048
1114
|
|
|
1049
1115
|
/** 기존 batch 모드: 모든 pane을 한 번에 생성하여 dispatch */
|
|
1050
1116
|
async function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
1051
|
-
const {
|
|
1117
|
+
const {
|
|
1118
|
+
layout,
|
|
1119
|
+
safeProgress,
|
|
1120
|
+
dashboardLayout = "single",
|
|
1121
|
+
sessionOwnership,
|
|
1122
|
+
_deps: deps = {},
|
|
1123
|
+
} = opts;
|
|
1052
1124
|
const paneCount = assignments.length + 1;
|
|
1053
1125
|
const resolvedDashboardLayout = resolveDashboardLayout(
|
|
1054
1126
|
dashboardLayout,
|
|
@@ -1056,11 +1128,14 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
1056
1128
|
);
|
|
1057
1129
|
// A2b fix: 2x2 레이아웃은 최대 4 pane — 초과 시 tiled로 자동 전환
|
|
1058
1130
|
const effectiveLayout = layout === "2x2" && paneCount > 4 ? "tiled" : layout;
|
|
1059
|
-
const
|
|
1131
|
+
const createSession = deps.createPsmuxSession || createPsmuxSession;
|
|
1132
|
+
const applyTheme = deps.applyTrifluxTheme || applyTrifluxTheme;
|
|
1133
|
+
const session = createSession(sessionName, {
|
|
1060
1134
|
layout: effectiveLayout,
|
|
1061
1135
|
paneCount,
|
|
1062
1136
|
});
|
|
1063
|
-
|
|
1137
|
+
sessionOwnership.acquire(session.sessionName || sessionName);
|
|
1138
|
+
applyTheme(sessionName);
|
|
1064
1139
|
if (safeProgress) {
|
|
1065
1140
|
safeProgress({
|
|
1066
1141
|
type: "session_created",
|
|
@@ -1700,21 +1775,28 @@ function collectGitDiffFiles(cwd) {
|
|
|
1700
1775
|
* @param {number} [opts.progressIntervalSec=0] — N초마다 progress 이벤트 발화 (0=비활성)
|
|
1701
1776
|
* @param {boolean} [opts.progressive=true] — true면 pane을 하나씩 split-window로 추가 (실시간 스플릿)
|
|
1702
1777
|
* @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
|
|
1703
|
-
* @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} }}
|
|
1704
1779
|
*/
|
|
1705
1780
|
export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
1781
|
+
const deps = opts._deps || {};
|
|
1782
|
+
const sessionOwnership =
|
|
1783
|
+
opts._sessionOwnership ||
|
|
1784
|
+
createHeadlessSessionOwnership(deps.killPsmuxSession || killPsmuxSession);
|
|
1706
1785
|
let {
|
|
1707
1786
|
timeoutSec = 900,
|
|
1708
1787
|
layout = "2x2",
|
|
1709
1788
|
onProgress,
|
|
1710
1789
|
progressIntervalSec = 0,
|
|
1711
1790
|
progressive = true,
|
|
1791
|
+
autoAttach = false,
|
|
1712
1792
|
dashboard = false,
|
|
1713
1793
|
dashboardLayout = "single",
|
|
1714
1794
|
stallDetect,
|
|
1715
1795
|
nativeBridge = false,
|
|
1716
1796
|
nativeBridgeMode = "roster",
|
|
1717
1797
|
onIntervene,
|
|
1798
|
+
leadPane = null,
|
|
1799
|
+
leadTmux = null,
|
|
1718
1800
|
} = opts;
|
|
1719
1801
|
if (!Number.isFinite(Number(timeoutSec)) || Number(timeoutSec) <= 0)
|
|
1720
1802
|
timeoutSec = 900;
|
|
@@ -1732,11 +1814,15 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1732
1814
|
);
|
|
1733
1815
|
|
|
1734
1816
|
if (normalizedAssignments.length === 0) {
|
|
1735
|
-
return { sessionName, results: [] };
|
|
1817
|
+
return { sessionName, results: [], sessionOwnership };
|
|
1736
1818
|
}
|
|
1737
1819
|
|
|
1738
1820
|
let nativeBridgeHandle = null;
|
|
1739
1821
|
let daemonDispatches = [];
|
|
1822
|
+
let runCompleted = false;
|
|
1823
|
+
let completedResults = [];
|
|
1824
|
+
let runFailed = false;
|
|
1825
|
+
let runError;
|
|
1740
1826
|
if (nativeBridge && nativeBridgeMode === "claude-wrapper") {
|
|
1741
1827
|
throw new Error(
|
|
1742
1828
|
"[headless] --native-bridge-mode claude-wrapper is reserved and not implemented yet",
|
|
@@ -1753,11 +1839,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1753
1839
|
workerType:
|
|
1754
1840
|
nativeBridgeMode === "interactive-attach" ? "interactive" : "headless",
|
|
1755
1841
|
onKill() {
|
|
1756
|
-
|
|
1757
|
-
killPsmuxSession(sessionName);
|
|
1758
|
-
} catch {
|
|
1759
|
-
/* session may not exist yet */
|
|
1760
|
-
}
|
|
1842
|
+
sessionOwnership.release();
|
|
1761
1843
|
},
|
|
1762
1844
|
});
|
|
1763
1845
|
process.stderr.write(
|
|
@@ -1904,7 +1986,10 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1904
1986
|
lastHubBeatByWorker.set(agentId, nowMs);
|
|
1905
1987
|
requestJson("/bridge/heartbeat", {
|
|
1906
1988
|
method: "POST",
|
|
1907
|
-
body: {
|
|
1989
|
+
body: {
|
|
1990
|
+
agent_id: agentId,
|
|
1991
|
+
...(presenceOwnerByAgent.get(agentId) || {}),
|
|
1992
|
+
},
|
|
1908
1993
|
timeoutMs: 500,
|
|
1909
1994
|
}).catch(() => {});
|
|
1910
1995
|
const assignJobId = normalizedAssignments[idx]?.assignJobId;
|
|
@@ -1939,24 +2024,62 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
1939
2024
|
};
|
|
1940
2025
|
|
|
1941
2026
|
try {
|
|
2027
|
+
const progressiveDispatch = deps.dispatchProgressive || dispatchProgressive;
|
|
2028
|
+
const batchDispatch = deps.dispatchBatch || dispatchBatch;
|
|
1942
2029
|
const dispatches =
|
|
1943
2030
|
nativeBridge && nativeBridgeMode === "agents"
|
|
1944
2031
|
? await dispatchDaemonBatch(sessionName, normalizedAssignments, {
|
|
1945
2032
|
safeProgress,
|
|
1946
2033
|
})
|
|
1947
2034
|
: progressive
|
|
1948
|
-
? await
|
|
2035
|
+
? await progressiveDispatch(sessionName, normalizedAssignments, {
|
|
1949
2036
|
layout,
|
|
1950
2037
|
safeProgress,
|
|
1951
2038
|
dashboardLayout,
|
|
2039
|
+
sessionOwnership,
|
|
2040
|
+
_deps: deps,
|
|
1952
2041
|
})
|
|
1953
|
-
: await
|
|
2042
|
+
: await batchDispatch(sessionName, normalizedAssignments, {
|
|
1954
2043
|
layout,
|
|
1955
2044
|
safeProgress,
|
|
1956
2045
|
dashboardLayout,
|
|
2046
|
+
sessionOwnership,
|
|
2047
|
+
_deps: deps,
|
|
1957
2048
|
});
|
|
1958
2049
|
if (nativeBridge && nativeBridgeMode === "agents") {
|
|
1959
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
|
+
}
|
|
1960
2083
|
}
|
|
1961
2084
|
|
|
1962
2085
|
const results =
|
|
@@ -2003,8 +2126,15 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
2003
2126
|
tui.close();
|
|
2004
2127
|
}
|
|
2005
2128
|
|
|
2006
|
-
|
|
2007
|
-
|
|
2129
|
+
runCompleted = true;
|
|
2130
|
+
completedResults = collected;
|
|
2131
|
+
} catch (error) {
|
|
2132
|
+
runFailed = true;
|
|
2133
|
+
runError = error;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
if (!runCompleted) sessionOwnership.release();
|
|
2137
|
+
try {
|
|
2008
2138
|
// Synapse: 세션 unregister (fire-and-forget)
|
|
2009
2139
|
for (const sid of synapseIds) {
|
|
2010
2140
|
requestJson("/synapse/unregister", {
|
|
@@ -2017,7 +2147,16 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
2017
2147
|
if (daemonDispatches.length > 0) {
|
|
2018
2148
|
await cleanupDaemonDispatches(daemonDispatches);
|
|
2019
2149
|
}
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
sessionOwnership.release();
|
|
2152
|
+
if (!runFailed) {
|
|
2153
|
+
runFailed = true;
|
|
2154
|
+
runError = error;
|
|
2155
|
+
}
|
|
2020
2156
|
}
|
|
2157
|
+
|
|
2158
|
+
if (runFailed) throw runError;
|
|
2159
|
+
return { sessionName, results: completedResults, sessionOwnership };
|
|
2021
2160
|
}
|
|
2022
2161
|
|
|
2023
2162
|
/**
|
|
@@ -2031,20 +2170,27 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
2031
2170
|
export async function runHeadlessWithCleanup(assignments, opts = {}) {
|
|
2032
2171
|
const { sessionPrefix = "tfx-hl", ...runOpts } = opts;
|
|
2033
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
|
+
);
|
|
2034
2178
|
|
|
2035
2179
|
try {
|
|
2036
|
-
return await
|
|
2180
|
+
return await run(sessionName, assignments, {
|
|
2181
|
+
...runOpts,
|
|
2182
|
+
_sessionOwnership: sessionOwnership,
|
|
2183
|
+
});
|
|
2037
2184
|
} finally {
|
|
2038
|
-
for (let index = 0; index < assignments.length; index++) {
|
|
2039
|
-
unregisterHeadlessSynapseWorker(
|
|
2040
|
-
getHeadlessWorkerAgentId(sessionName, index),
|
|
2041
|
-
);
|
|
2042
|
-
}
|
|
2043
|
-
await deregisterHeadlessWorkers(sessionName, assignments.length);
|
|
2044
2185
|
try {
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
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();
|
|
2048
2194
|
}
|
|
2049
2195
|
// WT split pane은 psmux 종료 시 셸이 끝나면서 자동으로 닫힘
|
|
2050
2196
|
// 수동 close-pane 불필요 (레이스 컨디션으로 WT 에러 발생)
|
|
@@ -2161,8 +2307,276 @@ function buildAttachTitle(sessionName, suffix = "") {
|
|
|
2161
2307
|
return suffix ? `${base} ${suffix}` : base;
|
|
2162
2308
|
}
|
|
2163
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
|
+
|
|
2164
2341
|
// ─── v6.0.0: Lead-Direct Interactive Mode ───
|
|
2165
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
|
+
|
|
2166
2580
|
/**
|
|
2167
2581
|
* Windows Terminal에서 psmux 세션을 자동 attach한다.
|
|
2168
2582
|
* 1명은 새 탭 단일 attach, 2/3/4명은 새 탭 내 split-pane, 5명 이상은 dashboard로 전환한다.
|
|
@@ -2311,7 +2725,7 @@ export function getProgressSnapshots(sessionName, dispatches, lines = 15) {
|
|
|
2311
2725
|
* @param {string} [opts.layout='2x2']
|
|
2312
2726
|
* @param {(event: object) => void} [opts.onProgress]
|
|
2313
2727
|
* @param {number} [opts.progressIntervalSec=0]
|
|
2314
|
-
* @param {boolean} [opts.autoAttach=false] —
|
|
2728
|
+
* @param {boolean} [opts.autoAttach=false] — 현재 platform의 관찰 pane/tab 자동 attach
|
|
2315
2729
|
* @param {string} [opts.dashboardLayout='single'] — dashboard viewer 레이아웃
|
|
2316
2730
|
* @param {AbortSignal} [opts.signal] — abort 시 자동 세션 정리
|
|
2317
2731
|
* @param {number} [opts.maxIdleSec=0] — 유휴 시 자동 정리 (0=비활성)
|
|
@@ -2337,52 +2751,53 @@ export async function runHeadlessInteractive(
|
|
|
2337
2751
|
dashboard = false,
|
|
2338
2752
|
dashboardSize = 0.4,
|
|
2339
2753
|
dashboardAnchor = "window",
|
|
2754
|
+
leadPane = null,
|
|
2755
|
+
leadTmux = null,
|
|
2340
2756
|
signal,
|
|
2341
2757
|
maxIdleSec = 0,
|
|
2342
2758
|
...runOpts
|
|
2343
2759
|
} = opts;
|
|
2344
2760
|
const headlessOpts = dashboard
|
|
2345
|
-
? { ...runOpts, dashboard: true }
|
|
2346
|
-
: { ...runOpts };
|
|
2761
|
+
? { ...runOpts, dashboard: true, leadPane, leadTmux }
|
|
2762
|
+
: { ...runOpts, leadPane, leadTmux };
|
|
2347
2763
|
|
|
2348
2764
|
// autoAttach를 session_created 시점에 트리거 (CLI 실행 전에 터미널 열림)
|
|
2349
2765
|
const userOnProgress = headlessOpts.onProgress;
|
|
2350
|
-
|
|
2351
|
-
const onProgress = (event) => {
|
|
2352
|
-
if (autoAttach && event.type === "session_created" && !terminalAttached) {
|
|
2353
|
-
terminalAttached = true;
|
|
2354
|
-
if (dashboard) {
|
|
2355
|
-
// v7.0: psmux attach로 대시보드+워커 전체 세션을 WT 탭에 표시
|
|
2356
|
-
attachDashboardTab(
|
|
2357
|
-
sessionName,
|
|
2358
|
-
assignments.length,
|
|
2359
|
-
event.dashboardLayout ||
|
|
2360
|
-
resolveDashboardLayout(
|
|
2361
|
-
headlessOpts.dashboardLayout,
|
|
2362
|
-
assignments.length,
|
|
2363
|
-
),
|
|
2364
|
-
dashboardSize,
|
|
2365
|
-
dashboardAnchor,
|
|
2366
|
-
);
|
|
2367
|
-
} else {
|
|
2368
|
-
autoAttachTerminal(
|
|
2369
|
-
sessionName,
|
|
2370
|
-
{ dashboardLayout: headlessOpts.dashboardLayout, dashboardAnchor },
|
|
2371
|
-
assignments.length,
|
|
2372
|
-
);
|
|
2373
|
-
}
|
|
2374
|
-
}
|
|
2375
|
-
if (userOnProgress) userOnProgress(event);
|
|
2376
|
-
};
|
|
2377
|
-
const interactiveRunOpts = { ...headlessOpts, onProgress };
|
|
2378
|
-
|
|
2379
|
-
// Phase 1: 세션 생성 → 즉시 터미널 팝업 → dispatch → 대기 → 결과 수집
|
|
2380
|
-
const { results } = await runHeadless(
|
|
2766
|
+
const onProgress = createHeadlessAutoAttachHandler(
|
|
2381
2767
|
sessionName,
|
|
2382
|
-
assignments,
|
|
2383
|
-
|
|
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,
|
|
2384
2791
|
);
|
|
2385
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
|
+
|
|
2386
2801
|
// Phase 2: 세션을 유지하고 interactive handle 반환
|
|
2387
2802
|
// Fix P2: paneId를 dispatches에 포함 (snapshots에서 필요)
|
|
2388
2803
|
const dispatches = results.map((r, i) => ({
|
|
@@ -2457,7 +2872,7 @@ export async function runHeadlessInteractive(
|
|
|
2457
2872
|
return psmuxSessionExists(sessionName);
|
|
2458
2873
|
},
|
|
2459
2874
|
|
|
2460
|
-
/** 세션 종료 —
|
|
2875
|
+
/** 세션 종료 — attach pane/tab은 psmux 종료 시 자동으로 닫힘 */
|
|
2461
2876
|
kill() {
|
|
2462
2877
|
if (this._killed) return;
|
|
2463
2878
|
this._killed = true;
|
|
@@ -2467,12 +2882,8 @@ export async function runHeadlessInteractive(
|
|
|
2467
2882
|
);
|
|
2468
2883
|
}
|
|
2469
2884
|
void deregisterHeadlessWorkers(sessionName, assignments.length);
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
} catch {
|
|
2473
|
-
/* 무시 */
|
|
2474
|
-
}
|
|
2475
|
-
// WT split pane은 psmux 종료 → 셸 종료 → 자동 닫힘
|
|
2885
|
+
sessionOwnership.release();
|
|
2886
|
+
// attach pane/tab은 psmux 종료 → attach client 종료 → 자동 닫힘
|
|
2476
2887
|
// 수동 close-pane 불필요 (레이스 컨디션으로 WT 0x80070002 에러 발생)
|
|
2477
2888
|
},
|
|
2478
2889
|
};
|