pty-manager 1.2.21 → 1.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.
package/README.md CHANGED
@@ -10,6 +10,7 @@ PTY session manager with lifecycle management, pluggable adapters, and blocking
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
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
13
14
  - **Terminal attachment** - Attach to sessions for raw I/O streaming
14
15
  - **Special key support** - Send Ctrl, Alt, Shift, and function key combinations via `sendKeys()`
15
16
  - **Bracketed paste** - Proper paste handling with bracketed paste mode support
@@ -130,6 +131,11 @@ class MyCLIAdapter extends BaseCLIAdapter {
130
131
  return /ready>/.test(output);
131
132
  }
132
133
 
134
+ // Optional: high-confidence task completion detection
135
+ detectTaskComplete(output) {
136
+ return /done in \d+s/.test(output) && /ready>/.test(output);
137
+ }
138
+
133
139
  parseOutput(output) {
134
140
  return {
135
141
  type: 'response',
@@ -189,6 +195,8 @@ class PTYManager extends EventEmitter {
189
195
  | `blocking_prompt` | `SessionHandle, promptInfo, autoResponded` | Prompt detected |
190
196
  | `message` | `SessionMessage` | Parsed message received |
191
197
  | `question` | `SessionHandle, question` | Question detected |
198
+ | `stall_detected` | `SessionHandle, recentOutput, stallDurationMs` | Output stalled, needs classification |
199
+ | `task_complete` | `SessionHandle` | Agent finished task, returned to idle |
192
200
 
193
201
  ### SpawnConfig
194
202
 
@@ -435,9 +443,11 @@ Adapters can declare `usesTuiMenus: true` to indicate they use arrow-key menus i
435
443
  await session.selectMenuOption(2); // Sends Down, Down, Enter with 50ms delays
436
444
  ```
437
445
 
438
- ## Stall Detection
446
+ ## Stall Detection & Task Completion
439
447
 
440
- Content-based stall detection monitors sessions for output that stops changing. When a stall is detected, the session emits a `stall_detected` event with the buffered output for external classification.
448
+ Content-based stall detection monitors sessions for output that stops changing. 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.
449
+
450
+ If the adapter doesn't recognize the output, the session falls back to emitting `stall_detected` for external classification.
441
451
 
442
452
  ```typescript
443
453
  // Enable stall detection with a pluggable classifier
@@ -460,6 +470,30 @@ const session = await manager.spawn({
460
470
  });
461
471
  ```
462
472
 
473
+ ### Adapter-Level Task Completion (Fast Path)
474
+
475
+ Adapters can implement `detectTaskComplete(output)` to recognize high-confidence completion patterns specific to their CLI. This avoids the latency and cost of an LLM classifier call.
476
+
477
+ ```typescript
478
+ class MyCLIAdapter extends BaseCLIAdapter {
479
+ // ...
480
+
481
+ detectTaskComplete(output: string): boolean {
482
+ // Match CLI-specific patterns that indicate work is done
483
+ return /completed in \d+s/.test(output) && /my-cli>/.test(output);
484
+ }
485
+ }
486
+ ```
487
+
488
+ The default `BaseCLIAdapter` implementation delegates to `detectReady()`. Coding agent adapters override this with CLI-specific patterns:
489
+
490
+ | Adapter | Completion Indicators |
491
+ |---------|----------------------|
492
+ | Claude Code | Turn duration (`Cooked for 3m 12s`) + `❯` prompt |
493
+ | Gemini CLI | `◇ Ready` window title, `Type your message` composer |
494
+ | Codex | `Worked for 1m 05s` separator + `›` prompt |
495
+ | Aider | `Aider is waiting for your input`, mode prompts with edit markers |
496
+
463
497
  ## Blocking Prompt Types
464
498
 
465
499
  The library recognizes these blocking prompt types:
package/dist/index.d.mts CHANGED
@@ -334,6 +334,15 @@ interface CLIAdapter {
334
334
  * Get prompt pattern to detect when CLI is waiting for input
335
335
  */
336
336
  getPromptPattern(): RegExp;
337
+ /**
338
+ * Optional: Detect if the CLI has completed a task and returned to its idle prompt.
339
+ * More specific than detectReady — matches high-confidence completion indicators
340
+ * (e.g. duration summaries, explicit "done" messages) alongside the idle prompt.
341
+ *
342
+ * Used as a fast-path in stall detection to avoid expensive LLM classifier calls.
343
+ * If not implemented, the stall classifier is used as the fallback.
344
+ */
345
+ detectTaskComplete?(output: string): boolean;
337
346
  /**
338
347
  * Optional: Validate that the CLI is installed and accessible
339
348
  */
@@ -405,6 +414,8 @@ interface PTYSessionEvents {
405
414
  exit: (code: number) => void;
406
415
  error: (error: Error) => void;
407
416
  stall_detected: (recentOutput: string, stallDurationMs: number) => void;
417
+ status_changed: (status: SessionStatus) => void;
418
+ task_complete: () => void;
408
419
  }
409
420
  /**
410
421
  * Special key mappings to escape sequences
@@ -433,6 +444,8 @@ declare class PTYSession extends EventEmitter {
433
444
  private _lastStallHash;
434
445
  private _stallStartedAt;
435
446
  private _lastContentHash;
447
+ private _taskCompleteTimer;
448
+ private static readonly TASK_COMPLETE_DEBOUNCE_MS;
436
449
  readonly id: string;
437
450
  readonly config: SpawnConfig;
438
451
  constructor(adapter: CLIAdapter, config: SpawnConfig, logger?: Logger, stallDetectionEnabled?: boolean, defaultStallTimeoutMs?: number);
@@ -501,6 +514,17 @@ declare class PTYSession extends EventEmitter {
501
514
  * Called by the manager after onStallClassify resolves.
502
515
  */
503
516
  handleStallClassification(classification: StallClassification | null): void;
517
+ /**
518
+ * Schedule a task_complete transition after a debounce period.
519
+ * If new non-whitespace output arrives before the timer fires,
520
+ * the timer is cancelled (by cancelTaskComplete in onData).
521
+ */
522
+ private scheduleTaskComplete;
523
+ /**
524
+ * Cancel a pending task_complete timer (new output arrived that
525
+ * doesn't match the idle prompt, so the agent is still working).
526
+ */
527
+ private cancelTaskComplete;
504
528
  /**
505
529
  * Start the PTY session
506
530
  */
@@ -612,6 +636,8 @@ interface PTYManagerEvents {
612
636
  message: (message: SessionMessage) => void;
613
637
  question: (session: SessionHandle, question: string) => void;
614
638
  stall_detected: (session: SessionHandle, recentOutput: string, stallDurationMs: number) => void;
639
+ session_status_changed: (session: SessionHandle) => void;
640
+ task_complete: (session: SessionHandle) => void;
615
641
  }
616
642
  declare class PTYManager extends EventEmitter {
617
643
  private sessions;
@@ -757,6 +783,12 @@ declare abstract class BaseCLIAdapter implements CLIAdapter {
757
783
  * Subclasses should override for CLI-specific detection.
758
784
  */
759
785
  detectBlockingPrompt(output: string): BlockingPromptDetection;
786
+ /**
787
+ * Default task completion detection — delegates to detectReady().
788
+ * Subclasses should override to match high-confidence completion patterns
789
+ * (e.g. duration summaries) that short-circuit the LLM stall classifier.
790
+ */
791
+ detectTaskComplete(output: string): boolean;
760
792
  /**
761
793
  * Default input formatting - just return as-is
762
794
  */
package/dist/index.d.ts CHANGED
@@ -334,6 +334,15 @@ interface CLIAdapter {
334
334
  * Get prompt pattern to detect when CLI is waiting for input
335
335
  */
336
336
  getPromptPattern(): RegExp;
337
+ /**
338
+ * Optional: Detect if the CLI has completed a task and returned to its idle prompt.
339
+ * More specific than detectReady — matches high-confidence completion indicators
340
+ * (e.g. duration summaries, explicit "done" messages) alongside the idle prompt.
341
+ *
342
+ * Used as a fast-path in stall detection to avoid expensive LLM classifier calls.
343
+ * If not implemented, the stall classifier is used as the fallback.
344
+ */
345
+ detectTaskComplete?(output: string): boolean;
337
346
  /**
338
347
  * Optional: Validate that the CLI is installed and accessible
339
348
  */
@@ -405,6 +414,8 @@ interface PTYSessionEvents {
405
414
  exit: (code: number) => void;
406
415
  error: (error: Error) => void;
407
416
  stall_detected: (recentOutput: string, stallDurationMs: number) => void;
417
+ status_changed: (status: SessionStatus) => void;
418
+ task_complete: () => void;
408
419
  }
409
420
  /**
410
421
  * Special key mappings to escape sequences
@@ -433,6 +444,8 @@ declare class PTYSession extends EventEmitter {
433
444
  private _lastStallHash;
434
445
  private _stallStartedAt;
435
446
  private _lastContentHash;
447
+ private _taskCompleteTimer;
448
+ private static readonly TASK_COMPLETE_DEBOUNCE_MS;
436
449
  readonly id: string;
437
450
  readonly config: SpawnConfig;
438
451
  constructor(adapter: CLIAdapter, config: SpawnConfig, logger?: Logger, stallDetectionEnabled?: boolean, defaultStallTimeoutMs?: number);
@@ -501,6 +514,17 @@ declare class PTYSession extends EventEmitter {
501
514
  * Called by the manager after onStallClassify resolves.
502
515
  */
503
516
  handleStallClassification(classification: StallClassification | null): void;
517
+ /**
518
+ * Schedule a task_complete transition after a debounce period.
519
+ * If new non-whitespace output arrives before the timer fires,
520
+ * the timer is cancelled (by cancelTaskComplete in onData).
521
+ */
522
+ private scheduleTaskComplete;
523
+ /**
524
+ * Cancel a pending task_complete timer (new output arrived that
525
+ * doesn't match the idle prompt, so the agent is still working).
526
+ */
527
+ private cancelTaskComplete;
504
528
  /**
505
529
  * Start the PTY session
506
530
  */
@@ -612,6 +636,8 @@ interface PTYManagerEvents {
612
636
  message: (message: SessionMessage) => void;
613
637
  question: (session: SessionHandle, question: string) => void;
614
638
  stall_detected: (session: SessionHandle, recentOutput: string, stallDurationMs: number) => void;
639
+ session_status_changed: (session: SessionHandle) => void;
640
+ task_complete: (session: SessionHandle) => void;
615
641
  }
616
642
  declare class PTYManager extends EventEmitter {
617
643
  private sessions;
@@ -757,6 +783,12 @@ declare abstract class BaseCLIAdapter implements CLIAdapter {
757
783
  * Subclasses should override for CLI-specific detection.
758
784
  */
759
785
  detectBlockingPrompt(output: string): BlockingPromptDetection;
786
+ /**
787
+ * Default task completion detection — delegates to detectReady().
788
+ * Subclasses should override to match high-confidence completion patterns
789
+ * (e.g. duration summaries) that short-circuit the LLM stall classifier.
790
+ */
791
+ detectTaskComplete(output: string): boolean;
760
792
  /**
761
793
  * Default input formatting - just return as-is
762
794
  */
package/dist/index.js CHANGED
@@ -306,7 +306,7 @@ var SPECIAL_KEYS = {
306
306
  };
307
307
  var BRACKETED_PASTE_START = "\x1B[200~";
308
308
  var BRACKETED_PASTE_END = "\x1B[201~";
309
- var PTYSession = class extends import_events.EventEmitter {
309
+ var PTYSession = class _PTYSession extends import_events.EventEmitter {
310
310
  constructor(adapter, config, logger, stallDetectionEnabled, defaultStallTimeoutMs) {
311
311
  super();
312
312
  this.adapter = adapter;
@@ -344,6 +344,9 @@ var PTYSession = class extends import_events.EventEmitter {
344
344
  _lastStallHash = null;
345
345
  _stallStartedAt = null;
346
346
  _lastContentHash = null;
347
+ // Task completion detection (idle detection when busy)
348
+ _taskCompleteTimer = null;
349
+ static TASK_COMPLETE_DEBOUNCE_MS = 1500;
347
350
  id;
348
351
  config;
349
352
  get status() {
@@ -483,6 +486,19 @@ var PTYSession = class extends import_events.EventEmitter {
483
486
  return;
484
487
  }
485
488
  this._lastStallHash = hash;
489
+ if (this._status === "busy" && this.adapter.detectTaskComplete?.(this.outputBuffer)) {
490
+ this._status = "ready";
491
+ this._lastBlockingPromptHash = null;
492
+ this.outputBuffer = "";
493
+ this.clearStallTimer();
494
+ this.emit("status_changed", "ready");
495
+ this.emit("task_complete");
496
+ this.logger.info(
497
+ { sessionId: this.id },
498
+ "Task complete (adapter fast-path) \u2014 agent returned to idle prompt"
499
+ );
500
+ return;
501
+ }
486
502
  const recentRaw = this.outputBuffer.slice(-2e3);
487
503
  const recentOutput = this.stripAnsiForStall(recentRaw);
488
504
  const stallDurationMs = this._stallStartedAt ? Date.now() - this._stallStartedAt : this._stallTimeoutMs;
@@ -519,10 +535,12 @@ var PTYSession = class extends import_events.EventEmitter {
519
535
  * word boundaries — e.g. "Do\x1b[5Cyou" becomes "Do you", not "Doyou".
520
536
  */
521
537
  stripAnsiForStall(str) {
522
- let result = str.replace(/\x1b\[\d*[CDABG]/g, " ");
538
+ let result = str.replace(/\x1b\[\d*[CDABGdEF]/g, " ");
523
539
  result = result.replace(/\x1b\[\d*(?:;\d+)?[Hf]/g, " ");
540
+ result = result.replace(/\x1b\[\d*[JK]/g, " ");
524
541
  result = result.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
525
- result = result.replace(/[│╭╰╮╯─═╌║╔╗╚╝╠╣╦╩╬┌┐└┘├┤┬┴┼●○❯❮▶◀⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷✻✶✳✢⏺←→↑↓]/g, " ");
542
+ result = result.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
543
+ result = result.replace(/[│╭╰╮╯─═╌║╔╗╚╝╠╣╦╩╬┌┐└┘├┤┬┴┼●○❯❮▶◀⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣾⣽⣻⢿⡿⣟⣯⣷✻✶✳✢⏺←→↑↓⬆⬇◆◇▪▫■□▲△▼▽◈⟨⟩⌘⏎⏏⌫⌦⇧⇪⌥]/g, " ");
526
544
  result = result.replace(/ {2,}/g, " ");
527
545
  return result;
528
546
  }
@@ -580,6 +598,39 @@ var PTYSession = class extends import_events.EventEmitter {
580
598
  }
581
599
  }
582
600
  // ─────────────────────────────────────────────────────────────────────────────
601
+ // Task Completion Detection
602
+ // ─────────────────────────────────────────────────────────────────────────────
603
+ /**
604
+ * Schedule a task_complete transition after a debounce period.
605
+ * If new non-whitespace output arrives before the timer fires,
606
+ * the timer is cancelled (by cancelTaskComplete in onData).
607
+ */
608
+ scheduleTaskComplete() {
609
+ if (this._taskCompleteTimer) return;
610
+ this._taskCompleteTimer = setTimeout(() => {
611
+ this._taskCompleteTimer = null;
612
+ if (this._status !== "busy") return;
613
+ if (!this.adapter.detectReady(this.outputBuffer)) return;
614
+ this._status = "ready";
615
+ this._lastBlockingPromptHash = null;
616
+ this.outputBuffer = "";
617
+ this.clearStallTimer();
618
+ this.emit("status_changed", "ready");
619
+ this.emit("task_complete");
620
+ this.logger.info({ sessionId: this.id }, "Task complete \u2014 agent returned to idle prompt");
621
+ }, _PTYSession.TASK_COMPLETE_DEBOUNCE_MS);
622
+ }
623
+ /**
624
+ * Cancel a pending task_complete timer (new output arrived that
625
+ * doesn't match the idle prompt, so the agent is still working).
626
+ */
627
+ cancelTaskComplete() {
628
+ if (this._taskCompleteTimer) {
629
+ clearTimeout(this._taskCompleteTimer);
630
+ this._taskCompleteTimer = null;
631
+ }
632
+ }
633
+ // ─────────────────────────────────────────────────────────────────────────────
583
634
  // Lifecycle
584
635
  // ─────────────────────────────────────────────────────────────────────────────
585
636
  /**
@@ -641,10 +692,6 @@ var PTYSession = class extends import_events.EventEmitter {
641
692
  this.resetStallTimer();
642
693
  }
643
694
  this.emit("output", data);
644
- const blockingPrompt = this.detectAndHandleBlockingPrompt();
645
- if (blockingPrompt) {
646
- return;
647
- }
648
695
  if ((this._status === "starting" || this._status === "authenticating") && this.adapter.detectReady(this.outputBuffer)) {
649
696
  this._status = "ready";
650
697
  this._lastBlockingPromptHash = null;
@@ -654,6 +701,15 @@ var PTYSession = class extends import_events.EventEmitter {
654
701
  this.logger.info({ sessionId: this.id }, "Session ready");
655
702
  return;
656
703
  }
704
+ if (this._status === "busy" && this.adapter.detectReady(this.outputBuffer)) {
705
+ this.scheduleTaskComplete();
706
+ } else {
707
+ this.cancelTaskComplete();
708
+ }
709
+ const blockingPrompt = this.detectAndHandleBlockingPrompt();
710
+ if (blockingPrompt) {
711
+ return;
712
+ }
657
713
  if (this._status !== "ready" && this._status !== "busy") {
658
714
  const loginDetection = this.adapter.detectLogin(this.outputBuffer);
659
715
  if (loginDetection.required && this._status !== "authenticating") {
@@ -876,6 +932,7 @@ var PTYSession = class extends import_events.EventEmitter {
876
932
  */
877
933
  send(message) {
878
934
  this._status = "busy";
935
+ this.emit("status_changed", "busy");
879
936
  this.resetStallTimer();
880
937
  const msg = {
881
938
  id: `${this.id}-msg-${++this.messageCounter}`,
@@ -986,6 +1043,7 @@ var PTYSession = class extends import_events.EventEmitter {
986
1043
  if (this.ptyProcess) {
987
1044
  this._status = "stopping";
988
1045
  this.clearStallTimer();
1046
+ this.cancelTaskComplete();
989
1047
  this.ptyProcess.kill(signal);
990
1048
  this.logger.info({ sessionId: this.id, signal }, "Killing PTY session");
991
1049
  }
@@ -1139,6 +1197,12 @@ var PTYManager = class extends import_events2.EventEmitter {
1139
1197
  session.on("error", (error) => {
1140
1198
  this.emit("session_error", session.toHandle(), error.message);
1141
1199
  });
1200
+ session.on("status_changed", () => {
1201
+ this.emit("session_status_changed", session.toHandle());
1202
+ });
1203
+ session.on("task_complete", () => {
1204
+ this.emit("task_complete", session.toHandle());
1205
+ });
1142
1206
  session.on("stall_detected", (recentOutput, stallDurationMs) => {
1143
1207
  const handle = session.toHandle();
1144
1208
  this.emit("stall_detected", handle, recentOutput, stallDurationMs);
@@ -1543,6 +1607,14 @@ var BaseCLIAdapter = class {
1543
1607
  }
1544
1608
  return { detected: false };
1545
1609
  }
1610
+ /**
1611
+ * Default task completion detection — delegates to detectReady().
1612
+ * Subclasses should override to match high-confidence completion patterns
1613
+ * (e.g. duration summaries) that short-circuit the LLM stall classifier.
1614
+ */
1615
+ detectTaskComplete(output) {
1616
+ return this.detectReady(output);
1617
+ }
1546
1618
  /**
1547
1619
  * Default input formatting - just return as-is
1548
1620
  */
@@ -1992,6 +2064,24 @@ var BunCompatiblePTYManager = class extends import_events3.EventEmitter {
1992
2064
  }
1993
2065
  break;
1994
2066
  }
2067
+ case "status_changed": {
2068
+ const session = this.sessions.get(id);
2069
+ if (session) {
2070
+ session.status = event.status;
2071
+ session.lastActivityAt = /* @__PURE__ */ new Date();
2072
+ this.emit("session_status_changed", session);
2073
+ }
2074
+ break;
2075
+ }
2076
+ case "task_complete": {
2077
+ const session = this.sessions.get(id);
2078
+ if (session) {
2079
+ session.status = "ready";
2080
+ session.lastActivityAt = /* @__PURE__ */ new Date();
2081
+ this.emit("task_complete", session);
2082
+ }
2083
+ break;
2084
+ }
1995
2085
  case "stall_detected": {
1996
2086
  const session = this.sessions.get(id);
1997
2087
  if (session) {