@hyperdrive.bot/paseo-server 0.3.15 → 0.3.18

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.
@@ -60,6 +60,16 @@ export declare class PtyQuery implements AsyncGenerator<SDKMessage, void> {
60
60
  private lastBusyAt;
61
61
  /** Last sighting of the large-session resume dialog. 0 = never seen. */
62
62
  private resumeDialogAt;
63
+ /** Set when the CLI rejected the just-submitted command; carries the CLI's own reply. */
64
+ private rejectedCommandNotice;
65
+ /**
66
+ * Raw capture window for echo verification. The rolling tail deliberately drops lines
67
+ * with fewer than three letters as spinner noise, which also swallows the echo of a
68
+ * short message: "> go" never reaches the tail, so verifying against the tail held a
69
+ * two-letter message forever. While this is non-null, appendTerminalTail mirrors every
70
+ * stripped chunk here unfiltered.
71
+ */
72
+ private echoProbe;
63
73
  private interruptPromptAt;
64
74
  constructor(opts: PtyQueryOptions);
65
75
  private emit;
@@ -77,6 +87,8 @@ export declare class PtyQuery implements AsyncGenerator<SDKMessage, void> {
77
87
  * resend. Shaped as a normal result so it renders in the timeline rather than being
78
88
  * buried in the daemon log.
79
89
  */
90
+ /** The CLI refused the command; relay its own reply so the sender can correct and resend. */
91
+ private buildCommandRejectedResult;
80
92
  private buildSubmitFailedResult;
81
93
  /**
82
94
  * Press Enter to submit the composed prompt, and CONFIRM the turn actually started by
@@ -110,6 +122,16 @@ export declare class PtyQuery implements AsyncGenerator<SDKMessage, void> {
110
122
  * Down+Enter is at best a wasted keystroke and at worst an answer to a different menu.
111
123
  */
112
124
  private answerResumeDialog;
125
+ /**
126
+ * Type the prompt and confirm the terminal actually received it, retrying against a
127
+ * deaf terminal with backoff.
128
+ *
129
+ * Returns true once an echo (or a transcript user line) confirms the keystrokes landed;
130
+ * false only after the terminal stayed deaf for the whole {@link READY_BUSY_MAX_WAIT_MS}
131
+ * ceiling. Retyping cannot double-deliver: a deaf terminal demonstrably discards input
132
+ * (zero echo), and every iteration first checks whether a user line landed anyway.
133
+ */
134
+ private typeWithEchoVerify;
113
135
  private submitTurn;
114
136
  /**
115
137
  * True once a real `user` line has been appended past `offset`. Reads only the bytes
@@ -73,6 +73,31 @@ const TURN_END_IDLE_MS = 700;
73
73
  * run it twice.
74
74
  */
75
75
  const SLASH_COMMAND_ECHO_WAIT_MS = 3000;
76
+ /**
77
+ * Echo-verified delivery.
78
+ *
79
+ * A claude TUI that is still parsing a large resumed transcript PAINTS its idle footer
80
+ * before its input loop is live, and in that state it DISCARDS keystrokes: typing produces
81
+ * no echo at all. Observed 2026-08-03 on a 215MB session: attach at 13:03:00, idle footer
82
+ * by 13:03:04, prompt typed plus five Enters, zero echo in the recording, no user line,
83
+ * message lost -- while every successful delivery in the same recordings shows the typed
84
+ * text echoed back. Echo is therefore the one signal that distinguishes "idle" from
85
+ * "idle-looking but deaf", and its absence also proves the keystrokes were discarded,
86
+ * which is exactly what makes retyping safe rather than a duplication risk.
87
+ */
88
+ const ECHO_VERIFY_WINDOW_MS = 1500;
89
+ /**
90
+ * How much of the typed text must be found in the terminal output for it to count as an
91
+ * echo. Byte-counting was not enough: a freshly spawned claude under load keeps painting
92
+ * boot output (splash, MCP connects) after the readiness gate settles, and those bytes
93
+ * passed the old any-bytes check while the input loop was still dead -- three scheduled
94
+ * spawns failed that way in five minutes on a loaded box, 2026-08-03. Boot output never
95
+ * contains the user's message; a real input-box echo always does.
96
+ */
97
+ const ECHO_TEXT_FRAGMENT_CHARS = 24;
98
+ /** Backoff between retype attempts against a deaf terminal. */
99
+ const DEAF_RETRY_INITIAL_MS = 5000;
100
+ const DEAF_RETRY_MAX_MS = 60000;
76
101
  const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
77
102
  /**
78
103
  * Liveness backstop: how long an in-flight turn may append NOTHING to the transcript
@@ -218,6 +243,16 @@ export class PtyQuery {
218
243
  this.lastBusyAt = 0;
219
244
  /** Last sighting of the large-session resume dialog. 0 = never seen. */
220
245
  this.resumeDialogAt = 0;
246
+ /** Set when the CLI rejected the just-submitted command; carries the CLI's own reply. */
247
+ this.rejectedCommandNotice = null;
248
+ /**
249
+ * Raw capture window for echo verification. The rolling tail deliberately drops lines
250
+ * with fewer than three letters as spinner noise, which also swallows the echo of a
251
+ * short message: "> go" never reaches the tail, so verifying against the tail held a
252
+ * two-letter message forever. While this is non-null, appendTerminalTail mirrors every
253
+ * stripped chunk here unfiltered.
254
+ */
255
+ this.echoProbe = null;
221
256
  this.interruptPromptAt = 0;
222
257
  this.transport = opts.transport;
223
258
  this.input = opts.input;
@@ -417,11 +452,32 @@ export class PtyQuery {
417
452
  // consumed by it, so recovering at submit time is already too late.
418
453
  await this.answerResumeDialog();
419
454
  await this.clearInterruptPrompt();
420
- await this.transport.write(text);
421
- await delay(200);
455
+ // Deliberately NOT normalized (e.g. lowercasing an auto-capitalized command
456
+ // token): paseo must never silently rewrite what the user typed. A wrong command is
457
+ // handled by relaying the CLI's own rejection -- including its "did you mean"
458
+ // suggestion -- back to the sender, which corrects every rejection cause honestly
459
+ // instead of hiding one narrow cause invisibly.
460
+ const typed = await this.typeWithEchoVerify(text);
461
+ if (this.done)
462
+ break;
463
+ if (!typed) {
464
+ // The terminal stayed deaf for the whole ceiling: keystrokes discarded, nothing
465
+ // delivered, and only the sender can decide what to do next.
466
+ this.recordLine("--- terminal deaf for the whole budget; message not delivered ---");
467
+ this.emit(this.buildSubmitFailedResult());
468
+ continue;
469
+ }
422
470
  const started = await this.submitTurn(text);
423
471
  if (!started && this.done)
424
472
  break;
473
+ if (!started && this.rejectedCommandNotice) {
474
+ // Not lost, refused: hand the CLI's own answer (which includes its "did you
475
+ // mean" suggestion) to the sender instead of a phantom running turn.
476
+ const notice = this.rejectedCommandNotice;
477
+ this.rejectedCommandNotice = null;
478
+ this.emit(this.buildCommandRejectedResult(notice));
479
+ continue;
480
+ }
425
481
  if (!started && !this.looksIdle()) {
426
482
  // NOT a lost message. The TUI accepts keystrokes into a queue while a turn is
427
483
  // running and only writes the user line when that turn ends, which can be far
@@ -460,6 +516,16 @@ export class PtyQuery {
460
516
  * resend. Shaped as a normal result so it renders in the timeline rather than being
461
517
  * buried in the daemon log.
462
518
  */
519
+ /** The CLI refused the command; relay its own reply so the sender can correct and resend. */
520
+ buildCommandRejectedResult(notice) {
521
+ const base = this.buildResult("submit_failed");
522
+ return {
523
+ ...base,
524
+ subtype: "error_during_execution",
525
+ is_error: true,
526
+ errors: [`The agent's CLI rejected the command: ${notice} Nothing was run.`],
527
+ };
528
+ }
463
529
  buildSubmitFailedResult() {
464
530
  const base = this.buildResult("submit_failed");
465
531
  const excerpt = this.terminalTail.slice(-TERMINAL_TAIL_EXCERPT_CHARS).trim();
@@ -541,6 +607,55 @@ export class PtyQuery {
541
607
  await delay(500);
542
608
  this.resumeDialogAt = 0;
543
609
  }
610
+ /**
611
+ * Type the prompt and confirm the terminal actually received it, retrying against a
612
+ * deaf terminal with backoff.
613
+ *
614
+ * Returns true once an echo (or a transcript user line) confirms the keystrokes landed;
615
+ * false only after the terminal stayed deaf for the whole {@link READY_BUSY_MAX_WAIT_MS}
616
+ * ceiling. Retyping cannot double-deliver: a deaf terminal demonstrably discards input
617
+ * (zero echo), and every iteration first checks whether a user line landed anyway.
618
+ */
619
+ async typeWithEchoVerify(text) {
620
+ const transcriptBefore = this.transcriptSize();
621
+ const deadline = monotonicNowMs() + READY_BUSY_MAX_WAIT_MS;
622
+ let backoff = DEAF_RETRY_INITIAL_MS;
623
+ let attempt = 0;
624
+ while (!this.done && monotonicNowMs() < deadline) {
625
+ attempt += 1;
626
+ // The needle is whitespace-stripped on both sides: the TUI wraps and re-spaces the
627
+ // echoed text freely (recordings show "Replywithexactly:PROBE_OK"), so only the
628
+ // character sequence survives rendering, never the spacing. Verified against a RAW
629
+ // probe buffer, never the rolling tail: the tail's noise filter drops lines with
630
+ // fewer than three letters, which swallowed the echo of a short message outright.
631
+ const needle = text.replace(/\s+/g, "").slice(0, ECHO_TEXT_FRAGMENT_CHARS);
632
+ this.echoProbe = { buf: "" };
633
+ let echoed = false;
634
+ try {
635
+ await this.transport.write(text);
636
+ const probe = this.echoProbe;
637
+ echoed = await pollUntil(() => (needle ? probe.buf.includes(needle) : probe.buf.length > 0), ECHO_VERIFY_WINDOW_MS, 100);
638
+ }
639
+ finally {
640
+ this.echoProbe = null;
641
+ }
642
+ if (echoed) {
643
+ if (attempt > 1) {
644
+ this.recordLine(`--- terminal came back after ${attempt - 1} deaf attempt(s); prompt delivered ---`);
645
+ }
646
+ await delay(200);
647
+ return true;
648
+ }
649
+ // Deaf. The keystrokes were discarded (no echo), so waiting and retyping is safe.
650
+ if (this.sawUserLineSince(transcriptBefore))
651
+ return true;
652
+ this.logger.warn({ sessionId: this.sessionId, attempt, nextRetryMs: backoff }, "PtyQuery: terminal painted but is not accepting input yet; holding the prompt");
653
+ this.recordLine(`--- terminal deaf (attempt ${attempt}); retrying in ${Math.round(backoff / 1000)}s ---`);
654
+ await delay(backoff);
655
+ backoff = Math.min(backoff * 2, DEAF_RETRY_MAX_MS);
656
+ }
657
+ return false;
658
+ }
544
659
  async submitTurn(text) {
545
660
  const before = this.transcriptSize();
546
661
  // A slash command gets EXACTLY ONE Enter. Never retry it.
@@ -567,6 +682,21 @@ export class PtyQuery {
567
682
  const bytesBefore = this.ptyBytesTotal;
568
683
  await this.transport.writeRaw?.("\r");
569
684
  const reacted = await pollUntil(() => this.sawUserLineSince(before) || this.ptyBytesTotal > bytesBefore, SLASH_COMMAND_ECHO_WAIT_MS, 150);
685
+ // The terminal responding is not the same as the command running. An unknown command
686
+ // is answered with `Unknown command: /X. Did you mean /y?` and NOTHING starts -- but
687
+ // that reply previously counted as acceptance, so the app showed a running turn that
688
+ // did not exist and the sender saw dead silence. Observed live: a phone-keyboard
689
+ // auto-capitalized "/WhatsApp-read" rejected twice while the UI said "working".
690
+ // Checked regardless of the byte-poll outcome: the rejection can repaint before the
691
+ // poll's baseline is captured, in which case `reacted` is false but the refusal is
692
+ // already sitting in the tail.
693
+ await delay(400);
694
+ const m = this.terminalTail.slice(-500).match(/Unknown command:[^\n]{0,160}/i);
695
+ if (m) {
696
+ this.rejectedCommandNotice = m[0].trim();
697
+ this.recordLine(`--- CLI rejected the command: ${this.rejectedCommandNotice} ---`);
698
+ return false;
699
+ }
570
700
  if (!reacted) {
571
701
  this.logger.warn({ sessionId: this.sessionId, command: text.trim().split(/\s/, 1)[0] }, "PtyQuery: slash command produced no terminal response; not retrying to avoid double execution");
572
702
  }
@@ -754,6 +884,9 @@ export class PtyQuery {
754
884
  const text = stripTerminalControl(chunk);
755
885
  if (!text)
756
886
  return;
887
+ if (this.echoProbe) {
888
+ this.echoProbe.buf = (this.echoProbe.buf + text.replace(/\s+/g, "")).slice(-2000);
889
+ }
757
890
  // Read the busy marker off the raw stripped text, NOT the filtered tail below:
758
891
  // isProgressNoise() drops a line that is only the footer, which is exactly the frame
759
892
  // that proves a turn is running.