@cr1ms0n/pi-subagent 0.8.1
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/CHANGELOG.md +352 -0
- package/LICENSE +21 -0
- package/README.md +543 -0
- package/docs/ARCHITECTURE.md +125 -0
- package/docs/COST-ACCOUNTING.md +66 -0
- package/docs/PLAN.md +325 -0
- package/docs/RELEASING.md +32 -0
- package/docs/ROADMAP.md +252 -0
- package/docs/SECURITY.md +85 -0
- package/docs/UI-OVERHAUL.md +186 -0
- package/docs/UX.md +141 -0
- package/extensions/subagent.ts +1 -0
- package/package.json +58 -0
- package/skills/subagent/SKILL.md +103 -0
- package/src/agents.ts +285 -0
- package/src/backend.ts +146 -0
- package/src/backends/claude.ts +384 -0
- package/src/backends/codex.ts +330 -0
- package/src/backends/index.ts +26 -0
- package/src/backends/pi.ts +94 -0
- package/src/btw.ts +34 -0
- package/src/config.ts +254 -0
- package/src/distill.ts +222 -0
- package/src/extension.ts +1527 -0
- package/src/format.ts +365 -0
- package/src/index.ts +60 -0
- package/src/launch.ts +120 -0
- package/src/maintenance.ts +6 -0
- package/src/model-policy.ts +157 -0
- package/src/notifications.ts +106 -0
- package/src/orchestrator.ts +247 -0
- package/src/output.ts +124 -0
- package/src/persistence.ts +334 -0
- package/src/policy.ts +500 -0
- package/src/process-lock.ts +687 -0
- package/src/protocol.ts +290 -0
- package/src/registry.ts +632 -0
- package/src/runner.ts +850 -0
- package/src/schema.ts +166 -0
- package/src/semaphore.ts +123 -0
- package/src/structured.ts +169 -0
- package/src/transcript.ts +360 -0
- package/src/types.ts +197 -0
- package/src/ui.ts +545 -0
- package/src/usage.ts +274 -0
- package/src/worktree.ts +753 -0
package/src/policy.ts
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
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
|
+
|
|
12
|
+
export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
|
|
13
|
+
export const SPAWNS_ENV_VAR = "PI_SUBAGENT_SPAWNS";
|
|
14
|
+
export const READ_ONLY_TOOLS = new Set([
|
|
15
|
+
"read",
|
|
16
|
+
"grep",
|
|
17
|
+
"find",
|
|
18
|
+
"ls",
|
|
19
|
+
"firecrawl_scrape",
|
|
20
|
+
"firecrawl_search",
|
|
21
|
+
"firecrawl_map",
|
|
22
|
+
"firecrawl_crawl",
|
|
23
|
+
"web_search",
|
|
24
|
+
"web_fetch",
|
|
25
|
+
]);
|
|
26
|
+
export const KNOWN_WRITE_TOOLS = new Set(["bash", "edit", "write"]);
|
|
27
|
+
/** Backward-compatible export; policy uses fail-closed classification above. */
|
|
28
|
+
export const WRITE_TOOLS = KNOWN_WRITE_TOOLS;
|
|
29
|
+
|
|
30
|
+
export interface ParentContext {
|
|
31
|
+
cwd: string;
|
|
32
|
+
model?: string;
|
|
33
|
+
thinking?: TaskSpec["thinking"];
|
|
34
|
+
availableTools: string[];
|
|
35
|
+
activeTools?: string[];
|
|
36
|
+
depth?: number;
|
|
37
|
+
/** Persisted parent session file; required for context:'fork'. */
|
|
38
|
+
sessionFile?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ResolvedTask extends TaskSpec {
|
|
42
|
+
label: string;
|
|
43
|
+
canWrite: boolean;
|
|
44
|
+
effectiveTools: string[];
|
|
45
|
+
resolutionNotes: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ManagementMode = "status" | "wait" | "cancel" | "steer" | "diff" | "apply" | "discard";
|
|
49
|
+
|
|
50
|
+
export type ValidationResult =
|
|
51
|
+
| {
|
|
52
|
+
ok: true;
|
|
53
|
+
mode: "single" | "parallel" | ManagementMode;
|
|
54
|
+
async: boolean;
|
|
55
|
+
id?: string;
|
|
56
|
+
message?: string;
|
|
57
|
+
index?: number;
|
|
58
|
+
synthesis?: string;
|
|
59
|
+
tasks: ResolvedTask[];
|
|
60
|
+
/** True when action:"plan" requested a dry-run — no spawn. */
|
|
61
|
+
planOnly?: boolean;
|
|
62
|
+
}
|
|
63
|
+
| { ok: false; error: string };
|
|
64
|
+
|
|
65
|
+
function resolvePath(cwd: string, value?: string): string {
|
|
66
|
+
return value ? (path.isAbsolute(value) ? path.normalize(value) : path.resolve(cwd, value)) : path.resolve(cwd);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveTools(
|
|
70
|
+
profile: TaskProfile,
|
|
71
|
+
requested: string[] | undefined,
|
|
72
|
+
availableTools: string[],
|
|
73
|
+
activeTools: string[],
|
|
74
|
+
): { tools?: string[]; canWrite?: boolean; error?: string } {
|
|
75
|
+
const available = new Set(availableTools);
|
|
76
|
+
if (requested) {
|
|
77
|
+
const unknown = requested.filter((tool) => !available.has(tool));
|
|
78
|
+
if (unknown.length) return { error: `Unknown or unavailable tools: ${unknown.join(", ")}` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (profile === "explore" || profile === "review") {
|
|
82
|
+
const source = requested ?? [...READ_ONLY_TOOLS].filter((tool) => available.has(tool));
|
|
83
|
+
const unsafe = source.filter((tool) => !READ_ONLY_TOOLS.has(tool));
|
|
84
|
+
if (unsafe.length) {
|
|
85
|
+
return {
|
|
86
|
+
error: `${profile} is strictly read-only. Unclassified or writable tools are not allowed: ${unsafe.join(", ")}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { tools: [...new Set(source)], canWrite: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const source = requested ?? activeTools;
|
|
93
|
+
const unknown = source.filter((tool) => !available.has(tool));
|
|
94
|
+
if (unknown.length) return { error: `Active tools are unavailable: ${unknown.join(", ")}` };
|
|
95
|
+
// General-profile custom tools are conservatively write-capable unless explicitly known read-only.
|
|
96
|
+
return {
|
|
97
|
+
tools: [...new Set(source)],
|
|
98
|
+
canWrite: source.some((tool) => KNOWN_WRITE_TOOLS.has(tool) || !READ_ONLY_TOOLS.has(tool)),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeTask(
|
|
103
|
+
item: {
|
|
104
|
+
task: string;
|
|
105
|
+
agent?: string;
|
|
106
|
+
description?: string;
|
|
107
|
+
system_prompt?: string;
|
|
108
|
+
model?: string;
|
|
109
|
+
thinking?: TaskSpec["thinking"];
|
|
110
|
+
tools?: string[];
|
|
111
|
+
profile?: TaskProfile;
|
|
112
|
+
cwd?: string;
|
|
113
|
+
timeout_ms?: number;
|
|
114
|
+
max_turns?: number;
|
|
115
|
+
max_cost?: number;
|
|
116
|
+
grace_turns?: number;
|
|
117
|
+
fallback_models?: string[];
|
|
118
|
+
max_retries?: number;
|
|
119
|
+
context?: "fresh" | "fork";
|
|
120
|
+
output?: string;
|
|
121
|
+
output_mode?: OutputMode;
|
|
122
|
+
output_schema?: Record<string, unknown>;
|
|
123
|
+
resume?: string;
|
|
124
|
+
fork_resume?: boolean;
|
|
125
|
+
isolation?: "shared" | "worktree";
|
|
126
|
+
allow_shared_writes?: boolean;
|
|
127
|
+
keep_background?: boolean;
|
|
128
|
+
include_wip?: boolean;
|
|
129
|
+
backend?: BackendName;
|
|
130
|
+
},
|
|
131
|
+
index: number,
|
|
132
|
+
parent: ParentContext,
|
|
133
|
+
defaultProfile: TaskProfile,
|
|
134
|
+
defaults: { timeoutMs?: number; taskDefaults?: TaskDefaultsByProfile; agents?: Map<string, AgentDefinition>; modelPolicy?: ModelPolicySnapshot; modelPolicyError?: string } = {},
|
|
135
|
+
): { task?: ResolvedTask; error?: string } {
|
|
136
|
+
if (!item.task?.trim()) return { error: `Task ${index + 1} must not be blank` };
|
|
137
|
+
|
|
138
|
+
// Named agent resolution is still used for persona/profile/tool behavior;
|
|
139
|
+
// its legacy model/fallback fields are deliberately ignored below.
|
|
140
|
+
// request params still win field-by-field. The agent body is the child's
|
|
141
|
+
// system prompt; an explicit system_prompt is appended after it.
|
|
142
|
+
let agent: AgentDefinition | undefined;
|
|
143
|
+
if ((item as { agent?: string }).agent) {
|
|
144
|
+
const lookup = resolveAgent(defaults.agents ?? new Map(), (item as { agent?: string }).agent!);
|
|
145
|
+
if (!lookup.agent) return { error: `Task ${index + 1}: ${lookup.error}` };
|
|
146
|
+
agent = lookup.agent;
|
|
147
|
+
}
|
|
148
|
+
if (!defaults.modelPolicy) {
|
|
149
|
+
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." };
|
|
150
|
+
}
|
|
151
|
+
const modelCheck = validateModelRequest(defaults.modelPolicy, {
|
|
152
|
+
agent: agent?.name ?? item.agent,
|
|
153
|
+
model: item.model,
|
|
154
|
+
fallbackModels: item.fallback_models,
|
|
155
|
+
fallbackModelsProvided: item.fallback_models !== undefined,
|
|
156
|
+
});
|
|
157
|
+
if (modelCheck.error || !modelCheck.route) return { error: `Task ${index + 1}: ${modelCheck.error ?? "model policy validation failed"}` };
|
|
158
|
+
const modelRoute = modelCheck.route;
|
|
159
|
+
if (item.output_mode && !item.output) return { error: `Task ${index + 1}: output_mode requires output` };
|
|
160
|
+
if (item.fork_resume && !item.resume) return { error: `Task ${index + 1}: fork_resume requires resume` };
|
|
161
|
+
if (item.timeout_ms !== undefined && (!Number.isInteger(item.timeout_ms) || item.timeout_ms < 1)) {
|
|
162
|
+
return { error: `Task ${index + 1}: timeout_ms must be a positive integer` };
|
|
163
|
+
}
|
|
164
|
+
if (item.max_turns !== undefined && (!Number.isInteger(item.max_turns) || item.max_turns < 1)) {
|
|
165
|
+
return { error: `Task ${index + 1}: max_turns must be a positive integer` };
|
|
166
|
+
}
|
|
167
|
+
if (item.max_cost !== undefined && (!Number.isFinite(item.max_cost) || item.max_cost < 0)) {
|
|
168
|
+
return { error: `Task ${index + 1}: max_cost must be >= 0` };
|
|
169
|
+
}
|
|
170
|
+
if (item.grace_turns !== undefined && (!Number.isInteger(item.grace_turns) || item.grace_turns < 0)) {
|
|
171
|
+
return { error: `Task ${index + 1}: grace_turns must be a non-negative integer` };
|
|
172
|
+
}
|
|
173
|
+
if (item.max_retries !== undefined && (!Number.isInteger(item.max_retries) || item.max_retries < 0)) {
|
|
174
|
+
return { error: `Task ${index + 1}: max_retries must be a non-negative integer` };
|
|
175
|
+
}
|
|
176
|
+
if (item.context === "fork") {
|
|
177
|
+
if (item.resume) return { error: `Task ${index + 1}: context:'fork' cannot be combined with resume (resume already carries its own context)` };
|
|
178
|
+
if (!parent.sessionFile) {
|
|
179
|
+
return { error: `Task ${index + 1}: context:'fork' requires a persisted parent session; this session has no session file. Use context:'fresh'.` };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (item.output_schema !== undefined && !isPlausibleSchema(item.output_schema)) {
|
|
183
|
+
return { error: `Task ${index + 1}: output_schema must be a JSON Schema object (type/properties/required)` };
|
|
184
|
+
}
|
|
185
|
+
if (item.include_wip === true) {
|
|
186
|
+
const isolation = item.isolation ?? agent?.isolation ?? "shared";
|
|
187
|
+
if (isolation !== "worktree") {
|
|
188
|
+
return { error: `Task ${index + 1}: include_wip requires isolation:"worktree" (dirty-baseline works only on isolated worktrees)` };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const profile = item.profile ?? agent?.profile ?? defaultProfile;
|
|
193
|
+
const requestedTools = item.tools ?? agent?.tools;
|
|
194
|
+
const resolved = resolveTools(profile, requestedTools, parent.availableTools, parent.activeTools ?? parent.availableTools);
|
|
195
|
+
if (resolved.error || !resolved.tools || resolved.canWrite === undefined) return { error: resolved.error ?? "Tool resolution failed" };
|
|
196
|
+
const cwd = resolvePath(parent.cwd, item.cwd);
|
|
197
|
+
const output = item.output ? resolvePath(cwd, item.output) : undefined;
|
|
198
|
+
// Non-model fields retain the existing precedence: explicit request > agent
|
|
199
|
+
// file > per-profile config defaults > parent inheritance. Model routing was
|
|
200
|
+
// validated above and is exclusively owned by modelPolicy.
|
|
201
|
+
const profileDefaults: TaskDefaults = defaults.taskDefaults?.[profile] ?? {};
|
|
202
|
+
const label = item.description?.trim()
|
|
203
|
+
? item.description.trim().slice(0, 60)
|
|
204
|
+
: agent
|
|
205
|
+
? agent.name
|
|
206
|
+
: `task-${index + 1}`;
|
|
207
|
+
const systemPrompt = [agent?.systemPrompt, item.system_prompt].filter(Boolean).join("\n\n") || undefined;
|
|
208
|
+
|
|
209
|
+
// Backend capability gate. Refuse combinations the backend cannot honor
|
|
210
|
+
// rather than silently dropping a budget or a read-only guarantee.
|
|
211
|
+
const backend: BackendName = item.backend ?? agent?.backend ?? "pi";
|
|
212
|
+
if (!BACKEND_NAMES.includes(backend)) {
|
|
213
|
+
return { error: `Task ${index + 1}: unknown backend '${backend}' (expected ${BACKEND_NAMES.join(", ")})` };
|
|
214
|
+
}
|
|
215
|
+
const capabilities = resolveBackend(backend).capabilities;
|
|
216
|
+
const problems = checkCapabilities(
|
|
217
|
+
{
|
|
218
|
+
maxCost: item.max_cost ?? agent?.maxCost ?? profileDefaults.maxCost,
|
|
219
|
+
resume: item.resume,
|
|
220
|
+
forkResume: item.fork_resume,
|
|
221
|
+
contextFork: item.context === "fork",
|
|
222
|
+
// Only report a tool-restriction problem when the profile actually
|
|
223
|
+
// restricts: profile 'general' inherits the parent set and does not
|
|
224
|
+
// promise a read-only sandbox.
|
|
225
|
+
tools: profile === "general" ? undefined : resolved.tools,
|
|
226
|
+
thinking: item.thinking ?? agent?.thinking,
|
|
227
|
+
outputSchema: item.output_schema ?? agent?.outputSchema,
|
|
228
|
+
profile,
|
|
229
|
+
canWrite: resolved.canWrite,
|
|
230
|
+
},
|
|
231
|
+
capabilities,
|
|
232
|
+
backend,
|
|
233
|
+
);
|
|
234
|
+
if (problems.length) {
|
|
235
|
+
return { error: `Task ${index + 1}: ${problems.join("; ")}` };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
task: {
|
|
240
|
+
backend,
|
|
241
|
+
label,
|
|
242
|
+
task: repairDoubleEncodedText(item.task.trim()),
|
|
243
|
+
systemPrompt: systemPrompt ? repairDoubleEncodedText(systemPrompt) : systemPrompt,
|
|
244
|
+
// Model routing is exclusively owned by modelPolicy. Legacy agent,
|
|
245
|
+
// taskDefaults, and parent-session model fields never participate.
|
|
246
|
+
model: item.model!.trim(),
|
|
247
|
+
thinking: item.thinking ?? agent?.thinking ?? profileDefaults.thinking ?? parent.thinking,
|
|
248
|
+
tools: resolved.tools,
|
|
249
|
+
profile,
|
|
250
|
+
cwd,
|
|
251
|
+
timeoutMs: item.timeout_ms ?? agent?.timeoutMs ?? profileDefaults.timeoutMs ?? defaults.timeoutMs ?? defaultConfig.defaultTimeoutMs,
|
|
252
|
+
maxTurns: item.max_turns ?? agent?.maxTurns ?? profileDefaults.maxTurns,
|
|
253
|
+
maxCost: item.max_cost ?? agent?.maxCost ?? profileDefaults.maxCost,
|
|
254
|
+
graceTurns: item.grace_turns ?? agent?.graceTurns,
|
|
255
|
+
// The immutable configured route is copied into the TaskSpec; callers
|
|
256
|
+
// cannot add, remove, or reorder fallback models after validation.
|
|
257
|
+
fallbackModels: [...modelRoute.fallbackModels],
|
|
258
|
+
maxRetries: item.max_retries ?? agent?.maxRetries ?? profileDefaults.maxRetries,
|
|
259
|
+
contextFork: item.context === "fork",
|
|
260
|
+
parentSessionFile: item.context === "fork" ? parent.sessionFile : undefined,
|
|
261
|
+
output,
|
|
262
|
+
outputMode: item.output_mode,
|
|
263
|
+
outputSchema: item.output_schema ?? agent?.outputSchema,
|
|
264
|
+
resume: item.resume,
|
|
265
|
+
forkResume: item.fork_resume,
|
|
266
|
+
isolation: item.isolation ?? agent?.isolation ?? "shared",
|
|
267
|
+
allowSharedWrites: item.allow_shared_writes === true,
|
|
268
|
+
keepBackground: item.keep_background === true,
|
|
269
|
+
includeWip: item.include_wip === true,
|
|
270
|
+
// Child's own future-spawn allowlist never inherits from boot env —
|
|
271
|
+
// only the named persona's frontmatter `spawns` restricts grandchildren.
|
|
272
|
+
spawns: agent?.spawns,
|
|
273
|
+
canWrite: resolved.canWrite,
|
|
274
|
+
effectiveTools: resolved.tools,
|
|
275
|
+
resolutionNotes: [
|
|
276
|
+
`backend=${backend}`,
|
|
277
|
+
`profile=${profile}`,
|
|
278
|
+
`access=${resolved.canWrite ? "RW" : "RO"}`,
|
|
279
|
+
...(agent ? [`agent=${agent.name}`] : []),
|
|
280
|
+
`model-policy=${defaults.modelPolicy.agents.has(agent?.name ?? item.agent?.trim().toLowerCase() ?? "") ? "agent" : "default"}`,
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function validateParallel(tasks: ResolvedTask[]): string | undefined {
|
|
287
|
+
const outputs = new Set<string>();
|
|
288
|
+
for (const task of tasks) {
|
|
289
|
+
if (task.output && outputs.has(task.output)) return `Duplicate output path: ${task.output}`;
|
|
290
|
+
if (task.output) outputs.add(task.output);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const sharedByCwd = new Map<string, ResolvedTask[]>();
|
|
294
|
+
for (const task of tasks.filter((task) => task.canWrite && task.isolation !== "worktree")) {
|
|
295
|
+
const list = sharedByCwd.get(task.cwd!) ?? [];
|
|
296
|
+
list.push(task);
|
|
297
|
+
sharedByCwd.set(task.cwd!, list);
|
|
298
|
+
}
|
|
299
|
+
for (const [cwd, writers] of sharedByCwd) {
|
|
300
|
+
if (writers.length > 1 && !writers.every((task) => task.allowSharedWrites)) {
|
|
301
|
+
return `Parallel writers share ${cwd}. Use isolation:"worktree", distinct cwd values, or explicit allow_shared_writes:true.`;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Parse nesting depth. Missing (undefined / empty) means top-level (0).
|
|
309
|
+
* Malformed or negative values fail *closed* by returning a large sentinel so
|
|
310
|
+
* the depth-cap check rejects nested work rather than resetting the counter
|
|
311
|
+
* after env scrubbing after a forged zero.
|
|
312
|
+
*/
|
|
313
|
+
export function parseDepth(value = process.env[DEPTH_ENV_VAR]): number {
|
|
314
|
+
if (value === undefined || value === "") return 0;
|
|
315
|
+
const parsed = Number.parseInt(value, 10);
|
|
316
|
+
if (!Number.isFinite(parsed) || parsed < 0) return 100; // fail closed
|
|
317
|
+
return Math.min(parsed, 100);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export type SpawnPolicy = { kind: "unrestricted" } | { kind: "disabled" } | { kind: "allowlist"; agents: string[] };
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Parse the spawn allowlist env var.
|
|
324
|
+
* - unset / "*" → unrestricted
|
|
325
|
+
* - empty / "false" / "off" / "none" → disabled
|
|
326
|
+
* - "a,b" / "[a, b]" → allowlist (agentless also rejected)
|
|
327
|
+
* Malformed values fail closed to disabled.
|
|
328
|
+
*/
|
|
329
|
+
export function parseSpawnPolicy(value?: string): SpawnPolicy {
|
|
330
|
+
if (value === undefined) return { kind: "unrestricted" };
|
|
331
|
+
// Empty after trim is intentional disable; also treat bare false synonyms.
|
|
332
|
+
const trimmed = value.trim();
|
|
333
|
+
if (trimmed === "" || /^false|off|none$/i.test(trimmed)) return { kind: "disabled" };
|
|
334
|
+
if (trimmed === "*") return { kind: "unrestricted" };
|
|
335
|
+
// Reject control characters / bad forms before any permissive poke.
|
|
336
|
+
if (/[\x00-\x1f]/.test(trimmed)) return { kind: "disabled" };
|
|
337
|
+
const inner = trimmed.startsWith("[") && trimmed.endsWith("]")
|
|
338
|
+
? trimmed.slice(1, -1)
|
|
339
|
+
: trimmed;
|
|
340
|
+
const agents = inner
|
|
341
|
+
.split(",")
|
|
342
|
+
.map((item) => item.trim().replace(/^["']|["']$/g, "").toLowerCase())
|
|
343
|
+
.filter(Boolean);
|
|
344
|
+
if (agents.length === 0) return { kind: "disabled" };
|
|
345
|
+
// Agent names must stay simple identifiers; anything else is a forged policy.
|
|
346
|
+
if (agents.some((name) => !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(name))) return { kind: "disabled" };
|
|
347
|
+
return { kind: "allowlist", agents };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function describeSpawnPolicy(policy: SpawnPolicy): string {
|
|
351
|
+
if (policy.kind === "disabled") return "spawning disabled";
|
|
352
|
+
if (policy.kind === "allowlist") return `spawn allowlist: ${policy.agents.join(", ")}`;
|
|
353
|
+
return "unrestricted";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** True when this process should avoid spawning further nested subagents. */
|
|
357
|
+
export function shouldRegisterSubagentTool(
|
|
358
|
+
depth = parseDepth(),
|
|
359
|
+
maxDepth = defaultConfig.maxDepth,
|
|
360
|
+
): boolean {
|
|
361
|
+
return depth < maxDepth;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function validateSubagentRequest(
|
|
365
|
+
params: SubagentParams,
|
|
366
|
+
parent: ParentContext,
|
|
367
|
+
options: {
|
|
368
|
+
maxDepth?: number;
|
|
369
|
+
maxTasks?: number;
|
|
370
|
+
defaultTimeoutMs?: number;
|
|
371
|
+
taskDefaults?: TaskDefaultsByProfile;
|
|
372
|
+
agents?: Map<string, AgentDefinition>;
|
|
373
|
+
modelPolicy?: ModelPolicySnapshot;
|
|
374
|
+
modelPolicyError?: string;
|
|
375
|
+
} = {},
|
|
376
|
+
): ValidationResult {
|
|
377
|
+
const defaults = {
|
|
378
|
+
timeoutMs: options.defaultTimeoutMs,
|
|
379
|
+
taskDefaults: options.taskDefaults,
|
|
380
|
+
agents: options.agents,
|
|
381
|
+
modelPolicy: options.modelPolicy,
|
|
382
|
+
modelPolicyError: options.modelPolicyError,
|
|
383
|
+
};
|
|
384
|
+
const depth = parent.depth ?? parseDepth();
|
|
385
|
+
const maxDepth = options.maxDepth ?? defaultConfig.maxDepth;
|
|
386
|
+
if (depth >= maxDepth) return { ok: false, error: `Subagent nesting depth limit reached (${depth} >= ${maxDepth})` };
|
|
387
|
+
|
|
388
|
+
// Boot spawn policy (from our parent) — fail closed; applies to new spawn modes only.
|
|
389
|
+
const spawnPolicy = parseSpawnPolicy(process.env[SPAWNS_ENV_VAR]);
|
|
390
|
+
if (spawnPolicy.kind !== "unrestricted") {
|
|
391
|
+
const hasSpawnWork = typeof params.task === "string" || Array.isArray(params.tasks);
|
|
392
|
+
if (hasSpawnWork) {
|
|
393
|
+
if (spawnPolicy.kind === "disabled") {
|
|
394
|
+
return { ok: false, error: `Subagent spawning is disabled by parent policy (${describeSpawnPolicy(spawnPolicy)})` };
|
|
395
|
+
}
|
|
396
|
+
// Allowlist requires a named agent from the list; agentless is rejected.
|
|
397
|
+
const requestedAgents: Array<string | undefined> = Array.isArray(params.tasks)
|
|
398
|
+
? params.tasks.map((t) => t.agent)
|
|
399
|
+
: [params.agent];
|
|
400
|
+
for (let i = 0; i < requestedAgents.length; i++) {
|
|
401
|
+
const agentName = requestedAgents[i]?.trim().toLowerCase();
|
|
402
|
+
if (!agentName) {
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
error: `Task ${i + 1}: agentless tasks are not allowed under parent ${describeSpawnPolicy(spawnPolicy)}`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (!spawnPolicy.agents.includes(agentName)) {
|
|
409
|
+
return {
|
|
410
|
+
ok: false,
|
|
411
|
+
error: `Task ${i + 1}: agent "${agentName}" is not in parent ${describeSpawnPolicy(spawnPolicy)}`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const hasAction = params.action !== undefined;
|
|
419
|
+
const hasTask = typeof params.task === "string";
|
|
420
|
+
const hasTasks = Array.isArray(params.tasks);
|
|
421
|
+
const planOnly = params.action === "plan";
|
|
422
|
+
|
|
423
|
+
// action:"plan" is a dry-run of spawn modes: it MUST combine with task/tasks.
|
|
424
|
+
// Other actions remain exclusive with task/tasks.
|
|
425
|
+
if (planOnly) {
|
|
426
|
+
if (hasTask === hasTasks) {
|
|
427
|
+
// Both or neither: plan alone is invalid; task+tasks is also invalid.
|
|
428
|
+
if (!hasTask && !hasTasks) {
|
|
429
|
+
return { ok: false, error: "action:\"plan\" requires task or tasks[] (dry-run of a spawn request)" };
|
|
430
|
+
}
|
|
431
|
+
return { ok: false, error: "Provide exactly one of: task or tasks" };
|
|
432
|
+
}
|
|
433
|
+
} else {
|
|
434
|
+
const modes = [hasAction, hasTask, hasTasks].filter(Boolean).length;
|
|
435
|
+
if (modes === 0) {
|
|
436
|
+
return { ok: false, error: "Provide task, tasks, or action (status|wait|cancel|plan)" };
|
|
437
|
+
}
|
|
438
|
+
if (modes > 1) {
|
|
439
|
+
return { ok: false, error: "Provide exactly one of: action, task, or tasks" };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (hasAction && !planOnly) {
|
|
444
|
+
if (params.action !== "status" && !params.id) {
|
|
445
|
+
return { ok: false, error: `${params.action} requires a run id` };
|
|
446
|
+
}
|
|
447
|
+
if (params.action === "steer" && !params.message?.trim()) {
|
|
448
|
+
return { ok: false, error: "steer requires a non-empty message" };
|
|
449
|
+
}
|
|
450
|
+
// Management actions ignore task-config fields; reject obvious conflict residues.
|
|
451
|
+
if (params.async !== undefined) {
|
|
452
|
+
return { ok: false, error: "async cannot be combined with action" };
|
|
453
|
+
}
|
|
454
|
+
// `!planOnly` above excludes "plan"; TS cannot narrow the union across the flag.
|
|
455
|
+
return { ok: true, mode: params.action as ManagementMode, async: false, id: params.id, message: params.message, index: params.index, tasks: [] };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (hasTasks) {
|
|
459
|
+
const rawTasks = params.tasks!;
|
|
460
|
+
const maxTasks = options.maxTasks ?? defaultConfig.maxTasksPerRun;
|
|
461
|
+
if (!rawTasks.length || rawTasks.length > maxTasks) return { ok: false, error: `Expected 1..${maxTasks} tasks (configurable via maxTasksPerRun)` };
|
|
462
|
+
// Top-level TaskFields apply only to single-task mode.
|
|
463
|
+
if (params.system_prompt !== undefined || params.model !== undefined || params.tools !== undefined || params.profile !== undefined || params.cwd !== undefined || params.resume !== undefined || params.agent !== undefined) {
|
|
464
|
+
return { ok: false, error: "Top-level task options cannot be combined with tasks[]; set them on each tasks[] item" };
|
|
465
|
+
}
|
|
466
|
+
// Context forking duplicates the whole parent conversation per child;
|
|
467
|
+
// that cost is intentional for one focused writer, not an 8-way fanout.
|
|
468
|
+
if (rawTasks.length > 1 && rawTasks.some((task) => task.context === "fork")) {
|
|
469
|
+
return { ok: false, error: "context:'fork' is single-task only; parallel fanout would duplicate the parent conversation per child" };
|
|
470
|
+
}
|
|
471
|
+
const tasks: ResolvedTask[] = [];
|
|
472
|
+
for (let index = 0; index < rawTasks.length; index++) {
|
|
473
|
+
const normalized = normalizeTask(rawTasks[index] as ParallelTaskInput, index, parent, "explore", defaults);
|
|
474
|
+
if (normalized.error || !normalized.task) return { ok: false, error: normalized.error ?? "Invalid task" };
|
|
475
|
+
tasks.push(normalized.task);
|
|
476
|
+
}
|
|
477
|
+
const parallelError = validateParallel(tasks);
|
|
478
|
+
if (parallelError) return { ok: false, error: parallelError };
|
|
479
|
+
return {
|
|
480
|
+
ok: true,
|
|
481
|
+
mode: tasks.length > 1 ? "parallel" : "single",
|
|
482
|
+
async: params.async === true,
|
|
483
|
+
synthesis: params.synthesis?.trim() || undefined,
|
|
484
|
+
tasks,
|
|
485
|
+
planOnly: planOnly || undefined,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (params.synthesis !== undefined) {
|
|
490
|
+
return { ok: false, error: "synthesis applies to parallel mode only (tasks[])" };
|
|
491
|
+
}
|
|
492
|
+
// Single-task mode: top-level fields form the one task.
|
|
493
|
+
const normalized = normalizeTask(params as ParallelTaskInput, 0, parent, "general", defaults);
|
|
494
|
+
if (normalized.error || !normalized.task) return { ok: false, error: normalized.error ?? "Invalid task" };
|
|
495
|
+
return { ok: true, mode: "single", async: params.async === true, tasks: [normalized.task], planOnly: planOnly || undefined };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function describeCapability(task: ResolvedTask): string {
|
|
499
|
+
return `${task.profile}/${task.canWrite ? "RW" : "RO"} tools=[${task.effectiveTools.join(",")}]`;
|
|
500
|
+
}
|