triflux 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.
@@ -15,61 +15,6 @@ import {
15
15
  import { IS_WINDOWS, killProcess } from "./platform.mjs";
16
16
  import { createInterventionLadder } from "./team/intervention.mjs";
17
17
 
18
- // ── Quota retry-after 파싱 ──────────────────────────────────────
19
-
20
- const FALLBACK_COOLDOWN_MS = { codex: 5 * 3600_000, gemini: 86400_000 };
21
-
22
- /**
23
- * 에러 텍스트에서 retry-after 시간을 동적 파싱.
24
- * "try again at 2026-04-11T10:00:00Z", "retry after 3600 seconds",
25
- * "resets in 7 days", "wait 5 hours" 등을 감지.
26
- */
27
- export function parseRetryAfterMs(text, provider) {
28
- // 1. ISO timestamp: "try again at 2026-04-11T10:00:00"
29
- const isoMatch = text.match(
30
- /(?:try again|retry|available|resets?)\s+(?:at|after)\s+(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i,
31
- );
32
- if (isoMatch) {
33
- const target = new Date(isoMatch[1]).getTime();
34
- if (target > Date.now()) return target - Date.now();
35
- }
36
-
37
- // 2. Duration: "N seconds/minutes/hours/days"
38
- const durMatch = text.match(
39
- /(?:retry|wait|resets?|again)\s+(?:in\s+|after\s+)?(\d+)\s*(second|minute|hour|day|week)/i,
40
- );
41
- if (durMatch) {
42
- const n = Number(durMatch[1]);
43
- const unit = durMatch[2].toLowerCase();
44
- const multipliers = {
45
- second: 1000,
46
- minute: 60_000,
47
- hour: 3600_000,
48
- day: 86400_000,
49
- week: 604800_000,
50
- };
51
- return n * (multipliers[unit] || 3600_000);
52
- }
53
-
54
- // 3. "7 days" / "24 hours" 단독 (context 없이)
55
- const standaloneMatch = text.match(/(\d+)\s*(day|hour|week|minute)/i);
56
- if (standaloneMatch) {
57
- const n = Number(standaloneMatch[1]);
58
- const unit = standaloneMatch[2].toLowerCase();
59
- const multipliers = {
60
- minute: 60_000,
61
- hour: 3600_000,
62
- day: 86400_000,
63
- week: 604800_000,
64
- };
65
- const parsed = n * (multipliers[unit] || 3600_000);
66
- if (parsed >= 3600_000) return parsed; // 1시간 이상만 신뢰
67
- }
68
-
69
- // 4. fallback: provider 기본값
70
- return FALLBACK_COOLDOWN_MS[provider] || 5 * 3600_000;
71
- }
72
-
73
18
  // ── Shell utilities ─────────────────────────────────────────────
74
19
 
75
20
  export function normalizePathForShell(value) {
@@ -195,47 +140,28 @@ export function appendWarnings(stderr, warnings = []) {
195
140
  return [stderr, text].filter(Boolean).join("\n");
196
141
  }
197
142
 
198
- // ── Broker-integrated execution ─────────────────────────────────
143
+ // ── Attempt execution ───────────────────────────────────────────
199
144
 
200
145
  /**
201
- * Shared execute() logic that uses account-broker for per-account circuit
202
- * breaking instead of a global breaker.
146
+ * preflight 뒤 buildAttemptsFn 이 만든 시도를 순서대로 실행한다.
203
147
  *
204
148
  * @param {object} params
205
- * @param {string} params.provider — 'codex' | 'gemini'
206
149
  * @param {(prompt: string, workdir: string, preflight: object, attempt: object) => Promise<object>} params.runFn
207
150
  * @param {(opts: object) => Promise<object>} params.preflightFn
208
151
  * @param {(opts: object, preflight: object) => object[]} params.buildAttemptsFn
209
152
  * @param {object} params.opts — caller-supplied execute options
210
153
  * @returns {Promise<object>} createResult-shaped result
211
154
  */
212
- export async function executeWithCircuitBroker({
213
- provider,
155
+ export async function executeWithAttempts({
214
156
  runFn,
215
157
  preflightFn,
216
158
  buildAttemptsFn,
217
159
  opts = {},
218
160
  }) {
219
- // late-import to avoid circular dependency at module load time
220
- const brokerMod = await import("./account-broker.mjs");
221
161
  const { withRetry } = await import("./workers/worker-utils.mjs");
222
162
 
223
- // access broker as live binding property (not destructured) so reloadBroker() propagates
224
- const hasBroker = brokerMod.broker != null;
225
- // Empty broker (TFX_DISABLE_ACCOUNT_BROKER=1 or zero accounts) must not
226
- // gate execution. lease() is null because there are no accounts to lease,
227
- // not because all accounts are busy/cooldown/circuit. Treat it like
228
- // hasBroker=false and fall through to the default auth path.
229
- const brokerDisabled = hasBroker && brokerMod.broker.isDisabled === true;
230
- const effectiveBroker = hasBroker && !brokerDisabled;
231
- const lease = effectiveBroker ? brokerMod.broker.lease({ provider }) : null;
232
- if (effectiveBroker && !lease) {
233
- return createResult(false, { fellBack: true, failureMode: "circuit_open" });
234
- }
235
-
236
163
  const preflight = await preflightFn(opts);
237
164
  if (!preflight.ok) {
238
- if (lease) brokerMod.broker.release(lease.id, { ok: false });
239
165
  return createResult(false, {
240
166
  stderr: appendWarnings("", preflight.warnings),
241
167
  fellBack: opts.fallbackToClaude !== false,
@@ -250,15 +176,11 @@ export async function executeWithCircuitBroker({
250
176
  try {
251
177
  lastResult = await withRetry(
252
178
  async () => {
253
- // PRD A1: lease 메타데이터를 runFn 에 전달해서 adapter 가 실제 spawn 시
254
- // 해당 account 의 authFile/env/profile 을 적용할 수 있게 한다. lease=null
255
- // 이면 adapter 는 default 경로 (단일 ~/.codex/auth.json) 로 동작한다.
256
179
  const result = await runFn(
257
180
  opts.prompt || "",
258
181
  opts.workdir || process.cwd(),
259
182
  preflight,
260
183
  attempts[attemptIndex],
261
- lease,
262
184
  );
263
185
  const current = {
264
186
  ...result,
@@ -286,26 +208,7 @@ export async function executeWithCircuitBroker({
286
208
  createResult(false, { stderr: String(error?.message || error) });
287
209
  }
288
210
 
289
- if (lastResult.ok) {
290
- if (lease) brokerMod.broker.release(lease.id, { ok: true });
291
- return lastResult;
292
- }
293
-
294
- if (lease) {
295
- if (lastResult.failureMode === "rate_limited") {
296
- const text = `${lastResult.output || ""}\n${lastResult.stderr || ""}`;
297
- const coolMs = parseRetryAfterMs(text, provider);
298
- brokerMod.broker.markRateLimited(lease.id, coolMs);
299
- brokerMod.broker.emit("cooldown", {
300
- id: lease.id,
301
- provider,
302
- coolMs,
303
- reason: "quota_exhausted",
304
- });
305
- } else {
306
- brokerMod.broker.release(lease.id, { ok: false });
307
- }
308
- }
211
+ if (lastResult.ok) return lastResult;
309
212
  return {
310
213
  ...lastResult,
311
214
  retried: attempts.length > 1,
@@ -374,13 +277,10 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
374
277
  let child;
375
278
 
376
279
  try {
377
- // PRD A1: opts.spawnEnv 가 있으면 그 env 로 spawn (lease 의 authFile/env 적용).
378
- // undefined 면 spawn 의 default 동작 (부모 process env inherit) 유지.
379
280
  child = spawnProcess(command, {
380
281
  cwd: workdir,
381
282
  shell: true,
382
283
  windowsHide: true,
383
- env: opts.spawnEnv,
384
284
  });
385
285
  } catch (error) {
386
286
  return createResult(false, {
@@ -1,11 +1,10 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
- import { basename, join } from "node:path";
3
+ import { join } from "node:path";
4
4
  import { codexProfileConfigOverrides } from "../scripts/lib/codex-profile-config.mjs";
5
5
  import {
6
6
  buildExecCommand,
7
- createResult,
8
- executeWithCircuitBroker,
7
+ executeWithAttempts,
9
8
  normalizePathForShell,
10
9
  runProcess,
11
10
  shellQuote,
@@ -113,7 +112,7 @@ export function buildLaunchScript(opts = {}) {
113
112
 
114
113
  // CTO boundary: this adapter assembles Codex prompts outside
115
114
  // scripts/tfx-route.sh. Keep north-star injection in tfx-route.sh until
116
- // launcher/circuit-broker workdir contracts have focused coverage.
115
+ // launcher workdir contracts have focused coverage.
117
116
  export function buildExecArgs(opts = {}) {
118
117
  const prompt = typeof opts.prompt === "string" ? opts.prompt : "";
119
118
  const command = buildExecCommand(prompt, opts.resultFile || null, {
@@ -153,7 +152,7 @@ export function buildExecArgs(opts = {}) {
153
152
 
154
153
  // ── Codex execution ─────────────────────────────────────────────
155
154
 
156
- async function runCodex(prompt, workdir, preflight, attempt, lease) {
155
+ async function runCodex(prompt, workdir, preflight, attempt) {
157
156
  const dir = join(tmpdir(), "triflux-codex-exec");
158
157
  mkdirSync(dir, { recursive: true });
159
158
  const resultFile = join(
@@ -162,7 +161,7 @@ async function runCodex(prompt, workdir, preflight, attempt, lease) {
162
161
  );
163
162
  const command = commandWithOverrides(
164
163
  buildExecCommand(prompt, resultFile, {
165
- profile: lease?.profile ?? attempt.profile,
164
+ profile: attempt.profile,
166
165
  skipGitRepoCheck: true,
167
166
  sandboxBypass: attempt.forceBypass,
168
167
  }),
@@ -170,125 +169,17 @@ async function runCodex(prompt, workdir, preflight, attempt, lease) {
170
169
  preflight.codexPath,
171
170
  buildOverrides(attempt.requested, attempt.excluded),
172
171
  );
173
- // PRD A1 — lease 메타데이터를 spawn env 에 적용한다. lease.authFile 이 있으면
174
- // 해당 파일이 위치한 디렉토리를 CODEX_HOME 으로 export 해서 codex CLI 가 그
175
- // account 의 auth.json 을 사용하게 한다. lease 가 null 이면 default ~/.codex
176
- // 동작 유지 (회귀 없음). lease.env 는 추가 환경변수 (provider 고정 등) 주입용.
177
- const spawnEnv = lease ? buildLeaseSpawnEnv(lease) : undefined;
178
- const authCheck = validateLeaseAuth(lease, spawnEnv);
179
- if (!authCheck.ok) {
180
- return createResult(false, {
181
- stderr: authCheck.error,
182
- failureMode: "auth_sync",
183
- skipCircuit: true,
184
- });
185
- }
186
172
  return runProcess(command, workdir, attempt.timeout, {
187
173
  resultFile,
188
174
  inferStallMode,
189
- spawnEnv,
190
175
  cli: "codex",
191
- codexHome: spawnEnv?.CODEX_HOME,
192
176
  });
193
177
  }
194
178
 
195
- function buildLeaseSpawnEnv(lease) {
196
- const extra = {};
197
- if (lease.authFile) {
198
- // lease.authFile 은 보통 ~/.claude/cache/tfx-hub/codex-auth-<account>.json 형식.
199
- // codex CLI 는 CODEX_HOME 을 통해 auth.json 위치를 결정하므로, 이 cache 파일을
200
- // 직접 CODEX_HOME 후보 디렉토리로 사용한다. cache 파일 자체가 auth.json 이름이
201
- // 아니라면 후속 PR 에서 isolated dir + symlink/복사 처리한다 (PRD Open Question).
202
- // 현재는 lease.authFile 의 dirname 을 export 하되, 그 dirname 안에 auth.json 이
203
- // 실제로 있을 때만 적용한다 (false-positive 회피).
204
- try {
205
- const dir = dirnameOf(lease.authFile);
206
- if (dir) extra.CODEX_HOME = dir;
207
- } catch {}
208
- }
209
- if (lease.env && typeof lease.env === "object") {
210
- Object.assign(extra, lease.env);
211
- }
212
- return Object.keys(extra).length ? { ...process.env, ...extra } : undefined;
213
- }
214
-
215
- function validateLeaseAuth(lease, spawnEnv) {
216
- if (!lease?.authFile) return { ok: true };
217
- if (basename(lease.authFile) !== "auth.json") {
218
- return {
219
- ok: false,
220
- error: `lease authFile must resolve to auth.json for account ${lease.id}`,
221
- };
222
- }
223
- if (!existsSync(lease.authFile)) {
224
- return {
225
- ok: false,
226
- error: `lease authFile missing for account ${lease.id}: ${lease.authFile}`,
227
- };
228
- }
229
- if (dirnameOf(lease.authFile) !== spawnEnv?.CODEX_HOME) {
230
- return {
231
- ok: false,
232
- // This is currently expected to match because buildLeaseSpawnEnv derives
233
- // CODEX_HOME from authFile. Keep the guard so future lease.env overrides
234
- // cannot silently point Codex at a different account home.
235
- error: `CODEX_HOME mismatch for account ${lease.id}`,
236
- };
237
- }
238
- try {
239
- const parsed = JSON.parse(readFileSync(lease.authFile, "utf8"));
240
- const accountId =
241
- parsed?.tokens?.account_id ??
242
- parsed?.account_id ??
243
- parsed?.accountId ??
244
- null;
245
- if (!accountId) {
246
- return {
247
- ok: false,
248
- error: `lease authFile has no account_id for account ${lease.id}`,
249
- };
250
- }
251
- if (accountId !== lease.id) {
252
- return {
253
- ok: false,
254
- error: `lease authFile account mismatch: expected ${lease.id}, got ${accountId}`,
255
- };
256
- }
257
- } catch (error) {
258
- return {
259
- ok: false,
260
- error: `lease authFile unreadable for account ${lease.id}: ${error?.message || error}`,
261
- };
262
- }
263
- return { ok: true };
264
- }
265
-
266
- function dirnameOf(filePath) {
267
- if (typeof filePath !== "string" || !filePath) return null;
268
- const lastSep = Math.max(
269
- filePath.lastIndexOf("/"),
270
- filePath.lastIndexOf("\\"),
271
- );
272
- if (lastSep < 0) return null;
273
- return filePath.slice(0, lastSep);
274
- }
275
-
276
179
  // ── Public API ──────────────────────────────────────────────────
277
180
 
278
- export async function getCircuitState() {
279
- const brokerMod = await import("./account-broker.mjs");
280
- if (!brokerMod.broker) return { state: "closed", failures: [] };
281
- const snap = brokerMod.broker
282
- .snapshot()
283
- .filter((a) => a.provider === "codex");
284
- return snap.length
285
- ? { state: snap[0].circuitState, accounts: snap }
286
- : { state: "closed", failures: [] };
287
- }
288
-
289
181
  export function execute(opts = {}) {
290
- return executeWithCircuitBroker({
291
- provider: "codex",
182
+ return executeWithAttempts({
292
183
  runFn: runCodex,
293
184
  preflightFn: (o) =>
294
185
  runPreflight({
@@ -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)
@@ -1,8 +1,9 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import { realpathSync } from "node:fs";
3
3
  import { open, readdir, readFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { createInterface } from "node:readline";
6
7
  import { StringDecoder } from "node:string_decoder";
7
8
  import { promisify } from "node:util";
8
9
  import { codexThreadNames } from "../lib/codex-session-registry.mjs";
@@ -214,6 +215,112 @@ export async function queueCodexMessage({
214
215
  return { queuedMessageId: match[1] };
215
216
  }
216
217
 
218
+ // 공식 app-server 를 stdio 로 잠깐 띄워 요청을 보낸다. 큐 조회와 삭제가 실험 API 라 experimentalApi 를 켠다.
219
+ export async function withCodexAppServer(
220
+ run,
221
+ { env = process.env, timeoutMs = 30_000, spawnFn = spawn } = {},
222
+ ) {
223
+ const child = spawnFn("codex", ["app-server"], {
224
+ env,
225
+ cwd: env.HOME || homedir(),
226
+ stdio: ["pipe", "pipe", "ignore"],
227
+ });
228
+ const pending = new Map();
229
+ let nextId = 0;
230
+ let closed = null;
231
+ const settleAll = (error) => {
232
+ closed ??= error;
233
+ for (const { reject } of pending.values()) reject(closed);
234
+ pending.clear();
235
+ };
236
+ child.on("error", settleAll);
237
+ // 먼저 끝난 app-server 에 쓰면 EPIPE 가 stdin 에서 나므로 요청 실패로 돌린다.
238
+ child.stdin.on("error", settleAll);
239
+ child.on("exit", (code) =>
240
+ settleAll(new Error(`codex app-server exited (${code})`)),
241
+ );
242
+ createInterface({ input: child.stdout }).on("line", (line) => {
243
+ let message;
244
+ try {
245
+ message = JSON.parse(line);
246
+ } catch {
247
+ return;
248
+ }
249
+ const waiter = pending.get(message.id);
250
+ if (!waiter) return;
251
+ pending.delete(message.id);
252
+ if (message.error)
253
+ waiter.reject(new Error(`${waiter.method}: ${message.error.message}`));
254
+ else waiter.resolve(message.result);
255
+ });
256
+ const send = (message) =>
257
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
258
+ const request = (method, params) => {
259
+ if (closed) return Promise.reject(closed);
260
+ const id = ++nextId;
261
+ return new Promise((resolve, reject) => {
262
+ pending.set(id, { method, resolve, reject });
263
+ send({ id, method, params });
264
+ });
265
+ };
266
+ const timer = setTimeout(
267
+ () => settleAll(new Error("codex app-server timed out")),
268
+ timeoutMs,
269
+ );
270
+ try {
271
+ await request("initialize", {
272
+ clientInfo: { name: "triflux-tfx-live", version: "1.0.0" },
273
+ capabilities: { experimentalApi: true },
274
+ });
275
+ send({ method: "initialized", params: {} });
276
+ return await run(request);
277
+ } finally {
278
+ clearTimeout(timer);
279
+ child.kill();
280
+ }
281
+ }
282
+
283
+ function queuedRequestId(texts) {
284
+ return texts.join("\n").match(/\[tfx-live req=([^\]\s]+)\]/)?.[1] ?? null;
285
+ }
286
+
287
+ // TUI 가 가져가기 전의 항목만 보인다. 가져간 뒤의 처리 여부는 wait 가 rollout 으로 본다.
288
+ export function listCodexQueue(threadId, options = {}) {
289
+ return withCodexAppServer(async (request) => {
290
+ const items = [];
291
+ let cursor = null;
292
+ do {
293
+ const page = await request("thread/queue/list", { threadId, cursor });
294
+ for (const item of page?.data ?? []) {
295
+ const texts = (item.input ?? []).map((part) => part.text ?? "");
296
+ items.push({
297
+ id: item.id,
298
+ requestId: queuedRequestId(texts),
299
+ text: texts.join("\n"),
300
+ });
301
+ }
302
+ cursor = page?.nextCursor ?? null;
303
+ } while (cursor);
304
+ return items;
305
+ }, options);
306
+ }
307
+
308
+ // 지우기 전에 TUI 가 가져간 항목은 deleted: false 라 missing 으로 돌려준다.
309
+ export function deleteCodexQueueItems(threadId, ids, options = {}) {
310
+ return withCodexAppServer(async (request) => {
311
+ const deleted = [];
312
+ const missing = [];
313
+ for (const id of ids) {
314
+ const result = await request("thread/queue/delete", {
315
+ threadId,
316
+ queuedSubmissionId: id,
317
+ });
318
+ (result?.deleted === true ? deleted : missing).push(id);
319
+ }
320
+ return { deleted, missing };
321
+ }, options);
322
+ }
323
+
217
324
  function userTexts(entry) {
218
325
  const payload = entry.payload ?? {};
219
326
  if (
@@ -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
+ }