@hyperdrive.bot/paseo-server 0.3.11 → 0.3.13

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.
Files changed (68) hide show
  1. package/dist/server/server/agent/providers/claude/transport/pty-query.d.ts +92 -10
  2. package/dist/server/server/agent/providers/claude/transport/pty-query.js +615 -38
  3. package/dist/server/server/relay-transport.js +36 -5
  4. package/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-bridge-9f03d060da06e8838a4e0fdd5c067757.js → desktop-attachment-bridge-e6e0f92f1a2959279a7c3b2b50bf1df3.js} +5 -5
  5. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-e6e0f92f1a2959279a7c3b2b50bf1df3.js.br +0 -0
  6. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-e6e0f92f1a2959279a7c3b2b50bf1df3.js.gz +0 -0
  7. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-e6e0f92f1a2959279a7c3b2b50bf1df3.js.map.br +0 -0
  8. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-e6e0f92f1a2959279a7c3b2b50bf1df3.js.map.gz +0 -0
  9. package/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-store-d0d9dffa612bf12bf551c5e513c28691.js → desktop-attachment-store-d5c93939344c993e52d655ccf8942928.js} +3 -3
  10. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d5c93939344c993e52d655ccf8942928.js.br +0 -0
  11. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d5c93939344c993e52d655ccf8942928.js.gz +0 -0
  12. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d5c93939344c993e52d655ccf8942928.js.map.br +0 -0
  13. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d5c93939344c993e52d655ccf8942928.js.map.gz +0 -0
  14. package/dist/server/web-ui/_expo/static/js/web/{index-5a54792f78b8475572ccfac9808dd926.js → index-7df9481c8716372042011cb97362e31e.js} +167 -166
  15. package/dist/server/web-ui/_expo/static/js/web/index-7df9481c8716372042011cb97362e31e.js.br +0 -0
  16. package/dist/server/web-ui/_expo/static/js/web/index-7df9481c8716372042011cb97362e31e.js.gz +0 -0
  17. package/dist/server/web-ui/_expo/static/js/web/index-7df9481c8716372042011cb97362e31e.js.map.br +0 -0
  18. package/dist/server/web-ui/_expo/static/js/web/{index-5a54792f78b8475572ccfac9808dd926.js.map.gz → index-7df9481c8716372042011cb97362e31e.js.map.gz} +0 -0
  19. package/dist/server/web-ui/_expo/static/js/web/{indexeddb-attachment-store-54584275ea7a32061741c119074ff13f.js → indexeddb-attachment-store-35fb4ddfe925b1fb6b7a6c1953b21621.js} +3 -3
  20. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-35fb4ddfe925b1fb6b7a6c1953b21621.js.br +0 -0
  21. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-35fb4ddfe925b1fb6b7a6c1953b21621.js.gz +0 -0
  22. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-35fb4ddfe925b1fb6b7a6c1953b21621.js.map.br +0 -0
  23. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-35fb4ddfe925b1fb6b7a6c1953b21621.js.map.gz +0 -0
  24. package/dist/server/web-ui/_expo/static/js/web/{native-file-attachment-store-b53f2ba81a9777f6f6d04a98b0a12c33.js → native-file-attachment-store-8602ccac919e2b56f2e9a252dd06483e.js} +5 -5
  25. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8602ccac919e2b56f2e9a252dd06483e.js.br +0 -0
  26. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8602ccac919e2b56f2e9a252dd06483e.js.gz +0 -0
  27. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8602ccac919e2b56f2e9a252dd06483e.js.map.br +0 -0
  28. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8602ccac919e2b56f2e9a252dd06483e.js.map.gz +0 -0
  29. package/dist/server/web-ui/_expo/static/js/web/{profiler-7b2011c26cedb58cc00c02a7c25fc0a0.js → profiler-b3dd30ba0a88aece50e7e403785f34ba.js} +11 -11
  30. package/dist/server/web-ui/_expo/static/js/web/profiler-b3dd30ba0a88aece50e7e403785f34ba.js.br +0 -0
  31. package/dist/server/web-ui/_expo/static/js/web/profiler-b3dd30ba0a88aece50e7e403785f34ba.js.gz +0 -0
  32. package/dist/server/web-ui/_expo/static/js/web/profiler-b3dd30ba0a88aece50e7e403785f34ba.js.map.br +0 -0
  33. package/dist/server/web-ui/_expo/static/js/web/profiler-b3dd30ba0a88aece50e7e403785f34ba.js.map.gz +0 -0
  34. package/dist/server/web-ui/_expo/static/js/web/{startRecording-1b57837c4c358ef963a1392d9d96354b.js → startRecording-fd9fa11e24a3b8d94e5161aae7f42a5d.js} +60 -60
  35. package/dist/server/web-ui/_expo/static/js/web/startRecording-fd9fa11e24a3b8d94e5161aae7f42a5d.js.br +0 -0
  36. package/dist/server/web-ui/_expo/static/js/web/startRecording-fd9fa11e24a3b8d94e5161aae7f42a5d.js.gz +0 -0
  37. package/dist/server/web-ui/_expo/static/js/web/startRecording-fd9fa11e24a3b8d94e5161aae7f42a5d.js.map.br +0 -0
  38. package/dist/server/web-ui/_expo/static/js/web/{startRecording-1b57837c4c358ef963a1392d9d96354b.js.map.gz → startRecording-fd9fa11e24a3b8d94e5161aae7f42a5d.js.map.gz} +0 -0
  39. package/dist/server/web-ui/index.html +1 -1
  40. package/dist/server/web-ui/index.html.br +0 -0
  41. package/dist/server/web-ui/index.html.gz +0 -0
  42. package/package.json +6 -6
  43. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-9f03d060da06e8838a4e0fdd5c067757.js.br +0 -0
  44. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-9f03d060da06e8838a4e0fdd5c067757.js.gz +0 -0
  45. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-9f03d060da06e8838a4e0fdd5c067757.js.map.br +0 -0
  46. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-9f03d060da06e8838a4e0fdd5c067757.js.map.gz +0 -0
  47. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d0d9dffa612bf12bf551c5e513c28691.js.br +0 -0
  48. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d0d9dffa612bf12bf551c5e513c28691.js.gz +0 -0
  49. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d0d9dffa612bf12bf551c5e513c28691.js.map.br +0 -0
  50. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d0d9dffa612bf12bf551c5e513c28691.js.map.gz +0 -0
  51. package/dist/server/web-ui/_expo/static/js/web/index-5a54792f78b8475572ccfac9808dd926.js.br +0 -0
  52. package/dist/server/web-ui/_expo/static/js/web/index-5a54792f78b8475572ccfac9808dd926.js.gz +0 -0
  53. package/dist/server/web-ui/_expo/static/js/web/index-5a54792f78b8475572ccfac9808dd926.js.map.br +0 -0
  54. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-54584275ea7a32061741c119074ff13f.js.br +0 -0
  55. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-54584275ea7a32061741c119074ff13f.js.gz +0 -0
  56. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-54584275ea7a32061741c119074ff13f.js.map.br +0 -0
  57. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-54584275ea7a32061741c119074ff13f.js.map.gz +0 -0
  58. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-b53f2ba81a9777f6f6d04a98b0a12c33.js.br +0 -0
  59. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-b53f2ba81a9777f6f6d04a98b0a12c33.js.gz +0 -0
  60. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-b53f2ba81a9777f6f6d04a98b0a12c33.js.map.br +0 -0
  61. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-b53f2ba81a9777f6f6d04a98b0a12c33.js.map.gz +0 -0
  62. package/dist/server/web-ui/_expo/static/js/web/profiler-7b2011c26cedb58cc00c02a7c25fc0a0.js.br +0 -0
  63. package/dist/server/web-ui/_expo/static/js/web/profiler-7b2011c26cedb58cc00c02a7c25fc0a0.js.gz +0 -0
  64. package/dist/server/web-ui/_expo/static/js/web/profiler-7b2011c26cedb58cc00c02a7c25fc0a0.js.map.br +0 -0
  65. package/dist/server/web-ui/_expo/static/js/web/profiler-7b2011c26cedb58cc00c02a7c25fc0a0.js.map.gz +0 -0
  66. package/dist/server/web-ui/_expo/static/js/web/startRecording-1b57837c4c358ef963a1392d9d96354b.js.br +0 -0
  67. package/dist/server/web-ui/_expo/static/js/web/startRecording-1b57837c4c358ef963a1392d9d96354b.js.gz +0 -0
  68. package/dist/server/web-ui/_expo/static/js/web/startRecording-1b57837c4c358ef963a1392d9d96354b.js.map.br +0 -0
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
+ import * as path from "node:path";
3
4
  import { TranscriptSdkReader } from "./transcript-sdk-reader.js";
4
5
  /**
5
6
  * PtyQuery — a `Query`-shaped adapter over an interactive `claude` PTY.
@@ -23,8 +24,48 @@ import { TranscriptSdkReader } from "./transcript-sdk-reader.js";
23
24
  * of `Query` methods (rewindFiles, readFile, …) is unsupported on the PTY path; the
24
25
  * instance is cast `as unknown as Query` at the seam.
25
26
  */
26
- const READY_FIRST_DATA_SETTLE_MS = 1500;
27
- const READY_MAX_WAIT_MS = 12000;
27
+ /**
28
+ * Readiness is "the terminal stopped talking", not "the terminal said something".
29
+ *
30
+ * This used to schedule a settle 1500ms after EVERY chunk, and because settling is
31
+ * idempotent the first chunk always won: readiness fired 1.5s after the first byte no
32
+ * matter how much boot output was still streaming. The splash banner is a first byte, so
33
+ * on a slow start (seven MCP servers to connect) paseo began typing into a terminal that
34
+ * was not accepting input yet, and the prompt was lost outright. Observed 2026-08-01: a
35
+ * session whose entire recording was the splash line, then 70s of failed Enters.
36
+ *
37
+ * Resetting the timer on each chunk makes it a real debounce, so it settles once output
38
+ * has actually gone quiet. The ceiling is correspondingly generous, because waiting a few
39
+ * extra seconds costs nothing next to losing the message.
40
+ */
41
+ const READY_QUIET_SETTLE_MS = 1500;
42
+ const READY_MAX_WAIT_MS = 60000;
43
+ /**
44
+ * How long the idle prompt must hold before we believe it. The TUI repaints its footer
45
+ * mid-turn, so a single frame without the busy marker is a flicker, not idleness.
46
+ */
47
+ const READY_IDLE_STABLE_MS = 400;
48
+ /**
49
+ * Absolute ceiling on waiting for a busy terminal to go idle. Only reached while the TUI is
50
+ * actively telling us it is working, so it is a backstop against a wedged agent, not a boot
51
+ * budget. Matches the turn-stall backstop deliberately: both answer "this terminal claims to
52
+ * be working but never finished".
53
+ */
54
+ const READY_BUSY_MAX_WAIT_MS = 10 * 60000;
55
+ /** The TUI prints this in its footer for exactly as long as a turn is running. */
56
+ const BUSY_MARKER = "esc to interrupt";
57
+ /**
58
+ * Footer fragments that only render when the input box is accepting typing. Matched against
59
+ * the rolling tail, and only believed when {@link BUSY_MARKER} is absent, because the busy
60
+ * footer contains some of these too (`… · esc to interrupt · ← for agents`).
61
+ */
62
+ const IDLE_PROMPT_MARKERS = [
63
+ "for agents",
64
+ "for shortcuts",
65
+ "bypass permissions",
66
+ "shift+tab to cycle",
67
+ "for commands",
68
+ ];
28
69
  const TURN_END_IDLE_MS = 700;
29
70
  const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"]);
30
71
  /**
@@ -48,6 +89,66 @@ const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop_sequence", "max_tokens"
48
89
  * with `stallTimeoutMs`; `0` disables the backstop entirely.
49
90
  */
50
91
  const TURN_STALL_TIMEOUT_MS = 10 * 60000;
92
+ /**
93
+ * How much terminal text to keep for diagnosis. The TUI is the only place that says what
94
+ * claude is actually doing while it is quiet ("Compacting conversation…", a retry notice,
95
+ * a prompt), and PtyQuery otherwise unsubscribes from it once the readiness gate settles,
96
+ * so a stall investigation has nothing to read.
97
+ *
98
+ * A rolling tail rather than a live log on purpose: the TUI redraws constantly (spinner
99
+ * frames, cursor moves), so streaming it would flood the daemon log for no benefit. This
100
+ * keeps only the last few KB and emits it exactly once, when a turn is declared stalled.
101
+ */
102
+ const TERMINAL_TAIL_MAX_CHARS = 4000;
103
+ /** Chars of the tail to include in the user-facing stall message; the rest goes to the log. */
104
+ const TERMINAL_TAIL_EXCERPT_CHARS = 300;
105
+ /**
106
+ * Telemetry. The PTY path had none: a session that went quiet left no record of what it had
107
+ * been doing, and four distinct failure modes (prompt never submitted, stall after a
108
+ * compaction, never finished booting, genuinely waiting on the model) were indistinguishable
109
+ * from each other and from a slow turn. Each of the three below exists to separate them.
110
+ */
111
+ /** Heartbeat cadence while a turn is outstanding. Cheap: one line per turn per interval. */
112
+ const LIVENESS_HEARTBEAT_MS = 30000;
113
+ /**
114
+ * The claude TUI's post-interrupt modal. A cancelled turn leaves the terminal sitting on
115
+ * `Interrupted · What should Claude do instead?`, and in that state Enter answers the dialog
116
+ * instead of submitting a turn, so every subsequent prompt is swallowed whole and the retry
117
+ * loop in submitTurn cannot win. Observed live 2026-08-01: a turn was cancelled at 16:56:08
118
+ * and the next message failed all five submit attempts 35s later.
119
+ *
120
+ * Matched against the terminal tail rather than guessed at, and acted on only while the
121
+ * sighting is recent, because sending ESC blindly would interrupt a healthy running turn.
122
+ */
123
+ const INTERRUPT_PROMPT_MARKER = "what should claude do instead";
124
+ /** How long a sighting stays actionable. Beyond this, assume the modal is long gone. */
125
+ const INTERRUPT_PROMPT_TTL_MS = 5 * 60000;
126
+ /**
127
+ * Full-session terminal recording, written next to the daemon log under
128
+ * `$PASEO_HOME/pty-recordings/<sessionId>.log`.
129
+ *
130
+ * The 4KB in-memory tail only survives to the next stall and holds seconds of history once
131
+ * the spinner is running; a session that failed at boot produced 579 chars total. A file
132
+ * keeps the whole run, so a failure can be read backwards afterwards instead of inferred.
133
+ * Capped and truncated rather than rotated: this is diagnostic data, the recent past is what
134
+ * matters, and an unbounded file on a long-running agent is its own incident.
135
+ */
136
+ const RECORDING_MAX_BYTES = 4 * 1024 * 1024;
137
+ /** Keep this much when the cap is hit, so a truncation never leaves an empty file. */
138
+ const RECORDING_KEEP_BYTES = 1024 * 1024;
139
+ /**
140
+ * Monotonic milliseconds for every liveness comparison here.
141
+ *
142
+ * Never Date.now(): a stepping wall clock silently breaks all of them. Measured on this
143
+ * machine, the clock oscillated +/-48.6s roughly six times a minute, which made the
144
+ * readiness gate believe its 60s budget had elapsed after ~11s and type the user's prompt
145
+ * into a terminal that was still working, losing the message. The tell was an overshoot of
146
+ * 3641ms on a loop that ticks every 500ms: real elapse cannot overshoot by more than one
147
+ * tick, a clock step can.
148
+ */
149
+ function monotonicNowMs() {
150
+ return performance.now();
151
+ }
51
152
  export class PtyQuery {
52
153
  constructor(opts) {
53
154
  // output queue → async iterator
@@ -55,6 +156,15 @@ export class PtyQuery {
55
156
  this.outResolvers = [];
56
157
  this.done = false;
57
158
  this.offReadyData = null;
159
+ /**
160
+ * Cancels the readiness gate and its timers.
161
+ *
162
+ * Teardown MUST call this. A query closed before the gate settles used to leave its
163
+ * deadline interval running, so a session that attached and detached in the same
164
+ * millisecond still logged "readiness gate hit its timeout" a full minute later, warning
165
+ * about a terminal nobody was waiting on and leaking a timer per closed session.
166
+ */
167
+ this.cancelReadiness = null;
58
168
  // turn-end tracking
59
169
  this.turnEndTimer = null;
60
170
  this.lastStopReason = null;
@@ -63,6 +173,22 @@ export class PtyQuery {
63
173
  this.numTurns = 0;
64
174
  this.stallTimer = null;
65
175
  this.turnInFlight = false;
176
+ // terminal tail (see TERMINAL_TAIL_MAX_CHARS)
177
+ this.terminalTail = "";
178
+ this.offTerminalTap = null;
179
+ // telemetry
180
+ this.startedAt = monotonicNowMs();
181
+ this.recordingPath = null;
182
+ this.recordingBytes = 0;
183
+ this.ptyBytesTotal = 0;
184
+ this.ptyBytesAtLastBeat = 0;
185
+ this.lastMeaningfulLine = "";
186
+ this.lastTranscriptAt = 0;
187
+ this.heartbeatTimer = null;
188
+ this.readySettledBy = null;
189
+ /** Last time the TUI footer said a turn was running. 0 = never seen. */
190
+ this.lastBusyAt = 0;
191
+ this.interruptPromptAt = 0;
66
192
  this.transport = opts.transport;
67
193
  this.input = opts.input;
68
194
  this.sessionId = opts.sessionId;
@@ -73,18 +199,117 @@ export class PtyQuery {
73
199
  transcriptPath: opts.transcriptPath,
74
200
  onMessage: (m) => this.onTranscriptMessage(m),
75
201
  });
202
+ // Keep a rolling tail of terminal output. Registered BEFORE the readiness tap below so
203
+ // that looksIdle() sees the CURRENT chunk: handlers fire in registration order, and
204
+ // with the order reversed an idle prompt in the final frame was never noticed, because
205
+ // readiness inspected a tail that did not yet contain it. This one lives for the whole
206
+ // session so a stall has something to explain itself with. Chunks arrive already redacted (PtyTransport.onData
207
+ // runs redactChunk), so nothing further is needed here.
208
+ this.offTerminalTap = this.transport.onData((chunk) => this.appendTerminalTail(chunk));
209
+ // The executor stays trivial and settlement lives outside it, so there is exactly one
210
+ // resolve() call in the file. Timers and handlers below all funnel through settle(),
211
+ // which is guarded by `settled`; expressing that inside the executor tripped
212
+ // promise/no-multiple-resolved, and the rule is right that multiple resolve paths in an
213
+ // executor are hard to audit.
214
+ let resolveReady = () => { };
76
215
  this.readyPromise = new Promise((resolve) => {
77
- let settled = false;
78
- const finish = () => {
79
- if (settled)
80
- return;
81
- settled = true;
82
- this.offReadyData?.();
83
- resolve();
84
- };
85
- this.offReadyData = this.transport.onData(() => setTimeout(finish, READY_FIRST_DATA_SETTLE_MS));
86
- setTimeout(finish, READY_MAX_WAIT_MS);
216
+ resolveReady = resolve;
87
217
  });
218
+ let settled = false;
219
+ // Single resolve site. Both the normal outcomes and teardown funnel through here, so
220
+ // the promise provably resolves once no matter which timer or path gets there first.
221
+ const settle = (by) => {
222
+ if (settled)
223
+ return;
224
+ settled = true;
225
+ this.readySettledBy = by === "cancelled" ? null : by;
226
+ this.offReadyData?.();
227
+ clearTimeout(quietTimer ?? undefined);
228
+ clearTimeout(idleTimer ?? undefined);
229
+ clearInterval(deadlineTimer);
230
+ // A torn-down query never had a readiness problem, so it reports nothing.
231
+ if (by !== "cancelled") {
232
+ // Settling on the timeout means claude never produced output that looked like a
233
+ // booted TUI, so the prompt about to be typed may go into a terminal that is not
234
+ // listening yet and be lost outright. Loud on purpose.
235
+ const level = by === "timeout" ? "warn" : "debug";
236
+ this.logger[level]({
237
+ sessionId: this.sessionId,
238
+ settledBy: by,
239
+ elapsedMs: monotonicNowMs() - this.startedAt,
240
+ ptyBytes: this.ptyBytesTotal,
241
+ sawBusyMarker: this.lastBusyAt > 0,
242
+ lastLine: this.lastMeaningfulLine.slice(-120),
243
+ }, by === "timeout"
244
+ ? "PtyQuery: readiness gate hit its timeout, claude may not be accepting input yet"
245
+ : "PtyQuery: readiness settled on terminal output");
246
+ }
247
+ resolveReady();
248
+ };
249
+ const finish = settle;
250
+ this.cancelReadiness = () => settle("cancelled");
251
+ let quietTimer = null;
252
+ let idleTimer = null;
253
+ this.offReadyData = this.transport.onData(() => {
254
+ // Two independent paths to ready, because a PtyQuery attaches to a LONG-LIVED
255
+ // terminal, not only to a fresh boot (observed 2026-08-01: nine attach/detach
256
+ // cycles on one session's PTY).
257
+ //
258
+ // - quiet: nothing has been painted for a while. Correct for a cold boot.
259
+ // - idle-prompt: the footer positively shows an input box and no busy marker.
260
+ //
261
+ // Quiet ALONE is wrong on re-attach: if the agent is mid-turn the spinner repaints
262
+ // at a couple of KB/s forever, so "went quiet" can never fire and the gate used to
263
+ // fall through to its timeout and type into a working terminal. That was the real
264
+ // cause of the lost prompts, and it is why absence-of-output cannot be the only
265
+ // signal here.
266
+ if (quietTimer)
267
+ clearTimeout(quietTimer);
268
+ quietTimer = setTimeout(() => finish("quiet"), READY_QUIET_SETTLE_MS);
269
+ quietTimer.unref?.();
270
+ if (this.looksIdle()) {
271
+ // Hold the idle read briefly: the footer repaints mid-turn and a single frame
272
+ // without the busy marker is a flicker, not idleness.
273
+ if (!idleTimer) {
274
+ idleTimer = setTimeout(() => {
275
+ if (this.looksIdle())
276
+ finish("idle-prompt");
277
+ idleTimer = null;
278
+ }, READY_IDLE_STABLE_MS);
279
+ idleTimer.unref?.();
280
+ }
281
+ }
282
+ else if (idleTimer) {
283
+ clearTimeout(idleTimer);
284
+ idleTimer = null;
285
+ }
286
+ });
287
+ // The deadline is conditional on evidence, not a flat budget. Only a terminal showing
288
+ // NO sign of life gets cut off at the short budget.
289
+ //
290
+ // Liveness is "has this PTY ever produced output", NOT "did the busy footer repaint
291
+ // recently". An earlier version keyed on the footer and timed out anyway on a session
292
+ // that was plainly working (60017ms, 78991 bytes, sawBusyMarker true but outside the
293
+ // window). Measured across captured sessions, the gap between footer repaints has a
294
+ // median of 37.7s and a p90 of 483s, so no recency window is defensible.
295
+ //
296
+ // Reaching this branch at all already proves the terminal is streaming: the quiet
297
+ // timer above resolves readiness after 1.5s of silence, so if we are still un-ready
298
+ // the quiet timer must be getting reset continuously. A genuinely dead terminal never
299
+ // fires onData, never arms the quiet timer, and never records a byte, which is exactly
300
+ // the case the short budget exists for.
301
+ const deadlineTimer = setInterval(() => {
302
+ const waited = monotonicNowMs() - this.startedAt;
303
+ if (this.ptyBytesTotal > 0) {
304
+ if (waited >= READY_BUSY_MAX_WAIT_MS)
305
+ finish("timeout");
306
+ return;
307
+ }
308
+ if (waited >= READY_MAX_WAIT_MS)
309
+ finish("timeout");
310
+ }, 500);
311
+ deadlineTimer.unref?.();
312
+ this.openRecording();
88
313
  if (opts.initMessage)
89
314
  this.emit(opts.initMessage);
90
315
  this.reader.start();
@@ -124,12 +349,17 @@ export class PtyQuery {
124
349
  if (this.done)
125
350
  return;
126
351
  this.done = true;
352
+ this.cancelReadiness?.();
127
353
  if (this.turnEndTimer)
128
354
  clearTimeout(this.turnEndTimer);
129
355
  this.turnEndTimer = null;
130
356
  this.turnInFlight = false;
131
357
  this.clearStallTimer();
132
358
  this.offReadyData?.();
359
+ this.offTerminalTap?.();
360
+ this.offTerminalTap = null;
361
+ this.stopHeartbeat();
362
+ this.recordLine("--- PtyQuery detached ---");
133
363
  try {
134
364
  this.reader.stop();
135
365
  }
@@ -152,35 +382,173 @@ export class PtyQuery {
152
382
  const text = extractUserText(userMessage);
153
383
  if (!text)
154
384
  continue;
155
- this.turnStartedAt = Date.now();
385
+ this.turnStartedAt = monotonicNowMs();
386
+ // Clear a modal BEFORE typing, not after: text typed into the interrupt prompt is
387
+ // consumed by the dialog, so recovering at submit time is already too late.
388
+ await this.clearInterruptPrompt();
156
389
  await this.transport.write(text);
157
390
  await delay(200);
158
- await this.submitTurn();
391
+ const started = await this.submitTurn();
392
+ if (!started && this.done)
393
+ break;
394
+ if (!started && !this.looksIdle()) {
395
+ // NOT a lost message. The TUI accepts keystrokes into a queue while a turn is
396
+ // running and only writes the user line when that turn ends, which can be far
397
+ // longer than submitTurn() waits. Observed live: reported failed at 23:04:40,
398
+ // delivered at 23:04:57. Telling the user it was lost is worse than saying
399
+ // nothing, because they resend and the agent gets it twice.
400
+ //
401
+ // A busy terminal has accepted the input, so treat the turn as started and let
402
+ // the stall backstop be the thing that notices if it never runs.
403
+ this.recordLine("--- submit queued behind a running turn ---");
404
+ }
405
+ else if (!started) {
406
+ // Idle terminal, five Enters, still no user line: the prompt genuinely did not
407
+ // land and only the user can resend it.
408
+ this.recordLine("--- submit failed: prompt never became a turn ---");
409
+ this.emit(this.buildSubmitFailedResult());
410
+ continue;
411
+ }
159
412
  // The turn is now claude's to answer; start the liveness clock. submitTurn() has
160
- // already confirmed claude accepted it (the transcript grew), so from here on
161
- // silence means a stall, not a slow start.
413
+ // confirmed claude accepted it (a real user line landed), so from here on silence
414
+ // means a stall, not a slow start.
162
415
  this.turnInFlight = true;
163
416
  this.armStallTimer();
417
+ this.startHeartbeat();
164
418
  }
165
419
  }
166
420
  catch (err) {
167
421
  this.logger.warn({ err }, "PtyQuery input loop ended");
168
422
  }
169
423
  }
424
+ /**
425
+ * The message the user sees when the terminal would not take their prompt.
426
+ *
427
+ * Only reachable while the query is still live (teardown breaks out before this), so it
428
+ * always describes a message that genuinely did not land and that only the user can
429
+ * resend. Shaped as a normal result so it renders in the timeline rather than being
430
+ * buried in the daemon log.
431
+ */
432
+ buildSubmitFailedResult() {
433
+ const base = this.buildResult("submit_failed");
434
+ const excerpt = this.terminalTail.slice(-TERMINAL_TAIL_EXCERPT_CHARS).trim();
435
+ const tailLine = excerpt ? ` Last terminal output: ${JSON.stringify(excerpt)}.` : "";
436
+ return {
437
+ ...base,
438
+ subtype: "error_during_execution",
439
+ is_error: true,
440
+ errors: [
441
+ `Your message did not reach the agent: the terminal would not accept it. ` +
442
+ `Nothing was sent, so please send it again. The session itself is still ` +
443
+ `running.${tailLine}`,
444
+ ],
445
+ };
446
+ }
170
447
  /**
171
448
  * Press Enter to submit the composed prompt, and CONFIRM the turn actually started by
172
- * watching the transcript grow claude appends the user message to <sid>.jsonl the
173
- * instant it accepts a turn. During boot the TUI intermittently swallows the first Enter
174
- * (a "what's new" notice / MCP-auth warning steals focus), so retry until it takes.
449
+ * watching for the USER LINE claude appends to <sid>.jsonl the instant it accepts a turn.
450
+ * During boot the TUI intermittently swallows the first Enter (a "what's new" notice /
451
+ * MCP-auth warning steals focus), so retry until it takes.
452
+ *
453
+ * The confirmation used to be `transcriptSize() > before`, which is any growth at all.
454
+ * That is not specific to the turn starting: claude writes `attachment`, `last-prompt`
455
+ * and other bookkeeping lines on its own schedule, so a swallowed Enter that happened to
456
+ * coincide with unrelated writes read as success. Observed in production 2026-08-01: a
457
+ * session logged 157 consecutive `attachment` lines and NOT ONE `user` line, yet
458
+ * submitTurn returned on the first attempt. The prompt stayed unsent in the TUI composer,
459
+ * paseo set turnInFlight and waited on a turn that had never begun, and the agent showed
460
+ * a spinner until the liveness backstop fired ten minutes later. The retry loop below was
461
+ * always right; the thing it was checking was not.
462
+ */
463
+ /**
464
+ * Dismiss the post-interrupt modal if the terminal is showing one. ESC is only sent on a
465
+ * recent sighting: on an idle prompt it is harmless, but on a live turn it would interrupt
466
+ * real work, so a stale marker must never trigger it.
175
467
  */
468
+ async clearInterruptPrompt() {
469
+ if (!this.interruptPromptAt)
470
+ return;
471
+ if (monotonicNowMs() - this.interruptPromptAt > INTERRUPT_PROMPT_TTL_MS) {
472
+ this.interruptPromptAt = 0;
473
+ return;
474
+ }
475
+ this.logger.warn({ sessionId: this.sessionId, lastLine: this.lastMeaningfulLine.slice(-120) }, "PtyQuery: terminal is showing the post-interrupt prompt, dismissing it before submitting");
476
+ this.recordLine("--- dismissing post-interrupt prompt before submit ---");
477
+ for (let attempt = 0; attempt < 3 && this.interruptPromptAt && !this.done; attempt++) {
478
+ const seenAt = this.interruptPromptAt;
479
+ await this.transport.writeRaw?.("\u001b");
480
+ await delay(250);
481
+ // The marker only refreshes while the modal is still drawing; if it stopped, we are out.
482
+ if (this.interruptPromptAt === seenAt) {
483
+ this.interruptPromptAt = 0;
484
+ return;
485
+ }
486
+ }
487
+ this.interruptPromptAt = 0;
488
+ }
176
489
  async submitTurn() {
177
490
  const before = this.transcriptSize();
178
491
  for (let attempt = 0; attempt < 5 && !this.done; attempt++) {
179
492
  await this.transport.writeRaw?.("\r");
180
- if (await pollUntil(() => this.transcriptSize() > before, 2500, 150))
181
- return;
493
+ if (await pollUntil(() => this.sawUserLineSince(before), 2500, 150))
494
+ return true;
495
+ }
496
+ // Say WHICH it is. A busy terminal has queued the keystrokes and will run them when the
497
+ // current turn ends, so warning identically for both trained every reader (and the
498
+ // alerting built on this line) to treat a normal queue as a lost message.
499
+ const queued = !this.looksIdle();
500
+ this.logger[queued ? "info" : "warn"]({
501
+ sessionId: this.sessionId,
502
+ queuedBehindRunningTurn: queued,
503
+ lastLine: this.lastMeaningfulLine.slice(-120),
504
+ }, queued
505
+ ? "PtyQuery: prompt queued behind a running turn, no user line yet"
506
+ : "PtyQuery: turn did not start after submit retries");
507
+ return false;
508
+ }
509
+ /**
510
+ * True once a real `user` line has been appended past `offset`. Reads only the bytes
511
+ * added since then, so it stays cheap on a transcript that can reach tens of MB.
512
+ * `isMeta` user lines are claude's own bookkeeping and do not mean a turn started.
513
+ */
514
+ sawUserLineSince(offset) {
515
+ let fd = null;
516
+ try {
517
+ const size = this.transcriptSize();
518
+ if (size <= offset)
519
+ return false;
520
+ fd = fs.openSync(this.transcriptPath, "r");
521
+ const length = size - offset;
522
+ const buffer = Buffer.alloc(length);
523
+ fs.readSync(fd, buffer, 0, length, offset);
524
+ for (const line of buffer.toString("utf8").split("\n")) {
525
+ if (!line.trim())
526
+ continue;
527
+ try {
528
+ // A trailing partial line simply fails to parse; the next poll sees it whole.
529
+ const parsed = JSON.parse(line);
530
+ if (parsed?.type === "user" && !parsed.isMeta)
531
+ return true;
532
+ }
533
+ catch {
534
+ /* partial or non-JSON line */
535
+ }
536
+ }
537
+ return false;
538
+ }
539
+ catch {
540
+ return false;
541
+ }
542
+ finally {
543
+ if (fd !== null) {
544
+ try {
545
+ fs.closeSync(fd);
546
+ }
547
+ catch {
548
+ /* ignore */
549
+ }
550
+ }
182
551
  }
183
- this.logger.warn({ sessionId: this.sessionId }, "PtyQuery: turn did not start after submit retries");
184
552
  }
185
553
  transcriptSize() {
186
554
  try {
@@ -200,6 +568,9 @@ export class PtyQuery {
200
568
  this.lastUsage = beta.usage;
201
569
  }
202
570
  // Any transcript activity proves the turn is alive; push the stall deadline out.
571
+ this.lastTranscriptAt = monotonicNowMs();
572
+ // Real turn activity means the modal is gone, whatever the tail still remembers.
573
+ this.interruptPromptAt = 0;
203
574
  this.armStallTimer();
204
575
  this.scheduleTurnEndCheck();
205
576
  }
@@ -218,6 +589,7 @@ export class PtyQuery {
218
589
  // The turn ended on its own; stand the liveness backstop down before emitting.
219
590
  this.turnInFlight = false;
220
591
  this.clearStallTimer();
592
+ this.stopHeartbeat();
221
593
  this.emit(this.buildResult(stop));
222
594
  // reset so a stale terminal reason can't re-fire before the next assistant turn
223
595
  this.lastStopReason = null;
@@ -250,14 +622,165 @@ export class PtyQuery {
250
622
  if (this.done || !this.turnInFlight)
251
623
  return;
252
624
  this.turnInFlight = false;
625
+ this.stopHeartbeat();
253
626
  const silentForMs = this.stallTimeoutMs;
627
+ const terminalTail = this.readTerminalTail();
628
+ this.recordLine(`--- STALLED after ${silentForMs}ms of transcript silence ---`);
254
629
  this.logger.error({
255
630
  sessionId: this.sessionId,
256
631
  transcriptPath: this.transcriptPath,
257
632
  silentForMs,
633
+ // The whole point of the tap: whatever the TUI was showing while it went quiet.
634
+ terminalTail,
258
635
  }, "PtyQuery: turn stalled, no transcript activity within the liveness budget");
259
636
  this.numTurns += 1;
260
- this.emit(this.buildStalledResult(silentForMs));
637
+ this.emit(this.buildStalledResult(silentForMs, terminalTail));
638
+ }
639
+ /**
640
+ * Append terminal output to the rolling tail.
641
+ *
642
+ * Scrubbed and filtered on the way IN, not on the way out, because the TUI's spinner
643
+ * redraws at a few hundred bytes/second: a raw buffer holds only a second or two of
644
+ * history and would evict the one line worth reading. Keeping only lines that carry
645
+ * letters or digits means a message like "Compacting conversation…" survives minutes of
646
+ * animation. Verified against a real stall: the raw tail was 163 chars of pure spinner.
647
+ */
648
+ /**
649
+ * True when the rolling tail's last frame looks like an input box waiting for typing.
650
+ *
651
+ * Deliberately requires the busy marker to be ABSENT rather than trusting the prompt
652
+ * markers alone: the working footer renders as `… · esc to interrupt · ← for agents`, so
653
+ * "for agents" on its own is present in both states and would read a mid-turn terminal
654
+ * as idle.
655
+ */
656
+ looksIdle() {
657
+ const tail = this.terminalTail.slice(-400).toLowerCase();
658
+ if (!tail)
659
+ return false;
660
+ if (tail.includes(BUSY_MARKER))
661
+ return false;
662
+ if (this.lastBusyAt > 0 && monotonicNowMs() - this.lastBusyAt < READY_IDLE_STABLE_MS)
663
+ return false;
664
+ return IDLE_PROMPT_MARKERS.some((m) => tail.includes(m));
665
+ }
666
+ appendTerminalTail(chunk) {
667
+ if (!chunk)
668
+ return;
669
+ this.ptyBytesTotal += chunk.length;
670
+ const text = stripTerminalControl(chunk);
671
+ if (!text)
672
+ return;
673
+ // Read the busy marker off the raw stripped text, NOT the filtered tail below:
674
+ // isProgressNoise() drops a line that is only the footer, which is exactly the frame
675
+ // that proves a turn is running.
676
+ if (text.toLowerCase().includes(BUSY_MARKER))
677
+ this.lastBusyAt = monotonicNowMs();
678
+ for (const rawLine of text.split("\n")) {
679
+ const line = rawLine.trim();
680
+ if (!line || !/[a-z0-9]/i.test(line))
681
+ continue;
682
+ if (isProgressNoise(line))
683
+ continue;
684
+ // de-dupe consecutive identical redraws of the same line
685
+ if (this.terminalTail.endsWith(line + "\n"))
686
+ continue;
687
+ this.terminalTail += line + "\n";
688
+ this.lastMeaningfulLine = line;
689
+ if (line.toLowerCase().includes(INTERRUPT_PROMPT_MARKER)) {
690
+ this.interruptPromptAt = monotonicNowMs();
691
+ }
692
+ this.recordLine(line);
693
+ }
694
+ if (this.terminalTail.length > TERMINAL_TAIL_MAX_CHARS) {
695
+ this.terminalTail = this.terminalTail.slice(-TERMINAL_TAIL_MAX_CHARS);
696
+ }
697
+ }
698
+ // ---- telemetry ------------------------------------------------------------
699
+ /** Open `$PASEO_HOME/pty-recordings/<sessionId>.log`. Best-effort: never fail a session. */
700
+ openRecording() {
701
+ try {
702
+ const home = process.env.PASEO_HOME;
703
+ if (!home)
704
+ return;
705
+ const dir = path.join(home, "pty-recordings");
706
+ fs.mkdirSync(dir, { recursive: true });
707
+ this.recordingPath = path.join(dir, `${this.sessionId}.log`);
708
+ this.recordingBytes = this.safeSize(this.recordingPath);
709
+ this.recordLine(`--- PtyQuery attached, transcript=${this.transcriptPath} ---`);
710
+ this.logger.debug({ sessionId: this.sessionId, recordingPath: this.recordingPath }, "PtyQuery: recording terminal output");
711
+ }
712
+ catch {
713
+ this.recordingPath = null;
714
+ }
715
+ }
716
+ recordLine(line) {
717
+ if (!this.recordingPath)
718
+ return;
719
+ try {
720
+ const entry = `${new Date().toISOString()} ${line}\n`;
721
+ fs.appendFileSync(this.recordingPath, entry);
722
+ this.recordingBytes += entry.length;
723
+ if (this.recordingBytes > RECORDING_MAX_BYTES) {
724
+ // Keep the recent past, drop the rest. Diagnostics, not an audit trail.
725
+ const kept = fs.readFileSync(this.recordingPath).subarray(-RECORDING_KEEP_BYTES);
726
+ fs.writeFileSync(this.recordingPath, kept);
727
+ this.recordingBytes = kept.length;
728
+ }
729
+ }
730
+ catch {
731
+ this.recordingPath = null;
732
+ }
733
+ }
734
+ safeSize(p) {
735
+ try {
736
+ return fs.statSync(p).size;
737
+ }
738
+ catch {
739
+ return 0;
740
+ }
741
+ }
742
+ /**
743
+ * While a turn is outstanding, emit one line every {@link LIVENESS_HEARTBEAT_MS} with
744
+ * enough state to tell the failure modes apart without waiting for the stall budget:
745
+ * terminal bytes since the last beat (0 means the TUI is frozen, not thinking), seconds
746
+ * since the transcript last grew, and the last meaningful line on screen.
747
+ */
748
+ startHeartbeat() {
749
+ this.stopHeartbeat();
750
+ this.ptyBytesAtLastBeat = this.ptyBytesTotal;
751
+ this.heartbeatTimer = setInterval(() => this.beat(), LIVENESS_HEARTBEAT_MS);
752
+ this.heartbeatTimer.unref?.();
753
+ }
754
+ stopHeartbeat() {
755
+ if (this.heartbeatTimer)
756
+ clearInterval(this.heartbeatTimer);
757
+ this.heartbeatTimer = null;
758
+ }
759
+ beat() {
760
+ if (this.done || !this.turnInFlight) {
761
+ this.stopHeartbeat();
762
+ return;
763
+ }
764
+ const ptyBytesSinceLastBeat = this.ptyBytesTotal - this.ptyBytesAtLastBeat;
765
+ this.ptyBytesAtLastBeat = this.ptyBytesTotal;
766
+ this.logger.info({
767
+ sessionId: this.sessionId,
768
+ event: "pty.turn.heartbeat",
769
+ turnAgeMs: this.turnStartedAt ? monotonicNowMs() - this.turnStartedAt : 0,
770
+ ptyBytesSinceLastBeat,
771
+ secondsSinceTranscript: this.lastTranscriptAt
772
+ ? Math.round((monotonicNowMs() - this.lastTranscriptAt) / 1000)
773
+ : null,
774
+ readySettledBy: this.readySettledBy,
775
+ lastLine: this.lastMeaningfulLine.slice(-160),
776
+ }, "PtyQuery: turn in flight");
777
+ }
778
+ /**
779
+ * The tail as readable text: ANSI/OSC sequences dropped and runs of whitespace collapsed,
780
+ * because a raw TUI capture is mostly cursor movement and redraw padding.
781
+ */
782
+ readTerminalTail() {
783
+ return this.terminalTail.trim().slice(-TERMINAL_TAIL_MAX_CHARS);
261
784
  }
262
785
  buildResult(stopReason) {
263
786
  const usage = (this.lastUsage ?? {
@@ -269,7 +792,7 @@ export class PtyQuery {
269
792
  const result = {
270
793
  type: "result",
271
794
  subtype: "success",
272
- duration_ms: this.turnStartedAt ? Date.now() - this.turnStartedAt : 0,
795
+ duration_ms: this.turnStartedAt ? Math.round(monotonicNowMs() - this.turnStartedAt) : 0,
273
796
  duration_api_ms: 0,
274
797
  is_error: false,
275
798
  num_turns: this.numTurns,
@@ -284,18 +807,13 @@ export class PtyQuery {
284
807
  };
285
808
  return result;
286
809
  }
287
- /**
288
- * A non-success `result` for a stalled turn. agent.ts's `appendResultEvents()` turns any
289
- * `subtype !== "success"` into a `turn_failed` carrying `errors.join("\n")`, which is what
290
- * puts a real message in front of the user.
291
- *
292
- * Wording matters: `isAbortError()` suppresses results whose errors match /\baborted\b/i,
293
- * and `buildTurnFailedEvent()` scrapes /\bcode\s+(\d+)\b/i for an exit code. Avoid both so
294
- * this neither gets swallowed nor reports a phantom exit code.
295
- */
296
- buildStalledResult(silentForMs) {
810
+ buildStalledResult(silentForMs, terminalTail) {
297
811
  const minutes = Math.round(silentForMs / 60000);
298
812
  const base = this.buildResult("stall_timeout");
813
+ // The last thing the terminal showed is usually the whole diagnosis ("Compacting
814
+ // conversation…", a retry notice), so lead the message with it when we have one.
815
+ const excerpt = (terminalTail ?? "").slice(-TERMINAL_TAIL_EXCERPT_CHARS).trim();
816
+ const tailLine = excerpt ? ` Last terminal output: ${JSON.stringify(excerpt)}.` : "";
299
817
  return {
300
818
  ...base,
301
819
  subtype: "error_during_execution",
@@ -303,8 +821,7 @@ export class PtyQuery {
303
821
  errors: [
304
822
  `No response from the claude PTY session for ${minutes} min. ` +
305
823
  `The turn was accepted but produced no output, so it was marked failed. ` +
306
- `The underlying session is still running and may recover; check the provider ` +
307
- `endpoint and credentials, then retry.`,
824
+ `The underlying session is still running and may recover.${tailLine}`,
308
825
  ],
309
826
  };
310
827
  }
@@ -349,10 +866,70 @@ function extractUserText(message) {
349
866
  }
350
867
  return "";
351
868
  }
869
+ /**
870
+ * Drop ANSI/OSC sequences and remaining control chars. Scanned by code point rather than a
871
+ * regex so the source carries no control characters and avoids no-control-regex (same
872
+ * approach as sanitizeUploadFilename).
873
+ */
874
+ function stripTerminalControl(src) {
875
+ const ESC = 0x1b;
876
+ const BEL = 0x07;
877
+ const out = [];
878
+ for (let i = 0; i < src.length; i++) {
879
+ const code = src.charCodeAt(i);
880
+ if (code === ESC) {
881
+ const next = src[i + 1];
882
+ if (next === "[") {
883
+ i += 2;
884
+ while (i < src.length && !isCsiFinal(src.charCodeAt(i)))
885
+ i++;
886
+ }
887
+ else if (next === "]") {
888
+ i += 2;
889
+ while (i < src.length && src.charCodeAt(i) !== BEL && src.charCodeAt(i) !== ESC)
890
+ i++;
891
+ if (i < src.length && src.charCodeAt(i) === ESC)
892
+ i++;
893
+ }
894
+ else {
895
+ i += 1;
896
+ }
897
+ continue;
898
+ }
899
+ if (code === 0x0a) {
900
+ out.push("\n");
901
+ continue;
902
+ }
903
+ if (code === 0x09) {
904
+ out.push(" ");
905
+ continue;
906
+ }
907
+ if (code <= 0x1f || code === 0x7f)
908
+ continue;
909
+ out.push(src[i]);
910
+ }
911
+ return out.join("").replace(/[ ]{2,}/g, " ");
912
+ }
913
+ /**
914
+ * True for the TUI's own progress chrome: spinner frames, the elapsed-time counter and
915
+ * token tickers. These redraw constantly and carry no diagnostic value, and keeping them
916
+ * would evict the lines that do.
917
+ */
918
+ function isProgressNoise(line) {
919
+ if (/^[^a-z]*\d+m?\s*\d*s\)?[^a-z]*$/i.test(line))
920
+ return true;
921
+ if (/^\W*(esc to interrupt|ctrl\+[a-z])\W*$/i.test(line))
922
+ return true;
923
+ const letters = (line.match(/[a-z]/gi) ?? []).length;
924
+ return letters < 3;
925
+ }
926
+ function isCsiFinal(code) {
927
+ return code >= 0x40 && code <= 0x7e;
928
+ }
352
929
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
353
930
  async function pollUntil(cond, timeoutMs, intervalMs) {
354
- const deadline = Date.now() + timeoutMs;
355
- while (Date.now() < deadline) {
931
+ const deadline = monotonicNowMs() + timeoutMs;
932
+ while (monotonicNowMs() < deadline) {
356
933
  if (cond())
357
934
  return true;
358
935
  await delay(intervalMs);