@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.
Files changed (48) hide show
  1. package/cto/hygiene.mjs +1 -1
  2. package/hub/lib/cto-env.mjs +1 -0
  3. package/hub/pipe.mjs +10 -9
  4. package/hub/schema.sql +62 -2
  5. package/hub/server.mjs +276 -132
  6. package/hub/store.mjs +699 -41
  7. package/hub/team/backend.mjs +4 -2
  8. package/hub/team/claude-daemon-control.mjs +2 -0
  9. package/hub/team/cli/commands/start/start-headless.mjs +2 -0
  10. package/hub/team/cli/commands/start/start-mux.mjs +24 -3
  11. package/hub/team/cli/services/runtime-mode.mjs +23 -9
  12. package/hub/team/conductor.mjs +101 -24
  13. package/hub/team/cto-auto-collect.mjs +2 -6
  14. package/hub/team/cto-pull.mjs +4 -0
  15. package/hub/team/dashboard-open.mjs +1 -0
  16. package/hub/team/execution-mode.mjs +50 -9
  17. package/hub/team/headless.mjs +539 -92
  18. package/hub/team/interactive-tui-transport.mjs +15 -1
  19. package/hub/team/intervention.mjs +1 -1
  20. package/hub/team/launcher-template.mjs +17 -1
  21. package/hub/team/notify.mjs +31 -11
  22. package/hub/team/orchestrator.mjs +36 -9
  23. package/hub/team/pane.mjs +176 -14
  24. package/hub/team/psmux.mjs +79 -70
  25. package/hub/team/retry-state-machine.mjs +41 -13
  26. package/hub/team/session.mjs +57 -0
  27. package/hub/team/swarm-hypervisor.mjs +4 -2
  28. package/hub/team/swarm-preflight.mjs +47 -0
  29. package/hub/team/synapse-http.mjs +149 -4
  30. package/hub/team/terminal-opener.mjs +68 -13
  31. package/hub/team/tui-viewer.mjs +65 -14
  32. package/hub/team/worktree-lifecycle.mjs +56 -59
  33. package/hub/tools.mjs +22 -1
  34. package/hub/workers/codex-mcp.mjs +26 -5
  35. package/hub/workers/delegator-mcp.mjs +3 -49
  36. package/package.json +1 -1
  37. package/scripts/lib/agent-route-policy.mjs +367 -0
  38. package/scripts/lib/cli-agy.mjs +24 -4
  39. package/scripts/lib/cli-codex.mjs +22 -160
  40. package/scripts/lib/codex-profile-config.mjs +48 -4
  41. package/scripts/lib/codex-recovery.sh +19 -0
  42. package/scripts/lib/gemini-profiles.mjs +113 -29
  43. package/scripts/lib/keyword-rules.mjs +24 -0
  44. package/scripts/lib/logger.mjs +60 -43
  45. package/scripts/lib/machine-profile.mjs +293 -0
  46. package/scripts/lib/mcp-health.mjs +44 -18
  47. package/cto/steward.mjs +0 -362
  48. package/hub/log-retention.mjs +0 -726
@@ -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
- await requestJsonFn("/bridge/register", {
234
+ const agentId = getHeadlessWorkerAgentId(sessionName, index);
235
+ const result = await requestJsonFn("/bridge/register", {
228
236
  body: {
229
- agent_id: getHeadlessWorkerAgentId(sessionName, index),
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: { agentId: getHeadlessWorkerAgentId(sessionName, index) },
263
- }).catch(() => {}),
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") return (await fallback(context)) === true;
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 { layout, safeProgress, dashboardLayout = "single" } = opts;
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 session = createPsmuxSession(sessionName, { layout, paneCount: 1 });
921
- applyTrifluxTheme(sessionName);
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 { layout, safeProgress, dashboardLayout = "single" } = opts;
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 session = createPsmuxSession(sessionName, {
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
- applyTrifluxTheme(sessionName);
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
- lifecycle?.enabled
1311
- ? (stallOpts.hardCeiling ??
1312
- stallOpts.completionTimeout ??
1313
- lifecycle.hardCeilingMs)
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(dispatch, lifecycle.onIntervene),
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
- try {
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 ((lastHubBeatByWorker.get(agentId) || 0) + HUB_ACTIVITY_MIN_INTERVAL_MS > nowMs) return;
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", { method: "POST", body: { agent_id: agentId }, timeoutMs: 500 }).catch(() => {});
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) requestJson("/bridge/assign/result", { method: "POST", body: { job_id: assignJobId, worker_agent: agentId, status: "running" }, timeoutMs: 500 }).catch(() => {});
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 dispatchProgressive(sessionName, normalizedAssignments, {
2035
+ ? await progressiveDispatch(sessionName, normalizedAssignments, {
1913
2036
  layout,
1914
2037
  safeProgress,
1915
2038
  dashboardLayout,
2039
+ sessionOwnership,
2040
+ _deps: deps,
1916
2041
  })
1917
- : await dispatchBatch(sessionName, normalizedAssignments, {
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
- return { sessionName, results: collected };
1971
- } finally {
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 runHeadless(sessionName, assignments, runOpts);
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
- killPsmuxSession(sessionName);
2010
- } catch {
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] — Windows Terminal 자동 attach
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
- let terminalAttached = false;
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
- interactiveRunOpts,
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
- /** 세션 종료 — WT pane은 psmux 종료 시 자동으로 닫힘 */
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
- try {
2435
- killPsmuxSession(sessionName);
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
  };