@hyperdrive.bot/paseo-server 0.3.38 → 0.3.40
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/README.md +3 -3
- package/dist/server/server/agent/providers/claude/agent.d.ts +10 -0
- package/dist/server/server/agent/providers/claude/agent.js +17 -0
- package/dist/server/server/agent/providers/claude/pty-session-launcher.d.ts +2 -0
- package/dist/server/server/agent/providers/claude/pty-session-launcher.js +1 -0
- package/dist/server/server/agent/providers/claude/transport/pty-query.d.ts +59 -1
- package/dist/server/server/agent/providers/claude/transport/pty-query.js +218 -11
- package/dist/server/server/agent/providers/claude/transport/pty.d.ts +32 -0
- package/dist/server/server/agent/providers/claude/transport/pty.js +69 -5
- package/dist/server/server/agent/providers/claude/transport/sdk.d.ts +2 -0
- package/dist/server/server/agent/providers/claude/transport/sdk.js +4 -0
- package/dist/server/server/agent/providers/claude/transport/types.d.ts +8 -0
- package/dist/server/server/session/workspace-provisioning/workspace-provisioning-service.d.ts +13 -1
- package/dist/server/server/session/workspace-provisioning/workspace-provisioning-service.js +27 -3
- package/dist/server/server/session.js +6 -2
- package/dist/server/web-ui/_expo/static/js/web/{index-56443c4ee726ad022938cfa50ba09aee.js → index-73ebfe5c6b82437cad59d50a40d2c8ef.js} +5 -5
- package/dist/server/web-ui/_expo/static/js/web/index-73ebfe5c6b82437cad59d50a40d2c8ef.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-56443c4ee726ad022938cfa50ba09aee.js.gz → index-73ebfe5c6b82437cad59d50a40d2c8ef.js.gz} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-56443c4ee726ad022938cfa50ba09aee.js.map.br → index-73ebfe5c6b82437cad59d50a40d2c8ef.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-56443c4ee726ad022938cfa50ba09aee.js.map.gz → index-73ebfe5c6b82437cad59d50a40d2c8ef.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/package.json +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-56443c4ee726ad022938cfa50ba09aee.js.br +0 -0
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ npm run dev
|
|
|
24
24
|
- **Vite Dev Server** (port 5173) - Hot-reload React UI in development
|
|
25
25
|
- **WebSocket** (`/ws`) - Real-time bidirectional communication
|
|
26
26
|
- **Agent** - STT → LLM → TTS pipeline with terminal control
|
|
27
|
-
- **Daemon** -
|
|
27
|
+
- **Daemon** - node-pty terminal management (in-process)
|
|
28
28
|
|
|
29
29
|
## Development
|
|
30
30
|
|
|
@@ -57,7 +57,7 @@ npm start
|
|
|
57
57
|
|
|
58
58
|
**⏳ In Progress** (Phase 3):
|
|
59
59
|
|
|
60
|
-
- Terminal control (
|
|
60
|
+
- Terminal control (node-pty integration)
|
|
61
61
|
|
|
62
62
|
**📋 Planned** (Phases 4-9):
|
|
63
63
|
|
|
@@ -92,7 +92,7 @@ PASEO_HOME=~/.paseo-blue PASEO_LISTEN=127.0.0.1:7777 npm run dev
|
|
|
92
92
|
|
|
93
93
|
- **Server**: Express, TypeScript, ws (WebSocket)
|
|
94
94
|
- **Client**: React 18, Vite, TypeScript
|
|
95
|
-
- **Terminal**:
|
|
95
|
+
- **Terminal**: node-pty (in-process PTY) + @xterm/headless (screen model)
|
|
96
96
|
- **AI**: OpenAI (LLM + TTS), Deepgram (STT)
|
|
97
97
|
|
|
98
98
|
## Testing
|
|
@@ -296,6 +296,16 @@ export declare class ClaudeAgentSession implements AgentSession {
|
|
|
296
296
|
* Scoped to questions on purpose: a plan dialog's options are the CLI's and version-
|
|
297
297
|
* dependent, so guessing an index there could approve an implementation nobody chose.
|
|
298
298
|
*/
|
|
299
|
+
/**
|
|
300
|
+
* Surface a delivery-path notice to whoever sent the message.
|
|
301
|
+
*
|
|
302
|
+
* Uses the same synthetic-timeline-message channel executeRewindTurn() already uses, so
|
|
303
|
+
* this needs no protocol change and renders in every client that renders a turn today.
|
|
304
|
+
* The alternative, PtyQuery.emit(), is the SDK message stream, where the only shapes
|
|
305
|
+
* available are `result` messages -- and a result ENDS the turn, which is the opposite
|
|
306
|
+
* of what a "still queued" notice means.
|
|
307
|
+
*/
|
|
308
|
+
private emitPtyNotice;
|
|
299
309
|
private handlePtyDialogChange;
|
|
300
310
|
/** Map the app's chosen answer back to the dialog's option index and press the keys. */
|
|
301
311
|
private answerPtyDialogFromPermission;
|
|
@@ -2568,6 +2568,7 @@ export class ClaudeAgentSession {
|
|
|
2568
2568
|
: undefined,
|
|
2569
2569
|
logger: this.logger,
|
|
2570
2570
|
onDialogChange: (dialog) => this.handlePtyDialogChange(dialog),
|
|
2571
|
+
onNotice: (text) => this.emitPtyNotice(text),
|
|
2571
2572
|
});
|
|
2572
2573
|
this.activeTransport = ptySession.transport;
|
|
2573
2574
|
this.query = ptySession.query;
|
|
@@ -3660,6 +3661,22 @@ export class ClaudeAgentSession {
|
|
|
3660
3661
|
* Scoped to questions on purpose: a plan dialog's options are the CLI's and version-
|
|
3661
3662
|
* dependent, so guessing an index there could approve an implementation nobody chose.
|
|
3662
3663
|
*/
|
|
3664
|
+
/**
|
|
3665
|
+
* Surface a delivery-path notice to whoever sent the message.
|
|
3666
|
+
*
|
|
3667
|
+
* Uses the same synthetic-timeline-message channel executeRewindTurn() already uses, so
|
|
3668
|
+
* this needs no protocol change and renders in every client that renders a turn today.
|
|
3669
|
+
* The alternative, PtyQuery.emit(), is the SDK message stream, where the only shapes
|
|
3670
|
+
* available are `result` messages -- and a result ENDS the turn, which is the opposite
|
|
3671
|
+
* of what a "still queued" notice means.
|
|
3672
|
+
*/
|
|
3673
|
+
emitPtyNotice(text) {
|
|
3674
|
+
this.notifySubscribers({
|
|
3675
|
+
type: "timeline",
|
|
3676
|
+
provider: "claude",
|
|
3677
|
+
item: { type: "assistant_message", text },
|
|
3678
|
+
});
|
|
3679
|
+
}
|
|
3663
3680
|
handlePtyDialogChange(dialog) {
|
|
3664
3681
|
if (!dialog) {
|
|
3665
3682
|
const pendingId = this.ptyDialogRequestId;
|
|
@@ -25,6 +25,8 @@ export interface CreatePtySessionOptions {
|
|
|
25
25
|
};
|
|
26
26
|
/** Surfaces TUI question/plan dialogs so the provider can raise a permission request. */
|
|
27
27
|
onDialogChange?: (dialog: PtyInteractiveDialog | null) => void;
|
|
28
|
+
/** Sender-facing notice from the delivery path (see PtyQueryOptions.onNotice). */
|
|
29
|
+
onNotice?: (text: string) => void;
|
|
28
30
|
}
|
|
29
31
|
export interface PtySession {
|
|
30
32
|
transport: PtyTransport;
|
|
@@ -56,6 +56,7 @@ export async function createPtySession(opts) {
|
|
|
56
56
|
sessionId: opts.sessionId,
|
|
57
57
|
logger: opts.logger,
|
|
58
58
|
...(opts.onDialogChange ? { onDialogChange: opts.onDialogChange } : {}),
|
|
59
|
+
...(opts.onNotice ? { onNotice: opts.onNotice } : {}),
|
|
59
60
|
});
|
|
60
61
|
opts.logger.debug({ pid: transport.context().pid, sessionId: opts.sessionId, transcriptPath }, "PTY claude session spawned");
|
|
61
62
|
return { transport, query, transcriptPath, systemPromptFilePath };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Logger } from "pino";
|
|
2
2
|
import type { ModelInfo, SDKMessage, SDKSystemMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
-
import type
|
|
3
|
+
import { type PtyTransport } from "./pty.js";
|
|
4
4
|
/** An interactive TUI dialog the user has to answer for the turn to continue. */
|
|
5
5
|
export interface PtyInteractiveDialog {
|
|
6
6
|
toolUseId: string;
|
|
@@ -31,6 +31,16 @@ export interface PtyQueryOptions {
|
|
|
31
31
|
* the real options instead of an inert tool chip nobody can answer.
|
|
32
32
|
*/
|
|
33
33
|
onDialogChange?: (dialog: PtyInteractiveDialog | null) => void;
|
|
34
|
+
/**
|
|
35
|
+
* Called with a short, sender-facing sentence when paseo knows something the person who
|
|
36
|
+
* sent the message cannot see. Today that is exactly one thing: the message was accepted
|
|
37
|
+
* but QUEUED behind a running turn, so the spinner they are looking at means "waiting in
|
|
38
|
+
* line", not "working on it".
|
|
39
|
+
*
|
|
40
|
+
* Everything PtyQuery already knew about this went to recordLine(), which appends to
|
|
41
|
+
* ~/.paseo/pty-recordings/<id>.log and reaches nobody.
|
|
42
|
+
*/
|
|
43
|
+
onNotice?: (text: string) => void;
|
|
34
44
|
}
|
|
35
45
|
export declare class PtyQuery implements AsyncGenerator<SDKMessage, void> {
|
|
36
46
|
private readonly transport;
|
|
@@ -40,11 +50,18 @@ export declare class PtyQuery implements AsyncGenerator<SDKMessage, void> {
|
|
|
40
50
|
private readonly logger;
|
|
41
51
|
private readonly reader;
|
|
42
52
|
private readonly onDialogChange;
|
|
53
|
+
private readonly onNotice;
|
|
54
|
+
/** One queued notice per message, not one per retry. Reset by deliverPrompt(). */
|
|
55
|
+
private queuedNoticeSent;
|
|
43
56
|
private readonly outQueue;
|
|
44
57
|
private readonly outResolvers;
|
|
45
58
|
private done;
|
|
46
59
|
private readonly readyPromise;
|
|
47
60
|
private offReadyData;
|
|
61
|
+
/** Unsubscribes the transport exit tap. Teardown MUST call this. */
|
|
62
|
+
private offExitTap;
|
|
63
|
+
/** Set once the agent process has exited; the two remaining "still running" claims read it. */
|
|
64
|
+
private transportExited;
|
|
48
65
|
/**
|
|
49
66
|
* Cancels the readiness gate and its timers.
|
|
50
67
|
*
|
|
@@ -120,6 +137,16 @@ export declare class PtyQuery implements AsyncGenerator<SDKMessage, void> {
|
|
|
120
137
|
* agent running by the time the input loop sees it: silence here is a forever-spinner.
|
|
121
138
|
*/
|
|
122
139
|
private buildNothingDeliverableResult;
|
|
140
|
+
/**
|
|
141
|
+
* " The session itself is still running." -- but only when it is.
|
|
142
|
+
*
|
|
143
|
+
* The exit tap fixes the in-flight turn and the stall message. These two other results
|
|
144
|
+
* asserted the same thing unconditionally and are reachable after the process is gone, so
|
|
145
|
+
* they need the same correction. Wording matches buildExitedResult: no "aborted", and
|
|
146
|
+
* "status" rather than "code", so isAbortError() cannot swallow it and
|
|
147
|
+
* buildTurnFailedEvent() cannot scrape a phantom exit code out of it.
|
|
148
|
+
*/
|
|
149
|
+
private stillRunningClause;
|
|
123
150
|
/** Tell the provider the dialog state changed; never let a listener throw into the loop. */
|
|
124
151
|
private notifyDialogChange;
|
|
125
152
|
/** The dialog the TUI is waiting on, if any. */
|
|
@@ -192,6 +219,19 @@ export declare class PtyQuery implements AsyncGenerator<SDKMessage, void> {
|
|
|
192
219
|
* pasted chip is dropped the same way. Best-effort: a transport that cannot take raw
|
|
193
220
|
* keys just leaves the composer as it was.
|
|
194
221
|
*/
|
|
222
|
+
/**
|
|
223
|
+
* Is the TUI currently showing its "queued messages" footer?
|
|
224
|
+
*
|
|
225
|
+
* This reads a CLI STATUS LINE, not a rendering of our own text. The distinction matters
|
|
226
|
+
* because this file has (rightly) burned four incidents on trusting renderings as proof
|
|
227
|
+
* of DELIVERY -- bytes, then echoed text, then a raw probe, then a paste chip. Delivery
|
|
228
|
+
* is still proved by one thing only, a matching `user` record. The footer answers a
|
|
229
|
+
* different and much cheaper question: is a turn running right now. The CLI is the
|
|
230
|
+
* authority on that, and it prints the answer.
|
|
231
|
+
*/
|
|
232
|
+
private queuedFooterVisible;
|
|
233
|
+
/** Tell the sender their message is in line, once per message. */
|
|
234
|
+
private notifyQueued;
|
|
195
235
|
private clearComposer;
|
|
196
236
|
/**
|
|
197
237
|
* Deliver a prompt and prove it, or say it failed. See docs/pty-delivery.md.
|
|
@@ -318,6 +358,24 @@ export declare class PtyQuery implements AsyncGenerator<SDKMessage, void> {
|
|
|
318
358
|
*/
|
|
319
359
|
private readTerminalTail;
|
|
320
360
|
private buildResult;
|
|
361
|
+
/**
|
|
362
|
+
* The agent process exited. Fail any in-flight turn immediately and end the query.
|
|
363
|
+
*
|
|
364
|
+
* Unlike a stall there is nothing to wait for: no retry, no recovery, no later transcript
|
|
365
|
+
* activity. Emitting here rather than letting the stall timer fire turns a ten-minute wait
|
|
366
|
+
* plus a false "still running" into an immediate, accurate failure, and finishing the query
|
|
367
|
+
* stops the layer above treating this transport as a live destination for prompts.
|
|
368
|
+
*/
|
|
369
|
+
private onTransportExit;
|
|
370
|
+
/**
|
|
371
|
+
* A non-success `result` for an agent that exited.
|
|
372
|
+
*
|
|
373
|
+
* Wording constraints match {@link buildStalledResult}: `isAbortError()` drops results
|
|
374
|
+
* matching /\baborted\b/i and `buildTurnFailedEvent()` scrapes /\bcode\s+(\d+)\b/i for an
|
|
375
|
+
* exit code, so this says "status", never "code", and never says "aborted". It also must
|
|
376
|
+
* NOT claim the session is still running, which is the exact lie this change deletes.
|
|
377
|
+
*/
|
|
378
|
+
private buildExitedResult;
|
|
321
379
|
private buildStalledResult;
|
|
322
380
|
interrupt(): Promise<void>;
|
|
323
381
|
setPermissionMode(): Promise<void>;
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
+
import { PtyExitedError } from "./pty.js";
|
|
5
6
|
import { TranscriptSdkReader } from "./transcript-sdk-reader.js";
|
|
6
7
|
/**
|
|
7
8
|
* PtyQuery — a `Query`-shaped adapter over an interactive `claude` PTY.
|
|
@@ -102,6 +103,33 @@ const RECEIPT_EXACT_MATCH_BELOW_CHARS = 12;
|
|
|
102
103
|
const RECEIPT_WINDOW_MS = 4000;
|
|
103
104
|
/** A queued message can only land when the running turn ends, so wait far longer for it. */
|
|
104
105
|
const QUEUED_RECEIPT_WINDOW_MS = 10 * 60000;
|
|
106
|
+
/**
|
|
107
|
+
* The TUI's own words for "your message is in the queue".
|
|
108
|
+
*
|
|
109
|
+
* A message typed while a turn is running is QUEUED by claude, and a queued message
|
|
110
|
+
* appends NO `user` record until the running turn ends -- so the one delivery oracle
|
|
111
|
+
* (receiptSince) cannot fire, by construction, for minutes at a time. The old test for
|
|
112
|
+
* "is a turn running" was `lastTranscriptAt > attemptStartedAt`, i.e. "did the transcript
|
|
113
|
+
* grow during my 4s window". That is a proxy, and it reads FALSE whenever the running
|
|
114
|
+
* turn is simply quiet for four seconds (thinking, or one slow tool call). When it reads
|
|
115
|
+
* false the loop concludes the terminal is deaf and retypes.
|
|
116
|
+
*
|
|
117
|
+
* Retyping a QUEUED message is not idempotent: the composer is empty (the text already
|
|
118
|
+
* left it), so Ctrl+U clears nothing and the retype either stacks a second queued copy or
|
|
119
|
+
* concatenates onto text the clear failed to kill. Observed live 2026-08-20 on session
|
|
120
|
+
* 0b1ec622, four retries logged at 18:28:13 / 18:29:59 / 18:30:06 / 18:30:14 / 18:30:27,
|
|
121
|
+
* producing these transcript records:
|
|
122
|
+
*
|
|
123
|
+
* mint another xpmint another xp
|
|
124
|
+
* a new one with another cpf: 76797678153, caixaa new one with another cpf: ...
|
|
125
|
+
*
|
|
126
|
+
* The terminal was stating the answer the whole time. Paseo's own turn heartbeat captured
|
|
127
|
+
* it at 18:30:15: `... Press up to edit queued messages - esc to interrupt`. This reads
|
|
128
|
+
* that footer instead of inferring it from file growth.
|
|
129
|
+
*/
|
|
130
|
+
const QUEUED_FOOTER_RE = /Press up to edit queued messages/i;
|
|
131
|
+
/** How much of the tail to scan for the footer. It is repainted on every frame. */
|
|
132
|
+
const QUEUED_FOOTER_SCAN_CHARS = 2000;
|
|
105
133
|
/** Backoff between retype attempts against a deaf terminal. */
|
|
106
134
|
const DEAF_RETRY_INITIAL_MS = 2000;
|
|
107
135
|
const DEAF_RETRY_MAX_MS = 60000;
|
|
@@ -211,11 +239,17 @@ function monotonicNowMs() {
|
|
|
211
239
|
}
|
|
212
240
|
export class PtyQuery {
|
|
213
241
|
constructor(opts) {
|
|
242
|
+
/** One queued notice per message, not one per retry. Reset by deliverPrompt(). */
|
|
243
|
+
this.queuedNoticeSent = false;
|
|
214
244
|
// output queue → async iterator
|
|
215
245
|
this.outQueue = [];
|
|
216
246
|
this.outResolvers = [];
|
|
217
247
|
this.done = false;
|
|
218
248
|
this.offReadyData = null;
|
|
249
|
+
/** Unsubscribes the transport exit tap. Teardown MUST call this. */
|
|
250
|
+
this.offExitTap = null;
|
|
251
|
+
/** Set once the agent process has exited; the two remaining "still running" claims read it. */
|
|
252
|
+
this.transportExited = null;
|
|
219
253
|
/**
|
|
220
254
|
* Cancels the readiness gate and its timers.
|
|
221
255
|
*
|
|
@@ -270,6 +304,7 @@ export class PtyQuery {
|
|
|
270
304
|
this.logger = opts.logger;
|
|
271
305
|
this.stallTimeoutMs = opts.stallTimeoutMs ?? TURN_STALL_TIMEOUT_MS;
|
|
272
306
|
this.onDialogChange = opts.onDialogChange ?? null;
|
|
307
|
+
this.onNotice = opts.onNotice ?? null;
|
|
273
308
|
this.reader = new TranscriptSdkReader({
|
|
274
309
|
transcriptPath: opts.transcriptPath,
|
|
275
310
|
onMessage: (m) => this.onTranscriptMessage(m),
|
|
@@ -281,6 +316,12 @@ export class PtyQuery {
|
|
|
281
316
|
// session so a stall has something to explain itself with. Chunks arrive already redacted (PtyTransport.onData
|
|
282
317
|
// runs redactChunk), so nothing further is needed here.
|
|
283
318
|
this.offTerminalTap = this.transport.onData((chunk) => this.appendTerminalTail(chunk));
|
|
319
|
+
// Learn about the agent process dying the moment it happens. Without this tap the only
|
|
320
|
+
// thing that ever noticed was the stall backstop, ten minutes later, and it then told the
|
|
321
|
+
// user the session was "still running and may recover" - of a process that had already
|
|
322
|
+
// printed its farewell banner and exited. The stall timer is for a live agent that is
|
|
323
|
+
// slow; it is the wrong instrument for a dead one.
|
|
324
|
+
this.offExitTap = this.transport.onExit((code, signal) => this.onTransportExit(code, signal));
|
|
284
325
|
// The executor stays trivial and settlement lives outside it, so there is exactly one
|
|
285
326
|
// resolve() call in the file. Timers and handlers below all funnel through settle(),
|
|
286
327
|
// which is guarded by `settled`; expressing that inside the executor tripped
|
|
@@ -437,6 +478,8 @@ export class PtyQuery {
|
|
|
437
478
|
this.offReadyData?.();
|
|
438
479
|
this.offTerminalTap?.();
|
|
439
480
|
this.offTerminalTap = null;
|
|
481
|
+
this.offExitTap?.();
|
|
482
|
+
this.offExitTap = null;
|
|
440
483
|
this.stopHeartbeat();
|
|
441
484
|
this.recordLine("--- PtyQuery detached ---");
|
|
442
485
|
try {
|
|
@@ -503,6 +546,13 @@ export class PtyQuery {
|
|
|
503
546
|
this.emit(this.buildCommandRejectedResult(notice));
|
|
504
547
|
continue;
|
|
505
548
|
}
|
|
549
|
+
if (delivery === "exited") {
|
|
550
|
+
// Dead, not deaf. `continue`, never `break`: the exit tap is what ends this query,
|
|
551
|
+
// and breaking here would instead leave the input channel undrained, so a resend
|
|
552
|
+
// would sit unread and the sender would watch a spinner - the symptom being fixed.
|
|
553
|
+
this.emit(this.buildExitedResult(this.transportExited?.code ?? null, this.transportExited?.signal ?? null));
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
506
556
|
if (delivery === "undelivered") {
|
|
507
557
|
// No transcript receipt for the whole budget: the message never became a turn,
|
|
508
558
|
// nothing was queued, and only the sender can decide what to do next.
|
|
@@ -590,11 +640,27 @@ export class PtyQuery {
|
|
|
590
640
|
is_error: true,
|
|
591
641
|
errors: [
|
|
592
642
|
`Your message had nothing the terminal transport could deliver: no text, and no ` +
|
|
593
|
-
`image it could stage to disk. Nothing was sent, so please resend it with text
|
|
594
|
-
|
|
643
|
+
`image it could stage to disk. Nothing was sent, so please resend it with text.` +
|
|
644
|
+
`${this.stillRunningClause()}`,
|
|
595
645
|
],
|
|
596
646
|
};
|
|
597
647
|
}
|
|
648
|
+
/**
|
|
649
|
+
* " The session itself is still running." -- but only when it is.
|
|
650
|
+
*
|
|
651
|
+
* The exit tap fixes the in-flight turn and the stall message. These two other results
|
|
652
|
+
* asserted the same thing unconditionally and are reachable after the process is gone, so
|
|
653
|
+
* they need the same correction. Wording matches buildExitedResult: no "aborted", and
|
|
654
|
+
* "status" rather than "code", so isAbortError() cannot swallow it and
|
|
655
|
+
* buildTurnFailedEvent() cannot scrape a phantom exit code out of it.
|
|
656
|
+
*/
|
|
657
|
+
stillRunningClause() {
|
|
658
|
+
if (!this.transportExited)
|
|
659
|
+
return " The session itself is still running.";
|
|
660
|
+
return (` The agent process has exited, so the session is NOT running any more and will not ` +
|
|
661
|
+
`recover on its own; the conversation is still on disk, recover it with ` +
|
|
662
|
+
`"paseo import ${this.sessionId}".`);
|
|
663
|
+
}
|
|
598
664
|
/** Tell the provider the dialog state changed; never let a listener throw into the loop. */
|
|
599
665
|
notifyDialogChange() {
|
|
600
666
|
if (!this.onDialogChange)
|
|
@@ -697,8 +763,7 @@ export class PtyQuery {
|
|
|
697
763
|
is_error: true,
|
|
698
764
|
errors: [
|
|
699
765
|
`Your message did not reach the agent: the terminal would not accept it. ` +
|
|
700
|
-
`Nothing was sent, so please send it again.
|
|
701
|
-
`running.${tailLine}`,
|
|
766
|
+
`Nothing was sent, so please send it again.${this.stillRunningClause()}${tailLine}`,
|
|
702
767
|
],
|
|
703
768
|
};
|
|
704
769
|
}
|
|
@@ -788,9 +853,60 @@ export class PtyQuery {
|
|
|
788
853
|
* pasted chip is dropped the same way. Best-effort: a transport that cannot take raw
|
|
789
854
|
* keys just leaves the composer as it was.
|
|
790
855
|
*/
|
|
856
|
+
/**
|
|
857
|
+
* Is the TUI currently showing its "queued messages" footer?
|
|
858
|
+
*
|
|
859
|
+
* This reads a CLI STATUS LINE, not a rendering of our own text. The distinction matters
|
|
860
|
+
* because this file has (rightly) burned four incidents on trusting renderings as proof
|
|
861
|
+
* of DELIVERY -- bytes, then echoed text, then a raw probe, then a paste chip. Delivery
|
|
862
|
+
* is still proved by one thing only, a matching `user` record. The footer answers a
|
|
863
|
+
* different and much cheaper question: is a turn running right now. The CLI is the
|
|
864
|
+
* authority on that, and it prints the answer.
|
|
865
|
+
*/
|
|
866
|
+
queuedFooterVisible() {
|
|
867
|
+
return QUEUED_FOOTER_RE.test(this.terminalTail.slice(-QUEUED_FOOTER_SCAN_CHARS));
|
|
868
|
+
}
|
|
869
|
+
/** Tell the sender their message is in line, once per message. */
|
|
870
|
+
notifyQueued() {
|
|
871
|
+
if (this.queuedNoticeSent)
|
|
872
|
+
return;
|
|
873
|
+
this.queuedNoticeSent = true;
|
|
874
|
+
try {
|
|
875
|
+
this.onNotice?.("Your message was accepted and is queued behind the turn this session is already " +
|
|
876
|
+
"running. It will start as soon as that turn finishes.");
|
|
877
|
+
}
|
|
878
|
+
catch (err) {
|
|
879
|
+
this.logger.debug({ err }, "PtyQuery: queued notice callback threw");
|
|
880
|
+
}
|
|
881
|
+
}
|
|
791
882
|
async clearComposer() {
|
|
792
883
|
try {
|
|
793
|
-
|
|
884
|
+
// Ctrl+E (end of line), Ctrl+U (kill to start), Ctrl+K (kill to end).
|
|
885
|
+
//
|
|
886
|
+
// Ctrl+U alone was not enough. In a readline-style buffer it kills from the CURSOR
|
|
887
|
+
// to the start of the line: it empties the composer only when the cursor already
|
|
888
|
+
// sits at the end, and it never touches what is after the cursor. Live 2026-08-20 a
|
|
889
|
+
// retype landed ON TOP of text a Ctrl+U was supposed to have killed, which is how
|
|
890
|
+
// session 0b1ec622 got these into its transcript:
|
|
891
|
+
//
|
|
892
|
+
// mint another xpmint another xp
|
|
893
|
+
// a new one with another cpf: 76797678153, caixaa new one with another cpf: ...
|
|
894
|
+
//
|
|
895
|
+
// This sequence is cursor-position independent and idempotent: all three keys are
|
|
896
|
+
// no-ops on an already empty composer, so it cannot damage the common case. Each
|
|
897
|
+
// gets its own beat, because the TUI only processes keys as fast as its input loop
|
|
898
|
+
// drains them.
|
|
899
|
+
//
|
|
900
|
+
// It makes the clear far more likely to succeed. It does NOT prove the composer is
|
|
901
|
+
// empty, and this file deliberately has no way to prove that: every signal for it is
|
|
902
|
+
// a rendering, and renderings are what four earlier incidents taught it not to
|
|
903
|
+
// trust. The real protection against duplication is not retyping at all once the
|
|
904
|
+
// text has demonstrably left the composer -- the queued-footer guard in
|
|
905
|
+
// deliverPrompt(), which removes the case behind every duplicate observed so far.
|
|
906
|
+
for (const key of ["\u0005", "\u0015", "\u000b"]) {
|
|
907
|
+
await this.transport.writeRaw?.(key);
|
|
908
|
+
await delay(60);
|
|
909
|
+
}
|
|
794
910
|
await delay(120);
|
|
795
911
|
}
|
|
796
912
|
catch (err) {
|
|
@@ -809,12 +925,18 @@ export class PtyQuery {
|
|
|
809
925
|
* running turn is never retyped - that is the one way to send it twice.
|
|
810
926
|
*/
|
|
811
927
|
async deliverPrompt(text) {
|
|
928
|
+
this.queuedNoticeSent = false;
|
|
812
929
|
const fingerprint = PtyQuery.fingerprint(text);
|
|
813
930
|
const watermark = this.transcriptSize();
|
|
814
931
|
const deadline = monotonicNowMs() + READY_BUSY_MAX_WAIT_MS;
|
|
815
932
|
let backoff = DEAF_RETRY_INITIAL_MS;
|
|
816
933
|
let attempt = 0;
|
|
817
934
|
while (!this.done && monotonicNowMs() < deadline) {
|
|
935
|
+
// Re-checked every attempt, not once up front: the child can die between attempts and
|
|
936
|
+
// this loop's budget is ten minutes. Retrying into a corpse is what produced the
|
|
937
|
+
// "no delivery receipt; retrying" storms that ended at the 20-retry cap.
|
|
938
|
+
if (this.transportExited)
|
|
939
|
+
return "exited";
|
|
818
940
|
attempt += 1;
|
|
819
941
|
const attemptStartedAt = monotonicNowMs();
|
|
820
942
|
// Empty the input box first: whatever an earlier attempt may have left there is
|
|
@@ -822,7 +944,17 @@ export class PtyQuery {
|
|
|
822
944
|
// became nine stacked paste blocks in production.
|
|
823
945
|
if (attempt > 1)
|
|
824
946
|
await this.clearComposer();
|
|
825
|
-
|
|
947
|
+
try {
|
|
948
|
+
await this.transport.write(text);
|
|
949
|
+
}
|
|
950
|
+
catch (err) {
|
|
951
|
+
// The transport now refuses a write into a dead pty. Without this catch the rejection
|
|
952
|
+
// escapes deliverPrompt into the input loop's handler, which only logs "input loop
|
|
953
|
+
// ended" -- so the sender would watch a spinner and never be told anything.
|
|
954
|
+
if (err instanceof PtyExitedError)
|
|
955
|
+
return "exited";
|
|
956
|
+
throw err;
|
|
957
|
+
}
|
|
826
958
|
const submitted = await this.submitTurn(text, watermark, fingerprint);
|
|
827
959
|
if (submitted === "rejected")
|
|
828
960
|
return "rejected";
|
|
@@ -833,15 +965,35 @@ export class PtyQuery {
|
|
|
833
965
|
return "delivered";
|
|
834
966
|
}
|
|
835
967
|
// No receipt. Is a turn actually RUNNING (so our text is queued behind it), or was
|
|
836
|
-
// the Enter simply swallowed?
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
|
|
968
|
+
// the Enter simply swallowed? Two independent signals, ORed on purpose:
|
|
969
|
+
//
|
|
970
|
+
// - the TUI's own queued footer, which is the CLI stating the fact outright, and
|
|
971
|
+
// - real SDK messages arriving during this attempt, the original heuristic.
|
|
972
|
+
//
|
|
973
|
+
// File growth alone cannot tell queued from swallowed: claude writes `attachment`
|
|
974
|
+
// and other bookkeeping records on its own schedule, and treating that as "queued"
|
|
975
|
+
// would sit here for ten minutes while the prompt was never submitted (the
|
|
976
|
+
// 2026-08-01 bug, in reverse). But it also misses the common case, a running turn
|
|
977
|
+
// that is quiet for the four-second receipt window, and the cost of missing it is a
|
|
978
|
+
// retype that corrupts the queued prompt. The footer settles that case directly.
|
|
979
|
+
if (this.queuedFooterVisible() || this.lastTranscriptAt > attemptStartedAt) {
|
|
841
980
|
this.recordLine("--- no receipt yet; queued behind a running turn ---");
|
|
981
|
+
// Say so where the sender can see it. Once per message, not once per poll.
|
|
982
|
+
this.notifyQueued();
|
|
842
983
|
const late = await pollUntil(() => this.receiptSince(watermark, fingerprint), QUEUED_RECEIPT_WINDOW_MS, 500);
|
|
843
984
|
if (late)
|
|
844
985
|
return "delivered";
|
|
986
|
+
// Queued, and still no receipt after the full window. Do NOT fall through to the
|
|
987
|
+
// retype: the text is demonstrably out of the composer, so retyping cannot undo
|
|
988
|
+
// anything and can only stack a duplicate.
|
|
989
|
+
//
|
|
990
|
+
// "unconfirmed", never "undelivered". They are not interchangeable here:
|
|
991
|
+
// `undelivered` makes the caller emit buildSubmitFailedResult(), whose text is
|
|
992
|
+
// "Nothing was sent, so please send it again" -- a lie about a message sitting in
|
|
993
|
+
// claude's queue, and an instruction that produces the exact duplicate this branch
|
|
994
|
+
// exists to prevent. `unconfirmed` means "accepted, start not observed", which is
|
|
995
|
+
// true, and the stall backstop is already the right net for it.
|
|
996
|
+
return "unconfirmed";
|
|
845
997
|
}
|
|
846
998
|
this.logger.warn({ sessionId: this.sessionId, attempt, nextRetryMs: backoff }, "PtyQuery: no delivery receipt for the prompt; retrying");
|
|
847
999
|
this.recordLine(`--- no delivery receipt (attempt ${attempt}); retrying in ${Math.round(backoff / 1000)}s ---`);
|
|
@@ -1324,7 +1476,62 @@ export class PtyQuery {
|
|
|
1324
1476
|
};
|
|
1325
1477
|
return result;
|
|
1326
1478
|
}
|
|
1479
|
+
/**
|
|
1480
|
+
* The agent process exited. Fail any in-flight turn immediately and end the query.
|
|
1481
|
+
*
|
|
1482
|
+
* Unlike a stall there is nothing to wait for: no retry, no recovery, no later transcript
|
|
1483
|
+
* activity. Emitting here rather than letting the stall timer fire turns a ten-minute wait
|
|
1484
|
+
* plus a false "still running" into an immediate, accurate failure, and finishing the query
|
|
1485
|
+
* stops the layer above treating this transport as a live destination for prompts.
|
|
1486
|
+
*/
|
|
1487
|
+
onTransportExit(code, signal) {
|
|
1488
|
+
// Recorded before the `done` bail: the wording helpers and the delivery guards must know
|
|
1489
|
+
// the process is gone even when the query had already been torn down.
|
|
1490
|
+
this.transportExited ?? (this.transportExited = { code, signal });
|
|
1491
|
+
if (this.done)
|
|
1492
|
+
return;
|
|
1493
|
+
const wasInFlight = this.turnInFlight;
|
|
1494
|
+
this.turnInFlight = false;
|
|
1495
|
+
this.clearStallTimer();
|
|
1496
|
+
this.logger.error({ sessionId: this.sessionId, exitCode: code, exitSignal: signal, wasInFlight }, "PtyQuery: the agent process exited");
|
|
1497
|
+
this.recordLine(`--- agent process exited (status ${code ?? "none"}, signal ${signal ?? "none"}) ---`);
|
|
1498
|
+
if (wasInFlight) {
|
|
1499
|
+
this.numTurns += 1;
|
|
1500
|
+
this.emit(this.buildExitedResult(code, signal));
|
|
1501
|
+
}
|
|
1502
|
+
this.finish();
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* A non-success `result` for an agent that exited.
|
|
1506
|
+
*
|
|
1507
|
+
* Wording constraints match {@link buildStalledResult}: `isAbortError()` drops results
|
|
1508
|
+
* matching /\baborted\b/i and `buildTurnFailedEvent()` scrapes /\bcode\s+(\d+)\b/i for an
|
|
1509
|
+
* exit code, so this says "status", never "code", and never says "aborted". It also must
|
|
1510
|
+
* NOT claim the session is still running, which is the exact lie this change deletes.
|
|
1511
|
+
*/
|
|
1512
|
+
buildExitedResult(code, signal) {
|
|
1513
|
+
const base = this.buildResult("process_exited");
|
|
1514
|
+
const how = signal ? `on signal ${signal}` : `with status ${code ?? "unknown"}`;
|
|
1515
|
+
const excerpt = this.terminalTail.slice(-TERMINAL_TAIL_EXCERPT_CHARS).trim();
|
|
1516
|
+
const tailLine = excerpt ? ` Last terminal output: ${JSON.stringify(excerpt)}.` : "";
|
|
1517
|
+
return {
|
|
1518
|
+
...base,
|
|
1519
|
+
subtype: "error_during_execution",
|
|
1520
|
+
is_error: true,
|
|
1521
|
+
errors: [
|
|
1522
|
+
`The agent process exited ${how} before finishing this turn, so the session is NOT ` +
|
|
1523
|
+
`running any more and will not recover on its own. The conversation is still on ` +
|
|
1524
|
+
`disk: recover it with "paseo import ${this.sessionId}", then restore the agent's ` +
|
|
1525
|
+
`model, thinking budget and permission mode, which import does not carry over.` +
|
|
1526
|
+
tailLine,
|
|
1527
|
+
],
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1327
1530
|
buildStalledResult(silentForMs, terminalTail) {
|
|
1531
|
+
// A dead process is not a stall, and must never be described as one that "may recover".
|
|
1532
|
+
if (this.transportExited) {
|
|
1533
|
+
return this.buildExitedResult(this.transportExited.code, this.transportExited.signal);
|
|
1534
|
+
}
|
|
1328
1535
|
const minutes = Math.round(silentForMs / 60000);
|
|
1329
1536
|
const base = this.buildResult("stall_timeout");
|
|
1330
1537
|
// The last thing the terminal showed is usually the whole diagnosis ("Compacting
|
|
@@ -28,6 +28,23 @@ export interface PtyInputEvent {
|
|
|
28
28
|
/** Install (or clear, with null) the input-provenance sink. */
|
|
29
29
|
export declare function setPtyInputSink(sink: ((event: PtyInputEvent) => void) | null): void;
|
|
30
30
|
export declare function __setNodePtyForTesting(stub: NodePtyModule | null): void;
|
|
31
|
+
/**
|
|
32
|
+
* Thrown when something tries to write to a pty whose process has already exited.
|
|
33
|
+
*
|
|
34
|
+
* This has a dedicated type because the two failure modes read identically at the call site
|
|
35
|
+
* and must not: "never spawned" is a programming error, while "already exited" is a normal
|
|
36
|
+
* runtime event that the layer above has to react to by failing the turn. Before this class
|
|
37
|
+
* existed, `doWrite` happily called `_pty.write()` on a dead pty, node-pty swallowed it, and
|
|
38
|
+
* `waitForEcho()`'s fixed timer reported success - so paseo typed prompts into a corpse for
|
|
39
|
+
* fourteen retries and then told the user "the session itself is still running".
|
|
40
|
+
*/
|
|
41
|
+
export declare class PtyExitedError extends Error {
|
|
42
|
+
readonly code = "PTY_EXITED";
|
|
43
|
+
/** How the process died, so a caller can report it without a second lookup. */
|
|
44
|
+
readonly exitCode: number | null;
|
|
45
|
+
readonly exitSignal: NodeJS.Signals | null;
|
|
46
|
+
constructor(op: string, exitCode?: number | null, exitSignal?: NodeJS.Signals | null);
|
|
47
|
+
}
|
|
31
48
|
export declare class PtyTransport implements AgentTransport {
|
|
32
49
|
private _pty;
|
|
33
50
|
private _cwd;
|
|
@@ -35,11 +52,26 @@ export declare class PtyTransport implements AgentTransport {
|
|
|
35
52
|
private _rows;
|
|
36
53
|
private _killed;
|
|
37
54
|
private _exited;
|
|
55
|
+
private _exitInfo;
|
|
56
|
+
private exitHandlers;
|
|
38
57
|
private injectChain;
|
|
39
58
|
private hookHandlers;
|
|
40
59
|
private hookBuffer;
|
|
41
60
|
private bridgeClose;
|
|
42
61
|
private systemPromptFilePath;
|
|
62
|
+
/**
|
|
63
|
+
* Has the child process exited?
|
|
64
|
+
*
|
|
65
|
+
* Public because guarding the write paths fixes the symptom but leaves the question
|
|
66
|
+
* unanswerable: `_exited` was private, so no caller could ask a transport whether its
|
|
67
|
+
* child was alive without provoking an error. `AgentTransport` now declares it.
|
|
68
|
+
*/
|
|
69
|
+
get exited(): boolean;
|
|
70
|
+
/** Exit status once the child is gone, else null. */
|
|
71
|
+
get exitInfo(): {
|
|
72
|
+
code: number | null;
|
|
73
|
+
signal: NodeJS.Signals | null;
|
|
74
|
+
} | null;
|
|
43
75
|
/**
|
|
44
76
|
* Bridge-side / test entry point — push a hook event into this transport's
|
|
45
77
|
* stream. If no `onHookEvent` handler is registered yet, the event is
|