pi-supernova 0.10.1 → 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 +376 -221
  2. package/docs/CHANGELOG.md +107 -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 +39 -19
  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 +25 -9
  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 +86 -17
  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
@@ -1,6 +1,6 @@
1
1
  import { isFunction, isObject, isString } from "../shared/decode.js";
2
2
 
3
- const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "find", "ls", "snap", "evidence", "surface", "asgrep_search", "asgrep_status", "ast_grep", "web_search"]);
3
+ const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "find", "ls", "snap", "evidence", "surface", "asgrep_search", "asgrep_status", "ast_grep", "web_search", "isearch"]);
4
4
 
5
5
  const READ_ONLY_LSP = new Set(["definition", "references", "hover", "symbols", "diagnostics", "implementation", "type_definition", "incoming_calls", "outgoing_calls"]);
6
6
 
@@ -55,9 +55,11 @@ function compatibleScheduledJob(job, active) {
55
55
 
56
56
  async function prepareScheduledJob(job) {
57
57
  if (!job.resolveKey) return;
58
+
58
59
  // Key lookup is advisory. Invalid paths still run in isolation so the real
59
60
  // adapter reports the error through its normal trace/permission checks.
60
61
  try { job.key = await job.resolveKey(); } catch {}
62
+
61
63
  job.resolveKey = undefined;
62
64
  }
63
65
 
@@ -98,6 +100,7 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
98
100
  if (!active.size) stats.readWaves++;
99
101
  stats.peakParallelReads = Math.max(stats.peakParallelReads, active.size + 1);
100
102
  }
103
+
101
104
  active.add(job);
102
105
  // Each promise settles independently. Barriers still wait for every active
103
106
  // sibling, including when Promise.all in the guest has already rejected.
@@ -107,15 +110,20 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
107
110
  async function drain() {
108
111
  if (draining) return;
109
112
  draining = true;
113
+
110
114
  try {
111
115
  while (queue.length && active.size < maxParallelReads) {
112
116
  const job = queue[0];
117
+
113
118
  if (job.cancelled) { queue.shift(); continue; }
119
+
114
120
  // Resolve identities after shell/override barriers, which may change
115
121
  // symlinks. Check synchronously: a finishing job must not lose its wakeup.
116
122
  if (job.resolveKey && [...active].some(running => !isString(running.key))) break;
117
123
  await prepareScheduledJob(job);
124
+
118
125
  if (job.cancelled) continue;
126
+
119
127
  if (!compatibleScheduledJob(job, active)) break;
120
128
  queue.shift();
121
129
  start(job);
@@ -143,51 +151,3 @@ export function createNativeScheduler({ maxParallelReads = 8 } = {}) {
143
151
  },
144
152
  };
145
153
  }
146
-
147
- function requireArray(value, name) {
148
- if (!Array.isArray(value)) throw new TypeError(name + " requires an array");
149
-
150
- return value;
151
- }
152
-
153
- function waveMutates(list, meta, config) {
154
- const names = meta?.names ?? [];
155
-
156
- return names.length !== list.length || names.some((name, i) => isMutatingTool(name, config, meta?.calls?.[i]?.args, meta?.definitions?.[i]));
157
- }
158
-
159
- function shouldRunParallel(mutating, mode, length) {
160
- return !mutating && (mode === "parallel" || (mode === "auto" && length > 1));
161
- }
162
-
163
- async function settleParallel(list) {
164
- // Do not finish a wave while its already-started host calls are still running.
165
- const settled = await Promise.allSettled(list.map(thunk => Promise.resolve().then(thunk)));
166
- const failure = settled.find(item => item.status === "rejected");
167
-
168
- if (failure) throw failure.reason;
169
-
170
- return { results: settled.map(item => item.value), mode: "parallel", reason: "independent-reads" };
171
- }
172
-
173
- async function runSerial(list, mutating) {
174
- const results = [];
175
-
176
- for (const thunk of list) results.push(await thunk());
177
-
178
- return { results, mode: "serial", reason: mutating ? "mutating" : "single-or-forced" };
179
- }
180
-
181
- export async function runParallelWave(thunks, meta, options = {}) {
182
- const list = requireArray(thunks, "parallel wave");
183
-
184
- if (list.some(item => !isFunction(item))) throw new TypeError("parallel wave requires functions");
185
-
186
- if (!list.length) return { results: [], mode: "serial", reason: "empty" };
187
- const { mode = "auto", config = {} } = options;
188
- const mutating = waveMutates(list, meta, config);
189
-
190
- if (shouldRunParallel(mutating, mode, list.length)) return settleParallel(list);
191
-
192
- return runSerial(list, mutating);
193
- }
@@ -13,8 +13,9 @@ const mutationTotals = results => results.reduce((total, result) => {
13
13
  return total;
14
14
  }, {committed:0,rolledBack:0,external:0,pendingCommits:0,recoveryFailed:false});
15
15
 
16
- export function programBatchText(results, total, stopped = "", failed = 0) {
16
+ export function programBatchText(results, total, stopped = "", failed = 0, texts = results.map(textOf)) {
17
17
  const m = mutationTotals(results);
18
+
18
19
  const summary = stopped
19
20
  ? "error: programs stopped: " + stopped + " " + results.length + "/" + total
20
21
  : failed > 0
@@ -22,10 +23,8 @@ export function programBatchText(results, total, stopped = "", failed = 0) {
22
23
  : "ok: programs " + results.length + "/" + total;
23
24
 
24
25
  return summary +
25
- (m.recoveryFailed || m.pendingCommits ? "; filesystem outcome uncertain: inspect disk" : "") + "\nresults (UTF-16 lengths):\n" + results.map((result,i) => {
26
- const text = textOf(result);
27
-
28
- return "[" + i + "] " + text.length + "\n" + text + "\n";
26
+ (m.recoveryFailed || m.pendingCommits ? "; filesystem outcome uncertain: inspect disk" : "") + "\nresults (UTF-16 lengths):\n" + texts.map((text,i) => {
27
+ return "[" + (results[i].details?.programIndex ?? i) + "] " + text.length + "\n" + text + "\n";
29
28
  }).join("");
30
29
  }
31
30
 
@@ -92,18 +91,16 @@ class ProgramBatch {
92
91
  return " (ran " + Math.round(performance.now() - this.started) + "ms of " + this.timeout + "ms)";
93
92
  }
94
93
  takeSettled(result, i) {
95
- this.results.push(result);
94
+ this.results.push({...result,details:{...result.details,programIndex:i}});
96
95
  this.trace.push(...(result.details?.trace ?? []));
97
96
  this.collectImages(result, i);
98
97
  }
99
98
 
100
99
  parallelBudgetStop(settled) {
101
- const results = settled.filter(Boolean);
102
100
  const {images, bytes} = imageTotals(settled);
103
- let kind;
104
- if (images > 16 || bytes > 20 * 1024 * 1024) kind = "image";
105
- 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";
106
- return kind ? "batch " + kind + " budget exceeded; completed commits remain" : "";
101
+
102
+ return images > 16 || bytes > 20 * 1024 * 1024
103
+ ? "batch image budget exceeded; completed commits remain" : "";
107
104
  }
108
105
 
109
106
  async runParallel() {
@@ -116,7 +113,9 @@ class ProgramBatch {
116
113
  const i = next++;
117
114
  settled[i] = await this.runOne(this.programs[i], i);
118
115
  this.live[i] = [];
119
- this.stopped ||= this.parallelBudgetStop(settled);
116
+ this.stopped ||= this.parallelBudgetStop(settled) || this.sequentialStop(settled[i], i);
117
+
118
+ if (settled[i].details?.mutations?.recoveryFailed || settled[i].details?.mutations?.pendingCommits) this.controller.abort(new Error("batch stopped: filesystem outcome uncertain"));
120
119
  }
121
120
  }));
122
121
 
@@ -125,7 +124,7 @@ class ProgramBatch {
125
124
  this.takeSettled(settled[i], i);
126
125
  }
127
126
 
128
- if ((!this.stopped && settled.includes(undefined)) || this.combined.aborted || performance.now() >= this.deadline) this.stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
127
+ if ((!this.stopped && settled.includes(undefined)) || this.combined.aborted || performance.now() >= this.deadline) this.stopped ||= "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
129
128
  }
130
129
 
131
130
  sequentialStop(result, i) {
@@ -133,11 +132,9 @@ class ProgramBatch {
133
132
 
134
133
  if (this.imageDropped) stopped = "batch image budget exceeded; remaining programs did not run";
135
134
 
136
- if (result.details?.ok === false) stopped = "program " + (i+1) + " failed; remaining programs did not run; earlier commits remain";
137
- // Display clipping is not an execution failure. Finish every requested entry
138
- // unless a real execution/resource limit stops it; boundedText caps delivery.
139
-
140
- if (result.details?.logTruncated) stopped ||= "batch log budget exceeded; remaining programs did not run; earlier commits remain";
135
+ if (!result.details?.mutations || result.details.mutations.recoveryFailed || result.details.mutations.pendingCommits || /host call budget exceeded/.test(result.details?.error ?? "")) stopped = "program " + (i+1) + " exhausted a shared budget or has an uncertain filesystem outcome; remaining programs did not run; earlier commits remain";
136
+ // Text/log clipping is not an execution failure. Finish every requested
137
+ // entry unless a real execution/resource limit stops it; delivery is bounded.
141
138
 
142
139
  // The deadline explains a killed program better than "program N failed".
143
140
  if (this.combined.aborted || performance.now() >= this.deadline) stopped = "batch deadline or cancellation; earlier commits remain" + this.deadlineNote();
@@ -159,30 +156,56 @@ class ProgramBatch {
159
156
  }
160
157
 
161
158
  failNote(failed) {
162
- return this.stopped || (this.parallel && failed ? failed + " program" + (failed>1?"s":"") + " failed" : undefined);
159
+ return this.stopped || (failed ? failed + " program" + (failed>1?"s":"") + " failed" : undefined);
163
160
  }
164
161
 
165
- boundedText(failed) {
166
- const note = this.imageDropped && !this.stopped ? "some images dropped: batch image budget" : "";
162
+ boundedText(failed, logTruncated) {
163
+ const note = [
164
+ this.imageDropped && !this.stopped ? "some images dropped: batch image budget" : "",
165
+ logTruncated ? "logs truncated: batch log budget" : "",
166
+ ].filter(Boolean).join("; ");
167
+
168
+ const limit = Math.max(0, this.config.maxReturnChars - this.imageTextChars());
169
+ const texts = this.results.map(textOf);
170
+ const render = previews => programBatchText(this.results, this.programs.length, this.stopped, failed, previews) + (note ? "; " + note : "");
171
+ const text = render(texts);
172
+
173
+ if (text.length <= limit) return { text, truncated: false };
174
+ const marker = "\n[batch output truncated; per-program previews]";
175
+ // Reserve framing and length digits before dividing space. Clipping the
176
+ // combined string can erase an entire program, including its error reason.
177
+ const overhead = render(texts.map(() => "")).length + marker.length + texts.length * String(limit).length;
178
+ const share = Math.max(0, Math.floor((limit - overhead) / Math.max(1, texts.length)));
179
+ const previews = texts.map(value => truncateChars(value, share, "program output").text);
167
180
 
168
- return truncateChars(programBatchText(this.results,this.programs.length,this.stopped,this.parallel ? failed : 0) + (note ? "; " + note : ""),Math.max(0,this.config.maxReturnChars-this.imageTextChars()),"batch output");
181
+ return { ...truncateChars(render(previews) + marker, limit, "batch output"), truncated: true };
169
182
  }
170
183
 
171
184
  finish() {
172
- const failed = this.results.filter(result => result.details?.ok === false).length;
173
- const bounded = this.boundedText(failed);
174
- const content = [{type:"text",text:bounded.text}];
185
+ const failedPrograms = [];
186
+
187
+ for (const result of this.results) {
188
+ if (result.details?.ok === false) failedPrograms.push(result.details.programIndex);
189
+ }
190
+
191
+ const attempted = new Set(this.results.map(result => result.details.programIndex));
192
+ const notRunPrograms = this.programs.flatMap((_, i) => attempted.has(i) ? [] : [i]);
193
+ const failed = failedPrograms.length;
175
194
  const logs = this.results.flatMap(result => result.details?.logs ?? []);
176
195
  const logLimit = this.config.maxLogLines ?? 100;
196
+ const logTruncated = logs.length > logLimit || this.results.some(result=>result.details?.logTruncated);
197
+ const bounded = this.boundedText(failed, logTruncated);
198
+ const mutations = mutationTotals(this.results);
199
+ const content = [{type:"text",text:bounded.text}];
177
200
  this.images.forEach((image,i) => content.push({type:"text",text:this.imageLabels[i]},image));
178
201
 
179
202
  // Return a typed stop report instead of throwing away earlier results/images.
180
203
  // Single-program errors retain their existing throwing behavior.
181
- return {content,isError:!!this.stopped || (this.parallel && failed>0),details:{ok:!this.stopped && !(this.parallel && failed),error:this.failNote(failed),wallMs:Math.round(performance.now()-this.started),
182
- programs:this.results,attempted:this.results.length,total:this.programs.length,stopped:this.stopped,parallel:this.parallel,
204
+ return {content,isError:!!this.stopped || failed>0,details:{ok:!this.stopped && !failed,partial:failed>0 && mutations.committed>0,error:this.failNote(failed),wallMs:Math.round(performance.now()-this.started),
205
+ programs:this.results,attempted:this.results.length,failed,failedPrograms,notRunPrograms,notRun:this.programs.length-this.results.length,total:this.programs.length,stopped:this.stopped,parallel:this.parallel,
183
206
  result:bounded.truncated ? bounded.text : this.results.map(result=>result.details?.result),
184
207
  returnTruncated:bounded.truncated || this.results.some(result=>result.details?.returnTruncated),
185
- logTruncated:logs.length > logLimit || this.results.some(result=>result.details?.logTruncated),logs:logs.slice(0,logLimit),trace:this.trace,mutations:mutationTotals(this.results)}};
208
+ logTruncated,logs:logs.slice(0,logLimit),trace:this.trace,mutations}};
186
209
  }
187
210
 
188
211
  async run() {
@@ -208,11 +231,13 @@ function imageBlocks(result) {
208
231
 
209
232
  function imageTotals(settled) {
210
233
  let images = 0, bytes = 0;
234
+
211
235
  for (const result of settled) {
212
236
  for (const block of imageBlocks(result)) {
213
237
  images++;
214
238
  bytes += Buffer.byteLength(block.data, "base64");
215
239
  }
216
240
  }
241
+
217
242
  return {images, bytes};
218
243
  }
@@ -1,4 +1,4 @@
1
- import { readLimitedBytes } from "../fs/file-io.js";
1
+ import { readLimitedBytes, sameFileVersion } from "../fs/file-io.js";
2
2
  import * as fs from "node:fs/promises";
3
3
  import { resolveWorkspacePath } from "../fs/workspace.js";
4
4
 
@@ -24,6 +24,7 @@ export async function readProgramFile(file, cwd, maxChars, signal) {
24
24
 
25
25
  const bytes = await readLimitedBytes(handle, stat, maxBytes, file, signal, tooLarge);
26
26
 
27
+ if (!sameFileVersion(stat, await handle.stat())) throw new Error("program file changed while reading: " + file + "; retry with a stable source file");
27
28
  signal?.throwIfAborted();
28
29
  // Do not silently replace invalid bytes in executable source. Preserve BOMs.
29
30
  let code;
@@ -6,16 +6,6 @@ const PARSE_OPTIONS = { ecmaVersion: "latest", sourceType: "module", allowReturn
6
6
 
7
7
  const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
8
8
 
9
- function hasReturn(node) {
10
- if (!isObject(node)) return false;
11
-
12
- if (node.type === "ReturnStatement") return true;
13
-
14
- if (FUNCTION_TYPES.has(node.type)) return false;
15
-
16
- return Object.values(node).some(value => Array.isArray(value) ? value.some(hasReturn) : hasReturn(value));
17
- }
18
-
19
9
  function parseExpressionFunction(code) {
20
10
  try {
21
11
  const program = parse(code, PARSE_OPTIONS);
@@ -24,7 +14,7 @@ function parseExpressionFunction(code) {
24
14
  const candidate = statement?.type === "ExpressionStatement" ? statement.expression : statement;
25
15
 
26
16
  if (candidate && FUNCTION_TYPES.has(candidate.type)) {
27
- return { program, expression: candidate, expressionSource: code.slice(statement.start, statement.end).replace(/;\s*$/, "") };
17
+ return { program, expression: candidate, sourceStart: statement.start, expressionSource: code.slice(statement.start, statement.end).replace(/;\s*$/, "") };
28
18
  }
29
19
 
30
20
  return { program };
@@ -44,28 +34,57 @@ function parseExpressionFunction(code) {
44
34
 
45
35
  function deniedSpecifier(node) {
46
36
  if (node?.type === "Literal" && isString(node.value)) return node.value;
37
+
47
38
  if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked;
48
39
  }
49
40
 
50
- function assertGuestImports(node) {
51
- if (Array.isArray(node)) { node.forEach(assertGuestImports); return; }
52
- if (!isObject(node)) return;
53
- if (["ImportDeclaration", "ImportExpression"].includes(node.type)) rejectGuestImport(node.source);
54
- if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "require") rejectGuestImport(node.arguments?.[0]);
55
- for (const key of Object.keys(node)) {
56
- if (!["start", "end", "loc", "range"].includes(key)) assertGuestImports(node[key]);
41
+ function inspectProgram(root, expression) {
42
+ let returns = expression?.type === "ArrowFunctionExpression" && expression.body.type !== "BlockStatement";
43
+ // Valid member chains can exceed the host call stack well below the code cap.
44
+ const pending = [{node:root,returnScope:true}];
45
+
46
+ while (pending.length) {
47
+ let {node,returnScope} = pending.pop();
48
+
49
+ if (Array.isArray(node)) {
50
+ for (let i = node.length - 1; i >= 0; i--) pending.push({node:node[i],returnScope});
51
+
52
+ continue;
53
+ }
54
+
55
+ // eslint-disable-next-line anti-slop/no-runtime-typeof -- This is an Acorn-owned AST, not unparsed host or guest data.
56
+ if (!node || typeof node !== "object") continue;
57
+ const type = node.type;
58
+
59
+ // Function scopes affect only return hints. Import checks must still visit
60
+ // nested/unreachable bodies, even after finding a return in the invoked scope.
61
+ if (FUNCTION_TYPES.has(type)) returnScope = node === expression;
62
+
63
+ if (type === "ReturnStatement" && returnScope) returns = true;
64
+
65
+ if (type === "ImportDeclaration" || type === "ImportExpression") rejectGuestImport(node.source);
66
+
67
+ if (type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "require") rejectGuestImport(node.arguments?.[0]);
68
+
69
+ const keys = Object.keys(node);
70
+
71
+ for (let i = keys.length - 1; i >= 0; i--) {
72
+ const value = node[keys[i]];
73
+
74
+ // eslint-disable-next-line anti-slop/no-runtime-typeof -- Acorn already parsed these fields; distinguish child objects from scalar AST metadata.
75
+ if (value && typeof value === "object") pending.push({node:value,returnScope});
76
+ }
57
77
  }
78
+
79
+ return returns;
58
80
  }
59
81
 
60
82
  function prepareProgram(code) {
61
83
  const parsed = parseExpressionFunction(code);
62
- assertGuestImports(parsed.program ?? parsed.expression);
84
+ const returns = inspectProgram(parsed.program ?? parsed.expression, parsed.expression);
63
85
  const body = parsed.expression ? "return await (" + parsed.expressionSource + "\n)();" : code;
64
- const returns = parsed.expression
65
- ? parsed.expression.type === "ArrowFunctionExpression" && parsed.expression.body.type !== "BlockStatement" || hasReturn(parsed.expression.body)
66
- : hasReturn(parsed.program);
67
86
 
68
- return { body, hasReturn: returns };
87
+ return { body, hasReturn: returns, sourceStart: parsed.sourceStart ?? 0, prefixLength: parsed.expression ? "return await (".length : 0 };
69
88
  }
70
89
 
71
90
  function admitData(data, cap) {
@@ -75,6 +94,7 @@ function admitData(data, cap) {
75
94
  const encoded = JSON.stringify(data);
76
95
 
77
96
  if (encoded === undefined) return { error: "data must be JSON-serializable" };
97
+
78
98
  if (encoded.length > cap) return { error: "data exceeds " + cap + " characters (serialized JSON: " + encoded.length + " UTF-16 characters); no commands ran. Split literal inputs across invocations; large text can use write({path,content,append:true}) chunks without omitting content" };
79
99
 
80
100
  return { data: JSON.parse(encoded) };
@@ -83,7 +103,9 @@ function admitData(data, cap) {
83
103
 
84
104
  function admitCode({ code, file, cap }) {
85
105
  if ((code === undefined) === (file === undefined)) return { error: "supply exactly one of code or file; no commands ran" };
106
+
86
107
  if (file === undefined && (!isString(code) || !code.trim())) return { error: "code must be a non-empty string" };
108
+
87
109
  if (file === undefined && code.length > cap) return { error: "code exceeds " + cap + " characters; split large writes into write({path,content,append:true}) chunks" };
88
110
  }
89
111
 
@@ -109,6 +131,7 @@ function admitGuest({ code, file, data, config }) {
109
131
 
110
132
  return { data: admitted.data, timeoutMs: timeout.timeoutMs };
111
133
  }
134
+
112
135
  export { admitGuest, prepareProgram };
113
136
 
114
137
  function rejectGuestImport(source) {
@@ -117,25 +140,55 @@ function rejectGuestImport(source) {
117
140
  throw new Error(reason + "; no commands ran");
118
141
  }
119
142
 
120
- function containingAwait(node, offset) {
121
- if (Array.isArray(node)) return node.map(child => containingAwait(child, offset)).find(Boolean);
122
- if (!isObject(node) || offset < node.start || offset >= node.end) return null;
123
- for (const child of Object.values(node)) {
124
- const found = containingAwait(child, offset);
125
- if (found) return found;
143
+ function containingAwait(root, offset) {
144
+ const pending = [root];
145
+ let found = null;
146
+
147
+ while (pending.length) {
148
+ const node = pending.pop();
149
+
150
+ if (Array.isArray(node)) {
151
+ for (const child of node) pending.push(child);
152
+
153
+ continue;
154
+ }
155
+
156
+ if (!isObject(node) || offset < node.start || offset >= node.end) continue;
157
+
158
+ // Containing awaits nest; descendants supersede the enclosing await.
159
+ if (node.type === "AwaitExpression") found = node;
160
+
161
+ for (const child of Object.values(node)) pending.push(child);
126
162
  }
127
- return node.type === "AwaitExpression" ? node : null;
163
+
164
+ return found;
128
165
  }
129
166
 
130
- // V8 points at 'await'; JSC points at the called function's parenthesis. Report
131
- // the enclosing await expression consistently, using source syntax, not offsets.
132
- export function normalizeGuestLocation(source, location) {
133
- if (!location?.awaited) return location;
134
- const {line, col} = location;
135
- const prefix = source.split("\n").slice(0, line - 1).join("\n");
136
- const offset = prefix.length + Number(line > 1) + col - 1;
137
- try {
138
- const node = containingAwait(parse(source, {...PARSE_OPTIONS, locations:true}), offset);
139
- return node ? {line:node.loc.start.line, col:node.loc.start.column + 1} : location;
140
- } catch { return location; }
167
+ // V8 points at 'await'; JSC points at the call parenthesis. Resolve the await
168
+ // in executable syntax, then undo wrapper/sliced-comment offsets. ECMAScript
169
+ // line separators differ from file/JSONL LF framing.
170
+ export function normalizeGuestLocation(source, location, prepared = { body: source }) {
171
+ if (!location) return location;
172
+ const body = prepared.body;
173
+ const lines = /\r\n|[\n\r\u2028\u2029]/g;
174
+ let start = 0;
175
+
176
+ for (let line = 1; line < location.line; line++) {
177
+ if (!lines.exec(body)) return null;
178
+ start = lines.lastIndex;
179
+ }
180
+
181
+ let offset = start + location.col - 1;
182
+
183
+ if (location.awaited) {
184
+ try { offset = containingAwait(parse(body, PARSE_OPTIONS), offset)?.start ?? offset; }
185
+ catch { /* Keep the engine's position when syntax cannot be recovered. */ }
186
+ }
187
+
188
+ offset += (prepared.sourceStart ?? 0) - (prepared.prefixLength ?? 0);
189
+
190
+ if (offset < 0 || offset > source.length) return null;
191
+ const original = source.slice(0, offset).split(/\r\n|[\n\r\u2028\u2029]/);
192
+
193
+ return { ...location, line: original.length, col: original.at(-1).length + 1 };
141
194
  }
@@ -1,13 +1,14 @@
1
1
  // Standing tool description: sent on every request. No result or history compression.
2
- export const REFERENCE = `JS body/async arrow; read/write/edit/bash, no fs/import/require. file: workspace JS; data: literals ≤48000 JSON chars.
2
+ export const REFERENCE = `JS body/async arrow; read/write/edit/bash, no fs/import/require. file: workspace JS; data: ≤48000 JSON chars.
3
3
  read(path|paths,offset=1,limit?) → text/text[]; dirs→entries; PNG/JPEG/GIF/WebP ≤16/20MiB.
4
- Text ≤64 MiB internally; complete:true requires whole file. Display capped: summarize or read(path,{offset:1,limit:80}); larger files via bash.
5
- read({path,json:true}) or json:".field" or json:[".a"] → JSON, no jq (16MiB input/64MiB selection). Values retain their types. selector is json's value; a leftover selector key folds.
4
+ Text ≤64 MiB internally; complete:true whole file. Display capped: summarize or read(path,{offset:1,limit:80}); larger files via bash.
5
+ read({path,json:true}) → JSON; json:".field" or json:[".a"] selects (16MiB input/64MiB selection; no jq). Values retain their types; leftover selector folds into json.
6
+ read({query,indexed:true}): optional callable isearch, then an owned editable read.
6
7
  read("symbol or question") = read({query,resolve:true}) → view; check status; view.text is a span. read(path,{about}) windows; read({query,evidence:true}) evidence; read({path,outline:true}) declarations.
7
- write/edit workspace-only; external changes need separately authorized command. write(path,text) or {path,content,append:true}; after read edit or replace:true.
8
- edit(path,oldText,newText) or {path,edits:[{oldText,newText}]}; unique exact match; numbered windows. edit(view,text) or edit(view,old,new). edit(async()=>{...}) checkpoint: no bash; merge/rollback+rethrow.
9
- bash(command,{cwd?,timeoutMs?}) or {command,args}; nonzero throws. Outer timeout bounds foreground; bash inherits. Shell mutations flush edits.
10
- bash({command,background:true,pty?:true,timeoutMs?:1800000})→sessionId. bash({action:"list"}) or {sessionId,action:"poll"|"write"|"stop",cursor?,waitMs?,input?}→status/output/cursor/exitCode. PTY macOS/Linux; jobs end at shutdown.
11
- programs:[{code?,file?,data?}] inherit defaults; mergeData:true shallow. Fresh guests/commits; sequential failure stops, prior commits stay; parallel:true disjoint work.
12
- Promise.all ≤8 disjoint reads/mutations; same-file serial; bash/checkpoints barriers. Optional reads: Promise.allSettled retains successes. ONE call for known work.
8
+ write/edit workspace-only; external changes need separately authorized command. write(path,text,opts?) or {path,content,append:true}; after read edit or replace:true.
9
+ edit(path,oldText,newText) or {path,edits:[{oldText,newText}]}; unique exact match; numbered windows. edit(view,text) or edit(view,old,new). edit(async()=>{...}) atomic checkpoint: no bash; commit/rollback+rethrow. Other edits save; retry failures only.
10
+ bash(command,opts?) or {command,args,cwd?,timeoutMs?}; nonzero throws. Capture≤2Mi chars; larger output via file; outer deadline.
11
+ bash({command,background:true,pty?:true,timeoutMs?:1800000})→sessionId. bash({action:"list"}) or {sessionId,action:"poll"|"write"|"stop",cursor?,waitMs?,input?}→status/output/cursor/exitCode. poll waitMs 0..30000. PTY macOS/Linux; jobs end at shutdown.
12
+ programs:[{code?,file?,data?}] inherit defaults; mergeData:true shallow. Fresh guests; failures continue; limits/uncertainty stop; parallel:true disjoint.
13
+ Promise.all ≤8 disjoint; same-file serial; bash/checkpoints barriers. Independent: Promise.allSettled; uncaught errors stop JS.
13
14
  `;