@bermudi/pi-delegate 0.1.15 → 0.1.16

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
@@ -120,7 +120,7 @@ export const BUILTIN_AGENT_CONFIGS: Readonly<Record<string, AgentConfig>> = {
120
120
  [DEFAULT_AGENT_NAME]: {
121
121
  name: DEFAULT_AGENT_NAME,
122
122
  description:
123
- "Mirror the live parent model, thinking level, native tools, and base prompt.",
123
+ "General-purpose subagent mirroring the live parent model, thinking level, native tools, and base prompt. Prefer it for general work; pick a specialist only when its role, tool limits, or scratch workspace genuinely fit.",
124
124
  tools: DEFAULT_TOOLS,
125
125
  systemPrompt: "",
126
126
  builtin: true,
package/constants.ts CHANGED
@@ -59,3 +59,13 @@ export const VALID_THINKING_LEVELS = [
59
59
  * `Set<VALID_THINKING_LEVELS[number]>`) so the `.has(string)` call sites in
60
60
  * task-resolution.ts and agents.ts stay ergonomic under strict typing. */
61
61
  export const VALID_THINKING: Set<string> = new Set(VALID_THINKING_LEVELS);
62
+
63
+ /** Session-control actions: RPC against the session pool, not work. Callers
64
+ * spell them at the TOP level (`sessionAction`, one action per call — #32).
65
+ * This predicate remains the one shared spelling of the control set for the
66
+ * session-mode intent check and validator in schema.ts and the pipeline
67
+ * guards over internally bridged tasks (task-resolution.ts). Per-action
68
+ * behavior lives at the branch sites (e.g. lifecycle applySessionAction). */
69
+ export function isSessionControlAction(action: unknown): boolean {
70
+ return action === "close" || action === "list";
71
+ }
package/delegate.ts CHANGED
@@ -3,6 +3,7 @@ export { default } from "./extension.ts";
3
3
  export type {
4
4
  AgentConfig,
5
5
  SessionAction,
6
+ DispatchableTask,
6
7
  WorkspaceMode,
7
8
  TicketAction,
8
9
  DelegateArguments,
package/dispatch.ts CHANGED
@@ -48,6 +48,7 @@ import type {
48
48
  DelegateToolResult,
49
49
  ParentAgentDefaults,
50
50
  ResolvedTask,
51
+ DispatchableTask,
51
52
  TaskDef,
52
53
  TaskProgress,
53
54
  TaskResult,
@@ -58,7 +59,7 @@ const UNSAFE_SHARED_WRITES_WARNING =
58
59
  "UNSAFE SHARED WRITES ENABLED: shared-write admission is bypassed. Delegate provides no isolation or rollback.";
59
60
 
60
61
  interface ActiveSyncDispatch {
61
- tasks: TaskDef[];
62
+ tasks: DispatchableTask[];
62
63
  resolved: ResolvedTask[];
63
64
  }
64
65
 
@@ -157,7 +158,7 @@ export function makeFireUpdater(
157
158
  export interface AsyncDispatchInput {
158
159
  pi: ExtensionAPI;
159
160
  ctx: DelegateToolCtx;
160
- tasks: TaskDef[];
161
+ tasks: DispatchableTask[];
161
162
  resolved: ResolvedTask[];
162
163
  progress: TaskProgress[];
163
164
  parentModelId: string | undefined;
@@ -169,7 +170,7 @@ export interface AsyncDispatchInput {
169
170
  /** Inputs needed by the sync (blocking) dispatch path. */
170
171
  export interface SyncDispatchInput {
171
172
  ctx: DelegateToolCtx;
172
- tasks: TaskDef[];
173
+ tasks: DispatchableTask[];
173
174
  resolved: ResolvedTask[];
174
175
  progress: TaskProgress[];
175
176
  parentModelId: string | undefined;
@@ -193,7 +194,7 @@ export interface DelegateDispatchInput {
193
194
  callSpan?: CallSpan;
194
195
  }
195
196
 
196
- function taskReference(task: TaskDef, index: number): string {
197
+ function taskReference(task: DispatchableTask, index: number): string {
197
198
  return `Task ${index + 1}${task.id ? `#${task.id}` : ""}`;
198
199
  }
199
200
 
@@ -207,7 +208,7 @@ function asAdmissionWriter(task: ResolvedTask): ResolvedTask {
207
208
  }
208
209
 
209
210
  function sharedWriteRejection(
210
- tasks: TaskDef[],
211
+ tasks: DispatchableTask[],
211
212
  parentModelId: string | undefined,
212
213
  conflicts: SharedWriteConflict[],
213
214
  references: readonly string[] = tasks.map(taskReference),
@@ -236,7 +237,7 @@ function sharedWriteRejection(
236
237
  }
237
238
 
238
239
  function sharedWriteSafetyFailure(
239
- tasks: TaskDef[],
240
+ tasks: DispatchableTask[],
240
241
  parentModelId: string | undefined,
241
242
  error: unknown,
242
243
  ): DelegateToolResult {
@@ -289,13 +290,26 @@ export async function dispatchDelegate(
289
290
  return validationError;
290
291
  }
291
292
 
292
- const resolved = resolveTasks(
293
+ const resolveResult = resolveTasks(
293
294
  tasks,
294
295
  ctx,
295
296
  agents,
296
297
  parentDefaults,
297
298
  dispatchConfig,
298
299
  );
300
+ if (resolveResult.error !== undefined) {
301
+ callSpan?.finish({
302
+ status: "failed",
303
+ totalTokens: 0,
304
+ totalCost: 0,
305
+ wallMs: Date.now() - callSpan.startedAt,
306
+ });
307
+ return {
308
+ content: [{ type: "text", text: resolveResult.error }],
309
+ details: { tasks, results: [], progress: [], parentModel: parentModelId },
310
+ };
311
+ }
312
+ const resolved = resolveResult.tasks;
299
313
  if (params.async && resolved.some((task) => task.workspace === "isolated")) {
300
314
  callSpan?.finish({
301
315
  status: "failed",
package/extension.ts CHANGED
@@ -44,7 +44,11 @@ import {
44
44
  prepareTelemetryForSession,
45
45
  sealTelemetryWrites,
46
46
  } from "./telemetry.ts";
47
- import type { DelegateArguments, DelegateDetails } from "./types.ts";
47
+ import type {
48
+ DelegateArguments,
49
+ DelegateDetails,
50
+ DispatchableTask,
51
+ } from "./types.ts";
48
52
 
49
53
  const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS = 10_000;
50
54
  let shutdownDrainTimeoutMs = DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
@@ -52,6 +56,17 @@ let shutdownDrainTimeoutMs = DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
52
56
  type ShutdownDrainResult =
53
57
  { drained: true; failures: unknown[] } | { drained: false; failures: [] };
54
58
 
59
+ /** Bridge the promoted top-level session RPC (`sessionAction` + `sessionId`)
60
+ * to the internal single-entry batch the runner executes. Internal only — the
61
+ * public task schema has no `sessionAction`; validation guarantees close
62
+ * carries a sessionId by this point. */
63
+ function bridgeSessionControlTask(params: DelegateArguments): DispatchableTask {
64
+ return {
65
+ ...(params.sessionId ? { sessionId: params.sessionId } : {}),
66
+ sessionAction: params.sessionAction,
67
+ };
68
+ }
69
+
55
70
  /** Wait for shutdown workers without allowing a stuck provider/tool to hold
56
71
  * Pi's reload or exit hostage. The allSettled promise is intentionally left
57
72
  * attached after timeout so a late rejection cannot become unhandled. */
@@ -160,6 +175,11 @@ export default function delegateExtension(pi: ExtensionAPI): void {
160
175
  let mode: string;
161
176
  if (params.ticketAction) {
162
177
  mode = params.ticketAction;
178
+ } else if (params.sessionAction) {
179
+ // Session RPC is its own mode, not a task shape — label it before the
180
+ // tasks-based branches so close/list spans stop misreporting as
181
+ // "sync" with a zero task count.
182
+ mode = "session";
163
183
  } else if (tasks.length === 0) {
164
184
  mode = "manual";
165
185
  } else if (params.async) {
@@ -246,6 +266,35 @@ export default function delegateExtension(pi: ExtensionAPI): void {
246
266
  // silently swap the agent roster.
247
267
  const agents = discoverAgents(ctx.cwd);
248
268
 
269
+ // ── Session RPC (top-level sessionAction, #32 promotion) ─────────
270
+ // Session control is no longer spelled as a task, but it still runs
271
+ // through the one runner: bridge it to an internal single-entry batch so
272
+ // busy-index validation, per-session locking (lifecycle's literal
273
+ // per-action branches), progress rows, and result formatting all keep
274
+ // their existing behavior. Never async — validation rejects that.
275
+ // Must precede the help short-circuit: session RPC carries no tasks and
276
+ // must not be mistaken for an empty help request.
277
+ if (params.sessionAction) {
278
+ invalidateHostDepsCache();
279
+ // Keep the footer-status pipeline in step exactly as a normal
280
+ // dispatch would (deduped no-op when nothing is running).
281
+ syncDelegateStatus(ctx);
282
+ return await dispatchDelegate({
283
+ pi,
284
+ params: { ...params, tasks: [bridgeSessionControlTask(params)] },
285
+ ctx,
286
+ agents,
287
+ parentModelId,
288
+ parentDefaults: {
289
+ thinking: pi.getThinkingLevel(),
290
+ tools: pi.getActiveTools(),
291
+ },
292
+ signal,
293
+ onUpdate,
294
+ callSpan,
295
+ });
296
+ }
297
+
249
298
  // ── Help mode ─────────────────────────────────────────────────
250
299
  if (!tasks.length) {
251
300
  succeedCall();
package/manual.ts CHANGED
@@ -138,6 +138,8 @@ export function getSubagentManualMarkdown(
138
138
  "",
139
139
  ...builtinLines,
140
140
  "",
141
+ "Prefer `default` for general work: it is the only built-in guaranteed to run the parent's exact model and thinking. `scout`/`coder`/`reviewer` apply any configured delegate.json tiers (`agentOverrides`, `agentOverridesByParentModel`) and may run a different model or thinking level than the parent. `scout` is read-only, and `reviewer` runs in a disposable scratch workspace — its writes are discarded by design. Choose a specialist for its role, not its name.",
142
+ "",
141
143
  "Fresh built-ins inherit the parent's exact model object and thinking level. A same-named Markdown file can override any built-in (first definition wins); an explicit `model` or `thinking` in that file replaces parent inheritance. Task-level `model`/`thinking`/`tools` always win. For `scout`/`coder`/`reviewer`, delegate.json overrides (`agentOverrides`, `agentOverridesByParentModel`) win over the Markdown file; `default` ignores overrides and uses only an explicit Markdown `model`/`thinking` when present. A prompt-only Markdown override keeps the built-in's tools and workspace, so `scout` stays read-only and `reviewer` stays scratch unless the file explicitly changes them. Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context and skills are rebuilt for the task's `cwd`; per-task fields remain explicit overrides.",
142
144
  "",
143
145
  "## Available Custom Agents",
@@ -177,10 +179,10 @@ export function getSubagentManualMarkdown(
177
179
  'delegate({ tasks: [{ prompt: "Now check the tests for that module", sessionId: "auth-research" }] })',
178
180
  "",
179
181
  "// Clean up when done",
180
- 'delegate({ tasks: [{ sessionId: "auth-research", sessionAction: "close" }] })',
182
+ 'delegate({ sessionAction: "close", sessionId: "auth-research" })',
181
183
  "```",
182
184
  "",
183
- 'Pooled sessions remain live until `sessionAction: "close"` or parent Pi session shutdown.',
185
+ 'Pooled sessions remain live until a top-level `sessionAction: "close"` or parent Pi session shutdown.',
184
186
  "",
185
187
  "## Resuming Previous Sessions",
186
188
  "",
@@ -239,7 +241,7 @@ export function getSubagentManualMarkdown(
239
241
  '- Shared writers that overlap tasks in the same call or a running sync/async dispatch are rejected. Unknown tools count as mutating. Run them sequentially, use `workspace: "isolated"` for Git-backed ordered reconciliation, or use `workspace: "scratch"` when changes may be discarded.',
240
242
  "- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
241
243
  '- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
242
- '- Use `agent: "default"` for the parent\'s live model/thinking/native tools/base prompt. Built-ins are `default`, `scout`, `coder`, and `reviewer`; omitting `agent` creates an ad-hoc task.',
244
+ '- Prefer `agent: "default"` the parent\'s live model/thinking/native tools/base prompt for general tasks. Pick `scout`/`coder`/`reviewer` only when the role, tool limits, or scratch workspace fit; they follow delegate.json tiers and may not run the parent model. Omitting `agent` creates an ad-hoc task (parent model, full tools).',
243
245
  "- Omit `thinking` for named agents unless the user asks or the task clearly needs escalation — task-level `thinking` overrides every configured tier (`agentOverrides`, `agentOverridesByParentModel`, Markdown frontmatter, `:level` model suffix), so a casual value silently defeats the configured budget. Ad-hoc tasks default to the parent's thinking.",
244
246
  "- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
245
247
  "- Subagents inherit all skills discovered in their `cwd` (via AgentSession's resource loader). Per-task skill filtering is not supported — curate the cwd's skill set instead.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -470,7 +470,8 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
470
470
  );
471
471
  }
472
472
 
473
- // Warnings (e.g. unknown tools ignored) — muted line under the task.
473
+ // Warnings (e.g. scratch-workspace notices, ignored model suffix) — muted
474
+ // line under the task.
474
475
  pushWarnings(p, ind);
475
476
 
476
477
  // Tool activities: compact summary only in expanded mode, terminal tasks only.
package/schema.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { Type, type SchemaOptions } from "@sinclair/typebox";
2
- import { VALID_THINKING_LEVELS } from "./constants.ts";
2
+ import {
3
+ VALID_THINKING_LEVELS,
4
+ isSessionControlAction,
5
+ } from "./constants.ts";
3
6
  import type { DelegateArguments } from "./types.ts";
4
7
 
5
8
  // JSON Schema string enum that keeps the literal union in `Static<>`.
@@ -30,19 +33,19 @@ export const delegateTaskSchema = Type.Object({
30
33
  minLength: 1,
31
34
  maxLength: 64,
32
35
  description:
33
- "Optional task correlation key; 1-64 chars; A-Z a-z 0-9 . _ - only; duplicate ids rejected. Omit for index.",
36
+ "Optional correlation key; duplicates rejected; omit for index.",
34
37
  }),
35
38
  ),
36
39
  prompt: Type.Optional(
37
40
  Type.String({
38
41
  description:
39
- "Self-contained task prompt; fresh context cannot see this chat. Omit only for close, list, or resumeFrom.",
42
+ "Self-contained task prompt; fresh context cannot see this chat. Omit only for resumeFrom.",
40
43
  }),
41
44
  ),
42
45
  agent: Type.Optional(
43
46
  Type.String({
44
47
  description:
45
- "Agent profile name. Built-ins: default, scout, coder, reviewer. Omit for ad-hoc.",
48
+ "default mirrors the parent's tools; scout/coder/reviewer specialists. Ad-hoc tasks get * tools even when the parent is narrower.",
46
49
  }),
47
50
  ),
48
51
  cwd: Type.Optional(
@@ -86,13 +89,6 @@ export const delegateTaskSchema = Type.Object({
86
89
  "Live pool key for multi-turn reuse; omit for one-shot tasks.",
87
90
  }),
88
91
  ),
89
- sessionAction: Type.Optional(
90
- StringEnum(["prompt", "close", "list"], {
91
- description:
92
- "Session action; close needs sessionId; list shows active pooled sessions.",
93
- default: "prompt",
94
- }),
95
- ),
96
92
  resumeFrom: Type.Optional(
97
93
  Type.String({
98
94
  description:
@@ -108,7 +104,7 @@ export const delegateTaskSchema = Type.Object({
108
104
  workspace: Type.Optional(
109
105
  StringEnum(["shared", "scratch", "isolated"], {
110
106
  description:
111
- "shared; scratch discarded; isolated=sync one-shot Git apply; not security isolation. Reviewer=scratch.",
107
+ "shared edits source; scratch discards; isolated orders Git worktree proposals; none confine access.",
112
108
  }),
113
109
  ),
114
110
  });
@@ -124,6 +120,18 @@ export const delegateArgumentsSchema = Type.Object(
124
120
  "Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
125
121
  }),
126
122
  ),
123
+ sessionAction: Type.Optional(
124
+ StringEnum(["close", "list"], {
125
+ description:
126
+ '"close" ends the named pooled session; "list" lists active ones. Runs instead of tasks.',
127
+ }),
128
+ ),
129
+ sessionId: Type.Optional(
130
+ Type.String({
131
+ description:
132
+ 'With "close": the pooled session to end. Alone, a sessionId folds into a task as reuse.',
133
+ }),
134
+ ),
127
135
  async: Type.Optional(
128
136
  Type.Boolean({
129
137
  description:
@@ -162,7 +170,10 @@ export const delegateArgumentsSchema = Type.Object(
162
170
  );
163
171
 
164
172
  /** Fields that belong to a task entry. Models sometimes place these at the top
165
- * level of the arguments; the shim folds them back into a single task. */
173
+ * level of the arguments; the normalizer folds them back into a single task.
174
+ * `sessionAction` is NOT here: it was promoted to a top-level field (#32), so
175
+ * top-level presence means session-RPC intent, not task intent — the classifier
176
+ * and its mode validators own it now. */
166
177
  const TASK_FIELD_NAMES = [
167
178
  "id",
168
179
  "prompt",
@@ -174,7 +185,6 @@ const TASK_FIELD_NAMES = [
174
185
  "tools",
175
186
  "thinking",
176
187
  "sessionId",
177
- "sessionAction",
178
188
  "resumeFrom",
179
189
  "deadlineMs",
180
190
  "workspace",
@@ -187,7 +197,18 @@ const TASK_FIELD_NAMES = [
187
197
  * the work while the call in fact ran synchronously). */
188
198
  const VALID_TASK_KEYS = new Set<string>(TASK_FIELD_NAMES);
189
199
 
190
- /** Validate the three operation modes after compatibility reshaping. */
200
+ /** Task fields that are known spellings of top-level fields — rejected inside
201
+ * task entries with a corrective hint pointing at their real home. */
202
+ const TOP_LEVEL_TASK_KEY_HINTS = new Set(["async", "sessionAction"]);
203
+
204
+ /** Validate the four operation modes after compatibility reshaping.
205
+ *
206
+ * One classifier with fixed precedence runs first: `ticketAction` → ticket
207
+ * RPC; `sessionAction` → session RPC; non-empty `tasks` → dispatch; otherwise
208
+ * help. Each mode gets a small total validator that rejects foreign fields
209
+ * generically (naming the offending field and the fix), so ordering is no
210
+ * longer load-bearing across checks and a new field costs one schema entry
211
+ * plus one mode check. */
191
212
  export function validateDelegateOperation(
192
213
  params: DelegateArguments,
193
214
  ): string | undefined {
@@ -195,37 +216,85 @@ export function validateDelegateOperation(
195
216
  if ("action" in rawParams) {
196
217
  return (
197
218
  "unsupported field 'action'; use 'ticketAction' for poll/cancel/wait " +
198
- "or 'sessionAction' for prompt/close/list."
219
+ "or 'sessionAction' for close/list."
199
220
  );
200
221
  }
201
- const tasks = params.tasks ?? [];
202
222
 
203
- const ticketAction = params.ticketAction;
204
- const isTicketControl = ticketAction !== undefined;
223
+ // Mode classification — precedence by selector presence.
224
+ if (params.ticketAction !== undefined) {
225
+ return validateTicketMode(params);
226
+ }
227
+ if (params.sessionAction !== undefined) {
228
+ return validateSessionMode(params);
229
+ }
230
+ return validateDispatchOrHelpMode(params);
231
+ }
205
232
 
206
- if (isTicketControl) {
207
- const taskIntentFields = ([...TASK_FIELD_NAMES, "tasks"] as const).filter(
208
- (field) => rawParams[field] !== undefined,
209
- );
210
- if (taskIntentFields.length) {
211
- return `ticket control cannot be combined with task-intent field(s) ${taskIntentFields
212
- .map((field) => `'${field}'`)
213
- .join(", ")}; call it separately.`;
214
- }
215
- if (params.async === true) {
216
- return "ticket control cannot include async; call it separately.";
217
- }
218
- if (ticketAction !== "poll" && !params.ticket) {
219
- return `ticketAction '${ticketAction}' requires ticket.`;
220
- }
221
- if (ticketAction !== "cancel" && params.force === true) {
222
- return "force is valid only with ticketAction 'cancel'.";
223
- }
224
- if (ticketAction !== "wait" && params.timeoutMs !== undefined) {
225
- return "timeoutMs is valid only with ticketAction 'wait'.";
226
- }
227
- return undefined;
233
+ /** Fields recognized at the top level in session mode: the selector itself
234
+ * plus its target. Anything else (ticket fields, async, tasks, stray task
235
+ * fields) is foreign to session RPC. */
236
+ const SESSION_MODE_FIELDS = new Set(["sessionAction", "sessionId"]);
237
+
238
+ /** Session RPC: one close/list action per call against one pooled session. */
239
+ function validateSessionMode(params: DelegateArguments): string | undefined {
240
+ const rawParams = params as Record<string, unknown>;
241
+ const { sessionAction } = params;
242
+ // Nothing strips or defaults anymore; any value besides 'close'/'list'
243
+ // fails closed below rather than misclassifying.
244
+ if (!isSessionControlAction(sessionAction)) {
245
+ return `sessionAction '${String(sessionAction)}' is not a session control action; use 'close' or 'list'.`;
246
+ }
247
+ if (sessionAction === "close" && !params.sessionId) {
248
+ return "sessionAction 'close' requires sessionId.";
249
+ }
250
+ const foreign = Object.keys(rawParams).filter(
251
+ (key) => !SESSION_MODE_FIELDS.has(key),
252
+ );
253
+ if (foreign.length) {
254
+ return `sessionAction '${sessionAction}' cannot be combined with ${foreign
255
+ .map((field) => `'${field}'`)
256
+ .join(", ")}; run it alone — a session action takes only 'sessionAction' (plus 'sessionId' for 'close').`;
257
+ }
258
+ return undefined;
259
+ }
260
+
261
+ /** Ticket RPC: `ticketAction` owns the call; every other field family is
262
+ * foreign. Note `sessionAction` must be listed explicitly: it left
263
+ * `TASK_FIELD_NAMES` when it was promoted to top level, and without owning it
264
+ * here the old task-level exclusion would silently evaporate. */
265
+ function validateTicketMode(params: DelegateArguments): string | undefined {
266
+ const rawParams = params as Record<string, unknown>;
267
+ const ticketAction = params.ticketAction;
268
+ const incompatibleFields = (
269
+ [...TASK_FIELD_NAMES, "tasks", "sessionAction"] as const
270
+ ).filter((field) => rawParams[field] !== undefined);
271
+ if (incompatibleFields.length) {
272
+ return `ticket control cannot be combined with field(s) ${incompatibleFields
273
+ .map((field) => `'${field}'`)
274
+ .join(", ")}; call it separately.`;
275
+ }
276
+ if (params.async === true) {
277
+ return "ticket control cannot include async; call it separately.";
278
+ }
279
+ if (ticketAction !== "poll" && !params.ticket) {
280
+ return `ticketAction '${ticketAction}' requires ticket.`;
281
+ }
282
+ if (ticketAction !== "cancel" && params.force === true) {
283
+ return "force is valid only with ticketAction 'cancel'.";
228
284
  }
285
+ if (ticketAction !== "wait" && params.timeoutMs !== undefined) {
286
+ return "timeoutMs is valid only with ticketAction 'wait'.";
287
+ }
288
+ return undefined;
289
+ }
290
+
291
+ /** Dispatch (non-empty `tasks`) or help (empty/absent). Stray ticket- and
292
+ * session-mode selectors are foreign here and rejected generically. */
293
+ function validateDispatchOrHelpMode(
294
+ params: DelegateArguments,
295
+ ): string | undefined {
296
+ const rawParams = params as Record<string, unknown>;
297
+ const tasks = params.tasks ?? [];
229
298
 
230
299
  if (params.ticket !== undefined) {
231
300
  return "ticket requires ticketAction 'poll', 'cancel', or 'wait'.";
@@ -249,30 +318,29 @@ export function validateDelegateOperation(
249
318
  // nonempty tasks array. The normalize shim only wraps flat fields when
250
319
  // there is no tasks array, so a mixed call silently lets tasks win —
251
320
  // a model mistake that should fail loudly.
252
- if (tasks.length > 0) {
253
- const flatTaskFields = TASK_FIELD_NAMES.filter(
254
- (field) => rawParams[field] !== undefined,
255
- );
256
- if (flatTaskFields.length) {
257
- return `cannot mix top-level task field(s) ${flatTaskFields
258
- .map((field) => `'${field}'`)
259
- .join(
260
- ", ",
261
- )} with an explicit tasks array; move them into a task entry or remove tasks.`;
262
- }
321
+ const flatTaskFields = TASK_FIELD_NAMES.filter(
322
+ (field) => rawParams[field] !== undefined,
323
+ );
324
+ if (flatTaskFields.length) {
325
+ return `cannot mix top-level task field(s) ${flatTaskFields
326
+ .map((field) => `'${field}'`)
327
+ .join(
328
+ ", ",
329
+ )} with an explicit tasks array; move them into a task entry or remove tasks.`;
263
330
  }
264
331
 
265
332
  for (const [index, task] of tasks.entries()) {
266
333
  const rawTask = task as Record<string, unknown>;
267
- const sessionAction = task.sessionAction;
268
334
 
269
335
  const unknownKeys = Object.keys(rawTask).filter(
270
336
  (key) => !VALID_TASK_KEYS.has(key),
271
337
  );
272
338
  if (unknownKeys.length) {
273
- const misplacedTopLevel = unknownKeys.filter((key) => key === "async");
339
+ const misplacedTopLevel = unknownKeys.filter((key) =>
340
+ TOP_LEVEL_TASK_KEY_HINTS.has(key),
341
+ );
274
342
  const topLevelHint = misplacedTopLevel.length
275
- ? ` ${misplacedTopLevel.map((key) => `'${key}'`).join(" and ")} ${misplacedTopLevel.length === 1 ? "is a" : "are"} top-level flag${misplacedTopLevel.length === 1 ? "" : "s"}; move ${misplacedTopLevel.length === 1 ? "it" : "them"} out of the task entry.`
343
+ ? ` ${misplacedTopLevel.map((key) => `'${key}'`).join(" and ")} ${misplacedTopLevel.length === 1 ? "is a" : "are"} top-level field${misplacedTopLevel.length === 1 ? "" : "s"}; move ${misplacedTopLevel.length === 1 ? "it" : "them"} out of the task entry.`
276
344
  : "";
277
345
  return (
278
346
  `task ${index + 1}: unknown field(s) ${unknownKeys
@@ -291,28 +359,9 @@ export function validateDelegateOperation(
291
359
  }
292
360
  if (
293
361
  (task.workspace === "scratch" || task.workspace === "isolated") &&
294
- (task.sessionId || task.resumeFrom || sessionAction !== undefined)
362
+ (task.sessionId || task.resumeFrom)
295
363
  ) {
296
- return `task ${index + 1}: workspace '${task.workspace}' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction. Set workspace: "shared" to use a persistent agent.`;
297
- }
298
- if (sessionAction === "close") {
299
- if (!task.sessionId) {
300
- return `task ${index + 1}: sessionAction 'close' requires sessionId.`;
301
- }
302
- const extras = Object.keys(rawTask).filter(
303
- (key) => key !== "sessionAction" && key !== "sessionId" && key !== "id",
304
- );
305
- if (extras.length) {
306
- return `task ${index + 1}: sessionAction 'close' accepts only sessionAction and sessionId.`;
307
- }
308
- }
309
- if (sessionAction === "list") {
310
- const extras = Object.keys(rawTask).filter(
311
- (key) => key !== "sessionAction" && key !== "id",
312
- );
313
- if (extras.length) {
314
- return `task ${index + 1}: sessionAction 'list' accepts only sessionAction.`;
315
- }
364
+ return `task ${index + 1}: workspace '${task.workspace}' is one-shot and cannot be combined with sessionId or resumeFrom. Set workspace: "shared" to use a persistent agent.`;
316
365
  }
317
366
  }
318
367
 
@@ -350,13 +399,29 @@ function hasTicketControlIntent(record: Record<string, unknown>): boolean {
350
399
  );
351
400
  }
352
401
 
402
+ /** True when `record` carries top-level session-RPC intent: an explicit
403
+ * close/list `sessionAction`. A bare top-level `sessionId` is deliberately
404
+ * NOT session intent — it stays task-intent and wraps into a task as reuse.
405
+ * Only `sessionAction` presence selects the session mode. */
406
+ function hasSessionControlIntent(record: Record<string, unknown>): boolean {
407
+ return isSessionControlAction(record.sessionAction);
408
+ }
409
+
353
410
  /** Fold top-level task fields into a single `tasks` entry. Only fires when
354
- * there is no usable tasks array and no ticket-control intent — those calls
355
- * are legitimately taskless. `sessionAction` is part of `TASK_FIELD_NAMES`,
356
- * so a top-level `sessionAction` rides along into the wrapped task. */
411
+ * there is no usable tasks array and neither ticket-control intent nor
412
+ * session-control intent makes those calls legitimately taskless.
413
+ * `sessionAction` is not part of `TASK_FIELD_NAMES`: a top-level close/list
414
+ * means session RPC and must reach the classifier unwrapped — wrapping first
415
+ * would swallow stray fields into a task instead of rejecting them. */
357
416
  function wrapFlatTaskFields(record: Record<string, unknown>): void {
358
417
  const hasTasks = Array.isArray(record.tasks) && record.tasks.length > 0;
359
- if (hasTasks || hasTicketControlIntent(record)) return;
418
+ if (
419
+ hasTasks ||
420
+ hasTicketControlIntent(record) ||
421
+ hasSessionControlIntent(record)
422
+ ) {
423
+ return;
424
+ }
360
425
  const task: Record<string, unknown> = {};
361
426
  for (const key of TASK_FIELD_NAMES) {
362
427
  if (record[key] !== undefined) {
@@ -390,9 +455,11 @@ function normalizeTaskEntry(entry: unknown): unknown {
390
455
  * models then misread as "the tool is broken"):
391
456
  * - `tasks` as a JSON string instead of an array;
392
457
  * - task fields (`prompt`, `systemPrompt`, `tools`, ...) placed at the top
393
- * level instead of inside a `tasks` entry — wrapped into a single task;
458
+ * level instead of inside a `tasks` entry — wrapped into a single task,
459
+ * unless ticket- or session-control intent makes the call legitimately
460
+ * taskless (see `hasTicketControlIntent` / `hasSessionControlIntent`);
394
461
  * - `tools` as a JSON string (or bare token) inside a task entry;
395
- * - `agent: ""` inside a task entry — treated as omitted (ad-hoc).
462
+ * - `agent: ""` inside a task entry — treated as omitted (ad-hoc);
396
463
  * All other invalid input is left for normal schema validation to reject
397
464
  * loudly.
398
465
  *
@@ -5,6 +5,7 @@ import {
5
5
  DEFAULT_AGENT_NAME,
6
6
  DEFAULT_TOOLS,
7
7
  VALID_THINKING,
8
+ isSessionControlAction,
8
9
  } from "./constants.ts";
9
10
  import {
10
11
  TOOL_FACTORIES,
@@ -36,7 +37,8 @@ import type {
36
37
  DelegateToolResult,
37
38
  ParentAgentDefaults,
38
39
  ResolvedTask,
39
- TaskDef,
40
+ ResolveTasksResult,
41
+ DispatchableTask,
40
42
  } from "./types.ts";
41
43
 
42
44
  const PROJECT_CONTEXT_START =
@@ -92,7 +94,7 @@ export function stripInheritedProjectContext(
92
94
  /** Build a tool result for an error/notice with no task progress. */
93
95
  function noticeResult(
94
96
  text: string,
95
- tasks: TaskDef[],
97
+ tasks: DispatchableTask[],
96
98
  parentModel: string | undefined,
97
99
  ): DelegateToolResult {
98
100
  return {
@@ -110,7 +112,7 @@ function formatTaskRef(index: number, id: string | undefined): string {
110
112
  * ticket, and unknown agent names. Returns an error result to short-circuit
111
113
  * the call, or null when all checks pass. */
112
114
  export function validateTasks(
113
- tasks: TaskDef[],
115
+ tasks: DispatchableTask[],
114
116
  agents: Map<string, AgentConfig>,
115
117
  parentModelId: string | undefined,
116
118
  ): DelegateToolResult | null {
@@ -152,7 +154,7 @@ export function validateTasks(
152
154
  : `uses workspace \`${workspace}\``;
153
155
  const persistentAgent = task.agent ?? "agent";
154
156
  return noticeResult(
155
- `${formatTaskRef(index, task.id)}: Agent \`${persistentAgent}\` ${defaultText}, which is one-shot and cannot use \`sessionId\`, \`resumeFrom\`, or session actions. Set \`workspace: "shared"\` to use a persistent ${persistentAgent}.`,
157
+ `${formatTaskRef(index, task.id)}: Agent \`${persistentAgent}\` ${defaultText}, which is one-shot and cannot use \`sessionId\` or \`resumeFrom\`. Set \`workspace: "shared"\` to use a persistent ${persistentAgent}.`,
156
158
  tasks,
157
159
  parentModelId,
158
160
  );
@@ -235,15 +237,17 @@ export function validateTasks(
235
237
 
236
238
  /** Resolve every task into a fully-specified `ResolvedTask`: cwd, system
237
239
  * prompt, model, tools, thinking, and prompt (with optional parent-transcript
238
- * injection). Throws on unrecoverable misconfiguration (missing prompt,
239
- * unavailable explicit model, no model at all). */
240
+ * injection). Returns `{ error }` rejecting the whole batch before any
241
+ * dispatch when any task names a tool outside the closed valid set for its
242
+ * resolved model's provider. Throws on unrecoverable misconfiguration
243
+ * (missing prompt, unavailable explicit model, no model at all). */
240
244
  export function resolveTasks(
241
- tasks: TaskDef[],
245
+ tasks: DispatchableTask[],
242
246
  ctx: DelegateToolCtx,
243
247
  agents: Map<string, AgentConfig>,
244
248
  parentDefaults: ParentAgentDefaults,
245
249
  dispatchConfig: DelegateConfig = getDelegateConfigSnapshot(),
246
- ): ResolvedTask[] {
250
+ ): ResolveTasksResult {
247
251
  // Build parent transcript lazily — only computed once if any task uses with-parent-transcript
248
252
  let parentTranscript: string | null = null;
249
253
  const needsParentContext = tasks.some(
@@ -269,7 +273,10 @@ export function resolveTasks(
269
273
  const agentOverrides = getAgentOverrides(dispatchConfig);
270
274
  const overridesByParentModel = getAgentOverridesByParentModel(dispatchConfig);
271
275
 
272
- return tasks.map((t, i) => {
276
+ const resolveTask = (
277
+ t: DispatchableTask,
278
+ i: number,
279
+ ): ResolvedTask | { error: string } => {
273
280
  const isDefaultAgent = t.agent === DEFAULT_AGENT_NAME;
274
281
  const agent = t.agent
275
282
  ? (agents.get(t.agent) ?? BUILTIN_AGENT_CONFIGS[t.agent])
@@ -318,8 +325,7 @@ export function resolveTasks(
318
325
 
319
326
  // Prompt is required for fresh tasks. ResumeFrom provides context already.
320
327
  if (
321
- t.sessionAction !== "close" &&
322
- t.sessionAction !== "list" &&
328
+ !isSessionControlAction(t.sessionAction) &&
323
329
  !t.resumeFrom &&
324
330
  !t.prompt?.trim()
325
331
  ) {
@@ -328,11 +334,12 @@ export function resolveTasks(
328
334
  );
329
335
  }
330
336
 
331
- // Resolve tools — warn about unknown tool names.
337
+ // Resolve tools — unknown names reject the whole batch (checked below,
338
+ // post-model-resolution, because the provider decides the valid set).
332
339
  // For active pooled sessions, fall back to the frozen pooled config so
333
340
  // "continue with only sessionId" works without re-supplying tools.
334
341
  // Explicit overrides that don't match get rejected by acquireAgentSession.
335
- if (t.sessionAction !== "close" && t.sessionAction !== "list") {
342
+ if (!isSessionControlAction(t.sessionAction)) {
336
343
  // For `default` a deny-only override (no explicit allowlist) is not
337
344
  // materialized at discovery; apply its denylist to the parent's actual
338
345
  // tools here so a read-only parent stays read-only.
@@ -428,7 +435,7 @@ export function resolveTasks(
428
435
  let modelSuffix: ThinkingLevel | undefined;
429
436
  let thinking: ThinkingLevel = "off";
430
437
 
431
- if (t.sessionAction !== "close" && t.sessionAction !== "list") {
438
+ if (!isSessionControlAction(t.sessionAction)) {
432
439
  const agentType = t.agent ?? "inline";
433
440
  // The built-in `default` profile bypasses delegate.json model overrides.
434
441
  // The other built-ins accept task and modern agent overrides, but
@@ -568,15 +575,20 @@ export function resolveTasks(
568
575
  dispatchConfig,
569
576
  );
570
577
 
578
+ // The valid tool set is closed: core TOOL_FACTORIES plus the static
579
+ // per-provider list. Unknown names hard-reject the whole batch (mirroring
580
+ // unknown agent names) instead of warn-dropping a silently weakened
581
+ // subagent. Frozen pooled configs are known-good: they were filtered
582
+ // against this same static set when first resolved, so bare
583
+ // "continue with only sessionId" resumes never trip this check.
571
584
  const availableTools = availableToolNames(model?.provider);
572
585
  const availableToolSet = new Set(availableTools);
573
586
  const unknownTools = tools.filter((name) => !availableToolSet.has(name));
574
587
  if (unknownTools.length) {
575
- warnings.push(
576
- `Unknown tool(s) ignored: ${unknownTools.join(", ")}. Available: ${availableTools.join(", ")}`,
577
- );
588
+ return {
589
+ error: `${formatTaskRef(i, t.id)}: unknown tool(s): ${unknownTools.join(", ")}. Available: ${availableTools.join(", ")}. Fix the tool names in the task's tools list or the agent profile's tools.`,
590
+ };
578
591
  }
579
- tools = tools.filter((name) => availableToolSet.has(name));
580
592
  systemPrompt = buildSubagentSystemPrompt({
581
593
  taskSystemPrompt: t.systemPrompt,
582
594
  agentSystemPrompt: agent?.systemPrompt,
@@ -627,5 +639,13 @@ export function resolveTasks(
627
639
  },
628
640
  providerExtensionSources,
629
641
  };
630
- });
642
+ };
643
+
644
+ const resolved: ResolvedTask[] = [];
645
+ for (const [i, task] of tasks.entries()) {
646
+ const result = resolveTask(task, i);
647
+ if ("error" in result) return { error: result.error };
648
+ resolved.push(result);
649
+ }
650
+ return { tasks: resolved };
631
651
  }
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
@@ -142,9 +142,61 @@ function throwIfSetupCancelled(
142
142
  );
143
143
  }
144
144
 
145
+ /** Project an absolute source-tree symlink target onto its counterpart inside
146
+ * the copy, as a relative target so the copy stays self-contained. Returns
147
+ * undefined when the target is not lexically inside the source root, which is
148
+ * the only case that can be rewritten without following it. */
149
+ function relinkTargetIntoCopy(
150
+ linkPath: string,
151
+ target: string,
152
+ root: string,
153
+ sourceRoot: string,
154
+ ): string | undefined {
155
+ if (!path.isAbsolute(target) || !isWithin(sourceRoot, target)) {
156
+ return undefined;
157
+ }
158
+ const projected = path.join(root, path.relative(sourceRoot, target));
159
+ if (!isWithin(root, projected)) return undefined;
160
+ const relative = path.relative(path.dirname(linkPath), projected);
161
+ return relative === "" ? "." : relative;
162
+ }
163
+
164
+ /** Restore owner traversal/write bits so a partial copy can be removed.
165
+ * `cp --archive` reproduces read-only source directories, and their copies
166
+ * would otherwise leak a lease directory behind a failed setup. */
167
+ async function makeTreeRemovable(root: string): Promise<void> {
168
+ let entries: fs.Dirent[];
169
+ try {
170
+ await fs.promises.chmod(root, 0o700);
171
+ entries = await fs.promises.readdir(root, { withFileTypes: true });
172
+ } catch {
173
+ return;
174
+ }
175
+ for (const entry of entries) {
176
+ if (entry.isDirectory()) {
177
+ await makeTreeRemovable(path.join(root, entry.name));
178
+ }
179
+ }
180
+ }
181
+
182
+ async function replaceSymlink(linkPath: string, target: string): Promise<void> {
183
+ const staging = path.join(
184
+ path.dirname(linkPath),
185
+ `.pi-delegate-relink-${process.pid.toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
186
+ );
187
+ await fs.promises.symlink(target, staging);
188
+ try {
189
+ await fs.promises.rename(staging, linkPath);
190
+ } catch (error) {
191
+ await fs.promises.rm(staging, { force: true });
192
+ throw error;
193
+ }
194
+ }
195
+
145
196
  /** Validate the completed copy before any subagent receives its path. */
146
197
  async function validateCopiedTree(
147
198
  root: string,
199
+ sourceRoot: string,
148
200
  signal: AbortSignal,
149
201
  parentSignal: AbortSignal | undefined,
150
202
  ): Promise<void> {
@@ -179,10 +231,31 @@ async function validateCopiedTree(
179
231
  }
180
232
  if (!entry.isSymbolicLink()) continue;
181
233
  const target = await fs.promises.readlink(candidate);
234
+ // `cp --archive` copies link text verbatim, so an absolute in-project
235
+ // link (bun/pnpm-style local package installs) still resolves to the
236
+ // real tree from inside the copy. Retarget it at its copied counterpart
237
+ // instead of failing: the link keeps working and stays disposable.
238
+ const relinked = relinkTargetIntoCopy(
239
+ candidate,
240
+ target,
241
+ root,
242
+ sourceRoot,
243
+ );
244
+ if (relinked !== undefined) {
245
+ try {
246
+ await replaceSymlink(candidate, relinked);
247
+ } catch (error) {
248
+ throw new ScratchSetupError(
249
+ `Scratch workspace could not retarget in-project symlink '${path.relative(root, candidate)}' at its copied counterpart.`,
250
+ { cause: error },
251
+ );
252
+ }
253
+ continue;
254
+ }
182
255
  const resolvedTarget = path.resolve(path.dirname(candidate), target);
183
256
  if (path.isAbsolute(target) || !isWithin(root, resolvedTarget)) {
184
257
  throw new ScratchSetupError(
185
- `Scratch workspace cannot safely copy symlink '${path.relative(root, candidate)}' because it points outside the project.`,
258
+ `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
259
  );
187
260
  }
188
261
  }
@@ -704,7 +777,12 @@ export async function createScratchWorkspace(
704
777
  // GNU cp --archive applies the source root's mode to the destination.
705
778
  // Restore the private boundary after it has finished copying metadata.
706
779
  await fs.promises.chmod(scratchRoot, 0o700);
707
- await validateCopiedTree(scratchRoot, controller.signal, signal);
780
+ await validateCopiedTree(
781
+ scratchRoot,
782
+ sourceRoot,
783
+ controller.signal,
784
+ signal,
785
+ );
708
786
  if (
709
787
  await fs.promises.stat(path.join(scratchRoot, ".git")).then(
710
788
  (stat) => stat.isDirectory(),
@@ -766,7 +844,7 @@ export async function createScratchWorkspace(
766
844
  } catch (error) {
767
845
  if (leaseRoot) {
768
846
  try {
769
- await fs.promises.chmod(leaseRoot, 0o700);
847
+ await makeTreeRemovable(leaseRoot);
770
848
  await fs.promises.rm(leaseRoot, { recursive: true, force: true });
771
849
  } catch (cleanupError) {
772
850
  console.error(
@@ -913,13 +991,18 @@ export async function createScratchWorkspace(
913
991
  readStableLink(source, sourceStat),
914
992
  ]);
915
993
  // 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;
994
+ // Keep the lexical source path rather than dropping it. An in-project
995
+ // absolute source link was deliberately retargeted at setup, so its
996
+ // copied form matches that projection rather than the source text.
997
+ if (scratchTarget !== undefined && sourceTarget !== undefined) {
998
+ const expected =
999
+ relinkTargetIntoCopy(
1000
+ lexical,
1001
+ sourceTarget,
1002
+ completedRoot,
1003
+ sourceRoot!,
1004
+ ) ?? sourceTarget;
1005
+ if (scratchTarget === expected) return undefined;
923
1006
  }
924
1007
  }
925
1008
  // This is evidence about the lexical node, not its current target.