@henryqw/pi-subagent 18.0.0 → 22.0.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/CONTEXT.md +76 -24
- package/README.md +116 -142
- package/dist/composition.d.ts +11 -0
- package/dist/composition.js +107 -0
- package/dist/direct-herdr.d.ts +17 -0
- package/dist/direct-herdr.js +219 -0
- package/dist/ephemeral.d.ts +2 -2
- package/dist/ephemeral.js +7 -7
- package/dist/git-runtime.d.ts +110 -0
- package/dist/git-runtime.js +472 -0
- package/dist/herdr-runtime.d.ts +137 -0
- package/dist/herdr-runtime.js +1228 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +20 -16
- package/dist/integration-git.d.ts +59 -0
- package/dist/integration-git.js +484 -0
- package/dist/launch-runtime.d.ts +54 -0
- package/dist/launch-runtime.js +195 -0
- package/dist/process.d.ts +9 -0
- package/dist/process.js +11 -0
- package/dist/runner.d.ts +356 -0
- package/dist/runner.js +2710 -0
- package/dist/schema.d.ts +2234 -0
- package/dist/schema.js +1236 -0
- package/dist/store.d.ts +50 -0
- package/dist/store.js +288 -0
- package/dist/working-evidence.d.ts +25 -0
- package/dist/working-evidence.js +261 -0
- package/docs/adr/001-composable-ephemeral-execution.md +3 -3
- package/docs/delegation-routing.html +89 -0
- package/docs/delegation-routing.svg +78 -0
- package/docs/orchestration-architecture.html +238 -0
- package/docs/orchestration-architecture.svg +189 -0
- package/docs/orchestration.md +122 -331
- package/example.png +0 -0
- package/examples/roles/implementer.md +2 -3
- package/extensions/admission.ts +66 -0
- package/extensions/config.ts +51 -37
- package/extensions/isolated.ts +638 -0
- package/extensions/result-transport.ts +13 -103
- package/extensions/role-tools.ts +11 -11
- package/extensions/subagent-command.ts +167 -0
- package/extensions/subagent.ts +264 -540
- package/extensions/task-name.ts +1 -2
- package/extensions/workflow.ts +60 -96
- package/package.json +19 -11
- package/skills/pi-subagent/SKILL.md +35 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { Role } from "../src/index.ts";
|
|
4
|
+
|
|
5
|
+
const READ_ONLY_TOOLS = new Set([
|
|
6
|
+
"read", "grep", "find", "ffgrep", "fffind", "ls", "codegraph_explore", "subagent_status",
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export function roleCanWrite(role: Role): boolean {
|
|
10
|
+
return Boolean(role.extensions.length || role.mcps?.length || role.tools.some((tool) => !READ_ONLY_TOOLS.has(tool)));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CheckoutAdmission {
|
|
14
|
+
register(pi: ExtensionAPI, directCanWrite: (input: unknown) => boolean): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function checkoutKey(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string> {
|
|
18
|
+
const cwd = await realpath(ctx.cwd);
|
|
19
|
+
const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 5_000 });
|
|
20
|
+
if (result.code !== 0 || result.killed) return cwd;
|
|
21
|
+
const root = result.stdout.replace(/\r?\n$/, "");
|
|
22
|
+
if (!root || /[\r\n\0]/.test(root)) throw new Error("Git returned a malformed checkout root during delegation admission.");
|
|
23
|
+
return await realpath(root);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Coordinate Pi-owned calls that can mutate one checkout. This is admission,
|
|
28
|
+
* not an OS sandbox: external processes and trusted extension lifecycle code
|
|
29
|
+
* remain outside interception.
|
|
30
|
+
*/
|
|
31
|
+
export function createCheckoutAdmission(): CheckoutAdmission {
|
|
32
|
+
const ownerByCheckout = new Map<string, string>();
|
|
33
|
+
const checkoutByCall = new Map<string, string>();
|
|
34
|
+
const release = (toolCallId: string) => {
|
|
35
|
+
const key = checkoutByCall.get(toolCallId);
|
|
36
|
+
if (!key) return;
|
|
37
|
+
checkoutByCall.delete(toolCallId);
|
|
38
|
+
if (ownerByCheckout.get(key) === toolCallId) ownerByCheckout.delete(key);
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
register(pi, directCanWrite) {
|
|
42
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
43
|
+
const potentiallyWriting = event.toolName === "delegate_task"
|
|
44
|
+
? directCanWrite(event.input)
|
|
45
|
+
: !READ_ONLY_TOOLS.has(event.toolName);
|
|
46
|
+
if (!potentiallyWriting) return;
|
|
47
|
+
const key = await checkoutKey(pi, ctx);
|
|
48
|
+
const owner = ownerByCheckout.get(key);
|
|
49
|
+
if (owner && owner !== event.toolCallId) {
|
|
50
|
+
return {
|
|
51
|
+
block: true,
|
|
52
|
+
reason: `Checkout ${key} already has an admitted Pi writer (${owner}); retry after it settles.`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
ownerByCheckout.set(key, event.toolCallId);
|
|
56
|
+
checkoutByCall.set(event.toolCallId, key);
|
|
57
|
+
});
|
|
58
|
+
pi.on("tool_result", (event) => release(event.toolCallId));
|
|
59
|
+
pi.on("tool_execution_end", (event) => release(event.toolCallId));
|
|
60
|
+
pi.on("session_shutdown", () => {
|
|
61
|
+
ownerByCheckout.clear();
|
|
62
|
+
checkoutByCall.clear();
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
package/extensions/config.ts
CHANGED
|
@@ -12,56 +12,68 @@ export interface SubagentConfig {
|
|
|
12
12
|
maxSubagents?: number;
|
|
13
13
|
maxTurns?: number;
|
|
14
14
|
maxTokens?: number;
|
|
15
|
+
maxCorrections?: number;
|
|
15
16
|
timeout?: SubagentTimeoutConfig;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export interface LoadedSubagentConfig {
|
|
19
20
|
source: "file" | "missing";
|
|
20
21
|
config: SubagentConfig;
|
|
21
|
-
/** Human-readable problems
|
|
22
|
+
/** Human-readable problems; malformed files are preserved and delegation fails closed. */
|
|
22
23
|
error?: string;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
export interface EffectiveExecutionPolicy {
|
|
27
|
+
maxSubagents: number;
|
|
28
|
+
maxTurns: number;
|
|
29
|
+
maxTokens?: number;
|
|
30
|
+
childIdleMs: number;
|
|
31
|
+
childMaxMs: number;
|
|
32
|
+
maxCorrections: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
type ParsedSubagentConfig = Omit<LoadedSubagentConfig, "source">;
|
|
26
36
|
|
|
27
37
|
const EXTENSION_ID = "pi-subagent";
|
|
28
38
|
const positive = (value: unknown): value is number =>
|
|
29
39
|
typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
30
|
-
|
|
31
|
-
// Node clamps setTimeout delays above 2^31 - 1 ms to 1 ms, which would kill
|
|
32
|
-
// every child immediately instead of applying the configured deadline.
|
|
33
40
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
34
41
|
export const DEFAULT_TIMEOUT_CONFIG = { idleMinutes: 10, maxMinutes: 30 } as const;
|
|
42
|
+
export const DEFAULT_EXECUTION_POLICY = {
|
|
43
|
+
maxSubagents: 5,
|
|
44
|
+
maxTurns: 50,
|
|
45
|
+
maxCorrections: 1,
|
|
46
|
+
} as const;
|
|
35
47
|
const INTEGER_FIELDS = ["maxSubagents", "maxTurns", "maxTokens"] as const;
|
|
48
|
+
const POLICY_FIELDS = ["maxCorrections"] as const;
|
|
36
49
|
const TIMEOUT_FIELDS = ["idleMinutes", "maxMinutes"] as const;
|
|
37
50
|
|
|
38
|
-
/** Return the canonical default JSON path for pi-subagent's config home. */
|
|
39
51
|
export const configPath = (agentDir = getAgentDir()): string =>
|
|
40
52
|
extensionConfigPath(EXTENSION_ID, agentDir);
|
|
41
53
|
|
|
42
54
|
function parseSubagentConfig(parsed: unknown, path: string): ParsedSubagentConfig {
|
|
43
55
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
44
|
-
return { config: {}, error: `${path} must contain a JSON object;
|
|
56
|
+
return { config: {}, error: `${path} must contain a JSON object; delegation is blocked until this file is corrected.` };
|
|
45
57
|
}
|
|
46
58
|
const record = parsed as Record<string, unknown>;
|
|
47
59
|
const problems: string[] = [];
|
|
48
60
|
const config: SubagentConfig = {};
|
|
49
61
|
for (const key of Object.keys(record)) {
|
|
50
|
-
if (![...INTEGER_FIELDS, "timeout"].includes(key as
|
|
51
|
-
problems.push(`unknown config key ${JSON.stringify(key)}; expected ${INTEGER_FIELDS.join(", ")}, timeout`);
|
|
62
|
+
if (![...INTEGER_FIELDS, ...POLICY_FIELDS, "timeout"].includes(key as never)) {
|
|
63
|
+
problems.push(`unknown config key ${JSON.stringify(key)}; expected ${[...INTEGER_FIELDS, ...POLICY_FIELDS].join(", ")}, timeout`);
|
|
52
64
|
}
|
|
53
65
|
}
|
|
54
|
-
|
|
55
66
|
for (const key of INTEGER_FIELDS) {
|
|
56
67
|
const value = record[key];
|
|
57
68
|
if (value === undefined) continue;
|
|
58
|
-
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 1)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 1) config[key] = value;
|
|
70
|
+
else problems.push(`${key} must be a safe integer >= 1, got ${JSON.stringify(value)}`);
|
|
71
|
+
}
|
|
72
|
+
if (record.maxCorrections !== undefined) {
|
|
73
|
+
if (!Number.isSafeInteger(record.maxCorrections) || (record.maxCorrections as number) < 0) {
|
|
74
|
+
problems.push(`maxCorrections must be a safe integer >= 0, got ${JSON.stringify(record.maxCorrections)}`);
|
|
75
|
+
} else config.maxCorrections = record.maxCorrections as number;
|
|
63
76
|
}
|
|
64
|
-
|
|
65
77
|
if (record.timeout !== undefined) {
|
|
66
78
|
if (!record.timeout || typeof record.timeout !== "object" || Array.isArray(record.timeout)) {
|
|
67
79
|
problems.push(`timeout must be a JSON object, got ${JSON.stringify(record.timeout)}`);
|
|
@@ -71,26 +83,16 @@ function parseSubagentConfig(parsed: unknown, path: string): ParsedSubagentConfi
|
|
|
71
83
|
for (const key of TIMEOUT_FIELDS) {
|
|
72
84
|
const value = timeoutRecord[key];
|
|
73
85
|
if (value === undefined) continue;
|
|
74
|
-
if (!positive(value)) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
problems.push(`timeout.${key} exceeds the maximum supported delay of ${MAX_TIMER_DELAY_MS} ms, got ${JSON.stringify(value)} minutes`);
|
|
78
|
-
} else {
|
|
79
|
-
timeout[key] = value;
|
|
80
|
-
}
|
|
86
|
+
if (!positive(value)) problems.push(`timeout.${key} must be a positive number of minutes, got ${JSON.stringify(value)}`);
|
|
87
|
+
else if (value * 60_000 > MAX_TIMER_DELAY_MS) problems.push(`timeout.${key} exceeds ${MAX_TIMER_DELAY_MS} ms, got ${JSON.stringify(value)} minutes`);
|
|
88
|
+
else timeout[key] = value;
|
|
81
89
|
}
|
|
82
90
|
for (const key of Object.keys(timeoutRecord)) {
|
|
83
|
-
if (!TIMEOUT_FIELDS.includes(key as keyof SubagentTimeoutConfig)) {
|
|
84
|
-
problems.push(`unknown timeout.${key}; expected ${TIMEOUT_FIELDS.join(", ")}`);
|
|
85
|
-
}
|
|
91
|
+
if (!TIMEOUT_FIELDS.includes(key as keyof SubagentTimeoutConfig)) problems.push(`unknown timeout.${key}; expected ${TIMEOUT_FIELDS.join(", ")}`);
|
|
86
92
|
}
|
|
87
93
|
if (Object.keys(timeout).length) config.timeout = timeout;
|
|
88
94
|
}
|
|
89
95
|
}
|
|
90
|
-
|
|
91
|
-
// The hard cap must exceed the idle deadline or every idle kill is
|
|
92
|
-
// unreachable. Falling back to defaults drops the whole timeout object,
|
|
93
|
-
// matching other invalid values.
|
|
94
96
|
if (config.timeout) {
|
|
95
97
|
const idleMinutes = config.timeout.idleMinutes ?? DEFAULT_TIMEOUT_CONFIG.idleMinutes;
|
|
96
98
|
const maxMinutes = config.timeout.maxMinutes ?? DEFAULT_TIMEOUT_CONFIG.maxMinutes;
|
|
@@ -99,15 +101,27 @@ function parseSubagentConfig(parsed: unknown, path: string): ParsedSubagentConfi
|
|
|
99
101
|
delete config.timeout;
|
|
100
102
|
}
|
|
101
103
|
}
|
|
104
|
+
return {
|
|
105
|
+
config,
|
|
106
|
+
error: problems.length ? `${path}: ${problems.join("; ")}; delegation is blocked until this file is corrected.` : undefined,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
102
109
|
|
|
103
|
-
|
|
110
|
+
/** Resolve one immutable policy. Malformed explicit safety settings fail closed. */
|
|
111
|
+
export function resolveExecutionPolicy(loaded: LoadedSubagentConfig): EffectiveExecutionPolicy {
|
|
112
|
+
if (loaded.error) throw new Error(loaded.error);
|
|
113
|
+
const timeout = loaded.config.timeout;
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
maxSubagents: loaded.config.maxSubagents ?? DEFAULT_EXECUTION_POLICY.maxSubagents,
|
|
116
|
+
maxTurns: loaded.config.maxTurns ?? DEFAULT_EXECUTION_POLICY.maxTurns,
|
|
117
|
+
...(loaded.config.maxTokens === undefined ? {} : { maxTokens: loaded.config.maxTokens }),
|
|
118
|
+
childIdleMs: (timeout?.idleMinutes ?? DEFAULT_TIMEOUT_CONFIG.idleMinutes) * 60_000,
|
|
119
|
+
childMaxMs: (timeout?.maxMinutes ?? DEFAULT_TIMEOUT_CONFIG.maxMinutes) * 60_000,
|
|
120
|
+
maxCorrections: loaded.config.maxCorrections ?? DEFAULT_EXECUTION_POLICY.maxCorrections,
|
|
121
|
+
});
|
|
104
122
|
}
|
|
105
123
|
|
|
106
|
-
/**
|
|
107
|
-
* Read the optional user config at `<agentDir>/config/pi-subagent/config.json`.
|
|
108
|
-
* Treated as untrusted user data: malformed files are preserved untouched and
|
|
109
|
-
* reported instead of crashing the session; callers fall back to defaults.
|
|
110
|
-
*/
|
|
124
|
+
/** Read untrusted optional config without rewriting it. */
|
|
111
125
|
export function readSubagentConfig(agentDir = getAgentDir()): LoadedSubagentConfig {
|
|
112
126
|
const path = configPath(agentDir);
|
|
113
127
|
let validationError: string | undefined;
|
|
@@ -136,8 +150,8 @@ export function readSubagentConfig(agentDir = getAgentDir()): LoadedSubagentConf
|
|
|
136
150
|
source: "file",
|
|
137
151
|
config: {},
|
|
138
152
|
error: error instanceof SyntaxError
|
|
139
|
-
? `${path} is not valid JSON (${reason});
|
|
140
|
-
: `${path} couldn't be read (${reason});
|
|
153
|
+
? `${path} is not valid JSON (${reason}); delegation is blocked until this file is corrected.`
|
|
154
|
+
: `${path} couldn't be read (${reason}); delegation is blocked until this file is corrected.`,
|
|
141
155
|
};
|
|
142
156
|
}
|
|
143
157
|
}
|