pi-herdr-agents 0.0.3 → 0.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-herdr-agents",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Asynchronous Pi subagents and approved review workflows in Herdr, with optional isolated Git worktrees",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,164 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import {
3
+ copyFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ unlinkSync,
9
+ } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { shellQuote } from "./terminal.ts";
12
+
13
+ const CLAUDE_SESSIONS_DIR = join(
14
+ process.env.HOME ?? "/tmp",
15
+ ".pi",
16
+ "agent",
17
+ "sessions",
18
+ "claude-code",
19
+ );
20
+
21
+ export interface ClaudeWorkspace {
22
+ baseline?: Set<string>;
23
+ cwd?: string;
24
+ }
25
+
26
+ export interface ClaudeLaunchParams {
27
+ cwd: string;
28
+ sentinelFile: string;
29
+ pluginDir: string;
30
+ model?: string;
31
+ systemPrompt?: string;
32
+ resumeSessionId?: string;
33
+ task: string;
34
+ }
35
+
36
+ export interface ClaudeCompletionParams extends ClaudeWorkspace {
37
+ sentinelFile: string;
38
+ exitCode: number;
39
+ readTerminal: () => string;
40
+ }
41
+
42
+ export interface ClaudeCompletion {
43
+ summary: string;
44
+ sessionId?: string;
45
+ }
46
+
47
+ export function requireClaudeAdapter(cli?: string): void {
48
+ if (cli && cli !== "claude") {
49
+ throw new Error(`Unsupported subagent CLI ${JSON.stringify(cli)}.`);
50
+ }
51
+ }
52
+
53
+ export function captureClaudeWorkspaceBaseline(cwd: string): Set<string> | undefined {
54
+ try {
55
+ const output = execFileSync(
56
+ "git",
57
+ ["status", "--porcelain=v1", "--untracked-files=all", "-z"],
58
+ { cwd, encoding: "utf8" },
59
+ );
60
+ return new Set(
61
+ output
62
+ .split("\0")
63
+ .filter(Boolean)
64
+ .map((entry) => entry.slice(3)),
65
+ );
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
71
+ export function cleanupClaudeWorkspace({ baseline, cwd }: ClaudeWorkspace): string | undefined {
72
+ if (!baseline || !cwd) return;
73
+
74
+ try {
75
+ const output = execFileSync(
76
+ "git",
77
+ ["status", "--porcelain=v1", "--untracked-files=all", "-z"],
78
+ { cwd, encoding: "utf8" },
79
+ );
80
+ const changed = output
81
+ .split("\0")
82
+ .filter(Boolean)
83
+ .map((entry) => ({ status: entry.slice(0, 2), path: entry.slice(3) }));
84
+ const cleaned: string[] = [];
85
+
86
+ for (const { status, path } of changed) {
87
+ if (baseline.has(path) || path.startsWith(".reviews/")) continue;
88
+ if (status === "??") {
89
+ rmSync(join(cwd, path), { recursive: true, force: true });
90
+ } else {
91
+ execFileSync("git", ["restore", "--staged", "--worktree", "--", path], {
92
+ cwd,
93
+ stdio: "ignore",
94
+ });
95
+ }
96
+ cleaned.push(path);
97
+ }
98
+
99
+ return cleaned.length > 0
100
+ ? `Claude workspace guard reverted newly introduced paths: ${cleaned.join(", ")}`
101
+ : undefined;
102
+ } catch (error: any) {
103
+ return `Claude workspace guard failed: ${error?.message ?? String(error)}`;
104
+ }
105
+ }
106
+
107
+ export function buildClaudeLaunchCommand(params: ClaudeLaunchParams): string {
108
+ const parts = [
109
+ `PI_CLAUDE_SENTINEL=${shellQuote(params.sentinelFile)}`,
110
+ "claude",
111
+ "--dangerously-skip-permissions",
112
+ ];
113
+ if (existsSync(params.pluginDir)) {
114
+ parts.push("--plugin-dir", shellQuote(params.pluginDir));
115
+ }
116
+ if (params.model) parts.push("--model", shellQuote(params.model));
117
+ if (params.systemPrompt) {
118
+ parts.push("--append-system-prompt", shellQuote(params.systemPrompt));
119
+ }
120
+ if (params.resumeSessionId) parts.push("--resume", shellQuote(params.resumeSessionId));
121
+ parts.push(shellQuote(params.task));
122
+ return `cd ${shellQuote(params.cwd)} && ${parts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
123
+ }
124
+
125
+ function copyClaudeSession(sentinelFile: string): string | undefined {
126
+ try {
127
+ const transcriptFile = `${sentinelFile}.transcript`;
128
+ if (!existsSync(transcriptFile)) return;
129
+ const transcriptPath = readFileSync(transcriptFile, "utf-8").trim();
130
+ if (!transcriptPath || !existsSync(transcriptPath)) return;
131
+ mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true });
132
+ const filename = transcriptPath.split("/").pop() ?? `claude-${Date.now()}.jsonl`;
133
+ copyFileSync(transcriptPath, join(CLAUDE_SESSIONS_DIR, filename));
134
+ return filename;
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
140
+ export function completeClaudeRun(params: ClaudeCompletionParams): ClaudeCompletion {
141
+ const guardMessage = cleanupClaudeWorkspace(params);
142
+ let summary = "";
143
+ try {
144
+ summary = readFileSync(params.sentinelFile, "utf-8").trim();
145
+ } catch {}
146
+ if (!summary) {
147
+ summary = params.readTerminal().replace(/__SUBAGENT_DONE_\d+__/, "").trimEnd();
148
+ }
149
+ if (!summary) {
150
+ summary =
151
+ params.exitCode !== 0
152
+ ? `Claude Code exited with code ${params.exitCode}`
153
+ : "Claude Code exited without output";
154
+ }
155
+ if (guardMessage) summary += `\n\n${guardMessage}`;
156
+
157
+ const sessionId = copyClaudeSession(params.sentinelFile);
158
+ for (const file of [params.sentinelFile, `${params.sentinelFile}.transcript`]) {
159
+ try {
160
+ unlinkSync(file);
161
+ } catch {}
162
+ }
163
+ return { summary, ...(sessionId ? { sessionId } : {}) };
164
+ }
@@ -21,8 +21,6 @@ import {
21
21
  writeFileSync,
22
22
  existsSync,
23
23
  mkdirSync,
24
- copyFileSync,
25
- unlinkSync,
26
24
  rmSync,
27
25
  renameSync,
28
26
  statSync,
@@ -45,6 +43,13 @@ import {
45
43
  waitForProcessesExit,
46
44
  } from "./terminal.ts";
47
45
  import { waitForCompletion } from "./completion.ts";
46
+ import {
47
+ buildClaudeLaunchCommand,
48
+ captureClaudeWorkspaceBaseline,
49
+ cleanupClaudeWorkspace,
50
+ completeClaudeRun,
51
+ requireClaudeAdapter,
52
+ } from "./claude.ts";
48
53
  import {
49
54
  buildAuthenticatedModelCatalog,
50
55
  resolveRuntimePlan,
@@ -804,69 +809,6 @@ function formatElapsed(seconds: number): string {
804
809
  * (for example direnv/devenv), so the delay is configurable for users who hit
805
810
  * dropped commands. Keep the historical default at 500ms.
806
811
  */
807
- function captureWorkspaceBaseline(cwd: string): Set<string> | undefined {
808
- try {
809
- const output = execFileSync(
810
- "git",
811
- ["status", "--porcelain=v1", "--untracked-files=all", "-z"],
812
- { cwd, encoding: "utf8" },
813
- );
814
- return new Set(
815
- output
816
- .split("\0")
817
- .filter(Boolean)
818
- .map((entry) => entry.slice(3)),
819
- );
820
- } catch {
821
- return undefined;
822
- }
823
- }
824
-
825
- function guardClaudeWorkspace(running: RunningSubagent): string | undefined {
826
- if (!running.workspaceBaseline || !running.workspaceCwd) return;
827
-
828
- try {
829
- const output = execFileSync(
830
- "git",
831
- ["status", "--porcelain=v1", "--untracked-files=all", "-z"],
832
- { cwd: running.workspaceCwd, encoding: "utf8" },
833
- );
834
- const changed = output
835
- .split("\0")
836
- .filter(Boolean)
837
- .map((entry) => ({
838
- status: entry.slice(0, 2),
839
- path: entry.slice(3),
840
- }));
841
- const newPaths = changed.filter(
842
- ({ path }) => !running.workspaceBaseline!.has(path),
843
- );
844
- const cleaned: string[] = [];
845
-
846
- for (const { status, path } of newPaths) {
847
- if (path.startsWith(".reviews/")) continue;
848
- if (status === "??") {
849
- rmSync(`${running.workspaceCwd}/${path}`, {
850
- recursive: true,
851
- force: true,
852
- });
853
- } else {
854
- execFileSync("git", ["restore", "--staged", "--worktree", "--", path], {
855
- cwd: running.workspaceCwd,
856
- stdio: "ignore",
857
- });
858
- }
859
- cleaned.push(path);
860
- }
861
-
862
- return cleaned.length > 0
863
- ? `Claude workspace guard reverted newly introduced paths: ${cleaned.join(", ")}`
864
- : undefined;
865
- } catch (error: any) {
866
- return `Claude workspace guard failed: ${error?.message ?? String(error)}`;
867
- }
868
- }
869
-
870
812
  function getShellReadyDelayMs(): number {
871
813
  const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim();
872
814
  const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
@@ -2119,6 +2061,7 @@ async function launchSubagent(
2119
2061
  diagnostic?.message ?? `Agent "${params.agent}" was not found.`,
2120
2062
  );
2121
2063
  }
2064
+ requireClaudeAdapter(agentDefs?.cli);
2122
2065
  if (!ctx.model)
2123
2066
  throw new Error("Subagent launch requires a resolved parent model");
2124
2067
  const runtimePlan = resolveRuntimePlan(
@@ -2244,7 +2187,7 @@ async function launchSubagent(
2244
2187
  );
2245
2188
  const workspaceBaseline =
2246
2189
  agentDefs?.cli === "claude" && !worktree
2247
- ? captureWorkspaceBaseline(targetCwdForSession)
2190
+ ? captureClaudeWorkspaceBaseline(targetCwdForSession)
2248
2191
  : undefined;
2249
2192
 
2250
2193
  // Generate a deterministic session file path for this subagent.
@@ -2312,35 +2255,15 @@ async function launchSubagent(
2312
2255
  const sentinelFile = `/tmp/pi-claude-${id}-done`;
2313
2256
  const pluginDir = join(SUBAGENTS_DIR, "plugin");
2314
2257
 
2315
- const cmdParts: string[] = [];
2316
- cmdParts.push(`PI_CLAUDE_SENTINEL=${shellQuote(sentinelFile)}`);
2317
- cmdParts.push("claude");
2318
- cmdParts.push("--dangerously-skip-permissions");
2319
-
2320
- if (existsSync(pluginDir)) {
2321
- cmdParts.push("--plugin-dir", shellQuote(pluginDir));
2322
- }
2323
-
2324
- const cliModel = agentDefs.cliModel ?? effectiveModel;
2325
- if (cliModel) {
2326
- cmdParts.push("--model", shellQuote(cliModel));
2327
- }
2328
-
2329
- const sp = params.systemPrompt ?? agentDefs.body;
2330
- if (sp) {
2331
- cmdParts.push("--append-system-prompt", shellQuote(sp));
2332
- }
2333
-
2334
- if (params.resumeSessionId) {
2335
- cmdParts.push("--resume", shellQuote(params.resumeSessionId));
2336
- }
2337
-
2338
- // Always pass the task as the prompt — even for resumed sessions,
2339
- // the caller's task is the follow-up instruction.
2340
- cmdParts.push(shellQuote(params.task));
2341
-
2342
- const cdPrefix = `cd ${shellQuote(targetCwdForSession)} && `;
2343
- const command = `${cdPrefix}${cmdParts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
2258
+ const command = buildClaudeLaunchCommand({
2259
+ cwd: targetCwdForSession,
2260
+ sentinelFile,
2261
+ pluginDir,
2262
+ model: agentDefs.cliModel ?? effectiveModel,
2263
+ systemPrompt: params.systemPrompt ?? agentDefs.body,
2264
+ resumeSessionId: params.resumeSessionId,
2265
+ task: params.task,
2266
+ });
2344
2267
 
2345
2268
  const launchScriptName = `${
2346
2269
  (params.name || "subagent")
@@ -2561,31 +2484,6 @@ async function launchSubagent(
2561
2484
  * the summary from the session file, and closes ordinary panes. Worktree
2562
2485
  * workspaces are retained for parent review.
2563
2486
  */
2564
- const CLAUDE_SESSIONS_DIR = join(
2565
- process.env.HOME ?? "/tmp",
2566
- ".pi",
2567
- "agent",
2568
- "sessions",
2569
- "claude-code",
2570
- );
2571
-
2572
- function copyClaudeSession(sentinelFile: string): string | null {
2573
- try {
2574
- const transcriptFile = sentinelFile + ".transcript";
2575
- if (!existsSync(transcriptFile)) return null;
2576
- const transcriptPath = readFileSync(transcriptFile, "utf-8").trim();
2577
- if (!transcriptPath || !existsSync(transcriptPath)) return null;
2578
- mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true });
2579
- const filename =
2580
- transcriptPath.split("/").pop() ?? `claude-${Date.now()}.jsonl`;
2581
- const dest = join(CLAUDE_SESSIONS_DIR, filename);
2582
- copyFileSync(transcriptPath, dest);
2583
- return filename;
2584
- } catch {
2585
- return null;
2586
- }
2587
- }
2588
-
2589
2487
  async function watchSubagent(
2590
2488
  running: RunningSubagent,
2591
2489
  signal: AbortSignal,
@@ -2623,41 +2521,13 @@ async function watchSubagent(
2623
2521
  const elapsed = Math.floor((detectedAt - startTime) / 1000);
2624
2522
 
2625
2523
  if (running.cli === "claude") {
2626
- // Claude Code result extraction
2627
- const guardMessage = guardClaudeWorkspace(running);
2628
- let summary = "";
2629
-
2630
- if (running.sentinelFile) {
2631
- try {
2632
- summary = readFileSync(running.sentinelFile, "utf-8").trim();
2633
- } catch {}
2634
- }
2635
-
2636
- if (!summary) {
2637
- summary = readPane(surface, 200)
2638
- .replace(/__SUBAGENT_DONE_\d+__/, "")
2639
- .trimEnd();
2640
- }
2641
-
2642
- if (!summary) {
2643
- summary =
2644
- result.exitCode !== 0
2645
- ? `Claude Code exited with code ${result.exitCode}`
2646
- : "Claude Code exited without output";
2647
- }
2648
- if (guardMessage) summary += `\n\n${guardMessage}`;
2649
-
2650
- // Copy Claude session transcript
2651
- let sessionId: string | null = null;
2652
- if (running.sentinelFile) {
2653
- sessionId = copyClaudeSession(running.sentinelFile);
2654
- try {
2655
- unlinkSync(running.sentinelFile);
2656
- } catch {}
2657
- try {
2658
- unlinkSync(running.sentinelFile + ".transcript");
2659
- } catch {}
2660
- }
2524
+ const claudeCompletion = completeClaudeRun({
2525
+ sentinelFile: running.sentinelFile!,
2526
+ exitCode: result.exitCode,
2527
+ baseline: running.workspaceBaseline,
2528
+ cwd: running.workspaceCwd,
2529
+ readTerminal: () => readPane(surface, 200),
2530
+ });
2661
2531
 
2662
2532
  const worktreeHandoff = finalizeSubagentSurface(
2663
2533
  running,
@@ -2668,7 +2538,7 @@ async function watchSubagent(
2668
2538
  ? markCompleted(running.lifecycle, Date.now())
2669
2539
  : markFailed(
2670
2540
  running.lifecycle,
2671
- result.errorMessage ?? summary,
2541
+ result.errorMessage ?? claudeCompletion.summary,
2672
2542
  Date.now(),
2673
2543
  result.exitCode,
2674
2544
  );
@@ -2676,10 +2546,12 @@ async function watchSubagent(
2676
2546
  return {
2677
2547
  name,
2678
2548
  task,
2679
- summary,
2549
+ summary: claudeCompletion.summary,
2680
2550
  exitCode: result.exitCode,
2681
2551
  elapsed,
2682
- ...(sessionId ? { claudeSessionId: sessionId } : {}),
2552
+ ...(claudeCompletion.sessionId
2553
+ ? { claudeSessionId: claudeCompletion.sessionId }
2554
+ : {}),
2683
2555
  ...(worktreeHandoff ? { worktree: worktreeHandoff } : {}),
2684
2556
  };
2685
2557
  }
@@ -2761,7 +2633,12 @@ async function watchSubagent(
2761
2633
  };
2762
2634
  } catch (err: any) {
2763
2635
  const guardMessage =
2764
- running.cli === "claude" ? guardClaudeWorkspace(running) : undefined;
2636
+ running.cli === "claude"
2637
+ ? cleanupClaudeWorkspace({
2638
+ baseline: running.workspaceBaseline,
2639
+ cwd: running.workspaceCwd,
2640
+ })
2641
+ : undefined;
2765
2642
  const worktreeHandoff = finalizeSubagentSurface(running, "failed", true);
2766
2643
  running.lifecycle = markFailed(
2767
2644
  running.lifecycle,