@bermudi/pi-delegate 0.1.16 → 0.1.17

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/agents.ts CHANGED
@@ -151,7 +151,7 @@ export const BUILTIN_AGENT_CONFIGS: Readonly<Record<string, AgentConfig>> = {
151
151
  systemPrompt:
152
152
  "Review the current snapshot for correctness, regressions, security problems, and missing tests. Do not modify the source project. Run focused checks when useful. Report actionable findings ordered by severity, with concrete paths and locations. If there are no material findings, say so plainly; do not invent issues or merely summarize the implementation.",
153
153
  builtin: true,
154
- workspace: "scratch",
154
+ workspace: "shared",
155
155
  },
156
156
  };
157
157
 
package/extension.ts CHANGED
@@ -59,10 +59,14 @@ type ShutdownDrainResult =
59
59
  /** Bridge the promoted top-level session RPC (`sessionAction` + `sessionId`)
60
60
  * to the internal single-entry batch the runner executes. Internal only — the
61
61
  * public task schema has no `sessionAction`; validation guarantees close
62
- * carries a sessionId by this point. */
62
+ * carries a sessionId by this point. `list` never carries a sessionId: it does
63
+ * not target a specific session, and attaching one would make validateTasks
64
+ * run busy/quarantine checks that can fail the list call. */
63
65
  function bridgeSessionControlTask(params: DelegateArguments): DispatchableTask {
64
66
  return {
65
- ...(params.sessionId ? { sessionId: params.sessionId } : {}),
67
+ ...(params.sessionAction === "close" && params.sessionId
68
+ ? { sessionId: params.sessionId }
69
+ : {}),
66
70
  sessionAction: params.sessionAction,
67
71
  };
68
72
  }
@@ -193,6 +197,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
193
197
  mode,
194
198
  taskCount,
195
199
  parentSessionFile,
200
+ parentCwd: ctx.cwd,
196
201
  });
197
202
 
198
203
  function failCall(): void {
package/lifecycle.ts CHANGED
@@ -142,9 +142,18 @@ function scratchSetupFailureResult(
142
142
  if (signalAborted) {
143
143
  message = "Aborted";
144
144
  failureKind = "cancelled";
145
- } else if (error instanceof ScratchDeadlineError) {
146
- message = formatDeadlineExceededError(task.deadlineMs ?? 0);
147
- failureKind = "deadline_exceeded";
145
+ } else {
146
+ if (error instanceof ScratchDeadlineError) {
147
+ message = formatDeadlineExceededError(task.deadlineMs ?? 0);
148
+ failureKind = "deadline_exceeded";
149
+ }
150
+ // Every non-aborted setup failure names its remedy in one place, so the
151
+ // paths (platform guard, pre-check, reflink, worktree, deadline) cannot
152
+ // drift. The symlink message already carries remedy text naming
153
+ // workspace "shared" — skip the append there to avoid saying it twice.
154
+ if (!message.includes('workspace "shared"')) {
155
+ message = `${message} — to retry without scratch containment, use workspace: "shared".`;
156
+ }
148
157
  }
149
158
  return {
150
159
  ...failTask(task, message, undefined, failureKind),
@@ -433,9 +442,10 @@ function recordTaskOutcome(
433
442
  * that model, so same-model retry is pointless. Distinguished from a bare
434
443
  * transient 429 (per-minute rate limit) by the *account-level* wording:
435
444
  * "usage limit", "quota", "upgrade for higher limits", "exceeded your
436
- * … quota", or an auth/credential failure (401/403 with word boundaries so a
437
- * port like 4019 doesn't false-positive). The parent should resume with a
438
- * different `model` (see `resumeFrom` + `model`). */
445
+ * … quota", an auth/credential failure (401/403 with word boundaries so a
446
+ * port like 4019 doesn't false-positive), or an invalidated OAuth token
447
+ * ("invalid"/"invalidated" anywhere alongside "oauth token"). The parent
448
+ * should resume with a different `model` (see `resumeFrom` + `model`). */
439
449
  export function isModelAttributableError(error: string | undefined): boolean {
440
450
  if (!error) return false;
441
451
  const e = error.toLowerCase();
@@ -454,6 +464,7 @@ export function isModelAttributableError(error: string | undefined): boolean {
454
464
  e.includes("authentication") ||
455
465
  e.includes("invalid api key") ||
456
466
  (e.includes("api key") && e.includes("invalid")) ||
467
+ (e.includes("oauth token") && e.includes("invalid")) ||
457
468
  /\b401\b/.test(e) ||
458
469
  /\b403\b/.test(e)
459
470
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
package/schema.ts CHANGED
@@ -1,8 +1,5 @@
1
1
  import { Type, type SchemaOptions } from "@sinclair/typebox";
2
- import {
3
- VALID_THINKING_LEVELS,
4
- isSessionControlAction,
5
- } from "./constants.ts";
2
+ import { VALID_THINKING_LEVELS, isSessionControlAction } from "./constants.ts";
6
3
  import type { DelegateArguments } from "./types.ts";
7
4
 
8
5
  // JSON Schema string enum that keeps the literal union in `Static<>`.
@@ -247,13 +244,33 @@ function validateSessionMode(params: DelegateArguments): string | undefined {
247
244
  if (sessionAction === "close" && !params.sessionId) {
248
245
  return "sessionAction 'close' requires sessionId.";
249
246
  }
250
- const foreign = Object.keys(rawParams).filter(
251
- (key) => !SESSION_MODE_FIELDS.has(key),
252
- );
247
+ // Skip only fields carrying their documented default/no-op value: a caller
248
+ // (or a schema validator that materialises defaults) may spell out
249
+ // `async: false`, `force: false`, or `tasks: []` explicitly. Other false or
250
+ // empty-array values remain foreign so future fields cannot bypass this mode
251
+ // boundary merely by sharing the same value shape.
252
+ const foreign = Object.keys(rawParams).filter((key) => {
253
+ if (SESSION_MODE_FIELDS.has(key) || rawParams[key] === undefined) {
254
+ return false;
255
+ }
256
+ if ((key === "async" || key === "force") && rawParams[key] === false) {
257
+ return false;
258
+ }
259
+ if (
260
+ key === "tasks" &&
261
+ Array.isArray(rawParams[key]) &&
262
+ rawParams[key].length === 0
263
+ ) {
264
+ return false;
265
+ }
266
+ return true;
267
+ });
253
268
  if (foreign.length) {
254
269
  return `sessionAction '${sessionAction}' cannot be combined with ${foreign
255
270
  .map((field) => `'${field}'`)
256
- .join(", ")}; run it alone — a session action takes only 'sessionAction' (plus 'sessionId' for 'close').`;
271
+ .join(
272
+ ", ",
273
+ )}; run it alone — a session action takes only 'sessionAction' (plus 'sessionId' for 'close').`;
257
274
  }
258
275
  return undefined;
259
276
  }
package/telemetry.ts CHANGED
@@ -7,7 +7,12 @@ import * as crypto from "node:crypto";
7
7
  import * as piCodingAgent from "@earendil-works/pi-coding-agent";
8
8
  import type { DatabaseSync, StatementSync } from "node:sqlite";
9
9
  import { getTelemetryConfig } from "./config.ts";
10
- import type { ResolvedTask, TaskProgress, TaskResult } from "./types.ts";
10
+ import type {
11
+ ResolvedTask,
12
+ TaskProgress,
13
+ TaskResult,
14
+ WorkspaceMode,
15
+ } from "./types.ts";
11
16
 
12
17
  export interface CallRecord {
13
18
  id: string;
@@ -22,6 +27,7 @@ export interface CallRecord {
22
27
  total_tokens: number;
23
28
  total_cost: number;
24
29
  parent_session_file: string | undefined;
30
+ parent_cwd: string | undefined;
25
31
  }
26
32
 
27
33
  export interface TaskRecord {
@@ -35,6 +41,7 @@ export interface TaskRecord {
35
41
  model: string | undefined;
36
42
  thinking: string | undefined;
37
43
  tools: string;
44
+ workspace: WorkspaceMode | undefined;
38
45
  outcome: string;
39
46
  failure_kind: string | undefined;
40
47
  duration_ms: number;
@@ -46,6 +53,7 @@ export interface TaskRecord {
46
53
  output_chars: number;
47
54
  session_file: string | undefined;
48
55
  async: number;
56
+ error_snippet: string | undefined;
49
57
  }
50
58
 
51
59
  export interface TelemetryRecorder {
@@ -87,13 +95,32 @@ let telemetryGeneration = 0;
87
95
  let telemetryClosed = false;
88
96
  let testingRecorder: TelemetryRecorder | undefined;
89
97
 
90
- const TELEMETRY_SCHEMA_VERSION = 1;
98
+ const TELEMETRY_SCHEMA_VERSION = 3;
91
99
  const SQLITE_BUSY_TIMEOUT_MS = 5_000;
92
100
 
93
101
  function defaultDbPath(): string {
94
102
  return path.join(os.homedir(), ".pi", "agent", "delegate-usage.db");
95
103
  }
96
104
 
105
+ /**
106
+ * Telemetry database path resolution: explicit config wins, then the
107
+ * DELEGATE_TELEMETRY_DB environment variable, then the default user path.
108
+ * The env var exists so test runs can redirect the default destination away
109
+ * from the production database without touching user config — the pi test
110
+ * harness builds real sessions in-process, so process.env is the extension's
111
+ * environment (see test-preload.ts). Config beats env on purpose: telemetry
112
+ * tests drive backends through explicit config dbPath values, including
113
+ * spawned Node children that inherit this variable.
114
+ */
115
+ function resolveTelemetryDbPath(
116
+ config: import("./config.ts").TelemetryConfig,
117
+ ): string {
118
+ if (config.dbPath) return config.dbPath;
119
+ const fromEnv = process.env.DELEGATE_TELEMETRY_DB;
120
+ if (fromEnv) return fromEnv;
121
+ return defaultDbPath();
122
+ }
123
+
97
124
  function findPackageJson(startFile: string): string | undefined {
98
125
  const candidates = [
99
126
  path.join(path.dirname(startFile), "package.json"),
@@ -175,6 +202,7 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
175
202
  ["total_tokens", "INTEGER"],
176
203
  ["total_cost", "REAL"],
177
204
  ["parent_session_file", "TEXT"],
205
+ ["parent_cwd", "TEXT"],
178
206
  ],
179
207
  createSql: `
180
208
  CREATE TABLE IF NOT EXISTS calls(
@@ -189,7 +217,8 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
189
217
  status TEXT,
190
218
  total_tokens INTEGER,
191
219
  total_cost REAL,
192
- parent_session_file TEXT
220
+ parent_session_file TEXT,
221
+ parent_cwd TEXT
193
222
  );
194
223
  `,
195
224
  },
@@ -206,6 +235,7 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
206
235
  ["model", "TEXT"],
207
236
  ["thinking", "TEXT"],
208
237
  ["tools", "TEXT"],
238
+ ["workspace", "TEXT"],
209
239
  ["outcome", "TEXT"],
210
240
  ["failure_kind", "TEXT"],
211
241
  ["duration_ms", "INTEGER"],
@@ -217,6 +247,7 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
217
247
  ["output_chars", "INTEGER"],
218
248
  ["session_file", "TEXT"],
219
249
  ["async", "INTEGER"],
250
+ ["error_snippet", "TEXT"],
220
251
  ],
221
252
  createSql: `
222
253
  CREATE TABLE IF NOT EXISTS tasks(
@@ -230,6 +261,7 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
230
261
  model TEXT,
231
262
  thinking TEXT,
232
263
  tools TEXT,
264
+ workspace TEXT,
233
265
  outcome TEXT,
234
266
  failure_kind TEXT,
235
267
  duration_ms INTEGER,
@@ -240,7 +272,8 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
240
272
  prompt_chars INTEGER,
241
273
  output_chars INTEGER,
242
274
  session_file TEXT,
243
- async INTEGER
275
+ async INTEGER,
276
+ error_snippet TEXT
244
277
  );
245
278
  `,
246
279
  },
@@ -312,6 +345,26 @@ function initSchema(db: DatabaseSync): void {
312
345
  }
313
346
  }
314
347
 
348
+ // Backfill legacy rows that predate the workspace column. New rows store
349
+ // 'shared' explicitly, but ALTER TABLE leaves existing rows as NULL. Without
350
+ // this, GROUP BY workspace splits NULL vs 'shared' for the same semantics.
351
+ // Reviewer defaulted to scratch when these rows were recorded, so preserve
352
+ // that heuristic for historical rows; everything else was shared by
353
+ // default. This is idempotent and runs inside the same transaction as the schema changes so a crash before COMMIT
354
+ // retries cleanly.
355
+ try {
356
+ db.exec(
357
+ "UPDATE tasks SET workspace='scratch' WHERE workspace IS NULL AND agent='reviewer'",
358
+ );
359
+ db.exec("UPDATE tasks SET workspace='shared' WHERE workspace IS NULL");
360
+ } catch {
361
+ // tasks may not exist on first run (fresh DB) or workspace column may
362
+ // have just been created via CREATE TABLE — UPDATE affecting 0 rows is fine.
363
+ // Any real error will surface on the next write and disable telemetry
364
+ // via the existing fail-open path, so swallowing here preserves the
365
+ // repair-loop's best-effort nature.
366
+ }
367
+
315
368
  // Set the marker only after every table/column operation succeeded.
316
369
  db.exec(`PRAGMA user_version = ${TELEMETRY_SCHEMA_VERSION}`);
317
370
  db.exec("COMMIT");
@@ -346,15 +399,16 @@ class SqliteTelemetryBackend implements TelemetryBackend {
346
399
  this.insertCall = db.prepare(
347
400
  `INSERT OR REPLACE INTO calls(
348
401
  id, ts, version, pi_version, mode, parent_model, task_count,
349
- wall_ms, status, total_tokens, total_cost, parent_session_file
350
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
402
+ wall_ms, status, total_tokens, total_cost, parent_session_file,
403
+ parent_cwd
404
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
351
405
  );
352
406
  this.insertTask = db.prepare(
353
407
  `INSERT OR REPLACE INTO tasks(
354
408
  id, call_id, ts, version, pi_version, idx, agent, model, thinking,
355
- tools, outcome, failure_kind, duration_ms, tokens, cost, tool_uses,
356
- retries, prompt_chars, output_chars, session_file, async
357
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
409
+ tools, workspace, outcome, failure_kind, duration_ms, tokens, cost, tool_uses,
410
+ retries, prompt_chars, output_chars, session_file, async, error_snippet
411
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
358
412
  );
359
413
  }
360
414
 
@@ -373,6 +427,7 @@ class SqliteTelemetryBackend implements TelemetryBackend {
373
427
  record.total_tokens,
374
428
  record.total_cost,
375
429
  record.parent_session_file ?? null,
430
+ record.parent_cwd ?? null,
376
431
  );
377
432
  } catch (error) {
378
433
  this.onFailure("recordCall", error);
@@ -392,6 +447,7 @@ class SqliteTelemetryBackend implements TelemetryBackend {
392
447
  record.model ?? null,
393
448
  record.thinking ?? null,
394
449
  record.tools,
450
+ record.workspace ?? null,
395
451
  record.outcome,
396
452
  record.failure_kind ?? null,
397
453
  record.duration_ms,
@@ -403,6 +459,7 @@ class SqliteTelemetryBackend implements TelemetryBackend {
403
459
  record.output_chars,
404
460
  record.session_file ?? null,
405
461
  record.async,
462
+ record.error_snippet ?? null,
406
463
  );
407
464
  } catch (error) {
408
465
  this.onFailure("recordTask", error);
@@ -453,7 +510,7 @@ function backendIdentity(
453
510
  config: import("./config.ts").TelemetryConfig,
454
511
  ): string | undefined {
455
512
  if (config.enabled === false) return undefined;
456
- return config.dbPath ?? defaultDbPath();
513
+ return resolveTelemetryDbPath(config);
457
514
  }
458
515
 
459
516
  function disableActiveBackend(
@@ -495,7 +552,7 @@ function openSqliteBackend(
495
552
  if (config.enabled === false || telemetryClosed) return undefined;
496
553
  if (!DatabaseSyncCtor) return undefined;
497
554
 
498
- const dbPath = config.dbPath ?? defaultDbPath();
555
+ const dbPath = resolveTelemetryDbPath(config);
499
556
  let db: DatabaseSync | undefined;
500
557
  try {
501
558
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
@@ -587,6 +644,8 @@ export interface CallSpanInput {
587
644
  mode: string;
588
645
  taskCount: number;
589
646
  parentSessionFile?: string;
647
+ /** Parent working directory at dispatch — one dispatch, one cwd. */
648
+ parentCwd?: string;
590
649
  }
591
650
 
592
651
  export interface CallSpanFinish {
@@ -637,6 +696,7 @@ class CallSpanImpl implements CallSpan {
637
696
  total_tokens: 0,
638
697
  total_cost: 0,
639
698
  parent_session_file: this.input.parentSessionFile,
699
+ parent_cwd: this.input.parentCwd,
640
700
  };
641
701
  }
642
702
 
@@ -700,6 +760,19 @@ function outcomeFromResult(result: TaskResult): string {
700
760
  return "success";
701
761
  }
702
762
 
763
+ /** Error text only — never prompt or output content — whitespace-collapsed
764
+ * and capped, so failure classification becomes a query instead of
765
+ * duration-based guessing. */
766
+ const ERROR_SNIPPET_MAX_CHARS = 200;
767
+
768
+ function errorSnippetOf(result: TaskResult): string | undefined {
769
+ if (!result.error) return undefined;
770
+ const collapsed = result.error.replace(/\s+/g, " ").trim();
771
+ if (collapsed.length === 0) return undefined;
772
+ if (collapsed.length <= ERROR_SNIPPET_MAX_CHARS) return collapsed;
773
+ return `${collapsed.slice(0, ERROR_SNIPPET_MAX_CHARS - 1)}…`;
774
+ }
775
+
703
776
  export function recordTask(input: TaskSpanInput): string | undefined {
704
777
  const b = input.telemetryConfig
705
778
  ? getBackendForConfig(
@@ -723,6 +796,7 @@ export function recordTask(input: TaskSpanInput): string | undefined {
723
796
  model: task.model?.id,
724
797
  thinking: task.thinking,
725
798
  tools: JSON.stringify(task.tools),
799
+ workspace: task.workspace ?? "shared",
726
800
  outcome: outcomeFromResult(result),
727
801
  failure_kind: result.failureKind,
728
802
  duration_ms: result.durationMs,
@@ -734,6 +808,7 @@ export function recordTask(input: TaskSpanInput): string | undefined {
734
808
  output_chars: result.output?.length ?? 0,
735
809
  session_file: result.sessionFile,
736
810
  async: async ? 1 : 0,
811
+ error_snippet: errorSnippetOf(result),
737
812
  };
738
813
  b.recordTask(record);
739
814
  return record.id;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Test-run telemetry isolation.
3
+ *
4
+ * The pi test harness builds real sessions in-process, so this process's
5
+ * environment IS the extension's environment. telemetry.ts resolves the
6
+ * database path as config.dbPath > DELEGATE_TELEMETRY_DB > default; setting
7
+ * the env var here redirects every test that runs on the default path to a
8
+ * throwaway directory instead of the production ~/.pi/agent/delegate-usage.db.
9
+ * Tests that set an explicit config dbPath are unaffected (config wins).
10
+ *
11
+ * Loaded once per `bun test` invocation via bunfig.toml [test] preload.
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+
17
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "delegate-telemetry-test-"));
18
+ process.env.DELEGATE_TELEMETRY_DB = path.join(dir, "usage.db");
19
+
20
+ process.on("exit", () => {
21
+ try {
22
+ fs.rmSync(dir, { recursive: true, force: true });
23
+ } catch {
24
+ // Best-effort cleanup; a leftover temp dir is harmless.
25
+ }
26
+ });
package/workspace.ts CHANGED
@@ -57,6 +57,7 @@ class CommandError extends Error {
57
57
  constructor(
58
58
  message: string,
59
59
  readonly stderr: string,
60
+ readonly file: string,
60
61
  options: ErrorOptions,
61
62
  ) {
62
63
  super(message, options);
@@ -85,6 +86,7 @@ function runFile(
85
86
  new CommandError(
86
87
  detail ? `${file}: ${detail}` : `${file}: ${error.message}`,
87
88
  detail,
89
+ file,
88
90
  { cause: error },
89
91
  ),
90
92
  );
@@ -193,10 +195,16 @@ async function replaceSymlink(linkPath: string, target: string): Promise<void> {
193
195
  }
194
196
  }
195
197
 
196
- /** Validate the completed copy before any subagent receives its path. */
197
- async function validateCopiedTree(
198
+ /** Validate a scratch candidate tree the source before copying (read-only
199
+ * fast-fail) or the completed copy before any subagent receives its path
200
+ * (authority, with the symlink-retarget side effect). One rule, two timings:
201
+ * the blockers are identical, so the pre-check cannot drift from the copy
202
+ * check, and the copy check remains authoritative for trees that change
203
+ * mid-copy. */
204
+ async function validateScratchTree(
198
205
  root: string,
199
206
  sourceRoot: string,
207
+ retargetLinks: boolean,
200
208
  signal: AbortSignal,
201
209
  parentSignal: AbortSignal | undefined,
202
210
  ): Promise<void> {
@@ -234,7 +242,8 @@ async function validateCopiedTree(
234
242
  // `cp --archive` copies link text verbatim, so an absolute in-project
235
243
  // link (bun/pnpm-style local package installs) still resolves to the
236
244
  // real tree from inside the copy. Retarget it at its copied counterpart
237
- // instead of failing: the link keeps working and stays disposable.
245
+ // instead of failing: the link keeps working and stays disposable. The
246
+ // pre-check runs the same rule read-only against the source.
238
247
  const relinked = relinkTargetIntoCopy(
239
248
  candidate,
240
249
  target,
@@ -242,6 +251,7 @@ async function validateCopiedTree(
242
251
  sourceRoot,
243
252
  );
244
253
  if (relinked !== undefined) {
254
+ if (!retargetLinks) continue;
245
255
  try {
246
256
  await replaceSymlink(candidate, relinked);
247
257
  } catch (error) {
@@ -262,6 +272,58 @@ async function validateCopiedTree(
262
272
  }
263
273
  }
264
274
 
275
+ /** Reject Git metadata that redirects the worktree or git dirs outside `root`
276
+ * (core.worktree, commondir pointers, gitdir redirects). Runs against the
277
+ * source before copying and against the copy afterwards — a source that is
278
+ * self-consistent can still copy into a copy that points back at the source. */
279
+ async function assertGitMetadataContained(
280
+ root: string,
281
+ signal: AbortSignal,
282
+ ): Promise<void> {
283
+ const hasGitDir = await fs.promises
284
+ .stat(path.join(root, ".git"))
285
+ .then((stat) => stat.isDirectory(), () => false);
286
+ if (!hasGitDir) return;
287
+ const effectiveWorktree = path.resolve(
288
+ (
289
+ await runFile("git", ["rev-parse", "--show-toplevel"], {
290
+ cwd: root,
291
+ signal,
292
+ timeout: 5000,
293
+ })
294
+ ).trim(),
295
+ );
296
+ const effectiveGitDir = path.resolve(
297
+ root,
298
+ (
299
+ await runFile("git", ["rev-parse", "--absolute-git-dir"], {
300
+ cwd: root,
301
+ signal,
302
+ timeout: 5000,
303
+ })
304
+ ).trim(),
305
+ );
306
+ const effectiveCommonDir = path.resolve(
307
+ effectiveGitDir,
308
+ (
309
+ await runFile("git", ["rev-parse", "--git-common-dir"], {
310
+ cwd: root,
311
+ signal,
312
+ timeout: 5000,
313
+ })
314
+ ).trim(),
315
+ );
316
+ if (
317
+ effectiveWorktree !== root ||
318
+ !isWithin(root, effectiveGitDir) ||
319
+ !isWithin(root, effectiveCommonDir)
320
+ ) {
321
+ throw new ScratchSetupError(
322
+ "Scratch workspace Git configuration redirects its worktree or metadata outside the copied project.",
323
+ );
324
+ }
325
+ }
326
+
265
327
  function isWithin(root: string, candidate: string): boolean {
266
328
  const relative = path.relative(root, candidate);
267
329
  return (
@@ -739,6 +801,20 @@ export async function createScratchWorkspace(
739
801
  );
740
802
  }
741
803
 
804
+ // Fast-fail on blockers that are knowable before anything is created:
805
+ // the same rule the copy validation enforces, run read-only against the
806
+ // source. A rejection here costs milliseconds instead of a doomed
807
+ // reflink copy, and no lease/container is left behind. The post-copy
808
+ // validation below stays the authority — the tree can change mid-copy.
809
+ await validateScratchTree(
810
+ sourceRoot,
811
+ sourceRoot,
812
+ false,
813
+ controller.signal,
814
+ signal,
815
+ );
816
+ await assertGitMetadataContained(sourceRoot, controller.signal);
817
+
742
818
  containerDir = path.join(path.dirname(sourceRoot), SCRATCH_CONTAINER_NAME);
743
819
  const uid = process.getuid?.();
744
820
  await ensureScratchContainer(containerDir, uid);
@@ -777,57 +853,14 @@ export async function createScratchWorkspace(
777
853
  // GNU cp --archive applies the source root's mode to the destination.
778
854
  // Restore the private boundary after it has finished copying metadata.
779
855
  await fs.promises.chmod(scratchRoot, 0o700);
780
- await validateCopiedTree(
856
+ await validateScratchTree(
781
857
  scratchRoot,
782
858
  sourceRoot,
859
+ true,
783
860
  controller.signal,
784
861
  signal,
785
862
  );
786
- if (
787
- await fs.promises.stat(path.join(scratchRoot, ".git")).then(
788
- (stat) => stat.isDirectory(),
789
- () => false,
790
- )
791
- ) {
792
- const effectiveWorktree = path.resolve(
793
- (
794
- await runFile("git", ["rev-parse", "--show-toplevel"], {
795
- cwd: scratchRoot,
796
- signal: controller.signal,
797
- timeout: 5000,
798
- })
799
- ).trim(),
800
- );
801
- const effectiveGitDir = path.resolve(
802
- scratchRoot,
803
- (
804
- await runFile("git", ["rev-parse", "--absolute-git-dir"], {
805
- cwd: scratchRoot,
806
- signal: controller.signal,
807
- timeout: 5000,
808
- })
809
- ).trim(),
810
- );
811
- const effectiveCommonDir = path.resolve(
812
- effectiveGitDir,
813
- (
814
- await runFile("git", ["rev-parse", "--git-common-dir"], {
815
- cwd: scratchRoot,
816
- signal: controller.signal,
817
- timeout: 5000,
818
- })
819
- ).trim(),
820
- );
821
- if (
822
- effectiveWorktree !== scratchRoot ||
823
- !isWithin(scratchRoot, effectiveGitDir) ||
824
- !isWithin(scratchRoot, effectiveCommonDir)
825
- ) {
826
- throw new ScratchSetupError(
827
- "Scratch workspace Git configuration redirects its worktree or metadata outside the copied project.",
828
- );
829
- }
830
- }
863
+ await assertGitMetadataContained(scratchRoot, controller.signal);
831
864
  throwIfSetupCancelled(controller.signal, signal);
832
865
  // Keep the copied project writable, but make its private parent immutable
833
866
  // to ordinary task commands. `mv "$PWD" …` then cannot unlink the project
@@ -865,8 +898,17 @@ export async function createScratchWorkspace(
865
898
  );
866
899
  }
867
900
  if (error instanceof ScratchSetupError) throw error;
901
+ // Surface the failed command's own stderr: the generic message's
902
+ // reflink guidance is a guess, and the real cp failure (or a btrfs
903
+ // project failing for an unrelated reason) is only visible in stderr.
904
+ let commandDetail = "";
905
+ if (error instanceof CommandError) {
906
+ const line = error.stderr.split("\n", 1)[0]?.trim();
907
+ if (line) commandDetail = ` ${error.file} failed: ${line}`;
908
+ }
868
909
  throw new Error(
869
- "Could not create a CoW scratch workspace. The project and scratch directory must be on a reflink-capable filesystem (for example Btrfs).",
910
+ "Could not create a CoW scratch workspace. The project and scratch directory must be on a reflink-capable filesystem (for example Btrfs)." +
911
+ commandDetail,
870
912
  { cause: error },
871
913
  );
872
914
  } finally {