xz-pi-side-agents-herdr 0.1.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 385169d: heder 兼容
8
+
9
+ ## 0.1.0
10
+
11
+ - Initial Herdr-native side-agent implementation.
12
+ - Added `/agent`, `/agents`, `/agent-resume` and orchestration tools.
13
+ - Added one-worktree-per-pane lifecycle and guarded `/quit` cleanup.
14
+ - Added project initialization skill with configurable integration branch.
15
+ - Fixed bootstrap completion detection racing with echoed shell commands.
16
+ - Explicitly loads the lifecycle extension in locally launched children.
17
+ - Added an arbitrary-key confirmation before deleting a merged worktree and closing its pane.
18
+ - Reconciles stale registry records when their worktree and pane are already gone, while preserving unmerged branches for resume.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xz-pi contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # xz-pi-side-agents-herdr
2
+
3
+ Herdr-native asynchronous side agents for Pi. It keeps the command/tool surface of `pi-side-agents`, but maps each child Git worktree to a visible Herdr pane instead of a tmux window.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 22+
8
+ - Pi 0.84.4+
9
+ - Herdr 0.9+
10
+ - A Git repository
11
+ - Pi must run inside Herdr (`HERDR_ENV=1`)
12
+
13
+ Do not enable this package together with `pi-side-agents`; both register the same commands and tools.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pi install ./xz-pi-side-agents-herdr
19
+ ```
20
+
21
+ Restart Pi, then initialize the current repository:
22
+
23
+ ```text
24
+ /skill:agent-setup
25
+ ```
26
+
27
+ The setup skill defaults `mainBranch` to the branch checked out during initialization. A different local branch can be specified in the setup request.
28
+
29
+ ## Commands
30
+
31
+ ```text
32
+ /agent [-model <provider/id>] [-mode <name>] <task>
33
+ /agents
34
+ /agent-resume
35
+ ```
36
+
37
+ Tools exposed to the parent agent:
38
+
39
+ ```text
40
+ agent-start
41
+ agent-check
42
+ agent-wait-any
43
+ agent-send
44
+ ```
45
+
46
+ ## Model
47
+
48
+ A child is represented by:
49
+
50
+ ```text
51
+ Agent: fix-auth-0001
52
+ Branch: side-agent/fix-auth-0001
53
+ Worktree: ../repo-agent-fix-auth-0001
54
+ Pane: Herdr pane id such as w1:p4
55
+ ```
56
+
57
+ The worktree starts from the configured integration branch. The first child opens in a narrow right column (`mainPaneRatio` defaults to `0.72`, so main stays largest). Additional children split the largest child pane downward, keeping a balanced stack on the right without shrinking main again. The extension preserves focus, runs project bootstrap in the child pane, starts Pi through `herdr agent start`, and submits the task through `herdr agent prompt`.
58
+
59
+ ## `/quit` safety
60
+
61
+ On child `/quit`, cleanup occurs only when both conditions hold:
62
+
63
+ 1. `git status --porcelain` is empty;
64
+ 2. the child branch has no commit missing from the configured integration branch.
65
+
66
+ When safe, the child exits to its shell and asks for one final arbitrary-key confirmation in the Herdr pane. After a key is pressed, a detached janitor removes the worktree, deletes the already-merged topic branch, closes the pane, and removes the registry entry.
67
+
68
+ If uncommitted or unmerged work exists, cleanup is skipped. The worktree and pane remain, the registry status becomes `paused`, and `/agent-resume` can reopen the child session.
69
+
70
+ ## Local state
71
+
72
+ ```text
73
+ .pi/side-agents-herdr/config.json
74
+ .pi/side-agents-herdr/registry.json
75
+ .pi/side-agents-herdr/registry.lock
76
+ .pi/side-agent-start.sh
77
+ .pi/side-agent-finish.sh
78
+ .pi/side-agent-skills/
79
+ ```
80
+
81
+ These files are local runtime configuration and should not be committed.
package/index.ts ADDED
@@ -0,0 +1,190 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+ import { Type } from "typebox";
6
+ import { handleChildQuit, linkChildSession } from "./src/lifecycle.js";
7
+ import { repoRoot, SideAgentService } from "./src/service.js";
8
+ import { ENV, type AgentRecord } from "./src/types.js";
9
+
10
+ const terminal = new Set(["waiting_user", "blocked", "paused", "failed", "crashed"]);
11
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
12
+
13
+ export function parseAgentArgs(raw: string): { task: string; model?: string; mode?: string } {
14
+ let rest = raw;
15
+ const modelMatch = rest.match(/(?:^|\s)-model\s+(\S+)/);
16
+ const modeMatch = rest.match(/(?:^|\s)-mode\s+(\S+)/);
17
+ if (modelMatch) rest = rest.replace(modelMatch[0], " ");
18
+ if (modeMatch) rest = rest.replace(modeMatch[0], " ");
19
+ return { task: rest.trim(), model: modelMatch?.[1], mode: modeMatch?.[1] };
20
+ }
21
+
22
+ async function modelFor(ctx: ExtensionContext, requested?: string, mode?: string): Promise<string | undefined> {
23
+ if (requested) return requested.includes("/") ? requested : requested;
24
+ if (mode) {
25
+ for (const path of [join(ctx.cwd, ".pi", "modes.json"), join(process.env.PI_CODING_AGENT_DIR ?? join(process.env.HOME ?? "", ".pi", "agent"), "modes.json")]) {
26
+ try {
27
+ const value = JSON.parse(await readFile(path, "utf8")) as { modes?: Record<string, { provider?: string; modelId?: string; thinkingLevel?: string }> };
28
+ const spec = value.modes?.[mode];
29
+ if (spec?.provider && spec.modelId) return `${spec.provider}/${spec.modelId}${spec.thinkingLevel ? `:${spec.thinkingLevel}` : ""}`;
30
+ } catch { /* try next */ }
31
+ }
32
+ throw new Error(`Mode '${mode}' was not found in .pi/modes.json or the global modes.json`);
33
+ }
34
+ return ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
35
+ }
36
+
37
+ async function serviceFor(ctx: ExtensionContext): Promise<SideAgentService> {
38
+ const root = process.env[ENV.stateRoot] || await repoRoot(ctx.cwd);
39
+ return new SideAgentService(root);
40
+ }
41
+
42
+ function summary(record: AgentRecord): string {
43
+ return `${record.id} ${record.status} pane:${record.paneId ?? "-"} worktree:${record.worktreePath}\n task: ${record.task.slice(0, 180)}`;
44
+ }
45
+
46
+ export default function sideAgentsHerdr(pi: ExtensionAPI): void {
47
+ if (process.env[ENV.agentId]) {
48
+ pi.on("session_start", async (_event, ctx) => { await linkChildSession(await serviceFor(ctx), ctx).catch(() => undefined); });
49
+ pi.on("agent_start", async (_event, ctx) => { const service = await serviceFor(ctx); await service.update(process.env[ENV.agentId]!, { status: "running" }).catch(() => undefined); });
50
+ pi.on("agent_end", async (_event, ctx) => { const service = await serviceFor(ctx); await service.update(process.env[ENV.agentId]!, { status: "waiting_user" }).catch(() => undefined); });
51
+ pi.on("session_shutdown", async (event, ctx) => {
52
+ if (event.reason === "quit") await handleChildQuit(await serviceFor(ctx), ctx).catch(error => console.error(`[side-agent] quit cleanup check failed: ${String(error)}`));
53
+ });
54
+ return;
55
+ }
56
+
57
+ let statusTimer: NodeJS.Timeout | undefined;
58
+ const updateStatus = async (ctx: ExtensionContext) => {
59
+ if (!ctx.hasUI) return;
60
+ try {
61
+ const records = await (await serviceFor(ctx)).list();
62
+ const text = records.map(record => `${record.id}:${record.status}@${record.paneId ?? "-"}`).join(" ");
63
+ ctx.ui.setStatus("side-agents-herdr", text || undefined);
64
+ } catch { /* status is best effort */ }
65
+ };
66
+ pi.on("session_start", (_event, ctx) => {
67
+ if (ctx.hasUI && !statusTimer) {
68
+ statusTimer = setInterval(() => void updateStatus(ctx), 2500);
69
+ statusTimer.unref();
70
+ }
71
+ void updateStatus(ctx);
72
+ });
73
+ pi.on("session_shutdown", () => { if (statusTimer) clearInterval(statusTimer); statusTimer = undefined; });
74
+
75
+ pi.registerCommand("agent", {
76
+ description: "Spawn a background child Pi agent in a Herdr pane/worktree: /agent [-model <provider/id>] [-mode <name>] <task>",
77
+ handler: async (args, ctx) => {
78
+ const parsed = parseAgentArgs(args);
79
+ if (!parsed.task) { ctx.ui.notify("Usage: /agent [-model <provider/id>] [-mode <name>] <task>", "error"); return; }
80
+ if (!ctx.isProjectTrusted()) { ctx.ui.notify("Side-agent creation requires a trusted project", "error"); return; }
81
+ try {
82
+ const result = await (await serviceFor(ctx)).start(ctx, { task: parsed.task, model: await modelFor(ctx, parsed.model, parsed.mode) });
83
+ ctx.ui.notify(`Started ${result.id}\npane: ${result.paneId}\nworktree: ${result.worktreePath}\nbranch: ${result.branch}`, "info");
84
+ await updateStatus(ctx);
85
+ } catch (error) { ctx.ui.notify(`Failed to start agent: ${error instanceof Error ? error.message : String(error)}`, "error"); }
86
+ },
87
+ });
88
+
89
+ pi.registerCommand("agents", {
90
+ description: "List tracked side agents",
91
+ handler: async (_args, ctx) => {
92
+ try {
93
+ const records = await (await serviceFor(ctx)).list();
94
+ ctx.ui.notify(records.length ? records.map(summary).join("\n\n") : "No tracked side agents.", "info");
95
+ } catch (error) { ctx.ui.notify(String(error), "error"); }
96
+ },
97
+ });
98
+
99
+ pi.registerCommand("agent-resume", {
100
+ description: "Resume a previously /quit side-agent session in its retained worktree/Herdr pane",
101
+ handler: async (args, ctx) => {
102
+ if (args.trim()) { ctx.ui.notify("/agent-resume takes no arguments", "error"); return; }
103
+ if (!ctx.hasUI) return;
104
+ const service = await serviceFor(ctx);
105
+ const candidates = (await service.list()).filter(record => ["paused", "failed", "crashed"].includes(record.status) && record.childSessionId);
106
+ if (!candidates.length) { ctx.ui.notify("No resumable side-agent sessions.", "info"); return; }
107
+ const labels = candidates.map(summary);
108
+ const selected = await ctx.ui.select("Resume side-agent", labels);
109
+ if (!selected) return;
110
+ const candidate = candidates[labels.indexOf(selected)];
111
+ try {
112
+ const result = await service.start(ctx, { task: candidate.task, resumeId: candidate.id });
113
+ ctx.ui.notify(`Resumed ${result.id} in pane ${result.paneId}`, "info");
114
+ } catch (error) { ctx.ui.notify(`Failed to resume: ${String(error)}`, "error"); }
115
+ },
116
+ });
117
+
118
+ pi.registerTool({
119
+ name: "agent-start", label: "Agent Start",
120
+ description: "Start a background side agent in a Herdr pane and isolated Git worktree. Commands match pi-side-agents. Returns { ok, id, task, paneId, tabId, workspaceId, worktreePath, branch, warnings }.",
121
+ parameters: Type.Object({
122
+ description: Type.String({ description: "Self-contained task description" }),
123
+ branchHint: Type.String({ description: "Short kebab-case feature slug, max 3 words" }),
124
+ model: Type.Optional(Type.String({ description: "Optional provider/modelId" })),
125
+ }),
126
+ async execute(_id, params, signal, _update, ctx) {
127
+ try {
128
+ signal?.throwIfAborted();
129
+ if (!ctx.isProjectTrusted()) throw new Error("agent-start requires a trusted project");
130
+ const result = await (await serviceFor(ctx)).start(ctx, { task: params.description, branchHint: params.branchHint, model: await modelFor(ctx, params.model) });
131
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
132
+ } catch (error) {
133
+ const result = { ok: false, error: error instanceof Error ? error.message : String(error) };
134
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
135
+ }
136
+ },
137
+ renderCall(args, theme) { return new Text(theme.fg("toolTitle", `Agent Start · ${args.branchHint}`), 0, 0); },
138
+ });
139
+
140
+ pi.registerTool({
141
+ name: "agent-check", label: "Agent Check",
142
+ description: "Check a side agent and return its Herdr status, pane/worktree metadata, and recent terminal output.",
143
+ parameters: Type.Object({ id: Type.String({ description: "Agent id" }) }),
144
+ async execute(_id, params, _signal, _update, ctx) {
145
+ try { const value = await (await serviceFor(ctx)).check(params.id); return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value }; }
146
+ catch (error) { const value = { ok: false, error: String(error) }; return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value }; }
147
+ },
148
+ renderCall(args, theme) { return new Text(theme.fg("toolTitle", `Agent Check · ${args.id}`), 0, 0); },
149
+ });
150
+
151
+ pi.registerTool({
152
+ name: "agent-wait-any", label: "Agent Wait Any",
153
+ description: "Wait until any requested side agent finishes, yields, blocks, pauses, fails, or crashes.",
154
+ parameters: Type.Object({ ids: Type.Array(Type.String(), { minItems: 1 }) }),
155
+ async execute(_id, params, signal, _update, ctx) {
156
+ const service = await serviceFor(ctx);
157
+ const known = new Set<string>();
158
+ while (!signal?.aborted) {
159
+ for (const id of [...new Set(params.ids)]) {
160
+ const value = await service.check(id);
161
+ if (value.ok === true) {
162
+ known.add(id);
163
+ const status = (value.agent as AgentRecord).status;
164
+ if (terminal.has(status)) return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
165
+ } else if (known.has(id)) {
166
+ const done = { ok: true, agent: { id, status: "done" }, backlog: [] };
167
+ return { content: [{ type: "text", text: JSON.stringify(done, null, 2) }], details: done };
168
+ } else {
169
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
170
+ }
171
+ }
172
+ await sleep(1000);
173
+ }
174
+ const aborted = { ok: false, error: "agent-wait-any aborted" };
175
+ return { content: [{ type: "text", text: JSON.stringify(aborted, null, 2) }], details: aborted };
176
+ },
177
+ renderCall(args, theme) { return new Text(theme.fg("toolTitle", `Agent Wait · ${args.ids?.join(", ") ?? ""}`), 0, 0); },
178
+ });
179
+
180
+ pi.registerTool({
181
+ name: "agent-send", label: "Agent Send",
182
+ description: "Send a prompt to a side agent through Herdr. Prefix ! interrupts first; slash commands such as /quit are forwarded unchanged.",
183
+ parameters: Type.Object({ id: Type.String(), prompt: Type.String() }),
184
+ async execute(_id, params, _signal, _update, ctx) {
185
+ try { const value = await (await serviceFor(ctx)).send(params.id, params.prompt); return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value }; }
186
+ catch (error) { const value = { ok: false, message: String(error) }; return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value }; }
187
+ },
188
+ renderCall(args, theme) { return new Text(theme.fg("toolTitle", `Agent Send · ${args.id}`), 0, 0); },
189
+ });
190
+ }
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "xz-pi-side-agents-herdr",
3
+ "version": "0.1.0",
4
+ "description": "Herdr-native side-agent orchestration for Pi with one Git worktree per pane",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "agents",
10
+ "herdr",
11
+ "worktree"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/Xuzan9396/xz-pi.git",
17
+ "directory": "xz-pi-side-agents-herdr"
18
+ },
19
+ "homepage": "https://github.com/Xuzan9396/xz-pi/tree/main/xz-pi-side-agents-herdr",
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "provenance": true
23
+ },
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "files": [
28
+ "index.ts",
29
+ "src",
30
+ "scripts",
31
+ "skills",
32
+ "README.md",
33
+ "LICENSE",
34
+ "CHANGELOG.md"
35
+ ],
36
+ "pi": {
37
+ "extensions": [
38
+ "./index.ts"
39
+ ],
40
+ "skills": [
41
+ "./skills"
42
+ ]
43
+ },
44
+ "scripts": {
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "node --import tsx/esm --test 'test/**/*.test.ts'",
47
+ "check": "npm run typecheck && npm test"
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-ai": ">=0.84.4",
51
+ "@earendil-works/pi-coding-agent": ">=0.84.4",
52
+ "@earendil-works/pi-tui": ">=0.84.4",
53
+ "typebox": "*"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "@earendil-works/pi-ai": {
57
+ "optional": true
58
+ },
59
+ "@earendil-works/pi-coding-agent": {
60
+ "optional": true
61
+ },
62
+ "@earendil-works/pi-tui": {
63
+ "optional": true
64
+ },
65
+ "typebox": {
66
+ "optional": true
67
+ }
68
+ },
69
+ "devDependencies": {
70
+ "@earendil-works/pi-ai": "0.84.4",
71
+ "@earendil-works/pi-coding-agent": "0.84.4",
72
+ "@earendil-works/pi-tui": "0.84.4",
73
+ "@types/node": "^22.0.0",
74
+ "tsx": "^4.20.0",
75
+ "typebox": "1.3.7",
76
+ "typescript": "^5.9.0"
77
+ }
78
+ }
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from "node:child_process";
3
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const exec = promisify(execFile);
8
+ const payload = JSON.parse(Buffer.from(process.argv[2] ?? "", "base64url").toString("utf8"));
9
+ const dir = join(payload.root, ".pi", "side-agents-herdr");
10
+ const registryPath = join(dir, "registry.json");
11
+ const lockPath = join(dir, "registry.lock");
12
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
13
+ const shellQuote = value => `'${String(value).replace(/'/g, `'"'"'`)}'`;
14
+
15
+ async function mutate(fn) {
16
+ await mkdir(dir, { recursive: true });
17
+ const started = Date.now();
18
+ while (true) {
19
+ try { const handle = await open(lockPath, "wx", 0o600); await handle.close(); break; }
20
+ catch (error) {
21
+ if (error.code !== "EEXIST") throw error;
22
+ const age = Date.now() - (await stat(lockPath).catch(() => ({ mtimeMs: Date.now() }))).mtimeMs;
23
+ if (age > 30_000) { await rm(lockPath, { force: true }); continue; }
24
+ if (Date.now() - started > 10_000) throw new Error(`registry lock timeout: ${lockPath}`);
25
+ await sleep(50);
26
+ }
27
+ }
28
+ try {
29
+ const registry = JSON.parse(await readFile(registryPath, "utf8"));
30
+ await fn(registry);
31
+ const temporary = `${registryPath}.${process.pid}.tmp`;
32
+ await writeFile(temporary, JSON.stringify(registry, null, 2) + "\n", { mode: 0o600 });
33
+ await rename(temporary, registryPath);
34
+ } finally { await rm(lockPath, { force: true }); }
35
+ }
36
+
37
+ try {
38
+ // Wait for Pi to finish session persistence and release its Herdr agent name.
39
+ // A bounded fallback still prevents a stuck detector from leaking cleanup forever.
40
+ for (let attempt = 0; attempt < 100; attempt += 1) {
41
+ const live = await exec("herdr", ["agent", "get", payload.id]).then(() => true, () => false);
42
+ if (!live) break;
43
+ await sleep(100);
44
+ }
45
+ if (payload.paneId) {
46
+ const confirmation = join(dir, `cleanup-confirm-${payload.id}`);
47
+ await rm(confirmation, { force: true });
48
+ const prompt = "[side-agent] Work is clean and merged. Press any key to remove the worktree and close this pane...";
49
+ const inner = `printf '%s' ${shellQuote(prompt)}; IFS= read -r -n 1 _; printf '\\n'; : > ${shellQuote(confirmation)}`;
50
+ await exec("herdr", ["pane", "run", payload.paneId, `bash -lc ${shellQuote(inner)}`]);
51
+ while (true) {
52
+ const confirmed = await stat(confirmation).then(() => true, () => false);
53
+ if (confirmed) break;
54
+ const paneExists = await exec("herdr", ["pane", "get", payload.paneId]).then(() => true, () => false);
55
+ if (!paneExists) {
56
+ await mutate(registry => {
57
+ const record = registry.agents[payload.id];
58
+ if (!record) return;
59
+ record.status = "paused";
60
+ record.warnings = [...(record.warnings ?? []), "Cleanup confirmation pane was closed; worktree was preserved."];
61
+ record.updatedAt = new Date().toISOString();
62
+ });
63
+ process.exit(0);
64
+ }
65
+ await sleep(200);
66
+ }
67
+ await rm(confirmation, { force: true });
68
+ }
69
+ await exec("git", ["-C", payload.root, "worktree", "remove", payload.worktreePath], { env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
70
+ await exec("git", ["-C", payload.root, "worktree", "prune"], { env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
71
+ // The child already checked this before requesting cleanup; verify again to
72
+ // avoid deleting a branch if the integration ref changed during shutdown.
73
+ const ancestry = await exec("git", ["-C", payload.root, "merge-base", "--is-ancestor", payload.branch, payload.mainBranch]).then(() => true, () => false);
74
+ if (ancestry) await exec("git", ["-C", payload.root, "branch", "-D", payload.branch], { env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
75
+ if (payload.paneId) await exec("herdr", ["pane", "close", payload.paneId]).catch(() => undefined);
76
+ await mutate(registry => { delete registry.agents[payload.id]; });
77
+ } catch (error) {
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ await mutate(registry => {
80
+ const record = registry.agents[payload.id];
81
+ if (!record) return;
82
+ const paneGone = message.includes("pane_not_found");
83
+ record.status = paneGone ? "paused" : "failed";
84
+ if (paneGone) {
85
+ record.warnings = [...(record.warnings ?? []), "Cleanup confirmation pane disappeared; worktree was preserved."];
86
+ delete record.error;
87
+ } else {
88
+ record.error = `Cleanup failed: ${message}`;
89
+ }
90
+ record.updatedAt = new Date().toISOString();
91
+ }).catch(() => undefined);
92
+ }
@@ -0,0 +1,141 @@
1
+ ---
2
+ name: agent-setup
3
+ description: Initialize or update project-local configuration for xz-pi-side-agents-herdr, including the integration branch and lifecycle scripts
4
+ ---
5
+
6
+ # Herdr Side-Agent Setup
7
+
8
+ Initialize this repository for `xz-pi-side-agents-herdr`. Everything created below is local runtime configuration under `.pi/`; do not commit it.
9
+
10
+ ## 1. Determine configuration
11
+
12
+ Find the repository root and current branch:
13
+
14
+ ```bash
15
+ GIT_ROOT=$(git rev-parse --show-toplevel)
16
+ CURRENT_BRANCH=$(git -C "$GIT_ROOT" branch --show-current)
17
+ ```
18
+
19
+ Use the current branch as the default integration branch. If the user supplied a branch in the skill arguments, use that instead. Verify the branch exists with:
20
+
21
+ ```bash
22
+ git -C "$GIT_ROOT" show-ref --verify "refs/heads/$MAIN_BRANCH"
23
+ ```
24
+
25
+ Ask before overwriting any existing files. Ask whether the project needs bootstrap commands such as dependency installation or copying local environment files.
26
+
27
+ Keep runtime files out of Git status without changing the tracked `.gitignore`. Ensure the shared Git exclude file contains these lines exactly once:
28
+
29
+ ```gitignore
30
+ .pi/side-agents-herdr/
31
+ .pi/side-agent-start.sh
32
+ .pi/side-agent-finish.sh
33
+ .pi/side-agent-skills
34
+ ```
35
+
36
+ Resolve the shared exclude path with `git -C "$GIT_ROOT" rev-parse --git-path info/exclude`, create its parent directory if needed, and append only missing lines. This exclude is shared by the main checkout and linked worktrees.
37
+
38
+ ## 2. Create `.pi/side-agents-herdr/config.json`
39
+
40
+ ```json
41
+ {
42
+ "version": 1,
43
+ "mainBranch": "MAIN_BRANCH_VALUE",
44
+ "mainPaneRatio": 0.72,
45
+ "childSplitRatio": 0.5,
46
+ "cleanupOnQuit": true
47
+ }
48
+ ```
49
+
50
+ The first child opens on the right. `mainPaneRatio: 0.72` keeps the main pane at roughly 72% width. Additional children split the largest child pane downward at `childSplitRatio`, producing a balanced stack in the right column without shrinking the main pane.
51
+
52
+ ## 3. Create executable `.pi/side-agent-start.sh`
53
+
54
+ ```bash
55
+ #!/usr/bin/env bash
56
+ set -euo pipefail
57
+
58
+ PARENT_ROOT="${1:?parent repository is required}"
59
+ WORKTREE="${2:?worktree is required}"
60
+ AGENT_ID="${3:?agent id is required}"
61
+ MAIN_BRANCH="MAIN_BRANCH_VALUE"
62
+ BRANCH="$(git -C "$WORKTREE" branch --show-current)"
63
+
64
+ if [[ -z "$BRANCH" || "$BRANCH" == "$MAIN_BRANCH" ]]; then
65
+ echo "[side-agent-start] invalid child branch: $BRANCH"
66
+ exit 1
67
+ fi
68
+
69
+ echo "[side-agent-start] agent=$AGENT_ID branch=$BRANCH main=$MAIN_BRANCH"
70
+ echo "[side-agent-start] base=$(git -C "$WORKTREE" rev-parse --short HEAD)"
71
+
72
+ # Add project-specific bootstrap commands below this line.
73
+ ```
74
+
75
+ Append bootstrap commands agreed with the user, then run `chmod +x`.
76
+
77
+ ## 4. Create executable `.pi/side-agent-finish.sh`
78
+
79
+ ```bash
80
+ #!/usr/bin/env bash
81
+ set -euo pipefail
82
+
83
+ PARENT_ROOT="${PI_SIDE_PARENT_REPO:?PI_SIDE_PARENT_REPO is required}"
84
+ MAIN_BRANCH="MAIN_BRANCH_VALUE"
85
+ BRANCH="$(git branch --show-current)"
86
+
87
+ if [[ -n "$(git status --porcelain)" ]]; then
88
+ echo "[side-agent-finish] commit or discard local changes first"
89
+ exit 2
90
+ fi
91
+
92
+ LOCK_DIR="$PARENT_ROOT/.pi/side-agents-herdr/merge.lock"
93
+ ACQUIRED=0
94
+ for _ in $(seq 1 120); do
95
+ if mkdir "$LOCK_DIR" 2>/dev/null; then ACQUIRED=1; break; fi
96
+ sleep 1
97
+ done
98
+ if [[ "$ACQUIRED" != 1 ]]; then
99
+ echo "[side-agent-finish] timed out waiting for merge lock"
100
+ exit 3
101
+ fi
102
+ trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT
103
+
104
+ git rebase "$MAIN_BRANCH"
105
+ git -C "$PARENT_ROOT" checkout "$MAIN_BRANCH"
106
+ git -C "$PARENT_ROOT" merge --ff-only "$BRANCH"
107
+ echo "[side-agent-finish] merged $BRANCH into $MAIN_BRANCH"
108
+ ```
109
+
110
+ Run `chmod +x`.
111
+
112
+ ## 5. Create `.pi/side-agent-skills/finish/SKILL.md`
113
+
114
+ ```markdown
115
+ ---
116
+ name: finish
117
+ description: Finish a Herdr side-agent branch after explicit user approval by rebasing and fast-forwarding it into the configured integration branch
118
+ ---
119
+
120
+ # Finish side-agent work
121
+
122
+ Only after explicit approval such as “LGTM, merge”:
123
+
124
+ 1. Ensure all intended changes are committed.
125
+ 2. Run `.pi/side-agent-finish.sh`.
126
+ 3. Resolve any rebase conflict and rerun the script.
127
+ 4. Report the landed commits.
128
+ 5. Run `/quit` when done. After Pi exits, press any key at the pane confirmation prompt; the extension then removes the clean, fully merged worktree and closes its Herdr pane.
129
+
130
+ If `/quit` detects uncommitted changes or commits not merged into the integration branch, it preserves both the worktree and pane and reports why cleanup was skipped.
131
+ ```
132
+
133
+
134
+ ## 6. Report
135
+
136
+ List files created, updated, or skipped. Remind the user:
137
+
138
+ - Start: `/agent <task>`
139
+ - Inspect: `/agents`
140
+ - Resume a preserved unfinished agent: `/agent-resume`
141
+ - Local `.pi/side-agent-*` and `.pi/side-agents-herdr/` files must remain untracked.
package/src/herdr.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { exec, parseJsonOutput } from "./process.js";
2
+
3
+ interface Envelope<T> { result: T }
4
+ interface PaneInfo { pane_id: string; tab_id?: string; workspace_id?: string; agent_status?: string }
5
+ export interface LayoutPane { pane_id: string; rect: { width: number; height: number; x: number; y: number } }
6
+
7
+ export class Herdr {
8
+ async ensureReady(): Promise<void> {
9
+ if (process.env.HERDR_ENV !== "1") throw new Error("/agent must be run inside a Herdr-managed pane (HERDR_ENV=1)");
10
+ const status = await exec("herdr", ["status"]);
11
+ if (!status.stdout.includes("endpoint_compatible: yes")) throw new Error("Herdr client/server endpoint is not compatible");
12
+ }
13
+
14
+ async layout(paneId: string): Promise<{ panes: LayoutPane[] }> {
15
+ const out = await exec("herdr", ["pane", "layout", "--pane", paneId]);
16
+ const json = parseJsonOutput<Envelope<{ layout: { panes: LayoutPane[] } }>>(out.stdout, "herdr pane layout");
17
+ return { panes: json.result.layout.panes };
18
+ }
19
+
20
+ async split(params: { targetPaneId: string; direction: "right" | "down"; ratio: number; cwd: string; env: Record<string, string> }): Promise<PaneInfo> {
21
+ const args = ["pane", "split", "--pane", params.targetPaneId, "--direction", params.direction, "--ratio", String(params.ratio), "--cwd", params.cwd, "--no-focus"];
22
+ for (const [key, value] of Object.entries(params.env)) args.push("--env", `${key}=${value}`);
23
+ const out = await exec("herdr", args);
24
+ const json = parseJsonOutput<Envelope<{ pane: PaneInfo }>>(out.stdout, "herdr pane split");
25
+ if (!json.result.pane?.pane_id) throw new Error("Herdr did not return a pane id");
26
+ return json.result.pane;
27
+ }
28
+
29
+ async run(paneId: string, command: string): Promise<void> {
30
+ await exec("herdr", ["pane", "run", paneId, command]);
31
+ }
32
+
33
+ async waitOutput(paneId: string, text: string, timeoutMs: number): Promise<string> {
34
+ const out = await exec("herdr", ["pane", "wait-output", paneId, "--match", text, "--source", "recent-unwrapped", "--timeout", String(timeoutMs)]);
35
+ return out.stdout;
36
+ }
37
+
38
+ async closePane(paneId: string): Promise<void> {
39
+ await exec("herdr", ["pane", "close", paneId], { allowFailure: true });
40
+ }
41
+
42
+ async startAgent(name: string, paneId: string, args: string[]): Promise<"ready" | "blocked"> {
43
+ const command = ["agent", "start", name, "--kind", "pi", "--pane", paneId];
44
+ if (args.length) command.push("--", ...args);
45
+ try {
46
+ await exec("herdr", command);
47
+ return "ready";
48
+ } catch (error) {
49
+ // Herdr keeps the assigned agent name when startup reaches an approval or
50
+ // question UI. Preserve the pane so the user can resolve it directly.
51
+ if (String(error).includes("agent_not_ready")) return "blocked";
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ async getAgent(target: string): Promise<PaneInfo | undefined> {
57
+ const out = await exec("herdr", ["agent", "get", target], { allowFailure: true });
58
+ if (!out.stdout.trim()) return undefined;
59
+ try {
60
+ const json = parseJsonOutput<Envelope<{ agent: PaneInfo }>>(out.stdout, "herdr agent get");
61
+ return json.result.agent;
62
+ } catch { return undefined; }
63
+ }
64
+
65
+ async readAgent(target: string, lines = 20): Promise<string[]> {
66
+ const out = await exec("herdr", ["agent", "read", target, "--source", "recent-unwrapped", "--lines", String(lines)], { allowFailure: true });
67
+ if (!out.stdout.trim()) return [];
68
+ try {
69
+ const json = parseJsonOutput<Envelope<Record<string, unknown>>>(out.stdout, "herdr agent read");
70
+ const result = json.result as { text?: string; content?: string; output?: string };
71
+ return (result.text ?? result.content ?? result.output ?? "").split(/\r?\n/).filter(Boolean).slice(-lines);
72
+ } catch { return out.stdout.split(/\r?\n/).filter(Boolean).slice(-lines); }
73
+ }
74
+
75
+ async prompt(target: string, text: string): Promise<void> {
76
+ await exec("herdr", ["agent", "prompt", target, text]);
77
+ }
78
+
79
+ async interrupt(target: string): Promise<void> {
80
+ await exec("herdr", ["agent", "send-keys", target, "ctrl+c"], { allowFailure: true });
81
+ }
82
+ }
83
+
84
+ export function mapHerdrStatus(status?: string): "running" | "waiting_user" | "blocked" | undefined {
85
+ if (status === "working") return "running";
86
+ if (status === "blocked") return "blocked";
87
+ if (status === "idle" || status === "done") return "waiting_user";
88
+ return undefined;
89
+ }
package/src/layout.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { LayoutPane } from "./herdr.js";
2
+
3
+ export function choosePlacement(
4
+ callerPaneId: string,
5
+ panes: LayoutPane[],
6
+ childPaneIds: string[],
7
+ mainRatio = 0.72,
8
+ childRatio = 0.5,
9
+ ): { targetPaneId: string; direction: "right" | "down"; ratio: number } {
10
+ const childIds = new Set(childPaneIds);
11
+ const children = panes
12
+ .filter(pane => childIds.has(pane.pane_id))
13
+ .sort((a, b) => (b.rect.height * b.rect.width) - (a.rect.height * a.rect.width));
14
+ if (!children.length) {
15
+ return { targetPaneId: callerPaneId, direction: "right", ratio: Math.min(0.82, Math.max(0.6, mainRatio)) };
16
+ }
17
+ return { targetPaneId: children[0].pane_id, direction: "down", ratio: Math.min(0.7, Math.max(0.3, childRatio)) };
18
+ }
@@ -0,0 +1,62 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { git } from "./process.js";
5
+ import { SideAgentService } from "./service.js";
6
+ import { ENV } from "./types.js";
7
+
8
+ export async function linkChildSession(service: SideAgentService, ctx: ExtensionContext): Promise<void> {
9
+ const id = process.env[ENV.agentId];
10
+ if (!id) return;
11
+ await service.update(id, {
12
+ childSessionId: ctx.sessionManager.getSessionFile(),
13
+ status: "running",
14
+ });
15
+ }
16
+
17
+ export function cleanupBlockers(dirty: string, unmerged: number, mainBranch: string): string[] {
18
+ return [
19
+ dirty.trim() ? "存在未提交修改" : "",
20
+ unmerged > 0 ? `有 ${unmerged} 个提交尚未合并到 ${mainBranch}` : "",
21
+ ].filter(Boolean);
22
+ }
23
+
24
+ export async function handleChildQuit(service: SideAgentService, ctx: ExtensionContext): Promise<void> {
25
+ const id = process.env[ENV.agentId];
26
+ if (!id) return;
27
+ const record = await service.requireRecord(id);
28
+ const config = await service.store.config();
29
+ if (!config.cleanupOnQuit) {
30
+ await service.update(id, { status: "paused" });
31
+ return;
32
+ }
33
+ const dirty = (await git(record.worktreePath, ["status", "--porcelain"])).stdout.trim();
34
+ const unmerged = Number((await git(record.worktreePath, ["rev-list", "--count", `${record.mainBranch}..${record.branch}`])).stdout.trim() || "0");
35
+
36
+ const reasons = cleanupBlockers(dirty, unmerged, record.mainBranch);
37
+ if (reasons.length) {
38
+ const message = `未清理 ${record.worktreePath},因为${reasons.join(",")}。Pane 保留,可使用 /agent-resume 恢复。`;
39
+ await service.update(id, { status: "paused", warnings: [...record.warnings, message] });
40
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
41
+ console.error(`[side-agent] ${message}`);
42
+ return;
43
+ }
44
+
45
+ await service.update(id, { status: "cleaning" });
46
+ const script = fileURLToPath(new URL("../scripts/janitor.mjs", import.meta.url));
47
+ const payload = Buffer.from(JSON.stringify({
48
+ root: service.root,
49
+ id,
50
+ paneId: record.paneId,
51
+ worktreePath: record.worktreePath,
52
+ branch: record.branch,
53
+ mainBranch: record.mainBranch,
54
+ })).toString("base64url");
55
+ const child = spawn(process.execPath, [script, payload], {
56
+ cwd: service.root,
57
+ detached: true,
58
+ stdio: "ignore",
59
+ env: process.env,
60
+ });
61
+ child.unref();
62
+ }
package/src/naming.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { basename, dirname, join } from "node:path";
2
+ import type { Registry } from "./types.js";
3
+
4
+ export function featureSlug(raw: string): string {
5
+ let slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
6
+ if (!slug) slug = "task";
7
+ if (!/^[a-z]/.test(slug)) slug = `task-${slug}`;
8
+ return slug.slice(0, 22).replace(/-+$/g, "") || "task";
9
+ }
10
+
11
+ export function nextIdentity(repoRoot: string, hint: string, registry: Registry, existingPaths: string[], existingBranches: string[] = []): {
12
+ id: string;
13
+ feature: string;
14
+ number: number;
15
+ branch: string;
16
+ worktreePath: string;
17
+ } {
18
+ const feature = featureSlug(hint);
19
+ const parent = dirname(repoRoot);
20
+ const repo = basename(repoRoot);
21
+ let number = 1;
22
+ const occupied = new Set(existingPaths);
23
+ const branches = new Set(existingBranches);
24
+ while (true) {
25
+ const suffix = String(number).padStart(4, "0");
26
+ const id = `${feature}-${suffix}`;
27
+ const worktreePath = join(parent, `${repo}-agent-${feature}-${suffix}`);
28
+ if (!registry.agents[id] && !occupied.has(worktreePath) && !branches.has(`side-agent/${id}`)) {
29
+ return { id, feature, number, branch: `side-agent/${id}`, worktreePath };
30
+ }
31
+ number += 1;
32
+ }
33
+ }
34
+
35
+ export function taskHint(task: string): string {
36
+ return featureSlug(task.split(/\s+/).slice(0, 3).join("-"));
37
+ }
package/src/process.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ export interface ProcessResult {
4
+ stdout: string;
5
+ stderr: string;
6
+ }
7
+
8
+ export function exec(command: string, args: string[], options: { cwd?: string; signal?: AbortSignal; allowFailure?: boolean } = {}): Promise<ProcessResult> {
9
+ return new Promise((resolve, reject) => {
10
+ execFile(command, args, {
11
+ cwd: options.cwd,
12
+ encoding: "utf8",
13
+ maxBuffer: 16 * 1024 * 1024,
14
+ windowsHide: true,
15
+ signal: options.signal,
16
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
17
+ }, (error, stdout, stderr) => {
18
+ const result = { stdout: String(stdout), stderr: String(stderr) };
19
+ if (error && !options.allowFailure) {
20
+ reject(new Error(`${command} ${args.join(" ")} failed: ${result.stderr.trim() || error.message}`));
21
+ } else resolve(result);
22
+ });
23
+ });
24
+ }
25
+
26
+ export async function git(cwd: string, args: string[], signal?: AbortSignal): Promise<ProcessResult> {
27
+ return exec("git", ["-C", cwd, ...args], { signal });
28
+ }
29
+
30
+ export function parseJsonOutput<T>(stdout: string, command: string): T {
31
+ try {
32
+ return JSON.parse(stdout) as T;
33
+ } catch {
34
+ throw new Error(`${command} returned invalid JSON: ${stdout.slice(0, 500)}`);
35
+ }
36
+ }
package/src/service.ts ADDED
@@ -0,0 +1,273 @@
1
+ import { access, mkdir, readdir, readFile, rm, symlink } from "node:fs/promises";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { git } from "./process.js";
6
+ import { Herdr, mapHerdrStatus } from "./herdr.js";
7
+ import { choosePlacement } from "./layout.js";
8
+ import { nextIdentity, taskHint } from "./naming.js";
9
+ import { Store } from "./store.js";
10
+ import { ENV, type AgentRecord, type StartResult } from "./types.js";
11
+
12
+ const quote = (value: string) => `'${value.replace(/'/g, `'"'"'`)}'`;
13
+ const now = () => new Date().toISOString();
14
+ const pathExists = async (path: string) => access(path).then(() => true, () => false);
15
+
16
+ export function buildBootstrapCommand(startScript: string, root: string, worktree: string, id: string, marker: string): string {
17
+ // Keep `${marker}:` out of the echoed shell command. wait-output otherwise
18
+ // matches the command line before the shell has executed the bootstrap.
19
+ return `marker=${quote(marker)}; if [ -x ${quote(startScript)} ]; then ${quote(startScript)} ${quote(root)} ${quote(worktree)} ${quote(id)}; fi; code=$?; printf '\\n%s:%s\\n' "$marker" "$code"`;
20
+ }
21
+
22
+ export interface StartOptions { task: string; branchHint?: string; model?: string; resumeId?: string }
23
+
24
+ export async function repoRoot(cwd: string): Promise<string> {
25
+ return (await git(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim();
26
+ }
27
+
28
+ async function worktreePaths(root: string): Promise<string[]> {
29
+ const output = (await git(root, ["worktree", "list", "--porcelain"])).stdout;
30
+ return output.split(/\r?\n/).filter(line => line.startsWith("worktree ")).map(line => resolve(line.slice(9).trim()));
31
+ }
32
+
33
+ async function syncLocalFiles(root: string, worktree: string): Promise<void> {
34
+ const names = ["side-agent-start.sh", "side-agent-finish.sh", "side-agent-skills"];
35
+ await mkdir(join(worktree, ".pi"), { recursive: true });
36
+ for (const name of names) {
37
+ const source = join(root, ".pi", name);
38
+ try { await readFile(source); } catch {
39
+ try { if (!(await readdir(source)).length && name === "side-agent-skills") continue; } catch { continue; }
40
+ }
41
+ const target = join(worktree, ".pi", name);
42
+ await rm(target, { recursive: true, force: true });
43
+ await symlink(source, target, process.platform === "win32" ? "junction" : undefined);
44
+ }
45
+ }
46
+
47
+ export class SideAgentService {
48
+ constructor(readonly root: string, readonly store = new Store(root), readonly herdr = new Herdr()) {}
49
+
50
+ async start(ctx: ExtensionContext, options: StartOptions): Promise<StartResult> {
51
+ await this.herdr.ensureReady();
52
+ const config = await this.store.config();
53
+ if (options.resumeId) return this.resume(ctx, options.resumeId, options.model);
54
+
55
+ const branchOutput = (await git(this.root, ["for-each-ref", "--format=%(refname:short)", "refs/heads/side-agent/"])).stdout;
56
+ const paths = await worktreePaths(this.root);
57
+ const baseCommit = (await git(this.root, ["rev-parse", `refs/heads/${config.mainBranch}`])).stdout.trim();
58
+ let record!: AgentRecord;
59
+ // Allocate and reserve the id under the registry lock. Two parent Pi
60
+ // sessions may call agent-start concurrently for the same feature.
61
+ await this.store.mutate(registry => {
62
+ const identity = nextIdentity(
63
+ this.root,
64
+ options.branchHint ?? taskHint(options.task),
65
+ registry,
66
+ paths,
67
+ branchOutput.split(/\r?\n/).filter(Boolean),
68
+ );
69
+ record = {
70
+ ...identity,
71
+ task: options.task,
72
+ status: "allocating_worktree",
73
+ mainBranch: config.mainBranch,
74
+ baseCommit,
75
+ warnings: [],
76
+ parentSessionId: ctx.sessionManager.getSessionFile(),
77
+ model: options.model,
78
+ startedAt: now(), updatedAt: now(),
79
+ };
80
+ registry.agents[record.id] = record;
81
+ });
82
+
83
+ let paneId: string | undefined;
84
+ try {
85
+ await git(this.root, ["worktree", "add", "-b", record.branch, record.worktreePath, baseCommit]);
86
+ await syncLocalFiles(this.root, record.worktreePath);
87
+ await this.update(record.id, { status: "spawning_pane" });
88
+
89
+ const placement = await this.placement(record.id, config.mainPaneRatio, config.childSplitRatio);
90
+ const pane = await this.herdr.split({
91
+ ...placement, cwd: record.worktreePath,
92
+ env: {
93
+ [ENV.agentId]: record.id,
94
+ [ENV.parentSession]: record.parentSessionId ?? "",
95
+ [ENV.parentRepo]: this.root,
96
+ [ENV.stateRoot]: this.root,
97
+ },
98
+ });
99
+ paneId = pane.pane_id;
100
+ // The actual pane id cannot be known until split; the child can also read HERDR_PANE_ID directly.
101
+ await this.update(record.id, {
102
+ paneId, tabId: pane.tab_id, workspaceId: pane.workspace_id,
103
+ herdrAgentName: record.id, status: "starting",
104
+ });
105
+
106
+ const startScript = join(record.worktreePath, ".pi", "side-agent-start.sh");
107
+ const marker = `__XZ_SIDE_AGENT_BOOTSTRAP_${record.id}__`;
108
+ const command = buildBootstrapCommand(startScript, this.root, record.worktreePath, record.id, marker);
109
+ await this.herdr.run(paneId, command);
110
+ const bootstrap = await this.herdr.waitOutput(paneId, `${marker}:`, 120_000);
111
+ if (!bootstrap.includes(`${marker}:0`)) throw new Error("side-agent bootstrap failed");
112
+
113
+ // Always pass this extension explicitly. This keeps child lifecycle hooks
114
+ // available when the parent itself was started with `pi -e <local-path>`;
115
+ // Pi de-duplicates an already installed extension by resolved path.
116
+ const args: string[] = ["--extension", fileURLToPath(new URL("../index.ts", import.meta.url))];
117
+ if (options.model) args.push("--model", options.model);
118
+ const skills = join(record.worktreePath, ".pi", "side-agent-skills");
119
+ try { await readdir(skills); args.push("--skill", skills); } catch { /* optional */ }
120
+ const startup = await this.herdr.startAgent(record.id, paneId, args);
121
+ if (startup === "blocked") {
122
+ const warning = "Child Pi is blocked during startup; inspect its Herdr pane. The kickoff task will be submitted after it becomes idle.";
123
+ await this.update(record.id, { status: "blocked", kickoffPending: true, warnings: [...record.warnings, warning] });
124
+ } else {
125
+ await this.update(record.id, { status: "running" });
126
+ await this.herdr.prompt(record.id, `${options.task}\n\nParent Pi session: ${record.parentSessionId ?? "unknown"}`);
127
+ }
128
+ return this.result(await this.requireRecord(record.id));
129
+ } catch (error) {
130
+ await this.update(record.id, { status: "failed", error: error instanceof Error ? error.message : String(error), finishedAt: now() });
131
+ if (paneId) await this.herdr.closePane(paneId);
132
+ const dirty = await git(record.worktreePath, ["status", "--porcelain"]).catch(() => ({ stdout: "dirty", stderr: "" }));
133
+ const commits = await git(record.worktreePath, ["rev-list", "--count", `${record.baseCommit}..${record.branch}`]).catch(() => ({ stdout: "1", stderr: "" }));
134
+ if (!dirty.stdout.trim() && Number(commits.stdout.trim()) === 0) {
135
+ const removed = await git(this.root, ["worktree", "remove", record.worktreePath]).then(() => true, () => false);
136
+ if (removed) {
137
+ await git(this.root, ["branch", "-D", record.branch]).catch(() => undefined);
138
+ await this.store.mutate(registry => { delete registry.agents[record.id]; });
139
+ }
140
+ }
141
+ throw error;
142
+ }
143
+ }
144
+
145
+ async resume(ctx: ExtensionContext, id: string, model?: string): Promise<StartResult> {
146
+ const record = await this.requireRecord(id);
147
+ if (record.status !== "paused" && record.status !== "failed" && record.status !== "crashed") throw new Error(`${id} is not resumable`);
148
+ if (!record.childSessionId) throw new Error(`${id} has no child session to resume`);
149
+ if (!(await pathExists(record.worktreePath))) {
150
+ const branchExists = await git(this.root, ["rev-parse", "--verify", `refs/heads/${record.branch}`]).then(() => true, () => false);
151
+ if (!branchExists) throw new Error(`${id} cannot resume: branch ${record.branch} no longer exists`);
152
+ await git(this.root, ["worktree", "add", record.worktreePath, record.branch]);
153
+ await syncLocalFiles(this.root, record.worktreePath);
154
+ }
155
+ let paneId = record.paneId;
156
+ if (!paneId || !(await this.herdr.getAgent(paneId)) && !(await this.paneExists(paneId))) {
157
+ const config = await this.store.config();
158
+ const placement = await this.placement(record.id, config.mainPaneRatio, config.childSplitRatio);
159
+ const pane = await this.herdr.split({
160
+ ...placement, cwd: record.worktreePath,
161
+ env: { [ENV.agentId]: id, [ENV.parentSession]: record.parentSessionId ?? "", [ENV.parentRepo]: this.root, [ENV.stateRoot]: this.root },
162
+ });
163
+ paneId = pane.pane_id;
164
+ await this.update(id, { paneId, tabId: pane.tab_id, workspaceId: pane.workspace_id });
165
+ }
166
+ const args = ["--extension", fileURLToPath(new URL("../index.ts", import.meta.url)), "--session", record.childSessionId];
167
+ if (model) args.unshift("--model", model);
168
+ const startup = await this.herdr.startAgent(id, paneId, args);
169
+ await this.update(id, { status: startup === "blocked" ? "blocked" : "waiting_user", finishedAt: undefined, error: undefined });
170
+ return this.result(await this.requireRecord(id));
171
+ }
172
+
173
+ private async placement(excludeId: string, mainRatio = 0.72, childRatio = 0.5): Promise<{ targetPaneId: string; direction: "right" | "down"; ratio: number }> {
174
+ const callerPane = process.env.HERDR_PANE_ID;
175
+ if (!callerPane) throw new Error("HERDR_PANE_ID is missing");
176
+ const layout = await this.herdr.layout(callerPane);
177
+ const visible = new Set(layout.panes.map(pane => pane.pane_id));
178
+ const registry = await this.store.load();
179
+ const childPaneIds = Object.values(registry.agents)
180
+ .filter(record => record.id !== excludeId && record.paneId && visible.has(record.paneId) && !["failed", "crashed"].includes(record.status))
181
+ .map(record => record.paneId!);
182
+ return choosePlacement(callerPane, layout.panes, childPaneIds, mainRatio, childRatio);
183
+ }
184
+
185
+ private async paneExists(paneId: string): Promise<boolean> {
186
+ const result = await import("./process.js").then(({ exec }) => exec("herdr", ["pane", "get", paneId], { allowFailure: true }));
187
+ return Boolean(result.stdout.trim());
188
+ }
189
+
190
+ private async reconcileMissingResources(record: AgentRecord): Promise<AgentRecord | undefined> {
191
+ if (await pathExists(record.worktreePath)) return record;
192
+ if (record.paneId && await this.paneExists(record.paneId)) return record;
193
+
194
+ const branchExists = await git(this.root, ["rev-parse", "--verify", `refs/heads/${record.branch}`]).then(() => true, () => false);
195
+ if (branchExists) {
196
+ const merged = await git(this.root, ["merge-base", "--is-ancestor", record.branch, record.mainBranch]).then(() => true, () => false);
197
+ if (!merged) {
198
+ if (record.paneId) return this.update(record.id, { paneId: undefined, tabId: undefined, workspaceId: undefined });
199
+ return record;
200
+ }
201
+ await git(this.root, ["branch", "-D", record.branch]);
202
+ }
203
+ await this.store.mutate(registry => { delete registry.agents[record.id]; });
204
+ return undefined;
205
+ }
206
+
207
+ async refresh(id: string): Promise<AgentRecord | undefined> {
208
+ const record = (await this.store.load()).agents[id];
209
+ if (!record) return undefined;
210
+ if (["paused", "cleaning", "failed", "crashed"].includes(record.status)) return this.reconcileMissingResources(record);
211
+ if (["allocating_worktree", "spawning_pane", "starting"].includes(record.status) || !record.paneId) return record;
212
+ const agent = await this.herdr.getAgent(record.herdrAgentName ?? record.paneId);
213
+ const mapped = mapHerdrStatus(agent?.agent_status);
214
+ if (mapped === "waiting_user" && record.kickoffPending && record.herdrAgentName) {
215
+ await this.herdr.prompt(record.herdrAgentName, `${record.task}\n\nParent Pi session: ${record.parentSessionId ?? "unknown"}`);
216
+ return this.update(id, { status: "running", kickoffPending: false });
217
+ }
218
+ if (mapped && mapped !== record.status) return this.update(id, { status: mapped });
219
+ if (!agent) {
220
+ const paneExists = await this.paneExists(record.paneId);
221
+ return this.update(id, { status: "crashed", error: paneExists ? "Child Pi disappeared but its Herdr pane remains" : "Herdr pane disappeared" });
222
+ }
223
+ return (await this.store.load()).agents[id];
224
+ }
225
+
226
+ async list(): Promise<AgentRecord[]> {
227
+ const records = Object.values((await this.store.load()).agents);
228
+ const refreshed = await Promise.all(records.map(record => this.refresh(record.id)));
229
+ return refreshed.filter((record): record is AgentRecord => record !== undefined);
230
+ }
231
+
232
+ async send(id: string, prompt: string): Promise<{ ok: boolean; message: string }> {
233
+ const record = await this.refresh(id);
234
+ if (!record?.herdrAgentName) return { ok: false, message: `Unknown agent id: ${id}` };
235
+ let payload = prompt;
236
+ if (payload.startsWith("!")) {
237
+ await this.herdr.interrupt(record.herdrAgentName);
238
+ payload = payload.slice(1).trimStart();
239
+ if (payload) await new Promise(resolve => setTimeout(resolve, 300));
240
+ }
241
+ if (payload) await this.herdr.prompt(record.herdrAgentName, payload);
242
+ await this.update(id, { status: "running" });
243
+ return { ok: true, message: `Sent prompt to ${id}` };
244
+ }
245
+
246
+ async check(id: string): Promise<Record<string, unknown>> {
247
+ const record = await this.refresh(id);
248
+ if (!record) return { ok: false, error: `Unknown agent id: ${id}` };
249
+ return { ok: true, agent: record, backlog: record.herdrAgentName ? await this.herdr.readAgent(record.herdrAgentName, 20) : [] };
250
+ }
251
+
252
+ async update(id: string, patch: Partial<AgentRecord>): Promise<AgentRecord> {
253
+ let result: AgentRecord | undefined;
254
+ await this.store.mutate(registry => {
255
+ const record = registry.agents[id];
256
+ if (!record) throw new Error(`Unknown agent id: ${id}`);
257
+ Object.assign(record, patch, { updatedAt: now() });
258
+ result = record;
259
+ });
260
+ return result!;
261
+ }
262
+
263
+ async requireRecord(id: string): Promise<AgentRecord> {
264
+ const record = (await this.store.load()).agents[id];
265
+ if (!record) throw new Error(`Unknown agent id: ${id}`);
266
+ return record;
267
+ }
268
+
269
+ private result(record: AgentRecord): StartResult {
270
+ if (!record.paneId) throw new Error(`Agent ${record.id} has no pane`);
271
+ return { ok: true, id: record.id, task: record.task, paneId: record.paneId, tabId: record.tabId, workspaceId: record.workspaceId, worktreePath: record.worktreePath, branch: record.branch, warnings: record.warnings };
272
+ }
273
+ }
package/src/store.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import type { Config, Registry } from "./types.js";
4
+
5
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
6
+
7
+ export class Store {
8
+ readonly dir: string;
9
+ readonly registryPath: string;
10
+ readonly lockPath: string;
11
+ readonly configPath: string;
12
+
13
+ constructor(readonly root: string) {
14
+ this.dir = join(root, ".pi", "side-agents-herdr");
15
+ this.registryPath = join(this.dir, "registry.json");
16
+ this.lockPath = join(this.dir, "registry.lock");
17
+ this.configPath = join(this.dir, "config.json");
18
+ }
19
+
20
+ async config(): Promise<Config> {
21
+ let value: Config;
22
+ try { value = JSON.parse(await readFile(this.configPath, "utf8")) as Config; }
23
+ catch { throw new Error(`Run /skill:agent-setup first; missing ${this.configPath}`); }
24
+ if (value.version !== 1 || !value.mainBranch) throw new Error(`Invalid config: ${this.configPath}`);
25
+ value.mainPaneRatio = typeof value.mainPaneRatio === "number" ? value.mainPaneRatio : 0.72;
26
+ value.childSplitRatio = typeof value.childSplitRatio === "number" ? value.childSplitRatio : 0.5;
27
+ value.cleanupOnQuit = value.cleanupOnQuit !== false;
28
+ return value;
29
+ }
30
+
31
+ async load(): Promise<Registry> {
32
+ try {
33
+ const value = JSON.parse(await readFile(this.registryPath, "utf8")) as Registry;
34
+ if (value.version !== 1 || !value.agents) throw new Error("unsupported registry");
35
+ return value;
36
+ } catch (error) {
37
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, agents: {} };
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ async save(registry: Registry): Promise<void> {
43
+ await mkdir(this.dir, { recursive: true });
44
+ const temporary = `${this.registryPath}.${process.pid}.tmp`;
45
+ await writeFile(temporary, JSON.stringify(registry, null, 2) + "\n", { mode: 0o600 });
46
+ await rename(temporary, this.registryPath);
47
+ }
48
+
49
+ async mutate(fn: (registry: Registry) => void | Promise<void>): Promise<Registry> {
50
+ await mkdir(dirname(this.lockPath), { recursive: true });
51
+ const started = Date.now();
52
+ while (true) {
53
+ try {
54
+ const handle = await open(this.lockPath, "wx", 0o600);
55
+ await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
56
+ await handle.close();
57
+ break;
58
+ } catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
60
+ const age = Date.now() - (await stat(this.lockPath).catch(() => ({ mtimeMs: Date.now() }))).mtimeMs;
61
+ if (age > 30_000) { await rm(this.lockPath, { force: true }); continue; }
62
+ if (Date.now() - started > 10_000) throw new Error(`Timed out waiting for ${this.lockPath}`);
63
+ await sleep(50);
64
+ }
65
+ }
66
+ try {
67
+ const registry = await this.load();
68
+ await fn(registry);
69
+ await this.save(registry);
70
+ return registry;
71
+ } finally {
72
+ await rm(this.lockPath, { force: true });
73
+ }
74
+ }
75
+ }
package/src/types.ts ADDED
@@ -0,0 +1,68 @@
1
+ export const ENV = {
2
+ agentId: "PI_SIDE_AGENT_ID",
3
+ parentSession: "PI_SIDE_PARENT_SESSION",
4
+ parentRepo: "PI_SIDE_PARENT_REPO",
5
+ stateRoot: "PI_SIDE_AGENTS_ROOT",
6
+ } as const;
7
+
8
+ export type AgentStatus =
9
+ | "allocating_worktree"
10
+ | "spawning_pane"
11
+ | "starting"
12
+ | "running"
13
+ | "waiting_user"
14
+ | "blocked"
15
+ | "paused"
16
+ | "cleaning"
17
+ | "failed"
18
+ | "crashed";
19
+
20
+ export interface Config {
21
+ version: 1;
22
+ mainBranch: string;
23
+ mainPaneRatio: number;
24
+ childSplitRatio: number;
25
+ cleanupOnQuit: boolean;
26
+ }
27
+
28
+ export interface AgentRecord {
29
+ id: string;
30
+ feature: string;
31
+ number: number;
32
+ task: string;
33
+ status: AgentStatus;
34
+ mainBranch: string;
35
+ baseCommit: string;
36
+ branch: string;
37
+ worktreePath: string;
38
+ paneId?: string;
39
+ tabId?: string;
40
+ workspaceId?: string;
41
+ herdrAgentName?: string;
42
+ parentSessionId?: string;
43
+ childSessionId?: string;
44
+ model?: string;
45
+ kickoffPending?: boolean;
46
+ startedAt: string;
47
+ updatedAt: string;
48
+ finishedAt?: string;
49
+ error?: string;
50
+ warnings: string[];
51
+ }
52
+
53
+ export interface Registry {
54
+ version: 1;
55
+ agents: Record<string, AgentRecord>;
56
+ }
57
+
58
+ export interface StartResult {
59
+ ok: true;
60
+ id: string;
61
+ task: string;
62
+ paneId: string;
63
+ tabId?: string;
64
+ workspaceId?: string;
65
+ worktreePath: string;
66
+ branch: string;
67
+ warnings: string[];
68
+ }