@triflux/remote 10.52.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.
@@ -157,6 +157,25 @@ function acceptLine(state, line, marker) {
157
157
  }
158
158
  }
159
159
 
160
+ // daemon 과 tmux wait 가 같은 기준으로 끝을 판정한다. 아직이면 null.
161
+ export function claudeWaitVerdict(transcript, idle) {
162
+ if (transcript?.error)
163
+ return { ok: false, status: "failed", error: transcript.error };
164
+ if (
165
+ !transcript?.userSeen ||
166
+ !transcript.turnEnded ||
167
+ !(idle || transcript.sectionClosed)
168
+ )
169
+ return null;
170
+ if (!transcript.response.trim())
171
+ return {
172
+ ok: false,
173
+ status: "failed",
174
+ error: "turn ended without assistant text",
175
+ };
176
+ return { status: "completed", done: true };
177
+ }
178
+
160
179
  export async function readClaudeTranscript(transcriptPath, { requestId } = {}) {
161
180
  if (!transcriptPath) return null;
162
181
  const marker = requestId ? `[tfx-live req=${requestId}]` : null;
@@ -173,6 +173,8 @@ export async function startHeadlessTeam({
173
173
  console.log(
174
174
  `=== HEADLESS_COMPLETE succeeded=${succeeded.length} failed=${failed.length} total=${results.length} ===`,
175
175
  );
176
+ if (handle.resultsIndexPath)
177
+ console.log(` ${DIM}결과 색인: ${handle.resultsIndexPath}${RESET}`);
176
178
 
177
179
  if (failed.length > 0) {
178
180
  for (const r of failed)
@@ -43,6 +43,7 @@ import {
43
43
  startCapture,
44
44
  waitForCompletion,
45
45
  } from "./psmux.mjs";
46
+ import { createResultsIndex, resultsIndexPath } from "./results-index.mjs";
46
47
  import { createLogDashboard } from "./tui.mjs";
47
48
  import { createWtManager } from "./wt-manager.mjs";
48
49
 
@@ -126,6 +127,14 @@ export function normalizeHeadlessRole(role) {
126
127
  return normalized && VALID_ROUTE_AGENTS.has(normalized) ? normalized : "";
127
128
  }
128
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
+
129
138
  export function resolveHeadlessDisplayName(assignment = {}, paneName = "") {
130
139
  const normalized = String(assignment.displayName || "")
131
140
  .trim()
@@ -543,10 +552,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
543
552
  });
544
553
 
545
554
  // 캡처 시작 + 컬러 배너 + 명령 dispatch
546
- const resultFile = join(
547
- RESULT_DIR,
548
- `${sessionName}-${paneName}.txt`,
549
- ).replace(/\\/g, "/");
555
+ const resultFile = headlessResultFile(sessionName, paneName);
550
556
  // Issue #118 review R1 HIGH: stale artifact 제거 (이전 run / restart 잔재)
551
557
  cleanStaleResultArtifacts(resultFile);
552
558
  const cmd = buildHeadlessCommand(
@@ -634,10 +640,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
634
640
  const displayName = resolveHeadlessDisplayName(assignment, paneName);
635
641
  const workerId = getHeadlessWorkerAgentId(sessionName, i);
636
642
  const resolvedCli = resolveCliType(assignment.cli);
637
- const resultFile = join(
638
- RESULT_DIR,
639
- `${sessionName}-${paneName}.txt`,
640
- ).replace(/\\/g, "/");
643
+ const resultFile = headlessResultFile(sessionName, paneName);
641
644
  // Issue #118 review R1 HIGH: stale artifact 제거 (이전 run / restart 잔재)
642
645
  cleanStaleResultArtifacts(resultFile);
643
646
  const cmd = buildHeadlessCommand(
@@ -918,6 +921,23 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
918
921
  return { sessionName, results: [], sessionOwnership, agentsRows };
919
922
  }
920
923
 
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
+
921
941
  let runCompleted = false;
922
942
  let completedResults = [];
923
943
  let runFailed = false;
@@ -996,6 +1016,11 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
996
1016
 
997
1017
  // onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
998
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);
999
1024
  feedTui(event);
1000
1025
  if (onProgress) {
1001
1026
  try {
@@ -1077,6 +1102,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1077
1102
  runFailed = true;
1078
1103
  runError = error;
1079
1104
  }
1105
+ resultsIndex.finish(runCompleted);
1080
1106
 
1081
1107
  if (!runCompleted) {
1082
1108
  sessionOwnership.release();
@@ -1086,6 +1112,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
1086
1112
  return {
1087
1113
  sessionName,
1088
1114
  results: completedResults,
1115
+ resultsIndexPath: resultsIndex.path,
1089
1116
  sessionOwnership,
1090
1117
  agentsRows,
1091
1118
  };
@@ -1638,6 +1665,7 @@ export async function runHeadlessInteractive(
1638
1665
  const handle = {
1639
1666
  sessionName,
1640
1667
  results,
1668
+ resultsIndexPath: runResult.resultsIndexPath,
1641
1669
  dispatches,
1642
1670
  _killed: false,
1643
1671
 
@@ -0,0 +1,92 @@
1
+ // headless 세션 결과 색인. 화면의 HEADLESS_COMPLETE 문자열 대신 읽도록 세션당 JSON 하나를 둔다 (ADR-0034).
2
+ import { renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ export const RESULTS_INDEX_VERSION = 1;
6
+
7
+ export function resultsIndexPath(dir, sessionName) {
8
+ return join(dir, `${sessionName}.results.json`);
9
+ }
10
+
11
+ /**
12
+ * @param {string} path
13
+ * @param {string} sessionName
14
+ * @param {Array<{paneName: string, displayName?: string, cli: string, role?: string, resultFile: string}>} workers
15
+ * @param {() => boolean} canWrite
16
+ */
17
+ export function createResultsIndex(path, sessionName, workers, canWrite) {
18
+ const now = () => new Date().toISOString();
19
+ const index = {
20
+ version: RESULTS_INDEX_VERSION,
21
+ sessionName,
22
+ startedAt: now(),
23
+ finishedAt: null,
24
+ completed: false,
25
+ workers: workers.map((w) => ({
26
+ paneName: w.paneName,
27
+ displayName: w.displayName || w.paneName,
28
+ cli: w.cli,
29
+ role: w.role || "",
30
+ status: "pending",
31
+ matched: null,
32
+ exitCode: null,
33
+ sessionDead: false,
34
+ startedAt: null,
35
+ finishedAt: null,
36
+ resultFile: w.resultFile,
37
+ })),
38
+ };
39
+ const byPane = new Map(index.workers.map((w) => [w.paneName, w]));
40
+
41
+ // 임시 파일에 쓰고 rename 해 읽는 쪽이 반쯤 쓴 JSON 을 보지 않게 한다.
42
+ // 색인은 보조 산출물이라 쓰기 실패가 실행을 멈추면 안 된다.
43
+ const flush = () => {
44
+ if (!canWrite()) return;
45
+ const tmp = `${path}.${process.pid}.tmp`;
46
+ try {
47
+ writeFileSync(tmp, `${JSON.stringify(index, null, 2)}\n`, "utf8");
48
+ renameSync(tmp, path);
49
+ } catch {
50
+ try {
51
+ rmSync(tmp, { force: true });
52
+ } catch {
53
+ /* best-effort */
54
+ }
55
+ }
56
+ };
57
+
58
+ return {
59
+ path,
60
+ // 같은 이름의 이전 실행 색인이 남아 있으면 세션을 잡자마자 덮어 오판을 막는다.
61
+ begin: flush,
62
+ workerStarted(paneName) {
63
+ const w = byPane.get(paneName);
64
+ if (!w || w.status !== "pending") return;
65
+ w.status = "running";
66
+ w.startedAt = now();
67
+ flush();
68
+ },
69
+ workerFinished(paneName, { matched, exitCode, sessionDead } = {}) {
70
+ const w = byPane.get(paneName);
71
+ if (!w) return;
72
+ w.status = matched && exitCode === 0 ? "completed" : "failed";
73
+ w.matched = Boolean(matched);
74
+ w.exitCode = exitCode ?? null;
75
+ w.sessionDead = Boolean(sessionDead);
76
+ w.finishedAt = now();
77
+ flush();
78
+ },
79
+ finish(completed) {
80
+ // 예외로 끝나면 끝나지 못한 워커를 failed 로 확정해 "아직 도는 중"과 구분한다.
81
+ for (const w of index.workers) {
82
+ if (w.status === "pending" || w.status === "running") {
83
+ w.status = "failed";
84
+ w.finishedAt = now();
85
+ }
86
+ }
87
+ index.finishedAt = now();
88
+ index.completed = Boolean(completed);
89
+ flush();
90
+ },
91
+ };
92
+ }
@@ -15,12 +15,12 @@ function codexClient(socketPath, timeoutMs, clientFactory) {
15
15
  return clientFactory({ socketPath, connectTimeoutMs: timeoutMs });
16
16
  }
17
17
 
18
- async function initializeCodexClient(client, timeoutMs) {
18
+ async function initializeCodexClient(client, timeoutMs, remainingMs) {
19
19
  await client.connect();
20
20
  await client.request(
21
21
  "initialize",
22
22
  { clientInfo: { name: "triflux-tfx-live", version: "1.0.0" } },
23
- timeoutMs,
23
+ remainingMs ? Math.max(1, remainingMs()) : timeoutMs,
24
24
  );
25
25
  client.notify("initialized", {});
26
26
  }
@@ -211,7 +211,14 @@ async function normalizedCwd(cwd) {
211
211
  }
212
212
  }
213
213
 
214
- async function loadedCodexThreads(client, cwd, timeoutMs) {
214
+ // timeoutMs 는 RPC 하나의 상한이다. remainingMs 를 주면 조회 전체가 그 안에 끝나야 한다.
215
+ async function loadedCodexThreads(client, cwd, timeoutMs, remainingMs) {
216
+ const limit = () => {
217
+ if (!remainingMs) return timeoutMs;
218
+ const left = remainingMs();
219
+ if (left <= 0) throw new Error("codex thread listing timed out");
220
+ return left;
221
+ };
215
222
  const wantedCwd = await normalizedCwd(cwd);
216
223
  const threads = [];
217
224
  let cursor;
@@ -219,10 +226,10 @@ async function loadedCodexThreads(client, cwd, timeoutMs) {
219
226
  const result = await client.request(
220
227
  "thread/loaded/list",
221
228
  cursor ? { cursor } : {},
222
- timeoutMs,
229
+ limit(),
223
230
  );
224
231
  for (const threadId of result?.data || []) {
225
- const thread = await readCodexThread(client, threadId, timeoutMs);
232
+ const thread = await readCodexThread(client, threadId, limit());
226
233
  if (wantedCwd && (await normalizedCwd(thread.cwd)) !== wantedCwd)
227
234
  continue;
228
235
  threads.push({
@@ -241,23 +248,15 @@ async function loadedCodexThreads(client, cwd, timeoutMs) {
241
248
  export async function listCodexAppServerThreads({
242
249
  socketPath,
243
250
  cwd,
251
+ timeoutMs = DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
244
252
  clientFactory = (opts) => new JsonRpcWsUdsClient(opts),
245
253
  } = {}) {
246
- const client = codexClient(
247
- socketPath,
248
- DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
249
- clientFactory,
250
- );
254
+ const deadline = Date.now() + timeoutMs;
255
+ const remainingMs = () => deadline - Date.now();
256
+ const client = codexClient(socketPath, timeoutMs, clientFactory);
251
257
  try {
252
- await initializeCodexClient(
253
- client,
254
- DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
255
- );
256
- return await loadedCodexThreads(
257
- client,
258
- cwd,
259
- DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
260
- );
258
+ await initializeCodexClient(client, timeoutMs, remainingMs);
259
+ return await loadedCodexThreads(client, cwd, timeoutMs, remainingMs);
261
260
  } finally {
262
261
  client.close();
263
262
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.52.0",
3
+ "version": "10.53.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -40,7 +40,6 @@ const DEFAULT_REGISTRY = Object.freeze({
40
40
  unknown_server_action: "warn",
41
41
  sync_denylist: [],
42
42
  watched_paths: [
43
- "~/.gemini/settings.json",
44
43
  "~/.codex/config.toml",
45
44
  "~/.claude.json",
46
45
  "~/.claude/settings.json",
@@ -709,7 +708,7 @@ function serverTargets(serverConfig) {
709
708
  ),
710
709
  ];
711
710
  }
712
- return ["claude", "gemini", "codex", "antigravity"];
711
+ return ["claude", "codex", "antigravity"];
713
712
  }
714
713
 
715
714
  function serverAppliesToClient(serverConfig, client) {
@@ -1718,7 +1717,7 @@ export function addRegistryServer(name, url, options = {}) {
1718
1717
  .filter(Boolean),
1719
1718
  ),
1720
1719
  ]
1721
- : ["claude", "gemini", "codex", "antigravity"],
1720
+ : ["claude", "codex", "antigravity"],
1722
1721
  description: options.description || `${trimmedName} MCP 서버`,
1723
1722
  };
1724
1723