triflux 10.44.0 → 10.45.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 (193) 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 +752 -77
  4. package/bin/triflux.mjs +9 -6
  5. package/hooks/codex-session-hook.mjs +67 -1
  6. package/hooks/cto-north-star-brief.mjs +3 -16
  7. package/hooks/error-context.mjs +3 -6
  8. package/hooks/hook-manager.mjs +1 -1
  9. package/hooks/hook-registry.json +0 -11
  10. package/hooks/keyword-rules.json +8 -4
  11. package/hooks/permission-safe-allow.mjs +36 -2
  12. package/hooks/session-start-fast.mjs +50 -1
  13. package/hooks/session-start-lake.mjs +3 -16
  14. package/hub/bridge.mjs +111 -5
  15. package/hub/gemini-adapter.mjs +29 -11
  16. package/hub/lib/memory-store.mjs +66 -30
  17. package/hub/lib/tfx-route-args.mjs +41 -4
  18. package/hub/pipe.mjs +10 -15
  19. package/hub/reflexion.mjs +0 -57
  20. package/hub/router.mjs +202 -24
  21. package/hub/schema.sql +1 -0
  22. package/hub/server.mjs +172 -93
  23. package/hub/store.mjs +106 -33
  24. package/hub/team/backend.mjs +4 -2
  25. package/hub/team/claude-daemon-control.mjs +2 -0
  26. package/hub/team/cli/commands/start/start-headless.mjs +2 -0
  27. package/hub/team/cli/commands/start/start-mux.mjs +24 -3
  28. package/hub/team/cli/services/runtime-mode.mjs +23 -9
  29. package/hub/team/codex-review.mjs +2 -3
  30. package/hub/team/conductor.mjs +80 -24
  31. package/hub/team/cto-auto-collect.mjs +2 -6
  32. package/hub/team/cto-pull.mjs +4 -0
  33. package/hub/team/dashboard-open.mjs +1 -0
  34. package/hub/team/execution-mode.mjs +38 -2
  35. package/hub/team/headless.mjs +493 -82
  36. package/hub/team/interactive-tui-transport.mjs +15 -1
  37. package/hub/team/launcher-template.mjs +19 -2
  38. package/hub/team/orchestrator.mjs +36 -9
  39. package/hub/team/pane.mjs +176 -14
  40. package/hub/team/psmux.mjs +79 -70
  41. package/hub/team/retry-state-machine.mjs +39 -16
  42. package/hub/team/session.mjs +57 -0
  43. package/hub/team/terminal-opener.mjs +68 -13
  44. package/hub/team/tui-viewer.mjs +65 -14
  45. package/hub/tools.mjs +6 -0
  46. package/hub/workers/codex-mcp.mjs +34 -5
  47. package/hud/cli-policy.mjs +69 -0
  48. package/hud/constants.mjs +20 -17
  49. package/hud/context-monitor.mjs +31 -21
  50. package/hud/hud-qos-status.mjs +81 -91
  51. package/hud/providers/claude.mjs +150 -84
  52. package/hud/providers/codex.mjs +10 -1
  53. package/hud/renderers.mjs +21 -59
  54. package/hud/utils.mjs +1 -15
  55. package/package.json +1 -1
  56. package/scripts/__tests__/keyword-detector.test.mjs +89 -1
  57. package/scripts/__tests__/release-governance.test.mjs +94 -1
  58. package/scripts/__tests__/skill-surface.test.mjs +6 -4
  59. package/scripts/__tests__/tfx-route-bash-node-parity.test.mjs +6 -4
  60. package/scripts/__tests__/tfx-route-node-entry.test.mjs +23 -17
  61. package/scripts/claude-login-detect.mjs +107 -25
  62. package/scripts/ensure-agy-hooks.mjs +31 -1
  63. package/scripts/keyword-detector.mjs +11 -1
  64. package/scripts/lib/agent-route-policy.mjs +29 -22
  65. package/scripts/lib/cli-agy.mjs +24 -4
  66. package/scripts/lib/codex-profile-config.mjs +26 -8
  67. package/scripts/lib/codex-recovery.sh +19 -0
  68. package/scripts/lib/gemini-profiles.mjs +113 -29
  69. package/scripts/lib/keyword-rules.mjs +12 -0
  70. package/scripts/lib/logger.mjs +60 -43
  71. package/scripts/lib/machine-profile.mjs +293 -0
  72. package/scripts/lib/mcp-health.mjs +44 -18
  73. package/scripts/notion-read.mjs +2 -1
  74. package/scripts/release/check-packages-mirror.mjs +53 -1
  75. package/scripts/release/lib.mjs +1 -2
  76. package/scripts/release/prepare.mjs +24 -9
  77. package/scripts/remote-spawn.mjs +830 -32
  78. package/scripts/session-stale-cleanup.mjs +0 -1
  79. package/scripts/setup.mjs +474 -143
  80. package/scripts/sync-hub-mcp-settings.mjs +11 -3
  81. package/scripts/test-lock.mjs +15 -1
  82. package/scripts/tfx-gate-activate.mjs +4 -4
  83. package/scripts/tfx-route-post.mjs +90 -13
  84. package/scripts/tfx-route.sh +502 -126
  85. package/skills/merge-worktree/SKILL.md +3 -3
  86. package/skills/star-prompt/skill.json +1 -1
  87. package/skills/tfx-analysis/SKILL.md +8 -9
  88. package/skills/tfx-analysis/skill.json +1 -10
  89. package/skills/tfx-auto/SKILL.md +203 -46
  90. package/skills/tfx-auto/skill.json +2 -23
  91. package/skills/tfx-doctor/skill.json +0 -3
  92. package/skills/tfx-find/skill.json +0 -7
  93. package/skills/tfx-forge/SKILL.md +2 -2
  94. package/skills/tfx-forge/skill.json +0 -7
  95. package/skills/tfx-goal-clarify/skill.json +5 -0
  96. package/skills/tfx-harness/SKILL.md +26 -3
  97. package/skills/tfx-harness/skill.json +5 -0
  98. package/skills/tfx-hooks/SKILL.md +0 -2
  99. package/skills/tfx-hooks/skill.json +0 -3
  100. package/skills/tfx-hub/SKILL.md +1 -6
  101. package/skills/tfx-hub/skill.json +0 -3
  102. package/skills/tfx-index/skill.json +0 -6
  103. package/skills/tfx-interview/SKILL.md +0 -2
  104. package/skills/tfx-live/SKILL.md +22 -1
  105. package/skills/tfx-live/skill.json +0 -5
  106. package/skills/tfx-plan/SKILL.md +7 -8
  107. package/skills/tfx-plan/skill.json +1 -12
  108. package/skills/tfx-profile/SKILL.md +6 -6
  109. package/skills/tfx-profile/skill.json +0 -3
  110. package/skills/tfx-prune/SKILL.md +4 -6
  111. package/skills/tfx-qa/SKILL.md +7 -7
  112. package/skills/tfx-qa/skill.json +1 -11
  113. package/skills/tfx-ralph/SKILL.md +8 -11
  114. package/skills/tfx-remote/SKILL.md +2 -2
  115. package/skills/tfx-remote/skill.json +0 -3
  116. package/skills/tfx-research/SKILL.md +2 -2
  117. package/skills/tfx-research/skill.json +1 -16
  118. package/skills/tfx-review/SKILL.md +11 -11
  119. package/skills/tfx-review/skill.json +1 -11
  120. package/skills/tfx-setup/SKILL.md +5 -6
  121. package/skills/tfx-setup/skill.json +0 -3
  122. package/skills/tfx-ship/skill.json +1 -9
  123. package/skills/tfx-wt/skill.json +0 -7
  124. package/tui/codex-profile.mjs +2 -1
  125. package/tui/gemini-profile.mjs +11 -24
  126. package/tui/monitor.mjs +1 -0
  127. package/tui/setup.mjs +3 -3
  128. package/scripts/headless-guard-fast.sh +0 -29
  129. package/scripts/headless-guard.mjs +0 -523
  130. package/skills/tfx-autopilot/SKILL.md +0 -51
  131. package/skills/tfx-autopilot/skill.json +0 -12
  132. package/skills/tfx-codex-swarm-workspace/iteration-1/benchmark.json +0 -75
  133. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/eval_metadata.json +0 -42
  134. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/grading.json +0 -39
  135. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/analysis.md +0 -87
  136. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/classification.md +0 -35
  137. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/commands.sh +0 -275
  138. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/outputs/routing.md +0 -56
  139. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/timing.json +0 -5
  140. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/grading.json +0 -39
  141. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/analysis.md +0 -92
  142. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/classification.md +0 -71
  143. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/commands.sh +0 -264
  144. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/outputs/routing.md +0 -113
  145. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/timing.json +0 -5
  146. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/eval_metadata.json +0 -32
  147. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/grading.json +0 -29
  148. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/analysis.md +0 -96
  149. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/classification.md +0 -38
  150. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/commands.sh +0 -151
  151. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/outputs/routing.md +0 -51
  152. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/timing.json +0 -5
  153. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/grading.json +0 -29
  154. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/analysis.md +0 -127
  155. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/classification.md +0 -57
  156. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/commands.sh +0 -129
  157. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/outputs/routing.md +0 -84
  158. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/timing.json +0 -5
  159. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/eval_metadata.json +0 -27
  160. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/grading.json +0 -24
  161. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/analysis.md +0 -98
  162. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/classification.md +0 -65
  163. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/commands.sh +0 -123
  164. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/outputs/routing.md +0 -66
  165. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/timing.json +0 -5
  166. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/grading.json +0 -20
  167. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/analysis.md +0 -88
  168. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/classification.md +0 -40
  169. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/commands.sh +0 -130
  170. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/outputs/routing.md +0 -61
  171. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/timing.json +0 -5
  172. package/skills/tfx-consensus/SKILL.md +0 -56
  173. package/skills/tfx-consensus/skill.json +0 -8
  174. package/skills/tfx-debate/SKILL.md +0 -60
  175. package/skills/tfx-debate/skill.json +0 -14
  176. package/skills/tfx-fullcycle/SKILL.md +0 -47
  177. package/skills/tfx-fullcycle/skill.json +0 -13
  178. package/skills/tfx-multi/SKILL.md +0 -45
  179. package/skills/tfx-multi/references/agent-wrapper-rules.md +0 -94
  180. package/skills/tfx-multi/references/thorough-pipeline.md +0 -105
  181. package/skills/tfx-multi/skill.json +0 -10
  182. package/skills/tfx-panel/SKILL.md +0 -60
  183. package/skills/tfx-panel/skill.json +0 -14
  184. package/skills/tfx-persist/SKILL.md +0 -63
  185. package/skills/tfx-persist/skill.json +0 -14
  186. package/skills/tfx-psmux-rules/SKILL.md +0 -41
  187. package/skills/tfx-psmux-rules/skill.json +0 -10
  188. package/skills/tfx-remote-setup/SKILL.md +0 -42
  189. package/skills/tfx-remote-setup/skill.json +0 -10
  190. package/skills/tfx-remote-spawn/SKILL.md +0 -52
  191. package/skills/tfx-remote-spawn/skill.json +0 -10
  192. package/skills/tfx-swarm/SKILL.md +0 -49
  193. 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, 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";
@@ -23,6 +23,9 @@ const DEFAULT_POLL_INTERVAL_MS = 1500;
23
23
  const DEFAULT_READY_TIMEOUT_MS = 30_000;
24
24
  const DEFAULT_ANSWER_TIMEOUT_MS = 60_000;
25
25
  const FALLBACK_QUIET_POLLS = 3;
26
+ const PROMPT_SUBMIT_MAX_ATTEMPTS = 3;
27
+ const PROMPT_SUBMIT_RETRY_DELAY_MS = 100;
28
+ const PROMPT_COMPOSER_NEEDLE_LENGTH = 30;
26
29
  // Capture the complete pane history so a long TUI response does not lose its
27
30
  // leading lines. runTmux's MAX_BUFFER remains the memory/output ceiling.
28
31
  const CAPTURE_START = "-";
@@ -36,6 +39,7 @@ const BRIDGE_TIMEOUT_BUFFER_MS = 15_000;
36
39
  const PAYLOAD_FILE_THRESHOLD = 96 * 1024;
37
40
  const VALID_TRANSPORTS = ["tmux", "uds", "auto"];
38
41
  const BOOLEAN_FLAGS = new Set(["json"]);
42
+ const PEER_HOP_DONE_MARKER = "<<<TFX_PEER_HOP_DONE>>>";
39
43
  // uds-fallback diagnostics land here, written async so a failed daemon attach
40
44
  // never blocks the tmux fallback path (see writeUdsBugReport / doAskAuto).
41
45
  const BUG_REPORT_DIR =
@@ -45,16 +49,17 @@ const BUG_REPORT_DIR =
45
49
  function usage() {
46
50
  return [
47
51
  "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]",
52
+ " 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]",
49
53
  " tfx-live ask --session NAME --prompt TEXT [--cli codex|claude] [--timeout 60] [--remote HOST] [--settle 1500] [--poll-interval 1500]",
50
54
  " 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
55
  " 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
56
  " 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
57
  " tfx-live stop --session NAME [--cli codex|claude] [--remote HOST]",
54
58
  " tfx-live probe [--short SHORT] [--session-id ID] [--config-dir DIR] [--bridge ABS] [--timeout 10]",
59
+ " tfx-live list-sessions --cli codex [--cwd DIR]",
55
60
  " tfx-live converse --session NAME --prompts-file PATH [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500]",
56
61
  " 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]",
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]",
58
63
  " tfx-live orchestrate --task TEXT [--mode peer|codex-led|claude-led] [--codex-transport exec|app-server-uds] [--cwd DIR] [--timeout 120]",
59
64
  " 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
65
  " tfx-live cto-hygiene-notify --root DIR --state-file PATH [--json]",
@@ -353,6 +358,119 @@ async function runTmux(remote, tmuxArgs, options = {}) {
353
358
  }
354
359
  }
355
360
 
361
+ function normalizeCwd(value) {
362
+ if (!value) return null;
363
+ const resolved = pathResolve(value);
364
+ try {
365
+ return realpathSync(resolved);
366
+ } catch {
367
+ return resolved;
368
+ }
369
+ }
370
+
371
+ function isCodexTmuxPane(currentCommand, startCommand) {
372
+ const current = String(currentCommand ?? "").trim();
373
+ const currentBase = current.split("/").at(-1);
374
+ if (currentBase === "codex") {
375
+ return true;
376
+ }
377
+
378
+ const started = String(startCommand ?? "").trim();
379
+ const directStart = started
380
+ .replace(/^exec\s+/, "")
381
+ .replace(/^['"]|['"]$/g, "");
382
+ if (/^(?:\S*\/)?codex(?:\s|$)/.test(directStart)) {
383
+ return true;
384
+ }
385
+
386
+ return (
387
+ /\bomx_codex_pid\b/.test(started) &&
388
+ /(?:^|[\s'""])(?:[^'" \t]+\/)?codex(?:[\s'"]|$)/.test(started)
389
+ );
390
+ }
391
+
392
+ function parseCodexTmuxSessions(stdout, cwd = null) {
393
+ const cwdFilter = normalizeCwd(cwd);
394
+ const sessions = new Map();
395
+
396
+ for (const line of String(stdout).split(/\r?\n/)) {
397
+ if (!line) continue;
398
+ const [
399
+ session,
400
+ createdRaw,
401
+ attachedRaw,
402
+ paneCwd,
403
+ currentCommand,
404
+ ...startParts
405
+ ] = line.split("\t");
406
+ const startCommand = startParts.join("\t");
407
+ if (!session || !isCodexTmuxPane(currentCommand, startCommand)) {
408
+ continue;
409
+ }
410
+
411
+ const normalizedPaneCwd = normalizeCwd(paneCwd);
412
+ if (cwdFilter && normalizedPaneCwd !== cwdFilter) {
413
+ continue;
414
+ }
415
+
416
+ const startedAtEpoch = Number.parseInt(createdRaw, 10);
417
+ const startedAt = Number.isSafeInteger(startedAtEpoch)
418
+ ? new Date(startedAtEpoch * 1000).toISOString()
419
+ : null;
420
+ const existing = sessions.get(session);
421
+ if (existing) {
422
+ if (!existing.cwd && normalizedPaneCwd) {
423
+ existing.cwd = normalizedPaneCwd;
424
+ }
425
+ continue;
426
+ }
427
+
428
+ sessions.set(session, {
429
+ session,
430
+ startedAt,
431
+ startedAtEpoch: Number.isSafeInteger(startedAtEpoch)
432
+ ? startedAtEpoch
433
+ : null,
434
+ cwd: normalizedPaneCwd,
435
+ attached: Number.parseInt(attachedRaw, 10) > 0,
436
+ });
437
+ }
438
+
439
+ return [...sessions.values()].sort((left, right) =>
440
+ left.session.localeCompare(right.session),
441
+ );
442
+ }
443
+
444
+ async function discoverCodexTmuxSessions({ cwd = null } = {}) {
445
+ const format = [
446
+ "#{session_name}",
447
+ "#{session_created}",
448
+ "#{session_attached}",
449
+ "#{pane_current_path}",
450
+ "#{pane_current_command}",
451
+ "#{pane_start_command}",
452
+ ].join("\t");
453
+
454
+ try {
455
+ const { stdout } = await runTmux(null, ["list-panes", "-a", "-F", format]);
456
+ return {
457
+ ok: true,
458
+ cli: "codex",
459
+ cwd: normalizeCwd(cwd),
460
+ sessions: parseCodexTmuxSessions(stdout, cwd),
461
+ };
462
+ } catch (error) {
463
+ return {
464
+ ok: false,
465
+ cli: "codex",
466
+ cwd: normalizeCwd(cwd),
467
+ reason: "tmux-unavailable",
468
+ error: error.message,
469
+ sessions: [],
470
+ };
471
+ }
472
+ }
473
+
356
474
  function sleep(ms) {
357
475
  return new Promise((resolve) => setTimeout(resolve, ms));
358
476
  }
@@ -461,23 +579,9 @@ function isWarningLine(line) {
461
579
  return /^\s*(?:\u26A0|\(warning\))/i.test(line);
462
580
  }
463
581
 
464
- function hasAssistantResponseLine(adapter, text) {
465
- return String(text)
466
- .split("\n")
467
- .some((line) => adapter.isResponseLine(line));
468
- }
469
-
470
582
  function promptLineIndex(lines, prompt) {
471
- if (!prompt) {
472
- return -1;
473
- }
474
-
475
583
  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
- ) {
584
+ if (composerLineMatchesPrompt(lines[index], prompt)) {
481
585
  return index;
482
586
  }
483
587
  }
@@ -489,6 +593,31 @@ function isComposerPromptLine(line) {
489
593
  return hasCodexComposerPrompt(line) || hasClaudeComposerPrompt(line);
490
594
  }
491
595
 
596
+ function promptComposerNeedle(prompt) {
597
+ const firstContentLine = String(prompt)
598
+ .split(/\r?\n/)
599
+ .map((line) => line.trim())
600
+ .find(Boolean);
601
+ return (firstContentLine ?? "").slice(0, PROMPT_COMPOSER_NEEDLE_LENGTH);
602
+ }
603
+
604
+ function composerLineMatchesPrompt(line, prompt) {
605
+ const needle = promptComposerNeedle(prompt);
606
+ return (
607
+ Boolean(needle) &&
608
+ isComposerPromptLine(line) &&
609
+ String(line).includes(needle)
610
+ );
611
+ }
612
+
613
+ function composerShowsPrompt(text, prompt) {
614
+ const activeComposerLine = String(text)
615
+ .split("\n")
616
+ .filter(isComposerPromptLine)
617
+ .at(-1);
618
+ return composerLineMatchesPrompt(activeComposerLine, prompt);
619
+ }
620
+
492
621
  function nextComposerPromptIndex(lines, startIndex) {
493
622
  for (
494
623
  let index = Math.max(0, startIndex + 1);
@@ -515,16 +644,6 @@ function responseLineIndexForPrompt(adapter, lines, promptIndex) {
515
644
  return -1;
516
645
  }
517
646
 
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
647
  function extractAssistantResponse(adapter, text, prompt = null) {
529
648
  const lines = String(text)
530
649
  .split("\n")
@@ -563,6 +682,56 @@ function extractAssistantResponse(adapter, text, prompt = null) {
563
682
  return response.join("\n").trim();
564
683
  }
565
684
 
685
+ function extractResponseSinceMarker({ beforeRaw, raw, doneMarker, adapter }) {
686
+ if (!doneMarker) {
687
+ return "";
688
+ }
689
+
690
+ const beforeLines = String(beforeRaw)
691
+ .split("\n")
692
+ .map((line) => line.replace(/\s+$/g, ""));
693
+ const rawLines = String(raw)
694
+ .split("\n")
695
+ .map((line) => line.replace(/\s+$/g, ""));
696
+ let sharedPrefixLength = 0;
697
+ while (
698
+ sharedPrefixLength < beforeLines.length &&
699
+ sharedPrefixLength < rawLines.length &&
700
+ beforeLines[sharedPrefixLength] === rawLines[sharedPrefixLength]
701
+ ) {
702
+ sharedPrefixLength += 1;
703
+ }
704
+
705
+ const newLines =
706
+ sharedPrefixLength > 0 ? rawLines.slice(sharedPrefixLength) : rawLines;
707
+ const markerIndex = newLines.findIndex((line) => line.trim() === doneMarker);
708
+ if (markerIndex === -1) {
709
+ return "";
710
+ }
711
+
712
+ const candidateLines = newLines.slice(0, markerIndex);
713
+ const responseStart = candidateLines.findIndex((line) =>
714
+ adapter.isResponseLine(line),
715
+ );
716
+ const responseLines =
717
+ responseStart === -1 ? candidateLines : candidateLines.slice(responseStart);
718
+
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();
733
+ }
734
+
566
735
  function normalizeClaudeTaskText(text) {
567
736
  return String(text)
568
737
  .replace(/[.…]+/g, " ")
@@ -685,19 +854,35 @@ function hasClaudeCompletedTaskListResponse(text, options = {}) {
685
854
  return extractClaudeCompletedTaskListResponse(text, options).length > 0;
686
855
  }
687
856
 
688
- function hasTmuxAssistantResponseAfterPrompt(
857
+ function hasTmuxAssistantResponseAfterCurrentPrompt(
689
858
  adapter,
690
859
  text,
691
860
  prompt,
692
861
  beforeText,
693
862
  ) {
863
+ if (adapter.cli === "claude") {
864
+ return hasClaudeCompletedTaskListResponse(text, {
865
+ beforeText,
866
+ prompt,
867
+ });
868
+ }
869
+
870
+ const lines = String(text).split("\n");
871
+ const promptIndex = promptLineIndex(lines, prompt);
872
+ if (promptIndex === -1) {
873
+ return false;
874
+ }
875
+
876
+ const beforePromptCount = String(beforeText)
877
+ .split("\n")
878
+ .filter((line) => composerLineMatchesPrompt(line, prompt)).length;
879
+ const currentPromptCount = lines.filter((line) =>
880
+ composerLineMatchesPrompt(line, prompt),
881
+ ).length;
882
+
694
883
  return (
695
- hasAssistantResponseAfterPrompt(adapter, text, prompt) ||
696
- (adapter.cli === "claude" &&
697
- hasClaudeCompletedTaskListResponse(text, {
698
- beforeText,
699
- prompt,
700
- }))
884
+ currentPromptCount > beforePromptCount &&
885
+ responseLineIndexForPrompt(adapter, lines, promptIndex) !== -1
701
886
  );
702
887
  }
703
888
 
@@ -912,9 +1097,21 @@ const ADAPTERS = {
912
1097
  codex: {
913
1098
  cli: "codex",
914
1099
  launchArgv: ["codex"],
915
- launchKeys: ["codex", "Enter"],
916
- resumeById: (id) => shellQuote(["codex", "resume", id]),
917
- resumeLast: () => shellQuote(["codex", "resume", "--last"]),
1100
+ launchKeys: ["codex --dangerously-bypass-approvals-and-sandbox", "Enter"],
1101
+ resumeById: (id) =>
1102
+ shellQuote([
1103
+ "codex",
1104
+ "resume",
1105
+ id,
1106
+ "--dangerously-bypass-approvals-and-sandbox",
1107
+ ]),
1108
+ resumeLast: () =>
1109
+ shellQuote([
1110
+ "codex",
1111
+ "resume",
1112
+ "--last",
1113
+ "--dangerously-bypass-approvals-and-sandbox",
1114
+ ]),
918
1115
  composerGlyph: "›",
919
1116
  isReady: isCodexReadyCapture,
920
1117
  contextPct: parseCodexContextPct,
@@ -996,6 +1193,39 @@ const ADAPTERS = {
996
1193
  },
997
1194
  };
998
1195
 
1196
+ function buildLaunchKeys(adapter, { model, effort } = {}) {
1197
+ const hasModel = model !== undefined;
1198
+ const hasEffort = effort !== undefined;
1199
+ if (!hasModel && !hasEffort) {
1200
+ return adapter.launchKeys;
1201
+ }
1202
+
1203
+ const overrideArgs = [];
1204
+ if (adapter.cli === "codex") {
1205
+ if (hasModel) {
1206
+ overrideArgs.push("-c", `model=${JSON.stringify(String(model))}`);
1207
+ }
1208
+ if (hasEffort) {
1209
+ overrideArgs.push(
1210
+ "-c",
1211
+ `model_reasoning_effort=${JSON.stringify(String(effort))}`,
1212
+ );
1213
+ }
1214
+ } else if (adapter.cli === "claude") {
1215
+ if (hasModel) {
1216
+ overrideArgs.push("--model", model);
1217
+ }
1218
+ if (hasEffort) {
1219
+ overrideArgs.push("--effort", effort);
1220
+ }
1221
+ } else {
1222
+ throw new Error(`Unsupported adapter: ${adapter.cli}`);
1223
+ }
1224
+
1225
+ const [command, ...remainingKeys] = adapter.launchKeys;
1226
+ return [`${command} ${shellQuote(overrideArgs)}`, ...remainingKeys];
1227
+ }
1228
+
999
1229
  async function dismissStartupScreens(adapter, remote, session) {
1000
1230
  let raw = "";
1001
1231
  let dismissed = false;
@@ -1258,6 +1488,8 @@ async function doStart(adapter, opts) {
1258
1488
  cwd,
1259
1489
  resume,
1260
1490
  resumeLast,
1491
+ model,
1492
+ effort,
1261
1493
  readyTimeoutMs,
1262
1494
  pollIntervalMs,
1263
1495
  } = opts;
@@ -1265,7 +1497,7 @@ async function doStart(adapter, opts) {
1265
1497
  ? [adapter.resumeById(resume), "Enter"]
1266
1498
  : resumeLast
1267
1499
  ? [adapter.resumeLast(), "Enter"]
1268
- : adapter.launchKeys;
1500
+ : buildLaunchKeys(adapter, { model, effort });
1269
1501
  const resumed = Boolean(resume || resumeLast);
1270
1502
  const resumeTarget = resume ?? (resumeLast ? "last" : null);
1271
1503
 
@@ -1276,6 +1508,16 @@ async function doStart(adapter, opts) {
1276
1508
  session,
1277
1509
  ...(cwd ? ["-c", cwd] : []),
1278
1510
  ]);
1511
+ // Default tmux history-limit (2000) is far too small for long tool-heavy
1512
+ // CLI sessions (file reads, test runs) — the actual response can scroll
1513
+ // out of scrollback before capturePane() runs, producing an empty extract.
1514
+ await runTmux(remote, [
1515
+ "set-option",
1516
+ "-t",
1517
+ session,
1518
+ "history-limit",
1519
+ "100000",
1520
+ ]);
1279
1521
  await runTmux(remote, ["send-keys", "-t", session, ...launchKeys]);
1280
1522
  await dismissStartupScreens(adapter, remote, session);
1281
1523
 
@@ -1298,13 +1540,49 @@ async function doStart(adapter, opts) {
1298
1540
  }
1299
1541
 
1300
1542
  async function doAskViaTmux(adapter, opts) {
1301
- const { session, prompt, remote, timeoutMs, settleMs, pollIntervalMs } = opts;
1543
+ const {
1544
+ session,
1545
+ prompt,
1546
+ remote,
1547
+ timeoutMs,
1548
+ settleMs,
1549
+ pollIntervalMs,
1550
+ doneMarker,
1551
+ } = opts;
1302
1552
  const beforeRaw = await capturePane(remote, session);
1303
1553
  const contextPctBefore = adapter.contextPct(beforeRaw);
1304
1554
 
1305
- await runTmux(remote, ["send-keys", "-t", session, "--", prompt]);
1555
+ const bufferName = `tfx-live-prompt-${session}-${Date.now()}`;
1556
+ await runTmux(remote, ["set-buffer", "-b", bufferName, "--", prompt]);
1557
+ await runTmux(remote, [
1558
+ "paste-buffer",
1559
+ "-b",
1560
+ bufferName,
1561
+ "-d",
1562
+ "-t",
1563
+ session,
1564
+ ]);
1306
1565
  await sleep(settleMs);
1307
- await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
1566
+
1567
+ let promptSubmitted = !doneMarker;
1568
+ if (doneMarker) {
1569
+ for (let attempt = 1; attempt <= PROMPT_SUBMIT_MAX_ATTEMPTS; attempt += 1) {
1570
+ await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
1571
+ await sleep(PROMPT_SUBMIT_RETRY_DELAY_MS);
1572
+ const submissionVisible = await captureVisible(remote, session);
1573
+ if (!composerShowsPrompt(submissionVisible, prompt)) {
1574
+ promptSubmitted = true;
1575
+ break;
1576
+ }
1577
+ }
1578
+ if (!promptSubmitted) {
1579
+ throw new Error(
1580
+ `Prompt did not submit after ${PROMPT_SUBMIT_MAX_ATTEMPTS} attempts`,
1581
+ );
1582
+ }
1583
+ } else {
1584
+ await runTmux(remote, ["send-keys", "-t", session, "Enter"]);
1585
+ }
1308
1586
 
1309
1587
  const startedAt = Date.now();
1310
1588
  let raw = "";
@@ -1316,18 +1594,36 @@ async function doAskViaTmux(adapter, opts) {
1316
1594
  let done = false;
1317
1595
 
1318
1596
  while (Date.now() - startedAt < timeoutMs) {
1319
- visible = await captureVisible(remote, session);
1597
+ visible = doneMarker
1598
+ ? await capturePane(remote, session)
1599
+ : await captureVisible(remote, session);
1320
1600
  contextPctAfter = adapter.contextPct(visible);
1321
1601
 
1322
1602
  quietPolls = visible === previousVisible ? quietPolls + 1 : 0;
1323
1603
  previousVisible = visible;
1324
1604
 
1325
- const ready = adapter.isReady(visible);
1326
- const doneSignal =
1605
+ const markerDone =
1606
+ promptSubmitted &&
1607
+ Boolean(
1608
+ extractResponseSinceMarker({
1609
+ beforeRaw,
1610
+ raw: visible,
1611
+ doneMarker,
1612
+ adapter,
1613
+ }),
1614
+ );
1615
+ const fallbackDone =
1616
+ !doneMarker &&
1327
1617
  !adapter.isBusy(visible) &&
1328
- ready &&
1329
- hasTmuxAssistantResponseAfterPrompt(adapter, visible, prompt, beforeRaw);
1330
- if (doneSignal && quietPolls >= FALLBACK_QUIET_POLLS) {
1618
+ adapter.isReady(visible) &&
1619
+ quietPolls >= FALLBACK_QUIET_POLLS &&
1620
+ hasTmuxAssistantResponseAfterCurrentPrompt(
1621
+ adapter,
1622
+ await capturePane(remote, session),
1623
+ prompt,
1624
+ beforeRaw,
1625
+ );
1626
+ if (markerDone || fallbackDone) {
1331
1627
  done = true;
1332
1628
  break;
1333
1629
  }
@@ -1336,7 +1632,20 @@ async function doAskViaTmux(adapter, opts) {
1336
1632
  }
1337
1633
 
1338
1634
  raw = await capturePane(remote, session);
1339
- response = extractAssistantResponse(adapter, raw, prompt);
1635
+ const markerResponse = promptSubmitted
1636
+ ? extractResponseSinceMarker({
1637
+ beforeRaw,
1638
+ raw,
1639
+ doneMarker,
1640
+ adapter,
1641
+ })
1642
+ : "";
1643
+ if (markerResponse) {
1644
+ response = markerResponse;
1645
+ done = true;
1646
+ } else {
1647
+ response = extractAssistantResponse(adapter, raw, prompt);
1648
+ }
1340
1649
  if (!response && adapter.cli === "claude") {
1341
1650
  response = extractClaudeCompletedTaskListResponse(raw, {
1342
1651
  beforeText: beforeRaw,
@@ -1767,6 +2076,8 @@ function startOpts(flags) {
1767
2076
  cwd: flags.cwd,
1768
2077
  resume: flags.resume,
1769
2078
  resumeLast: Object.hasOwn(flags, "resume-last"),
2079
+ model: flags.model,
2080
+ effort: flags.effort,
1770
2081
  readyTimeoutMs: secondsFlag(
1771
2082
  flags,
1772
2083
  "ready-timeout",
@@ -1887,11 +2198,25 @@ async function probe(flags) {
1887
2198
  );
1888
2199
  }
1889
2200
 
1890
- function startOptsForSession(flags, session) {
2201
+ async function listSessions(flags) {
2202
+ const adapter = selectAdapter(flags);
2203
+ if (adapter.cli !== "codex") {
2204
+ throw new Error("list-sessions currently supports only --cli codex");
2205
+ }
2206
+ printJson(await discoverCodexTmuxSessions({ cwd: flags.cwd }));
2207
+ }
2208
+
2209
+ function startOptsForSession(flags, session, side) {
1891
2210
  return {
1892
2211
  session,
1893
2212
  remote: flags.remote,
1894
2213
  cwd: flags.cwd,
2214
+ ...(side
2215
+ ? {
2216
+ model: flags[`model-${side}`],
2217
+ effort: flags[`effort-${side}`],
2218
+ }
2219
+ : {}),
1895
2220
  readyTimeoutMs: secondsFlag(
1896
2221
  flags,
1897
2222
  "ready-timeout",
@@ -2054,17 +2379,279 @@ function peerMode(flags) {
2054
2379
  }
2055
2380
 
2056
2381
  function peerPrompt(mode, hopIndex, previous) {
2382
+ let prompt;
2057
2383
  if (mode === "counting") {
2058
2384
  if (hopIndex === 0) {
2059
- return "Reply with only the integer 1.";
2385
+ prompt = "Reply with only the integer 1.";
2386
+ } else {
2387
+ prompt = `The current integer is ${previous}. Add 1 to it and reply with ONLY the resulting integer, nothing else.`;
2388
+ }
2389
+ } else if (hopIndex === 0) {
2390
+ prompt = previous;
2391
+ } else {
2392
+ prompt = `The other party said: ${previous}\nReply briefly in 1-2 sentences and ask one short follow-up question.`;
2393
+ }
2394
+
2395
+ return [
2396
+ prompt,
2397
+ `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.`,
2398
+ ].join("\n");
2399
+ }
2400
+
2401
+ function buildPeerClosurePrompt(previous) {
2402
+ return [
2403
+ "This is the final closure turn. Based on the discussion so far, declare whether a final agreement was reached.",
2404
+ "First reply with exactly one JSON object and no markdown fences:",
2405
+ '{"agreement_status":"complete|partial","unresolved_questions":[],"needs_more_rounds":false,"summary":"short final agreement"}',
2406
+ "Use agreement_status=complete only when no unresolved question remains and no more rounds are needed.",
2407
+ `The other party's latest response was: ${previous ?? "(none)"}`,
2408
+ `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.`,
2409
+ ].join("\n");
2410
+ }
2411
+
2412
+ function isCompleteDeclaration(value) {
2413
+ return ["complete", "completed", "agreed", "합의완료", "완료"].includes(
2414
+ String(value ?? "")
2415
+ .trim()
2416
+ .toLowerCase(),
2417
+ );
2418
+ }
2419
+
2420
+ function unstructuredPeerClosure() {
2421
+ return {
2422
+ structured: false,
2423
+ declaredComplete: false,
2424
+ unresolvedQuestions: [],
2425
+ needsMoreRounds: null,
2426
+ };
2427
+ }
2428
+
2429
+ function parsePeerClosure(response) {
2430
+ const text = String(response ?? "").trim();
2431
+ const fenced = text.match(/```(?:json)?\s*({[\s\S]*})\s*```/i)?.[1];
2432
+ const firstBrace = text.indexOf("{");
2433
+ const lastBrace = text.lastIndexOf("}");
2434
+ const objectText =
2435
+ fenced ??
2436
+ (firstBrace >= 0 && lastBrace > firstBrace
2437
+ ? text.slice(firstBrace, lastBrace + 1)
2438
+ : null);
2439
+
2440
+ if (objectText) {
2441
+ try {
2442
+ // Terminal word-wrap can inject a raw newline + indent mid-token in
2443
+ // compact JSON (e.g. "f\n alse" for "false") since there's often no
2444
+ // space nearby to wrap at. Try the raw text first, then retry with
2445
+ // wrap artifacts collapsed before giving up as unstructured.
2446
+ let parsed;
2447
+ try {
2448
+ parsed = JSON.parse(objectText);
2449
+ } catch {
2450
+ parsed = JSON.parse(objectText.replace(/\n[ \t]*/g, ""));
2451
+ }
2452
+ const unresolvedQuestions = parsed.unresolved_questions;
2453
+ const needsMoreRounds = parsed.needs_more_rounds;
2454
+ const structured =
2455
+ Object.hasOwn(parsed, "agreement_status") &&
2456
+ Array.isArray(unresolvedQuestions) &&
2457
+ typeof needsMoreRounds === "boolean";
2458
+ if (structured) {
2459
+ return {
2460
+ structured: true,
2461
+ declaredComplete: isCompleteDeclaration(parsed.agreement_status),
2462
+ unresolvedQuestions: unresolvedQuestions.map(String),
2463
+ needsMoreRounds,
2464
+ };
2465
+ }
2466
+ return unstructuredPeerClosure();
2467
+ } catch {
2468
+ return unstructuredPeerClosure();
2060
2469
  }
2061
- return `The current integer is ${previous}. Add 1 to it and reply with ONLY the resulting integer, nothing else.`;
2062
2470
  }
2063
2471
 
2064
- if (hopIndex === 0) {
2065
- return previous;
2472
+ const status = text.match(
2473
+ /(?:agreement_status|agreement status|합의상태|합의 상태)\s*[:=]\s*["']?([^\n,"'}]+)/i,
2474
+ )?.[1];
2475
+ const unresolved = text.match(
2476
+ /(?:unresolved_questions|unresolved questions|미해결 쟁점)\s*[:=]\s*([^\n]+)/i,
2477
+ )?.[1];
2478
+ const needsMore = text.match(
2479
+ /(?:needs_more_rounds|needs more rounds|추가 라운드 필요)\s*[:=]\s*(true|false)/i,
2480
+ )?.[1];
2481
+ const structured =
2482
+ status !== undefined && unresolved !== undefined && needsMore !== undefined;
2483
+ const unresolvedQuestions = /^(?:\[\s*\]|none|없음)$/i.test(
2484
+ String(unresolved ?? "").trim(),
2485
+ )
2486
+ ? []
2487
+ : String(unresolved ?? "")
2488
+ .replace(/^\[|\]$/g, "")
2489
+ .split(/[,;|]/)
2490
+ .map((item) => item.trim())
2491
+ .filter(Boolean);
2492
+
2493
+ return {
2494
+ structured,
2495
+ declaredComplete: isCompleteDeclaration(status),
2496
+ unresolvedQuestions,
2497
+ needsMoreRounds:
2498
+ needsMore === undefined ? null : needsMore.toLowerCase() === "true",
2499
+ };
2500
+ }
2501
+
2502
+ function derivePeerStatus({ hopsCompleted, closure, exitReason = null }) {
2503
+ if (hopsCompleted < 1) return "failed";
2504
+ if (exitReason) return "partial";
2505
+ if (
2506
+ closure?.structured === true &&
2507
+ closure.declaredComplete === true &&
2508
+ closure.needsMoreRounds === false &&
2509
+ closure.unresolvedQuestions.length === 0
2510
+ ) {
2511
+ return "complete";
2066
2512
  }
2067
- return `The other party said: ${previous}\nReply briefly in 1-2 sentences and ask one short follow-up question.`;
2513
+ return "partial";
2514
+ }
2515
+
2516
+ function classifyPeerExitReason(error) {
2517
+ const fingerprint = [error?.name, error?.code, error?.message]
2518
+ .filter(Boolean)
2519
+ .join(" ")
2520
+ .toLowerCase();
2521
+ return /timeout|timed out|etimedout|aborterror/.test(fingerprint)
2522
+ ? "timeout"
2523
+ : "transport_error";
2524
+ }
2525
+
2526
+ function peerSignalExit(signal) {
2527
+ return signal === "SIGTERM"
2528
+ ? { exitReason: "terminated", exitCode: 143 }
2529
+ : { exitReason: "user_interrupt", exitCode: 130 };
2530
+ }
2531
+
2532
+ function createPeerSignalController({
2533
+ output,
2534
+ persistTranscript,
2535
+ persistStatus,
2536
+ stopSessions,
2537
+ printOutput = printJson,
2538
+ exitProcess = (code) => process.exit(code),
2539
+ timeoutMs = 3000,
2540
+ now = Date.now,
2541
+ setTimer = setTimeout,
2542
+ clearTimer = clearTimeout,
2543
+ }) {
2544
+ let signalCount = 0;
2545
+ let settled = false;
2546
+
2547
+ const recordArtifactError = (kind, error) => {
2548
+ output.artifact_errors ??= [];
2549
+ output.artifact_errors.push(`${kind}: ${error.message}`);
2550
+ };
2551
+
2552
+ return {
2553
+ async handle(signal) {
2554
+ if (settled) return;
2555
+ signalCount += 1;
2556
+ const { exitReason, exitCode } = peerSignalExit(signal);
2557
+ if (signalCount > 1) {
2558
+ settled = true;
2559
+ exitProcess(exitCode);
2560
+ return;
2561
+ }
2562
+
2563
+ const startedAt = now();
2564
+ output.status = "aborted";
2565
+ output.exit_reason = exitReason;
2566
+ output.hops_completed = output.hops.length;
2567
+
2568
+ try {
2569
+ persistTranscript(output);
2570
+ } catch (error) {
2571
+ recordArtifactError("transcript", error);
2572
+ }
2573
+ try {
2574
+ persistStatus(output);
2575
+ } catch (error) {
2576
+ recordArtifactError("status", error);
2577
+ }
2578
+
2579
+ const remainingMs = Math.max(0, timeoutMs - (now() - startedAt));
2580
+ let timer;
2581
+ const graceExpired = new Promise((resolve) => {
2582
+ timer = setTimer(() => resolve("timeout"), remainingMs);
2583
+ });
2584
+ await Promise.race([
2585
+ Promise.resolve()
2586
+ .then(stopSessions)
2587
+ .then(
2588
+ () => "stopped",
2589
+ () => "stop_error",
2590
+ ),
2591
+ graceExpired,
2592
+ ]);
2593
+ clearTimer(timer);
2594
+
2595
+ if (settled) return;
2596
+ settled = true;
2597
+ printOutput(output);
2598
+ exitProcess(exitCode);
2599
+ },
2600
+ dispose() {
2601
+ settled = true;
2602
+ },
2603
+ };
2604
+ }
2605
+
2606
+ function peerArtifactPaths(sessionA, sessionB) {
2607
+ const preferredDir =
2608
+ process.env.TFX_LIVE_ARTIFACT_DIR ??
2609
+ pathJoin(homedir(), ".claude", "cache", "triflux", "tfx-live", "peer-runs");
2610
+ const fallbackDir = pathJoin(tmpdir(), "triflux-live");
2611
+ let artifactDir = preferredDir;
2612
+ try {
2613
+ mkdirSync(artifactDir, { recursive: true });
2614
+ } catch {
2615
+ artifactDir = fallbackDir;
2616
+ mkdirSync(artifactDir, { recursive: true });
2617
+ }
2618
+ const safe = (value) =>
2619
+ String(value)
2620
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
2621
+ .slice(0, 48);
2622
+ const runId = `peer-${safe(sessionA)}-${safe(sessionB)}-${Date.now()}-${process.pid}`;
2623
+ return {
2624
+ transcriptPath: pathJoin(artifactDir, `${runId}.transcript.json`),
2625
+ statusPath: pathJoin(artifactDir, `${runId}.status.json`),
2626
+ };
2627
+ }
2628
+
2629
+ function persistPeerTranscript(paths, output) {
2630
+ writeFileSync(
2631
+ paths.transcriptPath,
2632
+ `${JSON.stringify(
2633
+ {
2634
+ mode: output.mode,
2635
+ rounds: output.rounds,
2636
+ cliA: output.cliA,
2637
+ cliB: output.cliB,
2638
+ hops_completed: output.hops.length,
2639
+ hops: output.hops,
2640
+ },
2641
+ null,
2642
+ 2,
2643
+ )}\n`,
2644
+ "utf8",
2645
+ );
2646
+ }
2647
+
2648
+ function persistPeerStatus(paths, output) {
2649
+ const { hops: _hops, ...status } = output;
2650
+ writeFileSync(
2651
+ paths.statusPath,
2652
+ `${JSON.stringify(status, null, 2)}\n`,
2653
+ "utf8",
2654
+ );
2068
2655
  }
2069
2656
 
2070
2657
  function sideFlag(flags, side, name, fallback = undefined) {
@@ -2126,7 +2713,7 @@ function peerSideBaseOpts(flags, side, adapter, session) {
2126
2713
  }
2127
2714
 
2128
2715
  function askOptsFromPeerBase(base, prompt) {
2129
- return { ...base, prompt };
2716
+ return { ...base, prompt, doneMarker: PEER_HOP_DONE_MARKER };
2130
2717
  }
2131
2718
 
2132
2719
  function shouldPrestartPeerSide(base) {
@@ -2161,20 +2748,51 @@ async function peer(flags) {
2161
2748
  const hops = [];
2162
2749
  const numbers = [];
2163
2750
  const stopSpecs = [];
2751
+ const artifactPaths = peerArtifactPaths(sessionA, sessionB);
2752
+ const output = {
2753
+ mode,
2754
+ rounds,
2755
+ cliA: adapterA.cli,
2756
+ cliB: adapterB.cli,
2757
+ transportA: baseA.transport,
2758
+ transportB: baseB.transport,
2759
+ hops,
2760
+ transcript_path: artifactPaths.transcriptPath,
2761
+ status_path: artifactPaths.statusPath,
2762
+ };
2763
+ if (mode === "counting") {
2764
+ output.numbers = numbers;
2765
+ }
2164
2766
  let previous =
2165
2767
  mode === "freeform"
2166
2768
  ? (flags.seed ??
2167
2769
  "Introduce yourself in one sentence, then ask the other party one short question.")
2168
2770
  : null;
2169
2771
  let primaryError = null;
2772
+ let stopError = null;
2773
+ let closure = null;
2774
+ const signalController = createPeerSignalController({
2775
+ output,
2776
+ persistTranscript: (value) => persistPeerTranscript(artifactPaths, value),
2777
+ persistStatus: (value) => persistPeerStatus(artifactPaths, value),
2778
+ stopSessions: () => stopAfterLifecycle(stopSpecs),
2779
+ });
2780
+ const onSigint = () => {
2781
+ void signalController.handle("SIGINT");
2782
+ };
2783
+ const onSigterm = () => {
2784
+ void signalController.handle("SIGTERM");
2785
+ };
2786
+ process.on("SIGINT", onSigint);
2787
+ process.on("SIGTERM", onSigterm);
2170
2788
 
2171
2789
  try {
2172
2790
  if (shouldPrestartPeerSide(baseA)) {
2173
- await doStart(adapterA, startOptsForSession(flags, sessionA));
2791
+ await doStart(adapterA, startOptsForSession(flags, sessionA, "a"));
2174
2792
  addStopSpecOnce(stopSpecs, adapterA, baseA);
2175
2793
  }
2176
2794
  if (shouldPrestartPeerSide(baseB)) {
2177
- await doStart(adapterB, startOptsForSession(flags, sessionB));
2795
+ await doStart(adapterB, startOptsForSession(flags, sessionB, "b"));
2178
2796
  addStopSpecOnce(stopSpecs, adapterB, baseB);
2179
2797
  }
2180
2798
 
@@ -2186,11 +2804,26 @@ async function peer(flags) {
2186
2804
  const isA = hopIndex % 2 === 0;
2187
2805
  const adapter = isA ? adapterA : adapterB;
2188
2806
  const base = isA ? baseA : baseB;
2189
- const sent = peerPrompt(mode, hopIndex, previous);
2807
+ const isClosure = hopIndex === rounds * 2 - 1;
2808
+ const sent = isClosure
2809
+ ? buildPeerClosurePrompt(previous)
2810
+ : peerPrompt(mode, hopIndex, previous);
2190
2811
  const result = await doAsk(adapter, askOptsFromPeerBase(base, sent));
2191
2812
  if (result.tmuxStartedOnDemand) {
2192
2813
  addStopSpecOnce(stopSpecs, adapter, base);
2193
2814
  }
2815
+ if (
2816
+ result.done !== true ||
2817
+ result.error ||
2818
+ !String(result.response ?? "").trim()
2819
+ ) {
2820
+ throw new Error(
2821
+ (result.timedOut
2822
+ ? `Peer hop timed out at hop ${hopIndex + 1}`
2823
+ : result.error) ??
2824
+ `Empty response from ${adapter.cli} at hop ${hopIndex + 1}`,
2825
+ );
2826
+ }
2194
2827
 
2195
2828
  hops.push({
2196
2829
  hop: hopIndex + 1,
@@ -2205,40 +2838,70 @@ async function peer(flags) {
2205
2838
  aborted: result.aborted === true ? true : undefined,
2206
2839
  reason: result.reason,
2207
2840
  });
2841
+ if (isClosure) {
2842
+ closure = parsePeerClosure(result.response);
2843
+ }
2208
2844
 
2209
2845
  if (mode === "counting") {
2210
2846
  const parsed = parseFirstInteger(result.response);
2211
- if (parsed === null) {
2847
+ if (parsed === null && !isClosure) {
2212
2848
  throw new Error(
2213
2849
  `Could not parse integer from ${adapter.cli} response at hop ${hopIndex + 1}`,
2214
2850
  );
2215
2851
  }
2216
- numbers.push(parsed);
2217
- previous = parsed;
2852
+ if (parsed !== null) {
2853
+ numbers.push(parsed);
2854
+ previous = parsed;
2855
+ } else {
2856
+ previous = result.response;
2857
+ }
2218
2858
  } else {
2219
2859
  previous = result.response;
2220
2860
  }
2221
2861
  }
2222
2862
  } catch (error) {
2223
2863
  primaryError = error;
2224
- } finally {
2225
- await stopAfterLifecycle(stopSpecs, primaryError);
2226
2864
  }
2227
2865
 
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;
2866
+ try {
2867
+ await stopAfterLifecycle(stopSpecs);
2868
+ } catch (error) {
2869
+ stopError = error;
2870
+ }
2871
+ signalController.dispose();
2872
+ process.off("SIGINT", onSigint);
2873
+ process.off("SIGTERM", onSigterm);
2874
+
2875
+ const terminalError = primaryError ?? stopError;
2876
+ const exitReason = terminalError
2877
+ ? classifyPeerExitReason(terminalError)
2878
+ : null;
2879
+ output.hops_completed = hops.length;
2880
+ output.closure = closure;
2881
+ output.status = derivePeerStatus({
2882
+ hopsCompleted: hops.length,
2883
+ closure,
2884
+ exitReason,
2885
+ });
2886
+ if (exitReason) {
2887
+ output.exit_reason = exitReason;
2888
+ output.error = terminalError.message;
2889
+ }
2890
+ output.stoppedA = stopError === null;
2891
+ output.stoppedB = stopError === null;
2892
+
2893
+ try {
2894
+ persistPeerTranscript(artifactPaths, output);
2895
+ } catch (error) {
2896
+ output.artifact_errors ??= [];
2897
+ output.artifact_errors.push(`transcript: ${error.message}`);
2898
+ }
2899
+ try {
2900
+ persistPeerStatus(artifactPaths, output);
2901
+ } catch (error) {
2902
+ output.artifact_errors ??= [];
2903
+ output.artifact_errors.push(`status: ${error.message}`);
2239
2904
  }
2240
- output.stoppedA = true;
2241
- output.stoppedB = true;
2242
2905
  printJson(output);
2243
2906
  }
2244
2907
 
@@ -2344,6 +3007,8 @@ async function main() {
2344
3007
  await interrupt(flags);
2345
3008
  } else if (command === "probe") {
2346
3009
  await probe(flags);
3010
+ } else if (command === "list-sessions") {
3011
+ await listSessions(flags);
2347
3012
  } else if (command === "converse") {
2348
3013
  await converse(flags);
2349
3014
  } else if (command === "goal-driven") {
@@ -2361,12 +3026,22 @@ async function main() {
2361
3026
 
2362
3027
  export {
2363
3028
  ADAPTERS,
3029
+ buildLaunchKeys,
3030
+ buildPeerClosurePrompt,
2364
3031
  buildRemoteLiveCommand,
2365
3032
  callRemoteLive,
3033
+ classifyPeerExitReason,
3034
+ createPeerSignalController,
2366
3035
  ctoHygieneNotify,
3036
+ derivePeerStatus,
3037
+ discoverCodexTmuxSessions,
3038
+ doAskViaTmux,
2367
3039
  extractAssistantResponse,
2368
3040
  extractClaudeCompletedTaskListResponse,
3041
+ extractResponseSinceMarker,
2369
3042
  hasClaudeCompletedTaskListResponse,
3043
+ parseCodexTmuxSessions,
3044
+ parsePeerClosure,
2370
3045
  parseRemoteLiveJson,
2371
3046
  resolveAskTransport,
2372
3047
  };