opencode-reviewer 0.4.1 → 0.4.3

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