@triflux/remote 10.51.0 → 10.53.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 (62) hide show
  1. package/cto/brief.mjs +0 -8
  2. package/cto/collect.mjs +1 -127
  3. package/cto/current.schema.json +2 -19
  4. package/cto/hygiene.mjs +2 -61
  5. package/cto/lake-root.mjs +0 -56
  6. package/cto/status.mjs +5 -139
  7. package/hub/index.mjs +0 -9
  8. package/hub/team/agents-row-attach.py +120 -0
  9. package/hub/team/agents-row.mjs +200 -0
  10. package/hub/team/claude-daemon-control.mjs +1 -2
  11. package/hub/team/claude-session-projection.mjs +5 -73
  12. package/hub/team/claude-transcript.mjs +19 -0
  13. package/hub/team/cli/commands/debug.mjs +1 -2
  14. package/hub/team/cli/commands/start/index.mjs +2 -1
  15. package/hub/team/cli/commands/start/start-headless.mjs +2 -0
  16. package/hub/team/cli/commands/start/start-mux.mjs +2 -4
  17. package/hub/team/cli/services/state-store.mjs +5 -20
  18. package/hub/team/headless.mjs +132 -551
  19. package/hub/team/psmux.mjs +3 -33
  20. package/hub/team/results-index.mjs +92 -0
  21. package/hub/team/session-context.mjs +1 -1
  22. package/hub/team/tui-viewer.mjs +9 -56
  23. package/hub/team/tui.mjs +0 -37
  24. package/hub/team/uds-orchestrator.mjs +20 -599
  25. package/hub/workers/factory.mjs +6 -51
  26. package/hub/workers/interface.mjs +1 -7
  27. package/hub/workers/lib/jsonrpc-core.mjs +11 -0
  28. package/hub/workers/lib/jsonrpc-ws-uds.mjs +5 -6
  29. package/package.json +3 -8
  30. package/scripts/lib/agent-route-policy.mjs +0 -67
  31. package/scripts/lib/cli-claude.mjs +0 -12
  32. package/scripts/lib/env-probe.mjs +0 -70
  33. package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
  34. package/scripts/lib/mcp-guard-engine.mjs +42 -101
  35. package/hub/hub-lifecycle.mjs +0 -248
  36. package/hub/pipe.mjs +0 -825
  37. package/hub/promote-penalties.mjs +0 -138
  38. package/hub/public/dashboard.html +0 -416
  39. package/hub/schema.sql +0 -209
  40. package/hub/server.mjs +0 -3048
  41. package/hub/store-adapter.mjs +0 -182
  42. package/hub/store.mjs +0 -1736
  43. package/hub/team/cli/services/hub-client.mjs +0 -178
  44. package/hub/team/conductor-registry.mjs +0 -121
  45. package/hub/team/cto-auto-collect.mjs +0 -115
  46. package/hub/team/lead-control.mjs +0 -191
  47. package/hub/team/session-sync.mjs +0 -316
  48. package/hub/team/swarm-intent.mjs +0 -18
  49. package/hub/team/synapse-cli.mjs +0 -458
  50. package/hub/team/synapse-http.mjs +0 -311
  51. package/hub/team/synapse-registry.mjs +0 -618
  52. package/hub/team/tui-synapse.mjs +0 -223
  53. package/hub/team/worker-sandbox.mjs +0 -125
  54. package/hub/tools.mjs +0 -900
  55. package/hub/workers/codex-app-server-worker.mjs +0 -1067
  56. package/hub/workers/delegator-mcp.mjs +0 -1098
  57. package/hub/workers/lib/jsonrpc-stdio.mjs +0 -204
  58. package/scripts/lib/claudemd-scanner.mjs +0 -26
  59. package/scripts/lib/context.mjs +0 -51
  60. package/scripts/lib/handoff.mjs +0 -187
  61. package/scripts/lib/logger.mjs +0 -122
  62. package/scripts/lib/stealth-fetch.mjs +0 -176
@@ -12,7 +12,6 @@ import {
12
12
  writeFileSync,
13
13
  } from "node:fs";
14
14
  import { createRequire } from "node:module";
15
- import net from "node:net";
16
15
  import { tmpdir } from "node:os";
17
16
  import { dirname, join, resolve } from "node:path";
18
17
  import { fileURLToPath } from "node:url";
@@ -25,26 +24,15 @@ import {
25
24
  resolveStallInterventionMs,
26
25
  } from "@triflux/core/hub/lib/worker-lifecycle.mjs";
27
26
  import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
27
+ import { createAgentsRowOwnership, openAgentsRow } from "./agents-row.mjs";
28
28
  import { getBackend } from "./backend.mjs";
29
- import {
30
- buildDaemonControlAuth,
31
- buildDaemonExecDispatchPayload,
32
- deriveClaudeDaemonPaths as deriveClaudeControlPaths,
33
- dispatchClaudeDaemonJob,
34
- killDaemonJob,
35
- sendKillBySessionId,
36
- } from "./claude-daemon-control.mjs";
37
- import { removeClaudeSessionProjection } from "./claude-session-projection.mjs";
38
29
  import { resolveDashboardLayout } from "./dashboard-layout.mjs";
39
30
  import {
40
31
  formatHandoffForLead,
41
32
  HANDOFF_INSTRUCTION_SHORT,
42
33
  processHandoff,
43
34
  } from "./handoff.mjs";
44
- import {
45
- createFileActivitySource,
46
- createInterventionLadder,
47
- } from "./intervention.mjs";
35
+ import { createInterventionLadder } from "./intervention.mjs";
48
36
  import {
49
37
  capturePsmuxPane,
50
38
  createPsmuxSession,
@@ -52,15 +40,14 @@ import {
52
40
  killPsmuxSession,
53
41
  psmuxExec,
54
42
  psmuxSessionExists,
55
- sendKeysToPane,
56
43
  startCapture,
57
44
  waitForCompletion,
58
45
  } from "./psmux.mjs";
46
+ import { createResultsIndex, resultsIndexPath } from "./results-index.mjs";
59
47
  import { createLogDashboard } from "./tui.mjs";
60
48
  import { createWtManager } from "./wt-manager.mjs";
61
49
 
62
50
  const RESULT_DIR = join(tmpdir(), "tfx-headless");
63
- const DAEMON_COMPLETION_PREFIX = "__TFX_HEADLESS_DONE__:";
64
51
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
65
52
 
66
53
  /** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
@@ -140,6 +127,14 @@ export function normalizeHeadlessRole(role) {
140
127
  return normalized && VALID_ROUTE_AGENTS.has(normalized) ? normalized : "";
141
128
  }
142
129
 
130
+ export function headlessResultFile(sessionName, paneName) {
131
+ return join(RESULT_DIR, `${sessionName}-${paneName}.txt`).replace(/\\/g, "/");
132
+ }
133
+
134
+ export function headlessResultsIndexPath(sessionName) {
135
+ return resultsIndexPath(RESULT_DIR, sessionName).replace(/\\/g, "/");
136
+ }
137
+
143
138
  export function resolveHeadlessDisplayName(assignment = {}, paneName = "") {
144
139
  const normalized = String(assignment.displayName || "")
145
140
  .trim()
@@ -235,7 +230,11 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
235
230
  let ctx = readFileSync(contextFile, "utf8");
236
231
  let truncated = false;
237
232
  if (Buffer.byteLength(ctx, "utf8") > 32768) {
238
- ctx = Buffer.from(ctx).subarray(0, 32768).toString("utf8");
233
+ // 바이트 경계에서 잘린 다바이트 글자는 U+FFFD 로 디코딩되므로 떼어 낸다.
234
+ ctx = Buffer.from(ctx)
235
+ .subarray(0, 32768)
236
+ .toString("utf8")
237
+ .replace(/�+$/u, "");
239
238
  truncated = true;
240
239
  }
241
240
  if (truncated) {
@@ -309,48 +308,6 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
309
308
  return `cd '${safeCwd.replace(/'/g, "'\\''")}' && ${backendCommand}`;
310
309
  }
311
310
 
312
- function escapeRegExp(value) {
313
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
314
- }
315
-
316
- export function buildDaemonWrappedCommand(command, token) {
317
- return `{ ${command}; __ec=$?; echo "${DAEMON_COMPLETION_PREFIX}${token}:$__ec"; }`;
318
- }
319
-
320
- export function waitForDaemonCompletionFromMessages(
321
- messages,
322
- { token, resultFile } = {},
323
- ) {
324
- const completionRegex = new RegExp(
325
- `${escapeRegExp(DAEMON_COMPLETION_PREFIX)}${escapeRegExp(token)}:(\\d+)`,
326
- );
327
- const stream = [];
328
- let matched = false;
329
- let exitCode = null;
330
-
331
- for (const message of messages) {
332
- if (Array.isArray(message?.streamTail)) {
333
- stream.push(...message.streamTail.map((line) => `${line}\n`));
334
- }
335
- if (typeof message?.line !== "string") continue;
336
- const match = completionRegex.exec(message.line);
337
- if (match) {
338
- const beforeCompletion = message.line.slice(0, match.index);
339
- if (beforeCompletion) stream.push(beforeCompletion);
340
- matched = true;
341
- exitCode = Number.parseInt(match[1], 10);
342
- continue;
343
- }
344
- stream.push(message.line);
345
- }
346
-
347
- if (resultFile) {
348
- writeFileSync(`${resultFile}.partial`, stream.join(""), "utf8");
349
- }
350
-
351
- return { matched, exitCode };
352
- }
353
-
354
311
  /**
355
312
  * 이전 run 의 stale .txt / .partial / .err 를 제거한다.
356
313
  * Issue #118 Codex review R1 HIGH: resultFile 경로 재사용 시 (워커 restart 또는
@@ -413,43 +370,28 @@ function createHeadlessIntervention(dispatch, fallback) {
413
370
  if (typeof fallback === "function")
414
371
  return (await fallback(context)) === true;
415
372
 
416
- const isDaemon = context.channel === "daemon";
417
- const readActivitySignature = isDaemon
418
- ? createFileActivitySource({ files: [dispatch.resultFile] })
419
- : () => {
420
- let mtime = 0;
421
- try {
422
- mtime = statSync(dispatch.resultFile).mtimeMs;
423
- } catch {
424
- /* result file has not been written yet */
425
- }
426
- return `${capturePsmuxPane(context.paneId || dispatch.paneId, 50)}\0${mtime}`;
427
- };
373
+ const readActivitySignature = () => {
374
+ let mtime = 0;
375
+ try {
376
+ mtime = statSync(dispatch.resultFile).mtimeMs;
377
+ } catch {
378
+ /* result file has not been written yet */
379
+ }
380
+ return `${capturePsmuxPane(context.paneId || dispatch.paneId, 50)}\0${mtime}`;
381
+ };
428
382
  const ladder = createInterventionLadder({
429
- target: isDaemon
430
- ? {
431
- channel: "claude-daemon",
432
- cli: dispatch.cli || "claude",
433
- sessionId: dispatch.sessionId,
434
- daemon: {
435
- controlSock: dispatch.controlSock,
436
- short: dispatch.daemonShort,
437
- sessionId: dispatch.sessionId,
438
- configDir: dispatch.daemonPaths?.configDir,
439
- },
440
- }
441
- : {
442
- channel: "tmux-pane",
443
- paneId: context.paneId || dispatch.paneId,
444
- cli: dispatch.cli,
445
- sessionId: dispatch.sessionId,
446
- },
383
+ target: {
384
+ channel: "tmux-pane",
385
+ paneId: context.paneId || dispatch.paneId,
386
+ cli: dispatch.cli,
387
+ sessionId: dispatch.sessionId,
388
+ },
447
389
  readActivitySignature,
448
390
  deps: {
449
391
  // 이 트랙이 재실행 소유자다. pane 채널은 기존 completion wrapper를 다시
450
392
  // dispatch하여 token/result 계약을 보존한다.
451
393
  resumeHandler: async () => {
452
- if (isDaemon || !dispatch.command) return { ok: false };
394
+ if (!dispatch.command) return { ok: false };
453
395
  const restarted = dispatchCommand(
454
396
  context.sessionName,
455
397
  context.paneId || dispatch.paneId,
@@ -610,10 +552,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
610
552
  });
611
553
 
612
554
  // 캡처 시작 + 컬러 배너 + 명령 dispatch
613
- const resultFile = join(
614
- RESULT_DIR,
615
- `${sessionName}-${paneName}.txt`,
616
- ).replace(/\\/g, "/");
555
+ const resultFile = headlessResultFile(sessionName, paneName);
617
556
  // Issue #118 review R1 HIGH: stale artifact 제거 (이전 run / restart 잔재)
618
557
  cleanStaleResultArtifacts(resultFile);
619
558
  const cmd = buildHeadlessCommand(
@@ -701,10 +640,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
701
640
  const displayName = resolveHeadlessDisplayName(assignment, paneName);
702
641
  const workerId = getHeadlessWorkerAgentId(sessionName, i);
703
642
  const resolvedCli = resolveCliType(assignment.cli);
704
- const resultFile = join(
705
- RESULT_DIR,
706
- `${sessionName}-${paneName}.txt`,
707
- ).replace(/\\/g, "/");
643
+ const resultFile = headlessResultFile(sessionName, paneName);
708
644
  // Issue #118 review R1 HIGH: stale artifact 제거 (이전 run / restart 잔재)
709
645
  cleanStaleResultArtifacts(resultFile);
710
646
  const cmd = buildHeadlessCommand(
@@ -752,133 +688,6 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
752
688
  );
753
689
  }
754
690
 
755
- async function dispatchDaemonBatch(sessionName, assignments, opts = {}) {
756
- const { safeProgress, configDir } = opts;
757
- const paths = deriveClaudeControlPaths({ configDir });
758
- const dispatches = [];
759
-
760
- try {
761
- for (let i = 0; i < assignments.length; i += 1) {
762
- const assignment = assignments[i];
763
- const paneName = `worker-${i + 1}`;
764
- const displayName = resolveHeadlessDisplayName(assignment, paneName);
765
- const workerId = getHeadlessWorkerAgentId(sessionName, i);
766
- const resolvedCli = resolveCliType(assignment.cli);
767
- const resultFile = join(
768
- RESULT_DIR,
769
- `${sessionName}-${paneName}.txt`,
770
- ).replace(/\\/g, "/");
771
- cleanStaleResultArtifacts(resultFile);
772
- const command = buildHeadlessCommand(
773
- assignment.cli,
774
- assignment.prompt,
775
- resultFile,
776
- {
777
- mcp: assignment.mcp,
778
- role: assignment.role,
779
- model: assignment.model,
780
- profile: assignment.profile,
781
- cwd: assignment.cwd || assignment.workdir,
782
- },
783
- );
784
- const token = randomUUID().slice(0, 10);
785
- const short = randomUUID().replace(/-/g, "").slice(0, 8);
786
- const name = `Triflux ${resolvedCli || "worker"} ${displayName}`;
787
- const record = {
788
- paneId: `daemon:${short}`,
789
- paneName,
790
- displayName,
791
- resultFile,
792
- cli: resolvedCli,
793
- role: assignment.role,
794
- command,
795
- token,
796
- workerId,
797
- cwd: assignment.cwd || assignment.workdir,
798
- daemonShort: short,
799
- daemonPaths: paths,
800
- controlSock: paths.controlSock,
801
- sessionId: "",
802
- sessionProjectionPath: "",
803
- daemonCompletionMatched: false,
804
- };
805
- dispatches.push(record);
806
-
807
- const payload = buildDaemonExecDispatchPayload({
808
- short,
809
- cwd: assignment.cwd || assignment.workdir || process.cwd(),
810
- command: buildDaemonWrappedCommand(command, token),
811
- name,
812
- });
813
- record.sessionId = payload.sessionId;
814
- // buildDaemonWrappedCommand + token completion 시맨틱은 헬퍼 밖(headless 전용)에
815
- // 남기고, dispatch→pid→bridge→projection 시퀀스만 공유 헬퍼로 위임한다.
816
- // CRITICAL: waitForDaemonCompletion 이 나중에 record.daemonCompletionMatched 를
817
- // 변경하고 cleanupDaemonDispatches 가 같은 record 를 읽으므로, 헬퍼가 새 객체를
818
- // 반환해도 record 를 교체하지 말고 plain 필드를 Object.assign 한다.
819
- const dispatched = await dispatchClaudeDaemonJob({
820
- paths,
821
- controlSock: paths.controlSock,
822
- payload,
823
- agent: resolvedCli || "codex",
824
- name,
825
- cwd: assignment.cwd || assignment.workdir || process.cwd(),
826
- dispatchTimeoutMs: 5000,
827
- }).catch((error) => {
828
- throw new Error(
829
- `[headless] Claude daemon dispatch failed for ${paneName}: ${
830
- error?.message || error
831
- }`,
832
- );
833
- });
834
- Object.assign(record, {
835
- sessionId: dispatched.sessionId,
836
- sessionProjectionPath: dispatched.sessionProjectionPath,
837
- });
838
- if (safeProgress) {
839
- safeProgress({ type: "dispatched", paneName, cli: resolvedCli });
840
- }
841
- }
842
- } catch (error) {
843
- await cleanupDaemonDispatches(dispatches);
844
- throw error;
845
- }
846
-
847
- return dispatches;
848
- }
849
-
850
- export async function cleanupDaemonDispatches(dispatches) {
851
- await Promise.all(
852
- dispatches.map(async (dispatch) => {
853
- if (dispatch.sessionProjectionPath) {
854
- await removeClaudeSessionProjection(
855
- dispatch.sessionProjectionPath,
856
- ).catch(() => {});
857
- }
858
- if (
859
- dispatch.daemonCompletionMatched === true &&
860
- dispatch.daemonPaths &&
861
- dispatch.sessionId
862
- ) {
863
- await sendKillBySessionId({
864
- daemonPaths: dispatch.daemonPaths,
865
- sessionId: dispatch.sessionId,
866
- }).catch(() => {});
867
- }
868
- if (dispatch.controlSock && dispatch.daemonShort) {
869
- const controlAuth = await buildDaemonControlAuth(
870
- dispatch.daemonPaths?.configDir,
871
- ).catch(() => ({}));
872
- await killDaemonJob(
873
- dispatch.controlSock,
874
- dispatch.daemonShort,
875
- controlAuth,
876
- ).catch(() => {});
877
- }
878
- }),
879
- );
880
- }
881
-
882
691
  /**
883
692
  * 모든 dispatch를 병렬 대기하며 완료 결과를 수집한다.
884
693
  * @param {string} sessionName
@@ -998,169 +807,6 @@ async function awaitAll(
998
807
  );
999
808
  }
1000
809
 
1001
- export async function waitForDaemonCompletion(
1002
- dispatch,
1003
- timeoutSec,
1004
- safeProgress,
1005
- progressIntervalSec,
1006
- lifecycle = null,
1007
- ) {
1008
- const messages = [];
1009
- const socket = net.connect(dispatch.controlSock);
1010
- let buffer = "";
1011
- let settled = false;
1012
- let lastProgressAt = 0;
1013
- const activity = lifecycle?.enabled
1014
- ? createActivityLifecycle({
1015
- interventionMs: lifecycle.interventionMs,
1016
- hardCeilingMs: lifecycle.hardCeilingMs,
1017
- onIntervene: createHeadlessIntervention(
1018
- dispatch,
1019
- lifecycle.onIntervene,
1020
- ),
1021
- })
1022
- : null;
1023
-
1024
- return await new Promise((resolve) => {
1025
- const finish = (completion, timeoutReason = "") => {
1026
- if (settled) return;
1027
- settled = true;
1028
- clearTimeout(timer);
1029
- clearInterval(timer);
1030
- socket.destroy();
1031
- const finalCompletion =
1032
- completion ||
1033
- waitForDaemonCompletionFromMessages(messages, {
1034
- token: dispatch.token,
1035
- resultFile: dispatch.resultFile,
1036
- });
1037
- if (!finalCompletion.matched && timeoutReason) {
1038
- finalCompletion.timedOut = true;
1039
- finalCompletion.timeoutReason = timeoutReason;
1040
- }
1041
- if (finalCompletion.matched) {
1042
- dispatch.daemonCompletionMatched = true;
1043
- cleanupDaemonDispatches([dispatch]).catch(() => {});
1044
- } else {
1045
- buildDaemonControlAuth(dispatch.daemonPaths?.configDir)
1046
- .catch(() => ({}))
1047
- .then((controlAuth) =>
1048
- killDaemonJob(
1049
- dispatch.controlSock,
1050
- dispatch.daemonShort,
1051
- controlAuth,
1052
- ),
1053
- )
1054
- .catch(() => {});
1055
- }
1056
- resolve(finalCompletion);
1057
- };
1058
-
1059
- const timer = activity
1060
- ? setInterval(() => {
1061
- void activity
1062
- .check({
1063
- channel: "daemon",
1064
- controlSock: dispatch.controlSock,
1065
- daemonShort: dispatch.daemonShort,
1066
- resultFile: dispatch.resultFile,
1067
- })
1068
- .then((reason) => {
1069
- if (reason) finish(null, reason);
1070
- });
1071
- }, lifecycle.checkIntervalMs ?? 5_000)
1072
- : setTimeout(
1073
- () => finish(null, "wall_clock"),
1074
- Math.max(0, timeoutSec * 1000),
1075
- );
1076
- if (typeof timer.unref === "function") timer.unref();
1077
-
1078
- socket.on("error", () => finish(null, "socket_error"));
1079
- socket.on("close", () => {
1080
- if (!settled) finish(null, "socket_closed");
1081
- });
1082
- socket.on("connect", () => {
1083
- socket.write(
1084
- `${JSON.stringify({
1085
- proto: 1,
1086
- op: "subscribe",
1087
- short: dispatch.daemonShort,
1088
- tail: 20,
1089
- })}\n`,
1090
- );
1091
- });
1092
- socket.on("data", (chunk) => {
1093
- buffer += chunk.toString("utf8");
1094
- while (buffer.includes("\n")) {
1095
- const index = buffer.indexOf("\n");
1096
- const line = buffer.slice(0, index);
1097
- buffer = buffer.slice(index + 1);
1098
- if (!line.trim()) continue;
1099
- const message = JSON.parse(line);
1100
- messages.push(message);
1101
- activity?.observe();
1102
- if (
1103
- safeProgress &&
1104
- progressIntervalSec > 0 &&
1105
- typeof message.line === "string"
1106
- ) {
1107
- const now = Date.now();
1108
- if (now - lastProgressAt >= progressIntervalSec * 1000) {
1109
- lastProgressAt = now;
1110
- safeProgress({
1111
- type: "progress",
1112
- paneName: dispatch.paneName,
1113
- displayName: dispatch.displayName,
1114
- cli: dispatch.cli,
1115
- snapshot: message.line.split("\n").slice(-15).join("\n"),
1116
- });
1117
- }
1118
- }
1119
- const completion = waitForDaemonCompletionFromMessages(messages, {
1120
- token: dispatch.token,
1121
- resultFile: dispatch.resultFile,
1122
- });
1123
- if (completion.matched) finish(completion);
1124
- }
1125
- });
1126
- });
1127
- }
1128
-
1129
- async function awaitAllDaemon(
1130
- dispatches,
1131
- timeoutSec,
1132
- safeProgress,
1133
- progressIntervalSec,
1134
- lifecycle,
1135
- ) {
1136
- return Promise.all(
1137
- dispatches.map(async (d) => {
1138
- const completion = await waitForDaemonCompletion(
1139
- d,
1140
- timeoutSec,
1141
- safeProgress,
1142
- progressIntervalSec,
1143
- lifecycle,
1144
- );
1145
- const output = readResult(d.resultFile, d.paneId);
1146
-
1147
- if (safeProgress) {
1148
- safeProgress({
1149
- type: "completed",
1150
- paneName: d.paneName,
1151
- displayName: d.displayName,
1152
- cli: d.cli,
1153
- matched: completion.matched,
1154
- exitCode: completion.exitCode,
1155
- sessionDead: false,
1156
- });
1157
- }
1158
-
1159
- return { d, completion, output };
1160
- }),
1161
- );
1162
- }
1163
-
1164
810
  /**
1165
811
  * git diff + handoff 파이프라인을 적용하여 최종 결과 배열을 반환한다.
1166
812
  * @param {Array<{d, completion, output}>} results
@@ -1243,20 +889,18 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1243
889
  const sessionOwnership =
1244
890
  opts._sessionOwnership ||
1245
891
  createHeadlessSessionOwnership(deps.killPsmuxSession || killPsmuxSession);
892
+ const agentsRows = opts._agentsRows || createAgentsRowOwnership();
1246
893
  let {
1247
894
  timeoutSec = 900,
1248
895
  layout = "2x2",
1249
896
  onProgress,
1250
897
  progressIntervalSec = 0,
1251
898
  progressive = true,
1252
- autoAttach = false,
1253
899
  dashboard = false,
1254
900
  dashboardLayout = "single",
1255
901
  nativeBridge = false,
1256
902
  nativeBridgeMode = "agents",
1257
903
  onIntervene,
1258
- leadPane = null,
1259
- leadTmux = null,
1260
904
  } = opts;
1261
905
  if (!Number.isFinite(Number(timeoutSec)) || Number(timeoutSec) <= 0)
1262
906
  timeoutSec = 900;
@@ -1274,10 +918,26 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1274
918
  );
1275
919
 
1276
920
  if (normalizedAssignments.length === 0) {
1277
- return { sessionName, results: [], sessionOwnership };
921
+ return { sessionName, results: [], sessionOwnership, agentsRows };
1278
922
  }
1279
923
 
1280
- let daemonDispatches = [];
924
+ const resultsIndex = createResultsIndex(
925
+ headlessResultsIndexPath(sessionName),
926
+ sessionName,
927
+ normalizedAssignments.map((a, i) => {
928
+ const paneName = `worker-${i + 1}`;
929
+ return {
930
+ paneName,
931
+ displayName: a.displayName,
932
+ cli: resolveCliType(a.cli),
933
+ role: a.role,
934
+ resultFile: headlessResultFile(sessionName, paneName),
935
+ };
936
+ }),
937
+ // 같은 이름의 남의 세션 색인을 덮어쓰지 않도록 세션을 소유한 동안만 쓴다.
938
+ () => sessionOwnership.owned,
939
+ );
940
+
1281
941
  let runCompleted = false;
1282
942
  let completedResults = [];
1283
943
  let runFailed = false;
@@ -1356,6 +1016,11 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1356
1016
 
1357
1017
  // onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
1358
1018
  const combinedProgress = (event) => {
1019
+ if (event.type === "session_created") resultsIndex.begin();
1020
+ else if (event.type === "dispatched")
1021
+ resultsIndex.workerStarted(event.paneName);
1022
+ else if (event.type === "completed")
1023
+ resultsIndex.workerFinished(event.paneName, event);
1359
1024
  feedTui(event);
1360
1025
  if (onProgress) {
1361
1026
  try {
@@ -1376,79 +1041,36 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1376
1041
  try {
1377
1042
  const progressiveDispatch = deps.dispatchProgressive || dispatchProgressive;
1378
1043
  const batchDispatch = deps.dispatchBatch || dispatchBatch;
1379
- const dispatches =
1380
- nativeBridge && nativeBridgeMode === "agents"
1381
- ? await dispatchDaemonBatch(sessionName, normalizedAssignments, {
1382
- safeProgress,
1383
- })
1384
- : progressive
1385
- ? await progressiveDispatch(sessionName, normalizedAssignments, {
1386
- layout,
1387
- safeProgress,
1388
- dashboardLayout,
1389
- sessionOwnership,
1390
- _deps: deps,
1391
- })
1392
- : await batchDispatch(sessionName, normalizedAssignments, {
1393
- layout,
1394
- safeProgress,
1395
- dashboardLayout,
1396
- sessionOwnership,
1397
- _deps: deps,
1398
- });
1399
- if (nativeBridge && nativeBridgeMode === "agents") {
1400
- daemonDispatches = dispatches;
1401
- const createObservationSession =
1402
- deps.createDaemonObservationSession || createDaemonObservationSession;
1403
- let observation = null;
1404
- try {
1405
- observation = createObservationSession(sessionName, dispatches, {
1406
- autoAttach,
1407
- dashboard,
1044
+ const dispatches = progressive
1045
+ ? await progressiveDispatch(sessionName, normalizedAssignments, {
1046
+ layout,
1047
+ safeProgress,
1408
1048
  dashboardLayout,
1409
- leadPane,
1410
- leadTmux,
1411
- onProgress: safeProgress,
1049
+ sessionOwnership,
1412
1050
  _deps: deps,
1413
- });
1414
- } catch (error) {
1415
- emitObserverWarning(
1051
+ })
1052
+ : await batchDispatch(sessionName, normalizedAssignments, {
1053
+ layout,
1416
1054
  safeProgress,
1417
- sessionName,
1418
- "observer_session_create",
1419
- error,
1420
- "observer setup failed",
1421
- );
1422
- }
1423
- if (observation) {
1424
- sessionOwnership.acquire(observation.sessionName);
1425
- safeProgress({
1426
- type: "session_created",
1427
- sessionName: observation.sessionName,
1428
- panes: observation.panes,
1429
- dashboardLayout: observation.dashboardLayout,
1430
- observationSource: "daemon-files",
1055
+ dashboardLayout,
1056
+ sessionOwnership,
1057
+ _deps: deps,
1431
1058
  });
1432
- }
1059
+ if (nativeBridge && nativeBridgeMode === "agents") {
1060
+ await openHeadlessAgentsRows(sessionName, dispatches, agentsRows, {
1061
+ safeProgress,
1062
+ _deps: deps,
1063
+ });
1433
1064
  }
1434
1065
 
1435
- const results =
1436
- nativeBridge && nativeBridgeMode === "agents"
1437
- ? await awaitAllDaemon(
1438
- dispatches,
1439
- timeoutSec,
1440
- safeProgress,
1441
- progressIntervalSec,
1442
- lifecycle,
1443
- )
1444
- : await awaitAll(
1445
- sessionName,
1446
- dispatches,
1447
- timeoutSec,
1448
- safeProgress,
1449
- progressIntervalSec,
1450
- lifecycle,
1451
- );
1066
+ const results = await (deps.awaitAll || awaitAll)(
1067
+ sessionName,
1068
+ dispatches,
1069
+ timeoutSec,
1070
+ safeProgress,
1071
+ progressIntervalSec,
1072
+ lifecycle,
1073
+ );
1452
1074
  const collected = await collectResults(results);
1453
1075
 
1454
1076
  // 완료 시 TUI에 최종 상태 반영 후 닫기
@@ -1480,22 +1102,20 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1480
1102
  runFailed = true;
1481
1103
  runError = error;
1482
1104
  }
1105
+ resultsIndex.finish(runCompleted);
1483
1106
 
1484
- if (!runCompleted) sessionOwnership.release();
1485
- try {
1486
- if (daemonDispatches.length > 0) {
1487
- await cleanupDaemonDispatches(daemonDispatches);
1488
- }
1489
- } catch (error) {
1107
+ if (!runCompleted) {
1490
1108
  sessionOwnership.release();
1491
- if (!runFailed) {
1492
- runFailed = true;
1493
- runError = error;
1494
- }
1109
+ await agentsRows.release();
1495
1110
  }
1496
-
1497
1111
  if (runFailed) throw runError;
1498
- return { sessionName, results: completedResults, sessionOwnership };
1112
+ return {
1113
+ sessionName,
1114
+ results: completedResults,
1115
+ resultsIndexPath: resultsIndex.path,
1116
+ sessionOwnership,
1117
+ agentsRows,
1118
+ };
1499
1119
  }
1500
1120
 
1501
1121
  /**
@@ -1514,13 +1134,16 @@ export async function runHeadlessWithCleanup(assignments, opts = {}) {
1514
1134
  const sessionOwnership = createHeadlessSessionOwnership(
1515
1135
  deps.killPsmuxSession || killPsmuxSession,
1516
1136
  );
1137
+ const agentsRows = createAgentsRowOwnership();
1517
1138
 
1518
1139
  try {
1519
1140
  return await run(sessionName, assignments, {
1520
1141
  ...runOpts,
1521
1142
  _sessionOwnership: sessionOwnership,
1143
+ _agentsRows: agentsRows,
1522
1144
  });
1523
1145
  } finally {
1146
+ await agentsRows.release();
1524
1147
  sessionOwnership.release();
1525
1148
  // WT split pane은 psmux 종료 시 셸이 끝나면서 자동으로 닫힘
1526
1149
  // 수동 close-pane 불필요 (레이스 컨디션으로 WT 에러 발생)
@@ -1603,98 +1226,52 @@ function emitObserverWarning(
1603
1226
  });
1604
1227
  }
1605
1228
 
1606
- // ─── v6.0.0: Lead-Direct Interactive Mode ───
1607
-
1608
1229
  /**
1609
- * daemon/native-bridge worker를 read-only로 관찰하는 tmux session을 만든다.
1610
- * 실제 worker process는 daemon에 남고, viewer는 result/partial/stderr 파일만 읽는다.
1611
- *
1612
- * @param {string} sessionName
1613
- * @param {Array<{resultFile:string}>} dispatches
1614
- * @param {object} [opts]
1615
- * @returns {{sessionName:string, panes:string[], dashboardLayout:string}|null}
1230
+ * 워커 pane 마다 `claude agents` 행을 연다. 행은 그 pane 을 읽기 전용으로 보여 준다.
1231
+ * 행을 못 열어도 워커는 tmux 에서 계속 돈다. 실패는 observer_warning 으로 알린다.
1616
1232
  */
1617
- export function createDaemonObservationSession(
1233
+ async function openHeadlessAgentsRows(
1618
1234
  sessionName,
1619
1235
  dispatches,
1236
+ rows,
1620
1237
  opts = {},
1621
1238
  ) {
1622
1239
  const deps = opts._deps || {};
1623
1240
  const platform = deps.platform ?? process.platform;
1624
- const leadPane = String(opts.leadPane || "");
1625
- const leadTmux = String(opts.leadTmux || "");
1626
- if (!opts.autoAttach) return null;
1627
- if (platform !== "darwin" && platform !== "linux") return null;
1628
- if (!leadTmux.trim()) return null;
1629
- if (!/^%\d+$/u.test(leadPane)) return null;
1630
- if (!Array.isArray(dispatches) || dispatches.length === 0) return null;
1631
-
1632
- const safeSessionName = sanitizeSessionName(sessionName);
1633
- const resolvedLayout = opts.dashboard
1634
- ? resolveDashboardLayout(opts.dashboardLayout, dispatches.length)
1635
- : "lite";
1636
- const createSession = deps.createPsmuxSession || createPsmuxSession;
1637
- const applyTheme = deps.applyTrifluxTheme || applyTrifluxTheme;
1638
- const sendKeys = deps.sendKeysToPane || sendKeysToPane;
1639
- const killSession = deps.killPsmuxSession || killPsmuxSession;
1640
- let created = false;
1641
- let stage = "observer_session_create";
1642
-
1643
- try {
1644
- const session = createSession(safeSessionName, {
1645
- layout: "2x2",
1646
- paneCount: 1,
1647
- });
1648
- created = true;
1649
- stage = "observer_theme";
1650
- applyTheme(safeSessionName);
1651
- stage = "observer_viewer_start";
1652
- const targetPane = session.panes?.[0];
1653
- if (!targetPane) throw new Error("observer pane was not created");
1654
-
1655
- const viewerCommand = [
1656
- "exec",
1657
- shellQuote(process.execPath),
1658
- shellQuote(join(SCRIPT_DIR, "tui-viewer.mjs")),
1659
- "--session",
1660
- shellQuote(safeSessionName),
1661
- "--result-dir",
1662
- shellQuote(RESULT_DIR),
1663
- "--layout",
1664
- shellQuote(resolvedLayout),
1665
- "--source",
1666
- "files",
1667
- "--workers",
1668
- String(dispatches.length),
1669
- "--lead-pane",
1670
- shellQuote(leadPane),
1671
- ].join(" ");
1672
- sendKeys(targetPane, viewerCommand, true);
1673
-
1674
- return {
1675
- sessionName: safeSessionName,
1676
- panes: session.panes,
1677
- dashboardLayout: resolvedLayout,
1678
- };
1679
- } catch (error) {
1680
- if (created) {
1681
- try {
1682
- killSession(safeSessionName);
1683
- } catch {
1684
- /* fail-open: observer failure must not stop daemon workers */
1685
- }
1686
- }
1241
+ if (platform !== "darwin" && platform !== "linux") {
1687
1242
  emitObserverWarning(
1688
- opts.onProgress,
1689
- safeSessionName,
1690
- stage,
1691
- error,
1692
- "observer setup failed",
1243
+ opts.safeProgress,
1244
+ sessionName,
1245
+ "agents_row",
1246
+ null,
1247
+ `claude agents rows need tmux on macOS/Linux (platform: ${platform})`,
1693
1248
  );
1694
- return null;
1249
+ return;
1250
+ }
1251
+ const open = deps.openAgentsRow || openAgentsRow;
1252
+ for (const d of dispatches) {
1253
+ try {
1254
+ const row = await open({
1255
+ name: `${d.cli} ${d.displayName || d.paneName}`,
1256
+ target: d.paneId,
1257
+ cwd: d.cwd || process.cwd(),
1258
+ });
1259
+ rows.add(row.short);
1260
+ d.agentsRowShort = row.short;
1261
+ } catch (error) {
1262
+ emitObserverWarning(
1263
+ opts.safeProgress,
1264
+ sessionName,
1265
+ "agents_row",
1266
+ error,
1267
+ `claude agents row failed for ${d.paneName}`,
1268
+ );
1269
+ }
1695
1270
  }
1696
1271
  }
1697
1272
 
1273
+ // ─── v6.0.0: Lead-Direct Interactive Mode ───
1274
+
1698
1275
  /**
1699
1276
  * attached tmux의 현재 leader pane 옆에 headless session view를 연다.
1700
1277
  * 새 pane의 attach client는 headless session 종료와 함께 자연스럽게 종료된다.
@@ -2054,11 +1631,13 @@ export async function runHeadlessInteractive(
2054
1631
  let sessionOwnership = createHeadlessSessionOwnership(
2055
1632
  deps.killPsmuxSession || killPsmuxSession,
2056
1633
  );
1634
+ const agentsRows = createAgentsRowOwnership();
2057
1635
 
2058
1636
  // Phase 1: 세션 생성 → 즉시 관찰 pane/tab attach → dispatch → 대기 → 결과 수집
2059
1637
  const runResult = await run(sessionName, assignments, {
2060
1638
  ...interactiveRunOpts,
2061
1639
  _sessionOwnership: sessionOwnership,
1640
+ _agentsRows: agentsRows,
2062
1641
  });
2063
1642
  const { results } = runResult;
2064
1643
  sessionOwnership = runResult.sessionOwnership || sessionOwnership;
@@ -2086,6 +1665,7 @@ export async function runHeadlessInteractive(
2086
1665
  const handle = {
2087
1666
  sessionName,
2088
1667
  results,
1668
+ resultsIndexPath: runResult.resultsIndexPath,
2089
1669
  dispatches,
2090
1670
  _killed: false,
2091
1671
 
@@ -2141,6 +1721,7 @@ export async function runHeadlessInteractive(
2141
1721
  kill() {
2142
1722
  if (this._killed) return;
2143
1723
  this._killed = true;
1724
+ void agentsRows.release();
2144
1725
  sessionOwnership.release();
2145
1726
  // attach pane/tab은 psmux 종료 → attach client 종료 → 자동 닫힘
2146
1727
  // 수동 close-pane 불필요 (레이스 컨디션으로 WT 0x80070002 에러 발생)