@sema-agent/core 5.42.0 → 5.43.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.43.0 — 2026-08-18
4
+
5
+ ### Added
6
+ - `TaskResult.checkpointId` — the non-secret checkpoint identity now rides the suspended /
7
+ needs_review terminals beside `checkpointToken` (full chain: mint → publishCommittedSuspend →
8
+ suspend/review ref → runTask ref copy → assemble-result; the ref copy is an explicit-key
9
+ whitelist and the new key crosses it). **Erratum for the 5.42.0 entry**: that release minted
10
+ `checkpointId` on `Checkpoint`/`CheckpointSummary` only — it did NOT yet reach `TaskResult`;
11
+ the entry omitted that boundary. This release completes the face.
12
+ - `NodeExecutionEnv` constructor seat `onSecretEnvScrub` (#316): the Bash exec site's secret-env
13
+ scrub decision is observable. Findings name the denylist-matched keys withheld from the child
14
+ env; a caller-overridden key (constructor `shellEnv` or per-exec `env`) is NOT reported withheld —
15
+ the child has it by the caller's explicit choice; an async seat's rejection is consumed (an announcement must
16
+ never become an exec failure); unwired builds print a once-per-process console summary.
17
+ Companion doc fix (#315, doc half): the scrub matches key NAMES against the denylist — a secret
18
+ VALUE under an innocent name is not detected; the doc now says so where the denylist lives.
19
+ - Grep structured rows (#313, first stage): the ripgrep legs hand their served rows
20
+ (`{path?, text}`, post cap/offset) to the tool layer, and `grepDetailFields` prefers row
21
+ identity when every served row carries it — `details.filenames` on rg legs no longer re-parses
22
+ paths out of formatted text (a path containing `:digits:` can no longer split wrong). Text
23
+ parsing remains the honest fallback (JS fallback leg unchanged; its rows stage is a later
24
+ window).
25
+
26
+ ### Changed
27
+ - Grep's ripgrep legs always pass `-H` (#311): a single explicit file target keeps its path
28
+ field, so a deny-guarded single-file grep keeps the rg fast path instead of abandoning rg for
29
+ the JS scanner's explicit-file lane (an engine swap over that one file, losing rg's
30
+ streaming/large-file handling). Observable flip, both arms: content-mode output for a single
31
+ explicit file target now carries a path prefix in the TARGET's spelling as passed (the tool
32
+ layer hands an absolute canonical path, so the prefix is ABSOLUTE); without a deny judge the
33
+ line previously had no prefix, and WITH one it previously came from the JS lane with a
34
+ root-relative prefix — both spellings change. Multi-file and directory targets: byte-identical
35
+ (measured). The two engines still disagree on path shape for the same call (jsGrep emits
36
+ root-relative) — that alignment is tracked separately (#318), not silently changed here.
37
+ - The deny-withholding note no longer fabricates on single-file scopes (5.43 rescan): `rg --files`
38
+ lists an explicitly named file argument regardless of glob filters, so the existence probe
39
+ behind the note would have claimed "entries were excluded" on every deny-wired single-file grep
40
+ the `-H` change made reachable. A target the stat probe proves is a FILE is judged clean without
41
+ the probe — the tool layer already judged that one target against the same deny table before
42
+ the engine ran, and a scope of exactly one served file has nothing else to withhold. Directory
43
+ scopes keep the probe unchanged.
44
+ - A clean no-match ripgrep run now carries `rows: []` (5.43 rescan): the one rg terminal that
45
+ omitted the served-rows key, breaking the "rows present ⟺ ripgrep leg" reading.
46
+ - `onSecretEnvScrub` judges its seat by FUNCTION-ness (5.43 rescan): a present non-function seat
47
+ (null, an untyped host's JSON wiring) no longer silences both channels at once — the findings
48
+ keep the seatless once-per-process console summary and the seat defect itself is announced once
49
+ per process (the #170 shape; loud-bad-value law).
50
+
3
51
  ## 5.42.0 — 2026-08-18
4
52
 
5
53
  ### Added
@@ -170,6 +170,8 @@ export interface ResultFlags {
170
170
  * but does not set `threw`, so it never reaches the `flags.threw` branch. */
171
171
  suspendRef?: {
172
172
  token: import("../checkpoint-store.js").CheckpointToken;
173
+ /** The non-secret identity twin ({@link import("../checkpoint-store.js").Checkpoint.checkpointId}). */
174
+ checkpointId?: string;
173
175
  gate: import("../checkpoint-store.js").CheckpointGate;
174
176
  /** RB-439-b: how the paused workspace comes back (`"park_only"` = a non-suspendable target that was
175
177
  * never actually paused). Echoed on `TaskResult.workspaceRestoreMode`; absent for a process-local
@@ -190,6 +192,7 @@ export interface ResultFlags {
190
192
  * so it never hits the failure branches above. */
191
193
  reviewRef?: {
192
194
  token: import("../checkpoint-store.js").CheckpointToken;
195
+ checkpointId?: string;
193
196
  gate: import("../checkpoint-store.js").CheckpointGate;
194
197
  /** RB-439-b: the review pause pauses the same workspace the approval pause does — same discriminant. */
195
198
  restoreMode?: "snapshot" | "park_only";
@@ -60,6 +60,7 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
60
60
  let blockedReason;
61
61
  let salvagedOutput;
62
62
  let checkpointToken;
63
+ let checkpointId;
63
64
  let checkpointGate;
64
65
  let workspaceRestoreMode;
65
66
  const isDegenerate = final?.stopReason === "error" && isDegenerateCutMessage(final);
@@ -114,6 +115,7 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
114
115
  else if (flags.suspendRef) {
115
116
  status = "suspended";
116
117
  checkpointToken = flags.suspendRef.token;
118
+ checkpointId = flags.suspendRef.checkpointId;
117
119
  checkpointGate = flags.suspendRef.gate;
118
120
  workspaceRestoreMode = flags.suspendRef.restoreMode;
119
121
  }
@@ -121,6 +123,7 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
121
123
  status = "needs_review";
122
124
  errorCode = "review.pending";
123
125
  checkpointToken = flags.reviewRef.token;
126
+ checkpointId = flags.reviewRef.checkpointId;
124
127
  checkpointGate = flags.reviewRef.gate;
125
128
  workspaceRestoreMode = flags.reviewRef.restoreMode;
126
129
  }
@@ -162,5 +165,5 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
162
165
  void _internalCompaction;
163
166
  if (flags.unpricedSpend)
164
167
  delete publicStats.costMicroUsd;
165
- return { taskId, sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: flags.remoteEnvFailures } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), stats: publicStats };
168
+ return { taskId, sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, ...(checkpointId !== undefined ? { checkpointId } : {}), checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: flags.remoteEnvFailures } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), stats: publicStats };
166
169
  }
@@ -433,6 +433,7 @@ export interface Prepared {
433
433
  * fired this run. */
434
434
  suspendRef: {
435
435
  token?: CheckpointToken;
436
+ checkpointId?: string;
436
437
  gate?: CheckpointGate;
437
438
  scope?: string;
438
439
  restoreMode?: "snapshot" | "park_only";
@@ -453,6 +454,7 @@ export interface Prepared {
453
454
  * `status:"needs_review"`. Empty unless a review pause fired this run. */
454
455
  reviewRef: {
455
456
  token?: CheckpointToken;
457
+ checkpointId?: string;
456
458
  gate?: CheckpointGate;
457
459
  scope?: string;
458
460
  restoreMode?: "snapshot" | "park_only";
@@ -3537,9 +3537,11 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3537
3537
  return { ok: false, reason };
3538
3538
  }
3539
3539
  };
3540
- const publishCommittedSuspend = (token, gate, scope, remoteHandle) => {
3540
+ const publishCommittedSuspend = (token, gate, scope, remoteHandle, checkpointId) => {
3541
3541
  const ref = gate.kind === "needs_review" || gate.kind === "plan_review" ? reviewRef : suspendRef;
3542
3542
  ref.token = token;
3543
+ if (checkpointId !== undefined)
3544
+ ref.checkpointId = checkpointId;
3543
3545
  ref.gate = gate;
3544
3546
  if (remoteHandle !== undefined)
3545
3547
  ref.restoreMode = remoteHandle.restoreMode === "park_only" ? "park_only" : "snapshot";
@@ -3625,7 +3627,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3625
3627
  };
3626
3628
  if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)).ok)
3627
3629
  return false;
3628
- publishCommittedSuspend(token, gate, scope, remoteHandle);
3630
+ publishCommittedSuspend(token, gate, scope, remoteHandle, cp.checkpointId);
3629
3631
  try {
3630
3632
  await sessions.pin?.(sessionId);
3631
3633
  }
@@ -3711,7 +3713,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3711
3713
  };
3712
3714
  if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)).ok)
3713
3715
  return false;
3714
- publishCommittedSuspend(token, gate, scope, remoteHandle);
3716
+ publishCommittedSuspend(token, gate, scope, remoteHandle, cp.checkpointId);
3715
3717
  try {
3716
3718
  await sessions.pin?.(sessionId);
3717
3719
  }
@@ -4006,7 +4008,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4006
4008
  if (!committed.ok) {
4007
4009
  return committed.reason !== undefined ? { parkFailed: committed.reason } : undefined;
4008
4010
  }
4009
- publishCommittedSuspend(token, gate, cp.scope, remoteHandle);
4011
+ publishCommittedSuspend(token, gate, cp.scope, remoteHandle, cp.checkpointId);
4010
4012
  try {
4011
4013
  await sessions.pin?.(sessionId);
4012
4014
  }
@@ -3472,6 +3472,7 @@ export class Runner {
3472
3472
  suspendRef: prepared.suspendRef.token !== undefined && prepared.suspendRef.gate !== undefined
3473
3473
  ? {
3474
3474
  token: prepared.suspendRef.token,
3475
+ ...(prepared.suspendRef.checkpointId !== undefined ? { checkpointId: prepared.suspendRef.checkpointId } : {}),
3475
3476
  gate: prepared.suspendRef.gate,
3476
3477
  ...(prepared.suspendRef.restoreMode !== undefined ? { restoreMode: prepared.suspendRef.restoreMode } : {}),
3477
3478
  }
@@ -3479,6 +3480,7 @@ export class Runner {
3479
3480
  reviewRef: prepared.reviewRef.token !== undefined && prepared.reviewRef.gate !== undefined
3480
3481
  ? {
3481
3482
  token: prepared.reviewRef.token,
3483
+ ...(prepared.reviewRef.checkpointId !== undefined ? { checkpointId: prepared.reviewRef.checkpointId } : {}),
3482
3484
  gate: prepared.reviewRef.gate,
3483
3485
  ...(prepared.reviewRef.restoreMode !== undefined ? { restoreMode: prepared.reviewRef.restoreMode } : {}),
3484
3486
  }
@@ -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;
@@ -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
  });
@@ -9,7 +9,7 @@ import type { ReadFace } from "./read-face.js";
9
9
  * replace the text parsing with structured rows from runGrep; until then this seam is the honesty
10
10
  * boundary.
11
11
  */
12
- export declare function grepDetailFields(text: string, mode: "files_with_matches" | "content" | "count", offset?: number): Record<string, unknown>;
12
+ export declare function grepDetailFields(text: string, mode: "files_with_matches" | "content" | "count", offset?: number, structuredRows?: readonly import("./search.js").GrepRow[]): Record<string, unknown>;
13
13
  export declare function createGrepTool(env: ExecutionEnv, rootCanonical: string, additionalRoots?: readonly string[], readDeny?: ReadDenyMatcher, readFace?: ReadFace): AgentTool;
14
14
  export declare function createGlobTool(env: ExecutionEnv, rootCanonical: string, additionalRoots?: readonly string[], readDeny?: ReadDenyMatcher, readFace?: ReadFace): AgentTool;
15
15
  /** 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 } : {}) },
@@ -210,8 +210,19 @@ export type GrepDegradation = {
210
210
  };
211
211
  /** Structured grep result: the model-facing text plus the degradation facts, so the tool layer can
212
212
  * ship them on the structured frame instead of leaving them prose-only. */
213
+ /** #313 (first stage, rg legs) — one SERVED result row with its path read from ripgrep's own
214
+ * field ({@link parseRgRecords}), exactly the window `text` shows (post cap/offset). The tool
215
+ * layer prefers these over re-parsing `text` (whose `path:line:text` split mis-cuts a path that
216
+ * itself contains `:digits:`); absent = a leg that has no structured rows yet (the JS scanner —
217
+ * its rows stage is the ticket's remainder) and the text parse is the honest fallback. */
218
+ export interface GrepRow {
219
+ path?: string;
220
+ text: string;
221
+ }
213
222
  export interface GrepRunResult {
214
223
  text: string;
224
+ /** Served rows (#313): present on the ripgrep legs, absent on the JS-scanner legs. */
225
+ rows?: readonly GrepRow[];
215
226
  degraded?: GrepDegradation;
216
227
  /** design/199 件B — deny-list withholding facts (see {@link ReadDenyWithheld}); absent = nothing
217
228
  * withheld / no deny judge in play. */
@@ -1096,12 +1096,16 @@ function formatRgRecords(records, p, caveat = "") {
1096
1096
  const cap = p.head_limit === 0 ? Infinity : Math.max(1, Math.floor(p.head_limit ?? GREP_DEFAULT_CAP));
1097
1097
  const off = Math.max(0, Math.floor(p.offset ?? 0));
1098
1098
  const capped = records.slice(off, off + cap);
1099
+ const served = capped.map((r) => (r.path !== undefined ? { path: r.path, text: r.text } : { text: r.text }));
1099
1100
  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);
1101
+ return { text: NO_MATCHES + (off > 0 ? `\n[offset ${off}]` : "") + caveat, served };
1102
+ return {
1103
+ text: capped.map((r) => r.text).join("\n") +
1104
+ (records.length > off + cap ? `\n[capped at ${cap} of ${records.length}]` : "") +
1105
+ (off > 0 ? `\n[offset ${off}]` : "") +
1106
+ caveat,
1107
+ served,
1108
+ };
1105
1109
  }
1106
1110
  export function rgOutputDenyTripwire(stdout, mode, judge, opts = {}) {
1107
1111
  if (stdout.length === 0)
@@ -1143,7 +1147,7 @@ async function jsGrepFallback(env, root, p, signal, reason, deny) {
1143
1147
  }
1144
1148
  export async function rgGrepDetailed(env, root, p, signal, deny) {
1145
1149
  const mode = p.output_mode ?? "files_with_matches";
1146
- const flags = ["--null", "--no-messages", "--no-require-git", "--hidden"];
1150
+ const flags = ["--null", "-H", "--no-messages", "--no-require-git", "--hidden"];
1147
1151
  for (const d of VCS_DIRS)
1148
1152
  flags.push("--glob", `!${d}`);
1149
1153
  flags.push("--max-columns", "500", "--max-columns-preview");
@@ -1193,7 +1197,7 @@ export async function rgGrepDetailed(env, root, p, signal, deny) {
1193
1197
  if (records.length === 0)
1194
1198
  return jsGrepFallback(env, root, p, signal, "timed out before completing a result", deny);
1195
1199
  return {
1196
- text: `${delimitUntrusted("ripgrep partial output", formatRgRecords(records, p))}\n…[ripgrep timed out after producing partial output — results may be incomplete]`,
1200
+ ...(() => { 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
1201
  degraded: { partial: true, reason: "ripgrep timed out" },
1198
1202
  };
1199
1203
  }
@@ -1205,6 +1209,12 @@ export async function rgGrepDetailed(env, root, p, signal, deny) {
1205
1209
  const denyDisclosure = async () => {
1206
1210
  if (deny === undefined || deny.rgExclusionGlobs.length === 0)
1207
1211
  return { note: "" };
1212
+ if (p.path) {
1213
+ const startPath = isAbsolutePathForm(p.path) ? p.path : `${root.replace(/[\\/]+$/, "")}${root.includes("\\") ? "\\" : "/"}${p.path}`;
1214
+ const info = await env.fileInfo(startPath, signal);
1215
+ if (info.ok && info.value.kind === "file")
1216
+ return { note: "" };
1217
+ }
1208
1218
  const withheld = await rgDenyExistenceProbe(env, root, deny, target, signal);
1209
1219
  if (withheld === undefined)
1210
1220
  return { note: "" };
@@ -1215,7 +1225,7 @@ export async function rgGrepDetailed(env, root, p, signal, deny) {
1215
1225
  };
1216
1226
  if (exitCode === 1) {
1217
1227
  const d = await denyDisclosure();
1218
- return { text: NO_MATCHES + d.note, ...(d.withheld !== undefined ? { withheld: d.withheld } : {}) };
1228
+ return { text: NO_MATCHES + d.note, rows: [], ...(d.withheld !== undefined ? { withheld: d.withheld } : {}) };
1219
1229
  }
1220
1230
  if (exitCode >= 2) {
1221
1231
  if (stdout.trim().length > 0) {
@@ -1228,7 +1238,7 @@ export async function rgGrepDetailed(env, root, p, signal, deny) {
1228
1238
  if (records.length === 0)
1229
1239
  return jsGrepFallback(env, root, p, signal, `exited with code ${exitCode} and produced no complete result`, deny);
1230
1240
  return {
1231
- text: `${delimitUntrusted("ripgrep partial output", formatRgRecords(records, p))}\n…[ripgrep exited with an error after producing partial output — results may be incomplete]`,
1241
+ ...(() => { 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
1242
  degraded: { partial: true, reason: `ripgrep exited with code ${exitCode}` },
1233
1243
  };
1234
1244
  }
@@ -1242,7 +1252,8 @@ export async function rgGrepDetailed(env, root, p, signal, deny) {
1242
1252
  const parsed = parseRgRecords(stdout, mode);
1243
1253
  const ordered = mode === "files_with_matches" ? await sortRgFilesByMtime(env, root, parsed, signal) : parsed;
1244
1254
  const d = await denyDisclosure();
1245
- return { text: formatRgRecords(ordered, p) + d.note, ...(d.withheld !== undefined ? { withheld: d.withheld } : {}) };
1255
+ const formatted = formatRgRecords(ordered, p);
1256
+ return { text: formatted.text + d.note, rows: formatted.served, ...(d.withheld !== undefined ? { withheld: d.withheld } : {}) };
1246
1257
  }
1247
1258
  async function rgDenyExistenceProbe(env, root, deny, target, signal) {
1248
1259
  const probeFlags = ["--files", "--hidden", "--no-require-git", "--no-messages"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.42.0",
3
+ "version": "5.43.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",