@sema-agent/core 5.42.0 → 5.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2881,6 +2881,10 @@ export interface TaskResult {
2881
2881
  * the resume capability, §6). Undefined for every other status.
2882
2882
  */
2883
2883
  checkpointToken?: import("./checkpoint-store.js").CheckpointToken;
2884
+ /** The suspend's NON-SECRET stable identity ({@link import("./checkpoint-store.js").Checkpoint.checkpointId}),
2885
+ * set beside `checkpointToken` on the suspended/needs_review terminals — the display/correlation key a
2886
+ * consumer may log or render where the token must not travel. Absent on pre-identity checkpoints. */
2887
+ checkpointId?: string;
2884
2888
  /** Set with `checkpointToken`: who/what must resume (e.g. `{kind:"human", reason, toolName}`), so the
2885
2889
  * caller knows what decision the suspension is awaiting. */
2886
2890
  checkpointGate?: import("./checkpoint-store.js").CheckpointGate;
@@ -4679,6 +4683,34 @@ export interface EngineNotice {
4679
4683
  * `detail: { steer, taskId? }` / `{ followUp, taskId? }`. Per-run, at most once per family
4680
4684
  * (the terminal sweep is a single site).
4681
4685
  *
4686
+ * - `"memory.session_polluted"` (design/178 §3, #324a) — this session's memory crossed into the
4687
+ * one-way POLLUTED state (a tool classified as an external content source was invoked, directly
4688
+ * or through a delegated child): its memory writes are no longer eligible for the long-term
4689
+ * library. The notice states that VERDICT plus what a harvest does when it collects — it does
4690
+ * not promise quarantine, because a `writeScope`-null layering and a declared-unavailable
4691
+ * session both reach the zero-admission harvest arm (nothing collected ⇒ nothing captured), and
4692
+ * a mark landing after the pre-commit pollution read leaves already-committed rows to the
4693
+ * challenge sweep; what a harvest actually contained is the other code below. The challenge
4694
+ * clause is a policy statement, not a receipt — a sweep that cannot run announces
4695
+ * `"memory.challenge_sweep_failed"` on the engine's incident seat. Announced ONCE PER
4696
+ * SESSION, at the mark seat: a repeat mark inside the process and a resumed session whose
4697
+ * durable marker already exists stay quiet (the state is one-way, so a second line would carry
4698
+ * no new fact); a pollution state that cannot be read announces rather than assuming it was
4699
+ * already said. `detail: { reason, sessionId? }` — `reason` names the invoked tool and is
4700
+ * neutralized/length-bounded (the name comes from the host/protocol roster).
4701
+ * - `"memory.harvest_quarantined"` (design/178 §3, #324a) — a polluted session's harvest ran its
4702
+ * containment: `count` ENTRY files written or changed in the session were withheld from the
4703
+ * library, `moved` of them were physically moved into the control-plane quarantine directory,
4704
+ * and `escalated` of them carry a `HarvestReport.quarantineFailures` row (a failed capture, a
4705
+ * failed removal, or a file tombstoned in place — which counts as moved AND escalated, so the
4706
+ * two numbers are read off the report's rows, never subtracted from each other). One notice PER
4707
+ * HARVEST that withheld at least one entry file (a checkpoint harvest and the terminal harvest
4708
+ * are distinct facts), never minted for a clean session; `detail: { count, moved, escalated,
4709
+ * reason?, sessionId? }` (`reason` absent ⇔ the pollution marker could not be re-read at report
4710
+ * time — the withheld count stays true either way). Registered gap: the derived index (`MEMORY.md`) is
4711
+ * contained on a path that mints no rejection row, so an index-ONLY containment produces no
4712
+ * notice and is disclosed by the harvest report's warnings alone.
4713
+ *
4682
4714
  * Deliberately NOT a notice family: brain retry/reconnect liveness (a rate limit, a 5xx, a
4683
4715
  * transient network failure being retried). Those are per-attempt liveness frames with their own
4684
4716
  * frequency semantics and ride the wire `status` channel ({@link BrainStatus}), whose sink the
@@ -1,4 +1,5 @@
1
1
  import { type ExecResult, type ExecutionEnv, ExecutionError, type ExecutionEnvExecOptions, FileError, type FileInfo, type Result } from "../harness/types.js";
2
+ import { type SecretEnvFinding } from "../../core/secret-env.js";
2
3
  import type { BackgroundShellCapability, BackgroundShellId, BackgroundPoll, BackgroundSpawnOptions } from "../../core/background-shell.js";
3
4
  import { BackgroundShellError } from "../../core/background-shell.js";
4
5
  import type { SchedulerCapability, ScheduledIntent, SchedulerContext, ScheduledTaskId, ScheduledTaskSummary } from "../../core/scheduler.js";
@@ -18,6 +19,8 @@ export declare function getShellConfig(customShellPath?: string): Promise<Result
18
19
  shell: string;
19
20
  args: string[];
20
21
  }, ExecutionError>>;
22
+ /** test seam (mirrors the notice-seat announce reset): the once-per-process fallback latches. */
23
+ export declare function __resetSecretScrubAnnouncement(): void;
21
24
  /** RB-235 ([1937] BB2): the spool-reclaim decision, extracted PURE so every rule is unit-pinnable.
22
25
  * Both reclaim sites (foreground `reclaimSpool`, background `syncSpool`) route through this one
23
26
  * function so they cannot drift apart.
@@ -83,6 +86,7 @@ export declare class NodeExecutionEnv implements ExecutionEnv, BackgroundShellCa
83
86
  private readonly bgShells;
84
87
  private bgCounter;
85
88
  private readonly retainBackgroundProcesses;
89
+ private readonly onSecretEnvScrub?;
86
90
  /** design/128 T1-1: lazily-created env-owned spool dir (mkdtemp under os tmp — unpredictable, never the cwd). */
87
91
  private bgSpoolDir?;
88
92
  readonly backgroundCapabilities: BackgroundShellCapability["backgroundCapabilities"];
@@ -108,6 +112,10 @@ export declare class NodeExecutionEnv implements ExecutionEnv, BackgroundShellCa
108
112
  * deployments (their pre-suspend dispose becomes a no-op too — do not combine).
109
113
  */
110
114
  retainBackgroundProcesses?: boolean;
115
+ /** The D1 scrub decision's observation seat: called with every NON-EMPTY finding set the default
116
+ * "scrub" leg withholds from a shell child. Absent ⇒ a once-per-process console summary (loud
117
+ * enough to notice, quiet enough not to spam). Never called on the "all"/allowlist legs. */
118
+ onSecretEnvScrub?: (findings: readonly SecretEnvFinding[]) => void;
111
119
  });
112
120
  get schedulerCapabilities(): SchedulerCapability["schedulerCapabilities"];
113
121
  schedule(intent: ScheduledIntent, ctx: SchedulerContext): Promise<Result<{
@@ -188,7 +188,13 @@ export async function getShellConfig(customShellPath) {
188
188
  return ok({ shell: "sh", args: ["-c"] });
189
189
  }
190
190
  const EXEC_FORCE_SETTLE_GRACE_MS = 3000;
191
- function getShellEnv(inheritEnv, baseEnv, extraEnv) {
191
+ let secretScrubConsoleAnnounced = false;
192
+ let secretScrubSeatDefectAnnounced = false;
193
+ export function __resetSecretScrubAnnouncement() {
194
+ secretScrubConsoleAnnounced = false;
195
+ secretScrubSeatDefectAnnounced = false;
196
+ }
197
+ function getShellEnv(inheritEnv, baseEnv, extraEnv, onScrub) {
192
198
  let inherited;
193
199
  if (inheritEnv === "all") {
194
200
  inherited = process.env;
@@ -200,7 +206,36 @@ function getShellEnv(inheritEnv, baseEnv, extraEnv) {
200
206
  inherited[k] = process.env[k];
201
207
  }
202
208
  else {
203
- inherited = scrubSecretEnv(process.env);
209
+ const scrubbed = [];
210
+ inherited = scrubSecretEnv(process.env, scrubbed);
211
+ const findings = scrubbed.filter((f) => baseEnv?.[f.key] === undefined && extraEnv?.[f.key] === undefined);
212
+ if (findings.length > 0) {
213
+ if (typeof onScrub === "function") {
214
+ try {
215
+ const r = onScrub(findings);
216
+ if (typeof r?.then === "function") {
217
+ r.then(undefined, () => {
218
+ });
219
+ }
220
+ }
221
+ catch {
222
+ }
223
+ }
224
+ else {
225
+ if (onScrub !== undefined && !secretScrubSeatDefectAnnounced) {
226
+ secretScrubSeatDefectAnnounced = true;
227
+ try {
228
+ console.warn(`onSecretEnvScrub seat holds ${onScrub === null ? "null" : typeof onScrub} — not a function; scrub findings fall back to the console summary (once per process)`);
229
+ }
230
+ catch {
231
+ }
232
+ }
233
+ if (!secretScrubConsoleAnnounced) {
234
+ secretScrubConsoleAnnounced = true;
235
+ console.warn(`secret_env_scrubbed: ${findings.length} env key(s) withheld from the shell child (${findings.map((f) => f.key).join(", ")}) — once per process; wire onSecretEnvScrub for per-exec telemetry`);
236
+ }
237
+ }
238
+ }
204
239
  }
205
240
  return {
206
241
  ...inherited,
@@ -254,6 +289,7 @@ export class NodeExecutionEnv {
254
289
  bgShells = new Map();
255
290
  bgCounter = 0;
256
291
  retainBackgroundProcesses;
292
+ onSecretEnvScrub;
257
293
  bgSpoolDir;
258
294
  backgroundCapabilities;
259
295
  constructor(options) {
@@ -263,6 +299,7 @@ export class NodeExecutionEnv {
263
299
  this.inheritEnv = options.inheritEnv ?? "scrub";
264
300
  this.scheduler = options.scheduler;
265
301
  this.retainBackgroundProcesses = options.retainBackgroundProcesses ?? false;
302
+ this.onSecretEnvScrub = options.onSecretEnvScrub;
266
303
  this.backgroundCapabilities = {
267
304
  supported: true,
268
305
  maxConcurrent: 8,
@@ -420,7 +457,7 @@ export class NodeExecutionEnv {
420
457
  child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], {
421
458
  cwd,
422
459
  detached: process.platform !== "win32",
423
- env: getShellEnv(this.inheritEnv, this.shellEnv, options?.env),
460
+ env: getShellEnv(this.inheritEnv, this.shellEnv, options?.env, this.onSecretEnvScrub),
424
461
  stdio: execSpoolFds ? ["ignore", execSpoolFds[0], execSpoolFds[1]] : ["ignore", "pipe", "pipe"],
425
462
  windowsHide: true,
426
463
  });
@@ -1114,7 +1151,7 @@ export class NodeExecutionEnv {
1114
1151
  child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], {
1115
1152
  cwd: options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd,
1116
1153
  detached: process.platform !== "win32",
1117
- env: getShellEnv(this.inheritEnv, this.shellEnv, options?.env),
1154
+ env: getShellEnv(this.inheritEnv, this.shellEnv, options?.env, this.onSecretEnvScrub),
1118
1155
  stdio: spoolFds ? ["ignore", spoolFds[0], spoolFds[1]] : ["ignore", "pipe", "pipe"],
1119
1156
  windowsHide: true,
1120
1157
  });
@@ -5,11 +5,12 @@ import type { ReadFace } from "./read-face.js";
5
5
  /**
6
6
  * The Grep card's details assembly, PURE over the engine text ({mode, offset} from the request) —
7
7
  * exported so the text→structured mapping is pinnable with synthetic texts (the byte-truncation and
8
- * fenced-partial shapes are impractical to construct through a live tool call). Follow-up #313 will
9
- * replace the text parsing with structured rows from runGrep; until then this seam is the honesty
10
- * boundary.
8
+ * fenced-partial shapes are impractical to construct through a live tool call). #313 moved PATH
9
+ * IDENTITY off the text and onto the engines' served rows (both legs supply them now); the counts
10
+ * and totals below still read the engine's own honesty markers out of the text, which is what keeps
11
+ * this seam the honesty boundary.
11
12
  */
12
- export declare function grepDetailFields(text: string, mode: "files_with_matches" | "content" | "count", offset?: number): Record<string, unknown>;
13
+ export declare function grepDetailFields(text: string, mode: "files_with_matches" | "content" | "count", offset?: number, structuredRows?: readonly import("./search.js").GrepRow[]): Record<string, unknown>;
13
14
  export declare function createGrepTool(env: ExecutionEnv, rootCanonical: string, additionalRoots?: readonly string[], readDeny?: ReadDenyMatcher, readFace?: ReadFace): AgentTool;
14
15
  export declare function createGlobTool(env: ExecutionEnv, rootCanonical: string, additionalRoots?: readonly string[], readDeny?: ReadDenyMatcher, readFace?: ReadFace): AgentTool;
15
16
  /** Static side-effect class of every hand tool, by name (design/44 §3). Used by prepare-task to (a) feed
@@ -2,7 +2,9 @@ import { Type } from "typebox";
2
2
  import { defineTool, errorResult } from "../../core/tools.js";
3
3
  import { resolveKey, violationText, violationDetails } from "./safety.js";
4
4
  import { runGrepDetailed, runGlobDetailed, splitAbsoluteGlobPattern, invalidGlobTokens } from "./search.js";
5
- export function grepDetailFields(text, mode, offset) {
5
+ export function grepDetailFields(text, mode, offset, structuredRows) {
6
+ const structuredIdentityComplete = structuredRows !== undefined && structuredRows.every((r) => r.path !== undefined || r.text === "--");
7
+ const structuredPaths = structuredIdentityComplete && structuredRows !== undefined ? [...new Set(structuredRows.filter((r) => r.path !== undefined).map((r) => r.path))] : undefined;
6
8
  const rows = text.startsWith("No matches.")
7
9
  ? []
8
10
  : text
@@ -28,10 +30,11 @@ export function grepDetailFields(text, mode, offset) {
28
30
  const appliedLimit = cappedAt !== undefined ? { appliedLimit: cappedAt } : {};
29
31
  let detailFields;
30
32
  if (mode === "files_with_matches") {
31
- detailFields = { filenames: rows, numFiles: rows.length, totalFiles: capTotal ?? rows.length, ...appliedLimit, ...appliedOffset };
33
+ const fileNames = structuredPaths ?? rows;
34
+ detailFields = { filenames: fileNames, numFiles: fileNames.length, totalFiles: capTotal ?? fileNames.length, ...appliedLimit, ...appliedOffset };
32
35
  }
33
36
  else if (mode === "count") {
34
- const filenames = [...new Set(rows.map((l) => (l.includes(":") ? l.slice(0, l.lastIndexOf(":")) : l)))];
37
+ const filenames = structuredPaths ?? [...new Set(rows.map((l) => (l.includes(":") ? l.slice(0, l.lastIndexOf(":")) : l)))];
35
38
  let numMatches = 0;
36
39
  let malformed = false;
37
40
  for (const l of rows) {
@@ -52,7 +55,7 @@ export function grepDetailFields(text, mode, offset) {
52
55
  };
53
56
  }
54
57
  else {
55
- const filenames = [...new Set(rows.map(contentPathOf))];
58
+ const filenames = structuredPaths ?? [...new Set(rows.map(contentPathOf))];
56
59
  const joined = rows.join("\n");
57
60
  const GREP_CONTENT_PREVIEW_CHARS = 16_000;
58
61
  detailFields = {
@@ -161,7 +164,7 @@ export function createGrepTool(env, rootCanonical, additionalRoots, readDeny, re
161
164
  if (text.startsWith("Error (grep)") || text.startsWith("Error (Grep)"))
162
165
  return errorResult(text);
163
166
  const mode = a.output_mode ?? "files_with_matches";
164
- const detailFields = grepDetailFields(text, mode, a.offset);
167
+ const detailFields = grepDetailFields(text, mode, a.offset, grepRun.rows);
165
168
  return {
166
169
  content: text,
167
170
  details: { type: "grep", mode, ...detailFields, ...(grepRun.degraded ?? {}), ...(grepRun.withheld !== undefined ? { withheld: grepRun.withheld } : {}) },
@@ -190,7 +190,27 @@ export interface ReadDenyJudge {
190
190
  /** JS-fallback grep: ignore-aware walk + per-line scan, honoring output_mode / context / head_limit.
191
191
  * design/199 件B: `deny` prunes the walk (note appended to every output shape); `denyOut` is the
192
192
  * structured twin — a sink because this function's dozen error returns predate the facts (the sink
193
- * is written the moment the walk lands, whatever the scan then returns). */
193
+ * is written the moment the walk lands, whatever the scan then returns).
194
+ *
195
+ * backlog #313 — the SERVED rows ride back next to the text. This scanner knows every result's
196
+ * identity natively (the walk hands it whole paths), so a row's `path` is that walked identity,
197
+ * never a cut of the rendered line — the mis-split family the text re-parse carries cannot exist
198
+ * here. The rows ARE the window `text` shows: post offset, post row cap, post byte ceiling, in the
199
+ * same order. The unit is the RECORD, which is the unit the cap has always counted: one record is
200
+ * one rendered line EXCEPT where the file's own name carries a newline, and then the one record
201
+ * spans two physical lines — the same relation {@link formatRgRecords} states for the rg leg, and
202
+ * precisely the case where a row's identity beats anything the text can be split into.
203
+ * Every row carries a path (this leg emits no group-separator row — the one pathless shape
204
+ * ripgrep's output can carry), and a no-match run's row set is `[]`, not absent. A typed
205
+ * `Error (grep): …` return carries no rows at all: a refusal is not a row set. */
206
+ export declare function jsGrepDetailed(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal, guards?: JsGrepGuards, deny?: ReadDenyJudge, denyOut?: {
207
+ withheld?: ReadDenyWithheld;
208
+ }): Promise<{
209
+ text: string;
210
+ rows?: readonly GrepRow[];
211
+ }>;
212
+ /** Text-only wrapper of {@link jsGrepDetailed} (the shape callers that never look at the served rows
213
+ * want — the same relation {@link runGrep} has to {@link runGrepDetailed}). */
194
214
  export declare function jsGrep(env: ExecutionEnv, root: string, p: GrepParams, signal?: AbortSignal, guards?: JsGrepGuards, deny?: ReadDenyJudge, denyOut?: {
195
215
  withheld?: ReadDenyWithheld;
196
216
  }): Promise<string>;
@@ -210,8 +230,35 @@ export type GrepDegradation = {
210
230
  };
211
231
  /** Structured grep result: the model-facing text plus the degradation facts, so the tool layer can
212
232
  * ship them on the structured frame instead of leaving them prose-only. */
233
+ /** #313 — one SERVED result RECORD with its path taken from the engine's own knowledge, exactly the
234
+ * window `text` shows (post cap/offset): ripgrep's `--null` path field ({@link parseRgRecords}) on
235
+ * the rg legs, the walked file identity ({@link jsGrepDetailed}) on the JS-scanner legs. A record is
236
+ * one rendered line except where a path carries a newline (see {@link formatRgRecords}), which is
237
+ * also the case the text can no longer be split into records at all. The tool layer prefers these
238
+ * over re-parsing `text` (whose `path:line:text` split mis-cuts a path that
239
+ * itself contains `:digits:`).
240
+ *
241
+ * KNOWN EXCEPTION to "exactly the window `text` shows", stated because it is measurable: on the two
242
+ * ripgrep PARTIAL legs (timeout / error-exit with output) the delivered text is fenced by
243
+ * `delimitUntrusted`, which NEUTRALIZES fence markers inside the body, while the rows carry the
244
+ * record bytes as ripgrep emitted them. A row's `text` there is therefore the un-neutralized form —
245
+ * identity (`path`) is unaffected. Consume `text` for anything rendered; the rows' contract is
246
+ * identity. Reconciling the two (defuse per record, or withhold rows where the text is fenced) is a
247
+ * contract choice recorded for the backlog, not settled here.
248
+ *
249
+ * `path` is absent only where the record genuinely carries none —
250
+ * ripgrep's `--` group separator and the unaccountable shapes listed on {@link RgRecord} — and a
251
+ * row set carrying such a row sends the tool layer back to the text parse for identity. */
252
+ export interface GrepRow {
253
+ path?: string;
254
+ text: string;
255
+ }
213
256
  export interface GrepRunResult {
214
257
  text: string;
258
+ /** Served rows (#313): present on BOTH engine legs — ripgrep's and the JS scanner's, including the
259
+ * rescan legs. Absent only when the JS scanner refused the search outright (a typed
260
+ * `Error (grep): …` text): a refusal has no row set, truthfully, rather than an empty one. */
261
+ rows?: readonly GrepRow[];
215
262
  degraded?: GrepDegradation;
216
263
  /** design/199 件B — deny-list withholding facts (see {@link ReadDenyWithheld}); absent = nothing
217
264
  * withheld / no deny judge in play. */
@@ -1,6 +1,6 @@
1
1
  import { delimitUntrusted } from "../../core/untrusted-text.js";
2
2
  import { MAX_EXEC_OUTPUT_BYTES } from "../../core/exec-output-tail.js";
3
- import { hasBinaryExtension, isAbsolutePathForm } from "./safety.js";
3
+ import { hasBinaryExtension, isAbsolutePathForm, isWinFormPath } from "./safety.js";
4
4
  const WALK_MAX_FILES = 5000;
5
5
  const WALK_MAX_DEPTH = 32;
6
6
  const GREP_DEFAULT_CAP = 250;
@@ -35,6 +35,25 @@ const TYPE_GLOBS = {
35
35
  css: "*.{css,scss,less}",
36
36
  };
37
37
  const basename = (p) => p.slice(Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")) + 1);
38
+ function collapseDotSegments(p) {
39
+ const win = isWinFormPath(p);
40
+ const lead = (win ? /^[\\/]*/ : /^\/*/).exec(p)?.[0] ?? "";
41
+ const sep = win ? "\\" : "/";
42
+ const parts = p.slice(lead.length).split(win ? /[\\/]+/ : /\/+/);
43
+ const floor = !win ? 0 : lead.length >= 2 ? 2 : /^[A-Za-z]:$/.test(parts[0] ?? "") ? 1 : 0;
44
+ const kept = [];
45
+ for (const part of parts) {
46
+ if (part === "" || part === ".")
47
+ continue;
48
+ if (part === "..") {
49
+ if (kept.length > floor)
50
+ kept.pop();
51
+ continue;
52
+ }
53
+ kept.push(part);
54
+ }
55
+ return kept.length === 0 ? "" : lead + kept.join(sep);
56
+ }
38
57
  const NO_MATCHES = "No matches.";
39
58
  const GREP_LINE_CLIP = 500;
40
59
  function clipLine(line) {
@@ -759,10 +778,10 @@ function multilineSpans(content, p, captureText = false, deadline = Infinity) {
759
778
  }
760
779
  return spans;
761
780
  }
762
- export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
781
+ export async function jsGrepDetailed(env, root, p, signal, guards, deny, denyOut) {
763
782
  const analysis = analyzeRedos(p.pattern, p.ignore_case === true);
764
783
  if (analysis.risk)
765
- return redosRejection(analysis.risk);
784
+ return { text: redosRejection(analysis.risk) };
766
785
  const budgetMs = guards?.budgetMs ?? GREP_FILE_BUDGET_MS;
767
786
  const longLineLimit = guards?.longLineLimit ?? GREP_GRAY_LINE_MAX;
768
787
  const grayPattern = analysis.quantifiedGroup || analysis.unboundedCount >= 2;
@@ -776,7 +795,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
776
795
  test = compilePattern(p);
777
796
  }
778
797
  catch (e) {
779
- return `Error (grep): invalid regex: ${e instanceof Error ? e.message : String(e)}`;
798
+ return { text: `Error (grep): invalid regex: ${e instanceof Error ? e.message : String(e)}` };
780
799
  }
781
800
  const ignore = await buildIgnore(env, root, signal);
782
801
  const rootPrefix = root.replace(/[\\/]+$/, "") + (root.includes("\\") ? "\\" : "/");
@@ -816,9 +835,20 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
816
835
  const cap = p.head_limit === 0 ? Infinity : Math.max(1, Math.floor(p.head_limit ?? GREP_DEFAULT_CAP));
817
836
  const mode = p.output_mode ?? "files_with_matches";
818
837
  const rel = (abs) => (abs.startsWith(rootPrefix) ? abs.slice(rootPrefix.length) : abs);
838
+ const startWin = isWinFormPath(start);
839
+ const startNorm = collapseDotSegments(start);
840
+ const targetSpell = p.path ? p.path.replace(startWin ? /[\\/]+$/ : /\/+$/, "") : "";
841
+ const isBoundary = (c) => c === "/" || (startWin && c === "\\");
819
842
  const relOut = (abs) => {
820
- const r = rel(abs);
821
- return p.path || r === abs ? r : `./${r}`;
843
+ if (!p.path) {
844
+ const r = rel(abs);
845
+ return r === abs ? r : `./${r}`;
846
+ }
847
+ if (abs === startNorm || abs === start)
848
+ return targetSpell;
849
+ if (abs.startsWith(startNorm) && isBoundary(abs[startNorm.length]))
850
+ return targetSpell + abs.slice(startNorm.length);
851
+ return rel(abs);
822
852
  };
823
853
  if (p.glob) {
824
854
  const globRes = splitGlobParam(p.glob).map((g) => globTokenToRegExp(normalizeGlobToken(g), globIsAnchored(g), true));
@@ -846,7 +876,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
846
876
  let outBytes = 0;
847
877
  let outputTruncated = false;
848
878
  let rowsBeforeWindow = 0;
849
- const pushRow = (row) => {
879
+ const pushRow = (row, path) => {
850
880
  if (outputTruncated)
851
881
  return;
852
882
  if (rowsBeforeWindow < off) {
@@ -861,7 +891,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
861
891
  return;
862
892
  }
863
893
  outBytes += size;
864
- out.push(row);
894
+ out.push({ path, text: row });
865
895
  };
866
896
  const fileMatches = [];
867
897
  const counts = [];
@@ -889,6 +919,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
889
919
  const lines = read.value.split("\n");
890
920
  if (lines.length > 1 && lines.at(-1) === "")
891
921
  lines.pop();
922
+ const shownPath = relOut(f);
892
923
  let fileCount = 0;
893
924
  const fileMatchStart = monotonicNow();
894
925
  if (p.multiline) {
@@ -902,8 +933,8 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
902
933
  }
903
934
  catch (e) {
904
935
  if (e instanceof GrepBudgetExceeded)
905
- return budgetError(relOut(f), monotonicNow() - fileMatchStart);
906
- return `Error (grep): invalid regex: ${e instanceof Error ? e.message : String(e)}`;
936
+ return { text: budgetError(shownPath, monotonicNow() - fileMatchStart) };
937
+ return { text: `Error (grep): invalid regex: ${e instanceof Error ? e.message : String(e)}` };
907
938
  }
908
939
  fileCount = spans.length;
909
940
  if (mode === "content") {
@@ -911,12 +942,12 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
911
942
  if (p.only_matching && text !== undefined) {
912
943
  const parts = text.split("\n");
913
944
  for (let k = 0; k < parts.length; k++) {
914
- pushRow(`${relOut(f)}:${s + k}:${clipLine(parts[k])}`);
945
+ pushRow(`${shownPath}:${s + k}:${clipLine(parts[k])}`, shownPath);
915
946
  }
916
947
  continue;
917
948
  }
918
949
  for (let j = Math.max(0, s - 1 - ctxB); j <= Math.min(lines.length - 1, eL - 1 + ctxA); j++) {
919
- pushRow(`${relOut(f)}:${j + 1}:${clipLine(lines[j])}`);
950
+ pushRow(`${shownPath}:${j + 1}:${clipLine(lines[j])}`, shownPath);
920
951
  }
921
952
  }
922
953
  }
@@ -926,7 +957,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
926
957
  if (grayPattern || (i > 0 && i % GREP_LINE_CHECK_EVERY === 0)) {
927
958
  const elapsed = monotonicNow() - fileMatchStart;
928
959
  if (elapsed >= budgetMs)
929
- return budgetError(relOut(f), elapsed);
960
+ return { text: budgetError(shownPath, elapsed) };
930
961
  }
931
962
  if (i > 0 && i % GREP_LINE_CHECK_EVERY === 0) {
932
963
  await yieldEventLoop();
@@ -945,16 +976,16 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
945
976
  if (mode === "content") {
946
977
  if (p.only_matching) {
947
978
  for (const part of matchedParts(lines[i], p)) {
948
- pushRow(`${relOut(f)}:${i + 1}:${clipLine(part)}`);
979
+ pushRow(`${shownPath}:${i + 1}:${clipLine(part)}`, shownPath);
949
980
  }
950
981
  }
951
982
  else if (ctx > 0) {
952
983
  for (let j = Math.max(0, i - ctxB); j <= Math.min(lines.length - 1, i + ctxA); j++) {
953
- pushRow(`${relOut(f)}:${j + 1}:${clipLine(lines[j])}`);
984
+ pushRow(`${shownPath}:${j + 1}:${clipLine(lines[j])}`, shownPath);
954
985
  }
955
986
  }
956
987
  else {
957
- pushRow(`${relOut(f)}:${i + 1}:${clipLine(lines[i])}`);
988
+ pushRow(`${shownPath}:${i + 1}:${clipLine(lines[i])}`, shownPath);
958
989
  }
959
990
  }
960
991
  }
@@ -968,7 +999,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
968
999
  if (mode === "files_with_matches")
969
1000
  fileMatches.push(f);
970
1001
  else if (mode === "count" && counts.length < collectCap)
971
- counts.push(`${relOut(f)}:${fileCount}`);
1002
+ counts.push({ path: shownPath, text: `${shownPath}:${fileCount}` });
972
1003
  }
973
1004
  }
974
1005
  if (scanAborted || skippedUnreadableFiles > 0 || skippedLongLines > 0 || multilineSkippedFiles > 0) {
@@ -988,7 +1019,7 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
988
1019
  }
989
1020
  }
990
1021
  const caveat = honestyCaveat + denyNote;
991
- const paged = (arr) => (off > 0 ? arr.slice(off, off + cap) : arr);
1022
+ const paged = (arr) => (off > 0 ? arr.slice(off, off + cap) : [...arr]);
992
1023
  const offNote = off > 0 ? `\n[offset ${off}]` : "";
993
1024
  if (mode === "files_with_matches") {
994
1025
  const haveMtimes = fileMatches.length > 0 && fileMatches.every((f) => walked.mtimes.has(f));
@@ -999,26 +1030,37 @@ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
999
1030
  .map((x) => x.r)
1000
1031
  : fileMatches.map(relOut).sort();
1001
1032
  const body = sortedFileMatches.slice(off, off + cap);
1002
- return body.length === 0
1003
- ? NO_MATCHES + offNote + typeNote + caveat
1004
- : body.join("\n") + (totalFiles > off + cap ? `\n…[capped at ${cap} of ${totalFiles}]` : "") + offNote + typeNote + caveat;
1033
+ const rows = body.map((r) => ({ path: r, text: r }));
1034
+ return {
1035
+ text: body.length === 0
1036
+ ? NO_MATCHES + offNote + typeNote + caveat
1037
+ : body.join("\n") + (totalFiles > off + cap ? `\n…[capped at ${cap} of ${totalFiles}]` : "") + offNote + typeNote + caveat,
1038
+ rows,
1039
+ };
1005
1040
  }
1006
1041
  if (mode === "count") {
1007
1042
  const body = paged(counts);
1008
- return body.length === 0
1009
- ? NO_MATCHES + offNote + typeNote + caveat
1010
- : body.join("\n") + (totalFiles > off + cap ? `\n…[capped at ${cap} of ${totalFiles}]` : "") + offNote + typeNote + caveat;
1043
+ return {
1044
+ text: body.length === 0
1045
+ ? NO_MATCHES + offNote + typeNote + caveat
1046
+ : body.map((r) => r.text).join("\n") + (totalFiles > off + cap ? `\n…[capped at ${cap} of ${totalFiles}]` : "") + offNote + typeNote + caveat,
1047
+ rows: body,
1048
+ };
1011
1049
  }
1012
1050
  const body = out;
1013
1051
  const byteNote = outputTruncated ? `\n…[output truncated at ${outputMaxBytes} bytes — narrow the pattern or set a smaller head_limit]` : "";
1014
1052
  if (body.length === 0)
1015
- return NO_MATCHES + byteNote + offNote + typeNote + caveat;
1053
+ return { text: NO_MATCHES + byteNote + offNote + typeNote + caveat, rows: [] };
1054
+ const bodyText = body.map((r) => r.text).join("\n");
1016
1055
  if (out.length < cap)
1017
- return body.join("\n") + byteNote + offNote + typeNote + caveat;
1056
+ return { text: bodyText + byteNote + offNote + typeNote + caveat, rows: body };
1018
1057
  const marker = ctx > 0 || p.multiline || p.only_matching
1019
1058
  ? `\n…[capped at ${cap}; ${totalContent}+ match(es) found, more output omitted]`
1020
1059
  : `\n…[capped at ${cap} of ${totalContent}]`;
1021
- return body.join("\n") + marker + byteNote + offNote + typeNote + caveat;
1060
+ return { text: bodyText + marker + byteNote + offNote + typeNote + caveat, rows: body };
1061
+ }
1062
+ export async function jsGrep(env, root, p, signal, guards, deny, denyOut) {
1063
+ return (await jsGrepDetailed(env, root, p, signal, guards, deny, denyOut)).text;
1022
1064
  }
1023
1065
  const rgCache = new WeakMap();
1024
1066
  export function detectRipgrep(env) {
@@ -1096,12 +1138,16 @@ function formatRgRecords(records, p, caveat = "") {
1096
1138
  const cap = p.head_limit === 0 ? Infinity : Math.max(1, Math.floor(p.head_limit ?? GREP_DEFAULT_CAP));
1097
1139
  const off = Math.max(0, Math.floor(p.offset ?? 0));
1098
1140
  const capped = records.slice(off, off + cap);
1141
+ const served = capped.map((r) => (r.path !== undefined ? { path: r.path, text: r.text } : { text: r.text }));
1099
1142
  if (capped.length === 0)
1100
- return NO_MATCHES + (off > 0 ? `\n[offset ${off}]` : "") + caveat;
1101
- return (capped.map((r) => r.text).join("\n") +
1102
- (records.length > off + cap ? `\n…[capped at ${cap} of ${records.length}]` : "") +
1103
- (off > 0 ? `\n[offset ${off}]` : "") +
1104
- caveat);
1143
+ return { text: NO_MATCHES + (off > 0 ? `\n[offset ${off}]` : "") + caveat, served };
1144
+ return {
1145
+ text: capped.map((r) => r.text).join("\n") +
1146
+ (records.length > off + cap ? `\n[capped at ${cap} of ${records.length}]` : "") +
1147
+ (off > 0 ? `\n[offset ${off}]` : "") +
1148
+ caveat,
1149
+ served,
1150
+ };
1105
1151
  }
1106
1152
  export function rgOutputDenyTripwire(stdout, mode, judge, opts = {}) {
1107
1153
  if (stdout.length === 0)
@@ -1120,30 +1166,32 @@ export function rgOutputDenyTripwire(stdout, mode, judge, opts = {}) {
1120
1166
  }
1121
1167
  async function jsGrepDenyTripFallback(env, root, p, signal, trip, deny) {
1122
1168
  const denyOut = {};
1123
- const text = await jsGrep(env, root, p, signal, undefined, deny, denyOut);
1169
+ const { text, rows } = await jsGrepDetailed(env, root, p, signal, undefined, deny, denyOut);
1124
1170
  const why = trip.reason === "deny-hit" ? "its output involved sensitive-path deny-listed entries the exclusion globs cannot express" : "its output contained a record the line format cannot account for";
1125
1171
  return {
1126
1172
  text: text.startsWith("Error (grep)")
1127
1173
  ? text
1128
1174
  : `${text}\n[note: the ripgrep pass was abandoned (${why}); results are from the fallback scanner, which prunes with the authoritative deny judge]`,
1175
+ ...(rows !== undefined ? { rows } : {}),
1129
1176
  degraded: { fallback: "js-scan", reason: `ripgrep output tripped the deny tripwire (${trip.reason})` },
1130
1177
  ...(denyOut.withheld !== undefined ? { withheld: denyOut.withheld } : {}),
1131
1178
  };
1132
1179
  }
1133
1180
  async function jsGrepFallback(env, root, p, signal, reason, deny) {
1134
1181
  const denyOut = {};
1135
- const text = await jsGrep(env, root, p, signal, undefined, deny, denyOut);
1182
+ const { text, rows } = await jsGrepDetailed(env, root, p, signal, undefined, deny, denyOut);
1136
1183
  return {
1137
1184
  text: text.startsWith("Error (grep)")
1138
1185
  ? text
1139
1186
  : `${text}\n[note: ripgrep failed (${reason}); results are from a fallback scanner]`,
1187
+ ...(rows !== undefined ? { rows } : {}),
1140
1188
  degraded: { fallback: "js-scan", reason: `ripgrep failed (${reason})` },
1141
1189
  ...(denyOut.withheld !== undefined ? { withheld: denyOut.withheld } : {}),
1142
1190
  };
1143
1191
  }
1144
1192
  export async function rgGrepDetailed(env, root, p, signal, deny) {
1145
1193
  const mode = p.output_mode ?? "files_with_matches";
1146
- const flags = ["--null", "--no-messages", "--no-require-git", "--hidden"];
1194
+ const flags = ["--null", "-H", "--no-messages", "--no-require-git", "--hidden"];
1147
1195
  for (const d of VCS_DIRS)
1148
1196
  flags.push("--glob", `!${d}`);
1149
1197
  flags.push("--max-columns", "500", "--max-columns-preview");
@@ -1193,7 +1241,7 @@ export async function rgGrepDetailed(env, root, p, signal, deny) {
1193
1241
  if (records.length === 0)
1194
1242
  return jsGrepFallback(env, root, p, signal, "timed out before completing a result", deny);
1195
1243
  return {
1196
- text: `${delimitUntrusted("ripgrep partial output", formatRgRecords(records, p))}\n…[ripgrep timed out after producing partial output — results may be incomplete]`,
1244
+ ...(() => { const f = formatRgRecords(records, p); return { text: `${delimitUntrusted("ripgrep partial output", f.text)}\n…[ripgrep timed out after producing partial output — results may be incomplete]`, rows: f.served }; })(),
1197
1245
  degraded: { partial: true, reason: "ripgrep timed out" },
1198
1246
  };
1199
1247
  }
@@ -1205,6 +1253,12 @@ export async function rgGrepDetailed(env, root, p, signal, deny) {
1205
1253
  const denyDisclosure = async () => {
1206
1254
  if (deny === undefined || deny.rgExclusionGlobs.length === 0)
1207
1255
  return { note: "" };
1256
+ if (p.path) {
1257
+ const startPath = isAbsolutePathForm(p.path) ? p.path : `${root.replace(/[\\/]+$/, "")}${root.includes("\\") ? "\\" : "/"}${p.path}`;
1258
+ const info = await env.fileInfo(startPath, signal);
1259
+ if (info.ok && info.value.kind === "file")
1260
+ return { note: "" };
1261
+ }
1208
1262
  const withheld = await rgDenyExistenceProbe(env, root, deny, target, signal);
1209
1263
  if (withheld === undefined)
1210
1264
  return { note: "" };
@@ -1215,7 +1269,7 @@ export async function rgGrepDetailed(env, root, p, signal, deny) {
1215
1269
  };
1216
1270
  if (exitCode === 1) {
1217
1271
  const d = await denyDisclosure();
1218
- return { text: NO_MATCHES + d.note, ...(d.withheld !== undefined ? { withheld: d.withheld } : {}) };
1272
+ return { text: NO_MATCHES + d.note, rows: [], ...(d.withheld !== undefined ? { withheld: d.withheld } : {}) };
1219
1273
  }
1220
1274
  if (exitCode >= 2) {
1221
1275
  if (stdout.trim().length > 0) {
@@ -1228,7 +1282,7 @@ export async function rgGrepDetailed(env, root, p, signal, deny) {
1228
1282
  if (records.length === 0)
1229
1283
  return jsGrepFallback(env, root, p, signal, `exited with code ${exitCode} and produced no complete result`, deny);
1230
1284
  return {
1231
- text: `${delimitUntrusted("ripgrep partial output", formatRgRecords(records, p))}\n…[ripgrep exited with an error after producing partial output — results may be incomplete]`,
1285
+ ...(() => { const f = formatRgRecords(records, p); return { text: `${delimitUntrusted("ripgrep partial output", f.text)}\n…[ripgrep exited with an error after producing partial output — results may be incomplete]`, rows: f.served }; })(),
1232
1286
  degraded: { partial: true, reason: `ripgrep exited with code ${exitCode}` },
1233
1287
  };
1234
1288
  }
@@ -1242,7 +1296,8 @@ export async function rgGrepDetailed(env, root, p, signal, deny) {
1242
1296
  const parsed = parseRgRecords(stdout, mode);
1243
1297
  const ordered = mode === "files_with_matches" ? await sortRgFilesByMtime(env, root, parsed, signal) : parsed;
1244
1298
  const d = await denyDisclosure();
1245
- return { text: formatRgRecords(ordered, p) + d.note, ...(d.withheld !== undefined ? { withheld: d.withheld } : {}) };
1299
+ const formatted = formatRgRecords(ordered, p);
1300
+ return { text: formatted.text + d.note, rows: formatted.served, ...(d.withheld !== undefined ? { withheld: d.withheld } : {}) };
1246
1301
  }
1247
1302
  async function rgDenyExistenceProbe(env, root, deny, target, signal) {
1248
1303
  const probeFlags = ["--files", "--hidden", "--no-require-git", "--no-messages"];
@@ -1277,8 +1332,8 @@ export async function runGrepDetailed(env, root, p, signal, deny) {
1277
1332
  if (await detectRipgrep(env))
1278
1333
  return rgGrepDetailed(env, root, p, signal, deny);
1279
1334
  const denyOut = {};
1280
- const text = await jsGrep(env, root, p, signal, undefined, deny, denyOut);
1281
- return { text, ...(denyOut.withheld !== undefined ? { withheld: denyOut.withheld } : {}) };
1335
+ const { text, rows } = await jsGrepDetailed(env, root, p, signal, undefined, deny, denyOut);
1336
+ return { text, ...(rows !== undefined ? { rows } : {}), ...(denyOut.withheld !== undefined ? { withheld: denyOut.withheld } : {}) };
1282
1337
  }
1283
1338
  export async function runGrep(env, root, p, signal) {
1284
1339
  return (await runGrepDetailed(env, root, p, signal)).text;