@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.
- package/dist/server/server/agent/providers/claude/transport/pty-query.d.ts +22 -0
- package/dist/server/server/agent/providers/claude/transport/pty-query.js +135 -2
- package/dist/server/web-ui/_expo/static/js/web/{index-638e4dd51cc8b1802850f1fd8ad63b97.js → index-7ee29557905442c87b453da378ef2286.js} +7 -7
- package/dist/server/web-ui/_expo/static/js/web/index-7ee29557905442c87b453da378ef2286.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-7ee29557905442c87b453da378ef2286.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-638e4dd51cc8b1802850f1fd8ad63b97.js.map.br → index-7ee29557905442c87b453da378ef2286.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-638e4dd51cc8b1802850f1fd8ad63b97.js.map.gz → index-7ee29557905442c87b453da378ef2286.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/package.json +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-638e4dd51cc8b1802850f1fd8ad63b97.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-638e4dd51cc8b1802850f1fd8ad63b97.js.gz +0 -0
|
@@ -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
|
-
|
|
421
|
-
|
|
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.
|