pi-extensible-workflows 1.0.0 → 2.0.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/README.md +7 -2
- package/dist/src/agent-execution.d.ts +13 -0
- package/dist/src/agent-execution.js +177 -40
- package/dist/src/ambient-workflow-evals.js +14 -33
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +530 -6
- package/dist/src/doctor.d.ts +4 -4
- package/dist/src/doctor.js +9 -31
- package/dist/src/herdr.d.ts +12 -0
- package/dist/src/herdr.js +74 -0
- package/dist/src/index.d.ts +107 -27
- package/dist/src/index.js +838 -322
- package/dist/src/persistence.d.ts +28 -0
- package/dist/src/persistence.js +213 -16
- package/dist/src/session-inspector.d.ts +1 -0
- package/dist/src/session-inspector.js +3 -0
- package/dist/src/workflow-evals.d.ts +1 -0
- package/dist/src/workflow-evals.js +25 -26
- package/package.json +8 -4
- package/skills/pi-extensible-workflows/SKILL.md +48 -60
- package/src/agent-execution.ts +140 -27
- package/src/ambient-workflow-evals.ts +18 -28
- package/src/cli.ts +418 -6
- package/src/doctor.ts +13 -35
- package/src/herdr.ts +73 -0
- package/src/index.ts +734 -266
- package/src/persistence.ts +192 -16
- package/src/session-inspector.ts +4 -0
- package/src/workflow-evals.ts +15 -17
package/src/cli.ts
CHANGED
|
@@ -1,12 +1,406 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, linkSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { ProjectTrustStore, SessionManager, SettingsManager, createAgentSessionFromServices, createAgentSessionServices, getAgentDir, hasTrustRequiringProjectResources, type LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Value } from "typebox/value";
|
|
4
9
|
import { doctor, doctorExitCode, formatDoctorReport, type DoctorOptions } from "./doctor.js";
|
|
5
|
-
import {
|
|
10
|
+
import workflowExtension, { formatWorkflowProgress, workflowCatalog, type JsonSchema, type JsonValue } from "./index.js";
|
|
11
|
+
import { runSessionInspector, transcriptFileLines } from "./session-inspector.js";
|
|
12
|
+
import type { PersistedRun } from "./persistence.js";
|
|
13
|
+
import type { WorkflowCatalogFunction } from "./index.js";
|
|
6
14
|
|
|
7
|
-
export interface CliOptions extends DoctorOptions { inspect?: (sessionId?: string) => Promise<void
|
|
15
|
+
export interface CliOptions extends DoctorOptions { inspect?: (sessionId?: string) => Promise<void>; transcript?: (sessionFile: string) => Promise<void>; stderr?: (text: string) => void; signal?: AbortSignal; trustOverride?: boolean; isTTY?: boolean }
|
|
16
|
+
|
|
17
|
+
type CliScalar = "string" | "integer" | "number" | "boolean";
|
|
18
|
+
type CliField = { name: string; option: string; schema: Record<string, unknown>; type: CliScalar | "array"; itemType?: CliScalar; required: boolean };
|
|
19
|
+
type CliSchemaPlan = { fields: readonly CliField[]; positional?: CliField };
|
|
20
|
+
|
|
21
|
+
function object(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
22
|
+
function has(value: object, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); }
|
|
23
|
+
function clone(value: unknown): JsonValue { return structuredClone(value) as JsonValue; }
|
|
24
|
+
function kebabCase(value: string): string { return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase(); }
|
|
25
|
+
|
|
26
|
+
function scalarType(schema: unknown): CliScalar | undefined {
|
|
27
|
+
if (!object(schema) || typeof schema.type !== "string") return undefined;
|
|
28
|
+
return ["string", "integer", "number", "boolean"].includes(schema.type) ? schema.type as CliScalar : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function schemaPlan(schema: JsonSchema): CliSchemaPlan {
|
|
32
|
+
if (!object(schema) || schema.type !== "object") return { fields: [] };
|
|
33
|
+
const properties = object(schema.properties) ? schema.properties : {};
|
|
34
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required.filter((name): name is string => typeof name === "string") : []);
|
|
35
|
+
const fields: CliField[] = [];
|
|
36
|
+
for (const [name, property] of Object.entries(properties)) {
|
|
37
|
+
if (!object(property)) continue;
|
|
38
|
+
const directType = scalarType(property);
|
|
39
|
+
const itemType = object(property.items) ? scalarType(property.items) : undefined;
|
|
40
|
+
const type = directType ?? itemType ? directType ?? "array" : undefined;
|
|
41
|
+
if (!type) continue;
|
|
42
|
+
fields.push({ name, option: `--${kebabCase(name)}`, schema: property, type, ...(itemType ? { itemType } : {}), required: required.has(name) });
|
|
43
|
+
}
|
|
44
|
+
const requiredScalars = fields.filter((field) => field.required && field.type !== "array");
|
|
45
|
+
return { fields, ...(requiredScalars.length === 1 ? { positional: requiredScalars[0] } : {}) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function scalarLabel(type: CliScalar): string { return type === "integer" ? "integer" : type; }
|
|
49
|
+
function scalarFieldType(field: CliField): CliScalar { return field.type === "array" ? field.itemType as CliScalar : field.type; }
|
|
50
|
+
function fieldLabel(field: CliField): string { return field.type === "array" ? `${field.option} <${scalarLabel(field.itemType as CliScalar)}>` : `${field.option}${field.type === "boolean" ? "" : ` <${scalarLabel(field.type)}>`}`; }
|
|
51
|
+
function fieldDescription(field: CliField): string {
|
|
52
|
+
const description = typeof field.schema.description === "string" ? field.schema.description.trim() : "";
|
|
53
|
+
const required = field.required ? "required" : "optional";
|
|
54
|
+
const defaultValue = has(field.schema, "default") ? ` default=${JSON.stringify(field.schema.default)}` : "";
|
|
55
|
+
const enumSchema = field.type === "array" && object(field.schema.items) ? field.schema.items : field.schema;
|
|
56
|
+
const enumValue = Array.isArray(enumSchema.enum) ? ` enum=${enumSchema.enum.map((value) => JSON.stringify(value)).join(",")}` : "";
|
|
57
|
+
return [description, required, defaultValue, enumValue].filter(Boolean).join("; ");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatWorkflowCliHelp(fn: WorkflowCatalogFunction, command = "pi-extensible-workflows"): string {
|
|
61
|
+
const plan = schemaPlan(fn.input);
|
|
62
|
+
const lines = [`Usage: ${command} run ${fn.name}${plan.positional ? ` <${plan.positional.name}>` : ""} [options]`, "", fn.description];
|
|
63
|
+
if (plan.positional) {
|
|
64
|
+
lines.push("", "Arguments:", ` <${plan.positional.name}> ${scalarLabel(scalarFieldType(plan.positional))}; ${fieldDescription(plan.positional)}`);
|
|
65
|
+
}
|
|
66
|
+
lines.push("", "Options:");
|
|
67
|
+
for (const field of plan.fields) {
|
|
68
|
+
const label = field === plan.positional ? `${field.option} <${scalarLabel(scalarFieldType(field))}>` : fieldLabel(field);
|
|
69
|
+
lines.push(` ${label.padEnd(24)}${fieldDescription(field)}`);
|
|
70
|
+
}
|
|
71
|
+
lines.push(" --input <json>".padEnd(28) + "JSON input escape hatch for complex schemas", ...launcherHelpLines(), " -h, --help".padEnd(28) + "Show this help");
|
|
72
|
+
return `${lines.join("\n")}\n`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function enumAllows(schema: Record<string, unknown>, value: unknown): boolean {
|
|
76
|
+
return !Array.isArray(schema.enum) || schema.enum.some((candidate) => JSON.stringify(candidate) === JSON.stringify(value));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function coerce(raw: string, type: CliScalar, schema: Record<string, unknown>): JsonValue {
|
|
80
|
+
let value: JsonValue;
|
|
81
|
+
if (type === "string") value = raw;
|
|
82
|
+
else if (type === "integer") { if (!/^-?(?:0|[1-9]\d*)$/.test(raw)) throw new Error(`Invalid integer: ${raw}`); value = Number(raw); if (!Number.isSafeInteger(value)) throw new Error(`Invalid integer: ${raw}`); }
|
|
83
|
+
else if (type === "number") { value = Number(raw); if (!Number.isFinite(value)) throw new Error(`Invalid number: ${raw}`); }
|
|
84
|
+
else { if (raw !== "true" && raw !== "false") throw new Error(`Invalid boolean: ${raw}`); value = raw === "true"; }
|
|
85
|
+
if (!enumAllows(schema, value)) throw new Error(`Invalid value for enum: ${raw}`);
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseJsonInput(value: string): JsonValue {
|
|
90
|
+
try { return clone(JSON.parse(value)); } catch { throw new Error("Invalid JSON passed to --input"); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseWorkflowCliArgs(schema: JsonSchema, rawArgs: readonly string[]): Record<string, JsonValue> {
|
|
94
|
+
const plan = schemaPlan(schema);
|
|
95
|
+
const fields = new Map(plan.fields.map((field) => [field.option, field]));
|
|
96
|
+
const result: Record<string, JsonValue> = {};
|
|
97
|
+
let input: JsonValue | undefined;
|
|
98
|
+
let positionalUsed = false;
|
|
99
|
+
let endOptions = false;
|
|
100
|
+
const assign = (field: CliField, raw: string) => {
|
|
101
|
+
if (field.type === "array") { const values = Array.isArray(result[field.name]) ? result[field.name] as JsonValue[] : []; values.push(coerce(raw, field.itemType as CliScalar, field.schema.items as Record<string, unknown>)); result[field.name] = values; }
|
|
102
|
+
else result[field.name] = coerce(raw, field.type, field.schema);
|
|
103
|
+
};
|
|
104
|
+
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
105
|
+
const token = rawArgs[index] as string;
|
|
106
|
+
if (token === "--") { endOptions = true; continue; }
|
|
107
|
+
if (!endOptions && (token === "--input" || token.startsWith("--input="))) {
|
|
108
|
+
if (input !== undefined) throw new Error("--input may only be provided once");
|
|
109
|
+
const raw = token.startsWith("--input=") ? token.slice("--input=".length) : rawArgs[++index];
|
|
110
|
+
if (raw === undefined) throw new Error("Missing value for --input");
|
|
111
|
+
input = parseJsonInput(raw);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!endOptions && token.startsWith("--")) {
|
|
115
|
+
const equals = token.indexOf("=");
|
|
116
|
+
const option = equals >= 0 ? token.slice(0, equals) : token;
|
|
117
|
+
const negated = equals < 0 && option.startsWith("--no-");
|
|
118
|
+
const field = fields.get(negated ? `--${option.slice("--no-".length)}` : option);
|
|
119
|
+
if (!field) throw new Error(`Unknown option: ${option}`);
|
|
120
|
+
if (negated) {
|
|
121
|
+
if (field.type !== "boolean") throw new Error(`Invalid boolean option: ${option}`);
|
|
122
|
+
result[field.name] = false;
|
|
123
|
+
} else if (field.type === "boolean") {
|
|
124
|
+
if (equals >= 0) assign(field, token.slice(equals + 1));
|
|
125
|
+
else if (rawArgs[index + 1] === "true" || rawArgs[index + 1] === "false") assign(field, rawArgs[++index] as string);
|
|
126
|
+
else result[field.name] = true;
|
|
127
|
+
} else {
|
|
128
|
+
const raw = equals >= 0 ? token.slice(equals + 1) : rawArgs[++index];
|
|
129
|
+
if (raw === undefined || raw.startsWith("--")) throw new Error(`Missing value for ${option}`);
|
|
130
|
+
assign(field, raw);
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const positional = plan.positional;
|
|
135
|
+
const numericNegative = positional && (positional.type === "integer" || positional.type === "number") && /^-\d/.test(token);
|
|
136
|
+
if (!endOptions && token.startsWith("-") && !numericNegative) throw new Error(`Unknown option: ${token}`);
|
|
137
|
+
if (!positional || positionalUsed) throw new Error(`Unexpected argument: ${token}`);
|
|
138
|
+
assign(positional, token);
|
|
139
|
+
positionalUsed = true;
|
|
140
|
+
}
|
|
141
|
+
if (input !== undefined) {
|
|
142
|
+
if (Object.keys(result).length || positionalUsed) throw new Error("--input cannot be combined with CLI arguments");
|
|
143
|
+
if (!object(input)) throw new Error("Workflow input must be a JSON object");
|
|
144
|
+
for (const field of plan.fields) if (!has(input, field.name) && has(field.schema, "default")) input[field.name] = clone(field.schema.default);
|
|
145
|
+
return input;
|
|
146
|
+
}
|
|
147
|
+
for (const field of plan.fields) if (!has(result, field.name) && has(field.schema, "default")) result[field.name] = clone(field.schema.default);
|
|
148
|
+
for (const field of plan.fields) if (field.required && !has(result, field.name)) throw new Error(`Missing required argument: ${field.name}`);
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function launcherHelpLines(): string[] {
|
|
153
|
+
return [
|
|
154
|
+
" --approve".padEnd(28) + "Trust project resources for this launch",
|
|
155
|
+
" --no-approve".padEnd(28) + "Do not trust project resources for this launch",
|
|
156
|
+
" --".padEnd(28) + "End launcher option parsing; pass later tokens to workflow input",
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
function workflowUsage(): string { return [`Usage: pi-extensible-workflows run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
|
|
160
|
+
function exportUsage(): string { return [`Usage: pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
|
|
161
|
+
function stripTrustOptions(rawArgs: readonly string[]): { args: string[]; trustOverride?: boolean } {
|
|
162
|
+
const args: string[] = [];
|
|
163
|
+
let trustOverride: boolean | undefined;
|
|
164
|
+
let endOptions = false;
|
|
165
|
+
for (const arg of rawArgs) {
|
|
166
|
+
if (arg === "--") { endOptions = true; args.push(arg); continue; }
|
|
167
|
+
if (!endOptions && (arg === "--approve" || arg === "--no-approve")) {
|
|
168
|
+
const next = arg === "--approve";
|
|
169
|
+
if (trustOverride !== undefined && trustOverride !== next) throw new Error("--approve and --no-approve cannot be combined");
|
|
170
|
+
trustOverride = next;
|
|
171
|
+
} else args.push(arg);
|
|
172
|
+
}
|
|
173
|
+
return { args, ...(trustOverride !== undefined ? { trustOverride } : {}) };
|
|
174
|
+
}
|
|
175
|
+
type WorkflowIo = { write: (text: string) => void; stderr: (text: string) => void; cwd?: string; agentDir?: string; trustOverride?: boolean; isTTY?: boolean; signal?: AbortSignal };
|
|
176
|
+
|
|
177
|
+
type HeadlessWorkflowTool = { execute: (toolCallId: string, params: Record<string, JsonValue>, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, context: unknown) => Promise<{ content: Array<{ type: string; text: string }>; details?: unknown }> };
|
|
178
|
+
type ShutdownHandler = (event: unknown, context: unknown) => Promise<void> | void;
|
|
179
|
+
type WorkflowRuntime = { catalog: ReturnType<typeof workflowCatalog>; services: Awaited<ReturnType<typeof createAgentSessionServices>>; workflowTool: HeadlessWorkflowTool; shutdownHandlers: ShutdownHandler[] };
|
|
180
|
+
|
|
181
|
+
async function createWorkflowRuntime(options: WorkflowIo, shutdownHandlers: ShutdownHandler[] = []): Promise<WorkflowRuntime> {
|
|
182
|
+
const cwd = options.cwd ?? process.cwd();
|
|
183
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
184
|
+
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
|
|
185
|
+
const requiredTrust = hasTrustRequiringProjectResources(cwd);
|
|
186
|
+
const trustStore = new ProjectTrustStore(agentDir);
|
|
187
|
+
const defaultProjectTrust = settingsManager.getDefaultProjectTrust();
|
|
188
|
+
const resolveProjectTrust = async ({ extensionsResult }: { extensionsResult: LoadExtensionsResult }): Promise<boolean> => {
|
|
189
|
+
if (options.trustOverride !== undefined) return options.trustOverride;
|
|
190
|
+
if (!requiredTrust) return true;
|
|
191
|
+
const projectTrustContext = {
|
|
192
|
+
cwd,
|
|
193
|
+
mode: "print" as const,
|
|
194
|
+
hasUI: false,
|
|
195
|
+
ui: { select: async () => undefined, confirm: async () => false, input: async () => undefined, notify: () => {} },
|
|
196
|
+
};
|
|
197
|
+
for (const extension of extensionsResult.extensions) {
|
|
198
|
+
for (const handler of extension.handlers.get("project_trust") ?? []) {
|
|
199
|
+
try {
|
|
200
|
+
const result = await handler({ type: "project_trust", cwd }, projectTrustContext) as { trusted?: unknown; remember?: unknown };
|
|
201
|
+
if (result.trusted === "undecided") continue;
|
|
202
|
+
if (result.trusted !== "yes" && result.trusted !== "no") continue;
|
|
203
|
+
const trusted = result.trusted === "yes";
|
|
204
|
+
if (result.remember === true) trustStore.set(cwd, trusted);
|
|
205
|
+
return trusted;
|
|
206
|
+
} catch { /* Project trust extensions are best effort, as in Pi. */ }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const savedTrust = trustStore.get(cwd);
|
|
210
|
+
if (savedTrust !== null) return savedTrust;
|
|
211
|
+
return defaultProjectTrust === "always";
|
|
212
|
+
};
|
|
213
|
+
const services = await createAgentSessionServices({
|
|
214
|
+
cwd,
|
|
215
|
+
agentDir,
|
|
216
|
+
settingsManager,
|
|
217
|
+
resourceLoaderOptions: {},
|
|
218
|
+
resourceLoaderReloadOptions: { resolveProjectTrust },
|
|
219
|
+
});
|
|
220
|
+
const extensions = services.resourceLoader.getExtensions();
|
|
221
|
+
const tools: unknown[] = [];
|
|
222
|
+
const activeTools = [...new Set(["read", "bash", "edit", "write"].concat(extensions.extensions.flatMap((extension) => [...extension.tools.keys()]), ["workflow"]))];
|
|
223
|
+
const headlessPi = {
|
|
224
|
+
registerTool(tool: unknown) { tools.push(tool); },
|
|
225
|
+
registerCommand() {},
|
|
226
|
+
getThinkingLevel: () => services.settingsManager.getDefaultThinkingLevel() ?? "medium",
|
|
227
|
+
getActiveTools: () => activeTools,
|
|
228
|
+
on(name: string, handler: unknown) { if (name === "session_shutdown" && typeof handler === "function") shutdownHandlers.push(handler as ShutdownHandler); },
|
|
229
|
+
appendEntry() {},
|
|
230
|
+
sendMessage() {},
|
|
231
|
+
events: { emit() {} },
|
|
232
|
+
};
|
|
233
|
+
workflowExtension(headlessPi as never, homedir(), undefined, undefined, agentDir);
|
|
234
|
+
const workflowTool = tools.find((tool) => object(tool) && tool.name === "workflow") as HeadlessWorkflowTool | undefined;
|
|
235
|
+
if (!workflowTool) throw new Error("The workflow runtime could not be initialized");
|
|
236
|
+
return { catalog: workflowCatalog(), services, workflowTool, shutdownHandlers };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function availableModelInfo(services: WorkflowRuntime["services"], available = false): { provider: string; id: string }[] {
|
|
240
|
+
const models = available ? services.modelRuntime.getAvailableSnapshot() : services.modelRuntime.getModels();
|
|
241
|
+
return models.map(({ provider, id }) => ({ provider, id }));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function selectedModel(services: WorkflowRuntime["services"]): Promise<{ provider: string; id: string } | undefined> {
|
|
245
|
+
const { session } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(), noTools: "all" });
|
|
246
|
+
try {
|
|
247
|
+
const model = session.model;
|
|
248
|
+
return model ? { provider: model.provider, id: model.id } : undefined;
|
|
249
|
+
} finally {
|
|
250
|
+
session.dispose();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; }
|
|
255
|
+
function commandName(value: string): string { return value.trim() && !value.includes("/") && !value.includes("\\") ? value.trim() : ""; }
|
|
256
|
+
|
|
257
|
+
function writeLauncher(destination: string, workflowName: string, force: boolean): void {
|
|
258
|
+
const parent = dirname(destination);
|
|
259
|
+
mkdirSync(parent, { recursive: true });
|
|
260
|
+
const tempDir = mkdtempSync(join(parent, ".pi-extensible-workflows-"));
|
|
261
|
+
const tempPath = join(tempDir, "launcher");
|
|
262
|
+
try {
|
|
263
|
+
writeFileSync(tempPath, `#!/bin/sh\nexec node ${shellQuote(fileURLToPath(import.meta.url))} run ${shellQuote(workflowName)} "$@"\n`, { mode: 0o755 });
|
|
264
|
+
chmodSync(tempPath, 0o755);
|
|
265
|
+
if (force) renameSync(tempPath, destination);
|
|
266
|
+
else {
|
|
267
|
+
try { linkSync(tempPath, destination); }
|
|
268
|
+
catch (error) {
|
|
269
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Destination already exists: ${destination}; use --force to replace it`, { cause: error });
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} finally { rmSync(tempDir, { recursive: true, force: true }); }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class CliProgress {
|
|
278
|
+
#lastStable = "";
|
|
279
|
+
#lines = 0;
|
|
280
|
+
#frame = 0;
|
|
281
|
+
#run: PersistedRun | undefined;
|
|
282
|
+
#timer: ReturnType<typeof setInterval> | undefined;
|
|
283
|
+
constructor(private readonly stderr: (text: string) => void, private readonly tty: boolean) {}
|
|
284
|
+
update(run: PersistedRun): void {
|
|
285
|
+
const stable = formatWorkflowProgress(run, "◇");
|
|
286
|
+
if (!this.tty) { if (stable !== this.#lastStable) { this.#lastStable = stable; this.stderr(`${stable}\n`); } return; }
|
|
287
|
+
this.#run = run;
|
|
288
|
+
this.#timer ??= setInterval(() => { this.render(); }, 80);
|
|
289
|
+
this.#timer.unref();
|
|
290
|
+
this.render();
|
|
291
|
+
}
|
|
292
|
+
render(): void {
|
|
293
|
+
if (!this.#run) return;
|
|
294
|
+
const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][this.#frame++ % 10] ?? "◇";
|
|
295
|
+
const width = process.stderr.columns || 80;
|
|
296
|
+
const text = formatWorkflowProgress(this.#run, spinner).split("\n").map((line) => line.length <= width ? line : `${line.slice(0, Math.max(0, width - 1))}…`).join("\n");
|
|
297
|
+
this.stderr(`${this.#lines ? `\x1b[${String(this.#lines)}A` : ""}${this.#lines ? "" : "\x1b[?25l"}\x1b[0J${text}\n`);
|
|
298
|
+
this.#lines = text.split("\n").length;
|
|
299
|
+
}
|
|
300
|
+
finish(): void {
|
|
301
|
+
if (this.#timer) { clearInterval(this.#timer); this.#timer = undefined; }
|
|
302
|
+
if (this.tty && this.#lines) { this.stderr(`\x1b[${String(this.#lines)}A\x1b[0J\x1b[?25h`); this.#lines = 0; }
|
|
303
|
+
this.#run = undefined;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function invokeWorkflow(fn: WorkflowCatalogFunction, args: Record<string, JsonValue>, runtime: WorkflowRuntime, options: WorkflowIo, context: unknown): Promise<JsonValue> {
|
|
308
|
+
if (!Value.Check(fn.input, args)) throw new Error(`Invalid input for ${fn.name}`);
|
|
309
|
+
const progress = new CliProgress(options.stderr, options.isTTY ?? process.stderr.isTTY);
|
|
310
|
+
try {
|
|
311
|
+
const result = await runtime.workflowTool.execute(randomUUID(), { workflow: fn.name, args, foreground: true }, options.signal, (update: unknown) => { if (object(update) && object(update.details) && object(update.details.run)) progress.update(update.details.run as unknown as PersistedRun); }, context);
|
|
312
|
+
const details = object(result.details) ? result.details : {};
|
|
313
|
+
if (has(details, "value")) return details.value as JsonValue;
|
|
314
|
+
const first = result.content[0];
|
|
315
|
+
if (!first || first.type !== "text") throw new Error("Workflow returned no result");
|
|
316
|
+
try { return parseJsonInput(first.text); } catch { throw new Error("Workflow returned invalid JSON"); }
|
|
317
|
+
} finally {
|
|
318
|
+
progress.finish();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function createWorkflowContext(runtime: WorkflowRuntime, options: WorkflowIo): Promise<unknown> {
|
|
323
|
+
const model = await selectedModel(runtime.services);
|
|
324
|
+
const sessionManager = SessionManager.inMemory();
|
|
325
|
+
const modelRegistry = { getAll: () => availableModelInfo(runtime.services), getAvailable: () => availableModelInfo(runtime.services, true) };
|
|
326
|
+
return { cwd: options.cwd ?? process.cwd(), mode: "print" as const, hasUI: false, ...(model ? { model } : {}), modelRegistry, sessionManager, isProjectTrusted: () => runtime.services.settingsManager.isProjectTrusted(), ui: { select: async () => undefined, confirm: async () => false, input: async () => undefined, notify: () => {}, onTerminalInput: () => () => {}, setStatus: () => {}, setWorkingMessage: () => {}, setWorkingVisible: () => {}, setWorkingIndicator: () => {}, setHiddenThinkingLabel: () => {}, setWidget: () => {}, setFooter: () => {}, setHeader: () => {}, setTitle: () => {}, custom: async () => undefined, pasteToEditor: () => {}, setEditorText: () => {}, getEditorText: () => "", editor: async () => undefined, addAutocompleteProvider: () => {} }, headless: true };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function shutdownWorkflowRuntime(handlers: readonly ShutdownHandler[], context: unknown): Promise<void> {
|
|
330
|
+
for (const handler of handlers) {
|
|
331
|
+
try { await handler({ type: "session_shutdown", reason: "quit" }, context); } catch { /* Shutdown is best effort. */ }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function withWorkflowRuntime<T>(options: WorkflowIo, action: (runtime: WorkflowRuntime, context: unknown) => Promise<T>): Promise<T> {
|
|
336
|
+
const shutdownHandlers: ShutdownHandler[] = [];
|
|
337
|
+
let context: unknown = { cwd: options.cwd ?? process.cwd(), mode: "print", hasUI: false, headless: true };
|
|
338
|
+
try {
|
|
339
|
+
const runtime = await createWorkflowRuntime(options, shutdownHandlers);
|
|
340
|
+
context = await createWorkflowContext(runtime, options);
|
|
341
|
+
return await action(runtime, context);
|
|
342
|
+
} finally {
|
|
343
|
+
await shutdownWorkflowRuntime(shutdownHandlers, context);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function runWorkflowCli(rawArgs: readonly string[], options: WorkflowIo): Promise<number> {
|
|
348
|
+
const parsed = stripTrustOptions(rawArgs);
|
|
349
|
+
const args = parsed.args;
|
|
350
|
+
if (!args.length || args[0] === "--help" || args[0] === "-h") { options.write(workflowUsage()); return args.length ? 0 : 1; }
|
|
351
|
+
const name = args[0] as string;
|
|
352
|
+
return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime, context) => {
|
|
353
|
+
const help = args.slice(1).some((arg) => arg === "--help" || arg === "-h");
|
|
354
|
+
const fn = runtime.catalog.functions.find((candidate) => candidate.name === name);
|
|
355
|
+
if (!fn) throw new Error(`Unknown workflow function: ${name}`);
|
|
356
|
+
if (help) { options.write(formatWorkflowCliHelp(fn)); return 0; }
|
|
357
|
+
const input = parseWorkflowCliArgs(fn.input, args.slice(1));
|
|
358
|
+
const value = await invokeWorkflow(fn, input, runtime, options, context);
|
|
359
|
+
options.write(`${JSON.stringify(value)}\n`);
|
|
360
|
+
return 0;
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function exportWorkflowCli(rawArgs: readonly string[], options: WorkflowIo): Promise<number> {
|
|
365
|
+
const parsed = stripTrustOptions(rawArgs);
|
|
366
|
+
const args = parsed.args;
|
|
367
|
+
if (!args.length || args[0] === "--help" || args[0] === "-h") { options.write(exportUsage()); return args.length ? 0 : 1; }
|
|
368
|
+
const workflowName = args[0] as string;
|
|
369
|
+
return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime) => {
|
|
370
|
+
let name: string | undefined;
|
|
371
|
+
let output: string | undefined;
|
|
372
|
+
let force = false;
|
|
373
|
+
for (let index = 1; index < args.length; index += 1) {
|
|
374
|
+
const arg = args[index] as string;
|
|
375
|
+
if (arg === "--force") { force = true; continue; }
|
|
376
|
+
const equals = arg.indexOf("=");
|
|
377
|
+
const option = equals >= 0 ? arg.slice(0, equals) : arg;
|
|
378
|
+
if (option === "--name" || option === "--output") {
|
|
379
|
+
const value = equals >= 0 ? arg.slice(equals + 1) : args[++index];
|
|
380
|
+
if (!value) throw new Error(`Missing value for ${option}`);
|
|
381
|
+
if (option === "--name") name = value; else output = value;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (arg === "--help" || arg === "-h") { options.write(exportUsage()); return 0; }
|
|
385
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
386
|
+
}
|
|
387
|
+
if (!runtime.catalog.functions.some((candidate) => candidate.name === workflowName)) throw new Error(`Unknown workflow function: ${workflowName}`);
|
|
388
|
+
const command = commandName(name ?? kebabCase(workflowName));
|
|
389
|
+
if (!command) throw new Error("Command name must be a non-empty name without path separators");
|
|
390
|
+
const destination = output ? output : join(homedir(), ".local", "bin", command);
|
|
391
|
+
writeLauncher(destination, workflowName, force);
|
|
392
|
+
if (!output) {
|
|
393
|
+
const binDir = join(homedir(), ".local", "bin");
|
|
394
|
+
const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean).map((entry) => { try { return realpathSync(entry); } catch { return entry; } });
|
|
395
|
+
if (!pathEntries.includes(binDir)) options.stderr(`Warning: ${binDir} is not in PATH\n`);
|
|
396
|
+
}
|
|
397
|
+
options.write(`Exported ${destination}\n`);
|
|
398
|
+
return 0;
|
|
399
|
+
});
|
|
400
|
+
}
|
|
8
401
|
|
|
9
402
|
export async function runCli(args: readonly string[], options: CliOptions = {}, write: (text: string) => void = (text) => { process.stdout.write(text); }): Promise<number> {
|
|
403
|
+
const stderr = options.stderr ?? ((text: string) => { process.stderr.write(text); });
|
|
10
404
|
if (args[0] === "doctor" && args.length === 1) {
|
|
11
405
|
const report = await doctor(options);
|
|
12
406
|
write(formatDoctorReport(report));
|
|
@@ -16,8 +410,26 @@ export async function runCli(args: readonly string[], options: CliOptions = {},
|
|
|
16
410
|
try { await (options.inspect ?? runSessionInspector)(args[1]); return 0; }
|
|
17
411
|
catch (error) { write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
|
|
18
412
|
}
|
|
19
|
-
|
|
413
|
+
if (args[0] === "transcript" && args.length === 2) {
|
|
414
|
+
try {
|
|
415
|
+
if (options.transcript) await options.transcript(args[1] as string);
|
|
416
|
+
else write(`${transcriptFileLines(args[1] as string).join("\n")}\n`);
|
|
417
|
+
return 0;
|
|
418
|
+
} catch (error) { write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
|
|
419
|
+
}
|
|
420
|
+
if (args[0] === "run" || args[0] === "export") {
|
|
421
|
+
try {
|
|
422
|
+
const workflowOptions: WorkflowIo = { write, stderr, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), ...(options.agentDir !== undefined ? { agentDir: options.agentDir } : {}), ...(options.signal ? { signal: options.signal } : {}), ...(options.trustOverride !== undefined ? { trustOverride: options.trustOverride } : {}), ...(options.isTTY !== undefined ? { isTTY: options.isTTY } : {}) };
|
|
423
|
+
return args[0] === "run" ? await runWorkflowCli(args.slice(1), workflowOptions) : await exportWorkflowCli(args.slice(1), workflowOptions);
|
|
424
|
+
} catch (error) { stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
|
|
425
|
+
}
|
|
426
|
+
write("Usage: pi-extensible-workflows doctor | inspect [session-id] | transcript <session-file> | run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force]\n");
|
|
20
427
|
return 1;
|
|
21
428
|
}
|
|
22
429
|
|
|
23
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href)
|
|
430
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
431
|
+
const controller = new AbortController();
|
|
432
|
+
const onSignal = () => { controller.abort(); };
|
|
433
|
+
process.once("SIGINT", onSignal);
|
|
434
|
+
process.exitCode = await runCli(process.argv.slice(2), { signal: controller.signal });
|
|
435
|
+
}
|
package/src/doctor.ts
CHANGED
|
@@ -13,19 +13,16 @@ import {
|
|
|
13
13
|
import {
|
|
14
14
|
DEFAULT_SETTINGS,
|
|
15
15
|
loadSettings,
|
|
16
|
-
parseModelReference,
|
|
17
16
|
resolveAgentResourcePolicy,
|
|
18
17
|
resolveModelReference,
|
|
19
18
|
parseRoleMarkdown,
|
|
20
|
-
|
|
21
|
-
registeredWorkflowDefinitions,
|
|
19
|
+
registeredWorkflowFunctions,
|
|
22
20
|
workflowRoleDirectories,
|
|
23
21
|
workflowProjectSettingsPath,
|
|
24
22
|
workflowSettingsPath,
|
|
25
|
-
WorkflowError,
|
|
26
23
|
type AgentResourceExclusions,
|
|
27
24
|
type AgentResourcePolicy,
|
|
28
|
-
type
|
|
25
|
+
type WorkflowFunction,
|
|
29
26
|
type WorkflowSettings,
|
|
30
27
|
} from "./index.js";
|
|
31
28
|
import type { AgentDefinition } from "./agent-execution.js";
|
|
@@ -33,7 +30,7 @@ import type { AgentDefinition } from "./agent-execution.js";
|
|
|
33
30
|
export type DoctorSeverity = "error" | "warning";
|
|
34
31
|
export interface DoctorDiagnostic { severity: DoctorSeverity; code: string; message: string; source?: string; hint?: string }
|
|
35
32
|
export interface DoctorRole { name: string; path: string; scope: "global" | "project"; active: boolean; overrides?: string; overriddenBy?: string }
|
|
36
|
-
export interface
|
|
33
|
+
export interface DoctorFunction { name: string; description: string; valid: boolean }
|
|
37
34
|
export interface DoctorTrust { required: boolean; trusted: boolean; source: string }
|
|
38
35
|
export interface DoctorPiState {
|
|
39
36
|
trust: DoctorTrust;
|
|
@@ -43,7 +40,7 @@ export interface DoctorPiState {
|
|
|
43
40
|
extensionErrors: readonly { path?: string; message: string }[];
|
|
44
41
|
extensions?: readonly string[];
|
|
45
42
|
skills?: readonly string[];
|
|
46
|
-
|
|
43
|
+
functions: Readonly<Record<string, WorkflowFunction>>;
|
|
47
44
|
}
|
|
48
45
|
export interface DoctorReport {
|
|
49
46
|
cwd: string;
|
|
@@ -53,7 +50,7 @@ export interface DoctorReport {
|
|
|
53
50
|
trust: DoctorTrust;
|
|
54
51
|
activeTools: readonly string[];
|
|
55
52
|
roles: readonly DoctorRole[];
|
|
56
|
-
|
|
53
|
+
functions: readonly DoctorFunction[];
|
|
57
54
|
resourcePolicy: AgentResourcePolicy;
|
|
58
55
|
diagnostics: readonly DoctorDiagnostic[];
|
|
59
56
|
}
|
|
@@ -137,7 +134,7 @@ async function discoverPi(cwd: string, agentDir: string): Promise<DoctorPiState>
|
|
|
137
134
|
...extensions.errors.map(({ path, error }) => ({ path, message: error })),
|
|
138
135
|
...services.diagnostics.filter(({ type }) => type === "error").map(({ message }) => ({ message })),
|
|
139
136
|
],
|
|
140
|
-
|
|
137
|
+
functions: registeredWorkflowFunctions(),
|
|
141
138
|
};
|
|
142
139
|
} finally {
|
|
143
140
|
if (previousOffline === undefined) delete process.env.PI_OFFLINE;
|
|
@@ -188,9 +185,6 @@ function inspectRole(path: string, activeTools: ReadonlySet<string>, knownModels
|
|
|
188
185
|
return definition;
|
|
189
186
|
}
|
|
190
187
|
|
|
191
|
-
class DoctorModelSet extends Set<string> {
|
|
192
|
-
override has(value: string): boolean { parseModelReference(value); return true; }
|
|
193
|
-
}
|
|
194
188
|
function matchResourcePolicy(policy: AgentResourcePolicy, pi: DoctorPiState): AgentResourcePolicy {
|
|
195
189
|
const extensions = new Set((pi.extensions ?? []).map(canonical));
|
|
196
190
|
const skills = new Set(pi.skills ?? []);
|
|
@@ -212,7 +206,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
|
|
|
212
206
|
try { pi = await (options.discoverPi ?? discoverPi)(cwd, agentDir); }
|
|
213
207
|
catch (error) {
|
|
214
208
|
diagnostics.push(diagnostic("error", "PI_DISCOVERY", `Pi headless discovery failed: ${(error as Error).message}`, undefined, "Open and trust the project in Pi, fix extension errors, then rerun doctor."));
|
|
215
|
-
pi = { trust: { required: false, trusted: false, source: "discovery failed" }, activeTools: [], knownModels: [], availableModels: [], extensionErrors: [],
|
|
209
|
+
pi = { trust: { required: false, trusted: false, source: "discovery failed" }, activeTools: [], knownModels: [], availableModels: [], extensionErrors: [], functions: {} };
|
|
216
210
|
}
|
|
217
211
|
if (options.activeTools) pi = { ...pi, activeTools: options.activeTools.filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_catalog") };
|
|
218
212
|
if (pi.trust.required && !pi.trust.trusted) diagnostics.push(diagnostic("warning", "PROJECT_UNTRUSTED", "Pi project resources are inactive because the project is not trusted", cwd, "Open this project in Pi, choose Trust, then rerun doctor."));
|
|
@@ -257,31 +251,15 @@ export async function doctor(options: DoctorOptions = {}): Promise<DoctorReport>
|
|
|
257
251
|
if (definition) definitions.set(name, definition); else definitions.delete(name);
|
|
258
252
|
}
|
|
259
253
|
|
|
260
|
-
const
|
|
261
|
-
for (const [name,
|
|
262
|
-
|
|
263
|
-
try {
|
|
264
|
-
const checked = preflight(workflow.script, {
|
|
265
|
-
models: new DoctorModelSet(pi.knownModels),
|
|
266
|
-
tools: activeTools,
|
|
267
|
-
agentTypes: new Set(definitions.keys()),
|
|
268
|
-
modelAliases: aliases,
|
|
269
|
-
knownModels,
|
|
270
|
-
settingsPath,
|
|
271
|
-
}, [], { name, description: workflow.description });
|
|
272
|
-
for (const model of checked.referenced.models) if (!knownModels.has(model) || !availableModels.has(model)) diagnostics.push(diagnostic("warning", "MODEL_UNAVAILABLE", `Model is valid-shaped but unavailable: ${model}`, name));
|
|
273
|
-
} catch (error) {
|
|
274
|
-
valid = false;
|
|
275
|
-
const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
|
|
276
|
-
diagnostics.push(diagnostic("error", `WORKFLOW_${typed.code}`, typed.message, name, "Fix the registered workflow source or its referenced role/tool/model/extension."));
|
|
277
|
-
}
|
|
278
|
-
workflows.push({ name, description: workflow.description, valid });
|
|
254
|
+
const functions: DoctorFunction[] = [];
|
|
255
|
+
for (const [name, fn] of Object.entries(pi.functions).sort(([left], [right]) => left.localeCompare(right))) {
|
|
256
|
+
functions.push({ name, description: fn.description, valid: true });
|
|
279
257
|
}
|
|
280
258
|
|
|
281
259
|
const severityOrder: Record<DoctorSeverity, number> = { error: 0, warning: 1 };
|
|
282
260
|
diagnostics.sort((left, right) => severityOrder[left.severity] - severityOrder[right.severity] || (left.source ?? "").localeCompare(right.source ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message));
|
|
283
261
|
roles.sort((left, right) => left.name.localeCompare(right.name) || left.scope.localeCompare(right.scope));
|
|
284
|
-
return { cwd, agentDir, settingsPath, settings, trust: pi.trust, activeTools: [...activeTools].sort(), roles,
|
|
262
|
+
return { cwd, agentDir, settingsPath, settings, trust: pi.trust, activeTools: [...activeTools].sort(), roles, functions, resourcePolicy, diagnostics };
|
|
285
263
|
}
|
|
286
264
|
|
|
287
265
|
function count(report: DoctorReport, severity: DoctorSeverity): number { return report.diagnostics.filter((item) => item.severity === severity).length; }
|
|
@@ -319,8 +297,8 @@ export function formatDoctorReport(report: DoctorReport): string {
|
|
|
319
297
|
"## Roles",
|
|
320
298
|
...(report.roles.length ? report.roles.map((role) => `- \`${role.name}\` (${role.scope}, ${role.active ? "active" : role.overriddenBy ? `overridden by ${role.overriddenBy}` : "inactive: project untrusted"}) - \`${role.path}\`${role.overrides ? `; overrides \`${role.overrides}\`` : ""}`) : ["- None found"]),
|
|
321
299
|
"",
|
|
322
|
-
"## Reusable
|
|
323
|
-
...(report.
|
|
300
|
+
"## Reusable functions",
|
|
301
|
+
...(report.functions.length ? report.functions.map((fn) => `- [${fn.valid ? "ok" : "error"}] \`${fn.name}\` - ${fn.description}`) : ["- None registered"]),
|
|
324
302
|
"",
|
|
325
303
|
"## Diagnostics",
|
|
326
304
|
...(report.diagnostics.length ? report.diagnostics.map((item) => `- [${item.severity}] ${item.code}${item.source ? ` \`${item.source}\`` : ""}: ${item.message}${item.hint ? ` Fix: ${item.hint}` : ""}`) : ["- [ok] No diagnostics"]),
|
package/src/herdr.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
export type HerdrPaneAction = "inspect" | "transcript" | "fork";
|
|
5
|
+
export interface HerdrPaneRequest { action: HerdrPaneAction; cwd: string; sessionId?: string; original?: string; readOnly?: boolean }
|
|
6
|
+
export type HerdrCommandRunner = (args: readonly string[]) => Promise<string>;
|
|
7
|
+
|
|
8
|
+
const runHerdr: HerdrCommandRunner = (args) => new Promise<string>((resolve, reject) => {
|
|
9
|
+
execFile("herdr", [...args], { encoding: "utf8", maxBuffer: 1024 * 1024 }, (error, stdout) => {
|
|
10
|
+
if (error) { reject(new Error(error.message)); return; }
|
|
11
|
+
resolve(stdout);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function herdrPaneId(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
16
|
+
if (env.HERDR_ENV !== "1") return undefined;
|
|
17
|
+
const paneId = env.HERDR_PANE_ID?.trim();
|
|
18
|
+
return paneId || undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; }
|
|
22
|
+
function json(value: string): unknown { return JSON.parse(value) as unknown; }
|
|
23
|
+
function record(value: unknown): Record<string, unknown> | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined; }
|
|
24
|
+
function paneLayout(value: unknown, targetPane: string): { width: number; height: number } {
|
|
25
|
+
const root = record(value);
|
|
26
|
+
const result = record(root?.result);
|
|
27
|
+
const layout = record(result?.layout);
|
|
28
|
+
const rawPanes = layout?.panes;
|
|
29
|
+
if (!Array.isArray(rawPanes)) throw new Error("Herdr returned an invalid pane layout.");
|
|
30
|
+
const panes: unknown[] = rawPanes;
|
|
31
|
+
const pane = panes.find((candidate: unknown) => record(candidate)?.pane_id === targetPane);
|
|
32
|
+
const rect = record(record(pane)?.rect);
|
|
33
|
+
const width = rect?.width;
|
|
34
|
+
const height = rect?.height;
|
|
35
|
+
if (width === undefined || height === undefined || typeof width !== "number" || typeof height !== "number") throw new Error("Herdr returned an invalid target pane geometry.");
|
|
36
|
+
return { width, height };
|
|
37
|
+
}
|
|
38
|
+
function splitPaneId(value: unknown): string {
|
|
39
|
+
const pane = record(record(record(value)?.result)?.pane);
|
|
40
|
+
const paneId = pane?.pane_id;
|
|
41
|
+
if (typeof paneId !== "string" || !paneId) throw new Error("Herdr returned an invalid created pane ID.");
|
|
42
|
+
return paneId;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function commandFor(request: HerdrPaneRequest): string {
|
|
46
|
+
const cliPath = fileURLToPath(new URL("./cli.js", import.meta.resolve("pi-extensible-workflows")));
|
|
47
|
+
const environment = ["PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR"].flatMap((name) => process.env[name] === undefined ? [] : [`${name}=${shellQuote(process.env[name] ?? "")}`]);
|
|
48
|
+
const command = request.action === "inspect"
|
|
49
|
+
? [shellQuote(process.execPath), shellQuote(cliPath), "inspect", shellQuote(request.sessionId ?? "")]
|
|
50
|
+
: request.action === "transcript"
|
|
51
|
+
? [shellQuote(process.execPath), shellQuote(cliPath), "transcript", shellQuote(request.original ?? "")]
|
|
52
|
+
: ["pi", "--fork", shellQuote(request.original ?? ""), ...(request.readOnly ? ["--tools", shellQuote("read,grep,find,ls")] : [])];
|
|
53
|
+
return `cd ${shellQuote(request.cwd)} && ${environment.length ? `${environment.join(" ")} ` : ""}${command.join(" ")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function herdrPaneCommand(request: HerdrPaneRequest): string { return commandFor(request); }
|
|
57
|
+
|
|
58
|
+
export async function openHerdrPane(request: HerdrPaneRequest, runner: HerdrCommandRunner = runHerdr): Promise<string> {
|
|
59
|
+
const targetPane = herdrPaneId();
|
|
60
|
+
if (!targetPane) throw new Error("Pane actions require a Herdr-managed session with HERDR_PANE_ID.");
|
|
61
|
+
if (!request.cwd) throw new Error("Pane actions require a working directory.");
|
|
62
|
+
if ((request.action === "inspect" && !request.sessionId) || (request.action !== "inspect" && !request.original)) throw new Error("Pane action is missing its session source.");
|
|
63
|
+
const layout = paneLayout(json(await runner(["pane", "layout", "--pane", targetPane])), targetPane);
|
|
64
|
+
const direction = layout.width > layout.height ? "right" : "down";
|
|
65
|
+
const paneId = splitPaneId(json(await runner(["pane", "split", targetPane, "--direction", direction, "--no-focus"])));
|
|
66
|
+
try {
|
|
67
|
+
await runner(["pane", "run", paneId, commandFor(request)]);
|
|
68
|
+
return paneId;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
await runner(["pane", "close", paneId]).catch(() => undefined);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|