pi-supernova 0.10.2 → 0.11.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.
Files changed (81) hide show
  1. package/README.md +365 -90
  2. package/docs/CHANGELOG.md +89 -5
  3. package/docs/TOKEN_COSTS.md +149 -324
  4. package/index.js +40 -11
  5. package/package.json +4 -3
  6. package/src/adapters/bash.js +40 -10
  7. package/src/adapters/edit.js +77 -23
  8. package/src/adapters/errors.js +4 -0
  9. package/src/adapters/index.js +1 -0
  10. package/src/adapters/list.js +6 -3
  11. package/src/adapters/read-focus.js +6 -0
  12. package/src/adapters/read-image.js +17 -9
  13. package/src/adapters/read-indexed.js +72 -0
  14. package/src/adapters/read-text.js +19 -2
  15. package/src/adapters/read.js +57 -13
  16. package/src/adapters/refs.js +17 -7
  17. package/src/adapters/write.js +14 -21
  18. package/src/bridge/host-bridge.js +152 -39
  19. package/src/bridge/invoke.js +3 -1
  20. package/src/bridge/tool-registry.js +132 -16
  21. package/src/context/evidence-graph.js +1 -0
  22. package/src/context/evidence-rank.js +1 -0
  23. package/src/context/evidence.js +4 -2
  24. package/src/context/fuzzy.js +31 -8
  25. package/src/context/outline.js +33 -9
  26. package/src/context/query.js +7 -2
  27. package/src/context/repo-index.js +8 -3
  28. package/src/context/search-files.js +20 -0
  29. package/src/context/search.js +25 -10
  30. package/src/context/snap-search.js +28 -6
  31. package/src/context/snap.js +8 -3
  32. package/src/context/source-entry.js +64 -28
  33. package/src/context/spans.js +1 -0
  34. package/src/contract/bash.js +16 -7
  35. package/src/contract/edit.js +26 -7
  36. package/src/contract/program.js +2 -1
  37. package/src/contract/read.js +46 -46
  38. package/src/fs/background.js +10 -2
  39. package/src/fs/check.js +844 -46
  40. package/src/fs/commit.js +89 -15
  41. package/src/fs/diff.js +12 -9
  42. package/src/fs/directory.js +14 -4
  43. package/src/fs/file-io.js +28 -7
  44. package/src/fs/glob.js +93 -11
  45. package/src/fs/json-read.js +3 -2
  46. package/src/fs/json-size.js +2 -1
  47. package/src/fs/lines.js +49 -2
  48. package/src/fs/patch.js +26 -10
  49. package/src/fs/process-tree.js +19 -1
  50. package/src/fs/read-window.js +36 -20
  51. package/src/fs/source-window.js +5 -10
  52. package/src/fs/text-ops.js +38 -18
  53. package/src/fs/vfs.js +296 -41
  54. package/src/fs/workspace.js +24 -6
  55. package/src/output/bottleneck.js +50 -25
  56. package/src/output/final.js +20 -0
  57. package/src/output/format.js +97 -36
  58. package/src/output/outcome.js +16 -4
  59. package/src/runtime/batch-input.js +17 -3
  60. package/src/runtime/guest-api.js +32 -7
  61. package/src/runtime/guest-worker.js +22 -4
  62. package/src/runtime/parallel.js +9 -49
  63. package/src/runtime/program-batch.js +53 -28
  64. package/src/runtime/program-file.js +2 -1
  65. package/src/runtime/program.js +94 -41
  66. package/src/runtime/reference.js +10 -9
  67. package/src/runtime/runtime.js +69 -18
  68. package/src/runtime/worker-pool.js +10 -1
  69. package/src/shared/decode.js +9 -8
  70. package/src/shared/image-worker.js +7 -0
  71. package/src/shared/image.js +58 -15
  72. package/src/shared/png.js +18 -0
  73. package/src/shared/result.js +30 -9
  74. package/src/shared/syntax-context.js +7 -1
  75. package/src/shared/utf8.js +11 -13
  76. package/src/ui/host-render.js +4 -0
  77. package/src/ui/omp-frame.js +2 -0
  78. package/src/ui/progress.js +2 -1
  79. package/src/ui/render-measure.js +11 -0
  80. package/src/ui/render.js +92 -31
  81. package/src/ui/trace.js +8 -5
package/src/fs/vfs.js CHANGED
@@ -1,25 +1,66 @@
1
- import {textSignature,sameFileVersion,fileSignature,tooLargeRead,overlayOrThrow,assertReadableFile,readLimitedBytes,remapReadError} from './file-io.js';
2
- import {resolveCommitTarget,assertExpectedSignature,collectMissingAncestors,makeStageEntry,stageReplacement,installStaged,failCommit,cleanupStaged} from './commit.js';
1
+ import {textSignature,sameSignature,sameFileVersion,fileSignature,tooLargeRead,overlayOrThrow,assertReadableFile,readLimitedBytes,remapReadError} from './file-io.js';
2
+ import {resolveFileIdentity,resolveCommitTarget,assertExpectedSignature,collectMissingAncestors,makeStageEntry,stageReplacement,installStaged,failCommit,cleanupStaged} from './commit.js';
3
3
 
4
4
  export {resolveCommitTarget} from './commit.js';
5
5
 
6
6
  import * as fs from "node:fs/promises";
7
7
  import * as path from "node:path";
8
+ import { AsyncLocalStorage } from "node:async_hooks";
8
9
  import { isString } from "../shared/decode.js";
9
10
  import { decodeUtf8Strict } from "../shared/utf8.js";
10
11
 
11
12
  // Serialize validation + replacement across Supernova transactions in this host.
12
13
  let commitTail = Promise.resolve();
13
14
 
15
+ const readScopes = new Set();
16
+
17
+ let installingPaths = null;
18
+
19
+ function scopeContains(scope, target) {
20
+ const relative = path.relative(scope, target);
21
+
22
+ return relative === "" || (relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative));
23
+ }
24
+
25
+ function noteCommitPath(target) {
26
+ installingPaths.add(target);
27
+
28
+ for (const read of readScopes) {
29
+ if (scopeContains(read.scope, target)) read.changed = true;
30
+ }
31
+ }
32
+
33
+ // Readers validate after every in-process commit they could have observed.
34
+ // An aborted reader must not retain its buffers until another writer finishes.
35
+ async function waitForCommits(signal) {
36
+ signal?.throwIfAborted();
37
+
38
+ if (!signal) return commitTail;
39
+ let abort;
40
+
41
+ const cancelled = new Promise((_, reject) => {
42
+ abort = () => reject(signal.reason);
43
+ signal.addEventListener("abort", abort, { once: true });
44
+ });
45
+
46
+ try { await Promise.race([commitTail, cancelled]); }
47
+ finally { signal.removeEventListener("abort", abort); }
48
+ }
49
+
50
+
14
51
  export class CausalVfs {
15
52
  constructor(onNewFile, validateWrite, assertCurrent) {
16
53
  this.validateWrite = validateWrite;
17
54
  this.assertCurrent = assertCurrent;
18
55
  // No body cache: every read hits disk (or its overlay) so observed bytes
19
- // are never stale. CAS baselines in `expected` are the only retained
20
- // per-file state, cleared only at external-mutation boundaries.
56
+ // are never stale. CAS signatures and observed logical-to-physical bindings
57
+ // survive between operations, but clear at external-mutation boundaries.
21
58
  this.overlays = [];
22
59
  this.expected = new Map();
60
+ this.observedPaths = new Map();
61
+ this.pathScope = new AsyncLocalStorage();
62
+ this.bestEffort = false;
63
+ this.pendingSignatures = new Map();
23
64
  this.onNewFile = onNewFile;
24
65
  this.closed = false;
25
66
  this.signal = undefined;
@@ -33,25 +74,118 @@ export class CausalVfs {
33
74
  this.signal?.throwIfAborted();
34
75
  }
35
76
 
36
- getOverlay(target) {
77
+ // Reuse identity only within one native operation. Commit resolution is
78
+ // always fresh, and no path/body cache survives into the next operation.
79
+ withPathScope(fn) { return this.pathScope.run({paths:new Map()}, fn); }
80
+
81
+ bindMutationRecord(record) {
82
+ const scope = this.pathScope.getStore();
83
+
84
+ if (scope) scope.record = record;
85
+ }
86
+
87
+ async resolvePath(target, allowMissing = true) {
88
+ const scope = this.pathScope.getStore()?.paths;
89
+ let pending = scope?.get(target);
90
+
91
+ if (!pending) {
92
+ pending = resolveFileIdentity(target);
93
+ scope?.set(target, pending);
94
+ }
95
+
96
+ const identity = await pending;
97
+
98
+ if (!allowMissing && identity.missing) throw identity.missing;
99
+
100
+ return identity.path;
101
+ }
102
+
103
+ #assertWriteAlias(logicalPath, entry) {
104
+ if (entry && entry.logicalPath !== logicalPath) throw new Error("conflicting write aliases: " + logicalPath);
105
+ }
106
+
107
+ #overlay(target) {
37
108
  for (let i = this.overlays.length - 1; i >= 0; i--) {
38
- if (this.overlays[i].has(target)) return this.overlays[i].get(target);
109
+ const entry = this.overlays[i].get(target);
110
+
111
+ if (entry) return entry;
39
112
  }
40
113
  }
41
114
 
42
- getOverlayPaths() {
43
- return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
115
+ async getOverlay(target) {
116
+ this.signal?.throwIfAborted();
117
+
118
+ if (!this.overlays.some(layer => layer.size)) return undefined;
119
+
120
+ return this.#overlay(await this.resolvePath(target))?.content;
121
+ }
122
+
123
+ async getOverlayPaths(scope) {
124
+ const entries = new Map(this.overlays.flatMap(overlay => [...overlay]));
125
+
126
+ if (!scope || !entries.size) return [...entries.values()].map(entry => entry.logicalPath);
127
+ const canonical = await this.resolvePath(scope);
128
+
129
+ // Expose the caller's scope spelling, including directories not yet on disk.
130
+ const paths = [];
131
+
132
+ for (const target of entries.keys()) {
133
+ if (scopeContains(canonical, target)) paths.push(path.resolve(scope, path.relative(canonical, target)));
134
+ }
135
+
136
+ return paths;
44
137
  }
45
138
 
46
- async read(target, { preserveRead = false, maxBytes, label = "read input", strict = true } = {}) {
47
- const overlay = this.getOverlay(target);
139
+ #assertIdentity(logicalPath, target) {
140
+ const observed = this.observedPaths.get(logicalPath);
141
+
142
+ if (observed !== undefined && observed !== target) throw new Error("write conflict: path target changed since it was read: " + logicalPath + "; read it again before retrying");
143
+ }
144
+
145
+ #observe(logicalPath, target, signature, preserveRead = false) {
146
+ if (preserveRead) this.#assertIdentity(logicalPath, target);
147
+
148
+ if (!preserveRead || !this.expected.has(target)) this.expected.set(target, signature);
149
+ this.observedPaths.set(logicalPath, target);
150
+ }
151
+
152
+ async readRevision(scope) {
153
+ let canonical;
154
+
155
+ try { canonical = await this.resolvePath(scope); }
156
+ catch (error) { await remapReadError(error, scope); }
157
+
158
+ const read = { scope: canonical, changed: [...(installingPaths ?? [])].some(target => scopeContains(canonical, target)) };
159
+ readScopes.add(read);
160
+
161
+ return read;
162
+ }
163
+
164
+ releaseRead(read) { readScopes.delete(read); }
165
+
166
+ async assertReadCommitted(read) {
167
+ try {
168
+ if (read.changed) await waitForCommits(this.signal);
169
+ this.signal?.throwIfAborted();
170
+
171
+ if (read.changed) throw new Error("workspace may have changed while reading; retry the read");
172
+ } finally { this.releaseRead(read); }
173
+ }
48
174
 
49
- if (overlay !== undefined) return overlayOrThrow(overlay, maxBytes, label, target);
175
+ async read(target, { preserveRead = false, forWrite = false, maxBytes, label = "read input", strict = true } = {}) {
176
+ this.signal?.throwIfAborted();
177
+ let revision;
50
178
 
51
179
  // External editors and captured tools can change a file between any two reads.
52
180
  // Open once with O_NONBLOCK so a FIFO or device cannot park a host I/O worker.
53
181
  try {
54
- const file = await fs.open(target, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
182
+ revision = await this.readRevision(target);
183
+ const overlay = this.#overlay(revision.scope);
184
+
185
+ if (forWrite) this.#assertWriteAlias(target, overlay);
186
+
187
+ if (overlay) return overlayOrThrow(overlay.content, maxBytes, label, target);
188
+ const file = await fs.open(revision.scope, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
55
189
  let bytes;
56
190
 
57
191
  try {
@@ -64,13 +198,15 @@ export class CausalVfs {
64
198
  if (!sameFileVersion(stat, await file.stat())) throw new Error("file changed while reading: " + target);
65
199
  } finally { await file.close(); }
66
200
 
201
+ await this.assertReadCommitted(revision);
202
+
67
203
  // Hash the actual bytes, not a lossy UTF-8 decode/re-encode.
68
- if (!preserveRead || !this.expected.has(target)) this.expected.set(target, textSignature(bytes));
204
+ this.#observe(target, revision.scope, textSignature(bytes), preserveRead || forWrite);
69
205
 
70
206
  return strict ? decodeUtf8Strict(bytes, target) : bytes.toString("utf8");
71
207
  } catch (err) {
72
208
  await remapReadError(err, target);
73
- }
209
+ } finally { this.releaseRead(revision); }
74
210
  }
75
211
 
76
212
  async #diskSignature(target) {
@@ -83,15 +219,56 @@ export class CausalVfs {
83
219
  }
84
220
 
85
221
  async captureExpected(target) {
86
- if (this.getOverlay(target) !== undefined || this.expected.has(target)) return;
222
+ const canonical = await this.resolvePath(target);
223
+ await this.#captureExpected(target, canonical);
224
+ }
225
+
226
+ async #captureExpected(logicalPath, target) {
227
+ this.#assertIdentity(logicalPath, target);
87
228
 
88
- this.expected.set(target, await this.#diskSignature(target));
229
+ if (!this.#overlay(target) && !this.expected.has(target)) this.expected.set(target, await this.#diskSignature(target));
230
+ this.observedPaths.set(logicalPath, target);
89
231
  }
90
232
 
91
- async recordExpected(target, observed) {
92
- if (this.getOverlay(target) !== undefined) return;
93
- const signature = observed ? await fileSignature(target, this.signal, observed) : await this.#diskSignature(target);
94
- this.expected.set(target, signature);
233
+ async recordExpected(target, observed, bytes) {
234
+ this.signal?.throwIfAborted();
235
+ const revision = await this.readRevision(target), canonical = revision.scope;
236
+ let job;
237
+
238
+ try {
239
+ if (this.#overlay(canonical)) return;
240
+
241
+ if (!observed) {
242
+ this.#observe(target, canonical, await this.#diskSignature(canonical));
243
+
244
+ return;
245
+ }
246
+
247
+ // Windows timestamps can alias same-size rewrites; loaded bytes must
248
+ // still agree with disk. Other hosts can reuse a complete byte snapshot.
249
+ if (bytes !== undefined && (!Buffer.isBuffer(bytes) || bytes.length !== observed.size)) throw new Error("file changed while reading: " + target + "; expected complete bytes");
250
+ job = this.pendingSignatures.get(canonical);
251
+
252
+ if (!job || !sameFileVersion(job.observed, observed)) {
253
+ job = { observed, promise: bytes === undefined || process.platform === "win32"
254
+ ? fileSignature(canonical, this.signal, observed)
255
+ : Promise.resolve(textSignature(bytes)) };
256
+ this.pendingSignatures.set(canonical, job);
257
+ }
258
+
259
+ const signature = await job.promise;
260
+
261
+ if (bytes !== undefined && process.platform === "win32" && !sameSignature(signature, textSignature(bytes))) throw new Error("file changed while reading: " + target);
262
+ await this.assertReadCommitted(revision);
263
+
264
+ // Each window retains its own post-read check even when hashing overlaps.
265
+ if (!sameFileVersion(observed, await fs.stat(canonical))) throw new Error("file changed while reading: " + target);
266
+ this.#observe(target, canonical, signature);
267
+ } finally {
268
+ this.releaseRead(revision);
269
+
270
+ if (job && this.pendingSignatures.get(canonical) === job) this.pendingSignatures.delete(canonical);
271
+ }
95
272
  }
96
273
 
97
274
  async write(target, content) {
@@ -99,25 +276,51 @@ export class CausalVfs {
99
276
 
100
277
  if (!isString(content)) throw new Error("write requires string content");
101
278
 
102
- try {
103
- if ((await fs.stat(target)).isDirectory()) throw new Error("cannot write to a directory: " + target);
104
- } catch (err) {
105
- if (err.code !== "ENOENT") throw err;
279
+ if (!content.isWellFormed()) throw new Error("write requires well-formed Unicode text: unpaired UTF-16 surrogate in " + target + "; avoid splitting a Unicode character");
280
+
281
+ const canonical = await this.resolvePath(target);
282
+ const previous = this.#overlay(canonical);
283
+
284
+ this.#assertWriteAlias(target, previous);
285
+
286
+ if (!previous) {
287
+ try {
288
+ if ((await fs.stat(canonical)).isDirectory()) throw new Error("cannot write to a directory: " + target);
289
+ } catch (err) {
290
+ if (err.code !== "ENOENT") throw err;
291
+ }
106
292
  }
107
293
 
108
294
  this.assertWritable();
109
295
 
110
- await this.captureExpected(target);
296
+ await this.#captureExpected(target, canonical);
111
297
 
112
298
  this.assertWritable();
299
+ const latest = this.#overlay(canonical);
300
+
301
+ // Concurrent aliases may have staged while signature capture awaited I/O.
302
+ this.#assertWriteAlias(target, latest);
113
303
 
114
- if (this.overlays.length) {
115
- this.overlays.at(-1).set(target, content);
304
+ const record = this.pathScope.getStore()?.record;
305
+
306
+ // The host retains an empty outer layer for program lifecycle bookkeeping.
307
+ // Best-effort mode stages only inside an explicit inner checkpoint.
308
+ if (this.overlays.length && (!this.bestEffort || this.overlays.length > 1)) {
309
+ this.overlays.at(-1).set(canonical, { logicalPath: target, content });
310
+
311
+ if (record) { record.mutationState = "pending"; record.mutationDepth = this.overlays.length; }
116
312
 
117
313
  return { speculative: true };
118
314
  }
119
315
 
120
- await this.flush(new Map([[target, content]]));
316
+ try {
317
+ await this.flush(new Map([[target, content]]), new Map([[target, canonical]]));
318
+
319
+ if (record) record.mutationState = "saved";
320
+ } catch (error) {
321
+ if (record && this.mutations.recoveryFailed) record.mutationState = "uncertain";
322
+ throw error;
323
+ }
121
324
 
122
325
  return { speculative: false };
123
326
  }
@@ -130,15 +333,61 @@ export class CausalVfs {
130
333
  }
131
334
 
132
335
  /** Stage every file and its backup before replacing any destination. */
133
- async flush(writes) {
134
- this.mutations.pendingCommits++;
135
- const work = commitTail.then(() => this.flushWrites(writes)).finally(() => { this.mutations.pendingCommits--; });
136
- commitTail = work.catch(() => {});
336
+ async #flushOverlay(overlay) {
337
+ const writes = new Map(), targets = new Map();
338
+
339
+ for (const [target, entry] of overlay) {
340
+ writes.set(entry.logicalPath, entry.content);
341
+ targets.set(entry.logicalPath, target);
342
+ }
343
+
344
+ return this.flush(writes, targets);
345
+ }
346
+
347
+ async flush(writes, pinnedTargets = new Map()) {
348
+ this.assertCurrent?.();
349
+ this.signal?.throwIfAborted();
137
350
 
138
- return work;
351
+ if (!writes.size) return;
352
+ this.mutations.pendingCommits++;
353
+ const signal = this.signal;
354
+ let started = false, cancelled = false, pendingWrites = writes, pendingTargets = pinnedTargets;
355
+
356
+ return new Promise((resolve, reject) => {
357
+ const abort = () => {
358
+ if (started || cancelled) return;
359
+ cancelled = true;
360
+ pendingWrites = null;
361
+ pendingTargets = null;
362
+ this.mutations.pendingCommits--;
363
+ signal.removeEventListener("abort", abort);
364
+ reject(signal.reason);
365
+ };
366
+
367
+ // Withdrawal does not release the predecessor's lock. A later live
368
+ // transaction still waits for it; only this caller can settle early.
369
+ const work = commitTail.then(async () => {
370
+ if (cancelled) return;
371
+ started = true;
372
+ signal?.removeEventListener("abort", abort);
373
+
374
+ installingPaths = new Set();
375
+
376
+ for (const target of pendingWrites.keys()) noteCommitPath(target);
377
+
378
+ try { resolve(await this.flushWrites(pendingWrites, pendingTargets)); }
379
+ catch (error) { reject(error); }
380
+ finally { installingPaths = null; pendingWrites = null; pendingTargets = null; this.mutations.pendingCommits--; }
381
+ });
382
+
383
+ commitTail = work.catch(() => {});
384
+ signal?.addEventListener("abort", abort, {once:true});
385
+
386
+ if (signal?.aborted) abort();
387
+ });
139
388
  }
140
389
 
141
- async flushWrites(writes) {
390
+ async flushWrites(writes, pinnedTargets = new Map()) {
142
391
  const staged = [];
143
392
  const targets = new Set();
144
393
  const createdDirs = [];
@@ -151,7 +400,12 @@ export class CausalVfs {
151
400
  for (const [logicalPath, content] of writes) {
152
401
  this.signal?.throwIfAborted();
153
402
  const { target, stat } = await resolveCommitTarget(logicalPath);
403
+ // Validation may wait or cancel; publish the physical read hazard first.
404
+ noteCommitPath(target);
154
405
  await this.validateWrite?.(logicalPath);
406
+ this.#assertIdentity(logicalPath, target);
407
+
408
+ if (pinnedTargets.has(logicalPath) && pinnedTargets.get(logicalPath) !== target) throw new Error("write conflict: path target changed while staged: " + logicalPath);
155
409
 
156
410
  if (targets.has(target)) throw new Error("conflicting write aliases: " + logicalPath);
157
411
  targets.add(target);
@@ -179,12 +433,12 @@ export class CausalVfs {
179
433
  if (!this.overlays.length) return { committed: 0, depth: 0 };
180
434
  const top = this.overlays.at(-1);
181
435
 
182
- if (this.overlays.length > 1) {
436
+ if (this.overlays.length > 1 && !(this.bestEffort && this.overlays.length === 2)) {
183
437
  const parent = this.overlays[this.overlays.length - 2];
184
438
 
185
439
  for (const [key, value] of top) parent.set(key, value);
186
440
  } else {
187
- await this.flush(top);
441
+ await this.#flushOverlay(top);
188
442
  }
189
443
 
190
444
  this.overlays.pop();
@@ -216,24 +470,25 @@ export class CausalVfs {
216
470
  return false; }
217
471
 
218
472
  const pending = this.overlays[0];
219
- await this.flush(pending);
220
- this.assertWritable();
473
+ await this.#flushOverlay(pending);
474
+ // Committed writes no longer belong to the speculative rollback set.
221
475
  this.overlays[0] = new Map();
476
+ this.assertWritable();
222
477
  this.mutations.external++;
223
478
 
224
479
  return pending.size > 0;
225
480
  }
226
481
 
227
482
  /** External-mutation boundary: drop CAS baselines so the next access re-observes disk. */
228
- invalidateObserved() { this.expected.clear(); }
483
+ invalidateObserved() { this.expected.clear(); this.observedPaths.clear(); this.pathScope.getStore()?.paths.clear(); }
229
484
  getOverlayDepth() { return this.overlays.length; }
230
485
  describeOverlays() {
231
486
  let files = 0, bytes = 0;
232
487
 
233
488
  for (const layer of this.overlays) {
234
- for (const content of layer.values()) {
489
+ for (const entry of layer.values()) {
235
490
  files++;
236
- bytes += Buffer.byteLength(content, "utf8");
491
+ bytes += Buffer.byteLength(entry.content, "utf8");
237
492
  }
238
493
  }
239
494
 
@@ -4,6 +4,7 @@ import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { spawn } from "node:child_process";
6
6
  import { constants } from "node:os";
7
+ import { StringDecoder } from "node:string_decoder";
7
8
  import { isString } from "../shared/decode.js";
8
9
  import { truncateChars } from "../output/format.js";
9
10
 
@@ -92,11 +93,13 @@ function rejectUriPath(trimmed, opName, allowSessionRead) {
92
93
 
93
94
  /** Reject scheme:// and scheme:/ paths. A single-letter drive (C:/) stays a filesystem path. */
94
95
  export function assertFilesystemPath(inputPath, opName, allowSessionRead = false) {
95
- if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
96
+ if (inputPath == null || !isString(inputPath) || inputPath.length === 0) {
96
97
  throw new Error(`${opName} requires path`);
97
98
  }
98
99
 
99
- return rejectUriPath(inputPath.trim(), opName, allowSessionRead);
100
+ if (!inputPath.isWellFormed()) throw new Error(`${opName} requires a well-formed Unicode path: unpaired UTF-16 surrogate`);
101
+
102
+ return rejectUriPath(String(inputPath), opName, allowSessionRead);
100
103
  }
101
104
 
102
105
  export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false, fresh = false) {
@@ -206,6 +209,23 @@ function appendCommandOutput(state, current, chunk) {
206
209
  return remaining ? current + chunk.slice(0, remaining) : current;
207
210
  }
208
211
 
212
+ function captureCommandStream(state, name) {
213
+ const stream = state.child[name], decoder = new StringDecoder("utf8");
214
+
215
+ stream.on("data", chunk => {
216
+ // Keep draining after the shared cap fills, without decoding discarded text
217
+ // or backpressuring a child that still needs to finish its work.
218
+ if (state.stdout.length + state.stderr.length >= state.maxOutputChars) {
219
+ if (chunk.length) state.outputTruncated = true;
220
+
221
+ return;
222
+ }
223
+
224
+ state[name] = appendCommandOutput(state, state[name], decoder.write(chunk));
225
+ });
226
+ stream.once("end", () => { state[name] = appendCommandOutput(state, state[name], decoder.end()); });
227
+ }
228
+
209
229
  function attachCommandIO(state, options, argv, timeoutMs) {
210
230
  const { child } = state;
211
231
  const onAbort = () => terminateCommand(state, new Error("aborted"));
@@ -218,10 +238,8 @@ function attachCommandIO(state, options, argv, timeoutMs) {
218
238
  "command timed out after " + timeoutMs + "ms: " + truncateChars(options.commandLabel ?? argv.join(" "), 240, "command").text
219
239
  + "\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."
220
240
  )), timeoutMs);
221
- child.stdout.setEncoding("utf8");
222
- child.stderr.setEncoding("utf8");
223
- child.stdout.on("data", chunk => { state.stdout = appendCommandOutput(state, state.stdout, chunk); });
224
- child.stderr.on("data", chunk => { state.stderr = appendCommandOutput(state, state.stderr, chunk); });
241
+ captureCommandStream(state, "stdout");
242
+ captureCommandStream(state, "stderr");
225
243
  child.on("error", error => failCommand(state,commandSpawnError(error,argv[0])));
226
244
  child.once("exit", (code, signal) => {
227
245
  state.processExited = true;
@@ -65,7 +65,9 @@ function fitContainer(input, limit) {
65
65
  const out = Array.isArray(input) ? [] : { truncated: true };
66
66
  const entries = Object.entries(input);
67
67
 
68
- if (!Array.isArray(input)) entries.sort((a, b) => json(a[1]).length - json(b[1]).length);
68
+ const lengths = Array.isArray(input) ? null : new Map(entries.map(([key, child]) => [key, json(child).length]));
69
+
70
+ if (lengths) entries.sort((a, b) => lengths.get(a[0]) - lengths.get(b[0]));
69
71
 
70
72
  for (const [key, child] of entries) {
71
73
  const used = json(out).length;
@@ -73,7 +75,7 @@ function fitContainer(input, limit) {
73
75
  const available = limit - used - overhead;
74
76
 
75
77
  if (available < 4) break;
76
- assignBounded(out, key, fit(child, available));
78
+ assignBounded(out, key, fit(child, available, lengths?.get(key)));
77
79
 
78
80
  if (json(out).length > limit) rollbackBounded(out, key);
79
81
  }
@@ -81,10 +83,8 @@ function fitContainer(input, limit) {
81
83
  return out;
82
84
  }
83
85
 
84
- function fit(input, limit) {
85
- const serialized = json(input);
86
-
87
- if (serialized.length <= limit) return input;
86
+ function fit(input, limit, encodedLength = json(input).length) {
87
+ if (encodedLength <= limit) return input;
88
88
 
89
89
  if (isString(input)) return fitString(input, limit);
90
90
 
@@ -99,7 +99,7 @@ function summarizeDetails(value, budget = 2000) {
99
99
 
100
100
  if (encoded.length <= budget) return encoded;
101
101
 
102
- return json(fit(JSON.parse(encoded), budget));
102
+ return json(fit(JSON.parse(encoded), budget, encoded.length));
103
103
  }
104
104
 
105
105
  function spill(fullText, config) {
@@ -222,36 +222,53 @@ function attachTruncation(result, truncated, batch, text, config, maxChars, capp
222
222
  attachSpill(result, batch, text, config, maxChars, capped);
223
223
  }
224
224
 
225
- export function packageHostResult(raw, config) {
226
- const details = detailsOf(raw);
225
+ /** Already materialized batches retain items; never budget them a second time. */
226
+ function attachTypedBatch(result, batch, details) {
227
+ result.items = batch;
228
+ result.itemErrors = details.itemErrors ?? [];
227
229
 
228
- if (raw && Object.hasOwn(raw, READ_VALUE)) {
229
- const batch = batchFromDetails(details);
230
- const result = {ok: !hostResultFailed(raw), value: raw[READ_VALUE], typed: true, cloneItems: details?.jsonMany === true, truncated: details?.outputTruncated === true};
231
- attachDetails(result, details, batch);
230
+ if (details.sourcePaths) result.sourcePaths = details.sourcePaths;
231
+ }
232
232
 
233
- if (isString(details?.sourcePath)) result.sourcePath = details.sourcePath;
233
+ function typedHostResult(raw, details) {
234
+ const batch = batchFromDetails(details);
235
+ const result = {ok: !hostResultFailed(raw), value: raw[READ_VALUE], typed: true, cloneItems: details?.jsonMany === true, truncated: details?.outputTruncated === true};
236
+ attachDetails(result, details, batch);
234
237
 
235
- if (batch) {
236
- result.items = batch;
237
- result.itemErrors = details.itemErrors ?? [];
238
+ if (isString(details?.sourcePath)) result.sourcePath = details.sourcePath;
238
239
 
239
- if (details.sourcePaths) result.sourcePaths = details.sourcePaths;
240
- }
240
+ if (batch) attachTypedBatch(result, batch, details);
241
241
 
242
- if (details?.streamed) result.streamed = true;
242
+ if (details?.streamed) result.streamed = true;
243
243
 
244
- return result;
245
- }
244
+ return result;
245
+ }
246
+
247
+ function hasStructuredResult(raw, acceptsStructured) {
248
+ return acceptsStructured && !hostResultFailed(raw) && raw?.structuredContent !== undefined;
249
+ }
246
250
 
251
+ function displayedHostText(raw, batch, structured) {
252
+ return batch ? "" : structured ? json(raw.structuredContent) : extractRawString(raw);
253
+ }
254
+
255
+ function boundedStructuredValue(raw, capped) {
256
+ return capped.truncated ? capped.text : raw.structuredContent;
257
+ }
258
+
259
+ function boundedHostResult(raw, details, config, acceptsStructured) {
247
260
  const maxChars = config.maxCallResultChars ?? 65536;
248
- const batch = batchFromDetails(details);
249
- const text = batch ? "" : extractRawString(raw);
261
+ const structured = hasStructuredResult(raw, acceptsStructured);
262
+ const batch = structured ? undefined : batchFromDetails(details);
263
+ const text = displayedHostText(raw, batch, structured);
250
264
  const capped = truncateChars(text, maxChars, "host-result");
251
265
  let truncated = hostTruncated(capped, details);
252
266
  const image = hostImage(raw);
253
267
  const directoryEntries = directoryEntriesIfFit(image, details, maxChars);
254
- const result = { ok: !hostResultFailed(raw), value: hostResultValue(image, directoryEntries, capped), truncated };
268
+ const result = { ok: !hostResultFailed(raw), value: structured ? boundedStructuredValue(raw, capped) : hostResultValue(image, directoryEntries, capped), truncated };
269
+
270
+ // Even a clipped JSON display is already materialized; read must not parse it.
271
+ if (structured) result.typed = true;
255
272
  attachDetails(result, details, batch);
256
273
  truncated ||= attachBatch(result, batch, details, maxChars);
257
274
  attachTruncation(result, truncated, batch, text, config, maxChars, capped);
@@ -259,4 +276,12 @@ export function packageHostResult(raw, config) {
259
276
  return result;
260
277
  }
261
278
 
279
+ export function packageHostResult(raw, config, acceptsStructured = false) {
280
+ const details = detailsOf(raw);
281
+
282
+ return raw && Object.hasOwn(raw, READ_VALUE)
283
+ ? typedHostResult(raw, details)
284
+ : boundedHostResult(raw, details, config, acceptsStructured);
285
+ }
286
+
262
287
  export {packageFinalReturn} from "./final.js";