pi-supernova 0.9.1 → 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.
@@ -1,4 +1,5 @@
1
1
  import {remapReadError} from "./file-io.js";
2
+ import { retireProcessTree } from "./process-tree.js";
2
3
  import * as fs from "node:fs/promises";
3
4
  import * as path from "node:path";
4
5
  import { spawn } from "node:child_process";
@@ -45,7 +46,7 @@ async function realpathNearest(target) {
45
46
  try {
46
47
  return await fs.realpath(probe);
47
48
  } catch (err) {
48
- if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") remapReadError(err, target);
49
+ if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") await remapReadError(err, target);
49
50
  const parent = path.dirname(probe);
50
51
 
51
52
  if (parent === probe) throw err;
@@ -137,21 +138,22 @@ function commandTimeoutMs(options) {
137
138
  return Math.max(1, Math.min(2_147_483_647, Math.floor(requestedTimeout)));
138
139
  }
139
140
 
140
- function spawnCommand(argv, options, cwd) {
141
- return spawn(argv[0], argv.slice(1), {
142
- cwd, env: options.env ?? process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
143
- });
144
- }
141
+ export function commandSpawnError(error, command) {
142
+ if (error?.code === "EACCES" || error?.code === "EPERM") return new Error("cannot execute " + command + ": permission denied (is it executable?)");
145
143
 
146
- function signalProcessTree(child, signal) {
147
- if (!child.pid) return;
144
+ if (error?.code === "ENOENT") return new Error("command not found: " + command);
148
145
 
149
- if (process.platform === "win32") {
150
- const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
151
- killer.on("error", () => child.kill(signal));
152
- } else {
153
- try { process.kill(-child.pid, signal); } catch (err) { if (err.code !== "ESRCH") child.kill(signal); }
154
- }
146
+ if (error?.code === "ENOTDIR") return new Error("cannot run " + command + ": the working directory is not a directory");
147
+
148
+ return error;
149
+ }
150
+
151
+ export function spawnCommand(argv, options, cwd = options.cwd) {
152
+ try {
153
+ return spawn(argv[0], argv.slice(1), {
154
+ cwd, env: options.env ?? process.env, stdio: options.stdio ?? ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", windowsHide:true,
155
+ });
156
+ } catch (error) { throw commandSpawnError(error,argv[0]); }
155
157
  }
156
158
 
157
159
  function failCommand(state, error) {
@@ -171,11 +173,29 @@ function failCommand(state, error) {
171
173
  }
172
174
 
173
175
  function terminateCommand(state, error) {
174
- if (state.settled || state.terminationError) return;
175
- state.terminationError = error;
176
- signalProcessTree(state.child, "SIGTERM");
177
- // Keep ownership after the direct child exits: descendants may ignore SIGTERM.
178
- state.escalation = setTimeout(() => { signalProcessTree(state.child, "SIGKILL"); failCommand(state, error); }, 150);
176
+ if (state.settled) return;
177
+ state.terminationError ??= error;
178
+
179
+ if (state.stopping) return;
180
+ clearTimeout(state.timer);
181
+ state.stopping = retireProcessTree(state).then(()=>{
182
+ if (state.terminationError) {
183
+ failCommand(state,state.terminationError);
184
+
185
+ return;
186
+ }
187
+
188
+ state.settled = true;
189
+ state.cleanup();
190
+ state.resolve({ stdout:state.stdout, stderr:state.stderr,
191
+ exitCode:state.exitCode ?? (128 + (constants.signals[state.signal] ?? 1)),
192
+ signal:state.signal, outputTruncated:state.outputTruncated });
193
+ }).catch(cause=>{
194
+ state.child.stdout.destroy();
195
+ state.child.stderr.destroy();
196
+ const prefix = state.terminationError ? state.terminationError.message + "; " : "";
197
+ failCommand(state,new Error(prefix + "command cleanup failed: " + cause.message));
198
+ });
179
199
  }
180
200
 
181
201
  function appendCommandOutput(state, current, chunk) {
@@ -186,33 +206,14 @@ function appendCommandOutput(state, current, chunk) {
186
206
  return remaining ? current + chunk.slice(0, remaining) : current;
187
207
  }
188
208
 
189
- function onCommandClose(state, code, signal) {
190
- if (state.settled) return;
191
-
192
- if (state.terminationError) {
193
- // A closed pipe alone says nothing about descendants. Only ESRCH proves
194
- // the owned POSIX group is gone; otherwise retain the escalation timer.
195
- if (process.platform !== "win32" && state.child.pid) {
196
- try { process.kill(-state.child.pid, 0); }
197
- catch (error) { if (error.code === "ESRCH") failCommand(state, state.terminationError); }
198
- }
199
-
200
- return;
201
- }
202
-
203
- state.settled = true;
204
- state.cleanup();
205
- state.resolve({ stdout: state.stdout, stderr: state.stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated: state.outputTruncated });
206
- }
207
-
208
209
  function attachCommandIO(state, options, argv, timeoutMs) {
209
210
  const { child } = state;
210
211
  const onAbort = () => terminateCommand(state, new Error("aborted"));
211
212
  state.cleanup = () => {
212
213
  clearTimeout(state.timer);
213
- clearTimeout(state.escalation);
214
214
  options.signal?.removeEventListener("abort", onAbort);
215
215
  };
216
+
216
217
  state.timer = setTimeout(() => terminateCommand(state, new Error(
217
218
  "command timed out after " + timeoutMs + "ms: " + truncateChars(options.commandLabel ?? argv.join(" "), 240, "command").text
218
219
  + "\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."
@@ -221,13 +222,16 @@ function attachCommandIO(state, options, argv, timeoutMs) {
221
222
  child.stderr.setEncoding("utf8");
222
223
  child.stdout.on("data", chunk => { state.stdout = appendCommandOutput(state, state.stdout, chunk); });
223
224
  child.stderr.on("data", chunk => { state.stderr = appendCommandOutput(state, state.stderr, chunk); });
224
- child.on("error", error => {
225
- if (error?.code === "EACCES" || error?.code === "EPERM") failCommand(state, new Error("cannot execute " + argv[0] + ": permission denied (is it executable?)"));
226
- else if (error?.code === "ENOENT") failCommand(state, new Error("command not found: " + argv[0]));
227
- else if (error?.code === "ENOTDIR") failCommand(state, new Error("cannot run " + argv[0] + ": the working directory is not a directory"));
228
- else failCommand(state, error);
225
+ child.on("error", error => failCommand(state,commandSpawnError(error,argv[0])));
226
+ child.once("exit", (code, signal) => {
227
+ state.processExited = true;
228
+ state.exitCode = code;
229
+ state.signal = signal;
230
+ // An orphan can retain the pipes forever. Begin retirement at leader exit,
231
+ // then wait for close and group disappearance before reporting completion.
232
+ terminateCommand(state);
229
233
  });
230
- child.on("close", (code, signal) => onCommandClose(state, code, signal));
234
+ child.once("close", () => { state.processClosed = true; });
231
235
  options.signal?.addEventListener("abort", onAbort, { once: true });
232
236
 
233
237
  if (options.signal?.aborted) onAbort();
@@ -243,7 +247,8 @@ export async function runCommand(argv, options = {}) {
243
247
  const child = spawnCommand(argv, options, cwd);
244
248
  attachCommandIO({
245
249
  child, resolve, reject, stdout: "", stderr: "", settled: false,
246
- outputTruncated: false, terminationError: undefined, escalation: undefined,
250
+ outputTruncated: false, terminationError: undefined, stopping: undefined,
251
+ groups:new Set(child.pid ? [child.pid] : []), killedGroups:new Set(),
247
252
  maxOutputChars, timer: undefined, cleanup() {},
248
253
  }, options, argv, timeoutMs);
249
254
  });
@@ -167,6 +167,7 @@ function packageBatchItems(batch, details, maxChars) {
167
167
  const itemErrors = (details.itemErrors ?? []).map(error => boundItemError(error, maxChars, batch.length));
168
168
  let remaining = maxChars;
169
169
  let truncated = false;
170
+
170
171
  const items = batch.map((item, index) => {
171
172
  const share = details.independent === true ? maxChars : Math.floor(remaining / (batch.length - index));
172
173
  const bounded = boundBatchItem(item, share);
@@ -223,14 +224,26 @@ function attachTruncation(result, truncated, batch, text, config, maxChars, capp
223
224
 
224
225
  export function packageHostResult(raw, config) {
225
226
  const details = detailsOf(raw);
227
+
226
228
  if (raw && Object.hasOwn(raw, READ_VALUE)) {
227
229
  const batch = batchFromDetails(details);
228
230
  const result = {ok: !hostResultFailed(raw), value: raw[READ_VALUE], typed: true, cloneItems: details?.jsonMany === true, truncated: details?.outputTruncated === true};
229
231
  attachDetails(result, details, batch);
230
- if (batch) { result.items = batch; result.itemErrors = details.itemErrors ?? []; }
232
+
233
+ if (isString(details?.sourcePath)) result.sourcePath = details.sourcePath;
234
+
235
+ if (batch) {
236
+ result.items = batch;
237
+ result.itemErrors = details.itemErrors ?? [];
238
+
239
+ if (details.sourcePaths) result.sourcePaths = details.sourcePaths;
240
+ }
241
+
231
242
  if (details?.streamed) result.streamed = true;
243
+
232
244
  return result;
233
245
  }
246
+
234
247
  const maxChars = config.maxCallResultChars ?? 65536;
235
248
  const batch = batchFromDetails(details);
236
249
  const text = batch ? "" : extractRawString(raw);
@@ -15,16 +15,20 @@ function unwrapValue(res) {
15
15
  return res;
16
16
  }
17
17
 
18
- function unwrapRead(res, args) {
18
+ function unwrapRead(res, args, observe) {
19
19
  const value = unwrapValue(res);
20
20
 
21
21
  if ((args.complete === true || args.json !== undefined) && res?.truncated) throw new Error("incomplete read: complete:true or json refuses truncated host output");
22
22
 
23
- return decodeReadItem(args, value, res);
23
+ const decoded = decodeReadItem(args, value, res);
24
+ observe?.(args,res);
25
+
26
+ return decoded;
24
27
  }
25
28
 
26
29
  function decodeReadItem(args, value, res) {
27
30
  if (!res?.typed) return decodeReadValue(args, value);
31
+
28
32
  // JSON selectors used to cross RPC as separately encoded JSON values. Keep
29
33
  // their mutable results independent, but put the copies in the guest heap.
30
34
  return res.cloneItems ? value.map(item => structuredClone(item)) : value;
@@ -59,18 +63,22 @@ function missingResolvedIndex(values, paths) {
59
63
 
60
64
  function settleReadItem(wave, index, res) {
61
65
  const job = wave[index];
66
+
62
67
  if (!job) throw new Error("invalid streamed read index: " + index);
63
68
  wave[index] = null; // Release resolver closures and their potentially large values.
64
- try { job.resolve(unwrapRead(res,job.args)); }
69
+
70
+ try { job.resolve(unwrapRead(res,job.args,job.observe)); }
65
71
  catch (error) { job.reject(error); }
66
72
  }
67
73
 
68
74
  function settleInlineWave(wave,res,received) {
69
75
  unwrapValue(res);
76
+
70
77
  if (received || !Array.isArray(res.items) || res.items.length !== wave.length) throw new Error("invalid batch read response");
78
+
71
79
  for (let i=0;i<wave.length;i++) {
72
80
  const error = res.itemErrors?.[i];
73
- settleReadItem(wave,i,{...res,ok:!error,value:error ?? res.items[i],items:undefined});
81
+ settleReadItem(wave,i,{...res,ok:!error,value:error ?? res.items[i],sourcePath:res.sourcePaths?.[i],items:undefined});
74
82
  }
75
83
  }
76
84
 
@@ -78,24 +86,32 @@ async function rpcReadWave(rpc, wave) {
78
86
  if (wave.length === 1) {
79
87
  const res = leanEnvelope(await rpc("call",["read",wave[0].args]));
80
88
  settleReadItem(wave,0,res);
89
+
81
90
  return;
82
91
  }
92
+
83
93
  const args = {...wave[0].args,path:wave.map(job=>job.args.path),_independent:true};
84
94
  let received = 0, last;
95
+
85
96
  const onItem = (index,res) => {
86
97
  if (!Number.isInteger(index) || !wave[index] || last?.index === index) throw new Error("invalid streamed read response");
87
98
  received++;
99
+
88
100
  // Keep the final read pending until the host RPC/barrier itself has settled.
89
101
  // An awaited batch must never look complete while its host call is running.
90
102
  if (received === wave.length) last = {index,res};
91
103
  else settleReadItem(wave,index,res);
92
104
  };
105
+
93
106
  const res = leanEnvelope(await rpc("call",["read",args],onItem));
107
+
94
108
  if (res?.streamed) {
95
109
  if (received !== wave.length || !last) throw new Error("incomplete streamed read response");
96
110
  settleReadItem(wave,last.index,last.res);
111
+
97
112
  return;
98
113
  }
114
+
99
115
  settleInlineWave(wave,res,received);
100
116
  }
101
117
 
@@ -118,7 +134,7 @@ function enqueueCompatibleRead(readState, flushReads, args) {
118
134
  if (readState.queued.length && readState.queued[0].key !== key) flushReads();
119
135
 
120
136
  const promise = new Promise((resolve, reject) => {
121
- readState.queued.push({ args, key, resolve, reject });
137
+ readState.queued.push({ args, key, resolve, reject, observe:readState.observe });
122
138
 
123
139
  if (readState.queued.length === 1) queueMicrotask(flushReads);
124
140
  });
@@ -128,12 +144,16 @@ function enqueueCompatibleRead(readState, flushReads, args) {
128
144
 
129
145
  async function readManyPaths(readOne, args, paths) {
130
146
  assertReadPaths(paths);
147
+
131
148
  const values = await Promise.all(paths.map(async item => {
132
149
  try { return await readOne({...args,path:item}); }
133
150
  catch (error) { throwReadPathError(item,error.message); }
134
151
  }));
152
+
135
153
  const missing = args.resolve ? missingResolvedIndex(values,paths) : -1;
154
+
136
155
  if (missing >= 0) throwReadPathError(paths[missing],"not_found");
156
+
137
157
  return values;
138
158
  }
139
159
 
@@ -193,12 +213,13 @@ export function buildGuestApi(rpc, batchRead) {
193
213
  if (checkpoint) throw new Error("edit checkpoints cannot overlap or nest; await the current checkpoint");
194
214
  const token = {};
195
215
  checkpoint = token;
216
+
196
217
  try { return await runSpeculation(fn, token, checkpointScope, drainReads, rpc); }
197
218
  finally { checkpoint = null; }
198
219
  }
199
220
 
200
221
  // Coalesce already-started compatible reads without rewriting JS control flow.
201
- const readState = { queued: [], waves: new Set() };
222
+ const readState = { queued: [], waves: new Set(), observe:noteReadPath };
202
223
 
203
224
  function flushReads() {
204
225
  const pending = readState.queued;
@@ -227,26 +248,21 @@ export function buildGuestApi(rpc, batchRead) {
227
248
  const args = normalizeRead(gatherReadArgs(p, a, b));
228
249
  p = args.path;
229
250
 
230
- if (Array.isArray(p)) {
231
- const values = await readManyPaths(read, args, p);
232
- for (const item of p) if (isString(item)) noteReadPath({ path: item }, values);
233
-
234
- return values;
235
- }
251
+ if (Array.isArray(p)) return await readManyPaths(read, args, p);
236
252
 
237
- const readValue = !batchRead
238
- ? unwrapRead(await invoke("read", args), args)
253
+ return !batchRead
254
+ ? unwrapRead(await invoke("read", args), args, noteReadPath)
239
255
  : await enqueueCompatibleRead(readState, flushReads, args);
240
- noteReadPath(args, readValue);
241
-
242
- return readValue;
243
256
  };
244
257
 
245
258
  const readFiles = new Set();
246
259
 
247
- function noteReadPath(args, value) {
248
- if (isString(args.path) && looksLikePath(args.path)) readFiles.add(args.path);
249
- if (isObject(value) && isString(value.path) && looksLikePath(value.path)) readFiles.add(value.path);
260
+ function noteReadPath(args, envelope) {
261
+ if (isString(args.path) && args.resolve !== true && args.query === undefined && args.evidence !== true) readFiles.add(args.path);
262
+
263
+ // A document may itself contain path/status fields. Only adapter provenance,
264
+ // carried separately through scalar, inline and streamed replies, names a read.
265
+ if (isString(envelope.sourcePath)) readFiles.add(envelope.sourcePath);
250
266
  }
251
267
 
252
268
  const write = async (p, content) => {
@@ -1,16 +1,13 @@
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 scripts; data: literals (≤48000 JSON chars).
3
- read(path|paths,offset=1,limit?) → raw text/text[]; directories → entries; images: PNG/JPEG/GIF/WebP (≤16 images/20 MiB).
4
- Text ≤64 MiB internally; complete:true requires the whole file. Display alone is capped; return a summary or read(path,{offset:1,limit:80}). Larger files/JSONL: bounded bash parser.
5
- read({path,json:selector}) → parsed JSON: ".field", ".a[0:3]", ".a.length", quoted keys, true; input ≤16 MiB, selections ≤64 MiB storage, no jq. Values retain their types.
6
- read("symbol or question") = read({query,resolve:true}) → view; check status; view.text is a span, not the file.
7
- read(path,{about}) → windows; read({query,evidence:true}) → ranked evidence; read({path,outline:true}) → declarations.
8
- write(path,text) replaces unread workspace files; write({path,content,append:true}) appends without reading. After read: edit or replace:true.
9
- edit(path,oldText,newText) | edit({path,edits:[{oldText,newText}]}) unique exact read text; returns numbered windows/checks/references.
10
- edit(view,text) replaces span; edit(view,old,new) uniquely matches within it. edit(async()=>{...}) checkpoint: merge on success, rollback/rethrow on failure.
11
- bash(command,{cwd?,timeoutMs?}) | bash({command,args}) literal argv for scripts; bounded output, nonzero throws. Outer timeoutMs caps ALL waits/commands; bash inherits unless overridden.
12
- Edits commit on success/before bash. Promise.allSettled keeps partial results.
13
- programs:[{code?,file?,data?}] inherits source/data; entries override. mergeData:true shallow object merge (entry keys win).
14
- Promise.all: ≤8 reads or disjoint-file mutations; same-file serial; bash/checkpoint barriers.
15
- Fresh guests/separate commits. Sequential failure stops; prior commits stay. parallel:true for disjoint entries. ONE call for known work; split for new decisions.
2
+ export const REFERENCE = `JS body/async arrow; read/write/edit/bash, no fs/import/require. file: workspace JS; data: literals ≤48000 JSON chars.
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|selector}) → JSON, no jq (16MiB input/64MiB selection). Values retain their types.
6
+ 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.
16
13
  `;
@@ -1,12 +1,26 @@
1
+ import stringWidth from "string-width";
2
+
1
3
  /** Bounded source context for parse and syntax diagnostics. */
4
+ const JS_LINES = /\r\n|[\n\r\u2028\u2029]/;
5
+
6
+ const JSON_LINES = /\r\n|[\n\r]/;
7
+
8
+ // Source columns are UTF-16 offsets; expand controls identically before the
9
+ // source token and the caret. JSON strings can contain literal Unicode LS/PS.
10
+ const displaySource = text => text.replaceAll("\t"," ").replaceAll("\u2028","\\u2028").replaceAll("\u2029","\\u2029");
2
11
 
3
- export function sourceContext(source, line, column) {
12
+ export function sourceContext(source, line, column, lineBreaks = JS_LINES) {
4
13
  if (!Number.isInteger(line) || line < 1) return "";
5
- const text = String(source).split("\n")[line - 1];
14
+ const text = String(source).split(lineBreaks)[line - 1];
6
15
 
7
16
  if (text === undefined) return "";
8
- const shown = text.length > 160 ? text.slice(0, 160) : text;
9
- const caret = Number.isInteger(column) ? " ".repeat(Math.min(column, shown.length)) + "^" : "";
17
+ const located = Number.isInteger(column) && column >= 0;
18
+ const position = located ? Math.min(column, text.length) : 0;
19
+ const start = Math.max(0, Math.min(position - 80, text.length - 160));
20
+ const end = Math.min(text.length, start + 160);
21
+ const prefix = start > 0 ? "…" : "";
22
+ const shown = prefix + displaySource(text.slice(start, end)) + (end < text.length ? "…" : "");
23
+ const caret = located ? " ".repeat(stringWidth(prefix + displaySource(text.slice(start, position)))) + "^" : "";
10
24
 
11
25
  return "\n " + shown + (caret ? "\n " + caret : "");
12
26
  }
@@ -24,11 +38,12 @@ export function parsePosition(message, source) {
24
38
  const offsetMatch = /at position (\d+)/.exec(String(message));
25
39
 
26
40
  if (!offsetMatch) return null;
41
+
27
42
  return offsetPosition(source, Number(offsetMatch[1]));
28
43
  }
29
44
 
30
45
  function offsetPosition(source, offset) {
31
- const lines = String(source).slice(0, offset).split("\n");
46
+ const lines = String(source).slice(0, offset).split(JSON_LINES);
32
47
 
33
48
  return { line: lines.length, column: lines.at(-1).length };
34
49
  }
@@ -37,6 +52,7 @@ function offsetPosition(source, offset) {
37
52
  // offsets. JSON.parse remains the authority; cap extra work at 65,536 characters.
38
53
  // eslint-disable-next-line no-control-regex -- RFC 8259 excludes unescaped control characters from strings.
39
54
  const JSON_TOKEN = /("(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[\da-fA-F]{4}))*")|(-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|(true|false|null)|[{}[\]:,]/y;
55
+
40
56
  const JSON_NEXT = {
41
57
  end: { eof: [] },
42
58
  value: { string: [], number: [], literal: [], "{": ["object"], "[": ["array"] },
@@ -67,9 +83,12 @@ function invalidJsonOffset(source) {
67
83
  while (stack.length) {
68
84
  const token = jsonTokenAt(source, offset);
69
85
  let state = stack.pop();
86
+
70
87
  if (state === "array" && token.kind !== "]") { stack.push("arrayNext"); state = "value"; }
88
+
71
89
  if (state === "object" && token.kind !== "}") state = "key";
72
90
  const next = JSON_NEXT[state][token.kind];
91
+
73
92
  if (!next) return token.start;
74
93
  stack.push(...next);
75
94
  offset = token.end;
@@ -80,10 +99,12 @@ function invalidJsonOffset(source) {
80
99
 
81
100
  export function jsonErrorContext(message, source) {
82
101
  const native = parsePosition(message, source);
83
- if (native) return sourceContext(source, native.line, native.column);
102
+
103
+ if (native) return sourceContext(source, native.line, native.column, JSON_LINES);
84
104
  const offset = invalidJsonOffset(source);
105
+
85
106
  if (offset === null) return "";
86
107
  const { line, column } = offsetPosition(source, offset);
87
108
 
88
- return " (near line " + line + " column " + (column + 1) + ")" + sourceContext(source, line, column);
109
+ return " (near line " + line + " column " + (column + 1) + ")" + sourceContext(source, line, column, JSON_LINES);
89
110
  }