opencode-reviewer 0.4.1 → 0.4.2

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 (3) hide show
  1. package/README.md +74 -185
  2. package/dist/tui.js +876 -436
  3. package/package.json +10 -2
package/dist/tui.js CHANGED
@@ -768,10 +768,10 @@ var FileScope = class {
768
768
  var fileAccess = new FileAccess();
769
769
 
770
770
  // src/context.ts
771
- async function loadInvocation(request, reader, signal) {
771
+ async function loadInvocation(request, reader2, signal) {
772
772
  signal.throwIfAborted();
773
773
  if (!request.tool) throw new Error("Pending tool linkage unavailable");
774
- const message = await reader.message(request.sessionID, request.tool.messageID, signal);
774
+ const message = await reader2.message(request.sessionID, request.tool.messageID, signal);
775
775
  signal.throwIfAborted();
776
776
  if (!message || message.info.role !== "assistant" || message.info.sessionID !== request.sessionID || message.info.id !== request.tool.messageID) {
777
777
  throw new Error("Pending tool message unavailable or mismatched");
@@ -787,6 +787,7 @@ async function loadInvocation(request, reader, signal) {
787
787
  info: message.info,
788
788
  tool: part.tool,
789
789
  input: part.state.input,
790
+ linkage: { sessionID: request.sessionID, messageID: request.tool.messageID, callID: request.tool.callID },
790
791
  location: {
791
792
  instanceDirectory: typeof invocation?.cwd === "string" && path2.isAbsolute(invocation.cwd) ? invocation.cwd : null,
792
793
  instanceWorktree: typeof invocation?.root === "string" && path2.isAbsolute(invocation.root) ? invocation.root : null
@@ -824,27 +825,33 @@ function findUserPrompt(messages, sessionID) {
824
825
  function latestUserPrompt(messages, sessionID) {
825
826
  return findUserPrompt(messages, sessionID)?.text ?? null;
826
827
  }
827
- async function loadContext(request, reader, signal, scope = fileAccess.scope(signal)) {
828
+ async function nativeInvocation(request, reader2, signal, kind) {
829
+ try {
830
+ return await loadInvocation(request, reader2, signal);
831
+ } catch (error) {
832
+ signal.throwIfAborted();
833
+ throw new Error(`Pending native ${kind} message unavailable or arguments mismatched`, { cause: error });
834
+ }
835
+ }
836
+ function invocationFor(request, verified) {
837
+ if (verified.linkage.sessionID !== request.sessionID || verified.linkage.messageID !== request.tool?.messageID || verified.linkage.callID !== request.tool?.callID) throw new Error("Verified invocation linkage mismatched");
838
+ return verified;
839
+ }
840
+ async function loadContext(request, reader2, signal, scope = fileAccess.scope(signal), verified) {
841
+ signal.throwIfAborted();
828
842
  if (request.permission !== "bash" && request.permission !== "external_directory") return null;
829
843
  if (!request.tool) {
830
844
  if (request.permission === "external_directory") return null;
831
845
  throw new Error("Native shell tool context unavailable");
832
846
  }
833
- const message = await reader.message(request.sessionID, request.tool.messageID, signal);
834
- if (!message || message.info.sessionID !== request.sessionID || message.info.id !== request.tool.messageID) {
835
- throw new Error("Pending native shell message unavailable");
836
- }
837
- const part = message?.parts.find((p) => p.type === "tool" && p.callID === request.tool.callID);
838
- if (part?.type === "tool" && part.tool !== "bash" && request.permission === "external_directory") return null;
839
- if (!part || part.type !== "tool" || part.sessionID !== request.sessionID || part.messageID !== request.tool.messageID || part.tool !== "bash" || part.state.status !== "running") {
840
- throw new Error("Pending native shell arguments unavailable");
841
- }
842
- const { command, workdir } = part.state.input;
847
+ const invocation = verified ? invocationFor(request, verified) : await nativeInvocation(request, reader2, signal, "shell");
848
+ if (invocation.tool !== "bash" && request.permission === "external_directory") return null;
849
+ if (invocation.tool !== "bash") throw new Error("Pending native shell arguments unavailable");
850
+ const { command, workdir } = invocation.input;
851
+ if (typeof command === "string" && command.length > 16 * 1024 * 1024) throw new Error("Shell command exceeds the 16 MiB preprocessing limit");
843
852
  if (typeof command !== "string" || !command.trim()) throw new Error("Shell command unavailable");
844
853
  const limitations = [];
845
- const invocation = message.info.role === "assistant" ? message.info.path : void 0;
846
- const instanceDirectory = typeof invocation?.cwd === "string" && path2.isAbsolute(invocation.cwd) ? invocation.cwd : null;
847
- const instanceWorktree = typeof invocation?.root === "string" && path2.isAbsolute(invocation.root) ? invocation.root : null;
854
+ const { instanceDirectory, instanceWorktree } = invocation.location;
848
855
  const invalidWorkdir = workdir !== void 0 && typeof workdir !== "string";
849
856
  const requestedWorkdir = typeof workdir === "string" && workdir ? workdir : null;
850
857
  const cwd = invalidWorkdir ? null : requestedWorkdir && path2.isAbsolute(requestedWorkdir) ? requestedWorkdir : instanceDirectory ? path2.resolve(instanceDirectory, requestedWorkdir ?? ".") : null;
@@ -859,7 +866,7 @@ async function loadContext(request, reader, signal, scope = fileAccess.scope(sig
859
866
  cwdSource: invalidWorkdir ? "unavailable" : requestedWorkdir && path2.isAbsolute(requestedWorkdir) ? "absolute tool.workdir" : instanceDirectory ? requestedWorkdir ? "tool.workdir relative to assistant.path.cwd" : "assistant.path.cwd" : "unavailable",
860
867
  canonicalCwd: null
861
868
  };
862
- const conversation = await loadConversationContext(request, reader, signal);
869
+ const conversation = await loadConversationContext(request, reader2, signal);
863
870
  if (cwd) {
864
871
  const canonical = await scope.canonical(cwd);
865
872
  execution.canonicalCwd = canonical.path;
@@ -885,28 +892,20 @@ function permissionContext(request, clone = true) {
885
892
  tool: request.tool ? { ...request.tool } : null
886
893
  };
887
894
  }
888
- async function loadEditContext(request, reader, signal) {
895
+ async function loadEditContext(request, reader2, signal, verified) {
889
896
  signal.throwIfAborted();
890
897
  if (request.permission !== "edit" || !request.tool) throw new Error("Native edit tool context unavailable");
891
- const message = await reader.message(request.sessionID, request.tool.messageID, signal);
892
- if (!message || message.info.sessionID !== request.sessionID || message.info.id !== request.tool.messageID) {
893
- throw new Error("Pending native edit message unavailable");
894
- }
895
- const part = message.parts.find((p) => p.type === "tool" && p.callID === request.tool.callID);
896
- if (!part || part.type !== "tool" || part.sessionID !== request.sessionID || part.messageID !== request.tool.messageID || part.state.status !== "running" || part.tool !== "edit" && part.tool !== "write" && part.tool !== "apply_patch") {
898
+ const invocation = verified ? invocationFor(request, verified) : await nativeInvocation(request, reader2, signal, "edit");
899
+ if (invocation.tool !== "edit" && invocation.tool !== "write" && invocation.tool !== "apply_patch") {
897
900
  throw new Error("Pending native edit arguments unavailable or unsupported tool");
898
901
  }
899
- const invocation = message.info.role === "assistant" ? message.info.path : void 0;
900
- const location = {
901
- instanceDirectory: typeof invocation?.cwd === "string" && path2.isAbsolute(invocation.cwd) ? invocation.cwd : null,
902
- instanceWorktree: typeof invocation?.root === "string" && path2.isAbsolute(invocation.root) ? invocation.root : null
903
- };
904
- const conversation = await loadConversationContext(request, reader, signal);
902
+ const location = invocation.location;
903
+ const conversation = await loadConversationContext(request, reader2, signal);
905
904
  if (!location.instanceDirectory) conversation.limitations.push("Edit invocation directory unavailable; session origin is not substituted.");
906
905
  if (!location.instanceWorktree) conversation.limitations.push("Edit invocation worktree unavailable.");
907
- return { ...conversation, kind: "edit", tool: part.tool, location, permission: permissionContext(request) };
906
+ return { ...conversation, kind: "edit", tool: invocation.tool, location, permission: permissionContext(request, false) };
908
907
  }
909
- async function loadConversationContext(request, reader, signal) {
908
+ async function loadConversationContext(request, reader2, signal) {
910
909
  signal.throwIfAborted();
911
910
  const limitations = [];
912
911
  let current;
@@ -915,7 +914,7 @@ async function loadConversationContext(request, reader, signal) {
915
914
  const contextEnd = performance.now() + Math.min(5e3, remainingTime(signal) / 3);
916
915
  const contextMs = () => Math.max(0, contextEnd - performance.now());
917
916
  const ancestry = async (contextSignal) => {
918
- const loaded = await reader.session(request.sessionID, contextSignal);
917
+ const loaded = await reader2.session(request.sessionID, contextSignal);
919
918
  contextSignal.throwIfAborted();
920
919
  if (loaded?.id !== request.sessionID) throw new Error("Session mismatch");
921
920
  current = loaded;
@@ -926,13 +925,13 @@ async function loadConversationContext(request, reader, signal) {
926
925
  if (seen.has(ancestor.id) || seen.size >= 16) throw new Error("Parent chain incomplete");
927
926
  seen.add(ancestor.id);
928
927
  const expected = ancestor.parentID;
929
- const parent = await reader.session(expected, contextSignal);
928
+ const parent = await reader2.session(expected, contextSignal);
930
929
  contextSignal.throwIfAborted();
931
930
  if (parent?.id !== expected) throw new Error("Parent session unavailable");
932
931
  ancestor = parent;
933
932
  }
934
933
  root = ancestor;
935
- const messages = await reader.messages(root.id, contextSignal);
934
+ const messages = await reader2.messages(root.id, contextSignal);
936
935
  contextSignal.throwIfAborted();
937
936
  prompt2 = latestUserPrompt(messages, root.id);
938
937
  };
@@ -945,7 +944,7 @@ async function loadConversationContext(request, reader, signal) {
945
944
  let projects = [];
946
945
  if (current || root) {
947
946
  try {
948
- projects = await withDeadline(signal, contextMs(), (contextSignal) => reader.projects(contextSignal), "Project context lookup");
947
+ projects = await withDeadline(signal, contextMs(), (contextSignal) => reader2.projects(contextSignal), "Project context lookup");
949
948
  } catch (error) {
950
949
  signal.throwIfAborted();
951
950
  limitations.push(error instanceof DeadlineError ? error.message : "OpenCode project metadata unavailable.");
@@ -1122,7 +1121,9 @@ function estimatedUsage(body, requestedModel, pricing) {
1122
1121
  const result = { input: usage.prompt_tokens, output: usage.completion_tokens };
1123
1122
  const prices = pricing?.(typeof body.model === "string" ? body.model : requestedModel);
1124
1123
  if (!prices) return result;
1125
- const details = record(usage.prompt_tokens_details);
1124
+ const rawDetails = usage.prompt_tokens_details;
1125
+ if (rawDetails !== void 0 && (!rawDetails || typeof rawDetails !== "object" || Array.isArray(rawDetails))) return result;
1126
+ const details = record(rawDetails);
1126
1127
  const cached = details.cached_tokens === void 0 ? 0 : details.cached_tokens;
1127
1128
  const written = details.cache_write_tokens === void 0 ? 0 : details.cache_write_tokens;
1128
1129
  if (!count(cached) || !count(written) || cached + written > result.input || usage.cache_creation_input_tokens || usage.cache_read_input_tokens) return result;
@@ -1149,7 +1150,8 @@ function usageAttempt(baseURL, requestedModel, pricing, onUsage) {
1149
1150
  finalized = true;
1150
1151
  if (usage) {
1151
1152
  try {
1152
- onUsage?.({ ...usage });
1153
+ void Promise.resolve(onUsage?.({ ...usage })).catch(() => {
1154
+ });
1153
1155
  } catch {
1154
1156
  }
1155
1157
  }
@@ -1174,7 +1176,7 @@ function sumUsage(previous, next) {
1174
1176
  }
1175
1177
  function usageText(usage) {
1176
1178
  return [
1177
- ...count(usage.input) && count(usage.output) ? [`tokens in/out: ${usage.input}/${usage.output}`] : [],
1179
+ ...count(usage.input) && count(usage.output) ? [`token: ${usage.input} in ${usage.output} out`] : [],
1178
1180
  ...usage.cost === void 0 ? [] : [`cost: $${usage.cost.toFixed(4)}`]
1179
1181
  ].join("\n");
1180
1182
  }
@@ -1523,13 +1525,13 @@ function cancelBody(response) {
1523
1525
  }
1524
1526
  async function readBody(response, signal, consume) {
1525
1527
  if (!response.body) throw new Error("Reviewer returned an empty HTTP response");
1526
- const reader = response.body.getReader();
1528
+ const reader2 = response.body.getReader();
1527
1529
  try {
1528
1530
  while (true) {
1529
1531
  reviewStage(signal, "Reviewer response body");
1530
1532
  let part;
1531
1533
  try {
1532
- part = await abortable(reader.read(), signal);
1534
+ part = await abortable(reader2.read(), signal);
1533
1535
  } catch {
1534
1536
  signal.throwIfAborted();
1535
1537
  throw new Error("Reviewer response read failed");
@@ -1540,10 +1542,10 @@ async function readBody(response, signal, consume) {
1540
1542
  }
1541
1543
  } finally {
1542
1544
  try {
1543
- void reader.cancel().catch(() => {
1545
+ void reader2.cancel().catch(() => {
1544
1546
  });
1545
1547
  } finally {
1546
- reader.releaseLock();
1548
+ reader2.releaseLock();
1547
1549
  }
1548
1550
  }
1549
1551
  }
@@ -1644,7 +1646,8 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
1644
1646
  const progress = (phase, preview) => {
1645
1647
  if (signal.aborted) return;
1646
1648
  try {
1647
- onProgress?.({ attempt, phase, ...preview ? { preview: { ...preview } } : {} });
1649
+ void Promise.resolve(onProgress?.({ attempt, phase, ...preview ? { preview: { ...preview } } : {} })).catch(() => {
1650
+ });
1648
1651
  } catch {
1649
1652
  }
1650
1653
  };
@@ -1697,7 +1700,7 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
1697
1700
  const choices = object(envelope) ? envelope.choices : void 0;
1698
1701
  if (!Array.isArray(choices) || choices.length !== 1) throw new Error("Reviewer API must return one completion");
1699
1702
  const choice = choices[0], message = choice?.message;
1700
- if (!object(message) || typeof message.content !== "string" || !textMetadata(message) || object(envelope) && envelope.error != null || choice.index !== void 0 && choice.index !== 0 || choice.finish_reason !== void 0 && choice.finish_reason !== "stop") {
1703
+ if (!object(choice) || !object(message) || typeof message.content !== "string" || !textMetadata(message) || object(envelope) && envelope.error != null || choice.error != null || choice.index !== void 0 && choice.index !== 0 || choice.finish_reason !== void 0 && choice.finish_reason !== "stop") {
1701
1704
  throw new Error("Reviewer API did not return a text assessment");
1702
1705
  }
1703
1706
  if (message.content) diagnose?.("first-content");
@@ -1896,7 +1899,7 @@ var Controller = class {
1896
1899
  for (const entry of this.entries.values()) if (entry.root === root) this.suspend(entry);
1897
1900
  this.publish();
1898
1901
  }
1899
- suspend(entry) {
1902
+ suspend(entry, reason = "mode") {
1900
1903
  const id = entry.view.request.id;
1901
1904
  const state = entry.view.autoApproval?.status;
1902
1905
  if (state) this.manual.set(id, state === "failed" || state === "allowing" ? "failed" : "cancelled");
@@ -1904,6 +1907,7 @@ var Controller = class {
1904
1907
  this.clearProgress(entry);
1905
1908
  entry.approvalAbort?.abort();
1906
1909
  entry.abort.abort();
1910
+ entry.suspension = reason;
1907
1911
  entry.view = { request: entry.view.request, status: "suspended" };
1908
1912
  }
1909
1913
  /** Called only after the assessment has actually been rendered, or to hide it. */
@@ -2106,7 +2110,7 @@ var Controller = class {
2106
2110
  }
2107
2111
  } catch {
2108
2112
  if (active()) {
2109
- this.suspend(entry);
2113
+ this.suspend(entry, "unavailable");
2110
2114
  this.publish();
2111
2115
  }
2112
2116
  return null;
@@ -2157,7 +2161,7 @@ var Controller = class {
2157
2161
  for (const request of requests) {
2158
2162
  const entry = this.entries.get(request.id);
2159
2163
  if (entry && !entry.root) entry.gateRevision = this.version;
2160
- if (entry?.view.status === "suspended" && (!entry.root || this.modes?.enabled(entry.root))) {
2164
+ if (entry?.view.status === "suspended" && (entry.suspension === "unavailable" || !entry.root || this.modes?.enabled(entry.root))) {
2161
2165
  this.entries.delete(request.id);
2162
2166
  }
2163
2167
  this.asked(request);
@@ -2199,8 +2203,7 @@ init_define_REVIEW_PROMPTS();
2199
2203
 
2200
2204
  // src/evidence.ts
2201
2205
  init_define_REVIEW_PROMPTS();
2202
- var import_shell_quote = __toESM(require_shell_quote(), 1);
2203
- import path5 from "node:path";
2206
+ import path6 from "node:path";
2204
2207
  import { constants as constants2 } from "node:fs";
2205
2208
 
2206
2209
  // src/files.ts
@@ -2244,47 +2247,199 @@ var FileLimit = class {
2244
2247
  function omittedFile(filename) {
2245
2248
  return `[!] File ${JSON.stringify(filename)} not included in context.`;
2246
2249
  }
2247
- function diffDelta(diff, signal) {
2250
+ var DiffBudget = class {
2251
+ constructor(limits = { bytes: 16 * 1024 * 1024, lines: 65536, ms: 250 }, now = () => performance.now()) {
2252
+ this.now = now;
2253
+ this.bytes = limits.bytes;
2254
+ this.lines = limits.lines;
2255
+ this.ms = limits.ms;
2256
+ }
2257
+ now;
2258
+ bytes;
2259
+ lines;
2260
+ ms;
2261
+ exhausted = false;
2262
+ check(signal, end) {
2263
+ reviewStage(signal, "Evidence collection");
2264
+ if (this.now() >= end) {
2265
+ this.exhausted = true;
2266
+ return false;
2267
+ }
2268
+ return true;
2269
+ }
2270
+ };
2271
+ function diffDelta(diff, signal, budget = new DiffBudget()) {
2272
+ reviewStage(signal, "Evidence collection");
2248
2273
  if (typeof diff !== "string") return;
2249
- let old = 0, next = 0, added = 0, removed = 0, hunks = 0;
2250
- for (let start = 0; start < diff.length; ) {
2251
- signal.throwIfAborted();
2252
- const end = diff.indexOf("\n", start);
2253
- const line = diff.slice(start, end < 0 ? diff.length : end).replace(/\r$/, "");
2254
- start = end < 0 ? diff.length : end + 1;
2255
- const header = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@(?:.*)$/.exec(line);
2256
- if (header) {
2257
- if (old || next) return;
2258
- old = Number(header[1] ?? 1);
2259
- next = Number(header[2] ?? 1);
2260
- if (!Number.isSafeInteger(old) || !Number.isSafeInteger(next)) return;
2261
- hunks++;
2262
- } else if (line === "\") continue;
2263
- else if (old || next) {
2264
- if (line.startsWith("+")) {
2265
- next--;
2266
- added++;
2267
- } else if (line.startsWith("-")) {
2268
- old--;
2269
- removed++;
2270
- } else if (line.startsWith(" ")) {
2271
- old--;
2272
- next--;
2273
- } else return;
2274
- if (old < 0 || next < 0) return;
2275
- } else if (hunks && line) return;
2276
- }
2277
- if (hunks && !old && !next) return { added, removed };
2274
+ if (diff.length > budget.bytes || budget.ms <= 0) {
2275
+ budget.exhausted = true;
2276
+ return;
2277
+ }
2278
+ const startTime = budget.now(), endTime = startTime + budget.ms;
2279
+ try {
2280
+ const bytes = Buffer.byteLength(diff);
2281
+ const available = budget.bytes;
2282
+ budget.bytes = Math.max(0, available - bytes);
2283
+ if (bytes > available) {
2284
+ budget.exhausted = true;
2285
+ return;
2286
+ }
2287
+ let old = 0, next = 0, added = 0, removed = 0, hunks = 0;
2288
+ for (let start = 0; start < diff.length; ) {
2289
+ if (!budget.check(signal, endTime)) return;
2290
+ if (budget.lines <= 0) {
2291
+ budget.exhausted = true;
2292
+ return;
2293
+ }
2294
+ budget.lines--;
2295
+ const end = diff.indexOf("\n", start);
2296
+ const line = diff.slice(start, end < 0 ? diff.length : end).replace(/\r$/, "");
2297
+ start = end < 0 ? diff.length : end + 1;
2298
+ const header = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@(?:.*)$/.exec(line);
2299
+ if (header) {
2300
+ if (old || next) return;
2301
+ old = Number(header[1] ?? 1);
2302
+ next = Number(header[2] ?? 1);
2303
+ if (!Number.isSafeInteger(old) || !Number.isSafeInteger(next)) return;
2304
+ hunks++;
2305
+ } else if (line === "\") continue;
2306
+ else if (old || next) {
2307
+ if (line.startsWith("+")) {
2308
+ next--;
2309
+ added++;
2310
+ } else if (line.startsWith("-")) {
2311
+ old--;
2312
+ removed++;
2313
+ } else if (line.startsWith(" ")) {
2314
+ old--;
2315
+ next--;
2316
+ } else return;
2317
+ if (old < 0 || next < 0) return;
2318
+ } else if (hunks && line) return;
2319
+ }
2320
+ if (budget.check(signal, endTime) && hunks && !old && !next) return { added, removed };
2321
+ } finally {
2322
+ budget.ms = Math.max(0, budget.ms - (budget.now() - startTime));
2323
+ }
2278
2324
  }
2279
2325
 
2280
- // src/evidence.ts
2326
+ // src/shell-discovery.ts
2327
+ init_define_REVIEW_PROMPTS();
2328
+ var import_shell_quote = __toESM(require_shell_quote(), 1);
2329
+ import path5 from "node:path";
2281
2330
  var VARIABLE = "\0UNRESOLVED_VARIABLE\0";
2282
2331
  var python = /^python(?:[23](?:\.\d+)*)?$/;
2283
2332
  var shell = /^(?:ba|da|k|z)?sh$/;
2284
2333
  var assignment = /^[A-Za-z_][A-Za-z_0-9]*=/;
2285
- function balancedQuotes(command) {
2334
+ function literal(word) {
2335
+ return !word.includes(VARIABLE) && !word.startsWith("~");
2336
+ }
2337
+ function unsupportedWhitespace(char) {
2338
+ return /\s/.test(char) && char !== " " && char !== " ";
2339
+ }
2340
+ var DiscoveryLimit = class extends Error {
2341
+ };
2342
+ var DiscoveryBudget = class {
2343
+ constructor(signal) {
2344
+ this.signal = signal;
2345
+ }
2346
+ signal;
2347
+ bytes = 16 * 1024 * 1024;
2348
+ tokens = 65536;
2349
+ references = 16384;
2350
+ expansions = 16384;
2351
+ steps = 262144;
2352
+ notices = /* @__PURE__ */ new Set();
2353
+ end = performance.now() + 500;
2354
+ step() {
2355
+ if (this.signal) reviewStage(this.signal, "Evidence collection");
2356
+ if (--this.steps < 0 || performance.now() >= this.end) throw new DiscoveryLimit();
2357
+ }
2358
+ admit(command) {
2359
+ this.step();
2360
+ if (command.length > this.bytes) throw new DiscoveryLimit();
2361
+ this.bytes -= Buffer.byteLength(command);
2362
+ if (this.bytes < 0) throw new DiscoveryLimit();
2363
+ let quote = "", word = false, units = 0;
2364
+ for (let i = 0; i < command.length; i++) {
2365
+ if (!(i % 4096)) this.step();
2366
+ const char = command[i];
2367
+ if (char === "\\" && quote !== "'") {
2368
+ i++;
2369
+ if (!word) {
2370
+ word = true;
2371
+ units++;
2372
+ }
2373
+ } else if (quote) {
2374
+ if (char === quote) quote = "";
2375
+ else if (quote === '"' && char === "$" && --this.expansions < 0) throw new DiscoveryLimit();
2376
+ } else if (char === "'" || char === '"') {
2377
+ quote = char;
2378
+ if (!word) {
2379
+ word = true;
2380
+ units++;
2381
+ }
2382
+ } else if (char === "#" && !word) break;
2383
+ else if (/\s/.test(char)) word = false;
2384
+ else if (/[;&|()<>]/.test(char)) {
2385
+ word = false;
2386
+ units++;
2387
+ } else {
2388
+ if (char === "$" && --this.expansions < 0) throw new DiscoveryLimit();
2389
+ if (!word) {
2390
+ word = true;
2391
+ units++;
2392
+ }
2393
+ }
2394
+ if (units > this.tokens) throw new DiscoveryLimit();
2395
+ }
2396
+ }
2397
+ parsed(count3) {
2398
+ this.step();
2399
+ this.tokens -= count3;
2400
+ if (this.tokens < 0) throw new DiscoveryLimit();
2401
+ }
2402
+ reference() {
2403
+ this.step();
2404
+ if (--this.references < 0) throw new DiscoveryLimit();
2405
+ }
2406
+ notice(text) {
2407
+ if (this.notices.has(text)) return;
2408
+ if (this.notices.size >= 256) throw new DiscoveryLimit();
2409
+ this.notices.add(text);
2410
+ }
2411
+ };
2412
+ var Words = class {
2413
+ constructor(words, budget) {
2414
+ this.words = words;
2415
+ this.budget = budget;
2416
+ }
2417
+ words;
2418
+ budget;
2419
+ index = 0;
2420
+ get length() {
2421
+ return this.words.length - this.index;
2422
+ }
2423
+ peek() {
2424
+ this.budget.step();
2425
+ return this.words[this.index];
2426
+ }
2427
+ take() {
2428
+ const word = this.peek();
2429
+ if (this.length) this.index++;
2430
+ return word;
2431
+ }
2432
+ *values() {
2433
+ for (let i = this.index; i < this.words.length; i++) {
2434
+ this.budget.step();
2435
+ yield this.words[i];
2436
+ }
2437
+ }
2438
+ };
2439
+ function balancedQuotes(command, budget) {
2286
2440
  let quote = "";
2287
2441
  for (let i = 0; i < command.length; i++) {
2442
+ if (!(i % 4096)) budget.step();
2288
2443
  const char = command[i];
2289
2444
  if (char === "\\" && quote !== "'") {
2290
2445
  if (++i === command.length) return false;
@@ -2293,12 +2448,15 @@ function balancedQuotes(command) {
2293
2448
  }
2294
2449
  return !quote;
2295
2450
  }
2296
- function unsupportedShellSyntax(command) {
2451
+ function unsupportedShellSyntax(command, budget) {
2297
2452
  let quote = "";
2298
2453
  let inWord = false;
2454
+ const whitespace2 = "Unsupported unquoted shell whitespace is outside reliable tokenization; script targets were not resolved.";
2299
2455
  for (let i = 0; i < command.length; i++) {
2456
+ if (!(i % 4096)) budget.step();
2300
2457
  const char = command[i];
2301
2458
  if (char === "\\" && quote !== "'") {
2459
+ if (!quote && unsupportedWhitespace(command[i + 1] ?? "")) return whitespace2;
2302
2460
  i++;
2303
2461
  inWord = true;
2304
2462
  } else if (quote) {
@@ -2306,276 +2464,356 @@ function unsupportedShellSyntax(command) {
2306
2464
  } else if (char === "'" || char === '"') {
2307
2465
  quote = char;
2308
2466
  inWord = true;
2309
- } else if (char === "#") {
2467
+ } else if (unsupportedWhitespace(char)) return whitespace2;
2468
+ else if (char === "#") {
2310
2469
  if (!inWord) return;
2311
2470
  return "Unquoted # within a shell word is outside reliable tokenization; script targets were not resolved.";
2312
2471
  } else if (char === "[" || char === "{" || char === "}") {
2313
2472
  return "Unquoted bracket glob or brace syntax is outside literal discovery; script targets were not resolved.";
2314
- } else inWord = !/[\s;&|()<>]/.test(char);
2473
+ } else inWord = !/[ \t;&|()<>]/.test(char);
2315
2474
  }
2316
2475
  }
2317
- function discover(command, cwd, depth = 0) {
2318
- const references = [];
2319
- const limitations = [];
2320
- const note = (text) => {
2321
- if (!limitations.includes(text)) limitations.push(text);
2322
- };
2323
- if (depth > 4 || command.includes("\0") || /[\r\n`]/.test(command)) {
2324
- note("Script discovery unavailable for multiline commands, backticks, NULs, or deeply nested shell strings; inspect command text directly.");
2325
- return { references, limitations };
2476
+ function programIdentity(executable) {
2477
+ const name = /^(?:\/(?:usr\/)?bin\/)?([^/]+)$/.exec(executable)?.[1];
2478
+ if (!name) return;
2479
+ if (python.test(name)) return "python";
2480
+ if (shell.test(name)) return "shell";
2481
+ if (name === "cat" || name === "head" || name === "env") return name;
2482
+ }
2483
+ var DiscoveryState = class {
2484
+ constructor(cwd, depth, budget) {
2485
+ this.cwd = cwd;
2486
+ this.depth = depth;
2487
+ this.budget = budget;
2488
+ }
2489
+ cwd;
2490
+ depth;
2491
+ budget;
2492
+ references = [];
2493
+ limitations = [];
2494
+ uncertainWord = false;
2495
+ preceded = false;
2496
+ previousOperator;
2497
+ conditionalCwd = false;
2498
+ explicitCdpath = false;
2499
+ note(text) {
2500
+ this.budget.notice(text);
2501
+ if (!this.limitations.includes(text)) this.limitations.push(text);
2502
+ }
2503
+ invalidateCwd(text) {
2504
+ this.cwd = null;
2505
+ this.note(text);
2506
+ }
2507
+ observeAssignment(word) {
2508
+ if (!word.startsWith("CDPATH=")) return;
2509
+ this.explicitCdpath = true;
2510
+ this.note("Explicit CDPATH assignment is not evaluated; directory-search targets may be unresolved.");
2326
2511
  }
2327
- if (!balancedQuotes(command)) {
2328
- note("Shell quoting could not be resolved reliably; script contents were not collected.");
2329
- return { references, limitations };
2512
+ qualifySnapshot() {
2513
+ if (this.preceded) this.note("Files are current filesystem snapshots; preceding command statements may change them before execution.");
2330
2514
  }
2331
- const unsupported = unsupportedShellSyntax(command);
2332
- if (unsupported) {
2333
- note(unsupported);
2334
- return { references, limitations };
2515
+ add(filename, executable = false) {
2516
+ if (!literal(filename) || this.uncertainWord) {
2517
+ this.note(`Script filename unresolved: ${filename.replaceAll(VARIABLE, "<variable>")}`);
2518
+ return;
2519
+ }
2520
+ this.budget.reference();
2521
+ this.qualifySnapshot();
2522
+ this.references.push({ filename, cwd: this.cwd, executable });
2523
+ }
2524
+ nested(command) {
2525
+ const nested = scan(command, this.explicitCdpath ? null : this.cwd, this.depth + 1, this.budget);
2526
+ if (nested.references.length) this.qualifySnapshot();
2527
+ this.references.push(...nested.references);
2528
+ nested.limitations.forEach((text) => this.note(text));
2529
+ }
2530
+ afterOperator(operator) {
2531
+ this.previousOperator = operator;
2532
+ this.uncertainWord = false;
2533
+ this.preceded = true;
2534
+ if (operator === "||") {
2535
+ this.invalidateCwd("Conditional fallback makes the working directory uncertain for later statements.");
2536
+ } else if (operator === ";" && this.conditionalCwd) {
2537
+ this.conditionalCwd = false;
2538
+ this.invalidateCwd("Earlier cd may fail or be skipped before this statement; the working directory is unresolved at the branch join.");
2539
+ }
2335
2540
  }
2336
- let tokens;
2337
- try {
2338
- tokens = (0, import_shell_quote.parse)(command, () => VARIABLE);
2339
- } catch {
2340
- note("Shell tokenization failed; script targets could not be resolved.");
2341
- return { references, limitations };
2342
- }
2343
- if (tokens.some((t) => typeof t !== "string" && "op" in t && !["&&", "||", ";", "|", "glob"].includes(t.op))) {
2344
- note("Shell grouping, substitution, background execution or redirection prevents reliable script discovery; inspect command text directly.");
2345
- return { references, limitations };
2346
- }
2347
- let currentCwd = cwd;
2348
- let words = [];
2349
- let uncertainWord = false;
2350
- let preceded = false;
2351
- let previousOperator;
2352
- let conditionalCwd = false;
2353
- let explicitCdpath = false;
2354
- const observeAssignment = (word) => {
2355
- if (!word.startsWith("CDPATH=")) return;
2356
- explicitCdpath = true;
2357
- note("Explicit CDPATH assignment is not evaluated; directory-search targets may be unresolved.");
2358
- };
2359
- const literal = (s) => !s.includes(VARIABLE) && !s.startsWith("~");
2360
- const qualifySnapshot = () => {
2361
- if (preceded) note("Files are current filesystem snapshots; preceding command statements may change them before execution.");
2362
- };
2363
- const add2 = (filename, executable = false) => {
2364
- if (!literal(filename) || uncertainWord) {
2365
- note(`Script filename unresolved: ${filename.replaceAll(VARIABLE, "<variable>")}`);
2541
+ };
2542
+ function unwrap(args, state) {
2543
+ let builtins = true, builtinOnly = false, wrappers = 0;
2544
+ while (args.peek() && assignment.test(args.peek())) state.observeAssignment(args.take());
2545
+ while (args.peek()) {
2546
+ const name = args.peek();
2547
+ const env = programIdentity(name) === "env";
2548
+ if (!env && !["command", "exec", "builtin"].includes(name)) break;
2549
+ if (wrappers++ >= 8) {
2550
+ const text = "Wrapper nesting exceeds literal discovery's eight-wrapper limit; source was not resolved.";
2551
+ if (builtins) state.invalidateCwd(`${text} The working directory is unresolved.`);
2552
+ else state.note(text);
2366
2553
  return;
2367
2554
  }
2368
- references.push({ filename, cwd: currentCwd, executable });
2369
- qualifySnapshot();
2370
- };
2371
- const visit = (next) => {
2372
- const args = words;
2373
- words = [];
2374
- let builtins = true;
2375
- while (args[0] && assignment.test(args[0])) observeAssignment(args.shift());
2376
- let wrappers = 0;
2377
- while (["env", "/usr/bin/env", "command", "exec"].includes(args[0] ?? "")) {
2378
- if (wrappers++ >= 8) {
2379
- note("Wrapper nesting exceeds literal discovery's eight-wrapper limit; source was not resolved.");
2555
+ const wrapper = args.take();
2556
+ if (env) {
2557
+ if (builtinOnly) {
2558
+ state.note(`Unsupported wrapper composition: ${wrapper} is not assumed to be a shell builtin.`);
2380
2559
  return;
2381
2560
  }
2382
- const wrapper = args.shift();
2383
- if (wrapper === "env" || wrapper === "/usr/bin/env") {
2384
- builtins = false;
2385
- while (args[0]?.startsWith("-")) {
2386
- const option = args.shift();
2387
- if (option === "--") break;
2388
- if (option === "-i" || option === "--ignore-environment") continue;
2389
- if (option === "-u" || option === "--unset") {
2390
- if (!args[0] || !literal(args[0]) || uncertainWord) {
2391
- note("Missing or unresolved env option argument; invoked source was not resolved.");
2392
- return;
2393
- }
2394
- args.shift();
2395
- continue;
2561
+ builtins = false;
2562
+ while (args.peek()?.startsWith("-")) {
2563
+ const option = args.take();
2564
+ if (option === "--") break;
2565
+ if (option === "-i" || option === "--ignore-environment") continue;
2566
+ if (option === "-u" || option === "--unset") {
2567
+ if (!args.peek() || !literal(args.peek()) || state.uncertainWord) {
2568
+ state.note("Missing or unresolved env option argument; invoked source was not resolved.");
2569
+ return;
2396
2570
  }
2397
- note("Unsupported env option; invoked script source could not be resolved.");
2398
- return;
2399
- }
2400
- while (args[0] && assignment.test(args[0])) observeAssignment(args.shift());
2401
- } else {
2402
- if (!builtins) {
2403
- note(`Unsupported wrapper composition: ${wrapper} after an external launcher is not assumed to be a shell builtin.`);
2404
- return;
2405
- }
2406
- if (wrapper === "exec") builtins = false;
2407
- if (args[0] === "--") args.shift();
2408
- else if (args[0]?.startsWith("-")) {
2409
- note(`Unsupported ${wrapper} option; invoked script source was not resolved.`);
2410
- return;
2571
+ args.take();
2572
+ continue;
2411
2573
  }
2574
+ state.note("Unsupported env option; invoked script source could not be resolved.");
2575
+ return;
2576
+ }
2577
+ while (args.peek() && assignment.test(args.peek())) state.observeAssignment(args.take());
2578
+ } else {
2579
+ if (!builtins) {
2580
+ state.note(`Unsupported wrapper composition: ${wrapper} after an external launcher is not assumed to be a shell builtin.`);
2581
+ return;
2412
2582
  }
2413
- if (!args[0]) {
2414
- note(`No command operand after ${wrapper}; invoked script source was not resolved.`);
2583
+ builtinOnly = wrapper === "builtin";
2584
+ if (wrapper === "exec") builtins = false;
2585
+ if (args.peek() === "--") args.take();
2586
+ else if (args.peek()?.startsWith("-")) {
2587
+ const text = `Unsupported ${wrapper} option; invoked script source was not resolved.`;
2588
+ if (builtins && !(wrapper === "command" && ["-v", "-V"].includes(args.peek()))) {
2589
+ state.invalidateCwd(`${text} The working directory is unresolved.`);
2590
+ } else state.note(text);
2415
2591
  return;
2416
2592
  }
2417
2593
  }
2418
- const executable = args.shift();
2419
- if (!executable) return;
2420
- if (executable.startsWith("-")) {
2421
- note("Wrapper options prevent script discovery.");
2594
+ if (!args.peek()) {
2595
+ state.note(`No command operand after ${wrapper}; invoked script source was not resolved.`);
2422
2596
  return;
2423
2597
  }
2424
- const base = path5.basename(executable);
2425
- if (builtins && ["export", "readonly", "declare", "typeset"].includes(executable)) args.forEach(observeAssignment);
2426
- if (builtins && ["pushd", "popd"].includes(executable)) {
2427
- currentCwd = null;
2428
- note("Directory-stack operations are outside literal discovery; the working directory is unresolved.");
2429
- return;
2598
+ }
2599
+ const executable = args.take();
2600
+ if (!executable) return;
2601
+ if (executable.startsWith("-")) {
2602
+ state.note("Wrapper options prevent script discovery.");
2603
+ return;
2604
+ }
2605
+ return { executable, builtins, builtinOnly };
2606
+ }
2607
+ function builtin(executable, args, state, next) {
2608
+ if (["export", "readonly", "declare", "typeset"].includes(executable)) {
2609
+ let unsupported = state.uncertainWord;
2610
+ for (const word of args.values()) {
2611
+ state.observeAssignment(word);
2612
+ unsupported ||= !literal(word) || word.startsWith("-") && word !== "--" || word.startsWith("+");
2430
2613
  }
2431
- if (builtins && executable === "cd") {
2432
- if (args[0] === "--") args.shift();
2433
- const target = args[0];
2434
- const searchesCdpath = target && !path5.isAbsolute(target) && !/^\.\.?(?:\/|$)/.test(target);
2435
- if (previousOperator === "|" || next === "|") {
2436
- currentCwd = null;
2437
- note("Pipeline directory changes do not establish a reliable cwd for later script targets.");
2438
- } else if (args.length === 1 && target && !target.startsWith("-") && literal(target) && !uncertainWord && !(explicitCdpath && searchesCdpath) && next === "&&" && currentCwd) {
2439
- currentCwd = path5.resolve(currentCwd, target);
2440
- conditionalCwd = true;
2441
- } else {
2442
- currentCwd = null;
2443
- note("Working directory after cd is unresolved; relative script targets will not be guessed.");
2614
+ if (unsupported) {
2615
+ state.invalidateCwd(`Unsupported ${executable} operands or options may change shell state; the working directory is unresolved.`);
2616
+ }
2617
+ return true;
2618
+ }
2619
+ if (["pushd", "popd"].includes(executable)) {
2620
+ state.invalidateCwd("Directory-stack operations are outside literal discovery; the working directory is unresolved.");
2621
+ return true;
2622
+ }
2623
+ if (executable === "cd") {
2624
+ if (args.peek() === "--") args.take();
2625
+ const target = args.peek();
2626
+ const searchesCdpath = target && !path5.isAbsolute(target) && !/^\.\.?(?:\/|$)/.test(target);
2627
+ if (state.previousOperator === "|" || next === "|") {
2628
+ state.invalidateCwd("Pipeline directory changes do not establish a reliable cwd for later script targets.");
2629
+ } else if (args.length === 1 && target && !target.startsWith("-") && literal(target) && !state.uncertainWord && !(state.explicitCdpath && searchesCdpath) && next === "&&" && state.cwd) {
2630
+ state.cwd = path5.resolve(state.cwd, target);
2631
+ state.conditionalCwd = true;
2632
+ } else {
2633
+ state.invalidateCwd("Working directory after cd is unresolved; relative script targets will not be guessed.");
2634
+ }
2635
+ return true;
2636
+ }
2637
+ if (executable === "source" || executable === ".") {
2638
+ if (args.peek() === "--") args.take();
2639
+ const target = args.peek();
2640
+ if (target?.startsWith("-")) state.note("Unsupported sourcing option; source target was not resolved.");
2641
+ else if (target && !target.includes("/")) state.note("Bare source operand uses PATH lookup; its file was not resolved.");
2642
+ else if (target) state.add(target);
2643
+ state.invalidateCwd("Sourced code may change the shell environment or working directory.");
2644
+ return true;
2645
+ }
2646
+ if (["eval", "read", "readarray", "mapfile", "getopts", "let", "set", "shopt", "alias", "unalias", "enable", "unset"].includes(executable)) {
2647
+ state.invalidateCwd(`In-process shell state change through ${executable} is outside literal discovery; the working directory is unresolved.`);
2648
+ return true;
2649
+ }
2650
+ if (["if", "then", "else", "fi", "for", "while", "until", "do", "done", "case", "function", "{"].includes(executable)) {
2651
+ state.invalidateCwd("Shell control flow is outside literal discovery; script coverage is incomplete.");
2652
+ return true;
2653
+ }
2654
+ return false;
2655
+ }
2656
+ function reader(program, args, state) {
2657
+ const operands = [];
2658
+ let options = true;
2659
+ while (args.length) {
2660
+ const arg = args.take();
2661
+ if (options && arg === "--") {
2662
+ options = false;
2663
+ continue;
2664
+ }
2665
+ if (options && arg.startsWith("-") && arg !== "-") {
2666
+ if (program === "cat" && (/^-[AbEenstTuv]+$/.test(arg) || ["--show-all", "--number-nonblank", "--show-ends", "--number", "--squeeze-blank", "--show-tabs", "--show-nonprinting"].includes(arg))) continue;
2667
+ if (program === "head") {
2668
+ if (/^-[qvz]+$/.test(arg) || ["--quiet", "--silent", "--verbose", "--zero-terminated"].includes(arg)) continue;
2669
+ if (["-n", "-c", "--lines", "--bytes"].includes(arg) && /^-?\d+[kKMGTPEZY]?(?:B|iB)?$/.test(args.peek() ?? "")) {
2670
+ args.take();
2671
+ continue;
2672
+ }
2673
+ if (/^(?:-[nc]?|--(?:lines|bytes)=)-?\d+[kKMGTPEZY]?(?:B|iB)?$/.test(arg)) continue;
2444
2674
  }
2675
+ state.note(`Unsupported ${program} option; file operands were not resolved.`);
2445
2676
  return;
2446
2677
  }
2447
- if (["if", "then", "else", "fi", "for", "while", "until", "do", "done", "case", "function", "{"].includes(base)) {
2448
- note("Shell control flow is outside literal discovery; script coverage is incomplete.");
2449
- currentCwd = null;
2678
+ if (arg === "-") state.note(`${program} reads stdin; no file was resolved for that operand.`);
2679
+ else operands.push(arg);
2680
+ }
2681
+ operands.forEach((operand) => state.add(operand));
2682
+ }
2683
+ function interpreter(isPython, args, state) {
2684
+ while (args.length) {
2685
+ const arg = args.take();
2686
+ if (arg === "--") {
2687
+ const operand = args.peek();
2688
+ if (!operand || isPython && operand === "-") state.note("Interpreter reads stdin; no separate script file was resolved.");
2689
+ else if (!isPython && operand === "-") state.note("Shell '-' operand after -- is outside supported script discovery.");
2690
+ else state.add(operand);
2450
2691
  return;
2451
2692
  }
2452
- if (!literal(executable)) {
2453
- note("Executable name contains unresolved expansion; script coverage is incomplete.");
2693
+ if (!isPython && arg === "-") {
2694
+ state.note("Shell '-' invocation is outside supported script discovery.");
2454
2695
  return;
2455
2696
  }
2456
- if (base === "cat" || base === "head") {
2457
- const operands = [];
2458
- let options = true;
2459
- for (let i = 0; i < args.length; i++) {
2460
- const arg = args[i];
2461
- if (options && arg === "--") {
2462
- options = false;
2463
- continue;
2464
- }
2465
- if (options && arg.startsWith("-") && arg !== "-") {
2466
- if (base === "cat" && (/^-[AbEenstTuv]+$/.test(arg) || ["--show-all", "--number-nonblank", "--show-ends", "--number", "--squeeze-blank", "--show-tabs", "--show-nonprinting"].includes(arg))) continue;
2467
- if (base === "head") {
2468
- if (/^-[qvz]+$/.test(arg) || ["--quiet", "--silent", "--verbose", "--zero-terminated"].includes(arg)) continue;
2469
- if (["-n", "-c", "--lines", "--bytes"].includes(arg) && /^-?\d+[kKMGTPEZY]?(?:B|iB)?$/.test(args[i + 1] ?? "")) {
2470
- i++;
2471
- continue;
2472
- }
2473
- if (/^(?:-[nc]?|--(?:lines|bytes)=)-?\d+[kKMGTPEZY]?(?:B|iB)?$/.test(arg)) continue;
2474
- }
2475
- note(`Unsupported ${base} option; file operands were not resolved.`);
2476
- return;
2477
- }
2478
- if (arg === "-") note(`${base} reads stdin; no file was resolved for that operand.`);
2479
- else operands.push(arg);
2480
- }
2481
- operands.forEach((operand) => add2(operand));
2697
+ if (arg === "-" || !isPython && /^-[abefhiklmnptuvxBCEHPTs]*s[abefhiklmnptuvxBCEHPTs]*$/.test(arg)) {
2698
+ state.note("Interpreter reads stdin; no separate script file was resolved.");
2482
2699
  return;
2483
2700
  }
2484
- if (python.test(base) || shell.test(base)) {
2485
- const isPython = python.test(base);
2486
- for (let i = 0; i < args.length; i++) {
2487
- const arg = args[i];
2488
- if (arg === "--") {
2489
- const operand = args[i + 1];
2490
- if (!operand || isPython && operand === "-") note("Interpreter reads stdin; no separate script file was resolved.");
2491
- else if (!isPython && operand === "-") note("Shell '-' operand after -- is outside supported script discovery.");
2492
- else add2(operand);
2493
- return;
2494
- }
2495
- if (!isPython && arg === "-") {
2496
- note("Shell '-' invocation is outside supported script discovery.");
2497
- return;
2498
- }
2499
- if (arg === "-" || !isPython && /^-[abefhiklmnptuvxBCEHPTs]*s[abefhiklmnptuvxBCEHPTs]*$/.test(arg)) {
2500
- note("Interpreter reads stdin; no separate script file was resolved.");
2501
- return;
2502
- }
2503
- if (isPython && arg === "-c" || !isPython && /^-[abefhiklmnptuvxBCEHPT]*c$/.test(arg)) {
2504
- if (!args[i + 1] || !literal(args[i + 1]) || uncertainWord) note("Inline interpreter code contains unresolved expansion or is missing; its runtime contents are unavailable.");
2505
- if (!isPython && args[i + 1] && literal(args[i + 1]) && !uncertainWord) {
2506
- const nested = discover(args[i + 1], explicitCdpath ? null : currentCwd, depth + 1);
2507
- references.push(...nested.references);
2508
- nested.limitations.forEach(note);
2509
- if (nested.references.length) qualifySnapshot();
2510
- }
2511
- return;
2512
- }
2513
- if (isPython && (arg === "-m" || arg.startsWith("-m") || arg.startsWith("-c"))) {
2514
- if (!arg.startsWith("-c")) note("Python module execution: module source is not resolved by direct-script discovery.");
2515
- return;
2516
- }
2517
- if (isPython && (arg === "-W" || arg === "-X") || !isPython && (arg === "-o" || arg === "+o")) {
2518
- if (!args[i + 1] || !literal(args[i + 1]) || uncertainWord) {
2519
- note(`Interpreter option ${arg} has a missing or unresolved argument; script source was not resolved.`);
2520
- return;
2521
- }
2522
- i++;
2523
- continue;
2524
- }
2525
- if (arg.startsWith("-") || !isPython && arg.startsWith("+")) {
2526
- const known = isPython ? /^-[bBdEIOPqRsSuv]+$/.test(arg) || /^-(W|X).+/.test(arg) : /^[-+][abefhiklmnptuvxBCEHPT]+$/.test(arg) || ["--noprofile", "--norc"].includes(arg);
2527
- if (known) continue;
2528
- note(`Interpreter option ${arg} is outside supported script discovery.`);
2529
- return;
2530
- }
2531
- add2(arg);
2701
+ if (isPython && arg === "-c" || !isPython && /^-[abefhiklmnptuvxBCEHPT]*c$/.test(arg)) {
2702
+ const code = args.peek();
2703
+ if (!code || !literal(code) || state.uncertainWord) state.note("Inline interpreter code contains unresolved expansion or is missing; its runtime contents are unavailable.");
2704
+ if (!isPython && code && literal(code) && !state.uncertainWord) state.nested(code);
2705
+ return;
2706
+ }
2707
+ if (isPython && (arg === "-m" || arg.startsWith("-m") || arg.startsWith("-c"))) {
2708
+ if (!arg.startsWith("-c")) state.note("Python module execution: module source is not resolved by direct-script discovery.");
2709
+ return;
2710
+ }
2711
+ if (isPython && (arg === "-W" || arg === "-X") || !isPython && (arg === "-o" || arg === "+o")) {
2712
+ if (!args.peek() || !literal(args.peek()) || state.uncertainWord) {
2713
+ state.note(`Interpreter option ${arg} has a missing or unresolved argument; script source was not resolved.`);
2532
2714
  return;
2533
2715
  }
2534
- return;
2716
+ args.take();
2717
+ continue;
2535
2718
  }
2536
- if (builtins && (executable === "source" || executable === ".")) {
2537
- if (args[0] === "--") args.shift();
2538
- if (args[0]?.startsWith("-")) note("Unsupported sourcing option; source target was not resolved.");
2539
- else if (args[0] && !args[0].includes("/")) note("Bare source operand uses PATH lookup; its file was not resolved.");
2540
- else if (args[0]) add2(args[0]);
2541
- currentCwd = null;
2542
- note("Sourced code may change the shell environment or working directory.");
2719
+ if (arg.startsWith("-") || !isPython && arg.startsWith("+")) {
2720
+ const known = isPython ? /^-[bBdEIOPqRsSuv]+$/.test(arg) || /^-(W|X).+/.test(arg) : /^[-+][abefhiklmnptuvxBCEHPT]+$/.test(arg) || ["--noprofile", "--norc"].includes(arg);
2721
+ if (known) continue;
2722
+ state.note(`Interpreter option ${arg} is outside supported script discovery.`);
2543
2723
  return;
2544
2724
  }
2545
- if (executable.includes("/")) add2(executable, true);
2546
- else if (/\.(py|sh|bash|zsh)$/.test(executable)) note(`Executable ${executable} uses PATH lookup; its file was not resolved.`);
2547
- else if (["sudo", "su", "ssh", "docker", "podman", "eval", "xargs", "find", "npm", "npx", "make", "uv", "poetry"].includes(base)) {
2548
- note(`Indirect execution through ${base}: invoked source is not automatically discovered.`);
2725
+ state.add(arg);
2726
+ return;
2727
+ }
2728
+ }
2729
+ function visit(args, state, next) {
2730
+ const words = new Words(args, state.budget);
2731
+ const invocation = unwrap(words, state);
2732
+ if (!invocation) return;
2733
+ const { executable, builtins, builtinOnly } = invocation;
2734
+ if (!literal(executable)) {
2735
+ const text = "Executable name contains unresolved expansion; script coverage is incomplete.";
2736
+ if (builtins) state.invalidateCwd(`${text} The working directory is unresolved.`);
2737
+ else state.note(text);
2738
+ return;
2739
+ }
2740
+ if (builtins && builtin(executable, words, state, next)) return;
2741
+ if (builtinOnly) {
2742
+ if ([":", "true", "false", "pwd", "echo", "printf", "test"].includes(executable)) return;
2743
+ state.invalidateCwd(`Unsupported builtin operand ${executable}; source and subsequent working directory were not resolved.`);
2744
+ return;
2745
+ }
2746
+ const program = programIdentity(executable);
2747
+ if (program === "cat" || program === "head") return reader(program, words, state);
2748
+ if (program === "python" || program === "shell") return interpreter(program === "python", words, state);
2749
+ if (executable.includes("/")) state.add(executable, true);
2750
+ else if (/\.(py|sh|bash|zsh)$/.test(executable)) state.note(`Executable ${executable} uses PATH lookup; its file was not resolved.`);
2751
+ else if (["sudo", "su", "ssh", "docker", "podman", "eval", "xargs", "find", "npm", "npx", "make", "uv", "poetry"].includes(executable)) {
2752
+ state.note(`Indirect execution through ${executable}: invoked source is not automatically discovered.`);
2753
+ }
2754
+ }
2755
+ function discover(command, cwd, depth = 0, signal) {
2756
+ return scan(command, cwd, depth, new DiscoveryBudget(signal));
2757
+ }
2758
+ function scan(command, cwd, depth, budget) {
2759
+ const state = new DiscoveryState(cwd, depth, budget);
2760
+ const result = { references: state.references, limitations: state.limitations };
2761
+ try {
2762
+ budget.admit(command);
2763
+ if (depth > 4 || command.includes("\0") || /[\r\n`]/.test(command)) {
2764
+ state.note("Script discovery unavailable for multiline commands, backticks, NULs, or deeply nested shell strings; inspect command text directly.");
2765
+ return result;
2766
+ }
2767
+ if (!balancedQuotes(command, budget)) {
2768
+ state.note("Shell quoting could not be resolved reliably; script contents were not collected.");
2769
+ return result;
2549
2770
  }
2550
- };
2551
- for (const token of tokens) {
2552
- if (typeof token === "string") words.push(token);
2553
- else if ("comment" in token) break;
2554
- else if ("op" in token && token.op === "glob" && "pattern" in token) {
2555
- words.push(String(token.pattern));
2556
- uncertainWord = true;
2557
- } else if ("op" in token && typeof token.op === "string") {
2558
- visit(token.op);
2559
- previousOperator = token.op;
2560
- uncertainWord = false;
2561
- preceded = true;
2562
- if (token.op === "||") {
2563
- currentCwd = null;
2564
- note("Conditional fallback makes the working directory uncertain for later statements.");
2565
- } else if (token.op === ";" && conditionalCwd) {
2566
- currentCwd = null;
2567
- conditionalCwd = false;
2568
- note("Earlier cd may fail or be skipped before this statement; the working directory is unresolved at the branch join.");
2771
+ const unsupported = unsupportedShellSyntax(command, budget);
2772
+ if (unsupported) {
2773
+ state.note(unsupported);
2774
+ return result;
2775
+ }
2776
+ let tokens;
2777
+ try {
2778
+ tokens = (0, import_shell_quote.parse)(command, () => VARIABLE);
2779
+ } catch {
2780
+ state.note("Shell tokenization failed; script targets could not be resolved.");
2781
+ return result;
2782
+ }
2783
+ budget.parsed(tokens.length);
2784
+ if (tokens.some((t) => typeof t !== "string" && "op" in t && !["&&", "||", ";", "|", "glob"].includes(t.op))) {
2785
+ state.note("Shell grouping, substitution, background execution or redirection prevents reliable script discovery; inspect command text directly.");
2786
+ return result;
2787
+ }
2788
+ let words = [];
2789
+ for (const token of tokens) {
2790
+ budget.step();
2791
+ if (typeof token === "string") words.push(token);
2792
+ else if ("comment" in token) break;
2793
+ else if ("op" in token && token.op === "glob" && "pattern" in token) {
2794
+ words.push(String(token.pattern));
2795
+ state.uncertainWord = true;
2796
+ } else if ("op" in token && typeof token.op === "string") {
2797
+ visit(words, state, token.op);
2798
+ words = [];
2799
+ state.afterOperator(token.op);
2569
2800
  }
2570
2801
  }
2802
+ visit(words, state);
2803
+ return result;
2804
+ } catch (error) {
2805
+ if (!(error instanceof DiscoveryLimit)) throw error;
2806
+ const text = "Shell discovery exceeded its shared 16 MiB/65,536-token/16,384-reference/16,384-expansion/262,144-step/256-notice/500 ms work limits; remaining source targets were not resolved.";
2807
+ if (!state.limitations.includes(text)) state.limitations.push(text);
2808
+ return result;
2571
2809
  }
2572
- visit();
2573
- return { references, limitations };
2574
2810
  }
2811
+
2812
+ // src/evidence.ts
2575
2813
  async function capture(reference, budget, signal, scope) {
2576
2814
  const result = { filename: reference.filename, status: "unavailable" };
2577
- if (!reference.cwd && !path5.isAbsolute(reference.filename)) return { ...result, status: "working directory unresolved; contents not provided" };
2578
- const filename = path5.isAbsolute(reference.filename) ? reference.filename : `${reference.cwd}/${reference.filename}`;
2815
+ if (!reference.cwd && !path6.isAbsolute(reference.filename)) return { ...result, status: "working directory unresolved; contents not provided" };
2816
+ const filename = path6.isAbsolute(reference.filename) ? reference.filename : `${reference.cwd}/${reference.filename}`;
2579
2817
  result.path = filename;
2580
2818
  signal.throwIfAborted();
2581
2819
  try {
@@ -2632,9 +2870,10 @@ async function capture(reference, budget, signal, scope) {
2632
2870
  }
2633
2871
  async function collectEvidence(input, limits, signal, scope = fileAccess.scope(signal)) {
2634
2872
  signal.throwIfAborted();
2873
+ if (input.command.length > limits.maxEvidenceBytes) throw new Error("Command exceeds configured evidence budget");
2635
2874
  const commandBytes = Buffer.byteLength(input.command);
2636
2875
  if (commandBytes > limits.maxEvidenceBytes) throw new Error("Command exceeds configured evidence budget");
2637
- const discovery = discover(input.command, input.cwd);
2876
+ const discovery = discover(input.command, input.cwd, 0, signal);
2638
2877
  const evidence = { ...input, kind: "shell", files: [], limitations: [
2639
2878
  ...input.limitations ?? [],
2640
2879
  "Only literal Python/shell source and supported cat/head file operands are collected. Files are full review-time snapshots, not command output. Imports, dependencies, other runtimes and calls inside source files are not recursively inspected.",
@@ -2643,72 +2882,143 @@ async function collectEvidence(input, limits, signal, scope = fileAccess.scope(s
2643
2882
  if (!input.userPrompt) evidence.limitations.push("User prompt unavailable.");
2644
2883
  let remaining = limits.maxEvidenceBytes - commandBytes;
2645
2884
  const limit = new FileLimit(limits.maxFiles, scope);
2646
- const selected = /* @__PURE__ */ new Map();
2885
+ const declared = /* @__PURE__ */ new Map();
2647
2886
  for (const reference of discovery.references) {
2648
2887
  signal.throwIfAborted();
2649
- const filename = path5.isAbsolute(reference.filename) ? reference.filename : reference.cwd ? `${reference.cwd}/${reference.filename}` : null;
2888
+ const filename = path6.isAbsolute(reference.filename) ? reference.filename : reference.cwd ? `${reference.cwd}/${reference.filename}` : null;
2889
+ const key = filename ?? /* @__PURE__ */ Symbol();
2890
+ const previous = declared.get(key);
2891
+ if (previous) previous.executable &&= reference.executable;
2892
+ else declared.set(key, { ...reference });
2893
+ }
2894
+ const selected = /* @__PURE__ */ new Map();
2895
+ for (const reference of declared.values()) {
2896
+ signal.throwIfAborted();
2897
+ const filename = path6.isAbsolute(reference.filename) ? reference.filename : reference.cwd ? `${reference.cwd}/${reference.filename}` : null;
2650
2898
  const { key, withinLimit } = await limit.consider(filename, signal);
2651
2899
  const previous = selected.get(key);
2652
2900
  if (previous) {
2901
+ const promote = previous.reference.executable && !reference.executable;
2653
2902
  previous.reference.executable &&= reference.executable;
2654
- if (filename && filename !== previous.filename && !previous.aliases.includes(filename)) previous.aliases.push(filename);
2655
- } else selected.set(key, { reference: { ...reference }, filename, withinLimit, aliases: [] });
2656
- }
2657
- for (const { reference, filename, withinLimit, aliases } of selected.values()) {
2658
- signal.throwIfAborted();
2659
- if (!withinLimit) {
2660
- evidence.files.push({ filename: reference.filename, ...filename ? { path: filename } : {}, ...aliases.length ? { aliases } : {}, status: "file-count limit reached", warning: omittedFile(filename ?? reference.filename) });
2903
+ if (filename && filename !== previous.filename) previous.aliases.add(filename);
2904
+ if (promote && previous.withinLimit && previous.file.status === "direct executable is not identifiable as Python/shell source; contents not provided") {
2905
+ const file2 = await capture(previous.reference, remaining, signal, scope);
2906
+ Object.assign(previous.file, file2);
2907
+ if (file2.contents !== void 0) {
2908
+ delete previous.file.warning;
2909
+ remaining -= Buffer.byteLength(file2.contents);
2910
+ }
2911
+ }
2661
2912
  continue;
2662
2913
  }
2663
- const file = await capture(reference, remaining, signal, scope);
2664
- if (aliases.length) file.aliases = aliases;
2914
+ const file = withinLimit ? await capture(reference, remaining, signal, scope) : { filename: reference.filename, ...filename ? { path: filename } : {}, status: "file-count limit reached" };
2665
2915
  if (file.contents === void 0) file.warning = omittedFile(file.path ?? file.filename);
2916
+ selected.set(key, { reference, filename, withinLimit, aliases: /* @__PURE__ */ new Set(), file });
2666
2917
  evidence.files.push(file);
2667
2918
  remaining -= Buffer.byteLength(file.contents ?? "");
2668
2919
  }
2920
+ for (const { file, aliases } of selected.values()) if (aliases.size) file.aliases = [...aliases];
2669
2921
  signal.throwIfAborted();
2670
2922
  evidence.limitations.push(...limit.limitations);
2671
2923
  return evidence;
2672
2924
  }
2673
2925
  async function collectEditEvidence(input, limits, signal, scope = fileAccess.scope(signal)) {
2674
- signal.throwIfAborted();
2675
- const { metadata, ...permissionScope } = input.permission;
2926
+ reviewStage(signal, "Evidence collection");
2927
+ const metadata = input.permission.metadata;
2676
2928
  const limitations = [
2677
2929
  ...input.limitations,
2678
2930
  "Proposed diffs come from the pending host permission, not from applying edits. Full files, dependencies and post-approval formatter changes are not inspected; host diffs may normalize whitespace or omit BOMs."
2679
2931
  ];
2680
2932
  const record2 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
2933
+ const field = (value, key) => {
2934
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
2935
+ if (!descriptor) return void 0;
2936
+ if (!Object.hasOwn(descriptor, "value")) throw new Error("Native edit metadata contains an accessor");
2937
+ return descriptor.value;
2938
+ };
2939
+ let headerBytes = 16 * 1024 * 1024;
2940
+ const text = (value) => {
2941
+ if (typeof value !== "string") return;
2942
+ reviewStage(signal, "Evidence collection");
2943
+ if (value.length > headerBytes) throw new Error("Native edit scope exceeds the 16 MiB normalization limit");
2944
+ headerBytes -= Buffer.byteLength(value);
2945
+ if (headerBytes < 0) throw new Error("Native edit scope exceeds the 16 MiB normalization limit");
2946
+ return value;
2947
+ };
2948
+ const patterns = (values) => {
2949
+ if (values.length > 16384) throw new Error("Native edit scope exceeds the 16,384-entry normalization limit");
2950
+ const result = [];
2951
+ for (let index = 0; index < values.length; index++) {
2952
+ const value = text(field(values, String(index)));
2953
+ if (value === void 0) throw new Error("Native edit scope contains an invalid pattern");
2954
+ result.push(value);
2955
+ }
2956
+ return result;
2957
+ };
2958
+ const scopeText = (value) => {
2959
+ const result = text(value);
2960
+ if (result === void 0) throw new Error("Native edit scope contains invalid identity text");
2961
+ return result;
2962
+ };
2963
+ const permissionScope = {
2964
+ id: scopeText(input.permission.id),
2965
+ type: scopeText(input.permission.type),
2966
+ patterns: patterns(input.permission.patterns),
2967
+ always: patterns(input.permission.always),
2968
+ tool: input.permission.tool ? { messageID: scopeText(input.permission.tool.messageID), callID: scopeText(input.permission.tool.callID) } : null
2969
+ };
2681
2970
  const patch = input.tool === "apply_patch";
2682
- const files = patch ? Array.isArray(metadata.files) ? metadata.files : [] : [{ filePath: metadata.filepath, patch: metadata.diff, type: input.tool, additions: metadata.additions, deletions: metadata.deletions }];
2971
+ const hostFiles = patch ? field(record2(metadata), "files") : void 0;
2972
+ const files = patch ? Array.isArray(hostFiles) ? hostFiles : [] : [metadata];
2973
+ if (files.length > 16384) throw new Error("Native edit changes exceed the 16,384-entry normalization limit");
2683
2974
  if (!files.length) limitations.push("Per-file patch metadata unavailable; affected changes could not be enumerated.");
2684
2975
  let remaining = limits.maxEvidenceBytes;
2976
+ let measurementUnits = 16 * 1024 * 1024;
2685
2977
  const changes = [];
2686
2978
  const limit = new FileLimit(limits.maxFiles, scope);
2979
+ const countsBudget = new DiffBudget();
2687
2980
  for (let index = 0; index < files.length; index++) {
2688
- signal.throwIfAborted();
2689
- const file = record2(files[index]);
2690
- const operation = typeof file.type === "string" && ["add", "update", "delete", "move", "edit", "write"].includes(file.type) ? file.type : "unknown";
2981
+ reviewStage(signal, "Evidence collection");
2982
+ const file = record2(patch ? field(files, String(index)) : metadata);
2983
+ const type = patch ? field(file, "type") : input.tool;
2984
+ const operation = typeof type === "string" && ["add", "update", "delete", "move", "edit", "write"].includes(type) ? type : "unknown";
2985
+ const filePath = text(field(file, patch ? "filePath" : "filepath"));
2986
+ const movePath = patch ? text(field(file, "movePath")) : void 0;
2987
+ const diff = field(file, patch ? "patch" : "diff");
2691
2988
  const change = {
2692
- path: typeof file.filePath === "string" && file.filePath.trim() ? file.filePath : null,
2989
+ path: filePath?.trim() ? filePath : null,
2693
2990
  operation,
2694
2991
  status: "omitted",
2695
- ...typeof file.movePath === "string" ? { movePath: file.movePath } : {}
2992
+ ...movePath !== void 0 ? { movePath } : {}
2696
2993
  };
2697
- const { withinLimit } = await limit.consider(change.path && path5.isAbsolute(change.path) ? change.path : null, signal);
2994
+ const { withinLimit } = await limit.consider(change.path && path6.isAbsolute(change.path) ? change.path : null, signal);
2698
2995
  if (!withinLimit) change.reason = "file-count limit reached";
2699
- else if (!change.path || !path5.isAbsolute(change.path)) change.reason = "absolute target path unavailable";
2996
+ else if (!change.path || !path6.isAbsolute(change.path)) change.reason = "absolute target path unavailable";
2700
2997
  else if (operation === "unknown" || patch && (operation === "edit" || operation === "write")) change.reason = "file operation unavailable or unsupported";
2701
- else if (operation === "move" && (!change.movePath || !path5.isAbsolute(change.movePath))) change.reason = "absolute move destination unavailable";
2702
- else if (typeof file.patch !== "string" || !file.patch.trim()) change.reason = "proposed diff unavailable";
2703
- else if (Buffer.byteLength(file.patch) > remaining) change.reason = "complete diff exceeds remaining evidence byte budget";
2998
+ else if (operation === "move" && (!change.movePath || !path6.isAbsolute(change.movePath))) change.reason = "absolute move destination unavailable";
2999
+ else if (typeof diff !== "string" || !diff.length) change.reason = "proposed diff unavailable";
3000
+ else if (diff.length > remaining) change.reason = "complete diff exceeds remaining evidence byte budget";
3001
+ else if (diff.length > measurementUnits) change.reason = "shared diff UTF-8 measurement work limit reached";
2704
3002
  else {
2705
- change.status = "included";
2706
- change.diff = file.patch;
2707
- remaining -= Buffer.byteLength(file.patch);
3003
+ measurementUnits -= diff.length;
3004
+ const bytes = Buffer.byteLength(diff);
3005
+ if (bytes > remaining) change.reason = "complete diff exceeds remaining evidence byte budget";
3006
+ else if (!diff.trim()) change.reason = "proposed diff unavailable";
3007
+ else {
3008
+ change.status = "included";
3009
+ change.diff = diff;
3010
+ remaining -= bytes;
3011
+ }
2708
3012
  }
2709
3013
  if (change.status === "omitted") {
2710
3014
  change.warning = omittedFile(change.path);
2711
- const counts = diffDelta(file.patch, signal) ?? (Number.isSafeInteger(file.additions) && Number(file.additions) >= 0 && Number.isSafeInteger(file.deletions) && Number(file.deletions) >= 0 ? { added: Number(file.additions), removed: Number(file.deletions) } : void 0);
3015
+ let counts = diffDelta(diff, signal, countsBudget);
3016
+ if (!counts) {
3017
+ const added = field(file, "additions"), removed = field(file, "deletions");
3018
+ if (Number.isSafeInteger(added) && Number(added) >= 0 && Number.isSafeInteger(removed) && Number(removed) >= 0) {
3019
+ counts = { added: Number(added), removed: Number(removed) };
3020
+ }
3021
+ }
2712
3022
  if (counts) change.delta = `[\u0394] ${JSON.stringify(change.path)}: +${counts.added} \u2212${counts.removed} lines`;
2713
3023
  }
2714
3024
  changes.push(change);
@@ -2717,7 +3027,8 @@ async function collectEditEvidence(input, limits, signal, scope = fileAccess.sco
2717
3027
  if (partial) limitations.push("Edit evidence is incomplete. Omitted changes are not assessed by the supplied diffs; do not assume the whole proposal is safe.");
2718
3028
  if (!input.userPrompt) limitations.push("User prompt unavailable.");
2719
3029
  limitations.push(...limit.limitations);
2720
- signal.throwIfAborted();
3030
+ if (countsBudget.exhausted) limitations.push("Some omitted-diff line counts exceeded the shared 16 MiB/65,536-line/250 ms counting allowance; valid host counts were used when available.");
3031
+ reviewStage(signal, "Evidence collection");
2721
3032
  return {
2722
3033
  kind: "edit",
2723
3034
  tool: input.tool,
@@ -2775,10 +3086,10 @@ function patchOperations(input, signal) {
2775
3086
  update = void 0;
2776
3087
  const kind = line.startsWith("*** Add File:") ? "add" : line.startsWith("*** Delete File:") ? "delete" : line.startsWith("*** Update File:") ? "update" : void 0;
2777
3088
  if (!kind) continue;
2778
- const path10 = line.slice(line.indexOf(":") + 1).trim();
2779
- if (!path10) throw new Error("Patch operation summary contains an empty target path");
3089
+ const path11 = line.slice(line.indexOf(":") + 1).trim();
3090
+ if (!path11) throw new Error("Patch operation summary contains an empty target path");
2780
3091
  if (operations.length >= 16384) throw new Error("Patch operation summary exceeds bounded JSON structure limits");
2781
- const operation = { operation: kind, path: path10 };
3092
+ const operation = { operation: kind, path: path11 };
2782
3093
  operations.push(operation);
2783
3094
  if (kind === "update") update = operation;
2784
3095
  }
@@ -2809,7 +3120,7 @@ function boundedCopy(value, budget, valueBudget = 16384) {
2809
3120
  } else add2(code >= 55296 && code <= 57343 ? 6 : 3);
2810
3121
  }
2811
3122
  };
2812
- const visit = (value2, depth) => {
3123
+ const visit2 = (value2, depth) => {
2813
3124
  if (++nodes > valueBudget || depth > 32) throw new Error("Operation evidence exceeds bounded JSON structure limits");
2814
3125
  if (value2 === null) {
2815
3126
  add2(4);
@@ -2835,7 +3146,7 @@ function boundedCopy(value, budget, valueBudget = 16384) {
2835
3146
  const descriptor = Object.getOwnPropertyDescriptor(value2, String(index));
2836
3147
  if (!descriptor || !Object.hasOwn(descriptor, "value")) throw new Error("Operation evidence contains an array hole or accessor");
2837
3148
  if (count3++) add2(1);
2838
- entries.push(visit(descriptor.value, depth + 1));
3149
+ entries.push(visit2(descriptor.value, depth + 1));
2839
3150
  }
2840
3151
  } else {
2841
3152
  for (const key in value2) {
@@ -2845,13 +3156,13 @@ function boundedCopy(value, budget, valueBudget = 16384) {
2845
3156
  if (count3++) add2(1);
2846
3157
  text(key);
2847
3158
  add2(1);
2848
- Object.defineProperty(result, key, { value: visit(descriptor.value, depth + 1), enumerable: true, writable: true, configurable: true });
3159
+ Object.defineProperty(result, key, { value: visit2(descriptor.value, depth + 1), enumerable: true, writable: true, configurable: true });
2849
3160
  }
2850
3161
  }
2851
3162
  ancestors.delete(value2);
2852
3163
  return result;
2853
3164
  };
2854
- return { value: visit(value, 0), bytes, values: nodes };
3165
+ return { value: visit2(value, 0), bytes, values: nodes };
2855
3166
  }
2856
3167
  function collectToolEvidence(context, limits, signal) {
2857
3168
  signal.throwIfAborted();
@@ -2911,39 +3222,38 @@ function collectDirectoryEvidence(context, limits, signal) {
2911
3222
  }
2912
3223
 
2913
3224
  // src/evaluate.ts
2914
- async function evaluateEvidence(request, reader, options, config, configError, signal, onIdentified, files) {
3225
+ async function evaluateEvidence(request, reader2, options, config, configError, signal, onIdentified, files) {
2915
3226
  if (!candidateEnabled(request, options)) return null;
2916
3227
  reviewStage(signal, "Permission context lookup");
2917
- const invocation = await loadInvocation(request, reader, signal);
3228
+ const invocation = await loadInvocation(request, reader2, signal);
2918
3229
  let ids;
2919
3230
  try {
2920
3231
  ids = await withDeadline(signal, Math.min(1500, remainingTime(signal) / 4), async (s) => {
2921
- const value = await reader.toolIDs?.(s);
3232
+ const value = await reader2.toolIDs?.(s);
2922
3233
  if (!value || value.length > 16384 || !value.every((id) => typeof id === "string")) throw new Error("Tool registry unavailable");
2923
3234
  return value;
2924
3235
  }, "Tool registry lookup");
2925
3236
  } catch {
2926
3237
  signal.throwIfAborted();
2927
3238
  }
2928
- const classified = classify(request, invocation, ids, reader.mcpServers?.() ?? []);
3239
+ const classified = classify(request, invocation, ids, reader2.mcpServers?.() ?? []);
2929
3240
  if (!classified) return null;
2930
3241
  if (!enabledKind(classified.kind, options)) return null;
2931
3242
  onIdentified();
2932
3243
  if (!config) throw new Error(configError || "Invalid configuration");
2933
3244
  const scope = files.scope(signal);
2934
- const cachedReader = { ...reader, message: async () => invocation.message };
2935
3245
  if (classified.kind === "shell") {
2936
- const context = await loadContext(request, cachedReader, signal, scope);
3246
+ const context = await loadContext(request, reader2, signal, scope, invocation);
2937
3247
  if (!context) throw new Error("Native shell context unavailable");
2938
3248
  reviewStage(signal, "Evidence collection");
2939
3249
  return collectEvidence(context, config, signal, scope);
2940
3250
  }
2941
3251
  if (classified.kind === "edit") {
2942
- const context = await loadEditContext(request, cachedReader, signal);
3252
+ const context = await loadEditContext(request, reader2, signal, invocation);
2943
3253
  reviewStage(signal, "Evidence collection");
2944
3254
  return collectEditEvidence(context, config, signal, scope);
2945
3255
  }
2946
- const conversation = await loadConversationContext(request, reader, signal);
3256
+ const conversation = await loadConversationContext(request, reader2, signal);
2947
3257
  if (!invocation.location.instanceDirectory) conversation.limitations.push("Invocation directory unavailable; session origin is not substituted.");
2948
3258
  if (!invocation.location.instanceWorktree) conversation.limitations.push("Invocation worktree unavailable.");
2949
3259
  const common = { ...conversation, tool: invocation.tool, input: invocation.input, permission: permissionContext(request, false), location: invocation.location };
@@ -2957,7 +3267,7 @@ async function evaluateEvidence(request, reader, options, config, configError, s
2957
3267
  definition = await withDeadline(
2958
3268
  signal,
2959
3269
  Math.min(1e3, remainingTime(signal) / 4),
2960
- (s) => reader.definition?.(invocation.info, invocation.tool, s) ?? Promise.resolve(void 0),
3270
+ (s) => reader2.definition?.(invocation.info, invocation.tool, s) ?? Promise.resolve(void 0),
2961
3271
  "Tool definition lookup"
2962
3272
  );
2963
3273
  if (definition?.id !== invocation.tool) definition = void 0;
@@ -2989,11 +3299,58 @@ function approvalTransport(client, directory) {
2989
3299
  };
2990
3300
  }
2991
3301
 
3302
+ // src/pending-refresh.ts
3303
+ init_define_REVIEW_PROMPTS();
3304
+ var PendingRefresh = class {
3305
+ constructor(pending, read, parent) {
3306
+ this.pending = pending;
3307
+ this.read = read;
3308
+ this.signal = AbortSignal.any([parent, this.abort.signal]);
3309
+ }
3310
+ pending;
3311
+ read;
3312
+ syncing = false;
3313
+ stopped = false;
3314
+ again = false;
3315
+ generation = 0;
3316
+ abort = new AbortController();
3317
+ signal;
3318
+ async refresh() {
3319
+ if (this.stopped || this.signal.aborted) return;
3320
+ if (this.syncing) {
3321
+ this.again = true;
3322
+ return;
3323
+ }
3324
+ this.syncing = true;
3325
+ const generation = ++this.generation;
3326
+ const revision = this.pending.revision;
3327
+ try {
3328
+ const requests = await withDeadline(this.signal, 5e3, this.read, "Pending permission refresh");
3329
+ if (this.stopped || this.signal.aborted || this.generation !== generation || this.pending.revision !== revision) return;
3330
+ this.pending.reconcile(requests, revision);
3331
+ } catch {
3332
+ } finally {
3333
+ if (this.generation === generation) {
3334
+ this.syncing = false;
3335
+ const again = this.again;
3336
+ this.again = false;
3337
+ if (again) void this.refresh();
3338
+ }
3339
+ }
3340
+ }
3341
+ dispose() {
3342
+ this.stopped = true;
3343
+ this.again = false;
3344
+ this.generation++;
3345
+ this.abort.abort();
3346
+ }
3347
+ };
3348
+
2992
3349
  // src/lifetime-view.tsx
2993
3350
  init_define_REVIEW_PROMPTS();
2994
3351
  import { createComponent as _$createComponent } from "@opentui/solid";
2995
3352
  import { memo as _$memo } from "@opentui/solid";
2996
- import path7 from "node:path";
3353
+ import path8 from "node:path";
2997
3354
  import { createSignal } from "solid-js";
2998
3355
 
2999
3356
  // src/lifetime.ts
@@ -3001,7 +3358,7 @@ init_define_REVIEW_PROMPTS();
3001
3358
  import { constants as constants3 } from "node:fs";
3002
3359
  import { mkdir, open as open3, readdir as readdir2, rename, unlink } from "node:fs/promises";
3003
3360
  import { randomUUID } from "node:crypto";
3004
- import path6 from "node:path";
3361
+ import path7 from "node:path";
3005
3362
  var empty = () => ({ requests: 0, tokenRequests: 0, input: 0, output: 0, priced: 0, cost: 0, since: null });
3006
3363
  var count2 = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
3007
3364
  var amount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
@@ -3025,7 +3382,7 @@ var LifetimeUsage = class {
3025
3382
  constructor(directory, legacyDirectory) {
3026
3383
  this.directory = directory;
3027
3384
  this.legacyDirectory = legacyDirectory;
3028
- if (!path6.isAbsolute(directory) || legacyDirectory !== void 0 && !path6.isAbsolute(legacyDirectory)) {
3385
+ if (!path7.isAbsolute(directory) || legacyDirectory !== void 0 && !path7.isAbsolute(legacyDirectory)) {
3029
3386
  throw new Error("Lifetime usage directory must be absolute");
3030
3387
  }
3031
3388
  }
@@ -3052,7 +3409,7 @@ var LifetimeUsage = class {
3052
3409
  }).then(async () => {
3053
3410
  this.local = add(this.local, increment);
3054
3411
  await mkdir(this.directory, { recursive: true, mode: 448 });
3055
- const temporary = path6.join(this.directory, `${this.id}.${randomUUID()}.tmp`);
3412
+ const temporary = path7.join(this.directory, `${this.id}.${randomUUID()}.tmp`);
3056
3413
  try {
3057
3414
  const file = await open3(temporary, "wx", 384);
3058
3415
  try {
@@ -3061,7 +3418,7 @@ var LifetimeUsage = class {
3061
3418
  } finally {
3062
3419
  await file.close();
3063
3420
  }
3064
- await rename(temporary, path6.join(this.directory, `${this.id}.json`));
3421
+ await rename(temporary, path7.join(this.directory, `${this.id}.json`));
3065
3422
  const directory = await open3(this.directory, constants3.O_RDONLY | constants3.O_DIRECTORY);
3066
3423
  try {
3067
3424
  await directory.sync();
@@ -3082,8 +3439,9 @@ var LifetimeUsage = class {
3082
3439
  async totals(signal) {
3083
3440
  signal?.throwIfAborted();
3084
3441
  await this.flush();
3442
+ signal?.throwIfAborted();
3085
3443
  let total = empty();
3086
- const directories = new Set([this.directory, ...this.legacyDirectory ? [this.legacyDirectory] : []].map((directory) => path6.resolve(directory)));
3444
+ const directories = new Set([this.directory, ...this.legacyDirectory ? [this.legacyDirectory] : []].map((directory) => path7.resolve(directory)));
3087
3445
  for (const directory of directories) total = add(total, await this.readDirectory(directory, signal));
3088
3446
  signal?.throwIfAborted();
3089
3447
  return total;
@@ -3094,17 +3452,22 @@ var LifetimeUsage = class {
3094
3452
  if (error.code === "ENOENT") return [];
3095
3453
  throw error;
3096
3454
  });
3455
+ signal?.throwIfAborted();
3097
3456
  let total = empty();
3098
3457
  for (const name of names.filter((name2) => snapshotName.test(name2))) {
3099
3458
  signal?.throwIfAborted();
3100
- const file = await open3(path6.join(directory, name), constants3.O_RDONLY | constants3.O_NOFOLLOW | constants3.O_NONBLOCK);
3459
+ const file = await open3(path7.join(directory, name), constants3.O_RDONLY | constants3.O_NOFOLLOW | constants3.O_NONBLOCK);
3101
3460
  try {
3461
+ signal?.throwIfAborted();
3102
3462
  const stat = await file.stat();
3463
+ signal?.throwIfAborted();
3103
3464
  if (!stat.isFile() || stat.size > 1024) throw new Error("Invalid lifetime usage snapshot");
3104
3465
  const bytes = Buffer.alloc(1025);
3105
3466
  let size = 0;
3106
3467
  while (size < bytes.length) {
3468
+ signal?.throwIfAborted();
3107
3469
  const read = await file.read(bytes, size, bytes.length - size, size);
3470
+ signal?.throwIfAborted();
3108
3471
  if (!read.bytesRead) break;
3109
3472
  size += read.bytesRead;
3110
3473
  }
@@ -3123,6 +3486,7 @@ var LifetimeUsage = class {
3123
3486
  } finally {
3124
3487
  await file.close();
3125
3488
  }
3489
+ signal?.throwIfAborted();
3126
3490
  }
3127
3491
  signal?.throwIfAborted();
3128
3492
  return total;
@@ -3137,7 +3501,7 @@ function lifetimeReport(totals) {
3137
3501
  return [
3138
3502
  lifetimeCost(totals),
3139
3503
  `${totals.requests} requests with recorded usage`,
3140
- totals.tokenRequests ? `tokens in/out: ${totals.input}/${totals.output}${totals.tokenRequests < totals.requests ? " (partial coverage)" : ""}` : "tokens unavailable",
3504
+ totals.tokenRequests ? `token: ${totals.input} in ${totals.output} out${totals.tokenRequests < totals.requests ? " (partial coverage)" : ""}` : "tokens unavailable",
3141
3505
  `Token counts available: ${totals.tokenRequests}/${totals.requests} requests`,
3142
3506
  `Pricing available: ${totals.priced}/${totals.requests} requests`,
3143
3507
  ...totals.since === null ? [] : [`Recorded since: ${new Date(totals.since).toISOString().slice(0, 10)}`],
@@ -3146,25 +3510,96 @@ function lifetimeReport(totals) {
3146
3510
  ].join("\n");
3147
3511
  }
3148
3512
 
3513
+ // src/lifetime-refresh.ts
3514
+ init_define_REVIEW_PROMPTS();
3515
+ var LifetimeRefresh = class {
3516
+ constructor(read, publish, parent) {
3517
+ this.read = read;
3518
+ this.publish = publish;
3519
+ this.signal = AbortSignal.any([parent, this.abort.signal]);
3520
+ }
3521
+ read;
3522
+ publish;
3523
+ revision = 0;
3524
+ followUp = false;
3525
+ stopped = false;
3526
+ active;
3527
+ abort = new AbortController();
3528
+ signal;
3529
+ refresh() {
3530
+ if (this.stopped || this.signal.aborted) return;
3531
+ this.revision++;
3532
+ if (this.active) {
3533
+ this.followUp = true;
3534
+ return;
3535
+ }
3536
+ this.start();
3537
+ }
3538
+ /** A failed write invalidates active results and requests queued before it. */
3539
+ failed() {
3540
+ if (this.stopped || this.signal.aborted) return;
3541
+ this.revision++;
3542
+ this.followUp = false;
3543
+ this.publish(void 0);
3544
+ }
3545
+ start() {
3546
+ if (this.stopped || this.signal.aborted) return;
3547
+ this.followUp = false;
3548
+ const transaction = { revision: this.revision, settled: true, finished: false };
3549
+ this.active = transaction;
3550
+ void this.run(transaction);
3551
+ }
3552
+ live(transaction) {
3553
+ return !this.stopped && !this.signal.aborted && this.active === transaction;
3554
+ }
3555
+ async run(transaction) {
3556
+ try {
3557
+ const totals = await withDeadline(this.signal, 5e3, (signal) => {
3558
+ transaction.settled = false;
3559
+ const worker = Promise.resolve().then(() => {
3560
+ signal.throwIfAborted();
3561
+ return this.read(signal);
3562
+ });
3563
+ const settled = () => {
3564
+ transaction.settled = true;
3565
+ this.release(transaction);
3566
+ };
3567
+ void worker.then(settled, settled);
3568
+ return worker;
3569
+ }, "Lifetime usage loading");
3570
+ if (this.live(transaction) && this.revision === transaction.revision) this.publish(totals);
3571
+ } catch {
3572
+ if (this.live(transaction)) this.publish(void 0);
3573
+ } finally {
3574
+ transaction.finished = true;
3575
+ this.release(transaction);
3576
+ }
3577
+ }
3578
+ release(transaction) {
3579
+ if (this.active !== transaction || !transaction.finished || !transaction.settled) return;
3580
+ this.active = void 0;
3581
+ if (this.followUp) this.start();
3582
+ }
3583
+ dispose() {
3584
+ this.stopped = true;
3585
+ this.followUp = false;
3586
+ this.revision++;
3587
+ this.abort.abort();
3588
+ }
3589
+ };
3590
+
3149
3591
  // src/lifetime-view.tsx
3150
3592
  function lifetimeTracker(api) {
3151
- const directory = path7.join(api.state.path.state, "opencode-reviewer");
3152
- const store = new LifetimeUsage(path7.join(directory, "usage-v2"), path7.join(directory, "usage-v1"));
3593
+ const directory = path8.join(api.state.path.state, "opencode-reviewer");
3594
+ const store = new LifetimeUsage(path8.join(directory, "usage-v2"), path8.join(directory, "usage-v1"));
3153
3595
  const [totals, setTotals] = createSignal();
3154
3596
  const [unavailable, setUnavailable] = createSignal(false);
3155
- let alive = true, revision = 0;
3156
- const refresh = async () => {
3157
- const current = ++revision;
3158
- try {
3159
- const value = await withDeadline(api.lifecycle.signal, 5e3, (signal) => store.totals(signal));
3160
- if (alive && revision === current) {
3161
- setTotals(value);
3162
- setUnavailable(false);
3163
- }
3164
- } catch {
3165
- if (alive && revision === current) setUnavailable(true);
3166
- }
3167
- };
3597
+ const refresh = new LifetimeRefresh((signal) => store.totals(signal), (value) => {
3598
+ if (value) {
3599
+ setTotals(value);
3600
+ setUnavailable(false);
3601
+ } else setUnavailable(true);
3602
+ }, api.lifecycle.signal);
3168
3603
  const unregister = api.keymap.registerLayer({
3169
3604
  commands: [{
3170
3605
  name: "opencode-reviewer.lifetime",
@@ -3172,7 +3607,7 @@ function lifetimeTracker(api) {
3172
3607
  title: "Reviewer: Lifetime usage",
3173
3608
  category: "Reviewer",
3174
3609
  run: () => {
3175
- void refresh();
3610
+ refresh.refresh();
3176
3611
  api.ui.dialog.replace(() => _$createComponent(api.ui.DialogAlert, {
3177
3612
  title: "Reviewer lifetime usage",
3178
3613
  get message() {
@@ -3183,47 +3618,43 @@ function lifetimeTracker(api) {
3183
3618
  }]
3184
3619
  });
3185
3620
  api.lifecycle.onDispose(() => {
3186
- alive = false;
3621
+ refresh.dispose();
3187
3622
  unregister();
3188
3623
  });
3189
- void refresh();
3624
+ refresh.refresh();
3190
3625
  return {
3191
3626
  // The controller must settle aborted review workers before this final queue drain.
3192
3627
  flush: () => store.flush().catch(() => {
3193
3628
  }),
3194
3629
  text: () => unavailable() ? "lifetime: usage unavailable" : totals()?.requests ? lifetimeCost(totals()) : void 0,
3195
3630
  record: (usage) => {
3196
- void store.record(usage).then(refresh, () => {
3197
- if (alive) {
3198
- revision++;
3199
- setUnavailable(true);
3200
- }
3201
- });
3631
+ void store.record(usage).then(() => refresh.refresh(), () => refresh.failed());
3202
3632
  }
3203
3633
  };
3204
3634
  }
3205
3635
 
3206
3636
  // src/tui.tsx
3207
- import path9 from "node:path";
3637
+ import path10 from "node:path";
3208
3638
 
3209
3639
  // src/session-mode.ts
3210
3640
  init_define_REVIEW_PROMPTS();
3211
3641
  import { constants as constants4 } from "node:fs";
3212
3642
  import { mkdir as mkdir2, open as open4, rename as rename2, unlink as unlink2 } from "node:fs/promises";
3213
3643
  import { createHash, randomUUID as randomUUID2 } from "node:crypto";
3214
- import path8 from "node:path";
3644
+ import path9 from "node:path";
3645
+ var MAX_MODE_READS = 2;
3215
3646
  var SessionModeStore = class {
3216
3647
  constructor(directory, host) {
3217
3648
  this.directory = directory;
3218
3649
  this.host = host;
3219
- if (!path8.isAbsolute(directory)) throw new Error("Session mode directory must be absolute");
3650
+ if (!path9.isAbsolute(directory)) throw new Error("Session mode directory must be absolute");
3220
3651
  }
3221
3652
  directory;
3222
3653
  host;
3223
3654
  pending = Promise.resolve();
3224
3655
  file(root) {
3225
3656
  const key = createHash("sha256").update(JSON.stringify([this.host, root])).digest("hex");
3226
- return path8.join(this.directory, `${key}.json`);
3657
+ return path9.join(this.directory, `${key}.json`);
3227
3658
  }
3228
3659
  async read(root, signal) {
3229
3660
  signal.throwIfAborted();
@@ -3238,12 +3669,14 @@ var SessionModeStore = class {
3238
3669
  try {
3239
3670
  signal.throwIfAborted();
3240
3671
  const stat = await file.stat();
3672
+ signal.throwIfAborted();
3241
3673
  if (!stat.isFile() || stat.size > 1024) throw new Error("Invalid session mode record");
3242
3674
  const buffer = Buffer.alloc(1025);
3243
3675
  let size = 0;
3244
3676
  while (size < buffer.length) {
3245
3677
  signal.throwIfAborted();
3246
3678
  const result = await file.read(buffer, size, buffer.length - size, size);
3679
+ signal.throwIfAborted();
3247
3680
  if (!result.bytesRead) break;
3248
3681
  size += result.bytesRead;
3249
3682
  }
@@ -3300,6 +3733,12 @@ var SessionModes = class {
3300
3733
  roots = /* @__PURE__ */ new Map();
3301
3734
  values = /* @__PURE__ */ new Map();
3302
3735
  changes = /* @__PURE__ */ new Map();
3736
+ reads = /* @__PURE__ */ new Map();
3737
+ remember(visited, root, edges) {
3738
+ if (this.roots.size + visited.size > 4096) this.roots.clear();
3739
+ for (const child of visited) this.roots.set(child, { root, edges: edges-- });
3740
+ return root;
3741
+ }
3303
3742
  root(sessionID, parent) {
3304
3743
  return withDeadline(parent, 5e3, async (signal) => {
3305
3744
  const visited = /* @__PURE__ */ new Set();
@@ -3308,29 +3747,57 @@ var SessionModes = class {
3308
3747
  signal.throwIfAborted();
3309
3748
  if (!id || visited.has(id)) throw new Error("Session ancestry unavailable");
3310
3749
  const cached = this.roots.get(id);
3311
- if (cached) return cached;
3750
+ if (cached) {
3751
+ if (depth + cached.edges > 16) throw new Error("Session ancestry limit reached");
3752
+ return this.remember(visited, cached.root, depth + cached.edges);
3753
+ }
3312
3754
  visited.add(id);
3313
3755
  const value = await this.session(id, signal);
3314
3756
  signal.throwIfAborted();
3315
3757
  if (value?.id !== id || value.parentID !== void 0 && (typeof value.parentID !== "string" || !value.parentID)) {
3316
3758
  throw new Error("Session ancestry unavailable");
3317
3759
  }
3318
- if (!value.parentID) {
3319
- if (this.roots.size + visited.size > 4096) this.roots.clear();
3320
- for (const child of visited) this.roots.set(child, id);
3321
- return id;
3322
- }
3760
+ if (!value.parentID) return this.remember(visited, id, depth);
3323
3761
  id = value.parentID;
3324
3762
  }
3325
3763
  throw new Error("Session ancestry limit reached");
3326
3764
  }, "Session mode ancestry");
3327
3765
  }
3328
3766
  async load(root, parent) {
3767
+ parent.throwIfAborted();
3329
3768
  if (this.values.has(root)) return;
3769
+ const pending = this.reads.get(root);
3770
+ if (pending) return withDeadline(parent, 5e3, () => pending.wait, "Session mode loading");
3771
+ if (this.reads.size >= MAX_MODE_READS) throw new Error("Session mode read capacity unavailable");
3772
+ const read = { settled: true, finished: false };
3773
+ this.reads.set(root, read);
3774
+ return read.wait = this.loadMode(root, parent, read);
3775
+ }
3776
+ async loadMode(root, parent, read) {
3330
3777
  const revision = this.changes.get(root);
3331
- const value = await withDeadline(parent, 5e3, (signal) => this.store.read(root, signal), "Session mode loading");
3332
- parent.throwIfAborted();
3333
- if (this.changes.get(root) === revision) this.values.set(root, value);
3778
+ try {
3779
+ const value = await withDeadline(parent, 5e3, (signal) => {
3780
+ read.settled = false;
3781
+ const worker = Promise.resolve().then(() => {
3782
+ signal.throwIfAborted();
3783
+ return this.store.read(root, signal);
3784
+ });
3785
+ const settled = () => {
3786
+ read.settled = true;
3787
+ this.releaseRead(root, read);
3788
+ };
3789
+ void worker.then(settled, settled);
3790
+ return worker;
3791
+ }, "Session mode loading");
3792
+ parent.throwIfAborted();
3793
+ if (this.changes.get(root) === revision) this.values.set(root, value);
3794
+ } finally {
3795
+ read.finished = true;
3796
+ this.releaseRead(root, read);
3797
+ }
3798
+ }
3799
+ releaseRead(root, read) {
3800
+ if (read.finished && read.settled && this.reads.get(root) === read) this.reads.delete(root);
3334
3801
  }
3335
3802
  enabled(root) {
3336
3803
  return this.values.get(root) === true;
@@ -3341,7 +3808,7 @@ var SessionModes = class {
3341
3808
  return this.store.write(root, enabled);
3342
3809
  }
3343
3810
  deleted(id) {
3344
- for (const [child, root] of this.roots) if (child === id || root === id) this.roots.delete(child);
3811
+ for (const [child, value] of this.roots) if (child === id || value.root === id) this.roots.delete(child);
3345
3812
  }
3346
3813
  flush() {
3347
3814
  return this.store.flush();
@@ -3697,15 +4164,15 @@ async function reviewTui(api, options, fileIO, observer) {
3697
4164
  const hostTrace = observer ? new DiagnosticTrace(observer, 0) : void 0;
3698
4165
  const traces = observer ? /* @__PURE__ */ new WeakMap() : void 0;
3699
4166
  let reviewSequence = 0;
3700
- const reader = contextReader(api, hostTrace);
3701
- const modes = new SessionModes(new SessionModeStore(path9.join(api.state.path.state, "opencode-reviewer", "session-mode-v1"), api.state.path.directory), async (id, signal) => api.state.session.get(id) ?? reader.session(id, signal));
4167
+ const reader2 = contextReader(api, hostTrace);
4168
+ const modes = new SessionModes(new SessionModeStore(path10.join(api.state.path.state, "opencode-reviewer", "session-mode-v1"), api.state.path.directory), async (id, signal) => api.state.session.get(id) ?? reader2.session(id, signal));
3702
4169
  const files = new FileAccess(fileIO);
3703
4170
  const approval = approvalTransport(api.client, api.state.path.directory);
3704
4171
  let visibleApproval = () => void 0;
3705
4172
  const controller = new Controller(async (request, parent, onIdentified, onProgress) => {
3706
4173
  const trace = observer ? new DiagnosticTrace(observer, ++reviewSequence) : void 0;
3707
4174
  if (trace) traces.set(request, trace);
3708
- const reviewReader = trace ? contextReader(api, trace) : reader;
4175
+ const reviewReader = trace ? contextReader(api, trace) : reader2;
3709
4176
  let worker;
3710
4177
  try {
3711
4178
  return await withDeadline(parent, config?.timeoutMs ?? 3e4, async (signal) => {
@@ -3740,35 +4207,8 @@ async function reviewTui(api, options, fileIO, observer) {
3740
4207
  modes.deleted(event.properties.info.id);
3741
4208
  controller.deleted(event.properties.info.id);
3742
4209
  });
3743
- let syncing = false;
3744
- let stopped = false;
3745
- let refreshAgain = false;
3746
- const refresh = async () => {
3747
- if (stopped) return;
3748
- if (syncing) {
3749
- refreshAgain = true;
3750
- return;
3751
- }
3752
- syncing = true;
3753
- const revision = controller.revision;
3754
- try {
3755
- await withDeadline(api.lifecycle.signal, 5e3, async (signal) => {
3756
- const result = await measured(hostTrace, "pending-refresh", () => api.client.permission.list({
3757
- directory: api.state.path.directory
3758
- }, {
3759
- signal
3760
- }));
3761
- if (result.data) controller.reconcile(result.data, revision);
3762
- });
3763
- } catch {
3764
- } finally {
3765
- syncing = false;
3766
- if (refreshAgain) {
3767
- refreshAgain = false;
3768
- void refresh();
3769
- }
3770
- }
3771
- };
4210
+ const pendingRefresh = new PendingRefresh(controller, (signal) => measured(hostTrace, "pending-refresh", () => approval.list(signal)), api.lifecycle.signal);
4211
+ const refresh = () => pendingRefresh.refresh();
3772
4212
  const unregisterMode = sessionModeCommands(api, modes, (root) => {
3773
4213
  controller.modeChanged(root);
3774
4214
  void refresh();
@@ -3778,7 +4218,7 @@ async function reviewTui(api, options, fileIO, observer) {
3778
4218
  api.event.on("session.idle", () => void refresh());
3779
4219
  api.event.on("session.error", () => void refresh());
3780
4220
  api.lifecycle.onDispose(async () => {
3781
- stopped = true;
4221
+ pendingRefresh.dispose();
3782
4222
  clearInterval(interval);
3783
4223
  setSidebar(void 0);
3784
4224
  unregisterMode();
@@ -3912,7 +4352,7 @@ async function reviewTui(api, options, fileIO, observer) {
3912
4352
  }), null);
3913
4353
  _$insert(_el$14, _$createComponent2(Show, {
3914
4354
  get when() {
3915
- return view().status === "analyzing";
4355
+ return _$memo2(() => view().status === "analyzing")() && rating() === void 0;
3916
4356
  },
3917
4357
  get children() {
3918
4358
  return _$createComponent2(ReviewLoading, {