@bermudi/pi-delegate 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -38,6 +38,19 @@ Parent extension/MCP tools are not copied, and project instructions are rebuilt
38
38
  for the task's `cwd`. Omit `agent` when you want an ad-hoc task using delegate's
39
39
  normal inline defaults instead.
40
40
 
41
+ The other built-ins are:
42
+
43
+ - `scout` — read-only investigation with `read`, `grep`, `find`, and `ls`.
44
+ - `coder` — implementation and verification with `read`, `write`, `edit`, and
45
+ `bash` in the shared workspace.
46
+ - `reviewer` — review with `read` and `bash`, using a disposable scratch copy by
47
+ default. Set `workspace: "shared"` when a reviewer needs a persistent
48
+ `sessionId`.
49
+
50
+ Fresh built-ins inherit the parent's exact model object and thinking level.
51
+ Task-level overrides win; settings can provide unconditional overrides or exact
52
+ parent-model overrides under `delegate.agentOverridesByParentModel`.
53
+
41
54
  ### Disposable scratch workspace
42
55
 
43
56
  For review, tests, or other commands whose project changes should be thrown
@@ -136,7 +149,7 @@ over an installed extension.
136
149
  rare override. Markdown agents are examples of custom agents.
137
150
  - **Named agent** / **Markdown agent** — A reusable custom agent persisted as a
138
151
  Markdown file in `.pi/agents/*.md` or `~/.pi/agent/agents/*.md`. The frontmatter
139
- defines its name, description, model, tools, thinking level, and skills; the
152
+ defines its name, description, model, tools, and thinking level; the
140
153
  Markdown body is its system prompt.
141
154
  - **Ad-hoc subagent** — A subagent created from inline task fields instead of a
142
155
  named Markdown agent profile. In current output this is labeled `ad-hoc`.
package/agents.ts CHANGED
@@ -7,8 +7,13 @@ import type {
7
7
  } from "@earendil-works/pi-agent-core";
8
8
  import { parseFrontmatter as parsePiFrontmatter } from "@earendil-works/pi-coding-agent";
9
9
  import {
10
+ BUILTIN_AGENT_NAMES,
11
+ CODER_AGENT_NAME,
10
12
  DEFAULT_AGENT_NAME,
11
13
  DEFAULT_TOOLS,
14
+ READONLY_TOOLS,
15
+ REVIEWER_AGENT_NAME,
16
+ SCOUT_AGENT_NAME,
12
17
  VALID_THINKING,
13
18
  } from "./constants.ts";
14
19
  import { resolveToolGroups } from "./tools.ts";
@@ -110,6 +115,50 @@ export function parseFrontmatter(
110
115
 
111
116
  // ── Agent Discovery ───────────────────────────────────────────────────────
112
117
 
118
+ /** Built-in profiles are always available and cannot vary with Markdown files. */
119
+ export const BUILTIN_AGENT_CONFIGS: Readonly<Record<string, AgentConfig>> = {
120
+ [DEFAULT_AGENT_NAME]: {
121
+ name: DEFAULT_AGENT_NAME,
122
+ description:
123
+ "Mirror the live parent model, thinking level, native tools, and base prompt.",
124
+ tools: DEFAULT_TOOLS,
125
+ systemPrompt: "",
126
+ builtin: true,
127
+ workspace: "shared",
128
+ },
129
+ [SCOUT_AGENT_NAME]: {
130
+ name: SCOUT_AGENT_NAME,
131
+ description: "Investigate without modifying the source project.",
132
+ tools: READONLY_TOOLS,
133
+ systemPrompt:
134
+ "Explore the codebase to answer the assigned question. Do not modify files. Trace relevant code, tests, documentation, and history when useful. Return concise findings with concrete paths, symbols, and any uncertainty. Prefer evidence over speculation.",
135
+ builtin: true,
136
+ workspace: "shared",
137
+ },
138
+ [CODER_AGENT_NAME]: {
139
+ name: CODER_AGENT_NAME,
140
+ description: "Implement and verify changes in the shared source tree.",
141
+ tools: DEFAULT_TOOLS,
142
+ systemPrompt:
143
+ "Implement the assigned change. Read the existing code and project instructions first. Prefer the smallest maintainable solution that follows existing conventions. Surface failures clearly. Run focused tests or checks and report what changed, what passed, and any remaining risk.",
144
+ builtin: true,
145
+ workspace: "shared",
146
+ },
147
+ [REVIEWER_AGENT_NAME]: {
148
+ name: REVIEWER_AGENT_NAME,
149
+ description: "Inspect the current snapshot and report actionable findings.",
150
+ tools: ["read", "bash"],
151
+ systemPrompt:
152
+ "Review the current snapshot for correctness, regressions, security problems, and missing tests. Do not modify the source project. Run focused checks when useful. Report actionable findings ordered by severity, with concrete paths and locations. If there are no material findings, say so plainly; do not invent issues or merely summarize the implementation.",
153
+ builtin: true,
154
+ workspace: "scratch",
155
+ },
156
+ };
157
+
158
+ export function isBuiltinAgentName(name: string): boolean {
159
+ return (BUILTIN_AGENT_NAMES as readonly string[]).includes(name);
160
+ }
161
+
113
162
  /** Find the nearest ancestor containing project-scoped agent files. */
114
163
  export function findProjectRoot(cwd: string): string | null {
115
164
  let dir = cwd;
@@ -330,7 +379,9 @@ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
330
379
  scope: "claude",
331
380
  });
332
381
 
333
- const agents = new Map<string, AgentConfig>();
382
+ const agents = new Map<string, AgentConfig>(
383
+ Object.entries(BUILTIN_AGENT_CONFIGS),
384
+ );
334
385
  const loadDir = (
335
386
  { dir, scope }: { dir: string; scope: AgentConfig["scope"] },
336
387
  loader: (fp: string) => AgentConfig | null,
@@ -345,9 +396,9 @@ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
345
396
  if (!e.name.endsWith(".md") || e.name.endsWith(".chain.md")) continue;
346
397
  const filePath = path.join(dir, e.name);
347
398
  const cfg = loader(filePath);
348
- if (cfg?.name === DEFAULT_AGENT_NAME) {
399
+ if (cfg && isBuiltinAgentName(cfg.name)) {
349
400
  console.warn(
350
- `[delegate] ignoring agent profile '${DEFAULT_AGENT_NAME}' from ${filePath}: the name is reserved for the built-in parent-mirroring profile.`,
401
+ `[delegate] ignoring agent profile '${cfg.name}' from ${filePath}: the name is reserved for a built-in delegate profile.`,
351
402
  );
352
403
  continue;
353
404
  }
package/constants.ts CHANGED
@@ -1,6 +1,23 @@
1
1
  /** Reserved built-in profile that mirrors the live parent configuration. */
2
2
  export const DEFAULT_AGENT_NAME = "default";
3
3
 
4
+ /** Reserved built-in profile for read-only investigation. */
5
+ export const SCOUT_AGENT_NAME = "scout";
6
+
7
+ /** Reserved built-in profile for shared-workspace implementation work. */
8
+ export const CODER_AGENT_NAME = "coder";
9
+
10
+ /** Reserved built-in profile for isolated review work. */
11
+ export const REVIEWER_AGENT_NAME = "reviewer";
12
+
13
+ /** All names reserved by delegate's built-in profiles. */
14
+ export const BUILTIN_AGENT_NAMES = [
15
+ DEFAULT_AGENT_NAME,
16
+ SCOUT_AGENT_NAME,
17
+ CODER_AGENT_NAME,
18
+ REVIEWER_AGENT_NAME,
19
+ ] as const;
20
+
4
21
  /** Full-capability agent set. Inline-task default and the `*` shorthand.
5
22
  * Bash subsumes search, so the dedicated grep/find/ls tools are excluded. */
6
23
  export const DEFAULT_TOOLS = ["read", "write", "edit", "bash"];
package/delegate.ts CHANGED
@@ -24,6 +24,10 @@ export type { DelegateConfig } from "./config.ts";
24
24
 
25
25
  export {
26
26
  DEFAULT_AGENT_NAME,
27
+ SCOUT_AGENT_NAME,
28
+ CODER_AGENT_NAME,
29
+ REVIEWER_AGENT_NAME,
30
+ BUILTIN_AGENT_NAMES,
27
31
  DEFAULT_TOOLS,
28
32
  READONLY_TOOLS,
29
33
  MAX_CONCURRENCY,
@@ -123,6 +127,8 @@ export {
123
127
  loadAgentFile,
124
128
  loadClaudeAgentFile,
125
129
  discoverAgents,
130
+ BUILTIN_AGENT_CONFIGS,
131
+ isBuiltinAgentName,
126
132
  buildSubagentSystemPrompt,
127
133
  DEFAULT_SUBAGENT_SYSTEM_PROMPT,
128
134
  } from "./agents.ts";
@@ -133,7 +139,12 @@ export {
133
139
  resolveModelRequest,
134
140
  findAvailableAlternative,
135
141
  } from "./model.ts";
136
- export { readDelegateSettingsFile, loadDelegateSettings } from "./settings.ts";
142
+ export {
143
+ readDelegateSettingsFile,
144
+ loadDelegateSettings,
145
+ clearDelegateSettingsCache,
146
+ } from "./settings.ts";
147
+ export type { AgentOverride } from "./settings.ts";
137
148
  export { resolveCwd, extractOutput, extractUsage } from "./utils.ts";
138
149
  export {
139
150
  decideSpill,
package/dispatch.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  import { validateDelegateOperation } from "./schema.ts";
25
25
  import { notifyCrossLeafDelivery, syncDelegateStatus } from "./status.ts";
26
26
  import { validateTasks, resolveTasks } from "./task-resolution.ts";
27
+ import { clearDelegateSettingsCache } from "./settings.ts";
27
28
  import type { CallSpan } from "./telemetry.ts";
28
29
  import type {
29
30
  AgentConfig,
@@ -146,6 +147,9 @@ export interface DelegateDispatchInput {
146
147
  export async function dispatchDelegate(
147
148
  input: DelegateDispatchInput,
148
149
  ): Promise<DelegateToolResult> {
150
+ // Settings are user-editable. Clear once at the dispatch boundary so every
151
+ // task in this batch observes one consistent settings snapshot.
152
+ clearDelegateSettingsCache();
149
153
  const {
150
154
  pi,
151
155
  params,
@@ -293,7 +297,6 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
293
297
  const asyncEnv: TaskRunEnv = {
294
298
  signal: ticketSignal,
295
299
  modelRegistry,
296
- parentSessionManager: ctx.sessionManager,
297
300
  ticketId,
298
301
  delegateStartedAt: ticket.created,
299
302
  telemetryCallId: callSpan?.id,
@@ -426,7 +429,6 @@ export async function dispatchSync(
426
429
  const syncEnv: TaskRunEnv = {
427
430
  signal,
428
431
  modelRegistry: ctx.modelRegistry,
429
- parentSessionManager: ctx.sessionManager,
430
432
  ticketId: undefined,
431
433
  delegateStartedAt: startedAt,
432
434
  telemetryCallId: callSpan?.id,
package/lifecycle.ts CHANGED
@@ -18,7 +18,6 @@ import { isSessionBusy } from "./tickets.ts";
18
18
  import {
19
19
  createSubagentSessionManager,
20
20
  persistSessionHeader,
21
- setParentSession,
22
21
  } from "./sessions.ts";
23
22
  import { runAgentSession, formatDeadlineExceededError } from "./runner.ts";
24
23
  import { getGitChangedFiles } from "./file-tracking.ts";
@@ -329,8 +328,8 @@ async function sleepForWholeTaskRetry(
329
328
  }
330
329
 
331
330
  /** Build the AgentSession for a fresh or resumed subagent via createAgentSession.
332
- * Reuses the caller-supplied sessionManager (so parent-linking + per-task .jsonl
333
- * files stay under our control). Extension-free host deps may be cached, while
331
+ * Reuses the caller-supplied sessionManager (so per-task .jsonl files stay under
332
+ * our control). Extension-free host deps may be cached, while
334
333
  * provider-configured or allowlisted-extension deps are session-local because
335
334
  * Pi binds mutable extension callbacks onto each loader runtime. */
336
335
  async function buildDelegateSession(
@@ -484,10 +483,6 @@ async function acquireAgentSession(
484
483
  };
485
484
  }
486
485
 
487
- // Link resumed session to parent for /resume discoverability.
488
- const parentFile = env.parentSessionManager?.getSessionFile?.();
489
- if (parentFile) setParentSession(resumed, parentFile);
490
-
491
486
  const session = await buildDelegateSession(
492
487
  task,
493
488
  resumed,
@@ -508,10 +503,7 @@ async function acquireAgentSession(
508
503
  // isolation. Keep scratch transcripts in memory only.
509
504
  sessionManager = SessionManager.inMemory(task.cwd);
510
505
  } else {
511
- const fresh = createSubagentSessionManager(
512
- env.parentSessionManager,
513
- task.cwd,
514
- );
506
+ const fresh = createSubagentSessionManager(task.cwd);
515
507
  if (!fresh) {
516
508
  return {
517
509
  error: failTask(task, "Internal: could not create session file"),
package/manual.ts CHANGED
@@ -39,7 +39,7 @@ function schemaTable(properties: Record<string, TSchema>): string {
39
39
  export function getSubagentManualMarkdown(
40
40
  agents: Map<string, AgentConfig>,
41
41
  ): string {
42
- const entries = [...agents];
42
+ const entries = [...agents].filter(([, a]) => !a.builtin);
43
43
  const agentList = entries.length
44
44
  ? entries
45
45
  .map(([n, a]) => {
@@ -95,11 +95,14 @@ export function getSubagentManualMarkdown(
95
95
  "- Git failures degrade to an empty diff.",
96
96
  "- A path missing from `touched:` does **not** mean the file was unchanged. Delegate does not isolate file access or roll back writes.",
97
97
  "",
98
- "## Built-in Agent",
98
+ "## Built-in Agents",
99
99
  "",
100
- "- **default**: mirrors the live parent model, thinking level, delegatable native tools, and base system prompt.",
100
+ "- **default**: mirrors the live parent model, thinking level, delegatable native tools, and base prompt. It uses the shared workspace.",
101
+ "- **scout**: investigates without modifying files. Tools: `read`, `grep`, `find`, `ls`. Shared workspace.",
102
+ "- **coder**: implements and verifies changes. Tools: `read`, `write`, `edit`, `bash`. Shared workspace.",
103
+ '- **reviewer**: reviews the current snapshot and reports findings. Tools: `read`, `bash`. Defaults to a disposable scratch workspace; set `workspace: "shared"` for a persistent reviewer with `sessionId`.',
101
104
  "",
102
- "Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context is rebuilt safely for the task's `cwd`; per-task fields remain explicit overrides.",
105
+ "Fresh built-ins inherit the parent's exact model object and thinking level unless task fields or settings override 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.",
103
106
  "",
104
107
  "## Available Custom Agents",
105
108
  "",
@@ -199,7 +202,7 @@ export function getSubagentManualMarkdown(
199
202
  "- Dispatch validation is batch-wide and runs before spawning: one invalid task rejects the call without starting its siblings.",
200
203
  "- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
201
204
  '- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
202
- '- Use `agent: "default"` for the parent\'s live model/thinking/native tools/base prompt. Omitting `agent` creates an ad-hoc task with delegate defaults.',
205
+ '- 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.',
203
206
  "- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
204
207
  "- 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.",
205
208
  `- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
@@ -211,7 +214,7 @@ export function getSubagentManualMarkdown(
211
214
  "",
212
215
  "## Config",
213
216
  "",
214
- "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 per-agent model overrides.",
217
+ "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.",
215
218
  "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.",
216
219
  "",
217
220
  `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.`,
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Move old pi-delegate sessions out of Pi's normal session index.
3
+ *
4
+ * This is intentionally a standalone migration, not extension startup code:
5
+ * `pi -r` indexes sessions before extensions are loaded.
6
+ *
7
+ * Usage:
8
+ * bun run migrate-delegate-sessions.ts # report only
9
+ * bun run migrate-delegate-sessions.ts --apply # unlink and move
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
14
+
15
+ type JsonObject = Record<string, unknown>;
16
+
17
+ const agentDir = getAgentDir();
18
+ const sourceDir = path.join(agentDir, "sessions");
19
+ const destinationDir = path.join(agentDir, "delegate-sessions");
20
+ const apply = process.argv.includes("--apply");
21
+
22
+ function isObject(value: unknown): value is JsonObject {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value);
24
+ }
25
+
26
+ function isWithin(root: string, candidate: string): boolean {
27
+ const relative = path.relative(root, candidate);
28
+ return (
29
+ relative === "" ||
30
+ (!relative.startsWith(`..${path.sep}`) &&
31
+ relative !== ".." &&
32
+ !path.isAbsolute(relative))
33
+ );
34
+ }
35
+
36
+ function readSession(file: string): JsonObject[] | undefined {
37
+ try {
38
+ const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
39
+ const entries: JsonObject[] = [];
40
+ for (const line of lines) {
41
+ if (!line.trim()) continue;
42
+ const parsed: unknown = JSON.parse(line);
43
+ if (!isObject(parsed)) return undefined;
44
+ entries.push(parsed);
45
+ }
46
+ return entries;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ function readSessionHeader(file: string): JsonObject | undefined {
53
+ try {
54
+ const firstLine = fs.readFileSync(file, "utf8").split(/\r?\n/, 1)[0];
55
+ const parsed: unknown = JSON.parse(firstLine);
56
+ return isObject(parsed) && parsed.type === "session" ? parsed : undefined;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ function sessionHeader(entries: JsonObject[]): JsonObject | undefined {
63
+ const header = entries[0];
64
+ return header?.type === "session" ? header : undefined;
65
+ }
66
+
67
+ function entryKey(entry: JsonObject): string {
68
+ return JSON.stringify(entry);
69
+ }
70
+
71
+ /**
72
+ * Pi's forkFrom() copies every non-header entry from the source session before
73
+ * writing anything new. Old delegate sessions do not copy the parent history.
74
+ * This is the discriminator: parentSession by itself is deliberately not
75
+ * sufficient because genuine Pi forks also have it.
76
+ */
77
+ function isPiFork(
78
+ childEntries: JsonObject[],
79
+ parentEntries: JsonObject[],
80
+ ): boolean {
81
+ const childBody = childEntries.slice(1);
82
+ const parentBody = parentEntries.slice(1);
83
+ if (parentBody.length === 0 || childBody.length < parentBody.length) {
84
+ return false;
85
+ }
86
+ return parentBody.every(
87
+ (entry, index) => entryKey(entry) === entryKey(childBody[index]),
88
+ );
89
+ }
90
+
91
+ function findJsonlFiles(directory: string): string[] {
92
+ if (!fs.existsSync(directory)) return [];
93
+ const files: string[] = [];
94
+ const visit = (current: string): void => {
95
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
96
+ const candidate = path.join(current, entry.name);
97
+ if (entry.isDirectory()) visit(candidate);
98
+ else if (entry.isFile() && entry.name.endsWith(".jsonl"))
99
+ files.push(candidate);
100
+ }
101
+ };
102
+ visit(directory);
103
+ return files;
104
+ }
105
+
106
+ interface MigrationCandidate {
107
+ source: string;
108
+ destination: string;
109
+ }
110
+
111
+ const candidates: MigrationCandidate[] = [];
112
+ let skipped = 0;
113
+ const parentCache = new Map<string, JsonObject[] | undefined>();
114
+
115
+ for (const file of findJsonlFiles(sourceDir)) {
116
+ const header = readSessionHeader(file);
117
+ const parent = header?.parentSession;
118
+ if (!header || typeof parent !== "string") continue;
119
+
120
+ const parentPath = path.resolve(parent);
121
+ if (!isWithin(sourceDir, parentPath) || !fs.existsSync(parentPath)) {
122
+ skipped++;
123
+ console.warn(`skip (parent unavailable): ${file}`);
124
+ continue;
125
+ }
126
+
127
+ let parentEntries = parentCache.get(parentPath);
128
+ if (parentEntries === undefined && !parentCache.has(parentPath)) {
129
+ parentEntries = readSession(parentPath);
130
+ parentCache.set(parentPath, parentEntries);
131
+ }
132
+ const entries = readSession(file);
133
+ if (!entries) {
134
+ skipped++;
135
+ console.warn(`skip (invalid session): ${file}`);
136
+ continue;
137
+ }
138
+ if (!parentEntries || isPiFork(entries, parentEntries)) continue;
139
+
140
+ const relative = path.relative(sourceDir, file);
141
+ candidates.push({
142
+ source: file,
143
+ destination: path.join(destinationDir, relative),
144
+ });
145
+ }
146
+
147
+ console.log(
148
+ `${apply ? "Migrating" : "Found"} ${candidates.length} delegate session(s); ` +
149
+ `${skipped} skipped because their parent could not be verified.`,
150
+ );
151
+
152
+ if (!apply) {
153
+ for (const candidate of candidates) {
154
+ console.log(`would move: ${candidate.source} -> ${candidate.destination}`);
155
+ }
156
+ console.log("Nothing changed. Re-run with --apply to perform the migration.");
157
+ process.exit(0);
158
+ }
159
+
160
+ let moved = 0;
161
+ for (const candidate of candidates) {
162
+ const entries = readSession(candidate.source);
163
+ const header = entries && sessionHeader(entries);
164
+ if (!entries || !header) {
165
+ console.warn(`skip (changed during migration): ${candidate.source}`);
166
+ continue;
167
+ }
168
+
169
+ delete header.parentSession;
170
+ const parent = path.dirname(candidate.destination);
171
+ fs.mkdirSync(parent, { recursive: true });
172
+
173
+ const temporary = `${candidate.source}.delegate-migration-${process.pid}.tmp`;
174
+ try {
175
+ if (fs.existsSync(candidate.destination)) {
176
+ throw new Error("destination already exists");
177
+ }
178
+ fs.writeFileSync(
179
+ temporary,
180
+ `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
181
+ { flag: "wx", mode: 0o600 },
182
+ );
183
+ fs.renameSync(temporary, candidate.source);
184
+ fs.renameSync(candidate.source, candidate.destination);
185
+ console.log(`moved: ${candidate.source} -> ${candidate.destination}`);
186
+ moved++;
187
+ } catch (error) {
188
+ try {
189
+ if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
190
+ } catch {
191
+ // Preserve the original error below; the temp file is harmless and
192
+ // uniquely named for this process.
193
+ }
194
+ console.error(
195
+ `failed: ${candidate.source}: ${
196
+ error instanceof Error ? error.message : String(error)
197
+ }`,
198
+ );
199
+ }
200
+ }
201
+
202
+ console.log(`Moved ${moved}/${candidates.length} delegate session(s).`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
package/schema.ts CHANGED
@@ -40,7 +40,7 @@ export const delegateTaskSchema = Type.Object({
40
40
  agent: Type.Optional(
41
41
  Type.String({
42
42
  description:
43
- "Use `default`: parent model/thinking/native tools/base prompt. Omit=ad-hoc; unknown fails call.",
43
+ "Built-ins: default, scout, coder, reviewer. Reviewer defaults to one-shot scratch. Omit for ad-hoc.",
44
44
  }),
45
45
  ),
46
46
  cwd: Type.Optional(
@@ -106,8 +106,7 @@ export const delegateTaskSchema = Type.Object({
106
106
  workspace: Type.Optional(
107
107
  StringEnum(["shared", "scratch"], {
108
108
  description:
109
- "scratch=disposable CoW project copy; relative edits are discarded. Not security isolation. Default=shared.",
110
- default: "shared",
109
+ "shared source; scratch disposable copy, one-shot; not security isolation. Reviewer=scratch; others=shared.",
111
110
  }),
112
111
  ),
113
112
  });
@@ -327,9 +326,10 @@ export function validateDelegateOperation(
327
326
  }
328
327
  if (
329
328
  task.workspace === "scratch" &&
329
+ !task.agent &&
330
330
  (task.sessionId || task.resumeFrom || sessionAction !== undefined)
331
331
  ) {
332
- return `task ${index + 1}: workspace 'scratch' is one-shot and cannot be combined with sessionId, resumeFrom, or sessionAction.`;
332
+ 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.`;
333
333
  }
334
334
  if (sessionAction === "close") {
335
335
  if (!task.sessionId) {
package/sessions.ts CHANGED
@@ -1,67 +1,26 @@
1
1
  import * as fs from "node:fs";
2
- import { SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import { join } from "node:path";
3
+ import { SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
3
4
 
4
- /** Link a subagent session to its parent and persist the header when possible. */
5
- export function setParentSession(sm: SessionManager, parentPath: string): void {
6
- const inner = sm as unknown as {
7
- fileEntries: Array<{ type: string; parentSession?: string }>;
8
- getSessionFile?: () => string | undefined;
9
- _rewriteFile?: () => void;
10
- };
11
- const header = inner.fileEntries[0];
12
- if (header && header.type === "session") {
13
- header.parentSession = parentPath;
14
- // For a *resumed* session the file already exists on disk and the manager
15
- // is flushed (SessionManager.open/setSessionFile sets flushed=true). The
16
- // in-memory header mutation above is otherwise lost: upstream _persist()
17
- // only *appends* new entries once flushed — it never rewrites the header.
18
- // So a resumeFrom session would never surface as a child in /resume despite
19
- // the link being set in memory. Rewrite the whole file (header + entries)
20
- // so the parentSession field is actually persisted. Fresh sessions skip
21
- // this (file doesn't exist yet); their first _persist() writes the mutated
22
- // header along with the rest, and rewriting early would trip the
23
- // duplicate-header bug in _persist()'s not-yet-flushed path.
24
- const file = inner.getSessionFile?.();
25
- if (file && fs.existsSync(file)) {
26
- try {
27
- inner._rewriteFile?.();
28
- } catch {
29
- /* best effort — link stays in-memory; not fatal */
30
- }
31
- }
32
- }
5
+ /** Persistent storage for delegate-only conversations. */
6
+ export function getDelegateSessionDir(): string {
7
+ return join(getAgentDir(), "delegate-sessions");
33
8
  }
34
9
 
35
10
  /**
36
11
  * Create a session manager for a subagent run.
37
12
  *
38
- * Always creates a standalone session file in the target cwd.
39
- * Sets `parentSession` in the header so subagent work is discoverable
40
- * as a child of the parent session in `/resume`.
41
- *
42
- * Returns the concrete `SessionManager` (ready to hand to `createAgentSession`)
43
- * and its file path (for result reporting + pool bookkeeping).
13
+ * Delegate sessions are deliberately standalone and live in their own
14
+ * directory. They are not attached to the parent's session tree.
44
15
  */
45
16
  export function createSubagentSessionManager(
46
- parentSessionManager: unknown,
47
17
  cwd: string,
48
18
  ): { manager: SessionManager; file: string } | undefined {
49
- // Resolve parent session file path for linking.
50
- const parentFile = (
51
- parentSessionManager as
52
- { getSessionFile?(): string | undefined } | undefined
53
- )?.getSessionFile?.();
54
-
55
- // Always persist subagent work so the main agent can search it later.
56
- const sm = SessionManager.create(cwd);
19
+ // Always persist subagent work separately from the parent's session tree.
20
+ const sm = SessionManager.create(cwd, getDelegateSessionDir());
57
21
  const sessionFile = sm.getSessionFile();
58
22
  if (!sessionFile) return undefined;
59
23
 
60
- // Link to parent session so subagent appears as a child in /resume.
61
- if (parentFile) {
62
- setParentSession(sm, parentFile);
63
- }
64
-
65
24
  return { manager: sm, file: sessionFile };
66
25
  }
67
26
 
package/settings.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
5
+ import { VALID_THINKING } from "./constants.ts";
6
+
7
+ export interface AgentOverride {
8
+ model?: string;
9
+ thinking?: ThinkingLevel;
10
+ tools?: string[];
11
+ }
4
12
 
5
13
  export interface DelegateSettings {
6
- agentOverrides?: Record<
7
- string,
8
- { model?: string; thinking?: string; tools?: string[]; skills?: string[] }
9
- >;
14
+ agentOverrides?: Record<string, AgentOverride>;
15
+ agentOverridesByParentModel?: Record<string, Record<string, AgentOverride>>;
10
16
  }
11
17
 
12
18
  /** Read and validate a JSON settings object, returning null on I/O or parse errors. */
@@ -16,29 +22,192 @@ export function readDelegateSettingsFile(
16
22
  try {
17
23
  const raw = fs.readFileSync(filePath, "utf-8");
18
24
  const parsed = JSON.parse(raw);
19
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
25
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
26
+ console.warn(
27
+ `[delegate] ignoring malformed settings file ${filePath}: expected a JSON object.`,
28
+ );
20
29
  return null;
30
+ }
21
31
  return parsed as Record<string, unknown>;
22
- } catch {
32
+ } catch (error) {
33
+ if (
34
+ error instanceof Error &&
35
+ "code" in error &&
36
+ (error as NodeJS.ErrnoException).code === "ENOENT"
37
+ ) {
38
+ return null;
39
+ }
40
+ console.warn(
41
+ `[delegate] could not read settings file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
42
+ );
23
43
  return null;
24
44
  }
25
45
  }
26
46
 
47
+ function isRecord(value: unknown): value is Record<string, unknown> {
48
+ return value !== null && typeof value === "object" && !Array.isArray(value);
49
+ }
50
+
51
+ function normalizeOverride(
52
+ raw: unknown,
53
+ source: string,
54
+ agentName: string,
55
+ ): AgentOverride | null {
56
+ if (!isRecord(raw)) {
57
+ console.warn(
58
+ `[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: expected an object.`,
59
+ );
60
+ return null;
61
+ }
62
+
63
+ const result: AgentOverride = {};
64
+ for (const [key, value] of Object.entries(raw)) {
65
+ if (key === "model") {
66
+ if (typeof value !== "string" || value.trim().length === 0) {
67
+ console.warn(
68
+ `[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: model must be a nonempty string.`,
69
+ );
70
+ return null;
71
+ }
72
+ result.model = value.trim();
73
+ } else if (key === "thinking") {
74
+ if (typeof value !== "string" || !VALID_THINKING.has(value)) {
75
+ console.warn(
76
+ `[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: thinking must be a supported level.`,
77
+ );
78
+ return null;
79
+ }
80
+ result.thinking = value as ThinkingLevel;
81
+ } else if (key === "tools") {
82
+ if (
83
+ !Array.isArray(value) ||
84
+ value.some((tool) => typeof tool !== "string")
85
+ ) {
86
+ console.warn(
87
+ `[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: tools must be a string array.`,
88
+ );
89
+ return null;
90
+ }
91
+ result.tools = [...value];
92
+ } else if (key === "skills") {
93
+ console.warn(
94
+ `[delegate] ignoring unsupported skills override for agent '${agentName}' in ${source}: per-agent skill filtering is not supported.`,
95
+ );
96
+ return null;
97
+ } else {
98
+ console.warn(
99
+ `[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: unknown field '${key}'.`,
100
+ );
101
+ return null;
102
+ }
103
+ }
104
+ return result;
105
+ }
106
+
107
+ function normalizeOverrides(
108
+ raw: unknown,
109
+ source: string,
110
+ ): Record<string, AgentOverride> {
111
+ if (!isRecord(raw)) {
112
+ console.warn(
113
+ `[delegate] ignoring malformed agentOverrides in ${source}: expected an object.`,
114
+ );
115
+ return {};
116
+ }
117
+
118
+ const result: Record<string, AgentOverride> = {};
119
+ const seenNames = new Map<string, string>();
120
+ for (const [agentName, value] of Object.entries(raw)) {
121
+ const normalizedAgentName = agentName.trim();
122
+ if (normalizedAgentName.length === 0) {
123
+ console.warn(
124
+ `[delegate] ignoring malformed settings override in ${source}: agent name must be nonempty.`,
125
+ );
126
+ continue;
127
+ }
128
+ const previousName = seenNames.get(normalizedAgentName);
129
+ if (previousName !== undefined) {
130
+ console.warn(
131
+ `[delegate] ignoring duplicate settings override in ${source}: agent keys '${previousName}' and '${agentName}' both normalize to '${normalizedAgentName}'.`,
132
+ );
133
+ continue;
134
+ }
135
+ seenNames.set(normalizedAgentName, agentName);
136
+ const override = normalizeOverride(value, source, normalizedAgentName);
137
+ if (override) result[normalizedAgentName] = override;
138
+ }
139
+ return result;
140
+ }
141
+
142
+ function normalizeOverridesByParentModel(
143
+ raw: unknown,
144
+ source: string,
145
+ ): Record<string, Record<string, AgentOverride>> {
146
+ if (!isRecord(raw)) {
147
+ console.warn(
148
+ `[delegate] ignoring malformed agentOverridesByParentModel in ${source}: expected an object.`,
149
+ );
150
+ return {};
151
+ }
152
+
153
+ const result: Record<string, Record<string, AgentOverride>> = {};
154
+ const seenModels = new Map<string, string>();
155
+ for (const [parentModel, overrides] of Object.entries(raw)) {
156
+ const normalizedParentModel = parentModel.trim();
157
+ if (normalizedParentModel.length === 0) {
158
+ console.warn(
159
+ `[delegate] ignoring malformed parent-model override in ${source}: model key must be nonempty.`,
160
+ );
161
+ continue;
162
+ }
163
+ const previousModel = seenModels.get(normalizedParentModel);
164
+ if (previousModel !== undefined) {
165
+ console.warn(
166
+ `[delegate] ignoring duplicate parent-model override in ${source}: model keys '${previousModel}' and '${parentModel}' both normalize to '${normalizedParentModel}'.`,
167
+ );
168
+ continue;
169
+ }
170
+ seenModels.set(normalizedParentModel, parentModel);
171
+ result[normalizedParentModel] = normalizeOverrides(
172
+ overrides,
173
+ `${source} (parent model '${normalizedParentModel}')`,
174
+ );
175
+ }
176
+ return result;
177
+ }
178
+
27
179
  function getDelegateSettings(filePath: string): DelegateSettings | null {
28
180
  const settings = readDelegateSettingsFile(filePath);
29
181
  if (
30
182
  !settings?.delegate ||
31
183
  typeof settings.delegate !== "object" ||
32
184
  Array.isArray(settings.delegate)
33
- )
185
+ ) {
186
+ if (settings?.delegate !== undefined) {
187
+ console.warn(
188
+ `[delegate] ignoring malformed delegate settings in ${filePath}: expected an object.`,
189
+ );
190
+ }
34
191
  return null;
35
- return settings.delegate as DelegateSettings;
192
+ }
193
+ const raw = settings.delegate as Record<string, unknown>;
194
+ const result: DelegateSettings = {};
195
+ if (raw.agentOverrides !== undefined) {
196
+ result.agentOverrides = normalizeOverrides(raw.agentOverrides, filePath);
197
+ }
198
+ if (raw.agentOverridesByParentModel !== undefined) {
199
+ result.agentOverridesByParentModel = normalizeOverridesByParentModel(
200
+ raw.agentOverridesByParentModel,
201
+ filePath,
202
+ );
203
+ }
204
+ return result;
36
205
  }
37
206
 
38
207
  const delegateSettingsCache = new Map<string, DelegateSettings | null>();
39
208
 
40
209
  /** Load merged delegate settings: project overrides user.
41
- * Result is cached per cwd for the lifetime of the delegate call. */
210
+ * Result is cached per cwd until the next delegate dispatch clears it. */
42
211
  export function loadDelegateSettings(cwd: string): DelegateSettings | null {
43
212
  const key = path.resolve(cwd);
44
213
  const cached = delegateSettingsCache.get(key);
@@ -68,11 +237,64 @@ export function loadDelegateSettings(cwd: string): DelegateSettings | null {
68
237
  return null;
69
238
  }
70
239
  const result: DelegateSettings = {
71
- agentOverrides: {
72
- ...(user?.agentOverrides ?? {}),
73
- ...(project?.agentOverrides ?? {}),
74
- },
240
+ ...(user?.agentOverrides || project?.agentOverrides
241
+ ? {
242
+ agentOverrides: mergeOverrides(
243
+ user?.agentOverrides,
244
+ project?.agentOverrides,
245
+ ),
246
+ }
247
+ : {}),
248
+ ...(user?.agentOverridesByParentModel ||
249
+ project?.agentOverridesByParentModel
250
+ ? {
251
+ agentOverridesByParentModel: mergeParentModelOverrides(
252
+ user?.agentOverridesByParentModel,
253
+ project?.agentOverridesByParentModel,
254
+ ),
255
+ }
256
+ : {}),
75
257
  };
76
258
  delegateSettingsCache.set(key, result);
77
259
  return result;
78
260
  }
261
+
262
+ function mergeOverride(
263
+ base: AgentOverride | undefined,
264
+ override: AgentOverride | undefined,
265
+ ): AgentOverride {
266
+ return { ...(base ?? {}), ...(override ?? {}) };
267
+ }
268
+
269
+ function mergeOverrides(
270
+ user: Record<string, AgentOverride> | undefined,
271
+ project: Record<string, AgentOverride> | undefined,
272
+ ): Record<string, AgentOverride> {
273
+ const result: Record<string, AgentOverride> = {};
274
+ for (const name of new Set([
275
+ ...Object.keys(user ?? {}),
276
+ ...Object.keys(project ?? {}),
277
+ ])) {
278
+ result[name] = mergeOverride(user?.[name], project?.[name]);
279
+ }
280
+ return result;
281
+ }
282
+
283
+ function mergeParentModelOverrides(
284
+ user: Record<string, Record<string, AgentOverride>> | undefined,
285
+ project: Record<string, Record<string, AgentOverride>> | undefined,
286
+ ): Record<string, Record<string, AgentOverride>> {
287
+ const result: Record<string, Record<string, AgentOverride>> = {};
288
+ for (const model of new Set([
289
+ ...Object.keys(user ?? {}),
290
+ ...Object.keys(project ?? {}),
291
+ ])) {
292
+ result[model] = mergeOverrides(user?.[model], project?.[model]);
293
+ }
294
+ return result;
295
+ }
296
+
297
+ /** Clear settings read from earlier delegate calls so edits are visible. */
298
+ export function clearDelegateSettingsCache(): void {
299
+ delegateSettingsCache.clear();
300
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
3
  import {
4
+ BUILTIN_AGENT_NAMES,
4
5
  DEFAULT_AGENT_NAME,
5
6
  DEFAULT_TOOLS,
6
7
  VALID_THINKING,
@@ -8,7 +9,7 @@ import {
8
9
  import { TOOL_FACTORIES, resolveToolGroups } from "./tools.ts";
9
10
  import { configFor } from "./pool.ts";
10
11
  import { isSessionBusy } from "./tickets.ts";
11
- import { buildSubagentSystemPrompt } from "./agents.ts";
12
+ import { BUILTIN_AGENT_CONFIGS, buildSubagentSystemPrompt } from "./agents.ts";
12
13
  import { buildParentTranscript } from "./parent-context.ts";
13
14
  import { findAvailableAlternative, resolveModelRequest } from "./model.ts";
14
15
  import { resolveModelSpec } from "./config.ts";
@@ -91,6 +92,52 @@ export function validateTasks(
91
92
  agents: Map<string, AgentConfig>,
92
93
  parentModelId: string | undefined,
93
94
  ): DelegateToolResult | null {
95
+ const unknown: string[] = [];
96
+ for (const task of tasks) {
97
+ if (
98
+ task.agent &&
99
+ !(BUILTIN_AGENT_NAMES as readonly string[]).includes(task.agent) &&
100
+ !agents.has(task.agent)
101
+ ) {
102
+ unknown.push(task.agent);
103
+ }
104
+ }
105
+ if (unknown.length) {
106
+ const names = [...new Set([...BUILTIN_AGENT_NAMES, ...agents.keys()])];
107
+ return noticeResult(
108
+ `Unknown agent(s): ${unknown.join(", ")}. Available: ${names.join(", ") || "(none)"}. Call delegate with an empty tasks array for help.`,
109
+ tasks,
110
+ parentModelId,
111
+ );
112
+ }
113
+
114
+ // Scratch sessions are deliberately one-shot. This check uses the
115
+ // effective workspace, so reviewer gets the same protection even when the
116
+ // caller omits workspace. Explicit scratch is never silently promoted to
117
+ // shared.
118
+ for (const [index, task] of tasks.entries()) {
119
+ const agent = task.agent
120
+ ? (agents.get(task.agent) ?? BUILTIN_AGENT_CONFIGS[task.agent])
121
+ : undefined;
122
+ const workspace = task.workspace ?? agent?.workspace ?? "shared";
123
+ const sessionAction = task.sessionAction ?? task.action;
124
+ if (
125
+ workspace === "scratch" &&
126
+ (task.sessionId || task.resumeFrom || sessionAction !== undefined)
127
+ ) {
128
+ const defaultText =
129
+ task.workspace === undefined && agent?.workspace === "scratch"
130
+ ? "defaults to workspace `scratch`"
131
+ : "uses workspace `scratch`";
132
+ const persistentAgent = task.agent ?? "agent";
133
+ return noticeResult(
134
+ `${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}.`,
135
+ tasks,
136
+ parentModelId,
137
+ );
138
+ }
139
+ }
140
+
94
141
  // Disallow same sessionId across multiple parallel tasks (one agent can't serve two prompts concurrently).
95
142
  const sessionIds = tasks.map((t) => t.sessionId).filter(Boolean) as string[];
96
143
  const duplicateSessions = sessionIds.filter(
@@ -136,21 +183,6 @@ export function validateTasks(
136
183
  return noticeResult(duplicateIds.join(" "), tasks, parentModelId);
137
184
  }
138
185
 
139
- const unknown: string[] = [];
140
- for (const t of tasks) {
141
- if (t.agent && t.agent !== DEFAULT_AGENT_NAME && !agents.has(t.agent)) {
142
- unknown.push(t.agent);
143
- }
144
- }
145
- if (unknown.length) {
146
- const names = [DEFAULT_AGENT_NAME, ...agents.keys()];
147
- return noticeResult(
148
- `Unknown agent(s): ${unknown.join(", ")}. Available: ${names.join(", ") || "(none)"}. Call delegate with an empty tasks array for help.`,
149
- tasks,
150
- parentModelId,
151
- );
152
- }
153
-
154
186
  return null;
155
187
  }
156
188
 
@@ -187,11 +219,21 @@ export function resolveTasks(
187
219
 
188
220
  return tasks.map((t, i) => {
189
221
  const isDefaultAgent = t.agent === DEFAULT_AGENT_NAME;
190
- const agent = t.agent && !isDefaultAgent ? agents.get(t.agent) : undefined;
222
+ const agent = t.agent
223
+ ? (agents.get(t.agent) ?? BUILTIN_AGENT_CONFIGS[t.agent])
224
+ : undefined;
225
+ const isBuiltinAgent = agent?.builtin === true;
191
226
  const cwd = resolveCwd(t.cwd ?? ctx.cwd, ctx.cwd);
192
227
 
193
228
  // Load settings-based overrides for this agent
194
229
  const settings = loadDelegateSettings(cwd);
230
+ const parentModelKey = ctx.model
231
+ ? `${ctx.model.provider}/${ctx.model.id}`
232
+ : undefined;
233
+ const parentModelOverride =
234
+ t.agent && !isDefaultAgent && parentModelKey
235
+ ? settings?.agentOverridesByParentModel?.[parentModelKey]?.[t.agent]
236
+ : undefined;
195
237
  const agentOverride =
196
238
  t.agent && !isDefaultAgent && settings?.agentOverrides?.[t.agent]
197
239
  ? settings.agentOverrides[t.agent]
@@ -208,7 +250,8 @@ export function resolveTasks(
208
250
  );
209
251
  let tools: string[] = [];
210
252
  const warnings: string[] = [];
211
- if (t.workspace === "scratch") {
253
+ const workspace = t.workspace ?? agent?.workspace ?? "shared";
254
+ if (workspace === "scratch") {
212
255
  warnings.push(
213
256
  "Scratch workspace: relative file changes run in a disposable CoW copy and are discarded.",
214
257
  );
@@ -233,9 +276,11 @@ export function resolveTasks(
233
276
  if (t.sessionAction !== "close" && t.sessionAction !== "list") {
234
277
  tools = resolveToolGroups(
235
278
  t.tools ??
279
+ parentModelOverride?.tools ??
236
280
  agentOverride?.tools ??
237
- agent?.tools ??
238
281
  (isDefaultAgent ? parentNativeTools : undefined) ??
282
+ (isBuiltinAgent ? agent?.tools : undefined) ??
283
+ agent?.tools ??
239
284
  (isPoolHit ? pooledConfig?.tools : undefined) ??
240
285
  DEFAULT_TOOLS,
241
286
  );
@@ -318,20 +363,32 @@ export function resolveTasks(
318
363
  let thinking: ThinkingLevel = "off";
319
364
 
320
365
  if (t.sessionAction !== "close" && t.sessionAction !== "list") {
366
+ const agentType = t.agent ?? "inline";
367
+ // The built-in `default` profile bypasses delegate/settings model
368
+ // overrides for backwards compatibility. The other built-ins accept
369
+ // task and settings.json model overrides, but deliberately ignore the
370
+ // legacy delegate.json agent model map so they inherit the parent unless
371
+ // an explicit modern override wins.
372
+ const modelSpec = isDefaultAgent
373
+ ? t.model
374
+ : isBuiltinAgent
375
+ ? (t.model ?? parentModelOverride?.model ?? agentOverride?.model)
376
+ : resolveModelSpec({
377
+ taskModel:
378
+ t.model ?? parentModelOverride?.model ?? agentOverride?.model,
379
+ agentType,
380
+ frontmatterModel: agent?.model,
381
+ });
382
+
321
383
  // A pool hit always runs its frozen model, but an explicitly requested
322
384
  // task/profile model still has to be resolved so checkout can reject a
323
385
  // contradictory request rather than silently discarding it. Naming the
324
386
  // built-in `default` profile is also explicit: it requests the live
325
387
  // parent model, so reuse fails clearly if the pool was frozen differently.
326
388
  if (pooledConfig) {
327
- const requestedModelSpec =
328
- t.model ??
329
- (t.agent && !isDefaultAgent
330
- ? (agentOverride?.model ?? agent?.model)
331
- : undefined);
332
- if (requestedModelSpec) {
389
+ if (modelSpec) {
333
390
  const requested = resolveModelRequest(
334
- requestedModelSpec,
391
+ modelSpec,
335
392
  ctx.modelRegistry,
336
393
  ctx.model,
337
394
  );
@@ -339,7 +396,7 @@ export function resolveTasks(
339
396
  modelSuffix = requested.strippedSuffix;
340
397
  if (!requestedModel) {
341
398
  throw new Error(
342
- `${formatTaskRef(i, t.id)}: requested model '${requestedModelSpec}' is not available. Check provider config or remove the model field to continue the pooled session.`,
399
+ `${formatTaskRef(i, t.id)}: requested model '${modelSpec}' is not available. Check provider config or remove the model field to continue the pooled session.`,
343
400
  );
344
401
  }
345
402
  } else if (isDefaultAgent) {
@@ -347,17 +404,6 @@ export function resolveTasks(
347
404
  }
348
405
  model = pooledConfig.model;
349
406
  } else {
350
- // The built-in `default` profile bypasses delegate.json and settings:
351
- // absent a task override, it means this exact live parent Model object.
352
- // Other tasks retain the normal task > config > frontmatter chain.
353
- const agentType = t.agent ?? "inline";
354
- const modelSpec = isDefaultAgent
355
- ? t.model
356
- : resolveModelSpec({
357
- taskModel: t.model ?? agentOverride?.model,
358
- agentType,
359
- frontmatterModel: agent?.model,
360
- });
361
407
  const resolvedRequest = modelSpec
362
408
  ? resolveModelRequest(modelSpec, ctx.modelRegistry, ctx.model)
363
409
  : undefined;
@@ -376,7 +422,7 @@ export function resolveTasks(
376
422
  );
377
423
  }
378
424
 
379
- model = isDefaultAgent
425
+ model = isBuiltinAgent
380
426
  ? (resolvedModel ?? ctx.model)
381
427
  : (resolvedModel ??
382
428
  findAvailableAlternative(ctx.model, ctx.modelRegistry) ??
@@ -398,15 +444,28 @@ export function resolveTasks(
398
444
  // changed, rather than silently reusing a stale frozen value. The final
399
445
  // pooled fallback is reachable only when parentDefaults.thinking is
400
446
  // undefined (headless parent without a thinking level).
401
- const thinkingRaw =
402
- t.thinking ??
403
- agentOverride?.thinking ??
404
- agent?.thinking ??
405
- (isPoolHit && !isDefaultAgent ? pooledConfig?.thinking : undefined) ??
406
- modelSuffix ??
407
- (isDefaultAgent ? parentDefaults.thinking : undefined) ??
408
- (isPoolHit ? pooledConfig?.thinking : undefined) ??
409
- "off";
447
+ const thinkingRaw = isBuiltinAgent
448
+ ? isDefaultAgent
449
+ ? (t.thinking ??
450
+ parentModelOverride?.thinking ??
451
+ agentOverride?.thinking ??
452
+ modelSuffix ??
453
+ parentDefaults.thinking ??
454
+ (isPoolHit ? pooledConfig?.thinking : undefined) ??
455
+ "off")
456
+ : (t.thinking ??
457
+ parentModelOverride?.thinking ??
458
+ agentOverride?.thinking ??
459
+ (isPoolHit ? pooledConfig?.thinking : undefined) ??
460
+ modelSuffix ??
461
+ parentDefaults.thinking ??
462
+ "off")
463
+ : (t.thinking ??
464
+ agentOverride?.thinking ??
465
+ agent?.thinking ??
466
+ (isPoolHit ? pooledConfig?.thinking : undefined) ??
467
+ modelSuffix ??
468
+ "off");
410
469
  thinking = VALID_THINKING.has(thinkingRaw)
411
470
  ? (thinkingRaw as ThinkingLevel)
412
471
  : "off";
@@ -423,7 +482,7 @@ export function resolveTasks(
423
482
  ...t,
424
483
  id: t.id,
425
484
  cwd,
426
- workspace: t.workspace ?? "shared",
485
+ workspace,
427
486
  systemPrompt,
428
487
  model: model!,
429
488
  tools,
@@ -433,9 +492,7 @@ export function resolveTasks(
433
492
  prompt: prompt ?? "",
434
493
  // Keep the built-in selector visible in progress/results. Omitted-agent
435
494
  // inline tasks retain the established `ad-hoc` label and config namespace.
436
- agentName: isDefaultAgent
437
- ? DEFAULT_AGENT_NAME
438
- : (agent?.name ?? "ad-hoc"),
495
+ agentName: agent?.name ?? "ad-hoc",
439
496
  warnings,
440
497
  reuseIntent: {
441
498
  model: requestedModel,
package/types.ts CHANGED
@@ -18,9 +18,15 @@ export interface AgentConfig {
18
18
  name: string;
19
19
  description: string;
20
20
  model?: string;
21
- thinking: ThinkingLevel;
21
+ /** Markdown agents default invalid/omitted values to "off". Built-ins omit
22
+ * this field so they can inherit the parent's thinking level. */
23
+ thinking?: ThinkingLevel;
22
24
  tools: string[];
23
25
  systemPrompt: string;
26
+ /** Built-in profiles are immutable and cannot be shadowed by Markdown. */
27
+ builtin?: boolean;
28
+ /** Default workspace for a built-in profile. Custom agents use shared. */
29
+ workspace?: WorkspaceMode;
24
30
  /** Origin of the profile. `claude` denotes imported .claude/agents files. */
25
31
  scope?: "project" | "global" | "claude";
26
32
  }
@@ -251,8 +257,6 @@ export interface TaskRunEnv {
251
257
  /** Abort signal — parent's for sync, ticket's for async. May be undefined when no parent signal is available. */
252
258
  signal: AbortSignal | undefined;
253
259
  modelRegistry: ModelRegistry;
254
- /** Parent session manager — used to link subagent sessions for /resume. */
255
- parentSessionManager: { getSessionFile?(): string | undefined } | undefined;
256
260
  /** Ticket id for busy-guard self-checks. undefined for sync. */
257
261
  ticketId?: string;
258
262
  /** When the delegate started. Used for close/list progress (elapsed time). */