triflux 10.45.0 → 10.47.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 (37) hide show
  1. package/bin/tfx-live.mjs +576 -53
  2. package/config/routing-policy.json +1 -1
  3. package/hooks/session-start-fast.mjs +6 -0
  4. package/hub/dynamic-routing-engine.mjs +1 -1
  5. package/hub/lib/cto-env.mjs +2 -3
  6. package/hub/server.mjs +2 -51
  7. package/hub/team/recovery-store.mjs +1 -1
  8. package/hub/team/retry-state-machine.mjs +9 -1
  9. package/hub/team/synapse-registry.mjs +14 -11
  10. package/hub/team/uds-orchestrator.mjs +384 -83
  11. package/hub/team/worktree-lifecycle.mjs +8 -48
  12. package/hub/tray-lifecycle.mjs +1 -1
  13. package/hub/workers/codex-mcp.mjs +1 -1
  14. package/hud/providers/codex.mjs +91 -8
  15. package/hud/providers/cto.mjs +3 -0
  16. package/package.json +1 -1
  17. package/scripts/__tests__/release-governance.test.mjs +235 -11
  18. package/scripts/__tests__/release-publish-ancestor.test.mjs +67 -0
  19. package/scripts/__tests__/release-verify-wait.test.mjs +169 -0
  20. package/scripts/__tests__/resolve-auto-release.test.mjs +216 -0
  21. package/scripts/__tests__/tfx-route-bash-node-parity.test.mjs +1 -1
  22. package/scripts/__tests__/tfx-route-node-entry.test.mjs +4 -4
  23. package/scripts/lib/agent-route-policy.mjs +18 -17
  24. package/scripts/lib/codex-profile-config.mjs +19 -5
  25. package/scripts/release/publish.mjs +16 -1
  26. package/scripts/release/resolve-auto-release.mjs +136 -0
  27. package/scripts/release/verify.mjs +74 -25
  28. package/scripts/setup.mjs +20 -7
  29. package/scripts/tfx-route.sh +27 -24
  30. package/skills/tfx-auto/SKILL.md +5 -4
  31. package/skills/tfx-live/SKILL.md +112 -7
  32. package/skills/tfx-profile/SKILL.md +9 -7
  33. package/skills/tfx-setup/SKILL.md +1 -1
  34. package/skills/tfx-ship/SKILL.md +43 -36
  35. package/tui/codex-profile.mjs +2 -3
  36. package/tui/setup.mjs +4 -3
  37. package/hub/team/git-preflight.mjs +0 -286
package/bin/tfx-live.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile } from "node:child_process";
3
- import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
4
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { join as pathJoin, resolve as pathResolve } from "node:path";
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { promisify } from "node:util";
9
9
  import { runHygiene } from "../cto/hygiene.mjs";
10
10
  import { notifyCtoHygieneOnce } from "../cto/hygiene-notify.mjs";
11
+ import { resolveHardCeilingMs } from "../hub/lib/worker-lifecycle.mjs";
11
12
  import { createNotifier } from "../hub/team/notify.mjs";
12
13
  import {
13
14
  escapePwshSingleQuoted as escapeRemotePwshSingleQuoted,
@@ -38,7 +39,7 @@ const BRIDGE_TIMEOUT_BUFFER_MS = 15_000;
38
39
  // spill the JSON to a temp file and pass --payload-file instead.
39
40
  const PAYLOAD_FILE_THRESHOLD = 96 * 1024;
40
41
  const VALID_TRANSPORTS = ["tmux", "uds", "auto"];
41
- const BOOLEAN_FLAGS = new Set(["json"]);
42
+ const BOOLEAN_FLAGS = new Set(["json", "attach-a", "attach-b"]);
42
43
  const PEER_HOP_DONE_MARKER = "<<<TFX_PEER_HOP_DONE>>>";
43
44
  // uds-fallback diagnostics land here, written async so a failed daemon attach
44
45
  // never blocks the tmux fallback path (see writeUdsBugReport / doAskAuto).
@@ -50,17 +51,19 @@ function usage() {
50
51
  return [
51
52
  "Usage:",
52
53
  " tfx-live start --session NAME [--cli codex|claude] [--model ID] [--effort TIER] [--cwd DIR] [--remote HOST] [--resume ID] [--resume-last 1] [--ready-timeout 30] [--poll-interval 1500]",
53
- " tfx-live ask --session NAME --prompt TEXT [--cli codex|claude] [--timeout 60] [--remote HOST] [--settle 1500] [--poll-interval 1500]",
54
+ " tfx-live ask --session NAME[:WINDOW.PANE] --prompt TEXT [--cli codex|claude] [--if-busy wait|fail|interrupt] [--busy-timeout 60] [--timeout 60] [--remote HOST] [--settle 1500] [--poll-interval 1500]",
55
+ " tfx-live ask --cli codex --transport uds --thread ID|auto --prompt TEXT [--codex-socket PATH|default] [--cwd DIR] [--if-busy wait|fail|steer] [--busy-timeout 60] [--timeout 60] [--max-turn SECONDS]",
54
56
  " tfx-live ask --transport uds|auto (--short SHORT | --session-id ID) --prompt TEXT [--config-dir DIR] [--bridge ABS] [--session NAME (auto fallback)] [--timeout 60]",
55
57
  " transport: auto is the default for Claude when --short/--session-id is present; otherwise tmux. bridge path: --bridge > $TFX_BRIDGE > $TFX_REPO_ROOT/hub/bridge.mjs > bundled Triflux hub/bridge.mjs.",
56
58
  " tfx-live interrupt --session NAME [--cli codex|claude] [--transport tmux|uds|auto] [--short SHORT | --session-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 5]",
57
59
  " tfx-live stop --session NAME [--cli codex|claude] [--remote HOST]",
58
60
  " tfx-live probe [--short SHORT] [--session-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 10]",
59
- " tfx-live list-sessions --cli codex [--cwd DIR]",
61
+ " tfx-live list-sessions --cli codex [--transport tmux|uds] [--codex-socket PATH|default] [--cwd DIR] [--remote HOST (tmux)]",
60
62
  " tfx-live converse --session NAME --prompts-file PATH [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500]",
61
63
  " tfx-live goal-driven --session NAME --goal TEXT [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500] [--max-rounds 8] [--done-token DONE]",
62
- " tfx-live peer [--cli-a codex] [--cli-b claude] [--model-a ID] [--model-b ID] [--effort-a TIER] [--effort-b TIER] [--session-a peerA] [--session-b peerB] [--transport-a tmux|uds|auto] [--transport-b tmux|uds|auto] [--short-a SHORT] [--short-b SHORT] [--session-id-a ID] [--session-id-b ID] [--bridge ABS] [--remote HOST] [--cwd DIR] [--rounds 4] [--mode counting|freeform] [--seed TEXT] [--timeout 60]",
63
- " tfx-live orchestrate --task TEXT [--mode peer|codex-led|claude-led] [--codex-transport exec|app-server-uds] [--cwd DIR] [--timeout 120]",
64
+ " tfx-live peer [--cli-a codex] [--cli-b claude] [--model-a ID] [--model-b ID] [--effort-a TIER] [--effort-b TIER] [--session-a NAME[:WINDOW.PANE]] [--session-b NAME[:WINDOW.PANE]] [--attach-a] [--attach-b] [--transport-a tmux|uds|auto] [--transport-b tmux|uds|auto] [--short-a SHORT] [--short-b SHORT] [--session-id-a ID] [--session-id-b ID] [--thread-a ID|auto] [--thread-b ID|auto] [--codex-socket-a PATH|default] [--codex-socket-b PATH|default] [--bridge ABS] [--remote HOST] [--cwd DIR] [--if-busy wait|fail] [--if-busy-a POLICY] [--if-busy-b POLICY] [--busy-timeout 60] [--max-turn SECONDS] [--rounds 4] [--mode counting|freeform] [--seed TEXT] [--timeout 60]",
65
+ " tfx-live orchestrate --task TEXT [--mode peer|codex-led|claude-led] [--codex-transport exec|app-server-uds] [--codex-socket PATH|default] [--cwd DIR] [--timeout 120]",
66
+ " Codex TUI may use a shared app-server daemon. UDS ask resumes the thread to receive answer events; sending to an active thread with --if-busy steer merges input into that turn.",
64
67
  " Runs the Claude(UDS)+Codex orchestration engine. --codex-transport app-server-uds drives a real `codex app-server` over WebSocket-over-UDS (experimental); default exec keeps the codex stdio one-shot path.",
65
68
  " tfx-live cto-hygiene-notify --root DIR --state-file PATH [--json]",
66
69
  " One-shot CTO hygiene dry-run notification: sends only when actionable hygiene state changes; no polling or apply/steward lock.",
@@ -202,7 +205,7 @@ function buildRemoteLiveArgv(verb, opts) {
202
205
  "tfx-live",
203
206
  verb,
204
207
  "--cli",
205
- "claude",
208
+ opts.cli ?? "claude",
206
209
  "--transport",
207
210
  opts.transport ?? "uds",
208
211
  ];
@@ -210,6 +213,13 @@ function buildRemoteLiveArgv(verb, opts) {
210
213
  if (opts.sessionId) args.push("--session-id", opts.sessionId);
211
214
  if (opts.session) args.push("--session", opts.session);
212
215
  if (opts.configDir) args.push("--config-dir", opts.configDir);
216
+ if (opts.threadId) args.push("--thread", opts.threadId);
217
+ if (opts.codexSocket) args.push("--codex-socket", opts.codexSocket);
218
+ if (opts.cwd) args.push("--cwd", opts.cwd);
219
+ if (opts.ifBusy) args.push("--if-busy", opts.ifBusy);
220
+ if (opts.busyTimeoutMs)
221
+ args.push("--busy-timeout", String(opts.busyTimeoutMs / 1000));
222
+ if (opts.maxTurnMs) args.push("--max-turn", String(opts.maxTurnMs / 1000));
213
223
  if (verb === "ask") args.push("--prompt", opts.prompt ?? "");
214
224
  args.push("--timeout", timeoutSeconds(opts.timeoutMs));
215
225
  if (verb === "ask" && opts.settleMs) {
@@ -316,8 +326,21 @@ async function callRemoteLive(verb, opts, deps = {}) {
316
326
  const execRemote = deps.sshExec ?? execFileAsync;
317
327
  const env = await probeRemoteEnv(host);
318
328
  const plan = buildRemoteLiveCommand(host, verb, opts, env);
329
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_ANSWER_TIMEOUT_MS;
330
+ const codexUds =
331
+ verb === "ask" &&
332
+ opts.cli === "codex" &&
333
+ (opts.transport ?? "uds") === "uds";
334
+ const maxTurnMs = codexUds
335
+ ? (opts.maxTurnMs ?? Math.min(resolveHardCeilingMs(), 15 * 60_000))
336
+ : 0;
319
337
  const execOptions = {
320
- timeout: (opts.timeoutMs ?? DEFAULT_ANSWER_TIMEOUT_MS) + 30_000,
338
+ timeout:
339
+ (verb === "ask" ? (opts.busyTimeoutMs ?? timeoutMs) : 0) +
340
+ // Codex checks its hard ceiling on a timeoutMs tick.
341
+ maxTurnMs +
342
+ timeoutMs +
343
+ 30_000,
321
344
  maxBuffer: MAX_BUFFER,
322
345
  };
323
346
 
@@ -368,6 +391,37 @@ function normalizeCwd(value) {
368
391
  }
369
392
  }
370
393
 
394
+ function splitTmuxTarget(value) {
395
+ const target = String(value ?? "");
396
+ const session = target.split(":", 1)[0];
397
+ if (!session)
398
+ throw new Error("tmux target requires a session name before ':'");
399
+ return { session, target };
400
+ }
401
+
402
+ function tmuxBufferName(target) {
403
+ return `tfx-live-prompt-${target.replace(/[^A-Za-z0-9_-]/g, "_")}-${Date.now()}`;
404
+ }
405
+
406
+ function resolveCodexDaemonSocket(value = "default") {
407
+ const socketPath =
408
+ value === "default"
409
+ ? pathJoin(
410
+ process.env.CODEX_HOME ?? pathJoin(homedir(), ".codex"),
411
+ "app-server-control",
412
+ "app-server-control.sock",
413
+ )
414
+ : pathResolve(value);
415
+ try {
416
+ if (statSync(socketPath).isSocket()) return socketPath;
417
+ } catch {
418
+ /* handled below */
419
+ }
420
+ throw new Error(
421
+ `Codex daemon socket unavailable: ${socketPath}. Start it with codex app-server daemon start`,
422
+ );
423
+ }
424
+
371
425
  function isCodexTmuxPane(currentCommand, startCommand) {
372
426
  const current = String(currentCommand ?? "").trim();
373
427
  const currentBase = current.split("/").at(-1);
@@ -389,7 +443,102 @@ function isCodexTmuxPane(currentCommand, startCommand) {
389
443
  );
390
444
  }
391
445
 
392
- function parseCodexTmuxSessions(stdout, cwd = null) {
446
+ function isCodexProcessCommand(command) {
447
+ const argv = (command.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((arg) =>
448
+ arg.replace(/^["']|["']$/g, ""),
449
+ );
450
+ const isCodexPath = (arg) => /^(?:.*\/)?codex(?:\.js)?$/.test(arg ?? "");
451
+ const executable = argv.shift();
452
+ if (isCodexPath(executable)) return true;
453
+ if (!/^(?:.*\/)?node(?:js)?$/.test(executable ?? "")) return false;
454
+ const valueOptions = new Set([
455
+ "-r",
456
+ "--require",
457
+ "--import",
458
+ "--loader",
459
+ "--experimental-loader",
460
+ "--icu-data-dir",
461
+ ]);
462
+ const booleanOptions = new Set([
463
+ "--no-warnings",
464
+ "--enable-source-maps",
465
+ "--preserve-symlinks",
466
+ "--preserve-symlinks-main",
467
+ ]);
468
+ while (argv.length) {
469
+ const arg = argv.shift();
470
+ if (arg === "--") return isCodexPath(argv[0]);
471
+ if (/^(?:-[ep]|--eval(?:=|$)|--print(?:=|$))/.test(arg)) return false;
472
+ if (!arg.startsWith("-")) return isCodexPath(arg);
473
+ if (valueOptions.has(arg.split("=", 1)[0])) {
474
+ if (!arg.includes("=")) argv.shift();
475
+ } else if (!booleanOptions.has(arg)) {
476
+ // Unknown Node options may consume the next token as a value.
477
+ return false;
478
+ }
479
+ }
480
+ return false;
481
+ }
482
+
483
+ async function inspectCodexTmuxPane(pane, deps = {}) {
484
+ if (isCodexTmuxPane(pane.currentCommand, pane.startCommand))
485
+ return { isCodex: true };
486
+ if (!pane.panePid && !pane.paneTty) return { isCodex: false };
487
+ const args = ["-ax", "-o", "pid=,ppid=,tty=,args="];
488
+ const command = pane.remote ? "ssh" : "ps";
489
+ const commandArgs = pane.remote
490
+ ? [
491
+ "-o",
492
+ "BatchMode=yes",
493
+ "-o",
494
+ "ConnectTimeout=10",
495
+ pane.remote,
496
+ shellQuote(["ps", ...args]),
497
+ ]
498
+ : args;
499
+ let stdout;
500
+ try {
501
+ ({ stdout } = await (deps.psExec ?? execFileAsync)(command, commandArgs, {
502
+ timeout: 15_000,
503
+ maxBuffer: MAX_BUFFER,
504
+ }));
505
+ } catch (error) {
506
+ return {
507
+ isCodex: false,
508
+ preflightWarning: `Codex pane process inspection unavailable: ${error.message}`,
509
+ };
510
+ }
511
+ const processes = String(stdout)
512
+ .split(/\r?\n/)
513
+ .flatMap((line) => {
514
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
515
+ return match
516
+ ? [{ pid: match[1], ppid: match[2], tty: match[3], command: match[4] }]
517
+ : [];
518
+ });
519
+ const descendants = new Set([String(pane.panePid)]);
520
+ let added;
521
+ do {
522
+ added = false;
523
+ for (const process of processes) {
524
+ if (descendants.has(process.ppid) && !descendants.has(process.pid)) {
525
+ descendants.add(process.pid);
526
+ added = true;
527
+ }
528
+ }
529
+ } while (added);
530
+ const tty = String(pane.paneTty ?? "").replace(/^\/dev\//, "");
531
+ return {
532
+ isCodex: processes.some(
533
+ (process) =>
534
+ (descendants.has(process.pid) ||
535
+ (tty && tty !== "?" && tty !== "??" && process.tty === tty)) &&
536
+ isCodexProcessCommand(process.command),
537
+ ),
538
+ };
539
+ }
540
+
541
+ function parseCodexTmuxSessions(stdout, cwd = null, codexTargets = null) {
393
542
  const cwdFilter = normalizeCwd(cwd);
394
543
  const sessions = new Map();
395
544
 
@@ -399,12 +548,18 @@ function parseCodexTmuxSessions(stdout, cwd = null) {
399
548
  session,
400
549
  createdRaw,
401
550
  attachedRaw,
551
+ windowIndex,
552
+ paneIndex,
402
553
  paneCwd,
403
554
  currentCommand,
404
- ...startParts
555
+ startCommand,
405
556
  ] = line.split("\t");
406
- const startCommand = startParts.join("\t");
407
- if (!session || !isCodexTmuxPane(currentCommand, startCommand)) {
557
+ if (
558
+ !session ||
559
+ !(codexTargets
560
+ ? codexTargets.has(`${session}:${windowIndex}.${paneIndex}`)
561
+ : isCodexTmuxPane(currentCommand, startCommand))
562
+ ) {
408
563
  continue;
409
564
  }
410
565
 
@@ -418,10 +573,16 @@ function parseCodexTmuxSessions(stdout, cwd = null) {
418
573
  ? new Date(startedAtEpoch * 1000).toISOString()
419
574
  : null;
420
575
  const existing = sessions.get(session);
576
+ const pane = {
577
+ target: `${session}:${windowIndex}.${paneIndex}`,
578
+ cwd: normalizedPaneCwd,
579
+ command: currentCommand,
580
+ };
421
581
  if (existing) {
422
582
  if (!existing.cwd && normalizedPaneCwd) {
423
583
  existing.cwd = normalizedPaneCwd;
424
584
  }
585
+ existing.panes.push(pane);
425
586
  continue;
426
587
  }
427
588
 
@@ -433,6 +594,8 @@ function parseCodexTmuxSessions(stdout, cwd = null) {
433
594
  : null,
434
595
  cwd: normalizedPaneCwd,
435
596
  attached: Number.parseInt(attachedRaw, 10) > 0,
597
+ target: pane.target,
598
+ panes: [pane],
436
599
  });
437
600
  }
438
601
 
@@ -441,23 +604,56 @@ function parseCodexTmuxSessions(stdout, cwd = null) {
441
604
  );
442
605
  }
443
606
 
444
- async function discoverCodexTmuxSessions({ cwd = null } = {}) {
607
+ async function discoverCodexTmuxSessions(
608
+ { cwd = null, remote = null } = {},
609
+ deps = {},
610
+ ) {
445
611
  const format = [
446
612
  "#{session_name}",
447
613
  "#{session_created}",
448
614
  "#{session_attached}",
615
+ "#{window_index}",
616
+ "#{pane_index}",
449
617
  "#{pane_current_path}",
450
618
  "#{pane_current_command}",
451
619
  "#{pane_start_command}",
620
+ "#{pane_tty}",
621
+ "#{pane_pid}",
452
622
  ].join("\t");
453
623
 
454
624
  try {
455
- const { stdout } = await runTmux(null, ["list-panes", "-a", "-F", format]);
625
+ const { stdout } = await (deps.runTmux ?? runTmux)(remote, [
626
+ "list-panes",
627
+ "-a",
628
+ "-F",
629
+ format,
630
+ ]);
631
+ const codexTargets = new Set();
632
+ for (const line of String(stdout).split(/\r?\n/)) {
633
+ if (!line) continue;
634
+ const [
635
+ session,
636
+ ,
637
+ ,
638
+ window,
639
+ pane,
640
+ ,
641
+ currentCommand,
642
+ startCommand,
643
+ paneTty,
644
+ panePid,
645
+ ] = line.split("\t");
646
+ const result = await inspectCodexTmuxPane(
647
+ { currentCommand, startCommand, paneTty, panePid, remote },
648
+ deps,
649
+ );
650
+ if (result.isCodex) codexTargets.add(`${session}:${window}.${pane}`);
651
+ }
456
652
  return {
457
653
  ok: true,
458
654
  cli: "codex",
459
655
  cwd: normalizeCwd(cwd),
460
- sessions: parseCodexTmuxSessions(stdout, cwd),
656
+ sessions: parseCodexTmuxSessions(stdout, cwd, codexTargets),
461
657
  };
462
658
  } catch (error) {
463
659
  return {
@@ -501,6 +697,30 @@ function isBusyByEsc(text) {
501
697
  return String(text).includes("esc to interrupt");
502
698
  }
503
699
 
700
+ function isCodexLoadingCapture(text) {
701
+ const headers = [
702
+ ...stripAnsi(text).matchAll(/^\s*(?:│\s*)?model:\s*([^\n]*)/gim),
703
+ ];
704
+ return /^loading\b/i.test(headers.at(-1)?.[1] ?? "");
705
+ }
706
+
707
+ function isCodexTimeLine(line) {
708
+ return (
709
+ /^\s*\d{1,2}:\d{2}(?:\s?[AP]M)?\s*$/.test(line) ||
710
+ /^\s*(?:오전|오후)\s?\d{1,2}:\d{2}\s*$/.test(line)
711
+ );
712
+ }
713
+
714
+ function trimResponseLines(adapter, lines) {
715
+ while (
716
+ lines.length &&
717
+ (!lines.at(-1).trim() ||
718
+ (adapter.cli === "codex" && isCodexTimeLine(lines.at(-1))))
719
+ )
720
+ lines.pop();
721
+ return lines.join("\n").trim();
722
+ }
723
+
504
724
  function hasCodexComposerPrompt(text) {
505
725
  return /^\s*[›❯▶▸>]\s*/m.test(text);
506
726
  }
@@ -536,7 +756,11 @@ function isClaudeResponseLine(line) {
536
756
  }
537
757
 
538
758
  function isCodexReadyCapture(text) {
539
- return !isBusyByEsc(text) && hasCodexComposerPrompt(text);
759
+ return (
760
+ !isBusyByEsc(text) &&
761
+ !isCodexLoadingCapture(text) &&
762
+ hasCodexComposerPrompt(text)
763
+ );
540
764
  }
541
765
 
542
766
  function hasClaudeLoginGuard(text) {
@@ -679,7 +903,7 @@ function extractAssistantResponse(adapter, text, prompt = null) {
679
903
  }
680
904
  }
681
905
 
682
- return response.join("\n").trim();
906
+ return trimResponseLines(adapter, response);
683
907
  }
684
908
 
685
909
  function extractResponseSinceMarker({ beforeRaw, raw, doneMarker, adapter }) {
@@ -716,20 +940,21 @@ function extractResponseSinceMarker({ beforeRaw, raw, doneMarker, adapter }) {
716
940
  const responseLines =
717
941
  responseStart === -1 ? candidateLines : candidateLines.slice(responseStart);
718
942
 
719
- return responseLines
720
- .filter(
721
- (line) =>
722
- !adapter.isChromeLine(line) &&
723
- !isWarningLine(line) &&
724
- !adapter.isStatusLine(line) &&
725
- !hasCodexComposerPrompt(line) &&
726
- !hasClaudeComposerPrompt(line),
727
- )
728
- .map((line) =>
729
- adapter.isResponseLine(line) ? adapter.stripResponseMarker(line) : line,
730
- )
731
- .join("\n")
732
- .trim();
943
+ return trimResponseLines(
944
+ adapter,
945
+ responseLines
946
+ .filter(
947
+ (line) =>
948
+ !adapter.isChromeLine(line) &&
949
+ !isWarningLine(line) &&
950
+ !adapter.isStatusLine(line) &&
951
+ !hasCodexComposerPrompt(line) &&
952
+ !hasClaudeComposerPrompt(line),
953
+ )
954
+ .map((line) =>
955
+ adapter.isResponseLine(line) ? adapter.stripResponseMarker(line) : line,
956
+ ),
957
+ );
733
958
  }
734
959
 
735
960
  function normalizeClaudeTaskText(text) {
@@ -1414,7 +1639,11 @@ async function probeDaemon(bridgePath, ref, timeoutMs) {
1414
1639
  async function hasTmuxSession(adapter, opts) {
1415
1640
  if (!opts.session) return false;
1416
1641
  try {
1417
- await runTmux(opts.remote, ["has-session", "-t", opts.session]);
1642
+ await runTmux(opts.remote, [
1643
+ "has-session",
1644
+ "-t",
1645
+ splitTmuxTarget(opts.session).session,
1646
+ ]);
1418
1647
  return true;
1419
1648
  } catch {
1420
1649
  return false;
@@ -1493,6 +1722,8 @@ async function doStart(adapter, opts) {
1493
1722
  readyTimeoutMs,
1494
1723
  pollIntervalMs,
1495
1724
  } = opts;
1725
+ if (session.includes(":"))
1726
+ throw new Error("start --session accepts only a session name without ':'");
1496
1727
  const launchKeys = resume
1497
1728
  ? [adapter.resumeById(resume), "Enter"]
1498
1729
  : resumeLast
@@ -1539,6 +1770,36 @@ async function doStart(adapter, opts) {
1539
1770
  });
1540
1771
  }
1541
1772
 
1773
+ async function waitForTmuxIdle(adapter, opts) {
1774
+ const { session, remote, pollIntervalMs, busyTimeoutMs, ifBusy } = opts;
1775
+ if (!["wait", "fail", "interrupt"].includes(ifBusy)) {
1776
+ throw new Error("--if-busy must be wait, fail, or interrupt for tmux");
1777
+ }
1778
+ const startedAt = Date.now();
1779
+ let interrupted = false;
1780
+ while (true) {
1781
+ const visible = await captureVisible(remote, session);
1782
+ const elapsedMs = Date.now() - startedAt;
1783
+ const busy =
1784
+ isBusyByEsc(visible) ||
1785
+ (adapter.cli === "codex" && isCodexLoadingCapture(visible));
1786
+ if (!busy) return elapsedMs;
1787
+ if (ifBusy === "fail") throw new Error("target busy (--if-busy fail)");
1788
+ if (elapsedMs >= busyTimeoutMs) {
1789
+ throw new Error(
1790
+ `target busy after ${Math.ceil(busyTimeoutMs / 1000)}s (--if-busy ${ifBusy})`,
1791
+ );
1792
+ }
1793
+ if (ifBusy === "interrupt" && !interrupted) {
1794
+ await runTmux(remote, ["send-keys", "-t", session, "Escape"]);
1795
+ interrupted = true;
1796
+ }
1797
+ await sleep(
1798
+ Math.min(pollIntervalMs, Math.max(1, busyTimeoutMs - elapsedMs)),
1799
+ );
1800
+ }
1801
+ }
1802
+
1542
1803
  async function doAskViaTmux(adapter, opts) {
1543
1804
  const {
1544
1805
  session,
@@ -1548,11 +1809,20 @@ async function doAskViaTmux(adapter, opts) {
1548
1809
  settleMs,
1549
1810
  pollIntervalMs,
1550
1811
  doneMarker,
1812
+ ifBusy = "wait",
1813
+ busyTimeoutMs = timeoutMs,
1551
1814
  } = opts;
1815
+ const busyWaitedMs = await waitForTmuxIdle(adapter, {
1816
+ session,
1817
+ remote,
1818
+ pollIntervalMs,
1819
+ busyTimeoutMs,
1820
+ ifBusy,
1821
+ });
1552
1822
  const beforeRaw = await capturePane(remote, session);
1553
1823
  const contextPctBefore = adapter.contextPct(beforeRaw);
1554
1824
 
1555
- const bufferName = `tfx-live-prompt-${session}-${Date.now()}`;
1825
+ const bufferName = tmuxBufferName(session);
1556
1826
  await runTmux(remote, ["set-buffer", "-b", bufferName, "--", prompt]);
1557
1827
  await runTmux(remote, [
1558
1828
  "paste-buffer",
@@ -1662,6 +1932,8 @@ async function doAskViaTmux(adapter, opts) {
1662
1932
  session,
1663
1933
  remote: remote ?? null,
1664
1934
  transport: "tmux",
1935
+ ifBusy,
1936
+ busyWaitedMs,
1665
1937
  response,
1666
1938
  contextPctBefore,
1667
1939
  contextPctAfter,
@@ -1677,6 +1949,24 @@ async function doAsk(adapter, opts) {
1677
1949
  return doAskViaTmux(adapter, opts);
1678
1950
  }
1679
1951
 
1952
+ if (adapter.cli === "codex" && transport === "uds") {
1953
+ if (opts.remote) return doAskViaRemoteLive(adapter, opts);
1954
+ const { askCodexAppServerThread } = await import(
1955
+ "../hub/team/uds-orchestrator.mjs"
1956
+ );
1957
+ return askCodexAppServerThread({
1958
+ socketPath: opts.socketPath,
1959
+ threadId: opts.threadId,
1960
+ cwd: opts.cwd,
1961
+ prompt: opts.prompt,
1962
+ timeoutMs: opts.timeoutMs,
1963
+ maxTurnMs: opts.maxTurnMs,
1964
+ ifBusy: opts.ifBusy,
1965
+ busyTimeoutMs: opts.busyTimeoutMs,
1966
+ pollIntervalMs: opts.pollIntervalMs,
1967
+ });
1968
+ }
1969
+
1680
1970
  // uds/auto attach a live background Claude daemon, not a tmux TUI.
1681
1971
  if (adapter.cli !== "claude") {
1682
1972
  throw new Error(
@@ -1706,7 +1996,11 @@ async function doAsk(adapter, opts) {
1706
1996
  }
1707
1997
 
1708
1998
  async function doAskViaRemoteLive(adapter, opts, deps = {}) {
1709
- const result = await callRemoteLive("ask", opts, deps);
1999
+ const result = await callRemoteLive(
2000
+ "ask",
2001
+ { ...opts, cli: adapter.cli },
2002
+ deps,
2003
+ );
1710
2004
  return {
1711
2005
  ...result,
1712
2006
  cli: result.cli ?? adapter.cli,
@@ -1774,7 +2068,11 @@ function daemonProbeTargetAttachable(probe, opts) {
1774
2068
  async function ensureTmuxSession(adapter, opts) {
1775
2069
  if (!opts.session) return false;
1776
2070
  try {
1777
- await runTmux(opts.remote, ["has-session", "-t", opts.session]);
2071
+ await runTmux(opts.remote, [
2072
+ "has-session",
2073
+ "-t",
2074
+ splitTmuxTarget(opts.session).session,
2075
+ ]);
1778
2076
  return false;
1779
2077
  } catch {
1780
2078
  await doStart(adapter, {
@@ -1953,6 +2251,10 @@ async function doAskAuto(adapter, opts) {
1953
2251
 
1954
2252
  async function doStop(adapter, opts) {
1955
2253
  const { session, remote } = opts;
2254
+ if (/[:.]|^[%@]\d+$/.test(session))
2255
+ throw new Error(
2256
+ "stop --session accepts only a session name; pass the session name without a pane target",
2257
+ );
1956
2258
  await runTmux(remote, ["kill-session", "-t", session]);
1957
2259
  return {
1958
2260
  cli: adapter.cli,
@@ -2070,8 +2372,11 @@ async function doInterrupt(adapter, opts) {
2070
2372
  }
2071
2373
 
2072
2374
  function startOpts(flags) {
2375
+ const session = requireFlag(flags, "session");
2376
+ if (session.includes(":"))
2377
+ throw new Error("start --session accepts only a session name without ':'");
2073
2378
  return {
2074
- session: requireFlag(flags, "session"),
2379
+ session,
2075
2380
  remote: flags.remote,
2076
2381
  cwd: flags.cwd,
2077
2382
  resume: flags.resume,
@@ -2115,7 +2420,10 @@ function askOpts(flags, adapter) {
2115
2420
  const short = flags.short;
2116
2421
  const sessionId = flags["session-id"];
2117
2422
  const transport = transportFlag(flags, adapter, short, sessionId);
2118
- if (transport !== "tmux" && !short && !sessionId) {
2423
+ const codexUds = adapter.cli === "codex" && transport === "uds";
2424
+ if (adapter.cli === "codex" && transport === "auto")
2425
+ throw new Error("--transport auto is only supported with --cli claude");
2426
+ if (transport !== "tmux" && !codexUds && !short && !sessionId) {
2119
2427
  throw new Error(
2120
2428
  `--transport ${transport} requires --short or --session-id`,
2121
2429
  );
@@ -2124,15 +2432,37 @@ function askOpts(flags, adapter) {
2124
2432
  // tmux needs a tmux session name; uds needs a daemon ref (short/sessionId);
2125
2433
  // auto can take both (daemon ref to probe, tmux session for fallback).
2126
2434
  session:
2127
- transport === "tmux" ? requireFlag(flags, "session") : flags.session,
2435
+ transport === "tmux"
2436
+ ? splitTmuxTarget(requireFlag(flags, "session")).target
2437
+ : flags.session,
2128
2438
  short,
2129
2439
  sessionId,
2130
2440
  configDir: flags["config-dir"],
2131
2441
  transport,
2442
+ threadId: codexUds ? requireFlag(flags, "thread") : null,
2443
+ codexSocket: codexUds ? (flags["codex-socket"] ?? "default") : null,
2444
+ socketPath:
2445
+ codexUds && !flags.remote
2446
+ ? resolveCodexDaemonSocket(flags["codex-socket"] ?? "default")
2447
+ : null,
2448
+ cwd: flags.cwd,
2449
+ ifBusy: flags["if-busy"] ?? "wait",
2450
+ busyTimeoutMs: secondsFlag(
2451
+ flags,
2452
+ "busy-timeout",
2453
+ secondsFlag(flags, "timeout", DEFAULT_ANSWER_TIMEOUT_MS),
2454
+ ),
2132
2455
  bridgePath: resolveBridgePath(flags),
2133
2456
  prompt: requireFlag(flags, "prompt"),
2134
2457
  remote: flags.remote,
2135
2458
  timeoutMs: secondsFlag(flags, "timeout", DEFAULT_ANSWER_TIMEOUT_MS),
2459
+ maxTurnMs: codexUds
2460
+ ? secondsFlag(
2461
+ flags,
2462
+ "max-turn",
2463
+ Math.min(resolveHardCeilingMs(), 15 * 60_000),
2464
+ )
2465
+ : undefined,
2136
2466
  settleMs: msFlag(flags, "settle", DEFAULT_SETTLE_MS),
2137
2467
  pollIntervalMs: msFlag(flags, "poll-interval", DEFAULT_POLL_INTERVAL_MS),
2138
2468
  };
@@ -2149,9 +2479,15 @@ function interruptOpts(flags, adapter) {
2149
2479
  const short = flags.short;
2150
2480
  const sessionId = flags["session-id"];
2151
2481
  const transport = transportFlag(flags, adapter, short, sessionId);
2482
+ if (adapter.cli === "codex" && transport === "uds")
2483
+ throw new Error(
2484
+ "active turnId unavailable; cannot interrupt Codex UDS thread",
2485
+ );
2152
2486
  return {
2153
2487
  session:
2154
- transport === "tmux" ? requireFlag(flags, "session") : flags.session,
2488
+ transport === "tmux"
2489
+ ? splitTmuxTarget(requireFlag(flags, "session")).target
2490
+ : flags.session,
2155
2491
  short,
2156
2492
  sessionId,
2157
2493
  configDir: flags["config-dir"],
@@ -2203,7 +2539,25 @@ async function listSessions(flags) {
2203
2539
  if (adapter.cli !== "codex") {
2204
2540
  throw new Error("list-sessions currently supports only --cli codex");
2205
2541
  }
2206
- printJson(await discoverCodexTmuxSessions({ cwd: flags.cwd }));
2542
+ if (flags.transport === "uds") {
2543
+ const { listCodexAppServerThreads } = await import(
2544
+ "../hub/team/uds-orchestrator.mjs"
2545
+ );
2546
+ printJson(
2547
+ await listCodexAppServerThreads({
2548
+ socketPath: resolveCodexDaemonSocket(
2549
+ flags["codex-socket"] ?? "default",
2550
+ ),
2551
+ cwd: flags.cwd,
2552
+ }),
2553
+ );
2554
+ return;
2555
+ }
2556
+ if (flags.transport && flags.transport !== "tmux")
2557
+ throw new Error("list-sessions --transport must be tmux or uds");
2558
+ printJson(
2559
+ await discoverCodexTmuxSessions({ cwd: flags.cwd, remote: flags.remote }),
2560
+ );
2207
2561
  }
2208
2562
 
2209
2563
  function startOptsForSession(flags, session, side) {
@@ -2234,6 +2588,12 @@ function askOptsForSession(flags, session, prompt) {
2234
2588
  timeoutMs: secondsFlag(flags, "timeout", DEFAULT_ANSWER_TIMEOUT_MS),
2235
2589
  settleMs: msFlag(flags, "settle", DEFAULT_SETTLE_MS),
2236
2590
  pollIntervalMs: msFlag(flags, "poll-interval", DEFAULT_POLL_INTERVAL_MS),
2591
+ ifBusy: flags["if-busy"] ?? "wait",
2592
+ busyTimeoutMs: secondsFlag(
2593
+ flags,
2594
+ "busy-timeout",
2595
+ secondsFlag(flags, "timeout", DEFAULT_ANSWER_TIMEOUT_MS),
2596
+ ),
2237
2597
  };
2238
2598
  }
2239
2599
 
@@ -2254,9 +2614,12 @@ async function readPromptsFile(path) {
2254
2614
 
2255
2615
  async function stopAfterLifecycle(stopSpecs, primaryError = null) {
2256
2616
  let stopError = null;
2257
- for (const { adapter, opts } of stopSpecs) {
2617
+ for (const spec of stopSpecs) {
2258
2618
  try {
2259
- await doStop(adapter, opts);
2619
+ if (!spec.stopped) {
2620
+ const result = await doStop(spec.adapter, spec.opts);
2621
+ spec.stopped = result.stopped === true;
2622
+ }
2260
2623
  } catch (error) {
2261
2624
  stopError ??= error;
2262
2625
  }
@@ -2676,7 +3039,11 @@ function peerSideTransport(flags, side, adapter) {
2676
3039
  `--transport-${side} must be one of: ${VALID_TRANSPORTS.join(", ")}`,
2677
3040
  );
2678
3041
  }
2679
- if (transport !== "tmux" && adapter.cli !== "claude") {
3042
+ if (
3043
+ transport !== "tmux" &&
3044
+ adapter.cli !== "claude" &&
3045
+ !(adapter.cli === "codex" && transport === "uds")
3046
+ ) {
2680
3047
  throw new Error(
2681
3048
  `--transport-${side} ${transport} is only supported when --cli-${side} is claude`,
2682
3049
  );
@@ -2684,11 +3051,34 @@ function peerSideTransport(flags, side, adapter) {
2684
3051
  return transport;
2685
3052
  }
2686
3053
 
3054
+ function peerSideBusyPolicy(flags, side, adapter, transport) {
3055
+ const allowed =
3056
+ adapter.cli === "codex" && transport === "uds"
3057
+ ? ["wait", "fail", "steer"]
3058
+ : ["wait", "fail", "interrupt"];
3059
+ for (const name of ["if-busy", `if-busy-${side}`]) {
3060
+ if (flags[name] !== undefined && !allowed.includes(flags[name]))
3061
+ throw new Error(
3062
+ `--${name} must be ${allowed.join("|")} for side ${side} (${adapter.cli} ${transport})`,
3063
+ );
3064
+ }
3065
+ return sideFlag(flags, side, "if-busy", "wait");
3066
+ }
3067
+
2687
3068
  function peerSideBaseOpts(flags, side, adapter, session) {
2688
3069
  const transport = peerSideTransport(flags, side, adapter);
3070
+ const ifBusy = peerSideBusyPolicy(flags, side, adapter, transport);
2689
3071
  const short = sideFlag(flags, side, "short");
2690
3072
  const sessionId = sideFlag(flags, side, "session-id");
2691
- if (transport !== "tmux" && !short && !sessionId) {
3073
+ const threadId = sideFlag(flags, side, "thread");
3074
+ if (transport === "uds" && adapter.cli === "codex" && !threadId)
3075
+ throw new Error(`--transport-${side} uds requires --thread-${side}`);
3076
+ if (
3077
+ transport !== "tmux" &&
3078
+ adapter.cli === "claude" &&
3079
+ !short &&
3080
+ !sessionId
3081
+ ) {
2692
3082
  throw new Error(
2693
3083
  `--transport-${side} ${transport} requires --short-${side} or --session-id-${side}`,
2694
3084
  );
@@ -2697,13 +3087,38 @@ function peerSideBaseOpts(flags, side, adapter, session) {
2697
3087
  session,
2698
3088
  short,
2699
3089
  sessionId,
3090
+ threadId,
3091
+ codexSocket:
3092
+ adapter.cli === "codex" && transport === "uds"
3093
+ ? sideFlag(flags, side, "codex-socket", "default")
3094
+ : null,
3095
+ socketPath:
3096
+ adapter.cli === "codex" && transport === "uds" && !flags.remote
3097
+ ? resolveCodexDaemonSocket(
3098
+ sideFlag(flags, side, "codex-socket", "default"),
3099
+ )
3100
+ : null,
2700
3101
  transport,
2701
3102
  bridgePath: sideFlag(flags, side, "bridge", resolveBridgePath(flags)),
2702
3103
  remote: flags.remote,
2703
3104
  cwd: flags.cwd,
2704
3105
  timeoutMs: secondsFlag(flags, "timeout", DEFAULT_ANSWER_TIMEOUT_MS),
3106
+ maxTurnMs:
3107
+ adapter.cli === "codex" && transport === "uds"
3108
+ ? secondsFlag(
3109
+ flags,
3110
+ "max-turn",
3111
+ Math.min(resolveHardCeilingMs(), 15 * 60_000),
3112
+ )
3113
+ : undefined,
2705
3114
  settleMs: msFlag(flags, "settle", DEFAULT_SETTLE_MS),
2706
3115
  pollIntervalMs: msFlag(flags, "poll-interval", DEFAULT_POLL_INTERVAL_MS),
3116
+ ifBusy,
3117
+ busyTimeoutMs: secondsFlag(
3118
+ flags,
3119
+ "busy-timeout",
3120
+ secondsFlag(flags, "timeout", DEFAULT_ANSWER_TIMEOUT_MS),
3121
+ ),
2707
3122
  readyTimeoutMs: secondsFlag(
2708
3123
  flags,
2709
3124
  "ready-timeout",
@@ -2720,6 +3135,37 @@ function shouldPrestartPeerSide(base) {
2720
3135
  return base.transport === "tmux";
2721
3136
  }
2722
3137
 
3138
+ async function verifyAttachedPeerSide(adapter, base, deps = {}) {
3139
+ if (base.transport !== "tmux") return;
3140
+ const tmux = deps.runTmux ?? runTmux;
3141
+ const { session, target } = splitTmuxTarget(base.session);
3142
+ try {
3143
+ await tmux(base.remote, ["has-session", "-t", session]);
3144
+ } catch {
3145
+ throw new Error(`attached ${adapter.cli} session not found: ${session}`);
3146
+ }
3147
+ if (adapter.cli === "codex") {
3148
+ const { stdout } = await tmux(base.remote, [
3149
+ "display-message",
3150
+ "-p",
3151
+ "-t",
3152
+ target,
3153
+ "#{pane_current_command}\t#{pane_start_command}\t#{pane_tty}\t#{pane_pid}",
3154
+ ]);
3155
+ const [currentCommand, startCommand, paneTty, panePid] = stdout
3156
+ .trimEnd()
3157
+ .split("\t");
3158
+ const result = await inspectCodexTmuxPane(
3159
+ { currentCommand, startCommand, paneTty, panePid, remote: base.remote },
3160
+ deps,
3161
+ );
3162
+ if (result.preflightWarning) return result;
3163
+ if (!result.isCodex) {
3164
+ throw new Error(`attached target is not a Codex tmux pane: ${target}`);
3165
+ }
3166
+ }
3167
+ }
3168
+
2723
3169
  function addStopSpecOnce(stopSpecs, adapter, opts) {
2724
3170
  if (!opts.session) return;
2725
3171
  const exists = stopSpecs.some(
@@ -2739,10 +3185,26 @@ function addStopSpecOnce(stopSpecs, adapter, opts) {
2739
3185
  async function peer(flags) {
2740
3186
  const adapterA = selectAdapterName(flags["cli-a"] ?? "codex", "cli-a");
2741
3187
  const adapterB = selectAdapterName(flags["cli-b"] ?? "claude", "cli-b");
3188
+ peerSideBusyPolicy(
3189
+ flags,
3190
+ "a",
3191
+ adapterA,
3192
+ peerSideTransport(flags, "a", adapterA),
3193
+ );
3194
+ peerSideBusyPolicy(
3195
+ flags,
3196
+ "b",
3197
+ adapterB,
3198
+ peerSideTransport(flags, "b", adapterB),
3199
+ );
2742
3200
  const sessionA = flags["session-a"] ?? "peerA";
2743
3201
  const sessionB = flags["session-b"] ?? "peerB";
2744
3202
  const baseA = peerSideBaseOpts(flags, "a", adapterA, sessionA);
2745
3203
  const baseB = peerSideBaseOpts(flags, "b", adapterB, sessionB);
3204
+ const attachedA =
3205
+ Object.hasOwn(flags, "attach-a") || baseA.transport === "uds";
3206
+ const attachedB =
3207
+ Object.hasOwn(flags, "attach-b") || baseB.transport === "uds";
2746
3208
  const rounds = integerFlag(flags, "rounds", 4);
2747
3209
  const mode = peerMode(flags);
2748
3210
  const hops = [];
@@ -2756,6 +3218,10 @@ async function peer(flags) {
2756
3218
  cliB: adapterB.cli,
2757
3219
  transportA: baseA.transport,
2758
3220
  transportB: baseB.transport,
3221
+ attachedA,
3222
+ attachedB,
3223
+ stoppedA: false,
3224
+ stoppedB: false,
2759
3225
  hops,
2760
3226
  transcript_path: artifactPaths.transcriptPath,
2761
3227
  status_path: artifactPaths.statusPath,
@@ -2771,11 +3237,27 @@ async function peer(flags) {
2771
3237
  let primaryError = null;
2772
3238
  let stopError = null;
2773
3239
  let closure = null;
3240
+ const stopPeerSessions = async () => {
3241
+ try {
3242
+ await stopAfterLifecycle(stopSpecs);
3243
+ } finally {
3244
+ output.stoppedA =
3245
+ !attachedA &&
3246
+ stopSpecs.some(
3247
+ (spec) => spec.opts.session === sessionA && spec.stopped,
3248
+ );
3249
+ output.stoppedB =
3250
+ !attachedB &&
3251
+ stopSpecs.some(
3252
+ (spec) => spec.opts.session === sessionB && spec.stopped,
3253
+ );
3254
+ }
3255
+ };
2774
3256
  const signalController = createPeerSignalController({
2775
3257
  output,
2776
3258
  persistTranscript: (value) => persistPeerTranscript(artifactPaths, value),
2777
3259
  persistStatus: (value) => persistPeerStatus(artifactPaths, value),
2778
- stopSessions: () => stopAfterLifecycle(stopSpecs),
3260
+ stopSessions: stopPeerSessions,
2779
3261
  });
2780
3262
  const onSigint = () => {
2781
3263
  void signalController.handle("SIGINT");
@@ -2787,11 +3269,25 @@ async function peer(flags) {
2787
3269
  process.on("SIGTERM", onSigterm);
2788
3270
 
2789
3271
  try {
2790
- if (shouldPrestartPeerSide(baseA)) {
3272
+ for (const [side, adapter, base, attached] of [
3273
+ ["a", adapterA, baseA, attachedA],
3274
+ ["b", adapterB, baseB, attachedB],
3275
+ ]) {
3276
+ if (!attached) continue;
3277
+ const result = await verifyAttachedPeerSide(adapter, base);
3278
+ if (result?.preflightWarning) {
3279
+ output.preflightWarning ??= [];
3280
+ output.preflightWarning.push({
3281
+ side,
3282
+ message: result.preflightWarning,
3283
+ });
3284
+ }
3285
+ }
3286
+ if (!attachedA && shouldPrestartPeerSide(baseA)) {
2791
3287
  await doStart(adapterA, startOptsForSession(flags, sessionA, "a"));
2792
3288
  addStopSpecOnce(stopSpecs, adapterA, baseA);
2793
3289
  }
2794
- if (shouldPrestartPeerSide(baseB)) {
3290
+ if (!attachedB && shouldPrestartPeerSide(baseB)) {
2795
3291
  await doStart(adapterB, startOptsForSession(flags, sessionB, "b"));
2796
3292
  addStopSpecOnce(stopSpecs, adapterB, baseB);
2797
3293
  }
@@ -2809,7 +3305,17 @@ async function peer(flags) {
2809
3305
  ? buildPeerClosurePrompt(previous)
2810
3306
  : peerPrompt(mode, hopIndex, previous);
2811
3307
  const result = await doAsk(adapter, askOptsFromPeerBase(base, sent));
2812
- if (result.tmuxStartedOnDemand) {
3308
+ if (
3309
+ (result.transportSelected ?? result.transport ?? base.transport) ===
3310
+ "uds"
3311
+ ) {
3312
+ result.response = String(result.response ?? "")
3313
+ .split(/\r?\n/)
3314
+ .filter((line) => line.trim() !== PEER_HOP_DONE_MARKER)
3315
+ .join("\n")
3316
+ .trim();
3317
+ }
3318
+ if (result.tmuxStartedOnDemand && !(isA ? attachedA : attachedB)) {
2813
3319
  addStopSpecOnce(stopSpecs, adapter, base);
2814
3320
  }
2815
3321
  if (
@@ -2864,7 +3370,7 @@ async function peer(flags) {
2864
3370
  }
2865
3371
 
2866
3372
  try {
2867
- await stopAfterLifecycle(stopSpecs);
3373
+ await stopPeerSessions();
2868
3374
  } catch (error) {
2869
3375
  stopError = error;
2870
3376
  }
@@ -2887,8 +3393,6 @@ async function peer(flags) {
2887
3393
  output.exit_reason = exitReason;
2888
3394
  output.error = terminalError.message;
2889
3395
  }
2890
- output.stoppedA = stopError === null;
2891
- output.stoppedB = stopError === null;
2892
3396
 
2893
3397
  try {
2894
3398
  persistPeerTranscript(artifactPaths, output);
@@ -2914,7 +3418,11 @@ async function orchestrate(flags) {
2914
3418
  throw new Error(`--mode must be one of: ${ORCHESTRATION_MODES.join(", ")}`);
2915
3419
  }
2916
3420
  const task = requireFlag(flags, "task");
2917
- const codexTransport = flags["codex-transport"] ?? "exec";
3421
+ if (flags["codex-socket"] && flags["codex-transport"] === "exec")
3422
+ throw new Error("--codex-socket conflicts with --codex-transport exec");
3423
+ const codexTransport =
3424
+ flags["codex-transport"] ??
3425
+ (flags["codex-socket"] ? "app-server-uds" : "exec");
2918
3426
  if (!CODEX_ORCH_TRANSPORTS.includes(codexTransport)) {
2919
3427
  throw new Error(
2920
3428
  `--codex-transport must be one of: ${CODEX_ORCH_TRANSPORTS.join(", ")}`,
@@ -2922,6 +3430,9 @@ async function orchestrate(flags) {
2922
3430
  }
2923
3431
  const cwd = flags.cwd ?? process.cwd();
2924
3432
  const timeoutMs = secondsFlag(flags, "timeout", 120_000);
3433
+ const socketPath = flags["codex-socket"]
3434
+ ? resolveCodexDaemonSocket(flags["codex-socket"])
3435
+ : undefined;
2925
3436
 
2926
3437
  // Lazy import keeps the orchestration engine (and its hub/team deps) off the
2927
3438
  // hot path for every other thin-CLI verb; only `orchestrate` pays the cost.
@@ -2939,7 +3450,11 @@ async function orchestrate(flags) {
2939
3450
  const claude = createClaudeUdsEndpoint({ cwd, timeoutMs });
2940
3451
  const codex =
2941
3452
  codexTransport === "app-server-uds"
2942
- ? createCodexAppServerUdsEndpoint({ cwd, timeoutMs })
3453
+ ? createCodexAppServerUdsEndpoint({
3454
+ cwd,
3455
+ timeoutMs,
3456
+ ...(socketPath ? { socketPath, spawnServer: false } : {}),
3457
+ })
2943
3458
  : createCodexExecEndpoint({ workdir: cwd, timeout: timeoutMs });
2944
3459
 
2945
3460
  const result = await runUdsOrchestration({ mode, task, claude, codex });
@@ -3029,6 +3544,7 @@ export {
3029
3544
  buildLaunchKeys,
3030
3545
  buildPeerClosurePrompt,
3031
3546
  buildRemoteLiveCommand,
3547
+ buildTmuxCommand,
3032
3548
  callRemoteLive,
3033
3549
  classifyPeerExitReason,
3034
3550
  createPeerSignalController,
@@ -3040,10 +3556,17 @@ export {
3040
3556
  extractClaudeCompletedTaskListResponse,
3041
3557
  extractResponseSinceMarker,
3042
3558
  hasClaudeCompletedTaskListResponse,
3559
+ inspectCodexTmuxPane,
3560
+ isCodexLoadingCapture,
3043
3561
  parseCodexTmuxSessions,
3044
3562
  parsePeerClosure,
3045
3563
  parseRemoteLiveJson,
3564
+ peerSideBaseOpts,
3046
3565
  resolveAskTransport,
3566
+ resolveCodexDaemonSocket,
3567
+ splitTmuxTarget,
3568
+ tmuxBufferName,
3569
+ verifyAttachedPeerSide,
3047
3570
  };
3048
3571
 
3049
3572
  function isMainModule() {