pty-manager 1.5.0 → 1.6.1

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/README.md CHANGED
@@ -9,8 +9,8 @@ PTY session manager with lifecycle management, pluggable adapters, and blocking
9
9
  - **Blocking prompt detection** - Detect login prompts, confirmations, and interactive prompts
10
10
  - **Auto-response rules** - Automatically respond to known prompts with text or key sequences
11
11
  - **TUI menu navigation** - Navigate arrow-key menus via `selectMenuOption()` and key-sequence rules
12
- - **Stall detection** - Content-based stall detection with pluggable external classifiers
13
- - **Task completion detection** - Adapter-level fast-path that short-circuits the LLM stall classifier when the CLI returns to its idle prompt
12
+ - **Stall detection** - Content-based stall detection with pluggable external classifiers, loading suppression, and exponential backoff
13
+ - **Task completion detection** - Settle-based fast-path that short-circuits the LLM stall classifier when the CLI returns to its idle prompt, resilient to decorative TUI rendering after the prompt
14
14
  - **Terminal attachment** - Attach to sessions for raw I/O streaming
15
15
  - **Special key support** - Send Ctrl, Alt, Shift, and function key combinations via `sendKeys()`
16
16
  - **Bracketed paste** - Proper paste handling with bracketed paste mode support
@@ -136,6 +136,11 @@ class MyCLIAdapter extends BaseCLIAdapter {
136
136
  return /done in \d+s/.test(output) && /ready>/.test(output);
137
137
  }
138
138
 
139
+ // Optional: detect active loading state (suppresses stall detection)
140
+ detectLoading(output) {
141
+ return /processing|loading/i.test(output);
142
+ }
143
+
139
144
  parseOutput(output) {
140
145
  return {
141
146
  type: 'response',
@@ -210,6 +215,7 @@ interface SpawnConfig {
210
215
  cols?: number; // Terminal columns (default: 120)
211
216
  rows?: number; // Terminal rows (default: 40)
212
217
  timeout?: number; // Session timeout in ms
218
+ readySettleMs?: number; // Override adapter's ready settle delay
213
219
  }
214
220
  ```
215
221
 
@@ -457,13 +463,27 @@ class MyCLIAdapter extends BaseCLIAdapter {
457
463
  }
458
464
  ```
459
465
 
466
+ The settle delay can also be overridden per-spawn via `SpawnConfig.readySettleMs`, which takes precedence over the adapter default. This lets orchestrators tune the delay for varying environments (CI, remote containers, local dev) without forking adapters:
467
+
468
+ ```typescript
469
+ const handle = await manager.spawn({
470
+ name: 'agent',
471
+ type: 'claude',
472
+ readySettleMs: 1000, // Slow CI environment — wait longer
473
+ });
474
+ ```
475
+
460
476
  ## Stall Detection & Task Completion
461
477
 
462
- Content-based stall detection monitors sessions for output that stops changing. The content hash strips the full buffer first, then slices the last 500 characters of the normalized text — this ensures identical visual content always produces the same hash regardless of how many raw escape sequences surround it. The normalization strips ANSI escape codes, TUI spinner characters, and countdown/duration text (e.g. `8m 17s` → constant) so that live timers and TUI redraws don't perpetually reset the stall timer. All detection work (ready, blocking prompt, login, exit, stall) runs in a deferred `setImmediate()` tick so that node-pty's synchronous data delivery cannot starve the event loop — timers and I/O callbacks always get a chance to run between data bursts. The output buffer is capped at 100 KB to prevent unbounded growth during long tasks.
478
+ Content-based stall detection monitors sessions for output that stops changing. The content hash strips the full buffer first, then slices the last 500 characters of the normalized text — this ensures identical visual content always produces the same hash regardless of how many raw escape sequences surround it. The normalization strips ANSI escape codes, carriage returns (`\r`), non-breaking spaces (`\xa0`), TUI spinner characters, and countdown/duration text (e.g. `8m 17s` → constant) so that live timers, TUI line-overwrites, and redraws don't perpetually reset the stall timer. All detection work (ready, blocking prompt, login, exit, stall) runs in a deferred `setImmediate()` tick so that node-pty's synchronous data delivery cannot starve the event loop — timers and I/O callbacks always get a chance to run between data bursts. The output buffer is capped at 100 KB to prevent unbounded growth during long tasks.
479
+
480
+ ### Task Completion Fast-Path (Settle Pattern)
463
481
 
464
- When a stall is detected, the session first tries the adapter's `detectTaskComplete()` fast-path. If the adapter recognizes the output as a completed task (e.g. duration summary + idle prompt), it transitions directly to `ready` and emits `task_complete` skipping the expensive LLM stall classifier entirely.
482
+ When a busy session's output matches the adapter's `detectReady()` pattern, a `task_complete` debounce timer starts. TUI agents like Claude Code continue rendering decorative output after the prompt update notices, shortcut hints, status bar updates. Instead of cancelling the timer on each new data chunk (which would prevent the event from ever firing), the session uses a **settle pattern**: the debounce timer resets on each new chunk but is never cancelled. The timer callback re-verifies `detectReady()` before transitioning, so stale triggers are safe.
465
483
 
466
- If the adapter doesn't recognize the output, the session falls back to emitting `stall_detected` for external classification.
484
+ This mirrors the `readySettlePending` pattern used for startup ready detection, and ensures the `task_complete` event fires reliably even when TUI chrome continues rendering after the agent has finished its task.
485
+
486
+ If the fast-path timer doesn't fire (e.g. the prompt indicator disappears from the buffer), the session falls back to stall detection which emits `stall_detected` for external classification.
467
487
 
468
488
  ```typescript
469
489
  // Enable stall detection with a pluggable classifier
@@ -510,6 +530,37 @@ The default `BaseCLIAdapter` implementation delegates to `detectReady()`. Coding
510
530
  | Codex | `Worked for 1m 05s` separator + `›` prompt |
511
531
  | Aider | `Aider is waiting for your input`, mode prompts with edit markers |
512
532
 
533
+ ### Loading Pattern Suppression
534
+
535
+ Adapters can implement `detectLoading(output)` to detect when the CLI is actively working — thinking spinners, file reading progress, model streaming indicators. When `detectLoading()` returns `true`, stall detection is suppressed entirely because the agent is provably working, just not producing new visible text.
536
+
537
+ ```typescript
538
+ class MyCLIAdapter extends BaseCLIAdapter {
539
+ detectLoading(output: string): boolean {
540
+ // Match loading indicators specific to this CLI
541
+ return /esc to interrupt/i.test(output) || /Reading \d+ files/i.test(output);
542
+ }
543
+ }
544
+ ```
545
+
546
+ This avoids unnecessary LLM classifier calls during normal operation and prevents false stall alerts when agents are thinking or reading files.
547
+
548
+ ### Stall Backoff
549
+
550
+ When the external stall classifier returns `still_working` (or `null`), the next stall check interval doubles exponentially instead of repeating at the base rate. This prevents hammering the classifier every few seconds during long-running tasks.
551
+
552
+ - Base interval: `stallTimeoutMs` (default: 8000ms)
553
+ - After each `still_working`: interval doubles (8s → 16s → 30s cap)
554
+ - Maximum interval: 30 seconds
555
+ - Reset: backoff resets to the base interval whenever new real content arrives (content hash changes)
556
+
557
+ ```
558
+ First stall check: 8s → classifier says "still_working"
559
+ Second check: 16s → classifier says "still_working"
560
+ Third check: 30s → (capped at 30s)
561
+ New output arrives: → backoff resets to 8s
562
+ ```
563
+
513
564
  ## Blocking Prompt Types
514
565
 
515
566
  The library recognizes these blocking prompt types:
package/dist/index.d.mts CHANGED
@@ -37,6 +37,9 @@ interface SpawnConfig {
37
37
  adapterConfig?: Record<string, unknown>;
38
38
  /** Per-session stall timeout in ms. Overrides PTYManagerConfig.stallTimeoutMs. */
39
39
  stallTimeoutMs?: number;
40
+ /** Override adapter's readySettleMs for this session.
41
+ * Ms of output silence after detectReady match before emitting session_ready. */
42
+ readySettleMs?: number;
40
43
  /** Override or disable specific adapter auto-response rules for this session.
41
44
  * Keys are regex source strings (from rule.pattern.source).
42
45
  * - null value disables that rule entirely
@@ -353,6 +356,15 @@ interface CLIAdapter {
353
356
  }>;
354
357
  /** Ms of output silence after detectReady match before emitting session_ready (default: 100) */
355
358
  readonly readySettleMs?: number;
359
+ /**
360
+ * Optional: Detect if the CLI is actively loading/processing (thinking spinner,
361
+ * file reading, model streaming, etc.). When true, stall detection is suppressed
362
+ * because the agent is provably working — just not producing new visible text.
363
+ *
364
+ * Patterns should match active loading indicators like "esc to interrupt",
365
+ * "Reading N files", "Waiting for LLM", etc.
366
+ */
367
+ detectLoading?(output: string): boolean;
356
368
  /**
357
369
  * Optional: Get health check command
358
370
  */
@@ -446,7 +458,10 @@ declare class PTYSession extends EventEmitter {
446
458
  private _lastStallHash;
447
459
  private _stallStartedAt;
448
460
  private _lastContentHash;
461
+ private _stallBackoffMs;
462
+ private static readonly MAX_STALL_BACKOFF_MS;
449
463
  private _taskCompleteTimer;
464
+ private _taskCompletePending;
450
465
  private static readonly TASK_COMPLETE_DEBOUNCE_MS;
451
466
  private _readySettleTimer;
452
467
  private _readySettlePending;
@@ -522,8 +537,12 @@ declare class PTYSession extends EventEmitter {
522
537
  handleStallClassification(classification: StallClassification | null): void;
523
538
  /**
524
539
  * Schedule a task_complete transition after a debounce period.
525
- * If new non-whitespace output arrives before the timer fires,
526
- * the timer is cancelled (by cancelTaskComplete in onData).
540
+ * Uses a settle pattern: each call resets the debounce timer instead of
541
+ * being a no-op when already scheduled. This allows TUI agents that
542
+ * continue rendering decorative output (status bar, update notices) after
543
+ * the prompt to eventually settle, rather than having the timer cancelled
544
+ * by every new data chunk. The callback re-verifies detectReady() before
545
+ * transitioning, so stale triggers are safe.
527
546
  */
528
547
  private scheduleTaskComplete;
529
548
  /**
package/dist/index.d.ts CHANGED
@@ -37,6 +37,9 @@ interface SpawnConfig {
37
37
  adapterConfig?: Record<string, unknown>;
38
38
  /** Per-session stall timeout in ms. Overrides PTYManagerConfig.stallTimeoutMs. */
39
39
  stallTimeoutMs?: number;
40
+ /** Override adapter's readySettleMs for this session.
41
+ * Ms of output silence after detectReady match before emitting session_ready. */
42
+ readySettleMs?: number;
40
43
  /** Override or disable specific adapter auto-response rules for this session.
41
44
  * Keys are regex source strings (from rule.pattern.source).
42
45
  * - null value disables that rule entirely
@@ -353,6 +356,15 @@ interface CLIAdapter {
353
356
  }>;
354
357
  /** Ms of output silence after detectReady match before emitting session_ready (default: 100) */
355
358
  readonly readySettleMs?: number;
359
+ /**
360
+ * Optional: Detect if the CLI is actively loading/processing (thinking spinner,
361
+ * file reading, model streaming, etc.). When true, stall detection is suppressed
362
+ * because the agent is provably working — just not producing new visible text.
363
+ *
364
+ * Patterns should match active loading indicators like "esc to interrupt",
365
+ * "Reading N files", "Waiting for LLM", etc.
366
+ */
367
+ detectLoading?(output: string): boolean;
356
368
  /**
357
369
  * Optional: Get health check command
358
370
  */
@@ -446,7 +458,10 @@ declare class PTYSession extends EventEmitter {
446
458
  private _lastStallHash;
447
459
  private _stallStartedAt;
448
460
  private _lastContentHash;
461
+ private _stallBackoffMs;
462
+ private static readonly MAX_STALL_BACKOFF_MS;
449
463
  private _taskCompleteTimer;
464
+ private _taskCompletePending;
450
465
  private static readonly TASK_COMPLETE_DEBOUNCE_MS;
451
466
  private _readySettleTimer;
452
467
  private _readySettlePending;
@@ -522,8 +537,12 @@ declare class PTYSession extends EventEmitter {
522
537
  handleStallClassification(classification: StallClassification | null): void;
523
538
  /**
524
539
  * Schedule a task_complete transition after a debounce period.
525
- * If new non-whitespace output arrives before the timer fires,
526
- * the timer is cancelled (by cancelTaskComplete in onData).
540
+ * Uses a settle pattern: each call resets the debounce timer instead of
541
+ * being a no-op when already scheduled. This allows TUI agents that
542
+ * continue rendering decorative output (status bar, update notices) after
543
+ * the prompt to eventually settle, rather than having the timer cancelled
544
+ * by every new data chunk. The callback re-verifies detectReady() before
545
+ * transitioning, so stale triggers are safe.
527
546
  */
528
547
  private scheduleTaskComplete;
529
548
  /**
package/dist/index.js CHANGED
@@ -315,6 +315,7 @@ var PTYSession = class _PTYSession extends import_events.EventEmitter {
315
315
  this.logger = logger || consoleLogger;
316
316
  this._stallDetectionEnabled = stallDetectionEnabled ?? false;
317
317
  this._stallTimeoutMs = config.stallTimeoutMs ?? defaultStallTimeoutMs ?? 8e3;
318
+ this._stallBackoffMs = this._stallTimeoutMs;
318
319
  if (config.ruleOverrides) {
319
320
  for (const [key, value] of Object.entries(config.ruleOverrides)) {
320
321
  if (value === null) {
@@ -344,8 +345,12 @@ var PTYSession = class _PTYSession extends import_events.EventEmitter {
344
345
  _lastStallHash = null;
345
346
  _stallStartedAt = null;
346
347
  _lastContentHash = null;
348
+ _stallBackoffMs = 0;
349
+ // Initialized in constructor from _stallTimeoutMs
350
+ static MAX_STALL_BACKOFF_MS = 3e4;
347
351
  // Task completion detection (idle detection when busy)
348
352
  _taskCompleteTimer = null;
353
+ _taskCompletePending = false;
349
354
  static TASK_COMPLETE_DEBOUNCE_MS = 1500;
350
355
  // Ready detection settle delay — defers session_ready until output goes quiet
351
356
  _readySettleTimer = null;
@@ -466,6 +471,7 @@ var PTYSession = class _PTYSession extends import_events.EventEmitter {
466
471
  }
467
472
  this._stallStartedAt = Date.now();
468
473
  this._lastStallHash = null;
474
+ this._stallBackoffMs = this._stallTimeoutMs;
469
475
  this._stallTimer = setTimeout(() => {
470
476
  this.onStallTimerFired();
471
477
  }, this._stallTimeoutMs);
@@ -480,6 +486,7 @@ var PTYSession = class _PTYSession extends import_events.EventEmitter {
480
486
  }
481
487
  this._stallStartedAt = null;
482
488
  this._lastContentHash = null;
489
+ this._stallBackoffMs = this._stallTimeoutMs;
483
490
  }
484
491
  /**
485
492
  * Called when the stall timer fires (no output for stallTimeoutMs).
@@ -488,10 +495,18 @@ var PTYSession = class _PTYSession extends import_events.EventEmitter {
488
495
  if (this._status !== "busy" && this._status !== "authenticating") {
489
496
  return;
490
497
  }
498
+ if (this.adapter.detectLoading?.(this.outputBuffer)) {
499
+ this.logger.debug(
500
+ { sessionId: this.id },
501
+ "Loading pattern detected \u2014 suppressing stall emission"
502
+ );
503
+ this._stallTimer = setTimeout(() => this.onStallTimerFired(), this._stallBackoffMs);
504
+ return;
505
+ }
491
506
  const tail = this.outputBuffer.slice(-500);
492
507
  const hash = this.simpleHash(tail);
493
508
  if (hash === this._lastStallHash) {
494
- this._stallTimer = setTimeout(() => this.onStallTimerFired(), this._stallTimeoutMs);
509
+ this._stallTimer = setTimeout(() => this.onStallTimerFired(), this._stallBackoffMs);
495
510
  return;
496
511
  }
497
512
  this._lastStallHash = hash;
@@ -516,7 +531,7 @@ var PTYSession = class _PTYSession extends import_events.EventEmitter {
516
531
  "Stall detected"
517
532
  );
518
533
  this.emit("stall_detected", recentOutput, stallDurationMs);
519
- this._stallTimer = setTimeout(() => this.onStallTimerFired(), this._stallTimeoutMs);
534
+ this._stallTimer = setTimeout(() => this.onStallTimerFired(), this._stallBackoffMs);
520
535
  }
521
536
  /**
522
537
  * Promise-based delay helper.
@@ -548,7 +563,8 @@ var PTYSession = class _PTYSession extends import_events.EventEmitter {
548
563
  result = result.replace(/\x1b\[\d*(?:;\d+)?[Hf]/g, " ");
549
564
  result = result.replace(/\x1b\[\d*[JK]/g, " ");
550
565
  result = result.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
551
- result = result.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
566
+ result = result.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
567
+ result = result.replace(/\xa0/g, " ");
552
568
  result = result.replace(/[│╭╰╮╯─═╌║╔╗╚╝╠╣╦╩╬┌┐└┘├┤┬┴┼●○❯❮▶◀⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷✻✶✳✢⏺←→↑↓⬆⬇◆◇▪▫■□▲△▼▽◈⟨⟩⌘⏎⏏⌫⌦⇧⇪⌥]/g, " ");
553
569
  result = result.replace(/\d+[hms](?:\s+\d+[hms])*/g, "0s");
554
570
  result = result.replace(/ {2,}/g, " ");
@@ -563,8 +579,21 @@ var PTYSession = class _PTYSession extends import_events.EventEmitter {
563
579
  return;
564
580
  }
565
581
  if (!classification || classification.state === "still_working") {
582
+ this._stallBackoffMs = Math.min(
583
+ this._stallBackoffMs * 2,
584
+ _PTYSession.MAX_STALL_BACKOFF_MS
585
+ );
586
+ this.logger.debug(
587
+ { sessionId: this.id, nextCheckMs: this._stallBackoffMs },
588
+ "Still working \u2014 backing off stall check interval"
589
+ );
566
590
  this._lastContentHash = null;
567
- this.resetStallTimer();
591
+ this._lastStallHash = null;
592
+ if (this._stallTimer) {
593
+ clearTimeout(this._stallTimer);
594
+ this._stallTimer = null;
595
+ }
596
+ this._stallTimer = setTimeout(() => this.onStallTimerFired(), this._stallBackoffMs);
568
597
  return;
569
598
  }
570
599
  switch (classification.state) {
@@ -612,13 +641,21 @@ var PTYSession = class _PTYSession extends import_events.EventEmitter {
612
641
  // ─────────────────────────────────────────────────────────────────────────────
613
642
  /**
614
643
  * Schedule a task_complete transition after a debounce period.
615
- * If new non-whitespace output arrives before the timer fires,
616
- * the timer is cancelled (by cancelTaskComplete in onData).
644
+ * Uses a settle pattern: each call resets the debounce timer instead of
645
+ * being a no-op when already scheduled. This allows TUI agents that
646
+ * continue rendering decorative output (status bar, update notices) after
647
+ * the prompt to eventually settle, rather than having the timer cancelled
648
+ * by every new data chunk. The callback re-verifies detectReady() before
649
+ * transitioning, so stale triggers are safe.
617
650
  */
618
651
  scheduleTaskComplete() {
619
- if (this._taskCompleteTimer) return;
652
+ if (this._taskCompleteTimer) {
653
+ clearTimeout(this._taskCompleteTimer);
654
+ }
655
+ this._taskCompletePending = true;
620
656
  this._taskCompleteTimer = setTimeout(() => {
621
657
  this._taskCompleteTimer = null;
658
+ this._taskCompletePending = false;
622
659
  if (this._status !== "busy") return;
623
660
  if (!this.adapter.detectReady(this.outputBuffer)) return;
624
661
  this._status = "ready";
@@ -639,6 +676,7 @@ var PTYSession = class _PTYSession extends import_events.EventEmitter {
639
676
  clearTimeout(this._taskCompleteTimer);
640
677
  this._taskCompleteTimer = null;
641
678
  }
679
+ this._taskCompletePending = false;
642
680
  }
643
681
  // ─────────────────────────────────────────────────────────────────────────────
644
682
  // Ready Detection Settle Delay
@@ -654,7 +692,7 @@ var PTYSession = class _PTYSession extends import_events.EventEmitter {
654
692
  if (this._readySettleTimer) {
655
693
  clearTimeout(this._readySettleTimer);
656
694
  }
657
- const settleMs = this.adapter.readySettleMs ?? 100;
695
+ const settleMs = this.config.readySettleMs ?? this.adapter.readySettleMs ?? 100;
658
696
  this._readySettleTimer = setTimeout(() => {
659
697
  this._readySettleTimer = null;
660
698
  this._readySettlePending = false;
@@ -780,10 +818,10 @@ var PTYSession = class _PTYSession extends import_events.EventEmitter {
780
818
  this.scheduleReadySettle();
781
819
  return;
782
820
  }
783
- if (this._status === "busy" && this.adapter.detectReady(this.outputBuffer)) {
784
- this.scheduleTaskComplete();
785
- } else {
786
- this.cancelTaskComplete();
821
+ if (this._status === "busy") {
822
+ if (this._taskCompletePending || this.adapter.detectReady(this.outputBuffer)) {
823
+ this.scheduleTaskComplete();
824
+ }
787
825
  }
788
826
  const blockingPrompt = this.detectAndHandleBlockingPrompt();
789
827
  if (blockingPrompt) {