@fred-drake/anvil 0.0.1

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.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Herdr backend for Anvil's declarative subagent steps.
3
+ *
4
+ * Herdr panes are the terminal surfaces. The first subagent creates a right
5
+ * split beside the current Pi pane; later subagents open labelled tabs in the
6
+ * same workspace so the layout does not keep shrinking. Completion detection is
7
+ * shared with cmux via the `.exit` sidecar and terminal sentinel poller.
8
+ */
9
+ import { execFile } from "node:child_process";
10
+ import { mkdirSync, writeFileSync } from "node:fs";
11
+ import { dirname } from "node:path";
12
+ import { promisify } from "node:util";
13
+ import { shellEscape } from "../shell.ts";
14
+ import { pollForExitWithReadScreen } from "./exit.ts";
15
+ import type { SubagentExit } from "./exit.ts";
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ /** Workspace used for Anvil subagent tabs after the initial split. */
20
+ let subagentWorkspace: string | null = null;
21
+
22
+ export { shellEscape };
23
+
24
+ export function isHerdrAvailable(): boolean {
25
+ return process.env.HERDR_ENV === "1" && Boolean(process.env.HERDR_PANE_ID);
26
+ }
27
+
28
+ export function herdrUnavailableMessage(): string {
29
+ return 'herdr is not available. Start pi inside herdr to run workflows with `delegation: { subagent: "herdr" }`.';
30
+ }
31
+
32
+ async function runHerdr(args: string[]): Promise<string> {
33
+ const { stdout } = await execFileAsync(process.env.HERDR_BIN_PATH ?? "herdr", args, { encoding: "utf8" });
34
+ return stdout;
35
+ }
36
+
37
+ function parseJsonOutput(output: string, command: string): Record<string, unknown> {
38
+ try {
39
+ const parsed = JSON.parse(output);
40
+ if (parsed && typeof parsed === "object") return parsed as Record<string, unknown>;
41
+ } catch {}
42
+ throw new Error(`Unexpected herdr ${command} output: ${output.trim()}`);
43
+ }
44
+
45
+ function getPath(record: unknown, path: string[]): unknown {
46
+ let current = record;
47
+ for (const part of path) {
48
+ if (!current || typeof current !== "object") return undefined;
49
+ current = (current as Record<string, unknown>)[part];
50
+ }
51
+ return current;
52
+ }
53
+
54
+ function parseCreatedPane(output: string, command: string, candidatePaths: string[][]): string {
55
+ const parsed = parseJsonOutput(output, command);
56
+ for (const path of candidatePaths) {
57
+ const value = getPath(parsed, path);
58
+ if (typeof value === "string" && value.trim()) return value;
59
+ }
60
+ throw new Error(`Unexpected herdr ${command} output: missing pane_id in ${output.trim()}`);
61
+ }
62
+
63
+ function inferWorkspaceId(paneId: string | undefined): string | undefined {
64
+ if (!paneId) return undefined;
65
+ const match = paneId.match(/^(\d+)[-:]/);
66
+ return match?.[1];
67
+ }
68
+
69
+ async function createSplitSurface(name: string): Promise<string> {
70
+ const stdout = await runHerdr(["pane", "split", "--current", "--direction", "right", "--no-focus"]);
71
+ const paneId = parseCreatedPane(stdout, "pane split", [
72
+ ["result", "pane", "pane_id"],
73
+ ["result", "root_pane", "pane_id"],
74
+ ["pane", "pane_id"],
75
+ ["pane_id"],
76
+ ]);
77
+ await runHerdr(["pane", "rename", paneId, name]);
78
+ // Empty string means "ask Herdr to use the current workspace" when no stable id is available.
79
+ subagentWorkspace = process.env.HERDR_WORKSPACE_ID || inferWorkspaceId(process.env.HERDR_PANE_ID) || inferWorkspaceId(paneId) || "";
80
+ return paneId;
81
+ }
82
+
83
+ async function createTabSurface(name: string, workspace: string): Promise<string> {
84
+ const args = ["tab", "create"];
85
+ if (workspace) args.push("--workspace", workspace);
86
+ args.push("--label", name, "--no-focus");
87
+ const stdout = await runHerdr(args);
88
+ return parseCreatedPane(stdout, "tab create", [
89
+ ["result", "root_pane", "pane_id"],
90
+ ["result", "pane", "pane_id"],
91
+ ["root_pane", "pane_id"],
92
+ ["pane", "pane_id"],
93
+ ["pane_id"],
94
+ ]);
95
+ }
96
+
97
+ /**
98
+ * Create a Herdr pane for a subagent. The first call creates a right split;
99
+ * subsequent calls create labelled tabs in the same workspace.
100
+ */
101
+ export async function createSurface(name: string): Promise<string> {
102
+ if (subagentWorkspace !== null) return createTabSurface(name, subagentWorkspace);
103
+ return createSplitSurface(name);
104
+ }
105
+
106
+ /**
107
+ * Send a command by writing it to a script file and executing that script in
108
+ * the pane, so long commands survive terminal line-wrapping.
109
+ */
110
+ export async function sendLongCommand(surface: string, command: string, scriptPath: string): Promise<void> {
111
+ mkdirSync(dirname(scriptPath), { recursive: true });
112
+ writeFileSync(scriptPath, `#!/bin/bash\n${command}\n`, { mode: 0o600 });
113
+ await runHerdr(["pane", "run", surface, `bash ${shellEscape(scriptPath)}`]);
114
+ }
115
+
116
+ export async function readScreen(surface: string, lines = 5): Promise<string> {
117
+ return runHerdr(["pane", "read", surface, "--source", "recent-unwrapped", "--lines", String(lines)]);
118
+ }
119
+
120
+ export function pollForExit(
121
+ surface: string,
122
+ sessionFile: string,
123
+ signal?: AbortSignal,
124
+ intervalMs?: number,
125
+ timeoutMs?: number,
126
+ ): Promise<SubagentExit> {
127
+ const readForPoll = (pane: string, lines = 5) => readScreen(pane, Math.max(lines, 20));
128
+ return pollForExitWithReadScreen(readForPoll, surface, sessionFile, signal, intervalMs, timeoutMs);
129
+ }
130
+
131
+ export async function closeSurface(surface: string): Promise<void> {
132
+ await runHerdr(["pane", "close", surface]);
133
+ }
134
+
135
+ function resetState(): void {
136
+ subagentWorkspace = null;
137
+ }
138
+
139
+ export const __testing__ = { resetState };
140
+
141
+ // Keep the shared exit type visible from this backend for parity with cmux.
142
+ export type { SubagentExit };
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Launches a declaratively-delegated workflow step as a pi subagent in a
3
+ * terminal-multiplexer surface, waits for it to finish, and extracts the final
4
+ * assistant message as the step summary.
5
+ */
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import * as cmux from "./cmux.ts";
11
+ import * as herdr from "./herdr.ts";
12
+ import { shellEscape, SUBAGENT_SENTINEL_PREFIX, type SubagentExit } from "./cmux.ts";
13
+
14
+ export interface SubagentLaunch {
15
+ /** Display name for the subagent terminal surface. */
16
+ name: string;
17
+ /** Full task prompt for the subagent. */
18
+ task: string;
19
+ /** Working directory the subagent starts in. */
20
+ cwd: string;
21
+ runId: string;
22
+ stepId: string;
23
+ /** Pi model reference (provider/id) for the child session. */
24
+ model?: string;
25
+ thinkingLevel?: string;
26
+ timeoutMs?: number;
27
+ }
28
+
29
+ export interface SubagentResult {
30
+ summary: string;
31
+ sessionFile: string;
32
+ exitCode: number;
33
+ errorMessage?: string;
34
+ }
35
+
36
+ const CHILD_EXTENSION_PATH = join(dirname(fileURLToPath(import.meta.url)), "child.ts");
37
+
38
+ export function buildSubagentLaunchCommand(args: {
39
+ cwd: string;
40
+ sessionFile: string;
41
+ taskFile: string;
42
+ model?: string;
43
+ thinkingLevel?: string;
44
+ childExtensionPath?: string;
45
+ }): string {
46
+ const parts = [
47
+ "pi",
48
+ "--print",
49
+ "--session",
50
+ shellEscape(args.sessionFile),
51
+ "-e",
52
+ shellEscape(args.childExtensionPath ?? CHILD_EXTENSION_PATH),
53
+ ];
54
+ if (args.model) parts.push("--model", shellEscape(args.model));
55
+ if (args.thinkingLevel) parts.push("--thinking", shellEscape(args.thinkingLevel));
56
+ parts.push(shellEscape(`@${args.taskFile}`));
57
+
58
+ const envPrefix = `PI_ANVIL_SUBAGENT_SESSION=${shellEscape(args.sessionFile)} `;
59
+ return `cd ${shellEscape(args.cwd)} && ${envPrefix}${parts.join(" ")}; echo '${SUBAGENT_SENTINEL_PREFIX}'$?'__'`;
60
+ }
61
+
62
+ export function extractLastAssistantText(sessionFile: string): string | undefined {
63
+ if (!existsSync(sessionFile)) return undefined;
64
+ let last: string | undefined;
65
+ for (const line of readFileSync(sessionFile, "utf8").split("\n")) {
66
+ if (!line.trim()) continue;
67
+ let entry: { type?: string; message?: { role?: string; content?: unknown } };
68
+ try {
69
+ entry = JSON.parse(line);
70
+ } catch {
71
+ continue;
72
+ }
73
+ if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
74
+ const content = entry.message.content;
75
+ const text = Array.isArray(content)
76
+ ? content
77
+ .filter((block): block is { type: string; text: string } => block?.type === "text" && typeof block.text === "string")
78
+ .map((block) => block.text)
79
+ .join("\n")
80
+ .trim()
81
+ : "";
82
+ if (text) last = text;
83
+ }
84
+ return last;
85
+ }
86
+
87
+ interface SubagentBackendAdapter {
88
+ isAvailable(): boolean;
89
+ unavailableMessage(): string;
90
+ createSurface(name: string): Promise<string>;
91
+ sendLongCommand(surface: string, command: string, scriptPath: string): Promise<void>;
92
+ pollForExit(surface: string, sessionFile: string, signal?: AbortSignal, intervalMs?: number, timeoutMs?: number): Promise<SubagentExit>;
93
+ closeSurface(surface: string): Promise<void>;
94
+ }
95
+
96
+ /* v8 ignore start -- launches real multiplexer/pi child processes; command building and summary extraction are covered separately. */
97
+ export async function runCmuxSubagent(launch: SubagentLaunch, signal?: AbortSignal): Promise<SubagentResult> {
98
+ return runSubagentWithBackend(
99
+ launch,
100
+ {
101
+ isAvailable: cmux.isCmuxAvailable,
102
+ unavailableMessage: cmux.cmuxUnavailableMessage,
103
+ createSurface: cmux.createSurface,
104
+ sendLongCommand: cmux.sendLongCommand,
105
+ pollForExit: cmux.pollForExit,
106
+ closeSurface: cmux.closeSurface,
107
+ },
108
+ signal,
109
+ );
110
+ }
111
+
112
+ export async function runHerdrSubagent(launch: SubagentLaunch, signal?: AbortSignal): Promise<SubagentResult> {
113
+ return runSubagentWithBackend(
114
+ launch,
115
+ {
116
+ isAvailable: herdr.isHerdrAvailable,
117
+ unavailableMessage: herdr.herdrUnavailableMessage,
118
+ createSurface: herdr.createSurface,
119
+ sendLongCommand: herdr.sendLongCommand,
120
+ pollForExit: herdr.pollForExit,
121
+ closeSurface: herdr.closeSurface,
122
+ },
123
+ signal,
124
+ );
125
+ }
126
+
127
+ async function runSubagentWithBackend(
128
+ launch: SubagentLaunch,
129
+ backend: SubagentBackendAdapter,
130
+ signal?: AbortSignal,
131
+ ): Promise<SubagentResult> {
132
+ if (!backend.isAvailable()) throw new Error(backend.unavailableMessage());
133
+
134
+ const rootDir = join(tmpdir(), "anvil");
135
+ mkdirSync(rootDir, { recursive: true, mode: 0o700 });
136
+ chmodSync(rootDir, 0o700);
137
+ const workDir = join(rootDir, launch.runId);
138
+ mkdirSync(workDir, { recursive: true, mode: 0o700 });
139
+ chmodSync(workDir, 0o700);
140
+ const base = join(workDir, `${sanitizeForFilename(launch.stepId)}-${Date.now().toString(36)}`);
141
+ const sessionFile = `${base}.jsonl`;
142
+ const taskFile = `${base}.task.md`;
143
+ writeFileSync(taskFile, launch.task, "utf8");
144
+
145
+ const command = buildSubagentLaunchCommand({
146
+ cwd: launch.cwd,
147
+ sessionFile,
148
+ taskFile,
149
+ model: launch.model,
150
+ thinkingLevel: launch.thinkingLevel,
151
+ });
152
+
153
+ const surface = await backend.createSurface(launch.name);
154
+ try {
155
+ await backend.sendLongCommand(surface, command, `${base}.sh`);
156
+ const exit = await backend.pollForExit(surface, sessionFile, signal, undefined, launch.timeoutMs);
157
+ const summary =
158
+ extractLastAssistantText(sessionFile) ??
159
+ (exit.errorMessage
160
+ ? `Subagent error: ${exit.errorMessage}`
161
+ : exit.exitCode !== 0
162
+ ? `Subagent exited with code ${exit.exitCode}`
163
+ : "Subagent exited without output.");
164
+ return { summary, sessionFile, exitCode: exit.exitCode, errorMessage: exit.errorMessage };
165
+ } finally {
166
+ try {
167
+ await backend.closeSurface(surface);
168
+ } catch {
169
+ // Surface may already be gone.
170
+ }
171
+ }
172
+ }
173
+ /* v8 ignore stop */
174
+
175
+ /* v8 ignore next -- only used by ignored real-process launcher. */
176
+ function sanitizeForFilename(value: string): string {
177
+ return value.replace(/[^a-zA-Z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "step";
178
+ }
package/src/types.ts ADDED
@@ -0,0 +1,122 @@
1
+ export interface WorkflowContext {
2
+ /** Free-form text from `/anvil run <name> ...`. */
3
+ input: string;
4
+ step: { id: string; index: number };
5
+ /** "<checkId>-><stepId>" -> count. */
6
+ loopCounts: Record<string, number>;
7
+ cwd: string;
8
+ }
9
+
10
+ export type Templatable = string | ((ctx: WorkflowContext) => string | Promise<string>);
11
+
12
+ export type WorkflowThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
13
+
14
+ export type OnFailPolicy =
15
+ | "stop"
16
+ | "continue"
17
+ | {
18
+ goto: string;
19
+ /** Defaults to workflow.defaults.maxLoops ?? 3. */
20
+ maxLoops?: number;
21
+ /** Defaults to "stop". */
22
+ onExhausted?: "stop" | "continue";
23
+ /** Defaults to true. */
24
+ feedback?: boolean;
25
+ };
26
+
27
+ /** Terminal-multiplexer backend Anvil can spawn subagent sessions in. */
28
+ export type WorkflowSubagentBackend = "cmux" | "herdr";
29
+
30
+ export type WorkflowDelegation =
31
+ | "auto"
32
+ | "none"
33
+ | {
34
+ /** Pi skill name to prefer when delegating this workflow/step. */
35
+ skill: string;
36
+ }
37
+ | {
38
+ /** Anvil spawns the step itself in a dedicated subagent session on this backend. */
39
+ subagent: WorkflowSubagentBackend;
40
+ };
41
+
42
+ export interface DeterministicCheck {
43
+ type: "deterministic";
44
+ id?: string;
45
+ name?: string;
46
+ /** Executed with `bash -c`; exit code 0 means pass. */
47
+ command: Templatable;
48
+ cwd?: string;
49
+ /** Defaults to 300_000. */
50
+ timeoutMs?: number;
51
+ onFail?: OnFailPolicy;
52
+ }
53
+
54
+ export interface AgentCheck {
55
+ type: "agent";
56
+ id?: string;
57
+ name?: string;
58
+ /** Evaluation criteria; anvil wraps this with verdict instructions. */
59
+ prompt: Templatable;
60
+ /** Subagent to delegate evaluation to; omit for main-agent evaluation. */
61
+ agent?: string;
62
+ onFail?: OnFailPolicy;
63
+ }
64
+
65
+ export type Check = DeterministicCheck | AgentCheck;
66
+
67
+ export interface WorkflowModelSelection {
68
+ /** Model reference. Supports pi's provider/id and optional :<thinking> shorthand. */
69
+ model?: string;
70
+ /** Thinking level. Omitted values keep the workflow-start or base step default. */
71
+ thinkingLevel?: WorkflowThinkingLevel;
72
+ }
73
+
74
+ export interface WorkflowRetryModelSelection extends WorkflowModelSelection {
75
+ /** Retry count threshold where this selection begins applying; 0 is the first attempt. */
76
+ retry: number;
77
+ }
78
+
79
+ export interface WorkflowStep {
80
+ id: string;
81
+ title?: string;
82
+ prompt: Templatable;
83
+ /** Model reference for this step. Supports pi's provider/id and optional :<thinking> shorthand. */
84
+ model?: string;
85
+ /** Thinking level for this step. Omitted steps use the workflow-start default. */
86
+ thinkingLevel?: WorkflowThinkingLevel;
87
+ /** Retry-based model/thinking overrides. Highest retry <= current retry count wins. */
88
+ retryModelSelections?: WorkflowRetryModelSelection[];
89
+ /** Preferred per-step delegation mode; overrides workflow.defaults.delegation. */
90
+ delegation?: WorkflowDelegation;
91
+ /** Timeout for declarative subagent execution. Defaults to 1_800_000ms. */
92
+ subagentTimeoutMs?: number;
93
+ /** Legacy delegation hint. Prefer delegation: { skill: "..." } or delegation: "auto". */
94
+ agent?: string;
95
+ /** Main agent does the work itself. */
96
+ runInMain?: boolean;
97
+ skipIf?: (ctx: WorkflowContext) => boolean | Promise<boolean>;
98
+ checks?: Check[];
99
+ /** Default for this step's checks. */
100
+ onFail?: OnFailPolicy;
101
+ }
102
+
103
+ export interface WorkflowDefinition {
104
+ /** Must match /^[a-z0-9-]+$/. */
105
+ name: string;
106
+ description?: string;
107
+ defaults?: {
108
+ /** Preferred workflow-wide delegation mode; defaults to auto-detected delegation. */
109
+ delegation?: WorkflowDelegation;
110
+ /** Default timeout for declarative subagent execution. Defaults to 1_800_000ms. */
111
+ subagentTimeoutMs?: number;
112
+ /** Legacy delegation hint. Prefer delegation: { skill: "..." } or delegation: "auto". */
113
+ agent?: string;
114
+ onFail?: OnFailPolicy;
115
+ maxLoops?: number;
116
+ };
117
+ steps: WorkflowStep[];
118
+ }
119
+
120
+ export function defineWorkflow(def: WorkflowDefinition): WorkflowDefinition {
121
+ return def;
122
+ }
package/src/ui.ts ADDED
@@ -0,0 +1,94 @@
1
+ import type { RunSummary, StepRunState } from "./engine.ts";
2
+
3
+ export interface StatusInfo {
4
+ workflowName: string;
5
+ stepIndex?: number;
6
+ stepTotal?: number;
7
+ stepTitle?: string;
8
+ phase: "starting" | "step" | "check" | "loop" | "done" | "failed" | "aborted";
9
+ checkIndex?: number;
10
+ checkTotal?: number;
11
+ checkName?: string;
12
+ }
13
+
14
+ export function formatStatus(info: StatusInfo): string | undefined {
15
+ if (info.phase === "done") return `anvil: ${info.workflowName} done`;
16
+ if (info.phase === "failed") return `anvil: ${info.workflowName} failed`;
17
+ if (info.phase === "aborted") return undefined;
18
+ if (info.phase === "starting") return `anvil: ${info.workflowName} starting`;
19
+
20
+ const step =
21
+ info.stepIndex !== undefined && info.stepTotal !== undefined
22
+ ? `${info.stepIndex + 1}/${info.stepTotal}`
23
+ : "?/?";
24
+ const title = info.stepTitle ?? "step";
25
+ if (info.phase === "check") {
26
+ const check =
27
+ info.checkIndex !== undefined && info.checkTotal !== undefined
28
+ ? ` — check ${info.checkIndex + 1}/${info.checkTotal}`
29
+ : " — check";
30
+ return `anvil: ${step} ${title}${check}${info.checkName ? ` (${info.checkName})` : ""}`;
31
+ }
32
+ if (info.phase === "loop") return `anvil: ${step} ${title} — retrying`;
33
+ return `anvil: ${step} ${title}`;
34
+ }
35
+
36
+ export function formatStepWidget(steps: StepRunState[], currentStepId?: string): string[] | undefined {
37
+ if (steps.length === 0) return undefined;
38
+ return steps.map((step) => {
39
+ const icon = iconForStep(step, currentStepId);
40
+ const loopSuffix = step.loops > 0 ? ` ↻(${step.loops})` : "";
41
+ const title = step.title ? ` — ${step.title}` : "";
42
+ const checkSummary = step.checks.length > 0 ? ` [${step.checks.filter((c) => c.pass).length}/${step.checks.length} checks]` : "";
43
+ return `${icon} ${step.id}${title}${loopSuffix}${checkSummary}`;
44
+ });
45
+ }
46
+
47
+ export function renderSummaryMarkdown(summary: RunSummary): string {
48
+ const stateIcon = summary.state === "succeeded" ? "✅" : summary.state === "failed" ? "❌" : "⏹";
49
+ const lines = [
50
+ `${stateIcon} **Anvil workflow \`${summary.workflowName}\` ${summary.state}**`,
51
+ "",
52
+ `Run ID: \`${summary.runId}\``,
53
+ "",
54
+ "| Step | Status | Checks |",
55
+ "| --- | --- | --- |",
56
+ ];
57
+
58
+ for (const step of summary.steps) {
59
+ const checks = step.checks.length === 0
60
+ ? "—"
61
+ : step.checks
62
+ .map((check) => `${check.pass ? "✔" : "✖"} ${escapePipes(check.name)}${check.pass ? "" : ` — ${escapePipes(check.reason)}`}`)
63
+ .join("<br>");
64
+ lines.push(`| \`${escapePipes(step.id)}\` | ${step.status}${step.loops ? ` ↻ ${step.loops}` : ""} | ${checks} |`);
65
+ }
66
+
67
+ if (summary.failureReason) {
68
+ lines.push("", `Failure: ${summary.failureReason}`);
69
+ }
70
+
71
+ return lines.join("\n");
72
+ }
73
+
74
+ function iconForStep(step: StepRunState, currentStepId?: string): string {
75
+ if (step.id === currentStepId && step.status === "running") return "▶";
76
+ switch (step.status) {
77
+ case "passed":
78
+ return "✔";
79
+ case "failed":
80
+ return "✖";
81
+ case "skipped":
82
+ return "↷";
83
+ case "continued":
84
+ return "⚠";
85
+ case "running":
86
+ return "▶";
87
+ default:
88
+ return "○";
89
+ }
90
+ }
91
+
92
+ function escapePipes(text: string): string {
93
+ return text.replaceAll("|", "\\|").replaceAll("\n", " ");
94
+ }