pi-herdr-agents 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.
Files changed (49) hide show
  1. package/AGENTS.md +116 -0
  2. package/CONTEXT.md +159 -0
  3. package/LICENSE +21 -0
  4. package/README.md +874 -0
  5. package/RELEASING.md +139 -0
  6. package/agents/adversarial-reviewer.md +80 -0
  7. package/agents/claude-reviewer.md +23 -0
  8. package/agents/planner.md +539 -0
  9. package/agents/poteto.md +32 -0
  10. package/agents/reviewer.md +164 -0
  11. package/agents/scout.md +106 -0
  12. package/agents/visual-tester.md +224 -0
  13. package/agents/worker.md +132 -0
  14. package/config.json.example +8 -0
  15. package/docs/README.md +42 -0
  16. package/docs/adr/0001-btw-ephemeral-side-questions.md +142 -0
  17. package/docs/adr/0002-agent-workflow-skill-runtime-taxonomy.md +265 -0
  18. package/docs/adr/0003-installable-role-packs.md +135 -0
  19. package/docs/adr/0004-require-active-user-approval-for-workflow-execution.md +17 -0
  20. package/docs/adr/0005-parent-owns-workflow-script-authority.md +17 -0
  21. package/docs/adr/0006-limit-v1-execution-effects-to-isolated-worktrees.md +18 -0
  22. package/docs/adr/0007-require-fresh-review-for-workflow-scripts.md +19 -0
  23. package/docs/orchestrated-review-workflow-plan.md +479 -0
  24. package/docs/research/pdw-architecture-assessment.md +525 -0
  25. package/docs/research/pi-workflows-sol-advisor.md +255 -0
  26. package/docs/research/worktree-subagent-orchestration.md +317 -0
  27. package/docs/worktree-subagents.md +196 -0
  28. package/examples/role-pack/extension.ts +18 -0
  29. package/examples/role-pack/package.json +16 -0
  30. package/examples/role-pack/roles/example-reviewer.md +12 -0
  31. package/package.json +58 -0
  32. package/pi-extension/subagents/activity.ts +511 -0
  33. package/pi-extension/subagents/completion.ts +177 -0
  34. package/pi-extension/subagents/herdr.ts +541 -0
  35. package/pi-extension/subagents/index.ts +4730 -0
  36. package/pi-extension/subagents/lifecycle.ts +477 -0
  37. package/pi-extension/subagents/model-config.ts +95 -0
  38. package/pi-extension/subagents/plan-skill.md +262 -0
  39. package/pi-extension/subagents/plugin/.claude-plugin/plugin.json +5 -0
  40. package/pi-extension/subagents/plugin/hooks/hooks.json +15 -0
  41. package/pi-extension/subagents/plugin/hooks/on-stop.sh +68 -0
  42. package/pi-extension/subagents/runtime-routing.ts +313 -0
  43. package/pi-extension/subagents/session.ts +216 -0
  44. package/pi-extension/subagents/status.ts +513 -0
  45. package/pi-extension/subagents/subagent-done.ts +326 -0
  46. package/pi-extension/subagents/terminal.ts +163 -0
  47. package/pi-extension/subagents/workflow-worker.js +56 -0
  48. package/pi-extension/subagents/workflow.ts +1210 -0
  49. package/skills/orchestrate/SKILL.md +184 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Extension loaded into sub-agents.
3
+ * - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+J)
4
+ * - Provides a `subagent_done` tool for autonomous agents to self-terminate
5
+ */
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import { Box, Text } from "@earendil-works/pi-tui";
8
+ import { Type } from "@sinclair/typebox";
9
+ import { writeFileSync } from "node:fs";
10
+ import { createSubagentActivityRecorder } from "./activity.ts";
11
+
12
+ export function shouldMarkUserTookOver(agentStarted: boolean): boolean {
13
+ return agentStarted;
14
+ }
15
+
16
+ export function shouldAutoExitOnAgentEnd(
17
+ _userTookOver: boolean,
18
+ messages: any[] | undefined,
19
+ ): boolean {
20
+ // Manual input should not strand an auto-exit subagent. If the latest agent
21
+ // turn completed normally, close the session. Escape/abort still leaves it
22
+ // open for inspection or another prompt.
23
+ //
24
+ // stopReason: "error" (e.g. exhausted retries on a provider overload) also
25
+ // returns true — we want to shut down so the parent is woken up — but we
26
+ // pair this with findLatestAssistantError() so the parent learns it was an
27
+ // error, not a clean completion.
28
+ if (messages) {
29
+ for (let i = messages.length - 1; i >= 0; i--) {
30
+ const msg = messages[i];
31
+ if (msg?.role === "assistant") {
32
+ return msg.stopReason !== "aborted";
33
+ }
34
+ }
35
+ }
36
+
37
+ return true;
38
+ }
39
+
40
+ export interface SubagentErrorInfo {
41
+ errorMessage: string;
42
+ stopReason: "error";
43
+ }
44
+
45
+ /**
46
+ * If the last assistant message in the turn ended with `stopReason: "error"`
47
+ * (typically auto-retry exhausted on an overload / rate limit / server error),
48
+ * return its error info so the parent orchestrator can surface a clear
49
+ * failure instead of silently treating the run as completed.
50
+ *
51
+ * Returns `null` when the latest assistant turn completed normally or was
52
+ * aborted by the user (handled separately by shouldAutoExitOnAgentEnd).
53
+ */
54
+ export function findLatestAssistantError(
55
+ messages: any[] | undefined,
56
+ ): SubagentErrorInfo | null {
57
+ if (!messages) return null;
58
+ for (let i = messages.length - 1; i >= 0; i--) {
59
+ const msg = messages[i];
60
+ if (msg?.role !== "assistant") continue;
61
+ if (msg.stopReason !== "error") return null;
62
+ const raw = typeof msg.errorMessage === "string" ? msg.errorMessage.trim() : "";
63
+ return {
64
+ errorMessage: raw || "Subagent agent loop ended with stopReason=error (no errorMessage field).",
65
+ stopReason: "error",
66
+ };
67
+ }
68
+ return null;
69
+ }
70
+
71
+ export function buildCompletionSidecar(messages: any[] | undefined):
72
+ | { type: "done" }
73
+ | { type: "error"; errorMessage: string; stopReason: "error" } {
74
+ const errorInfo = findLatestAssistantError(messages);
75
+ return errorInfo ? { type: "error", ...errorInfo } : { type: "done" };
76
+ }
77
+
78
+ export function parseDeniedTools(rawValue: string | undefined): string[] {
79
+ return (rawValue ?? "")
80
+ .split(",")
81
+ .map((value) => value.trim())
82
+ .filter(Boolean);
83
+ }
84
+
85
+ export default function (pi: ExtensionAPI) {
86
+ let toolNames: string[] = [];
87
+ let denied: string[] = [];
88
+ let expanded = false;
89
+
90
+ // Read subagent identity from env vars (set by parent orchestrator)
91
+ const subagentName = process.env.PI_SUBAGENT_NAME ?? "";
92
+ const subagentAgent = process.env.PI_SUBAGENT_AGENT ?? "";
93
+ const deniedToolsValue = process.env.PI_DENY_TOOLS;
94
+ const autoExit = process.env.PI_SUBAGENT_AUTO_EXIT === "1";
95
+ const recorder = createSubagentActivityRecorder({
96
+ runningChildId: process.env.PI_SUBAGENT_ID,
97
+ activityFile: process.env.PI_SUBAGENT_ACTIVITY_FILE,
98
+ });
99
+
100
+ function renderWidget(ctx: { ui: { setWidget: Function } }, _theme: any) {
101
+ ctx.ui.setWidget(
102
+ "subagent-tools",
103
+ (_tui: any, theme: any) => {
104
+ const box = new Box(1, 0, (text: string) => theme.bg("toolSuccessBg", text));
105
+
106
+ const label = subagentAgent || subagentName;
107
+ const agentTag = label ? theme.bold(theme.fg("accent", `[${label}]`)) : "";
108
+
109
+ if (expanded) {
110
+ // Expanded: full tool list + denied
111
+ const countInfo = theme.fg("dim", ` — ${toolNames.length} available`);
112
+ const hint = theme.fg("muted", " (Ctrl+J to collapse)");
113
+
114
+ const toolList = toolNames
115
+ .map((name: string) => theme.fg("dim", name))
116
+ .join(theme.fg("muted", ", "));
117
+
118
+ let deniedLine = "";
119
+ if (denied.length > 0) {
120
+ const deniedList = denied
121
+ .map((name: string) => theme.fg("error", name))
122
+ .join(theme.fg("muted", ", "));
123
+ deniedLine = "\n" + theme.fg("muted", "denied: ") + deniedList;
124
+ }
125
+
126
+ const content = new Text(
127
+ `${agentTag}${countInfo}${hint}\n${toolList}${deniedLine}`,
128
+ 0,
129
+ 0,
130
+ );
131
+ box.addChild(content);
132
+ } else {
133
+ // Collapsed: one-line summary
134
+ const countInfo = theme.fg("dim", ` — ${toolNames.length} tools`);
135
+ const deniedInfo =
136
+ denied.length > 0
137
+ ? theme.fg("dim", " · ") + theme.fg("error", `${denied.length} denied`)
138
+ : "";
139
+ const hint = theme.fg("muted", " (Ctrl+J to expand)");
140
+
141
+ const content = new Text(`${agentTag}${countInfo}${deniedInfo}${hint}`, 0, 0);
142
+ box.addChild(content);
143
+ }
144
+
145
+ return box;
146
+ },
147
+ { placement: "aboveEditor" },
148
+ );
149
+ }
150
+
151
+ let userTookOver = false;
152
+ let agentStarted = false;
153
+
154
+ // Show widget + status bar on session start
155
+ pi.on("session_start", (_event, ctx) => {
156
+ recorder.sessionStart();
157
+ const tools = pi.getAllTools();
158
+ toolNames = tools.map((t) => t.name).sort();
159
+ denied = parseDeniedTools(deniedToolsValue);
160
+
161
+ renderWidget(ctx, null);
162
+ });
163
+
164
+ pi.on("input", () => {
165
+ recorder.input();
166
+ // Ignore the initial task message that starts an autonomous subagent.
167
+ // Only inputs after the first agent run has started count as user takeover.
168
+ if (!shouldMarkUserTookOver(agentStarted)) return;
169
+ userTookOver = true;
170
+ });
171
+
172
+ pi.on("before_agent_start", () => {
173
+ recorder.beforeAgentStart();
174
+ });
175
+
176
+ pi.on("agent_start", () => {
177
+ agentStarted = true;
178
+ recorder.agentStart();
179
+ });
180
+
181
+ pi.on("agent_end", (event, ctx) => {
182
+ const messages = (event as any).messages as any[] | undefined;
183
+ const shouldExit = autoExit && shouldAutoExitOnAgentEnd(userTookOver, messages);
184
+
185
+ if (shouldExit) {
186
+ // Surface stopReason: "error" turns (auto-retry exhausted, provider
187
+ // overload, etc.) to the parent via the .exit sidecar so the watcher
188
+ // can report a clear failure with the underlying error message.
189
+ // Without this the parent would only see exit code 0 and a stale
190
+ // assistant message, mistaking the crash for a successful completion.
191
+ const sessionFile = process.env.PI_SUBAGENT_SESSION;
192
+ if (sessionFile) {
193
+ try {
194
+ writeFileSync(
195
+ `${sessionFile}.exit`,
196
+ JSON.stringify(buildCompletionSidecar(messages)),
197
+ );
198
+ } catch {
199
+ // Best effort — the watcher can still detect the terminal sentinel
200
+ // after shutdown if the completion sidecar cannot be written.
201
+ }
202
+ }
203
+
204
+ recorder.agentEndDone();
205
+ ctx.shutdown();
206
+ return;
207
+ }
208
+
209
+ recorder.agentEndWaiting();
210
+ if (autoExit) {
211
+ // Reset any recorded manual input marker. Auto-exit is decided by whether
212
+ // the latest agent turn completed normally, not by who initiated it.
213
+ userTookOver = false;
214
+ }
215
+ });
216
+
217
+ pi.on("turn_start", (event) => {
218
+ recorder.turnStart((event as any).turnIndex);
219
+ });
220
+
221
+ pi.on("turn_end", (event) => {
222
+ recorder.turnEnd((event as any).turnIndex);
223
+ });
224
+
225
+ pi.on("before_provider_request", () => {
226
+ recorder.beforeProviderRequest();
227
+ });
228
+
229
+ pi.on("after_provider_response", () => {
230
+ recorder.afterProviderResponse();
231
+ });
232
+
233
+ pi.on("message_update", (event) => {
234
+ recorder.messageUpdate((event as any).assistantMessageEvent?.type);
235
+ });
236
+
237
+ pi.on("tool_execution_start", (event) => {
238
+ recorder.toolExecutionStart((event as any).toolCallId, (event as any).toolName);
239
+ });
240
+
241
+ pi.on("tool_call", (event) => {
242
+ recorder.toolCall((event as any).toolCallId, (event as any).toolName);
243
+ });
244
+
245
+ pi.on("tool_execution_update", (event) => {
246
+ recorder.toolExecutionUpdate((event as any).toolCallId, (event as any).toolName);
247
+ });
248
+
249
+ pi.on("tool_result", (event) => {
250
+ recorder.toolResult((event as any).toolCallId, (event as any).toolName);
251
+ });
252
+
253
+ pi.on("tool_execution_end", (event) => {
254
+ recorder.toolExecutionEnd((event as any).toolCallId, (event as any).toolName);
255
+ });
256
+
257
+ pi.on("session_shutdown", (event) => {
258
+ recorder.sessionShutdown((event as any).reason);
259
+ });
260
+
261
+ // Toggle expand/collapse with Ctrl+J
262
+ pi.registerShortcut("ctrl+j", {
263
+ description: "Toggle subagent tools widget",
264
+ handler: (ctx) => {
265
+ expanded = !expanded;
266
+ renderWidget(ctx, null);
267
+ },
268
+ });
269
+
270
+ pi.registerTool({
271
+ name: "caller_ping",
272
+ label: "Caller Ping",
273
+ description:
274
+ "Send a help request to the parent agent and exit this session. " +
275
+ "The parent will be notified with your message and can resume this session with a response. " +
276
+ "Use when you're stuck, need clarification, or need the parent to take action.",
277
+ parameters: Type.Object({
278
+ message: Type.String({ description: "What you need help with" }),
279
+ }),
280
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
281
+ const sessionFile = process.env.PI_SUBAGENT_SESSION;
282
+ if (!sessionFile) {
283
+ throw new Error(
284
+ "caller_ping is only available in subagent contexts. " +
285
+ "PI_SUBAGENT_SESSION environment variable is not set.",
286
+ );
287
+ }
288
+
289
+ recorder.callerPing();
290
+ const exitData = {
291
+ type: "ping" as const,
292
+ name: process.env.PI_SUBAGENT_NAME ?? "subagent",
293
+ message: params.message,
294
+ };
295
+ writeFileSync(`${sessionFile}.exit`, JSON.stringify(exitData));
296
+
297
+ ctx.shutdown();
298
+ return {
299
+ content: [{ type: "text", text: "Ping sent. Session will exit and parent will be notified." }],
300
+ details: {},
301
+ };
302
+ },
303
+ });
304
+
305
+ pi.registerTool({
306
+ name: "subagent_done",
307
+ label: "Subagent Done",
308
+ description:
309
+ "Call this tool when you have completed your task. " +
310
+ "It will close this session and return your results to the main session. " +
311
+ "Your LAST assistant message before calling this becomes the summary returned to the caller.",
312
+ parameters: Type.Object({}),
313
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
314
+ const sessionFile = process.env.PI_SUBAGENT_SESSION;
315
+ recorder.subagentDone();
316
+ if (sessionFile) {
317
+ writeFileSync(`${sessionFile}.exit`, JSON.stringify({ type: "done" }));
318
+ }
319
+ ctx.shutdown();
320
+ return {
321
+ content: [{ type: "text", text: "Shutting down subagent session." }],
322
+ details: {},
323
+ };
324
+ },
325
+ });
326
+ }
@@ -0,0 +1,163 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import {
5
+ closeHerdrSurface,
6
+ createHerdrSurface,
7
+ createHerdrSurfaceSplit,
8
+ createHerdrWorktree,
9
+ getHerdrPaneProcessInfo,
10
+ isHerdrAvailable,
11
+ isProcessAlive,
12
+ readHerdrScreen,
13
+ readHerdrScreenAsync,
14
+ inspectHerdrPane,
15
+ renameHerdrTab,
16
+ renameHerdrWorkspace,
17
+ sendHerdrCommand,
18
+ sendHerdrEscape,
19
+ waitForHerdrPaneAbsence,
20
+ waitForProcessesExit,
21
+ type HerdrPaneProcessInfo,
22
+ } from "./herdr.ts";
23
+
24
+ export type PaneId = string;
25
+ export type SplitDirection = "right" | "down";
26
+ export type { HerdrWorktreeSurface } from "./herdr.ts";
27
+
28
+ const SETUP_HINT = "Start pi inside herdr (`herdr`, then run `pi`).";
29
+
30
+ export function isTerminalAvailable(): boolean {
31
+ return isHerdrAvailable();
32
+ }
33
+
34
+ export function terminalSetupHint(): string {
35
+ return SETUP_HINT;
36
+ }
37
+
38
+ function assertTerminalAvailable(): void {
39
+ if (!isTerminalAvailable())
40
+ throw new Error(`herdr is not available. ${SETUP_HINT}`);
41
+ }
42
+
43
+ export function shellQuote(value: string): string {
44
+ return "'" + value.replace(/'/g, "'\\''") + "'";
45
+ }
46
+
47
+ /** Create a new herdr tab and return its root pane ID. */
48
+ export function createSubagentPane(name: string): PaneId {
49
+ assertTerminalAvailable();
50
+ return createHerdrSurface(name);
51
+ }
52
+
53
+ /** Create a Git worktree in its own herdr workspace and return its root surface. */
54
+ export function createSubagentWorktree(
55
+ name: string,
56
+ cwd: string,
57
+ branch: string,
58
+ base: string,
59
+ ): import("./herdr.ts").HerdrWorktreeSurface {
60
+ assertTerminalAvailable();
61
+ return createHerdrWorktree(name, cwd, branch, base);
62
+ }
63
+
64
+ /** Split the current herdr pane and return the child pane ID. */
65
+ export function splitCurrentPane(
66
+ name: string,
67
+ direction: SplitDirection,
68
+ ): PaneId {
69
+ assertTerminalAvailable();
70
+ return createHerdrSurfaceSplit(name, direction);
71
+ }
72
+
73
+ export function renameCurrentTab(title: string): void {
74
+ assertTerminalAvailable();
75
+ renameHerdrTab(title);
76
+ }
77
+
78
+ export function renameCurrentWorkspace(title: string): void {
79
+ assertTerminalAvailable();
80
+ renameHerdrWorkspace(title);
81
+ }
82
+
83
+ export function runInPane(paneId: PaneId, command: string): void {
84
+ assertTerminalAvailable();
85
+ sendHerdrCommand(paneId, command);
86
+ }
87
+
88
+ export function interruptPane(paneId: PaneId): void {
89
+ assertTerminalAvailable();
90
+ sendHerdrEscape(paneId);
91
+ }
92
+
93
+ export function runScriptInPane(
94
+ paneId: PaneId,
95
+ command: string,
96
+ options?: { scriptPath?: string; scriptPreamble?: string },
97
+ ): string {
98
+ const scriptPath =
99
+ options?.scriptPath ??
100
+ join(
101
+ tmpdir(),
102
+ "pi-herdr-subagent-scripts",
103
+ `cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
104
+ );
105
+ mkdirSync(dirname(scriptPath), { recursive: true });
106
+
107
+ const scriptLines = ["#!/bin/bash"];
108
+ if (options?.scriptPreamble)
109
+ scriptLines.push(options.scriptPreamble.trimEnd());
110
+ scriptLines.push(command);
111
+ writeFileSync(scriptPath, `${scriptLines.join("\n")}\n`, { mode: 0o755 });
112
+
113
+ runInPane(paneId, `bash ${shellQuote(scriptPath)}`);
114
+ return scriptPath;
115
+ }
116
+
117
+ export function readPane(paneId: PaneId, lines = 50): string {
118
+ assertTerminalAvailable();
119
+ return readHerdrScreen(paneId, lines);
120
+ }
121
+
122
+ export async function readPaneAsync(
123
+ paneId: PaneId,
124
+ lines = 50,
125
+ ): Promise<string> {
126
+ assertTerminalAvailable();
127
+ return readHerdrScreenAsync(paneId, lines);
128
+ }
129
+
130
+ export type { PaneInspection, HerdrAgentStatus } from "./lifecycle.ts";
131
+
132
+ export async function inspectPane(
133
+ paneId: PaneId,
134
+ ): Promise<import("./lifecycle.ts").PaneInspection> {
135
+ assertTerminalAvailable();
136
+ const result = await inspectHerdrPane(paneId);
137
+ if (result.kind === "present") {
138
+ return { ...result, observedAt: Date.now() };
139
+ }
140
+ return result;
141
+ }
142
+
143
+ export function closePane(paneId: PaneId): void {
144
+ assertTerminalAvailable();
145
+ closeHerdrSurface(paneId);
146
+ }
147
+
148
+ export type { HerdrPaneProcessInfo };
149
+
150
+ export function getPaneProcessInfo(paneId: PaneId): HerdrPaneProcessInfo {
151
+ assertTerminalAvailable();
152
+ return getHerdrPaneProcessInfo(paneId);
153
+ }
154
+
155
+ export async function waitForPaneAbsence(
156
+ paneId: PaneId,
157
+ options?: { timeoutMs?: number; intervalMs?: number },
158
+ ): Promise<boolean> {
159
+ assertTerminalAvailable();
160
+ return waitForHerdrPaneAbsence(paneId, options);
161
+ }
162
+
163
+ export { isProcessAlive, waitForProcessesExit };
@@ -0,0 +1,56 @@
1
+ import vm from "node:vm";
2
+ import { parentPort, workerData } from "node:worker_threads";
3
+
4
+ const port = parentPort;
5
+ if (!port) throw new Error("workflow worker requires a parent port");
6
+
7
+ const pendingAgents = new Map();
8
+ let nextAgentId = 0;
9
+
10
+ function agent(prompt, options) {
11
+ if (
12
+ typeof prompt !== "string" ||
13
+ !options ||
14
+ typeof options !== "object" ||
15
+ Array.isArray(options) ||
16
+ Object.keys(options).length !== 2 ||
17
+ options.kind !== "review" ||
18
+ typeof options.role !== "string"
19
+ ) {
20
+ throw new Error("Workflow agent requires a prompt and { kind: 'review', role } options");
21
+ }
22
+ const id = String(++nextAgentId);
23
+ port.postMessage({ type: "agent", id, prompt, options });
24
+ return new Promise((resolve, reject) => pendingAgents.set(id, { resolve, reject }));
25
+ }
26
+
27
+ function log(message) {
28
+ port.postMessage({ type: "log", message });
29
+ }
30
+
31
+ port.on("message", (message) => {
32
+ if (message?.type !== "agent_result") return;
33
+ const pending = pendingAgents.get(message.id);
34
+ if (!pending) return;
35
+ pendingAgents.delete(message.id);
36
+ pending.resolve(message.result);
37
+ });
38
+
39
+ (async () => {
40
+ try {
41
+ const sandbox = Object.freeze({ agent: Object.freeze(agent), log: Object.freeze(log), console: undefined });
42
+ const context = vm.createContext(sandbox, {
43
+ codeGeneration: { strings: false, wasm: false },
44
+ });
45
+ const script = new vm.Script(`(async () => {\n${workerData.source}\n})()`, {
46
+ filename: workerData.filename,
47
+ });
48
+ const result = await script.runInContext(context);
49
+ port.postMessage({ type: "result", result });
50
+ } catch (error) {
51
+ port.postMessage({
52
+ type: "error",
53
+ message: error instanceof Error ? error.message : String(error),
54
+ });
55
+ }
56
+ })();