pi-supernova 0.9.1 → 0.10.1

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,3 +1,4 @@
1
+ import { createBackgroundTerminals } from "../fs/background.js";
1
2
  import {createToolRegistry} from './tool-registry.js';
2
3
  import {traceArgs,finishRecord} from './trace.js';
3
4
  import * as fs from "node:fs/promises";
@@ -16,20 +17,23 @@ import { resolveWorkspacePath, runCommand, clearPathCache, relativeSlash } from
16
17
  import { createNativeAdapters } from "../adapters/index.js";
17
18
  import { resultDiff, boundedWriteDiff, writeSnapshot } from "../fs/text-ops.js";
18
19
 
19
- export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget }) {
20
+ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedger, budget, terminalIdentity }) {
20
21
  const index = registry?.index ?? new WorkspaceIndex((argv, opts) => runCommand(argv, opts));
21
22
  const ledger = runLedger ?? new SeenLedger({ window: config.seenWindow ?? 0 });
22
23
 
23
24
  const vfs = new CausalVfs(paths => {
24
25
  index.invalidate();
25
26
  notifyWorkspaceChanged(paths);
26
- }, target => resolveWorkspacePath(getCwd(), target, "commit", false, true));
27
+ }, target => resolveWorkspacePath(getCwd(), target, "commit", false, true), assertSession);
27
28
 
28
29
  const executors = registry?.executors ?? new Map();
29
30
  const definitions = registry?.definitions ?? new Map();
30
31
  const sharedRegistry = registry ?? { executors, definitions, index, callSeq: 0 };
32
+ sharedRegistry.terminals ??= createBackgroundTerminals();
31
33
  let closed = false;
32
- const hooks = {};
34
+ const hooks = { terminals: sharedRegistry.terminals, terminalGeneration: terminalIdentity?.generation ?? sharedRegistry.terminals.getGeneration() };
35
+ const ownerOf = ctx => JSON.stringify([ctx?.sessionManager?.getSessionId?.() ?? null, path.resolve(getCwd())]);
36
+ let terminalOwner = terminalIdentity?.owner ?? ownerOf(null);
33
37
  const natives = createNativeAdapters(getCwd, vfs, config, index, ledger, hooks);
34
38
  let callCount = 0;
35
39
  let activeCtx = null;
@@ -51,9 +55,16 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
51
55
  try { pi.events.emit("workspace:changed", event)?.catch?.(() => {}); } catch {}
52
56
  }
53
57
 
58
+ hooks.terminalOwner = () => terminalOwner;
54
59
  hooks.workspaceChanged = notifyWorkspaceChanged;
55
- hooks.artifactsDir = () => activeCtx?.sessionManager?.getArtifactsDir?.();
60
+ hooks.artifactsDir = () => {
61
+ assertSession();
62
+
63
+ return activeCtx?.sessionManager?.getArtifactsDir?.();
64
+ };
65
+
56
66
  hooks.commandEnv = () => {
67
+ assertSession();
57
68
  const env = { ...process.env };
58
69
 
59
70
  const current = {
@@ -77,6 +88,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
77
88
 
78
89
  function bindCallContext(ctx, signal) {
79
90
  activeCtx = ctx || null;
91
+ terminalOwner = terminalIdentity?.owner ?? ownerOf(ctx);
80
92
  tools.bindSession(ctx);
81
93
  activeSignal = signal;
82
94
  vfs.signal = signal;
@@ -100,8 +112,15 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
100
112
  } catch {}
101
113
  }
102
114
 
115
+ function assertSession() {
116
+ if (hooks.terminalGeneration !== sharedRegistry.terminals.getGeneration() || terminalOwner !== ownerOf(activeCtx)) {
117
+ throw new Error("host session changed; start a new call");
118
+ }
119
+ }
120
+
103
121
  function assertRunOpen(name) {
104
122
  if (closed) throw new Error("program is already complete");
123
+ assertSession();
105
124
 
106
125
  if (activeSignal?.aborted) throw new Error("aborted");
107
126
 
@@ -149,7 +168,10 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
149
168
  }
150
169
 
151
170
  function assertOwnedOverride(name, args) {
171
+ if (name === "bash" && (args?.background === true || args?.action !== undefined)) throw new Error("background terminals require the Supernova-owned bash adapter, not an external override");
172
+
152
173
  if (name === "read" && (args?.json !== undefined || /^(agent|artifact):\/\/.*\?/i.test(String(args?.path)))) throw new Error("JSON projection requires the Supernova-owned read adapter, not an external override");
174
+
153
175
  if (name === "write" && args?.append === true) throw new Error("append requires the Supernova-owned write adapter, not an external override");
154
176
  }
155
177
 
@@ -159,8 +181,11 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
159
181
  const mutating = isMutatingTool(name, config, args, definitions.get(name));
160
182
 
161
183
  if (mutating) await vfs.prepareExternalMutation(name);
184
+
162
185
  if (activeSignal?.aborted || closed) throw new Error("aborted");
186
+
163
187
  if (!isCallable(name)) throw new Error("tool is no longer enabled in this session: " + name);
188
+ assertSession();
164
189
 
165
190
  try {
166
191
  const res = await target.exec(`supernova:${name}:${callId}`, args || {}, activeSignal, undefined, target.delegated
@@ -176,6 +201,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
176
201
  }
177
202
 
178
203
  async function invokeNative(target, args, record, onItem) {
204
+ assertSession();
179
205
  const res = await target.native(target.argvOwned ? { ...args, args: args.args.map(String) } : args || {}, activeSignal, onItem);
180
206
  completeRecord(record, res);
181
207
 
@@ -207,6 +233,7 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
207
233
  const target = resolveInvokeTarget(name, args, { hostTool, hostSession: tools.session, executors, natives });
208
234
 
209
235
  if (target.kind === "override") return await invokeOverride(target, name, args, record, callId);
236
+
210
237
  if (target.kind === "native") return await invokeNative(target, args, record, onItem);
211
238
 
212
239
  throw new Error(unknownToolMessage(name, [...executors.keys(), ...Object.keys(natives)]));
@@ -218,11 +245,13 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
218
245
 
219
246
  function fileMutationKey(name, args) {
220
247
  if (!["edit", "write", "apply_patch"].includes(name) || !isString(args?.path)) return;
248
+
221
249
  // Overrides can mutate more than their declared path: keep them global.
222
250
  if (hostTool(name) || executors.has(name)) return;
223
251
 
224
252
  return async () => {
225
253
  const target = await resolveWorkspacePath(getCwd(), args.path, name, false, true);
254
+
226
255
  // Share the commit identity, including symlinks and not-yet-created files.
227
256
  return (await resolveCommitTarget(target)).target;
228
257
  };
@@ -296,8 +325,14 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
296
325
  fork(options) {
297
326
  const runConfig = options.timeoutMs === undefined ? config : { ...config, timeoutMs: Number(options.timeoutMs) };
298
327
 
299
- return createHostBridge({ pi, config: runConfig, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork(), budget: options.budget });
328
+ return createHostBridge({ pi, config: runConfig, getCwd: options.getCwd, registry: sharedRegistry, ledger: ledger.fork(), budget: options.budget, terminalIdentity:options.terminalIdentity });
300
329
  },
330
+ captureTerminalIdentity: (ctx, runCwd) => ({
331
+ generation:sharedRegistry.terminals.getGeneration(),
332
+ owner:JSON.stringify([ctx?.sessionManager?.getSessionId?.() ?? null, path.resolve(runCwd)]),
333
+ }),
334
+ shutdownTerminals: () => sharedRegistry.terminals.shutdown(),
335
+ reopenTerminals: () => sharedRegistry.terminals.reopen(),
301
336
  close() { closed = true; vfs.closed = true; },
302
337
  bindCallContext,
303
338
  resetCallBudget,
@@ -305,8 +340,16 @@ export function createHostBridge({ pi, config, getCwd, registry, ledger: runLedg
305
340
  getMutations: () => ({ ...vfs.mutations }),
306
341
  setCallListener: fn => { callListener = isFunction(fn) ? fn : null; },
307
342
  barrier: run => scheduler.schedule("write", run, activeSignal),
308
- beginSpeculation: () => vfs.begin(),
309
- commitSpeculation: async () => await vfs.commit(),
343
+ beginSpeculation() {
344
+ assertSession();
345
+
346
+ return vfs.begin();
347
+ },
348
+ async commitSpeculation() {
349
+ assertSession();
350
+
351
+ return await vfs.commit();
352
+ },
310
353
  rollbackSpeculation: () => vfs.rollback(),
311
354
  getOverlayDepth: () => vfs.getOverlayDepth(),
312
355
  call,
@@ -3,15 +3,18 @@ import { isString } from "../shared/decode.js";
3
3
  /** Ordered permission rules. First decisive answer wins. */
4
4
  export function toolIsCallable(name, env) {
5
5
  if (name === "supernova" || env.excluded.has(name)) return false;
6
+
6
7
  if (env.sessionInvalid()) return false;
8
+
7
9
  if (env.nativeOwned(name)) return true;
10
+
8
11
  if (env.hostSession) return env.evalAllows(name);
9
12
 
10
13
  return env.listed(name);
11
14
  }
12
15
 
13
16
  function isArgvOwned(name, args) {
14
- return name === "bash" && Array.isArray(args?.args) && process.platform !== "win32"
17
+ return name === "bash" && args?.background !== true && args?.action === undefined && Array.isArray(args?.args)
15
18
  && args.args.length === Object.keys(args.args).length && args.args.every(isString);
16
19
  }
17
20
 
@@ -19,6 +22,7 @@ function hostExecutor(name, env) {
19
22
  const delegated = env.hostTool(name);
20
23
 
21
24
  if (delegated) return { exec: delegated.execute.bind(delegated), delegated };
25
+
22
26
  if (env.hostSession) return { exec: undefined, delegated };
23
27
 
24
28
  return { exec: env.executors.get(name), delegated };
@@ -29,6 +33,7 @@ export function resolveInvokeTarget(name, args, env) {
29
33
  const { exec, delegated } = hostExecutor(name, env);
30
34
 
31
35
  if (exec && !argvOwned) return { kind: "override", exec, delegated, argvOwned: false };
36
+
32
37
  if (env.natives[name]) return { kind: "native", native: env.natives[name], argvOwned };
33
38
 
34
39
  return { kind: "unknown" };
@@ -7,7 +7,7 @@ function traceArgs(args) {
7
7
  if (!isObject(args)) return {};
8
8
  const out = {};
9
9
 
10
- for (const key of ["path", "target", "query", "pattern", "command", "cwd", "glob", "action", "op"]) {
10
+ for (const key of ["path", "target", "query", "pattern", "command", "cwd", "glob", "action", "sessionId", "op"]) {
11
11
  const value = args[key];
12
12
 
13
13
  if (isString(value)) out[key] = truncateChars(value, 240, "trace").text;
@@ -16,7 +16,9 @@ function traceArgs(args) {
16
16
  }
17
17
 
18
18
  if (isString(args.content)) out.content = args.content.length + " chars";
19
+
19
20
  if (Array.isArray(args.edits)) out.edits = args.edits.length + " edits";
21
+
20
22
  if (Array.isArray(args.args)) out.args = args.args.length + " argv";
21
23
 
22
24
  return out;
@@ -38,4 +40,5 @@ function finishRecord(record, res) {
38
40
 
39
41
  if (text) record.resultText = truncateChars(text, 4096, "trace").text;
40
42
  }
43
+
41
44
  export { traceArgs, finishRecord };
@@ -3,6 +3,7 @@ import {pendingInScope,overlaySearchEntry} from './search-files.js';
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { WorkspaceIndex } from "./repo-index.js";
6
+ import { relativeSlash } from "../fs/workspace.js";
6
7
  import { tokenizeQuery, scorePathTopology, stem } from "./query.js";
7
8
 
8
9
  // Zero-token evidence selection over source code, after Zero-Mem (arXiv:2607.29377).
@@ -148,15 +149,18 @@ function render(spans, picks, fused, opts, root) {
148
149
  const lastLine = span.start + text.split("\n").length - 1;
149
150
  const truncated = lastLine < span.sourceEnd;
150
151
  budget -= text.length;
152
+
151
153
  const rendered = {
152
- path: path.relative(root, span.path) || span.path,
154
+ path: relativeSlash(root, span.path),
153
155
  lines: [span.start, lastLine],
154
156
  name: span.name,
155
157
  kind: span.kind,
156
158
  why,
157
159
  text,
158
160
  };
161
+
159
162
  if (truncated) { rendered.truncated = true; rendered.nextOffset = lastLine+1; }
163
+
160
164
  out.push(rendered);
161
165
 
162
166
  if (budget <= 0) break;
@@ -206,15 +210,20 @@ function collectSpans(chosenFiles, overlayText, index, maxSpanLines) {
206
210
  // its declaration. Keep the matching line in the bounded window, even in long bodies.
207
211
  function usageSpans(spans, profile, maxSpanLines) {
208
212
  if (profile.answerType !== "usage" || !profile.subjects.length) return spans;
213
+
209
214
  return spans.flatMap(span => {
210
215
  for (let i = span.start - 1; i < span.sourceEnd; i++) {
211
216
  const words = span.lines.idents[i];
217
+
212
218
  const matched = profile.subjects.some(subject =>
213
219
  words.filter(word => word === subject).length > Number(span.lines.defNames[i] === subject.toLowerCase()));
220
+
214
221
  if (!matched) continue;
215
222
  const start = Math.max(span.start, i - 1);
223
+
216
224
  return [{ ...span, start, end: Math.min(span.sourceEnd, start + maxSpanLines - 1) }];
217
225
  }
226
+
218
227
  return [];
219
228
  });
220
229
  }
@@ -1,6 +1,8 @@
1
1
  import {inScope,makeCandidate,contentCandidates,MAX_SEARCH_CHARS} from './snap-search.js';
2
2
  import { tokenizeQuery, stem } from "./query.js";
3
+
3
4
  export {tokenizeQuery,scorePathTopology,stem} from './query.js';
5
+
4
6
  import * as path from "node:path";
5
7
 
6
8
  import * as fs from "node:fs/promises";
@@ -22,13 +24,13 @@ function rankScore(candidate, tokenCount) {
22
24
  function location(candidate, root) {
23
25
  const context = candidate.context;
24
26
 
25
- return { path: path.relative(root, candidate.path), line: candidate.line, signature: candidate.signature,
27
+ return { path: relativeSlash(root, candidate.path), line: candidate.line, signature: candidate.signature,
26
28
  context: [...context].sort((a, b) => a[0] - b[0]).map(([line, text]) => (line === candidate.line ? "►" : " ") + line + " " + text) };
27
29
  }
28
30
 
29
31
  async function spanCandidates(filePath, lines, root, overlayText, signal) {
30
32
  const staged = overlayText(filePath);
31
- const rel = path.relative(root, filePath);
33
+ const rel = relativeSlash(root, filePath);
32
34
  let text = staged;
33
35
 
34
36
  if (text === undefined) {
@@ -38,10 +40,12 @@ async function spanCandidates(filePath, lines, root, overlayText, signal) {
38
40
  const stat = await file.stat();
39
41
 
40
42
  if (!stat.isFile()) throw new Error("source candidate is not a regular file: " + filePath);
43
+
41
44
  if (stat.size > 512 * 1024) return lines.map(line => ({ path: rel, line, signature: "", context: [] }));
42
45
  text = await file.readFile({ encoding: "utf8", signal });
43
46
  } finally { await file.close(); }
44
47
  }
48
+
45
49
  const spans = WorkspaceIndex.spansOf(WorkspaceIndex.fromText(filePath, text));
46
50
 
47
51
  return lines.map(line => {
@@ -68,6 +72,7 @@ async function rankedSpanCandidates(ranked, root, overlayText, signal) {
68
72
  try { out.push(...await spanCandidates(candidate.path, lines, root, overlayText, signal)); }
69
73
  catch { signal?.throwIfAborted(); out.push(location(candidate, root)); }
70
74
  }
75
+
71
76
  if (out.length >= MAX_ALTERNATIVES) break;
72
77
  }
73
78
 
@@ -104,7 +109,9 @@ function listedSnapPaths(listing, dir, includeHidden, focusFile, pendingPaths) {
104
109
 
105
110
  function filenameEligible(search, filePath, relative, tokens, exact, queryLower) {
106
111
  if (search.candidates.has(filePath)) return false;
112
+
107
113
  if (!tokens.some(token => relative.includes(token))) return false;
114
+
108
115
  if (exact && tokens.length > 1 && !relative.includes(queryLower)) return false;
109
116
 
110
117
  return true;
@@ -174,6 +181,7 @@ async function decideSnapResult(ranked, tokens, empty, candidates, relativeRoot,
174
181
  function fuzzySnapMiss(exact, query, paths, pathContext, relativeRoot, empty) {
175
182
  const eligible = exact && query.length >= 4 && query.length <= 64;
176
183
  const limited = eligible && paths.length > 1024;
184
+
177
185
  const fuzzy = eligible ? rankPaths(query, paths.slice(0, 1024).map(file => relativeSlash(relativeRoot, file)),
178
186
  { ...pathContext, maxTypos: 1 }).filter(hit => hit.score > 0).slice(0, MAX_ALTERNATIVES) : [];
179
187
 
@@ -209,6 +217,7 @@ export async function executeSnap({ query, searchDir, root, includeHidden = fals
209
217
  signal?.throwIfAborted();
210
218
 
211
219
  const empty = emptySnap();
220
+
212
221
  const dirStat = await fs.stat(dir).catch(error => {
213
222
  if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
214
223
 
@@ -2,13 +2,10 @@ import { isString, isObject } from "../shared/decode.js";
2
2
 
3
3
  const ARGV_ERROR = "bash argv requires a command string and an array of string args";
4
4
 
5
- function quoteShellArg(value) {
6
- return "'" + String(value).replaceAll("'", "'\\''") + "'";
7
- }
8
-
9
5
  /** Normalize guest bash(command, opts) / bash({command, args}) into host args. */
10
6
  function normalizeArgv(args) {
11
7
  if (args.args === undefined) return;
8
+
12
9
  if (!isString(args.command) || !Array.isArray(args.args)) throw new Error(ARGV_ERROR);
13
10
 
14
11
  for (let i = 0; i < args.args.length; i++) {
@@ -17,30 +14,39 @@ function normalizeArgv(args) {
17
14
  throw new Error(`${ARGV_ERROR}; args[${i}] is ${type}; check the supplied data fields and pass each argument as a string`);
18
15
  }
19
16
  }
17
+
20
18
  args.args = args.args.map((arg, i) => {
21
19
  if (arg.includes("\0")) throw new Error(`bash args[${i}] must not contain null bytes`);
20
+
22
21
  return String(arg);
23
22
  });
24
23
 
25
- if (process.platform === "win32") {
26
- delete args._directArgv;
27
- args.command = [args.command, ...args.args].map(quoteShellArg).join(" ");
28
- delete args.args;
29
- } else args._directArgv = true;
24
+ args._directArgv = true;
30
25
  }
31
26
 
32
- const BASH_OPTION_KEYS = ["command", "args", "cwd", "timeout", "timeoutMs", "_directArgv"];
27
+ const BASH_OPTION_KEYS = ["command", "args", "cwd", "timeout", "timeoutMs", "_directArgv", "background", "pty"];
33
28
 
34
29
  /** Unknown options used to be dropped silently: env/maxOutputChars never applied. */
35
30
  function assertBashOptions(args) {
36
31
  const unknown = Object.keys(args).filter(key => !BASH_OPTION_KEYS.includes(key));
37
32
 
38
- if (unknown.length) throw new Error("bash does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are command, args, cwd, timeout, timeoutMs");
33
+ if (unknown.length) throw new Error("bash does not accept option " + unknown.map(key => JSON.stringify(key)).join(", ") + "; supported options are command, args, cwd, timeout, timeoutMs, background, pty");
39
34
  }
35
+
40
36
  export function normalizeBash(command, opts) {
41
37
  const args = isObject(command) ? { ...opts, ...command } : { command, ...opts };
38
+
39
+ if (args.action !== undefined || args.sessionId !== undefined) return normalizeTerminalControl(args);
42
40
  assertBashOptions(args);
41
+
42
+ for (const key of ["background", "pty"]) {
43
+ if (args[key] !== undefined && args[key] !== true && args[key] !== false) throw new Error(`bash ${key} must be boolean`);
44
+ }
45
+
46
+ if (args.pty !== undefined && args.background !== true) throw new Error("bash pty requires background:true");
47
+
43
48
  if (!isString(args.command) || !args.command.trim()) throw new Error("bash requires a non-empty command string");
49
+
44
50
  if (args.command.includes("\0")) throw new Error("bash command must not contain null bytes");
45
51
  normalizeArgv(args);
46
52
 
@@ -49,11 +55,37 @@ export function normalizeBash(command, opts) {
49
55
  return args;
50
56
  }
51
57
 
58
+ function normalizeTerminalControl(args) {
59
+ const fields = {
60
+ list: ["action"],
61
+ poll: ["action", "sessionId", "cursor", "waitMs"],
62
+ write: ["action", "sessionId", "input"],
63
+ stop: ["action", "sessionId"],
64
+ };
65
+
66
+ if (!Object.hasOwn(fields, args.action)) throw new Error("bash terminal action must be list, poll, write or stop");
67
+ const unknown = Object.keys(args).filter(key => !fields[args.action].includes(key));
68
+
69
+ if (unknown.length) throw new Error(`bash ${args.action} does not accept option ${unknown.join(", ")}`);
70
+
71
+ if (args.action !== "list" && (!isString(args.sessionId) || !args.sessionId.trim())) throw new Error("bash terminal action requires sessionId");
72
+
73
+ if (args.action === "write" && (!isString(args.input) || args.input.length > 16384)) throw new Error("bash terminal input must be a string of at most 16384 characters");
74
+
75
+ if (args.cursor !== undefined && (!Number.isSafeInteger(args.cursor) || args.cursor < 0)) throw new Error("bash terminal cursor must be a non-negative safe integer");
76
+
77
+ if (args.waitMs !== undefined && (!Number.isInteger(args.waitMs) || args.waitMs < 0 || args.waitMs > 30000)) throw new Error("bash terminal waitMs must be an integer from 0 to 30000");
78
+
79
+ return args;
80
+ }
81
+
52
82
  function normalizeTimeout(args) {
53
83
  if (args.timeout !== undefined && args.timeoutMs === undefined) args.timeoutMs = args.timeout * 1000;
84
+
54
85
  // Reject before the host's external-mutation barrier can flush staged files.
55
86
  if (args.timeoutMs !== undefined) {
56
87
  const timeout = Number(args.timeoutMs);
88
+
57
89
  if (!Number.isFinite(timeout) || timeout <= 0) throw new Error("command timeoutMs must be a positive finite number");
58
90
  args.timeoutMs = Math.max(1, Math.min(2_147_483_647, Math.floor(timeout)));
59
91
  }
@@ -1,5 +1,5 @@
1
1
  import { errorMessage, isString, isObject, isNumber, looksLikePath } from "../shared/decode.js";
2
- import { sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
2
+ import { foldJsonSelectorAlias, sessionJsonArgs, validateJsonRead } from "../fs/json-read.js";
3
3
 
4
4
  export const SESSION_URI = /^(?:agent|artifact):\/\//i;
5
5
 
@@ -12,6 +12,7 @@ function assertReadOptions(args) {
12
12
  const unknown = Object.keys(args).filter(key => !READ_OPTION_KEYS.includes(key));
13
13
 
14
14
  if (unknown.length === 0) return;
15
+
15
16
  const windowHint = unknown.some(key => key === "start" || key === "end")
16
17
  ? " For a line window use read(path, {offset:1, limit:80}): offset is the first line and limit is the line count."
17
18
  : "";
@@ -33,11 +34,14 @@ export function gatherReadArgs(p, a, b) {
33
34
  return args;
34
35
  }
35
36
 
36
- return isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b };
37
+ // Only the string shorthand guesses between a path and a symbol. Explicit
38
+ // path/target objects and path arrays must not turn missing files into search.
39
+ return autoResolve(isObject(a) && !Array.isArray(a) ? { path: p, ...a } : { path: p, offset: a, limit: b });
37
40
  }
38
41
 
39
42
  export function assertReadPaths(targetParam) {
40
43
  if (!Array.isArray(targetParam)) return;
44
+
41
45
  if (targetParam.length > 64) throw new Error("read accepts at most 64 paths per batch");
42
46
 
43
47
  for (const item of targetParam) if (!isString(item) || !item.trim()) throw new Error("read paths must be non-empty strings");
@@ -49,6 +53,7 @@ function assertReadFlags(args) {
49
53
  }
50
54
 
51
55
  if (args.about !== undefined && !isString(args.about)) throw new Error("read about must be a string");
56
+
52
57
  if (args.query !== undefined && !isString(args.query)) throw new Error("read query must be a string");
53
58
  }
54
59
 
@@ -56,32 +61,38 @@ function assertExclusiveRead(args) {
56
61
  const focusModes = [args.about !== undefined, args.query !== undefined, args.outline === true].filter(Boolean).length;
57
62
 
58
63
  if (focusModes > 1 || (args.outline === true && args.evidence === true)) throw new Error("read accepts only one of about, query, outline, or evidence");
64
+
59
65
  if (args.resolve === true && args.complete === true) throw new Error("read accepts either resolve or complete, not both");
66
+
60
67
  if ((focusModes === 1 || args.evidence === true) && args.complete === true) throw new Error("complete:true requires a raw file read, not a source view");
61
68
  }
62
69
 
63
70
  function autoResolve(args) {
64
71
  if (!isString(args.path) || args.resolve !== undefined || args.complete === true || args.json !== undefined) return args;
65
- if (args.about !== undefined || args.query !== undefined || looksLikePath(args.path) || isSessionUri(args.path)) return args;
72
+
73
+ if (args.about !== undefined || args.query !== undefined || args.offset !== undefined || args.limit !== undefined || looksLikePath(args.path) || isSessionUri(args.path)) return args;
66
74
 
67
75
  return { ...args, resolve: true };
68
76
  }
69
77
 
70
- /** Exclusive-mode + JSON + auto-resolve. Same rules for guest and host. */
78
+ /** Preserve explicit intent across guest, host, and coalesced batch normalization. */
71
79
  export function normalizeRead(params) {
72
80
  if (!isObject(params)) throw new Error("read requires an options object");
73
- if (params.path !== undefined && params.target !== undefined && params.path !== params.target) {
81
+
82
+ const folded = foldJsonSelectorAlias(params);
83
+
84
+ if (folded.path !== undefined && folded.target !== undefined && folded.path !== folded.target) {
74
85
  throw new Error("read accepts either path or target, not both");
75
86
  }
76
87
 
77
- const args = sessionJsonArgs({ ...params, path: params.path ?? params.target });
88
+ const args = sessionJsonArgs({ ...folded, path: folded.path ?? folded.target });
78
89
  validateJsonRead(args);
79
90
  assertReadOptions(args);
80
91
  assertReadFlags(args);
81
92
  assertExclusiveRead(args);
82
93
  assertReadPaths(args.path);
83
94
 
84
- return autoResolve(args);
95
+ return args;
85
96
  }
86
97
 
87
98
  export function needsProbe(params) {
@@ -126,7 +137,7 @@ function classifyEvidence(params, target) {
126
137
  }
127
138
 
128
139
  function classifyBarePath(params, target) {
129
- if (params.json === undefined && !looksLikePath(target)) {
140
+ if (params.resolve === true && params.json === undefined && !looksLikePath(target)) {
130
141
  return { kind: "snap", query: isString(params.about) ? params.about : target, scoped: isString(params.about) };
131
142
  }
132
143
 
@@ -139,9 +150,13 @@ export function classifyRead(params, existing) {
139
150
  const target = params.path;
140
151
 
141
152
  if (isSessionUri(target)) return classifySession(params);
153
+
142
154
  if (params.evidence === true) return classifyEvidence(params, target);
155
+
143
156
  if (isString(params.query)) return { kind: "snap", query: params.query, scoped: Boolean(target && target !== params.query) };
157
+
144
158
  if (params.outline === true) return { kind: "outline" };
159
+
145
160
  if (existing) return classifyExisting(params, existing);
146
161
 
147
162
  return classifyBarePath(params, target);
@@ -186,5 +201,6 @@ export function decodeReadValue(args, value) {
186
201
 
187
202
  function jsonReadError(args, error) {
188
203
  const target = String(args.path ?? args.target ?? "resource");
204
+
189
205
  return new Error("JSON read failed for " + target + jsonSelectorNote(args) + ": " + errorMessage(error));
190
206
  }