botmux 2.24.5 → 2.24.6

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 (66) hide show
  1. package/README.en.md +4 -2
  2. package/README.md +4 -2
  3. package/dist/adapters/backend/session-backend-selector.d.ts +11 -0
  4. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -0
  5. package/dist/adapters/backend/session-backend-selector.js +26 -0
  6. package/dist/adapters/backend/session-backend-selector.js.map +1 -0
  7. package/dist/adapters/backend/tmux-pipe-backend.d.ts +55 -15
  8. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  9. package/dist/adapters/backend/tmux-pipe-backend.js +163 -21
  10. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  11. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  12. package/dist/adapters/cli/claude-code.js +91 -15
  13. package/dist/adapters/cli/claude-code.js.map +1 -1
  14. package/dist/adapters/cli/shared-hints.js +2 -2
  15. package/dist/adapters/cli/shared-hints.js.map +1 -1
  16. package/dist/cli/arg-utils.d.ts +11 -0
  17. package/dist/cli/arg-utils.d.ts.map +1 -0
  18. package/dist/cli/arg-utils.js +25 -0
  19. package/dist/cli/arg-utils.js.map +1 -0
  20. package/dist/cli/quoted-render.d.ts +30 -0
  21. package/dist/cli/quoted-render.d.ts.map +1 -0
  22. package/dist/cli/quoted-render.js +29 -0
  23. package/dist/cli/quoted-render.js.map +1 -0
  24. package/dist/cli.js +65 -16
  25. package/dist/cli.js.map +1 -1
  26. package/dist/daemon.d.ts.map +1 -1
  27. package/dist/daemon.js +13 -4
  28. package/dist/daemon.js.map +1 -1
  29. package/dist/im/lark/client.d.ts +7 -7
  30. package/dist/im/lark/client.d.ts.map +1 -1
  31. package/dist/im/lark/client.js +14 -15
  32. package/dist/im/lark/client.js.map +1 -1
  33. package/dist/im/lark/message-parser.d.ts +9 -3
  34. package/dist/im/lark/message-parser.d.ts.map +1 -1
  35. package/dist/im/lark/message-parser.js +13 -5
  36. package/dist/im/lark/message-parser.js.map +1 -1
  37. package/dist/im/lark/quote-hint.d.ts +18 -0
  38. package/dist/im/lark/quote-hint.d.ts.map +1 -0
  39. package/dist/im/lark/quote-hint.js +23 -0
  40. package/dist/im/lark/quote-hint.js.map +1 -0
  41. package/dist/skills/definitions.d.ts +4 -0
  42. package/dist/skills/definitions.d.ts.map +1 -1
  43. package/dist/skills/definitions.js +69 -12
  44. package/dist/skills/definitions.js.map +1 -1
  45. package/dist/skills/installer.d.ts +3 -1
  46. package/dist/skills/installer.d.ts.map +1 -1
  47. package/dist/skills/installer.js +18 -3
  48. package/dist/skills/installer.js.map +1 -1
  49. package/dist/types.d.ts +3 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/utils/logger.d.ts +1 -1
  52. package/dist/utils/logger.js +1 -1
  53. package/dist/utils/screenshot-renderer.d.ts.map +1 -1
  54. package/dist/utils/screenshot-renderer.js +63 -26
  55. package/dist/utils/screenshot-renderer.js.map +1 -1
  56. package/dist/utils/terminal-renderer.d.ts +16 -0
  57. package/dist/utils/terminal-renderer.d.ts.map +1 -1
  58. package/dist/utils/terminal-renderer.js +35 -21
  59. package/dist/utils/terminal-renderer.js.map +1 -1
  60. package/dist/utils/transient-snapshot.d.ts +28 -0
  61. package/dist/utils/transient-snapshot.d.ts.map +1 -0
  62. package/dist/utils/transient-snapshot.js +96 -0
  63. package/dist/utils/transient-snapshot.js.map +1 -0
  64. package/dist/worker.js +94 -31
  65. package/dist/worker.js.map +1 -1
  66. package/package.json +1 -1
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Transient snapshot helper for tmux pipe-pane sessions.
3
+ *
4
+ * Instead of accumulating PTY data into a long-lived xterm-headless instance
5
+ * (the legacy renderer path), we ask tmux for a fresh ANSI snapshot of the
6
+ * current pane viewport and feed it to a throwaway xterm-headless that gets
7
+ * disposed immediately after we read it.
8
+ *
9
+ * Why: the long-lived buffer accumulates errors over time —
10
+ * - drift if the headless cols/rows don't match the real pane (web client
11
+ * resize changes the real pane via tmux resize-window, but the headless
12
+ * stays at spawn-time dimensions),
13
+ * - leftover history from before alt-buffer switches that the long buffer
14
+ * never strictly cleared,
15
+ * - cursor-positioning sequences emitted by the CLI landing at the wrong
16
+ * coordinates when the headless terminal is narrower than the real pane.
17
+ *
18
+ * Tmux's own state IS the authoritative current screen — capture-pane returns
19
+ * what the user actually sees in their terminal. Seeding a fresh xterm-headless
20
+ * with this snapshot every screenshot/screen-update means there is no
21
+ * accumulated state to drift.
22
+ */
23
+ import xtermHeadless from '@xterm/headless';
24
+ const { Terminal } = xtermHeadless;
25
+ import { TmuxPipeBackend } from '../adapters/backend/tmux-pipe-backend.js';
26
+ import { readViewportText } from './terminal-renderer.js';
27
+ import { captureToPng } from './screenshot-renderer.js';
28
+ import { clamp, MIN_RENDER_COLS, MAX_RENDER_COLS, MIN_RENDER_ROWS, MAX_RENDER_ROWS } from './render-dimensions.js';
29
+ /** Attempt to capture a fresh ANSI snapshot from a TmuxPipeBackend. Returns
30
+ * null if the backend isn't a pipe backend, the pane has gone away, or
31
+ * tmux refuses to answer. Callers should fall back to the legacy renderer
32
+ * path on null. */
33
+ export function tryCapturePipeSnapshot(backend, fallbackCols, fallbackRows) {
34
+ if (!(backend instanceof TmuxPipeBackend))
35
+ return null;
36
+ const live = backend.getPaneSize();
37
+ const cols = clamp(live?.cols ?? fallbackCols, MIN_RENDER_COLS, MAX_RENDER_COLS);
38
+ const rows = clamp(live?.rows ?? fallbackRows, MIN_RENDER_ROWS, MAX_RENDER_ROWS);
39
+ // Viewport-only capture: same number of rows as the transient terminal,
40
+ // so the snapshot never overflows and triggers a normal-buffer scroll.
41
+ const ansi = backend.captureViewport();
42
+ if (!ansi)
43
+ return null;
44
+ return { cols, rows, ansi };
45
+ }
46
+ /** Feed an ANSI snapshot into a transient xterm-headless and yield the
47
+ * terminal once tmux's bytes have been consumed by the parser. Caller MUST
48
+ * dispose() the terminal when done. */
49
+ async function buildTransientTerminal(snap) {
50
+ const terminal = new Terminal({ cols: snap.cols, rows: snap.rows, allowProposedApi: true });
51
+ // xterm.write() with a callback resolves after the async parser has fully
52
+ // consumed the chunk. Without awaiting this, reading the buffer can race
53
+ // and pick up partial state mid-parse.
54
+ await new Promise(resolve => terminal.write(snap.ansi, () => resolve()));
55
+ return terminal;
56
+ }
57
+ /** Render a tmux pipe-pane snapshot to a PNG buffer. Returns null when the
58
+ * backend can't provide a snapshot — caller falls back to the legacy
59
+ * long-lived-renderer path. */
60
+ export async function snapshotToPng(backend, fallbackCols, fallbackRows) {
61
+ const snap = tryCapturePipeSnapshot(backend, fallbackCols, fallbackRows);
62
+ if (!snap)
63
+ return null;
64
+ const terminal = await buildTransientTerminal(snap);
65
+ try {
66
+ // Read the actual viewport — baseY may have shifted if the snapshot
67
+ // ended with a newline that triggered a normal-buffer scroll. We want
68
+ // the *current* visible rows, not buffer rows 0..N-1 which may be
69
+ // stale scrollback after that scroll.
70
+ const startY = terminal.buffer.active.baseY;
71
+ const png = captureToPng(terminal, { cols: snap.cols, rows: snap.rows, startY });
72
+ return { png, ansi: snap.ansi };
73
+ }
74
+ finally {
75
+ terminal.dispose();
76
+ }
77
+ }
78
+ /** Render a tmux pipe-pane snapshot to filtered text (drops the bare prompt
79
+ * + input echo lines, same rules as TerminalRenderer.snapshot()). Returns
80
+ * null when the backend can't provide a snapshot. */
81
+ export async function snapshotToText(backend, fallbackCols, fallbackRows, opts) {
82
+ const snap = tryCapturePipeSnapshot(backend, fallbackCols, fallbackRows);
83
+ if (!snap)
84
+ return null;
85
+ const terminal = await buildTransientTerminal(snap);
86
+ try {
87
+ // Read from the actual current viewport (see snapshotToPng comment).
88
+ const startY = terminal.buffer.active.baseY;
89
+ const content = readViewportText(terminal, { filter: opts.filter, startY, rows: snap.rows });
90
+ return { content, ansi: snap.ansi };
91
+ }
92
+ finally {
93
+ terminal.dispose();
94
+ }
95
+ }
96
+ //# sourceMappingURL=transient-snapshot.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transient-snapshot.js","sourceRoot":"","sources":["../../src/utils/transient-snapshot.ts"],"names":[],"mappings":"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"}
package/dist/worker.js CHANGED
@@ -29,13 +29,14 @@ import { TerminalRenderer } from './utils/terminal-renderer.js';
29
29
  import { DEFAULT_RENDER_COLS, DEFAULT_RENDER_ROWS, MAX_RENDER_COLS, MAX_RENDER_ROWS, MIN_RENDER_COLS, MIN_RENDER_ROWS, clamp, resolveRenderDimensions, } from './utils/render-dimensions.js';
30
30
  import { createCliAdapterSync } from './adapters/cli/registry.js';
31
31
  import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds } from './adapters/cli/claude-code.js';
32
- import { PtyBackend } from './adapters/backend/pty-backend.js';
33
32
  import { TmuxBackend } from './adapters/backend/tmux-backend.js';
34
33
  import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js';
34
+ import { selectSessionBackend } from './adapters/backend/session-backend-selector.js';
35
35
  import { tmuxEnv } from './setup/ensure-tmux.js';
36
36
  import { IdleDetector } from './utils/idle-detector.js';
37
37
  import { ScreenAnalyzer } from './utils/screen-analyzer.js';
38
38
  import { captureToPng } from './utils/screenshot-renderer.js';
39
+ import { snapshotToPng, snapshotToText } from './utils/transient-snapshot.js';
39
40
  import { uploadImageBuffer } from './utils/lark-upload.js';
40
41
  import { config } from './config.js';
41
42
  import * as sessionStore from './services/session-store.js';
@@ -1574,6 +1575,10 @@ let renderCols = PTY_COLS;
1574
1575
  let renderRows = PTY_ROWS;
1575
1576
  // ─── Headless Terminal for Screen Capture ────────────────────────────────────
1576
1577
  let renderer = null;
1578
+ /** Most recent unfiltered viewport text — kept in sync by the screen_update
1579
+ * timer for pipe-pane backends so ScreenAnalyzer (which is synchronous) has
1580
+ * a fresh snapshot to read without needing its own tmux capture-pane call. */
1581
+ let lastAnalyzerSnapshot = '';
1577
1582
  let screenUpdateTimer = null;
1578
1583
  const SCREEN_UPDATE_INTERVAL_MS = 2_000;
1579
1584
  // ─── Scrollback Buffer (replay to late-connecting WS clients) ───────────────
@@ -1607,7 +1612,14 @@ function startScreenAnalyzer() {
1607
1612
  extraHeaders: sa.extraHeaders,
1608
1613
  extraBody: sa.extraBody,
1609
1614
  }, {
1610
- getSnapshot: () => renderer?.rawSnapshot() ?? '',
1615
+ getSnapshot: () => {
1616
+ // ScreenAnalyzer is called every ~5s for TUI-prompt detection. We
1617
+ // can't make this async without overhauling the analyzer, so cache
1618
+ // the last pipe-pane text snapshot here and refresh it eagerly.
1619
+ // For pipe-pane backends, the cache is repopulated by the screen
1620
+ // update timer; for others, fall through to the long-lived renderer.
1621
+ return lastAnalyzerSnapshot || renderer?.rawSnapshot() || '';
1622
+ },
1611
1623
  onAnalyzing: () => { },
1612
1624
  onTuiPrompt: (description, options, multiSelect) => {
1613
1625
  tuiPromptBlocking = true;
@@ -1709,28 +1721,41 @@ async function captureAndUpload() {
1709
1721
  logScreenshotSkip('awaitingFirstPrompt');
1710
1722
  return;
1711
1723
  }
1712
- if (!renderer) {
1713
- logScreenshotSkip('renderer=null');
1714
- return;
1715
- }
1716
1724
  if (!larkAppIdForUpload || !larkAppSecretForUpload) {
1717
1725
  logScreenshotSkip('lark credentials missing');
1718
1726
  return;
1719
1727
  }
1720
- const term = renderer.xterm;
1721
- const startY = term.buffer.active.baseY;
1722
- // Hash dedup — same content → skip upload. Not logged: this is the expected
1723
- // "nothing changed" path and would dominate the log signal.
1724
- const snap = renderer.rawSnapshot();
1725
- const hash = createHash('md5').update(snap).digest('hex');
1726
- if (hash === lastShotHash)
1727
- return;
1728
- lastShotHash = hash;
1729
1728
  let png;
1730
1729
  try {
1731
- const shotCols = clamp(term.cols, MIN_RENDER_COLS, MAX_RENDER_COLS);
1732
- const shotRows = clamp(term.rows, MIN_RENDER_ROWS, MAX_RENDER_ROWS);
1733
- png = captureToPng(term, { cols: shotCols, rows: shotRows, startY });
1730
+ // Preferred path: pipe-pane backends ask tmux for a fresh viewport
1731
+ // snapshot and render it through a transient xterm-headless. This
1732
+ // avoids the accumulated-buffer drift that produced duplicated /
1733
+ // staircase content under the legacy long-lived renderer.
1734
+ const pipeResult = await snapshotToPng(backend, renderCols, renderRows);
1735
+ if (pipeResult) {
1736
+ if (pipeResult.ansi === lastShotHash)
1737
+ return;
1738
+ lastShotHash = pipeResult.ansi;
1739
+ png = pipeResult.png;
1740
+ }
1741
+ else {
1742
+ // Fallback path: non-pipe backends (PtyBackend, legacy TmuxBackend)
1743
+ // still drive the long-lived renderer.
1744
+ if (!renderer) {
1745
+ logScreenshotSkip('renderer=null');
1746
+ return;
1747
+ }
1748
+ const term = renderer.xterm;
1749
+ const startY = term.buffer.active.baseY;
1750
+ const snap = renderer.rawSnapshot();
1751
+ const hash = createHash('md5').update(snap).digest('hex');
1752
+ if (hash === lastShotHash)
1753
+ return;
1754
+ lastShotHash = hash;
1755
+ const shotCols = clamp(term.cols, MIN_RENDER_COLS, MAX_RENDER_COLS);
1756
+ const shotRows = clamp(term.rows, MIN_RENDER_ROWS, MAX_RENDER_ROWS);
1757
+ png = captureToPng(term, { cols: shotCols, rows: shotRows, startY });
1758
+ }
1734
1759
  }
1735
1760
  catch (err) {
1736
1761
  logError(`Screenshot render failed: ${err?.message ?? err}`);
@@ -2187,18 +2212,46 @@ function startScreenUpdates() {
2187
2212
  // content (the live failure that prompted this fix).
2188
2213
  renderer = new TerminalRenderer(renderCols, renderRows);
2189
2214
  let lastSentStatus;
2215
+ let lastTextSnapshotHash = '';
2190
2216
  screenUpdateTimer = setInterval(() => {
2191
- if (!renderer || awaitingFirstPrompt)
2217
+ if (awaitingFirstPrompt)
2192
2218
  return;
2193
- const { content, changed } = renderer.snapshot();
2194
2219
  let status = isPromptReady ? 'idle' : 'working';
2195
2220
  if (screenAnalyzer?.isAnalyzing)
2196
2221
  status = 'analyzing';
2197
- // Send update when content changed OR status changed (e.g. idle → analyzing)
2198
- if (changed || status !== lastSentStatus) {
2199
- lastSentStatus = status;
2200
- send({ type: 'screen_update', content, status });
2201
- }
2222
+ void (async () => {
2223
+ let content;
2224
+ let changed;
2225
+ // Preferred path: pipe-pane backends pull a fresh viewport snapshot
2226
+ // from tmux every tick. This eliminates the accumulated-buffer drift
2227
+ // that produced duplicated/staircase text in 'text' display mode.
2228
+ const pipeText = await snapshotToText(backend, renderCols, renderRows, { filter: true });
2229
+ if (pipeText) {
2230
+ content = pipeText.content;
2231
+ const hash = pipeText.ansi;
2232
+ changed = hash !== lastTextSnapshotHash;
2233
+ lastTextSnapshotHash = hash;
2234
+ // Refresh the unfiltered cache that ScreenAnalyzer reads from. Same
2235
+ // tmux call would otherwise need to fire twice per tick.
2236
+ if (changed) {
2237
+ const rawSnap = await snapshotToText(backend, renderCols, renderRows, { filter: false });
2238
+ if (rawSnap)
2239
+ lastAnalyzerSnapshot = rawSnap.content;
2240
+ }
2241
+ }
2242
+ else if (renderer) {
2243
+ const snap = renderer.snapshot();
2244
+ content = snap.content;
2245
+ changed = snap.changed;
2246
+ }
2247
+ else {
2248
+ return;
2249
+ }
2250
+ if (changed || status !== lastSentStatus) {
2251
+ lastSentStatus = status;
2252
+ send({ type: 'screen_update', content, status });
2253
+ }
2254
+ })();
2202
2255
  }, SCREEN_UPDATE_INTERVAL_MS);
2203
2256
  }
2204
2257
  function stopScreenUpdates() {
@@ -2210,6 +2263,7 @@ function stopScreenUpdates() {
2210
2263
  renderer.dispose();
2211
2264
  renderer = null;
2212
2265
  }
2266
+ lastAnalyzerSnapshot = '';
2213
2267
  }
2214
2268
  // ─── PTY Management ──────────────────────────────────────────────────────────
2215
2269
  function spawnCli(cfg) {
@@ -2395,9 +2449,10 @@ function spawnCli(cfg) {
2395
2449
  log('tmux backend requested but functional probe failed — falling back to PTY backend');
2396
2450
  useTmux = false;
2397
2451
  }
2398
- isTmuxMode = useTmux;
2399
- const tmuxBe = useTmux ? new TmuxBackend(TmuxBackend.sessionName(cfg.sessionId)) : null;
2400
- backend = tmuxBe ?? new PtyBackend();
2452
+ const selectedBackend = selectSessionBackend({ sessionId: cfg.sessionId, useTmux });
2453
+ isTmuxMode = selectedBackend.isTmuxMode;
2454
+ isPipeMode = selectedBackend.isPipeMode;
2455
+ backend = selectedBackend.backend;
2401
2456
  // Claude Code appends a line to ~/.claude/projects/<cwd-hash>/<sid>.jsonl each
2402
2457
  // time the user submits. The adapter uses this file to verify paste+Enter
2403
2458
  // actually committed (rather than trusting a fixed sleep), so wire it up now.
@@ -2470,9 +2525,6 @@ function spawnCli(cfg) {
2470
2525
  // On tmux re-attach, keep awaitingFirstPrompt = true so screen updates are
2471
2526
  // suppressed until the idle detector fires markNewTurn() — this prevents the
2472
2527
  // full tmux scrollback history from leaking into the streaming card.
2473
- if (tmuxBe?.isReattach) {
2474
- log('Re-attached to existing tmux session');
2475
- }
2476
2528
  // Bridge fallback: claude-code only. Tail Claude's transcript JSONL so a
2477
2529
  // turn the model finishes WITHOUT calling `botmux send` still gets its
2478
2530
  // assistant text forwarded to Lark (the gate in emitReadyTurns suppresses
@@ -2547,6 +2599,17 @@ function spawnCli(cfg) {
2547
2599
  isPromptReady = false;
2548
2600
  send({ type: 'claude_exit', code, signal });
2549
2601
  });
2602
+ if (isPipeMode && backend instanceof TmuxPipeBackend && backend.isReattach) {
2603
+ log(`Re-attached to existing tmux session via pipe-pane: ${TmuxBackend.sessionName(cfg.sessionId)}`);
2604
+ try {
2605
+ const initial = backend.captureCurrentScreen();
2606
+ if (initial.length > 0)
2607
+ onPtyData(initial);
2608
+ }
2609
+ catch (err) {
2610
+ log(`captureCurrentScreen failed: ${err.message}`);
2611
+ }
2612
+ }
2550
2613
  // Fallback: if the CLI takes too long to show its prompt (e.g. slow
2551
2614
  // plugin init), unblock screen updates so the card doesn't stay at
2552
2615
  // "启动中" forever. markNewTurn() sets a clean baseline at the current