@bermudi/pi-delegate 0.1.11 → 0.1.13

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/lifecycle.ts CHANGED
@@ -70,11 +70,15 @@ export function _setWholeTaskRetryForTesting(
70
70
  testWholeTaskBaseDelayMs = opts?.baseDelayMs;
71
71
  }
72
72
 
73
- function resolvedWholeTaskMaxRetries(): number {
74
- return testWholeTaskMaxRetries ?? getWholeTaskMaxRetries();
73
+ function resolvedWholeTaskMaxRetries(env: TaskRunEnv): number {
74
+ return (
75
+ testWholeTaskMaxRetries ?? getWholeTaskMaxRetries(env.config ?? undefined)
76
+ );
75
77
  }
76
- function resolvedWholeTaskBaseDelayMs(): number {
77
- return testWholeTaskBaseDelayMs ?? getWholeTaskBaseDelayMs();
78
+ function resolvedWholeTaskBaseDelayMs(env: TaskRunEnv): number {
79
+ return (
80
+ testWholeTaskBaseDelayMs ?? getWholeTaskBaseDelayMs(env.config ?? undefined)
81
+ );
78
82
  }
79
83
 
80
84
  /** Build a failed TaskResult. Used for early-failure paths (abort, busy, validation). */
@@ -254,6 +258,7 @@ function recordTaskOutcome(
254
258
  recordTask({
255
259
  callId: env.telemetryCallId,
256
260
  generation: env.telemetryGeneration,
261
+ telemetryConfig: env.telemetryConfig,
257
262
  async: env.async ?? false,
258
263
  taskIndex: p.index,
259
264
  task,
@@ -385,7 +390,7 @@ async function sleepForWholeTaskRetry(
385
390
  async function buildDelegateSession(
386
391
  task: ResolvedTask,
387
392
  sessionManager: SessionManager,
388
- modelRegistry: TaskRunEnv["modelRegistry"],
393
+ env: TaskRunEnv,
389
394
  ): Promise<AgentSession> {
390
395
  // Resolve host deps for this task's cwd + system prompt. Extension-free
391
396
  // resource loaders are cached after the first call; provider-configured or
@@ -396,7 +401,7 @@ async function buildDelegateSession(
396
401
  // Pass only the provider needed by this task. This keeps a non-Kilo task
397
402
  // from receiving Kilo's provider/auth adapter merely because Kilo is also
398
403
  // configured in the parent runtime.
399
- const providerConfig = modelRegistry.getRegisteredProviderConfig?.(
404
+ const providerConfig = env.modelRegistry.getRegisteredProviderConfig?.(
400
405
  task.model.provider,
401
406
  );
402
407
  const providerConfigs = providerConfig
@@ -407,6 +412,10 @@ async function buildDelegateSession(
407
412
  systemPrompt: task.systemPrompt,
408
413
  providerConfigs,
409
414
  modelProvider: task.model.provider,
415
+ // Freeze the provider-extension allowlist to the dispatch-scoped snapshot
416
+ // so a later delegate.json edit cannot change which executable code an
417
+ // already-spawned async worker is allowed to load.
418
+ delegateConfig: env.config,
410
419
  });
411
420
 
412
421
  const { session } = await createAgentSession({
@@ -444,11 +453,16 @@ function checkoutPooledSession(
444
453
  cwd: task.cwd,
445
454
  thinking: task.thinking,
446
455
  tools: task.tools,
456
+ providerExtensions: task.providerExtensionSources ?? "",
447
457
  ...task.reuseIntent,
448
458
  });
449
459
  if (co.status === "mismatch") {
450
460
  const detail = co.mismatches
451
- .map((m) => `${m.field}: '${m.frozen}' vs '${m.requested}'`)
461
+ .map((m) =>
462
+ m.field === "providerExtensions"
463
+ ? "providerExtensions: changed"
464
+ : `${m.field}: '${m.frozen}' vs '${m.requested}'`,
465
+ )
452
466
  .join("; ");
453
467
  return {
454
468
  error: failTask(
@@ -529,7 +543,7 @@ async function resumeFromSessionFile(
529
543
  };
530
544
  }
531
545
 
532
- const session = await buildDelegateSession(task, resumed, env.modelRegistry);
546
+ const session = await buildDelegateSession(task, resumed, env);
533
547
  return {
534
548
  session,
535
549
  sessionManager: resumed,
@@ -544,7 +558,7 @@ async function createFreshSession(
544
558
  ): Promise<AcquireResult> {
545
559
  let sessionManager: SessionManager;
546
560
  let sessionFile: string | undefined;
547
- if (task.workspace === "scratch") {
561
+ if (task.workspace === "scratch" || task.workspace === "isolated") {
548
562
  // A discarded filesystem must not advertise a resumable conversation: a
549
563
  // later resume would run against the source cwd and silently lose scratch
550
564
  // isolation. Keep scratch transcripts in memory only.
@@ -560,11 +574,7 @@ async function createFreshSession(
560
574
  sessionFile = fresh.file;
561
575
  }
562
576
 
563
- const session = await buildDelegateSession(
564
- task,
565
- sessionManager,
566
- env.modelRegistry,
567
- );
577
+ const session = await buildDelegateSession(task, sessionManager, env);
568
578
  return {
569
579
  session,
570
580
  sessionManager,
@@ -635,6 +645,24 @@ async function runResolvedTaskUnlocked(
635
645
  p: TaskProgress,
636
646
  taskIndex: number,
637
647
  ): Promise<TaskResult> {
648
+ if (
649
+ task.workspace === "isolated" &&
650
+ (task.sessionId || task.resumeFrom || task.sessionAction)
651
+ ) {
652
+ return recordTaskOutcome(
653
+ env,
654
+ p,
655
+ task,
656
+ finishTask(
657
+ env,
658
+ p,
659
+ failTask(
660
+ task,
661
+ "workspace 'isolated' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction.",
662
+ ),
663
+ ),
664
+ );
665
+ }
638
666
  if (task.workspace !== "scratch") {
639
667
  const outcome = await runResolvedTaskCore(env, task, p, taskIndex);
640
668
  return recordTaskOutcome(env, p, task, outcome);
@@ -944,6 +972,7 @@ async function settlePooledAttempt(
944
972
  thinking: task.thinking,
945
973
  tools: task.tools,
946
974
  cwd: task.cwd,
975
+ providerExtensions: task.providerExtensionSources ?? "",
947
976
  },
948
977
  tokens: r.tokens,
949
978
  });
@@ -1035,6 +1064,7 @@ async function runTaskAttempt(
1035
1064
  gitBaseline,
1036
1065
  timing.taskStartedAt,
1037
1066
  timing.deadlineAt,
1067
+ env.config,
1038
1068
  );
1039
1069
 
1040
1070
  accounting.cumulativeTokens += r.tokens;
@@ -1159,8 +1189,12 @@ async function runWithWholeTaskRetries(
1159
1189
  };
1160
1190
  }
1161
1191
 
1162
- const maxRetries = resolvedWholeTaskMaxRetries();
1163
- const baseDelayMs = resolvedWholeTaskBaseDelayMs();
1192
+ // An isolated worker is snapshotted once and reconciled once. Retrying the
1193
+ // whole conversation against a mutated proposal tree would make attribution
1194
+ // ambiguous, so v1 deliberately runs one attempt.
1195
+ const maxRetries =
1196
+ task.workspace === "isolated" ? 0 : resolvedWholeTaskMaxRetries(env);
1197
+ const baseDelayMs = resolvedWholeTaskBaseDelayMs(env);
1164
1198
  let retries = 0;
1165
1199
  for (
1166
1200
  let retry = 0;
package/manual.ts CHANGED
@@ -110,7 +110,7 @@ export function getSubagentManualMarkdown(
110
110
  'delegate({ tasks: [{ agent: "default", prompt: "Investigate the auth module" }] })',
111
111
  "```",
112
112
  "",
113
- "Delegate subagents to execute tasks in parallel. Each subagent gets an independent conversation but uses the real filesystem at its task `cwd`; tasks sharing a directory can observe and overwrite one another's changes. Fresh prompts must therefore be self-contained, and dependent or shared-file work should run separately.",
113
+ 'Delegate subagents to execute tasks in parallel. Each subagent gets an independent conversation. A batch with multiple write-capable tasks in one shared tree is rejected before dispatch; run them sequentially, use synchronous one-shot `workspace: "isolated"` for Git-backed ordered reconciliation, or use disposable scratch workspaces.',
114
114
  "",
115
115
  "The three handles have different lifetimes:",
116
116
  "",
@@ -120,6 +120,8 @@ export function getSubagentManualMarkdown(
120
120
  "",
121
121
  "Each task entry may also carry an optional `id` — a caller-provided per-dispatch correlation key. Duplicate `id` values in the same call are rejected; when omitted, tasks are identified by array index, agent, and prompt.",
122
122
  "",
123
+ "Vocabulary, since 'agent' does double duty elsewhere: a **task** is one job; an **agent** is a named profile — a saved bundle of system prompt, tools, workspace, and model policy — that a task references by `agent` name; a **subagent** is the live session that executes one task. Agents are templates, not processes; nothing runs until a task dispatches.",
124
+ "",
123
125
  "Subagents cannot call `delegate` recursively. Their tool activity runs at `cwd`, while Pi stores the runtime session transcript in its own session directory outside that `cwd`.",
124
126
  "",
125
127
  "## Touched Files (best-effort)",
@@ -136,13 +138,13 @@ export function getSubagentManualMarkdown(
136
138
  "",
137
139
  ...builtinLines,
138
140
  "",
139
- "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`, settings overrides (`settings.json` `delegate.agentOverrides` and `delegate.agentOverridesByParentModel`) win over the Markdown file; `default` ignores settings 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.",
141
+ "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.",
140
142
  "",
141
143
  "## Available Custom Agents",
142
144
  "",
143
145
  agentList,
144
146
  "",
145
- "Custom agents are defined either inline in a task (using `systemPrompt`, `tools`, and `thinking`) or persisted as Markdown files in `.pi/agents/*.md` (project-local), `~/.pi/agent/agents/` (global), and `.claude/agents/` (interchange with Claude Code). Markdown agents are examples of custom agents — the parent model can shape the subagent it needs on each call. Each Markdown file is an agent with YAML frontmatter:",
147
+ "Custom agents are profiles, defined either inline in a task (using `systemPrompt`, `tools`, and `thinking`) or persisted as Markdown files in `.pi/agents/*.md` (project-local), `~/.pi/agent/agents/` (global), and `.claude/agents/` (interchange with Claude Code). Inline definitions let the parent model shape the subagent it needs on each call. Each Markdown file is one profile with YAML frontmatter:",
146
148
  "",
147
149
  "```markdown",
148
150
  "---",
@@ -165,26 +167,26 @@ export function getSubagentManualMarkdown(
165
167
  "## Session Reuse",
166
168
  "",
167
169
  "When `sessionId` is set, the subagent is kept alive in a pool for the duration of the pi session.",
168
- "Subsequent calls with the same `sessionId` continue the conversation — the agent remembers prior context.",
170
+ "Subsequent calls with the same `sessionId` continue the conversation — the session retains prior context.",
169
171
  "",
170
172
  "```ts",
171
173
  "// First call — creates and runs an inline custom agent",
172
174
  'delegate({ tasks: [{ prompt: "Investigate the auth module", systemPrompt: "You are a focused investigator. Map files and dependencies.", tools: ["read", "grep", "find", "ls"], sessionId: "auth-research" }] })',
173
175
  "",
174
- "// Second call — continues the same agent",
176
+ "// Second call — continues the same subagent",
175
177
  'delegate({ tasks: [{ prompt: "Now check the tests for that module", sessionId: "auth-research" }] })',
176
178
  "",
177
179
  "// Clean up when done",
178
180
  'delegate({ tasks: [{ sessionId: "auth-research", sessionAction: "close" }] })',
179
181
  "```",
180
182
  "",
181
- 'Pooled agents remain live until `sessionAction: "close"` or parent Pi session shutdown.',
183
+ 'Pooled sessions remain live until `sessionAction: "close"` or parent Pi session shutdown.',
182
184
  "",
183
185
  "## Resuming Previous Sessions",
184
186
  "",
185
187
  "Use `resumeFrom` to continue a failed or interrupted subagent from where it left off.",
186
188
  "Pass the exact absolute path to the session `.jsonl` file copied from delegate retry output. Do not invent placeholder values or use it as a ticket ID; async resume is supported.",
187
- "The agent gets the full conversation history and the new `prompt` continues naturally.",
189
+ "The subagent gets the full conversation history and the new `prompt` continues naturally.",
188
190
  "",
189
191
  "```ts",
190
192
  "// Copy this exact path from the failed delegate result; do not invent it.",
@@ -193,7 +195,7 @@ export function getSubagentManualMarkdown(
193
195
  " resumeFrom: exactRetrySessionFile }] })",
194
196
  "```",
195
197
  "",
196
- "Combine with `sessionId` to resume AND pool the agent for further multi-turn use:",
198
+ "Combine with `sessionId` to resume AND pool the subagent for further multi-turn use:",
197
199
  "",
198
200
  "```ts",
199
201
  'delegate({ tasks: [{ prompt: "Continue the investigation",',
@@ -234,9 +236,11 @@ export function getSubagentManualMarkdown(
234
236
  "## Gotchas",
235
237
  "",
236
238
  "- Dispatch validation is batch-wide and runs before spawning: one invalid task rejects the call without starting its siblings.",
239
+ '- 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.',
237
240
  "- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
238
241
  '- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
239
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.',
243
+ "- 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.",
240
244
  "- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
241
245
  "- 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.",
242
246
  `- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
@@ -244,7 +248,7 @@ export function getSubagentManualMarkdown(
244
248
  "",
245
249
  "## Config",
246
250
  "",
247
- "Tunables live in `~/.pi/agent/delegate.json`: `maxConcurrent` (sync ceiling), `maxAsyncTickets` (background ticket cap), `stallTimeoutMs` (inactivity watchdog; default 900000, 0 disables), per-model/per-provider concurrency limits, and legacy custom-agent model overrides. Built-in model/thinking/tools overrides live in `settings.json`; `agentOverridesByParentModel` uses an exact `provider/model-id` key and project settings take precedence.",
251
+ "Tunables live in `~/.pi/agent/delegate.json` (user scope, global — no project-level config): `maxConcurrent` (sync ceiling), `maxAsyncTickets` (background ticket cap), `stallTimeoutMs` (inactivity watchdog; default 900000, 0 disables), per-model/per-provider concurrency limits, legacy custom-agent model overrides (`agent`), and agent model/thinking/tools overrides `agentOverrides` and `agentOverridesByParentModel` (exact `provider/model-id` key of the parent model; wins over `agentOverrides` on match). Config edits apply from the next delegate call.",
248
252
  "The inactivity watchdog requests cooperative `AgentSession.abort()` cancellation and waits for the subagent to become idle; it is not a hard wall-clock execution deadline.",
249
253
  "",
250
254
  `Output bounding: subagent outputs longer than ${OUTPUT_SPILL_THRESHOLD_CHARS} characters are spilled to a temp file, and only the last ${OUTPUT_SPILL_TAIL_CHARS} characters stay in the LLM-facing result. Adjust with \`output.spillThresholdChars\` and \`output.spillTailChars\`. Spill files are written to the system temp directory with owner-only permissions; the full output is always available in the expanded TUI view and the spilled file.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
package/pool.ts CHANGED
@@ -18,6 +18,10 @@ export interface FrozenConfig {
18
18
  thinking: ThinkingLevel;
19
19
  tools: string[];
20
20
  cwd: string;
21
+ /** Stable signature of the provider-scoped extension allowlist this session
22
+ * was built with. A change in `delegate.json` providerExtensions must force
23
+ * session recreation so the old executable runtime is not silently reused. */
24
+ providerExtensions?: string;
21
25
  }
22
26
 
23
27
  /** The subset a reuse request supplies for validation. `model` and
@@ -29,12 +33,20 @@ export interface ConfigCandidate {
29
33
  tools: string[];
30
34
  model?: Model<Api>;
31
35
  systemPrompt?: string;
36
+ /** Provider-scoped extension allowlist signature for the current dispatch. */
37
+ providerExtensions?: string;
32
38
  }
33
39
 
34
40
  /** One field-level diff from a reuse that conflicts with the frozen config. The
35
41
  * pool computes these; the caller formats the error string. */
36
42
  export interface ConfigMismatch {
37
- field: "cwd" | "thinking" | "tools" | "model" | "systemPrompt";
43
+ field:
44
+ | "cwd"
45
+ | "thinking"
46
+ | "tools"
47
+ | "model"
48
+ | "systemPrompt"
49
+ | "providerExtensions";
38
50
  frozen: string;
39
51
  requested: string;
40
52
  }
@@ -189,6 +201,16 @@ export function checkout(
189
201
  requested: "<requested>",
190
202
  });
191
203
  }
204
+ if (frozen.providerExtensions !== candidate.providerExtensions) {
205
+ // This field is derived from configured package sources, which may contain
206
+ // credentials. Never expose either the sources or their digest through the
207
+ // public checkout result; a digest can still enable dictionary guessing.
208
+ mismatches.push({
209
+ field: "providerExtensions",
210
+ frozen: "<redacted>",
211
+ requested: "<redacted>",
212
+ });
213
+ }
192
214
  if (mismatches.length) return { status: "mismatch", mismatches };
193
215
 
194
216
  return {
@@ -35,7 +35,11 @@ import {
35
35
  SettingsManager,
36
36
  } from "@earendil-works/pi-coding-agent";
37
37
  import { getSubagentProviderExtensionSourcesForProvider } from "./config.ts";
38
- import { canonicalPath, isPathWithinDirectory, isPathWithinDirectoryLexical } from "./trusted-paths.ts";
38
+ import {
39
+ canonicalPath,
40
+ isPathWithinDirectory,
41
+ isPathWithinDirectoryLexical,
42
+ } from "./trusted-paths.ts";
39
43
  import {
40
44
  parseGitOriginIdentity,
41
45
  parsePackageSource,
@@ -331,8 +335,13 @@ export async function getProviderExtensionPaths(
331
335
  provider: string | undefined,
332
336
  cwd: string,
333
337
  agentDir: string,
338
+ /** Dispatch-scoped snapshot for the provider-extension allowlist. */
339
+ config?: import("./config.ts").DelegateConfig,
334
340
  ): Promise<ProviderExtensionResolution> {
335
- const requested = getSubagentProviderExtensionSourcesForProvider(provider);
341
+ const requested = getSubagentProviderExtensionSourcesForProvider(
342
+ provider,
343
+ config,
344
+ );
336
345
  if (!requested.length) {
337
346
  return { paths: [], bestEffortPaths: new Set<string>() };
338
347
  }
@@ -378,6 +378,36 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
378
378
  if (!isLive && !isCancelledPending && r && "error" in r && r.error) {
379
379
  lines.push(truncLine(`${ind}${theme.fg("error", r.error)}`, w));
380
380
  }
381
+ if (r && "integration" in r && r.integration) {
382
+ const integration = r.integration;
383
+ const tone =
384
+ integration.status === "applied_unverified" ||
385
+ integration.status === "no_changes"
386
+ ? "warning"
387
+ : "error";
388
+ lines.push(
389
+ truncLine(
390
+ `${ind}${theme.fg(tone, `integration: ${integration.status} · ${integration.appliedFiles.length}/${integration.proposedFiles.length} files applied`)}`,
391
+ w,
392
+ ),
393
+ );
394
+ if (expanded) {
395
+ if (integration.patchPath)
396
+ lines.push(
397
+ truncLine(
398
+ `${ind}${theme.fg("muted", `patch: ${integration.patchPath}`)}`,
399
+ w,
400
+ ),
401
+ );
402
+ if (integration.worktreePath)
403
+ lines.push(
404
+ truncLine(
405
+ `${ind}${theme.fg("muted", `worktree: ${integration.worktreePath}`)}`,
406
+ w,
407
+ ),
408
+ );
409
+ }
410
+ }
381
411
  // Collapsed: one-line output preview so a human scanning the TUI sees
382
412
  // the payoff without expanding every task. Expanded mode renders the
383
413
  // full markdown below instead.
package/render-result.ts CHANGED
@@ -166,11 +166,14 @@ export function renderDelegateResult(
166
166
  ticketStatus,
167
167
  };
168
168
 
169
- // Surface the touched-file overlap warning at the top of the TUI. The same
170
- // text already lives in the textual content, but the progress-based renderer
171
- // ignores content, so we must render it explicitly from details. Rendering it
172
- // before the progress tree places it at the top of the budgeted region, so it
173
- // survives truncation from the bottom when many tasks collapse the view.
169
+ // Surface batch-level warnings at the top of the TUI. The same text already
170
+ // lives in textual content, but the progress renderer ignores content.
171
+ if (details?.dispatchWarning) {
172
+ lines.push(
173
+ truncLine(theme.fg("warning", `⚠ ${details.dispatchWarning}`), w),
174
+ "",
175
+ );
176
+ }
174
177
  if (details?.overlapWarning) {
175
178
  lines.push(
176
179
  truncLine(theme.fg("warning", `⚠ ${details.overlapWarning}`), w),
package/runner.ts CHANGED
@@ -72,6 +72,8 @@ export async function runAgentSession(
72
72
  gitBaseline: Set<string> | undefined,
73
73
  start: number,
74
74
  deadlineAt?: number,
75
+ /** Dispatch-scoped delegate.json snapshot for the stall timeout. */
76
+ delegateConfig?: import("./config.ts").DelegateConfig,
75
77
  ): Promise<{
76
78
  output: string;
77
79
  error?: string;
@@ -89,7 +91,7 @@ export async function runAgentSession(
89
91
  prompted: boolean;
90
92
  }> {
91
93
  const startTime = start ?? Date.now();
92
- const stallTimeoutMs = getStallTimeoutMs();
94
+ const stallTimeoutMs = getStallTimeoutMs(delegateConfig);
93
95
  let toolUses = 0;
94
96
  let lastActivityAt: number | undefined = startTime;
95
97
  let phase = "starting agent";
package/schema.ts CHANGED
@@ -42,7 +42,7 @@ export const delegateTaskSchema = Type.Object({
42
42
  agent: Type.Optional(
43
43
  Type.String({
44
44
  description:
45
- "Built-ins: default, scout, coder, reviewer. Reviewer defaults to one-shot scratch. Omit for ad-hoc.",
45
+ "Agent profile name. Built-ins: default, scout, coder, reviewer. Omit for ad-hoc.",
46
46
  }),
47
47
  ),
48
48
  cwd: Type.Optional(
@@ -77,7 +77,7 @@ export const delegateTaskSchema = Type.Object({
77
77
  thinking: Type.Optional(
78
78
  StringEnum(VALID_THINKING_LEVELS, {
79
79
  description:
80
- "Thinking: off/minimal/low/medium/high/xhigh/max; default=parent; others=agent/off.",
80
+ "off/minimal/low/medium/high/xhigh/max. Omit for agents — it overrides delegate.json tiers; default inherits.",
81
81
  }),
82
82
  ),
83
83
  sessionId: Type.Optional(
@@ -106,9 +106,9 @@ export const delegateTaskSchema = Type.Object({
106
106
  }),
107
107
  ),
108
108
  workspace: Type.Optional(
109
- StringEnum(["shared", "scratch"], {
109
+ StringEnum(["shared", "scratch", "isolated"], {
110
110
  description:
111
- "shared source; scratch disposable copy, one-shot; not security isolation. Reviewer=scratch; others=shared.",
111
+ "shared; scratch discarded; isolated=sync one-shot Git apply; not security isolation. Reviewer=scratch.",
112
112
  }),
113
113
  ),
114
114
  });
@@ -116,47 +116,50 @@ export const delegateTaskSchema = Type.Object({
116
116
  // Single source of truth for registration and generated help. The exported
117
117
  // argument types in types.ts project this canonical schema; providers see
118
118
  // only these fields.
119
- export const delegateArgumentsSchema = Type.Object({
120
- ticketAction: Type.Optional(
121
- StringEnum(["poll", "cancel", "wait"], {
122
- description:
123
- "Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
124
- }),
125
- ),
126
- async: Type.Optional(
127
- Type.Boolean({
128
- description:
129
- "Detach work, return a ticket; applies to ALL tasks. Results auto-deliver. Wait only if blocked.",
130
- default: false,
131
- }),
132
- ),
133
- ticket: Type.Optional(
134
- Type.String({
135
- description: "Ticket ID; omit only when polling all tickets.",
136
- }),
137
- ),
138
- force: Type.Optional(
139
- Type.Boolean({
140
- description:
141
- "With cancel: false previews active work; true confirms abort. Completed writes/commands remain.",
142
- default: false,
143
- }),
144
- ),
145
- timeoutMs: Type.Optional(
146
- Type.Number({
147
- minimum: 0,
148
- description:
149
- "Bounds wait (ms); omit to block until settled. Timeout returns a snapshot; do not poll afterward.",
150
- }),
151
- ),
152
- tasks: Type.Optional(
153
- Type.Array(delegateTaskSchema, {
154
- minItems: 0,
155
- description:
156
- "Tasks run concurrently; shared workspaces share files. scratch uses a disposable CoW copy. []=full manual.",
157
- }),
158
- ),
159
- });
119
+ export const delegateArgumentsSchema = Type.Object(
120
+ {
121
+ ticketAction: Type.Optional(
122
+ StringEnum(["poll", "cancel", "wait"], {
123
+ description:
124
+ "Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
125
+ }),
126
+ ),
127
+ async: Type.Optional(
128
+ Type.Boolean({
129
+ description:
130
+ "Detach work, return a ticket; applies to ALL tasks. Results auto-deliver. Wait only if blocked.",
131
+ default: false,
132
+ }),
133
+ ),
134
+ ticket: Type.Optional(
135
+ Type.String({
136
+ description: "Ticket ID; omit only when polling all tickets.",
137
+ }),
138
+ ),
139
+ force: Type.Optional(
140
+ Type.Boolean({
141
+ description:
142
+ "With cancel: false previews active work; true confirms abort. Completed writes/commands remain.",
143
+ default: false,
144
+ }),
145
+ ),
146
+ timeoutMs: Type.Optional(
147
+ Type.Number({
148
+ minimum: 0,
149
+ description:
150
+ "Bounds wait (ms); omit to block until settled. Timeout returns a snapshot; do not poll afterward.",
151
+ }),
152
+ ),
153
+ tasks: Type.Optional(
154
+ Type.Array(delegateTaskSchema, {
155
+ minItems: 0,
156
+ description:
157
+ "Tasks run concurrently; shared workspaces share files. scratch uses a disposable CoW copy. []=full manual.",
158
+ }),
159
+ ),
160
+ },
161
+ { additionalProperties: false },
162
+ );
160
163
 
161
164
  /** Fields that belong to a task entry. Models sometimes place these at the top
162
165
  * level of the arguments; the shim folds them back into a single task. */
@@ -238,6 +241,10 @@ export function validateDelegateOperation(
238
241
  : undefined; // Intentional help request.
239
242
  }
240
243
 
244
+ if (params.async && tasks.some((task) => task.workspace === "isolated")) {
245
+ return 'workspace "isolated" is synchronous; remove async.';
246
+ }
247
+
241
248
  // Reject mixed shapes: flat task fields at the top level alongside a
242
249
  // nonempty tasks array. The normalize shim only wraps flat fields when
243
250
  // there is no tasks array, so a mixed call silently lets tasks win —
@@ -263,13 +270,14 @@ export function validateDelegateOperation(
263
270
  (key) => !VALID_TASK_KEYS.has(key),
264
271
  );
265
272
  if (unknownKeys.length) {
266
- const asyncHint = unknownKeys.includes("async")
267
- ? " 'async' is a top-level flag; move it out of the task entry."
273
+ const misplacedTopLevel = unknownKeys.filter((key) => key === "async");
274
+ 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.`
268
276
  : "";
269
277
  return (
270
278
  `task ${index + 1}: unknown field(s) ${unknownKeys
271
279
  .map((key) => `'${key}'`)
272
- .join(", ")}.${asyncHint} ` +
280
+ .join(", ")}.${topLevelHint} ` +
273
281
  `Valid task fields: ${[...VALID_TASK_KEYS].join(", ")}.`
274
282
  );
275
283
  }
@@ -282,11 +290,10 @@ export function validateDelegateOperation(
282
290
  return `task ${index + 1}: deadlineMs must be a positive number of milliseconds.`;
283
291
  }
284
292
  if (
285
- task.workspace === "scratch" &&
286
- !task.agent &&
293
+ (task.workspace === "scratch" || task.workspace === "isolated") &&
287
294
  (task.sessionId || task.resumeFrom || sessionAction !== undefined)
288
295
  ) {
289
- return `task ${index + 1}: workspace 'scratch' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction. Set workspace: "shared" to use a persistent agent.`;
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.`;
290
297
  }
291
298
  if (sessionAction === "close") {
292
299
  if (!task.sessionId) {