pty-manager 1.2.13 → 1.2.15

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.
@@ -290,12 +290,14 @@ var SPECIAL_KEYS = {
290
290
  var BRACKETED_PASTE_START = "\x1B[200~";
291
291
  var BRACKETED_PASTE_END = "\x1B[201~";
292
292
  var PTYSession = class extends import_events.EventEmitter {
293
- constructor(adapter, config, logger) {
293
+ constructor(adapter, config, logger, stallDetectionEnabled, defaultStallTimeoutMs) {
294
294
  super();
295
295
  this.adapter = adapter;
296
296
  this.id = config.id || generateId();
297
297
  this.config = { ...config, id: this.id };
298
298
  this.logger = logger || consoleLogger;
299
+ this._stallDetectionEnabled = stallDetectionEnabled ?? false;
300
+ this._stallTimeoutMs = config.stallTimeoutMs ?? defaultStallTimeoutMs ?? 8e3;
299
301
  }
300
302
  ptyProcess = null;
301
303
  outputBuffer = "";
@@ -306,6 +308,12 @@ var PTYSession = class extends import_events.EventEmitter {
306
308
  logger;
307
309
  sessionRules = [];
308
310
  _lastBlockingPromptHash = null;
311
+ // Stall detection
312
+ _stallTimer = null;
313
+ _stallTimeoutMs;
314
+ _stallDetectionEnabled;
315
+ _lastStallHash = null;
316
+ _stallStartedAt = null;
309
317
  id;
310
318
  config;
311
319
  get status() {
@@ -387,6 +395,135 @@ var PTYSession = class extends import_events.EventEmitter {
387
395
  this.logger.debug({ sessionId: this.id }, "Cleared auto-response rules");
388
396
  }
389
397
  // ─────────────────────────────────────────────────────────────────────────────
398
+ // Stall Detection
399
+ // ─────────────────────────────────────────────────────────────────────────────
400
+ /**
401
+ * Start or reset the stall detection timer.
402
+ * Only active when status is "busy" and stall detection is enabled.
403
+ */
404
+ resetStallTimer() {
405
+ this.clearStallTimer();
406
+ if (!this._stallDetectionEnabled || this._status !== "busy") {
407
+ return;
408
+ }
409
+ this._stallStartedAt = Date.now();
410
+ this._lastStallHash = null;
411
+ this._stallTimer = setTimeout(() => {
412
+ this.onStallTimerFired();
413
+ }, this._stallTimeoutMs);
414
+ }
415
+ /**
416
+ * Clear the stall detection timer.
417
+ */
418
+ clearStallTimer() {
419
+ if (this._stallTimer) {
420
+ clearTimeout(this._stallTimer);
421
+ this._stallTimer = null;
422
+ }
423
+ this._stallStartedAt = null;
424
+ }
425
+ /**
426
+ * Called when the stall timer fires (no output for stallTimeoutMs).
427
+ */
428
+ onStallTimerFired() {
429
+ if (this._status !== "busy") {
430
+ return;
431
+ }
432
+ const tail = this.outputBuffer.slice(-500);
433
+ const hash = this.simpleHash(tail);
434
+ if (hash === this._lastStallHash) {
435
+ this._stallTimer = setTimeout(() => this.onStallTimerFired(), this._stallTimeoutMs);
436
+ return;
437
+ }
438
+ this._lastStallHash = hash;
439
+ const recentRaw = this.outputBuffer.slice(-2e3);
440
+ const recentOutput = this.stripAnsiForStall(recentRaw);
441
+ const stallDurationMs = this._stallStartedAt ? Date.now() - this._stallStartedAt : this._stallTimeoutMs;
442
+ this.logger.debug(
443
+ { sessionId: this.id, stallDurationMs, bufferTailLength: tail.length },
444
+ "Stall detected"
445
+ );
446
+ this.emit("stall_detected", recentOutput, stallDurationMs);
447
+ this._stallTimer = setTimeout(() => this.onStallTimerFired(), this._stallTimeoutMs);
448
+ }
449
+ /**
450
+ * Promise-based delay helper.
451
+ */
452
+ delay(ms) {
453
+ return new Promise((resolve) => setTimeout(resolve, ms));
454
+ }
455
+ /**
456
+ * Simple string hash for deduplication.
457
+ */
458
+ simpleHash(str) {
459
+ let hash = 0;
460
+ for (let i = 0; i < str.length; i++) {
461
+ const char = str.charCodeAt(i);
462
+ hash = (hash << 5) - hash + char;
463
+ hash |= 0;
464
+ }
465
+ return hash.toString(36);
466
+ }
467
+ /**
468
+ * Strip ANSI codes for stall detection output.
469
+ * Replaces cursor-forward sequences with spaces first.
470
+ */
471
+ stripAnsiForStall(str) {
472
+ const withSpaces = str.replace(/\x1b\[\d*C/g, " ");
473
+ return withSpaces.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
474
+ }
475
+ /**
476
+ * Handle external stall classification result.
477
+ * Called by the manager after onStallClassify resolves.
478
+ */
479
+ handleStallClassification(classification) {
480
+ if (this._status !== "busy") {
481
+ return;
482
+ }
483
+ if (!classification || classification.state === "still_working") {
484
+ this.resetStallTimer();
485
+ return;
486
+ }
487
+ switch (classification.state) {
488
+ case "waiting_for_input": {
489
+ const promptInfo = {
490
+ type: "stall_classified",
491
+ prompt: classification.prompt,
492
+ canAutoRespond: !!classification.suggestedResponse
493
+ };
494
+ if (classification.suggestedResponse) {
495
+ this.logger.info(
496
+ { sessionId: this.id, response: classification.suggestedResponse },
497
+ "Auto-responding to stall-classified prompt"
498
+ );
499
+ const resp = classification.suggestedResponse;
500
+ if (resp.startsWith("keys:")) {
501
+ const keys = resp.slice(5).split(",").map((k) => k.trim());
502
+ this.sendKeySequence(keys);
503
+ } else {
504
+ this.writeRaw(resp + "\r");
505
+ }
506
+ this.emit("blocking_prompt", promptInfo, true);
507
+ } else {
508
+ this.emit("blocking_prompt", promptInfo, false);
509
+ }
510
+ break;
511
+ }
512
+ case "task_complete":
513
+ this._status = "ready";
514
+ this._lastBlockingPromptHash = null;
515
+ this.outputBuffer = "";
516
+ this.clearStallTimer();
517
+ this.emit("ready");
518
+ this.logger.info({ sessionId: this.id }, "Stall classified as task_complete, transitioning to ready");
519
+ break;
520
+ case "error":
521
+ this.clearStallTimer();
522
+ this.emit("error", new Error(classification.prompt || "Stall classified as error"));
523
+ break;
524
+ }
525
+ }
526
+ // ─────────────────────────────────────────────────────────────────────────────
390
527
  // Lifecycle
391
528
  // ─────────────────────────────────────────────────────────────────────────────
392
529
  /**
@@ -444,11 +581,15 @@ var PTYSession = class extends import_events.EventEmitter {
444
581
  this.ptyProcess.onData((data) => {
445
582
  this._lastActivityAt = /* @__PURE__ */ new Date();
446
583
  this.outputBuffer += data;
584
+ if (this._status === "busy") {
585
+ this.resetStallTimer();
586
+ }
447
587
  this.emit("output", data);
448
588
  if ((this._status === "starting" || this._status === "authenticating") && this.adapter.detectReady(this.outputBuffer)) {
449
589
  this._status = "ready";
450
590
  this._lastBlockingPromptHash = null;
451
591
  this.outputBuffer = "";
592
+ this.clearStallTimer();
452
593
  this.emit("ready");
453
594
  this.logger.info({ sessionId: this.id }, "Session ready");
454
595
  return;
@@ -461,6 +602,7 @@ var PTYSession = class extends import_events.EventEmitter {
461
602
  const loginDetection = this.adapter.detectLogin(this.outputBuffer);
462
603
  if (loginDetection.required && this._status !== "authenticating") {
463
604
  this._status = "authenticating";
605
+ this.clearStallTimer();
464
606
  this.emit("login_required", loginDetection.instructions, loginDetection.url);
465
607
  this.logger.warn(
466
608
  { sessionId: this.id, loginType: loginDetection.type },
@@ -472,6 +614,7 @@ var PTYSession = class extends import_events.EventEmitter {
472
614
  const exitDetection = this.adapter.detectExit(this.outputBuffer);
473
615
  if (exitDetection.exited) {
474
616
  this._status = "stopped";
617
+ this.clearStallTimer();
475
618
  this.emit("exit", exitDetection.code || 0);
476
619
  }
477
620
  if (this._status !== "starting" && this._status !== "authenticating") {
@@ -480,6 +623,7 @@ var PTYSession = class extends import_events.EventEmitter {
480
623
  });
481
624
  this.ptyProcess.onExit(({ exitCode, signal }) => {
482
625
  this._status = "stopped";
626
+ this.clearStallTimer();
483
627
  this.logger.info(
484
628
  { sessionId: this.id, exitCode, signal },
485
629
  "PTY session exited"
@@ -521,7 +665,13 @@ var PTYSession = class extends import_events.EventEmitter {
521
665
  },
522
666
  "Auto-responding to blocking prompt"
523
667
  );
524
- this.writeRaw(detection.suggestedResponse + "\r");
668
+ const resp = detection.suggestedResponse;
669
+ if (resp.startsWith("keys:")) {
670
+ const keys = resp.slice(5).split(",").map((k) => k.trim());
671
+ this.sendKeySequence(keys);
672
+ } else {
673
+ this.writeRaw(resp + "\r");
674
+ }
525
675
  this._lastBlockingPromptHash = null;
526
676
  this.emit("blocking_prompt", promptInfo, true);
527
677
  return true;
@@ -555,8 +705,11 @@ var PTYSession = class extends import_events.EventEmitter {
555
705
  if (allRules.length === 0) {
556
706
  return false;
557
707
  }
708
+ let stripped = this.stripAnsiForStall(this.outputBuffer);
709
+ stripped = stripped.replace(/[│╭╰╮╯─═╌║╔╗╚╝╠╣╦╩╬┌┐└┘├┤┬┴┼●○❯❮▶◀⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⏺←→↑↓]/g, " ");
710
+ stripped = stripped.replace(/ {2,}/g, " ");
558
711
  for (const rule of allRules) {
559
- if (rule.pattern.test(this.outputBuffer)) {
712
+ if (rule.pattern.test(stripped)) {
560
713
  const safe = rule.safe !== false;
561
714
  const isSessionRule = this.sessionRules.includes(rule);
562
715
  if (safe) {
@@ -570,7 +723,15 @@ var PTYSession = class extends import_events.EventEmitter {
570
723
  },
571
724
  "Applying auto-response rule"
572
725
  );
573
- this.writeRaw(rule.response + "\r");
726
+ const useKeys = rule.keys && rule.keys.length > 0;
727
+ const isTuiDefault = !rule.responseType && !rule.keys && this.adapter.usesTuiMenus;
728
+ if (useKeys) {
729
+ this.sendKeySequence(rule.keys);
730
+ } else if (isTuiDefault) {
731
+ this.sendKeys("enter");
732
+ } else {
733
+ this.writeRaw(rule.response + "\r");
734
+ }
574
735
  this.outputBuffer = this.outputBuffer.replace(rule.pattern, "");
575
736
  const promptInfo = {
576
737
  type: rule.type,
@@ -647,6 +808,7 @@ var PTYSession = class extends import_events.EventEmitter {
647
808
  */
648
809
  send(message) {
649
810
  this._status = "busy";
811
+ this.resetStallTimer();
650
812
  const msg = {
651
813
  id: `${this.id}-msg-${++this.messageCounter}`,
652
814
  sessionId: this.id,
@@ -699,6 +861,26 @@ var PTYSession = class extends import_events.EventEmitter {
699
861
  }
700
862
  }
701
863
  }
864
+ /**
865
+ * Select a TUI menu option by index (0-based).
866
+ * Sends Down arrow `optionIndex` times, then Enter, with 50ms delays.
867
+ */
868
+ async selectMenuOption(optionIndex) {
869
+ for (let i = 0; i < optionIndex; i++) {
870
+ this.sendKeys("down");
871
+ await this.delay(50);
872
+ }
873
+ this.sendKeys("enter");
874
+ }
875
+ /**
876
+ * Send a sequence of keys with staggered timing.
877
+ * Each key is sent 50ms apart using setTimeout to keep the caller synchronous.
878
+ */
879
+ sendKeySequence(keys) {
880
+ keys.forEach((key, i) => {
881
+ setTimeout(() => this.sendKeys(key), i * 50);
882
+ });
883
+ }
702
884
  /**
703
885
  * Paste text using bracketed paste mode
704
886
  *
@@ -735,6 +917,7 @@ var PTYSession = class extends import_events.EventEmitter {
735
917
  kill(signal) {
736
918
  if (this.ptyProcess) {
737
919
  this._status = "stopping";
920
+ this.clearStallTimer();
738
921
  this.ptyProcess.kill(signal);
739
922
  this.logger.info({ sessionId: this.id, signal }, "Killing PTY session");
740
923
  }
@@ -804,11 +987,18 @@ var PTYManager = class extends import_events2.EventEmitter {
804
987
  maxLogLines;
805
988
  logger;
806
989
  adapters;
990
+ // Stall detection config
991
+ _stallDetectionEnabled;
992
+ _stallTimeoutMs;
993
+ _onStallClassify;
807
994
  constructor(config = {}) {
808
995
  super();
809
996
  this.adapters = new AdapterRegistry();
810
997
  this.logger = config.logger || consoleLogger2;
811
998
  this.maxLogLines = config.maxLogLines || 1e3;
999
+ this._stallDetectionEnabled = config.stallDetectionEnabled ?? false;
1000
+ this._stallTimeoutMs = config.stallTimeoutMs ?? 8e3;
1001
+ this._onStallClassify = config.onStallClassify;
812
1002
  }
813
1003
  /**
814
1004
  * Register a CLI adapter
@@ -831,7 +1021,13 @@ var PTYManager = class extends import_events2.EventEmitter {
831
1021
  { type: config.type, name: config.name },
832
1022
  "Spawning session"
833
1023
  );
834
- const session = new PTYSession(adapter, config, this.logger);
1024
+ const session = new PTYSession(
1025
+ adapter,
1026
+ config,
1027
+ this.logger,
1028
+ this._stallDetectionEnabled,
1029
+ this._stallTimeoutMs
1030
+ );
835
1031
  this.setupSessionEvents(session);
836
1032
  this.sessions.set(session.id, session);
837
1033
  this.outputLogs.set(session.id, []);
@@ -875,6 +1071,21 @@ var PTYManager = class extends import_events2.EventEmitter {
875
1071
  session.on("error", (error) => {
876
1072
  this.emit("session_error", session.toHandle(), error.message);
877
1073
  });
1074
+ session.on("stall_detected", (recentOutput, stallDurationMs) => {
1075
+ const handle = session.toHandle();
1076
+ this.emit("stall_detected", handle, recentOutput, stallDurationMs);
1077
+ if (this._onStallClassify) {
1078
+ this._onStallClassify(session.id, recentOutput, stallDurationMs).then((classification) => {
1079
+ session.handleStallClassification(classification);
1080
+ }).catch((err) => {
1081
+ this.logger.error(
1082
+ { sessionId: session.id, error: err },
1083
+ "Stall classification callback failed"
1084
+ );
1085
+ session.handleStallClassification(null);
1086
+ });
1087
+ }
1088
+ });
878
1089
  }
879
1090
  /**
880
1091
  * Stop a session
@@ -1046,6 +1257,22 @@ var PTYManager = class extends import_events2.EventEmitter {
1046
1257
  return this.sessions.get(sessionId);
1047
1258
  }
1048
1259
  // ─────────────────────────────────────────────────────────────────────────────
1260
+ // Stall Detection Configuration
1261
+ // ─────────────────────────────────────────────────────────────────────────────
1262
+ /**
1263
+ * Configure stall detection at runtime.
1264
+ * Affects newly spawned sessions only — existing sessions keep their config.
1265
+ */
1266
+ configureStallDetection(enabled, timeoutMs, classify) {
1267
+ this._stallDetectionEnabled = enabled;
1268
+ if (timeoutMs !== void 0) {
1269
+ this._stallTimeoutMs = timeoutMs;
1270
+ }
1271
+ if (classify !== void 0) {
1272
+ this._onStallClassify = classify;
1273
+ }
1274
+ }
1275
+ // ─────────────────────────────────────────────────────────────────────────────
1049
1276
  // Runtime Auto-Response Rules API
1050
1277
  // ─────────────────────────────────────────────────────────────────────────────
1051
1278
  /**
@@ -1234,6 +1461,14 @@ manager.on("question", (handle, question) => {
1234
1461
  question
1235
1462
  });
1236
1463
  });
1464
+ manager.on("stall_detected", (handle, recentOutput, stallDurationMs) => {
1465
+ emit({
1466
+ event: "stall_detected",
1467
+ id: handle.id,
1468
+ recentOutput,
1469
+ stallDurationMs
1470
+ });
1471
+ });
1237
1472
  async function handleSpawn(id, config) {
1238
1473
  try {
1239
1474
  if (manager.has(id)) {
@@ -1363,6 +1598,8 @@ function deserializeRule(serialized) {
1363
1598
  pattern: new RegExp(serialized.pattern, serialized.flags || ""),
1364
1599
  type: serialized.type,
1365
1600
  response: serialized.response,
1601
+ responseType: serialized.responseType,
1602
+ keys: serialized.keys,
1366
1603
  description: serialized.description,
1367
1604
  safe: serialized.safe
1368
1605
  };
@@ -1373,6 +1610,8 @@ function serializeRule(rule) {
1373
1610
  flags: rule.pattern.flags || void 0,
1374
1611
  type: rule.type,
1375
1612
  response: rule.response,
1613
+ responseType: rule.responseType,
1614
+ keys: rule.keys,
1376
1615
  description: rule.description,
1377
1616
  safe: rule.safe
1378
1617
  };
@@ -1421,6 +1660,35 @@ function handleClearRules(id) {
1421
1660
  ack("clearRules", id, false, err instanceof Error ? err.message : String(err));
1422
1661
  }
1423
1662
  }
1663
+ function handleConfigureStallDetection(enabled, timeoutMs) {
1664
+ try {
1665
+ manager.configureStallDetection(enabled, timeoutMs);
1666
+ ack("configureStallDetection", void 0, true);
1667
+ } catch (err) {
1668
+ ack("configureStallDetection", void 0, false, err instanceof Error ? err.message : String(err));
1669
+ }
1670
+ }
1671
+ function handleSelectMenuOption(id, optionIndex) {
1672
+ const session = manager.getSession(id);
1673
+ if (!session) {
1674
+ ack("selectMenuOption", id, false, `Session ${id} not found`);
1675
+ return;
1676
+ }
1677
+ session.selectMenuOption(optionIndex).then(() => ack("selectMenuOption", id, true)).catch((err) => ack("selectMenuOption", id, false, err instanceof Error ? err.message : String(err)));
1678
+ }
1679
+ function handleClassifyStallResult(id, classification) {
1680
+ try {
1681
+ const session = manager.getSession(id);
1682
+ if (!session) {
1683
+ ack("classifyStallResult", id, false, `Session ${id} not found`);
1684
+ return;
1685
+ }
1686
+ session.handleStallClassification(classification);
1687
+ ack("classifyStallResult", id, true);
1688
+ } catch (err) {
1689
+ ack("classifyStallResult", id, false, err instanceof Error ? err.message : String(err));
1690
+ }
1691
+ }
1424
1692
  function processCommand(line) {
1425
1693
  let command;
1426
1694
  try {
@@ -1520,6 +1788,23 @@ function processCommand(line) {
1520
1788
  }
1521
1789
  handleClearRules(command.id);
1522
1790
  break;
1791
+ case "selectMenuOption":
1792
+ if (!command.id || command.optionIndex === void 0) {
1793
+ ack("selectMenuOption", command.id, false, "Missing id or optionIndex");
1794
+ return;
1795
+ }
1796
+ handleSelectMenuOption(command.id, command.optionIndex);
1797
+ break;
1798
+ case "configureStallDetection":
1799
+ handleConfigureStallDetection(command.enabled ?? false, command.timeoutMs);
1800
+ break;
1801
+ case "classifyStallResult":
1802
+ if (!command.id) {
1803
+ ack("classifyStallResult", command.id, false, "Missing id");
1804
+ return;
1805
+ }
1806
+ handleClassifyStallResult(command.id, command.classification ?? null);
1807
+ break;
1523
1808
  default:
1524
1809
  emit({ event: "error", message: `Unknown command: ${command.cmd}` });
1525
1810
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pty-manager",
3
- "version": "1.2.13",
3
+ "version": "1.2.15",
4
4
  "description": "PTY session manager with lifecycle management, pluggable adapters, and blocking prompt detection",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -24,18 +24,6 @@
24
24
  "README.md",
25
25
  "LICENSE"
26
26
  ],
27
- "scripts": {
28
- "postinstall": "node scripts/postinstall.js",
29
- "build": "tsup",
30
- "dev": "tsup --watch",
31
- "test": "vitest",
32
- "test:run": "vitest run",
33
- "test:coverage": "vitest --coverage",
34
- "test:integration": "tsx test-integration.ts",
35
- "type-check": "tsc --noEmit",
36
- "clean": "rm -rf dist",
37
- "prepublishOnly": "pnpm run build"
38
- },
39
27
  "keywords": [
40
28
  "pty",
41
29
  "terminal",
@@ -73,5 +61,16 @@
73
61
  },
74
62
  "engines": {
75
63
  "node": ">=18"
64
+ },
65
+ "scripts": {
66
+ "postinstall": "node scripts/postinstall.js",
67
+ "build": "tsup",
68
+ "dev": "tsup --watch",
69
+ "test": "vitest",
70
+ "test:run": "vitest run",
71
+ "test:coverage": "vitest --coverage",
72
+ "test:integration": "tsx test-integration.ts",
73
+ "type-check": "tsc --noEmit",
74
+ "clean": "rm -rf dist"
76
75
  }
77
- }
76
+ }