@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.
Files changed (25) hide show
  1. package/README.md +3 -3
  2. package/dist/server/server/agent/providers/claude/agent.d.ts +10 -0
  3. package/dist/server/server/agent/providers/claude/agent.js +17 -0
  4. package/dist/server/server/agent/providers/claude/pty-session-launcher.d.ts +2 -0
  5. package/dist/server/server/agent/providers/claude/pty-session-launcher.js +1 -0
  6. package/dist/server/server/agent/providers/claude/transport/pty-query.d.ts +59 -1
  7. package/dist/server/server/agent/providers/claude/transport/pty-query.js +218 -11
  8. package/dist/server/server/agent/providers/claude/transport/pty.d.ts +32 -0
  9. package/dist/server/server/agent/providers/claude/transport/pty.js +69 -5
  10. package/dist/server/server/agent/providers/claude/transport/sdk.d.ts +2 -0
  11. package/dist/server/server/agent/providers/claude/transport/sdk.js +4 -0
  12. package/dist/server/server/agent/providers/claude/transport/types.d.ts +8 -0
  13. package/dist/server/server/session/workspace-provisioning/workspace-provisioning-service.d.ts +13 -1
  14. package/dist/server/server/session/workspace-provisioning/workspace-provisioning-service.js +27 -3
  15. package/dist/server/server/session.js +6 -2
  16. package/dist/server/web-ui/_expo/static/js/web/{index-56443c4ee726ad022938cfa50ba09aee.js → index-73ebfe5c6b82437cad59d50a40d2c8ef.js} +5 -5
  17. package/dist/server/web-ui/_expo/static/js/web/index-73ebfe5c6b82437cad59d50a40d2c8ef.js.br +0 -0
  18. package/dist/server/web-ui/_expo/static/js/web/{index-56443c4ee726ad022938cfa50ba09aee.js.gz → index-73ebfe5c6b82437cad59d50a40d2c8ef.js.gz} +0 -0
  19. package/dist/server/web-ui/_expo/static/js/web/{index-56443c4ee726ad022938cfa50ba09aee.js.map.br → index-73ebfe5c6b82437cad59d50a40d2c8ef.js.map.br} +0 -0
  20. package/dist/server/web-ui/_expo/static/js/web/{index-56443c4ee726ad022938cfa50ba09aee.js.map.gz → index-73ebfe5c6b82437cad59d50a40d2c8ef.js.map.gz} +0 -0
  21. package/dist/server/web-ui/index.html +1 -1
  22. package/dist/server/web-ui/index.html.br +0 -0
  23. package/dist/server/web-ui/index.html.gz +0 -0
  24. package/package.json +6 -6
  25. 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** - tmux-based terminal management (in-process)
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 (tmux integration)
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**: tmux (via child_process)
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 { PtyTransport } from "./pty.js";
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
- `The session itself is still running.`,
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. The session itself is still ` +
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
- await this.transport.writeRaw?.("\u0015");
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
- await this.transport.write(text);
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? File growth cannot tell those apart: claude writes
837
- // `attachment` and other bookkeeping records on its own schedule, and treating that
838
- // as "queued" would sit here for ten minutes while the prompt was never submitted
839
- // (the 2026-08-01 bug, in reverse). Real SDK messages arriving IS a running turn.
840
- if (this.lastTranscriptAt > attemptStartedAt) {
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