@hcflabs/pi-subagent 0.0.0-stage → 27.10.7

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 (66) hide show
  1. package/CONTEXT.md +77 -0
  2. package/LICENSE +22 -0
  3. package/README.md +282 -2
  4. package/dist/composition.d.ts +11 -0
  5. package/dist/composition.js +104 -0
  6. package/dist/direct-herdr.d.ts +71 -0
  7. package/dist/direct-herdr.js +527 -0
  8. package/dist/discard.d.ts +51 -0
  9. package/dist/discard.js +218 -0
  10. package/dist/display-text.d.ts +5 -0
  11. package/dist/display-text.js +9 -0
  12. package/dist/ephemeral.d.ts +79 -0
  13. package/dist/ephemeral.js +916 -0
  14. package/dist/git-process.d.ts +8 -0
  15. package/dist/git-process.js +25 -0
  16. package/dist/git-runtime.d.ts +115 -0
  17. package/dist/git-runtime.js +489 -0
  18. package/dist/herdr-runtime.d.ts +147 -0
  19. package/dist/herdr-runtime.js +1366 -0
  20. package/dist/index.d.ts +170 -0
  21. package/dist/index.js +587 -0
  22. package/dist/integration-git.d.ts +63 -0
  23. package/dist/integration-git.js +504 -0
  24. package/dist/launch-runtime.d.ts +54 -0
  25. package/dist/launch-runtime.js +178 -0
  26. package/dist/mcp-role.d.ts +12 -0
  27. package/dist/mcp-role.js +152 -0
  28. package/dist/process-lease.d.ts +5 -0
  29. package/dist/process-lease.js +43 -0
  30. package/dist/process.d.ts +9 -0
  31. package/dist/process.js +11 -0
  32. package/dist/prune.d.ts +78 -0
  33. package/dist/prune.js +205 -0
  34. package/dist/review-evidence.d.ts +17 -0
  35. package/dist/review-evidence.js +221 -0
  36. package/dist/runner.d.ts +431 -0
  37. package/dist/runner.js +3380 -0
  38. package/dist/schema.d.ts +2560 -0
  39. package/dist/schema.js +1365 -0
  40. package/dist/store.d.ts +66 -0
  41. package/dist/store.js +394 -0
  42. package/dist/worktree.d.ts +65 -0
  43. package/dist/worktree.js +288 -0
  44. package/docs/orchestration-architecture.html +138 -0
  45. package/docs/orchestration-architecture.svg +135 -0
  46. package/docs/orchestration.md +577 -0
  47. package/docs/worker-lifecycle.html +97 -0
  48. package/docs/worker-lifecycle.svg +86 -0
  49. package/example.png +0 -0
  50. package/examples/roles/implementer.md +25 -0
  51. package/examples/roles/reviewer.md +25 -0
  52. package/examples/roles/scout.md +25 -0
  53. package/extensions/admission.ts +82 -0
  54. package/extensions/config.ts +19 -0
  55. package/extensions/git-read.ts +126 -0
  56. package/extensions/isolated.ts +945 -0
  57. package/extensions/model-class-policy.ts +1 -0
  58. package/extensions/result-transport.ts +236 -0
  59. package/extensions/role-mcp.ts +14 -0
  60. package/extensions/role-tools.ts +178 -0
  61. package/extensions/subagent-command.ts +699 -0
  62. package/extensions/subagent.ts +958 -0
  63. package/extensions/workflow.ts +336 -0
  64. package/package.json +84 -4
  65. package/prompts/review.md +20 -0
  66. package/skills/pi-subagent/SKILL.md +41 -0
@@ -0,0 +1,218 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { runGit } from "./git-process.js";
4
+ import { inspectIndexFlags, OWNED_BRANCH_PATTERN, WORKTREES_DIRNAME } from "./worktree.js";
5
+ import { requireGit, within } from "./prune.js";
6
+ export function discardResources(state) {
7
+ return [
8
+ ...state.tasks.flatMap((task) => task.kind === "changeset" ? task.attempts.map((attempt, index) => ({
9
+ key: `task/${task.taskId}/${index + 1}`, task, attempt,
10
+ worktree: attempt.allocations.filter((item) => item.kind === "worktree").at(-1)?.worktree,
11
+ })) : task.attempts.flatMap((attempt) => attempt.cleanup ? [{
12
+ key: `task/${task.taskId}/${attempt.number}`, textAttempt: attempt,
13
+ worktree: { path: attempt.cleanup.path, cwd: attempt.cleanup.path, branch: attempt.cleanup.branch,
14
+ repoRoot: dirname(dirname(attempt.cleanup.path)) },
15
+ }] : [])),
16
+ ...state.integration.generations.map((generation) => ({ key: `integration/${generation.number}`, generation, worktree: generation.worktree })),
17
+ ];
18
+ }
19
+ export async function primaryCheckout(root, run = runGit) {
20
+ const first = (await requireGit(run, ["worktree", "list", "--porcelain", "-z"], root)).split("\0")[0];
21
+ if (!first?.startsWith("worktree "))
22
+ throw new Error("Primary Git checkout is unavailable.");
23
+ return realpathSync.native(first.slice(9));
24
+ }
25
+ const identity = (resource) => JSON.stringify([resource.worktree, resource.attempt?.allocations]);
26
+ /** Read-only evidence. A resource is one worker attempt or integration generation, including its host resources. */
27
+ export async function inspectDiscardResource(primary, mainCwd, resource, guards, run = runGit) {
28
+ const { worktree, attempt } = resource;
29
+ const allocations = attempt?.allocations ?? [];
30
+ const item = {
31
+ key: resource.key, identity: identity(resource), checkout: false, ignored: [], blockers: [],
32
+ hosts: allocations.filter((allocation) => allocation.kind !== "worktree").map((allocation) => JSON.stringify(allocation)),
33
+ };
34
+ const blocked = await guards.blocked();
35
+ if (blocked)
36
+ item.blockers.push(blocked);
37
+ if (allocations.some((allocation) => allocation.status === "allocating" || allocation.status === "unknown")) {
38
+ item.blockers.push("Allocation ownership is uncertain; inspect the recorded resources.");
39
+ }
40
+ if (!worktree)
41
+ return item;
42
+ item.path = worktree.path;
43
+ item.branch = worktree.branch;
44
+ if (!OWNED_BRANCH_PATTERN.test(worktree.branch) || worktree.repoRoot !== primary
45
+ || worktree.path !== join(primary, WORKTREES_DIRNAME, basename(worktree.branch))) {
46
+ item.blockers.push("Checkout or branch is not at this repository's owned path.");
47
+ return item;
48
+ }
49
+ const ref = `refs/heads/${worktree.branch}`;
50
+ const branches = (await requireGit(run, ["for-each-ref", "--format=%(refname) %(objectname) %(symref)", ref], primary)).split("\n");
51
+ const branch = branches.find((line) => line.startsWith(`${ref} `))?.split(" ");
52
+ item.tip = branch?.[1];
53
+ if (branch?.[2])
54
+ item.blockers.push("Owned branch is a symbolic reference; inspect it manually.");
55
+ if (item.tip && worktree.baseCommit)
56
+ item.commits = Number(await requireGit(run, ["rev-list", "--count", `${worktree.baseCommit}..${item.tip}`], primary));
57
+ const records = (await requireGit(run, ["worktree", "list", "--porcelain", "-z"], primary)).split("\0\0");
58
+ const registered = records.filter((record) => record.split("\0").includes(`branch refs/heads/${worktree.branch}`));
59
+ const owned = records.find((record) => record.startsWith(`worktree ${worktree.path}\0`));
60
+ if (registered.some((record) => !record.startsWith(`worktree ${worktree.path}\0`)))
61
+ item.blockers.push("Branch is checked out at another path.");
62
+ item.checkout = existsSync(worktree.path);
63
+ const cleanup = attempt?.cleanup ?? resource.generation?.cleanup;
64
+ if (item.checkout && (resource.textAttempt?.cleanup?.outcome === "pruned" || cleanup?.some((step) => step.kind === "worktree" && step.status === "completed"))
65
+ || item.tip && (resource.textAttempt?.cleanup?.outcome === "pruned" || cleanup?.some((step) => step.kind === "branch" && step.status === "completed"))) {
66
+ item.blockers.push("A resource exists after completed cleanup; new ownership is unproved.");
67
+ }
68
+ if (allocations.filter((allocation) => allocation.kind === "worktree").at(-1)?.status === "absent"
69
+ && (item.checkout || item.tip))
70
+ item.blockers.push("Allocation was recorded absent; ownership of the existing resource is unproved.");
71
+ if (owned && !item.checkout)
72
+ item.blockers.push("Checkout registration remains but its directory is missing; inspect it manually.");
73
+ if (!item.checkout)
74
+ return item;
75
+ if (!owned || !owned.split("\0").includes(`branch refs/heads/${worktree.branch}`))
76
+ item.blockers.push("Checkout registration changed or checkout is detached.");
77
+ if (owned?.split("\0").some((field) => field === "locked" || field.startsWith("locked ")))
78
+ item.blockers.push("Checkout is locked.");
79
+ const real = realpathSync.native(worktree.path);
80
+ if (real !== worktree.path)
81
+ item.blockers.push("Owned checkout path is redirected.");
82
+ if (within(real, realpathSync.native(mainCwd)))
83
+ item.blockers.push("Main runs inside the checkout.");
84
+ const status = (await requireGit(run, ["--no-optional-locks", "status", "--porcelain=v1", "-z", "--ignored=matching", "--untracked-files=normal", "--ignore-submodules=none"], worktree.path)).split("\0").filter(Boolean);
85
+ item.ignored = status.filter((entry) => entry.startsWith("!! ")).map((entry) => entry.slice(3));
86
+ item.dirty = status.some((entry) => !entry.startsWith("!! "));
87
+ if (!item.dirty) {
88
+ const flags = await inspectIndexFlags(worktree.path, run);
89
+ if (flags.failure || flags.hidden) {
90
+ delete item.dirty;
91
+ item.blockers.push(flags.failure ?? "Index flags hide tracked files; dirty state cannot be proved.");
92
+ }
93
+ }
94
+ const submodules = await requireGit(run, ["submodule", "status", "--recursive"], worktree.path);
95
+ if (submodules.split("\n").some((line) => line && !line.startsWith("-"))) {
96
+ item.blockers.push("Initialized submodules require separate inspection of their commits and ignored files.");
97
+ }
98
+ try {
99
+ const ownPanes = allocations.flatMap((allocation) => allocation.status !== "owned" ? []
100
+ : allocation.kind === "workspace" ? [allocation.rootPaneId]
101
+ : allocation.kind === "worker_tab" || allocation.kind === "agent" ? [allocation.paneId] : []);
102
+ const foreign = (await guards.panes()).filter((pane) => !ownPanes.includes(pane.paneId)
103
+ && pane.cwds.some((cwd) => within(real, cwd) || within(worktree.path, cwd)));
104
+ if (foreign.length)
105
+ item.blockers.push(`Other Herdr panes use this checkout: ${foreign.map((pane) => pane.paneId).join(", ")}`);
106
+ }
107
+ catch (error) {
108
+ item.blockers.push(`Herdr cannot be inspected: ${error instanceof Error ? error.message : String(error)}`);
109
+ }
110
+ return item;
111
+ }
112
+ export async function inspectDiscard(primary, mainCwd, state, guards) {
113
+ const items = [];
114
+ for (const resource of discardResources(state)) {
115
+ try {
116
+ const item = await inspectDiscardResource(primary, mainCwd, resource, guards);
117
+ const complete = resource.textAttempt?.cleanup?.outcome === "pruned"
118
+ || (resource.attempt?.cleanup ?? resource.generation?.cleanup)?.every((step) => step.status === "completed");
119
+ if (!complete || item.checkout || item.tip || item.blockers.length)
120
+ items.push(item);
121
+ }
122
+ catch (error) {
123
+ items.push({ key: resource.key, identity: identity(resource), path: resource.worktree?.path,
124
+ branch: resource.worktree?.branch, checkout: false, ignored: [], hosts: [], blockers: [`Inspection failed: ${String(error)}`] });
125
+ }
126
+ }
127
+ return items;
128
+ }
129
+ /** Called only after the command's confirmation. Never exposed as an agent tool. */
130
+ export async function removeDiscardResources(handle, primary, mainCwd, reviewed, guards, close, run = runGit) {
131
+ const results = [];
132
+ const removals = [];
133
+ for (const item of reviewed) {
134
+ const resource = discardResources(handle.state).find((resource) => resource.key === item.key);
135
+ if (!resource || identity(resource) !== item.identity || item.blockers.length) {
136
+ results.push({ key: item.key, outcome: "skipped", reason: "Ownership changed or the reviewed item is blocked." });
137
+ continue;
138
+ }
139
+ let checkoutRemoved = false;
140
+ const verify = async () => {
141
+ if (!guards.current())
142
+ throw new Error("Session changed; reopen /subagent.");
143
+ const fresh = await inspectDiscardResource(primary, mainCwd, resource, guards, run);
144
+ if (fresh.blockers.length)
145
+ throw new Error(fresh.blockers.join("; "));
146
+ if (fresh.tip !== item.tip)
147
+ throw new Error("Branch was deleted or moved since review; branch kept.");
148
+ if (fresh.checkout !== (checkoutRemoved ? false : item.checkout))
149
+ throw new Error("Checkout changed since review.");
150
+ if (fresh.ignored.some((entry) => !item.ignored.includes(entry)))
151
+ throw new Error("Ignored entries changed since review.");
152
+ const blocked = await guards.blocked();
153
+ if (blocked)
154
+ throw new Error(blocked);
155
+ if (!guards.current())
156
+ throw new Error("Session changed; reopen /subagent.");
157
+ };
158
+ try {
159
+ await verify();
160
+ }
161
+ catch (error) {
162
+ results.push({ key: item.key, outcome: "skipped", reason: String(error) });
163
+ continue;
164
+ }
165
+ const failed = async (error) => {
166
+ const pending = (resource.attempt?.cleanup ?? resource.generation?.cleanup)?.find((step) => step.status === "running");
167
+ if (pending) {
168
+ pending.failure = `User-confirmed discard failed: ${String(error)}`.slice(0, 500);
169
+ await handle.save();
170
+ }
171
+ results.push({ key: item.key, outcome: "failed", reason: `${checkoutRemoved ? "Checkout removed; " : ""}${String(error)}` });
172
+ };
173
+ try {
174
+ await close(resource, verify);
175
+ }
176
+ catch (error) {
177
+ await failed(error);
178
+ continue;
179
+ }
180
+ // Close all reviewed workers and host resources before any Git checkout is removed.
181
+ removals.push(async () => {
182
+ try {
183
+ const pending = [{ kind: "worktree", status: "pending" }, { kind: "branch", status: "pending" }];
184
+ const steps = resource.attempt?.cleanup ?? (resource.generation ? resource.generation.cleanup ??= pending : pending);
185
+ for (const kind of ["worktree", "branch"]) {
186
+ const step = steps.find((step) => step.kind === kind);
187
+ await verify();
188
+ if (step.status === "completed")
189
+ continue;
190
+ step.status = "running";
191
+ delete step.failure;
192
+ await handle.save();
193
+ await verify();
194
+ if (kind === "worktree" && item.checkout) {
195
+ await requireGit(run, ["worktree", "remove", "--force", item.path], primary);
196
+ checkoutRemoved = true;
197
+ }
198
+ else if (kind === "branch" && item.tip) {
199
+ await requireGit(run, ["update-ref", "--no-deref", "-d", `refs/heads/${item.branch}`, item.tip], primary);
200
+ }
201
+ step.status = "completed";
202
+ if (resource.textAttempt)
203
+ resource.textAttempt.cleanup = kind === "branch"
204
+ ? { outcome: "pruned", path: item.path, branch: item.branch }
205
+ : { outcome: "recovery", path: item.path, branch: item.branch, note: "User confirmed discard: checkout removed; branch cleanup pending." };
206
+ await handle.save();
207
+ }
208
+ results.push({ key: item.key, outcome: "removed", reason: "Reviewed resources removed; cleanup saved." });
209
+ }
210
+ catch (error) {
211
+ await failed(error);
212
+ }
213
+ });
214
+ }
215
+ for (const remove of removals)
216
+ await remove();
217
+ return results;
218
+ }
@@ -0,0 +1,5 @@
1
+ export declare const DISPLAY_TEXT_CONTRACT: {
2
+ readonly controlRanges: "\\u0000-\\u001F\\u007F-\\u009F";
3
+ readonly pattern: "^(?![\\s\\S]*[\\u0000-\\u001F\\u007F-\\u009F])[\\s\\S]+$";
4
+ };
5
+ export declare function hasDisplayControlCharacters(value: string): boolean;
@@ -0,0 +1,9 @@
1
+ const C0_C1_CONTROL_RANGES = "\\u0000-\\u001F\\u007F-\\u009F";
2
+ export const DISPLAY_TEXT_CONTRACT = {
3
+ controlRanges: C0_C1_CONTROL_RANGES,
4
+ pattern: `^(?![\\s\\S]*[${C0_C1_CONTROL_RANGES}])[\\s\\S]+$`,
5
+ };
6
+ const DISPLAY_TEXT_CONTROL_PATTERN = new RegExp(`[${DISPLAY_TEXT_CONTRACT.controlRanges}]`, "u");
7
+ export function hasDisplayControlCharacters(value) {
8
+ return DISPLAY_TEXT_CONTROL_PATTERN.test(value);
9
+ }
@@ -0,0 +1,79 @@
1
+ import type { Usage } from "@earendil-works/pi-ai";
2
+ import type { PiLaunch } from "./index.ts";
3
+ export declare const DEFAULT_MAX_TURNS = 50;
4
+ export declare const EXECUTION_BUDGET_ENV = "PI_SUBAGENT_EXECUTION_BUDGET";
5
+ export interface EphemeralSubagentExecutionBudget {
6
+ maxTurns: number;
7
+ maxMs: number | null;
8
+ startedAt: number;
9
+ maxTokens?: number;
10
+ }
11
+ export interface EphemeralSubagentTimeout {
12
+ idleMs: number;
13
+ maxMs: number | null;
14
+ }
15
+ export interface EphemeralSubagentExecutorOptions {
16
+ maxConcurrency: number;
17
+ maxTurns?: number;
18
+ maxTokens?: number;
19
+ timeout: EphemeralSubagentTimeout;
20
+ }
21
+ export type EphemeralSubagentActivityEvent = {
22
+ type: "tool_execution_start";
23
+ toolCallId: string;
24
+ toolName: string;
25
+ path?: string;
26
+ } | {
27
+ type: "tool_execution_end";
28
+ toolCallId: string;
29
+ toolName: string;
30
+ } | {
31
+ type: "message_end";
32
+ };
33
+ export interface EphemeralSubagentRunInput {
34
+ signal?: AbortSignal;
35
+ onUpdate?: (text: string) => void;
36
+ onTokens?: (tokens: number) => void;
37
+ onActivity?: (event: EphemeralSubagentActivityEvent) => void;
38
+ prepare: () => Promise<{
39
+ launch: PiLaunch;
40
+ task: string;
41
+ cwd: string;
42
+ }>;
43
+ }
44
+ interface EphemeralSubagentResultBase {
45
+ exitCode: number;
46
+ output: string;
47
+ outputTruncated: boolean;
48
+ stderr: string;
49
+ stopReason?: string;
50
+ errorMessage?: string;
51
+ usage?: Usage;
52
+ }
53
+ export type EphemeralSubagentResult = (EphemeralSubagentResultBase & {
54
+ outcome: "success";
55
+ }) | (EphemeralSubagentResultBase & {
56
+ outcome: "failure";
57
+ });
58
+ export type EphemeralSubagentErrorCode = "aborted" | "timeout" | "turn_limit" | "token_limit" | "spawn" | "protocol" | "prepare" | "callback";
59
+ export declare class EphemeralSubagentError extends Error {
60
+ name: string;
61
+ readonly code: EphemeralSubagentErrorCode;
62
+ readonly usage?: Usage;
63
+ readonly output?: string;
64
+ constructor(code: EphemeralSubagentErrorCode, message: string, cause?: unknown, usage?: Usage, output?: string);
65
+ }
66
+ export interface EphemeralSubagentExecutor {
67
+ run(input: EphemeralSubagentRunInput): Promise<EphemeralSubagentResult>;
68
+ }
69
+ /**
70
+ * Creates a bounded ephemeral executor for callers already running inside Pi.
71
+ * It reuses the active Pi process invocation; it does not resolve a standalone Pi installation.
72
+ */
73
+ export declare function createEphemeralSubagentExecutor(options: EphemeralSubagentExecutorOptions): EphemeralSubagentExecutor;
74
+ /** Longest prefix of `text` within `maxBytes` UTF-8 bytes, never splitting a code point. */
75
+ export declare function utf8Prefix(text: string, maxBytes: number): string;
76
+ export declare function capEphemeralSubagentOutput(text: string): string;
77
+ export declare function addUsage(left: Usage | undefined, right: Usage | undefined): Usage | undefined;
78
+ export declare function formatDuration(milliseconds: number): string;
79
+ export {};