pi-supernova 0.9.0 → 0.10.0

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,6 +12,16 @@ 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.10.0
16
+
17
+ - Background bash sessions support launch, polling, input and stop with bounded
18
+ transcripts, deadlines, ownership and process-tree cleanup. macOS/Linux offer
19
+ interactive PTYs; Windows uses pipes and explicitly rejects PTYs.
20
+ - Literal argv stays native across platforms. Session changes, file commits,
21
+ read provenance and error diagnostics retain their safety boundaries during
22
+ asynchronous work. See [verification](#verification) for the cross-platform
23
+ Node/Bun matrix and its limits.
24
+
15
25
  ## What is new in 0.9.0
16
26
 
17
27
  - **Text clipping does not stop batches:** sequential and parallel programs keep
@@ -49,9 +59,9 @@ transactional file operations, batching, bounded results and the grouped nova UI
49
59
  tool ownership, source ranking and rendering have separate modules. All read
50
60
  modes, batching, checkpoints and rollback behavior remain supported.
51
61
 
52
- **Verified release candidate:** 315/315 package tests pass on both Node and Bun;
53
- actual Pi/OMP checks and clean tarball installation checks also pass. See
54
- [verification results and coverage limits](#verification) below.
62
+ **0.9.0 release-candidate baseline:** 315/315 package tests passed on Node and
63
+ Bun, plus actual Pi/OMP and clean tarball installation checks. See the newer
64
+ [cross-platform verification results and coverage limits](#verification) below.
55
65
 
56
66
  ### Concurrent file operations
57
67
 
@@ -230,10 +240,14 @@ settings. The runtime does not silently rewrite your tool policy.
230
240
  | `bash` | `bash({command,args:[...]})`; literal executable argv, without shell expansion of argument strings |
231
241
 
232
242
  `bash` also accepts `timeout` in seconds for familiar object arguments. `timeoutMs`
233
- is milliseconds and takes precedence. The owned POSIX adapter launches executable
234
- argv directly; use the string form for shell builtins, functions or startup hooks.
235
- Windows and delegated/older executors retain quoted-shell compatibility. Argument
236
- payloads are not repeated in owned direct-execution errors;
243
+ is milliseconds and takes precedence. The owned adapter launches executable argv
244
+ directly on every supported OS, including Windows; use the string form for shell
245
+ builtins, functions or startup hooks. Literal argv never enters a captured shell
246
+ executor. Windows string commands require native Git Bash on `PATH`, not WSL's
247
+ `System32/bash.exe`; source search requires a spawnable native `rg.exe`. Put their
248
+ real executable directories before WSL or broken WinGet links in the host's `PATH`.
249
+ Windows limits process command lines to 32K characters; use a workspace script file
250
+ for larger payloads. Argument payloads are not repeated in owned direct-execution errors;
237
251
  stdout/stderr, exit status and source context remain. Session environment variables are taken
238
252
  from the current execution context, not inherited from a different parent session.
239
253
 
@@ -503,10 +517,11 @@ per-program rollback. Missing files are created. Multiple invocations are not
503
517
  one atomic transaction: for an all-or-nothing publication, assemble a new staging
504
518
  file and publish it only when complete. External write overrides reject append.
505
519
 
506
- Supernova is a bounded foreground executor, not a durable background-job manager.
507
- For long archive scans, use resumable chunks or a host background-job tool and write
508
- progress records under `.work`. Shell commands inherit the current program
509
- `timeoutMs` unless they specify their own; increasing the outer deadline no longer
520
+ Supernova supports bounded foreground execution and session-owned background terminals
521
+ (see below), not durable jobs across host restarts. For long archive scans, use a
522
+ background terminal or resumable chunks and write progress records under `.work`.
523
+ Foreground shell commands inherit the current program `timeoutMs` unless they
524
+ specify their own; increasing the outer deadline no longer
510
525
  leaves a hidden 60-second shell cap. Set the inner `bash` timeout shorter than the
511
526
  outer program timeout (for example 10 seconds inside a 20-second program). The outer
512
527
  deadline covers **all** waits and commands, including `sleep`; a shell's own `timeout`
@@ -515,10 +530,106 @@ pending host calls get a bounded 250ms drain to retain owned-shell diagnostics a
515
530
  finalize process termination. Non-cooperating host executors may still outlive that
516
531
  drain. Cancellation is reported separately from timeout; neither triggers a retry.
517
532
  Progress files survive shell execution but staged VFS writes may roll back.
533
+ On macOS/Linux, foreground and background commands share process-group cleanup:
534
+ normal leader exit, cancellation and timeout retire ordinary descendants before
535
+ reporting completion. A shell's `command &` does not create a durable job; launch
536
+ the long-running command itself with `background:true` instead.
518
537
 
519
538
  Large returned objects are bounded previews, not retained artifacts. Select fields
520
539
  and array windows before returning, rather than parsing a truncated preview.
521
540
 
541
+ ## Background terminal sessions
542
+
543
+ Start a command without waiting for it to finish:
544
+
545
+ ```js
546
+ return await bash({command:"npm test", background:true});
547
+ // {sessionId, pid, status:"running", pty:false, output, outputStart, cursor,
548
+ // truncated:false, exitCode:null, signal:null}
549
+ ```
550
+
551
+ Use `pty:true` for interactive programs that require a terminal, such as a
552
+ browser/passkey publisher. Literal argv remains supported:
553
+
554
+ ```js
555
+ return await bash({command:"npm", args:["publish"], background:true, pty:true});
556
+ ```
557
+
558
+ Starting a publish still requires the user's authorization. This API does not
559
+ approve commands, bypass shell overrides or provide credentials. PTY mode uses
560
+ `/usr/bin/script` on macOS/Linux and fails explicitly if unavailable; it adds no
561
+ npm/native dependency. Pipes are the default. PTY output includes terminal echo,
562
+ ANSI escapes and CRLF; it is a transcript, not a full-screen terminal renderer.
563
+ PTY commands start at 80 columns by 24 rows; dynamic resizing is not supported.
564
+
565
+ Control the returned ID in a later Supernova invocation:
566
+
567
+ ```js
568
+ return await bash({sessionId:data.sessionId, action:"poll", cursor:0, waitMs:1000});
569
+ // waitMs waits for new output/exit only when cursor is at the current end.
570
+ ```
571
+
572
+ ```js
573
+ await bash({sessionId:data.sessionId, action:"write", input:"yes\n"});
574
+ return await bash({sessionId:data.sessionId, action:"poll", cursor:data.cursor});
575
+ ```
576
+
577
+ ```js
578
+ return await bash({action:"list"});
579
+ // Or: return await bash({sessionId:data.sessionId, action:"stop"});
580
+ ```
581
+
582
+ - Results are objects, not encoded JSON or foreground text. `poll` returns
583
+ `running`, `exited`, `stopped`, `timed_out`, or `failed`, with nullable `exitCode`
584
+ and `signal`. A nonzero child exit does not throw from `poll`; inspect it.
585
+ Invalid arguments/IDs, startup failures and failed controls do throw. In PTY
586
+ mode, `exitCode` is the system `script` utility's status; signal termination
587
+ may be encoded there instead of in `signal` and is platform-dependent.
588
+ - `cursor` is an absolute UTF-16 output offset. Pass the previous cursor for
589
+ incremental output, or omit it to replay retained output. stdout/stderr share
590
+ a 65,536-character tail. `truncated:true` and `outputStart` disclose discarded
591
+ history; polling does not consume it. `waitMs` is 0--30,000 (default 0).
592
+ - Input is literal, at most 16,384 characters per call. Include `\n` for Enter
593
+ and `\u0003` for Ctrl-C in a PTY. Input is not echoed into tool traces, but
594
+ the child/terminal may echo it into output. Do not send secrets casually.
595
+ - Up to 8 running jobs and 32 retained sessions per extension instance. Oldest
596
+ completed sessions are evicted when needed. `list` returns metadata without
597
+ replaying output. IDs are scoped to the originating Pi session and workspace;
598
+ controls still waiting when that session closes reject rather than returning
599
+ results from the closed session.
600
+ - A job defaults to a **30-minute deadline**, independently of the starting
601
+ Supernova call. Set its `timeoutMs` explicitly to change that deadline.
602
+ Outer program timeouts/cancellation still bound start and control calls;
603
+ cancelling a poll does not stop its job. Use `stop` to terminate it.
604
+ - Launch, input and stop are external-mutation barriers; staged edits flush
605
+ first. List/poll do not flush staged edits. None runs inside an edit checkpoint.
606
+ Background effects are not transactional and may race edits; ordinary CAS
607
+ checks still detect changed file bytes, not all external side effects.
608
+ - On macOS/Linux, normal exit, explicit stop, job deadline and session shutdown
609
+ clean up owned process groups, escalating to kill when necessary. Completion
610
+ is reported only after cleanup, so ordinary orphaned children are not left
611
+ running. Failed cleanup remains listed and consumes a job slot until a later
612
+ stop/shutdown succeeds; it is not silently discarded. Windows pipe cleanup is
613
+ best-effort (`taskkill` while the leader is alive), not POSIX group supervision.
614
+ Jobs do not survive reload, session
615
+ replacement, host restart or crash as managed sessions. Deliberately detached
616
+ daemons are not a supported supervision model. Cleanup errors are reported.
617
+ Launches and controls from an old session generation are rejected, including
618
+ after a reload that retains the same Pi session ID. A guest started before
619
+ replacement cannot list, poll, write to or stop the replacement's jobs.
620
+ Identity is captured once per tool invocation, including queued sequential and
621
+ parallel batch entries, rather than reread from a mutable host SessionManager.
622
+ Replacement invalidates the whole guest bridge: further commands, delegated
623
+ calls, session-resource lookups and pending commits are rejected. Queued commits
624
+ recheck identity after waiting, and staged replacements recheck before each
625
+ installation; a failed transaction restores any earlier replacements. Delegated
626
+ executors recheck immediately before dispatch. External effects that already
627
+ started or committed are not rolled back by a session change.
628
+ - Background actions require Supernova's owned shell adapter. A registered shell
629
+ override is rejected rather than silently ignoring background options or
630
+ bypassing the override's permissions.
631
+
632
+
522
633
  ## JSON reports and targeted text audits
523
634
 
524
635
  For JSON, select fields inside the read adapter, **before** output budgeting:
@@ -547,6 +658,15 @@ Plain .json reads are raw text, including malformed JSON; parsing is requested
547
658
  only by `json`. Explicit line windows are not necessarily JSON documents.
548
659
  Do not combine json with complete, line windows, or source views. External read
549
660
  overrides reject JSON projection rather than silently ignoring the option.
661
+ Explicit `read({path:"TOKEN"})` / `read({target:"TOKEN"})` and path arrays mean
662
+ filesystem reads even for extensionless names. Missing files throw, including
663
+ with `resolve:false`, `complete:true` or line windows. Only a bare string without
664
+ those options guesses between a filename and a symbol; use `{query:"symbol"}`
665
+ or `resolve:true` when source search is intended.
666
+ Source reads protect the resolved file from accidental `write` replacement even
667
+ when the result is text or arrives in a streamed batch. JSON document fields such
668
+ as `path` and `status` are data, never evidence that another file was read.
669
+
550
670
  Uncaught read errors abort the program, including `return {a:await read(...),
551
671
  b:await read(...)}`; earlier successful values are not an implicit partial return.
552
672
  For optional sources, explicitly return `await Promise.allSettled(paths.map(path =>
@@ -609,8 +729,15 @@ are outside the VFS counters; this is not a filesystem audit.
609
729
  `{ok:true,committed:true,value}` on success. On failure it rolls back and rethrows
610
730
  the cause, so an ignored failed checkpoint cannot report program success. Use
611
731
  `try { await edit(async () => {...}); } catch (error) {...}` for deliberate recovery.
612
- Shell commands, overlapping/nested checkpoints, and concurrent commands outside
613
- the active callback are rejected. Await the checkpoint before proceeding.
732
+ Shell commands (including background terminal controls), overlapping/nested
733
+ checkpoints, and concurrent commands outside the active callback are rejected.
734
+ Await the checkpoint before running shell checks. A checkpoint cannot roll back
735
+ external shell effects. For a temporary mutation test, save the original text,
736
+ edit and run the check outside a checkpoint, then explicitly restore it. Catch
737
+ the check failure, restore, and let that program succeed so the restoration
738
+ commits before reporting the failure. A `finally` restoration followed by an
739
+ uncaught error is only staged and rolls back too. Keep a backup: cancellation
740
+ or a worker deadline can prevent cleanup from running.
614
741
 
615
742
  ## Context, caching and failure fidelity
616
743
 
@@ -718,6 +845,7 @@ delivery, and execution-context environment.
718
845
  | Read routing and typed readers | `adapters/read.js`, `adapters/read-{text,json,image,focus}.js` |
719
846
  | Image validation and isolated decoding | `shared/png.js`, `shared/image.js`, `shared/image-worker.js` |
720
847
  | Bounded I/O and atomic transactions | `fs/file-io.js`, `fs/read-window.js`, `fs/vfs.js`, `fs/commit.js` |
848
+ | Foreground/background process ownership | `fs/workspace.js`, `fs/process-tree.js`, `fs/background.js` |
721
849
  | Source search and ranking | `context/query.js`, `context/snap-search.js`, `context/source-entry.js`, `context/evidence-{graph,rank}.js` |
722
850
  | Model output and host rendering | `output/final.js`, `output/outcome.js`, `ui/host-render.js`, `ui/trace.js`, `ui/render.js` |
723
851
 
@@ -726,7 +854,56 @@ acyclic import graph and prohibit context modules from importing runtime code.
726
854
 
727
855
  ### Verification
728
856
 
729
- The final 0.9.0 release candidate was verified on **macOS**:
857
+ **Portability pass, 2026-09-22:** the current product sources, including the
858
+ in-flight terminal-poll shutdown guard, passed the package suite on real hosts:
859
+
860
+ | Host | Node | Bun 1.4.0 |
861
+ |---|---|---|
862
+ | macOS, Node 26.7.0 | 357 passed | 355 passed |
863
+ | DGX Spark, Linux arm64, Node 24.16.0 | 357 passed | 355 passed |
864
+ | Windows x64, Node 24.16.0 | 348 passed, 9 skipped | 346 passed, 9 skipped |
865
+
866
+ A fresh Pi 0.87 loader followed the configured Supernova symlink into this
867
+ checkout, registered its single `supernova` tool, and executed read/write.
868
+ The restarted active extension also completed a pipe job and interactive PTY
869
+ with stdin, output, and exit code 7. This does not inspect Pi's in-memory
870
+ module cache or test remote provider sessions.
871
+
872
+ Commands: `npm test --prefix packages/pi-supernova` locally; in the standalone
873
+ remote package, `node --test tests/*/*.test.mjs`; for Bun, `bun --run test`
874
+ on a clean tree. The remote archive contained macOS AppleDouble
875
+ `._background.test.mjs` metadata, which Bun mistakenly discovered as a test;
876
+ the successful Spark/Windows Bun reruns explicitly selected real files with
877
+ `bun test tests/*/[!.]*.test.mjs` (PowerShell constructed the same filtered
878
+ file list on Windows). New macOS verification archives should use
879
+ `tar --no-mac-metadata --no-xattrs` to prevent AppleDouble entries. Final
880
+ fixture-only changes were additionally checked on macOS/Spark with
881
+ `node --test tests/codemode/{papercuts,guest-contracts}.test.mjs` (59 passed)
882
+ and `bun test tests/codemode/{papercuts,guest-contracts}.test.mjs` (57 passed);
883
+ Windows reran the full suite. Totals are reported by each runtime's test runner.
884
+
885
+ Windows verification selected native Git Bash and the real ripgrep executable
886
+ directory in the test process's `PATH`. Its SSH token's backup/restore privileges
887
+ were disabled only for the test process so real deny-read/execute/write ACLs
888
+ could exercise permission errors. No machine-wide configuration was changed.
889
+ Bun 1.4.0 was installed into the temporary Windows verification directory;
890
+ the machine's older Bun 1.3.14 test runner is not a passing result.
891
+
892
+ The nine Windows skips remain explicit POSIX/platform-specific cases: shell
893
+ startup/quoted-script behavior, FIFO admission, alias and descendant semantics,
894
+ and the large JSON allocation case. Windows literal argv now runs the same
895
+ ownership/malformed-argument tests as POSIX. PTY tests verify explicit Windows
896
+ rejection rather than silently skipping or treating pipes as terminals.
897
+
898
+ Mutation checks rejected lost native-argv ownership, missing-parent and source/
899
+ evidence-path regressions, dropped cleanup errors, and unmapped launch failures.
900
+ A surviving ambiguous-source-path mutant led to a stronger regression. Tests also
901
+ exercise both synchronous Windows and asynchronous POSIX launch failures.
902
+ These results do not claim native Windows PTYs, exhaustive interleavings, or
903
+ new Pi/OMP/provider end-to-end coverage on the remote hosts. Root release gates
904
+ were not run in this pass.
905
+
906
+ The historical 0.9.0 release candidate was verified on **macOS**:
730
907
 
731
908
  | Check | Result |
732
909
  |---|---|
package/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import {programParameters} from './src/contract/program.js';
2
2
  import {progressEmitter} from './src/ui/progress.js';
3
+
3
4
  export {progressEmitter} from './src/ui/progress.js';
5
+
4
6
  import {result,errorText,successText,fitOutput,attachReceipts,throwIfFailed} from './src/output/outcome.js';
5
7
 
6
8
  import { runProgramBatch } from "./src/runtime/program-batch.js";
@@ -77,6 +79,7 @@ export function registerCodeMode(pi) {
77
79
 
78
80
  function rejectLoneParallel(params) {
79
81
  if (params?.parallel !== undefined) throw new Error("parallel applies to the programs array; no commands ran");
82
+
80
83
  if (params?.mergeData !== undefined) throw new Error("mergeData applies to the programs array; no commands ran");
81
84
  }
82
85
 
@@ -90,8 +93,8 @@ export function registerCodeMode(pi) {
90
93
  return { runController, abortRun };
91
94
  }
92
95
 
93
- function openRunBridge(ctx, runCwd, budget, runController, timeoutMs) {
94
- const runBridge = bridge.fork({ getCwd: () => runCwd, budget, timeoutMs });
96
+ function openRunBridge(ctx, runCwd, budget, runController, timeoutMs, terminalIdentity) {
97
+ const runBridge = bridge.fork({ getCwd: () => runCwd, budget, timeoutMs, terminalIdentity });
95
98
  runBridge.bindCallContext(ctx, runController.signal);
96
99
  runBridge.resetCallBudget();
97
100
 
@@ -101,6 +104,7 @@ export function registerCodeMode(pi) {
101
104
  async function runAndCommit(params, runCwd, runBridge, abortRun, runController, budget) {
102
105
  refreshCatalog(runBridge);
103
106
  runBridge.beginSpeculation();
107
+
104
108
  const outcome = await runGuestProgram({
105
109
  code: params?.code,
106
110
  file: params?.file,
@@ -111,6 +115,7 @@ export function registerCodeMode(pi) {
111
115
  signal: runController.signal,
112
116
  onTimeout: abortRun,
113
117
  });
118
+
114
119
  runBridge.close();
115
120
 
116
121
  if (outcome.ok) {
@@ -153,6 +158,7 @@ export function registerCodeMode(pi) {
153
158
  attachReceipts(outcome, trace);
154
159
  const bounded = fitOutput(outcome, call, config.maxReturnChars, outcome.ok ? successText : errorText);
155
160
  const visible = runBridge.ledger.dedupe(bounded, call);
161
+
156
162
  const response = result(visible, {
157
163
  ok: outcome.ok, error: outcome.error, wallMs: outcome.wallMs,
158
164
  returnTruncated: outcome.returnTruncated, logTruncated: outcome.logTruncated,
@@ -177,12 +183,22 @@ export function registerCodeMode(pi) {
177
183
  renderCall: renderSupernovaCall,
178
184
  renderResult: renderSupernovaResult,
179
185
  execute: async function execute(_id, params, signal, onUpdate, ctx, budget, runOpts) {
180
- if (params?.programs !== undefined) return runProgramBatch(_id,params,signal,onUpdate,ctx,config,execute);
186
+ const runCwd = isString(ctx?.cwd) && ctx.cwd ? ctx.cwd : cwd;
187
+ // The entire invocation owns one identity, including entries queued by a
188
+ // sequential/parallel batch. Dispatch after reload cannot renew its lease.
189
+ const terminalIdentity = runOpts?.terminalIdentity ?? bridge.captureTerminalIdentity(ctx, runCwd);
190
+
191
+ if (params?.programs !== undefined) {
192
+ const entry = (id, payload, abort, update, context, sharedBudget, options) =>
193
+ execute(id, payload, abort, update, context, sharedBudget, {...options,terminalIdentity});
194
+
195
+ return runProgramBatch(_id,params,signal,onUpdate,ctx,config,entry);
196
+ }
197
+
181
198
  rejectLoneParallel(params);
182
199
  cancelWarmTimer();
183
- const runCwd = isString(ctx?.cwd) && ctx.cwd ? ctx.cwd : cwd;
184
200
  const { runController, abortRun } = bindRunSignal(signal);
185
- const runBridge = openRunBridge(ctx, runCwd, budget, runController, params?.timeoutMs);
201
+ const runBridge = openRunBridge(ctx, runCwd, budget, runController, params?.timeoutMs, terminalIdentity);
186
202
  const call = ++programSeq;
187
203
  runBridge.ledger.beginProgram(call);
188
204
  const emitProgress = progressEmitter(onUpdate);
@@ -205,6 +221,7 @@ export function registerCodeMode(pi) {
205
221
  } finally {
206
222
  peakSeen = Math.max(peakSeen, overlapPeak);
207
223
  inFlight -= 1;
224
+
208
225
  if (inFlight === 0) overlapPeak = 0;
209
226
  finishRun(runBridge, emitProgress, signal, abortRun, runController);
210
227
  }
@@ -222,10 +239,14 @@ export function registerCodeMode(pi) {
222
239
  });
223
240
  }
224
241
 
225
- pi.on("session_shutdown", () => { stopped = true; cancelWarmTimer();
226
-
227
- return stopWarmGuestWorker(); });
228
- pi.on("session_start", (_event, ctx) => {
242
+ pi.on("session_shutdown", async () => {
243
+ stopped = true;
244
+ cancelWarmTimer();
245
+ await Promise.all([stopWarmGuestWorker(), bridge.shutdownTerminals()]);
246
+ });
247
+ pi.on("session_start", async (_event, ctx) => {
248
+ await bridge.shutdownTerminals();
249
+ bridge.reopenTerminals();
229
250
  stopped = false;
230
251
 
231
252
  cwd = ctx && isString(ctx.cwd) && ctx.cwd ? ctx.cwd : process.cwd();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
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,4 +1,5 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import { readResult } from "../shared/result.js";
2
3
 
3
4
  import { normalizeBash } from "../contract/bash.js";
4
5
  import { sourceForReferences } from "../fs/source-window.js";
@@ -6,12 +7,15 @@ import { resolveWorkspacePath, runCommand, clearPathCache } from "../fs/workspac
6
7
 
7
8
  export function createBash(ctx) {
8
9
  const { getCwd, vfs, config, index, ledger, hooks } = ctx;
10
+
9
11
  function combineBashText(stdout, stderr) {
10
12
  return stdout && stderr ? stdout + (stdout.endsWith("\n") ? "" : "\n") + stderr : stdout || stderr;
11
13
  }
12
14
 
13
15
  async function bash(params, signal) {
14
16
  params = normalizeBash(params);
17
+
18
+ if (params.background === true || params.action !== undefined) return background(params, signal);
15
19
  const cwd = getCwd();
16
20
  const command = String(params.command);
17
21
  const literal = Array.isArray(params.args);
@@ -47,6 +51,7 @@ export function createBash(ctx) {
47
51
  if (!literal && /\bbash: (?:-c: )?line \d+: (?:syntax error|unexpected EOF)/.test(stderr)) {
48
52
  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.';
49
53
  }
54
+
50
55
  text += await sourceForReferences(cwd, targetCwd, text, signal, ledger);
51
56
  }
52
57
 
@@ -57,14 +62,49 @@ export function createBash(ctx) {
57
62
  };
58
63
  }
59
64
 
65
+ async function background(params, signal) {
66
+ const manager = hooks.terminals;
67
+ const owner = hooks.terminalOwner();
68
+ vfs.assertExternalAllowed("bash");
69
+ const mutating = params.background === true || params.action === "write" || params.action === "stop";
70
+ let targetCwd;
71
+
72
+ if (params.background === true) {
73
+ await manager.validateStart(params, hooks.terminalGeneration);
74
+ targetCwd = await commandCwd(params, getCwd());
75
+ } else manager.validateControl(params, owner, hooks.terminalGeneration);
76
+ const transactionBarrier = mutating ? await vfs.prepareExternalMutation("bash") : false;
77
+
78
+ try {
79
+ const value = params.background === true
80
+ ? await manager.start(Array.isArray(params.args) ? [params.command,...params.args] : ["bash","-c",params.command], {
81
+ ...params, cwd:targetCwd, owner, env:hooks.commandEnv(), signal, generation:hooks.terminalGeneration,
82
+ // Asynchronous completion must not erase an active program's CAS observations.
83
+ changed:()=>{index.invalidate(); hooks.workspaceChanged();},
84
+ })
85
+ : await manager.control(params, owner, signal, hooks.terminalGeneration);
86
+
87
+ // A completed job's nonzero exit is status data, not a failure to poll it.
88
+ return readResult(value, {background:true,transactionBarrier}, Array.isArray(value) ? `${value.length} background terminals` : `terminal ${value.sessionId}: ${value.status}`);
89
+ } finally {
90
+ if (mutating) { vfs.invalidateObserved(); hooks.workspaceChanged(); }
91
+
92
+ index.invalidate();
93
+ clearPathCache();
94
+ }
95
+ }
96
+
60
97
  return { bash };
61
98
  }
62
99
 
63
100
  async function commandCwd(params, cwd) {
64
101
  const target = params.cwd ? await resolveWorkspacePath(cwd, params.cwd, "bash cwd", true) : cwd;
102
+
65
103
  if (params.cwd !== undefined) {
66
104
  const stat = await fs.stat(target).catch(() => null);
105
+
67
106
  if (!stat?.isDirectory()) throw new Error("bash cwd is not a directory: " + params.cwd);
68
107
  }
108
+
69
109
  return target;
70
110
  }
@@ -24,6 +24,7 @@ export function createRead(ctx) {
24
24
  const readTextFile = createTextReader(ctx, readWindow);
25
25
  const maybeImage = createImageReader(vfs);
26
26
  const focusAbout = createFocusedReader(vfs, readBudget);
27
+
27
28
  async function sourceRead(query, searchDir, signal, params = {}) {
28
29
  params = { ...params, resolve: params.resolve !== false };
29
30
  const cwd = getCwd();
@@ -43,6 +44,7 @@ export function createRead(ctx) {
43
44
  const overlay = vfs.getOverlay(target);
44
45
 
45
46
  if (overlay !== undefined) return Buffer.byteLength(overlay, "utf8") > 512 * 1024;
47
+
46
48
  try { return (await fs.stat(target)).size > 512 * 1024; } catch { return false; }
47
49
  }
48
50
 
@@ -57,6 +59,7 @@ export function createRead(ctx) {
57
59
  const opened = await readFile(target, bounded
58
60
  ? { ...params, about: undefined, offset: Math.max(1, result.line - 4), limit: params.limit ?? 120 }
59
61
  : { ...params, about: undefined }, result.line, result.path, bounded ? undefined : query, signal);
62
+
60
63
  const block = opened.content?.[0];
61
64
 
62
65
  if (block?.type !== "text") throw new Error("source resolution requires a text file; read the image path directly");
@@ -75,14 +78,16 @@ export function createRead(ctx) {
75
78
  function foundSource(result, params, block, details, firstLine, lastLine, sourceChars, nextOffset, complete) {
76
79
  const source = { status: "found", path: result.path, line: result.line, lines: [firstLine, lastLine],
77
80
  text: block.text.slice(0, sourceChars), complete };
81
+
78
82
  if (nextOffset !== undefined) source.nextOffset = nextOffset;
79
83
 
80
84
  return readResult(params.resolve ? source : "// " + result.path + ":" + firstLine + "-" + lastLine + "\n" + block.text,
81
- { ...details, isSnap: true });
85
+ { ...details, isSnap: true, sourcePath:result.path });
82
86
  }
83
87
 
84
88
  async function addBatchItem(state, index, raw, onItem) {
85
89
  const bytes = raw[READ_BYTES];
90
+
86
91
  if (!onItem) {
87
92
  if (state.bytes + bytes > MAX_READ_VALUE_BYTES) throw new Error("batched read exceeds " + MAX_READ_VALUE_BYTES + " bytes; use individual reads");
88
93
  } else if (!state.streamed && state.bytes + bytes > 65536) {
@@ -90,12 +95,14 @@ export function createRead(ctx) {
90
95
  const retained = state.items.splice(0);
91
96
  await Promise.all(retained.map(async (item,i) => { if (item) await onItem(i,item); }));
92
97
  }
98
+
93
99
  if (state.streamed) await onItem(index,raw);
94
100
  else { state.items[index] = raw; state.bytes += bytes; }
95
101
  }
96
102
 
97
103
  async function readBatchItem(params, target, index, signal, state, onItem) {
98
104
  let raw;
105
+
99
106
  try { raw = asReadResult(await readSingle({...params,path:target,target:undefined},getCwd(),target,signal)); }
100
107
  catch (error) {
101
108
  signal?.throwIfAborted();
@@ -103,30 +110,41 @@ export function createRead(ctx) {
103
110
  raw = readResult(error.message,{path:target});
104
111
  raw.isError = true;
105
112
  }
113
+
114
+ state.sourcePaths[index] = raw.details?.sourcePath;
106
115
  await addBatchItem(state,index,raw,onItem);
107
116
  }
108
117
 
109
118
  async function readBatch(params, signal, onItem) {
110
- const state = {items:[],errors:Array(params.path.length).fill(null),bytes:0,streamed:false};
119
+ const state = {items:[],sourcePaths:[],errors:Array(params.path.length).fill(null),bytes:0,streamed:false};
120
+
111
121
  // Delivery/acknowledgement stays inside the same eight-operation scheduler
112
122
  // slot as I/O. A busy guest cannot cause unbounded host/message-queue buffering.
113
123
  const settled = await Promise.allSettled(params.path.map((target,index) =>
114
124
  reads.schedule("read",()=>readBatchItem(params,target,index,signal,state,onItem),signal)));
125
+
115
126
  const failed = settled.find(result=>result.status === "rejected");
127
+
116
128
  if (failed) throw failed.reason;
117
129
  signal?.throwIfAborted();
130
+
118
131
  const response = readResult("",{count:params.path.length,batch:true,independent:params._independent===true,
119
132
  jsonMany:Array.isArray(params.json),streamed:state.streamed,
120
- items:state.streamed ? [] : state.items.map(raw=>raw[READ_VALUE]),itemErrors:state.errors,
133
+ items:state.streamed ? [] : state.items.map(raw=>raw[READ_VALUE]),
134
+ sourcePaths:!state.streamed && state.sourcePaths.some(isString) ? state.sourcePaths : undefined,itemErrors:state.errors,
121
135
  errors:state.errors.flatMap((message,i)=>message ? [{path:params.path[i],message}] : [])});
136
+
122
137
  response.isError = params._independent!==true && state.errors.some(Boolean);
138
+
123
139
  return response;
124
140
  }
125
141
 
126
142
  async function readAdapter(params, signal, onItem) {
127
143
  signal?.throwIfAborted();
128
144
  params = normalizeRead(normalizeReadWindow(params));
145
+
129
146
  if (Array.isArray(params.path)) return readBatch(params,signal,onItem);
147
+
130
148
  return reads.schedule("read",async()=>asReadResult(await readSingle(params,getCwd(),params.path,signal)),signal);
131
149
  }
132
150
 
@@ -136,6 +154,7 @@ export function createRead(ctx) {
136
154
  const cls = classifyRead(params, existing);
137
155
  const relOf = hit => relativeSlash(cwd, hit.path);
138
156
  const snapScope = (scoped, hit) => hit?.directory ? hit.path : scoped ? resolveReadPath(cwd, params.path) : cwd;
157
+
139
158
  const kinds = {
140
159
  session: async () => {
141
160
  const target = await resolveSessionResource(params.path, signal, hooks);
@@ -153,6 +172,7 @@ export function createRead(ctx) {
153
172
  dir: () => readDirectory(cls.existing.path, signal),
154
173
  missing: () => readResult({ status: "not_found", path: null, line: null, signature: "", confidence: 0, context: [] }, { isSnap: true }),
155
174
  };
175
+
156
176
  const run = kinds[cls.kind];
157
177
 
158
178
  if (!run) throw new Error("unhandled read kind: " + cls.kind);
@@ -224,6 +244,7 @@ export function createRead(ctx) {
224
244
  const cwd = getCwd();
225
245
 
226
246
  if (!isString(params?.query) || !params.query.trim()) throw new Error("evidence requires query");
247
+
227
248
  if (tokenizeQuery(params.query).tokens.length > 16) throw new Error("evidence query is too broad; use at most 16 keywords");
228
249
 
229
250
  if (signal?.aborted) throw new Error("aborted");