@bermudi/pi-delegate 0.1.15 → 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/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/ticket-format.ts CHANGED
@@ -274,9 +274,13 @@ export function formatCancelPreview(ticket: AsyncTicket): string {
274
274
 
275
275
  for (const p of ticket.progress) {
276
276
  if (p.status === "done") {
277
- lines.push(`✓ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · completed`);
277
+ lines.push(
278
+ `✓ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · completed`,
279
+ );
278
280
  } else if (p.status === "failed") {
279
- lines.push(`✗ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · ${p.error ?? "failed"}`);
281
+ lines.push(
282
+ `✗ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · ${p.error ?? "failed"}`,
283
+ );
280
284
  } else if (p.status === "running") {
281
285
  lines.push(formatInFlightTaskLine(p));
282
286
  } else {
package/types.ts CHANGED
@@ -53,13 +53,22 @@ type CanonicalTaskDef = NonNullable<
53
53
  export type TicketAction = NonNullable<
54
54
  CanonicalDelegateArguments["ticketAction"]
55
55
  >;
56
- /** Per-task session action: "prompt" | "close" | "list". */
57
- export type SessionAction = NonNullable<CanonicalTaskDef["sessionAction"]>;
56
+ /** Top-level session RPC action: "close" | "list" (#32 promotion). */
57
+ export type SessionAction = NonNullable<
58
+ CanonicalDelegateArguments["sessionAction"]
59
+ >;
58
60
  /** Filesystem mode: shared source tree or an ephemeral CoW scratch copy. */
59
61
  export type WorkspaceMode = NonNullable<CanonicalTaskDef["workspace"]>;
60
62
 
61
63
  export type TaskDef = CanonicalTaskDef;
62
64
 
65
+ /** Pipeline-wide task type: caller-provided TaskDefs plus the internal
66
+ * session-RPC bridge task built by extension.ts from the promoted top-level
67
+ * `sessionAction`/`sessionId` fields. `sessionAction` left the public task
68
+ * schema in #32 (it became top-level); the runner still executes the bridged
69
+ * operation through ResolvedTask, so the dispatch pipeline accepts both. */
70
+ export type DispatchableTask = TaskDef & { sessionAction?: SessionAction };
71
+
63
72
  export type DelegateArguments = CanonicalDelegateArguments;
64
73
 
65
74
  // ── Async Ticket Types ─────────────────────────────────────────────────────
@@ -169,6 +178,14 @@ export interface ResolvedTask {
169
178
  providerExtensionSources?: string;
170
179
  }
171
180
 
181
+ /** Result of `resolveTasks`: either every task resolved, or a batch-wide
182
+ * rejection (e.g. an unknown tool name) with no tasks to dispatch. The
183
+ * optional-`error` discriminant keeps `.tasks` accessible on the whole union
184
+ * so success-path callers can narrow with a single `error !== undefined` check. */
185
+ export type ResolveTasksResult =
186
+ | { tasks: ResolvedTask[]; error?: undefined }
187
+ | { tasks?: undefined; error: string };
188
+
172
189
  export interface FileAttributionPathSignature {
173
190
  /** Absolute component inspected while resolving the pre-execution target. */
174
191
  path: string;
@@ -251,8 +268,9 @@ export interface TaskProgress {
251
268
  model?: string;
252
269
  lastActivityAt?: number;
253
270
  activities: ToolActivity[];
254
- /** Human-facing notices (e.g. unknown tools ignored). Surfaced in the TUI
255
- * under the task; the LLM gets the same text in `content` already. */
271
+ /** Human-facing notices (e.g. scratch/isolated workspace notices, an
272
+ * ignored model `:level` suffix). Surfaced in the TUI under the task; the
273
+ * LLM gets the same text in `content` already. */
256
274
  warnings?: string[];
257
275
  }
258
276
 
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
  );
@@ -142,9 +144,67 @@ function throwIfSetupCancelled(
142
144
  );
143
145
  }
144
146
 
145
- /** Validate the completed copy before any subagent receives its path. */
146
- async function validateCopiedTree(
147
+ /** Project an absolute source-tree symlink target onto its counterpart inside
148
+ * the copy, as a relative target so the copy stays self-contained. Returns
149
+ * undefined when the target is not lexically inside the source root, which is
150
+ * the only case that can be rewritten without following it. */
151
+ function relinkTargetIntoCopy(
152
+ linkPath: string,
153
+ target: string,
147
154
  root: string,
155
+ sourceRoot: string,
156
+ ): string | undefined {
157
+ if (!path.isAbsolute(target) || !isWithin(sourceRoot, target)) {
158
+ return undefined;
159
+ }
160
+ const projected = path.join(root, path.relative(sourceRoot, target));
161
+ if (!isWithin(root, projected)) return undefined;
162
+ const relative = path.relative(path.dirname(linkPath), projected);
163
+ return relative === "" ? "." : relative;
164
+ }
165
+
166
+ /** Restore owner traversal/write bits so a partial copy can be removed.
167
+ * `cp --archive` reproduces read-only source directories, and their copies
168
+ * would otherwise leak a lease directory behind a failed setup. */
169
+ async function makeTreeRemovable(root: string): Promise<void> {
170
+ let entries: fs.Dirent[];
171
+ try {
172
+ await fs.promises.chmod(root, 0o700);
173
+ entries = await fs.promises.readdir(root, { withFileTypes: true });
174
+ } catch {
175
+ return;
176
+ }
177
+ for (const entry of entries) {
178
+ if (entry.isDirectory()) {
179
+ await makeTreeRemovable(path.join(root, entry.name));
180
+ }
181
+ }
182
+ }
183
+
184
+ async function replaceSymlink(linkPath: string, target: string): Promise<void> {
185
+ const staging = path.join(
186
+ path.dirname(linkPath),
187
+ `.pi-delegate-relink-${process.pid.toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
188
+ );
189
+ await fs.promises.symlink(target, staging);
190
+ try {
191
+ await fs.promises.rename(staging, linkPath);
192
+ } catch (error) {
193
+ await fs.promises.rm(staging, { force: true });
194
+ throw error;
195
+ }
196
+ }
197
+
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(
205
+ root: string,
206
+ sourceRoot: string,
207
+ retargetLinks: boolean,
148
208
  signal: AbortSignal,
149
209
  parentSignal: AbortSignal | undefined,
150
210
  ): Promise<void> {
@@ -179,16 +239,91 @@ async function validateCopiedTree(
179
239
  }
180
240
  if (!entry.isSymbolicLink()) continue;
181
241
  const target = await fs.promises.readlink(candidate);
242
+ // `cp --archive` copies link text verbatim, so an absolute in-project
243
+ // link (bun/pnpm-style local package installs) still resolves to the
244
+ // real tree from inside the copy. Retarget it at its copied counterpart
245
+ // instead of failing: the link keeps working and stays disposable. The
246
+ // pre-check runs the same rule read-only against the source.
247
+ const relinked = relinkTargetIntoCopy(
248
+ candidate,
249
+ target,
250
+ root,
251
+ sourceRoot,
252
+ );
253
+ if (relinked !== undefined) {
254
+ if (!retargetLinks) continue;
255
+ try {
256
+ await replaceSymlink(candidate, relinked);
257
+ } catch (error) {
258
+ throw new ScratchSetupError(
259
+ `Scratch workspace could not retarget in-project symlink '${path.relative(root, candidate)}' at its copied counterpart.`,
260
+ { cause: error },
261
+ );
262
+ }
263
+ continue;
264
+ }
182
265
  const resolvedTarget = path.resolve(path.dirname(candidate), target);
183
266
  if (path.isAbsolute(target) || !isWithin(root, resolvedTarget)) {
184
267
  throw new ScratchSetupError(
185
- `Scratch workspace cannot safely copy symlink '${path.relative(root, candidate)}' because it points outside the project.`,
268
+ `Scratch workspace cannot safely copy symlink '${path.relative(root, candidate)}' -> '${target}': it resolves outside the disposable copy, so relative writes through it would reach the host. Remove the link, or run this task with workspace "shared" (read-only tools) or "isolated".`,
186
269
  );
187
270
  }
188
271
  }
189
272
  }
190
273
  }
191
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
+
192
327
  function isWithin(root: string, candidate: string): boolean {
193
328
  const relative = path.relative(root, candidate);
194
329
  return (
@@ -666,6 +801,20 @@ export async function createScratchWorkspace(
666
801
  );
667
802
  }
668
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
+
669
818
  containerDir = path.join(path.dirname(sourceRoot), SCRATCH_CONTAINER_NAME);
670
819
  const uid = process.getuid?.();
671
820
  await ensureScratchContainer(containerDir, uid);
@@ -704,52 +853,14 @@ export async function createScratchWorkspace(
704
853
  // GNU cp --archive applies the source root's mode to the destination.
705
854
  // Restore the private boundary after it has finished copying metadata.
706
855
  await fs.promises.chmod(scratchRoot, 0o700);
707
- await validateCopiedTree(scratchRoot, controller.signal, signal);
708
- if (
709
- await fs.promises.stat(path.join(scratchRoot, ".git")).then(
710
- (stat) => stat.isDirectory(),
711
- () => false,
712
- )
713
- ) {
714
- const effectiveWorktree = path.resolve(
715
- (
716
- await runFile("git", ["rev-parse", "--show-toplevel"], {
717
- cwd: scratchRoot,
718
- signal: controller.signal,
719
- timeout: 5000,
720
- })
721
- ).trim(),
722
- );
723
- const effectiveGitDir = path.resolve(
724
- scratchRoot,
725
- (
726
- await runFile("git", ["rev-parse", "--absolute-git-dir"], {
727
- cwd: scratchRoot,
728
- signal: controller.signal,
729
- timeout: 5000,
730
- })
731
- ).trim(),
732
- );
733
- const effectiveCommonDir = path.resolve(
734
- effectiveGitDir,
735
- (
736
- await runFile("git", ["rev-parse", "--git-common-dir"], {
737
- cwd: scratchRoot,
738
- signal: controller.signal,
739
- timeout: 5000,
740
- })
741
- ).trim(),
742
- );
743
- if (
744
- effectiveWorktree !== scratchRoot ||
745
- !isWithin(scratchRoot, effectiveGitDir) ||
746
- !isWithin(scratchRoot, effectiveCommonDir)
747
- ) {
748
- throw new ScratchSetupError(
749
- "Scratch workspace Git configuration redirects its worktree or metadata outside the copied project.",
750
- );
751
- }
752
- }
856
+ await validateScratchTree(
857
+ scratchRoot,
858
+ sourceRoot,
859
+ true,
860
+ controller.signal,
861
+ signal,
862
+ );
863
+ await assertGitMetadataContained(scratchRoot, controller.signal);
753
864
  throwIfSetupCancelled(controller.signal, signal);
754
865
  // Keep the copied project writable, but make its private parent immutable
755
866
  // to ordinary task commands. `mv "$PWD" …` then cannot unlink the project
@@ -766,7 +877,7 @@ export async function createScratchWorkspace(
766
877
  } catch (error) {
767
878
  if (leaseRoot) {
768
879
  try {
769
- await fs.promises.chmod(leaseRoot, 0o700);
880
+ await makeTreeRemovable(leaseRoot);
770
881
  await fs.promises.rm(leaseRoot, { recursive: true, force: true });
771
882
  } catch (cleanupError) {
772
883
  console.error(
@@ -787,8 +898,17 @@ export async function createScratchWorkspace(
787
898
  );
788
899
  }
789
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
+ }
790
909
  throw new Error(
791
- "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,
792
912
  { cause: error },
793
913
  );
794
914
  } finally {
@@ -913,13 +1033,18 @@ export async function createScratchWorkspace(
913
1033
  readStableLink(source, sourceStat),
914
1034
  ]);
915
1035
  // A readlink or identity race is not evidence that the nodes matched.
916
- // Keep the lexical source path rather than dropping it.
917
- if (
918
- scratchTarget !== undefined &&
919
- sourceTarget !== undefined &&
920
- scratchTarget === sourceTarget
921
- ) {
922
- return undefined;
1036
+ // Keep the lexical source path rather than dropping it. An in-project
1037
+ // absolute source link was deliberately retargeted at setup, so its
1038
+ // copied form matches that projection rather than the source text.
1039
+ if (scratchTarget !== undefined && sourceTarget !== undefined) {
1040
+ const expected =
1041
+ relinkTargetIntoCopy(
1042
+ lexical,
1043
+ sourceTarget,
1044
+ completedRoot,
1045
+ sourceRoot!,
1046
+ ) ?? sourceTarget;
1047
+ if (scratchTarget === expected) return undefined;
923
1048
  }
924
1049
  }
925
1050
  // This is evidence about the lexical node, not its current target.