@triflux/remote 10.50.2 → 10.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/cto/brief.mjs +0 -20
  2. package/cto/collect.mjs +1 -200
  3. package/cto/current.schema.json +2 -23
  4. package/cto/hygiene.mjs +2 -61
  5. package/cto/lake-root.mjs +0 -56
  6. package/cto/status.mjs +5 -187
  7. package/hub/index.mjs +0 -17
  8. package/hub/team/agent-map.json +0 -1
  9. package/hub/team/agents-row-attach.py +120 -0
  10. package/hub/team/agents-row.mjs +200 -0
  11. package/hub/team/ansi.mjs +1 -35
  12. package/hub/team/backend.mjs +9 -145
  13. package/hub/team/claude-daemon-control.mjs +1 -2
  14. package/hub/team/claude-session-projection.mjs +5 -73
  15. package/hub/team/cli/commands/attach.mjs +1 -17
  16. package/hub/team/cli/commands/control.mjs +12 -38
  17. package/hub/team/cli/commands/debug.mjs +2 -39
  18. package/hub/team/cli/commands/focus.mjs +5 -31
  19. package/hub/team/cli/commands/interrupt.mjs +6 -25
  20. package/hub/team/cli/commands/kill.mjs +1 -30
  21. package/hub/team/cli/commands/list.mjs +0 -16
  22. package/hub/team/cli/commands/send.mjs +6 -26
  23. package/hub/team/cli/commands/start/index.mjs +42 -133
  24. package/hub/team/cli/commands/start/parse-args.mjs +15 -40
  25. package/hub/team/cli/commands/start/start-headless.mjs +138 -105
  26. package/hub/team/cli/commands/start/start-mux.mjs +2 -7
  27. package/hub/team/cli/commands/status.mjs +2 -57
  28. package/hub/team/cli/commands/stop.mjs +14 -19
  29. package/hub/team/cli/help.mjs +4 -5
  30. package/hub/team/cli/manifest.mjs +5 -1
  31. package/hub/team/cli/render.mjs +0 -12
  32. package/hub/team/cli/services/runtime-mode.mjs +27 -98
  33. package/hub/team/cli/services/state-store.mjs +5 -20
  34. package/hub/team/handoff.mjs +3 -34
  35. package/hub/team/headless.mjs +108 -1296
  36. package/hub/team/orchestrator.mjs +4 -30
  37. package/hub/team/pane.mjs +6 -28
  38. package/hub/team/psmux.mjs +3 -34
  39. package/hub/team/remote-session.mjs +1 -2
  40. package/hub/team/session-context.mjs +1 -1
  41. package/hub/team/session.mjs +7 -91
  42. package/hub/team/tui-core.mjs +1 -1
  43. package/hub/team/tui-viewer.mjs +11 -58
  44. package/hub/team/tui.mjs +8 -306
  45. package/hub/team/uds-orchestrator.mjs +2 -580
  46. package/hub/team/wt-manager.mjs +2 -2
  47. package/hub/workers/factory.mjs +6 -84
  48. package/hub/workers/interface.mjs +1 -7
  49. package/hub/workers/lib/jsonrpc-core.mjs +11 -0
  50. package/hub/workers/lib/jsonrpc-ws-uds.mjs +5 -8
  51. package/package.json +3 -8
  52. package/scripts/lib/agent-route-policy.mjs +143 -62
  53. package/scripts/lib/cli-claude.mjs +0 -14
  54. package/scripts/lib/env-probe.mjs +2 -131
  55. package/scripts/lib/gemini-profiles.mjs +2 -69
  56. package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
  57. package/scripts/lib/machine-profile.mjs +3 -17
  58. package/scripts/lib/mcp-filter.mjs +109 -53
  59. package/scripts/lib/mcp-guard-engine.mjs +40 -98
  60. package/scripts/lib/mcp-health.mjs +2 -2
  61. package/scripts/lib/mcp-server-catalog.mjs +2 -2
  62. package/scripts/lib/psmux-info.mjs +11 -59
  63. package/scripts/lib/skill-template.mjs +0 -245
  64. package/hub/hub-lifecycle.mjs +0 -248
  65. package/hub/pipe.mjs +0 -825
  66. package/hub/promote-penalties.mjs +0 -138
  67. package/hub/public/dashboard.html +0 -416
  68. package/hub/schema.sql +0 -209
  69. package/hub/server.mjs +0 -3048
  70. package/hub/store-adapter.mjs +0 -182
  71. package/hub/store.mjs +0 -1736
  72. package/hub/team/build-worker-prompt.mjs +0 -92
  73. package/hub/team/check-mcp-hub.mjs +0 -49
  74. package/hub/team/claude-native-bridge.mjs +0 -987
  75. package/hub/team/cli/commands/start/start-in-process.mjs +0 -50
  76. package/hub/team/cli/commands/start/start-wt.mjs +0 -86
  77. package/hub/team/cli/services/hub-client.mjs +0 -264
  78. package/hub/team/cli/services/native-control.mjs +0 -145
  79. package/hub/team/codex-review.mjs +0 -520
  80. package/hub/team/conductor-mesh-bridge.mjs +0 -122
  81. package/hub/team/conductor-registry.mjs +0 -121
  82. package/hub/team/conductor.mjs +0 -1529
  83. package/hub/team/consensus-meta.mjs +0 -176
  84. package/hub/team/cto-auto-collect.mjs +0 -115
  85. package/hub/team/cto-pull.mjs +0 -31
  86. package/hub/team/daemon-pty-tmux-bridge.mjs +0 -146
  87. package/hub/team/dashboard.mjs +0 -322
  88. package/hub/team/event-log.mjs +0 -84
  89. package/hub/team/execution-mode.mjs +0 -324
  90. package/hub/team/extract-completion-payload.mjs +0 -110
  91. package/hub/team/headless-bridge-session.mjs +0 -17
  92. package/hub/team/health-probe.mjs +0 -435
  93. package/hub/team/interactive-native-launcher.mjs +0 -119
  94. package/hub/team/interactive-native-worker.mjs +0 -80
  95. package/hub/team/interactive-tui-transport.mjs +0 -479
  96. package/hub/team/launcher-template.mjs +0 -144
  97. package/hub/team/lead-control.mjs +0 -191
  98. package/hub/team/mcp-selector.mjs +0 -145
  99. package/hub/team/native-supervisor.mjs +0 -460
  100. package/hub/team/native.mjs +0 -727
  101. package/hub/team/recovery-store.mjs +0 -149
  102. package/hub/team/remote-probe.mjs +0 -292
  103. package/hub/team/remote-watcher.mjs +0 -506
  104. package/hub/team/runtime-strategy.mjs +0 -200
  105. package/hub/team/schemas/worker-completion.json +0 -39
  106. package/hub/team/sentinel-capture.mjs +0 -127
  107. package/hub/team/session-sync.mjs +0 -316
  108. package/hub/team/swarm-cli.mjs +0 -390
  109. package/hub/team/swarm-hypervisor.mjs +0 -2206
  110. package/hub/team/swarm-intent.mjs +0 -216
  111. package/hub/team/swarm-locks.mjs +0 -335
  112. package/hub/team/swarm-planner.mjs +0 -371
  113. package/hub/team/swarm-preflight.mjs +0 -366
  114. package/hub/team/swarm-reconciler.mjs +0 -242
  115. package/hub/team/synapse-cli.mjs +0 -458
  116. package/hub/team/synapse-http.mjs +0 -311
  117. package/hub/team/synapse-registry.mjs +0 -618
  118. package/hub/team/tui-remote-adapter.mjs +0 -377
  119. package/hub/team/tui-synapse.mjs +0 -223
  120. package/hub/team/worker-completion-validator.mjs +0 -79
  121. package/hub/team/worker-sandbox.mjs +0 -125
  122. package/hub/team/worktree-lifecycle.mjs +0 -639
  123. package/hub/team/wt-templates.json +0 -43
  124. package/hub/tools.mjs +0 -900
  125. package/hub/workers/antigravity-route-worker.mjs +0 -179
  126. package/hub/workers/codex-app-server-worker.mjs +0 -1067
  127. package/hub/workers/delegator-mcp.mjs +0 -1098
  128. package/hub/workers/lib/jsonrpc-stdio.mjs +0 -204
  129. package/scripts/lib/claudemd-scanner.mjs +0 -223
  130. package/scripts/lib/context.mjs +0 -67
  131. package/scripts/lib/dynamic-route-cli.mjs +0 -107
  132. package/scripts/lib/handoff.mjs +0 -187
  133. package/scripts/lib/logger.mjs +0 -122
  134. package/scripts/lib/stealth-fetch.mjs +0 -176
@@ -1,8 +1,5 @@
1
1
  // hub/team/headless.mjs — 헤드리스 CLI 오케스트레이션
2
2
  // psmux pane에서 CLI를 헤드리스 모드로 실행하고 결과를 수집한다.
3
- // v5.2.0: 기본 headless 엔진 (runHeadless, runHeadlessWithCleanup)
4
- // v6.0.0: Lead-direct 모드 (runHeadlessInteractive, autoAttachTerminal)
5
- // 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
6
3
 
7
4
  import { execSync } from "node:child_process";
8
5
  import { randomUUID } from "node:crypto";
@@ -10,20 +7,16 @@ import {
10
7
  existsSync,
11
8
  mkdirSync,
12
9
  readFileSync,
13
- renameSync,
14
10
  rmSync,
15
11
  statSync,
16
12
  writeFileSync,
17
13
  } from "node:fs";
18
14
  import { createRequire } from "node:module";
19
- import net from "node:net";
20
15
  import { tmpdir } from "node:os";
21
16
  import { dirname, join, resolve } from "node:path";
22
17
  import { fileURLToPath } from "node:url";
23
18
  import { resolveNestedCodexAgentProfile } from "../../scripts/lib/agent-route-policy.mjs";
24
- import { requestJson } from "@triflux/core/hub/bridge.mjs";
25
19
  import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
26
- import { getMaxSpawnPerSec } from "@triflux/core/hub/lib/spawn-trace.mjs";
27
20
  import {
28
21
  createActivityLifecycle,
29
22
  isActivityLifecycleEnabled,
@@ -31,27 +24,15 @@ import {
31
24
  resolveStallInterventionMs,
32
25
  } from "@triflux/core/hub/lib/worker-lifecycle.mjs";
33
26
  import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
27
+ import { createAgentsRowOwnership, openAgentsRow } from "./agents-row.mjs";
34
28
  import { getBackend } from "./backend.mjs";
35
- import {
36
- buildDaemonControlAuth,
37
- buildDaemonExecDispatchPayload,
38
- deriveClaudeDaemonPaths as deriveClaudeControlPaths,
39
- dispatchClaudeDaemonJob,
40
- killDaemonJob,
41
- sendKillBySessionId,
42
- } from "./claude-daemon-control.mjs";
43
- import { startClaudeNativeBridge } from "./claude-native-bridge.mjs";
44
- import { removeClaudeSessionProjection } from "./claude-session-projection.mjs";
45
29
  import { resolveDashboardLayout } from "./dashboard-layout.mjs";
46
30
  import {
47
31
  formatHandoffForLead,
48
32
  HANDOFF_INSTRUCTION_SHORT,
49
33
  processHandoff,
50
34
  } from "./handoff.mjs";
51
- import {
52
- createFileActivitySource,
53
- createInterventionLadder,
54
- } from "./intervention.mjs";
35
+ import { createInterventionLadder } from "./intervention.mjs";
55
36
  import {
56
37
  capturePsmuxPane,
57
38
  createPsmuxSession,
@@ -59,26 +40,18 @@ import {
59
40
  killPsmuxSession,
60
41
  psmuxExec,
61
42
  psmuxSessionExists,
62
- sendKeysToPane,
63
43
  startCapture,
64
44
  waitForCompletion,
65
45
  } from "./psmux.mjs";
66
- import {
67
- buildSynapseTaskSummary,
68
- registerSynapseSession,
69
- unregisterSynapseSession,
70
- } from "./synapse-http.mjs";
71
46
  import { createLogDashboard } from "./tui.mjs";
72
47
  import { createWtManager } from "./wt-manager.mjs";
73
48
 
74
49
  const RESULT_DIR = join(tmpdir(), "tfx-headless");
75
- const DAEMON_COMPLETION_PREFIX = "__TFX_HEADLESS_DONE__:";
76
50
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
77
51
 
78
52
  /** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
79
53
  const CLI_BRAND = {
80
54
  codex: { emoji: "\u{26AA}", label: "Codex", ansi: "\x1b[97m" }, // ⚪ bright white (codexWhite)
81
- gemini: { emoji: "\u{1F535}", label: "Gemini", ansi: "\x1b[38;5;39m" }, // 🔵 geminiBlue
82
55
  antigravity: {
83
56
  emoji: "\u{1F535}",
84
57
  label: "Antigravity",
@@ -90,8 +63,6 @@ const CLI_BRAND = {
90
63
  ansi: "\x1b[38;2;232;112;64m",
91
64
  }, // 🟠 claudeOrange
92
65
  };
93
- const _ANSI_RESET = "\x1b[0m";
94
- const _ANSI_DIM = "\x1b[2m";
95
66
 
96
67
  /** 에이전트 역할명 → CLI 타입 매핑 (단일 소스: agent-map.json) */
97
68
  const _require = createRequire(import.meta.url);
@@ -109,11 +80,6 @@ export function resolveCliType(agentOrCli) {
109
80
  return AGENT_TO_CLI[agentOrCli] || agentOrCli;
110
81
  }
111
82
 
112
- function resolveHeadlessCliType(agentOrCli) {
113
- const resolved = resolveCliType(agentOrCli);
114
- return resolved === "gemini" ? "antigravity" : resolved;
115
- }
116
-
117
83
  function shellQuote(value) {
118
84
  return `'${String(value).replace(/'/g, "'\\''")}'`;
119
85
  }
@@ -146,7 +112,7 @@ function resolveRouteAgentForHeadless(resolvedCli, opts = {}) {
146
112
  if (
147
113
  role &&
148
114
  VALID_ROUTE_AGENTS.has(role) &&
149
- !["gemini", "antigravity", "agy"].includes(role)
115
+ !["antigravity", "agy"].includes(role)
150
116
  ) {
151
117
  return role;
152
118
  }
@@ -215,95 +181,6 @@ export function getHeadlessWorkerAgentId(sessionName, index) {
215
181
  return `headless-${sessionName}-${index}`;
216
182
  }
217
183
 
218
- export function getHeadlessLeadAgentId(sessionName) {
219
- return `headless-${sessionName}-lead`;
220
- }
221
-
222
- const HUB_CLI_VALUES = new Set(["codex", "gemini", "claude", "other"]);
223
- const presenceOwnerByAgent = new Map();
224
- function normalizeCliForHub(cli) {
225
- return HUB_CLI_VALUES.has(cli) ? cli : "other";
226
- }
227
-
228
- export async function registerHeadlessWorker(
229
- sessionName,
230
- index,
231
- cli,
232
- requestJsonFn = requestJson,
233
- ) {
234
- const agentId = getHeadlessWorkerAgentId(sessionName, index);
235
- const result = await requestJsonFn("/bridge/register", {
236
- body: {
237
- agent_id: agentId,
238
- cli: normalizeCliForHub(cli),
239
- topics: ["headless.worker"],
240
- capabilities: [cli],
241
- },
242
- }).catch(() => null);
243
- if (result?.ok && result.data?.presence_generation != null) {
244
- presenceOwnerByAgent.set(agentId, {
245
- presence_generation: result.data.presence_generation,
246
- hub_instance_id: result.data.hub_instance_id,
247
- store_fingerprint: result.data.store_fingerprint,
248
- });
249
- }
250
- return result;
251
- }
252
-
253
- export async function publishHeadlessResult(
254
- sessionName,
255
- workerId,
256
- status,
257
- handoff,
258
- requestJsonFn = requestJson,
259
- ) {
260
- await requestJsonFn("/bridge/publish", {
261
- body: {
262
- from: getHeadlessLeadAgentId(sessionName),
263
- to: "topic:headless.results",
264
- type: "event",
265
- payload: { workerId, status, handoff },
266
- },
267
- }).catch(() => {});
268
- }
269
-
270
- export async function deregisterHeadlessWorkers(
271
- sessionName,
272
- workerCount,
273
- requestJsonFn = requestJson,
274
- ) {
275
- await Promise.all(
276
- Array.from({ length: workerCount }, (_, index) =>
277
- requestJsonFn("/bridge/deregister", {
278
- body: {
279
- agent_id: getHeadlessWorkerAgentId(sessionName, index),
280
- ...(presenceOwnerByAgent.get(
281
- getHeadlessWorkerAgentId(sessionName, index),
282
- ) || {}),
283
- },
284
- })
285
- .catch(() => {})
286
- .finally(() =>
287
- presenceOwnerByAgent.delete(
288
- getHeadlessWorkerAgentId(sessionName, index),
289
- ),
290
- ),
291
- ),
292
- );
293
- }
294
-
295
- function registerHeadlessSynapseWorker(workerId, prompt) {
296
- registerSynapseSession({
297
- sessionId: workerId,
298
- host: "local",
299
- taskSummary: buildSynapseTaskSummary(prompt),
300
- });
301
- }
302
-
303
- function unregisterHeadlessSynapseWorker(workerId) {
304
- unregisterSynapseSession(workerId);
305
- }
306
-
307
184
  /** MCP 프로필별 프롬프트 힌트 (tfx-route.sh resolve_mcp_policy의 경량 미러) */
308
185
  const MCP_PROFILE_HINTS = {
309
186
  implement:
@@ -314,19 +191,6 @@ const MCP_PROFILE_HINTS = {
314
191
  docs: "Focus on documentation and explanation tasks.",
315
192
  };
316
193
 
317
- /**
318
- * CLI별 헤드리스 명령 빌더
319
- * @param {'codex'|'antigravity'|'claude'} cli
320
- * @param {string} prompt — 실행할 프롬프트
321
- * @param {string} resultFile — 결과 저장 파일 경로
322
- * @param {object} [opts]
323
- * @param {boolean} [opts.handoff=true]
324
- * @param {string} [opts.mcp] — MCP 프로필 ("implement"|"analyze"|"review"|"docs")
325
- * @param {string} [opts.contextFile] — 컨텍스트 파일 경로 (최대 32KB, UTF-8 안전 절단)
326
- * @returns {string} PowerShell 명령
327
- */
328
- // ── Dashboard attach args for WT ────────────────────────────────
329
-
330
194
  export function buildDashboardAttachArgs(
331
195
  sessionName,
332
196
  layout,
@@ -348,7 +212,7 @@ export function buildDashboardAttachArgs(
348
212
 
349
213
  export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
350
214
  const { handoff = true, mcp, contextFile, model, cwd } = opts;
351
- const resolvedCli = resolveHeadlessCliType(cli);
215
+ const resolvedCli = resolveCliType(cli);
352
216
 
353
217
  // contextFile 처리: 32KB(32768 bytes) 초과 시 UTF-8 안전 절단
354
218
  let contextPrefix = "";
@@ -357,7 +221,11 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
357
221
  let ctx = readFileSync(contextFile, "utf8");
358
222
  let truncated = false;
359
223
  if (Buffer.byteLength(ctx, "utf8") > 32768) {
360
- ctx = Buffer.from(ctx).subarray(0, 32768).toString("utf8");
224
+ // 바이트 경계에서 잘린 다바이트 글자는 U+FFFD 로 디코딩되므로 떼어 낸다.
225
+ ctx = Buffer.from(ctx)
226
+ .subarray(0, 32768)
227
+ .toString("utf8")
228
+ .replace(/�+$/u, "");
361
229
  truncated = true;
362
230
  }
363
231
  if (truncated) {
@@ -391,7 +259,6 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
391
259
  "prompt-" + randomUUID().slice(0, 8) + ".txt",
392
260
  ).replace(/\\/g, "/");
393
261
  writeFileSync(promptFile, fullPrompt, "utf8");
394
- void IS_WINDOWS; // referenced for diagnostic guard chain below
395
262
 
396
263
  // Codex와 Antigravity는 tfx-route.sh를 통해서만 headless 실행한다. 이 경로가
397
264
  // disable/fallback/fail-loud 정책의 유일한 판정원이다. 여기서 같은 정책을
@@ -432,48 +299,6 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
432
299
  return `cd '${safeCwd.replace(/'/g, "'\\''")}' && ${backendCommand}`;
433
300
  }
434
301
 
435
- function escapeRegExp(value) {
436
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
437
- }
438
-
439
- export function buildDaemonWrappedCommand(command, token) {
440
- return `{ ${command}; __ec=$?; echo "${DAEMON_COMPLETION_PREFIX}${token}:$__ec"; }`;
441
- }
442
-
443
- export function waitForDaemonCompletionFromMessages(
444
- messages,
445
- { token, resultFile } = {},
446
- ) {
447
- const completionRegex = new RegExp(
448
- `${escapeRegExp(DAEMON_COMPLETION_PREFIX)}${escapeRegExp(token)}:(\\d+)`,
449
- );
450
- const stream = [];
451
- let matched = false;
452
- let exitCode = null;
453
-
454
- for (const message of messages) {
455
- if (Array.isArray(message?.streamTail)) {
456
- stream.push(...message.streamTail.map((line) => `${line}\n`));
457
- }
458
- if (typeof message?.line !== "string") continue;
459
- const match = completionRegex.exec(message.line);
460
- if (match) {
461
- const beforeCompletion = message.line.slice(0, match.index);
462
- if (beforeCompletion) stream.push(beforeCompletion);
463
- matched = true;
464
- exitCode = Number.parseInt(match[1], 10);
465
- continue;
466
- }
467
- stream.push(message.line);
468
- }
469
-
470
- if (resultFile) {
471
- writeFileSync(`${resultFile}.partial`, stream.join(""), "utf8");
472
- }
473
-
474
- return { matched, exitCode };
475
- }
476
-
477
302
  /**
478
303
  * 이전 run 의 stale .txt / .partial / .err 를 제거한다.
479
304
  * Issue #118 Codex review R1 HIGH: resultFile 경로 재사용 시 (워커 restart 또는
@@ -531,125 +356,33 @@ export function readResult(resultFile, paneId) {
531
356
  return capturePsmuxPane(paneId, 30);
532
357
  }
533
358
 
534
- // ─── Stall Detection ───
535
-
536
- /** Stall detection 기본값 (immutable) */
537
- export const STALL_DEFAULTS = Object.freeze({
538
- pollInterval: 5_000,
539
- stallTimeout: 120_000,
540
- interventionTimeout: resolveStallInterventionMs(),
541
- hardCeiling: resolveHardCeilingMs(),
542
- maxRestarts: 2,
543
- maxInterventions: 1,
544
- });
545
-
546
- /** CLI pane stall 감지 에러 (STALL_EXHAUSTED | COMPLETION_TIMEOUT) */
547
- export class StallError extends Error {
548
- constructor(
549
- message,
550
- { code = "STALL_DETECTED", category = "transient", recovery = "" } = {},
551
- ) {
552
- super(message);
553
- this.name = "StallError";
554
- this.code = code;
555
- this.category = category;
556
- this.recovery = recovery;
557
- }
558
- }
559
-
560
- /**
561
- * Stall 모니터 팩토리 — output + resultFile mtime 하이브리드 감지
562
- * @param {string} paneId
563
- * @param {string} resultFile
564
- * @param {{ stallTimeout: number }} config
565
- * @param {{ capturePsmuxPane?: Function, statSync?: Function }} [deps]
566
- * @returns {{ poll: () => { snapshot: string, mtimeChanged: boolean, stalled: boolean, elapsed: number } }}
567
- */
568
- export function createStallMonitor(paneId, resultFile, config, deps = {}) {
569
- const capture = deps.capturePsmuxPane || capturePsmuxPane;
570
- const stat = deps.statSync || statSync;
571
- let lastSnapshot = "";
572
- let lastMtime = 0;
573
- let lastChangeAt = Date.now();
574
-
575
- try {
576
- lastMtime = stat(resultFile).mtimeMs;
577
- } catch {
578
- /* not created yet */
579
- }
580
-
581
- return Object.freeze({
582
- poll() {
583
- const snapshot = capture(paneId, 50);
584
- let currentMtime = 0;
585
- try {
586
- currentMtime = stat(resultFile).mtimeMs;
587
- } catch {
588
- /* ignore */
589
- }
590
-
591
- const outputChanged = snapshot !== lastSnapshot;
592
- const mtimeChanged = currentMtime > 0 && currentMtime !== lastMtime;
593
-
594
- if (outputChanged || mtimeChanged) {
595
- lastChangeAt = Date.now();
596
- lastSnapshot = snapshot;
597
- if (mtimeChanged) lastMtime = currentMtime;
598
- }
599
-
600
- const elapsed = Date.now() - lastChangeAt;
601
- return Object.freeze({
602
- snapshot,
603
- mtimeChanged,
604
- stalled: elapsed >= config.stallTimeout,
605
- elapsed,
606
- });
607
- },
608
- });
609
- }
610
-
611
359
  function createHeadlessIntervention(dispatch, fallback) {
612
360
  return async (context) => {
613
361
  if (typeof fallback === "function")
614
362
  return (await fallback(context)) === true;
615
363
 
616
- const isDaemon = context.channel === "daemon";
617
- const readActivitySignature = isDaemon
618
- ? createFileActivitySource({ files: [dispatch.resultFile] })
619
- : () => {
620
- let mtime = 0;
621
- try {
622
- mtime = statSync(dispatch.resultFile).mtimeMs;
623
- } catch {
624
- /* result file has not been written yet */
625
- }
626
- return `${capturePsmuxPane(context.paneId || dispatch.paneId, 50)}\0${mtime}`;
627
- };
364
+ const readActivitySignature = () => {
365
+ let mtime = 0;
366
+ try {
367
+ mtime = statSync(dispatch.resultFile).mtimeMs;
368
+ } catch {
369
+ /* result file has not been written yet */
370
+ }
371
+ return `${capturePsmuxPane(context.paneId || dispatch.paneId, 50)}\0${mtime}`;
372
+ };
628
373
  const ladder = createInterventionLadder({
629
- target: isDaemon
630
- ? {
631
- channel: "claude-daemon",
632
- cli: dispatch.cli || "claude",
633
- sessionId: dispatch.sessionId,
634
- daemon: {
635
- controlSock: dispatch.controlSock,
636
- short: dispatch.daemonShort,
637
- sessionId: dispatch.sessionId,
638
- configDir: dispatch.daemonPaths?.configDir,
639
- },
640
- }
641
- : {
642
- channel: "tmux-pane",
643
- paneId: context.paneId || dispatch.paneId,
644
- cli: dispatch.cli,
645
- sessionId: dispatch.sessionId,
646
- },
374
+ target: {
375
+ channel: "tmux-pane",
376
+ paneId: context.paneId || dispatch.paneId,
377
+ cli: dispatch.cli,
378
+ sessionId: dispatch.sessionId,
379
+ },
647
380
  readActivitySignature,
648
381
  deps: {
649
382
  // 이 트랙이 재실행 소유자다. pane 채널은 기존 completion wrapper를 다시
650
383
  // dispatch하여 token/result 계약을 보존한다.
651
384
  resumeHandler: async () => {
652
- if (isDaemon || !dispatch.command) return { ok: false };
385
+ if (!dispatch.command) return { ok: false };
653
386
  const restarted = dispatchCommand(
654
387
  context.sessionName,
655
388
  context.paneId || dispatch.paneId,
@@ -706,254 +439,6 @@ export function createActivityPollGuard({
706
439
  });
707
440
  }
708
441
 
709
- /**
710
- * 하이브리드 stall 감지 대기 — output 변화 + resultFile mtime 모니터링.
711
- * 2분 무변화 시 pane kill → re-dispatch (최대 2회 재시작).
712
- *
713
- * @param {string} sessionName
714
- * @param {string} paneId — 현재 pane 타겟 (예: "tfx:0.1")
715
- * @param {string} resultFile — 결과 저장 파일 경로
716
- * @param {object} [opts]
717
- * @param {number} [opts.pollInterval=5000] — 폴링 간격 ms
718
- * @param {number} [opts.stallTimeout=120000] — 무변화 stall 판정 ms
719
- * @param {number} [opts.hardCeiling] — 활동과 무관한 절대 상한 ms
720
- * @param {number} [opts.completionTimeout] — hardCeiling의 하위호환 alias
721
- * @param {number} [opts.maxRestarts=2] — 최대 재시작 횟수
722
- * @param {string} [opts.command] — re-dispatch용 원본 명령
723
- * @param {string} [opts.token] — completion token
724
- * @param {(snapshot: string) => void} [opts.onPoll] — 폴링 콜백
725
- * @returns {Promise<{ matched: boolean, exitCode: number|null, restarts: number, stallDetected: boolean, paneId: string, token?: string, logPath?: string|null }>}
726
- */
727
- export async function waitForCompletionWithStallDetect(
728
- sessionName,
729
- paneId,
730
- resultFile,
731
- opts = {},
732
- ) {
733
- const {
734
- pollInterval = 5000,
735
- stallTimeout = 120000,
736
- maxRestarts = 2,
737
- maxInterventions = 1,
738
- command,
739
- token,
740
- onPoll,
741
- onIntervene,
742
- _deps,
743
- } = opts;
744
- const hardCeiling =
745
- opts.hardCeiling ??
746
- opts.completionTimeout ??
747
- (isActivityLifecycleEnabled() ? resolveHardCeilingMs() : 900_000);
748
-
749
- // 의존성 (테스트 시 _deps로 주입 가능)
750
- const deps = _deps || {};
751
- const _capture = deps.capturePsmuxPane || capturePsmuxPane;
752
- const _exists = deps.existsSync || existsSync;
753
- const _stat = deps.statSync || statSync;
754
- const _readFile = deps.readFileSync || readFileSync;
755
- const _exec = deps.psmuxExec || psmuxExec;
756
- const _dispatch = deps.dispatchCommand || dispatchCommand;
757
- const _startCapture = deps.startCapture || startCapture;
758
-
759
- const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
760
- const buildCompletionRegex = (activeToken) => {
761
- const completionPatterns = [
762
- activeToken
763
- ? `${esc("__TRIFLUX_DONE__:")}${esc(activeToken)}:(\\d+)`
764
- : `${esc("__TRIFLUX_DONE__:")}\\S+:(\\d+)`,
765
- activeToken
766
- ? `${esc("TFX_DONE_")}${esc(activeToken)}:(\\d+)`
767
- : `${esc("TFX_DONE_")}\\S+:(\\d+)`,
768
- ];
769
- return new RegExp(completionPatterns.join("|"), "m");
770
- };
771
-
772
- let restarts = 0;
773
- let interventions = 0;
774
- const runStartedAt = Date.now();
775
- let currentPaneId = paneId;
776
- let stallDetected = false;
777
- let currentToken = token;
778
- let currentLogPath = opts.logPath || null;
779
-
780
- while (true) {
781
- let lastOutput = "";
782
- let lastMtime = 0;
783
- let lastChangeAt = Date.now();
784
-
785
- // 초기 resultFile mtime
786
- try {
787
- if (_exists(resultFile)) lastMtime = _stat(resultFile).mtimeMs;
788
- } catch {
789
- /* 무시 */
790
- }
791
-
792
- while (true) {
793
- await new Promise((r) => setTimeout(r, pollInterval));
794
- const now = Date.now();
795
-
796
- // 절대 상한은 재시작과 활동 여부를 관통하는 유일한 wall-clock kill이다.
797
- if (now - runStartedAt > hardCeiling) {
798
- // Issue #118: timeout kill 전에 capture-pane 출력을 .partial 파일로 persist.
799
- // resultFile(`.txt`)이 아직 생성되지 않았을 수 있으므로 readResult 가 fallback 으로 읽는다.
800
- try {
801
- const partialSnapshot = _capture(currentPaneId, 200);
802
- if (partialSnapshot && partialSnapshot.trim().length > 0) {
803
- const writer = deps.writeFileSync || writeFileSync;
804
- writer(`${resultFile}.partial`, partialSnapshot, "utf8");
805
- }
806
- } catch {
807
- /* best-effort, timeout 은 이미 확정 */
808
- }
809
- return {
810
- matched: false,
811
- exitCode: null,
812
- restarts,
813
- stallDetected,
814
- timedOut: true,
815
- timeoutReason: "hard_ceiling",
816
- paneId: currentPaneId,
817
- token: currentToken,
818
- logPath: currentLogPath,
819
- };
820
- }
821
-
822
- // 1) capture-pane 출력 확인
823
- const currentOutput = _capture(currentPaneId, 50);
824
- if (onPoll) {
825
- try {
826
- onPoll(currentOutput);
827
- } catch {
828
- /* 삼킴 */
829
- }
830
- }
831
-
832
- // 2) completion 토큰 감지
833
- const completionRe = buildCompletionRegex(currentToken);
834
- const completionMatch = completionRe.exec(currentOutput);
835
- if (completionMatch) {
836
- return {
837
- matched: true,
838
- exitCode: Number.parseInt(
839
- completionMatch.slice(1).find(Boolean) || "0",
840
- 10,
841
- ),
842
- restarts,
843
- stallDetected,
844
- timedOut: false,
845
- paneId: currentPaneId,
846
- token: currentToken,
847
- logPath: currentLogPath,
848
- };
849
- }
850
-
851
- // 3) resultFile 존재 + mtime 변화 확인
852
- let currentMtime = 0;
853
- try {
854
- if (_exists(resultFile)) currentMtime = _stat(resultFile).mtimeMs;
855
- } catch {
856
- /* 무시 */
857
- }
858
-
859
- // 4) 변화 감지 → stallTimer 리셋
860
- const outputChanged = currentOutput !== lastOutput;
861
- const mtimeChanged = currentMtime > 0 && currentMtime !== lastMtime;
862
-
863
- if (outputChanged || mtimeChanged) {
864
- lastChangeAt = now;
865
- lastOutput = currentOutput;
866
- if (mtimeChanged) lastMtime = currentMtime;
867
- }
868
-
869
- // resultFile이 갱신되고 내용이 있으면 완료로 간주
870
- if (mtimeChanged && currentMtime > 0 && _exists(resultFile)) {
871
- try {
872
- const content = _readFile(resultFile, "utf8").trim();
873
- if (content.length > 0) {
874
- return {
875
- matched: true,
876
- exitCode: 0,
877
- restarts,
878
- stallDetected,
879
- timedOut: false,
880
- paneId: currentPaneId,
881
- token: currentToken,
882
- logPath: currentLogPath,
883
- };
884
- }
885
- } catch {
886
- /* 무시 */
887
- }
888
- }
889
-
890
- // 5) stall 판정
891
- if (now - lastChangeAt >= stallTimeout) {
892
- stallDetected = true;
893
-
894
- if (restarts >= maxRestarts) {
895
- if (onIntervene && interventions < maxInterventions) {
896
- interventions += 1;
897
- let handled = false;
898
- try {
899
- handled =
900
- (await onIntervene({
901
- channel: "pane",
902
- sessionName,
903
- paneId: currentPaneId,
904
- resultFile,
905
- inactiveMs: now - lastChangeAt,
906
- restarts,
907
- interventions,
908
- })) === true;
909
- } catch {
910
- handled = false;
911
- }
912
- if (handled) {
913
- lastChangeAt = Date.now();
914
- continue;
915
- }
916
- }
917
- const err = new Error("CLI가 반복적으로 멈춤. 수동 확인 필요.");
918
- err.code = "STALL_EXHAUSTED";
919
- err.category = "transient";
920
- err.recovery = "CLI가 반복적으로 멈춤. 수동 확인 필요.";
921
- err.restarts = restarts;
922
- err.interventions = interventions;
923
- throw err;
924
- }
925
-
926
- // kill pane → re-dispatch
927
- try {
928
- _exec(["kill-pane", "-t", currentPaneId]);
929
- } catch {
930
- /* 이미 종료 */
931
- }
932
-
933
- if (command) {
934
- // 새 pane split + 동일 command re-dispatch
935
- const newPaneId = _exec([
936
- "split-window",
937
- "-t",
938
- sessionName,
939
- "-P",
940
- "-F",
941
- "#{session_name}:#{window_index}.#{pane_index}",
942
- ]);
943
- _startCapture(sessionName, newPaneId);
944
- const redispatch = _dispatch(sessionName, newPaneId, command);
945
- currentPaneId = redispatch?.paneId || newPaneId;
946
- if (redispatch?.token) currentToken = redispatch.token;
947
- if (redispatch?.logPath) currentLogPath = redispatch.logPath;
948
- }
949
-
950
- restarts++;
951
- break; // inner loop 재시작 (stallTimer 리셋)
952
- }
953
- }
954
- }
955
- }
956
-
957
442
  function createHeadlessSessionOwnership(killSession) {
958
443
  let owned = false;
959
444
  let ownedSessionName = "";
@@ -1021,7 +506,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
1021
506
  const paneName = `worker-${i + 1}`;
1022
507
  const displayName = resolveHeadlessDisplayName(assignment, paneName);
1023
508
  const workerId = getHeadlessWorkerAgentId(sessionName, i);
1024
- const resolvedCli = resolveHeadlessCliType(assignment.cli);
509
+ const resolvedCli = resolveCliType(assignment.cli);
1025
510
  const brand = CLI_BRAND[resolvedCli] || {
1026
511
  emoji: "\u{25CF}",
1027
512
  label: resolvedCli,
@@ -1047,7 +532,6 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
1047
532
  } catch {
1048
533
  /* 무시 */
1049
534
  }
1050
- await registerHeadlessWorker(sessionName, i, resolvedCli);
1051
535
 
1052
536
  if (safeProgress)
1053
537
  safeProgress({
@@ -1081,7 +565,6 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
1081
565
  // pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
1082
566
  if (i > 0) await new Promise((r) => setTimeout(r, 300));
1083
567
  const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
1084
- registerHeadlessSynapseWorker(workerId, assignment.prompt);
1085
568
 
1086
569
  if (safeProgress)
1087
570
  safeProgress({ type: "dispatched", paneName, cli: resolvedCli });
@@ -1150,7 +633,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
1150
633
  const paneName = `worker-${i + 1}`;
1151
634
  const displayName = resolveHeadlessDisplayName(assignment, paneName);
1152
635
  const workerId = getHeadlessWorkerAgentId(sessionName, i);
1153
- const resolvedCli = resolveHeadlessCliType(assignment.cli);
636
+ const resolvedCli = resolveCliType(assignment.cli);
1154
637
  const resultFile = join(
1155
638
  RESULT_DIR,
1156
639
  `${sessionName}-${paneName}.txt`,
@@ -1170,12 +653,10 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
1170
653
  },
1171
654
  );
1172
655
  const scriptDir = join(RESULT_DIR, sessionName);
1173
- await registerHeadlessWorker(sessionName, i, resolvedCli);
1174
656
  const dispatch = dispatchCommand(sessionName, paneName, cmd, {
1175
657
  scriptDir,
1176
658
  scriptName: paneName,
1177
659
  });
1178
- registerHeadlessSynapseWorker(workerId, assignment.prompt);
1179
660
 
1180
661
  // P1 fix: 비-progressive에서는 pane 리네임 금지 — 캡처 로그 경로가 타이틀 기반이므로
1181
662
  // 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
@@ -1204,135 +685,6 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
1204
685
  );
1205
686
  }
1206
687
 
1207
- async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
1208
- const { safeProgress, configDir } = opts;
1209
- const paths = deriveClaudeControlPaths({ configDir });
1210
- const dispatches = [];
1211
-
1212
- try {
1213
- for (let i = 0; i < assignments.length; i += 1) {
1214
- const assignment = assignments[i];
1215
- const paneName = `worker-${i + 1}`;
1216
- const displayName = resolveHeadlessDisplayName(assignment, paneName);
1217
- const workerId = getHeadlessWorkerAgentId(sessionName, i);
1218
- const resolvedCli = resolveHeadlessCliType(assignment.cli);
1219
- const resultFile = join(
1220
- RESULT_DIR,
1221
- `${sessionName}-${paneName}.txt`,
1222
- ).replace(/\\/g, "/");
1223
- cleanStaleResultArtifacts(resultFile);
1224
- const command = buildHeadlessCommand(
1225
- assignment.cli,
1226
- assignment.prompt,
1227
- resultFile,
1228
- {
1229
- mcp: assignment.mcp,
1230
- role: assignment.role,
1231
- model: assignment.model,
1232
- profile: assignment.profile,
1233
- cwd: assignment.cwd || assignment.workdir,
1234
- },
1235
- );
1236
- const token = randomUUID().slice(0, 10);
1237
- const short = randomUUID().replace(/-/g, "").slice(0, 8);
1238
- const name = `Triflux ${resolvedCli || "worker"} ${displayName}`;
1239
- const record = {
1240
- paneId: `daemon:${short}`,
1241
- paneName,
1242
- displayName,
1243
- resultFile,
1244
- cli: resolvedCli,
1245
- role: assignment.role,
1246
- command,
1247
- token,
1248
- workerId,
1249
- cwd: assignment.cwd || assignment.workdir,
1250
- daemonShort: short,
1251
- daemonPaths: paths,
1252
- controlSock: paths.controlSock,
1253
- sessionId: "",
1254
- sessionProjectionPath: "",
1255
- daemonCompletionMatched: false,
1256
- };
1257
- dispatches.push(record);
1258
-
1259
- const payload = buildDaemonExecDispatchPayload({
1260
- short,
1261
- cwd: assignment.cwd || assignment.workdir || process.cwd(),
1262
- command: buildDaemonWrappedCommand(command, token),
1263
- name,
1264
- });
1265
- record.sessionId = payload.sessionId;
1266
- // buildDaemonWrappedCommand + token completion 시맨틱은 헬퍼 밖(headless 전용)에
1267
- // 남기고, dispatch→pid→bridge→projection 시퀀스만 공유 헬퍼로 위임한다.
1268
- // CRITICAL: waitForDaemonCompletion 이 나중에 record.daemonCompletionMatched 를
1269
- // 변경하고 cleanupDaemonDispatches 가 같은 record 를 읽으므로, 헬퍼가 새 객체를
1270
- // 반환해도 record 를 교체하지 말고 plain 필드를 Object.assign 한다.
1271
- const dispatched = await dispatchClaudeDaemonJob({
1272
- paths,
1273
- controlSock: paths.controlSock,
1274
- payload,
1275
- agent: resolvedCli || "codex",
1276
- name,
1277
- cwd: assignment.cwd || assignment.workdir || process.cwd(),
1278
- dispatchTimeoutMs: 5000,
1279
- }).catch((error) => {
1280
- throw new Error(
1281
- `[headless] Claude daemon dispatch failed for ${paneName}: ${
1282
- error?.message || error
1283
- }`,
1284
- );
1285
- });
1286
- Object.assign(record, {
1287
- sessionId: dispatched.sessionId,
1288
- sessionProjectionPath: dispatched.sessionProjectionPath,
1289
- });
1290
- await registerHeadlessWorker(sessionName, i, resolvedCli);
1291
- registerHeadlessSynapseWorker(workerId, assignment.prompt);
1292
- if (safeProgress) {
1293
- safeProgress({ type: "dispatched", paneName, cli: resolvedCli });
1294
- }
1295
- }
1296
- } catch (error) {
1297
- await cleanupDaemonDispatches(dispatches);
1298
- throw error;
1299
- }
1300
-
1301
- return dispatches;
1302
- }
1303
-
1304
- export async function cleanupDaemonDispatches(dispatches) {
1305
- await Promise.all(
1306
- dispatches.map(async (dispatch) => {
1307
- if (dispatch.sessionProjectionPath) {
1308
- await removeClaudeSessionProjection(
1309
- dispatch.sessionProjectionPath,
1310
- ).catch(() => {});
1311
- }
1312
- if (
1313
- dispatch.daemonCompletionMatched === true &&
1314
- dispatch.daemonPaths &&
1315
- dispatch.sessionId
1316
- ) {
1317
- await sendKillBySessionId({
1318
- daemonPaths: dispatch.daemonPaths,
1319
- sessionId: dispatch.sessionId,
1320
- }).catch(() => {});
1321
- }
1322
- if (dispatch.controlSock && dispatch.daemonShort) {
1323
- const controlAuth = await buildDaemonControlAuth(
1324
- dispatch.daemonPaths?.configDir,
1325
- ).catch(() => ({}));
1326
- await killDaemonJob(
1327
- dispatch.controlSock,
1328
- dispatch.daemonShort,
1329
- controlAuth,
1330
- ).catch(() => {});
1331
- }
1332
- }),
1333
- );
1334
- }
1335
-
1336
688
  /**
1337
689
  * 모든 dispatch를 병렬 대기하며 완료 결과를 수집한다.
1338
690
  * @param {string} sessionName
@@ -1348,7 +700,6 @@ async function awaitAll(
1348
700
  timeoutSec,
1349
701
  safeProgress,
1350
702
  progressIntervalSec,
1351
- stallOpts,
1352
703
  lifecycle,
1353
704
  ) {
1354
705
  // 병렬 대기 (Promise.all — 모든 pane 동시 폴링, 총 시간 = max(개별 시간))
@@ -1375,73 +726,7 @@ async function awaitAll(
1375
726
  }
1376
727
 
1377
728
  let completion;
1378
- if (stallOpts?.enabled) {
1379
- // 하이브리드 stall detection 모드
1380
- try {
1381
- const stallPollCb =
1382
- safeProgress && progressIntervalSec > 0
1383
- ? (snapshot) => {
1384
- try {
1385
- safeProgress({
1386
- type: "progress",
1387
- paneName: d.paneName,
1388
- displayName: d.displayName,
1389
- cli: d.cli,
1390
- snapshot: snapshot.split("\n").slice(-15).join("\n"),
1391
- });
1392
- } catch {
1393
- /* 삼킴 */
1394
- }
1395
- }
1396
- : undefined;
1397
-
1398
- const stallResult = await waitForCompletionWithStallDetect(
1399
- sessionName,
1400
- d.paneId || d.paneName,
1401
- d.resultFile,
1402
- {
1403
- pollInterval: stallOpts.pollInterval,
1404
- stallTimeout: stallOpts.stallTimeout,
1405
- hardCeiling: lifecycle?.enabled
1406
- ? (stallOpts.hardCeiling ??
1407
- stallOpts.completionTimeout ??
1408
- lifecycle.hardCeilingMs)
1409
- : (stallOpts.completionTimeout ?? timeoutSec * 1000),
1410
- maxRestarts: stallOpts.maxRestarts,
1411
- maxInterventions: stallOpts.maxInterventions,
1412
- command: d.command,
1413
- token: d.token,
1414
- onPoll: stallPollCb,
1415
- onIntervene: lifecycle?.enabled
1416
- ? createHeadlessIntervention(
1417
- d,
1418
- stallOpts.onIntervene ?? lifecycle.onIntervene,
1419
- )
1420
- : undefined,
1421
- },
1422
- );
1423
- if (stallResult.paneId) d.paneId = stallResult.paneId;
1424
- if (stallResult.token) d.token = stallResult.token;
1425
- if (stallResult.logPath) d.logPath = stallResult.logPath;
1426
- completion = {
1427
- matched: stallResult.matched,
1428
- exitCode: stallResult.exitCode,
1429
- stallDetected: stallResult.stallDetected,
1430
- restarts: stallResult.restarts,
1431
- };
1432
- } catch (stallErr) {
1433
- if (stallErr.code === "STALL_EXHAUSTED") {
1434
- completion = {
1435
- matched: false,
1436
- exitCode: null,
1437
- stallExhausted: true,
1438
- restarts: stallErr.restarts,
1439
- };
1440
- } else {
1441
- throw stallErr;
1442
- }
1443
- }
1444
- } else if (lifecycle?.enabled) {
729
+ if (lifecycle?.enabled) {
1445
730
  if (d.logPath) pollOpts.logPath = d.logPath;
1446
731
  const activity = createActivityLifecycle({
1447
732
  interventionMs: lifecycle.interventionMs,
@@ -1501,7 +786,6 @@ async function awaitAll(
1501
786
  }
1502
787
 
1503
788
  const output = readResult(d.resultFile, d.paneId);
1504
- unregisterHeadlessSynapseWorker(d.workerId);
1505
789
 
1506
790
  if (safeProgress) {
1507
791
  safeProgress({
@@ -1512,174 +796,6 @@ async function awaitAll(
1512
796
  matched: completion.matched,
1513
797
  exitCode: completion.exitCode,
1514
798
  sessionDead: completion.sessionDead || false,
1515
- stallDetected: completion.stallDetected || false,
1516
- stallExhausted: completion.stallExhausted || false,
1517
- });
1518
- }
1519
-
1520
- return { d, completion, output };
1521
- }),
1522
- );
1523
- }
1524
-
1525
- export async function waitForDaemonCompletion(
1526
- dispatch,
1527
- timeoutSec,
1528
- safeProgress,
1529
- progressIntervalSec,
1530
- lifecycle = null,
1531
- ) {
1532
- const messages = [];
1533
- const socket = net.connect(dispatch.controlSock);
1534
- let buffer = "";
1535
- let settled = false;
1536
- let lastProgressAt = 0;
1537
- const activity = lifecycle?.enabled
1538
- ? createActivityLifecycle({
1539
- interventionMs: lifecycle.interventionMs,
1540
- hardCeilingMs: lifecycle.hardCeilingMs,
1541
- onIntervene: createHeadlessIntervention(
1542
- dispatch,
1543
- lifecycle.onIntervene,
1544
- ),
1545
- })
1546
- : null;
1547
-
1548
- return await new Promise((resolve) => {
1549
- const finish = (completion, timeoutReason = "") => {
1550
- if (settled) return;
1551
- settled = true;
1552
- clearTimeout(timer);
1553
- clearInterval(timer);
1554
- socket.destroy();
1555
- const finalCompletion =
1556
- completion ||
1557
- waitForDaemonCompletionFromMessages(messages, {
1558
- token: dispatch.token,
1559
- resultFile: dispatch.resultFile,
1560
- });
1561
- if (!finalCompletion.matched && timeoutReason) {
1562
- finalCompletion.timedOut = true;
1563
- finalCompletion.timeoutReason = timeoutReason;
1564
- }
1565
- if (finalCompletion.matched) {
1566
- dispatch.daemonCompletionMatched = true;
1567
- cleanupDaemonDispatches([dispatch]).catch(() => {});
1568
- } else {
1569
- buildDaemonControlAuth(dispatch.daemonPaths?.configDir)
1570
- .catch(() => ({}))
1571
- .then((controlAuth) =>
1572
- killDaemonJob(
1573
- dispatch.controlSock,
1574
- dispatch.daemonShort,
1575
- controlAuth,
1576
- ),
1577
- )
1578
- .catch(() => {});
1579
- }
1580
- resolve(finalCompletion);
1581
- };
1582
-
1583
- const timer = activity
1584
- ? setInterval(() => {
1585
- void activity
1586
- .check({
1587
- channel: "daemon",
1588
- controlSock: dispatch.controlSock,
1589
- daemonShort: dispatch.daemonShort,
1590
- resultFile: dispatch.resultFile,
1591
- })
1592
- .then((reason) => {
1593
- if (reason) finish(null, reason);
1594
- });
1595
- }, lifecycle.checkIntervalMs ?? 5_000)
1596
- : setTimeout(
1597
- () => finish(null, "wall_clock"),
1598
- Math.max(0, timeoutSec * 1000),
1599
- );
1600
- if (typeof timer.unref === "function") timer.unref();
1601
-
1602
- socket.on("error", () => finish(null, "socket_error"));
1603
- socket.on("close", () => {
1604
- if (!settled) finish(null, "socket_closed");
1605
- });
1606
- socket.on("connect", () => {
1607
- socket.write(
1608
- `${JSON.stringify({
1609
- proto: 1,
1610
- op: "subscribe",
1611
- short: dispatch.daemonShort,
1612
- tail: 20,
1613
- })}\n`,
1614
- );
1615
- });
1616
- socket.on("data", (chunk) => {
1617
- buffer += chunk.toString("utf8");
1618
- while (buffer.includes("\n")) {
1619
- const index = buffer.indexOf("\n");
1620
- const line = buffer.slice(0, index);
1621
- buffer = buffer.slice(index + 1);
1622
- if (!line.trim()) continue;
1623
- const message = JSON.parse(line);
1624
- messages.push(message);
1625
- activity?.observe();
1626
- if (
1627
- safeProgress &&
1628
- progressIntervalSec > 0 &&
1629
- typeof message.line === "string"
1630
- ) {
1631
- const now = Date.now();
1632
- if (now - lastProgressAt >= progressIntervalSec * 1000) {
1633
- lastProgressAt = now;
1634
- safeProgress({
1635
- type: "progress",
1636
- paneName: dispatch.paneName,
1637
- displayName: dispatch.displayName,
1638
- cli: dispatch.cli,
1639
- snapshot: message.line.split("\n").slice(-15).join("\n"),
1640
- });
1641
- }
1642
- }
1643
- const completion = waitForDaemonCompletionFromMessages(messages, {
1644
- token: dispatch.token,
1645
- resultFile: dispatch.resultFile,
1646
- });
1647
- if (completion.matched) finish(completion);
1648
- }
1649
- });
1650
- });
1651
- }
1652
-
1653
- async function awaitAllDaemon(
1654
- dispatches,
1655
- timeoutSec,
1656
- safeProgress,
1657
- progressIntervalSec,
1658
- lifecycle,
1659
- ) {
1660
- return Promise.all(
1661
- dispatches.map(async (d) => {
1662
- const completion = await waitForDaemonCompletion(
1663
- d,
1664
- timeoutSec,
1665
- safeProgress,
1666
- progressIntervalSec,
1667
- lifecycle,
1668
- );
1669
- const output = readResult(d.resultFile, d.paneId);
1670
- unregisterHeadlessSynapseWorker(d.workerId);
1671
-
1672
- if (safeProgress) {
1673
- safeProgress({
1674
- type: "completed",
1675
- paneName: d.paneName,
1676
- displayName: d.displayName,
1677
- cli: d.cli,
1678
- matched: completion.matched,
1679
- exitCode: completion.exitCode,
1680
- sessionDead: false,
1681
- stallDetected: false,
1682
- stallExhausted: false,
1683
799
  });
1684
800
  }
1685
801
 
@@ -1693,7 +809,7 @@ async function awaitAllDaemon(
1693
809
  * @param {Array<{d, completion, output}>} results
1694
810
  * @returns {Array}
1695
811
  */
1696
- async function collectResults(sessionName, results) {
812
+ async function collectResults(results) {
1697
813
  // handoff 파이프라인: parse → validate → format (각 워커 결과에 적용)
1698
814
  return await Promise.all(
1699
815
  results.map(async ({ d, completion, output }) => {
@@ -1715,18 +831,6 @@ async function collectResults(sessionName, results) {
1715
831
  };
1716
832
  handoffResult.formatted = formatHandoffForLead(handoffResult.handoff);
1717
833
  }
1718
- const status =
1719
- handoffResult.handoff?.status ||
1720
- (completion.matched && completion.exitCode === 0
1721
- ? "completed"
1722
- : "failed");
1723
- await publishHeadlessResult(
1724
- sessionName,
1725
- d.workerId,
1726
- status,
1727
- handoffResult.handoff,
1728
- );
1729
-
1730
834
  return {
1731
835
  cli: d.cli,
1732
836
  paneName: d.paneName,
@@ -1782,21 +886,18 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1782
886
  const sessionOwnership =
1783
887
  opts._sessionOwnership ||
1784
888
  createHeadlessSessionOwnership(deps.killPsmuxSession || killPsmuxSession);
889
+ const agentsRows = opts._agentsRows || createAgentsRowOwnership();
1785
890
  let {
1786
891
  timeoutSec = 900,
1787
892
  layout = "2x2",
1788
893
  onProgress,
1789
894
  progressIntervalSec = 0,
1790
895
  progressive = true,
1791
- autoAttach = false,
1792
896
  dashboard = false,
1793
897
  dashboardLayout = "single",
1794
- stallDetect,
1795
898
  nativeBridge = false,
1796
- nativeBridgeMode = "roster",
899
+ nativeBridgeMode = "agents",
1797
900
  onIntervene,
1798
- leadPane = null,
1799
- leadTmux = null,
1800
901
  } = opts;
1801
902
  if (!Number.isFinite(Number(timeoutSec)) || Number(timeoutSec) <= 0)
1802
903
  timeoutSec = 900;
@@ -1814,70 +915,13 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1814
915
  );
1815
916
 
1816
917
  if (normalizedAssignments.length === 0) {
1817
- return { sessionName, results: [], sessionOwnership };
918
+ return { sessionName, results: [], sessionOwnership, agentsRows };
1818
919
  }
1819
920
 
1820
- let nativeBridgeHandle = null;
1821
- let daemonDispatches = [];
1822
921
  let runCompleted = false;
1823
922
  let completedResults = [];
1824
923
  let runFailed = false;
1825
924
  let runError;
1826
- if (nativeBridge && nativeBridgeMode === "claude-wrapper") {
1827
- throw new Error(
1828
- "[headless] --native-bridge-mode claude-wrapper is reserved and not implemented yet",
1829
- );
1830
- }
1831
- if (
1832
- nativeBridge &&
1833
- (nativeBridgeMode === "roster" || nativeBridgeMode === "interactive-attach")
1834
- ) {
1835
- nativeBridgeHandle = await startClaudeNativeBridge({
1836
- sessionName,
1837
- assignments: normalizedAssignments,
1838
- cwd: process.cwd(),
1839
- workerType:
1840
- nativeBridgeMode === "interactive-attach" ? "interactive" : "headless",
1841
- onKill() {
1842
- sessionOwnership.release();
1843
- },
1844
- });
1845
- process.stderr.write(
1846
- `[headless] Claude native bridge roster written: ${nativeBridgeHandle.rosterPath}\n`,
1847
- );
1848
- }
1849
-
1850
- // Hub version skew pre-flight (fail-open, best-effort)
1851
- requestJson("/status", { method: "GET", timeoutMs: 500 })
1852
- .then((status) => {
1853
- const hubRate = status?.spawn_trace?.max_per_sec;
1854
- const localRate = getMaxSpawnPerSec();
1855
- if (typeof hubRate === "number" && hubRate !== localRate) {
1856
- console.warn(
1857
- `[headless] Hub version skew detected: hub spawn rate=${hubRate}/s, local=${localRate}/s. Restart hub to sync.`,
1858
- );
1859
- }
1860
- })
1861
- .catch(() => {});
1862
-
1863
- // Synapse: 세션 registration (fire-and-forget, hub 미응답 시 무시)
1864
- const synapseIds = normalizedAssignments.map(
1865
- (_, i) => `${sessionName}-worker-${i + 1}`,
1866
- );
1867
- for (let i = 0; i < normalizedAssignments.length; i++) {
1868
- const a = normalizedAssignments[i];
1869
- requestJson("/synapse/register", {
1870
- method: "POST",
1871
- body: {
1872
- sessionId: synapseIds[i],
1873
- host: "local",
1874
- taskSummary: String(a.prompt || "").slice(0, 100),
1875
- isRemote: false,
1876
- },
1877
- timeoutMs: 1000,
1878
- }).catch(() => {});
1879
- }
1880
-
1881
925
  // in-process TUI: dashboard=true이고 stdout이 TTY일 때 직접 구동
1882
926
  let tui = null;
1883
927
  const resolvedLayout = resolveDashboardLayout(
@@ -1897,7 +941,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1897
941
  const a = normalizedAssignments[i];
1898
942
  const paneName = `worker-${i + 1}`;
1899
943
  tui.updateWorker(paneName, {
1900
- cli: resolveHeadlessCliType(a.cli || "codex"),
944
+ cli: resolveCliType(a.cli || "codex"),
1901
945
  displayName: resolveHeadlessDisplayName(a, paneName),
1902
946
  role: a.role || "",
1903
947
  status: "pending",
@@ -1905,11 +949,10 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1905
949
  });
1906
950
  }
1907
951
  } else if (dashboard) {
1908
- // Issue #116-F: --dashboard 요청이 non-TTY 환경에서 silent skip 되던 혼란 해소.
1909
- // stderr 로만 안내 (stdout 은 readHeadlessResult 파싱 대상이라 건드리지 않는다).
952
+ // stdout은 결과 파싱 대상이므로 안내는 stderr로 보낸다.
1910
953
  const logDir = join(tmpdir(), "tfx-headless");
1911
954
  process.stderr.write(
1912
- `\n⚠ --dashboard requested but stdout is not a TTY; dashboard is skipped.\n` +
955
+ `\n⚠ stdout is not a TTY; dashboard is skipped.\n` +
1913
956
  ` Session is running in background. Worker logs:\n` +
1914
957
  ` ${logDir}${IS_WINDOWS ? "\\" : "/"}${sessionName}-worker-N.txt\n` +
1915
958
  ` ${logDir}${IS_WINDOWS ? "\\" : "/"}${sessionName}-worker-N.txt.err\n\n`,
@@ -1951,62 +994,9 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1951
994
  }
1952
995
  }
1953
996
 
1954
- // Synapse heartbeat: progress 이벤트마다 해당 워커의 세션 갱신
1955
- const feedSynapse = (event) => {
1956
- if (!event?.paneName) return;
1957
- const match = event.paneName.match(/worker-(\d+)/);
1958
- if (!match) return;
1959
- const idx = parseInt(match[1], 10) - 1;
1960
- const sid = synapseIds[idx];
1961
- if (!sid) return;
1962
- requestJson("/synapse/heartbeat", {
1963
- method: "POST",
1964
- body: {
1965
- sessionId: sid,
1966
- partial: { taskSummary: (event.snapshot || "").slice(0, 100) },
1967
- },
1968
- timeoutMs: 500,
1969
- }).catch(() => {});
1970
- };
1971
-
1972
- const HUB_ACTIVITY_MIN_INTERVAL_MS = 30_000;
1973
- const lastHubBeatByWorker = new Map();
1974
- const feedHubActivity = (event) => {
1975
- if (event?.type !== "progress" || !event.paneName) return;
1976
- const match = event.paneName.match(/worker-(\d+)/);
1977
- if (!match) return;
1978
- const idx = parseInt(match[1], 10) - 1;
1979
- const agentId = getHeadlessWorkerAgentId(sessionName, idx);
1980
- const nowMs = Date.now();
1981
- if (
1982
- (lastHubBeatByWorker.get(agentId) || 0) + HUB_ACTIVITY_MIN_INTERVAL_MS >
1983
- nowMs
1984
- )
1985
- return;
1986
- lastHubBeatByWorker.set(agentId, nowMs);
1987
- requestJson("/bridge/heartbeat", {
1988
- method: "POST",
1989
- body: {
1990
- agent_id: agentId,
1991
- ...(presenceOwnerByAgent.get(agentId) || {}),
1992
- },
1993
- timeoutMs: 500,
1994
- }).catch(() => {});
1995
- const assignJobId = normalizedAssignments[idx]?.assignJobId;
1996
- if (assignJobId)
1997
- requestJson("/bridge/assign/result", {
1998
- method: "POST",
1999
- body: { job_id: assignJobId, worker_agent: agentId, status: "running" },
2000
- timeoutMs: 500,
2001
- }).catch(() => {});
2002
- };
2003
-
2004
997
  // onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
2005
998
  const combinedProgress = (event) => {
2006
- nativeBridgeHandle?.handleProgress(event);
2007
999
  feedTui(event);
2008
- feedSynapse(event);
2009
- feedHubActivity(event);
2010
1000
  if (onProgress) {
2011
1001
  try {
2012
1002
  onProgress(event);
@@ -2026,82 +1016,37 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
2026
1016
  try {
2027
1017
  const progressiveDispatch = deps.dispatchProgressive || dispatchProgressive;
2028
1018
  const batchDispatch = deps.dispatchBatch || dispatchBatch;
2029
- const dispatches =
2030
- nativeBridge && nativeBridgeMode === "agents"
2031
- ? await dispatchDaemonBatch(sessionName, normalizedAssignments, {
2032
- safeProgress,
2033
- })
2034
- : progressive
2035
- ? await progressiveDispatch(sessionName, normalizedAssignments, {
2036
- layout,
2037
- safeProgress,
2038
- dashboardLayout,
2039
- sessionOwnership,
2040
- _deps: deps,
2041
- })
2042
- : await batchDispatch(sessionName, normalizedAssignments, {
2043
- layout,
2044
- safeProgress,
2045
- dashboardLayout,
2046
- sessionOwnership,
2047
- _deps: deps,
2048
- });
2049
- if (nativeBridge && nativeBridgeMode === "agents") {
2050
- daemonDispatches = dispatches;
2051
- const createObservationSession =
2052
- deps.createDaemonObservationSession || createDaemonObservationSession;
2053
- let observation = null;
2054
- try {
2055
- observation = createObservationSession(sessionName, dispatches, {
2056
- autoAttach,
2057
- dashboard,
1019
+ const dispatches = progressive
1020
+ ? await progressiveDispatch(sessionName, normalizedAssignments, {
1021
+ layout,
1022
+ safeProgress,
2058
1023
  dashboardLayout,
2059
- leadPane,
2060
- leadTmux,
2061
- onProgress: safeProgress,
1024
+ sessionOwnership,
2062
1025
  _deps: deps,
2063
- });
2064
- } catch (error) {
2065
- emitObserverWarning(
1026
+ })
1027
+ : await batchDispatch(sessionName, normalizedAssignments, {
1028
+ layout,
2066
1029
  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",
1030
+ dashboardLayout,
1031
+ sessionOwnership,
1032
+ _deps: deps,
2081
1033
  });
2082
- }
1034
+ if (nativeBridge && nativeBridgeMode === "agents") {
1035
+ await openHeadlessAgentsRows(sessionName, dispatches, agentsRows, {
1036
+ safeProgress,
1037
+ _deps: deps,
1038
+ });
2083
1039
  }
2084
1040
 
2085
- const results =
2086
- nativeBridge && nativeBridgeMode === "agents"
2087
- ? await awaitAllDaemon(
2088
- dispatches,
2089
- timeoutSec,
2090
- safeProgress,
2091
- progressIntervalSec,
2092
- lifecycle,
2093
- )
2094
- : await awaitAll(
2095
- sessionName,
2096
- dispatches,
2097
- timeoutSec,
2098
- safeProgress,
2099
- progressIntervalSec,
2100
- stallDetect,
2101
- lifecycle,
2102
- );
2103
- const collected = await collectResults(sessionName, results);
2104
- for (const result of collected) nativeBridgeHandle?.completeWorker(result);
1041
+ const results = await (deps.awaitAll || awaitAll)(
1042
+ sessionName,
1043
+ dispatches,
1044
+ timeoutSec,
1045
+ safeProgress,
1046
+ progressIntervalSec,
1047
+ lifecycle,
1048
+ );
1049
+ const collected = await collectResults(results);
2105
1050
 
2106
1051
  // 완료 시 TUI에 최종 상태 반영 후 닫기
2107
1052
  if (tui) {
@@ -2133,30 +1078,17 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
2133
1078
  runError = error;
2134
1079
  }
2135
1080
 
2136
- if (!runCompleted) sessionOwnership.release();
2137
- try {
2138
- // Synapse: 세션 unregister (fire-and-forget)
2139
- for (const sid of synapseIds) {
2140
- requestJson("/synapse/unregister", {
2141
- method: "POST",
2142
- body: { sessionId: sid },
2143
- timeoutMs: 1000,
2144
- }).catch(() => {});
2145
- }
2146
- if (nativeBridgeHandle) await nativeBridgeHandle.close();
2147
- if (daemonDispatches.length > 0) {
2148
- await cleanupDaemonDispatches(daemonDispatches);
2149
- }
2150
- } catch (error) {
1081
+ if (!runCompleted) {
2151
1082
  sessionOwnership.release();
2152
- if (!runFailed) {
2153
- runFailed = true;
2154
- runError = error;
2155
- }
1083
+ await agentsRows.release();
2156
1084
  }
2157
-
2158
1085
  if (runFailed) throw runError;
2159
- return { sessionName, results: completedResults, sessionOwnership };
1086
+ return {
1087
+ sessionName,
1088
+ results: completedResults,
1089
+ sessionOwnership,
1090
+ agentsRows,
1091
+ };
2160
1092
  }
2161
1093
 
2162
1094
  /**
@@ -2175,23 +1107,17 @@ export async function runHeadlessWithCleanup(assignments, opts = {}) {
2175
1107
  const sessionOwnership = createHeadlessSessionOwnership(
2176
1108
  deps.killPsmuxSession || killPsmuxSession,
2177
1109
  );
1110
+ const agentsRows = createAgentsRowOwnership();
2178
1111
 
2179
1112
  try {
2180
1113
  return await run(sessionName, assignments, {
2181
1114
  ...runOpts,
2182
1115
  _sessionOwnership: sessionOwnership,
1116
+ _agentsRows: agentsRows,
2183
1117
  });
2184
1118
  } finally {
2185
- try {
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();
2194
- }
1119
+ await agentsRows.release();
1120
+ sessionOwnership.release();
2195
1121
  // WT split pane은 psmux 종료 시 셸이 끝나면서 자동으로 닫힘
2196
1122
  // 수동 close-pane 불필요 (레이스 컨디션으로 WT 에러 발생)
2197
1123
  }
@@ -2233,71 +1159,6 @@ export function applyTrifluxTheme(sessionName) {
2233
1159
  }
2234
1160
  }
2235
1161
 
2236
- /**
2237
- * Windows Terminal에 triflux 프로필을 자동 생성/갱신한다.
2238
- * 반투명 + 비포커스 시 더 투명 + Catppuccin 테마.
2239
- * @returns {boolean} 성공 여부
2240
- */
2241
-
2242
- /**
2243
- * WT 기본 프로필의 폰트 크기를 읽는다.
2244
- * @returns {number} 기본 폰트 크기 (못 읽으면 12)
2245
- */
2246
- function _getWtDefaultFontSize() {
2247
- const settingsPaths = [
2248
- join(
2249
- process.env.LOCALAPPDATA || "",
2250
- "Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json",
2251
- ),
2252
- join(
2253
- process.env.LOCALAPPDATA || "",
2254
- "Microsoft/Windows Terminal/settings.json",
2255
- ),
2256
- ];
2257
- for (const p of settingsPaths) {
2258
- if (!existsSync(p)) continue;
2259
- try {
2260
- const settings = JSON.parse(
2261
- readFileSync(p, "utf8").replace(/^\s*\/\/.*$/gm, ""),
2262
- );
2263
- // 기본 프로필 or 첫 프로필의 폰트
2264
- const defaultGuid = settings.defaultProfile;
2265
- const profiles = settings.profiles?.list || [];
2266
- const defaultProfile =
2267
- profiles.find((pr) => pr.guid === defaultGuid) || profiles[0];
2268
- return (
2269
- defaultProfile?.font?.size ||
2270
- settings.profiles?.defaults?.font?.size ||
2271
- 12
2272
- );
2273
- } catch {
2274
- /* 다음 */
2275
- }
2276
- }
2277
- return 12;
2278
- }
2279
-
2280
- /**
2281
- * 파일을 원자적으로 쓴다 — 임시 파일에 먼저 기록 후 rename으로 교체.
2282
- * 프로세스가 쓰기 도중 충돌해도 원본 파일이 손상되지 않는다.
2283
- * @param {string} filePath — 대상 파일 경로
2284
- * @param {string} data — 쓸 내용
2285
- */
2286
- function _atomicWriteSync(filePath, data) {
2287
- const tmpPath = `${filePath}.${process.pid}.tmp`;
2288
- try {
2289
- writeFileSync(tmpPath, data, "utf8");
2290
- renameSync(tmpPath, filePath);
2291
- } catch (err) {
2292
- try {
2293
- writeFileSync(tmpPath.replace(/\.tmp$/, ".tmp.del"), "");
2294
- } catch {
2295
- /* 무시 */
2296
- }
2297
- throw err;
2298
- }
2299
- }
2300
-
2301
1162
  function sanitizeSessionName(value) {
2302
1163
  return String(value || "").replace(/[^a-zA-Z0-9_-]/g, "") || "tfx-session";
2303
1164
  }
@@ -2338,98 +1199,52 @@ function emitObserverWarning(
2338
1199
  });
2339
1200
  }
2340
1201
 
2341
- // ─── v6.0.0: Lead-Direct Interactive Mode ───
2342
-
2343
1202
  /**
2344
- * 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}
1203
+ * 워커 pane 마다 `claude agents` 행을 연다. 행은 그 pane 을 읽기 전용으로 보여 준다.
1204
+ * 행을 못 열어도 워커는 tmux 에서 계속 돈다. 실패는 observer_warning 으로 알린다.
2351
1205
  */
2352
- export function createDaemonObservationSession(
1206
+ async function openHeadlessAgentsRows(
2353
1207
  sessionName,
2354
1208
  dispatches,
1209
+ rows,
2355
1210
  opts = {},
2356
1211
  ) {
2357
1212
  const deps = opts._deps || {};
2358
1213
  const platform = deps.platform ?? process.platform;
2359
- 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
- }
1214
+ if (platform !== "darwin" && platform !== "linux") {
2422
1215
  emitObserverWarning(
2423
- opts.onProgress,
2424
- safeSessionName,
2425
- stage,
2426
- error,
2427
- "observer setup failed",
1216
+ opts.safeProgress,
1217
+ sessionName,
1218
+ "agents_row",
1219
+ null,
1220
+ `claude agents rows need tmux on macOS/Linux (platform: ${platform})`,
2428
1221
  );
2429
- return null;
1222
+ return;
1223
+ }
1224
+ const open = deps.openAgentsRow || openAgentsRow;
1225
+ for (const d of dispatches) {
1226
+ try {
1227
+ const row = await open({
1228
+ name: `${d.cli} ${d.displayName || d.paneName}`,
1229
+ target: d.paneId,
1230
+ cwd: d.cwd || process.cwd(),
1231
+ });
1232
+ rows.add(row.short);
1233
+ d.agentsRowShort = row.short;
1234
+ } catch (error) {
1235
+ emitObserverWarning(
1236
+ opts.safeProgress,
1237
+ sessionName,
1238
+ "agents_row",
1239
+ error,
1240
+ `claude agents row failed for ${d.paneName}`,
1241
+ );
1242
+ }
2430
1243
  }
2431
1244
  }
2432
1245
 
1246
+ // ─── v6.0.0: Lead-Direct Interactive Mode ───
1247
+
2433
1248
  /**
2434
1249
  * attached tmux의 현재 leader pane 옆에 headless session view를 연다.
2435
1250
  * 새 pane의 attach client는 headless session 종료와 함께 자연스럽게 종료된다.
@@ -2789,11 +1604,13 @@ export async function runHeadlessInteractive(
2789
1604
  let sessionOwnership = createHeadlessSessionOwnership(
2790
1605
  deps.killPsmuxSession || killPsmuxSession,
2791
1606
  );
1607
+ const agentsRows = createAgentsRowOwnership();
2792
1608
 
2793
1609
  // Phase 1: 세션 생성 → 즉시 관찰 pane/tab attach → dispatch → 대기 → 결과 수집
2794
1610
  const runResult = await run(sessionName, assignments, {
2795
1611
  ...interactiveRunOpts,
2796
1612
  _sessionOwnership: sessionOwnership,
1613
+ _agentsRows: agentsRows,
2797
1614
  });
2798
1615
  const { results } = runResult;
2799
1616
  sessionOwnership = runResult.sessionOwnership || sessionOwnership;
@@ -2876,12 +1693,7 @@ export async function runHeadlessInteractive(
2876
1693
  kill() {
2877
1694
  if (this._killed) return;
2878
1695
  this._killed = true;
2879
- for (let index = 0; index < assignments.length; index++) {
2880
- unregisterHeadlessSynapseWorker(
2881
- getHeadlessWorkerAgentId(sessionName, index),
2882
- );
2883
- }
2884
- void deregisterHeadlessWorkers(sessionName, assignments.length);
1696
+ void agentsRows.release();
2885
1697
  sessionOwnership.release();
2886
1698
  // attach pane/tab은 psmux 종료 → attach client 종료 → 자동 닫힘
2887
1699
  // 수동 close-pane 불필요 (레이스 컨디션으로 WT 0x80070002 에러 발생)