@hyperdrive.bot/paseo-server 0.3.39 → 0.3.41

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 (41) hide show
  1. package/README.md +3 -3
  2. package/dist/server/server/agent/agent-manager.js +15 -0
  3. package/dist/server/server/agent/agent-projections.js +3 -0
  4. package/dist/server/server/agent/agent-sdk-types.d.ts +23 -0
  5. package/dist/server/server/agent/agent-storage.d.ts +2 -1
  6. package/dist/server/server/agent/agent-storage.js +4 -0
  7. package/dist/server/server/agent/mcp-shared.js +5 -2
  8. package/dist/server/server/agent/providers/claude/agent.d.ts +34 -0
  9. package/dist/server/server/agent/providers/claude/agent.js +74 -0
  10. package/dist/server/server/agent/providers/claude/pty-session-launcher.d.ts +7 -0
  11. package/dist/server/server/agent/providers/claude/pty-session-launcher.js +6 -0
  12. package/dist/server/server/agent/providers/claude/tool-allowlist-guard.d.ts +41 -0
  13. package/dist/server/server/agent/providers/claude/tool-allowlist-guard.js +93 -0
  14. package/dist/server/server/agent/providers/claude/tool-allowlist.d.ts +68 -0
  15. package/dist/server/server/agent/providers/claude/tool-allowlist.js +133 -0
  16. package/dist/server/server/agent/providers/claude/transport/pty-query.d.ts +33 -1
  17. package/dist/server/server/agent/providers/claude/transport/pty-query.js +110 -5
  18. package/dist/server/server/agent/providers/claude/transport/pty.d.ts +32 -0
  19. package/dist/server/server/agent/providers/claude/transport/pty.js +69 -5
  20. package/dist/server/server/agent/providers/claude/transport/sdk.d.ts +2 -0
  21. package/dist/server/server/agent/providers/claude/transport/sdk.js +4 -0
  22. package/dist/server/server/agent/providers/claude/transport/types.d.ts +8 -0
  23. package/dist/server/server/agent/tools/paseo-tools.d.ts +19 -0
  24. package/dist/server/server/agent/tools/paseo-tools.js +213 -38
  25. package/dist/server/server/agent/tools/read-only-surface.d.ts +1 -0
  26. package/dist/server/server/agent/tools/read-only-surface.js +1 -0
  27. package/dist/server/server/persistence-hooks.js +2 -0
  28. package/dist/server/server/session/workspace-provisioning/workspace-provisioning-service.d.ts +13 -1
  29. package/dist/server/server/session/workspace-provisioning/workspace-provisioning-service.js +27 -3
  30. package/dist/server/server/session.js +6 -2
  31. package/dist/server/web-ui/_expo/static/js/web/{index-46b675f5daddb88c514f0eee26695f71.js → index-cb251ddad56c08c3021036af3a43fc0f.js} +5 -5
  32. package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.js.br +0 -0
  33. package/dist/server/web-ui/_expo/static/js/web/index-cb251ddad56c08c3021036af3a43fc0f.js.gz +0 -0
  34. package/dist/server/web-ui/_expo/static/js/web/{index-46b675f5daddb88c514f0eee26695f71.js.map.br → index-cb251ddad56c08c3021036af3a43fc0f.js.map.br} +0 -0
  35. package/dist/server/web-ui/_expo/static/js/web/{index-46b675f5daddb88c514f0eee26695f71.js.map.gz → index-cb251ddad56c08c3021036af3a43fc0f.js.map.gz} +0 -0
  36. package/dist/server/web-ui/index.html +1 -1
  37. package/dist/server/web-ui/index.html.br +0 -0
  38. package/dist/server/web-ui/index.html.gz +0 -0
  39. package/package.json +6 -6
  40. package/dist/server/web-ui/_expo/static/js/web/index-46b675f5daddb88c514f0eee26695f71.js.br +0 -0
  41. package/dist/server/web-ui/_expo/static/js/web/index-46b675f5daddb88c514f0eee26695f71.js.gz +0 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Per-run tool allowlist enforcement for the claude provider.
3
+ *
4
+ * WHY A HOOK AND NOT `--allowedTools`
5
+ * -----------------------------------
6
+ * `--allowedTools` (CLI) and `options.allowedTools` (SDK) are *permission allow
7
+ * rules*: they pre-approve a tool so it does not prompt. They do not restrict
8
+ * anything. Both of paseo's claude transports also run the child with
9
+ * `--dangerously-skip-permissions` / `allowDangerouslySkipPermissions`, under
10
+ * which every permission rule is moot, so passing an allowlist there would be
11
+ * decorative: accepted, parsed, and silently unenforcing.
12
+ *
13
+ * A `PreToolUse` hook is the one gate that still fires in that state. The Agent
14
+ * SDK says so explicitly (sdk.d.ts, PermissionDeniedHookInput): "PreToolUse hook
15
+ * denies bypass canUseTool". Verified against claude 2.1.239 with
16
+ * `--permission-mode acceptEdits --dangerously-skip-permissions`: a hook exiting
17
+ * 2 blocked a Bash call while an allowlisted Read call went through.
18
+ *
19
+ * FAIL CLOSED
20
+ * -----------
21
+ * When an allowlist is configured, anything this module cannot positively match
22
+ * is DENIED. A matcher that is too strict fails loudly (the model is told which
23
+ * rule set rejected it, and the run keeps going), while a matcher that is too
24
+ * lax fails silently and hands back a guarantee that is not real. Strictness is
25
+ * the safe direction, so unknown rule shapes and unknown specifier sources deny.
26
+ *
27
+ * Note this is *stricter* than `claude-pool launch --allowedTools`, where tools
28
+ * that never request permission (Read, Grep, TodoWrite, ...) were unaffected by
29
+ * the allowlist. Here the allowlist means exactly what it says: a tool absent
30
+ * from it cannot run at all.
31
+ */
32
+ /** Tools whose specifier (the `Tool(spec)` argument) we know how to read off the input. */
33
+ export const SPECIFIER_SOURCES = {
34
+ Bash: "command",
35
+ BashOutput: "bash_id",
36
+ Read: "file_path",
37
+ Edit: "file_path",
38
+ Write: "file_path",
39
+ NotebookEdit: "notebook_path",
40
+ WebFetch: "url",
41
+ Glob: "pattern",
42
+ Grep: "pattern",
43
+ };
44
+ /**
45
+ * Parse CSV-or-array allowlist entries into rules. Entries are the same strings
46
+ * Claude Code permission rules use: `Bash`, `Bash(git:*)`, `Read`,
47
+ * `mcp__server__tool`. Blank entries are dropped.
48
+ */
49
+ export function parseToolAllowlist(entries) {
50
+ const rules = [];
51
+ for (const raw of entries) {
52
+ for (const piece of raw.split(",")) {
53
+ const entry = piece.trim();
54
+ if (!entry)
55
+ continue;
56
+ const match = /^([^()\s]+)\((.*)\)$/.exec(entry);
57
+ if (match?.[1] !== undefined && match[2] !== undefined) {
58
+ rules.push({ tool: match[1], specifier: match[2].trim() });
59
+ }
60
+ else {
61
+ rules.push({ tool: entry });
62
+ }
63
+ }
64
+ }
65
+ return rules;
66
+ }
67
+ /** Escape a literal for use inside a RegExp, leaving `*` to be expanded by the caller. */
68
+ export function escapeLiteral(value) {
69
+ return value.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
70
+ }
71
+ /**
72
+ * Match a Claude-Code-style specifier pattern against a value.
73
+ *
74
+ * Supported shapes (anything else denies):
75
+ * - `*` any value
76
+ * - `prefix:*` value starts with `prefix` (Claude Code's command-prefix form)
77
+ * - `glob` `*` wildcards, everything else literal, anchored both ends
78
+ */
79
+ export function specifierMatches(pattern, value) {
80
+ if (pattern === "*")
81
+ return true;
82
+ const prefixForm = /^(.*):\*$/.exec(pattern);
83
+ if (prefixForm?.[1] !== undefined) {
84
+ const prefix = prefixForm[1].trim();
85
+ if (!prefix)
86
+ return false;
87
+ return value === prefix || value.startsWith(`${prefix} `);
88
+ }
89
+ const expanded = pattern.split("*").map(escapeLiteral).join(".*");
90
+ return new RegExp(`^${expanded}$`).test(value);
91
+ }
92
+ /** Read the specifier value a `Tool(spec)` rule compares against, or null when unknown. */
93
+ export function readSpecifierValue(toolName, input) {
94
+ const key = SPECIFIER_SOURCES[toolName];
95
+ if (!key)
96
+ return null;
97
+ if (typeof input !== "object" || input === null)
98
+ return null;
99
+ const value = input[key];
100
+ return typeof value === "string" ? value : null;
101
+ }
102
+ /**
103
+ * True when `toolName` (with `input`) is permitted by `rules`.
104
+ *
105
+ * An EMPTY rule list means "no allowlist configured" and allows everything; the
106
+ * caller is responsible for not installing the guard at all in that case.
107
+ */
108
+ export function isToolAllowed(toolName, input, rules) {
109
+ if (rules.length === 0)
110
+ return true;
111
+ for (const rule of rules) {
112
+ if (rule.tool !== toolName)
113
+ continue;
114
+ if (rule.specifier === undefined)
115
+ return true;
116
+ const value = readSpecifierValue(toolName, input);
117
+ // Unknown specifier source: we cannot verify the rule, so we do not honor it.
118
+ if (value === null)
119
+ continue;
120
+ if (specifierMatches(rule.specifier, value))
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+ /** The message handed back to the model when a call is blocked. */
126
+ export function formatDenialMessage(toolName, rules) {
127
+ const listed = rules
128
+ .map((rule) => (rule.specifier === undefined ? rule.tool : `${rule.tool}(${rule.specifier})`))
129
+ .join(", ");
130
+ return (`Tool "${toolName}" is blocked by this run's tool allowlist and was not executed. ` +
131
+ `Allowed: ${listed}. Do not retry this tool; achieve the goal with an allowed tool or report that you cannot.`);
132
+ }
133
+ //# sourceMappingURL=tool-allowlist.js.map
@@ -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;
@@ -58,6 +58,10 @@ export declare class PtyQuery implements AsyncGenerator<SDKMessage, void> {
58
58
  private done;
59
59
  private readonly readyPromise;
60
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;
61
65
  /**
62
66
  * Cancels the readiness gate and its timers.
63
67
  *
@@ -133,6 +137,16 @@ export declare class PtyQuery implements AsyncGenerator<SDKMessage, void> {
133
137
  * agent running by the time the input loop sees it: silence here is a forever-spinner.
134
138
  */
135
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;
136
150
  /** Tell the provider the dialog state changed; never let a listener throw into the loop. */
137
151
  private notifyDialogChange;
138
152
  /** The dialog the TUI is waiting on, if any. */
@@ -344,6 +358,24 @@ export declare class PtyQuery implements AsyncGenerator<SDKMessage, void> {
344
358
  */
345
359
  private readTerminalTail;
346
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;
347
379
  private buildStalledResult;
348
380
  interrupt(): Promise<void>;
349
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.
@@ -245,6 +246,10 @@ export class PtyQuery {
245
246
  this.outResolvers = [];
246
247
  this.done = false;
247
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;
248
253
  /**
249
254
  * Cancels the readiness gate and its timers.
250
255
  *
@@ -311,6 +316,12 @@ export class PtyQuery {
311
316
  // session so a stall has something to explain itself with. Chunks arrive already redacted (PtyTransport.onData
312
317
  // runs redactChunk), so nothing further is needed here.
313
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));
314
325
  // The executor stays trivial and settlement lives outside it, so there is exactly one
315
326
  // resolve() call in the file. Timers and handlers below all funnel through settle(),
316
327
  // which is guarded by `settled`; expressing that inside the executor tripped
@@ -467,6 +478,8 @@ export class PtyQuery {
467
478
  this.offReadyData?.();
468
479
  this.offTerminalTap?.();
469
480
  this.offTerminalTap = null;
481
+ this.offExitTap?.();
482
+ this.offExitTap = null;
470
483
  this.stopHeartbeat();
471
484
  this.recordLine("--- PtyQuery detached ---");
472
485
  try {
@@ -533,6 +546,13 @@ export class PtyQuery {
533
546
  this.emit(this.buildCommandRejectedResult(notice));
534
547
  continue;
535
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
+ }
536
556
  if (delivery === "undelivered") {
537
557
  // No transcript receipt for the whole budget: the message never became a turn,
538
558
  // nothing was queued, and only the sender can decide what to do next.
@@ -620,11 +640,27 @@ export class PtyQuery {
620
640
  is_error: true,
621
641
  errors: [
622
642
  `Your message had nothing the terminal transport could deliver: no text, and no ` +
623
- `image it could stage to disk. Nothing was sent, so please resend it with text. ` +
624
- `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()}`,
625
645
  ],
626
646
  };
627
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
+ }
628
664
  /** Tell the provider the dialog state changed; never let a listener throw into the loop. */
629
665
  notifyDialogChange() {
630
666
  if (!this.onDialogChange)
@@ -727,8 +763,7 @@ export class PtyQuery {
727
763
  is_error: true,
728
764
  errors: [
729
765
  `Your message did not reach the agent: the terminal would not accept it. ` +
730
- `Nothing was sent, so please send it again. The session itself is still ` +
731
- `running.${tailLine}`,
766
+ `Nothing was sent, so please send it again.${this.stillRunningClause()}${tailLine}`,
732
767
  ],
733
768
  };
734
769
  }
@@ -897,6 +932,11 @@ export class PtyQuery {
897
932
  let backoff = DEAF_RETRY_INITIAL_MS;
898
933
  let attempt = 0;
899
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";
900
940
  attempt += 1;
901
941
  const attemptStartedAt = monotonicNowMs();
902
942
  // Empty the input box first: whatever an earlier attempt may have left there is
@@ -904,7 +944,17 @@ export class PtyQuery {
904
944
  // became nine stacked paste blocks in production.
905
945
  if (attempt > 1)
906
946
  await this.clearComposer();
907
- 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
+ }
908
958
  const submitted = await this.submitTurn(text, watermark, fingerprint);
909
959
  if (submitted === "rejected")
910
960
  return "rejected";
@@ -1426,7 +1476,62 @@ export class PtyQuery {
1426
1476
  };
1427
1477
  return result;
1428
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
+ }
1429
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
+ }
1430
1535
  const minutes = Math.round(silentForMs / 60000);
1431
1536
  const base = this.buildResult("stall_timeout");
1432
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
@@ -49,6 +49,25 @@ function getNodePty() {
49
49
  export function __setNodePtyForTesting(stub) {
50
50
  nodePty = stub;
51
51
  }
52
+ /**
53
+ * Thrown when something tries to write to a pty whose process has already exited.
54
+ *
55
+ * This has a dedicated type because the two failure modes read identically at the call site
56
+ * and must not: "never spawned" is a programming error, while "already exited" is a normal
57
+ * runtime event that the layer above has to react to by failing the turn. Before this class
58
+ * existed, `doWrite` happily called `_pty.write()` on a dead pty, node-pty swallowed it, and
59
+ * `waitForEcho()`'s fixed timer reported success - so paseo typed prompts into a corpse for
60
+ * fourteen retries and then told the user "the session itself is still running".
61
+ */
62
+ export class PtyExitedError extends Error {
63
+ constructor(op, exitCode = null, exitSignal = null) {
64
+ super(`PtyTransport.${op} called after the pty process exited`);
65
+ this.code = "PTY_EXITED";
66
+ this.name = "PtyExitedError";
67
+ this.exitCode = exitCode;
68
+ this.exitSignal = exitSignal;
69
+ }
70
+ }
52
71
  export class PtyTransport {
53
72
  constructor() {
54
73
  this._pty = null;
@@ -57,12 +76,28 @@ export class PtyTransport {
57
76
  this._rows = DEFAULT_ROWS;
58
77
  this._killed = false;
59
78
  this._exited = false;
79
+ this._exitInfo = null;
80
+ this.exitHandlers = [];
60
81
  this.injectChain = Promise.resolve();
61
82
  this.hookHandlers = [];
62
83
  this.hookBuffer = [];
63
84
  this.bridgeClose = null;
64
85
  this.systemPromptFilePath = null;
65
86
  }
87
+ /**
88
+ * Has the child process exited?
89
+ *
90
+ * Public because guarding the write paths fixes the symptom but leaves the question
91
+ * unanswerable: `_exited` was private, so no caller could ask a transport whether its
92
+ * child was alive without provoking an error. `AgentTransport` now declares it.
93
+ */
94
+ get exited() {
95
+ return this._exited;
96
+ }
97
+ /** Exit status once the child is gone, else null. */
98
+ get exitInfo() {
99
+ return this._exitInfo ? { ...this._exitInfo } : null;
100
+ }
66
101
  /**
67
102
  * Bridge-side / test entry point — push a hook event into this transport's
68
103
  * stream. If no `onHookEvent` handler is registered yet, the event is
@@ -114,8 +149,24 @@ export class PtyTransport {
114
149
  cwd: opts.cwd,
115
150
  env: opts.env,
116
151
  });
117
- this._pty.onExit(() => {
152
+ // ONE internal subscription owns exit state and fans it out to every public onExit()
153
+ // handler. Registering handlers straight onto node-pty (the previous shape) meant a
154
+ // subscriber that attached AFTER the child had already died heard nothing at all -- and
155
+ // a PtyQuery attaches to a long-lived terminal, not only to a fresh boot, so that is a
156
+ // reachable case and not a theoretical one.
157
+ this._pty.onExit(({ exitCode, signal }) => {
158
+ const code = typeof exitCode === "number" ? exitCode : null;
159
+ const sig = signal ?? null;
118
160
  this._exited = true;
161
+ this._exitInfo = { code, signal: sig };
162
+ for (const h of this.exitHandlers) {
163
+ try {
164
+ h(code, sig);
165
+ }
166
+ catch {
167
+ // one bad subscriber must not stop the others hearing about the death
168
+ }
169
+ }
119
170
  });
120
171
  }
121
172
  write(text) {
@@ -138,6 +189,9 @@ export class PtyTransport {
138
189
  if (!this._pty) {
139
190
  throw new Error("PtyTransport.writeRaw called before spawn()");
140
191
  }
192
+ if (this._exited) {
193
+ throw new PtyExitedError("writeRaw", this._exitInfo?.code ?? null, this._exitInfo?.signal ?? null);
194
+ }
141
195
  this._pty.write(data);
142
196
  await this.waitForEcho();
143
197
  }
@@ -148,6 +202,9 @@ export class PtyTransport {
148
202
  if (!this._pty) {
149
203
  throw new Error("PtyTransport.write called before spawn()");
150
204
  }
205
+ if (this._exited) {
206
+ throw new PtyExitedError("write", this._exitInfo?.code ?? null, this._exitInfo?.signal ?? null);
207
+ }
151
208
  this._pty.write(this.bracketedPaste(text));
152
209
  await this.waitForEcho();
153
210
  }
@@ -216,10 +273,17 @@ export class PtyTransport {
216
273
  if (!this._pty) {
217
274
  throw new Error("PtyTransport.onExit called before spawn()");
218
275
  }
219
- const disposable = this._pty.onExit(({ exitCode, signal }) => {
220
- handler(typeof exitCode === "number" ? exitCode : null, signal ?? null);
221
- });
222
- return () => disposable.dispose();
276
+ this.exitHandlers.push(handler);
277
+ // Already dead: fire, rather than never. Deferred a microtask so a subscriber that
278
+ // registers from a constructor (PtyQuery does) cannot be re-entered before it has
279
+ // finished building itself.
280
+ if (this._exited) {
281
+ const info = this._exitInfo;
282
+ queueMicrotask(() => handler(info?.code ?? null, info?.signal ?? null));
283
+ }
284
+ return () => {
285
+ this.exitHandlers = this.exitHandlers.filter((h) => h !== handler);
286
+ };
223
287
  }
224
288
  context() {
225
289
  if (!this._pty) {
@@ -53,6 +53,8 @@ export declare class SdkTransport implements AgentTransport {
53
53
  createQuery(input: ClaudeQueryInput): Query;
54
54
  spawn(opts: AgentTransportSpawnOptions): Promise<void>;
55
55
  write(text: string): Promise<void>;
56
+ /** True once the SDK query has been closed/returned. Mirrors PtyTransport.exited. */
57
+ get exited(): boolean;
56
58
  kill(timeoutMs?: number): Promise<void>;
57
59
  onHookEvent(_handler: (event: HookEvent) => void): () => void;
58
60
  onData(_handler: (chunk: string) => void): () => void;
@@ -135,6 +135,10 @@ export class SdkTransport {
135
135
  const queryWithInput = this._query;
136
136
  await queryWithInput.input?.send(message);
137
137
  }
138
+ /** True once the SDK query has been closed/returned. Mirrors PtyTransport.exited. */
139
+ get exited() {
140
+ return this._exited;
141
+ }
138
142
  async kill(timeoutMs = 5000) {
139
143
  if (this._killed)
140
144
  return;
@@ -29,6 +29,14 @@ export type AgentTransportContext = {
29
29
  cwd: string;
30
30
  };
31
31
  export interface AgentTransport {
32
+ /**
33
+ * True once the underlying agent process has exited.
34
+ *
35
+ * Optional so a transport that genuinely cannot know stays honest by omitting it;
36
+ * `undefined` means "no liveness signal" and must not be read as "alive". Both shipped
37
+ * transports implement it.
38
+ */
39
+ readonly exited?: boolean;
32
40
  spawn(opts: AgentTransportSpawnOptions): Promise<void>;
33
41
  write(text: string): Promise<void>;
34
42
  /**
@@ -8,6 +8,7 @@ import type { TerminalManager } from "../../../terminal/terminal-manager.js";
8
8
  import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js";
9
9
  import type { JudgeRelayGate } from "../judge-relay-gate.js";
10
10
  import type { ScheduleService } from "../../schedule/service.js";
11
+ import { type ScheduleCadence } from "@hyperdrive.bot/paseo-protocol/schedule/types";
11
12
  import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
12
13
  import type { GitHubService } from "../../../services/github-service.js";
13
14
  import type { WorkspaceGitService } from "../../workspace-git-service.js";
@@ -62,5 +63,23 @@ export interface PaseoToolHostDependencies {
62
63
  judgeRelayGate?: JudgeRelayGate;
63
64
  logger: Logger;
64
65
  }
66
+ export interface ScheduleCreateCadenceArgs {
67
+ at?: string;
68
+ every?: string;
69
+ cron?: string;
70
+ timezone?: string;
71
+ }
72
+ export interface ResolvedScheduleCreateCadence {
73
+ cadence: ScheduleCadence;
74
+ /**
75
+ * `at` is a one-off. It lowers to an `every` cadence whose interval is the
76
+ * distance to the instant, pinned to a single run. An `every` schedule stores
77
+ * the computed `nextRunAt`, so a daemon that was down at that moment fires it
78
+ * late on the next tick, which is what a reminder wants. A pinned cron would
79
+ * instead skip silently to the next calendar match a year away.
80
+ */
81
+ oneOff: boolean;
82
+ }
83
+ export declare function resolveScheduleCreateCadence(input: ScheduleCreateCadenceArgs, now: Date): ResolvedScheduleCreateCadence;
65
84
  export declare function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog;
66
85
  //# sourceMappingURL=paseo-tools.d.ts.map