pi-supernova 0.8.1 → 0.8.2

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 CHANGED
@@ -12,7 +12,35 @@ Ordinary JavaScript control flow remains available; the guest command bindings
12
12
  are only `read`, `edit`, `write`, and `bash`. Supernova supplies retrieval,
13
13
  transactional file operations, batching, bounded results and the grouped nova UI.
14
14
 
15
- ## What is new in 0.8.0
15
+ ## What is new in 0.8.2
16
+
17
+ This patch release fixes shell failure handling, cancellation and parallel-batch
18
+ limits, and makes one-call batching guidance explicit.
19
+
20
+ - **Shell quoting stays intact:** quoted executable paths are no longer unwrapped.
21
+ Shell syntax errors suggest literal `bash({command,args})` with `data` for
22
+ embedded scripts, or a quoted heredoc. Commands are not rewritten or retried.
23
+ - **Validation before commit:** invalid timeouts and null-byte
24
+ arguments are rejected before the shell boundary flushes staged files.
25
+ - **Useful failure output:** long command labels are bounded so the original
26
+ stderr is not crowded out by a repeated script.
27
+ - **Timeouts retain diagnostics:** the outer program deadline stops the worker
28
+ and gives pending host calls a bounded drain to retain shell output. Explicit
29
+ cancellation is reported separately from timeout. The outer `timeoutMs` covers
30
+ every wait and command, including `sleep`.
31
+ - **Parallel budgets fail honestly:** exceeding the shared output, log or image
32
+ allowance marks the batch failed and stops queued entries. Already-running
33
+ entries settle; their results and completed commits remain. Aggregate logs stay
34
+ capped rather than multiplying the allowance per guest.
35
+ - **Batch known work in one call:** combine independent reads/checks with
36
+ `Promise.all`, then sequence edits and verification in the same program. Use
37
+ another invocation when returned evidence is needed for the next decision.
38
+
39
+ Verified on **macOS / Node 26.7**: 267 package tests (384 repository tests),
40
+ 2,328 stress invocations, actual Pi/OMP host checks, lint and both token-budget
41
+ checks. This is not a claim of exhaustive platform or formal mutation testing.
42
+
43
+ ## 0.8.0 features and measurements
16
44
 
17
45
  - **Shared program source:** top-level `code` or `file` supplies a batch default;
18
46
  entries may override it. A shared program is sent once instead of in every entry,
@@ -81,14 +109,12 @@ Local checkout installs are for development, not distribution:
81
109
  pi install /path/to/pi-stack/packages/pi-supernova
82
110
  ```
83
111
 
84
- Git pushes do not update npm installations. Publish the new npm version first;
85
- then reinstall it in the host. Reinstall explicitly when an existing version
86
- range excludes the new minor version (`^0.6.0` excludes `0.7.0`). After 0.7.0 is
87
- published, pin that release with:
112
+ Git pushes do not update npm installations. Publish the new npm version first,
113
+ then reinstall it in your host. To pin **0.8.2** once it is published:
88
114
 
89
115
  ```bash
90
- pi install npm:pi-supernova@0.7.0
91
- omp install npm:pi-supernova@0.7.0
116
+ pi install npm:pi-supernova@0.8.2
117
+ omp install npm:pi-supernova@0.8.2
92
118
  ```
93
119
 
94
120
  In Pi, `pi list` shows the configured package sources. A local path uses that
@@ -135,6 +161,21 @@ payloads are not repeated in owned direct-execution errors;
135
161
  stdout/stderr, exit status and source context remain. Session environment variables are taken
136
162
  from the current execution context, not inherited from a different parent session.
137
163
 
164
+ Shell strings are executed unchanged, including quoted executable paths. For inline
165
+ Python/Node scripts, prefer literal argv with `data` instead of nested shell quotes:
166
+
167
+ ```json
168
+ {
169
+ "code": "return await bash({command:\"python3\",args:[\"-c\",data.script]});",
170
+ "data": {"script": "q = {'name': 'example'}\nprint(f\"{q['name']}\")\n"}
171
+ }
172
+ ```
173
+
174
+ Shell syntax errors keep the original diagnostic and suggest argv or a quoted
175
+ heredoc; commands are never automatically rewritten or retried. Invalid timeouts
176
+ and null-byte arguments fail before flushing staged changes. Failure labels are
177
+ bounded so a large script cannot crowd out its stderr.
178
+
138
179
  Source questions locate a declaration in one command. An exact
139
180
  declaration match uses one bounded direct ripgrep search, without a prerequisite
140
181
  file listing, persistent index, embeddings or summarization. A transient filename
@@ -349,6 +390,9 @@ Set `parallel: true` with `programs` to run independent entries concurrently
349
390
  in submission order. A failed entry does not stop siblings. Two entries writing
350
391
  the same file race: the losing commit reports a conflict. Sequential remains the
351
392
  default. `parallel` and `mergeData` are invalid on a lone `code` or `file` call.
393
+ Budget overflow marks the batch failed and stops queued entries; already-running
394
+ entries settle and their completed commits remain. Parallel execution does not
395
+ multiply the aggregate output, log, or image allowance.
352
396
 
353
397
  The outer deadline, host-call budget, log allowance, text budget and image limits
354
398
  are shared across the batch. Individual read budgets are not reduced. Every
@@ -380,10 +424,13 @@ For long archive scans, use resumable chunks or a host background-job tool and w
380
424
  progress records under `.work`. Shell commands inherit the current program
381
425
  `timeoutMs` unless they specify their own; increasing the outer deadline no longer
382
426
  leaves a hidden 60-second shell cap. Set the inner `bash` timeout shorter than the
383
- outer program timeout (for example 10 seconds inside a 20-second program) to retain
384
- bounded shell diagnostics. A hard guest deadline cannot guarantee pending shell
385
- output delivery; progress files survive shell execution but staged VFS writes may
386
- roll back.
427
+ outer program timeout (for example 10 seconds inside a 20-second program). The outer
428
+ deadline covers **all** waits and commands, including `sleep`; a shell's own `timeout`
429
+ command does not extend it. On a deadline or cancellation, the worker stops and
430
+ pending host calls get a bounded 250ms drain to retain owned-shell diagnostics and
431
+ finalize process termination. Non-cooperating host executors may still outlive that
432
+ drain. Cancellation is reported separately from timeout; neither triggers a retry.
433
+ Progress files survive shell execution but staged VFS writes may roll back.
387
434
 
388
435
  Large returned objects are bounded previews, not retained artifacts. Select fields
389
436
  and array windows before returning, rather than parsing a truncated preview.
@@ -432,6 +479,12 @@ continuation handles.
432
479
 
433
480
  ## Execution and automatic batching
434
481
 
482
+ Put already-known independent reads and checks in **one** Supernova program using
483
+ `Promise.all` (or `Promise.allSettled` when failures should remain independent).
484
+ Sequence edits and their known verification in that same program. Start another
485
+ invocation only when the returned evidence is needed to decide what to do next;
486
+ use focused read windows to keep the combined result within its output budget.
487
+
435
488
  Compatible independently started reads coalesce at the worker/host boundary.
436
489
  No additional batching command is required. Individual promises preserve their
437
490
  values, errors and per-read budgets. File reads have bounded parallelism; writes,
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## [0.8.2] - 2026-09-19
6
+
7
+ ### Fixed
8
+
9
+ - Shell commands preserve quoted executable paths. Parser failures suggest
10
+ literal argv for embedded scripts or a quoted heredoc, without rewriting or
11
+ automatically retrying commands.
12
+ - Invalid timeouts and null-byte arguments fail before flushing staged files.
13
+ Bounded command labels keep long scripts from crowding out diagnostics.
14
+ - Program deadlines retain pending shell output during bounded termination;
15
+ explicit cancellation is no longer misreported as a timeout.
16
+ - Parallel batches enforce shared output, log and image budgets instead of
17
+ reporting success after overflow. Queued entries stop, in-flight results and
18
+ completed commits remain, and aggregate logs stay capped.
19
+
20
+ ### Changed
21
+
22
+ - Guidance explicitly batches known reads/checks and edits/verification in one
23
+ invocation, and states that the outer deadline includes all waits and commands.
24
+
25
+ ### Internals
26
+
27
+ - 267 package tests (384 repository tests), 2,328 stress invocations, actual
28
+ Pi + OMP host checks, lint and both frozen token gates passed on macOS/Node 26.7.
29
+
5
30
  ## [0.8.1] - 2026-09-19
6
31
 
7
32
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "CodeMode for Pi and OMP: read, edit, write and bash, with transactional files, source views and shared-input program batches.",
5
5
  "type": "module",
6
6
  "author": "AdityaVG13",
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import { isString } from "../shared/decode.js";
3
- import { unwrapIfFullyQuoted } from "../fs/text-ops.js";
3
+ import { normalizeBash } from "../contract/bash.js";
4
4
  import { sourceForReferences } from "../fs/source-window.js";
5
5
  import { resolveWorkspacePath, runCommand, clearPathCache } from "../fs/workspace.js";
6
6
 
@@ -17,7 +17,7 @@ export function createBash(ctx) {
17
17
  function bashCommand(params, literal) {
18
18
  if (params?.command !== undefined && !isString(params.command)) throw new Error("bash command must be a string");
19
19
  if (literal && (!isString(params.command) || params.args.some(arg => !isString(arg)))) throw new Error("bash argv requires a command string and an array of string args");
20
- const command = literal ? String(params.command) : unwrapIfFullyQuoted(String(params?.command ?? "").trim());
20
+ const command = String(params?.command ?? "");
21
21
 
22
22
  if (!command.trim()) throw new Error("bash requires command");
23
23
 
@@ -32,6 +32,7 @@ export function createBash(ctx) {
32
32
  }
33
33
 
34
34
  async function bash(params, signal) {
35
+ params = normalizeBash(params);
35
36
  const cwd = getCwd();
36
37
  const { literal, command, argv } = parseBash(params);
37
38
  const targetCwd = params?.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
@@ -49,7 +50,7 @@ export function createBash(ctx) {
49
50
  res = await runCommand(argv, {
50
51
  cwd: targetCwd,
51
52
  env: hooks.commandEnv(),
52
- commandLabel: literal ? command : undefined,
53
+ commandLabel: command,
53
54
  timeoutMs: params?.timeoutMs === undefined ? config.timeoutMs : params.timeoutMs,
54
55
  signal,
55
56
  maxOutputChars: config.maxCallResultChars,
@@ -67,7 +68,12 @@ export function createBash(ctx) {
67
68
  const { stdout, stderr } = res;
68
69
  let text = combineBashText(stdout, stderr);
69
70
 
70
- if (res.exitCode !== 0) text += await sourceForReferences(cwd, targetCwd, text, signal, ledger);
71
+ if (res.exitCode !== 0) {
72
+ if (!literal && /\bbash: (?:-c: )?line \d+: (?:syntax error|unexpected EOF)/.test(stderr)) {
73
+ text += '\nhint: Bash could not parse the command. For embedded scripts use literal argv, e.g. bash({command:"python3",args:["-c",data.script]}), or a quoted heredoc for shell pipelines. Do not blindly retry: earlier commands may have run.';
74
+ }
75
+ text += await sourceForReferences(cwd, targetCwd, text, signal, ledger);
76
+ }
71
77
 
72
78
  return {
73
79
  content: [{ type: "text", text }],
@@ -17,7 +17,10 @@ function normalizeArgv(args) {
17
17
  throw new Error(`${ARGV_ERROR}; args[${i}] is ${type}; check the supplied data fields and pass each argument as a string`);
18
18
  }
19
19
  }
20
- args.args = args.args.map(String);
20
+ args.args = args.args.map((arg, i) => {
21
+ if (arg.includes("\0")) throw new Error(`bash args[${i}] must not contain null bytes`);
22
+ return String(arg);
23
+ });
21
24
 
22
25
  if (process.platform === "win32") {
23
26
  delete args._directArgv;
@@ -37,9 +40,17 @@ function assertBashOptions(args) {
37
40
  export function normalizeBash(command, opts) {
38
41
  const args = isObject(command) ? { ...opts, ...command } : { command, ...opts };
39
42
  assertBashOptions(args);
43
+ if (!isString(args.command) || !args.command.trim()) throw new Error("bash requires a non-empty command string");
44
+ if (args.command.includes("\0")) throw new Error("bash command must not contain null bytes");
40
45
  normalizeArgv(args);
41
46
 
42
47
  if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
48
+ // Reject before the host's external-mutation barrier can flush staged files.
49
+ if (args.timeoutMs !== undefined) {
50
+ const timeout = Number(args.timeoutMs);
51
+ if (!Number.isFinite(timeout) || timeout <= 0) throw new Error("command timeoutMs must be a positive finite number");
52
+ args.timeoutMs = Math.max(1, Math.min(2_147_483_647, Math.floor(timeout)));
53
+ }
43
54
 
44
55
  return args;
45
56
  }
@@ -81,21 +81,6 @@ export function resultDiff(response) {
81
81
  return isObject(details) ? details.diff : undefined;
82
82
  }
83
83
 
84
- /** Unwrap a single matching quote pair around the whole string (`'git status'`). */
85
- export function unwrapIfFullyQuoted(s) {
86
- if (s.length < 2) return s;
87
- const q = s[0];
88
-
89
- if (q !== "'" && q !== '"') return s;
90
-
91
- if (s[s.length - 1] !== q) return s;
92
- const inner = s.slice(1, -1);
93
-
94
- if (inner.includes(q)) return s;
95
-
96
- return inner;
97
- }
98
-
99
84
  function totalContentLines(text) {
100
85
  if (text === "") return 1;
101
86
 
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { constants } from "node:os";
5
5
  import { isString } from "../shared/decode.js";
6
+ import { truncateChars } from "../output/format.js";
6
7
 
7
8
  let cachedCwd = null;
8
9
 
@@ -211,7 +212,10 @@ function attachCommandIO(state, options, argv, timeoutMs) {
211
212
  clearTimeout(state.escalation);
212
213
  options.signal?.removeEventListener("abort", onAbort);
213
214
  };
214
- state.timer = setTimeout(() => terminateCommand(state, new Error("command timed out after " + timeoutMs + "ms: " + (options.commandLabel ?? argv.join(" ")))), timeoutMs);
215
+ state.timer = setTimeout(() => terminateCommand(state, new Error(
216
+ "command timed out after " + timeoutMs + "ms: " + truncateChars(options.commandLabel ?? argv.join(" "), 240, "command").text
217
+ + "\nhint: Increase this bash timeoutMs and the outer supernova timeoutMs, or split the work. Sleeps and every command in a shell chain share the same limit."
218
+ )), timeoutMs);
215
219
  child.stdout.setEncoding("utf8");
216
220
  child.stderr.setEncoding("utf8");
217
221
  child.stdout.on("data", chunk => { state.stdout = appendCommandOutput(state, state.stdout, chunk); });
@@ -256,7 +256,7 @@ function formatBashFailure(command, res) {
256
256
  const output = String(res.value).trimEnd();
257
257
  const suffix = Number.isInteger(exitCode) ? " (exit " + exitCode + ")" : "";
258
258
 
259
- return "command failed" + suffix + ": " + command + (output ? "\n" + output : "");
259
+ return "command failed" + suffix + ": " + truncateChars(command, 240, "command").text + (output ? "\n" + output : "");
260
260
  }
261
261
 
262
262
  function markTruncatedOutput(res, text) {
@@ -156,16 +156,35 @@ class ProgramBatch {
156
156
  this.collectImages(result, i);
157
157
  }
158
158
 
159
+ parallelBudgetStop(settled) {
160
+ const results = settled.filter(Boolean);
161
+ let images = 0, bytes = 0, labelChars = 0;
162
+ for (const [i, result] of settled.entries()) {
163
+ let imageSeq = 0;
164
+ for (const block of result?.content ?? []) if (block.type === "image" && isString(block.data)) {
165
+ images++;
166
+ bytes += Buffer.byteLength(block.data, "base64");
167
+ labelChars += ("program " + (i + 1) + " image " + (++imageSeq)).length + 1;
168
+ }
169
+ }
170
+ let kind;
171
+ if (images > 16 || bytes > 20 * 1024 * 1024) kind = "image";
172
+ else if (results.some(result => result.details?.returnTruncated) || programBatchText(results, this.programs.length).length + labelChars > this.config.maxReturnChars) kind = "output";
173
+ else if (results.some(result => result.details?.logTruncated) || results.reduce((n, result) => n + (result.details?.logs?.length ?? 0), 0) > (this.config.maxLogLines ?? 100)) kind = "log";
174
+ return kind ? "batch " + kind + " budget exceeded; completed commits remain" : "";
175
+ }
176
+
159
177
  async runParallel() {
160
178
  const limit = Math.min(this.programs.length, MAX_PARALLEL_PROGRAMS);
161
179
  const settled = Array.from({ length: this.programs.length });
162
180
  let next = 0;
163
181
 
164
182
  await Promise.all(Array.from({length: limit}, async () => {
165
- while (next < this.programs.length && !this.combined.aborted && performance.now() < this.deadline) {
183
+ while (next < this.programs.length && !this.stopped && !this.combined.aborted && performance.now() < this.deadline) {
166
184
  const i = next++;
167
185
  settled[i] = await this.runOne(this.programs[i], i);
168
186
  this.live[i] = [];
187
+ this.stopped ||= this.parallelBudgetStop(settled);
169
188
  }
170
189
  }));
171
190
 
@@ -174,7 +193,7 @@ class ProgramBatch {
174
193
  this.takeSettled(settled[i], i);
175
194
  }
176
195
 
177
- if (settled.includes(undefined) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
196
+ if ((!this.stopped && settled.includes(undefined)) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
178
197
  }
179
198
 
180
199
  sequentialStop(result, i) {
@@ -222,6 +241,8 @@ class ProgramBatch {
222
241
  const failed = this.results.filter(result => result.details?.ok === false).length;
223
242
  const bounded = this.boundedText(failed);
224
243
  const content = [{type:"text",text:bounded.text}];
244
+ const logs = this.results.flatMap(result => result.details?.logs ?? []);
245
+ const logLimit = this.config.maxLogLines ?? 100;
225
246
  this.images.forEach((image,i) => content.push({type:"text",text:this.imageLabels[i]},image));
226
247
 
227
248
  // Return a typed stop report instead of throwing away earlier results/images.
@@ -230,7 +251,7 @@ class ProgramBatch {
230
251
  programs:this.results,attempted:this.results.length,total:this.programs.length,stopped:this.stopped,parallel:this.parallel,
231
252
  result:bounded.truncated ? bounded.text : this.results.map(result=>result.details?.result),
232
253
  returnTruncated:bounded.truncated || this.results.some(result=>result.details?.returnTruncated),
233
- logTruncated:this.results.some(result=>result.details?.logTruncated),logs:this.results.flatMap(result=>result.details?.logs ?? []),trace:this.trace,mutations:mutationTotals(this.results)}};
254
+ logTruncated:logs.length > logLimit || this.results.some(result=>result.details?.logTruncated),logs:logs.slice(0,logLimit),trace:this.trace,mutations:mutationTotals(this.results)}};
234
255
  }
235
256
 
236
257
  async run() {
@@ -1,15 +1,15 @@
1
1
  // Standing tool description: sent on every request. No result or history compression.
2
- export const REFERENCE = `JS body/async arrow with read/write/edit/bash; no fs/import/require. file runs workspace scripts. data holds literal text/scripts/argv (≤48000 serialized JSON chars).
3
- read(path|paths,offset=1,limit?) → raw text/text[]; directories → entries; images: PNG/JPEG/GIF/WebP (≤16 attachments/20 MiB total).
4
- Path-only: ≤160 lines AND 8192 characters (UTF-16). Use read(path,{offset:1,limit:80}), about, or complete:true (whole file ≤31744 chars). Large JSONL: bounded parser via bash.
5
- read({path,json:selector}) → parsed JSON; selectors ".field", ".a[0:3]", ".a.length", quoted keys, true; 16 MiB input, no jq. Oversized → {status:"too_large",keys} (arrays: length); narrow selectors.
2
+ export const REFERENCE = `JS body/async arrow: read/write/edit/bash; no fs/import/require. file: workspace scripts; data: literals (≤48000 JSON chars).
3
+ read(path|paths,offset=1,limit?) → raw text/text[]; directories → entries; images: PNG/JPEG/GIF/WebP (≤16 images/20 MiB).
4
+ Path-only ≤160 lines AND 8192 characters (UTF-16); larger: read(path,{offset:1,limit:80}), about, or complete:true (whole file ≤31744 chars). Large JSONL: bounded bash parser.
5
+ read({path,json:selector}) → parsed JSON: ".field", ".a[0:3]", ".a.length", quoted keys, true; ≤16 MiB, no jq. {status:"too_large",keys|length} → narrow selector.
6
6
  read("symbol or question") = read({query,resolve:true}) → view; check status; view.text is a span, not the file.
7
7
  read(path,{about}) → windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations.
8
8
  write(path,text) replaces unread workspace files; write({path,content,append:true}) appends without reading. After read: edit or replace:true.
9
- edit(path,oldText,newText) | edit({path,edits:[{oldText,newText}]}) exact unique read text; numbered windows (including misses), checks/references.
10
- edit(view,text) replaces the span; edit(view,old,new) matches uniquely within it. edit(async()=>{...}) checkpoint merges on success, rolls back/rethrows on failure; catch to recover.
11
- bash(command,{cwd?,timeoutMs?}) | bash({command,args}) literal argv; bounded output, nonzero throws; inherits program timeout unless overridden.
9
+ edit(path,oldText,newText) | edit({path,edits:[{oldText,newText}]}) unique exact read text; returns numbered windows/checks/references.
10
+ edit(view,text) replaces span; edit(view,old,new) uniquely matches within it. edit(async()=>{...}) checkpoint: merge on success, rollback/rethrow on failure.
11
+ bash(command,{cwd?,timeoutMs?}) | bash({command,args}) literal argv for scripts; bounded output, nonzero throws. Outer timeoutMs caps ALL waits/commands; bash inherits unless overridden.
12
12
  Edits stage until success; bash commits first. Array errors abort; Promise.allSettled for optional reads.
13
- programs:[{code?,file?,data?}] inherits top-level code OR file and data. Entry source overrides; data replaces unless mergeData:true (shallow objects, entry keys win).
14
- Fresh guests/separate commits; sequential failure stops, prior commits stay. parallel:true for disjoint entries. Batch known work; separate calls only for new decisions.
13
+ programs:[{code?,file?,data?}] inherits code OR file and data. Entries override source/data; mergeData:true shallow-merges objects (entry keys win).
14
+ Fresh guests/separate commits; sequential failure stops, prior commits stay. parallel:true for disjoint entries. Batch known reads/checks (Promise.all) + edits/verification in ONE call; split for new decisions.
15
15
  `;
@@ -10,7 +10,8 @@ import { errorContext } from "../shared/syntax-context.js";
10
10
 
11
11
  const WORKER_URL = new URL("./guest-worker.js", import.meta.url);
12
12
 
13
- const ABORT_MESSAGE = "supernova timed out or aborted: pass timeoutMs to allow longer runs, or split the program";
13
+ const ABORT_MESSAGE = "supernova aborted";
14
+ const TIMEOUT_MESSAGE = "supernova timed out: increase the outer timeoutMs (and any shorter bash timeoutMs), or split the program; sleeps count toward the deadline";
14
15
 
15
16
  const MEMORY_POLL_MS = 50;
16
17
 
@@ -281,6 +282,7 @@ class GuestRun {
281
282
  this.hostError = undefined;
282
283
  this.notifyingHost = false;
283
284
  this.aborting = false;
285
+ this.abortOutcome = undefined;
284
286
  this.pending = new Set();
285
287
  this.inputController = new AbortController();
286
288
  this.rpcCount = 0;
@@ -320,15 +322,18 @@ class GuestRun {
320
322
  try { this.nova.cancel?.(); } catch {} finally { this.notifyingHost = false; }
321
323
  }
322
324
 
323
- abort() {
325
+ abort(timedOut = false) {
324
326
  if (this.finished || this.aborting) return;
325
327
  this.aborting = true;
328
+ this.accepting = false;
329
+ this.abortOutcome = this.fail((timedOut ? TIMEOUT_MESSAGE : ABORT_MESSAGE) + " (ran " + this.wall() + "ms of " + this.timeoutMs + "ms)");
326
330
  this.cancelHost();
327
331
 
328
- try { this.onTimeout?.(); } catch {}
332
+ if (timedOut) { try { this.onTimeout?.(); } catch {} }
329
333
 
330
- this.aborting = false;
331
- this.finish(this.fail(ABORT_MESSAGE + " (ran " + Math.round(this.wall()) + "ms of " + this.timeoutMs + "ms)"));
334
+ // Stop the guest immediately, then use the bounded host drain to retain
335
+ // shell diagnostics and wait for process-tree termination before returning.
336
+ void this.complete(this.abortOutcome);
332
337
  }
333
338
 
334
339
  postResult(message) {
@@ -365,6 +370,10 @@ class GuestRun {
365
370
  void killWorker(this.handle);
366
371
  await this.drainPending(outcome);
367
372
  if (this.finished) return;
373
+ if (this.abortOutcome) {
374
+ const diagnostic = this.hostError && this.hostError !== "aborted" ? "\n" + this.hostError : "";
375
+ outcome = this.fail(this.abortOutcome.error + diagnostic);
376
+ }
368
377
  this.finish(outcome.ok && this.hostError ? this.fail(this.hostError) : outcome);
369
378
  }
370
379
 
@@ -448,7 +457,7 @@ class GuestRun {
448
457
  }
449
458
 
450
459
  async attachWorker() {
451
- if (this.wall() >= this.timeoutMs) { this.abort(); return false; }
460
+ if (this.wall() >= this.timeoutMs) { this.abort(true); return false; }
452
461
  this.handle = acquireWorker(this.config);
453
462
  await this.handle.ready;
454
463
  if (this.finished || this.signal?.aborted) { this.abort(); return false; }
@@ -459,7 +468,7 @@ class GuestRun {
459
468
  this.handle.worker.on("message", this.onMessage);
460
469
  this.handle.worker.on("error", this.onError);
461
470
  this.handle.worker.on("exit", this.onExit);
462
- if (this.wall() >= this.timeoutMs) { this.abort(); return false; }
471
+ if (this.wall() >= this.timeoutMs) { this.abort(true); return false; }
463
472
  this.available = available;
464
473
 
465
474
  return true;
@@ -488,7 +497,7 @@ class GuestRun {
488
497
  start() {
489
498
  return new Promise((resolve) => {
490
499
  this.resolve = resolve;
491
- this.timer = setTimeout(() => this.abort(), Math.min(this.timeoutMs, 2147483647));
500
+ this.timer = setTimeout(() => this.abort(true), Math.min(this.timeoutMs, 2147483647));
492
501
  this.memTimer = setInterval(() => {
493
502
  const now = rssBytes();
494
503