@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.
- package/README.md +72 -0
- package/assets/anvil-logo.png +0 -0
- package/assets/anvil-logo.txt +21 -0
- package/extensions/anvil/index.ts +1 -0
- package/package.json +58 -0
- package/skills/anvil-workflow-builder/SKILL.md +109 -0
- package/src/discovery.ts +149 -0
- package/src/engine.ts +591 -0
- package/src/errors.ts +18 -0
- package/src/gates.ts +198 -0
- package/src/index.ts +700 -0
- package/src/paths.ts +35 -0
- package/src/prompts.ts +253 -0
- package/src/shell.ts +3 -0
- package/src/subagent/child.ts +61 -0
- package/src/subagent/cmux.ts +227 -0
- package/src/subagent/exit.ts +113 -0
- package/src/subagent/herdr.ts +142 -0
- package/src/subagent/runner.ts +178 -0
- package/src/types.ts +122 -0
- package/src/ui.ts +94 -0
- package/src/validate.ts +340 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { Type, type Api, type Model } from "@earendil-works/pi-ai";
|
|
4
|
+
import { defineTool, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { discoverWorkflows, type DiscoveredWorkflow } from "./discovery.ts";
|
|
6
|
+
import { type AnvilCheckpoint, type EngineHost, runWorkflow, type StepModelSelection } from "./engine.ts";
|
|
7
|
+
import { AnvilAbortError } from "./errors.ts";
|
|
8
|
+
import { VerdictBus } from "./gates.ts";
|
|
9
|
+
import { buildSubagentResultMessage, workflowSubagentBackends } from "./prompts.ts";
|
|
10
|
+
import { cmuxUnavailableMessage, isCmuxAvailable } from "./subagent/cmux.ts";
|
|
11
|
+
import { herdrUnavailableMessage, isHerdrAvailable } from "./subagent/herdr.ts";
|
|
12
|
+
import { runCmuxSubagent, runHerdrSubagent } from "./subagent/runner.ts";
|
|
13
|
+
import type { WorkflowDefinition, WorkflowSubagentBackend } from "./types.ts";
|
|
14
|
+
import { renderSummaryMarkdown } from "./ui.ts";
|
|
15
|
+
|
|
16
|
+
const baseDir = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const builderSkillPath = join(baseDir, "..", "skills", "anvil-workflow-builder", "SKILL.md");
|
|
18
|
+
|
|
19
|
+
type ActiveRun = {
|
|
20
|
+
controller: AbortController;
|
|
21
|
+
runId: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type ResumableRun = {
|
|
25
|
+
runId: string;
|
|
26
|
+
workflowName: string;
|
|
27
|
+
input: string;
|
|
28
|
+
finalState: "failed" | "aborted";
|
|
29
|
+
timestamp: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type AutocompleteItem = {
|
|
33
|
+
value: string;
|
|
34
|
+
label: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type AutocompleteSuggestions = {
|
|
39
|
+
items: AutocompleteItem[];
|
|
40
|
+
prefix: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type AutocompleteProvider = {
|
|
44
|
+
triggerCharacters?: string[];
|
|
45
|
+
getSuggestions(
|
|
46
|
+
lines: string[],
|
|
47
|
+
cursorLine: number,
|
|
48
|
+
cursorCol: number,
|
|
49
|
+
options: { signal: AbortSignal; force?: boolean },
|
|
50
|
+
): Promise<AutocompleteSuggestions | null>;
|
|
51
|
+
applyCompletion(
|
|
52
|
+
lines: string[],
|
|
53
|
+
cursorLine: number,
|
|
54
|
+
cursorCol: number,
|
|
55
|
+
item: AutocompleteItem,
|
|
56
|
+
prefix: string,
|
|
57
|
+
): { lines: string[]; cursorLine: number; cursorCol: number };
|
|
58
|
+
shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type TurnWaiter = {
|
|
62
|
+
started: boolean;
|
|
63
|
+
resolve: () => void;
|
|
64
|
+
reject: (error: Error) => void;
|
|
65
|
+
timer: NodeJS.Timeout;
|
|
66
|
+
signal?: AbortSignal;
|
|
67
|
+
onAbort?: () => void;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const verdictBus = new VerdictBus();
|
|
71
|
+
const turnWaiters = new Set<TurnWaiter>();
|
|
72
|
+
|
|
73
|
+
const anvilVerdictTool = defineTool({
|
|
74
|
+
name: "anvil_verdict",
|
|
75
|
+
label: "Anvil Verdict",
|
|
76
|
+
description: "Report the pass/fail verdict for an active anvil agent check.",
|
|
77
|
+
parameters: Type.Object({
|
|
78
|
+
check_id: Type.String({ description: "The exact check_id provided by Anvil." }),
|
|
79
|
+
pass: Type.Boolean({ description: "Whether the check passed." }),
|
|
80
|
+
reason: Type.String({ description: "Concise reason for the verdict." }),
|
|
81
|
+
}),
|
|
82
|
+
|
|
83
|
+
async execute(_toolCallId, params) {
|
|
84
|
+
const matched = verdictBus.reportVerdict(params.check_id, params.pass, params.reason);
|
|
85
|
+
return {
|
|
86
|
+
content: [
|
|
87
|
+
{
|
|
88
|
+
type: "text" as const,
|
|
89
|
+
text: matched
|
|
90
|
+
? `Anvil verdict recorded for ${params.check_id}.`
|
|
91
|
+
: `No active Anvil check is waiting for ${params.check_id}; the verdict was ignored.`,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
details: { matched, check_id: params.check_id, pass: params.pass, reason: params.reason },
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/* c8 ignore start -- Pi extension registration and command/UI wiring are exercised through integration-style tests; pure internals remain covered directly. */
|
|
100
|
+
/* v8 ignore start -- Pi extension registration and command/UI wiring are exercised through integration-style tests; pure internals remain covered directly. */
|
|
101
|
+
export default function piAnvil(pi: ExtensionAPI) {
|
|
102
|
+
let activeRun: ActiveRun | undefined;
|
|
103
|
+
|
|
104
|
+
pi.registerTool(anvilVerdictTool);
|
|
105
|
+
|
|
106
|
+
pi.registerMessageRenderer("anvil-summary", () => undefined);
|
|
107
|
+
|
|
108
|
+
pi.on("resources_discover", () => ({ skillPaths: [builderSkillPath] }));
|
|
109
|
+
|
|
110
|
+
pi.on("session_start", (_event, ctx) => {
|
|
111
|
+
ctx.ui.addAutocompleteProvider((current) => createAnvilAutocompleteProvider(current, ctx.cwd));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
pi.on("agent_start", () => {
|
|
115
|
+
for (const waiter of turnWaiters) waiter.started = true;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.on("agent_end", () => {
|
|
119
|
+
for (const waiter of [...turnWaiters]) resolveTurnWaiter(waiter);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
123
|
+
activeRun?.controller.abort();
|
|
124
|
+
activeRun = undefined;
|
|
125
|
+
verdictBus.clear();
|
|
126
|
+
ctx.ui.setStatus("anvil", undefined);
|
|
127
|
+
ctx.ui.setWidget("anvil-steps", undefined);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
pi.registerCommand("anvil", {
|
|
131
|
+
description: "Run and manage declarative Anvil workflows.",
|
|
132
|
+
getArgumentCompletions: async (argumentPrefix) => getAnvilCompletions(argumentPrefix, process.cwd()),
|
|
133
|
+
handler: async (args, ctx) => {
|
|
134
|
+
const { subcommand, rest } = parseAnvilArgs(args);
|
|
135
|
+
try {
|
|
136
|
+
switch (subcommand) {
|
|
137
|
+
case "list":
|
|
138
|
+
await handleList(pi, ctx);
|
|
139
|
+
return;
|
|
140
|
+
case "validate":
|
|
141
|
+
await handleValidate(pi, ctx, rest);
|
|
142
|
+
return;
|
|
143
|
+
case "abort":
|
|
144
|
+
if (!activeRun) {
|
|
145
|
+
ctx.ui.notify("No Anvil workflow is running.", "info");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
activeRun.controller.abort();
|
|
149
|
+
if (!ctx.isIdle()) ctx.abort();
|
|
150
|
+
ctx.ui.notify(`Aborting Anvil run ${activeRun.runId}.`, "warning");
|
|
151
|
+
return;
|
|
152
|
+
case "run":
|
|
153
|
+
await handleRun(pi, ctx, rest, () => activeRun, (run) => {
|
|
154
|
+
activeRun = run;
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
case "resume":
|
|
158
|
+
await handleResume(pi, ctx, rest, () => activeRun, (run) => {
|
|
159
|
+
activeRun = run;
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
default:
|
|
163
|
+
ctx.ui.notify("Usage: /anvil <run|list|validate|abort|resume> ...", "warning");
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
async function handleRun(
|
|
172
|
+
piApi: ExtensionAPI,
|
|
173
|
+
ctx: ExtensionCommandContext,
|
|
174
|
+
rest: string,
|
|
175
|
+
getActiveRun: () => ActiveRun | undefined,
|
|
176
|
+
setActiveRun: (run: ActiveRun | undefined) => void,
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
if (getActiveRun()) {
|
|
179
|
+
ctx.ui.notify("An Anvil workflow is already running in this session.", "error");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { name, input } = parseRunArgs(rest);
|
|
184
|
+
if (!name) {
|
|
185
|
+
ctx.ui.notify("Usage: /anvil run <workflow-name> <task input>", "warning");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const controller = new AbortController();
|
|
190
|
+
const runId = newRunId();
|
|
191
|
+
setActiveRun({ controller, runId });
|
|
192
|
+
let launched = false;
|
|
193
|
+
try {
|
|
194
|
+
const workflow = await findWorkflow(ctx.cwd, name);
|
|
195
|
+
if (!workflow) {
|
|
196
|
+
ctx.ui.notify(`Workflow "${name}" was not found.`, "error");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (workflow.errors?.length || !workflow.workflow) {
|
|
200
|
+
postCommandMessage(piApi, "anvil-validate", formatWorkflowErrors(workflow));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const unavailableBackend = workflowSubagentBackends(workflow.workflow).find((backend) => !isSubagentBackendAvailable(backend));
|
|
205
|
+
if (unavailableBackend) {
|
|
206
|
+
ctx.ui.notify(
|
|
207
|
+
`Workflow "${workflow.workflow.name}" declares ${unavailableBackend} subagent delegation. ${subagentUnavailableMessage(unavailableBackend)}`,
|
|
208
|
+
"error",
|
|
209
|
+
);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!ctx.isIdle()) await ctx.waitForIdle();
|
|
214
|
+
if (controller.signal.aborted) return;
|
|
215
|
+
|
|
216
|
+
const host = createEngineHost(piApi, ctx, controller);
|
|
217
|
+
launched = true;
|
|
218
|
+
ctx.ui.notify(`Started Anvil workflow "${workflow.workflow.name}" (${runId}).`, "info");
|
|
219
|
+
|
|
220
|
+
void runWorkflow({
|
|
221
|
+
workflow: workflow.workflow,
|
|
222
|
+
input,
|
|
223
|
+
cwd: ctx.cwd,
|
|
224
|
+
host,
|
|
225
|
+
runId,
|
|
226
|
+
signal: controller.signal,
|
|
227
|
+
})
|
|
228
|
+
.catch((error) => {
|
|
229
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
230
|
+
})
|
|
231
|
+
.finally(() => {
|
|
232
|
+
if (getActiveRun()?.runId === runId) setActiveRun(undefined);
|
|
233
|
+
});
|
|
234
|
+
} finally {
|
|
235
|
+
if (!launched && getActiveRun()?.runId === runId) setActiveRun(undefined);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function handleResume(
|
|
240
|
+
piApi: ExtensionAPI,
|
|
241
|
+
ctx: ExtensionCommandContext,
|
|
242
|
+
rest: string,
|
|
243
|
+
getActiveRun: () => ActiveRun | undefined,
|
|
244
|
+
setActiveRun: (run: ActiveRun | undefined) => void,
|
|
245
|
+
): Promise<void> {
|
|
246
|
+
if (getActiveRun()) {
|
|
247
|
+
ctx.ui.notify("An Anvil workflow is already running in this session.", "error");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const previousRun = findLatestResumableRun(getSessionEntries(ctx));
|
|
252
|
+
if (!previousRun) {
|
|
253
|
+
ctx.ui.notify("No failed or aborted Anvil run was found to resume in this session.", "warning");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const parsed = parseResumeArgs(rest);
|
|
258
|
+
if (parsed.error || parsed.stepNumber === undefined) {
|
|
259
|
+
const workflow = await findWorkflow(ctx.cwd, previousRun.workflowName);
|
|
260
|
+
if (!workflow) {
|
|
261
|
+
ctx.ui.notify(`Workflow "${previousRun.workflowName}" was not found.`, "error");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (workflow.errors?.length || !workflow.workflow) {
|
|
265
|
+
postCommandMessage(piApi, "anvil-validate", formatWorkflowErrors(workflow));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (parsed.error) ctx.ui.notify(parsed.error, "error");
|
|
269
|
+
postCommandMessage(piApi, "anvil-resume", formatResumeStepMap(previousRun, workflow.workflow));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const controller = new AbortController();
|
|
274
|
+
const runId = newRunId();
|
|
275
|
+
setActiveRun({ controller, runId });
|
|
276
|
+
let launched = false;
|
|
277
|
+
try {
|
|
278
|
+
const workflow = await findWorkflow(ctx.cwd, previousRun.workflowName);
|
|
279
|
+
if (!workflow) {
|
|
280
|
+
ctx.ui.notify(`Workflow "${previousRun.workflowName}" was not found.`, "error");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (workflow.errors?.length || !workflow.workflow) {
|
|
284
|
+
postCommandMessage(piApi, "anvil-validate", formatWorkflowErrors(workflow));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (parsed.stepNumber < 1 || parsed.stepNumber > workflow.workflow.steps.length) {
|
|
288
|
+
ctx.ui.notify(`Resume step ${parsed.stepNumber} is out of range for workflow "${workflow.workflow.name}".`, "error");
|
|
289
|
+
postCommandMessage(piApi, "anvil-resume", formatResumeStepMap(previousRun, workflow.workflow));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const unavailableBackend = workflowSubagentBackends(workflow.workflow).find((backend) => !isSubagentBackendAvailable(backend));
|
|
294
|
+
if (unavailableBackend) {
|
|
295
|
+
ctx.ui.notify(
|
|
296
|
+
`Workflow "${workflow.workflow.name}" declares ${unavailableBackend} subagent delegation. ${subagentUnavailableMessage(unavailableBackend)}`,
|
|
297
|
+
"error",
|
|
298
|
+
);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!ctx.isIdle()) await ctx.waitForIdle();
|
|
303
|
+
if (controller.signal.aborted) return;
|
|
304
|
+
|
|
305
|
+
const host = createEngineHost(piApi, ctx, controller);
|
|
306
|
+
launched = true;
|
|
307
|
+
ctx.ui.notify(`Resumed Anvil workflow "${workflow.workflow.name}" from step ${parsed.stepNumber} (${runId}).`, "info");
|
|
308
|
+
|
|
309
|
+
void runWorkflow({
|
|
310
|
+
workflow: workflow.workflow,
|
|
311
|
+
input: previousRun.input,
|
|
312
|
+
cwd: ctx.cwd,
|
|
313
|
+
host,
|
|
314
|
+
runId,
|
|
315
|
+
resume: { stepNumber: parsed.stepNumber, retryCount: parsed.retryCount },
|
|
316
|
+
signal: controller.signal,
|
|
317
|
+
})
|
|
318
|
+
.catch((error) => {
|
|
319
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
320
|
+
})
|
|
321
|
+
.finally(() => {
|
|
322
|
+
if (getActiveRun()?.runId === runId) setActiveRun(undefined);
|
|
323
|
+
});
|
|
324
|
+
} finally {
|
|
325
|
+
if (!launched && getActiveRun()?.runId === runId) setActiveRun(undefined);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/* v8 ignore stop */
|
|
330
|
+
/* c8 ignore stop */
|
|
331
|
+
|
|
332
|
+
function createEngineHost(pi: ExtensionAPI, ctx: ExtensionCommandContext, controller: AbortController): EngineHost {
|
|
333
|
+
let pendingTurn: Promise<void> | undefined;
|
|
334
|
+
const defaultModel = ctx.model;
|
|
335
|
+
const defaultThinkingLevel = pi.getThinkingLevel();
|
|
336
|
+
|
|
337
|
+
async function applyModelAndThinking(
|
|
338
|
+
model: Model<Api> | undefined,
|
|
339
|
+
thinkingLevel: ReturnType<ExtensionAPI["getThinkingLevel"]>,
|
|
340
|
+
): Promise<void> {
|
|
341
|
+
if (model) {
|
|
342
|
+
const changed = await pi.setModel(model);
|
|
343
|
+
if (!changed) throw new Error(`Unable to switch to model "${model.provider}/${model.id}"; no API key is available.`);
|
|
344
|
+
}
|
|
345
|
+
pi.setThinkingLevel(thinkingLevel);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
async applyStepModelSelection(selection) {
|
|
350
|
+
if (!selection) {
|
|
351
|
+
await applyModelAndThinking(defaultModel, defaultThinkingLevel);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const model = selection.model ? resolveModelReference(selection.model, ctx.modelRegistry.getAll()) : defaultModel;
|
|
356
|
+
await applyModelAndThinking(
|
|
357
|
+
model,
|
|
358
|
+
(selection.thinkingLevel ?? defaultThinkingLevel) as ReturnType<ExtensionAPI["getThinkingLevel"]>,
|
|
359
|
+
);
|
|
360
|
+
},
|
|
361
|
+
async runSubagent(request, signal) {
|
|
362
|
+
const launch = {
|
|
363
|
+
name: `Anvil: ${request.stepTitle}`,
|
|
364
|
+
task: request.task,
|
|
365
|
+
cwd: request.cwd,
|
|
366
|
+
runId: request.runId,
|
|
367
|
+
stepId: request.stepId,
|
|
368
|
+
model: request.model,
|
|
369
|
+
thinkingLevel: request.thinkingLevel,
|
|
370
|
+
timeoutMs: request.timeoutMs,
|
|
371
|
+
};
|
|
372
|
+
const result =
|
|
373
|
+
request.backend === "herdr" ? await runHerdrSubagent(launch, signal) : await runCmuxSubagent(launch, signal);
|
|
374
|
+
// Inject the outcome into the main session's context (no extra turn)
|
|
375
|
+
// so agent checks and later steps know what the subagent did.
|
|
376
|
+
pi.sendMessage(
|
|
377
|
+
{
|
|
378
|
+
customType: "anvil-subagent-result",
|
|
379
|
+
content: buildSubagentResultMessage({
|
|
380
|
+
workflowName: request.workflowName,
|
|
381
|
+
stepTitle: request.stepTitle,
|
|
382
|
+
stepIndex: request.stepIndex,
|
|
383
|
+
stepCount: request.stepCount,
|
|
384
|
+
backend: request.backend,
|
|
385
|
+
summary: result.summary,
|
|
386
|
+
sessionFile: result.sessionFile,
|
|
387
|
+
}),
|
|
388
|
+
display: true,
|
|
389
|
+
details: { ...result, stepId: request.stepId, runId: request.runId },
|
|
390
|
+
},
|
|
391
|
+
{ triggerTurn: false },
|
|
392
|
+
);
|
|
393
|
+
return result;
|
|
394
|
+
},
|
|
395
|
+
sendInstruction(instruction) {
|
|
396
|
+
pendingTurn = waitForTurnCompletion(ctx, controller.signal);
|
|
397
|
+
pi.sendUserMessage(instruction);
|
|
398
|
+
},
|
|
399
|
+
async waitForTurnComplete(signal) {
|
|
400
|
+
const wait = pendingTurn ?? waitForTurnCompletion(ctx, signal);
|
|
401
|
+
pendingTurn = undefined;
|
|
402
|
+
await wait;
|
|
403
|
+
},
|
|
404
|
+
exec(command, args, options) {
|
|
405
|
+
return pi.exec(command, args, {
|
|
406
|
+
cwd: options?.cwd,
|
|
407
|
+
timeout: options?.timeout,
|
|
408
|
+
signal: options?.signal,
|
|
409
|
+
});
|
|
410
|
+
},
|
|
411
|
+
awaitVerdict(checkId, timeoutMs, signal) {
|
|
412
|
+
return verdictBus.awaitVerdict(checkId, timeoutMs, signal);
|
|
413
|
+
},
|
|
414
|
+
checkpoint(entry) {
|
|
415
|
+
pi.appendEntry("anvil-run", entry);
|
|
416
|
+
},
|
|
417
|
+
notify(message, type) {
|
|
418
|
+
ctx.ui.notify(message, type);
|
|
419
|
+
},
|
|
420
|
+
setStatus(text) {
|
|
421
|
+
ctx.ui.setStatus("anvil", text);
|
|
422
|
+
},
|
|
423
|
+
setWidget(lines) {
|
|
424
|
+
ctx.ui.setWidget("anvil-steps", lines);
|
|
425
|
+
},
|
|
426
|
+
postSummary(summary) {
|
|
427
|
+
pi.sendMessage(
|
|
428
|
+
{
|
|
429
|
+
customType: "anvil-summary",
|
|
430
|
+
content: renderSummaryMarkdown(summary),
|
|
431
|
+
display: true,
|
|
432
|
+
details: summary,
|
|
433
|
+
},
|
|
434
|
+
{ triggerTurn: false },
|
|
435
|
+
);
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function isSubagentBackendAvailable(backend: WorkflowSubagentBackend): boolean {
|
|
441
|
+
return backend === "herdr" ? isHerdrAvailable() : isCmuxAvailable();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function subagentUnavailableMessage(backend: WorkflowSubagentBackend): string {
|
|
445
|
+
return backend === "herdr" ? herdrUnavailableMessage() : cmuxUnavailableMessage();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function handleList(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
|
449
|
+
const workflows = await discoverWorkflows({ cwd: ctx.cwd, useCache: false });
|
|
450
|
+
if (workflows.length === 0) {
|
|
451
|
+
ctx.ui.notify("No Anvil workflows found in ~/.pi/agent/anvil/workflows or .pi/anvil/workflows.", "info");
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
postCommandMessage(pi, "anvil-list", formatWorkflowList(workflows));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function handleValidate(pi: ExtensionAPI, ctx: ExtensionCommandContext, rest: string): Promise<void> {
|
|
458
|
+
const name = rest.trim();
|
|
459
|
+
if (!name) {
|
|
460
|
+
ctx.ui.notify("Usage: /anvil validate <workflow-name>", "warning");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const workflow = await findWorkflow(ctx.cwd, name);
|
|
464
|
+
if (!workflow) {
|
|
465
|
+
ctx.ui.notify(`Workflow "${name}" was not found.`, "error");
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
postCommandMessage(
|
|
469
|
+
pi,
|
|
470
|
+
"anvil-validate",
|
|
471
|
+
workflow.errors?.length ? formatWorkflowErrors(workflow) : `✅ Workflow \`${workflow.name}\` is valid.\n\n${workflow.file}`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function findWorkflow(cwd: string, name: string): Promise<DiscoveredWorkflow | undefined> {
|
|
476
|
+
const workflows = await discoverWorkflows({ cwd, useCache: false });
|
|
477
|
+
return workflows.find((workflow) => workflow.name === name);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function postCommandMessage(pi: ExtensionAPI, customType: string, content: string): void {
|
|
481
|
+
pi.sendMessage({ customType, content, display: true }, { triggerTurn: false });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function resolveModelReference(reference: string, models: Model<Api>[]): Model<Api> {
|
|
485
|
+
const providerSeparator = reference.indexOf("/");
|
|
486
|
+
if (providerSeparator > 0) {
|
|
487
|
+
const provider = reference.slice(0, providerSeparator);
|
|
488
|
+
const modelId = reference.slice(providerSeparator + 1);
|
|
489
|
+
const model = models.find((candidate) => candidate.provider === provider && candidate.id === modelId);
|
|
490
|
+
if (model) return model;
|
|
491
|
+
throw new Error(`model "${reference}" was not found`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const exactMatches = models.filter((model) => model.id === reference);
|
|
495
|
+
if (exactMatches.length === 1) return exactMatches[0]!;
|
|
496
|
+
if (exactMatches.length > 1) {
|
|
497
|
+
throw new Error(`model "${reference}" is ambiguous; use provider/model syntax`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
throw new Error(`model "${reference}" was not found`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function createAnvilAutocompleteProvider(current: AutocompleteProvider, cwd: string): AutocompleteProvider {
|
|
504
|
+
return {
|
|
505
|
+
triggerCharacters: current.triggerCharacters,
|
|
506
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
507
|
+
const currentLine = lines[cursorLine] ?? "";
|
|
508
|
+
const beforeCursor = currentLine.slice(0, cursorCol);
|
|
509
|
+
const anvilArgs = extractAnvilArgumentText(beforeCursor);
|
|
510
|
+
if (anvilArgs === undefined) return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
511
|
+
|
|
512
|
+
const items = await getAnvilCompletions(anvilArgs, cwd);
|
|
513
|
+
if (items === null) return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
514
|
+
return { items, prefix: anvilArgs };
|
|
515
|
+
},
|
|
516
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
517
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
518
|
+
},
|
|
519
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
520
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function extractAnvilArgumentText(textBeforeCursor: string): string | undefined {
|
|
526
|
+
return /^\/anvil(?::\d+)?\s+([\s\S]*)$/.exec(textBeforeCursor)?.[1];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export async function getAnvilCompletions(argumentPrefix: string, cwd: string) {
|
|
530
|
+
const subcommands = ["run", "list", "validate", "abort", "resume"];
|
|
531
|
+
const trimmedStart = argumentPrefix.trimStart();
|
|
532
|
+
const parts = trimmedStart.split(/\s+/);
|
|
533
|
+
if (parts.length <= 1 && !trimmedStart.endsWith(" ")) {
|
|
534
|
+
return subcommands
|
|
535
|
+
.filter((cmd) => cmd.startsWith(parts[0] ?? ""))
|
|
536
|
+
.map((label) => ({ value: label, label }));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const subcommand = parts[0];
|
|
540
|
+
if ((subcommand === "run" || subcommand === "validate") && parts.length <= 2) {
|
|
541
|
+
const prefix = parts[1] ?? "";
|
|
542
|
+
const workflows = await discoverWorkflows({ cwd });
|
|
543
|
+
return workflows
|
|
544
|
+
.filter((workflow) => workflow.name.startsWith(prefix))
|
|
545
|
+
.map((workflow) => ({
|
|
546
|
+
value: `${subcommand} ${workflow.name}`,
|
|
547
|
+
label: workflow.name,
|
|
548
|
+
description: workflow.errors?.length ? "invalid" : workflow.source,
|
|
549
|
+
}));
|
|
550
|
+
}
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function parseAnvilArgs(args: string): { subcommand: string; rest: string } {
|
|
555
|
+
const trimmed = args.trim();
|
|
556
|
+
if (!trimmed) return { subcommand: "list", rest: "" };
|
|
557
|
+
const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed);
|
|
558
|
+
return { subcommand: match?.[1] ?? "list", rest: match?.[2] ?? "" };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function parseRunArgs(rest: string): { name: string; input: string } {
|
|
562
|
+
const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(rest.trim());
|
|
563
|
+
return { name: match?.[1] ?? "", input: match?.[2] ?? "" };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function parseResumeArgs(rest: string): { stepNumber?: number; retryCount?: number; error?: string } {
|
|
567
|
+
const parts = rest.trim().split(/\s+/).filter(Boolean);
|
|
568
|
+
if (parts.length === 0) return {};
|
|
569
|
+
if (parts.length > 2) return { error: "Usage: /anvil resume <step> [retry-number]" };
|
|
570
|
+
if (!/^\d+$/.test(parts[0]!)) return { error: "Resume step must be a positive integer." };
|
|
571
|
+
const stepNumber = Number(parts[0]);
|
|
572
|
+
if (stepNumber < 1) return { error: "Resume step must be a positive integer." };
|
|
573
|
+
if (parts[1] === undefined) return { stepNumber };
|
|
574
|
+
if (!/^\d+$/.test(parts[1]!)) return { error: "Resume retry-number must be a non-negative integer." };
|
|
575
|
+
return { stepNumber, retryCount: Number(parts[1]) };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function getSessionEntries(ctx: ExtensionCommandContext): unknown[] {
|
|
579
|
+
const sessionManager = (ctx as ExtensionCommandContext & { sessionManager?: { getEntries?: () => unknown[] } }).sessionManager;
|
|
580
|
+
const entries = sessionManager?.getEntries?.();
|
|
581
|
+
return Array.isArray(entries) ? entries : [];
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function findLatestResumableRun(entries: unknown[]): ResumableRun | undefined {
|
|
585
|
+
let latest: ResumableRun | undefined;
|
|
586
|
+
for (const entry of entries) {
|
|
587
|
+
const checkpoint = toAnvilCheckpoint(entry);
|
|
588
|
+
if (!checkpoint || checkpoint.phase !== "run_end") continue;
|
|
589
|
+
if (checkpoint.finalState !== "aborted" && checkpoint.finalState !== "failed") continue;
|
|
590
|
+
if (!checkpoint.runId || !checkpoint.workflowName || checkpoint.input === undefined) continue;
|
|
591
|
+
latest = {
|
|
592
|
+
runId: checkpoint.runId,
|
|
593
|
+
workflowName: checkpoint.workflowName,
|
|
594
|
+
input: checkpoint.input,
|
|
595
|
+
finalState: checkpoint.finalState,
|
|
596
|
+
timestamp: checkpoint.timestamp ?? "",
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
return latest;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function toAnvilCheckpoint(entry: unknown): Partial<AnvilCheckpoint> | undefined {
|
|
603
|
+
if (!entry || typeof entry !== "object") return undefined;
|
|
604
|
+
const record = entry as Record<string, unknown>;
|
|
605
|
+
if (record.customType !== "anvil-run") return undefined;
|
|
606
|
+
const data = record.data ?? record.details ?? record;
|
|
607
|
+
return data && typeof data === "object" ? (data as Partial<AnvilCheckpoint>) : undefined;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function formatResumeStepMap(run: ResumableRun, workflow: WorkflowDefinition): string {
|
|
611
|
+
const lines = [
|
|
612
|
+
`# Resume Anvil workflow \`${workflow.name}\``,
|
|
613
|
+
"",
|
|
614
|
+
`Latest resumable run: \`${run.runId}\` (${run.finalState})`,
|
|
615
|
+
`Task input: ${run.input || "_(empty)_"}`,
|
|
616
|
+
"",
|
|
617
|
+
"Choose the one-based step number to resume from:",
|
|
618
|
+
"",
|
|
619
|
+
];
|
|
620
|
+
workflow.steps.forEach((step, index) => {
|
|
621
|
+
const title = step.title ?? step.id;
|
|
622
|
+
lines.push(`${index + 1}. ${title} (\`${step.id}\`)`);
|
|
623
|
+
});
|
|
624
|
+
lines.push("", "Run `/anvil resume <step> [retry-number]`.");
|
|
625
|
+
lines.push("Omit `retry-number` for no retries, or pass the current retry count to seed `{loop}` for the resumed step.");
|
|
626
|
+
return lines.join("\n");
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function formatWorkflowList(workflows: DiscoveredWorkflow[]): string {
|
|
630
|
+
const lines = ["# Anvil workflows", ""];
|
|
631
|
+
for (const workflow of workflows) {
|
|
632
|
+
const icon = workflow.errors?.length ? "❌" : "✅";
|
|
633
|
+
const description = workflow.workflow?.description ? ` — ${workflow.workflow.description}` : "";
|
|
634
|
+
lines.push(`${icon} \`${workflow.name}\` (${workflow.source})${description}`);
|
|
635
|
+
if (workflow.errors?.length) {
|
|
636
|
+
for (const error of workflow.errors) lines.push(` - ${error}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return lines.join("\n");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function formatWorkflowErrors(workflow: DiscoveredWorkflow): string {
|
|
643
|
+
return [
|
|
644
|
+
`❌ Workflow \`${workflow.name}\` is invalid.`,
|
|
645
|
+
"",
|
|
646
|
+
workflow.file,
|
|
647
|
+
"",
|
|
648
|
+
...(workflow.errors ?? ["unknown error"]).map((error) => `- ${error}`),
|
|
649
|
+
].join("\n");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function waitForTurnCompletion(ctx: ExtensionCommandContext, signal?: AbortSignal): Promise<void> {
|
|
653
|
+
return waitForOneTurnOrIdle(ctx, signal).then(async () => {
|
|
654
|
+
await ctx.waitForIdle();
|
|
655
|
+
while (ctx.hasPendingMessages()) {
|
|
656
|
+
await waitForOneTurnOrIdle(ctx, signal);
|
|
657
|
+
await ctx.waitForIdle();
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function waitForOneTurnOrIdle(ctx: ExtensionCommandContext, signal?: AbortSignal): Promise<void> {
|
|
663
|
+
if (signal?.aborted) return Promise.reject(new AnvilAbortError());
|
|
664
|
+
return new Promise<void>((resolve, reject) => {
|
|
665
|
+
const waiter: TurnWaiter = {
|
|
666
|
+
started: false,
|
|
667
|
+
resolve: () => resolve(),
|
|
668
|
+
reject,
|
|
669
|
+
timer: setTimeout(() => {
|
|
670
|
+
if (!waiter.started && ctx.isIdle()) resolveTurnWaiter(waiter);
|
|
671
|
+
}, 2_000),
|
|
672
|
+
signal,
|
|
673
|
+
};
|
|
674
|
+
waiter.onAbort = () => rejectTurnWaiter(waiter, new AnvilAbortError());
|
|
675
|
+
if (signal) signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
676
|
+
turnWaiters.add(waiter);
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function resolveTurnWaiter(waiter: TurnWaiter): void {
|
|
681
|
+
cleanupTurnWaiter(waiter);
|
|
682
|
+
waiter.resolve();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function rejectTurnWaiter(waiter: TurnWaiter, error: Error): void {
|
|
686
|
+
cleanupTurnWaiter(waiter);
|
|
687
|
+
waiter.reject(error);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function cleanupTurnWaiter(waiter: TurnWaiter): void {
|
|
691
|
+
clearTimeout(waiter.timer);
|
|
692
|
+
if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
693
|
+
turnWaiters.delete(waiter);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function newRunId(): string {
|
|
697
|
+
return `anvil-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export const __testing__ = { resolveModelReference, parseAnvilArgs, parseRunArgs };
|