pi-subagents 0.45.2 → 0.46.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 (45) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +2 -0
  3. package/docs/agents.md +342 -0
  4. package/docs/configuration.md +320 -0
  5. package/docs/extension-api.md +308 -0
  6. package/docs/missions.md +117 -0
  7. package/docs/models.md +190 -0
  8. package/docs/observability.md +174 -0
  9. package/docs/tool-reference.md +343 -0
  10. package/docs/watchdog.md +176 -0
  11. package/docs/workflows.md +163 -0
  12. package/package.json +4 -2
  13. package/skills/pi-subagents/references/execution-controls.md +2 -2
  14. package/src/agents/agents.ts +17 -8
  15. package/src/agents/frontmatter.ts +7 -3
  16. package/src/agents/skills.ts +2 -9
  17. package/src/api/project-panes.ts +30 -0
  18. package/src/extension/config.ts +15 -1
  19. package/src/extension/index.ts +36 -16
  20. package/src/extension/schemas.ts +3 -2
  21. package/src/extension/subagent-guide.ts +39 -0
  22. package/src/extension/tool-description.ts +4 -4
  23. package/src/inspectors/herdr/project-panes.ts +457 -62
  24. package/src/missions/actions.ts +25 -2
  25. package/src/missions/lifecycle.ts +21 -2
  26. package/src/missions/store.ts +77 -1
  27. package/src/missions/types.ts +33 -0
  28. package/src/runs/background/async-execution.ts +7 -1
  29. package/src/runs/background/completion-replay.ts +267 -0
  30. package/src/runs/background/result-watcher.ts +12 -4
  31. package/src/runs/background/wait-completions.ts +39 -5
  32. package/src/runs/background/wait-subscriptions.ts +18 -3
  33. package/src/runs/foreground/execution.ts +4 -0
  34. package/src/runs/foreground/foreground-history.ts +137 -0
  35. package/src/runs/foreground/subagent-executor.ts +310 -44
  36. package/src/shared/fork-context.ts +13 -0
  37. package/src/shared/prompt-resources.ts +51 -0
  38. package/src/shared/types.ts +30 -1
  39. package/src/shared/utf8.ts +11 -0
  40. package/src/slash/prompt-workflows.ts +2 -15
  41. package/src/slash/slash-commands.ts +19 -1
  42. package/src/tui/fleet-status.ts +8 -2
  43. package/src/tui/fleet.ts +135 -25
  44. package/src/tui/render.ts +120 -7
  45. package/src/workflows/scripted-workflow.ts +167 -10
@@ -4,11 +4,15 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
4
  import { getPiSpawnCommand } from "../../runs/shared/pi-spawn.ts";
5
5
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
6
6
  import type { Details } from "../../shared/types.ts";
7
- import { createHerdrClient, detectHerdr, type HerdrClient, type HerdrErrorCode, type HerdrResult } from "./client.ts";
7
+ import { createHerdrClient, detectHerdr, type HerdrClient, type HerdrErrorCode } from "./client.ts";
8
8
 
9
9
  export const HERDR_PROJECT_PANE_ACTIONS = ["project.open", "project.status", "project.close"] as const;
10
10
  export type HerdrProjectPaneAction = typeof HERDR_PROJECT_PANE_ACTIONS[number];
11
11
 
12
+ /** Versioned public contract exported through `pi-subagents/project-panes`. */
13
+ export const PROJECT_PANES_API_VERSION = 1 as const;
14
+ export const PROJECT_PANE_TRUST_STATUS = "human-verification-required" as const;
15
+
12
16
  export interface HerdrProjectPaneBinding {
13
17
  schemaVersion: 1;
14
18
  kind: "herdr-project-pane";
@@ -21,6 +25,108 @@ export interface HerdrProjectPaneBinding {
21
25
  startupMessage?: string;
22
26
  }
23
27
 
28
+ export interface ProjectPaneRuntime {
29
+ paneId: string;
30
+ agent?: string;
31
+ agentStatus: string;
32
+ cwd?: string;
33
+ foregroundCwd?: string;
34
+ focused?: boolean;
35
+ terminalTitle?: string;
36
+ }
37
+
38
+ export type ProjectPaneErrorCode = HerdrErrorCode
39
+ | "INVALID_PROJECT_ROOT"
40
+ | "INVALID_PANE_RESPONSE"
41
+ | "INVALID_BINDING"
42
+ | "BINDING_READ_FAILED"
43
+ | "BINDING_WRITE_FAILED"
44
+ | "BINDING_REMOVE_FAILED"
45
+ | "PANE_NOT_IDLE"
46
+ | "PANE_OWNERSHIP_UNVERIFIED";
47
+
48
+ export interface ProjectPaneError {
49
+ code: ProjectPaneErrorCode;
50
+ message: string;
51
+ projectRoot?: string;
52
+ bindingPath?: string;
53
+ details?: unknown;
54
+ }
55
+
56
+ export type ProjectPaneResult<T> =
57
+ | { ok: true; data: T }
58
+ | { ok: false; error: ProjectPaneError };
59
+
60
+ interface ProjectPaneCommonData {
61
+ apiVersion: typeof PROJECT_PANES_API_VERSION;
62
+ projectRoot: string;
63
+ bindingPath: string;
64
+ trust: typeof PROJECT_PANE_TRUST_STATUS;
65
+ }
66
+
67
+ export interface OpenProjectPaneData extends ProjectPaneCommonData {
68
+ disposition: "opened" | "already-open";
69
+ binding: HerdrProjectPaneBinding;
70
+ runtime?: ProjectPaneRuntime;
71
+ }
72
+
73
+ export interface ProjectPaneStatusData extends ProjectPaneCommonData {
74
+ state: "absent" | "open" | "stale";
75
+ binding?: HerdrProjectPaneBinding;
76
+ runtime?: ProjectPaneRuntime;
77
+ ownership: "verified" | "unknown" | "mismatch";
78
+ safeToClose: boolean;
79
+ staleReason?: { code: HerdrErrorCode; message: string };
80
+ }
81
+
82
+ export interface CloseProjectPaneData extends ProjectPaneCommonData {
83
+ disposition: "closed" | "absent" | "stale-binding-removed";
84
+ binding?: HerdrProjectPaneBinding;
85
+ runtime?: ProjectPaneRuntime;
86
+ }
87
+
88
+ export interface OpenProjectPaneOptions {
89
+ cwd: string;
90
+ message?: string;
91
+ focus?: boolean;
92
+ signal?: AbortSignal;
93
+ }
94
+
95
+ export interface GetProjectPaneStatusOptions {
96
+ cwd: string;
97
+ signal?: AbortSignal;
98
+ }
99
+
100
+ export interface CloseProjectPaneOptions {
101
+ cwd: string;
102
+ /** Fail closed unless Herdr explicitly reports the owning Pi pane as idle. */
103
+ requireIdle?: boolean;
104
+ signal?: AbortSignal;
105
+ }
106
+
107
+ export interface ProjectPaneCommandClient {
108
+ run<T = unknown>(
109
+ args: string[],
110
+ options?: { timeoutMs?: number; signal?: AbortSignal; textOk?: boolean },
111
+ ): Promise<{ ok: true; data: T } | { ok: false; error: { code: HerdrErrorCode; message: string; details?: unknown } }>;
112
+ }
113
+
114
+ export interface ProjectPaneManagerOptions {
115
+ client?: ProjectPaneCommandClient;
116
+ now?: () => Date;
117
+ }
118
+
119
+ export interface ProjectPaneManager {
120
+ open(options: OpenProjectPaneOptions): Promise<ProjectPaneResult<OpenProjectPaneData>>;
121
+ status(options: GetProjectPaneStatusOptions): Promise<ProjectPaneResult<ProjectPaneStatusData>>;
122
+ close(options: CloseProjectPaneOptions): Promise<ProjectPaneResult<CloseProjectPaneData>>;
123
+ }
124
+
125
+ interface InternalProjectPaneManagerOptions extends ProjectPaneManagerOptions {
126
+ /** Preserve the historical model-facing action behavior without weakening the public API defaults. */
127
+ legacyToolCompatibility?: boolean;
128
+ }
129
+
24
130
  interface ProjectPaneParams {
25
131
  cwd?: string;
26
132
  message?: string;
@@ -34,11 +140,15 @@ interface ProjectPaneDeps {
34
140
  now?: () => Date;
35
141
  }
36
142
 
37
- function result(text: string, isError = false): AgentToolResult<Details> {
143
+ function toolResult(text: string, isError = false): AgentToolResult<Details> {
38
144
  return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}), details: { mode: "management", results: [] } };
39
145
  }
40
146
 
41
- function formatHerdrError(input: { code: HerdrErrorCode; message: string }): string {
147
+ function projectPaneError<T>(code: ProjectPaneErrorCode, message: string, fields: Omit<ProjectPaneError, "code" | "message"> = {}): ProjectPaneResult<T> {
148
+ return { ok: false, error: { code, message, ...fields } };
149
+ }
150
+
151
+ function formatProjectPaneError(input: { code: ProjectPaneErrorCode; message: string }): string {
42
152
  return `Herdr project pane error (${input.code}): ${input.message}`;
43
153
  }
44
154
 
@@ -46,49 +156,128 @@ function projectPaneDir(projectRoot: string): string {
46
156
  return path.join(projectRoot, ".pi-subagents", "project-panes");
47
157
  }
48
158
 
49
- function bindingPath(projectRoot: string): string {
159
+ export function projectPaneBindingPath(projectRoot: string): string {
50
160
  return path.join(projectPaneDir(projectRoot), "herdr.json");
51
161
  }
52
162
 
53
- function parseBinding(value: unknown): HerdrProjectPaneBinding | undefined {
163
+ function parseBinding(value: unknown, strict = false): HerdrProjectPaneBinding | undefined {
54
164
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
55
165
  const input = value as Partial<HerdrProjectPaneBinding>;
56
166
  if (input.schemaVersion !== 1 || input.kind !== "herdr-project-pane") return undefined;
57
167
  if (typeof input.projectRoot !== "string" || typeof input.paneId !== "string" || typeof input.openedAt !== "string" || typeof input.command !== "string") return undefined;
168
+ if (strict) {
169
+ if (![input.projectRoot, input.paneId, input.openedAt, input.command].every((field) => field.trim().length > 0)) return undefined;
170
+ for (const field of ["lastFocusedAt", "herdrVersion", "startupMessage"] as const) {
171
+ if (input[field] !== undefined && typeof input[field] !== "string") return undefined;
172
+ }
173
+ }
58
174
  return input as HerdrProjectPaneBinding;
59
175
  }
60
176
 
177
+ type BindingReadResult =
178
+ | { state: "absent" }
179
+ | { state: "invalid" }
180
+ | { state: "read-error"; cause: unknown }
181
+ | { state: "valid"; binding: HerdrProjectPaneBinding };
182
+
183
+ function readBinding(projectRoot: string, strict: boolean): BindingReadResult {
184
+ const file = projectPaneBindingPath(projectRoot);
185
+ let raw: string;
186
+ try {
187
+ raw = fs.readFileSync(file, "utf-8");
188
+ } catch (cause) {
189
+ const code = (cause as NodeJS.ErrnoException).code;
190
+ if (code === "ENOENT" || code === "ENOTDIR") return { state: "absent" };
191
+ return { state: "read-error", cause };
192
+ }
193
+ try {
194
+ const binding = parseBinding(JSON.parse(raw), strict);
195
+ return binding ? { state: "valid", binding } : { state: "invalid" };
196
+ } catch {
197
+ return { state: "invalid" };
198
+ }
199
+ }
200
+
201
+ /** Legacy model-facing reader; preserves the original required-field-only parsing contract. */
61
202
  export function readHerdrProjectPaneBinding(projectRoot: string): HerdrProjectPaneBinding | undefined {
62
- try { return parseBinding(JSON.parse(fs.readFileSync(bindingPath(projectRoot), "utf-8"))); } catch { return undefined; }
203
+ const read = readBinding(projectRoot, false);
204
+ return read.state === "valid" ? read.binding : undefined;
63
205
  }
64
206
 
65
- function extractPaneId(value: unknown): string | undefined {
207
+ /** Strict public reader for extension integrations. */
208
+ export function readProjectPaneBinding(projectRoot: string): ProjectPaneResult<HerdrProjectPaneBinding | undefined> {
209
+ const read = readBinding(projectRoot, true);
210
+ if (read.state === "absent") return { ok: true, data: undefined };
211
+ if (read.state === "read-error") {
212
+ const bindingPath = projectPaneBindingPath(projectRoot);
213
+ return projectPaneError("BINDING_READ_FAILED", `Failed to read project pane binding '${bindingPath}': ${read.cause instanceof Error ? read.cause.message : String(read.cause)}`, {
214
+ projectRoot, bindingPath, details: fileSystemErrorDetails(read.cause),
215
+ });
216
+ }
217
+ if (read.state === "invalid") {
218
+ return projectPaneError("INVALID_BINDING", `Project pane binding '${projectPaneBindingPath(projectRoot)}' is malformed.`, {
219
+ projectRoot, bindingPath: projectPaneBindingPath(projectRoot),
220
+ });
221
+ }
222
+ return { ok: true, data: read.binding };
223
+ }
224
+
225
+ function paneRecord(value: unknown): Record<string, unknown> | undefined {
66
226
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
67
227
  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;
228
+ return record.pane && typeof record.pane === "object" && !Array.isArray(record.pane)
229
+ ? record.pane as Record<string, unknown>
230
+ : record;
231
+ }
232
+
233
+ function extractPaneId(value: unknown): string | undefined {
234
+ const pane = paneRecord(value);
235
+ if (!pane) return undefined;
69
236
  for (const key of ["pane_id", "paneId", "id"]) if (typeof pane[key] === "string") return pane[key];
70
237
  return undefined;
71
238
  }
72
239
 
240
+ function projectPaneRuntime(value: unknown): ProjectPaneRuntime | undefined {
241
+ const pane = paneRecord(value);
242
+ const paneId = extractPaneId(value);
243
+ if (!pane || !paneId) return undefined;
244
+ const text = (key: string): string | undefined => typeof pane[key] === "string" ? pane[key] as string : undefined;
245
+ const agentStatus = text("agent_status") ?? text("agentStatus") ?? "unknown";
246
+ return {
247
+ paneId,
248
+ ...(text("agent") ? { agent: text("agent") } : {}),
249
+ agentStatus: agentStatus.toLowerCase(),
250
+ ...(text("cwd") ? { cwd: text("cwd") } : {}),
251
+ ...(text("foreground_cwd") || text("foregroundCwd") ? { foregroundCwd: text("foreground_cwd") ?? text("foregroundCwd") } : {}),
252
+ ...(typeof pane.focused === "boolean" ? { focused: pane.focused } : {}),
253
+ ...(text("terminal_title_stripped") || text("terminal_title") || text("terminalTitle")
254
+ ? { terminalTitle: text("terminal_title_stripped") ?? text("terminal_title") ?? text("terminalTitle") }
255
+ : {}),
256
+ };
257
+ }
258
+
73
259
  function shellQuote(value: string): string {
74
260
  if (process.platform === "win32") return `"${value.replaceAll('"', '\\"')}"`;
75
261
  return `'${value.replaceAll("'", "'\\''")}'`;
76
262
  }
77
263
 
78
- function resolveProjectRoot(params: ProjectPaneParams, deps: ProjectPaneDeps): string | { error: string } {
79
- const requested = params.cwd?.trim() || deps.cwd;
264
+ function resolveProjectRoot(requested: string): ProjectPaneResult<string> {
80
265
  const resolved = path.resolve(requested);
81
266
  try {
82
267
  const stat = fs.statSync(resolved);
83
- if (!stat.isDirectory()) return { error: `Project pane target '${resolved}' is not a directory.` };
84
- return fs.realpathSync(resolved);
268
+ if (!stat.isDirectory()) return projectPaneError("INVALID_PROJECT_ROOT", `Project pane target '${resolved}' is not a directory.`, { projectRoot: resolved });
269
+ return { ok: true, data: fs.realpathSync(resolved) };
85
270
  } catch (cause) {
86
- return { error: `Project pane target '${resolved}' is unavailable: ${cause instanceof Error ? cause.message : String(cause)}` };
271
+ return projectPaneError("INVALID_PROJECT_ROOT", `Project pane target '${resolved}' is unavailable: ${cause instanceof Error ? cause.message : String(cause)}`, { projectRoot: resolved });
87
272
  }
88
273
  }
89
274
 
90
- async function paneExists(client: HerdrClient, paneId: string, signal?: AbortSignal): Promise<HerdrResult<unknown>> {
91
- return client.run(["pane", "get", paneId], { timeoutMs: 5_000, signal });
275
+ async function inspectPane(client: HerdrClient, paneId: string, signal?: AbortSignal): Promise<ProjectPaneResult<ProjectPaneRuntime>> {
276
+ const live = await client.run(["pane", "get", paneId], { timeoutMs: 5_000, signal });
277
+ if (live.ok === false) return projectPaneError(live.error.code, live.error.message, { details: live.error.details });
278
+ const runtime = projectPaneRuntime(live.data);
279
+ if (!runtime) return projectPaneError("INVALID_PANE_RESPONSE", `Herdr pane get returned no pane runtime for '${paneId}'.`, { details: live.data });
280
+ return { ok: true, data: runtime };
92
281
  }
93
282
 
94
283
  function projectPaneCommand(message: string | undefined): string {
@@ -97,58 +286,264 @@ function projectPaneCommand(message: string | undefined): string {
97
286
  return `${process.platform === "win32" ? "& " : ""}${[command.command, ...command.args].map(shellQuote).join(" ")}`;
98
287
  }
99
288
 
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);
289
+ function canonicalRuntimePath(value: string | undefined): string | undefined {
290
+ if (!value) return undefined;
291
+ try { return fs.realpathSync(path.resolve(value)); } catch { return path.resolve(value); }
292
+ }
105
293
 
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
- }
294
+ function projectPaneOwnership(runtime: ProjectPaneRuntime, binding: HerdrProjectPaneBinding, projectRoot: string): "verified" | "unknown" | "mismatch" {
295
+ if (runtime.paneId !== binding.paneId) return "mismatch";
296
+ const runtimeRoot = canonicalRuntimePath(runtime.cwd);
297
+ if (!runtimeRoot) return "unknown";
298
+ return runtimeRoot === projectRoot ? "verified" : "mismatch";
299
+ }
112
300
 
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}.`);
301
+ function bindingForManager(projectRoot: string, legacyToolCompatibility: boolean | undefined): ProjectPaneResult<HerdrProjectPaneBinding | undefined> {
302
+ const read = readBinding(projectRoot, !legacyToolCompatibility);
303
+ if (read.state === "absent") return { ok: true, data: undefined };
304
+ if (read.state === "read-error") {
305
+ if (legacyToolCompatibility) return { ok: true, data: undefined };
306
+ const bindingPath = projectPaneBindingPath(projectRoot);
307
+ return projectPaneError("BINDING_READ_FAILED", `Failed to read project pane binding '${bindingPath}': ${read.cause instanceof Error ? read.cause.message : String(read.cause)}`, {
308
+ projectRoot, bindingPath, details: fileSystemErrorDetails(read.cause),
309
+ });
119
310
  }
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." : ""}`);
311
+ if (read.state === "invalid") {
312
+ if (legacyToolCompatibility) return { ok: true, data: undefined };
313
+ return projectPaneError("INVALID_BINDING", `Project pane binding '${projectPaneBindingPath(projectRoot)}' is malformed.`, {
314
+ projectRoot, bindingPath: projectPaneBindingPath(projectRoot),
315
+ });
126
316
  }
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);
317
+ const binding = read.binding;
318
+ if (!legacyToolCompatibility && canonicalRuntimePath(binding.projectRoot) !== projectRoot) {
319
+ return projectPaneError("INVALID_BINDING", `Project pane binding root '${binding.projectRoot}' does not match '${projectRoot}'.`, {
320
+ projectRoot, bindingPath: projectPaneBindingPath(projectRoot), details: binding,
321
+ });
139
322
  }
140
- const now = (deps.now?.() ?? new Date()).toISOString();
141
- const binding: HerdrProjectPaneBinding = {
142
- schemaVersion: 1,
143
- kind: "herdr-project-pane",
323
+ return { ok: true, data: binding };
324
+ }
325
+
326
+ function common(projectRoot: string): ProjectPaneCommonData {
327
+ return {
328
+ apiVersion: PROJECT_PANES_API_VERSION,
144
329
  projectRoot,
145
- paneId,
146
- openedAt: now,
147
- ...(params.focus !== false ? { lastFocusedAt: now } : {}),
148
- herdrVersion: detected.data.versionText,
149
- command,
150
- ...(startupMessage ? { startupMessage } : {}),
330
+ bindingPath: projectPaneBindingPath(projectRoot),
331
+ trust: PROJECT_PANE_TRUST_STATUS,
332
+ };
333
+ }
334
+
335
+ function fileSystemErrorDetails(cause: unknown): unknown {
336
+ if (!(cause instanceof Error)) return cause;
337
+ const code = (cause as NodeJS.ErrnoException).code;
338
+ return { name: cause.name, message: cause.message, ...(code ? { code } : {}) };
339
+ }
340
+
341
+ function removeProjectPaneBinding(projectRoot: string): ProjectPaneResult<void> {
342
+ const bindingPath = projectPaneBindingPath(projectRoot);
343
+ try {
344
+ fs.rmSync(bindingPath, { force: true });
345
+ return { ok: true, data: undefined };
346
+ } catch (cause) {
347
+ return projectPaneError("BINDING_REMOVE_FAILED", `Failed to remove project pane binding '${bindingPath}': ${cause instanceof Error ? cause.message : String(cause)}`, {
348
+ projectRoot, bindingPath, details: fileSystemErrorDetails(cause),
349
+ });
350
+ }
351
+ }
352
+
353
+ function createProjectPaneManagerInternal(options: InternalProjectPaneManagerOptions = {}): ProjectPaneManager {
354
+ const client = options.client ?? createHerdrClient();
355
+ return {
356
+ async status(input) {
357
+ const root = resolveProjectRoot(input.cwd);
358
+ if (!root.ok) return root;
359
+ const projectRoot = root.data;
360
+ const bindingResult = bindingForManager(projectRoot, options.legacyToolCompatibility);
361
+ if (!bindingResult.ok) return bindingResult;
362
+ const existing = bindingResult.data;
363
+ if (!existing) return { ok: true, data: { ...common(projectRoot), state: "absent", ownership: "unknown", safeToClose: true } };
364
+ const live = await inspectPane(client, existing.paneId, input.signal);
365
+ if (!live.ok) {
366
+ if (live.error.code === "INVALID_PANE_RESPONSE" && options.legacyToolCompatibility) {
367
+ const runtime: ProjectPaneRuntime = { paneId: existing.paneId, agentStatus: "unknown" };
368
+ return { ok: true, data: { ...common(projectRoot), state: "open", binding: existing, runtime, ownership: "unknown", safeToClose: false } };
369
+ }
370
+ if (live.error.code === "NOT_FOUND" || live.error.code === "PANE_GONE") {
371
+ return { ok: true, data: {
372
+ ...common(projectRoot), state: "stale", binding: existing, ownership: "unknown", safeToClose: false,
373
+ staleReason: { code: live.error.code, message: live.error.message },
374
+ } };
375
+ }
376
+ return { ok: false, error: { ...live.error, projectRoot, bindingPath: projectPaneBindingPath(projectRoot) } };
377
+ }
378
+ const ownership = projectPaneOwnership(live.data, existing, projectRoot);
379
+ return { ok: true, data: {
380
+ ...common(projectRoot), state: "open", binding: existing, runtime: live.data, ownership,
381
+ safeToClose: live.data.agentStatus === "idle" && ownership === "verified",
382
+ } };
383
+ },
384
+
385
+ async open(input) {
386
+ const root = resolveProjectRoot(input.cwd);
387
+ if (!root.ok) return root;
388
+ const projectRoot = root.data;
389
+ const detected = await detectHerdr(client, input.signal);
390
+ if (!detected.ok) return projectPaneError(detected.error.code, detected.error.message, { projectRoot, details: detected.error.details });
391
+ const bindingResult = bindingForManager(projectRoot, options.legacyToolCompatibility);
392
+ if (!bindingResult.ok) return bindingResult;
393
+ const existing = bindingResult.data;
394
+ if (existing) {
395
+ const live = await inspectPane(client, existing.paneId, input.signal);
396
+ if (live.ok) {
397
+ const ownership = projectPaneOwnership(live.data, existing, projectRoot);
398
+ if (!options.legacyToolCompatibility && ownership !== "verified") {
399
+ return projectPaneError("PANE_OWNERSHIP_UNVERIFIED", `Project pane '${existing.paneId}' ownership is '${ownership}' for '${projectRoot}'.`, {
400
+ projectRoot, bindingPath: projectPaneBindingPath(projectRoot), details: live.data,
401
+ });
402
+ }
403
+ return { ok: true, data: { ...common(projectRoot), disposition: "already-open", binding: existing, runtime: live.data } };
404
+ }
405
+ if (live.error.code === "INVALID_PANE_RESPONSE" && options.legacyToolCompatibility) {
406
+ return { ok: true, data: {
407
+ ...common(projectRoot), disposition: "already-open", binding: existing,
408
+ runtime: { paneId: existing.paneId, agentStatus: "unknown" },
409
+ } };
410
+ }
411
+ const stale = live.error.code === "NOT_FOUND" || live.error.code === "PANE_GONE";
412
+ if (!stale && !options.legacyToolCompatibility) {
413
+ return { ok: false, error: { ...live.error, projectRoot, bindingPath: projectPaneBindingPath(projectRoot) } };
414
+ }
415
+ }
416
+ const splitArgs = ["pane", "split", "--current", "--direction", "right", "--cwd", projectRoot];
417
+ if (input.focus !== false) splitArgs.push("--focus");
418
+ const split = await client.run(splitArgs, { timeoutMs: 15_000, signal: input.signal });
419
+ if (!split.ok) return projectPaneError(split.error.code, split.error.message, { projectRoot, details: split.error.details });
420
+ const paneId = extractPaneId(split.data);
421
+ if (!paneId) return projectPaneError(options.legacyToolCompatibility ? "PANE_GONE" : "INVALID_PANE_RESPONSE", "Herdr pane split returned no pane id.", { projectRoot, details: split.data });
422
+ const startupMessage = input.message?.trim();
423
+ const command = projectPaneCommand(startupMessage);
424
+ const started = await client.run(["pane", "run", paneId, command], { timeoutMs: 15_000, signal: input.signal });
425
+ if (!started.ok) {
426
+ await client.run(["pane", "close", paneId], { timeoutMs: 5_000 });
427
+ return projectPaneError(started.error.code, started.error.message, { projectRoot, details: started.error.details });
428
+ }
429
+ const now = (options.now?.() ?? new Date()).toISOString();
430
+ const binding: HerdrProjectPaneBinding = {
431
+ schemaVersion: 1,
432
+ kind: "herdr-project-pane",
433
+ projectRoot,
434
+ paneId,
435
+ openedAt: now,
436
+ ...(input.focus !== false ? { lastFocusedAt: now } : {}),
437
+ herdrVersion: detected.data.versionText,
438
+ command,
439
+ ...(startupMessage ? { startupMessage } : {}),
440
+ };
441
+ const bindingPath = projectPaneBindingPath(projectRoot);
442
+ try {
443
+ writeAtomicJson(bindingPath, binding);
444
+ } catch (cause) {
445
+ let cleanup: { paneClosed: true } | { paneClosed: false; error: unknown };
446
+ try {
447
+ const closed = await client.run(["pane", "close", paneId], { timeoutMs: 5_000 });
448
+ cleanup = closed.ok
449
+ ? { paneClosed: true }
450
+ : { paneClosed: false, error: closed.error };
451
+ } catch (cleanupCause) {
452
+ cleanup = { paneClosed: false, error: fileSystemErrorDetails(cleanupCause) };
453
+ }
454
+ const cleanupMessage = cleanup.paneClosed
455
+ ? ` The newly opened pane '${paneId}' was closed.`
456
+ : ` Cleanup could not close the newly opened pane '${paneId}'.`;
457
+ return projectPaneError("BINDING_WRITE_FAILED", `Failed to persist project pane binding '${bindingPath}': ${cause instanceof Error ? cause.message : String(cause)}.${cleanupMessage}`, {
458
+ projectRoot, bindingPath, details: { cause: fileSystemErrorDetails(cause), cleanup },
459
+ });
460
+ }
461
+ return { ok: true, data: { ...common(projectRoot), disposition: "opened", binding } };
462
+ },
463
+
464
+ async close(input) {
465
+ const root = resolveProjectRoot(input.cwd);
466
+ if (!root.ok) return root;
467
+ const projectRoot = root.data;
468
+ const bindingResult = bindingForManager(projectRoot, options.legacyToolCompatibility);
469
+ if (!bindingResult.ok) return bindingResult;
470
+ const existing = bindingResult.data;
471
+ if (!existing) return { ok: true, data: { ...common(projectRoot), disposition: "absent" } };
472
+ let runtime: ProjectPaneRuntime | undefined;
473
+ if (input.requireIdle) {
474
+ const live = await inspectPane(client, existing.paneId, input.signal);
475
+ if (!live.ok) {
476
+ if (live.error.code === "NOT_FOUND" || live.error.code === "PANE_GONE") {
477
+ const removed = removeProjectPaneBinding(projectRoot);
478
+ if (!removed.ok) return removed;
479
+ return { ok: true, data: { ...common(projectRoot), disposition: "stale-binding-removed", binding: existing } };
480
+ }
481
+ return { ok: false, error: { ...live.error, projectRoot, bindingPath: projectPaneBindingPath(projectRoot) } };
482
+ }
483
+ runtime = live.data;
484
+ const ownership = projectPaneOwnership(runtime, existing, projectRoot);
485
+ if (ownership !== "verified") {
486
+ return projectPaneError("PANE_OWNERSHIP_UNVERIFIED", `Project pane '${existing.paneId}' ownership is '${ownership}' for '${projectRoot}'.`, {
487
+ projectRoot, bindingPath: projectPaneBindingPath(projectRoot), details: runtime,
488
+ });
489
+ }
490
+ if (runtime.agentStatus !== "idle") {
491
+ return projectPaneError("PANE_NOT_IDLE", `Project pane '${existing.paneId}' is '${runtime.agentStatus}', not explicitly idle.`, {
492
+ projectRoot, bindingPath: projectPaneBindingPath(projectRoot), details: runtime,
493
+ });
494
+ }
495
+ }
496
+ const closed = await client.run(["pane", "close", existing.paneId], { timeoutMs: 10_000, signal: input.signal });
497
+ if (!closed.ok && closed.error.code !== "NOT_FOUND" && closed.error.code !== "PANE_GONE") {
498
+ return projectPaneError(closed.error.code, closed.error.message, { projectRoot, bindingPath: projectPaneBindingPath(projectRoot), details: closed.error.details });
499
+ }
500
+ const disposition: CloseProjectPaneData["disposition"] = closed.ok ? "closed" : "stale-binding-removed";
501
+ const removed = removeProjectPaneBinding(projectRoot);
502
+ if (!removed.ok) return removed;
503
+ return { ok: true, data: { ...common(projectRoot), disposition, binding: existing, ...(runtime ? { runtime } : {}) } };
504
+ },
151
505
  };
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.`);
506
+ }
507
+
508
+ export function createProjectPaneManager(options: ProjectPaneManagerOptions = {}): ProjectPaneManager {
509
+ return createProjectPaneManagerInternal(options);
510
+ }
511
+
512
+ export async function openProjectPane(options: OpenProjectPaneOptions): Promise<ProjectPaneResult<OpenProjectPaneData>> {
513
+ return createProjectPaneManager().open(options);
514
+ }
515
+
516
+ export async function getProjectPaneStatus(options: GetProjectPaneStatusOptions): Promise<ProjectPaneResult<ProjectPaneStatusData>> {
517
+ return createProjectPaneManager().status(options);
518
+ }
519
+
520
+ export async function closeProjectPane(options: CloseProjectPaneOptions): Promise<ProjectPaneResult<CloseProjectPaneData>> {
521
+ return createProjectPaneManager().close(options);
522
+ }
523
+
524
+ export async function handleHerdrProjectPaneAction(action: HerdrProjectPaneAction, params: ProjectPaneParams, deps: ProjectPaneDeps): Promise<AgentToolResult<Details>> {
525
+ const requested = params.cwd?.trim() || deps.cwd;
526
+ const manager = createProjectPaneManagerInternal({ client: deps.client, now: deps.now, legacyToolCompatibility: true });
527
+ if (action === "project.status") {
528
+ const status = await manager.status({ cwd: requested, signal: deps.signal });
529
+ if (!status.ok) return toolResult(formatProjectPaneError(status.error), true);
530
+ if (status.data.state === "absent") return toolResult(`No Herdr project pane binding exists for ${status.data.projectRoot}.`);
531
+ if (status.data.state === "stale") {
532
+ const reason = status.data.staleReason!;
533
+ return toolResult(`${formatProjectPaneError(reason)}\nBinding: ${status.data.bindingPath}`, true);
534
+ }
535
+ return toolResult(`Herdr project pane ${status.data.binding!.paneId} is open for ${status.data.projectRoot}.\nBinding: ${status.data.bindingPath}`);
536
+ }
537
+ if (action === "project.close") {
538
+ const closed = await manager.close({ cwd: requested, signal: deps.signal });
539
+ if (!closed.ok) return toolResult(formatProjectPaneError(closed.error), true);
540
+ if (closed.data.disposition === "absent") return toolResult(`No Herdr project pane binding exists for ${closed.data.projectRoot}.`);
541
+ return toolResult(`Closed Herdr project pane ${closed.data.binding!.paneId} for ${closed.data.projectRoot}.`);
542
+ }
543
+ const opened = await manager.open({ cwd: requested, message: params.message, focus: params.focus, signal: deps.signal });
544
+ if (!opened.ok) return toolResult(formatProjectPaneError(opened.error), true);
545
+ if (opened.data.disposition === "already-open") {
546
+ return toolResult(`Herdr project pane ${opened.data.binding.paneId} is already open for ${opened.data.projectRoot}.${params.focus ? " Herdr cannot refocus an arbitrary raw pane id; select it in the Herdr UI." : ""}`);
547
+ }
548
+ return toolResult(`Opened Herdr project pane ${opened.data.binding.paneId} for ${opened.data.projectRoot}. The pane runs its own Pi session; subagents launched there belong to that project.`);
154
549
  }
@@ -34,6 +34,7 @@ export const MISSION_ACTIONS = [
34
34
  "mission.list",
35
35
  "mission.show",
36
36
  "mission.update",
37
+ "mission.resolve-decision",
37
38
  "mission.attach-run",
38
39
  "mission.close",
39
40
  ] as const;
@@ -298,9 +299,20 @@ function formatMission(record: MissionRecord): string {
298
299
  lines.push("Runs:");
299
300
  for (const run of record.runs) lines.push(` ${run.runId} (${run.mode}${run.status ? `, ${run.status}` : ""})${run.asyncDir ? ` — ${run.asyncDir}` : ""}`);
300
301
  }
302
+ if (record.workflowChildren.length) {
303
+ lines.push("Workflow children:");
304
+ for (const child of record.workflowChildren) {
305
+ const identity = child.runId ? `${child.key} (${child.runId})` : child.key;
306
+ const heartbeat = child.heartbeat ? `; heartbeat ${child.heartbeat.status ?? child.status}${child.heartbeat.phase ? `/${child.heartbeat.phase}` : ""} at ${child.heartbeat.updatedAt}` : "";
307
+ const recovery = child.sessionPath ? `; session ${child.sessionPath}` : child.artifactPaths.length ? `; artifacts ${child.artifactPaths.length}` : "";
308
+ lines.push(` ${identity}: ${child.status}${child.agent ? ` — ${child.agent}` : ""}${child.phase ? ` [${child.phase}]` : ""}; updated ${child.updatedAt}${heartbeat}${recovery}`);
309
+ }
310
+ }
301
311
  if (record.decisions.length) {
302
312
  lines.push("Decisions:");
303
- for (const decision of record.decisions) lines.push(` ${decision.id}: ${decision.status} — ${decision.title}`);
313
+ for (const decision of record.decisions) {
314
+ lines.push(` ${decision.id}: ${decision.status} — ${decision.title}${decision.resolution ? `; resolution: ${decision.resolution}` : ""}`);
315
+ }
304
316
  }
305
317
  if (record.artifacts.length) {
306
318
  lines.push("Artifacts:");
@@ -351,7 +363,11 @@ export function handleMissionAction(
351
363
  const listed = listMissions(location);
352
364
  const lines = listed.records.length === 0
353
365
  ? ["No project missions."]
354
- : listed.records.map((record) => `${record.id} ${record.status} ${record.title} ${record.updatedAt}`);
366
+ : listed.records.map((record) => {
367
+ const open = record.decisions.filter((decision) => decision.status === "open").length;
368
+ const resolved = record.decisions.length - open;
369
+ return `${record.id} ${record.status} ${record.title} ${record.updatedAt}${record.decisions.length ? ` decisions: ${open} open, ${resolved} resolved` : ""}`;
370
+ });
355
371
  if (listed.warnings.length) lines.push("", ...listed.warnings.map((warning) => `Warning: ${warning}`));
356
372
  return textResult(lines.join("\n"), { mode: "management", results: [], missions: { records: listed.records, warnings: listed.warnings } });
357
373
  }
@@ -372,6 +388,13 @@ export function handleMissionAction(
372
388
  const record = updateMission(location, requireMissionId(params), validateMissionUpdate(params.missionUpdate));
373
389
  return textResult(`Updated mission ${record.id}.\n\n${formatMission(record)}`, { mode: "management", results: [], missionId: record.id, missionPath: pathFor(record.id), mission: record });
374
390
  }
391
+ if (action === "mission.resolve-decision") {
392
+ const missionId = requireMissionId(params);
393
+ const decisionId = validateMissionId(params.id, "id");
394
+ if (typeof params.summary !== "string" || !params.summary.trim()) throw new Error("mission.resolve-decision requires a non-empty summary");
395
+ const record = updateMission(location, missionId, { resolveDecision: { id: decisionId, resolution: params.summary.trim() } });
396
+ return textResult(`Resolved decision ${decisionId} for mission ${record.id}.\n\n${formatMission(record)}`, { mode: "management", results: [], missionId: record.id, missionPath: pathFor(record.id), mission: record });
397
+ }
375
398
  if (action === "mission.attach-run") {
376
399
  const missionId = requireMissionId(params);
377
400
  const runId = params.runId ?? params.id;