@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/paths.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface AnvilPathOptions {
|
|
5
|
+
cwd?: string;
|
|
6
|
+
homeDir?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface WorkflowDirs {
|
|
10
|
+
user: string;
|
|
11
|
+
project: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getHomeDir(options: AnvilPathOptions = {}): string {
|
|
15
|
+
return options.homeDir ?? homedir();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getProjectCwd(options: AnvilPathOptions = {}): string {
|
|
19
|
+
return resolve(options.cwd ?? process.cwd());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getUserAnvilDir(options: AnvilPathOptions = {}): string {
|
|
23
|
+
return join(getHomeDir(options), ".pi", "agent", "anvil");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getProjectAnvilDir(options: AnvilPathOptions = {}): string {
|
|
27
|
+
return join(getProjectCwd(options), ".pi", "anvil");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getWorkflowDirs(options: AnvilPathOptions = {}): WorkflowDirs {
|
|
31
|
+
return {
|
|
32
|
+
user: join(getUserAnvilDir(options), "workflows"),
|
|
33
|
+
project: join(getProjectAnvilDir(options), "workflows"),
|
|
34
|
+
};
|
|
35
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { shellEscape } from "./shell.ts";
|
|
2
|
+
import type {
|
|
3
|
+
AgentCheck,
|
|
4
|
+
Templatable,
|
|
5
|
+
WorkflowContext,
|
|
6
|
+
WorkflowDefinition,
|
|
7
|
+
WorkflowDelegation,
|
|
8
|
+
WorkflowStep,
|
|
9
|
+
WorkflowSubagentBackend,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
|
|
12
|
+
export interface StepInstructionOptions {
|
|
13
|
+
workflow: WorkflowDefinition;
|
|
14
|
+
step: WorkflowStep;
|
|
15
|
+
ctx: WorkflowContext;
|
|
16
|
+
stepIndex: number;
|
|
17
|
+
stepCount: number;
|
|
18
|
+
feedback?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function renderTemplatable(template: Templatable, ctx: WorkflowContext): Promise<string> {
|
|
22
|
+
if (typeof template === "function") return template(ctx);
|
|
23
|
+
return renderTemplateString(template, ctx);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function renderCommandTemplatable(template: Templatable, ctx: WorkflowContext): Promise<string> {
|
|
27
|
+
if (typeof template === "function") return template(ctx);
|
|
28
|
+
return renderCommandTemplateString(template, ctx);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderTemplateString(template: string, ctx: WorkflowContext): string {
|
|
32
|
+
return replaceTemplatePlaceholders(template, ctx, (value) => value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function renderCommandTemplateString(template: string, ctx: WorkflowContext): string {
|
|
36
|
+
return renderCommandPlaceholders(template, [
|
|
37
|
+
{ token: "{input}", variable: "__ANVIL_INPUT", value: ctx.input },
|
|
38
|
+
{ token: "{loop}", variable: "__ANVIL_LOOP", value: String(getCurrentLoopCount(ctx)) },
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function replaceTemplatePlaceholders(
|
|
43
|
+
template: string,
|
|
44
|
+
ctx: WorkflowContext,
|
|
45
|
+
escapeValue: (value: string) => string,
|
|
46
|
+
): string {
|
|
47
|
+
return template
|
|
48
|
+
.replaceAll("{input}", escapeValue(ctx.input))
|
|
49
|
+
.replaceAll("{loop}", escapeValue(String(getCurrentLoopCount(ctx))));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type ShellQuote = "single" | "double" | undefined;
|
|
53
|
+
|
|
54
|
+
interface CommandPlaceholder {
|
|
55
|
+
token: string;
|
|
56
|
+
variable: string;
|
|
57
|
+
value: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderCommandPlaceholders(template: string, placeholders: CommandPlaceholder[]): string {
|
|
61
|
+
let rendered = "";
|
|
62
|
+
let quote: ShellQuote;
|
|
63
|
+
let usedPlaceholder = false;
|
|
64
|
+
|
|
65
|
+
for (let index = 0; index < template.length;) {
|
|
66
|
+
const placeholder = placeholders.find((candidate) => template.startsWith(candidate.token, index));
|
|
67
|
+
if (placeholder) {
|
|
68
|
+
rendered += commandPlaceholderExpansion(placeholder.variable, quote);
|
|
69
|
+
index += placeholder.token.length;
|
|
70
|
+
usedPlaceholder = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const char = template[index]!;
|
|
75
|
+
if (char === "\\" && quote !== "single" && index + 1 < template.length) {
|
|
76
|
+
rendered += template.slice(index, index + 2);
|
|
77
|
+
index += 2;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
rendered += char;
|
|
82
|
+
if (quote === "single") {
|
|
83
|
+
if (char === "'") quote = undefined;
|
|
84
|
+
} else if (quote === "double") {
|
|
85
|
+
if (char === '"') quote = undefined;
|
|
86
|
+
} else if (char === "'") {
|
|
87
|
+
quote = "single";
|
|
88
|
+
} else if (char === '"') {
|
|
89
|
+
quote = "double";
|
|
90
|
+
}
|
|
91
|
+
index += 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!usedPlaceholder) return template;
|
|
95
|
+
|
|
96
|
+
const assignments = placeholders
|
|
97
|
+
.map((placeholder) => `${placeholder.variable}=${shellEscape(placeholder.value)}`)
|
|
98
|
+
.join(" ");
|
|
99
|
+
return `${assignments}; ${rendered}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function commandPlaceholderExpansion(variable: string, quote: ShellQuote): string {
|
|
103
|
+
const parameterExpansion = `\${${variable}}`;
|
|
104
|
+
if (quote === "single") return `'"${parameterExpansion}"'`;
|
|
105
|
+
if (quote === "double") return parameterExpansion;
|
|
106
|
+
return `"${parameterExpansion}"`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function buildStepInstruction(options: StepInstructionOptions): Promise<string> {
|
|
110
|
+
const renderedPrompt = await renderTemplatable(options.step.prompt, options.ctx);
|
|
111
|
+
const task = appendFeedback(renderedPrompt, options.feedback);
|
|
112
|
+
const title = options.step.title ?? options.step.id;
|
|
113
|
+
const header = `[anvil] Workflow "${options.workflow.name}" — step ${options.stepIndex + 1}/${options.stepCount}: ${title}`;
|
|
114
|
+
|
|
115
|
+
const delegation = resolveStepDelegation(options.workflow, options.step);
|
|
116
|
+
if (delegation.mode === "skill") {
|
|
117
|
+
return (
|
|
118
|
+
`${header}\n\n` +
|
|
119
|
+
`Delegate this workflow step to a subagent using skill "${delegation.skill}" if a delegation capability is available. ` +
|
|
120
|
+
`If no delegation capability is available, do the work directly in the main agent using that skill.\n\n` +
|
|
121
|
+
`Task:\n${task}`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (delegation.mode === "auto") {
|
|
125
|
+
const hint = delegation.hint ? ` Prefer agent/skill "${delegation.hint}" if appropriate.` : "";
|
|
126
|
+
return (
|
|
127
|
+
`${header}\n\n` +
|
|
128
|
+
`Choose whether to use a subagent for this workflow step.${hint} ` +
|
|
129
|
+
`If a suitable delegation capability is available, delegate to the best agent or skill; otherwise do the work directly in the main agent.\n\n` +
|
|
130
|
+
`Task:\n${task}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return `${header}\n\nDo this workflow step directly in the main agent. Do not delegate to a subagent.\n\n${task}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Task prompt for a step Anvil runs itself in a dedicated subagent session. */
|
|
138
|
+
export async function buildSubagentStepTask(options: StepInstructionOptions): Promise<string> {
|
|
139
|
+
const renderedPrompt = await renderTemplatable(options.step.prompt, options.ctx);
|
|
140
|
+
const task = appendFeedback(renderedPrompt, options.feedback);
|
|
141
|
+
const title = options.step.title ?? options.step.id;
|
|
142
|
+
const header = `[anvil] Workflow "${options.workflow.name}" — step ${options.stepIndex + 1}/${options.stepCount}: ${title}`;
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
`${header}\n\n` +
|
|
146
|
+
`You are a subagent session executing this workflow step. Complete the task autonomously, without asking for confirmation. ` +
|
|
147
|
+
`Your final message is reported back to the main workflow session as this step's outcome, so end with a concise summary of what you did.\n\n` +
|
|
148
|
+
`Task:\n${task}`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function buildSubagentResultMessage(args: {
|
|
153
|
+
workflowName: string;
|
|
154
|
+
stepTitle: string;
|
|
155
|
+
stepIndex: number;
|
|
156
|
+
stepCount: number;
|
|
157
|
+
backend: WorkflowSubagentBackend;
|
|
158
|
+
summary: string;
|
|
159
|
+
sessionFile?: string;
|
|
160
|
+
}): string {
|
|
161
|
+
const sessionLine = args.sessionFile ? `\n\nSubagent session: ${args.sessionFile}` : "";
|
|
162
|
+
return (
|
|
163
|
+
`[anvil] Workflow "${args.workflowName}" — step ${args.stepIndex + 1}/${args.stepCount} "${args.stepTitle}" ` +
|
|
164
|
+
`was executed by a ${args.backend} subagent session. Treat the summary below as this step's outcome; do not redo the work.\n\n` +
|
|
165
|
+
`Subagent summary:\n${args.summary}${sessionLine}`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function buildAgentCheckInstruction(args: {
|
|
170
|
+
workflow: WorkflowDefinition;
|
|
171
|
+
step: WorkflowStep;
|
|
172
|
+
check: AgentCheck;
|
|
173
|
+
ctx: WorkflowContext;
|
|
174
|
+
checkId: string;
|
|
175
|
+
}): Promise<string> {
|
|
176
|
+
const criteria = await renderTemplatable(args.check.prompt, args.ctx);
|
|
177
|
+
const delegateLine = args.check.agent
|
|
178
|
+
? `\nIf you use subagents for evaluations, delegate this evaluation to subagent "${args.check.agent}".`
|
|
179
|
+
: "";
|
|
180
|
+
|
|
181
|
+
return `[anvil] Workflow "${args.workflow.name}" — evaluate step "${args.step.id}".\n\n` +
|
|
182
|
+
`Evaluation criteria:\n${criteria}${delegateLine}\n\n` +
|
|
183
|
+
`Call the \`anvil_verdict\` tool exactly once with:\n` +
|
|
184
|
+
`- check_id: ${args.checkId}\n` +
|
|
185
|
+
`- pass: true if the criteria are satisfied, otherwise false\n` +
|
|
186
|
+
`- reason: a concise explanation\n\n` +
|
|
187
|
+
`Do not report the verdict only in prose; the workflow engine only accepts the tool call.`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function buildVerdictReprompt(checkId: string): string {
|
|
191
|
+
return `[anvil] You did not report a verdict for check_id ${checkId}. Call the \`anvil_verdict\` tool now exactly once with check_id ${checkId}, pass, and reason.`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function appendFeedback(prompt: string, feedback?: string): string {
|
|
195
|
+
if (!feedback?.trim()) return prompt;
|
|
196
|
+
return `${prompt}\n\n## Feedback from failed check\n${feedback.trim()}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export type ResolvedStepDelegation =
|
|
200
|
+
| { mode: "none" }
|
|
201
|
+
| { mode: "auto"; hint?: string }
|
|
202
|
+
| { mode: "skill"; skill: string }
|
|
203
|
+
| { mode: "subagent"; backend: WorkflowSubagentBackend };
|
|
204
|
+
|
|
205
|
+
export function resolveStepDelegation(workflow: WorkflowDefinition, step: WorkflowStep): ResolvedStepDelegation {
|
|
206
|
+
if (step.runInMain) return { mode: "none" };
|
|
207
|
+
|
|
208
|
+
const legacyHint = step.agent ?? workflow.defaults?.agent;
|
|
209
|
+
const configured = step.delegation ?? workflow.defaults?.delegation;
|
|
210
|
+
if (configured) return resolveConfiguredDelegation(configured, legacyHint);
|
|
211
|
+
|
|
212
|
+
return resolveAutoDelegation(legacyHint);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function resolveConfiguredDelegation(delegation: WorkflowDelegation, legacyHint?: string): ResolvedStepDelegation {
|
|
216
|
+
if (delegation === "auto") return resolveAutoDelegation(legacyHint);
|
|
217
|
+
if (delegation === "none") return { mode: "none" };
|
|
218
|
+
if ("subagent" in delegation) return { mode: "subagent", backend: delegation.subagent };
|
|
219
|
+
return { mode: "skill", skill: delegation.skill };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveAutoDelegation(hint?: string): ResolvedStepDelegation {
|
|
223
|
+
const backend = detectAutoSubagentBackend();
|
|
224
|
+
return backend ? { mode: "subagent", backend } : hint ? { mode: "auto", hint } : { mode: "auto" };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function detectAutoSubagentBackend(): WorkflowSubagentBackend | undefined {
|
|
228
|
+
if (process.env.HERDR_ENV === "1") return "herdr";
|
|
229
|
+
if (process.env.CMUX_SHELL_INTEGRATION === "1") return "cmux";
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function workflowSubagentBackends(workflow: WorkflowDefinition): WorkflowSubagentBackend[] {
|
|
234
|
+
const backends = new Set<WorkflowSubagentBackend>();
|
|
235
|
+
for (const step of workflow.steps) {
|
|
236
|
+
const delegation = resolveStepDelegation(workflow, step);
|
|
237
|
+
if (delegation.mode === "subagent") backends.add(delegation.backend);
|
|
238
|
+
}
|
|
239
|
+
return [...backends];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function workflowUsesSubagentDelegation(workflow: WorkflowDefinition): boolean {
|
|
243
|
+
return workflowSubagentBackends(workflow).length > 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function getCurrentLoopCount(ctx: WorkflowContext): number {
|
|
247
|
+
let count = 0;
|
|
248
|
+
const suffix = `->${ctx.step.id}`;
|
|
249
|
+
for (const [key, value] of Object.entries(ctx.loopCounts)) {
|
|
250
|
+
if (key.endsWith(suffix) && value > count) count = value;
|
|
251
|
+
}
|
|
252
|
+
return count;
|
|
253
|
+
}
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension loaded into Anvil-spawned subagent sessions (`pi -e .../child.ts`).
|
|
3
|
+
*
|
|
4
|
+
* When the agent finishes its turn, it writes a `<sessionFile>.exit` sidecar
|
|
5
|
+
* (consumed by the parent's pollForExit) and shuts the session down. Turns the
|
|
6
|
+
* user aborted with Escape stay open for inspection; provider-error turns exit
|
|
7
|
+
* with the error message so the parent reports a failure instead of a stale
|
|
8
|
+
* summary.
|
|
9
|
+
*/
|
|
10
|
+
import { writeFileSync } from "node:fs";
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
interface AssistantLike {
|
|
14
|
+
role?: string;
|
|
15
|
+
stopReason?: string;
|
|
16
|
+
errorMessage?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function shouldAutoExitOnAgentEnd(messages: AssistantLike[] | undefined): boolean {
|
|
20
|
+
if (messages) {
|
|
21
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
22
|
+
const message = messages[i];
|
|
23
|
+
if (message?.role === "assistant") return message.stopReason !== "aborted";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function findLatestAssistantError(messages: AssistantLike[] | undefined): string | undefined {
|
|
30
|
+
if (!messages) return undefined;
|
|
31
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
32
|
+
const message = messages[i];
|
|
33
|
+
if (message?.role !== "assistant") continue;
|
|
34
|
+
if (message.stopReason !== "error") return undefined;
|
|
35
|
+
return message.errorMessage?.trim() || "Subagent agent loop ended with stopReason=error.";
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* v8 ignore start -- loaded only inside spawned pi subagent sessions. */
|
|
41
|
+
export default function anvilSubagentChild(pi: ExtensionAPI) {
|
|
42
|
+
const sessionFile = process.env.PI_ANVIL_SUBAGENT_SESSION;
|
|
43
|
+
if (!sessionFile) return;
|
|
44
|
+
|
|
45
|
+
pi.on("agent_end", (event, ctx) => {
|
|
46
|
+
const messages = (event as { messages?: AssistantLike[] }).messages;
|
|
47
|
+
if (!shouldAutoExitOnAgentEnd(messages)) return;
|
|
48
|
+
|
|
49
|
+
const errorMessage = findLatestAssistantError(messages);
|
|
50
|
+
try {
|
|
51
|
+
writeFileSync(
|
|
52
|
+
`${sessionFile}.exit`,
|
|
53
|
+
JSON.stringify(errorMessage ? { type: "error", errorMessage } : { type: "done" }),
|
|
54
|
+
);
|
|
55
|
+
} catch {
|
|
56
|
+
// Best effort — the terminal sentinel still reports the exit.
|
|
57
|
+
}
|
|
58
|
+
ctx.shutdown();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/* v8 ignore stop */
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal cmux backend for Anvil's declarative subagent steps.
|
|
3
|
+
*
|
|
4
|
+
* Ported (cmux-only) from HazAT/pi-interactive-subagents: surfaces are cmux
|
|
5
|
+
* tabs; the first subagent creates a right split, later ones add tabs to the
|
|
6
|
+
* same pane. Completion is detected via a `<sessionFile>.exit` sidecar written
|
|
7
|
+
* by the child extension, with the terminal sentinel as crash fallback.
|
|
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 { interpretExitSidecar, pollForExitWithReadScreen } from "./exit.ts";
|
|
15
|
+
import type { SubagentExit } from "./exit.ts";
|
|
16
|
+
export {
|
|
17
|
+
DEFAULT_READ_SCREEN_FAILURE_LIMIT,
|
|
18
|
+
DEFAULT_SUBAGENT_TIMEOUT_MS,
|
|
19
|
+
SUBAGENT_SENTINEL_PREFIX,
|
|
20
|
+
} from "./exit.ts";
|
|
21
|
+
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
/** Tracked subagent pane — reused across launches so tabs stack instead of splitting. */
|
|
25
|
+
let subagentPane: string | null = null;
|
|
26
|
+
|
|
27
|
+
export { shellEscape };
|
|
28
|
+
|
|
29
|
+
/* v8 ignore start -- cmux process/UI launch integration is covered by source-level contracts and manual cmux behavior. */
|
|
30
|
+
export function isCmuxAvailable(): boolean {
|
|
31
|
+
return Boolean(process.env.CMUX_SOCKET_PATH);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function cmuxUnavailableMessage(): string {
|
|
35
|
+
return 'cmux is not available. Start pi inside cmux (`cmux pi`) to run workflows with `delegation: { subagent: "cmux" }`.';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface FocusSnapshot {
|
|
39
|
+
surfaceRef?: string;
|
|
40
|
+
paneRef?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface CreatedSurface {
|
|
44
|
+
surface: string;
|
|
45
|
+
paneRef?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readCmuxJson(args: string[]): Promise<Record<string, unknown> | null> {
|
|
49
|
+
try {
|
|
50
|
+
const { stdout } = await execFileAsync("cmux", args, { encoding: "utf8" });
|
|
51
|
+
if (!stdout.trim()) return null;
|
|
52
|
+
const parsed = JSON.parse(stdout);
|
|
53
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toFocusSnapshot(value: unknown): FocusSnapshot | null {
|
|
60
|
+
if (!value || typeof value !== "object") return null;
|
|
61
|
+
const record = value as { surface_ref?: unknown; pane_ref?: unknown };
|
|
62
|
+
const surfaceRef = typeof record.surface_ref === "string" && record.surface_ref ? record.surface_ref : undefined;
|
|
63
|
+
const paneRef = typeof record.pane_ref === "string" && record.pane_ref ? record.pane_ref : undefined;
|
|
64
|
+
return surfaceRef || paneRef ? { surfaceRef, paneRef } : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function captureIdentify(): Promise<{ focused: FocusSnapshot | null; caller: FocusSnapshot | null }> {
|
|
68
|
+
const parsed = await readCmuxJson(["identify", "--json"]);
|
|
69
|
+
return {
|
|
70
|
+
focused: toFocusSnapshot(parsed?.focused),
|
|
71
|
+
caller: toFocusSnapshot(parsed?.caller),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readPaneRefForSurface(surface: string): Promise<string | undefined> {
|
|
76
|
+
const parsed = await readCmuxJson(["identify", "--surface", surface]);
|
|
77
|
+
if (!parsed) return undefined;
|
|
78
|
+
if (parsed.surface_ref === surface && typeof parsed.pane_ref === "string" && parsed.pane_ref) return parsed.pane_ref;
|
|
79
|
+
const caller = parsed.caller;
|
|
80
|
+
if (caller && typeof caller === "object") {
|
|
81
|
+
const record = caller as { surface_ref?: unknown; pane_ref?: unknown };
|
|
82
|
+
if (record.surface_ref === surface && typeof record.pane_ref === "string" && record.pane_ref) return record.pane_ref;
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function restoreFocus(snapshot: FocusSnapshot | null): Promise<void> {
|
|
88
|
+
if (!snapshot) return;
|
|
89
|
+
try {
|
|
90
|
+
if (snapshot.paneRef) await execFileAsync("cmux", ["focus-pane", "--pane", snapshot.paneRef], { encoding: "utf8" });
|
|
91
|
+
if (snapshot.surfaceRef) await execFileAsync("cmux", ["focus-panel", "--panel", snapshot.surfaceRef], { encoding: "utf8" });
|
|
92
|
+
} catch {
|
|
93
|
+
// Best-effort focus restoration only.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function focusMatches(focus: FocusSnapshot | null, snapshot: FocusSnapshot | null | undefined): boolean {
|
|
98
|
+
if (!focus || !snapshot) return false;
|
|
99
|
+
return (!!snapshot.surfaceRef && focus.surfaceRef === snapshot.surfaceRef) || (!!snapshot.paneRef && focus.paneRef === snapshot.paneRef);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Creating a split/surface can steal keyboard focus. If focus landed on the
|
|
104
|
+
* new child (or settled back onto the caller's pane), put it back where it was.
|
|
105
|
+
*/
|
|
106
|
+
async function restoreFocusIfStolen(snapshot: FocusSnapshot | null, child: CreatedSurface, caller: FocusSnapshot | null): Promise<void> {
|
|
107
|
+
if (!snapshot) return;
|
|
108
|
+
await sleep(100);
|
|
109
|
+
const current = (await captureIdentify()).focused;
|
|
110
|
+
if (
|
|
111
|
+
focusMatches(current, { surfaceRef: child.surface, paneRef: child.paneRef }) ||
|
|
112
|
+
focusMatches(current, caller)
|
|
113
|
+
) {
|
|
114
|
+
await restoreFocus(snapshot);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseCreatedSurface(output: string, command: string): CreatedSurface {
|
|
119
|
+
const surfaceMatch = output.match(/surface:\d+/);
|
|
120
|
+
if (!surfaceMatch) throw new Error(`Unexpected cmux ${command} output: ${output}`);
|
|
121
|
+
return { surface: surfaceMatch[0], paneRef: output.match(/pane:\d+/)?.[0] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function renameSurface(surface: string, name: string): Promise<void> {
|
|
125
|
+
await execFileAsync("cmux", ["rename-tab", "--surface", surface, name], { encoding: "utf8" });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function createSplitSurface(name: string): Promise<CreatedSurface> {
|
|
129
|
+
const { focused, caller } = await captureIdentify();
|
|
130
|
+
let child: CreatedSurface | null = null;
|
|
131
|
+
try {
|
|
132
|
+
const args = ["new-split", "right"];
|
|
133
|
+
if (process.env.CMUX_SURFACE_ID) args.push("--surface", process.env.CMUX_SURFACE_ID);
|
|
134
|
+
const { stdout } = await execFileAsync("cmux", args, { encoding: "utf8" });
|
|
135
|
+
child = parseCreatedSurface(stdout.trim(), "new-split");
|
|
136
|
+
child.paneRef ??= await readPaneRefForSurface(child.surface);
|
|
137
|
+
await renameSurface(child.surface, name);
|
|
138
|
+
return child;
|
|
139
|
+
} finally {
|
|
140
|
+
if (child) await restoreFocusIfStolen(focused, child, caller);
|
|
141
|
+
else await restoreFocus(focused);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function createSurfaceInPane(name: string, pane: string): Promise<string> {
|
|
146
|
+
const { focused, caller } = await captureIdentify();
|
|
147
|
+
let child: CreatedSurface | null = null;
|
|
148
|
+
try {
|
|
149
|
+
const { stdout } = await execFileAsync("cmux", ["new-surface", "--pane", pane], { encoding: "utf8" });
|
|
150
|
+
child = parseCreatedSurface(stdout.trim(), "new-surface");
|
|
151
|
+
child.paneRef ??= pane;
|
|
152
|
+
await renameSurface(child.surface, name);
|
|
153
|
+
return child.surface;
|
|
154
|
+
} finally {
|
|
155
|
+
if (child) await restoreFocusIfStolen(focused, child, caller);
|
|
156
|
+
else await restoreFocus(focused);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create a terminal surface for a subagent. The first call creates a right
|
|
162
|
+
* split; subsequent calls add tabs to that pane so splits don't keep shrinking.
|
|
163
|
+
* Returns a `surface:<n>` identifier.
|
|
164
|
+
*/
|
|
165
|
+
export async function createSurface(name: string): Promise<string> {
|
|
166
|
+
if (subagentPane) {
|
|
167
|
+
try {
|
|
168
|
+
const { stdout: tree } = await execFileAsync("cmux", ["tree"], { encoding: "utf8" });
|
|
169
|
+
if (new RegExp(`(^|\\s)${escapeRegExp(subagentPane)}($|\\s)`).test(tree)) {
|
|
170
|
+
return createSurfaceInPane(name, subagentPane);
|
|
171
|
+
}
|
|
172
|
+
} catch {}
|
|
173
|
+
subagentPane = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const created = await createSplitSurface(name);
|
|
177
|
+
subagentPane = created.paneRef ?? null;
|
|
178
|
+
return created.surface;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Send a command by writing it to a script file and executing that, so long
|
|
183
|
+
* commands survive terminal line-wrapping. The script is kept for debugging.
|
|
184
|
+
*/
|
|
185
|
+
export async function sendLongCommand(surface: string, command: string, scriptPath: string): Promise<void> {
|
|
186
|
+
mkdirSync(dirname(scriptPath), { recursive: true });
|
|
187
|
+
writeFileSync(scriptPath, `#!/bin/bash\n${command}\n`, { mode: 0o600 });
|
|
188
|
+
await execFileAsync("cmux", ["send", "--surface", surface, `bash ${shellEscape(scriptPath)}\n`], { encoding: "utf8" });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function readScreen(surface: string, lines = 5): Promise<string> {
|
|
192
|
+
const { stdout } = await execFileAsync("cmux", ["read-screen", "--surface", surface, "--lines", String(lines)], {
|
|
193
|
+
encoding: "utf8",
|
|
194
|
+
});
|
|
195
|
+
return stdout;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function closeSurface(surface: string): Promise<void> {
|
|
199
|
+
await execFileAsync("cmux", ["close-surface", "--surface", surface], { encoding: "utf8" });
|
|
200
|
+
}
|
|
201
|
+
/* v8 ignore stop */
|
|
202
|
+
|
|
203
|
+
export type { SubagentExit };
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Poll until the subagent exits: `.exit` sidecar first (written by the child
|
|
207
|
+
* extension), terminal sentinel as fallback for crashes / early shell errors.
|
|
208
|
+
*/
|
|
209
|
+
export function pollForExit(
|
|
210
|
+
surface: string,
|
|
211
|
+
sessionFile: string,
|
|
212
|
+
signal?: AbortSignal,
|
|
213
|
+
intervalMs?: number,
|
|
214
|
+
timeoutMs?: number,
|
|
215
|
+
): Promise<SubagentExit> {
|
|
216
|
+
return pollForExitWithReadScreen(readScreen, surface, sessionFile, signal, intervalMs, timeoutMs);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function sleep(ms: number): Promise<void> {
|
|
220
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function escapeRegExp(value: string): string {
|
|
224
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const __testing__ = { interpretExitSidecar };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { AnvilAbortError, throwIfAborted } from "../errors.ts";
|
|
3
|
+
|
|
4
|
+
export const SUBAGENT_SENTINEL_PREFIX = "__ANVIL_SUBAGENT_DONE_";
|
|
5
|
+
const SENTINEL_RE = /__ANVIL_SUBAGENT_DONE_(\d+)__/;
|
|
6
|
+
const MISSING_CWD_PROMPT_RE = /cwd from session file does not exist/i;
|
|
7
|
+
const CONTINUE_CANCEL_PROMPT_RE = /continue in current cwd[\s\S]*?(?:^|\n)\s*(?:→\s*)?Continue\b[\s\S]*?(?:^|\n)\s*Cancel\b/im;
|
|
8
|
+
export const DEFAULT_SUBAGENT_TIMEOUT_MS = 1_800_000;
|
|
9
|
+
export const DEFAULT_READ_SCREEN_FAILURE_LIMIT = 2;
|
|
10
|
+
|
|
11
|
+
export interface SubagentExit {
|
|
12
|
+
reason: "done" | "error" | "sentinel";
|
|
13
|
+
exitCode: number;
|
|
14
|
+
errorMessage?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ReadSubagentScreen = (surface: string, lines?: number) => Promise<string>;
|
|
18
|
+
|
|
19
|
+
export function interpretExitSidecar(data: unknown): SubagentExit {
|
|
20
|
+
const record = (typeof data === "object" && data !== null ? data : {}) as { type?: unknown; errorMessage?: unknown };
|
|
21
|
+
if (record.type === "error") {
|
|
22
|
+
const errorMessage =
|
|
23
|
+
typeof record.errorMessage === "string" && record.errorMessage.trim()
|
|
24
|
+
? record.errorMessage
|
|
25
|
+
: "Subagent exited with stopReason=error.";
|
|
26
|
+
return { reason: "error", exitCode: 1, errorMessage };
|
|
27
|
+
}
|
|
28
|
+
return { reason: "done", exitCode: 0 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function consumeExitSidecar(sessionFile: string): SubagentExit | undefined {
|
|
32
|
+
try {
|
|
33
|
+
const exitFile = `${sessionFile}.exit`;
|
|
34
|
+
if (!existsSync(exitFile)) return undefined;
|
|
35
|
+
const data = JSON.parse(readFileSync(exitFile, "utf8"));
|
|
36
|
+
rmSync(exitFile, { force: true });
|
|
37
|
+
return interpretExitSidecar(data);
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Poll until the subagent exits: `.exit` sidecar first (written by the child
|
|
45
|
+
* extension), terminal sentinel as fallback for crashes / early shell errors.
|
|
46
|
+
*
|
|
47
|
+
* The screen reader is injected by each multiplexer backend so Herdr panes are
|
|
48
|
+
* polled with `herdr pane read` and cmux surfaces with `cmux read-screen`.
|
|
49
|
+
*/
|
|
50
|
+
export async function pollForExitWithReadScreen(
|
|
51
|
+
readScreen: ReadSubagentScreen,
|
|
52
|
+
surface: string,
|
|
53
|
+
sessionFile: string,
|
|
54
|
+
signal?: AbortSignal,
|
|
55
|
+
intervalMs = 1000,
|
|
56
|
+
timeoutMs = DEFAULT_SUBAGENT_TIMEOUT_MS,
|
|
57
|
+
): Promise<SubagentExit> {
|
|
58
|
+
const deadline = Date.now() + timeoutMs;
|
|
59
|
+
let consecutiveReadFailures = 0;
|
|
60
|
+
for (;;) {
|
|
61
|
+
throwIfAborted(signal);
|
|
62
|
+
if (Date.now() >= deadline) throw new Error(`Subagent timed out after ${timeoutMs}ms`);
|
|
63
|
+
|
|
64
|
+
const sidecar = consumeExitSidecar(sessionFile);
|
|
65
|
+
if (sidecar) return sidecar;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const screen = await readScreen(surface, 5);
|
|
69
|
+
consecutiveReadFailures = 0;
|
|
70
|
+
const match = screen.match(SENTINEL_RE);
|
|
71
|
+
if (match) return { reason: "sentinel", exitCode: Number.parseInt(match[1]!, 10) };
|
|
72
|
+
const blockingPrompt = detectBlockingPiStartupPrompt(screen);
|
|
73
|
+
if (blockingPrompt) return { reason: "error", exitCode: 1, errorMessage: blockingPrompt };
|
|
74
|
+
} catch {
|
|
75
|
+
// Surface may already be gone — give the child a short grace period to write the sidecar,
|
|
76
|
+
// then fail fast instead of waiting for the full subagent timeout.
|
|
77
|
+
if (Date.now() >= deadline) throw new Error(`Subagent timed out after ${timeoutMs}ms`);
|
|
78
|
+
const lateSidecar = consumeExitSidecar(sessionFile);
|
|
79
|
+
if (lateSidecar) return lateSidecar;
|
|
80
|
+
consecutiveReadFailures += 1;
|
|
81
|
+
if (consecutiveReadFailures >= DEFAULT_READ_SCREEN_FAILURE_LIMIT) {
|
|
82
|
+
throw new Error(`Subagent surface closed before completion: ${surface}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())), signal);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function detectBlockingPiStartupPrompt(screen: string): string | undefined {
|
|
91
|
+
if (MISSING_CWD_PROMPT_RE.test(screen)) {
|
|
92
|
+
return "Pi startup prompt blocked subagent auto-exit: cwd from session file does not exist.";
|
|
93
|
+
}
|
|
94
|
+
if (CONTINUE_CANCEL_PROMPT_RE.test(screen)) {
|
|
95
|
+
return "Pi startup prompt blocked subagent auto-exit; rerun the step after the session startup prompt is resolved.";
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
if (signal?.aborted) return reject(new AnvilAbortError());
|
|
103
|
+
const timer = setTimeout(() => {
|
|
104
|
+
signal?.removeEventListener("abort", onAbort);
|
|
105
|
+
resolve();
|
|
106
|
+
}, ms);
|
|
107
|
+
function onAbort() {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
reject(new AnvilAbortError());
|
|
110
|
+
}
|
|
111
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
112
|
+
});
|
|
113
|
+
}
|