agent-relay 11.2.0 → 11.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/cli/agent-relay-mcp.d.ts +1 -0
  2. package/dist/cli/agent-relay-mcp.d.ts.map +1 -1
  3. package/dist/cli/agent-relay-mcp.js +10 -3
  4. package/dist/cli/agent-relay-mcp.js.map +1 -1
  5. package/dist/cli/bootstrap.d.ts +14 -0
  6. package/dist/cli/bootstrap.d.ts.map +1 -1
  7. package/dist/cli/bootstrap.js +83 -7
  8. package/dist/cli/bootstrap.js.map +1 -1
  9. package/dist/cli/commands/cloud.d.ts.map +1 -1
  10. package/dist/cli/commands/cloud.js +10 -3
  11. package/dist/cli/commands/cloud.js.map +1 -1
  12. package/dist/cli/commands/local-agent.d.ts.map +1 -1
  13. package/dist/cli/commands/local-agent.js +13 -1
  14. package/dist/cli/commands/local-agent.js.map +1 -1
  15. package/dist/cli/commands/setup.d.ts.map +1 -1
  16. package/dist/cli/commands/setup.js +9 -0
  17. package/dist/cli/commands/setup.js.map +1 -1
  18. package/dist/cli/lib/attach-drive.d.ts +37 -18
  19. package/dist/cli/lib/attach-drive.d.ts.map +1 -1
  20. package/dist/cli/lib/attach-drive.js +178 -75
  21. package/dist/cli/lib/attach-drive.js.map +1 -1
  22. package/dist/cli/lib/attach-passthrough.d.ts +5 -0
  23. package/dist/cli/lib/attach-passthrough.d.ts.map +1 -1
  24. package/dist/cli/lib/attach-passthrough.js +108 -31
  25. package/dist/cli/lib/attach-passthrough.js.map +1 -1
  26. package/dist/cli/lib/attach-view.d.ts +2 -0
  27. package/dist/cli/lib/attach-view.d.ts.map +1 -1
  28. package/dist/cli/lib/attach-view.js +10 -0
  29. package/dist/cli/lib/attach-view.js.map +1 -1
  30. package/dist/cli/lib/attach.d.ts +127 -11
  31. package/dist/cli/lib/attach.d.ts.map +1 -1
  32. package/dist/cli/lib/attach.js +471 -31
  33. package/dist/cli/lib/attach.js.map +1 -1
  34. package/dist/cli/lib/broker-lifecycle.d.ts.map +1 -1
  35. package/dist/cli/lib/broker-lifecycle.js +26 -1
  36. package/dist/cli/lib/broker-lifecycle.js.map +1 -1
  37. package/dist/cli/mcp/action-schema.d.ts +2 -2
  38. package/dist/cli/mcp/action-schema.d.ts.map +1 -1
  39. package/dist/cli/mcp/action-schema.js +1 -1
  40. package/dist/cli/mcp/action-schema.js.map +1 -1
  41. package/dist/cli/mcp/messaging-tools.js +5 -5
  42. package/dist/cli/mcp/messaging-tools.js.map +1 -1
  43. package/dist/cli/mcp/tool-results.d.ts +1 -1
  44. package/dist/cli/mcp/tool-results.d.ts.map +1 -1
  45. package/dist/cli/mcp/tool-results.js +1 -1
  46. package/dist/cli/mcp/tool-results.js.map +1 -1
  47. package/dist/cli/telemetry/client.d.ts +16 -0
  48. package/dist/cli/telemetry/client.d.ts.map +1 -1
  49. package/dist/cli/telemetry/client.js +122 -5
  50. package/dist/cli/telemetry/client.js.map +1 -1
  51. package/dist/cli/telemetry/config.d.ts +14 -1
  52. package/dist/cli/telemetry/config.d.ts.map +1 -1
  53. package/dist/cli/telemetry/config.js +10 -0
  54. package/dist/cli/telemetry/config.js.map +1 -1
  55. package/dist/cli/telemetry/events.d.ts +35 -0
  56. package/dist/cli/telemetry/events.d.ts.map +1 -1
  57. package/dist/cli/telemetry/index.d.ts +1 -1
  58. package/dist/cli/telemetry/index.d.ts.map +1 -1
  59. package/dist/cli/telemetry/index.js +1 -1
  60. package/dist/cli/telemetry/index.js.map +1 -1
  61. package/dist/cli/telemetry/posthog-config.d.ts +21 -15
  62. package/dist/cli/telemetry/posthog-config.d.ts.map +1 -1
  63. package/dist/cli/telemetry/posthog-config.js +32 -21
  64. package/dist/cli/telemetry/posthog-config.js.map +1 -1
  65. package/dist/cli/telemetry/posthog-key.d.ts +18 -0
  66. package/dist/cli/telemetry/posthog-key.d.ts.map +1 -0
  67. package/dist/cli/telemetry/posthog-key.js +18 -0
  68. package/dist/cli/telemetry/posthog-key.js.map +1 -0
  69. package/dist/index.cjs +3427 -32330
  70. package/package.json +11 -11
@@ -276,6 +276,195 @@ export function pickInitialTerminalRows(localSize, snapshotRows) {
276
276
  return snapshotRows;
277
277
  return undefined;
278
278
  }
279
+ /**
280
+ * Pick the status-line width. Same precedence as
281
+ * {@link pickInitialTerminalRows} — the LOCAL terminal wins, because the
282
+ * status line has to fit the pane the human is looking at, not the agent's
283
+ * PTY.
284
+ */
285
+ export function pickInitialTerminalCols(localSize, snapshotCols) {
286
+ if (localSize)
287
+ return localSize.cols;
288
+ if (typeof snapshotCols === 'number' && snapshotCols > 0)
289
+ return snapshotCols;
290
+ return undefined;
291
+ }
292
+ /** Width assumed when the local terminal never reported one. */
293
+ export const DEFAULT_STATUS_LINE_COLS = 80;
294
+ /**
295
+ * Clamp a status-line label to the terminal width.
296
+ *
297
+ * The interactive verbs paint their status line at the bottom row inside an
298
+ * `ESC 7` / `ESC 8` (DECSC/DECRC) pair. That is only safe while the text
299
+ * *fits*: a label wider than the pane wraps past the last row, which scrolls
300
+ * the screen. Because the label is painted on the bottom row, every repaint
301
+ * then scrolls again — promoting the previous status line into the scrollback
302
+ * as content and eating one row of the agent's output each time. A narrow
303
+ * pane therefore turns a single status line into a growing stack of them with
304
+ * the agent's TUI shredded behind it. The `drive` label is 87 columns, so this
305
+ * fired on a standard 80-column terminal too; at 66 columns six repaints cost
306
+ * six rows of agent output.
307
+ *
308
+ * Truncation keeps the paint inside one row, so the wrap — and the scroll
309
+ * cascade it triggers — can never happen. Callers painting a terminal row can
310
+ * reserve the final column as well, avoiding the emulator's wrap-pending state.
311
+ * The tail is the part that carries the key hints, so an over-long label is
312
+ * trimmed from the *middle*: the verb and agent name stay readable and the
313
+ * `Ctrl+…` hints survive.
314
+ *
315
+ * Measured in display columns, not UTF-16 code units: an agent name carrying
316
+ * CJK or emoji is double-width, so a code-unit count would pass a label that
317
+ * still wraps — re-arming the exact cascade this prevents. Slicing is done on
318
+ * whole code points so a surrogate pair is never split in half.
319
+ */
320
+ export function clampStatusLineText(text, cols, reserveFinalColumn = false) {
321
+ const terminalWidth = typeof cols === 'number' && cols > 0 ? Math.floor(cols) : DEFAULT_STATUS_LINE_COLS;
322
+ const width = reserveFinalColumn ? Math.max(terminalWidth - 1, 0) : terminalWidth;
323
+ if (displayWidth(text) <= width)
324
+ return text;
325
+ // Too narrow to say anything useful — a bare head is still better than a
326
+ // wrap, and `…` alone would be meaningless.
327
+ if (width <= 1)
328
+ return width === 1 ? takeColumns(text, 1).text : '';
329
+ const keep = width - 1; // the ellipsis occupies one column
330
+ const tailBudget = Math.floor(keep / 2);
331
+ const headBudget = keep - tailBudget;
332
+ const head = takeColumns(text, headBudget);
333
+ const tail = tailBudget > 0 ? takeColumns(text, tailBudget, 'end') : { text: '' };
334
+ return `${head.text}…${tail.text}`;
335
+ }
336
+ /**
337
+ * Column width of a string, counting East Asian Wide/Fullwidth characters and
338
+ * emoji as two columns and zero-width joiners/combining marks as none. This is
339
+ * the pragmatic subset a status line needs — it is not a full `wcwidth`.
340
+ */
341
+ function displayWidth(text) {
342
+ let total = 0;
343
+ for (const char of text)
344
+ total += charWidth(char);
345
+ return total;
346
+ }
347
+ /** Codepoints that occupy no column: combining marks, ZWJ, variation selectors. */
348
+ const ZERO_WIDTH_RANGES = [
349
+ [0x0300, 0x036f],
350
+ [0x200d, 0x200d],
351
+ [0xfe00, 0xfe0f],
352
+ ];
353
+ /** East Asian Wide / Fullwidth blocks, plus the emoji planes. */
354
+ const DOUBLE_WIDTH_RANGES = [
355
+ [0x1100, 0x115f],
356
+ [0x2e80, 0xa4cf],
357
+ [0xac00, 0xd7a3],
358
+ [0xf900, 0xfaff],
359
+ [0xfe30, 0xfe6f],
360
+ [0xff00, 0xff60],
361
+ [0xffe0, 0xffe6],
362
+ [0x1f300, 0x1faff],
363
+ [0x20000, 0x3fffd],
364
+ ];
365
+ function inRanges(code, ranges) {
366
+ return ranges.some(([lo, hi]) => code >= lo && code <= hi);
367
+ }
368
+ function charWidth(char) {
369
+ const code = char.codePointAt(0) ?? 0;
370
+ if (inRanges(code, ZERO_WIDTH_RANGES))
371
+ return 0;
372
+ return inRanges(code, DOUBLE_WIDTH_RANGES) ? 2 : 1;
373
+ }
374
+ /**
375
+ * Take whole code points from `text` until `budget` columns are used, from the
376
+ * start or the `end`. Never splits a surrogate pair, and never overshoots the
377
+ * budget — a double-width character that would exceed it is dropped.
378
+ */
379
+ function takeColumns(text, budget, from = 'start') {
380
+ const chars = Array.from(text);
381
+ const ordered = from === 'end' ? chars.slice().reverse() : chars;
382
+ const taken = [];
383
+ let used = 0;
384
+ for (const char of ordered) {
385
+ const w = charWidth(char);
386
+ if (used + w > budget)
387
+ break;
388
+ used += w;
389
+ taken.push(char);
390
+ }
391
+ return { text: (from === 'end' ? taken.reverse() : taken).join('') };
392
+ }
393
+ /**
394
+ * Reserve the local terminal's final row for an attach status line.
395
+ *
396
+ * Full-screen CLIs treat every PTY row as application-owned. If the remote PTY
397
+ * is resized to the local terminal's full height and Relay then paints a status
398
+ * line over its final row, both renderers continually overwrite each other.
399
+ * The result is the duplicated status text and torn TUI frames seen in drive
400
+ * and passthrough sessions. Give the child one fewer row and column: the row
401
+ * prevents cursor-addressed overlap, while the spare column prevents terminal
402
+ * autowrap from stepping past the child scroll margin into Relay's row.
403
+ */
404
+ export function reserveStatusLineRow(localSize) {
405
+ if (!localSize)
406
+ return null;
407
+ if (localSize.rows < 3 || localSize.cols < 2)
408
+ return localSize;
409
+ return {
410
+ rows: localSize.rows - 1,
411
+ cols: localSize.cols - 1,
412
+ };
413
+ }
414
+ /** Whether a terminal is large enough to dedicate a non-wrapping status row. */
415
+ export function canReserveStatusLine(localSize) {
416
+ return localSize !== null && localSize.rows >= 3 && localSize.cols >= 2;
417
+ }
418
+ /**
419
+ * Constrain local scrolling to the child PTY's rows while preserving the
420
+ * current cursor. DECSTBM otherwise defaults to the physical terminal height,
421
+ * which includes Relay's status row.
422
+ */
423
+ export function renderChildScrollRegion(rows) {
424
+ // CSI s/u save only the cursor. DEC ESC 7/8 also preserve terminal state in
425
+ // common emulators and would therefore undo the newly installed margins.
426
+ return `\x1b[s\x1b[1;${Math.max(1, Math.floor(rows))}r\x1b[u`;
427
+ }
428
+ /**
429
+ * Keep a status label strictly inside one terminal row.
430
+ *
431
+ * Writing the final column with autowrap enabled advances to the next row and,
432
+ * on the terminal's bottom row, scrolls the entire TUI. Leave one cell unused
433
+ * and conservatively count non-ASCII code points as two cells so even unusual
434
+ * agent names cannot trigger a wrap.
435
+ */
436
+ function conservativeTerminalCellWidth(character) {
437
+ const codePoint = character.codePointAt(0) ?? 0;
438
+ return codePoint >= 0x20 && codePoint <= 0x7e ? 1 : 2;
439
+ }
440
+ export function fitStatusLineText(text, columns) {
441
+ if (columns === undefined || !Number.isFinite(columns))
442
+ return text;
443
+ const available = Math.max(0, Math.floor(columns) - 1);
444
+ if (available === 0)
445
+ return '';
446
+ let width = 0;
447
+ let result = '';
448
+ let truncated = false;
449
+ for (const character of text) {
450
+ const characterWidth = conservativeTerminalCellWidth(character);
451
+ if (width + characterWidth > available) {
452
+ truncated = true;
453
+ break;
454
+ }
455
+ result += character;
456
+ width += characterWidth;
457
+ }
458
+ if (!truncated)
459
+ return result;
460
+ while (width + 1 > available && result.length > 0) {
461
+ const characters = Array.from(result);
462
+ const removed = characters.pop() ?? '';
463
+ width -= conservativeTerminalCellWidth(removed);
464
+ result = characters.join('');
465
+ }
466
+ return `${result}~`;
467
+ }
279
468
  /**
280
469
  * Sync the agent's PTY to the driver's local terminal size. tmux /
281
470
  * screen / ssh all do this — without it a TUI in the agent renders into
@@ -285,20 +474,27 @@ export function pickInitialTerminalRows(localSize, snapshotRows) {
285
474
  */
286
475
  export async function syncInitialPtySize(connection, name, localSize, verb, deps, options) {
287
476
  if (!localSize)
288
- return;
477
+ return false;
289
478
  try {
290
- await createBrokerClient(connection, deps.fetch).resizePty(name, localSize.rows, localSize.cols, options);
479
+ const result = await createBrokerClient(connection, deps.fetch).resizePty(name, localSize.rows, localSize.cols, options);
480
+ if (result.applied === false) {
481
+ deps.log(`[${verb}] broker did not apply the reserved PTY size; using status repaint fallback`);
482
+ return false;
483
+ }
484
+ return true;
291
485
  }
292
486
  catch (err) {
293
487
  const failure = mapBrokerSdkFailure(err);
294
488
  deps.log(`[${verb}] could not sync agent PTY size to local terminal (${failure.message ?? 'unknown'}); continuing`);
489
+ return false;
295
490
  }
296
491
  }
297
492
  /**
298
493
  * Read the worker's prior inbound delivery mode and flip it to
299
494
  * `targetMode`. Returns the previous mode on success so the caller can
300
495
  * restore it on detach; returns `null` (and writes an error) when the
301
- * flip fails so the caller bails before touching the terminal.
496
+ * request fails or the broker reports that the requested transition did not
497
+ * complete, so the caller bails before touching the terminal.
302
498
  *
303
499
  * Non-404 errors are surfaced as `Error: could not ${actionPhrase}:
304
500
  * ${message}` so callers pass verb-appropriate wording (e.g. "switch to
@@ -316,6 +512,15 @@ export async function switchInboundDeliveryModeOrAbort(connection, name, targetM
316
512
  }
317
513
  try {
318
514
  const result = await createBrokerClient(connection, deps.fetch).setInboundDeliveryMode(name, targetMode);
515
+ // A manual_flush → auto_inject transition can fail partway through when
516
+ // draining a parked message into the harness fails. The broker deliberately
517
+ // keeps and reports manual_flush in that case even though the PUT itself
518
+ // succeeded. Starting an attach session as if the target mode landed would
519
+ // render false state and make its first guarded toggle a no-op.
520
+ if (result.mode !== targetMode) {
521
+ deps.error(`Error: could not ${actionPhrase}: broker remained in ${result.mode} mode while draining queued messages`);
522
+ return null;
523
+ }
319
524
  return { previousMode, sessionRevision: result.revision };
320
525
  }
321
526
  catch (err) {
@@ -419,6 +624,12 @@ export class AnsiBoundaryScanner {
419
624
  case 'ground':
420
625
  if (c === 0x1b)
421
626
  this.state = 'esc';
627
+ else if (c === 0x9b)
628
+ this.state = 'csi';
629
+ else if (c === 0x9d)
630
+ this.state = 'osc';
631
+ else if (c === 0x90 || c === 0x98 || c === 0x9e || c === 0x9f)
632
+ this.state = 'str';
422
633
  return;
423
634
  case 'esc':
424
635
  if (c === 0x5b)
@@ -431,30 +642,260 @@ export class AnsiBoundaryScanner {
431
642
  this.state = 'ground'; // 2-byte escape (ESC 7, ESC 8, ESC c, …)
432
643
  return;
433
644
  case 'csi':
434
- if (c === 0x1b)
645
+ if (c === 0x18 || c === 0x1a)
646
+ this.state = 'ground'; // CAN / SUB cancel the sequence
647
+ else if (c === 0x1b)
435
648
  this.state = 'esc'; // stray ESC restarts
436
649
  else if (c >= 0x40 && c <= 0x7e)
437
650
  this.state = 'ground'; // final byte ends the CSI
438
651
  return;
439
652
  case 'osc':
440
- if (c === 0x07)
653
+ if (c === 0x07 || c === 0x9c || c === 0x18 || c === 0x1a)
441
654
  this.state = 'ground'; // BEL terminator
442
655
  else if (c === 0x1b)
443
656
  this.state = 'osc_esc'; // possible ST (ESC \)
444
657
  return;
445
658
  case 'osc_esc':
446
- this.state = c === 0x5c ? 'ground' : 'osc';
659
+ this.state =
660
+ c === 0x5c || c === 0x9c || c === 0x18 || c === 0x1a ? 'ground' : c === 0x1b ? 'osc_esc' : 'osc';
447
661
  return;
448
662
  case 'str':
449
- if (c === 0x1b)
663
+ if (c === 0x9c || c === 0x18 || c === 0x1a)
664
+ this.state = 'ground';
665
+ else if (c === 0x1b)
450
666
  this.state = 'str_esc';
451
667
  return;
452
668
  case 'str_esc':
453
- this.state = c === 0x5c ? 'ground' : 'str';
669
+ this.state =
670
+ c === 0x5c || c === 0x9c || c === 0x18 || c === 0x1a ? 'ground' : c === 0x1b ? 'str_esc' : 'str';
454
671
  return;
455
672
  }
456
673
  }
457
674
  }
675
+ /**
676
+ * Detect terminal controls that can erase or replace Relay's reserved status
677
+ * row even though the child PTY itself is one row shorter.
678
+ *
679
+ * Cursor-addressed TUI drawing stays inside the child grid, but erase-display,
680
+ * terminal reset, and alternate-screen switches act on the local terminal as a
681
+ * whole. Attach clients reinstall their child scroll region after those
682
+ * operations; the status controller separately repaints at a safe ANSI
683
+ * boundary. A short carry handles controls split across WS frames.
684
+ */
685
+ export class StatusLineInvalidationScanner {
686
+ carry = '';
687
+ push(chunk) {
688
+ const input = this.carry + chunk;
689
+ const esc = String.fromCharCode(0x1b);
690
+ const privateModes = new RegExp(`${esc}\\[\\?([0-9;]+)[hl]`, 'g');
691
+ let invalidatesDisplay = false;
692
+ let privateMatch;
693
+ while ((privateMatch = privateModes.exec(input)) !== null) {
694
+ if (privateMatch[1].split(';').some((mode) => ['47', '1047', '1049'].includes(mode))) {
695
+ invalidatesDisplay = true;
696
+ }
697
+ }
698
+ // Retain only an incomplete candidate control. Keeping arbitrary completed
699
+ // output would rediscover the same erase on later chunks and recreate the
700
+ // repaint storm this scanner exists to prevent.
701
+ const lastEscape = input.lastIndexOf(esc);
702
+ const suffix = lastEscape >= 0 ? input.slice(lastEscape) : '';
703
+ const incompleteCsi = suffix.startsWith(`${esc}[`) && /^[?0-9;]*$/.test(suffix.slice(2));
704
+ this.carry = suffix === esc || incompleteCsi ? suffix : '';
705
+ return (
706
+ // ED 0/default clears from the cursor through the reserved final row;
707
+ // ED 2 clears the full display.
708
+ input.includes(`${esc}[J`) ||
709
+ input.includes(`${esc}[0J`) ||
710
+ input.includes(`${esc}[2J`) ||
711
+ // Alternate-screen changes replace the entire visible buffer.
712
+ invalidatesDisplay ||
713
+ // RIS resets the terminal and clears the active display.
714
+ input.includes(`${esc}c`));
715
+ }
716
+ }
717
+ /** Track the child application's active DECSTBM margins across streamed ANSI. */
718
+ export class TerminalScrollRegionTracker {
719
+ parserState = 'ground';
720
+ csiParams = '';
721
+ csiSupported = true;
722
+ stringAllowsBel = false;
723
+ rows;
724
+ top = 1;
725
+ bottom;
726
+ originMode = false;
727
+ alternateActive = false;
728
+ primaryRegion;
729
+ alternateRegion;
730
+ constructor(rows) {
731
+ this.rows = Math.max(1, Math.floor(rows));
732
+ this.bottom = this.rows;
733
+ this.primaryRegion = { top: 1, bottom: this.rows };
734
+ this.alternateRegion = { top: 1, bottom: this.rows };
735
+ }
736
+ setRows(rows) {
737
+ this.rows = Math.max(1, Math.floor(rows));
738
+ this.top = 1;
739
+ this.bottom = this.rows;
740
+ this.primaryRegion = { top: 1, bottom: this.rows };
741
+ this.alternateRegion = { top: 1, bottom: this.rows };
742
+ }
743
+ push(chunk) {
744
+ for (const character of chunk)
745
+ this.consumeCharacter(character);
746
+ }
747
+ get region() {
748
+ return { top: this.top, bottom: this.bottom };
749
+ }
750
+ get isOriginMode() {
751
+ return this.originMode;
752
+ }
753
+ consumeCharacter(character) {
754
+ switch (this.parserState) {
755
+ case 'ground':
756
+ this.consumeGround(character);
757
+ break;
758
+ case 'escape':
759
+ this.consumeEscape(character);
760
+ break;
761
+ case 'csi':
762
+ this.consumeCsi(character);
763
+ break;
764
+ case 'string':
765
+ this.consumeString(character);
766
+ break;
767
+ case 'string_escape':
768
+ this.consumeStringEscape(character);
769
+ break;
770
+ }
771
+ }
772
+ consumeString(character) {
773
+ if (character === '\x1b') {
774
+ this.parserState = 'string_escape';
775
+ return;
776
+ }
777
+ if (character === '\x9c' ||
778
+ character === '\x18' ||
779
+ character === '\x1a' ||
780
+ (this.stringAllowsBel && character === '\x07')) {
781
+ this.parserState = 'ground';
782
+ }
783
+ }
784
+ consumeStringEscape(character) {
785
+ if (character === '\\' || character === '\x9c' || character === '\x18' || character === '\x1a') {
786
+ this.parserState = 'ground';
787
+ }
788
+ else {
789
+ this.parserState = character === '\x1b' ? 'string_escape' : 'string';
790
+ }
791
+ }
792
+ consumeGround(character) {
793
+ if (character === '\x1b')
794
+ this.parserState = 'escape';
795
+ else if (character === '\x9b')
796
+ this.beginCsi();
797
+ else if (['\x90', '\x98', '\x9d', '\x9e', '\x9f'].includes(character)) {
798
+ this.beginString(character === '\x9d');
799
+ }
800
+ }
801
+ consumeEscape(character) {
802
+ if (character === '[')
803
+ this.beginCsi();
804
+ else if (['P', 'X', ']', '^', '_'].includes(character))
805
+ this.beginString(character === ']');
806
+ else {
807
+ if (character === 'c')
808
+ this.resetTrackedState();
809
+ this.parserState = character === '\x1b' ? 'escape' : 'ground';
810
+ }
811
+ }
812
+ beginCsi() {
813
+ this.parserState = 'csi';
814
+ this.csiParams = '';
815
+ this.csiSupported = true;
816
+ }
817
+ beginString(allowsBel) {
818
+ this.parserState = 'string';
819
+ this.stringAllowsBel = allowsBel;
820
+ }
821
+ consumeCsi(character) {
822
+ if (character === '\x18' || character === '\x1a') {
823
+ this.parserState = 'ground';
824
+ return;
825
+ }
826
+ if (character === '\x1b') {
827
+ this.parserState = 'escape';
828
+ return;
829
+ }
830
+ const code = character.codePointAt(0) ?? 0;
831
+ if (code >= 0x30 && code <= 0x3f) {
832
+ this.csiParams += character;
833
+ return;
834
+ }
835
+ if (code >= 0x20 && code <= 0x2f) {
836
+ this.csiSupported = false;
837
+ return;
838
+ }
839
+ if (code >= 0x40 && code <= 0x7e) {
840
+ if (this.csiSupported)
841
+ this.applyCsi(this.csiParams, character);
842
+ this.parserState = 'ground';
843
+ }
844
+ }
845
+ applyCsi(params, final) {
846
+ if ((final === 'h' || final === 'l') && params.startsWith('?')) {
847
+ this.applyPrivateModes(params.slice(1).split(';'), final === 'h');
848
+ return;
849
+ }
850
+ if (final !== 'r' || params.startsWith('?'))
851
+ return;
852
+ const [topRaw = '', bottomRaw = ''] = params.split(';');
853
+ const parsedTop = topRaw === '' ? 0 : Number.parseInt(topRaw, 10);
854
+ const parsedBottom = bottomRaw === '' ? 0 : Number.parseInt(bottomRaw, 10);
855
+ const top = parsedTop === 0 ? 1 : parsedTop;
856
+ const bottom = parsedBottom === 0 ? this.rows : parsedBottom;
857
+ if (Number.isFinite(top) && Number.isFinite(bottom) && top >= 1 && bottom > top) {
858
+ const clampedTop = Math.min(top, this.rows);
859
+ const clampedBottom = Math.min(bottom, this.rows);
860
+ if (clampedBottom > clampedTop) {
861
+ this.top = clampedTop;
862
+ this.bottom = clampedBottom;
863
+ }
864
+ }
865
+ }
866
+ applyPrivateModes(modes, enabled) {
867
+ if (modes.includes('6'))
868
+ this.originMode = enabled;
869
+ if (modes.some((mode) => ['47', '1047', '1049'].includes(mode))) {
870
+ this.switchScreenBuffer(enabled, enabled);
871
+ }
872
+ }
873
+ resetTrackedState() {
874
+ this.top = 1;
875
+ this.bottom = this.rows;
876
+ this.originMode = false;
877
+ this.alternateActive = false;
878
+ this.primaryRegion = { top: 1, bottom: this.rows };
879
+ this.alternateRegion = { top: 1, bottom: this.rows };
880
+ }
881
+ switchScreenBuffer(enterAlternate, resetAlternate) {
882
+ if (enterAlternate === this.alternateActive)
883
+ return;
884
+ if (resetAlternate) {
885
+ this.alternateRegion = { top: 1, bottom: this.rows };
886
+ }
887
+ if (this.alternateActive) {
888
+ this.alternateRegion = { top: this.top, bottom: this.bottom };
889
+ }
890
+ else {
891
+ this.primaryRegion = { top: this.top, bottom: this.bottom };
892
+ }
893
+ this.alternateActive = enterAlternate;
894
+ const region = enterAlternate ? this.alternateRegion : this.primaryRegion;
895
+ this.top = region.top;
896
+ this.bottom = region.bottom;
897
+ }
898
+ }
458
899
  /**
459
900
  * Coordinates status-line repaints for the interactive attach clients.
460
901
  *
@@ -462,8 +903,8 @@ export class AnsiBoundaryScanner {
462
903
  * - **Non-TTY skip** — when `enabled` is false, nothing is ever written.
463
904
  * - **Boundary-hold** — a repaint is deferred while the observed output ends
464
905
  * mid ANSI escape sequence, so the status line never splices into a
465
- * half-transmitted CSI. It paints as soon as the next chunk lands at a
466
- * boundary (or after `boundaryHoldMs` as a bounded fallback).
906
+ * half-transmitted CSI. It paints only after a later chunk returns the
907
+ * stream to a real boundary.
467
908
  * - **Coalescing** — repaints are rate-limited to at most one per
468
909
  * `coalesceMs`, shrinking the window in which our save/restore-cursor wrap
469
910
  * can clobber the agent's own pending DECSC.
@@ -478,25 +919,32 @@ export class StatusLineController {
478
919
  opts;
479
920
  scanner = new AnsiBoundaryScanner();
480
921
  timer = null;
481
- timerForce = false;
482
922
  wantPaint = false;
483
923
  lastPaintAt = Number.NEGATIVE_INFINITY;
484
924
  disposed = false;
485
925
  constructor(opts) {
486
926
  this.opts = opts;
487
927
  }
928
+ isEnabled() {
929
+ return typeof this.opts.enabled === 'function' ? this.opts.enabled() : this.opts.enabled;
930
+ }
488
931
  /** Record server output for boundary tracking; flush a held repaint if the
489
932
  * stream is now back at a sequence boundary. */
490
933
  observeOutput(chunk) {
491
934
  if (this.disposed)
492
935
  return;
493
936
  this.scanner.push(chunk);
937
+ if (!this.isEnabled()) {
938
+ this.wantPaint = false;
939
+ this.clearTimer();
940
+ return;
941
+ }
494
942
  if (this.wantPaint)
495
943
  this.flush();
496
944
  }
497
945
  /** Request a repaint (coalesced + boundary-held). */
498
946
  request() {
499
- if (this.disposed || !this.opts.enabled)
947
+ if (this.disposed || !this.isEnabled())
500
948
  return;
501
949
  this.wantPaint = true;
502
950
  this.flush();
@@ -511,39 +959,32 @@ export class StatusLineController {
511
959
  return (this.opts.now ?? Date.now)();
512
960
  }
513
961
  flush() {
514
- if (this.disposed || !this.wantPaint)
962
+ if (this.disposed || !this.wantPaint || !this.isEnabled())
515
963
  return;
516
964
  if (!this.scanner.atBoundary) {
517
- this.arm(this.opts.boundaryHoldMs ?? 100, true);
518
965
  return;
519
966
  }
520
- // Back at a boundary: any pending *force* (boundary-hold) timer carries the
521
- // wrong deadline now — it was armed to paint mid-sequence after
522
- // `boundaryHoldMs`, which would either paint later than the coalescing
523
- // window needs or bypass the window entirely. Clear it so the normal
524
- // coalescing logic below re-arms a plain timer for the correct remainder.
525
- // A non-force (coalescing) timer already has the right deadline, so leave
526
- // it in place.
527
- if (this.timer && this.timerForce)
528
- this.clearTimer();
529
967
  const elapsed = this.now() - this.lastPaintAt;
530
968
  if (elapsed >= this.opts.coalesceMs) {
531
969
  this.paintNow();
532
970
  }
533
971
  else {
534
- this.arm(this.opts.coalesceMs - elapsed, false);
972
+ this.arm(this.opts.coalesceMs - elapsed);
535
973
  }
536
974
  }
537
975
  paintNow() {
538
976
  this.clearTimer();
977
+ if (!this.isEnabled()) {
978
+ this.wantPaint = false;
979
+ return;
980
+ }
539
981
  this.wantPaint = false;
540
982
  this.lastPaintAt = this.now();
541
983
  this.opts.write(this.opts.render());
542
984
  }
543
- arm(ms, force) {
985
+ arm(ms) {
544
986
  if (this.timer)
545
987
  return;
546
- this.timerForce = force;
547
988
  const set = this.opts.setTimer ??
548
989
  ((fn, delay) => {
549
990
  const t = setTimeout(fn, delay);
@@ -552,12 +993,11 @@ export class StatusLineController {
552
993
  });
553
994
  this.timer = set(() => {
554
995
  this.timer = null;
555
- if (this.disposed || !this.wantPaint)
996
+ if (this.disposed || !this.wantPaint || !this.isEnabled()) {
997
+ this.wantPaint = false;
556
998
  return;
557
- if (this.timerForce)
558
- this.paintNow();
559
- else
560
- this.flush();
999
+ }
1000
+ this.flush();
561
1001
  }, ms);
562
1002
  }
563
1003
  clearTimer() {