@hcflabs/pi-subagent 0.0.0-stage → 27.10.8

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 +589 -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,178 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { chmod, lstat, mkdtemp, open, realpath, rmdir, unlink } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { isAbsolute, join, normalize } from "node:path";
6
+ import { resolveConfiguredRoleLaunch } from "./index.js";
7
+ import { registerModelTask } from "@hcflabs/pi-task-models";
8
+ import { isMissing, isWithin } from "./worktree.js";
9
+ const PROMPT_FLAG = "--append-system-prompt";
10
+ const DIRECTORY_MODE = 0o700;
11
+ const PROMPT_MODE = 0o600;
12
+ export const ISOLATED_MODEL_TASK = {
13
+ id: "pi-subagent/isolatedRoleLaunch",
14
+ label: "Isolated Role launch",
15
+ purpose: "Resolve one task-scoped isolated Role launch.",
16
+ defaultProfile: "balanced",
17
+ };
18
+ async function removeTransientLaunch(directory, promptPath, promptRequired) {
19
+ const failures = [];
20
+ try {
21
+ await unlink(promptPath);
22
+ }
23
+ catch (error) {
24
+ if (promptRequired || !isMissing(error)) {
25
+ failures.push(new Error(`Could not remove transient Role prompt ${promptPath}.`, { cause: error }));
26
+ }
27
+ }
28
+ try {
29
+ await rmdir(directory);
30
+ }
31
+ catch (error) {
32
+ failures.push(new Error(`Could not remove transient Role launch directory ${directory}.`, { cause: error }));
33
+ }
34
+ if (failures.length === 1)
35
+ throw failures[0];
36
+ if (failures.length > 1)
37
+ throw new AggregateError(failures, "Transient Role launch cleanup was incomplete.");
38
+ }
39
+ export async function materializeTransientLaunch(prepared, signal) {
40
+ signal?.throwIfAborted();
41
+ const promptBytes = Buffer.from(prepared.prompt, "utf8");
42
+ const created = await mkdtemp(join(tmpdir(), "pi-subagent-role-"));
43
+ let directory = normalize(created);
44
+ let promptPath = join(directory, "system-prompt");
45
+ let promptCreated = false;
46
+ let file;
47
+ try {
48
+ directory = normalize(await realpath(created));
49
+ promptPath = join(directory, "system-prompt");
50
+ await chmod(directory, DIRECTORY_MODE);
51
+ const directoryInfo = await lstat(directory);
52
+ if (directoryInfo.isSymbolicLink() || !directoryInfo.isDirectory()
53
+ || (directoryInfo.mode & 0o7777) !== DIRECTORY_MODE
54
+ || !isWithin(normalize(await realpath(tmpdir())), directory)) {
55
+ throw new Error("Transient Role launch directory is not unique canonical mode 0700 OS-temp storage.");
56
+ }
57
+ file = await open(promptPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, PROMPT_MODE);
58
+ promptCreated = true;
59
+ await file.chmod(PROMPT_MODE);
60
+ signal?.throwIfAborted();
61
+ await file.writeFile(promptBytes, { signal });
62
+ await file.sync();
63
+ await file.close();
64
+ file = undefined;
65
+ const promptInfo = await lstat(promptPath);
66
+ if (promptInfo.isSymbolicLink() || !promptInfo.isFile()
67
+ || (promptInfo.mode & 0o7777) !== PROMPT_MODE
68
+ || normalize(await realpath(promptPath)) !== promptPath) {
69
+ throw new Error("Transient Role prompt is not a canonical mode 0600 regular file.");
70
+ }
71
+ const args = [...prepared.launch.args];
72
+ args.splice(prepared.promptArgIndex, 0, PROMPT_FLAG, promptPath);
73
+ const launch = Object.freeze({
74
+ ...prepared.launch,
75
+ args: Object.freeze(args),
76
+ });
77
+ let cleanup;
78
+ return Object.freeze({
79
+ launch,
80
+ cleanup: async () => await (cleanup ??= removeTransientLaunch(directory, promptPath, true)),
81
+ });
82
+ }
83
+ catch (error) {
84
+ const failures = [error];
85
+ if (file) {
86
+ try {
87
+ await file.close();
88
+ }
89
+ catch (closeError) {
90
+ failures.push(closeError);
91
+ }
92
+ }
93
+ try {
94
+ await removeTransientLaunch(directory, promptPath, promptCreated);
95
+ }
96
+ catch (cleanupError) {
97
+ failures.push(cleanupError);
98
+ }
99
+ if (failures.length > 1) {
100
+ throw new AggregateError(failures, `Transient Role prompt materialization for ${prepared.launch.role} failed and cleanup was incomplete.`);
101
+ }
102
+ throw error;
103
+ }
104
+ }
105
+ export class RoleLaunchRuntime {
106
+ options;
107
+ constructor(options) {
108
+ this.options = options;
109
+ registerModelTask(options.pi, ISOLATED_MODEL_TASK);
110
+ }
111
+ now() {
112
+ return this.options.now?.() ?? Date.now();
113
+ }
114
+ randomToken() {
115
+ return this.options.randomToken?.() ?? randomBytes(12).toString("hex");
116
+ }
117
+ async prepareLaunch(role, modelClass, context) {
118
+ context.signal?.throwIfAborted();
119
+ const prepared = await resolveConfiguredRoleLaunch(this.options.pi, this.options.context(), { role, modelClass });
120
+ return Object.freeze({
121
+ launch: Object.freeze({
122
+ role: prepared.role,
123
+ modelClass,
124
+ model: `${prepared.model.provider}/${prepared.model.id}`,
125
+ thinkingLevel: prepared.thinkingLevel,
126
+ args: Object.freeze([...prepared.args]),
127
+ env: Object.freeze({ ...prepared.env }),
128
+ tools: Object.freeze([...prepared.tools]),
129
+ }),
130
+ prompt: prepared.systemPrompt,
131
+ promptArgIndex: prepared.promptArgIndex,
132
+ });
133
+ }
134
+ async preflight(input, context) {
135
+ context.signal?.throwIfAborted();
136
+ const resolvedRoot = await this.options.resolveRoot(input.cwd, context);
137
+ if (typeof resolvedRoot !== "string" || !isAbsolute(resolvedRoot) || resolvedRoot.includes("\0")) {
138
+ throw new Error("Pi Subagent root resolver must return an absolute canonical path.");
139
+ }
140
+ const root = await realpath(resolvedRoot);
141
+ if (root !== resolvedRoot)
142
+ throw new Error("Pi Subagent root resolver returned a non-canonical path.");
143
+ const rootInfo = await lstat(root);
144
+ if (!rootInfo.isDirectory())
145
+ throw new Error("Pi Subagent root must be an existing local directory.");
146
+ context.signal?.throwIfAborted();
147
+ if (!input.dryRun && input.request.tasks.some((task) => task.kind === "changeset")) {
148
+ await this.options.preflightHost?.({ request: input.request, cwd: input.cwd, root }, context);
149
+ }
150
+ const main = await this.options.inspectMainBase({ root }, context);
151
+ const required = new Map();
152
+ const addRequired = (role, modelClass) => {
153
+ const modelClasses = required.get(role) ?? new Set();
154
+ modelClasses.add(modelClass);
155
+ required.set(role, modelClasses);
156
+ };
157
+ for (const task of input.request.tasks) {
158
+ addRequired(task.role, task.modelClass);
159
+ if (task.kind === "changeset" && task.judgment)
160
+ addRequired(task.judgment.role, task.judgment.modelClass);
161
+ }
162
+ if (input.request.finalJudgment) {
163
+ addRequired(input.request.finalJudgment.role, input.request.finalJudgment.modelClass);
164
+ }
165
+ const routes = [];
166
+ for (const [role, modelClasses] of required) {
167
+ for (const modelClass of modelClasses) {
168
+ const { launch } = await this.prepareLaunch(role, modelClass, context);
169
+ routes.push({ role, modelClass, model: launch.model, thinkingLevel: launch.thinkingLevel });
170
+ }
171
+ }
172
+ return { root, main, routes };
173
+ }
174
+ async acquireLaunch(role, modelClass, context) {
175
+ const prepared = await this.prepareLaunch(role, modelClass, context);
176
+ return await materializeTransientLaunch(prepared, context.signal);
177
+ }
178
+ }
@@ -0,0 +1,12 @@
1
+ import type { LoadedMcpConfig } from "@earendil-works/pi-coding-agent";
2
+ export declare function parseRoleMcpAllowlist(value: unknown): string[];
3
+ /**
4
+ * Select the Role's allowlisted servers from the global `mcp.json` in Pi's native `mcpServers`
5
+ * shape; the project `.pi/mcp.json` is ignored. A child that activates `codemode` keeps each
6
+ * server's configured exposure, so scripts reach `codemode` and `deferred` tools. Any other child
7
+ * cannot reach undeclared tools, so every selected server is forced to direct exposure. Codemode is
8
+ * never activated by MCP configuration: the Role tool policy owns the active set.
9
+ */
10
+ export declare function loadRoleMcpConfig(agentDir: string, allowlist: readonly string[], options?: {
11
+ codemode: boolean;
12
+ }): LoadedMcpConfig;
@@ -0,0 +1,152 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export function parseRoleMcpAllowlist(value) {
4
+ if (typeof value !== "string")
5
+ throw new Error("The Role MCP policy flag must contain a JSON array of MCP server names.");
6
+ let parsed;
7
+ try {
8
+ parsed = JSON.parse(value);
9
+ }
10
+ catch {
11
+ throw new Error("The Role MCP policy flag must contain a JSON array of MCP server names.");
12
+ }
13
+ if (!Array.isArray(parsed)
14
+ || parsed.some((name) => typeof name !== "string" || !name.trim() || name.includes("\0"))) {
15
+ throw new Error("The Role MCP policy flag must contain a JSON array of MCP server names.");
16
+ }
17
+ const names = parsed.map((name) => name.trim());
18
+ if (new Set(names).size !== names.length)
19
+ throw new Error("The Role MCP policy flag contains duplicate MCP server names.");
20
+ return names;
21
+ }
22
+ function isRecord(value) {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value);
24
+ }
25
+ const isString = (value) => typeof value === "string";
26
+ const isStringRecord = (value) => isRecord(value) && Object.values(value).every(isString);
27
+ const parseUrl = (value) => (typeof value === "string" && URL.canParse(value) ? new URL(value) : undefined);
28
+ // Pi's native MCP rules (core/mcp-servers.ts); `validateMcpServerConfig` is not exported, so they are mirrored here.
29
+ const SERVER_NAME = /^[A-Za-z0-9_-]+$/;
30
+ const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
31
+ const isHttpsOrLoopback = (url) => url !== undefined && (url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname)));
32
+ /** Tool namespace `mcp__<server>` replaces `-` with `_`, so names differing only there collide. */
33
+ const mcpNamespace = (name) => name.replace(/-/g, "_");
34
+ // Pi's exposure names and the alias it still accepts (core/mcp-servers.ts); the native loader is bypassed, so resolve it here.
35
+ const MCP_EXPOSURES = ["codemode", "deferred", "direct", "hidden"];
36
+ const MCP_EXPOSURE_ALIASES = { "codemode-deferred": "codemode" };
37
+ const resolveExposure = (value) => MCP_EXPOSURE_ALIASES[value] ?? value;
38
+ const isExposure = (value) => typeof value === "string" && MCP_EXPOSURES.includes(resolveExposure(value));
39
+ /** Documented `mcpServers` fields (Pi's docs/mcp.md) and their shapes, by transport; dotted keys are nested. */
40
+ const COMMON_CHECKS = {
41
+ enabled: [(value) => typeof value === "boolean", "a boolean"],
42
+ timeout: [(value) => typeof value === "number" && Number.isFinite(value) && value > 0, "a positive number"],
43
+ description: [isString, "a string"],
44
+ };
45
+ const STDIO_CHECKS = {
46
+ args: [(value) => Array.isArray(value) && value.every(isString), "an array of strings"],
47
+ env: [isStringRecord, "an object of strings"],
48
+ cwd: [isString, "a string"],
49
+ };
50
+ const HTTP_CHECKS = {
51
+ url: [(value) => /^https?:$/.test(parseUrl(value)?.protocol ?? ""), "an http or https URL"],
52
+ headers: [isStringRecord, "an object of strings"],
53
+ oauth: [isRecord, "an object"],
54
+ "oauth.clientId": [isString, "a string"],
55
+ "oauth.clientSecret": [isString, "a string"],
56
+ "oauth.callbackPort": [(value) => Number.isInteger(value) && value >= 1 && value <= 65535, "a port number"],
57
+ "oauth.callbackUrl": [(value, server) => {
58
+ const url = parseUrl(value);
59
+ const port = server.oauth.callbackPort;
60
+ return url !== undefined && url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname) && url.search === "" && url.hash === ""
61
+ && (url.port === "" || port === undefined || Number(url.port) === port);
62
+ }, "an http URL on localhost, 127.0.0.1, or [::1] without query or fragment, on the \"oauth.callbackPort\" port"],
63
+ "oauth.scope": [isString, "a string"],
64
+ "oauth.clientName": [(value) => typeof value === "string" && value.trim() !== "", "a non-empty string"],
65
+ "oauth.clientRegistration": [(value) => value === "dcr" || value === "cimd", '"dcr" or "cimd"'],
66
+ "oauth.authServerMetadataUrl": [(value) => isHttpsOrLoopback(parseUrl(value)), "an https URL, or http on localhost, 127.0.0.1, or [::1]"],
67
+ auth: [(value) => isRecord(value) && typeof value.provider === "string" && value.provider !== "", "an object with a non-empty \"provider\" string"],
68
+ };
69
+ const EXPOSURE_CHECKS = {
70
+ exposure: [isExposure, `one of ${MCP_EXPOSURES.map((exposure) => `"${exposure}"`).join(", ")}`],
71
+ toolExposure: [(value) => isRecord(value) && Object.values(value).every(isExposure), "an object mapping tool names to exposures"],
72
+ };
73
+ /** Validate one selected server against Pi's documented `mcpServers` rules; the native loader is bypassed by `loadConfig`. */
74
+ function validateRoleMcpServer(path, name, value, codemode) {
75
+ const fail = (message) => { throw new Error(`${path}: MCP server "${name}" ${message}`); };
76
+ if (!SERVER_NAME.test(name))
77
+ fail("has an invalid name (use letters, digits, \"_\" and \"-\").");
78
+ if (!isRecord(value))
79
+ fail("must be an object.");
80
+ if (value.enabled === false)
81
+ throw new Error(`${path}: Role MCP server "${name}" is disabled.`);
82
+ const stdio = typeof value.command === "string" && value.command.trim() !== "" && (value.type === undefined || value.type === "stdio");
83
+ const http = typeof value.url === "string" && value.url.trim() !== "" && (value.type === undefined || value.type === "http" || value.type === "streamable-http");
84
+ if (stdio === http)
85
+ fail("needs either a \"command\" (stdio) or a \"url\" (http or streamable-http).");
86
+ for (const [field, [valid, shape]] of Object.entries({ ...COMMON_CHECKS, ...(stdio ? STDIO_CHECKS : HTTP_CHECKS), ...(codemode ? EXPOSURE_CHECKS : {}) })) {
87
+ const fieldValue = field.split(".").reduce((parent, key) => (isRecord(parent) ? parent[key] : undefined), value);
88
+ if (fieldValue !== undefined && !valid(fieldValue, value))
89
+ fail(`field "${field}" must be ${shape}.`);
90
+ }
91
+ if (http && isRecord(value.oauth) && value.oauth.clientRegistration === "cimd") {
92
+ if (value.oauth.clientId !== undefined || value.oauth.clientName !== undefined) {
93
+ fail('field "oauth.clientRegistration" "cimd" cannot be combined with "oauth.clientId" or "oauth.clientName".');
94
+ }
95
+ const callback = parseUrl(value.oauth.callbackUrl);
96
+ if (callback && (callback.hostname === "[::1]" || callback.pathname !== "/callback")) {
97
+ fail('field "oauth.clientRegistration" "cimd" requires "oauth.callbackUrl" on localhost or 127.0.0.1 with path /callback.');
98
+ }
99
+ }
100
+ // The native transport sends the provider's token to `url`, so Pi only allows it over https or loopback.
101
+ if (value.auth !== undefined && !isHttpsOrLoopback(parseUrl(value.url))) {
102
+ fail("field \"auth\" requires \"url\" to use https, or http on localhost, 127.0.0.1, or [::1].");
103
+ }
104
+ if (codemode) {
105
+ const { exposure, toolExposure } = value;
106
+ return {
107
+ ...value,
108
+ ...(typeof exposure === "string" ? { exposure: resolveExposure(exposure) } : {}),
109
+ ...(isRecord(toolExposure) ? { toolExposure: Object.fromEntries(Object.entries(toolExposure).map(([tool, entry]) => [tool, resolveExposure(entry)])) } : {}),
110
+ };
111
+ }
112
+ const { toolExposure: _toolExposure, ...config } = value;
113
+ return { ...config, exposure: "direct" };
114
+ }
115
+ /**
116
+ * Select the Role's allowlisted servers from the global `mcp.json` in Pi's native `mcpServers`
117
+ * shape; the project `.pi/mcp.json` is ignored. A child that activates `codemode` keeps each
118
+ * server's configured exposure, so scripts reach `codemode` and `deferred` tools. Any other child
119
+ * cannot reach undeclared tools, so every selected server is forced to direct exposure. Codemode is
120
+ * never activated by MCP configuration: the Role tool policy owns the active set.
121
+ */
122
+ export function loadRoleMcpConfig(agentDir, allowlist, options = { codemode: false }) {
123
+ // A Role without mcps must not depend on mcp.json being readable.
124
+ if (!allowlist.length)
125
+ return { servers: [], autoEnableCodemode: false, errors: [] };
126
+ const path = join(agentDir, "mcp.json");
127
+ let parsed = {};
128
+ try {
129
+ parsed = JSON.parse(readFileSync(path, "utf8"));
130
+ }
131
+ catch (error) {
132
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
133
+ throw new Error(`${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
134
+ }
135
+ }
136
+ if (!isRecord(parsed) || (parsed.mcpServers !== undefined && !isRecord(parsed.mcpServers))) {
137
+ throw new Error(`${path}: expected an object with an "mcpServers" object.`);
138
+ }
139
+ const configured = parsed.mcpServers ?? {};
140
+ const missing = allowlist.filter((name) => !Object.hasOwn(configured, name));
141
+ if (missing.length)
142
+ throw new Error(`Role MCP servers are not configured: ${missing.join(", ")}.`);
143
+ const namespaces = new Map();
144
+ const servers = allowlist.map((name) => {
145
+ const clash = namespaces.get(mcpNamespace(name));
146
+ if (clash)
147
+ throw new Error(`${path}: Role MCP server "${name}" conflicts with "${clash}": names that differ only in "-" and "_" share one tool namespace.`);
148
+ namespaces.set(mcpNamespace(name), name);
149
+ return { name, config: validateRoleMcpServer(path, name, configured[name], options.codemode), source: path, scope: "global" };
150
+ });
151
+ return { servers, autoEnableCodemode: false, errors: [] };
152
+ }
@@ -0,0 +1,5 @@
1
+ import type { HerdrExecResult } from "@hcflabs/pi-herdr";
2
+ export declare const PI_SUBAGENT_PROCESS_LEASE = "PI_SUBAGENT_PROCESS_LEASE";
3
+ export declare function assertPrivateLease(path: string, allowMissing: boolean): Promise<boolean>;
4
+ /** Shared exact process-lease evidence for direct and isolated workers. */
5
+ export declare function scanProcessLease(path: string, execute: (args: string[]) => Promise<HerdrExecResult>, pid?: number, allowMissing?: boolean): Promise<number[]>;
@@ -0,0 +1,43 @@
1
+ import { lstat } from "node:fs/promises";
2
+ export const PI_SUBAGENT_PROCESS_LEASE = "PI_SUBAGENT_PROCESS_LEASE";
3
+ export async function assertPrivateLease(path, allowMissing) {
4
+ let info;
5
+ try {
6
+ info = await lstat(path);
7
+ }
8
+ catch (error) {
9
+ if (allowMissing && error.code === "ENOENT")
10
+ return false;
11
+ throw new Error(`Exact process lease cannot be inspected: ${path}`, { cause: error });
12
+ }
13
+ const uid = process.getuid?.();
14
+ if (info.isSymbolicLink() || !info.isFile() || (info.mode & 0o777) !== 0o600 || (uid !== undefined && info.uid !== uid)) {
15
+ throw new Error("Exact process lease must be a current-user regular non-symlink mode-0600 file.");
16
+ }
17
+ return true;
18
+ }
19
+ /** Shared exact process-lease evidence for direct and isolated workers. */
20
+ export async function scanProcessLease(path, execute, pid, allowMissing = false) {
21
+ if (!await assertPrivateLease(path, allowMissing))
22
+ return [];
23
+ // -w: host-wide warnings (for example an unreachable network mount) are not lease evidence; real errors still reach stderr.
24
+ const args = ["-w", "-nP", "-a", ...(pid === undefined ? [] : ["-p", String(pid)]), "-F", "p", "--", path];
25
+ const result = await execute(args);
26
+ if (result.killed || ![0, 1].includes(result.code) || (result.code === 1 && (result.stdout.trim() || result.stderr.trim()))) {
27
+ throw new Error("Exact process lease lsof scan failed or was ambiguous.");
28
+ }
29
+ if (result.code === 1)
30
+ return [];
31
+ if (result.stderr.trim())
32
+ throw new Error("Exact process lease lsof scan returned unexpected diagnostics.");
33
+ // lsof always selects the file descriptor field, so `-F p` also emits `f<fd>` lines.
34
+ const lines = result.stdout.trim().split(/\r?\n/).filter((line) => line && !/^f\w+$/.test(line));
35
+ if (!lines.length || lines.some((line) => !/^p[1-9]\d*$/.test(line))) {
36
+ throw new Error("Exact process lease lsof scan returned malformed PID fields.");
37
+ }
38
+ const holders = [...new Set(lines.map((line) => Number(line.slice(1))))];
39
+ if (holders.some((holder) => !Number.isSafeInteger(holder) || holder <= 0) || (pid !== undefined && holders.some((holder) => holder !== pid))) {
40
+ throw new Error("Exact process lease lsof scan returned an unexpected PID.");
41
+ }
42
+ return holders;
43
+ }
@@ -0,0 +1,9 @@
1
+ type ProcessOptions = {
2
+ cwd: string;
3
+ signal?: AbortSignal;
4
+ timeoutMs?: number;
5
+ stdin?: string;
6
+ };
7
+ /** Infrastructure errors reject; only ordinary command exits return a result. */
8
+ export declare const runProcess: (command: string, args: string[], options: ProcessOptions) => Promise<import("@hcflabs/pi-process").ExecResult>;
9
+ export {};
@@ -0,0 +1,11 @@
1
+ import { spawnBounded } from "@hcflabs/pi-process";
2
+ const OUTPUT_LIMIT_BYTES = 1024 * 1024;
3
+ /** Infrastructure errors reject; only ordinary command exits return a result. */
4
+ export const runProcess = async (command, args, options) => await spawnBounded(command, args, {
5
+ cwd: options.cwd,
6
+ signal: options.signal,
7
+ timeoutMs: options.timeoutMs ?? null,
8
+ ...(options.stdin === undefined ? {} : { stdin: options.stdin }),
9
+ stdoutLimitBytes: OUTPUT_LIMIT_BYTES,
10
+ stderrLimitBytes: OUTPUT_LIMIT_BYTES,
11
+ });
@@ -0,0 +1,78 @@
1
+ import { type GitRunner } from "./git-process.ts";
2
+ /** Blockers that a separately confirmed force removal of an unowned item may override; every other kind is hard. */
3
+ export declare const FORCEABLE_PRUNE_BLOCKERS: readonly ["unmerged", "changes", "operation", "submodules"];
4
+ export type PruneBlockerKind = typeof FORCEABLE_PRUNE_BLOCKERS[number] | "owner" | "lease" | "hard";
5
+ export interface PruneBlocker {
6
+ kind: PruneBlockerKind;
7
+ text: string;
8
+ }
9
+ /** Saved identity of a readable request that owns a resource name. */
10
+ export interface PruneRequest {
11
+ id: string;
12
+ name: string;
13
+ status: string;
14
+ directory: string;
15
+ root: string;
16
+ orphaned: boolean;
17
+ }
18
+ /** One reviewed pi-subagent branch, with its registered checkout when one exists. */
19
+ export interface PruneItem {
20
+ branch: string;
21
+ tip: string;
22
+ path?: string;
23
+ prunable: boolean;
24
+ /** Why the item is prunable, or every exact blocker. */
25
+ reason: string;
26
+ blockers: readonly PruneBlocker[];
27
+ /** The only, readable request that owns the item. */
28
+ owner?: PruneRequest;
29
+ /** How a blocked item can still go after separate confirmation: discard of its whole owning request, or
30
+ * force removal when every blocker is forceable. Absent for prunable and hard-blocked items. */
31
+ override?: "discard" | "force";
32
+ /** Ignored entries as Git collapses them; removing the checkout deletes them. */
33
+ ignored: readonly string[];
34
+ }
35
+ export interface PruneOwnership {
36
+ /** Every request or state that may still own an exact resource name; unreadable state has no request. */
37
+ owners(name: string): readonly {
38
+ label: string;
39
+ request?: PruneRequest;
40
+ }[];
41
+ /** The live productive lease that blocks pruning, if any. */
42
+ lease?: string;
43
+ }
44
+ export interface PruneGuards {
45
+ /** Ownership and lease evidence across the repository's existing checkouts, primary first. */
46
+ ownership(checkouts: readonly string[]): Promise<PruneOwnership>;
47
+ /** Every Herdr pane; rejects when Herdr cannot be inspected. */
48
+ panes(): Promise<readonly {
49
+ paneId: string;
50
+ cwds: readonly string[];
51
+ }[]>;
52
+ /** The confirming session still owns the command; checked immediately before each removal step. */
53
+ current?(): boolean;
54
+ }
55
+ export type PruneRemoval = {
56
+ outcome: "removed" | "skipped" | "failed";
57
+ item: PruneItem;
58
+ reason: string;
59
+ };
60
+ export declare function requireGit(run: GitRunner, args: string[], cwd: string): Promise<string>;
61
+ export declare const within: (directory: string, path: string) => boolean;
62
+ /** Every pane in every Herdr workspace with its shell and foreground working directories. */
63
+ export declare function listHerdrPanes(cwd: string): Promise<{
64
+ paneId: string;
65
+ cwds: string[];
66
+ }[]>;
67
+ /** Read-only review of every pi-subagent worker or integration branch and its checkout. */
68
+ export declare function inspectPruneCandidates(root: string, mainCwd: string, guards: PruneGuards, run?: GitRunner): Promise<PruneItem[]>;
69
+ /**
70
+ * Re-verify each reviewed prunable item immediately before removing its checkout, then delete its
71
+ * branch only at the reviewed tip. Ignored entries are deleted only when `allowIgnored` and all of
72
+ * them were in the review. With `force`, a reviewed forceable item is removed with `--force` while
73
+ * every fresh blocker is forceable and of a reviewed kind. Returns the repository checkouts that still exist.
74
+ */
75
+ export declare function removePruneCandidates(root: string, mainCwd: string, reviewed: readonly PruneItem[], allowIgnored: boolean, force: boolean, guards: PruneGuards, run?: GitRunner): Promise<{
76
+ removals: PruneRemoval[];
77
+ checkouts: string[];
78
+ }>;