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.
- package/dist/agent/branch.d.ts.map +1 -1
- package/dist/agent/branch.js +19 -13
- package/dist/agent/branch.js.map +1 -1
- package/dist/agent/dispatch-reconciler.d.ts +45 -0
- package/dist/agent/dispatch-reconciler.d.ts.map +1 -0
- package/dist/agent/dispatch-reconciler.js +513 -0
- package/dist/agent/dispatch-reconciler.js.map +1 -0
- package/dist/agent/manager.d.ts +89 -8
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +975 -368
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/pane-streamer-manager.d.ts +70 -0
- package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
- package/dist/agent/pane-streamer-manager.js +487 -5
- package/dist/agent/pane-streamer-manager.js.map +1 -1
- package/dist/agent/pane-streamer.d.ts +48 -1
- package/dist/agent/pane-streamer.d.ts.map +1 -1
- package/dist/agent/pane-streamer.js +338 -15
- package/dist/agent/pane-streamer.js.map +1 -1
- package/dist/agent/preflight.d.ts.map +1 -1
- package/dist/agent/preflight.js +18 -8
- package/dist/agent/preflight.js.map +1 -1
- package/dist/agent/repo-store.d.ts +3 -0
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +192 -67
- package/dist/agent/repo-store.js.map +1 -1
- package/dist/agent/review-transport.d.ts.map +1 -1
- package/dist/agent/review-transport.js +4 -2
- package/dist/agent/review-transport.js.map +1 -1
- package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
- package/dist/agent/tmux-probe-poller.js +29 -20
- package/dist/agent/tmux-probe-poller.js.map +1 -1
- package/dist/agent/tmux.d.ts +79 -32
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +435 -225
- package/dist/agent/tmux.js.map +1 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +10 -0
- package/dist/api/config.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +186 -94
- package/dist/api/projects.js.map +1 -1
- package/dist/api/tasks.d.ts.map +1 -1
- package/dist/api/tasks.js +5 -1
- package/dist/api/tasks.js.map +1 -1
- package/dist/app.d.ts +2 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +1 -0
- package/dist/app.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -3
- package/dist/cli.js.map +1 -1
- package/dist/config/hot-reload.d.ts.map +1 -1
- package/dist/config/hot-reload.js +1 -0
- package/dist/config/hot-reload.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +48 -3
- package/dist/config/loader.js.map +1 -1
- package/dist/config/validator.js +3 -0
- package/dist/config/validator.js.map +1 -1
- package/dist/errors.d.ts +2 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -1
- package/dist/errors.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +138 -41
- package/dist/event/handlers.js.map +1 -1
- package/dist/github/pr-conversation-cache.d.ts +41 -0
- package/dist/github/pr-conversation-cache.d.ts.map +1 -0
- package/dist/github/pr-conversation-cache.js +105 -0
- package/dist/github/pr-conversation-cache.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -1
- package/dist/shared/constants.d.ts +6 -0
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +6 -0
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +4 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js.map +1 -1
- package/dist/state/agent-store.d.ts +5 -1
- package/dist/state/agent-store.d.ts.map +1 -1
- package/dist/state/agent-store.js +29 -15
- package/dist/state/agent-store.js.map +1 -1
- package/dist/state/lock.d.ts +9 -0
- package/dist/state/lock.d.ts.map +1 -1
- package/dist/state/lock.js +24 -1
- package/dist/state/lock.js.map +1 -1
- package/dist/state/task-store.js +2 -2
- package/dist/state/task-store.js.map +1 -1
- package/dist/terminal/attach.d.ts +10 -1
- package/dist/terminal/attach.d.ts.map +1 -1
- package/dist/terminal/attach.js +40 -20
- package/dist/terminal/attach.js.map +1 -1
- package/dist/terminal/stream-ws.d.ts.map +1 -1
- package/dist/terminal/stream-ws.js +27 -0
- package/dist/terminal/stream-ws.js.map +1 -1
- package/dist/web/assets/{index-DfcbGriX.css → index-CwYBVTyn.css} +1 -1
- package/dist/web/assets/index-DtnDvjOU.js +16 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DskVWe7e.js +0 -13
package/dist/agent/tmux.js
CHANGED
|
@@ -1,20 +1,55 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { shellQuote
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
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} ${
|
|
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(
|
|
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
|
-
|
|
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
|
|
672
|
+
async getSinglePaneByRef(ref, claim, opts) {
|
|
512
673
|
assertSessionRef(ref);
|
|
513
|
-
|
|
674
|
+
assertPlainSessionName(claim);
|
|
514
675
|
const result = await run(this.runner, `tmux list-panes -a -F '#{pane_id} #{pane_current_command}' ` +
|
|
515
|
-
`-f ${shellQuote(
|
|
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
|
|
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
|
-
|
|
690
|
+
const paneId = panes[0].split(' ')[0];
|
|
691
|
+
assertPaneId(paneId);
|
|
692
|
+
return { session: ref, paneId, claim };
|
|
530
693
|
}
|
|
531
|
-
// Generation
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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 ${
|
|
710
|
+
`${shellQuote(`display-message -p ${REFUSED_MARKER}`)}`, opts);
|
|
543
711
|
if (result.exitCode === 0) {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
589
|
-
|
|
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
|
|
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
|
|
606
|
-
const
|
|
607
|
-
|
|
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
|
|
632
|
-
const
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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(
|
|
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
|
|
717
|
-
|
|
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
|
-
|
|
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
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
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(
|
|
798
|
-
const
|
|
799
|
-
const
|
|
800
|
-
|
|
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(
|
|
815
|
-
await this.sendKeysToPane(
|
|
1020
|
+
async sendEnter(pane) {
|
|
1021
|
+
await this.sendKeysToPane(pane, 'Enter');
|
|
816
1022
|
}
|
|
817
1023
|
// 空格先弄脏 composer——空 composer 上 C-c 会退出 codex;间隔防止连发被 codex 粘贴检测合并。
|
|
818
|
-
async clearComposerDraft(
|
|
819
|
-
await this.sendKeysLiteral(
|
|
1024
|
+
async clearComposerDraft(pane) {
|
|
1025
|
+
await this.sendKeysLiteral(pane, ' ');
|
|
820
1026
|
await sleep(COMPOSER_DIRTY_SETTLE_MS);
|
|
821
|
-
await this.sendKeysToPane(
|
|
1027
|
+
await this.sendKeysToPane(pane, 'C-c');
|
|
822
1028
|
}
|
|
823
|
-
async captureSettledSnapshot(
|
|
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(
|
|
1032
|
+
let prev = await this.capturePaneSnapshot(pane);
|
|
827
1033
|
while (Date.now() < deadline) {
|
|
828
1034
|
await sleep(interval);
|
|
829
|
-
const cur = await this.capturePaneSnapshot(
|
|
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(
|
|
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(
|
|
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(
|
|
1057
|
+
const visible = stripHistorySuffix(await this.capturePaneSnapshot(pane));
|
|
852
1058
|
if (submitAckBusy(visible, runtime))
|
|
853
1059
|
return;
|
|
854
|
-
const title = await this.readPaneTitle(
|
|
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(
|
|
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(
|
|
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(
|
|
1095
|
+
await this.sendKeysToPane(pane, 'Enter');
|
|
890
1096
|
await sleep(800);
|
|
891
1097
|
return true;
|
|
892
1098
|
}
|
|
893
|
-
const current = await this.displayMessage(
|
|
1099
|
+
const current = await this.displayMessage(pane, '#{pane_current_command}');
|
|
894
1100
|
if (hasReplProcTitle(current, runtime)) {
|
|
895
|
-
const freshStripped = stripAnsi(await this.capturePaneById(
|
|
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(
|
|
904
|
-
const
|
|
905
|
-
const
|
|
906
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|