baxian 2.0.28 → 2.0.30
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/bootstrap.js +2 -2
- package/dist/agent/bootstrap.js.map +1 -1
- package/dist/agent/manager.d.ts +27 -1
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +352 -130
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/runner.d.ts +3 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +6 -0
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tmux-probe-poller.d.ts +12 -2
- package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
- package/dist/agent/tmux-probe-poller.js +199 -57
- package/dist/agent/tmux-probe-poller.js.map +1 -1
- package/dist/agent/tmux.d.ts +21 -5
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +289 -51
- package/dist/agent/tmux.js.map +1 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +38 -4
- package/dist/api/config.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +112 -92
- package/dist/api/projects.js.map +1 -1
- package/dist/web/assets/{index-CItUtzU7.js → index-vQFMnvpO.js} +3 -3
- package/dist/web/index.html +1 -1
- package/package.json +2 -1
package/dist/agent/tmux.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { shellQuote } from './runner.js';
|
|
2
|
+
import { ExecNotStartedError, shellQuote } from './runner.js';
|
|
3
3
|
import { execOutcomeUnknown, isTransientNetworkFailure } from './net-exec.js';
|
|
4
4
|
import { classifyScreen, isTrustedIdleRule } from './detect/classify.js';
|
|
5
5
|
import { MAX_PROMPT_BYTES } from './prompt.js';
|
|
@@ -9,6 +9,11 @@ const SESSION_REF_FORMAT = '#{pid}|#{start_time}|#{session_id}';
|
|
|
9
9
|
const REFUSED_MARKER = 'BX_KILL_REFUSED';
|
|
10
10
|
const TARGET_GONE_MARKER = 'BX_TARGET_GONE';
|
|
11
11
|
const PANE_OK_MARKER = 'BX_PANE_OK';
|
|
12
|
+
const SHELL_OK_MARKER = 'BX_SHELL_OK';
|
|
13
|
+
const SHELL_REFUSED_MARKER = 'BX_SHELL_REFUSED';
|
|
14
|
+
const SHELL_WRITE_NONCE_OPTION = '@bx_shell_write';
|
|
15
|
+
const RUNTIME_OK_MARKER = 'BX_RUNTIME_OK';
|
|
16
|
+
const RUNTIME_REFUSED_MARKER = 'BX_RUNTIME_REFUSED';
|
|
12
17
|
export class PaneGoneError extends Error {
|
|
13
18
|
target;
|
|
14
19
|
constructor(target, detail) {
|
|
@@ -39,12 +44,13 @@ function generationCond(ref) {
|
|
|
39
44
|
function sessionCond(ref, claim) {
|
|
40
45
|
return `#{&&:#{&&:${generationCond(ref)},#{==:#{session_id},${ref.sessionId}}},#{==:#{@baxian-agent-id},${claim}}}`;
|
|
41
46
|
}
|
|
47
|
+
// 不比 #{pane_id}:pane 已由 -t <paneId> 锁定,而 display-message 先跑 strftime,%N 字面量会被当转换符吃掉(tmux 3.6a)
|
|
42
48
|
function paneCond(pane) {
|
|
43
|
-
|
|
44
|
-
return `#{&&:#{&&:${generationCond(pane.session)},${sess}},#{==:#{@baxian-agent-id},${pane.claim}}}`;
|
|
49
|
+
return sessionCond(pane.session, pane.claim);
|
|
45
50
|
}
|
|
51
|
+
// 拒 %:格式要塞进 display-message 正文,tmux 先跑 strftime,带 % 的字面量会被当转换符吃掉
|
|
46
52
|
function assertPlainFormat(fmt) {
|
|
47
|
-
if (fmt.includes("'") || fmt.includes('\n')) {
|
|
53
|
+
if (fmt.includes("'") || fmt.includes('\n') || fmt.includes('%')) {
|
|
48
54
|
throw new Error(`tmux format ${JSON.stringify(fmt)} contains unsupported characters`);
|
|
49
55
|
}
|
|
50
56
|
}
|
|
@@ -56,6 +62,11 @@ export function tmuxQuote(value) {
|
|
|
56
62
|
return "''";
|
|
57
63
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
58
64
|
}
|
|
65
|
+
// send-keys 的正文是第一个位置参数,getopt 此时还没停止解析:'-l' 会被当成又一个选项吞掉(退出 0、一个键也不送),
|
|
66
|
+
// 其它 - 开头的正文直接 unknown flag 退出 1。-- 终止选项解析,其后的参数才一定按正文/键名处理
|
|
67
|
+
function sendKeysCommand(paneId, args, literal = false) {
|
|
68
|
+
return `send-keys${literal ? ' -l' : ''} -t ${paneId} -- ${args.map(tmuxQuote).join(' ')}`;
|
|
69
|
+
}
|
|
59
70
|
function assertSessionRef(ref) {
|
|
60
71
|
if (!/^\$\d+$/.test(ref.sessionId) || !/^\d+$/.test(ref.serverPid) || !/^\d+$/.test(ref.serverStart)) {
|
|
61
72
|
throw new Error(`tmux: malformed session ref ${JSON.stringify(ref)}`);
|
|
@@ -145,13 +156,35 @@ export class SessionAbsentError extends Error {
|
|
|
145
156
|
this.name = 'SessionAbsentError';
|
|
146
157
|
}
|
|
147
158
|
}
|
|
159
|
+
function capturePaneCommand(paneId, opts) {
|
|
160
|
+
const flags = ['-p', '-J'];
|
|
161
|
+
if (opts.ansi)
|
|
162
|
+
flags.push('-e');
|
|
163
|
+
if (typeof opts.scrollback === 'number' && opts.scrollback > 0) {
|
|
164
|
+
flags.push('-S', `-${opts.scrollback}`);
|
|
165
|
+
}
|
|
166
|
+
else if (opts.scrollback === 0) {
|
|
167
|
+
flags.push('-S', '0');
|
|
168
|
+
}
|
|
169
|
+
return `capture-pane ${flags.join(' ')} -t ${paneId}`;
|
|
170
|
+
}
|
|
148
171
|
const NEVER_RE = /[^\s\S]/;
|
|
149
|
-
const
|
|
150
|
-
'claude-code':
|
|
151
|
-
codex:
|
|
152
|
-
opencode:
|
|
153
|
-
qodercli:
|
|
172
|
+
const REPL_PROC_TITLE_SPEC = {
|
|
173
|
+
'claude-code': { exact: ['claude', 'claude.exe'], shapes: ['version-triple'] },
|
|
174
|
+
codex: { exact: ['codex', 'node'], shapes: [] },
|
|
175
|
+
opencode: { exact: ['opencode'], shapes: [] },
|
|
176
|
+
qodercli: { exact: ['qodercli'], shapes: [{ digitsAndDotsAfter: 'qodercli-' }] },
|
|
154
177
|
};
|
|
178
|
+
function escapeRegExp(text) {
|
|
179
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
180
|
+
}
|
|
181
|
+
function replProcTitleShapeSource(shape) {
|
|
182
|
+
return shape === 'version-triple' ? '\\d+\\.\\d+\\.\\d+' : `${escapeRegExp(shape.digitsAndDotsAfter)}[0-9.]+`;
|
|
183
|
+
}
|
|
184
|
+
const REPL_PROC_TITLES = Object.fromEntries(Object.entries(REPL_PROC_TITLE_SPEC).map(([runtime, { exact, shapes }]) => [
|
|
185
|
+
runtime,
|
|
186
|
+
new RegExp(`^(?:${[...exact.map(escapeRegExp), ...shapes.map(replProcTitleShapeSource)].join('|')})$`),
|
|
187
|
+
]));
|
|
155
188
|
const READY_ANCHORS = {
|
|
156
189
|
'claude-code': /⏵⏵ bypass permissions on/,
|
|
157
190
|
// tmux keeps styled blank cells, so the row between composer and footer may hold whitespace
|
|
@@ -266,7 +299,33 @@ export class ReplNotReadyError extends Error {
|
|
|
266
299
|
export function isBlockedOnStartupDialog(err, runtime) {
|
|
267
300
|
return err instanceof ReplNotReadyError && !err.shellForeground && detectStartupDialog(err.lastScreen, runtime);
|
|
268
301
|
}
|
|
269
|
-
const
|
|
302
|
+
const SHELL_PROC_NAMES = ['zsh', 'bash', 'sh', 'fish', 'dash', 'ash', 'ksh', 'mksh', 'tcsh', 'csh', 'nu', 'xonsh', 'pwsh'];
|
|
303
|
+
const SHELL_PROC_TITLES = new RegExp(`^(?:${SHELL_PROC_NAMES.join('|')})$`);
|
|
304
|
+
// tmux 格式没有正则交替(m/r 要 3.1+),同一份 shell 名单用 || 链搬进服务端条件
|
|
305
|
+
function shellForegroundCond() {
|
|
306
|
+
return SHELL_PROC_NAMES
|
|
307
|
+
.map(name => `#{==:#{pane_current_command},${name}}`)
|
|
308
|
+
.reduceRight((rest, eq) => `#{||:${eq},${rest}}`);
|
|
309
|
+
}
|
|
310
|
+
// runtime 侧写的服务端条件:前台正是目标 runtime;"不是 shell"放不过 runtime 拉起的编辑器、分页器这类会错收按键的前台进程
|
|
311
|
+
// fnmatch 的 * 匹配任意串而不是重复字符类:版本号形态要由"纯数字点、恰两个点、不以点开头结尾、无连续点"的交集拼出;tmux 格式没有取反,用 #{?c,0,1}
|
|
312
|
+
function runtimeForegroundCond(runtime) {
|
|
313
|
+
const fg = '#{pane_current_command}';
|
|
314
|
+
const glob = (pattern) => `#{m:${pattern},${fg}}`;
|
|
315
|
+
const not = (cond) => `#{?${cond},0,1}`;
|
|
316
|
+
const all = (conds) => conds.reduceRight((rest, cond) => `#{&&:${cond},${rest}}`);
|
|
317
|
+
const shapeCond = (shape) => (shape === 'version-triple'
|
|
318
|
+
? all([glob('*.*.*'), ...['*.*.*.*', '*[!0-9.]*', '.*', '*.', '*..*'].map(pattern => not(glob(pattern)))])
|
|
319
|
+
: all([glob(`${shape.digitsAndDotsAfter}?*`), not(glob(`${shape.digitsAndDotsAfter}*[!0-9.]*`))]));
|
|
320
|
+
const { exact, shapes } = REPL_PROC_TITLE_SPEC[runtime];
|
|
321
|
+
return [...exact.map(name => `#{==:${fg},${name}}`), ...shapes.map(shapeCond)]
|
|
322
|
+
.reduceRight((rest, cond) => `#{||:${cond},${rest}}`);
|
|
323
|
+
}
|
|
324
|
+
// 拒绝按前台分类:shell 前台让调用方走清理/relaunch 分支,其他前台进程只是此刻不可写
|
|
325
|
+
function foreignForegroundError(pane, runtime, foreground, withheld) {
|
|
326
|
+
const shell = isShellProcTitle(foreground);
|
|
327
|
+
return new ReplNotReadyError(pane.paneId, runtime, '', `pane foreground is "${foreground}", ${shell ? 'a shell, ' : ''}not ${runtime}; ${withheld}`, shell);
|
|
328
|
+
}
|
|
270
329
|
const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
271
330
|
const stripAnsi = (s) => s.replace(ANSI_PATTERN, '');
|
|
272
331
|
const CODEX_SPARKLE_GLYPHS = /[⠁⠂⠄⠈⠐⠠⡀⢀]/g;
|
|
@@ -302,6 +361,12 @@ function blankSparkles(body, runtime) {
|
|
|
302
361
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
303
362
|
const MIN_POLL_INTERVAL_MS = 50;
|
|
304
363
|
const COMPOSER_DIRTY_SETTLE_MS = 200;
|
|
364
|
+
const COMPOSER_DIRTY_TIMEOUT_MS = 2_000;
|
|
365
|
+
// codex 空 composer 上单次 C-c 即退出:只有光标列位移能证明弄脏键已入 composer,而不是还暂存在粘贴突发检测里
|
|
366
|
+
const CTRL_C_QUITS_EMPTY_COMPOSER = new Set(['codex']);
|
|
367
|
+
const CODEX_COMPOSER_DIRTY_KEY = ',';
|
|
368
|
+
// 折行边界的 overflowing separator 可让首个字符的光标坐标原地不动,第二个字符必定推进(此时 composer 已非空,C-c 只会清稿)
|
|
369
|
+
const COMPOSER_DIRTY_KEY_ATTEMPTS = 2;
|
|
305
370
|
const HISTORY_SUFFIX_RE = /\n---history_size:\d+---$/;
|
|
306
371
|
function stripHistorySuffix(snapshot) {
|
|
307
372
|
return snapshot.replace(HISTORY_SUFFIX_RE, '');
|
|
@@ -566,11 +631,13 @@ export class TmuxManager {
|
|
|
566
631
|
if (result.exitCode === 1 && isSessionAbsent(result.stderr)) {
|
|
567
632
|
throw new PaneGoneError(pane.paneId, result.stderr.trim());
|
|
568
633
|
}
|
|
569
|
-
if (result.exitCode !== 0) {
|
|
570
|
-
throw new Error(`tmux guarded read of ${pane.paneId} failed (exit ${result.exitCode}): ${result.stderr}`);
|
|
571
|
-
}
|
|
572
634
|
const nl = result.stdout.indexOf('\n');
|
|
573
635
|
const firstLine = nl === -1 ? result.stdout : result.stdout.slice(0, nl);
|
|
636
|
+
// 只有 header 的读以整行到齐的标记为准:它是服务端已答复的直接证据,SSH 收尾才断开的 255 不推翻它;带 body 的读无法判断 body 是否被截断,仍按 exit code
|
|
637
|
+
const headerLanded = extra.length === 0 && nl !== -1 && firstLine.startsWith(PANE_OK_MARKER);
|
|
638
|
+
if (result.exitCode !== 0 && !(headerLanded && execOutcomeUnknown(result))) {
|
|
639
|
+
throw new Error(`tmux guarded read of ${pane.paneId} failed (exit ${result.exitCode}): ${result.stderr}`);
|
|
640
|
+
}
|
|
574
641
|
if (!firstLine.startsWith(PANE_OK_MARKER)) {
|
|
575
642
|
throw new PaneGoneError(pane.paneId, 'identity condition failed');
|
|
576
643
|
}
|
|
@@ -664,32 +731,148 @@ export class TmuxManager {
|
|
|
664
731
|
async sendKeysToPane(pane, ...keys) {
|
|
665
732
|
if (keys.length === 0)
|
|
666
733
|
return;
|
|
667
|
-
await this.guardedPaneWrite(pane, [
|
|
668
|
-
`send-keys -t ${pane.paneId} ${keys.map(k => tmuxQuote(k)).join(' ')}`,
|
|
669
|
-
]);
|
|
734
|
+
await this.guardedPaneWrite(pane, [sendKeysCommand(pane.paneId, keys)]);
|
|
670
735
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
736
|
+
// 给出 runtime 即由服务端确认前台不是 shell 才键入;不给的是往 shell 提交启动/退出命令的调用方
|
|
737
|
+
async sendKeysLiteral(pane, text, runtime) {
|
|
738
|
+
const inner = [sendKeysCommand(pane.paneId, [text], true)];
|
|
739
|
+
if (runtime)
|
|
740
|
+
return this.writeIfRuntimeForeground(pane, runtime, inner, `the text ${JSON.stringify(text)}`);
|
|
741
|
+
await this.guardedPaneWrite(pane, inner);
|
|
675
742
|
}
|
|
676
|
-
async
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
743
|
+
async sendKeysToRuntime(pane, runtime, ...keys) {
|
|
744
|
+
if (keys.length === 0)
|
|
745
|
+
return;
|
|
746
|
+
await this.writeIfRuntimeForeground(pane, runtime, [sendKeysCommand(pane.paneId, keys)], keys.join(' '));
|
|
747
|
+
}
|
|
748
|
+
// 一行文本与回车在同一条守卫命令里排入:分两次守卫,前台在两者之间换了进程会只留半行在 composer(/exit 留到下次回车才生效)
|
|
749
|
+
async submitToRuntime(pane, runtime, text) {
|
|
750
|
+
const outcome = await this.guardedForegroundWrite(pane, runtime, [sendKeysCommand(pane.paneId, [text], true), sendKeysCommand(pane.paneId, ['Enter'])]);
|
|
751
|
+
if (outcome.kind === 'refused') {
|
|
752
|
+
throw foreignForegroundError(pane, runtime, outcome.foreground, `the line ${JSON.stringify(text)} and Enter withheld`);
|
|
684
753
|
}
|
|
685
|
-
|
|
686
|
-
|
|
754
|
+
if (outcome.kind === 'uncertain') {
|
|
755
|
+
throw new TmuxOutcomeUnknownError(`tmux submit to ${pane.paneId} outcome unknown: ${outcome.cause}; inspect the pane before retrying`);
|
|
687
756
|
}
|
|
757
|
+
}
|
|
758
|
+
// 前台判定与按键在同一条 if-shell 里由 tmux 服务端一次完成:分两次往返,间隙里前台换了进程,按键就落到另一个进程上
|
|
759
|
+
async guardedForegroundWrite(pane, guard, inner, opts) {
|
|
760
|
+
assertPaneRef(pane);
|
|
761
|
+
const [okMarker, refusedMarker, cond, label] = guard === 'shell'
|
|
762
|
+
? [SHELL_OK_MARKER, SHELL_REFUSED_MARKER, shellForegroundCond(), 'shell']
|
|
763
|
+
: [RUNTIME_OK_MARKER, RUNTIME_REFUSED_MARKER, runtimeForegroundCond(guard), 'runtime'];
|
|
764
|
+
const refused = `display-message -p -t ${pane.paneId} '${refusedMarker}|${paneCond(pane)}|#{pane_current_command}'`;
|
|
765
|
+
let result;
|
|
766
|
+
try {
|
|
767
|
+
result = await run(this.runner, `tmux if-shell -t ${shellQuote(pane.paneId)} -F ${shellQuote(`#{&&:${paneCond(pane)},${cond}}`)} ` +
|
|
768
|
+
`${shellQuote([...inner, `display-message -p ${okMarker}`].join(' ; '))} ${shellQuote(refused)}`, opts);
|
|
769
|
+
}
|
|
770
|
+
catch (err) {
|
|
771
|
+
if (err instanceof ExecNotStartedError)
|
|
772
|
+
throw err;
|
|
773
|
+
return { kind: 'uncertain', cause: `exec rejected: ${err instanceof Error ? err.message : String(err)}` };
|
|
774
|
+
}
|
|
775
|
+
// 标记是服务端已执行/已拒绝的直接证据,先于 exit code 判读:成功标记已收到、SSH 却在收尾时断开会给出 exit 255
|
|
776
|
+
const lines = result.stdout.split('\n');
|
|
777
|
+
if (lines.some(line => line.startsWith(okMarker)))
|
|
778
|
+
return { kind: 'applied' };
|
|
779
|
+
const refusal = lines.find(line => line.startsWith(refusedMarker));
|
|
780
|
+
if (refusal) {
|
|
781
|
+
const [, identityOk, foreground = ''] = refusal.split('|');
|
|
782
|
+
if (identityOk !== '1')
|
|
783
|
+
throw new PaneGoneError(pane.paneId, 'identity condition failed');
|
|
784
|
+
return { kind: 'refused', foreground };
|
|
785
|
+
}
|
|
786
|
+
if (result.exitCode === 1 && isSessionAbsent(result.stderr))
|
|
787
|
+
throw new PaneGoneError(pane.paneId, result.stderr.trim());
|
|
788
|
+
if (result.exitCode === 0 || execOutcomeUnknown(result)) {
|
|
789
|
+
return {
|
|
790
|
+
kind: 'uncertain',
|
|
791
|
+
exitCode: result.exitCode,
|
|
792
|
+
cause: `neither marker returned (exit ${result.exitCode}): ${JSON.stringify(result.stdout)} ${result.stderr.trim()}`.trim(),
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
throw new Error(`tmux ${label}-guarded write to ${pane.paneId} failed (exit ${result.exitCode}): ${result.stderr}`);
|
|
796
|
+
}
|
|
797
|
+
// runtime 侧的按键与粘贴:前台不是目标 runtime 就一个键都不发。落进 shell 的提示词和回车会被当作命令执行,落进其他前台进程的按键会被错收
|
|
798
|
+
async writeIfRuntimeForeground(pane, runtime, inner, what, opts) {
|
|
799
|
+
const outcome = await this.guardedForegroundWrite(pane, runtime, inner, opts);
|
|
800
|
+
if (outcome.kind === 'applied')
|
|
801
|
+
return;
|
|
802
|
+
if (outcome.kind === 'refused')
|
|
803
|
+
throw foreignForegroundError(pane, runtime, outcome.foreground, `${what} withheld`);
|
|
804
|
+
if (outcome.exitCode === 0)
|
|
805
|
+
return;
|
|
806
|
+
throw new Error(`tmux runtime-guarded write to ${pane.paneId} failed: ${outcome.cause}`);
|
|
807
|
+
}
|
|
808
|
+
// 弄脏键可能已落进 shell 行:只在前台仍是 shell 时 C-c 丢弃它,间隙里被拉起的 runtime 不能吃到这个 C-c
|
|
809
|
+
async discardShellInputLine(pane) {
|
|
810
|
+
const outcome = await this.guardedForegroundWrite(pane, 'shell', [sendKeysCommand(pane.paneId, ['C-c'])]);
|
|
811
|
+
if (outcome.kind === 'refused')
|
|
812
|
+
return false;
|
|
813
|
+
if (outcome.kind === 'uncertain' && outcome.exitCode !== 0) {
|
|
814
|
+
throw new Error(`tmux shell-guarded write to ${pane.paneId} failed: ${outcome.cause}`);
|
|
815
|
+
}
|
|
816
|
+
return true;
|
|
817
|
+
}
|
|
818
|
+
// shell 判定与 C-c/命令/Enter 在同一条 if-shell 里由 tmux 服务端一次完成:分两次往返,间隙里被人手动拉起的 runtime 会吃到 C-c(codex 空 composer 直接退出)
|
|
819
|
+
async submitCommandOnShell(pane, command, opts) {
|
|
820
|
+
// nonce 排在按键之前:tmux 命令列表中途出错即中止,事后读不到本次 nonce 就是一个键都没发
|
|
821
|
+
const nonce = randomUUID();
|
|
822
|
+
const outcome = await this.guardedForegroundWrite(pane, 'shell', [
|
|
823
|
+
`set-option -t ${pane.paneId} ${SHELL_WRITE_NONCE_OPTION} ${nonce}`,
|
|
824
|
+
sendKeysCommand(pane.paneId, ['C-c']),
|
|
825
|
+
sendKeysCommand(pane.paneId, [command], true),
|
|
826
|
+
sendKeysCommand(pane.paneId, ['Enter']),
|
|
827
|
+
], opts);
|
|
828
|
+
if (outcome.kind === 'applied')
|
|
829
|
+
return;
|
|
830
|
+
if (outcome.kind === 'refused') {
|
|
831
|
+
throw new Error(`tmux pane ${pane.paneId} foreground is "${outcome.foreground}", not a shell; C-c, command and Enter withheld`);
|
|
832
|
+
}
|
|
833
|
+
return this.reconcileShellWrite(pane, nonce, outcome.cause, opts);
|
|
834
|
+
}
|
|
835
|
+
// 结果不确定就按 nonce 对账:读到本次 nonce 即按键已排入;没有就是一个键都没发;连 nonce 都读不到则明确报 outcome unknown,不冒充普通失败
|
|
836
|
+
async reconcileShellWrite(pane, nonce, cause, opts) {
|
|
837
|
+
let seen;
|
|
838
|
+
try {
|
|
839
|
+
seen = (await this.guardedPaneRead(pane, `#{${SHELL_WRITE_NONCE_OPTION}}`, [], opts)).header.trim();
|
|
840
|
+
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
if (err instanceof PaneGoneError)
|
|
843
|
+
throw err;
|
|
844
|
+
throw new TmuxOutcomeUnknownError(`tmux shell-guarded write to ${pane.paneId}: outcome unknown (${cause}) and the nonce probe failed ` +
|
|
845
|
+
`(${err instanceof Error ? err.message : String(err)}); inspect the pane before retrying`);
|
|
846
|
+
}
|
|
847
|
+
if (seen === nonce) {
|
|
848
|
+
console.warn(`[tmux] shell-guarded write to ${pane.paneId}: reconciled as executed after an uncertain result (${cause})`);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
throw new Error(`tmux shell-guarded write to ${pane.paneId} did not reach the tmux server (${cause}); nothing was typed`);
|
|
852
|
+
}
|
|
853
|
+
async capturePaneById(pane, opts = {}) {
|
|
688
854
|
const execOpts = opts.timeoutMs ? { timeout: opts.timeoutMs } : undefined;
|
|
689
|
-
const { body } = await this.guardedPaneRead(pane, '', [
|
|
855
|
+
const { body } = await this.guardedPaneRead(pane, '', [capturePaneCommand(pane.paneId, opts)], execOpts);
|
|
690
856
|
return blankSparkles(body, opts.runtime);
|
|
691
857
|
}
|
|
692
|
-
async
|
|
858
|
+
async readReplSnapshot(pane, runtime, opts) {
|
|
859
|
+
const titleMarker = `BX_REPL_TITLE_${randomUUID()}`;
|
|
860
|
+
const { header, body } = await this.guardedPaneRead(pane, '#{pane_current_command}', [
|
|
861
|
+
capturePaneCommand(pane.paneId, { scrollback: 0 }),
|
|
862
|
+
`display-message -p -t ${pane.paneId} '${titleMarker}#{pane_title}'`,
|
|
863
|
+
], opts);
|
|
864
|
+
// 标题可含换行或分隔符,不能与抓屏共用固定行数/固定分隔符。
|
|
865
|
+
const separator = body.startsWith(titleMarker) ? 0 : body.lastIndexOf(`\n${titleMarker}`) + 1;
|
|
866
|
+
if (!body.slice(separator).startsWith(titleMarker) || !body.endsWith('\n')) {
|
|
867
|
+
throw new Error(`tmux readReplSnapshot ${pane.paneId}: incomplete snapshot`);
|
|
868
|
+
}
|
|
869
|
+
return {
|
|
870
|
+
current: header,
|
|
871
|
+
title: body.slice(separator + titleMarker.length, -1),
|
|
872
|
+
cap: blankSparkles(body.slice(0, separator), runtime),
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
async injectPrompt(pane, prompt, agentId, runtime) {
|
|
693
876
|
assertPaneRef(pane);
|
|
694
877
|
const bytes = Buffer.byteLength(prompt, 'utf8');
|
|
695
878
|
if (bytes > MAX_PROMPT_BYTES) {
|
|
@@ -721,9 +904,10 @@ export class TmuxManager {
|
|
|
721
904
|
throw new PaneGoneError(pane.paneId, loaded.stderr.trim());
|
|
722
905
|
throw new PaneGoneError(pane.paneId, `identity probe or buffer load failed before any buffer was created: ${loaded.stderr.trim() || 'probe mismatch'}`);
|
|
723
906
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
`${shellQuote(`
|
|
907
|
+
// 粘贴也由服务端确认前台是目标 runtime:拒绝时顺手删掉 buffer,提示词不在远端滞留
|
|
908
|
+
const pasteCmd = `tmux if-shell -t ${shellQuote(pane.paneId)} -F ${shellQuote(`#{&&:${paneCond(pane)},${runtimeForegroundCond(runtime)}}`)} ` +
|
|
909
|
+
`${shellQuote(`paste-buffer -b ${buf} -t ${pane.paneId} -d -p -r ; display-message -p ${RUNTIME_OK_MARKER}`)} ` +
|
|
910
|
+
`${shellQuote(`delete-buffer -b ${buf} ; display-message -p -t ${pane.paneId} '${RUNTIME_REFUSED_MARKER}|${paneCond(pane)}|#{pane_current_command}'`)}`;
|
|
727
911
|
let pasted;
|
|
728
912
|
try {
|
|
729
913
|
pasted = await run(this.runner, pasteCmd);
|
|
@@ -732,12 +916,18 @@ export class TmuxManager {
|
|
|
732
916
|
await this.reconcileInjectBuffer(buf, `paste exec rejected: ${err instanceof Error ? err.message : String(err)}`);
|
|
733
917
|
throw new Error(`tmux injectPrompt ${pane.paneId} failed (paste exec layer): ${err instanceof Error ? err.message : String(err)}`);
|
|
734
918
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
throw new PaneGoneError(pane.paneId, 'identity condition failed at paste (buffer self-cleaned)');
|
|
738
|
-
}
|
|
919
|
+
const pastedLines = pasted.stdout.split('\n');
|
|
920
|
+
if (pastedLines.some(line => line.startsWith(RUNTIME_OK_MARKER)))
|
|
739
921
|
return;
|
|
922
|
+
const pasteRefusal = pastedLines.find(line => line.startsWith(RUNTIME_REFUSED_MARKER));
|
|
923
|
+
if (pasteRefusal) {
|
|
924
|
+
const [, identityOk, foreground = ''] = pasteRefusal.split('|');
|
|
925
|
+
if (identityOk !== '1')
|
|
926
|
+
throw new PaneGoneError(pane.paneId, 'identity condition failed at paste (buffer self-cleaned)');
|
|
927
|
+
throw foreignForegroundError(pane, runtime, foreground, 'prompt paste withheld (buffer self-cleaned)');
|
|
740
928
|
}
|
|
929
|
+
if (pasted.exitCode === 0)
|
|
930
|
+
return;
|
|
741
931
|
await this.reconcileInjectBuffer(buf, `paste failed (exit ${pasted.exitCode}): ${pasted.stderr.trim()}`);
|
|
742
932
|
if (pasted.exitCode === 1 && (isSessionAbsent(pasted.stderr) || isTargetGone(pasted.stderr))) {
|
|
743
933
|
throw new PaneGoneError(pane.paneId, pasted.stderr.trim() || 'pane gone before paste');
|
|
@@ -805,11 +995,9 @@ export class TmuxManager {
|
|
|
805
995
|
}
|
|
806
996
|
return { buf };
|
|
807
997
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
throw new Error(`tmux pasteStagedBuffer ${paneId} failed: ${result.stderr}`);
|
|
812
|
-
}
|
|
998
|
+
// 已暂存的提示词也按 pane 身份 + 前台不是 shell 粘贴;拒绝时 buffer 留给调用方 drop,它据此区分"没贴"与"贴了但结果未知"
|
|
999
|
+
async pasteStagedBuffer(pane, buf, runtime) {
|
|
1000
|
+
await this.writeIfRuntimeForeground(pane, runtime, [`paste-buffer -b ${buf} -t ${pane.paneId} -d -p -r`], 'the prompt paste');
|
|
813
1001
|
}
|
|
814
1002
|
async dropStagedBuffer(buf) {
|
|
815
1003
|
const result = await run(this.runner, `tmux delete-buffer -b ${shellQuote(buf)}`);
|
|
@@ -826,13 +1014,63 @@ export class TmuxManager {
|
|
|
826
1014
|
: stripAnsi(body);
|
|
827
1015
|
return `${visible}\n---history_size:${history}---`;
|
|
828
1016
|
}
|
|
829
|
-
async sendEnter(pane) {
|
|
1017
|
+
async sendEnter(pane, runtime) {
|
|
1018
|
+
if (runtime)
|
|
1019
|
+
return this.sendKeysToRuntime(pane, runtime, 'Enter');
|
|
830
1020
|
await this.sendKeysToPane(pane, 'Enter');
|
|
831
1021
|
}
|
|
832
|
-
async clearComposerDraft(pane) {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1022
|
+
async clearComposerDraft(pane, runtime, opts = {}) {
|
|
1023
|
+
if (!CTRL_C_QUITS_EMPTY_COMPOSER.has(runtime)) {
|
|
1024
|
+
await this.sendKeysLiteral(pane, ' ', runtime);
|
|
1025
|
+
await sleep(COMPOSER_DIRTY_SETTLE_MS);
|
|
1026
|
+
await this.sendKeysToRuntime(pane, runtime, 'C-c');
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const rest = await this.readComposerFrame(pane);
|
|
1030
|
+
if (!hasReplProcTitle(rest.foreground, runtime)) {
|
|
1031
|
+
throw new ReplNotReadyError(pane.paneId, runtime, await this.captureForDiagnosis(pane, runtime), `pane foreground is "${rest.foreground}", not ${runtime}; nothing typed and C-c withheld`);
|
|
1032
|
+
}
|
|
1033
|
+
for (let attempt = 1; attempt <= COMPOSER_DIRTY_KEY_ATTEMPTS; attempt++) {
|
|
1034
|
+
await this.sendKeysLiteral(pane, CODEX_COMPOSER_DIRTY_KEY, runtime);
|
|
1035
|
+
const frame = await this.waitComposerFrameChange(pane, runtime, rest, opts);
|
|
1036
|
+
if (!frame)
|
|
1037
|
+
continue;
|
|
1038
|
+
if (!hasReplProcTitle(frame.foreground, runtime)) {
|
|
1039
|
+
const shell = isShellProcTitle(frame.foreground);
|
|
1040
|
+
// 弄脏键可能已落进 shell 行,不清会让下一条 relaunch 命令接在逗号后面
|
|
1041
|
+
const discarded = shell && await this.discardShellInputLine(pane);
|
|
1042
|
+
throw new ReplNotReadyError(pane.paneId, runtime, await this.captureForDiagnosis(pane, runtime), `pane foreground became "${frame.foreground}" after the dirtying key; the cursor move is not composer evidence` +
|
|
1043
|
+
(discarded ? '; the stray key was discarded with C-c on the shell' : '') +
|
|
1044
|
+
(shell && !discarded ? '; a runtime took the foreground again before the stray key could be discarded, so no C-c was sent' : ''));
|
|
1045
|
+
}
|
|
1046
|
+
// 同帧证据到 C-c 之间 runtime 仍可能退出:前台已是 shell 就不发,随 runtime 一起消失的逗号也无需再清
|
|
1047
|
+
await this.sendKeysToRuntime(pane, runtime, 'C-c');
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
throw new ReplNotReadyError(pane.paneId, runtime, await this.captureForDiagnosis(pane, runtime), `${COMPOSER_DIRTY_KEY_ATTEMPTS} dirtying keys "${CODEX_COMPOSER_DIRTY_KEY}" left the cursor at column ${rest.column} ` +
|
|
1051
|
+
`(${opts.timeoutMs ?? COMPOSER_DIRTY_TIMEOUT_MS}ms each); C-c withheld because an empty ${runtime} composer would quit on it`);
|
|
1052
|
+
}
|
|
1053
|
+
// 光标位移只证明"某个行编辑器"收下了字符:与前台进程同帧读取,runtime 退出后 shell 提示符造成的位移不算
|
|
1054
|
+
async readComposerFrame(pane) {
|
|
1055
|
+
const raw = await this.displayMessage(pane, '#{cursor_x}|#{pane_current_command}');
|
|
1056
|
+
const [column = '', foreground = ''] = raw.trim().split('|');
|
|
1057
|
+
return { column, foreground };
|
|
1058
|
+
}
|
|
1059
|
+
async waitComposerFrameChange(pane, runtime, rest, opts) {
|
|
1060
|
+
const deadline = Date.now() + (opts.timeoutMs ?? COMPOSER_DIRTY_TIMEOUT_MS);
|
|
1061
|
+
const interval = Math.max(opts.intervalMs ?? MIN_POLL_INTERVAL_MS, MIN_POLL_INTERVAL_MS);
|
|
1062
|
+
while (true) {
|
|
1063
|
+
const frame = await this.readComposerFrame(pane);
|
|
1064
|
+
if (frame.column !== rest.column || !hasReplProcTitle(frame.foreground, runtime))
|
|
1065
|
+
return frame;
|
|
1066
|
+
if (Date.now() >= deadline)
|
|
1067
|
+
return undefined;
|
|
1068
|
+
await sleep(interval);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
async captureForDiagnosis(pane, runtime) {
|
|
1072
|
+
const { body } = await this.guardedPaneRead(pane, '', [`capture-pane -t ${pane.paneId} -e -p`]);
|
|
1073
|
+
return blankSparkles(stripAnsi(body), runtime).replace(/ +$/gm, '');
|
|
836
1074
|
}
|
|
837
1075
|
async captureSettledSnapshot(pane, opts = {}) {
|
|
838
1076
|
const deadline = Date.now() + (opts.timeoutMs ?? 3_000);
|
|
@@ -944,8 +1182,8 @@ export class TmuxManager {
|
|
|
944
1182
|
const cmdOpts = opts.perCommandTimeoutMs ? { timeout: opts.perCommandTimeoutMs } : undefined;
|
|
945
1183
|
let lastStripped = '';
|
|
946
1184
|
let lastTitle;
|
|
947
|
-
// 见过 runtime 后回落 shell 才提前放弃;没见过=启动钩子尚未 exec runtime,等到窗口耗尽仍是 shell
|
|
948
|
-
let sawRuntime = false;
|
|
1185
|
+
// 见过 runtime 后回落 shell 才提前放弃;没见过=启动钩子尚未 exec runtime,等到窗口耗尽仍是 shell 才据实报告;调用方刚确认过前台是 runtime 时首帧的 shell 就是已退出
|
|
1186
|
+
let sawRuntime = opts.runtimeSeen ?? false;
|
|
949
1187
|
while (true) {
|
|
950
1188
|
const current = await this.displayMessage(pane, '#{pane_current_command}', cmdOpts);
|
|
951
1189
|
if (failFastOnShell && sawRuntime && SHELL_PROC_TITLES.test(current)) {
|