baxian 1.2.59 → 1.2.60

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 (103) hide show
  1. package/dist/agent/branch.d.ts.map +1 -1
  2. package/dist/agent/branch.js +19 -13
  3. package/dist/agent/branch.js.map +1 -1
  4. package/dist/agent/dispatch-reconciler.d.ts +45 -0
  5. package/dist/agent/dispatch-reconciler.d.ts.map +1 -0
  6. package/dist/agent/dispatch-reconciler.js +513 -0
  7. package/dist/agent/dispatch-reconciler.js.map +1 -0
  8. package/dist/agent/manager.d.ts +89 -8
  9. package/dist/agent/manager.d.ts.map +1 -1
  10. package/dist/agent/manager.js +975 -368
  11. package/dist/agent/manager.js.map +1 -1
  12. package/dist/agent/pane-streamer-manager.d.ts +70 -0
  13. package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
  14. package/dist/agent/pane-streamer-manager.js +487 -5
  15. package/dist/agent/pane-streamer-manager.js.map +1 -1
  16. package/dist/agent/pane-streamer.d.ts +48 -1
  17. package/dist/agent/pane-streamer.d.ts.map +1 -1
  18. package/dist/agent/pane-streamer.js +338 -15
  19. package/dist/agent/pane-streamer.js.map +1 -1
  20. package/dist/agent/preflight.d.ts.map +1 -1
  21. package/dist/agent/preflight.js +18 -8
  22. package/dist/agent/preflight.js.map +1 -1
  23. package/dist/agent/repo-store.d.ts +3 -0
  24. package/dist/agent/repo-store.d.ts.map +1 -1
  25. package/dist/agent/repo-store.js +192 -67
  26. package/dist/agent/repo-store.js.map +1 -1
  27. package/dist/agent/review-transport.d.ts.map +1 -1
  28. package/dist/agent/review-transport.js +4 -2
  29. package/dist/agent/review-transport.js.map +1 -1
  30. package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
  31. package/dist/agent/tmux-probe-poller.js +29 -20
  32. package/dist/agent/tmux-probe-poller.js.map +1 -1
  33. package/dist/agent/tmux.d.ts +79 -32
  34. package/dist/agent/tmux.d.ts.map +1 -1
  35. package/dist/agent/tmux.js +435 -225
  36. package/dist/agent/tmux.js.map +1 -1
  37. package/dist/api/config.d.ts.map +1 -1
  38. package/dist/api/config.js +10 -0
  39. package/dist/api/config.js.map +1 -1
  40. package/dist/api/projects.d.ts.map +1 -1
  41. package/dist/api/projects.js +186 -94
  42. package/dist/api/projects.js.map +1 -1
  43. package/dist/api/tasks.d.ts.map +1 -1
  44. package/dist/api/tasks.js +5 -1
  45. package/dist/api/tasks.js.map +1 -1
  46. package/dist/app.d.ts +2 -0
  47. package/dist/app.d.ts.map +1 -1
  48. package/dist/app.js +1 -0
  49. package/dist/app.js.map +1 -1
  50. package/dist/cli.d.ts.map +1 -1
  51. package/dist/cli.js +11 -3
  52. package/dist/cli.js.map +1 -1
  53. package/dist/config/hot-reload.d.ts.map +1 -1
  54. package/dist/config/hot-reload.js +1 -0
  55. package/dist/config/hot-reload.js.map +1 -1
  56. package/dist/config/loader.d.ts.map +1 -1
  57. package/dist/config/loader.js +48 -3
  58. package/dist/config/loader.js.map +1 -1
  59. package/dist/config/validator.js +3 -0
  60. package/dist/config/validator.js.map +1 -1
  61. package/dist/errors.d.ts +2 -1
  62. package/dist/errors.d.ts.map +1 -1
  63. package/dist/errors.js +3 -1
  64. package/dist/errors.js.map +1 -1
  65. package/dist/event/handlers.d.ts.map +1 -1
  66. package/dist/event/handlers.js +138 -41
  67. package/dist/event/handlers.js.map +1 -1
  68. package/dist/github/pr-conversation-cache.d.ts +41 -0
  69. package/dist/github/pr-conversation-cache.d.ts.map +1 -0
  70. package/dist/github/pr-conversation-cache.js +105 -0
  71. package/dist/github/pr-conversation-cache.js.map +1 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +14 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/shared/constants.d.ts +6 -0
  76. package/dist/shared/constants.d.ts.map +1 -1
  77. package/dist/shared/constants.js +6 -0
  78. package/dist/shared/constants.js.map +1 -1
  79. package/dist/shared/types.d.ts +4 -0
  80. package/dist/shared/types.d.ts.map +1 -1
  81. package/dist/shared/types.js.map +1 -1
  82. package/dist/state/agent-store.d.ts +5 -1
  83. package/dist/state/agent-store.d.ts.map +1 -1
  84. package/dist/state/agent-store.js +29 -15
  85. package/dist/state/agent-store.js.map +1 -1
  86. package/dist/state/lock.d.ts +9 -0
  87. package/dist/state/lock.d.ts.map +1 -1
  88. package/dist/state/lock.js +24 -1
  89. package/dist/state/lock.js.map +1 -1
  90. package/dist/state/task-store.js +2 -2
  91. package/dist/state/task-store.js.map +1 -1
  92. package/dist/terminal/attach.d.ts +10 -1
  93. package/dist/terminal/attach.d.ts.map +1 -1
  94. package/dist/terminal/attach.js +40 -20
  95. package/dist/terminal/attach.js.map +1 -1
  96. package/dist/terminal/stream-ws.d.ts.map +1 -1
  97. package/dist/terminal/stream-ws.js +27 -0
  98. package/dist/terminal/stream-ws.js.map +1 -1
  99. package/dist/web/assets/{index-DfcbGriX.css → index-CwYBVTyn.css} +1 -1
  100. package/dist/web/assets/index-DtnDvjOU.js +16 -0
  101. package/dist/web/index.html +2 -2
  102. package/package.json +1 -1
  103. package/dist/web/assets/index-DskVWe7e.js +0 -13
@@ -1,20 +1,55 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { shellQuote, SshRunner } from './runner.js';
3
- import { execOutcomeUnknown } from './net-exec.js';
2
+ import { shellQuote } from './runner.js';
3
+ import { execOutcomeUnknown, isTransientNetworkFailure } from './net-exec.js';
4
4
  import { isHorizontalRule, tailNonEmpty } from './detect/region.js';
5
5
  import { MAX_PROMPT_BYTES } from './prompt.js';
6
6
  const run = (runner, cmd, opts) => runner['exec'](cmd, opts);
7
7
  export const CREATION_NONCE_ENV = 'BAXIAN_CREATION_NONCE';
8
8
  const SESSION_REF_FORMAT = '#{pid}|#{start_time}|#{session_id}';
9
- const STALE_SERVER_MARKER = 'BX_STALE_SERVER';
10
9
  const REFUSED_MARKER = 'BX_KILL_REFUSED';
11
10
  const TARGET_GONE_MARKER = 'BX_TARGET_GONE';
12
- // Values embedded in tmux command strings; reject rather than escape what tmux quoting can't hold.
13
- function tmuxSingleQuote(value) {
14
- if (value.includes("'") || value.includes('\n')) {
15
- throw new Error(`tmux option value ${JSON.stringify(value)} contains unsupported characters`);
11
+ const PANE_OK_MARKER = 'BX_PANE_OK';
12
+ export class PaneGoneError extends Error {
13
+ target;
14
+ constructor(target, detail) {
15
+ super(`tmux target ${target} is gone or no longer owned: ${detail}`);
16
+ this.target = target;
17
+ this.name = 'PaneGoneError';
16
18
  }
17
- return `'${value}'`;
19
+ }
20
+ function assertPaneId(paneId) {
21
+ if (!/^%\d+$/.test(paneId)) {
22
+ throw new Error(`tmux: malformed pane id ${JSON.stringify(paneId)}`);
23
+ }
24
+ }
25
+ function assertPaneRef(pane) {
26
+ assertSessionRef(pane.session);
27
+ assertPaneId(pane.paneId);
28
+ assertPlainSessionName(pane.claim);
29
+ }
30
+ function generationCond(ref) {
31
+ return `#{&&:#{==:#{pid},${ref.serverPid}},#{==:#{start_time},${ref.serverStart}}}`;
32
+ }
33
+ function sessionCond(ref, claim) {
34
+ return `#{&&:#{&&:${generationCond(ref)},#{==:#{session_id},${ref.sessionId}}},#{==:#{@baxian-agent-id},${claim}}}`;
35
+ }
36
+ function paneCond(pane) {
37
+ const sess = `#{&&:#{==:#{session_id},${pane.session.sessionId}},#{==:#{pane_id},${pane.paneId}}}`;
38
+ return `#{&&:#{&&:${generationCond(pane.session)},${sess}},#{==:#{@baxian-agent-id},${pane.claim}}}`;
39
+ }
40
+ function assertPlainFormat(fmt) {
41
+ if (fmt.includes("'") || fmt.includes('\n')) {
42
+ throw new Error(`tmux format ${JSON.stringify(fmt)} contains unsupported characters`);
43
+ }
44
+ }
45
+ // Newline/NUL cannot survive tmux command re-parsing; everything else round-trips via '\'' splicing.
46
+ export function tmuxQuote(value) {
47
+ if (value.includes('\n') || value.includes('\0')) {
48
+ throw new Error(`tmux argument ${JSON.stringify(value)} contains unsupported characters (newline/NUL)`);
49
+ }
50
+ if (value === '')
51
+ return "''";
52
+ return `'${value.replace(/'/g, `'\\''`)}'`;
18
53
  }
19
54
  function assertSessionRef(ref) {
20
55
  if (!/^\$\d+$/.test(ref.sessionId) || !/^\d+$/.test(ref.serverPid) || !/^\d+$/.test(ref.serverStart)) {
@@ -33,6 +68,82 @@ function assertPlainSessionName(name) {
33
68
  throw new Error(`tmux session name ${JSON.stringify(name)} contains unsupported characters`);
34
69
  }
35
70
  }
71
+ // One read carries geometry, owner state, non-reusable session identity, the
72
+ // session claim, and a feature probe evaluated by the same server that will
73
+ // evaluate write guards (plus its version for the legacy determination).
74
+ const WINDOW_GEOMETRY_FORMAT = '#{window_width} #{window_height} #{status} #{window-size} ' +
75
+ '#{@bx_owner_gen}|#{pid}|#{start_time}|#{session_id}|#{@baxian-agent-id}|#{version}|#{e|<=:1,2}';
76
+ const TMUX_VERSION_RE = /^(?:next-)?(\d+)\.(\d+)/;
77
+ export function classifyOwnerWriteCapability(versionRaw, probeRaw) {
78
+ if (probeRaw === '1')
79
+ return 'full';
80
+ const m = TMUX_VERSION_RE.exec(versionRaw.trim());
81
+ if (!m)
82
+ return null;
83
+ const major = parseInt(m[1], 10);
84
+ const minor = parseInt(m[2], 10);
85
+ if (major > 3 || (major === 3 && minor >= 2)) {
86
+ // A >=3.2 server whose arithmetic probe did NOT evaluate is contradictory
87
+ // evidence — never downgrade it to an unguarded-adjacent path.
88
+ return null;
89
+ }
90
+ return 'legacy';
91
+ }
92
+ // A client's usable content area excludes its status line(s); tmux only keeps the
93
+ // scroll-optimized incremental stream for clients whose content area matches the
94
+ // window size, so both conversions must stay N-aware or geometry runs away.
95
+ export function contentArea(tty, statusLines) {
96
+ return { cols: tty.cols, rows: Math.max(1, tty.rows - statusLines) };
97
+ }
98
+ export function desiredTty(geometry) {
99
+ return { cols: geometry.width, rows: geometry.height + geometry.statusLines };
100
+ }
101
+ export function parseStatusLines(raw) {
102
+ if (raw === 'off')
103
+ return 0;
104
+ if (raw === 'on')
105
+ return 1;
106
+ if (/^[2-5]$/.test(raw))
107
+ return parseInt(raw, 10);
108
+ throw new Error(`tmux: unrecognized status value ${JSON.stringify(raw)}`);
109
+ }
110
+ export function parseWindowGeometry(stdout) {
111
+ const line = stdout.replace(/\n+$/, '');
112
+ const m = /^(\d+) (\d+) (\S+) (\S*) ([^|]*)\|(\d+)\|(\d+)\|(\$\d+)\|([^|]*)\|([^|]*)\|(.*)$/.exec(line);
113
+ if (!m) {
114
+ throw new Error(`tmux: unparseable window geometry ${JSON.stringify(line)}`);
115
+ }
116
+ const [, w, h, status, sizeMode, genRaw, pid, start, sessionId, claim, versionRaw, probeRaw] = m;
117
+ const width = parseInt(w, 10);
118
+ const height = parseInt(h, 10);
119
+ if (!Number.isSafeInteger(width) || width <= 0 || !Number.isSafeInteger(height) || height <= 0) {
120
+ throw new Error(`tmux: invalid window geometry ${w}x${h}`);
121
+ }
122
+ let ownerGen = null;
123
+ if (genRaw !== '') {
124
+ const parsed = Number(genRaw);
125
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
126
+ throw new Error(`tmux: invalid @bx_owner_gen ${JSON.stringify(genRaw)}`);
127
+ }
128
+ ownerGen = parsed;
129
+ }
130
+ return {
131
+ width,
132
+ height,
133
+ statusLines: parseStatusLines(status),
134
+ sizeMode,
135
+ ownerGen,
136
+ ref: { serverPid: pid, serverStart: start, sessionId },
137
+ claim,
138
+ ownerWriteCapability: classifyOwnerWriteCapability(versionRaw, probeRaw),
139
+ };
140
+ }
141
+ export class SessionAbsentError extends Error {
142
+ constructor(name, detail) {
143
+ super(`tmux session ${name} is absent: ${detail}`);
144
+ this.name = 'SessionAbsentError';
145
+ }
146
+ }
36
147
  // A pattern that can never match — used where a runtime has no dialog of a given kind.
37
148
  const NEVER_RE = /[^\s\S]/;
38
149
  const REPL_PROC_TITLES = {
@@ -447,23 +558,27 @@ export class TmuxManager {
447
558
  }
448
559
  throw new Error(`tmux session ${name} exists but was not created by this call (nonce mismatch) — leaving it untouched; cause: ${cause}`);
449
560
  }
450
- async readCreationNonce(name) {
561
+ async readCreationNonce(name, opts) {
451
562
  assertPlainSessionName(name);
452
- const result = await run(this.runner, `tmux show-environment -t ${shellQuote(`=${name}:`)} ${CREATION_NONCE_ENV}`);
563
+ const result = await run(this.runner, `tmux show-environment -t ${shellQuote(`=${name}:`)} ${CREATION_NONCE_ENV}`, opts);
453
564
  if (result.exitCode === 0) {
454
565
  const line = result.stdout.trim();
455
566
  return line.startsWith(`${CREATION_NONCE_ENV}=`) ? line.slice(CREATION_NONCE_ENV.length + 1) : null;
456
567
  }
568
+ // Transient-network / 255 markers on either stream must NOT be read as "no nonce"; treat as unknown.
569
+ if (tmuxProbeOutcomeUnknown(result)) {
570
+ throw new Error(`tmux creation-nonce probe for ${name} outcome unknown (transient): exit ${result.exitCode}: ${result.stderr}`);
571
+ }
457
572
  if (result.exitCode === 1 && (isUnknownTmuxVariable(result.stderr) || isSessionAbsent(result.stderr))) {
458
573
  return null;
459
574
  }
460
575
  throw new Error(`tmux creation-nonce probe for ${name} failed (exit ${result.exitCode}): ${result.stderr}`);
461
576
  }
462
577
  // Ref and claim come from one round trip — the claim can't belong to a different session.
463
- async getSessionSnapshot(name) {
578
+ async getSessionSnapshot(name, opts) {
464
579
  assertPlainSessionName(name);
465
580
  const result = await run(this.runner, `tmux list-sessions -F '${SESSION_REF_FORMAT}|#{@baxian-agent-id}' ` +
466
- `-f ${shellQuote(`#{==:#{session_name},${name}}`)}`);
581
+ `-f ${shellQuote(`#{==:#{session_name},${name}}`)}`, opts);
467
582
  if (result.exitCode === 0) {
468
583
  const line = result.stdout.trim();
469
584
  if (line === '')
@@ -476,27 +591,29 @@ export class TmuxManager {
476
591
  const claim = line.slice(sep + 1);
477
592
  return { ref, claim: claim === '' ? null : claim };
478
593
  }
594
+ if (tmuxProbeOutcomeUnknown(result)) {
595
+ throw new Error(`tmux getSessionSnapshot ${name} outcome unknown (transient): exit ${result.exitCode}: ${result.stderr}`);
596
+ }
479
597
  if (result.exitCode === 1 && isSessionAbsent(result.stderr))
480
598
  return null;
481
599
  throw new Error(`tmux getSessionSnapshot ${name} failed (exit ${result.exitCode}): ${result.stderr}`);
482
600
  }
483
- async hasCreationNonce(name) {
484
- return (await this.readCreationNonce(name)) !== null;
601
+ async hasCreationNonce(name, opts) {
602
+ return (await this.readCreationNonce(name, opts)) !== null;
485
603
  }
486
- // With a dead id and a single surviving session, tmux target lookup silently
487
- // falls back to that survivor — and a fresh server reissues the same $n. Every
488
- // by-ref write re-proves id AND server generation inside the server itself.
489
- async setSessionOptionsIfAlive(ref, entries) {
604
+ // Every session-option write re-proves generation+session+claim inside the server itself;
605
+ // expectedClaim '' is the fresh-create batch writing the claim onto a still-unclaimed session.
606
+ async setSessionOptionsIfAlive(ref, entries, opts) {
490
607
  assertSessionRef(ref);
608
+ if (opts.expectedClaim !== '')
609
+ assertPlainSessionName(opts.expectedClaim);
491
610
  if (entries.length === 0)
492
611
  return 'applied';
493
612
  const sets = entries
494
- .map(([key, value]) => `set-option -t '${ref.sessionId}' ${key} ${tmuxSingleQuote(value)}`)
613
+ .map(([key, value]) => `set-option -t '${ref.sessionId}' ${key} ${tmuxQuote(value)}`)
495
614
  .join(' ; ');
496
- const generation = `#{&&:#{==:#{pid},${ref.serverPid}},#{==:#{start_time},${ref.serverStart}}}`;
497
- const cond = `#{&&:${generation},#{==:#{session_id},${ref.sessionId}}}`;
498
615
  const result = await run(this.runner, `tmux if-shell -t ${shellQuote(ref.sessionId)} ` +
499
- `-F ${shellQuote(cond)} ` +
616
+ `-F ${shellQuote(sessionCond(ref, opts.expectedClaim))} ` +
500
617
  `${shellQuote(sets)} ` +
501
618
  `${shellQuote(`display-message -p ${TARGET_GONE_MARKER}`)}`);
502
619
  if (result.exitCode === 0) {
@@ -506,18 +623,62 @@ export class TmuxManager {
506
623
  return 'gone';
507
624
  throw new Error(`tmux setSessionOptionsIfAlive ${ref.sessionId} failed (exit ${result.exitCode}): ${result.stderr}`);
508
625
  }
509
- // list-panes -a with an exact id+generation filter cannot fall back to another
626
+ async getSessionOptionByRef(ref, claim, key) {
627
+ assertSessionRef(ref);
628
+ assertPlainSessionName(claim);
629
+ assertPlainFormat(key);
630
+ const result = await run(this.runner, `tmux if-shell -t ${shellQuote(ref.sessionId)} ` +
631
+ `-F ${shellQuote(sessionCond(ref, claim))} ` +
632
+ `${shellQuote(`display-message -p -t '${ref.sessionId}' '${PANE_OK_MARKER}#{${key}}'`)} ` +
633
+ `${shellQuote(`display-message -p ${TARGET_GONE_MARKER}`)}`);
634
+ if (result.exitCode === 1 && isSessionAbsent(result.stderr)) {
635
+ throw new PaneGoneError(ref.sessionId, result.stderr.trim());
636
+ }
637
+ if (result.exitCode !== 0) {
638
+ throw new Error(`tmux getSessionOptionByRef ${ref.sessionId} ${key} failed: ${result.stderr}`);
639
+ }
640
+ const firstLine = result.stdout.split('\n', 1)[0];
641
+ if (!firstLine.startsWith(PANE_OK_MARKER)) {
642
+ throw new PaneGoneError(ref.sessionId, 'identity condition failed');
643
+ }
644
+ const value = firstLine.slice(PANE_OK_MARKER.length);
645
+ return value === '' ? null : value;
646
+ }
647
+ // The web-terminal resize path: size change and window-size pin ride one claim-checked command.
648
+ async resizeWindowByRef(ref, claim, cols, rows) {
649
+ assertSessionRef(ref);
650
+ assertPlainSessionName(claim);
651
+ if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0) {
652
+ throw new Error(`tmux resizeWindowByRef: invalid dimensions ${cols}x${rows}`);
653
+ }
654
+ const inner = `resize-window -t '${ref.sessionId}' -x ${cols} -y ${rows} ; ` +
655
+ `set-option -t '${ref.sessionId}' window-size latest`;
656
+ const result = await run(this.runner, `tmux if-shell -t ${shellQuote(ref.sessionId)} ` +
657
+ `-F ${shellQuote(sessionCond(ref, claim))} ` +
658
+ `${shellQuote(inner)} ` +
659
+ `${shellQuote(`display-message -p ${TARGET_GONE_MARKER}`)}`);
660
+ if (result.exitCode === 1 && isSessionAbsent(result.stderr)) {
661
+ throw new PaneGoneError(ref.sessionId, result.stderr.trim());
662
+ }
663
+ if (result.exitCode !== 0) {
664
+ throw new Error(`tmux resizeWindowByRef ${ref.sessionId} failed: ${result.stderr}`);
665
+ }
666
+ if (result.stdout.includes(TARGET_GONE_MARKER)) {
667
+ throw new PaneGoneError(ref.sessionId, 'identity condition failed');
668
+ }
669
+ }
670
+ // list-panes -a with an exact generation+session+claim filter cannot fall back to another
510
671
  // session, and a fresh server reissuing the same $n fails the generation half.
511
- async getSinglePaneIdByRef(ref) {
672
+ async getSinglePaneByRef(ref, claim, opts) {
512
673
  assertSessionRef(ref);
513
- const generation = `#{&&:#{==:#{pid},${ref.serverPid}},#{==:#{start_time},${ref.serverStart}}}`;
674
+ assertPlainSessionName(claim);
514
675
  const result = await run(this.runner, `tmux list-panes -a -F '#{pane_id} #{pane_current_command}' ` +
515
- `-f ${shellQuote(`#{&&:${generation},#{==:#{session_id},${ref.sessionId}}}`)}`);
676
+ `-f ${shellQuote(sessionCond(ref, claim))}`, opts);
516
677
  if (result.exitCode === 1 && isSessionAbsent(result.stderr)) {
517
678
  throw new Error(`tmux session ${ref.sessionId} is gone (no server)`);
518
679
  }
519
680
  if (result.exitCode !== 0) {
520
- throw new Error(`tmux getSinglePaneIdByRef ${ref.sessionId} failed: ${result.stderr}`);
681
+ throw new Error(`tmux getSinglePaneByRef ${ref.sessionId} failed: ${result.stderr}`);
521
682
  }
522
683
  const panes = result.stdout.split('\n').map(l => l.trim()).filter(Boolean);
523
684
  if (panes.length === 0) {
@@ -526,135 +687,152 @@ export class TmuxManager {
526
687
  if (panes.length > 1) {
527
688
  throw new Error(`tmux session ${ref.sessionId} has ${panes.length} panes; baxian expects exactly one — external split-window not supported`);
528
689
  }
529
- return panes[0].split(' ')[0];
690
+ const paneId = panes[0].split(' ')[0];
691
+ assertPaneId(paneId);
692
+ return { session: ref, paneId, claim };
530
693
  }
531
- // Generation (and opted-in claim-still-empty) are evaluated inside the killing server itself.
532
- async killSessionRef(ref, opts = {}) {
694
+ // Generation AND the caller-declared claim condition are evaluated inside the killing server itself.
695
+ async killSessionRef(ref, claimCond, opts) {
533
696
  assertSessionRef(ref);
534
- const generation = `#{&&:#{==:#{pid},${ref.serverPid}},#{==:#{start_time},${ref.serverStart}}}`;
535
- const cond = opts.onlyIfUnclaimed
536
- ? `#{&&:${generation},#{==:#{@baxian-agent-id},}}`
537
- : generation;
538
- const marker = opts.onlyIfUnclaimed ? REFUSED_MARKER : STALE_SERVER_MARKER;
539
- const target = opts.onlyIfUnclaimed ? `-t ${shellQuote(ref.sessionId)} ` : '';
540
- const result = await run(this.runner, `tmux if-shell ${target}-F ${shellQuote(cond)} ` +
697
+ let claimClause;
698
+ if (claimCond.kind === 'unclaimed') {
699
+ claimClause = '#{==:#{@baxian-agent-id},}';
700
+ }
701
+ else {
702
+ assertPlainSessionName(claimCond.claim);
703
+ claimClause = claimCond.kind === 'equals'
704
+ ? `#{==:#{@baxian-agent-id},${claimCond.claim}}`
705
+ : `#{||:#{==:#{@baxian-agent-id},},#{==:#{@baxian-agent-id},${claimCond.claim}}}`;
706
+ }
707
+ const cond = `#{&&:#{&&:${generationCond(ref)},#{==:#{session_id},${ref.sessionId}}},${claimClause}}`;
708
+ const result = await run(this.runner, `tmux if-shell -t ${shellQuote(ref.sessionId)} -F ${shellQuote(cond)} ` +
541
709
  `${shellQuote(`kill-session -t '${ref.sessionId}'`)} ` +
542
- `${shellQuote(`display-message -p ${marker}`)}`);
710
+ `${shellQuote(`display-message -p ${REFUSED_MARKER}`)}`, opts);
543
711
  if (result.exitCode === 0) {
544
- if (result.stdout.includes(REFUSED_MARKER))
545
- return 'refused';
546
- return result.stdout.includes(STALE_SERVER_MARKER) ? 'stale-server' : 'killed';
712
+ return result.stdout.includes(REFUSED_MARKER) ? 'refused' : 'killed';
713
+ }
714
+ // Transient / 255 must not be read as "absent" — a stuck kill would otherwise let DELETE drop the session.
715
+ if (tmuxProbeOutcomeUnknown(result)) {
716
+ throw new Error(`tmux killSessionRef ${ref.sessionId} outcome unknown (transient): exit ${result.exitCode}: ${result.stderr}`);
547
717
  }
548
718
  if (result.exitCode === 1 && isSessionAbsent(result.stderr))
549
719
  return 'absent';
550
- if (result.exitCode === 255) {
551
- throw new Error(`tmux killSessionRef ${ref.sessionId} runner error (ssh/exec layer): ${result.stderr}`);
552
- }
553
720
  throw new Error(`tmux killSessionRef ${ref.sessionId} unexpected exit ${result.exitCode}: ${result.stderr}`);
554
721
  }
555
- async sendInput(name, data) {
556
- if (data.length === 0)
557
- return;
558
- const bufName = `bx-input-${randomUUID()}`;
559
- const payload = Buffer.from(data, 'utf8');
560
- const loadCmd = `tmux load-buffer -b ${shellQuote(bufName)} -`;
561
- const pasteCmd = `tmux paste-buffer -d -b ${shellQuote(bufName)} -t ${shellQuote(`=${name}:`)}`;
562
- const deleteCmd = `tmux delete-buffer -b ${shellQuote(bufName)}`;
563
- try {
564
- const r1 = this.runner instanceof SshRunner
565
- ? await this.runner.execRawRemoteWithStdin(loadCmd, payload)
566
- : await this.runner.execWithStdin(loadCmd, payload);
567
- if (r1.exitCode !== 0) {
568
- throw new Error(`tmux sendInput ${name} load-buffer failed: ${r1.stderr || 'unknown error'}`);
569
- }
570
- const r2 = await run(this.runner, pasteCmd);
571
- if (r2.exitCode !== 0) {
572
- throw new Error(`tmux sendInput ${name} paste-buffer failed: ${r2.stderr || 'unknown error'}`);
722
+ async guardedPaneWrite(pane, inner, opts) {
723
+ assertPaneRef(pane);
724
+ const result = await run(this.runner, `tmux if-shell -t ${shellQuote(pane.paneId)} -F ${shellQuote(paneCond(pane))} ` +
725
+ `${shellQuote(inner.join(' ; '))} ` +
726
+ `${shellQuote(`display-message -p ${TARGET_GONE_MARKER}`)}`, opts);
727
+ if (result.exitCode === 0) {
728
+ if (result.stdout.includes(TARGET_GONE_MARKER)) {
729
+ throw new PaneGoneError(pane.paneId, 'identity condition failed');
573
730
  }
731
+ return;
574
732
  }
575
- finally {
576
- try {
577
- const del = await run(this.runner, deleteCmd);
578
- // paste -d already deleted it on success; absent is the norm, not a leak.
579
- if (del.exitCode !== 0 && !isUnknownTmuxBuffer(del.stderr)) {
580
- console.warn(`[tmux] sendInput ${name}: delete-buffer failed (input may linger in tmux): ${del.stderr.trim() || `exit ${del.exitCode}`}`);
581
- }
582
- }
583
- catch (err) {
584
- console.warn(`[tmux] sendInput ${name}: delete-buffer failed (input may linger in tmux):`, err);
585
- }
733
+ if (result.exitCode === 1 && isSessionAbsent(result.stderr)) {
734
+ throw new PaneGoneError(pane.paneId, result.stderr.trim());
735
+ }
736
+ throw new Error(`tmux guarded write to ${pane.paneId} failed (exit ${result.exitCode}): ${result.stderr}`);
737
+ }
738
+ // Marker-first read: the FIRST line decides ok/gone, so pane text can never spoof the verdict.
739
+ async guardedPaneRead(pane, headerFmt, extra = [], opts) {
740
+ assertPaneRef(pane);
741
+ assertPlainFormat(headerFmt);
742
+ const inner = [
743
+ `display-message -p -t ${pane.paneId} '${PANE_OK_MARKER}${headerFmt}'`,
744
+ ...extra,
745
+ ].join(' ; ');
746
+ const result = await run(this.runner, `tmux if-shell -t ${shellQuote(pane.paneId)} -F ${shellQuote(paneCond(pane))} ` +
747
+ `${shellQuote(inner)} ` +
748
+ `${shellQuote(`display-message -p ${TARGET_GONE_MARKER}`)}`, opts);
749
+ if (result.exitCode === 1 && isSessionAbsent(result.stderr)) {
750
+ throw new PaneGoneError(pane.paneId, result.stderr.trim());
751
+ }
752
+ if (result.exitCode !== 0) {
753
+ throw new Error(`tmux guarded read of ${pane.paneId} failed (exit ${result.exitCode}): ${result.stderr}`);
754
+ }
755
+ const nl = result.stdout.indexOf('\n');
756
+ const firstLine = nl === -1 ? result.stdout : result.stdout.slice(0, nl);
757
+ if (!firstLine.startsWith(PANE_OK_MARKER)) {
758
+ throw new PaneGoneError(pane.paneId, 'identity condition failed');
759
+ }
760
+ return {
761
+ header: firstLine.slice(PANE_OK_MARKER.length),
762
+ body: nl === -1 ? '' : result.stdout.slice(nl + 1),
763
+ };
764
+ }
765
+ // Absent / transient / other failures are distinct outcomes: SSH flakes and
766
+ // exit 255 must never be read as "session gone" (three-state discipline).
767
+ async getWindowGeometry(name, opts) {
768
+ assertPlainSessionName(name);
769
+ const result = await run(this.runner, `tmux display-message -p -t ${shellQuote(`=${name}:`)} ${shellQuote(WINDOW_GEOMETRY_FORMAT)}`, opts);
770
+ if (result.exitCode === 0)
771
+ return parseWindowGeometry(result.stdout);
772
+ if (tmuxProbeOutcomeUnknown(result)) {
773
+ throw new Error(`tmux getWindowGeometry ${name} outcome unknown (transient): exit ${result.exitCode}: ${result.stderr}`);
774
+ }
775
+ if (result.exitCode === 1 && isSessionAbsent(result.stderr)) {
776
+ throw new SessionAbsentError(name, result.stderr.trim());
586
777
  }
778
+ throw new Error(`tmux getWindowGeometry ${name} failed (exit ${result.exitCode}): ${result.stderr}`);
587
779
  }
588
- async resizeWindow(name, cols, rows) {
589
- const result = await run(this.runner, `tmux resize-window -t ${shellQuote(`=${name}`)} -x ${cols} -y ${rows}`);
780
+ // The guard re-evaluates identity + claim (+ generation when the server proves
781
+ // format arithmetic) inside the tmux server at execution time: a delayed write —
782
+ // stale gen, a same-name session rebuilt after ours died, or a foreign/unclaimed
783
+ // same-name session — lands as a no-op instead of mutating a window we don't own.
784
+ async ownerWrite(name, ref, claim, gen, mode, resizeTo, opts) {
785
+ assertPlainSessionName(name);
786
+ assertSessionRef(ref);
787
+ assertPlainSessionName(claim);
788
+ if (gen !== null && (!Number.isSafeInteger(gen) || gen < 0)) {
789
+ throw new Error(`tmux ownerWrite ${name}: invalid generation ${gen}`);
790
+ }
791
+ const target = `=${name}:`;
792
+ const identityAndClaim = sessionCond(ref, claim);
793
+ const guard = gen === null
794
+ ? identityAndClaim
795
+ : `#{&&:${identityAndClaim},#{?#{@bx_owner_gen},#{e|<=:#{@bx_owner_gen},${gen}},1}}`;
796
+ const actions = [
797
+ ...(gen !== null ? [`set-option -t "${target}" @bx_owner_gen ${gen}`] : []),
798
+ `set-option -t "${target}" window-size ${mode}`,
799
+ ...(resizeTo ? [`resize-window -t "${target}" -x ${resizeTo.cols} -y ${resizeTo.rows}`] : []),
800
+ ].join(' ; ');
801
+ const result = await run(this.runner, `tmux if-shell -F -t ${shellQuote(target)} ${shellQuote(guard)} ${shellQuote(actions)} ''`, opts);
590
802
  if (result.exitCode !== 0) {
591
- throw new Error(`tmux resizeWindow ${name} failed: ${result.stderr}`);
803
+ throw new Error(`tmux ownerWrite ${name} failed (exit ${result.exitCode}): ${result.stderr}`);
592
804
  }
593
805
  }
594
806
  async hasSession(name, opts) {
595
807
  const result = await run(this.runner, `tmux has-session -t ${shellQuote(`=${name}`)}`, opts);
596
808
  if (result.exitCode === 0)
597
809
  return true;
810
+ if (tmuxProbeOutcomeUnknown(result)) {
811
+ throw new Error(`tmux hasSession ${name} outcome unknown (transient): exit ${result.exitCode}: ${result.stderr}`);
812
+ }
598
813
  if (result.exitCode === 1 && isSessionAbsent(result.stderr))
599
814
  return false;
600
- if (result.exitCode === 255) {
601
- throw new Error(`tmux hasSession ${name} runner error (ssh/exec layer): ${result.stderr}`);
602
- }
603
815
  throw new Error(`tmux hasSession ${name} unexpected exit ${result.exitCode}: ${result.stderr}`);
604
816
  }
605
- async listPanes(name, opts) {
606
- const result = await run(this.runner, `tmux list-panes -t ${shellQuote(`=${name}`)} -F '#{pane_id} #{pane_current_command}'`, opts);
607
- if (result.exitCode !== 0) {
608
- throw new Error(`tmux listPanes ${name} failed: ${result.stderr}`);
609
- }
610
- return result.stdout
611
- .split('\n')
612
- .map(line => line.trim())
613
- .filter(Boolean)
614
- .map(line => {
615
- const idx = line.indexOf(' ');
616
- return idx === -1
617
- ? { paneId: line, current: '' }
618
- : { paneId: line.slice(0, idx), current: line.slice(idx + 1) };
619
- });
620
- }
621
- async getSinglePaneId(name, opts) {
622
- const panes = await this.listPanes(name, opts);
623
- if (panes.length === 0) {
624
- throw new Error(`tmux session ${name} has no panes`);
625
- }
626
- if (panes.length > 1) {
627
- throw new Error(`tmux session ${name} has ${panes.length} panes; baxian expects exactly one — external split-window not supported`);
628
- }
629
- return panes[0].paneId;
817
+ async displayMessage(pane, fmt, opts) {
818
+ const { header } = await this.guardedPaneRead(pane, fmt, [], opts);
819
+ return header;
630
820
  }
631
- async displayMessage(paneId, fmt, opts) {
632
- const result = await run(this.runner, `tmux display-message -p -t ${shellQuote(paneId)} ${shellQuote(fmt)}`, opts);
633
- if (result.exitCode !== 0) {
634
- throw new Error(`tmux displayMessage ${paneId} failed: ${result.stderr}`);
635
- }
636
- return result.stdout.replace(/\n$/, '');
637
- }
638
- async getPaneCurrentPath(paneId, opts) {
639
- const path = await this.displayMessage(paneId, '#{pane_current_path}', opts);
821
+ async getPaneCurrentPath(pane, opts) {
822
+ const path = await this.displayMessage(pane, '#{pane_current_path}', opts);
640
823
  if (path === '')
641
- throw new Error(`tmux pane ${paneId} has an empty current path`);
824
+ throw new Error(`tmux pane ${pane.paneId} has an empty current path`);
642
825
  return path;
643
826
  }
644
- async readPaneTitle(paneId, opts) {
827
+ // A gone pane reads as an empty title on purpose: title is an advisory signal, never an authority.
828
+ async readPaneTitle(pane, opts) {
645
829
  try {
646
- return await this.displayMessage(paneId, '#{pane_title}', opts);
830
+ return await this.displayMessage(pane, '#{pane_title}', opts);
647
831
  }
648
832
  catch {
649
833
  return '';
650
834
  }
651
835
  }
652
- async setOption(name, key, value) {
653
- const result = await run(this.runner, `tmux set-option -t ${shellQuote(`=${name}:`)} ${shellQuote(key)} ${shellQuote(value)}`);
654
- if (result.exitCode !== 0) {
655
- throw new Error(`tmux setOption ${name} ${key} failed: ${result.stderr}`);
656
- }
657
- }
658
836
  async setServerOption(key, value) {
659
837
  const result = await run(this.runner, `tmux set-option -s ${shellQuote(key)} ${shellQuote(value)}`);
660
838
  if (result.exitCode !== 0) {
@@ -669,38 +847,19 @@ export class TmuxManager {
669
847
  throw new Error(`tmux appendServerOptionIfMissing ${key} failed: ${result.stderr}`);
670
848
  }
671
849
  }
672
- async getOption(name, key) {
673
- const result = await run(this.runner, `tmux show-option -t ${shellQuote(`=${name}:`)} -v ${shellQuote(key)}`);
674
- if (result.exitCode === 0) {
675
- const trimmed = result.stdout.replace(/\n$/, '');
676
- return trimmed === '' ? null : trimmed;
677
- }
678
- if (result.exitCode === 1)
679
- return null;
680
- throw new Error(`tmux getOption ${name} ${key} unexpected exit ${result.exitCode}: ${result.stderr}`);
681
- }
682
- async sendKeysToPane(paneId, ...keys) {
850
+ async sendKeysToPane(pane, ...keys) {
683
851
  if (keys.length === 0)
684
852
  return;
685
- const args = keys.map(k => shellQuote(k)).join(' ');
686
- const result = await run(this.runner, `tmux send-keys -t ${shellQuote(paneId)} ${args}`);
687
- if (result.exitCode !== 0) {
688
- throw new Error(`tmux sendKeys ${paneId} failed: ${result.stderr}`);
689
- }
690
- }
691
- async sendKeysLiteral(paneId, text) {
692
- const result = await run(this.runner, `tmux send-keys -l -t ${shellQuote(paneId)} ${shellQuote(text)}`);
693
- if (result.exitCode !== 0) {
694
- throw new Error(`tmux sendKeysLiteral ${paneId} failed: ${result.stderr}`);
695
- }
853
+ await this.guardedPaneWrite(pane, [
854
+ `send-keys -t ${pane.paneId} ${keys.map(k => tmuxQuote(k)).join(' ')}`,
855
+ ]);
696
856
  }
697
- async sendKeys(name, keys) {
698
- const result = await run(this.runner, `tmux send-keys -t ${shellQuote(name)} ${shellQuote(keys)} Enter`);
699
- if (result.exitCode !== 0) {
700
- throw new Error(`tmux sendKeys ${name} failed: ${result.stderr}`);
701
- }
857
+ async sendKeysLiteral(pane, text) {
858
+ await this.guardedPaneWrite(pane, [
859
+ `send-keys -l -t ${pane.paneId} ${tmuxQuote(text)}`,
860
+ ]);
702
861
  }
703
- async capturePaneById(paneId, opts = {}) {
862
+ async capturePaneById(pane, opts = {}) {
704
863
  const ansi = opts.ansi ?? false;
705
864
  const scrollback = opts.scrollback;
706
865
  const flags = ['-p', '-J'];
@@ -713,27 +872,85 @@ export class TmuxManager {
713
872
  flags.push('-S', '0');
714
873
  }
715
874
  const execOpts = opts.timeoutMs ? { timeout: opts.timeoutMs } : undefined;
716
- const result = await run(this.runner, `tmux capture-pane ${flags.join(' ')} -t ${shellQuote(paneId)}`, execOpts);
717
- if (result.exitCode !== 0) {
718
- throw new Error(`tmux capturePane ${paneId} failed: ${result.stderr || 'unknown error'}`);
719
- }
720
- return result.stdout;
875
+ const { body } = await this.guardedPaneRead(pane, '', [`capture-pane ${flags.join(' ')} -t ${pane.paneId}`], execOpts);
876
+ return body;
721
877
  }
722
- async injectPrompt(paneId, prompt, agentId) {
878
+ // Probe-gated stdin load (bytes reach a buffer only on a five-field identity match, no openssl) then a claim-checked paste; once the load is confirmed, any paste non-success reconciles the named buffer so nothing leaks.
879
+ async injectPrompt(pane, prompt, agentId) {
880
+ assertPaneRef(pane);
723
881
  const bytes = Buffer.byteLength(prompt, 'utf8');
724
882
  if (bytes > MAX_PROMPT_BYTES) {
725
- throw new Error(`tmux injectPrompt ${paneId} prompt too large: ${bytes} bytes > ${MAX_PROMPT_BYTES}`);
883
+ throw new Error(`tmux injectPrompt ${pane.paneId} prompt too large: ${bytes} bytes > ${MAX_PROMPT_BYTES}`);
726
884
  }
727
- const promptB64 = Buffer.from(prompt, 'utf8').toString('base64');
728
885
  const buf = `baxian-${agentId}-${randomUUID()}`;
729
- const cmd = `printf '%s' ${shellQuote(promptB64)} | openssl base64 -d -A | ` +
730
- `tmux load-buffer -b ${shellQuote(buf)} - && ` +
731
- `tmux paste-buffer -b ${shellQuote(buf)} -t ${shellQuote(paneId)} -d -p -r`;
732
- const result = await run(this.runner, cmd);
733
- if (result.exitCode !== 0) {
734
- throw new Error(`tmux injectPrompt ${paneId} failed: ${result.stderr}`);
886
+ const payload = Buffer.from(prompt, 'utf8');
887
+ const probeFmt = '#{pid}|#{start_time}|#{session_id}|#{pane_id}|#{@baxian-agent-id}';
888
+ const expected = [
889
+ pane.session.serverPid, pane.session.serverStart, pane.session.sessionId,
890
+ pane.paneId, pane.claim,
891
+ ].join('|');
892
+ const loadCmd = `[ "$(tmux display-message -p -t ${shellQuote(pane.paneId)} '${probeFmt}')" = ${shellQuote(expected)} ] && ` +
893
+ `tmux load-buffer -b ${shellQuote(buf)} -`;
894
+ // Step 1 — probe-gated load via execWithStdin (login-shell PATH resolves tmux); a failed gate/load leaves NO buffer, only an unknown outcome reconciles.
895
+ let loaded;
896
+ try {
897
+ loaded = await this.runner.execWithStdin(loadCmd, payload);
898
+ }
899
+ catch (err) {
900
+ await this.reconcileInjectBuffer(buf, `load exec rejected: ${err instanceof Error ? err.message : String(err)}`);
901
+ throw new Error(`tmux injectPrompt ${pane.paneId} failed (load exec layer): ${err instanceof Error ? err.message : String(err)}`);
902
+ }
903
+ if (loaded.exitCode !== 0) {
904
+ if (execOutcomeUnknown(loaded)) {
905
+ await this.reconcileInjectBuffer(buf, `load outcome unknown (exit ${loaded.exitCode}): ${loaded.stderr.trim()}`);
906
+ throw new Error(`tmux injectPrompt ${pane.paneId} load outcome unknown (exit ${loaded.exitCode}): ${loaded.stderr}`);
907
+ }
908
+ if (isSessionAbsent(loaded.stderr))
909
+ throw new PaneGoneError(pane.paneId, loaded.stderr.trim());
910
+ throw new PaneGoneError(pane.paneId, `identity probe or buffer load failed before any buffer was created: ${loaded.stderr.trim() || 'probe mismatch'}`);
911
+ }
912
+ // Step 2 — the buffer now exists. Any non-success here may leave it behind → reconcile by name.
913
+ const pasteCmd = `tmux if-shell -t ${shellQuote(pane.paneId)} -F ${shellQuote(paneCond(pane))} ` +
914
+ `${shellQuote(`paste-buffer -b ${buf} -t ${pane.paneId} -d -p -r`)} ` +
915
+ `${shellQuote(`delete-buffer -b ${buf} ; display-message -p ${TARGET_GONE_MARKER}`)}`;
916
+ let pasted;
917
+ try {
918
+ pasted = await run(this.runner, pasteCmd);
919
+ }
920
+ catch (err) {
921
+ await this.reconcileInjectBuffer(buf, `paste exec rejected: ${err instanceof Error ? err.message : String(err)}`);
922
+ throw new Error(`tmux injectPrompt ${pane.paneId} failed (paste exec layer): ${err instanceof Error ? err.message : String(err)}`);
923
+ }
924
+ if (pasted.exitCode === 0) {
925
+ if (pasted.stdout.includes(TARGET_GONE_MARKER)) {
926
+ throw new PaneGoneError(pane.paneId, 'identity condition failed at paste (buffer self-cleaned)');
927
+ }
928
+ return;
929
+ }
930
+ // Non-zero after a confirmed load (e.g. pane vanished before if-shell resolved -t, returning
931
+ // "can't find pane") does NOT run the else-branch, so the buffer lingers — reconcile by name.
932
+ await this.reconcileInjectBuffer(buf, `paste failed (exit ${pasted.exitCode}): ${pasted.stderr.trim()}`);
933
+ if (pasted.exitCode === 1 && (isSessionAbsent(pasted.stderr) || isTargetGone(pasted.stderr))) {
934
+ throw new PaneGoneError(pane.paneId, pasted.stderr.trim() || 'pane gone before paste');
935
+ }
936
+ throw new Error(`tmux injectPrompt ${pane.paneId} paste failed (exit ${pasted.exitCode}): ${pasted.stderr}`);
937
+ }
938
+ // The uuid buffer name is the non-rebindable credential: deleting it by name on any server removes only this call's bytes, so reconciliation ignores generation.
939
+ async reconcileInjectBuffer(buf, cause) {
940
+ try {
941
+ const del = await run(this.runner, `tmux delete-buffer -b ${shellQuote(buf)}`);
942
+ if (del.exitCode === 0) {
943
+ console.warn(`[tmux] injectPrompt reconcile: removed leftover buffer ${buf} (${cause})`);
944
+ return;
945
+ }
946
+ if (del.exitCode === 1 && (isUnknownTmuxBuffer(del.stderr) || isSessionAbsent(del.stderr)))
947
+ return;
948
+ console.warn(`[tmux] injectPrompt reconcile: buffer ${buf} cleanup NOT confirmed ` +
949
+ `(exit ${del.exitCode}: ${del.stderr.trim()}); prompt may linger in tmux (${cause})`);
950
+ }
951
+ catch (err) {
952
+ console.warn(`[tmux] injectPrompt reconcile: buffer ${buf} cleanup NOT confirmed; prompt may linger in tmux (${cause}):`, err);
735
953
  }
736
- return { pasted: true };
737
954
  }
738
955
  // Staging split from pasting lets a caller fence the paste after every await the staging exec hides.
739
956
  async stagePromptBuffer(paneId, prompt, agentId) {
@@ -741,12 +958,13 @@ export class TmuxManager {
741
958
  if (bytes > MAX_PROMPT_BYTES) {
742
959
  throw new Error(`tmux stagePromptBuffer ${paneId} prompt too large: ${bytes} bytes > ${MAX_PROMPT_BYTES}`);
743
960
  }
744
- const promptB64 = Buffer.from(prompt, 'utf8').toString('base64');
745
961
  const buf = `baxian-${agentId}-${randomUUID()}`;
962
+ const payload = Buffer.from(prompt, 'utf8');
746
963
  let result;
747
964
  let transportErr;
748
965
  try {
749
- result = await run(this.runner, `printf '%s' ${shellQuote(promptB64)} | openssl base64 -d -A | tmux load-buffer -b ${shellQuote(buf)} -`);
966
+ // Raw stdin load (no openssl) — same transport as injectPrompt, keeping the guard path openssl-free.
967
+ result = await this.runner.execWithStdin(`tmux load-buffer -b ${shellQuote(buf)} -`, payload);
750
968
  }
751
969
  catch (err) {
752
970
  transportErr = err;
@@ -794,64 +1012,52 @@ export class TmuxManager {
794
1012
  throw new Error(`tmux dropStagedBuffer ${buf} failed: ${result.stderr}`);
795
1013
  }
796
1014
  }
797
- async capturePaneSnapshot(paneId) {
798
- const SEP = '___bx-snap-sep___';
799
- const cmd = `tmux capture-pane -t ${shellQuote(paneId)} -e -p && ` +
800
- `printf '%s\\n' ${shellQuote(SEP)} && ` +
801
- `tmux display-message -t ${shellQuote(paneId)} -p '#{history_size}'`;
802
- const result = await run(this.runner, cmd);
803
- if (result.exitCode !== 0) {
804
- throw new Error(`tmux capturePaneSnapshot ${paneId} failed: ${result.stderr || 'unknown error'}`);
805
- }
806
- const idx = result.stdout.lastIndexOf(`${SEP}\n`);
807
- if (idx < 0) {
808
- throw new Error(`tmux capturePaneSnapshot ${paneId}: separator missing in output`);
809
- }
810
- const visible = stripAnsi(result.stdout.slice(0, idx));
811
- const history = result.stdout.slice(idx + SEP.length + 1).trim();
812
- return `${visible}\n---history_size:${history}---`;
1015
+ async capturePaneSnapshot(pane) {
1016
+ const { header, body } = await this.guardedPaneRead(pane, '|#{history_size}', [`capture-pane -t ${pane.paneId} -e -p`]);
1017
+ const history = header.replace(/^\|/, '').trim();
1018
+ return `${stripAnsi(body)}\n---history_size:${history}---`;
813
1019
  }
814
- async sendEnter(paneId) {
815
- await this.sendKeysToPane(paneId, 'Enter');
1020
+ async sendEnter(pane) {
1021
+ await this.sendKeysToPane(pane, 'Enter');
816
1022
  }
817
1023
  // 空格先弄脏 composer——空 composer 上 C-c 会退出 codex;间隔防止连发被 codex 粘贴检测合并。
818
- async clearComposerDraft(paneId) {
819
- await this.sendKeysLiteral(paneId, ' ');
1024
+ async clearComposerDraft(pane) {
1025
+ await this.sendKeysLiteral(pane, ' ');
820
1026
  await sleep(COMPOSER_DIRTY_SETTLE_MS);
821
- await this.sendKeysToPane(paneId, 'C-c');
1027
+ await this.sendKeysToPane(pane, 'C-c');
822
1028
  }
823
- async captureSettledSnapshot(paneId, opts = {}) {
1029
+ async captureSettledSnapshot(pane, opts = {}) {
824
1030
  const deadline = Date.now() + (opts.timeoutMs ?? 3_000);
825
1031
  const interval = Math.max(opts.intervalMs ?? 150, MIN_POLL_INTERVAL_MS);
826
- let prev = await this.capturePaneSnapshot(paneId);
1032
+ let prev = await this.capturePaneSnapshot(pane);
827
1033
  while (Date.now() < deadline) {
828
1034
  await sleep(interval);
829
- const cur = await this.capturePaneSnapshot(paneId);
1035
+ const cur = await this.capturePaneSnapshot(pane);
830
1036
  if (cur === prev)
831
1037
  return cur;
832
1038
  prev = cur;
833
1039
  }
834
1040
  return prev;
835
1041
  }
836
- async waitSubmitAck(paneId, baseline, runtime, opts = {}) {
1042
+ async waitSubmitAck(pane, baseline, runtime, opts = {}) {
837
1043
  const deadline = Date.now() + (opts.timeoutMs ?? 30_000);
838
1044
  const interval = Math.max(opts.intervalMs ?? 100, MIN_POLL_INTERVAL_MS);
839
1045
  const composerBaseline = stripHistorySuffix(baseline);
840
1046
  if (submitAckBusy(composerBaseline, runtime)) {
841
- throw new Error(`runtime ack timeout (paneId=${paneId}): pane already busy at baseline`);
1047
+ throw new Error(`runtime ack timeout (paneId=${pane.paneId}): pane already busy at baseline`);
842
1048
  }
843
1049
  // Width-independent busy authority: at narrow width the in-pane "working" line soft-wraps out of regex range, but the OSC title still transitions idle→working. Prefer the caller's PRE-Enter sample; reading here would race a runtime that flips to working right after submit.
844
- const baselineTitle = opts.baselineTitle ?? await this.readPaneTitle(paneId);
1050
+ const baselineTitle = opts.baselineTitle ?? await this.readPaneTitle(pane);
845
1051
  if (hasOscTitleWorking(baselineTitle)) {
846
- throw new Error(`runtime ack timeout (paneId=${paneId}): pane already busy at baseline`);
1052
+ throw new Error(`runtime ack timeout (paneId=${pane.paneId}): pane already busy at baseline`);
847
1053
  }
848
1054
  const resendIntervalMs = Math.max(opts.resendIntervalMs ?? 3_000, interval);
849
1055
  let lastResend = Date.now();
850
1056
  while (Date.now() < deadline) {
851
- const visible = stripHistorySuffix(await this.capturePaneSnapshot(paneId));
1057
+ const visible = stripHistorySuffix(await this.capturePaneSnapshot(pane));
852
1058
  if (submitAckBusy(visible, runtime))
853
1059
  return;
854
- const title = await this.readPaneTitle(paneId);
1060
+ const title = await this.readPaneTitle(pane);
855
1061
  if (hasOscTitleWorking(title) && title !== baselineTitle)
856
1062
  return;
857
1063
  if (opts.acceptComposerChange && visible !== composerBaseline)
@@ -876,23 +1082,23 @@ export class TmuxManager {
876
1082
  }
877
1083
  await sleep(interval);
878
1084
  }
879
- throw new Error(`runtime ack timeout (paneId=${paneId})`);
1085
+ throw new Error(`runtime ack timeout (paneId=${pane.paneId})`);
880
1086
  }
881
- async handleTrustDialog(paneId, runtime, opts = {}) {
1087
+ async handleTrustDialog(pane, runtime, opts = {}) {
882
1088
  const deadline = Date.now() + (opts.timeoutMs ?? 10_000);
883
1089
  const interval = opts.intervalMs ?? 500;
884
1090
  const dialogPattern = TRUST_DIALOGS[runtime];
885
1091
  while (Date.now() < deadline) {
886
- const cap = await this.capturePaneById(paneId, { ansi: true, scrollback: 50 });
1092
+ const cap = await this.capturePaneById(pane, { ansi: true, scrollback: 50 });
887
1093
  const stripped = stripAnsi(cap);
888
1094
  if (dialogPattern.test(stripped)) {
889
- await this.sendKeysToPane(paneId, 'Enter');
1095
+ await this.sendKeysToPane(pane, 'Enter');
890
1096
  await sleep(800);
891
1097
  return true;
892
1098
  }
893
- const current = await this.displayMessage(paneId, '#{pane_current_command}');
1099
+ const current = await this.displayMessage(pane, '#{pane_current_command}');
894
1100
  if (hasReplProcTitle(current, runtime)) {
895
- const freshStripped = stripAnsi(await this.capturePaneById(paneId, { ansi: false, scrollback: 0 }));
1101
+ const freshStripped = stripAnsi(await this.capturePaneById(pane, { ansi: false, scrollback: 0 }));
896
1102
  if (hasRuntimeReadyView(freshStripped, runtime))
897
1103
  return false;
898
1104
  }
@@ -900,21 +1106,10 @@ export class TmuxManager {
900
1106
  }
901
1107
  return false;
902
1108
  }
903
- async classifyPaneForAdopt(paneId, runtime, opts) {
904
- const SEP = '___bx-classify-sep___';
905
- const result = await this.runner.exec(`tmux display-message -p -t ${shellQuote(paneId)} '#{pane_current_command}' ` +
906
- `&& printf '%s\\n' ${shellQuote(SEP)} ` +
907
- `&& tmux capture-pane -p -t ${shellQuote(paneId)} -e -S 0`, opts);
908
- if (result.exitCode !== 0) {
909
- throw new Error(`classifyPaneForAdopt(${paneId}) tmux probe failed: ${result.stderr || 'unknown error'}`);
910
- }
911
- const sepIdx = result.stdout.indexOf(SEP);
912
- if (sepIdx < 0) {
913
- throw new Error(`classifyPaneForAdopt(${paneId}): separator missing in output`);
914
- }
915
- const current = result.stdout.slice(0, sepIdx).split('\n')[0]?.trim() ?? '';
916
- const captureStart = result.stdout.indexOf('\n', sepIdx) + 1;
917
- const stripped = stripAnsi(result.stdout.slice(captureStart));
1109
+ async classifyPaneForAdopt(pane, runtime, opts) {
1110
+ const { header, body } = await this.guardedPaneRead(pane, '#{pane_current_command}', [`capture-pane -p -t ${pane.paneId} -e -S 0`], opts);
1111
+ const current = header.trim();
1112
+ const stripped = stripAnsi(body);
918
1113
  if (REPL_PROC_TITLES[runtime].test(current)) {
919
1114
  if (READY_ANCHORS[runtime].test(stripped))
920
1115
  return { kind: 'live-runtime' };
@@ -928,7 +1123,7 @@ export class TmuxManager {
928
1123
  return { kind: 'shell' };
929
1124
  return { kind: 'other', paneCurrentCommand: current };
930
1125
  }
931
- async waitReplReady(paneId, runtime, opts = {}) {
1126
+ async waitReplReady(pane, runtime, opts = {}) {
932
1127
  const deadline = Date.now() + (opts.timeoutMs ?? 30_000);
933
1128
  const interval = opts.intervalMs ?? 500;
934
1129
  const failFastOnShell = opts.failFastOnShell ?? false;
@@ -938,18 +1133,18 @@ export class TmuxManager {
938
1133
  let lastStripped = '';
939
1134
  let lastTitle;
940
1135
  while (Date.now() < deadline) {
941
- const current = await this.displayMessage(paneId, '#{pane_current_command}', cmdOpts);
1136
+ const current = await this.displayMessage(pane, '#{pane_current_command}', cmdOpts);
942
1137
  if (failFastOnShell && SHELL_PROC_TITLES.test(current)) {
943
- throw new ReplNotReadyError(paneId, runtime, lastStripped, `pane_current_command=${current} (shell), failFastOnShell triggered`);
1138
+ throw new ReplNotReadyError(pane.paneId, runtime, lastStripped, `pane_current_command=${current} (shell), failFastOnShell triggered`);
944
1139
  }
945
- const cap = await this.capturePaneById(paneId, { ansi: true, scrollback, timeoutMs: opts.perCommandTimeoutMs });
1140
+ const cap = await this.capturePaneById(pane, { ansi: true, scrollback, timeoutMs: opts.perCommandTimeoutMs });
946
1141
  const stripped = stripAnsi(cap);
947
1142
  lastStripped = stripped;
948
1143
  if (procTitle.test(current)) {
949
1144
  if (hasRuntimeReadyView(stripped, runtime))
950
1145
  return;
951
1146
  if (opts.titleIdleFastPath && screenAllowsTitleIdle(stripped, runtime)) {
952
- lastTitle = await this.readPaneTitle(paneId, cmdOpts);
1147
+ lastTitle = await this.readPaneTitle(pane, cmdOpts);
953
1148
  if (hasOscTitleIdle(lastTitle, runtime))
954
1149
  return;
955
1150
  }
@@ -957,9 +1152,9 @@ export class TmuxManager {
957
1152
  await sleep(interval);
958
1153
  }
959
1154
  if (opts.titleIdleFastPath && lastTitle === undefined) {
960
- lastTitle = await this.readPaneTitle(paneId, cmdOpts);
1155
+ lastTitle = await this.readPaneTitle(pane, cmdOpts);
961
1156
  }
962
- throw new ReplNotReadyError(paneId, runtime, lastStripped, lastTitle === undefined ? undefined : `paneTitle=${JSON.stringify(lastTitle)}`);
1157
+ throw new ReplNotReadyError(pane.paneId, runtime, lastStripped, lastTitle === undefined ? undefined : `paneTitle=${JSON.stringify(lastTitle)}`);
963
1158
  }
964
1159
  }
965
1160
  function isUnknownTmuxVariable(stderr) {
@@ -976,4 +1171,19 @@ function isSessionAbsent(stderr) {
976
1171
  s.includes('no such session') ||
977
1172
  (s.includes('error connecting to') && s.includes('no such file or directory')));
978
1173
  }
1174
+ // A tmux probe outcome is UNKNOWN (not "absent") when exit 255 (ssh/exec layer) or an INDEPENDENT
1175
+ // transient marker appears. The local "error connecting to <socket> (No such file)" absence itself
1176
+ // matches the generic "error connecting to" transient pattern, so it is stripped before deciding —
1177
+ // only a remaining genuine transient (ssh drop, connection reset, …) makes the outcome unknown.
1178
+ function tmuxProbeOutcomeUnknown(result) {
1179
+ if (result.exitCode === 255)
1180
+ return true;
1181
+ const stderrSansSocketAbsence = (result.stderr || '').replace(/error connecting to \S+ \(no such file or directory\)/gi, '');
1182
+ return isTransientNetworkFailure(stderrSansSocketAbsence) || isTransientNetworkFailure(result.stdout);
1183
+ }
1184
+ // A pane/window that vanished (distinct from a whole-session/server absence).
1185
+ function isTargetGone(stderr) {
1186
+ const s = (stderr || '').trim().toLowerCase();
1187
+ return /can'?t find (?:pane|window)/.test(s) || s.includes('no such pane') || s.includes('no such window');
1188
+ }
979
1189
  //# sourceMappingURL=tmux.js.map