@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.
Files changed (47) hide show
  1. package/CONTEXT.md +76 -24
  2. package/README.md +116 -142
  3. package/dist/composition.d.ts +11 -0
  4. package/dist/composition.js +107 -0
  5. package/dist/direct-herdr.d.ts +17 -0
  6. package/dist/direct-herdr.js +219 -0
  7. package/dist/ephemeral.d.ts +2 -2
  8. package/dist/ephemeral.js +7 -7
  9. package/dist/git-runtime.d.ts +110 -0
  10. package/dist/git-runtime.js +472 -0
  11. package/dist/herdr-runtime.d.ts +137 -0
  12. package/dist/herdr-runtime.js +1228 -0
  13. package/dist/index.d.ts +11 -5
  14. package/dist/index.js +20 -16
  15. package/dist/integration-git.d.ts +59 -0
  16. package/dist/integration-git.js +484 -0
  17. package/dist/launch-runtime.d.ts +54 -0
  18. package/dist/launch-runtime.js +195 -0
  19. package/dist/process.d.ts +9 -0
  20. package/dist/process.js +11 -0
  21. package/dist/runner.d.ts +356 -0
  22. package/dist/runner.js +2710 -0
  23. package/dist/schema.d.ts +2234 -0
  24. package/dist/schema.js +1236 -0
  25. package/dist/store.d.ts +50 -0
  26. package/dist/store.js +288 -0
  27. package/dist/working-evidence.d.ts +25 -0
  28. package/dist/working-evidence.js +261 -0
  29. package/docs/adr/001-composable-ephemeral-execution.md +3 -3
  30. package/docs/delegation-routing.html +89 -0
  31. package/docs/delegation-routing.svg +78 -0
  32. package/docs/orchestration-architecture.html +238 -0
  33. package/docs/orchestration-architecture.svg +189 -0
  34. package/docs/orchestration.md +122 -331
  35. package/example.png +0 -0
  36. package/examples/roles/implementer.md +2 -3
  37. package/extensions/admission.ts +66 -0
  38. package/extensions/config.ts +51 -37
  39. package/extensions/isolated.ts +638 -0
  40. package/extensions/result-transport.ts +13 -103
  41. package/extensions/role-tools.ts +11 -11
  42. package/extensions/subagent-command.ts +167 -0
  43. package/extensions/subagent.ts +264 -540
  44. package/extensions/task-name.ts +1 -2
  45. package/extensions/workflow.ts +60 -96
  46. package/package.json +19 -11
  47. 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
+ }
@@ -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 when the file exists but is partly unusable; the file is never rewritten. */
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; using defaults.` };
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 typeof INTEGER_FIELDS[number] | "timeout")) {
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
- config[key] = value;
60
- } else {
61
- problems.push(`${key} must be a safe integer >= 1, got ${JSON.stringify(value)}`);
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
- problems.push(`timeout.${key} must be a positive number of minutes, got ${JSON.stringify(value)}`);
76
- } else if (value * 60_000 > MAX_TIMER_DELAY_MS) {
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
- return { config, error: problems.length ? `${path}: ${problems.join("; ")}; invalid settings use defaults while valid settings still apply.` : undefined };
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}); using defaults.`
140
- : `${path} couldn't be read (${reason}); using defaults.`,
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
  }