@hyperdrive.bot/paseo-server 0.3.11 → 0.3.12
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 +92 -10
- package/dist/server/server/agent/providers/claude/transport/pty-query.js +615 -38
- package/dist/server/server/relay-transport.js +36 -5
- package/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-bridge-9f03d060da06e8838a4e0fdd5c067757.js → desktop-attachment-bridge-e6e0f92f1a2959279a7c3b2b50bf1df3.js} +5 -5
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-e6e0f92f1a2959279a7c3b2b50bf1df3.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-e6e0f92f1a2959279a7c3b2b50bf1df3.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-e6e0f92f1a2959279a7c3b2b50bf1df3.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-e6e0f92f1a2959279a7c3b2b50bf1df3.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-store-d0d9dffa612bf12bf551c5e513c28691.js → desktop-attachment-store-d5c93939344c993e52d655ccf8942928.js} +3 -3
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d5c93939344c993e52d655ccf8942928.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d5c93939344c993e52d655ccf8942928.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d5c93939344c993e52d655ccf8942928.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d5c93939344c993e52d655ccf8942928.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-5a54792f78b8475572ccfac9808dd926.js → index-853ef1a7fc9c9b7a39577b79564e0896.js} +164 -163
- package/dist/server/web-ui/_expo/static/js/web/index-853ef1a7fc9c9b7a39577b79564e0896.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-853ef1a7fc9c9b7a39577b79564e0896.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-853ef1a7fc9c9b7a39577b79564e0896.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-5a54792f78b8475572ccfac9808dd926.js.map.gz → index-853ef1a7fc9c9b7a39577b79564e0896.js.map.gz} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{indexeddb-attachment-store-54584275ea7a32061741c119074ff13f.js → indexeddb-attachment-store-35fb4ddfe925b1fb6b7a6c1953b21621.js} +3 -3
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-35fb4ddfe925b1fb6b7a6c1953b21621.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-35fb4ddfe925b1fb6b7a6c1953b21621.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-35fb4ddfe925b1fb6b7a6c1953b21621.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-35fb4ddfe925b1fb6b7a6c1953b21621.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{native-file-attachment-store-b53f2ba81a9777f6f6d04a98b0a12c33.js → native-file-attachment-store-8602ccac919e2b56f2e9a252dd06483e.js} +5 -5
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8602ccac919e2b56f2e9a252dd06483e.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8602ccac919e2b56f2e9a252dd06483e.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8602ccac919e2b56f2e9a252dd06483e.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8602ccac919e2b56f2e9a252dd06483e.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{profiler-7b2011c26cedb58cc00c02a7c25fc0a0.js → profiler-b3dd30ba0a88aece50e7e403785f34ba.js} +11 -11
- package/dist/server/web-ui/_expo/static/js/web/profiler-b3dd30ba0a88aece50e7e403785f34ba.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-b3dd30ba0a88aece50e7e403785f34ba.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-b3dd30ba0a88aece50e7e403785f34ba.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-b3dd30ba0a88aece50e7e403785f34ba.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{startRecording-1b57837c4c358ef963a1392d9d96354b.js → startRecording-fd9fa11e24a3b8d94e5161aae7f42a5d.js} +60 -60
- package/dist/server/web-ui/_expo/static/js/web/startRecording-fd9fa11e24a3b8d94e5161aae7f42a5d.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/startRecording-fd9fa11e24a3b8d94e5161aae7f42a5d.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/startRecording-fd9fa11e24a3b8d94e5161aae7f42a5d.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{startRecording-1b57837c4c358ef963a1392d9d96354b.js.map.gz → startRecording-fd9fa11e24a3b8d94e5161aae7f42a5d.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/desktop-attachment-bridge-9f03d060da06e8838a4e0fdd5c067757.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-9f03d060da06e8838a4e0fdd5c067757.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-9f03d060da06e8838a4e0fdd5c067757.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-9f03d060da06e8838a4e0fdd5c067757.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d0d9dffa612bf12bf551c5e513c28691.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d0d9dffa612bf12bf551c5e513c28691.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d0d9dffa612bf12bf551c5e513c28691.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-d0d9dffa612bf12bf551c5e513c28691.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-5a54792f78b8475572ccfac9808dd926.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-5a54792f78b8475572ccfac9808dd926.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-5a54792f78b8475572ccfac9808dd926.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-54584275ea7a32061741c119074ff13f.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-54584275ea7a32061741c119074ff13f.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-54584275ea7a32061741c119074ff13f.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-54584275ea7a32061741c119074ff13f.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-b53f2ba81a9777f6f6d04a98b0a12c33.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-b53f2ba81a9777f6f6d04a98b0a12c33.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-b53f2ba81a9777f6f6d04a98b0a12c33.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-b53f2ba81a9777f6f6d04a98b0a12c33.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-7b2011c26cedb58cc00c02a7c25fc0a0.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-7b2011c26cedb58cc00c02a7c25fc0a0.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-7b2011c26cedb58cc00c02a7c25fc0a0.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-7b2011c26cedb58cc00c02a7c25fc0a0.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/startRecording-1b57837c4c358ef963a1392d9d96354b.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/startRecording-1b57837c4c358ef963a1392d9d96354b.js.gz +0 -0
- 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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
161
|
-
//
|
|
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
|
|
173
|
-
*
|
|
174
|
-
*
|
|
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.
|
|
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 ?
|
|
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
|
|
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 =
|
|
355
|
-
while (
|
|
931
|
+
const deadline = monotonicNowMs() + timeoutMs;
|
|
932
|
+
while (monotonicNowMs() < deadline) {
|
|
356
933
|
if (cond())
|
|
357
934
|
return true;
|
|
358
935
|
await delay(intervalMs);
|