@howarewoo/pi-subagents 0.0.0-stage → 0.2.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/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "@howarewoo/pi-subagents",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Pi-native single-assignment delegation with explicit profiles and parent-owned SDK children",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "homepage": "https://github.com/howarewoo/pi-subagents#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/howarewoo/pi-subagents/issues"
11
+ },
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/howarewoo/pi-subagents.git"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "docs",
20
+ "README.md",
21
+ "CHANGELOG.md",
22
+ "LICENSE",
23
+ "THIRD_PARTY_NOTICES.md"
24
+ ],
25
+ "type": "module",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "test": "node --test test/*.test.ts",
31
+ "typecheck": "tsc --noEmit",
32
+ "lint": "oxlint --deny-warnings .",
33
+ "lint:fix": "npm run lint -- --fix",
34
+ "format": "oxfmt --write .",
35
+ "format:check": "oxfmt --check .",
36
+ "exercise": "node examples/sdk-exercise.ts",
37
+ "check": "npm run lint && npm run format:check && npm run typecheck && npm test && npm run exercise",
38
+ "test:package": "node scripts/test-package.mjs",
39
+ "release:check": "node scripts/release-notes.mjs"
40
+ },
41
+ "dependencies": {
42
+ "@earendil-works/pi-schema": "1.5.2"
43
+ },
44
+ "devDependencies": {
45
+ "@earendil-works/pi-ai": "1.5.2",
46
+ "@earendil-works/pi-coding-agent": "1.5.2",
47
+ "@earendil-works/pi-tui": "1.5.2",
48
+ "@types/node": "26.6.6",
49
+ "oxfmt": "0.78.0",
50
+ "oxlint": "1.93.0",
51
+ "typescript": "7.0.2"
52
+ },
53
+ "peerDependencies": {
54
+ "@earendil-works/pi-ai": "*",
55
+ "@earendil-works/pi-coding-agent": "*",
56
+ "@earendil-works/pi-tui": "*"
57
+ },
58
+ "engines": {
59
+ "node": ">=22.19.0"
60
+ },
61
+ "pi": {
62
+ "extensions": [
63
+ "./src/pi-extended-subagents.ts"
64
+ ]
65
+ }
66
+ }
@@ -0,0 +1,39 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { WRITE_TOOLS } from "./profiles.ts";
3
+
4
+ export type ToolMetadata = ReturnType<ExtensionAPI["getAllTools"]>[number];
5
+ export type CapabilityDenial = { name: string; reason: string };
6
+
7
+ /** These controls belong to the child coordinator, never to the root dispatcher. */
8
+ const CONTROLS = new Set([
9
+ "delegate_task",
10
+ "delegate_coordinate",
11
+ "subagent",
12
+ "submit_result",
13
+ "codemode",
14
+ "tool_search",
15
+ ]);
16
+
17
+ /** A leaf grant is an operator assertion about trusted code, not a JavaScript sandbox.
18
+ * Undeclared orchestration is outside that contract; annotations never authorize forwarding.
19
+ */
20
+ export function leafDenial(
21
+ tool: ToolMetadata,
22
+ grants: readonly string[],
23
+ sameWorkspace: boolean,
24
+ ): string | undefined {
25
+ if (CONTROLS.has(tool.name)) return "owned or orchestrating control cannot be forwarded";
26
+ if (tool.reachesTools) return "tool-to-tool forwarding has no child execution scope";
27
+ if (tool.exposure === "hidden" || tool.exposure === "model-only") return "tool is not callable";
28
+ if (tool.sourceInfo.path.startsWith("builtin:")) return "builtin is outside the profile ceiling";
29
+ if (WRITE_TOOLS.includes(tool.name as (typeof WRITE_TOOLS)[number]))
30
+ return "builtin replacements cannot be leaf grants";
31
+ if (!grants.includes(tool.name))
32
+ return "no operator-reviewed leaf grant (unknown execution capabilities)";
33
+ if (!sameWorkspace) return "custom tool execution cwd cannot be rebound to the child workspace";
34
+ return undefined;
35
+ }
36
+
37
+ export function toolProvenance(tool: ToolMetadata): string {
38
+ return JSON.stringify([tool.sourceInfo, tool.exposure, tool.namespace]);
39
+ }
@@ -0,0 +1,459 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ createAgentSession,
5
+ createCodemodeExtension,
6
+ createExtensionRuntime,
7
+ DefaultResourceLoader,
8
+ SessionManager,
9
+ SettingsManager,
10
+ type AgentSession,
11
+ type AgentSessionEvent,
12
+ type ExtensionToolContext,
13
+ type ResourceLoader,
14
+ type Skill,
15
+ type ToolDefinition,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import type { Validator } from "@earendil-works/pi-schema";
18
+ import { submissionParameters, saveOutcomeArtifact } from "./outcomes.ts";
19
+ import { validateModelSelection, type ModelSelection } from "./model-policy.ts";
20
+ import { parentModelRuntime } from "./model-adapter.ts";
21
+ import { workspaceArguments } from "./workspace.ts";
22
+ import type { BuiltinTool } from "./profiles.ts";
23
+ import type { ChildSnapshot, StartTask } from "./delegation-types.ts";
24
+
25
+ /** Shared live execution fields; lifecycle ownership stays with the coordinator. */
26
+ export type ExecutionRecord = {
27
+ validator?: Validator;
28
+ input: StartTask;
29
+ selection: Pick<
30
+ ExtensionToolContext,
31
+ "model" | "modelRegistry" | "scopedModels" | "thinkingLevel" | "cwd"
32
+ >;
33
+ state: ChildSnapshot;
34
+ timer?: ReturnType<typeof setTimeout>;
35
+ run?: Promise<void>;
36
+ session?: AgentSession;
37
+ unsubscribe?: () => void;
38
+ paused?: boolean;
39
+ prompt?: (text: string) => Promise<void>;
40
+ resumeText?: string;
41
+ prompted?: boolean;
42
+ cancelled: boolean;
43
+ };
44
+
45
+ /** Operations bound to the exact admitted entry, never a later generation with its ID. */
46
+ export interface ExecutionOwner {
47
+ readonly closing: boolean;
48
+ update(patch: Partial<ChildSnapshot>, event?: AgentSessionEvent): void;
49
+ revalidateModel(selected: ModelSelection, requirePrewalk: boolean): Promise<void>;
50
+ authorizeTool(name: string, builtin: boolean): Promise<void>;
51
+ assertTool(name: string, builtin: boolean, signal?: AbortSignal): void;
52
+ chargeRequest(): void;
53
+ chargeToolCall(): void;
54
+ observeUserMessage(text: string): void;
55
+ drainDescendants(): Promise<void>;
56
+ dispose(): void;
57
+ }
58
+
59
+ export function childResources(prompt: string, skills: Skill[] = []): ResourceLoader {
60
+ const extensions = { extensions: [], errors: [], runtime: createExtensionRuntime() };
61
+ return {
62
+ getExtensions: () => extensions,
63
+ getSkills: () => ({ skills, diagnostics: [] }),
64
+ getPrompts: () => ({ prompts: [], diagnostics: [] }),
65
+ getThemes: () => ({ themes: [], diagnostics: [] }),
66
+ getAgentsFiles: () => ({ agentsFiles: [] }),
67
+ getSystemPrompt: () =>
68
+ prompt +
69
+ "\nYou are a delegated child. Complete only this assignment; use only explicitly granted tools and spawn roles.",
70
+ getSystemPromptSource: () => undefined,
71
+ getAppendSystemPrompt: () => [],
72
+ getAppendSystemPromptSources: () => [],
73
+ extendResources: () => {},
74
+ reload: async () => {},
75
+ };
76
+ }
77
+
78
+ export async function createChildExecution(
79
+ root: string,
80
+ entry: ExecutionRecord,
81
+ owner: ExecutionOwner,
82
+ ): Promise<void> {
83
+ const { input, selection } = entry;
84
+ try {
85
+ await mkdir(root, { recursive: true });
86
+ const taskDir = join(
87
+ root,
88
+ entry.state.generation === 1
89
+ ? entry.state.id
90
+ : `${entry.state.id}-g${entry.state.generation}`,
91
+ );
92
+ await mkdir(taskDir, { mode: 0o700 }); // exclusive ownership: never reuse an existing task record
93
+ const agentDir = join(taskDir, "agent");
94
+ await mkdir(agentDir, { mode: 0o700 });
95
+ const transcriptDir = join(taskDir, "transcripts");
96
+ await mkdir(transcriptDir, { mode: 0o700 });
97
+ const target =
98
+ entry.state.prewalk?.status === "armed"
99
+ ? validateModelSelection(input.context, entry.state.prewalk.target)
100
+ : undefined;
101
+ let policyError: string | undefined;
102
+ let completedMutation = false;
103
+ const inheritCodemode = !!input.codemodeEnabled?.();
104
+ if (!inheritCodemode) input.codemodeEnabled = () => false;
105
+ const effective = (): ModelSelection => ({
106
+ ...entry.state.model,
107
+ effort: entry.state.effort as ModelSelection["effort"],
108
+ });
109
+ const runtime = await parentModelRuntime(
110
+ selection,
111
+ agentDir,
112
+ () => {
113
+ if (policyError) throw new Error(policyError);
114
+ owner.chargeRequest();
115
+ },
116
+ target ? [target] : [],
117
+ );
118
+ const resources = childResources(
119
+ input.profile.prompt +
120
+ (target
121
+ ? "\nPrewalk: investigate and plan using only granted context/tools. After the first completed edit or write the same conversation switches once to the explicitly granted implementation target; all normal budgets still apply."
122
+ : "") +
123
+ `\nYour delegation identity is ${entry.state.id} generation ${entry.state.generation}; parent ${entry.state.parentId}. Coordination, if granted, is limited to family identities explicitly supplied to you.` +
124
+ (entry.validator
125
+ ? "\nThis assignment requires a structured result. Call submit_result with the actual value; final prose is not a submission."
126
+ : ""),
127
+ input.skills,
128
+ );
129
+ const loader = new DefaultResourceLoader({
130
+ cwd: selection.cwd,
131
+ agentDir,
132
+ settingsManager: SettingsManager.inMemory(),
133
+ noExtensions: true,
134
+ noSkills: true,
135
+ noPromptTemplates: true,
136
+ noThemes: true,
137
+ noContextFiles: true,
138
+ extensionFactories: [
139
+ ...(inheritCodemode ? [createCodemodeExtension({ models: false })] : []),
140
+ (pi) => {
141
+ pi.on("tool_call", (event, ctx) => {
142
+ if (event.toolName !== "codemode") return;
143
+ if (entry.cancelled || entry.paused || owner.closing)
144
+ throw new Error("Delegation cancelled");
145
+ ctx.signal?.throwIfAborted();
146
+ owner.chargeToolCall();
147
+ if (!input.codemodeEnabled?.())
148
+ return { block: true, reason: "Parent codemode is no longer enabled" };
149
+ });
150
+ pi.on("turn_start", async () => {
151
+ try {
152
+ await owner.revalidateModel(effective(), entry.state.prewalk?.status === "armed");
153
+ policyError = undefined;
154
+ } catch (error) {
155
+ policyError = String(error);
156
+ }
157
+ });
158
+ pi.on("turn_end", async (event) => {
159
+ if (
160
+ !entry.cancelled &&
161
+ !entry.paused &&
162
+ !owner.closing &&
163
+ entry.state.prewalk?.status === "armed" &&
164
+ (completedMutation ||
165
+ event.toolResults.some(
166
+ (result) =>
167
+ !result.isError &&
168
+ (result.toolName === "edit" || result.toolName === "write") &&
169
+ input.profile.tools.includes(result.toolName),
170
+ ))
171
+ ) {
172
+ try {
173
+ const next = entry.state.prewalk.target;
174
+ await owner.revalidateModel(next, true);
175
+ if (entry.cancelled || entry.paused || owner.closing) return;
176
+ await entry.session!.setModel(target!);
177
+ entry.session!.setThinkingLevel(next.effort);
178
+ if (entry.session!.thinkingLevel !== next.effort)
179
+ throw new Error("SDK coerced prewalk effort");
180
+ owner.update({
181
+ model: { provider: next.provider, id: next.id },
182
+ effort: next.effort,
183
+ prewalk: { target: next, status: "handed-off" },
184
+ });
185
+ } catch (error) {
186
+ policyError = String(error);
187
+ }
188
+ }
189
+ });
190
+ },
191
+ ],
192
+ });
193
+ await loader.reload();
194
+ resources.getExtensions = () => loader.getExtensions();
195
+ const tools: ToolDefinition[] = [
196
+ ...input.profile.tools,
197
+ ...(input.profile.leafTools ?? []),
198
+ ].map((name) => {
199
+ const tool = input.context.tools.find((tool) => tool.name === name);
200
+ if (!tool) throw new Error(`Admitted parent tool is no longer callable: ${name}`);
201
+ return {
202
+ name,
203
+ label: name,
204
+ description: tool.description,
205
+ parameters: tool.parameters,
206
+ execute: async (_id, args, signal, onUpdate) => {
207
+ if (entry.cancelled || entry.paused || owner.closing)
208
+ throw new Error("Delegation cancelled");
209
+ if (!input.context.tools.some((tool) => tool.name === name))
210
+ throw new Error(`Admitted parent tool is no longer callable: ${name}`);
211
+ const builtin = input.profile.tools.includes(name as BuiltinTool);
212
+ await owner.authorizeTool(name, builtin);
213
+ owner.assertTool(name, builtin, signal);
214
+ // The public parent dispatcher preserves validation and permission hooks. Do not
215
+ // call a raw create*Tool.execute here: that would bypass operator policy.
216
+ const outcome = await input.context.executeTool(
217
+ name,
218
+ builtin
219
+ ? workspaceArguments(name as BuiltinTool, args, selection.cwd, input.context.cwd)
220
+ : args,
221
+ { signal, onUpdate },
222
+ );
223
+ // SDK turn results contain only the outer codemode call, not its nested edits.
224
+ if (!outcome.isError && builtin && (name === "edit" || name === "write"))
225
+ completedMutation = true;
226
+ return { ...outcome.result, isError: outcome.isError };
227
+ },
228
+ };
229
+ });
230
+ if (entry.validator) {
231
+ const submission: ToolDefinition<typeof submissionParameters> = {
232
+ name: "submit_result",
233
+ label: "Submit result",
234
+ exposure: "model-only",
235
+ description: `Submit the assignment's required structured JSON value exactly once. Invalid values return errors and can be corrected within normal limits. Submission is provisional until prompt/tools/descendants drain; it never overrides cancellation or failure. Required schema: ${JSON.stringify(input.resultSchema)}`,
236
+ parameters: submissionParameters,
237
+ execute: async (_id, args, signal) => {
238
+ signal?.throwIfAborted();
239
+ if (
240
+ entry.cancelled ||
241
+ owner.closing ||
242
+ entry.state.disposed ||
243
+ entry.state.status !== "running"
244
+ )
245
+ throw new Error("Submission identity is no longer running");
246
+ const outcome = entry.state.outcome!;
247
+ if (outcome.validation === "submitted")
248
+ throw new Error("A result was already submitted; duplicate submission rejected");
249
+ const checked = entry.validator!.validate(args.value);
250
+ if (!checked.ok) {
251
+ const errors = checked.errors
252
+ .map((error) => `${error.path || "/"}: ${error.message}`)
253
+ .slice(0, 32);
254
+ owner.update({
255
+ outcome: {
256
+ validation: "invalid",
257
+ attempts: outcome.attempts + 1,
258
+ errors,
259
+ finalized: false,
260
+ },
261
+ });
262
+ throw new Error(
263
+ "Invalid structured result; correct and resubmit: " + errors.join("; "),
264
+ );
265
+ }
266
+ owner.update({
267
+ outcome: {
268
+ validation: "submitted",
269
+ attempts: outcome.attempts + 1,
270
+ errors: [],
271
+ value: structuredClone(args.value),
272
+ finalized: false,
273
+ },
274
+ });
275
+ return {
276
+ content: [
277
+ {
278
+ type: "text",
279
+ text: "Structured value submitted; final outcome awaits prompt/tool/descendant drain.",
280
+ },
281
+ ],
282
+ details: {},
283
+ };
284
+ },
285
+ };
286
+ tools.push(submission);
287
+ }
288
+ if (input.profile.coordination?.length && input.coordinationTools)
289
+ tools.push(
290
+ ...input.coordinationTools({ id: entry.state.id, generation: entry.state.generation }),
291
+ );
292
+ if (input.profile.spawn?.length && input.nestedTool) tools.push(input.nestedTool(entry.state));
293
+ for (const tool of tools) {
294
+ const execute = tool.execute;
295
+ tool.execute = async (...args) => {
296
+ if (entry.cancelled || entry.paused || owner.closing)
297
+ throw new Error("Delegation cancelled");
298
+ owner.chargeToolCall();
299
+ return execute(...args);
300
+ };
301
+ }
302
+ const { session, modelFallbackMessage } = await createAgentSession({
303
+ cwd: selection.cwd,
304
+ agentDir,
305
+ modelRuntime: runtime,
306
+ model: selection.model,
307
+ thinkingLevel: selection.thinkingLevel,
308
+ scopedModels: [
309
+ { model: selection.model!, thinkingLevel: selection.thinkingLevel },
310
+ ...(target ? [{ model: target, thinkingLevel: input.profile.prewalk!.effort }] : []),
311
+ ],
312
+ resourceLoader: resources,
313
+ tools: [...tools.map((tool) => tool.name), ...(inheritCodemode ? ["codemode"] : [])],
314
+ customTools: tools,
315
+ settingsManager: SettingsManager.inMemory({
316
+ compaction: { enabled: false },
317
+ retry: { enabled: false },
318
+ }),
319
+ sessionManager: SessionManager.create(selection.cwd, transcriptDir),
320
+ });
321
+ entry.session = session;
322
+ if (session.thinkingLevel !== selection.thinkingLevel)
323
+ throw new Error("SDK did not preserve selected effort");
324
+ if (
325
+ modelFallbackMessage ||
326
+ session.model?.id !== selection.model?.id ||
327
+ session.model?.provider !== selection.model?.provider
328
+ )
329
+ throw new Error("SDK did not preserve selected model");
330
+ let markStarted = () => {};
331
+ entry.unsubscribe = session.subscribe((event) => {
332
+ const patch: { -readonly [K in keyof ChildSnapshot]?: ChildSnapshot[K] } = {};
333
+ if (event.type === "agent_start") {
334
+ patch.status = entry.cancelled ? "draining" : entry.paused ? "pausing" : "running";
335
+ markStarted();
336
+ }
337
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
338
+ patch.progress = (entry.state.progress + event.assistantMessageEvent.delta).slice(-4096);
339
+ patch.activity = patch.progress;
340
+ }
341
+ if (event.type === "message_end" && event.message.role === "user") {
342
+ const text =
343
+ typeof event.message.content === "string"
344
+ ? event.message.content
345
+ : event.message.content
346
+ .filter((block) => block.type === "text")
347
+ .map((block) => block.text)
348
+ .join("");
349
+ owner.observeUserMessage(text);
350
+ }
351
+ if (event.type === "tool_execution_start") patch.activity = `Tool: ${event.toolName}`;
352
+ if (event.type === "message_end" && event.message.role === "assistant") {
353
+ const stats = session.getSessionStats();
354
+ patch.telemetry = {
355
+ ...entry.state.telemetry,
356
+ tokens: stats.tokens,
357
+ cost: stats.cost,
358
+ context: session.getContextUsage() ?? null,
359
+ };
360
+ }
361
+ owner.update(patch, event);
362
+ });
363
+ owner.update({ transcript: session.sessionFile });
364
+ if (owner.closing || entry.cancelled) {
365
+ owner.update({ status: "cancelled" });
366
+ owner.dispose();
367
+ return;
368
+ }
369
+ entry.prompt = (text) => {
370
+ if (!entry.prompted) {
371
+ text = entry.resumeText ? input.assignment + "\n\n" + entry.resumeText : input.assignment;
372
+ entry.prompted = true;
373
+ }
374
+ const started = new Promise<void>((resolve) => {
375
+ markStarted = resolve;
376
+ });
377
+ entry.run = session
378
+ .prompt(text, { expandPromptTemplates: false })
379
+ .then(async () => {
380
+ if (entry.paused && !entry.cancelled && !owner.closing) return;
381
+ await owner.drainDescendants();
382
+ if (entry.paused && !entry.cancelled && !owner.closing) return;
383
+ clearTimeout(entry.timer); // Work has drained; artifact publication is finalization.
384
+ const last = session.messages.filter((message) => message.role === "assistant").at(-1);
385
+ const failed = !last || last.stopReason === "error";
386
+ const stats = session.getSessionStats();
387
+ owner.update({ status: "draining", activity: "Finalizing outcome artifacts" });
388
+ const result = session.getLastAssistantText() ?? "";
389
+ const artifacts: NonNullable<ChildSnapshot["artifacts"]> = {};
390
+ try {
391
+ artifacts.text = await saveOutcomeArtifact(
392
+ taskDir,
393
+ "result.txt",
394
+ last?.content
395
+ .filter((block) => block.type === "text")
396
+ .map((block) => block.text)
397
+ .join("") ?? "",
398
+ );
399
+ if (entry.state.outcome?.validation === "submitted")
400
+ artifacts.structured = await saveOutcomeArtifact(
401
+ taskDir,
402
+ "structured.json",
403
+ JSON.stringify(entry.state.outcome.value),
404
+ );
405
+ } catch (error) {
406
+ artifacts.error = `Outcome artifact persistence failed: ${String(error)}`;
407
+ }
408
+ const missing = entry.validator && entry.state.outcome?.validation !== "submitted";
409
+ owner.update({
410
+ artifacts,
411
+ outcome: entry.state.outcome ? { ...entry.state.outcome, finalized: true } : undefined,
412
+ status:
413
+ entry.cancelled || last?.stopReason === "aborted"
414
+ ? "cancelled"
415
+ : failed || missing || artifacts.error
416
+ ? "failed"
417
+ : "settled",
418
+ result,
419
+ error: entry.state.limitReason
420
+ ? entry.state.error
421
+ : failed
422
+ ? (last?.errorMessage ?? "No final assistant outcome")
423
+ : missing
424
+ ? "Required structured result was not submitted successfully"
425
+ : artifacts.error,
426
+ activity: "Prompt completed",
427
+ telemetry: {
428
+ requests: entry.state.telemetry.requests,
429
+ tokens: stats.assistantMessages ? stats.tokens : null,
430
+ cost: stats.assistantMessages ? stats.cost : null,
431
+ context: session.getContextUsage() ?? null,
432
+ },
433
+ });
434
+ })
435
+ .catch(async (error) => {
436
+ if (entry.paused && !entry.cancelled && !owner.closing) return;
437
+ await owner.drainDescendants();
438
+ owner.update({
439
+ status: entry.cancelled ? "cancelled" : "failed",
440
+ error: entry.state.limitReason ? entry.state.error : String(error),
441
+ });
442
+ })
443
+ .finally(() => {
444
+ if (!entry.paused || entry.cancelled || owner.closing) owner.dispose();
445
+ });
446
+ // Prompt preparation precedes abort-signal ownership in every execution window.
447
+ return Promise.race([started, entry.run]);
448
+ };
449
+ if (entry.paused) return;
450
+ await entry.prompt(entry.resumeText ?? input.assignment);
451
+ } catch (error) {
452
+ owner.update({
453
+ status: entry.cancelled || owner.closing ? "cancelled" : "failed",
454
+ error: String(error),
455
+ });
456
+ owner.dispose();
457
+ throw error;
458
+ }
459
+ }