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.
- package/CHANGELOG.md +24 -0
- package/README.md +2 -0
- package/docs/agents.md +342 -0
- package/docs/configuration.md +320 -0
- package/docs/extension-api.md +308 -0
- package/docs/missions.md +117 -0
- package/docs/models.md +190 -0
- package/docs/observability.md +174 -0
- package/docs/tool-reference.md +343 -0
- package/docs/watchdog.md +176 -0
- package/docs/workflows.md +163 -0
- package/package.json +4 -2
- package/skills/pi-subagents/references/execution-controls.md +2 -2
- package/src/agents/agents.ts +17 -8
- package/src/agents/frontmatter.ts +7 -3
- package/src/agents/skills.ts +2 -9
- package/src/api/project-panes.ts +30 -0
- package/src/extension/config.ts +15 -1
- package/src/extension/index.ts +36 -16
- package/src/extension/schemas.ts +3 -2
- package/src/extension/subagent-guide.ts +39 -0
- package/src/extension/tool-description.ts +4 -4
- package/src/inspectors/herdr/project-panes.ts +457 -62
- package/src/missions/actions.ts +25 -2
- package/src/missions/lifecycle.ts +21 -2
- package/src/missions/store.ts +77 -1
- package/src/missions/types.ts +33 -0
- package/src/runs/background/async-execution.ts +7 -1
- package/src/runs/background/completion-replay.ts +267 -0
- package/src/runs/background/result-watcher.ts +12 -4
- package/src/runs/background/wait-completions.ts +39 -5
- package/src/runs/background/wait-subscriptions.ts +18 -3
- package/src/runs/foreground/execution.ts +4 -0
- package/src/runs/foreground/foreground-history.ts +137 -0
- package/src/runs/foreground/subagent-executor.ts +310 -44
- package/src/shared/fork-context.ts +13 -0
- package/src/shared/prompt-resources.ts +51 -0
- package/src/shared/types.ts +30 -1
- package/src/shared/utf8.ts +11 -0
- package/src/slash/prompt-workflows.ts +2 -15
- package/src/slash/slash-commands.ts +19 -1
- package/src/tui/fleet-status.ts +8 -2
- package/src/tui/fleet.ts +135 -25
- package/src/tui/render.ts +120 -7
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
203
|
+
const read = readBinding(projectRoot, false);
|
|
204
|
+
return read.state === "valid" ? read.binding : undefined;
|
|
63
205
|
}
|
|
64
206
|
|
|
65
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
91
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
}
|
package/src/missions/actions.ts
CHANGED
|
@@ -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)
|
|
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) =>
|
|
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;
|