pi-subagents 0.40.0 → 0.42.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 (121) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +54 -1879
  3. package/agents/oracle.md +1 -0
  4. package/inspector-runner.mjs +11 -0
  5. package/package.json +13 -5
  6. package/prompts/parallel-context-build.md +1 -1
  7. package/prompts/parallel-handoff-plan.md +1 -1
  8. package/prompts/review-loop.md +1 -1
  9. package/skills/pi-subagents/SKILL.md +9 -7
  10. package/skills/pi-subagents/references/constraints-and-recipes.md +24 -32
  11. package/skills/pi-subagents/references/execution-controls.md +99 -97
  12. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  13. package/skills/pi-subagents/references/prompting-and-roles.md +23 -30
  14. package/src/agents/agent-management.ts +155 -65
  15. package/src/agents/agent-serializer.ts +19 -0
  16. package/src/agents/agents.ts +154 -71
  17. package/src/agents/chain-serializer.ts +10 -7
  18. package/src/agents/frontmatter.ts +5 -3
  19. package/src/agents/identity.ts +1 -1
  20. package/src/agents/proactive-skills.ts +13 -10
  21. package/src/agents/skills.ts +23 -6
  22. package/src/api/control-channel.ts +4 -0
  23. package/src/api/delegation.ts +26 -194
  24. package/src/api/external-runs.ts +129 -0
  25. package/src/api/intercom-bridge.ts +3 -0
  26. package/src/api/pi-args.ts +5 -0
  27. package/src/api/preflight.ts +3 -3
  28. package/src/api/shared-types.ts +19 -0
  29. package/src/extension/config.ts +10 -0
  30. package/src/extension/control-notices.ts +5 -39
  31. package/src/extension/doctor.ts +10 -9
  32. package/src/extension/fanout-child.ts +7 -4
  33. package/src/extension/index.ts +232 -68
  34. package/src/extension/rpc.ts +18 -12
  35. package/src/extension/schemas.ts +48 -37
  36. package/src/extension/tool-description.ts +36 -86
  37. package/src/inspectors/herdr/actions.ts +229 -0
  38. package/src/inspectors/herdr/client.ts +130 -0
  39. package/src/inspectors/herdr/inspector-runner.ts +141 -0
  40. package/src/inspectors/herdr/project-panes.ts +154 -0
  41. package/src/integrations/herdr-status.ts +330 -0
  42. package/src/intercom/intercom-bridge.ts +3 -2
  43. package/src/intercom/result-intercom.ts +5 -1
  44. package/src/missions/actions.ts +374 -0
  45. package/src/missions/lifecycle.ts +314 -0
  46. package/src/missions/store.ts +442 -0
  47. package/src/missions/types.ts +135 -0
  48. package/src/policy/authority.ts +46 -0
  49. package/src/profiles/profiles.ts +29 -3
  50. package/src/runs/background/async-execution.ts +110 -50
  51. package/src/runs/background/async-job-tracker.ts +27 -4
  52. package/src/runs/background/async-resume.ts +6 -6
  53. package/src/runs/background/async-status.ts +29 -1
  54. package/src/runs/background/auto-drain.ts +3 -3
  55. package/src/runs/background/chain-append.ts +3 -2
  56. package/src/runs/background/control-channel.ts +9 -7
  57. package/src/runs/background/fleet-view.ts +3 -4
  58. package/src/runs/background/notify.ts +2 -1
  59. package/src/runs/background/process-terminal.ts +5 -5
  60. package/src/runs/background/result-watcher.ts +13 -5
  61. package/src/runs/background/run-id-resolver.ts +3 -3
  62. package/src/runs/background/run-status.ts +35 -8
  63. package/src/runs/background/scheduled-runs.ts +602 -375
  64. package/src/runs/background/stale-run-reconciler.ts +3 -3
  65. package/src/runs/background/subagent-runner.ts +608 -445
  66. package/src/runs/background/subagent-wait.ts +50 -9
  67. package/src/runs/background/wait-subscriptions.ts +253 -0
  68. package/src/runs/background/wait-tool.ts +12 -4
  69. package/src/runs/foreground/async-steering-action.ts +3 -3
  70. package/src/runs/foreground/chain-clarify.ts +8 -4
  71. package/src/runs/foreground/chain-execution.ts +56 -30
  72. package/src/runs/foreground/execution.ts +34 -9
  73. package/src/runs/foreground/subagent-executor.ts +1036 -274
  74. package/src/runs/shared/acceptance.ts +28 -6
  75. package/src/runs/shared/child-protocol.ts +302 -22
  76. package/src/runs/shared/dynamic-fanout.ts +1 -1
  77. package/src/runs/shared/external-cli-runner.ts +130 -0
  78. package/src/runs/shared/long-running-guard.ts +42 -1
  79. package/src/runs/shared/mcp-direct-tool-allowlist.ts +2 -0
  80. package/src/runs/shared/nested-events.ts +59 -5
  81. package/src/runs/shared/nested-render.ts +9 -4
  82. package/src/runs/shared/parallel-handoff.ts +86 -2
  83. package/src/runs/shared/parallel-utils.ts +11 -2
  84. package/src/runs/shared/permissions.ts +95 -0
  85. package/src/runs/shared/pi-args.ts +468 -109
  86. package/src/runs/shared/pi-spawn.ts +11 -1
  87. package/src/runs/shared/run-history.ts +1 -1
  88. package/src/runs/shared/subagent-prompt-runtime.ts +36 -5
  89. package/src/runs/shared/subagent-startup-retry.ts +5 -2
  90. package/src/runs/shared/turn-budget.ts +6 -6
  91. package/src/runs/shared/worktree.ts +122 -12
  92. package/src/shared/accessible-dir.ts +29 -7
  93. package/src/shared/artifacts.ts +143 -1
  94. package/src/shared/fork-context.ts +3 -2
  95. package/src/shared/launch-contract.ts +1 -0
  96. package/src/shared/settings.ts +10 -0
  97. package/src/shared/types.ts +156 -14
  98. package/src/shared/utils.ts +8 -6
  99. package/src/slash/delegation-adapters.ts +32 -194
  100. package/src/slash/delegation-request.ts +43 -126
  101. package/src/slash/prompt-template-bridge.ts +158 -205
  102. package/src/slash/prompt-workflows.ts +21 -57
  103. package/src/slash/slash-bridge.ts +14 -0
  104. package/src/slash/slash-commands.ts +31 -632
  105. package/src/slash/subagents-admin.ts +18 -14
  106. package/src/tui/fleet-status.ts +156 -21
  107. package/src/tui/fleet-transcript.ts +110 -5
  108. package/src/tui/fleet.ts +57 -25
  109. package/src/tui/render.ts +368 -152
  110. package/src/types/pi-runtime-compat.d.ts +8 -0
  111. package/src/watchdog/lsp-diagnostics.ts +12 -7
  112. package/src/watchdog/model-selection.ts +2 -2
  113. package/src/watchdog/permission-arbiter.ts +145 -0
  114. package/src/watchdog/register-child.ts +1 -1
  115. package/src/watchdog/register-main.ts +1 -1
  116. package/src/watchdog/review.ts +4 -1
  117. package/src/watchdog/runtime.ts +3 -2
  118. package/src/workflows/chat-progress.ts +140 -0
  119. package/src/workflows/scripted-workflow.ts +415 -0
  120. package/agents/advisor.md +0 -73
  121. package/src/extension/chain-validation.ts +0 -181
@@ -0,0 +1,130 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+
3
+ export type HerdrErrorCode =
4
+ | "HERDR_UNAVAILABLE"
5
+ | "HERDR_UNSUPPORTED_VERSION"
6
+ | "PANE_GONE"
7
+ | "NOT_FOUND"
8
+ | "TIMEOUT"
9
+ | "VALIDATION_ERROR";
10
+
11
+ export type HerdrResult<T> =
12
+ | { ok: true; data: T }
13
+ | { ok: false; error: { code: HerdrErrorCode; message: string; details?: unknown } };
14
+
15
+ export interface HerdrClient {
16
+ run<T = unknown>(args: string[], options?: { timeoutMs?: number; signal?: AbortSignal; textOk?: boolean }): Promise<HerdrResult<T>>;
17
+ }
18
+
19
+ type SpawnHerdr = (command: string, args: readonly string[], options: { shell: false; windowsHide: true; env: NodeJS.ProcessEnv }) => ChildProcess;
20
+
21
+ function error(code: HerdrErrorCode, message: string, details?: unknown): HerdrResult<never> {
22
+ return { ok: false, error: { code, message, ...(details !== undefined ? { details } : {}) } };
23
+ }
24
+
25
+ function parseLastJson(value: string): unknown | undefined {
26
+ const trimmed = value.trim();
27
+ if (!trimmed) return undefined;
28
+ try { return JSON.parse(trimmed) as unknown; } catch {}
29
+ for (const line of trimmed.split(/\r?\n/).reverse()) {
30
+ try { return JSON.parse(line) as unknown; } catch {}
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ function normalizeCode(raw: unknown): HerdrErrorCode {
36
+ const code = String(raw ?? "").toLowerCase();
37
+ if (code.includes("timeout") || code.includes("timed_out")) return "TIMEOUT";
38
+ if (code.includes("gone")) return "PANE_GONE";
39
+ if (code.includes("not_found") || code.includes("not-found") || code === "no_such_pane") return "NOT_FOUND";
40
+ return "VALIDATION_ERROR";
41
+ }
42
+
43
+ export function createHerdrClient(options: { bin?: string; spawn?: SpawnHerdr } = {}): HerdrClient {
44
+ const bin = options.bin ?? process.env.HERDR_BIN ?? "herdr";
45
+ const spawnImpl = options.spawn ?? spawn;
46
+ return {
47
+ run<T>(args: string[], runOptions: { timeoutMs?: number; signal?: AbortSignal; textOk?: boolean } = {}): Promise<HerdrResult<T>> {
48
+ return new Promise((resolve) => {
49
+ let child: ChildProcess;
50
+ try {
51
+ child = spawnImpl(bin, args, { shell: false, windowsHide: true, env: process.env });
52
+ } catch (cause) {
53
+ const code = (cause as NodeJS.ErrnoException | undefined)?.code;
54
+ resolve(error("HERDR_UNAVAILABLE", code === "ENOENT"
55
+ ? "Herdr is not installed or is not on PATH. Install Herdr 0.7.5+ or set HERDR_BIN."
56
+ : `Failed to start Herdr: ${cause instanceof Error ? cause.message : String(cause)}`));
57
+ return;
58
+ }
59
+ let stdout = "";
60
+ let stderr = "";
61
+ let settled = false;
62
+ const finish = (result: HerdrResult<T>) => {
63
+ if (settled) return;
64
+ settled = true;
65
+ clearTimeout(timer);
66
+ runOptions.signal?.removeEventListener("abort", abort);
67
+ resolve(result);
68
+ };
69
+ const abort = () => {
70
+ try { child.kill(); } catch {}
71
+ finish(error("TIMEOUT", `Herdr command '${args.join(" ")}' was aborted.`));
72
+ };
73
+ const timer = setTimeout(() => {
74
+ try { child.kill(); } catch {}
75
+ finish(error("TIMEOUT", `Herdr command '${args.join(" ")}' timed out after ${runOptions.timeoutMs ?? 15_000}ms.`));
76
+ }, runOptions.timeoutMs ?? 15_000);
77
+ timer.unref?.();
78
+ if (runOptions.signal?.aborted) abort();
79
+ else runOptions.signal?.addEventListener("abort", abort, { once: true });
80
+ child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); });
81
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
82
+ child.on("error", (cause) => {
83
+ const code = (cause as NodeJS.ErrnoException).code;
84
+ finish(error("HERDR_UNAVAILABLE", code === "ENOENT"
85
+ ? "Herdr is not installed or is not on PATH. Install Herdr 0.7.5+ or set HERDR_BIN."
86
+ : `Failed to run Herdr: ${cause.message}`));
87
+ });
88
+ child.on("close", (exitCode) => {
89
+ const parsed = parseLastJson(stdout) ?? (exitCode === 0 ? undefined : parseLastJson(stderr));
90
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "error" in parsed) {
91
+ const raw = (parsed as { error?: { code?: unknown; message?: unknown } }).error;
92
+ finish(error(normalizeCode(raw?.code), String(raw?.message ?? "Herdr command failed."), raw));
93
+ return;
94
+ }
95
+ if (exitCode === 0) {
96
+ if (parsed !== undefined) {
97
+ const envelope = parsed as { result?: unknown };
98
+ finish({ ok: true, data: (envelope.result ?? parsed) as T });
99
+ } else if (runOptions.textOk) finish({ ok: true, data: stdout.trim() as T });
100
+ else finish({ ok: true, data: {} as T });
101
+ return;
102
+ }
103
+ const message = stderr.split(/\r?\n/).find((line) => line.trim())?.trim() ?? `Herdr exited with code ${exitCode}.`;
104
+ finish(error("VALIDATION_ERROR", message, { exitCode }));
105
+ });
106
+ });
107
+ },
108
+ };
109
+ }
110
+
111
+ export interface HerdrVersion { major: number; minor: number; patch: number }
112
+
113
+ export function parseHerdrVersion(value: string): HerdrVersion | undefined {
114
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(value);
115
+ return match ? { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) } : undefined;
116
+ }
117
+
118
+ export function supportsRawPanes(version: HerdrVersion): boolean {
119
+ return version.major > 0 || version.minor > 7 || (version.minor === 7 && version.patch >= 5);
120
+ }
121
+
122
+ export async function detectHerdr(client: HerdrClient, signal?: AbortSignal): Promise<HerdrResult<{ version: HerdrVersion; versionText: string }>> {
123
+ const result = await client.run<string>(["--version"], { timeoutMs: 3_000, signal, textOk: true });
124
+ if (result.ok === false) return result;
125
+ const versionText = typeof result.data === "string" ? result.data : JSON.stringify(result.data);
126
+ const version = parseHerdrVersion(versionText);
127
+ if (!version) return error("VALIDATION_ERROR", `Could not parse the Herdr version from '${versionText}'.`);
128
+ if (!supportsRawPanes(version)) return error("HERDR_UNSUPPORTED_VERSION", `Herdr ${versionText} does not support raw inspector panes. Upgrade to Herdr 0.7.5 or newer.`);
129
+ return { ok: true, data: { version, versionText } };
130
+ }
@@ -0,0 +1,141 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as readline from "node:readline";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseMissionRecord } from "../../missions/store.ts";
6
+ import type { MissionRecord } from "../../missions/types.ts";
7
+ import { requestAsyncSteer, requestAsyncStop } from "../../runs/background/control-channel.ts";
8
+ import { formatAsyncRunTranscript } from "../../runs/background/fleet-view.ts";
9
+ import type { AsyncStatus } from "../../shared/types.ts";
10
+ import { readStatus } from "../../shared/utils.ts";
11
+
12
+ export interface RunnerOptions {
13
+ asyncDir: string;
14
+ runId: string;
15
+ index?: number;
16
+ missionPath?: string;
17
+ refreshMs: number;
18
+ allowSteer?: boolean;
19
+ allowStop?: boolean;
20
+ }
21
+
22
+ function readMission(filePath: string | undefined): MissionRecord | undefined {
23
+ if (!filePath) return undefined;
24
+ try { return parseMissionRecord(JSON.parse(fs.readFileSync(filePath, "utf-8")), filePath); } catch { return undefined; }
25
+ }
26
+
27
+ export function formatInspectorDashboard(input: { status: AsyncStatus; asyncDir: string; index?: number; mission?: MissionRecord; allowSteer?: boolean; allowStop?: boolean }): string {
28
+ const { status, asyncDir, mission } = input;
29
+ const lines = [
30
+ `pi-subagents inspector for ${status.runId}`,
31
+ "This pane mirrors lifecycle artifacts; closing it does not stop the run.",
32
+ "",
33
+ ];
34
+ if (mission) {
35
+ lines.push(`Mission: ${mission.title} (${mission.status})`, `Mission id: ${mission.id}`);
36
+ const open = mission.decisions.filter((decision) => decision.status === "open");
37
+ if (open.length) lines.push(`Open decisions: ${open.map((decision) => `${decision.id}: ${decision.title}`).join(" | ")}`);
38
+ lines.push("");
39
+ }
40
+ lines.push(formatAsyncRunTranscript(status, asyncDir, { index: input.index, lines: 60 }));
41
+ const controls = [input.allowSteer === false ? undefined : "steer <message>", input.allowStop === false ? undefined : "stop", "status"].filter(Boolean);
42
+ lines.push("", `Controls: ${controls.join(" | ")}`, "Supervisor replies remain in the parent Pi session (subagent_supervisor/intercom).");
43
+ return lines.join("\n");
44
+ }
45
+
46
+ function parseArgs(argv: string[]): RunnerOptions {
47
+ const values = new Map<string, string>();
48
+ for (let index = 0; index < argv.length; index += 2) {
49
+ const key = argv[index];
50
+ const value = argv[index + 1];
51
+ if (!key?.startsWith("--") || value === undefined) throw new Error(`Invalid inspector argument '${key ?? ""}'.`);
52
+ values.set(key, value);
53
+ }
54
+ const asyncDir = values.get("--async-dir");
55
+ const runId = values.get("--run-id");
56
+ if (!asyncDir || !runId) throw new Error("Inspector requires --async-dir and --run-id.");
57
+ const indexRaw = values.get("--index");
58
+ const childIndex = indexRaw === undefined ? undefined : Number(indexRaw);
59
+ if (childIndex !== undefined && (!Number.isInteger(childIndex) || childIndex < 0)) throw new Error("--index must be a non-negative integer.");
60
+ const refreshRaw = values.get("--refresh-ms");
61
+ const refreshMs = refreshRaw === undefined ? 1_500 : Number(refreshRaw);
62
+ if (!Number.isInteger(refreshMs) || refreshMs < 250) throw new Error("--refresh-ms must be an integer >= 250.");
63
+ return {
64
+ asyncDir: path.resolve(asyncDir),
65
+ runId,
66
+ ...(childIndex !== undefined ? { index: childIndex } : {}),
67
+ ...(values.get("--mission-path") ? { missionPath: path.resolve(values.get("--mission-path")!) } : {}),
68
+ refreshMs,
69
+ allowSteer: values.get("--allow-steer") !== "false",
70
+ allowStop: values.get("--allow-stop") !== "false",
71
+ };
72
+ }
73
+
74
+ function isTerminal(status: AsyncStatus): boolean {
75
+ return status.state !== "queued" && status.state !== "running";
76
+ }
77
+
78
+ export function submitInspectorControl(options: RunnerOptions, line: string): string {
79
+ const command = line.trim();
80
+ if (!command || command === "status") return "Status refreshed.";
81
+ const status = readStatus(options.asyncDir);
82
+ if (!status || status.runId !== options.runId) throw new Error(`Lifecycle status for run '${options.runId}' is unavailable.`);
83
+ if (command === "stop") {
84
+ if (options.allowStop === false) throw new Error("Authority policy does not allow stop from this inspector.");
85
+ if (isTerminal(status)) throw new Error(`Run '${options.runId}' is ${status.state} and cannot be stopped.`);
86
+ requestAsyncStop(options.asyncDir, { source: "herdr-inspector" });
87
+ return `Stop requested for run ${options.runId}.`;
88
+ }
89
+ if (command.startsWith("steer ")) {
90
+ if (options.allowSteer === false) throw new Error("Authority policy does not allow steer from this inspector.");
91
+ const message = command.slice("steer ".length).trim();
92
+ if (!message) throw new Error("steer requires a message.");
93
+ if (isTerminal(status)) throw new Error(`Run '${options.runId}' is ${status.state} and cannot be steered.`);
94
+ const runningIndexes = (status.steps ?? []).map((step, index) => step.status === "running" ? index : undefined).filter((index): index is number => index !== undefined);
95
+ const targetIndex = options.index ?? (status.mode === "single" ? 0 : undefined);
96
+ if (targetIndex === undefined && runningIndexes.length === 0) throw new Error("No running child is available to steer. Open a child-specific inspector for a pending child.");
97
+ requestAsyncSteer(options.asyncDir, {
98
+ message,
99
+ ...(targetIndex !== undefined ? { targetIndex } : { targetIndexes: runningIndexes }),
100
+ source: "herdr-inspector",
101
+ });
102
+ return `Steering queued for run ${options.runId}.`;
103
+ }
104
+ if (command.startsWith("reply ")) throw new Error("Supervisor replies are owned by the parent Pi session; use subagent_supervisor/intercom there.");
105
+ throw new Error("Unknown control. Use steer <message>, stop, or status.");
106
+ }
107
+
108
+ export function runInspector(argv = process.argv.slice(2)): void {
109
+ const options = parseArgs(argv);
110
+ let notice = "";
111
+ let timer: ReturnType<typeof setInterval> | undefined;
112
+ const render = () => {
113
+ const status = readStatus(options.asyncDir);
114
+ if (!status || status.runId !== options.runId) {
115
+ process.stdout.write(`\x1b[2J\x1b[Hpi-subagents inspector\n\nLifecycle status for ${options.runId} is unavailable.\n`);
116
+ return;
117
+ }
118
+ process.stdout.write(`\x1b[2J\x1b[H${formatInspectorDashboard({ status, asyncDir: options.asyncDir, index: options.index, mission: readMission(options.missionPath), allowSteer: options.allowSteer, allowStop: options.allowStop })}${notice ? `\n\n${notice}` : ""}\n> `);
119
+ if (isTerminal(status) && timer) {
120
+ clearInterval(timer);
121
+ timer = undefined;
122
+ }
123
+ };
124
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
125
+ rl.on("line", (line) => {
126
+ try { notice = submitInspectorControl(options, line); } catch (cause) { notice = `Control error: ${cause instanceof Error ? cause.message : String(cause)}`; }
127
+ render();
128
+ });
129
+ render();
130
+ if (!isTerminal(readStatus(options.asyncDir) ?? { state: "failed" } as AsyncStatus)) {
131
+ timer = setInterval(render, options.refreshMs);
132
+ timer.unref?.();
133
+ }
134
+ }
135
+
136
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
137
+ try { runInspector(); } catch (cause) {
138
+ process.stderr.write(`Herdr inspector failed: ${cause instanceof Error ? cause.message : String(cause)}\n`);
139
+ process.exitCode = 1;
140
+ }
141
+ }
@@ -0,0 +1,154 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
+ import { getPiSpawnCommand } from "../../runs/shared/pi-spawn.ts";
5
+ import { writeAtomicJson } from "../../shared/atomic-json.ts";
6
+ import type { Details } from "../../shared/types.ts";
7
+ import { createHerdrClient, detectHerdr, type HerdrClient, type HerdrErrorCode, type HerdrResult } from "./client.ts";
8
+
9
+ export const HERDR_PROJECT_PANE_ACTIONS = ["project.open", "project.status", "project.close"] as const;
10
+ export type HerdrProjectPaneAction = typeof HERDR_PROJECT_PANE_ACTIONS[number];
11
+
12
+ export interface HerdrProjectPaneBinding {
13
+ schemaVersion: 1;
14
+ kind: "herdr-project-pane";
15
+ projectRoot: string;
16
+ paneId: string;
17
+ openedAt: string;
18
+ lastFocusedAt?: string;
19
+ herdrVersion?: string;
20
+ command: string;
21
+ startupMessage?: string;
22
+ }
23
+
24
+ interface ProjectPaneParams {
25
+ cwd?: string;
26
+ message?: string;
27
+ focus?: boolean;
28
+ }
29
+
30
+ interface ProjectPaneDeps {
31
+ cwd: string;
32
+ client?: HerdrClient;
33
+ signal?: AbortSignal;
34
+ now?: () => Date;
35
+ }
36
+
37
+ function result(text: string, isError = false): AgentToolResult<Details> {
38
+ return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}), details: { mode: "management", results: [] } };
39
+ }
40
+
41
+ function formatHerdrError(input: { code: HerdrErrorCode; message: string }): string {
42
+ return `Herdr project pane error (${input.code}): ${input.message}`;
43
+ }
44
+
45
+ function projectPaneDir(projectRoot: string): string {
46
+ return path.join(projectRoot, ".pi-subagents", "project-panes");
47
+ }
48
+
49
+ function bindingPath(projectRoot: string): string {
50
+ return path.join(projectPaneDir(projectRoot), "herdr.json");
51
+ }
52
+
53
+ function parseBinding(value: unknown): HerdrProjectPaneBinding | undefined {
54
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
55
+ const input = value as Partial<HerdrProjectPaneBinding>;
56
+ if (input.schemaVersion !== 1 || input.kind !== "herdr-project-pane") return undefined;
57
+ if (typeof input.projectRoot !== "string" || typeof input.paneId !== "string" || typeof input.openedAt !== "string" || typeof input.command !== "string") return undefined;
58
+ return input as HerdrProjectPaneBinding;
59
+ }
60
+
61
+ export function readHerdrProjectPaneBinding(projectRoot: string): HerdrProjectPaneBinding | undefined {
62
+ try { return parseBinding(JSON.parse(fs.readFileSync(bindingPath(projectRoot), "utf-8"))); } catch { return undefined; }
63
+ }
64
+
65
+ function extractPaneId(value: unknown): string | undefined {
66
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
67
+ const record = value as Record<string, unknown>;
68
+ const pane = record.pane && typeof record.pane === "object" && !Array.isArray(record.pane) ? record.pane as Record<string, unknown> : record;
69
+ for (const key of ["pane_id", "paneId", "id"]) if (typeof pane[key] === "string") return pane[key];
70
+ return undefined;
71
+ }
72
+
73
+ function shellQuote(value: string): string {
74
+ if (process.platform === "win32") return `"${value.replaceAll('"', '\\"')}"`;
75
+ return `'${value.replaceAll("'", "'\\''")}'`;
76
+ }
77
+
78
+ function resolveProjectRoot(params: ProjectPaneParams, deps: ProjectPaneDeps): string | { error: string } {
79
+ const requested = params.cwd?.trim() || deps.cwd;
80
+ const resolved = path.resolve(requested);
81
+ try {
82
+ const stat = fs.statSync(resolved);
83
+ if (!stat.isDirectory()) return { error: `Project pane target '${resolved}' is not a directory.` };
84
+ return fs.realpathSync(resolved);
85
+ } catch (cause) {
86
+ return { error: `Project pane target '${resolved}' is unavailable: ${cause instanceof Error ? cause.message : String(cause)}` };
87
+ }
88
+ }
89
+
90
+ async function paneExists(client: HerdrClient, paneId: string, signal?: AbortSignal): Promise<HerdrResult<unknown>> {
91
+ return client.run(["pane", "get", paneId], { timeoutMs: 5_000, signal });
92
+ }
93
+
94
+ function projectPaneCommand(message: string | undefined): string {
95
+ const args = message?.trim() ? [message.trim()] : [];
96
+ const command = getPiSpawnCommand(args);
97
+ return [command.command, ...command.args].map(shellQuote).join(" ");
98
+ }
99
+
100
+ export async function handleHerdrProjectPaneAction(action: HerdrProjectPaneAction, params: ProjectPaneParams, deps: ProjectPaneDeps): Promise<AgentToolResult<Details>> {
101
+ const projectRoot = resolveProjectRoot(params, deps);
102
+ if (typeof projectRoot !== "string") return result(projectRoot.error, true);
103
+ const client = deps.client ?? createHerdrClient();
104
+ const existing = readHerdrProjectPaneBinding(projectRoot);
105
+
106
+ if (action === "project.status") {
107
+ if (!existing) return result(`No Herdr project pane binding exists for ${projectRoot}.`);
108
+ const live = await paneExists(client, existing.paneId, deps.signal);
109
+ if (live.ok === false) return result(`${formatHerdrError(live.error)}\nBinding: ${bindingPath(projectRoot)}`, true);
110
+ return result(`Herdr project pane ${existing.paneId} is open for ${projectRoot}.\nBinding: ${bindingPath(projectRoot)}`);
111
+ }
112
+
113
+ if (action === "project.close") {
114
+ if (!existing) return result(`No Herdr project pane binding exists for ${projectRoot}.`);
115
+ const closed = await client.run(["pane", "close", existing.paneId], { timeoutMs: 10_000, signal: deps.signal });
116
+ if (closed.ok === false && closed.error.code !== "NOT_FOUND" && closed.error.code !== "PANE_GONE") return result(formatHerdrError(closed.error), true);
117
+ fs.rmSync(bindingPath(projectRoot), { force: true });
118
+ return result(`Closed Herdr project pane ${existing.paneId} for ${projectRoot}.`);
119
+ }
120
+
121
+ const detected = await detectHerdr(client, deps.signal);
122
+ if (detected.ok === false) return result(formatHerdrError(detected.error), true);
123
+ if (existing) {
124
+ const live = await paneExists(client, existing.paneId, deps.signal);
125
+ if (live.ok) return result(`Herdr project pane ${existing.paneId} is already open for ${projectRoot}.${params.focus ? " Herdr cannot refocus an arbitrary raw pane id; select it in the Herdr UI." : ""}`);
126
+ }
127
+ const splitArgs = ["pane", "split", "--current", "--direction", "right", "--cwd", projectRoot];
128
+ if (params.focus !== false) splitArgs.push("--focus");
129
+ const split = await client.run(splitArgs, { timeoutMs: 15_000, signal: deps.signal });
130
+ if (split.ok === false) return result(formatHerdrError(split.error), true);
131
+ const paneId = extractPaneId(split.data);
132
+ if (!paneId) return result("Herdr project pane error (PANE_GONE): pane split returned no pane id.", true);
133
+ const startupMessage = params.message?.trim();
134
+ const command = projectPaneCommand(startupMessage);
135
+ const started = await client.run(["pane", "run", paneId, command], { timeoutMs: 15_000, signal: deps.signal });
136
+ if (started.ok === false) {
137
+ await client.run(["pane", "close", paneId], { timeoutMs: 5_000 });
138
+ return result(formatHerdrError(started.error), true);
139
+ }
140
+ const now = (deps.now?.() ?? new Date()).toISOString();
141
+ const binding: HerdrProjectPaneBinding = {
142
+ schemaVersion: 1,
143
+ kind: "herdr-project-pane",
144
+ projectRoot,
145
+ paneId,
146
+ openedAt: now,
147
+ ...(params.focus !== false ? { lastFocusedAt: now } : {}),
148
+ herdrVersion: detected.data.versionText,
149
+ command,
150
+ ...(startupMessage ? { startupMessage } : {}),
151
+ };
152
+ writeAtomicJson(bindingPath(projectRoot), binding);
153
+ return result(`Opened Herdr project pane ${paneId} for ${projectRoot}. The pane runs its own Pi session; subagents launched there belong to that project.`);
154
+ }