@triflux/remote 10.44.0 → 10.46.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 (40) hide show
  1. package/hub/pipe.mjs +10 -15
  2. package/hub/schema.sql +1 -0
  3. package/hub/server.mjs +172 -93
  4. package/hub/store.mjs +127 -61
  5. package/hub/team/backend.mjs +4 -2
  6. package/hub/team/claude-daemon-control.mjs +2 -0
  7. package/hub/team/cli/commands/start/start-headless.mjs +2 -0
  8. package/hub/team/cli/commands/start/start-mux.mjs +24 -3
  9. package/hub/team/cli/services/runtime-mode.mjs +23 -9
  10. package/hub/team/conductor.mjs +80 -24
  11. package/hub/team/cto-auto-collect.mjs +2 -6
  12. package/hub/team/cto-pull.mjs +4 -0
  13. package/hub/team/dashboard-open.mjs +1 -0
  14. package/hub/team/execution-mode.mjs +38 -2
  15. package/hub/team/headless.mjs +493 -82
  16. package/hub/team/interactive-tui-transport.mjs +15 -1
  17. package/hub/team/launcher-template.mjs +17 -1
  18. package/hub/team/orchestrator.mjs +36 -9
  19. package/hub/team/pane.mjs +176 -14
  20. package/hub/team/psmux.mjs +79 -70
  21. package/hub/team/retry-state-machine.mjs +47 -16
  22. package/hub/team/session.mjs +57 -0
  23. package/hub/team/synapse-http.mjs +149 -4
  24. package/hub/team/terminal-opener.mjs +68 -13
  25. package/hub/team/tui-viewer.mjs +65 -14
  26. package/hub/team/uds-orchestrator.mjs +384 -83
  27. package/hub/tools.mjs +10 -1
  28. package/hub/workers/codex-mcp.mjs +9 -4
  29. package/hub/workers/delegator-mcp.mjs +1 -1
  30. package/package.json +1 -1
  31. package/scripts/lib/agent-route-policy.mjs +99 -38
  32. package/scripts/lib/cli-agy.mjs +24 -4
  33. package/scripts/lib/cli-codex.mjs +2 -0
  34. package/scripts/lib/codex-profile-config.mjs +45 -13
  35. package/scripts/lib/codex-recovery.sh +19 -0
  36. package/scripts/lib/gemini-profiles.mjs +113 -29
  37. package/scripts/lib/keyword-rules.mjs +12 -0
  38. package/scripts/lib/logger.mjs +60 -43
  39. package/scripts/lib/machine-profile.mjs +293 -0
  40. package/scripts/lib/mcp-health.mjs +44 -18
@@ -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
- await requestJsonFn("/bridge/register", {
234
+ const agentId = getHeadlessWorkerAgentId(sessionName, index);
235
+ const result = await requestJsonFn("/bridge/register", {
229
236
  body: {
230
- agent_id: getHeadlessWorkerAgentId(sessionName, index),
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: { agentId: getHeadlessWorkerAgentId(sessionName, index) },
264
- }).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
+ ),
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 { layout, safeProgress, dashboardLayout = "single" } = opts;
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 session = createPsmuxSession(sessionName, { layout, paneCount: 1 });
939
- 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);
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 { layout, safeProgress, dashboardLayout = "single" } = opts;
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 session = createPsmuxSession(sessionName, {
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
- applyTrifluxTheme(sessionName);
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
- try {
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: { agent_id: agentId },
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 dispatchProgressive(sessionName, normalizedAssignments, {
2035
+ ? await progressiveDispatch(sessionName, normalizedAssignments, {
1949
2036
  layout,
1950
2037
  safeProgress,
1951
2038
  dashboardLayout,
2039
+ sessionOwnership,
2040
+ _deps: deps,
1952
2041
  })
1953
- : await dispatchBatch(sessionName, normalizedAssignments, {
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
- return { sessionName, results: collected };
2007
- } 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 {
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 runHeadless(sessionName, assignments, runOpts);
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
- killPsmuxSession(sessionName);
2046
- } catch {
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] — Windows Terminal 자동 attach
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
- let terminalAttached = false;
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
- 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,
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
- /** 세션 종료 — WT pane은 psmux 종료 시 자동으로 닫힘 */
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
- try {
2471
- killPsmuxSession(sessionName);
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
  };