@cr1ms0n/pi-subagent 0.8.8 → 0.9.0

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/src/policy.ts CHANGED
@@ -1,561 +1,562 @@
1
- import * as path from "node:path";
2
- import type { AgentDefinition } from "./agents.js";
3
- import { resolveAgent } from "./agents.js";
4
- import { isPlausibleSchema, repairDoubleEncodedText } from "./structured.js";
5
- import { defaultConfig, type TaskDefaults, type TaskDefaultsByProfile } from "./config.js";
6
- import type { OutputMode, TaskProfile, TaskSpec } from "./types.js";
7
- import type { ParallelTaskInput, SubagentParams } from "./schema.js";
8
- import { BACKEND_NAMES, checkCapabilities, type BackendName } from "./backend.js";
9
- import { resolveBackend } from "./backends/index.js";
10
- import { validateModelRequest, type ModelPolicySnapshot } from "./model-policy.js";
11
- import { isThinkingLevel } from "./thinking.js";
12
- import {
13
- CONTEXT_MANAGEMENT_TOOLS,
14
- EMPTY_CONTEXT_MANAGEMENT_POLICY,
15
- contextToolsForModel,
16
- isGatewayContextModel,
17
- type ContextManagementPolicy,
18
- } from "./context-policy.js";
19
-
20
- export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
21
- export const SPAWNS_ENV_VAR = "PI_SUBAGENT_SPAWNS";
22
- export const READ_ONLY_TOOLS = new Set([
23
- "read",
24
- "grep",
25
- "find",
26
- "ls",
27
- "fffind",
28
- "ffgrep",
29
- "fff-multi-grep",
30
- "firecrawl_scrape",
31
- "firecrawl_search",
32
- "firecrawl_map",
33
- "firecrawl_crawl",
34
- "web_search",
35
- "web_fetch",
36
- ]);
37
- const NON_WRITING_TOOLS = new Set([...READ_ONLY_TOOLS, ...CONTEXT_MANAGEMENT_TOOLS]);
38
- export { CONTEXT_MANAGEMENT_TOOLS };
39
- export const KNOWN_WRITE_TOOLS = new Set(["bash", "edit", "write"]);
40
- /** Backward-compatible export; policy uses fail-closed classification above. */
41
- export const WRITE_TOOLS = KNOWN_WRITE_TOOLS;
42
-
43
- export interface ParentContext {
44
- cwd: string;
45
- model?: string;
46
- thinking?: TaskSpec["thinking"];
47
- availableTools: string[];
48
- activeTools?: string[];
49
- depth?: number;
50
- /** Persisted parent session file; required for context:'fork'. */
51
- sessionFile?: string;
52
- }
53
-
54
- export interface ResolvedTask extends TaskSpec {
55
- label: string;
56
- canWrite: boolean;
57
- effectiveTools: string[];
58
- resolutionNotes: string[];
59
- }
60
-
61
- export type ManagementMode = "status" | "wait" | "cancel" | "steer" | "diff" | "apply" | "discard";
62
-
63
- export type ValidationResult =
64
- | {
65
- ok: true;
66
- mode: "single" | "parallel" | ManagementMode;
67
- async: boolean;
68
- id?: string;
69
- message?: string;
70
- index?: number;
71
- synthesis?: string;
72
- tasks: ResolvedTask[];
73
- /** True when action:"plan" requested a dry-run — no spawn. */
74
- planOnly?: boolean;
75
- }
76
- | { ok: false; error: string };
77
-
78
- function resolvePath(cwd: string, value?: string): string {
79
- return value ? (path.isAbsolute(value) ? path.normalize(value) : path.resolve(cwd, value)) : path.resolve(cwd);
80
- }
81
-
82
- function resolveTools(
83
- profile: TaskProfile,
84
- requested: string[] | undefined,
85
- availableTools: string[],
86
- activeTools: string[],
87
- backend: BackendName,
88
- contextPolicy: ContextManagementPolicy = EMPTY_CONTEXT_MANAGEMENT_POLICY,
89
- targetModel: string | undefined = undefined,
90
- ): { tools?: string[]; canWrite?: boolean; error?: string } {
91
- const isPi = backend === "pi";
92
- // Remote Context eligibility belongs to the exact target model, never to the
93
- // parent session: a parent that exposes context tools is not authorization
94
- // for a different child model.
95
- const eligible = isPi && isGatewayContextModel(contextPolicy, targetModel);
96
- const contextTools = eligible ? contextToolsForModel(contextPolicy, targetModel, availableTools) : [];
97
- const nonWritingTools = isPi ? NON_WRITING_TOOLS : READ_ONLY_TOOLS;
98
- // A non-eligible Pi target must not see context names at all: not inherited
99
- // from the parent's active tools, not in the read-only default set, and not
100
- // as a valid explicit request.
101
- const availableForTarget = new Set(availableTools);
102
- if (isPi && !eligible) {
103
- for (const tool of CONTEXT_MANAGEMENT_TOOLS) availableForTarget.delete(tool);
104
- }
105
- const addContextTools = (tools: readonly string[]): string[] =>
106
- [...new Set([...tools, ...contextTools])];
107
- const dropContextTools = (tools: readonly string[]): string[] => {
108
- if (!isPi || eligible) return [...tools];
109
- return tools.filter((tool) => !CONTEXT_MANAGEMENT_TOOLS.has(tool));
110
- };
111
-
112
- if (requested) {
113
- // An explicit context name on a non-eligible target is unavailable, not
114
- // silently dropped — the caller gets the existing rejection channel.
115
- const unknown = requested.filter((tool) => !availableForTarget.has(tool));
116
- if (unknown.length) return { error: `Unknown or unavailable tools: ${unknown.join(", ")}` };
117
- }
118
-
119
- if (profile === "explore" || profile === "review") {
120
- // Non-writing defaults come from READ_ONLY_TOOLS; context tools are added
121
- // back only through the eligibility-gated addContextTools above.
122
- const source = addContextTools(
123
- requested ?? [...READ_ONLY_TOOLS].filter((tool) => availableForTarget.has(tool)),
124
- );
125
- const unsafe = source.filter((tool) => !nonWritingTools.has(tool));
126
- if (unsafe.length) {
127
- return {
128
- error: `${profile} is strictly read-only. Unclassified or writable tools are not allowed: ${unsafe.join(", ")}`,
129
- };
130
- }
131
- return { tools: source, canWrite: false };
132
- }
133
-
134
- const source = dropContextTools(addContextTools(requested ?? activeTools));
135
- const unknown = source.filter((tool) => !availableForTarget.has(tool));
136
- if (unknown.length) return { error: `Active tools are unavailable: ${unknown.join(", ")}` };
137
- // General-profile custom tools are conservatively write-capable unless explicitly known non-writing.
138
- return {
139
- tools: source,
140
- canWrite: source.some((tool) => KNOWN_WRITE_TOOLS.has(tool) || !nonWritingTools.has(tool)),
141
- };
142
- }
143
-
144
- function normalizeTask(
145
- item: {
146
- task: string;
147
- agent?: string;
148
- description?: string;
149
- system_prompt?: string;
150
- model?: string;
151
- thinking?: TaskSpec["thinking"];
152
- tools?: string[];
153
- profile?: TaskProfile;
154
- cwd?: string;
155
- timeout_ms?: number;
156
- max_turns?: number;
157
- max_cost?: number;
158
- grace_turns?: number;
159
- fallback_models?: string[];
160
- max_retries?: number;
161
- context?: "fresh" | "fork";
162
- output?: string;
163
- output_mode?: OutputMode;
164
- output_schema?: Record<string, unknown>;
165
- resume?: string;
166
- fork_resume?: boolean;
167
- isolation?: "shared" | "worktree";
168
- allow_shared_writes?: boolean;
169
- keep_background?: boolean;
170
- include_wip?: boolean;
171
- backend?: BackendName;
172
- },
173
- index: number,
174
- parent: ParentContext,
175
- defaultProfile: TaskProfile,
176
- defaults: { timeoutMs?: number; taskDefaults?: TaskDefaultsByProfile; agents?: Map<string, AgentDefinition>; modelPolicy?: ModelPolicySnapshot; modelPolicyError?: string; contextPolicy?: ContextManagementPolicy } = {},
177
- ): { task?: ResolvedTask; error?: string } {
178
- if (!item.task?.trim()) return { error: `Task ${index + 1} must not be blank` };
179
-
180
- // Named agent resolution is still used for persona/profile/tool behavior;
181
- // its legacy model/fallback fields are deliberately ignored below.
182
- // request params still win field-by-field. The agent body is the child's
183
- // system prompt; an explicit system_prompt is appended after it.
184
- let agent: AgentDefinition | undefined;
185
- if ((item as { agent?: string }).agent) {
186
- const lookup = resolveAgent(defaults.agents ?? new Map(), (item as { agent?: string }).agent!);
187
- if (!lookup.agent) return { error: `Task ${index + 1}: ${lookup.error}` };
188
- agent = lookup.agent;
189
- }
190
- if (!defaults.modelPolicy) {
191
- return { error: defaults.modelPolicyError ?? "Model policy is not configured. Add modelPolicy.default to ~/.pi/subagent.json; management actions remain available, but new spawns are rejected." };
192
- }
193
- const modelCheck = validateModelRequest(defaults.modelPolicy, {
194
- agent: agent?.name ?? item.agent,
195
- model: item.model,
196
- fallbackModels: item.fallback_models,
197
- fallbackModelsProvided: item.fallback_models !== undefined,
198
- });
199
- if (modelCheck.error || !modelCheck.route) return { error: `Task ${index + 1}: ${modelCheck.error ?? "model policy validation failed"}` };
200
- const modelRoute = modelCheck.route;
201
- if (item.output_mode && !item.output) return { error: `Task ${index + 1}: output_mode requires output` };
202
- if (item.fork_resume && !item.resume) return { error: `Task ${index + 1}: fork_resume requires resume` };
203
- if (item.timeout_ms !== undefined && (!Number.isInteger(item.timeout_ms) || item.timeout_ms < 1)) {
204
- return { error: `Task ${index + 1}: timeout_ms must be a positive integer` };
205
- }
206
- if (item.max_turns !== undefined && (!Number.isInteger(item.max_turns) || item.max_turns < 1)) {
207
- return { error: `Task ${index + 1}: max_turns must be a positive integer` };
208
- }
209
- if (item.max_cost !== undefined && (!Number.isFinite(item.max_cost) || item.max_cost < 0)) {
210
- return { error: `Task ${index + 1}: max_cost must be >= 0` };
211
- }
212
- if (item.grace_turns !== undefined && (!Number.isInteger(item.grace_turns) || item.grace_turns < 0)) {
213
- return { error: `Task ${index + 1}: grace_turns must be a non-negative integer` };
214
- }
215
- if (item.max_retries !== undefined && (!Number.isInteger(item.max_retries) || item.max_retries < 0)) {
216
- return { error: `Task ${index + 1}: max_retries must be a non-negative integer` };
217
- }
218
- if (item.context === "fork") {
219
- if (item.resume) return { error: `Task ${index + 1}: context:'fork' cannot be combined with resume (resume already carries its own context)` };
220
- if (!parent.sessionFile) {
221
- return { error: `Task ${index + 1}: context:'fork' requires a persisted parent session; this session has no session file. Use context:'fresh'.` };
222
- }
223
- }
224
- if (item.output_schema !== undefined && !isPlausibleSchema(item.output_schema)) {
225
- return { error: `Task ${index + 1}: output_schema must be a JSON Schema object (type/properties/required)` };
226
- }
227
- if (item.include_wip === true) {
228
- const isolation = item.isolation ?? agent?.isolation ?? "shared";
229
- if (isolation !== "worktree") {
230
- return { error: `Task ${index + 1}: include_wip requires isolation:"worktree" (dirty-baseline works only on isolated worktrees)` };
231
- }
232
- }
233
-
234
- const backend: BackendName = item.backend ?? agent?.backend ?? "pi";
235
- if (!BACKEND_NAMES.includes(backend)) {
236
- return { error: `Task ${index + 1}: unknown backend '${backend}' (expected ${BACKEND_NAMES.join(", ")})` };
237
- }
238
- const profile = item.profile ?? agent?.profile ?? defaultProfile;
239
- const requestedTools = item.tools ?? agent?.tools;
240
- const resolved = resolveTools(
241
- profile,
242
- requestedTools,
243
- parent.availableTools,
244
- parent.activeTools ?? parent.availableTools,
245
- backend,
246
- defaults.contextPolicy ?? EMPTY_CONTEXT_MANAGEMENT_POLICY,
247
- // The target model is the policy-routed model; the parent's model is not
248
- // authorization for a different child.
249
- modelRoute.model,
250
- );
251
- if (resolved.error || !resolved.tools || resolved.canWrite === undefined) {
252
- return { error: `Task ${index + 1}: ${resolved.error ?? "Tool resolution failed"}` };
253
- }
254
- const cwd = resolvePath(parent.cwd, item.cwd);
255
- const output = item.output ? resolvePath(cwd, item.output) : undefined;
256
- // Non-model fields retain the existing precedence: explicit request > agent
257
- // file > per-profile config defaults > parent inheritance. Model routing was
258
- // validated above and is exclusively owned by modelPolicy.
259
- const profileDefaults: TaskDefaults = defaults.taskDefaults?.[profile] ?? {};
260
- const effectiveThinking = item.thinking ?? agent?.thinking ?? profileDefaults.thinking ?? modelRoute.thinking ?? parent.thinking;
261
- if (effectiveThinking !== undefined && !isThinkingLevel(effectiveThinking)) {
262
- return { error: `Task ${index + 1}: thinking must be a non-empty Pi thinking level string without whitespace or control characters` };
263
- }
264
- const label = item.description?.trim()
265
- ? item.description.trim().slice(0, 60)
266
- : agent
267
- ? agent.name
268
- : `task-${index + 1}`;
269
- const systemPrompt = [agent?.systemPrompt, item.system_prompt].filter(Boolean).join("\n\n") || undefined;
270
-
271
- // Backend capability gate. Refuse combinations the backend cannot honor
272
- // rather than silently dropping a budget or a read-only guarantee.
273
- const capabilities = resolveBackend(backend).capabilities;
274
- const problems = checkCapabilities(
275
- {
276
- maxCost: item.max_cost ?? agent?.maxCost ?? profileDefaults.maxCost,
277
- resume: item.resume,
278
- forkResume: item.fork_resume,
279
- contextFork: item.context === "fork",
280
- // Only report a tool-restriction problem when the profile actually
281
- // restricts: profile 'general' inherits the parent set and does not
282
- // promise a read-only sandbox.
283
- tools: profile === "general" ? undefined : resolved.tools,
284
- thinking: effectiveThinking,
285
- outputSchema: item.output_schema ?? agent?.outputSchema,
286
- profile,
287
- canWrite: resolved.canWrite,
288
- },
289
- capabilities,
290
- backend,
291
- );
292
- if (problems.length) {
293
- return { error: `Task ${index + 1}: ${problems.join("; ")}` };
294
- }
295
-
296
- return {
297
- task: {
298
- backend,
299
- label,
300
- task: repairDoubleEncodedText(item.task.trim()),
301
- systemPrompt: systemPrompt ? repairDoubleEncodedText(systemPrompt) : systemPrompt,
302
- // Model routing is exclusively owned by modelPolicy. Legacy agent,
303
- // taskDefaults, and parent-session model fields never participate.
304
- model: item.model!.trim(),
305
- thinking: effectiveThinking,
306
- tools: resolved.tools,
307
- profile,
308
- cwd,
309
- timeoutMs: item.timeout_ms ?? agent?.timeoutMs ?? profileDefaults.timeoutMs ?? defaults.timeoutMs ?? defaultConfig.defaultTimeoutMs,
310
- maxTurns: item.max_turns ?? agent?.maxTurns ?? profileDefaults.maxTurns,
311
- maxCost: item.max_cost ?? agent?.maxCost ?? profileDefaults.maxCost,
312
- graceTurns: item.grace_turns ?? agent?.graceTurns,
313
- // The immutable configured route is copied into the TaskSpec; callers
314
- // cannot add, remove, or reorder fallback models after validation.
315
- fallbackModels: [...modelRoute.fallbackModels],
316
- maxRetries: item.max_retries ?? agent?.maxRetries ?? profileDefaults.maxRetries,
317
- contextFork: item.context === "fork",
318
- parentSessionFile: item.context === "fork" ? parent.sessionFile : undefined,
319
- output,
320
- outputMode: item.output_mode,
321
- outputSchema: item.output_schema ?? agent?.outputSchema,
322
- resume: item.resume,
323
- forkResume: item.fork_resume,
324
- isolation: item.isolation ?? agent?.isolation ?? "shared",
325
- allowSharedWrites: item.allow_shared_writes === true,
326
- keepBackground: item.keep_background === true,
327
- includeWip: item.include_wip === true,
328
- // Child's own future-spawn allowlist never inherits from boot env —
329
- // only the named persona's frontmatter `spawns` restricts grandchildren.
330
- spawns: agent?.spawns,
331
- canWrite: resolved.canWrite,
332
- effectiveTools: resolved.tools,
333
- resolutionNotes: [
334
- `backend=${backend}`,
335
- `profile=${profile}`,
336
- `access=${resolved.canWrite ? "RW" : "RO"}`,
337
- ...(agent ? [`agent=${agent.name}`] : []),
338
- `model-policy=${defaults.modelPolicy.agents.has(agent?.name ?? item.agent?.trim().toLowerCase() ?? "") ? "agent" : "default"}`,
339
- ],
340
- },
341
- };
342
- }
343
-
344
- function validateParallel(tasks: ResolvedTask[]): string | undefined {
345
- const outputs = new Set<string>();
346
- for (const task of tasks) {
347
- if (task.output && outputs.has(task.output)) return `Duplicate output path: ${task.output}`;
348
- if (task.output) outputs.add(task.output);
349
- }
350
-
351
- const sharedByCwd = new Map<string, ResolvedTask[]>();
352
- for (const task of tasks.filter((task) => task.canWrite && task.isolation !== "worktree")) {
353
- const list = sharedByCwd.get(task.cwd!) ?? [];
354
- list.push(task);
355
- sharedByCwd.set(task.cwd!, list);
356
- }
357
- for (const [cwd, writers] of sharedByCwd) {
358
- if (writers.length > 1 && !writers.every((task) => task.allowSharedWrites)) {
359
- return `Parallel writers share ${cwd}. Use isolation:"worktree", distinct cwd values, or explicit allow_shared_writes:true.`;
360
- }
361
- }
362
- return undefined;
363
- }
364
-
365
- /**
366
- * Parse nesting depth. Missing (undefined / empty) means top-level (0).
367
- * Malformed or negative values fail *closed* by returning a large sentinel so
368
- * the depth-cap check rejects nested work rather than resetting the counter
369
- * after env scrubbing after a forged zero.
370
- */
371
- export function parseDepth(value = process.env[DEPTH_ENV_VAR]): number {
372
- if (value === undefined || value === "") return 0;
373
- const parsed = Number.parseInt(value, 10);
374
- if (!Number.isFinite(parsed) || parsed < 0) return 100; // fail closed
375
- return Math.min(parsed, 100);
376
- }
377
-
378
- export type SpawnPolicy = { kind: "unrestricted" } | { kind: "disabled" } | { kind: "allowlist"; agents: string[] };
379
-
380
- /**
381
- * Parse the spawn allowlist env var.
382
- * - unset / "*" → unrestricted
383
- * - empty / "false" / "off" / "none" → disabled
384
- * - "a,b" / "[a, b]" → allowlist (agentless also rejected)
385
- * Malformed values fail closed to disabled.
386
- */
387
- export function parseSpawnPolicy(value?: string): SpawnPolicy {
388
- if (value === undefined) return { kind: "unrestricted" };
389
- // Empty after trim is intentional disable; also treat bare false synonyms.
390
- const trimmed = value.trim();
391
- if (trimmed === "" || /^false|off|none$/i.test(trimmed)) return { kind: "disabled" };
392
- if (trimmed === "*") return { kind: "unrestricted" };
393
- // Reject control characters / bad forms before any permissive poke.
394
- if (/[\x00-\x1f]/.test(trimmed)) return { kind: "disabled" };
395
- const inner = trimmed.startsWith("[") && trimmed.endsWith("]")
396
- ? trimmed.slice(1, -1)
397
- : trimmed;
398
- const agents = inner
399
- .split(",")
400
- .map((item) => item.trim().replace(/^["']|["']$/g, "").toLowerCase())
401
- .filter(Boolean);
402
- if (agents.length === 0) return { kind: "disabled" };
403
- // Agent names must stay simple identifiers; anything else is a forged policy.
404
- if (agents.some((name) => !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(name))) return { kind: "disabled" };
405
- return { kind: "allowlist", agents };
406
- }
407
-
408
- function describeSpawnPolicy(policy: SpawnPolicy): string {
409
- if (policy.kind === "disabled") return "spawning disabled";
410
- if (policy.kind === "allowlist") return `spawn allowlist: ${policy.agents.join(", ")}`;
411
- return "unrestricted";
412
- }
413
-
414
- /** True when this process should avoid spawning further nested subagents. */
415
- export function shouldRegisterSubagentTool(
416
- depth = parseDepth(),
417
- maxDepth = defaultConfig.maxDepth,
418
- ): boolean {
419
- return depth < maxDepth;
420
- }
421
-
422
- export function validateSubagentRequest(
423
- params: SubagentParams,
424
- parent: ParentContext,
425
- options: {
426
- maxDepth?: number;
427
- maxTasks?: number;
428
- defaultTimeoutMs?: number;
429
- taskDefaults?: TaskDefaultsByProfile;
430
- agents?: Map<string, AgentDefinition>;
431
- modelPolicy?: ModelPolicySnapshot;
432
- modelPolicyError?: string;
433
- /** Remote Context allowlist snapshot; omitted means fail closed (no context tools). */
434
- contextPolicy?: ContextManagementPolicy;
435
- } = {},
436
- ): ValidationResult {
437
- const defaults = {
438
- timeoutMs: options.defaultTimeoutMs,
439
- taskDefaults: options.taskDefaults,
440
- agents: options.agents,
441
- modelPolicy: options.modelPolicy,
442
- modelPolicyError: options.modelPolicyError,
443
- contextPolicy: options.contextPolicy,
444
- };
445
- const depth = parent.depth ?? parseDepth();
446
- const maxDepth = options.maxDepth ?? defaultConfig.maxDepth;
447
- if (depth >= maxDepth) return { ok: false, error: `Subagent nesting depth limit reached (${depth} >= ${maxDepth})` };
448
-
449
- // Boot spawn policy (from our parent) — fail closed; applies to new spawn modes only.
450
- const spawnPolicy = parseSpawnPolicy(process.env[SPAWNS_ENV_VAR]);
451
- if (spawnPolicy.kind !== "unrestricted") {
452
- const hasSpawnWork = typeof params.task === "string" || Array.isArray(params.tasks);
453
- if (hasSpawnWork) {
454
- if (spawnPolicy.kind === "disabled") {
455
- return { ok: false, error: `Subagent spawning is disabled by parent policy (${describeSpawnPolicy(spawnPolicy)})` };
456
- }
457
- // Allowlist requires a named agent from the list; agentless is rejected.
458
- const requestedAgents: Array<string | undefined> = Array.isArray(params.tasks)
459
- ? params.tasks.map((t) => t.agent)
460
- : [params.agent];
461
- for (let i = 0; i < requestedAgents.length; i++) {
462
- const agentName = requestedAgents[i]?.trim().toLowerCase();
463
- if (!agentName) {
464
- return {
465
- ok: false,
466
- error: `Task ${i + 1}: agentless tasks are not allowed under parent ${describeSpawnPolicy(spawnPolicy)}`,
467
- };
468
- }
469
- if (!spawnPolicy.agents.includes(agentName)) {
470
- return {
471
- ok: false,
472
- error: `Task ${i + 1}: agent "${agentName}" is not in parent ${describeSpawnPolicy(spawnPolicy)}`,
473
- };
474
- }
475
- }
476
- }
477
- }
478
-
479
- const hasAction = params.action !== undefined;
480
- const hasTask = typeof params.task === "string";
481
- const hasTasks = Array.isArray(params.tasks);
482
- const planOnly = params.action === "plan";
483
-
484
- // action:"plan" is a dry-run of spawn modes: it MUST combine with task/tasks.
485
- // Other actions remain exclusive with task/tasks.
486
- if (planOnly) {
487
- if (hasTask === hasTasks) {
488
- // Both or neither: plan alone is invalid; task+tasks is also invalid.
489
- if (!hasTask && !hasTasks) {
490
- return { ok: false, error: "action:\"plan\" requires task or tasks[] (dry-run of a spawn request)" };
491
- }
492
- return { ok: false, error: "Provide exactly one of: task or tasks" };
493
- }
494
- } else {
495
- const modes = [hasAction, hasTask, hasTasks].filter(Boolean).length;
496
- if (modes === 0) {
497
- return { ok: false, error: "Provide task, tasks, or action (status|wait|cancel|plan)" };
498
- }
499
- if (modes > 1) {
500
- return { ok: false, error: "Provide exactly one of: action, task, or tasks" };
501
- }
502
- }
503
-
504
- if (hasAction && !planOnly) {
505
- if (params.action !== "status" && !params.id) {
506
- return { ok: false, error: `${params.action} requires a run id` };
507
- }
508
- if (params.action === "steer" && !params.message?.trim()) {
509
- return { ok: false, error: "steer requires a non-empty message" };
510
- }
511
- // Management actions ignore task-config fields; reject obvious conflict residues.
512
- if (params.async !== undefined) {
513
- return { ok: false, error: "async cannot be combined with action" };
514
- }
515
- // `!planOnly` above excludes "plan"; TS cannot narrow the union across the flag.
516
- return { ok: true, mode: params.action as ManagementMode, async: false, id: params.id, message: params.message, index: params.index, tasks: [] };
517
- }
518
-
519
- if (hasTasks) {
520
- const rawTasks = params.tasks!;
521
- const maxTasks = options.maxTasks ?? defaultConfig.maxTasksPerRun;
522
- if (!rawTasks.length || rawTasks.length > maxTasks) return { ok: false, error: `Expected 1..${maxTasks} tasks (configurable via maxTasksPerRun)` };
523
- // Top-level TaskFields apply only to single-task mode.
524
- if (params.system_prompt !== undefined || params.model !== undefined || params.tools !== undefined || params.profile !== undefined || params.cwd !== undefined || params.resume !== undefined || params.agent !== undefined) {
525
- return { ok: false, error: "Top-level task options cannot be combined with tasks[]; set them on each tasks[] item" };
526
- }
527
- // Context forking duplicates the whole parent conversation per child;
528
- // that cost is intentional for one focused writer, not an 8-way fanout.
529
- if (rawTasks.length > 1 && rawTasks.some((task) => task.context === "fork")) {
530
- return { ok: false, error: "context:'fork' is single-task only; parallel fanout would duplicate the parent conversation per child" };
531
- }
532
- const tasks: ResolvedTask[] = [];
533
- for (let index = 0; index < rawTasks.length; index++) {
534
- const normalized = normalizeTask(rawTasks[index] as ParallelTaskInput, index, parent, "explore", defaults);
535
- if (normalized.error || !normalized.task) return { ok: false, error: normalized.error ?? "Invalid task" };
536
- tasks.push(normalized.task);
537
- }
538
- const parallelError = validateParallel(tasks);
539
- if (parallelError) return { ok: false, error: parallelError };
540
- return {
541
- ok: true,
542
- mode: tasks.length > 1 ? "parallel" : "single",
543
- async: params.async === true,
544
- synthesis: params.synthesis?.trim() || undefined,
545
- tasks,
546
- planOnly: planOnly || undefined,
547
- };
548
- }
549
-
550
- if (params.synthesis !== undefined) {
551
- return { ok: false, error: "synthesis applies to parallel mode only (tasks[])" };
552
- }
553
- // Single-task mode: top-level fields form the one task.
554
- const normalized = normalizeTask(params as ParallelTaskInput, 0, parent, "general", defaults);
555
- if (normalized.error || !normalized.task) return { ok: false, error: normalized.error ?? "Invalid task" };
556
- return { ok: true, mode: "single", async: params.async === true, tasks: [normalized.task], planOnly: planOnly || undefined };
557
- }
558
-
559
- export function describeCapability(task: ResolvedTask): string {
560
- return `${task.profile}/${task.canWrite ? "RW" : "RO"} tools=[${task.effectiveTools.join(",")}]`;
561
- }
1
+ import * as path from "node:path";
2
+ import type { AgentDefinition } from "./agents.js";
3
+ import { resolveAgent } from "./agents.js";
4
+ import { isPlausibleSchema, repairDoubleEncodedText } from "./structured.js";
5
+ import { defaultConfig, type TaskDefaults, type TaskDefaultsByProfile } from "./config.js";
6
+ import type { OutputMode, TaskProfile, TaskSpec } from "./types.js";
7
+ import type { ParallelTaskInput, SubagentParams } from "./schema.js";
8
+ import { BACKEND_NAMES, checkCapabilities, type BackendName } from "./backend.js";
9
+ import { resolveBackend } from "./backends/index.js";
10
+ import type { JevRoutingConfig, RoutingDecision, RoutingModelCandidate } from "./routing-types.js";
11
+ import { isThinkingLevel } from "./thinking.js";
12
+
13
+ export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
14
+ export const SPAWNS_ENV_VAR = "PI_SUBAGENT_SPAWNS";
15
+ export const READ_ONLY_TOOLS = new Set([
16
+ "read",
17
+ "grep",
18
+ "find",
19
+ "ls",
20
+ "fffind",
21
+ "ffgrep",
22
+ "fff-multi-grep",
23
+ "firecrawl_scrape",
24
+ "firecrawl_search",
25
+ "firecrawl_map",
26
+ "firecrawl_crawl",
27
+ "web_search",
28
+ "web_fetch",
29
+ ]);
30
+ /**
31
+ * Pi context-management tools are control-plane capabilities: they may update
32
+ * continuity notes or the remote context window, but they cannot modify the
33
+ * child checkout. Keep them separate from ordinary source-inspection tools so
34
+ * the read-only profile's exception remains explicit.
35
+ */
36
+ export const CONTEXT_MANAGEMENT_TOOLS = new Set([
37
+ "new_context",
38
+ "get_context_remaining",
39
+ "history",
40
+ "notes",
41
+ ]);
42
+ const NON_WRITING_TOOLS = new Set([...READ_ONLY_TOOLS, ...CONTEXT_MANAGEMENT_TOOLS]);
43
+ export const KNOWN_WRITE_TOOLS = new Set(["bash", "edit", "write"]);
44
+ /** Backward-compatible export; policy uses fail-closed classification above. */
45
+ export const WRITE_TOOLS = KNOWN_WRITE_TOOLS;
46
+
47
+ export interface ParentContext {
48
+ cwd: string;
49
+ model?: string;
50
+ thinking?: TaskSpec["thinking"];
51
+ availableTools: string[];
52
+ activeTools?: string[];
53
+ depth?: number;
54
+ /** Persisted parent session file; required for context:'fork'. */
55
+ sessionFile?: string;
56
+ }
57
+
58
+ export interface ResolvedTask extends TaskSpec {
59
+ label: string;
60
+ canWrite: boolean;
61
+ effectiveTools: string[];
62
+ resolutionNotes: string[];
63
+ }
64
+
65
+ /** Local preparation cannot launch: it has candidates, not an execution model/tools. */
66
+ export interface PreparedTask extends Omit<ResolvedTask, "model" | "canWrite" | "effectiveTools" | "routing"> {
67
+ candidateTools: string[];
68
+ mandatoryTools: string[];
69
+ /** Request > agent > profile. Selected candidate and parent are applied only after routing. */
70
+ requestedThinking?: TaskSpec["thinking"];
71
+ parentThinking?: TaskSpec["thinking"];
72
+ }
73
+
74
+ export interface PreparationOptions {
75
+ maxDepth?: number;
76
+ maxTasks?: number;
77
+ defaultTimeoutMs?: number;
78
+ taskDefaults?: TaskDefaultsByProfile;
79
+ agents?: Map<string, AgentDefinition>;
80
+ jevRouting?: JevRoutingConfig;
81
+ jevRoutingError?: string;
82
+ }
83
+
84
+ export type ManagementMode = "status" | "wait" | "cancel" | "steer" | "diff" | "apply" | "discard";
85
+
86
+ export type ValidationResult =
87
+ | {
88
+ ok: true;
89
+ mode: "single" | "parallel" | ManagementMode;
90
+ async: boolean;
91
+ id?: string;
92
+ message?: string;
93
+ index?: number;
94
+ synthesis?: string;
95
+ tasks: PreparedTask[];
96
+ /** True when action:"plan" requested a dry-run no spawn. */
97
+ planOnly?: boolean;
98
+ }
99
+ | { ok: false; error: string };
100
+
101
+ function resolvePath(cwd: string, value?: string): string {
102
+ return value ? (path.isAbsolute(value) ? path.normalize(value) : path.resolve(cwd, value)) : path.resolve(cwd);
103
+ }
104
+
105
+ function resolveTools(
106
+ profile: TaskProfile,
107
+ requested: string[] | undefined,
108
+ availableTools: string[],
109
+ backend: BackendName,
110
+ ): { tools?: string[]; canWrite?: boolean; error?: string } {
111
+ const available = new Set(availableTools);
112
+ const contextTools = backend === "pi"
113
+ ? [...CONTEXT_MANAGEMENT_TOOLS].filter((tool) => available.has(tool))
114
+ : [];
115
+ const nonWritingTools = backend === "pi" ? NON_WRITING_TOOLS : READ_ONLY_TOOLS;
116
+ // Keep Pi's context-management control plane available to every child when
117
+ // the parent exposes it, even if the task requested a narrower tool subset.
118
+ const addContextTools = (tools: readonly string[]): string[] =>
119
+ [...new Set([...tools, ...contextTools])];
120
+
121
+ if (requested) {
122
+ const unknown = requested.filter((tool) => !available.has(tool));
123
+ if (unknown.length) return { error: `Unknown or unavailable tools: ${unknown.join(", ")}` };
124
+ }
125
+
126
+ if (profile === "explore" || profile === "review") {
127
+ const source = addContextTools(requested ?? [...nonWritingTools].filter((tool) => available.has(tool)));
128
+ const unsafe = source.filter((tool) => !nonWritingTools.has(tool));
129
+ if (unsafe.length) {
130
+ return {
131
+ error: `${profile} is strictly read-only. Unclassified or writable tools are not allowed: ${unsafe.join(", ")}`,
132
+ };
133
+ }
134
+ return { tools: source, canWrite: false };
135
+ }
136
+
137
+ const source = addContextTools(requested ?? availableTools);
138
+ const unknown = source.filter((tool) => !available.has(tool));
139
+ if (unknown.length) return { error: `Candidate tools are unavailable: ${unknown.join(", ")}` };
140
+ // General-profile custom tools are conservatively write-capable unless explicitly known non-writing.
141
+ return {
142
+ tools: source,
143
+ canWrite: source.some((tool) => KNOWN_WRITE_TOOLS.has(tool) || !nonWritingTools.has(tool)),
144
+ };
145
+ }
146
+
147
+ function normalizeTask(
148
+ item: {
149
+ task: string;
150
+ agent?: string;
151
+ description?: string;
152
+ system_prompt?: string;
153
+ model?: string;
154
+ thinking?: TaskSpec["thinking"];
155
+ tools?: string[];
156
+ profile?: TaskProfile;
157
+ cwd?: string;
158
+ timeout_ms?: number;
159
+ max_turns?: number;
160
+ max_cost?: number;
161
+ grace_turns?: number;
162
+ fallback_models?: string[];
163
+ max_retries?: number;
164
+ context?: "fresh" | "fork";
165
+ output?: string;
166
+ output_mode?: OutputMode;
167
+ output_schema?: Record<string, unknown>;
168
+ resume?: string;
169
+ fork_resume?: boolean;
170
+ isolation?: "shared" | "worktree";
171
+ allow_shared_writes?: boolean;
172
+ keep_background?: boolean;
173
+ include_wip?: boolean;
174
+ backend?: BackendName;
175
+ },
176
+ index: number,
177
+ parent: ParentContext,
178
+ defaultProfile: TaskProfile,
179
+ defaults: PreparationOptions = {},
180
+ ): { task?: PreparedTask; error?: string } {
181
+ if (!item.task?.trim()) return { error: `Task ${index + 1} must not be blank` };
182
+
183
+ // Named agent resolution is still used for persona/profile/tool behavior;
184
+ // its legacy model/fallback fields are deliberately ignored below.
185
+ // request params still win field-by-field. The agent body is the child's
186
+ // system prompt; an explicit system_prompt is appended after it.
187
+ let agent: AgentDefinition | undefined;
188
+ if ((item as { agent?: string }).agent) {
189
+ const lookup = resolveAgent(defaults.agents ?? new Map(), (item as { agent?: string }).agent!);
190
+ if (!lookup.agent) return { error: `Task ${index + 1}: ${lookup.error}` };
191
+ agent = lookup.agent;
192
+ }
193
+ if (item.model !== undefined || item.fallback_models !== undefined) {
194
+ return { error: `Task ${index + 1}: omit model and fallback_models (including empty lists). Jev must select from jevRouting.models; manual/fixed routing is no longer supported.` };
195
+ }
196
+ if (item.output_mode && !item.output) return { error: `Task ${index + 1}: output_mode requires output` };
197
+ if (item.fork_resume && !item.resume) return { error: `Task ${index + 1}: fork_resume requires resume` };
198
+ if (item.timeout_ms !== undefined && (!Number.isInteger(item.timeout_ms) || item.timeout_ms < 1)) {
199
+ return { error: `Task ${index + 1}: timeout_ms must be a positive integer` };
200
+ }
201
+ if (item.max_turns !== undefined && (!Number.isInteger(item.max_turns) || item.max_turns < 1)) {
202
+ return { error: `Task ${index + 1}: max_turns must be a positive integer` };
203
+ }
204
+ if (item.max_cost !== undefined && (!Number.isFinite(item.max_cost) || item.max_cost < 0)) {
205
+ return { error: `Task ${index + 1}: max_cost must be >= 0` };
206
+ }
207
+ if (item.grace_turns !== undefined && (!Number.isInteger(item.grace_turns) || item.grace_turns < 0)) {
208
+ return { error: `Task ${index + 1}: grace_turns must be a non-negative integer` };
209
+ }
210
+ if (item.max_retries !== undefined && (!Number.isInteger(item.max_retries) || item.max_retries < 0)) {
211
+ return { error: `Task ${index + 1}: max_retries must be a non-negative integer` };
212
+ }
213
+ if (item.context === "fork") {
214
+ if (item.resume) return { error: `Task ${index + 1}: context:'fork' cannot be combined with resume (resume already carries its own context)` };
215
+ if (!parent.sessionFile) {
216
+ return { error: `Task ${index + 1}: context:'fork' requires a persisted parent session; this session has no session file. Use context:'fresh'.` };
217
+ }
218
+ }
219
+ if (item.output_schema !== undefined && !isPlausibleSchema(item.output_schema)) {
220
+ return { error: `Task ${index + 1}: output_schema must be a JSON Schema object (type/properties/required)` };
221
+ }
222
+ if (item.include_wip === true) {
223
+ const isolation = item.isolation ?? agent?.isolation ?? "shared";
224
+ if (isolation !== "worktree") {
225
+ return { error: `Task ${index + 1}: include_wip requires isolation:"worktree" (dirty-baseline works only on isolated worktrees)` };
226
+ }
227
+ }
228
+
229
+ const backend: BackendName = item.backend ?? agent?.backend ?? "pi";
230
+ if (!BACKEND_NAMES.includes(backend)) {
231
+ return { error: `Task ${index + 1}: unknown backend '${backend}' (expected ${BACKEND_NAMES.join(", ")})` };
232
+ }
233
+ if (backend !== "pi") return { error: `Task ${index + 1}: new Jev-routed work supports backend:"pi" only; ${backend} is not supported. Existing-run management remains available.` };
234
+ const profile = item.profile ?? agent?.profile ?? defaultProfile;
235
+ const requestedTools = item.tools;
236
+ const childDepth = (parent.depth ?? parseDepth()) + 1;
237
+ const nestedAllowed = profile === "general" && childDepth < (defaults.maxDepth ?? defaultConfig.maxDepth)
238
+ && agent?.spawns !== false && (!Array.isArray(agent?.spawns) || agent.spawns.length > 0);
239
+ const availableTools = parent.availableTools.filter((tool) => nestedAllowed || !["subagent", "subagent_wait"].includes(tool));
240
+ const resolved = resolveTools(profile, requestedTools, availableTools, backend);
241
+ if (resolved.error || !resolved.tools || resolved.canWrite === undefined) return { error: resolved.error ?? "Tool resolution failed" };
242
+ const cwd = resolvePath(parent.cwd, item.cwd);
243
+ const output = item.output ? resolvePath(cwd, item.output) : undefined;
244
+ // Non-model fields retain the existing precedence: explicit request > agent
245
+ // file > per-profile config defaults; candidate/parent thinking waits for routing.
246
+ const profileDefaults: TaskDefaults = defaults.taskDefaults?.[profile] ?? {};
247
+ const requestedThinking = item.thinking ?? agent?.thinking ?? profileDefaults.thinking;
248
+ const effectiveThinking = requestedThinking ?? parent.thinking;
249
+ if (effectiveThinking !== undefined && !isThinkingLevel(effectiveThinking)) {
250
+ return { error: `Task ${index + 1}: thinking must be a non-empty Pi thinking level string without whitespace or control characters` };
251
+ }
252
+ const label = item.description?.trim()
253
+ ? item.description.trim().slice(0, 60)
254
+ : agent
255
+ ? agent.name
256
+ : `task-${index + 1}`;
257
+ const systemPrompt = [agent?.systemPrompt, item.system_prompt].filter(Boolean).join("\n\n") || undefined;
258
+
259
+ // Backend capability gate. Refuse combinations the backend cannot honor
260
+ // rather than silently dropping a budget or a read-only guarantee.
261
+ const capabilities = resolveBackend(backend).capabilities;
262
+ const problems = checkCapabilities(
263
+ {
264
+ maxCost: item.max_cost ?? agent?.maxCost ?? profileDefaults.maxCost,
265
+ resume: item.resume,
266
+ forkResume: item.fork_resume,
267
+ contextFork: item.context === "fork",
268
+ // Only report a tool-restriction problem when the profile actually
269
+ // restricts: profile 'general' inherits the parent set and does not
270
+ // promise a read-only sandbox.
271
+ tools: profile === "general" ? undefined : resolved.tools,
272
+ thinking: effectiveThinking,
273
+ outputSchema: item.output_schema ?? agent?.outputSchema,
274
+ profile,
275
+ canWrite: resolved.canWrite,
276
+ },
277
+ capabilities,
278
+ backend,
279
+ );
280
+ if (problems.length) {
281
+ return { error: `Task ${index + 1}: ${problems.join("; ")}` };
282
+ }
283
+
284
+ return {
285
+ task: {
286
+ backend,
287
+ label,
288
+ task: repairDoubleEncodedText(item.task.trim()),
289
+ systemPrompt: systemPrompt ? repairDoubleEncodedText(systemPrompt) : systemPrompt,
290
+ // No model or effective tool set exists until finalizeRoutedTasks succeeds.
291
+ requestedThinking,
292
+ parentThinking: parent.thinking,
293
+ thinking: requestedThinking,
294
+ candidateTools: resolved.tools.filter((tool) => !CONTEXT_MANAGEMENT_TOOLS.has(tool)),
295
+ mandatoryTools: resolved.tools.filter((tool) => CONTEXT_MANAGEMENT_TOOLS.has(tool)),
296
+ profile,
297
+ cwd,
298
+ timeoutMs: item.timeout_ms ?? agent?.timeoutMs ?? profileDefaults.timeoutMs ?? defaults.defaultTimeoutMs ?? defaultConfig.defaultTimeoutMs,
299
+ maxTurns: item.max_turns ?? agent?.maxTurns ?? profileDefaults.maxTurns,
300
+ maxCost: item.max_cost ?? agent?.maxCost ?? profileDefaults.maxCost,
301
+ graceTurns: item.grace_turns ?? agent?.graceTurns,
302
+ fallbackModels: [],
303
+ maxRetries: item.max_retries ?? agent?.maxRetries ?? profileDefaults.maxRetries,
304
+ contextFork: item.context === "fork",
305
+ parentSessionFile: item.context === "fork" ? parent.sessionFile : undefined,
306
+ output,
307
+ outputMode: item.output_mode,
308
+ outputSchema: item.output_schema ?? agent?.outputSchema,
309
+ resume: item.resume,
310
+ forkResume: item.fork_resume,
311
+ isolation: item.isolation ?? agent?.isolation ?? "shared",
312
+ allowSharedWrites: item.allow_shared_writes === true,
313
+ keepBackground: item.keep_background === true,
314
+ includeWip: item.include_wip === true,
315
+ // Child's own future-spawn allowlist never inherits from boot env —
316
+ // only the named persona's frontmatter `spawns` restricts grandchildren.
317
+ spawns: agent?.spawns,
318
+
319
+ resolutionNotes: [
320
+ `backend=${backend}`,
321
+ `profile=${profile}`,
322
+
323
+ ...(agent ? [`agent=${agent.name}`] : []),
324
+ "routing=jev (pending)",
325
+ ],
326
+ },
327
+ };
328
+ }
329
+
330
+ function validateParallel(tasks: Array<Pick<TaskSpec, "output" | "canWrite" | "isolation" | "cwd" | "allowSharedWrites">>): string | undefined {
331
+ const outputs = new Set<string>();
332
+ for (const task of tasks) {
333
+ if (task.output && outputs.has(task.output)) return `Duplicate output path: ${task.output}`;
334
+ if (task.output) outputs.add(task.output);
335
+ }
336
+
337
+ const sharedByCwd = new Map<string, Array<Pick<TaskSpec, "output" | "canWrite" | "isolation" | "cwd" | "allowSharedWrites">>>();
338
+ for (const task of tasks.filter((task) => task.canWrite && task.isolation !== "worktree")) {
339
+ const list = sharedByCwd.get(task.cwd!) ?? [];
340
+ list.push(task);
341
+ sharedByCwd.set(task.cwd!, list);
342
+ }
343
+ for (const [cwd, writers] of sharedByCwd) {
344
+ if (writers.length > 1 && !writers.every((task) => task.allowSharedWrites)) {
345
+ return `Parallel writers share ${cwd}. Use isolation:"worktree", distinct cwd values, or explicit allow_shared_writes:true.`;
346
+ }
347
+ }
348
+ return undefined;
349
+ }
350
+
351
+ /**
352
+ * Parse nesting depth. Missing (undefined / empty) means top-level (0).
353
+ * Malformed or negative values fail *closed* by returning a large sentinel so
354
+ * the depth-cap check rejects nested work rather than resetting the counter
355
+ * after env scrubbing after a forged zero.
356
+ */
357
+ export function parseDepth(value = process.env[DEPTH_ENV_VAR]): number {
358
+ if (value === undefined || value === "") return 0;
359
+ const parsed = Number.parseInt(value, 10);
360
+ if (!Number.isFinite(parsed) || parsed < 0) return 100; // fail closed
361
+ return Math.min(parsed, 100);
362
+ }
363
+
364
+ export type SpawnPolicy = { kind: "unrestricted" } | { kind: "disabled" } | { kind: "allowlist"; agents: string[] };
365
+
366
+ /**
367
+ * Parse the spawn allowlist env var.
368
+ * - unset / "*" unrestricted
369
+ * - empty / "false" / "off" / "none" → disabled
370
+ * - "a,b" / "[a, b]" → allowlist (agentless also rejected)
371
+ * Malformed values fail closed to disabled.
372
+ */
373
+ export function parseSpawnPolicy(value?: string): SpawnPolicy {
374
+ if (value === undefined) return { kind: "unrestricted" };
375
+ // Empty after trim is intentional disable; also treat bare false synonyms.
376
+ const trimmed = value.trim();
377
+ if (trimmed === "" || /^false|off|none$/i.test(trimmed)) return { kind: "disabled" };
378
+ if (trimmed === "*") return { kind: "unrestricted" };
379
+ // Reject control characters / bad forms before any permissive poke.
380
+ if (/[\x00-\x1f]/.test(trimmed)) return { kind: "disabled" };
381
+ const inner = trimmed.startsWith("[") && trimmed.endsWith("]")
382
+ ? trimmed.slice(1, -1)
383
+ : trimmed;
384
+ const agents = inner
385
+ .split(",")
386
+ .map((item) => item.trim().replace(/^["']|["']$/g, "").toLowerCase())
387
+ .filter(Boolean);
388
+ if (agents.length === 0) return { kind: "disabled" };
389
+ // Agent names must stay simple identifiers; anything else is a forged policy.
390
+ if (agents.some((name) => !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(name))) return { kind: "disabled" };
391
+ return { kind: "allowlist", agents };
392
+ }
393
+
394
+ function describeSpawnPolicy(policy: SpawnPolicy): string {
395
+ if (policy.kind === "disabled") return "spawning disabled";
396
+ if (policy.kind === "allowlist") return `spawn allowlist: ${policy.agents.join(", ")}`;
397
+ return "unrestricted";
398
+ }
399
+
400
+ /** True when this process should avoid spawning further nested subagents. */
401
+ export function shouldRegisterSubagentTool(
402
+ depth = parseDepth(),
403
+ maxDepth = defaultConfig.maxDepth,
404
+ ): boolean {
405
+ return depth < maxDepth;
406
+ }
407
+
408
+ export function validateSubagentRequest(
409
+ params: SubagentParams,
410
+ parent: ParentContext,
411
+ options: PreparationOptions = {},
412
+ ): ValidationResult {
413
+ const defaults = options;
414
+ const hasAction = params.action !== undefined;
415
+ const hasTask = typeof params.task === "string";
416
+ const hasTasks = Array.isArray(params.tasks);
417
+ const planOnly = params.action === "plan";
418
+
419
+ // action:"plan" is a dry-run of spawn modes: it MUST combine with task/tasks.
420
+ // Other actions remain exclusive with task/tasks.
421
+ if (planOnly) {
422
+ if (hasTask === hasTasks) {
423
+ // Both or neither: plan alone is invalid; task+tasks is also invalid.
424
+ if (!hasTask && !hasTasks) {
425
+ return { ok: false, error: "action:\"plan\" requires task or tasks[] (dry-run of a spawn request)" };
426
+ }
427
+ return { ok: false, error: "Provide exactly one of: task or tasks" };
428
+ }
429
+ } else {
430
+ const modes = [hasAction, hasTask, hasTasks].filter(Boolean).length;
431
+ if (modes === 0) {
432
+ return { ok: false, error: "Provide task, tasks, or action (status|wait|cancel|plan)" };
433
+ }
434
+ if (modes > 1) {
435
+ return { ok: false, error: "Provide exactly one of: action, task, or tasks" };
436
+ }
437
+ }
438
+
439
+ if (hasAction && !planOnly) {
440
+ if (params.action !== "status" && !params.id) {
441
+ return { ok: false, error: `${params.action} requires a run id` };
442
+ }
443
+ if (params.action === "steer" && !params.message?.trim()) {
444
+ return { ok: false, error: "steer requires a non-empty message" };
445
+ }
446
+ // Management actions ignore task-config fields; reject obvious conflict residues.
447
+ if (params.async !== undefined) {
448
+ return { ok: false, error: "async cannot be combined with action" };
449
+ }
450
+ // `!planOnly` above excludes "plan"; TS cannot narrow the union across the flag.
451
+ return { ok: true, mode: params.action as ManagementMode, async: false, id: params.id, message: params.message, index: params.index, tasks: [] };
452
+ }
453
+
454
+ const depth = parent.depth ?? parseDepth();
455
+ const maxDepth = options.maxDepth ?? defaultConfig.maxDepth;
456
+ if (depth >= maxDepth) return { ok: false, error: `Subagent nesting depth limit reached (${depth} >= ${maxDepth})` };
457
+ if (!options.jevRouting) return { ok: false, error: options.jevRoutingError ?? "Jev routing is not configured. Add jevRouting.models with exact IDs and descriptions to ~/.pi/subagent.json; existing-run management remains available." };
458
+ // Boot spawn policy (from our parent) — fail closed; applies to new spawn modes only.
459
+ const spawnPolicy = parseSpawnPolicy(process.env[SPAWNS_ENV_VAR]);
460
+ if (spawnPolicy.kind !== "unrestricted") {
461
+ const hasSpawnWork = typeof params.task === "string" || Array.isArray(params.tasks);
462
+ if (hasSpawnWork) {
463
+ if (spawnPolicy.kind === "disabled") {
464
+ return { ok: false, error: `Subagent spawning is disabled by parent policy (${describeSpawnPolicy(spawnPolicy)})` };
465
+ }
466
+ // Allowlist requires a named agent from the list; agentless is rejected.
467
+ const requestedAgents: Array<string | undefined> = Array.isArray(params.tasks)
468
+ ? params.tasks.map((t) => t.agent)
469
+ : [params.agent];
470
+ for (let i = 0; i < requestedAgents.length; i++) {
471
+ const agentName = requestedAgents[i]?.trim().toLowerCase();
472
+ if (!agentName) {
473
+ return {
474
+ ok: false,
475
+ error: `Task ${i + 1}: agentless tasks are not allowed under parent ${describeSpawnPolicy(spawnPolicy)}`,
476
+ };
477
+ }
478
+ if (!spawnPolicy.agents.includes(agentName)) {
479
+ return {
480
+ ok: false,
481
+ error: `Task ${i + 1}: agent "${agentName}" is not in parent ${describeSpawnPolicy(spawnPolicy)}`,
482
+ };
483
+ }
484
+ }
485
+ }
486
+ }
487
+
488
+ if (hasTasks) {
489
+ const rawTasks = params.tasks!;
490
+ const maxTasks = options.maxTasks ?? defaultConfig.maxTasksPerRun;
491
+ if (!rawTasks.length || rawTasks.length > maxTasks) return { ok: false, error: `Expected 1..${maxTasks} tasks (configurable via maxTasksPerRun)` };
492
+ // Top-level TaskFields apply only to single-task mode.
493
+ if (params.system_prompt !== undefined || params.model !== undefined || params.fallback_models !== undefined || params.tools !== undefined || params.profile !== undefined || params.cwd !== undefined || params.resume !== undefined || params.agent !== undefined) {
494
+ return { ok: false, error: "Top-level task options cannot be combined with tasks[]; set them on each tasks[] item" };
495
+ }
496
+ // Context forking duplicates the whole parent conversation per child;
497
+ // that cost is intentional for one focused writer, not an 8-way fanout.
498
+ if (rawTasks.length > 1 && rawTasks.some((task) => task.context === "fork")) {
499
+ return { ok: false, error: "context:'fork' is single-task only; parallel fanout would duplicate the parent conversation per child" };
500
+ }
501
+ const tasks: PreparedTask[] = [];
502
+ for (let index = 0; index < rawTasks.length; index++) {
503
+ const normalized = normalizeTask(rawTasks[index] as ParallelTaskInput, index, parent, "explore", defaults);
504
+ if (normalized.error || !normalized.task) return { ok: false, error: normalized.error ?? "Invalid task" };
505
+ tasks.push(normalized.task);
506
+ }
507
+ const parallelError = validateParallel(tasks);
508
+ if (parallelError) return { ok: false, error: parallelError };
509
+ return {
510
+ ok: true,
511
+ mode: tasks.length > 1 ? "parallel" : "single",
512
+ async: params.async === true,
513
+ synthesis: params.synthesis?.trim() || undefined,
514
+ tasks,
515
+ planOnly: planOnly || undefined,
516
+ };
517
+ }
518
+
519
+ if (params.synthesis !== undefined) {
520
+ return { ok: false, error: "synthesis applies to parallel mode only (tasks[])" };
521
+ }
522
+ // Single-task mode: top-level fields form the one task.
523
+ const normalized = normalizeTask(params as ParallelTaskInput, 0, parent, "general", defaults);
524
+ if (normalized.error || !normalized.task) return { ok: false, error: normalized.error ?? "Invalid task" };
525
+ return { ok: true, mode: "single", async: params.async === true, tasks: [normalized.task], planOnly: planOnly || undefined };
526
+ }
527
+
528
+ export function describeCapability(task: ResolvedTask): string {
529
+ return `${task.profile}/${task.canWrite ? "RW" : "RO"} tools=[${task.effectiveTools.join(",")}]`;
530
+ }
531
+
532
+ /** Final local authority: no raw caller model field is synthesized to bypass policy. */
533
+ export function finalizeRoutedTasks(
534
+ prepared: readonly PreparedTask[],
535
+ decisions: readonly RoutingDecision[],
536
+ models: readonly RoutingModelCandidate[],
537
+ ): { ok: true; tasks: ResolvedTask[] } | { ok: false; error: string } {
538
+ if (prepared.length !== decisions.length) return { ok: false, error: "Routing decision count does not match the prepared tasks." };
539
+ const tasks: ResolvedTask[] = [];
540
+ for (let index = 0; index < prepared.length; index++) {
541
+ const item = prepared[index]!;
542
+ const decision = decisions[index]!;
543
+ const candidate = models.find((entry) => entry.model === decision.selectedModel);
544
+ if (!candidate) return { ok: false, error: `Task ${index + 1}: selector chose a model outside the available dedicated candidates.` };
545
+ if (new Set(decision.selectedTools).size !== decision.selectedTools.length || decision.selectedTools.some((tool) => !item.candidateTools.includes(tool))) {
546
+ return { ok: false, error: `Task ${index + 1}: selector chose tools outside the locally permitted candidates.` };
547
+ }
548
+ const tools = [...new Set([...decision.selectedTools, ...item.mandatoryTools])];
549
+ const canWrite = tools.some((tool) => !NON_WRITING_TOOLS.has(tool));
550
+ if (item.profile !== "general" && canWrite) return { ok: false, error: `Task ${index + 1}: writable selector choice violates ${item.profile}.` };
551
+ const { candidateTools: _candidates, mandatoryTools, requestedThinking, parentThinking, ...spec } = item;
552
+ const thinking = requestedThinking ?? candidate.thinking ?? parentThinking;
553
+ tasks.push({
554
+ ...spec, model: candidate.model, thinking, tools, effectiveTools: tools, canWrite,
555
+ fallbackModels: [],
556
+ routing: { ...decision, mandatoryTools: [...mandatoryTools], outcome: "success" },
557
+ resolutionNotes: [...item.resolutionNotes.filter((note) => !note.startsWith("routing=")), "routing=jev", `access=${canWrite ? "RW" : "RO"}`],
558
+ });
559
+ }
560
+ const problem = validateParallel(tasks);
561
+ return problem ? { ok: false, error: problem } : { ok: true, tasks };
562
+ }