triflux 10.44.0 → 10.46.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 (203) hide show
  1. package/README.md +1 -1
  2. package/adapters/codex/skills/tfx-harness/SKILL.md +45 -9
  3. package/bin/tfx-live.mjs +1298 -100
  4. package/bin/triflux.mjs +9 -6
  5. package/config/routing-policy.json +1 -1
  6. package/hooks/codex-session-hook.mjs +67 -1
  7. package/hooks/cto-north-star-brief.mjs +3 -16
  8. package/hooks/error-context.mjs +3 -6
  9. package/hooks/hook-manager.mjs +1 -1
  10. package/hooks/hook-registry.json +0 -11
  11. package/hooks/keyword-rules.json +8 -4
  12. package/hooks/permission-safe-allow.mjs +36 -2
  13. package/hooks/session-start-fast.mjs +50 -1
  14. package/hooks/session-start-lake.mjs +3 -16
  15. package/hub/bridge.mjs +111 -5
  16. package/hub/dynamic-routing-engine.mjs +1 -1
  17. package/hub/gemini-adapter.mjs +29 -11
  18. package/hub/lib/memory-store.mjs +66 -30
  19. package/hub/lib/tfx-route-args.mjs +41 -4
  20. package/hub/pipe.mjs +10 -15
  21. package/hub/reflexion.mjs +0 -57
  22. package/hub/router.mjs +202 -24
  23. package/hub/schema.sql +1 -0
  24. package/hub/server.mjs +172 -93
  25. package/hub/store.mjs +106 -33
  26. package/hub/team/backend.mjs +4 -2
  27. package/hub/team/claude-daemon-control.mjs +2 -0
  28. package/hub/team/cli/commands/start/start-headless.mjs +2 -0
  29. package/hub/team/cli/commands/start/start-mux.mjs +24 -3
  30. package/hub/team/cli/services/runtime-mode.mjs +23 -9
  31. package/hub/team/codex-review.mjs +2 -3
  32. package/hub/team/conductor.mjs +80 -24
  33. package/hub/team/cto-auto-collect.mjs +2 -6
  34. package/hub/team/cto-pull.mjs +4 -0
  35. package/hub/team/dashboard-open.mjs +1 -0
  36. package/hub/team/execution-mode.mjs +38 -2
  37. package/hub/team/headless.mjs +493 -82
  38. package/hub/team/interactive-tui-transport.mjs +15 -1
  39. package/hub/team/launcher-template.mjs +19 -2
  40. package/hub/team/orchestrator.mjs +36 -9
  41. package/hub/team/pane.mjs +176 -14
  42. package/hub/team/psmux.mjs +79 -70
  43. package/hub/team/retry-state-machine.mjs +47 -16
  44. package/hub/team/session.mjs +57 -0
  45. package/hub/team/terminal-opener.mjs +68 -13
  46. package/hub/team/tui-viewer.mjs +65 -14
  47. package/hub/team/uds-orchestrator.mjs +384 -83
  48. package/hub/tools.mjs +6 -0
  49. package/hub/workers/codex-mcp.mjs +35 -6
  50. package/hud/cli-policy.mjs +69 -0
  51. package/hud/constants.mjs +20 -17
  52. package/hud/context-monitor.mjs +31 -21
  53. package/hud/hud-qos-status.mjs +81 -91
  54. package/hud/providers/claude.mjs +150 -84
  55. package/hud/providers/codex.mjs +10 -1
  56. package/hud/renderers.mjs +21 -59
  57. package/hud/utils.mjs +1 -15
  58. package/package.json +1 -1
  59. package/scripts/__tests__/keyword-detector.test.mjs +89 -1
  60. package/scripts/__tests__/release-governance.test.mjs +329 -12
  61. package/scripts/__tests__/release-publish-ancestor.test.mjs +67 -0
  62. package/scripts/__tests__/release-verify-wait.test.mjs +169 -0
  63. package/scripts/__tests__/resolve-auto-release.test.mjs +216 -0
  64. package/scripts/__tests__/skill-surface.test.mjs +6 -4
  65. package/scripts/__tests__/tfx-route-bash-node-parity.test.mjs +7 -5
  66. package/scripts/__tests__/tfx-route-node-entry.test.mjs +27 -21
  67. package/scripts/claude-login-detect.mjs +107 -25
  68. package/scripts/ensure-agy-hooks.mjs +31 -1
  69. package/scripts/keyword-detector.mjs +11 -1
  70. package/scripts/lib/agent-route-policy.mjs +47 -39
  71. package/scripts/lib/cli-agy.mjs +24 -4
  72. package/scripts/lib/codex-profile-config.mjs +45 -13
  73. package/scripts/lib/codex-recovery.sh +19 -0
  74. package/scripts/lib/gemini-profiles.mjs +113 -29
  75. package/scripts/lib/keyword-rules.mjs +12 -0
  76. package/scripts/lib/logger.mjs +60 -43
  77. package/scripts/lib/machine-profile.mjs +293 -0
  78. package/scripts/lib/mcp-health.mjs +44 -18
  79. package/scripts/notion-read.mjs +2 -1
  80. package/scripts/release/check-packages-mirror.mjs +53 -1
  81. package/scripts/release/lib.mjs +1 -2
  82. package/scripts/release/prepare.mjs +24 -9
  83. package/scripts/release/publish.mjs +16 -1
  84. package/scripts/release/resolve-auto-release.mjs +136 -0
  85. package/scripts/release/verify.mjs +74 -25
  86. package/scripts/remote-spawn.mjs +830 -32
  87. package/scripts/session-stale-cleanup.mjs +0 -1
  88. package/scripts/setup.mjs +487 -150
  89. package/scripts/sync-hub-mcp-settings.mjs +11 -3
  90. package/scripts/test-lock.mjs +15 -1
  91. package/scripts/tfx-gate-activate.mjs +4 -4
  92. package/scripts/tfx-route-post.mjs +90 -13
  93. package/scripts/tfx-route.sh +520 -141
  94. package/skills/merge-worktree/SKILL.md +3 -3
  95. package/skills/star-prompt/skill.json +1 -1
  96. package/skills/tfx-analysis/SKILL.md +8 -9
  97. package/skills/tfx-analysis/skill.json +1 -10
  98. package/skills/tfx-auto/SKILL.md +208 -50
  99. package/skills/tfx-auto/skill.json +2 -23
  100. package/skills/tfx-doctor/skill.json +0 -3
  101. package/skills/tfx-find/skill.json +0 -7
  102. package/skills/tfx-forge/SKILL.md +2 -2
  103. package/skills/tfx-forge/skill.json +0 -7
  104. package/skills/tfx-goal-clarify/skill.json +5 -0
  105. package/skills/tfx-harness/SKILL.md +26 -3
  106. package/skills/tfx-harness/skill.json +5 -0
  107. package/skills/tfx-hooks/SKILL.md +0 -2
  108. package/skills/tfx-hooks/skill.json +0 -3
  109. package/skills/tfx-hub/SKILL.md +1 -6
  110. package/skills/tfx-hub/skill.json +0 -3
  111. package/skills/tfx-index/skill.json +0 -6
  112. package/skills/tfx-interview/SKILL.md +0 -2
  113. package/skills/tfx-live/SKILL.md +131 -5
  114. package/skills/tfx-live/skill.json +0 -5
  115. package/skills/tfx-plan/SKILL.md +7 -8
  116. package/skills/tfx-plan/skill.json +1 -12
  117. package/skills/tfx-profile/SKILL.md +15 -13
  118. package/skills/tfx-profile/skill.json +0 -3
  119. package/skills/tfx-prune/SKILL.md +4 -6
  120. package/skills/tfx-qa/SKILL.md +7 -7
  121. package/skills/tfx-qa/skill.json +1 -11
  122. package/skills/tfx-ralph/SKILL.md +8 -11
  123. package/skills/tfx-remote/SKILL.md +2 -2
  124. package/skills/tfx-remote/skill.json +0 -3
  125. package/skills/tfx-research/SKILL.md +2 -2
  126. package/skills/tfx-research/skill.json +1 -16
  127. package/skills/tfx-review/SKILL.md +11 -11
  128. package/skills/tfx-review/skill.json +1 -11
  129. package/skills/tfx-setup/SKILL.md +5 -6
  130. package/skills/tfx-setup/skill.json +0 -3
  131. package/skills/tfx-ship/SKILL.md +43 -36
  132. package/skills/tfx-ship/skill.json +1 -9
  133. package/skills/tfx-wt/skill.json +0 -7
  134. package/tui/codex-profile.mjs +3 -3
  135. package/tui/gemini-profile.mjs +11 -24
  136. package/tui/monitor.mjs +1 -0
  137. package/tui/setup.mjs +7 -6
  138. package/scripts/headless-guard-fast.sh +0 -29
  139. package/scripts/headless-guard.mjs +0 -523
  140. package/skills/tfx-autopilot/SKILL.md +0 -51
  141. package/skills/tfx-autopilot/skill.json +0 -12
  142. package/skills/tfx-codex-swarm-workspace/iteration-1/benchmark.json +0 -75
  143. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/eval_metadata.json +0 -42
  144. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/grading.json +0 -39
  145. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/analysis.md +0 -87
  146. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/classification.md +0 -35
  147. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/commands.sh +0 -275
  148. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/routing.md +0 -56
  149. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/timing.json +0 -5
  150. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/grading.json +0 -39
  151. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/analysis.md +0 -92
  152. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/classification.md +0 -71
  153. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/commands.sh +0 -264
  154. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/routing.md +0 -113
  155. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/timing.json +0 -5
  156. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/eval_metadata.json +0 -32
  157. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/grading.json +0 -29
  158. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/analysis.md +0 -96
  159. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/classification.md +0 -38
  160. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/commands.sh +0 -151
  161. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/routing.md +0 -51
  162. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/timing.json +0 -5
  163. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/grading.json +0 -29
  164. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/analysis.md +0 -127
  165. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/classification.md +0 -57
  166. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/commands.sh +0 -129
  167. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/routing.md +0 -84
  168. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/timing.json +0 -5
  169. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/eval_metadata.json +0 -27
  170. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/grading.json +0 -24
  171. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/analysis.md +0 -98
  172. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/classification.md +0 -65
  173. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/commands.sh +0 -123
  174. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/routing.md +0 -66
  175. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/timing.json +0 -5
  176. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/grading.json +0 -20
  177. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/analysis.md +0 -88
  178. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/classification.md +0 -40
  179. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/commands.sh +0 -130
  180. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/routing.md +0 -61
  181. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/timing.json +0 -5
  182. package/skills/tfx-consensus/SKILL.md +0 -56
  183. package/skills/tfx-consensus/skill.json +0 -8
  184. package/skills/tfx-debate/SKILL.md +0 -60
  185. package/skills/tfx-debate/skill.json +0 -14
  186. package/skills/tfx-fullcycle/SKILL.md +0 -47
  187. package/skills/tfx-fullcycle/skill.json +0 -13
  188. package/skills/tfx-multi/SKILL.md +0 -45
  189. package/skills/tfx-multi/references/agent-wrapper-rules.md +0 -94
  190. package/skills/tfx-multi/references/thorough-pipeline.md +0 -105
  191. package/skills/tfx-multi/skill.json +0 -10
  192. package/skills/tfx-panel/SKILL.md +0 -60
  193. package/skills/tfx-panel/skill.json +0 -14
  194. package/skills/tfx-persist/SKILL.md +0 -63
  195. package/skills/tfx-persist/skill.json +0 -14
  196. package/skills/tfx-psmux-rules/SKILL.md +0 -41
  197. package/skills/tfx-psmux-rules/skill.json +0 -10
  198. package/skills/tfx-remote-setup/SKILL.md +0 -42
  199. package/skills/tfx-remote-setup/skill.json +0 -10
  200. package/skills/tfx-remote-spawn/SKILL.md +0 -52
  201. package/skills/tfx-remote-spawn/skill.json +0 -10
  202. package/skills/tfx-swarm/SKILL.md +0 -49
  203. package/skills/tfx-swarm/skill.json +0 -13
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 { realpathSync } 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,
@@ -23,6 +24,9 @@ const DEFAULT_POLL_INTERVAL_MS = 1500;
23
24
  const DEFAULT_READY_TIMEOUT_MS = 30_000;
24
25
  const DEFAULT_ANSWER_TIMEOUT_MS = 60_000;
25
26
  const FALLBACK_QUIET_POLLS = 3;
27
+ const PROMPT_SUBMIT_MAX_ATTEMPTS = 3;
28
+ const PROMPT_SUBMIT_RETRY_DELAY_MS = 100;
29
+ const PROMPT_COMPOSER_NEEDLE_LENGTH = 30;
26
30
  // Capture the complete pane history so a long TUI response does not lose its
27
31
  // leading lines. runTmux's MAX_BUFFER remains the memory/output ceiling.
28
32
  const CAPTURE_START = "-";
@@ -35,7 +39,8 @@ const BRIDGE_TIMEOUT_BUFFER_MS = 15_000;
35
39
  // spill the JSON to a temp file and pass --payload-file instead.
36
40
  const PAYLOAD_FILE_THRESHOLD = 96 * 1024;
37
41
  const VALID_TRANSPORTS = ["tmux", "uds", "auto"];
38
- const BOOLEAN_FLAGS = new Set(["json"]);
42
+ const BOOLEAN_FLAGS = new Set(["json", "attach-a", "attach-b"]);
43
+ const PEER_HOP_DONE_MARKER = "<<<TFX_PEER_HOP_DONE>>>";
39
44
  // uds-fallback diagnostics land here, written async so a failed daemon attach
40
45
  // never blocks the tmux fallback path (see writeUdsBugReport / doAskAuto).
41
46
  const BUG_REPORT_DIR =
@@ -45,17 +50,20 @@ const BUG_REPORT_DIR =
45
50
  function usage() {
46
51
  return [
47
52
  "Usage:",
48
- " tfx-live start --session NAME [--cli codex|claude] [--cwd DIR] [--remote HOST] [--resume ID] [--resume-last 1] [--ready-timeout 30] [--poll-interval 1500]",
49
- " tfx-live ask --session NAME --prompt TEXT [--cli codex|claude] [--timeout 60] [--remote HOST] [--settle 1500] [--poll-interval 1500]",
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]",
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]",
50
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]",
51
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.",
52
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]",
53
59
  " tfx-live stop --session NAME [--cli codex|claude] [--remote HOST]",
54
60
  " tfx-live probe [--short SHORT] [--session-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 10]",
61
+ " tfx-live list-sessions --cli codex [--transport tmux|uds] [--codex-socket PATH|default] [--cwd DIR] [--remote HOST (tmux)]",
55
62
  " tfx-live converse --session NAME --prompts-file PATH [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500]",
56
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]",
57
- " tfx-live peer [--cli-a codex] [--cli-b claude] [--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]",
58
- " 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.",
59
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.",
60
68
  " tfx-live cto-hygiene-notify --root DIR --state-file PATH [--json]",
61
69
  " One-shot CTO hygiene dry-run notification: sends only when actionable hygiene state changes; no polling or apply/steward lock.",
@@ -197,7 +205,7 @@ function buildRemoteLiveArgv(verb, opts) {
197
205
  "tfx-live",
198
206
  verb,
199
207
  "--cli",
200
- "claude",
208
+ opts.cli ?? "claude",
201
209
  "--transport",
202
210
  opts.transport ?? "uds",
203
211
  ];
@@ -205,6 +213,13 @@ function buildRemoteLiveArgv(verb, opts) {
205
213
  if (opts.sessionId) args.push("--session-id", opts.sessionId);
206
214
  if (opts.session) args.push("--session", opts.session);
207
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));
208
223
  if (verb === "ask") args.push("--prompt", opts.prompt ?? "");
209
224
  args.push("--timeout", timeoutSeconds(opts.timeoutMs));
210
225
  if (verb === "ask" && opts.settleMs) {
@@ -311,8 +326,21 @@ async function callRemoteLive(verb, opts, deps = {}) {
311
326
  const execRemote = deps.sshExec ?? execFileAsync;
312
327
  const env = await probeRemoteEnv(host);
313
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;
314
337
  const execOptions = {
315
- 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,
316
344
  maxBuffer: MAX_BUFFER,
317
345
  };
318
346
 
@@ -353,6 +381,292 @@ async function runTmux(remote, tmuxArgs, options = {}) {
353
381
  }
354
382
  }
355
383
 
384
+ function normalizeCwd(value) {
385
+ if (!value) return null;
386
+ const resolved = pathResolve(value);
387
+ try {
388
+ return realpathSync(resolved);
389
+ } catch {
390
+ return resolved;
391
+ }
392
+ }
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
+
425
+ function isCodexTmuxPane(currentCommand, startCommand) {
426
+ const current = String(currentCommand ?? "").trim();
427
+ const currentBase = current.split("/").at(-1);
428
+ if (currentBase === "codex") {
429
+ return true;
430
+ }
431
+
432
+ const started = String(startCommand ?? "").trim();
433
+ const directStart = started
434
+ .replace(/^exec\s+/, "")
435
+ .replace(/^['"]|['"]$/g, "");
436
+ if (/^(?:\S*\/)?codex(?:\s|$)/.test(directStart)) {
437
+ return true;
438
+ }
439
+
440
+ return (
441
+ /\bomx_codex_pid\b/.test(started) &&
442
+ /(?:^|[\s'""])(?:[^'" \t]+\/)?codex(?:[\s'"]|$)/.test(started)
443
+ );
444
+ }
445
+
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) {
542
+ const cwdFilter = normalizeCwd(cwd);
543
+ const sessions = new Map();
544
+
545
+ for (const line of String(stdout).split(/\r?\n/)) {
546
+ if (!line) continue;
547
+ const [
548
+ session,
549
+ createdRaw,
550
+ attachedRaw,
551
+ windowIndex,
552
+ paneIndex,
553
+ paneCwd,
554
+ currentCommand,
555
+ startCommand,
556
+ ] = line.split("\t");
557
+ if (
558
+ !session ||
559
+ !(codexTargets
560
+ ? codexTargets.has(`${session}:${windowIndex}.${paneIndex}`)
561
+ : isCodexTmuxPane(currentCommand, startCommand))
562
+ ) {
563
+ continue;
564
+ }
565
+
566
+ const normalizedPaneCwd = normalizeCwd(paneCwd);
567
+ if (cwdFilter && normalizedPaneCwd !== cwdFilter) {
568
+ continue;
569
+ }
570
+
571
+ const startedAtEpoch = Number.parseInt(createdRaw, 10);
572
+ const startedAt = Number.isSafeInteger(startedAtEpoch)
573
+ ? new Date(startedAtEpoch * 1000).toISOString()
574
+ : null;
575
+ const existing = sessions.get(session);
576
+ const pane = {
577
+ target: `${session}:${windowIndex}.${paneIndex}`,
578
+ cwd: normalizedPaneCwd,
579
+ command: currentCommand,
580
+ };
581
+ if (existing) {
582
+ if (!existing.cwd && normalizedPaneCwd) {
583
+ existing.cwd = normalizedPaneCwd;
584
+ }
585
+ existing.panes.push(pane);
586
+ continue;
587
+ }
588
+
589
+ sessions.set(session, {
590
+ session,
591
+ startedAt,
592
+ startedAtEpoch: Number.isSafeInteger(startedAtEpoch)
593
+ ? startedAtEpoch
594
+ : null,
595
+ cwd: normalizedPaneCwd,
596
+ attached: Number.parseInt(attachedRaw, 10) > 0,
597
+ target: pane.target,
598
+ panes: [pane],
599
+ });
600
+ }
601
+
602
+ return [...sessions.values()].sort((left, right) =>
603
+ left.session.localeCompare(right.session),
604
+ );
605
+ }
606
+
607
+ async function discoverCodexTmuxSessions(
608
+ { cwd = null, remote = null } = {},
609
+ deps = {},
610
+ ) {
611
+ const format = [
612
+ "#{session_name}",
613
+ "#{session_created}",
614
+ "#{session_attached}",
615
+ "#{window_index}",
616
+ "#{pane_index}",
617
+ "#{pane_current_path}",
618
+ "#{pane_current_command}",
619
+ "#{pane_start_command}",
620
+ "#{pane_tty}",
621
+ "#{pane_pid}",
622
+ ].join("\t");
623
+
624
+ try {
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
+ }
652
+ return {
653
+ ok: true,
654
+ cli: "codex",
655
+ cwd: normalizeCwd(cwd),
656
+ sessions: parseCodexTmuxSessions(stdout, cwd, codexTargets),
657
+ };
658
+ } catch (error) {
659
+ return {
660
+ ok: false,
661
+ cli: "codex",
662
+ cwd: normalizeCwd(cwd),
663
+ reason: "tmux-unavailable",
664
+ error: error.message,
665
+ sessions: [],
666
+ };
667
+ }
668
+ }
669
+
356
670
  function sleep(ms) {
357
671
  return new Promise((resolve) => setTimeout(resolve, ms));
358
672
  }
@@ -383,6 +697,30 @@ function isBusyByEsc(text) {
383
697
  return String(text).includes("esc to interrupt");
384
698
  }
385
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
+
386
724
  function hasCodexComposerPrompt(text) {
387
725
  return /^\s*[›❯▶▸>]\s*/m.test(text);
388
726
  }
@@ -418,7 +756,11 @@ function isClaudeResponseLine(line) {
418
756
  }
419
757
 
420
758
  function isCodexReadyCapture(text) {
421
- return !isBusyByEsc(text) && hasCodexComposerPrompt(text);
759
+ return (
760
+ !isBusyByEsc(text) &&
761
+ !isCodexLoadingCapture(text) &&
762
+ hasCodexComposerPrompt(text)
763
+ );
422
764
  }
423
765
 
424
766
  function hasClaudeLoginGuard(text) {
@@ -461,23 +803,9 @@ function isWarningLine(line) {
461
803
  return /^\s*(?:\u26A0|\(warning\))/i.test(line);
462
804
  }
463
805
 
464
- function hasAssistantResponseLine(adapter, text) {
465
- return String(text)
466
- .split("\n")
467
- .some((line) => adapter.isResponseLine(line));
468
- }
469
-
470
806
  function promptLineIndex(lines, prompt) {
471
- if (!prompt) {
472
- return -1;
473
- }
474
-
475
807
  for (let index = lines.length - 1; index >= 0; index -= 1) {
476
- const line = lines[index];
477
- if (
478
- (hasCodexComposerPrompt(line) || hasClaudeComposerPrompt(line)) &&
479
- line.includes(prompt)
480
- ) {
808
+ if (composerLineMatchesPrompt(lines[index], prompt)) {
481
809
  return index;
482
810
  }
483
811
  }
@@ -489,6 +817,31 @@ function isComposerPromptLine(line) {
489
817
  return hasCodexComposerPrompt(line) || hasClaudeComposerPrompt(line);
490
818
  }
491
819
 
820
+ function promptComposerNeedle(prompt) {
821
+ const firstContentLine = String(prompt)
822
+ .split(/\r?\n/)
823
+ .map((line) => line.trim())
824
+ .find(Boolean);
825
+ return (firstContentLine ?? "").slice(0, PROMPT_COMPOSER_NEEDLE_LENGTH);
826
+ }
827
+
828
+ function composerLineMatchesPrompt(line, prompt) {
829
+ const needle = promptComposerNeedle(prompt);
830
+ return (
831
+ Boolean(needle) &&
832
+ isComposerPromptLine(line) &&
833
+ String(line).includes(needle)
834
+ );
835
+ }
836
+
837
+ function composerShowsPrompt(text, prompt) {
838
+ const activeComposerLine = String(text)
839
+ .split("\n")
840
+ .filter(isComposerPromptLine)
841
+ .at(-1);
842
+ return composerLineMatchesPrompt(activeComposerLine, prompt);
843
+ }
844
+
492
845
  function nextComposerPromptIndex(lines, startIndex) {
493
846
  for (
494
847
  let index = Math.max(0, startIndex + 1);
@@ -515,16 +868,6 @@ function responseLineIndexForPrompt(adapter, lines, promptIndex) {
515
868
  return -1;
516
869
  }
517
870
 
518
- function hasAssistantResponseAfterPrompt(adapter, text, prompt) {
519
- const lines = String(text).split("\n");
520
- const promptIndex = promptLineIndex(lines, prompt);
521
- if (promptIndex === -1) {
522
- return hasAssistantResponseLine(adapter, text);
523
- }
524
-
525
- return responseLineIndexForPrompt(adapter, lines, promptIndex) !== -1;
526
- }
527
-
528
871
  function extractAssistantResponse(adapter, text, prompt = null) {
529
872
  const lines = String(text)
530
873
  .split("\n")
@@ -560,7 +903,58 @@ function extractAssistantResponse(adapter, text, prompt = null) {
560
903
  }
561
904
  }
562
905
 
563
- return response.join("\n").trim();
906
+ return trimResponseLines(adapter, response);
907
+ }
908
+
909
+ function extractResponseSinceMarker({ beforeRaw, raw, doneMarker, adapter }) {
910
+ if (!doneMarker) {
911
+ return "";
912
+ }
913
+
914
+ const beforeLines = String(beforeRaw)
915
+ .split("\n")
916
+ .map((line) => line.replace(/\s+$/g, ""));
917
+ const rawLines = String(raw)
918
+ .split("\n")
919
+ .map((line) => line.replace(/\s+$/g, ""));
920
+ let sharedPrefixLength = 0;
921
+ while (
922
+ sharedPrefixLength < beforeLines.length &&
923
+ sharedPrefixLength < rawLines.length &&
924
+ beforeLines[sharedPrefixLength] === rawLines[sharedPrefixLength]
925
+ ) {
926
+ sharedPrefixLength += 1;
927
+ }
928
+
929
+ const newLines =
930
+ sharedPrefixLength > 0 ? rawLines.slice(sharedPrefixLength) : rawLines;
931
+ const markerIndex = newLines.findIndex((line) => line.trim() === doneMarker);
932
+ if (markerIndex === -1) {
933
+ return "";
934
+ }
935
+
936
+ const candidateLines = newLines.slice(0, markerIndex);
937
+ const responseStart = candidateLines.findIndex((line) =>
938
+ adapter.isResponseLine(line),
939
+ );
940
+ const responseLines =
941
+ responseStart === -1 ? candidateLines : candidateLines.slice(responseStart);
942
+
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
+ );
564
958
  }
565
959
 
566
960
  function normalizeClaudeTaskText(text) {
@@ -685,19 +1079,35 @@ function hasClaudeCompletedTaskListResponse(text, options = {}) {
685
1079
  return extractClaudeCompletedTaskListResponse(text, options).length > 0;
686
1080
  }
687
1081
 
688
- function hasTmuxAssistantResponseAfterPrompt(
1082
+ function hasTmuxAssistantResponseAfterCurrentPrompt(
689
1083
  adapter,
690
1084
  text,
691
1085
  prompt,
692
1086
  beforeText,
693
1087
  ) {
1088
+ if (adapter.cli === "claude") {
1089
+ return hasClaudeCompletedTaskListResponse(text, {
1090
+ beforeText,
1091
+ prompt,
1092
+ });
1093
+ }
1094
+
1095
+ const lines = String(text).split("\n");
1096
+ const promptIndex = promptLineIndex(lines, prompt);
1097
+ if (promptIndex === -1) {
1098
+ return false;
1099
+ }
1100
+
1101
+ const beforePromptCount = String(beforeText)
1102
+ .split("\n")
1103
+ .filter((line) => composerLineMatchesPrompt(line, prompt)).length;
1104
+ const currentPromptCount = lines.filter((line) =>
1105
+ composerLineMatchesPrompt(line, prompt),
1106
+ ).length;
1107
+
694
1108
  return (
695
- hasAssistantResponseAfterPrompt(adapter, text, prompt) ||
696
- (adapter.cli === "claude" &&
697
- hasClaudeCompletedTaskListResponse(text, {
698
- beforeText,
699
- prompt,
700
- }))
1109
+ currentPromptCount > beforePromptCount &&
1110
+ responseLineIndexForPrompt(adapter, lines, promptIndex) !== -1
701
1111
  );
702
1112
  }
703
1113
 
@@ -912,9 +1322,21 @@ const ADAPTERS = {
912
1322
  codex: {
913
1323
  cli: "codex",
914
1324
  launchArgv: ["codex"],
915
- launchKeys: ["codex", "Enter"],
916
- resumeById: (id) => shellQuote(["codex", "resume", id]),
917
- resumeLast: () => shellQuote(["codex", "resume", "--last"]),
1325
+ launchKeys: ["codex --dangerously-bypass-approvals-and-sandbox", "Enter"],
1326
+ resumeById: (id) =>
1327
+ shellQuote([
1328
+ "codex",
1329
+ "resume",
1330
+ id,
1331
+ "--dangerously-bypass-approvals-and-sandbox",
1332
+ ]),
1333
+ resumeLast: () =>
1334
+ shellQuote([
1335
+ "codex",
1336
+ "resume",
1337
+ "--last",
1338
+ "--dangerously-bypass-approvals-and-sandbox",
1339
+ ]),
918
1340
  composerGlyph: "›",
919
1341
  isReady: isCodexReadyCapture,
920
1342
  contextPct: parseCodexContextPct,
@@ -996,6 +1418,39 @@ const ADAPTERS = {
996
1418
  },
997
1419
  };
998
1420
 
1421
+ function buildLaunchKeys(adapter, { model, effort } = {}) {
1422
+ const hasModel = model !== undefined;
1423
+ const hasEffort = effort !== undefined;
1424
+ if (!hasModel && !hasEffort) {
1425
+ return adapter.launchKeys;
1426
+ }
1427
+
1428
+ const overrideArgs = [];
1429
+ if (adapter.cli === "codex") {
1430
+ if (hasModel) {
1431
+ overrideArgs.push("-c", `model=${JSON.stringify(String(model))}`);
1432
+ }
1433
+ if (hasEffort) {
1434
+ overrideArgs.push(
1435
+ "-c",
1436
+ `model_reasoning_effort=${JSON.stringify(String(effort))}`,
1437
+ );
1438
+ }
1439
+ } else if (adapter.cli === "claude") {
1440
+ if (hasModel) {
1441
+ overrideArgs.push("--model", model);
1442
+ }
1443
+ if (hasEffort) {
1444
+ overrideArgs.push("--effort", effort);
1445
+ }
1446
+ } else {
1447
+ throw new Error(`Unsupported adapter: ${adapter.cli}`);
1448
+ }
1449
+
1450
+ const [command, ...remainingKeys] = adapter.launchKeys;
1451
+ return [`${command} ${shellQuote(overrideArgs)}`, ...remainingKeys];
1452
+ }
1453
+
999
1454
  async function dismissStartupScreens(adapter, remote, session) {
1000
1455
  let raw = "";
1001
1456
  let dismissed = false;
@@ -1184,7 +1639,11 @@ async function probeDaemon(bridgePath, ref, timeoutMs) {
1184
1639
  async function hasTmuxSession(adapter, opts) {
1185
1640
  if (!opts.session) return false;
1186
1641
  try {
1187
- 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
+ ]);
1188
1647
  return true;
1189
1648
  } catch {
1190
1649
  return false;
@@ -1258,14 +1717,18 @@ async function doStart(adapter, opts) {
1258
1717
  cwd,
1259
1718
  resume,
1260
1719
  resumeLast,
1720
+ model,
1721
+ effort,
1261
1722
  readyTimeoutMs,
1262
1723
  pollIntervalMs,
1263
1724
  } = opts;
1725
+ if (session.includes(":"))
1726
+ throw new Error("start --session accepts only a session name without ':'");
1264
1727
  const launchKeys = resume
1265
1728
  ? [adapter.resumeById(resume), "Enter"]
1266
1729
  : resumeLast
1267
1730
  ? [adapter.resumeLast(), "Enter"]
1268
- : adapter.launchKeys;
1731
+ : buildLaunchKeys(adapter, { model, effort });
1269
1732
  const resumed = Boolean(resume || resumeLast);
1270
1733
  const resumeTarget = resume ?? (resumeLast ? "last" : null);
1271
1734
 
@@ -1276,6 +1739,16 @@ async function doStart(adapter, opts) {
1276
1739
  session,
1277
1740
  ...(cwd ? ["-c", cwd] : []),
1278
1741
  ]);
1742
+ // Default tmux history-limit (2000) is far too small for long tool-heavy
1743
+ // CLI sessions (file reads, test runs) — the actual response can scroll
1744
+ // out of scrollback before capturePane() runs, producing an empty extract.
1745
+ await runTmux(remote, [
1746
+ "set-option",
1747
+ "-t",
1748
+ session,
1749
+ "history-limit",
1750
+ "100000",
1751
+ ]);
1279
1752
  await runTmux(remote, ["send-keys", "-t", session, ...launchKeys]);
1280
1753
  await dismissStartupScreens(adapter, remote, session);
1281
1754
 
@@ -1297,14 +1770,89 @@ async function doStart(adapter, opts) {
1297
1770
  });
1298
1771
  }
1299
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
+
1300
1803
  async function doAskViaTmux(adapter, opts) {
1301
- const { session, prompt, remote, timeoutMs, settleMs, pollIntervalMs } = opts;
1804
+ const {
1805
+ session,
1806
+ prompt,
1807
+ remote,
1808
+ timeoutMs,
1809
+ settleMs,
1810
+ pollIntervalMs,
1811
+ doneMarker,
1812
+ ifBusy = "wait",
1813
+ busyTimeoutMs = timeoutMs,
1814
+ } = opts;
1815
+ const busyWaitedMs = await waitForTmuxIdle(adapter, {
1816
+ session,
1817
+ remote,
1818
+ pollIntervalMs,
1819
+ busyTimeoutMs,
1820
+ ifBusy,
1821
+ });
1302
1822
  const beforeRaw = await capturePane(remote, session);
1303
1823
  const contextPctBefore = adapter.contextPct(beforeRaw);
1304
1824
 
1305
- await runTmux(remote, ["send-keys", "-t", session, "--", prompt]);
1825
+ const bufferName = tmuxBufferName(session);
1826
+ await runTmux(remote, ["set-buffer", "-b", bufferName, "--", prompt]);
1827
+ await runTmux(remote, [
1828
+ "paste-buffer",
1829
+ "-b",
1830
+ bufferName,
1831
+ "-d",
1832
+ "-t",
1833
+ session,
1834
+ ]);
1306
1835
  await sleep(settleMs);
1307
- await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
1836
+
1837
+ let promptSubmitted = !doneMarker;
1838
+ if (doneMarker) {
1839
+ for (let attempt = 1; attempt <= PROMPT_SUBMIT_MAX_ATTEMPTS; attempt += 1) {
1840
+ await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
1841
+ await sleep(PROMPT_SUBMIT_RETRY_DELAY_MS);
1842
+ const submissionVisible = await captureVisible(remote, session);
1843
+ if (!composerShowsPrompt(submissionVisible, prompt)) {
1844
+ promptSubmitted = true;
1845
+ break;
1846
+ }
1847
+ }
1848
+ if (!promptSubmitted) {
1849
+ throw new Error(
1850
+ `Prompt did not submit after ${PROMPT_SUBMIT_MAX_ATTEMPTS} attempts`,
1851
+ );
1852
+ }
1853
+ } else {
1854
+ await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
1855
+ }
1308
1856
 
1309
1857
  const startedAt = Date.now();
1310
1858
  let raw = "";
@@ -1316,18 +1864,36 @@ async function doAskViaTmux(adapter, opts) {
1316
1864
  let done = false;
1317
1865
 
1318
1866
  while (Date.now() - startedAt < timeoutMs) {
1319
- visible = await captureVisible(remote, session);
1867
+ visible = doneMarker
1868
+ ? await capturePane(remote, session)
1869
+ : await captureVisible(remote, session);
1320
1870
  contextPctAfter = adapter.contextPct(visible);
1321
1871
 
1322
1872
  quietPolls = visible === previousVisible ? quietPolls + 1 : 0;
1323
1873
  previousVisible = visible;
1324
1874
 
1325
- const ready = adapter.isReady(visible);
1326
- const doneSignal =
1875
+ const markerDone =
1876
+ promptSubmitted &&
1877
+ Boolean(
1878
+ extractResponseSinceMarker({
1879
+ beforeRaw,
1880
+ raw: visible,
1881
+ doneMarker,
1882
+ adapter,
1883
+ }),
1884
+ );
1885
+ const fallbackDone =
1886
+ !doneMarker &&
1327
1887
  !adapter.isBusy(visible) &&
1328
- ready &&
1329
- hasTmuxAssistantResponseAfterPrompt(adapter, visible, prompt, beforeRaw);
1330
- if (doneSignal && quietPolls >= FALLBACK_QUIET_POLLS) {
1888
+ adapter.isReady(visible) &&
1889
+ quietPolls >= FALLBACK_QUIET_POLLS &&
1890
+ hasTmuxAssistantResponseAfterCurrentPrompt(
1891
+ adapter,
1892
+ await capturePane(remote, session),
1893
+ prompt,
1894
+ beforeRaw,
1895
+ );
1896
+ if (markerDone || fallbackDone) {
1331
1897
  done = true;
1332
1898
  break;
1333
1899
  }
@@ -1336,7 +1902,20 @@ async function doAskViaTmux(adapter, opts) {
1336
1902
  }
1337
1903
 
1338
1904
  raw = await capturePane(remote, session);
1339
- response = extractAssistantResponse(adapter, raw, prompt);
1905
+ const markerResponse = promptSubmitted
1906
+ ? extractResponseSinceMarker({
1907
+ beforeRaw,
1908
+ raw,
1909
+ doneMarker,
1910
+ adapter,
1911
+ })
1912
+ : "";
1913
+ if (markerResponse) {
1914
+ response = markerResponse;
1915
+ done = true;
1916
+ } else {
1917
+ response = extractAssistantResponse(adapter, raw, prompt);
1918
+ }
1340
1919
  if (!response && adapter.cli === "claude") {
1341
1920
  response = extractClaudeCompletedTaskListResponse(raw, {
1342
1921
  beforeText: beforeRaw,
@@ -1353,6 +1932,8 @@ async function doAskViaTmux(adapter, opts) {
1353
1932
  session,
1354
1933
  remote: remote ?? null,
1355
1934
  transport: "tmux",
1935
+ ifBusy,
1936
+ busyWaitedMs,
1356
1937
  response,
1357
1938
  contextPctBefore,
1358
1939
  contextPctAfter,
@@ -1368,6 +1949,24 @@ async function doAsk(adapter, opts) {
1368
1949
  return doAskViaTmux(adapter, opts);
1369
1950
  }
1370
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
+
1371
1970
  // uds/auto attach a live background Claude daemon, not a tmux TUI.
1372
1971
  if (adapter.cli !== "claude") {
1373
1972
  throw new Error(
@@ -1397,7 +1996,11 @@ async function doAsk(adapter, opts) {
1397
1996
  }
1398
1997
 
1399
1998
  async function doAskViaRemoteLive(adapter, opts, deps = {}) {
1400
- const result = await callRemoteLive("ask", opts, deps);
1999
+ const result = await callRemoteLive(
2000
+ "ask",
2001
+ { ...opts, cli: adapter.cli },
2002
+ deps,
2003
+ );
1401
2004
  return {
1402
2005
  ...result,
1403
2006
  cli: result.cli ?? adapter.cli,
@@ -1465,7 +2068,11 @@ function daemonProbeTargetAttachable(probe, opts) {
1465
2068
  async function ensureTmuxSession(adapter, opts) {
1466
2069
  if (!opts.session) return false;
1467
2070
  try {
1468
- 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
+ ]);
1469
2076
  return false;
1470
2077
  } catch {
1471
2078
  await doStart(adapter, {
@@ -1644,6 +2251,10 @@ async function doAskAuto(adapter, opts) {
1644
2251
 
1645
2252
  async function doStop(adapter, opts) {
1646
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
+ );
1647
2258
  await runTmux(remote, ["kill-session", "-t", session]);
1648
2259
  return {
1649
2260
  cli: adapter.cli,
@@ -1761,12 +2372,17 @@ async function doInterrupt(adapter, opts) {
1761
2372
  }
1762
2373
 
1763
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 ':'");
1764
2378
  return {
1765
- session: requireFlag(flags, "session"),
2379
+ session,
1766
2380
  remote: flags.remote,
1767
2381
  cwd: flags.cwd,
1768
2382
  resume: flags.resume,
1769
2383
  resumeLast: Object.hasOwn(flags, "resume-last"),
2384
+ model: flags.model,
2385
+ effort: flags.effort,
1770
2386
  readyTimeoutMs: secondsFlag(
1771
2387
  flags,
1772
2388
  "ready-timeout",
@@ -1804,7 +2420,10 @@ function askOpts(flags, adapter) {
1804
2420
  const short = flags.short;
1805
2421
  const sessionId = flags["session-id"];
1806
2422
  const transport = transportFlag(flags, adapter, short, sessionId);
1807
- 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) {
1808
2427
  throw new Error(
1809
2428
  `--transport ${transport} requires --short or --session-id`,
1810
2429
  );
@@ -1813,15 +2432,37 @@ function askOpts(flags, adapter) {
1813
2432
  // tmux needs a tmux session name; uds needs a daemon ref (short/sessionId);
1814
2433
  // auto can take both (daemon ref to probe, tmux session for fallback).
1815
2434
  session:
1816
- transport === "tmux" ? requireFlag(flags, "session") : flags.session,
2435
+ transport === "tmux"
2436
+ ? splitTmuxTarget(requireFlag(flags, "session")).target
2437
+ : flags.session,
1817
2438
  short,
1818
2439
  sessionId,
1819
2440
  configDir: flags["config-dir"],
1820
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
+ ),
1821
2455
  bridgePath: resolveBridgePath(flags),
1822
2456
  prompt: requireFlag(flags, "prompt"),
1823
2457
  remote: flags.remote,
1824
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,
1825
2466
  settleMs: msFlag(flags, "settle", DEFAULT_SETTLE_MS),
1826
2467
  pollIntervalMs: msFlag(flags, "poll-interval", DEFAULT_POLL_INTERVAL_MS),
1827
2468
  };
@@ -1838,9 +2479,15 @@ function interruptOpts(flags, adapter) {
1838
2479
  const short = flags.short;
1839
2480
  const sessionId = flags["session-id"];
1840
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
+ );
1841
2486
  return {
1842
2487
  session:
1843
- transport === "tmux" ? requireFlag(flags, "session") : flags.session,
2488
+ transport === "tmux"
2489
+ ? splitTmuxTarget(requireFlag(flags, "session")).target
2490
+ : flags.session,
1844
2491
  short,
1845
2492
  sessionId,
1846
2493
  configDir: flags["config-dir"],
@@ -1887,11 +2534,43 @@ async function probe(flags) {
1887
2534
  );
1888
2535
  }
1889
2536
 
1890
- function startOptsForSession(flags, session) {
2537
+ async function listSessions(flags) {
2538
+ const adapter = selectAdapter(flags);
2539
+ if (adapter.cli !== "codex") {
2540
+ throw new Error("list-sessions currently supports only --cli codex");
2541
+ }
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
+ );
2561
+ }
2562
+
2563
+ function startOptsForSession(flags, session, side) {
1891
2564
  return {
1892
2565
  session,
1893
2566
  remote: flags.remote,
1894
2567
  cwd: flags.cwd,
2568
+ ...(side
2569
+ ? {
2570
+ model: flags[`model-${side}`],
2571
+ effort: flags[`effort-${side}`],
2572
+ }
2573
+ : {}),
1895
2574
  readyTimeoutMs: secondsFlag(
1896
2575
  flags,
1897
2576
  "ready-timeout",
@@ -1909,6 +2588,12 @@ function askOptsForSession(flags, session, prompt) {
1909
2588
  timeoutMs: secondsFlag(flags, "timeout", DEFAULT_ANSWER_TIMEOUT_MS),
1910
2589
  settleMs: msFlag(flags, "settle", DEFAULT_SETTLE_MS),
1911
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
+ ),
1912
2597
  };
1913
2598
  }
1914
2599
 
@@ -1929,9 +2614,12 @@ async function readPromptsFile(path) {
1929
2614
 
1930
2615
  async function stopAfterLifecycle(stopSpecs, primaryError = null) {
1931
2616
  let stopError = null;
1932
- for (const { adapter, opts } of stopSpecs) {
2617
+ for (const spec of stopSpecs) {
1933
2618
  try {
1934
- await doStop(adapter, opts);
2619
+ if (!spec.stopped) {
2620
+ const result = await doStop(spec.adapter, spec.opts);
2621
+ spec.stopped = result.stopped === true;
2622
+ }
1935
2623
  } catch (error) {
1936
2624
  stopError ??= error;
1937
2625
  }
@@ -2054,17 +2742,279 @@ function peerMode(flags) {
2054
2742
  }
2055
2743
 
2056
2744
  function peerPrompt(mode, hopIndex, previous) {
2745
+ let prompt;
2057
2746
  if (mode === "counting") {
2058
2747
  if (hopIndex === 0) {
2059
- return "Reply with only the integer 1.";
2748
+ prompt = "Reply with only the integer 1.";
2749
+ } else {
2750
+ prompt = `The current integer is ${previous}. Add 1 to it and reply with ONLY the resulting integer, nothing else.`;
2751
+ }
2752
+ } else if (hopIndex === 0) {
2753
+ prompt = previous;
2754
+ } else {
2755
+ prompt = `The other party said: ${previous}\nReply briefly in 1-2 sentences and ask one short follow-up question.`;
2756
+ }
2757
+
2758
+ return [
2759
+ prompt,
2760
+ `After you have completely finished your response, print a new line containing only this token: \`${PEER_HOP_DONE_MARKER}\`. Do not put any other characters on that line.`,
2761
+ ].join("\n");
2762
+ }
2763
+
2764
+ function buildPeerClosurePrompt(previous) {
2765
+ return [
2766
+ "This is the final closure turn. Based on the discussion so far, declare whether a final agreement was reached.",
2767
+ "First reply with exactly one JSON object and no markdown fences:",
2768
+ '{"agreement_status":"complete|partial","unresolved_questions":[],"needs_more_rounds":false,"summary":"short final agreement"}',
2769
+ "Use agreement_status=complete only when no unresolved question remains and no more rounds are needed.",
2770
+ `The other party's latest response was: ${previous ?? "(none)"}`,
2771
+ `After the JSON is complete, print a new line containing only this token: \`${PEER_HOP_DONE_MARKER}\`. Do not put any other characters on that line.`,
2772
+ ].join("\n");
2773
+ }
2774
+
2775
+ function isCompleteDeclaration(value) {
2776
+ return ["complete", "completed", "agreed", "합의완료", "완료"].includes(
2777
+ String(value ?? "")
2778
+ .trim()
2779
+ .toLowerCase(),
2780
+ );
2781
+ }
2782
+
2783
+ function unstructuredPeerClosure() {
2784
+ return {
2785
+ structured: false,
2786
+ declaredComplete: false,
2787
+ unresolvedQuestions: [],
2788
+ needsMoreRounds: null,
2789
+ };
2790
+ }
2791
+
2792
+ function parsePeerClosure(response) {
2793
+ const text = String(response ?? "").trim();
2794
+ const fenced = text.match(/```(?:json)?\s*({[\s\S]*})\s*```/i)?.[1];
2795
+ const firstBrace = text.indexOf("{");
2796
+ const lastBrace = text.lastIndexOf("}");
2797
+ const objectText =
2798
+ fenced ??
2799
+ (firstBrace >= 0 && lastBrace > firstBrace
2800
+ ? text.slice(firstBrace, lastBrace + 1)
2801
+ : null);
2802
+
2803
+ if (objectText) {
2804
+ try {
2805
+ // Terminal word-wrap can inject a raw newline + indent mid-token in
2806
+ // compact JSON (e.g. "f\n alse" for "false") since there's often no
2807
+ // space nearby to wrap at. Try the raw text first, then retry with
2808
+ // wrap artifacts collapsed before giving up as unstructured.
2809
+ let parsed;
2810
+ try {
2811
+ parsed = JSON.parse(objectText);
2812
+ } catch {
2813
+ parsed = JSON.parse(objectText.replace(/\n[ \t]*/g, ""));
2814
+ }
2815
+ const unresolvedQuestions = parsed.unresolved_questions;
2816
+ const needsMoreRounds = parsed.needs_more_rounds;
2817
+ const structured =
2818
+ Object.hasOwn(parsed, "agreement_status") &&
2819
+ Array.isArray(unresolvedQuestions) &&
2820
+ typeof needsMoreRounds === "boolean";
2821
+ if (structured) {
2822
+ return {
2823
+ structured: true,
2824
+ declaredComplete: isCompleteDeclaration(parsed.agreement_status),
2825
+ unresolvedQuestions: unresolvedQuestions.map(String),
2826
+ needsMoreRounds,
2827
+ };
2828
+ }
2829
+ return unstructuredPeerClosure();
2830
+ } catch {
2831
+ return unstructuredPeerClosure();
2060
2832
  }
2061
- return `The current integer is ${previous}. Add 1 to it and reply with ONLY the resulting integer, nothing else.`;
2062
2833
  }
2063
2834
 
2064
- if (hopIndex === 0) {
2065
- return previous;
2835
+ const status = text.match(
2836
+ /(?:agreement_status|agreement status|합의상태|합의 상태)\s*[:=]\s*["']?([^\n,"'}]+)/i,
2837
+ )?.[1];
2838
+ const unresolved = text.match(
2839
+ /(?:unresolved_questions|unresolved questions|미해결 쟁점)\s*[:=]\s*([^\n]+)/i,
2840
+ )?.[1];
2841
+ const needsMore = text.match(
2842
+ /(?:needs_more_rounds|needs more rounds|추가 라운드 필요)\s*[:=]\s*(true|false)/i,
2843
+ )?.[1];
2844
+ const structured =
2845
+ status !== undefined && unresolved !== undefined && needsMore !== undefined;
2846
+ const unresolvedQuestions = /^(?:\[\s*\]|none|없음)$/i.test(
2847
+ String(unresolved ?? "").trim(),
2848
+ )
2849
+ ? []
2850
+ : String(unresolved ?? "")
2851
+ .replace(/^\[|\]$/g, "")
2852
+ .split(/[,;|]/)
2853
+ .map((item) => item.trim())
2854
+ .filter(Boolean);
2855
+
2856
+ return {
2857
+ structured,
2858
+ declaredComplete: isCompleteDeclaration(status),
2859
+ unresolvedQuestions,
2860
+ needsMoreRounds:
2861
+ needsMore === undefined ? null : needsMore.toLowerCase() === "true",
2862
+ };
2863
+ }
2864
+
2865
+ function derivePeerStatus({ hopsCompleted, closure, exitReason = null }) {
2866
+ if (hopsCompleted < 1) return "failed";
2867
+ if (exitReason) return "partial";
2868
+ if (
2869
+ closure?.structured === true &&
2870
+ closure.declaredComplete === true &&
2871
+ closure.needsMoreRounds === false &&
2872
+ closure.unresolvedQuestions.length === 0
2873
+ ) {
2874
+ return "complete";
2066
2875
  }
2067
- return `The other party said: ${previous}\nReply briefly in 1-2 sentences and ask one short follow-up question.`;
2876
+ return "partial";
2877
+ }
2878
+
2879
+ function classifyPeerExitReason(error) {
2880
+ const fingerprint = [error?.name, error?.code, error?.message]
2881
+ .filter(Boolean)
2882
+ .join(" ")
2883
+ .toLowerCase();
2884
+ return /timeout|timed out|etimedout|aborterror/.test(fingerprint)
2885
+ ? "timeout"
2886
+ : "transport_error";
2887
+ }
2888
+
2889
+ function peerSignalExit(signal) {
2890
+ return signal === "SIGTERM"
2891
+ ? { exitReason: "terminated", exitCode: 143 }
2892
+ : { exitReason: "user_interrupt", exitCode: 130 };
2893
+ }
2894
+
2895
+ function createPeerSignalController({
2896
+ output,
2897
+ persistTranscript,
2898
+ persistStatus,
2899
+ stopSessions,
2900
+ printOutput = printJson,
2901
+ exitProcess = (code) => process.exit(code),
2902
+ timeoutMs = 3000,
2903
+ now = Date.now,
2904
+ setTimer = setTimeout,
2905
+ clearTimer = clearTimeout,
2906
+ }) {
2907
+ let signalCount = 0;
2908
+ let settled = false;
2909
+
2910
+ const recordArtifactError = (kind, error) => {
2911
+ output.artifact_errors ??= [];
2912
+ output.artifact_errors.push(`${kind}: ${error.message}`);
2913
+ };
2914
+
2915
+ return {
2916
+ async handle(signal) {
2917
+ if (settled) return;
2918
+ signalCount += 1;
2919
+ const { exitReason, exitCode } = peerSignalExit(signal);
2920
+ if (signalCount > 1) {
2921
+ settled = true;
2922
+ exitProcess(exitCode);
2923
+ return;
2924
+ }
2925
+
2926
+ const startedAt = now();
2927
+ output.status = "aborted";
2928
+ output.exit_reason = exitReason;
2929
+ output.hops_completed = output.hops.length;
2930
+
2931
+ try {
2932
+ persistTranscript(output);
2933
+ } catch (error) {
2934
+ recordArtifactError("transcript", error);
2935
+ }
2936
+ try {
2937
+ persistStatus(output);
2938
+ } catch (error) {
2939
+ recordArtifactError("status", error);
2940
+ }
2941
+
2942
+ const remainingMs = Math.max(0, timeoutMs - (now() - startedAt));
2943
+ let timer;
2944
+ const graceExpired = new Promise((resolve) => {
2945
+ timer = setTimer(() => resolve("timeout"), remainingMs);
2946
+ });
2947
+ await Promise.race([
2948
+ Promise.resolve()
2949
+ .then(stopSessions)
2950
+ .then(
2951
+ () => "stopped",
2952
+ () => "stop_error",
2953
+ ),
2954
+ graceExpired,
2955
+ ]);
2956
+ clearTimer(timer);
2957
+
2958
+ if (settled) return;
2959
+ settled = true;
2960
+ printOutput(output);
2961
+ exitProcess(exitCode);
2962
+ },
2963
+ dispose() {
2964
+ settled = true;
2965
+ },
2966
+ };
2967
+ }
2968
+
2969
+ function peerArtifactPaths(sessionA, sessionB) {
2970
+ const preferredDir =
2971
+ process.env.TFX_LIVE_ARTIFACT_DIR ??
2972
+ pathJoin(homedir(), ".claude", "cache", "triflux", "tfx-live", "peer-runs");
2973
+ const fallbackDir = pathJoin(tmpdir(), "triflux-live");
2974
+ let artifactDir = preferredDir;
2975
+ try {
2976
+ mkdirSync(artifactDir, { recursive: true });
2977
+ } catch {
2978
+ artifactDir = fallbackDir;
2979
+ mkdirSync(artifactDir, { recursive: true });
2980
+ }
2981
+ const safe = (value) =>
2982
+ String(value)
2983
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
2984
+ .slice(0, 48);
2985
+ const runId = `peer-${safe(sessionA)}-${safe(sessionB)}-${Date.now()}-${process.pid}`;
2986
+ return {
2987
+ transcriptPath: pathJoin(artifactDir, `${runId}.transcript.json`),
2988
+ statusPath: pathJoin(artifactDir, `${runId}.status.json`),
2989
+ };
2990
+ }
2991
+
2992
+ function persistPeerTranscript(paths, output) {
2993
+ writeFileSync(
2994
+ paths.transcriptPath,
2995
+ `${JSON.stringify(
2996
+ {
2997
+ mode: output.mode,
2998
+ rounds: output.rounds,
2999
+ cliA: output.cliA,
3000
+ cliB: output.cliB,
3001
+ hops_completed: output.hops.length,
3002
+ hops: output.hops,
3003
+ },
3004
+ null,
3005
+ 2,
3006
+ )}\n`,
3007
+ "utf8",
3008
+ );
3009
+ }
3010
+
3011
+ function persistPeerStatus(paths, output) {
3012
+ const { hops: _hops, ...status } = output;
3013
+ writeFileSync(
3014
+ paths.statusPath,
3015
+ `${JSON.stringify(status, null, 2)}\n`,
3016
+ "utf8",
3017
+ );
2068
3018
  }
2069
3019
 
2070
3020
  function sideFlag(flags, side, name, fallback = undefined) {
@@ -2089,7 +3039,11 @@ function peerSideTransport(flags, side, adapter) {
2089
3039
  `--transport-${side} must be one of: ${VALID_TRANSPORTS.join(", ")}`,
2090
3040
  );
2091
3041
  }
2092
- if (transport !== "tmux" && adapter.cli !== "claude") {
3042
+ if (
3043
+ transport !== "tmux" &&
3044
+ adapter.cli !== "claude" &&
3045
+ !(adapter.cli === "codex" && transport === "uds")
3046
+ ) {
2093
3047
  throw new Error(
2094
3048
  `--transport-${side} ${transport} is only supported when --cli-${side} is claude`,
2095
3049
  );
@@ -2097,11 +3051,34 @@ function peerSideTransport(flags, side, adapter) {
2097
3051
  return transport;
2098
3052
  }
2099
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
+
2100
3068
  function peerSideBaseOpts(flags, side, adapter, session) {
2101
3069
  const transport = peerSideTransport(flags, side, adapter);
3070
+ const ifBusy = peerSideBusyPolicy(flags, side, adapter, transport);
2102
3071
  const short = sideFlag(flags, side, "short");
2103
3072
  const sessionId = sideFlag(flags, side, "session-id");
2104
- 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
+ ) {
2105
3082
  throw new Error(
2106
3083
  `--transport-${side} ${transport} requires --short-${side} or --session-id-${side}`,
2107
3084
  );
@@ -2110,13 +3087,38 @@ function peerSideBaseOpts(flags, side, adapter, session) {
2110
3087
  session,
2111
3088
  short,
2112
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,
2113
3101
  transport,
2114
3102
  bridgePath: sideFlag(flags, side, "bridge", resolveBridgePath(flags)),
2115
3103
  remote: flags.remote,
2116
3104
  cwd: flags.cwd,
2117
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,
2118
3114
  settleMs: msFlag(flags, "settle", DEFAULT_SETTLE_MS),
2119
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
+ ),
2120
3122
  readyTimeoutMs: secondsFlag(
2121
3123
  flags,
2122
3124
  "ready-timeout",
@@ -2126,13 +3128,44 @@ function peerSideBaseOpts(flags, side, adapter, session) {
2126
3128
  }
2127
3129
 
2128
3130
  function askOptsFromPeerBase(base, prompt) {
2129
- return { ...base, prompt };
3131
+ return { ...base, prompt, doneMarker: PEER_HOP_DONE_MARKER };
2130
3132
  }
2131
3133
 
2132
3134
  function shouldPrestartPeerSide(base) {
2133
3135
  return base.transport === "tmux";
2134
3136
  }
2135
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
+
2136
3169
  function addStopSpecOnce(stopSpecs, adapter, opts) {
2137
3170
  if (!opts.session) return;
2138
3171
  const exists = stopSpecs.some(
@@ -2152,29 +3185,110 @@ function addStopSpecOnce(stopSpecs, adapter, opts) {
2152
3185
  async function peer(flags) {
2153
3186
  const adapterA = selectAdapterName(flags["cli-a"] ?? "codex", "cli-a");
2154
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
+ );
2155
3200
  const sessionA = flags["session-a"] ?? "peerA";
2156
3201
  const sessionB = flags["session-b"] ?? "peerB";
2157
3202
  const baseA = peerSideBaseOpts(flags, "a", adapterA, sessionA);
2158
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";
2159
3208
  const rounds = integerFlag(flags, "rounds", 4);
2160
3209
  const mode = peerMode(flags);
2161
3210
  const hops = [];
2162
3211
  const numbers = [];
2163
3212
  const stopSpecs = [];
3213
+ const artifactPaths = peerArtifactPaths(sessionA, sessionB);
3214
+ const output = {
3215
+ mode,
3216
+ rounds,
3217
+ cliA: adapterA.cli,
3218
+ cliB: adapterB.cli,
3219
+ transportA: baseA.transport,
3220
+ transportB: baseB.transport,
3221
+ attachedA,
3222
+ attachedB,
3223
+ stoppedA: false,
3224
+ stoppedB: false,
3225
+ hops,
3226
+ transcript_path: artifactPaths.transcriptPath,
3227
+ status_path: artifactPaths.statusPath,
3228
+ };
3229
+ if (mode === "counting") {
3230
+ output.numbers = numbers;
3231
+ }
2164
3232
  let previous =
2165
3233
  mode === "freeform"
2166
3234
  ? (flags.seed ??
2167
3235
  "Introduce yourself in one sentence, then ask the other party one short question.")
2168
3236
  : null;
2169
3237
  let primaryError = null;
3238
+ let stopError = null;
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
+ };
3256
+ const signalController = createPeerSignalController({
3257
+ output,
3258
+ persistTranscript: (value) => persistPeerTranscript(artifactPaths, value),
3259
+ persistStatus: (value) => persistPeerStatus(artifactPaths, value),
3260
+ stopSessions: stopPeerSessions,
3261
+ });
3262
+ const onSigint = () => {
3263
+ void signalController.handle("SIGINT");
3264
+ };
3265
+ const onSigterm = () => {
3266
+ void signalController.handle("SIGTERM");
3267
+ };
3268
+ process.on("SIGINT", onSigint);
3269
+ process.on("SIGTERM", onSigterm);
2170
3270
 
2171
3271
  try {
2172
- if (shouldPrestartPeerSide(baseA)) {
2173
- await doStart(adapterA, startOptsForSession(flags, sessionA));
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)) {
3287
+ await doStart(adapterA, startOptsForSession(flags, sessionA, "a"));
2174
3288
  addStopSpecOnce(stopSpecs, adapterA, baseA);
2175
3289
  }
2176
- if (shouldPrestartPeerSide(baseB)) {
2177
- await doStart(adapterB, startOptsForSession(flags, sessionB));
3290
+ if (!attachedB && shouldPrestartPeerSide(baseB)) {
3291
+ await doStart(adapterB, startOptsForSession(flags, sessionB, "b"));
2178
3292
  addStopSpecOnce(stopSpecs, adapterB, baseB);
2179
3293
  }
2180
3294
 
@@ -2186,11 +3300,36 @@ async function peer(flags) {
2186
3300
  const isA = hopIndex % 2 === 0;
2187
3301
  const adapter = isA ? adapterA : adapterB;
2188
3302
  const base = isA ? baseA : baseB;
2189
- const sent = peerPrompt(mode, hopIndex, previous);
3303
+ const isClosure = hopIndex === rounds * 2 - 1;
3304
+ const sent = isClosure
3305
+ ? buildPeerClosurePrompt(previous)
3306
+ : peerPrompt(mode, hopIndex, previous);
2190
3307
  const result = await doAsk(adapter, askOptsFromPeerBase(base, sent));
2191
- 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)) {
2192
3319
  addStopSpecOnce(stopSpecs, adapter, base);
2193
3320
  }
3321
+ if (
3322
+ result.done !== true ||
3323
+ result.error ||
3324
+ !String(result.response ?? "").trim()
3325
+ ) {
3326
+ throw new Error(
3327
+ (result.timedOut
3328
+ ? `Peer hop timed out at hop ${hopIndex + 1}`
3329
+ : result.error) ??
3330
+ `Empty response from ${adapter.cli} at hop ${hopIndex + 1}`,
3331
+ );
3332
+ }
2194
3333
 
2195
3334
  hops.push({
2196
3335
  hop: hopIndex + 1,
@@ -2205,40 +3344,68 @@ async function peer(flags) {
2205
3344
  aborted: result.aborted === true ? true : undefined,
2206
3345
  reason: result.reason,
2207
3346
  });
3347
+ if (isClosure) {
3348
+ closure = parsePeerClosure(result.response);
3349
+ }
2208
3350
 
2209
3351
  if (mode === "counting") {
2210
3352
  const parsed = parseFirstInteger(result.response);
2211
- if (parsed === null) {
3353
+ if (parsed === null && !isClosure) {
2212
3354
  throw new Error(
2213
3355
  `Could not parse integer from ${adapter.cli} response at hop ${hopIndex + 1}`,
2214
3356
  );
2215
3357
  }
2216
- numbers.push(parsed);
2217
- previous = parsed;
3358
+ if (parsed !== null) {
3359
+ numbers.push(parsed);
3360
+ previous = parsed;
3361
+ } else {
3362
+ previous = result.response;
3363
+ }
2218
3364
  } else {
2219
3365
  previous = result.response;
2220
3366
  }
2221
3367
  }
2222
3368
  } catch (error) {
2223
3369
  primaryError = error;
2224
- } finally {
2225
- await stopAfterLifecycle(stopSpecs, primaryError);
2226
3370
  }
2227
3371
 
2228
- const output = {
2229
- mode,
2230
- rounds,
2231
- cliA: adapterA.cli,
2232
- cliB: adapterB.cli,
2233
- transportA: baseA.transport,
2234
- transportB: baseB.transport,
2235
- hops,
2236
- };
2237
- if (mode === "counting") {
2238
- output.numbers = numbers;
3372
+ try {
3373
+ await stopPeerSessions();
3374
+ } catch (error) {
3375
+ stopError = error;
3376
+ }
3377
+ signalController.dispose();
3378
+ process.off("SIGINT", onSigint);
3379
+ process.off("SIGTERM", onSigterm);
3380
+
3381
+ const terminalError = primaryError ?? stopError;
3382
+ const exitReason = terminalError
3383
+ ? classifyPeerExitReason(terminalError)
3384
+ : null;
3385
+ output.hops_completed = hops.length;
3386
+ output.closure = closure;
3387
+ output.status = derivePeerStatus({
3388
+ hopsCompleted: hops.length,
3389
+ closure,
3390
+ exitReason,
3391
+ });
3392
+ if (exitReason) {
3393
+ output.exit_reason = exitReason;
3394
+ output.error = terminalError.message;
3395
+ }
3396
+
3397
+ try {
3398
+ persistPeerTranscript(artifactPaths, output);
3399
+ } catch (error) {
3400
+ output.artifact_errors ??= [];
3401
+ output.artifact_errors.push(`transcript: ${error.message}`);
3402
+ }
3403
+ try {
3404
+ persistPeerStatus(artifactPaths, output);
3405
+ } catch (error) {
3406
+ output.artifact_errors ??= [];
3407
+ output.artifact_errors.push(`status: ${error.message}`);
2239
3408
  }
2240
- output.stoppedA = true;
2241
- output.stoppedB = true;
2242
3409
  printJson(output);
2243
3410
  }
2244
3411
 
@@ -2251,7 +3418,11 @@ async function orchestrate(flags) {
2251
3418
  throw new Error(`--mode must be one of: ${ORCHESTRATION_MODES.join(", ")}`);
2252
3419
  }
2253
3420
  const task = requireFlag(flags, "task");
2254
- 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");
2255
3426
  if (!CODEX_ORCH_TRANSPORTS.includes(codexTransport)) {
2256
3427
  throw new Error(
2257
3428
  `--codex-transport must be one of: ${CODEX_ORCH_TRANSPORTS.join(", ")}`,
@@ -2259,6 +3430,9 @@ async function orchestrate(flags) {
2259
3430
  }
2260
3431
  const cwd = flags.cwd ?? process.cwd();
2261
3432
  const timeoutMs = secondsFlag(flags, "timeout", 120_000);
3433
+ const socketPath = flags["codex-socket"]
3434
+ ? resolveCodexDaemonSocket(flags["codex-socket"])
3435
+ : undefined;
2262
3436
 
2263
3437
  // Lazy import keeps the orchestration engine (and its hub/team deps) off the
2264
3438
  // hot path for every other thin-CLI verb; only `orchestrate` pays the cost.
@@ -2276,7 +3450,11 @@ async function orchestrate(flags) {
2276
3450
  const claude = createClaudeUdsEndpoint({ cwd, timeoutMs });
2277
3451
  const codex =
2278
3452
  codexTransport === "app-server-uds"
2279
- ? createCodexAppServerUdsEndpoint({ cwd, timeoutMs })
3453
+ ? createCodexAppServerUdsEndpoint({
3454
+ cwd,
3455
+ timeoutMs,
3456
+ ...(socketPath ? { socketPath, spawnServer: false } : {}),
3457
+ })
2280
3458
  : createCodexExecEndpoint({ workdir: cwd, timeout: timeoutMs });
2281
3459
 
2282
3460
  const result = await runUdsOrchestration({ mode, task, claude, codex });
@@ -2344,6 +3522,8 @@ async function main() {
2344
3522
  await interrupt(flags);
2345
3523
  } else if (command === "probe") {
2346
3524
  await probe(flags);
3525
+ } else if (command === "list-sessions") {
3526
+ await listSessions(flags);
2347
3527
  } else if (command === "converse") {
2348
3528
  await converse(flags);
2349
3529
  } else if (command === "goal-driven") {
@@ -2361,14 +3541,32 @@ async function main() {
2361
3541
 
2362
3542
  export {
2363
3543
  ADAPTERS,
3544
+ buildLaunchKeys,
3545
+ buildPeerClosurePrompt,
2364
3546
  buildRemoteLiveCommand,
3547
+ buildTmuxCommand,
2365
3548
  callRemoteLive,
3549
+ classifyPeerExitReason,
3550
+ createPeerSignalController,
2366
3551
  ctoHygieneNotify,
3552
+ derivePeerStatus,
3553
+ discoverCodexTmuxSessions,
3554
+ doAskViaTmux,
2367
3555
  extractAssistantResponse,
2368
3556
  extractClaudeCompletedTaskListResponse,
3557
+ extractResponseSinceMarker,
2369
3558
  hasClaudeCompletedTaskListResponse,
3559
+ inspectCodexTmuxPane,
3560
+ isCodexLoadingCapture,
3561
+ parseCodexTmuxSessions,
3562
+ parsePeerClosure,
2370
3563
  parseRemoteLiveJson,
3564
+ peerSideBaseOpts,
2371
3565
  resolveAskTransport,
3566
+ resolveCodexDaemonSocket,
3567
+ splitTmuxTarget,
3568
+ tmuxBufferName,
3569
+ verifyAttachedPeerSide,
2372
3570
  };
2373
3571
 
2374
3572
  function isMainModule() {