@alexanderbianchi/herdr-aside 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/package.json +48 -0
  4. package/src/index.ts +266 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Bianchi
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,59 @@
1
+ # herdr-aside
2
+
3
+ A focused Pi extension for opening independent side conversations in [Herdr](https://herdr.dev).
4
+
5
+ `/aside` forks the current persisted Pi session into a new Herdr location. The child starts with the current conversation context and then evolves independently. Every invocation creates a new fork; it never reuses or routes to an existing agent.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install npm:@alexanderbianchi/herdr-aside
11
+ ```
12
+
13
+ Until the npm package is published:
14
+
15
+ ```bash
16
+ pi install git:github.com/alexanderbianchi/herdr-aside
17
+ ```
18
+
19
+ Then run `/reload` in an existing Pi session.
20
+
21
+ ## Usage
22
+
23
+ ```text
24
+ /aside explain this RFC while the main agent keeps working
25
+ /aside h compare these APIs
26
+ /aside v --focus investigate this failure
27
+ /aside t draft an alternative design
28
+ /aside w explore this independently
29
+ ```
30
+
31
+ The optional first argument controls where the fork opens:
32
+
33
+ | Argument | Herdr location |
34
+ |---|---|
35
+ | `h`, `horizontal` | Horizontal divider; new pane below |
36
+ | `v`, `vertical` | Vertical divider; new pane to the right |
37
+ | `t`, `tab` | New tab in the current workspace |
38
+ | `w`, `workspace` | New workspace |
39
+
40
+ Without a topology argument, `/aside` uses a horizontal split and treats all input as the prompt.
41
+
42
+ A prompted aside stays in the background by default. An aside without a prompt receives focus so you can type into it. Put `--focus` or `--no-focus` after the topology to override that behavior.
43
+
44
+ ## Requirements
45
+
46
+ - Pi running in interactive TUI mode
47
+ - Pi's current session must be persisted
48
+ - Pi must be running inside a Herdr-managed pane
49
+ - `herdr` must be available on `PATH`
50
+
51
+ The extension uses Herdr's `agent start` command and Pi's native `--fork` option. It does not provide general Herdr orchestration tools; [`@andrewjacop/pi-herdr`](https://pi.dev/packages/@andrewjacop/pi-herdr) is complementary and can be installed alongside it.
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ npm install
57
+ npm run check
58
+ pi -ne -e ./src/index.ts
59
+ ```
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@alexanderbianchi/herdr-aside",
3
+ "version": "0.1.0",
4
+ "description": "Fork the current Pi session into a new Herdr split, tab, or workspace with /aside",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Alexander Bianchi",
8
+ "homepage": "https://github.com/alexanderbianchi/herdr-aside#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/alexanderbianchi/herdr-aside.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/alexanderbianchi/herdr-aside/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-coding-agent",
19
+ "herdr",
20
+ "aside",
21
+ "session",
22
+ "fork"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "pi": {
28
+ "extensions": ["./src/index.ts"]
29
+ },
30
+ "files": ["src/index.ts", "README.md", "LICENSE"],
31
+ "scripts": {
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "vitest --run",
34
+ "check": "npm run typecheck && npm test"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*"
38
+ },
39
+ "devDependencies": {
40
+ "@earendil-works/pi-coding-agent": "0.82.1",
41
+ "@types/node": "^24.5.2",
42
+ "typescript": "^5.9.2",
43
+ "vitest": "^4.1.0"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,266 @@
1
+ import type {
2
+ ExecResult,
3
+ ExtensionAPI,
4
+ ExtensionCommandContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ export type HerdrTopology = "horizontal" | "vertical" | "tab" | "workspace";
8
+
9
+ type SpawnOptions = {
10
+ focus: boolean;
11
+ prompt: string;
12
+ };
13
+
14
+ type HerdrTarget = {
15
+ paneId: string;
16
+ cleanup: { command: "pane" | "tab" | "workspace"; id: string };
17
+ };
18
+
19
+ const ASIDE_SYSTEM_PROMPT = [
20
+ "You are in a side conversation forked from the user's main Pi session.",
21
+ "Treat inherited messages as context; another agent may still be handling the main task.",
22
+ "Focus on the user's side question and continue independently in this Herdr location.",
23
+ ].join(" ");
24
+
25
+ const TOPOLOGY_ALIASES: Readonly<Record<string, HerdrTopology>> = {
26
+ h: "horizontal",
27
+ horizontal: "horizontal",
28
+ v: "vertical",
29
+ vertical: "vertical",
30
+ t: "tab",
31
+ tab: "tab",
32
+ w: "workspace",
33
+ workspace: "workspace",
34
+ };
35
+
36
+ function notify(
37
+ ctx: ExtensionCommandContext,
38
+ message: string,
39
+ level: "info" | "warning" | "error",
40
+ ): void {
41
+ if (ctx.hasUI) ctx.ui.notify(message, level);
42
+ }
43
+
44
+ function resultError(result: ExecResult, fallback: string): Error {
45
+ return new Error(result.stderr.trim() || result.stdout.trim() || fallback);
46
+ }
47
+
48
+ function parseJsonResult(result: ExecResult, fallback: string): Record<string, unknown> {
49
+ if (result.code !== 0) throw resultError(result, fallback);
50
+
51
+ try {
52
+ return JSON.parse(result.stdout) as Record<string, unknown>;
53
+ } catch {
54
+ throw new Error(`${fallback}: Herdr returned invalid JSON`);
55
+ }
56
+ }
57
+
58
+ function objectAt(value: unknown, key: string): Record<string, unknown> | undefined {
59
+ if (!value || typeof value !== "object") return undefined;
60
+ const nested = (value as Record<string, unknown>)[key];
61
+ return nested && typeof nested === "object" ? (nested as Record<string, unknown>) : undefined;
62
+ }
63
+
64
+ function stringAt(value: unknown, key: string): string | undefined {
65
+ if (!value || typeof value !== "object") return undefined;
66
+ const nested = (value as Record<string, unknown>)[key];
67
+ return typeof nested === "string" && nested.length > 0 ? nested : undefined;
68
+ }
69
+
70
+ export function parseSpawnOptions(args: string): SpawnOptions {
71
+ let remaining = args.trim();
72
+ let focus: boolean | undefined;
73
+
74
+ while (true) {
75
+ const match = remaining.match(/^(--focus|--no-focus)(?:\s+|$)/);
76
+ if (!match) break;
77
+ focus = match[1] === "--focus";
78
+ remaining = remaining.slice(match[0].length).trimStart();
79
+ }
80
+
81
+ return { focus: focus ?? remaining.length === 0, prompt: remaining };
82
+ }
83
+
84
+ export function parseAsideArgs(args: string): {
85
+ topology: HerdrTopology;
86
+ options: SpawnOptions;
87
+ } {
88
+ const trimmed = args.trim();
89
+ const match = trimmed.match(/^(\S+)(?:\s+([\s\S]*))?$/);
90
+ if (!match) {
91
+ return { topology: "horizontal", options: parseSpawnOptions("") };
92
+ }
93
+
94
+ const topology = TOPOLOGY_ALIASES[match[1].toLowerCase()];
95
+ if (topology) {
96
+ return { topology, options: parseSpawnOptions(match[2] ?? "") };
97
+ }
98
+
99
+ return { topology: "horizontal", options: parseSpawnOptions(trimmed) };
100
+ }
101
+
102
+ async function createTarget(
103
+ pi: ExtensionAPI,
104
+ ctx: ExtensionCommandContext,
105
+ topology: HerdrTopology,
106
+ focus: boolean,
107
+ ): Promise<HerdrTarget> {
108
+ const focusArg = focus ? "--focus" : "--no-focus";
109
+ let result: ExecResult;
110
+
111
+ if (topology === "horizontal" || topology === "vertical") {
112
+ result = await pi.exec("herdr", [
113
+ "pane",
114
+ "split",
115
+ "--pane",
116
+ process.env.HERDR_PANE_ID!,
117
+ "--direction",
118
+ topology === "horizontal" ? "down" : "right",
119
+ "--cwd",
120
+ ctx.cwd,
121
+ focusArg,
122
+ ]);
123
+ const json = parseJsonResult(result, `Could not create the ${topology} Herdr split`);
124
+ const pane = objectAt(objectAt(json, "result"), "pane");
125
+ const paneId = stringAt(pane, "pane_id");
126
+ if (!paneId) throw new Error("Herdr did not return the new pane ID");
127
+ return { paneId, cleanup: { command: "pane", id: paneId } };
128
+ }
129
+
130
+ if (topology === "tab") {
131
+ result = await pi.exec("herdr", [
132
+ "tab",
133
+ "create",
134
+ "--workspace",
135
+ process.env.HERDR_WORKSPACE_ID!,
136
+ "--cwd",
137
+ ctx.cwd,
138
+ "--label",
139
+ "Aside",
140
+ focusArg,
141
+ ]);
142
+ const json = parseJsonResult(result, "Could not create the Herdr tab");
143
+ const payload = objectAt(json, "result");
144
+ const pane = objectAt(payload, "root_pane");
145
+ const tab = objectAt(payload, "tab");
146
+ const paneId = stringAt(pane, "pane_id");
147
+ const tabId = stringAt(tab, "tab_id");
148
+ if (!paneId || !tabId) throw new Error("Herdr did not return the new tab and pane IDs");
149
+ return { paneId, cleanup: { command: "tab", id: tabId } };
150
+ }
151
+
152
+ result = await pi.exec("herdr", [
153
+ "workspace",
154
+ "create",
155
+ "--cwd",
156
+ ctx.cwd,
157
+ "--label",
158
+ "Aside",
159
+ focusArg,
160
+ ]);
161
+ const json = parseJsonResult(result, "Could not create the Herdr workspace");
162
+ const payload = objectAt(json, "result");
163
+ const pane = objectAt(payload, "root_pane");
164
+ const workspace = objectAt(payload, "workspace");
165
+ const paneId = stringAt(pane, "pane_id");
166
+ const workspaceId = stringAt(workspace, "workspace_id");
167
+ if (!paneId || !workspaceId) {
168
+ throw new Error("Herdr did not return the new workspace and pane IDs");
169
+ }
170
+ return { paneId, cleanup: { command: "workspace", id: workspaceId } };
171
+ }
172
+
173
+ async function cleanupTarget(pi: ExtensionAPI, target: HerdrTarget): Promise<void> {
174
+ await pi.exec("herdr", [target.cleanup.command, "close", target.cleanup.id]);
175
+ }
176
+
177
+ function agentName(topology: HerdrTopology): string {
178
+ const short = topology === "horizontal" ? "h" : topology === "vertical" ? "v" : topology[0];
179
+ const unique = `${Date.now().toString(36).slice(-6)}${Math.random().toString(36).slice(2, 6)}`;
180
+ return `aside-${short}-${unique}`;
181
+ }
182
+
183
+ export async function spawnAsideFork(
184
+ pi: ExtensionAPI,
185
+ ctx: ExtensionCommandContext,
186
+ topology: HerdrTopology,
187
+ options: SpawnOptions,
188
+ ): Promise<void> {
189
+ if (ctx.mode !== "tui" || process.env.HERDR_ENV !== "1" || !process.env.HERDR_PANE_ID) {
190
+ notify(ctx, "/aside must run from Pi inside a Herdr-managed pane.", "error");
191
+ return;
192
+ }
193
+ if ((topology === "tab" || topology === "workspace") && !process.env.HERDR_WORKSPACE_ID) {
194
+ notify(ctx, "Herdr did not provide the current workspace ID.", "error");
195
+ return;
196
+ }
197
+
198
+ const sessionFile = ctx.sessionManager.getSessionFile();
199
+ if (!sessionFile) {
200
+ notify(ctx, "The current Pi session is ephemeral and cannot be used for an aside fork.", "error");
201
+ return;
202
+ }
203
+
204
+ let target: HerdrTarget | undefined;
205
+ try {
206
+ target = await createTarget(pi, ctx, topology, options.focus);
207
+ const name = agentName(topology);
208
+ const start = await pi.exec("herdr", [
209
+ "agent",
210
+ "start",
211
+ name,
212
+ "--kind",
213
+ "pi",
214
+ "--pane",
215
+ target.paneId,
216
+ "--",
217
+ "--fork",
218
+ sessionFile,
219
+ "--name",
220
+ `Aside ${topology}`,
221
+ "--append-system-prompt",
222
+ ASIDE_SYSTEM_PROMPT,
223
+ ], { timeout: 45_000 });
224
+ if (start.code !== 0) throw resultError(start, "Could not start the aside Pi fork");
225
+
226
+ if (options.prompt) {
227
+ const prompt = await pi.exec("herdr", ["agent", "prompt", name, options.prompt]);
228
+ if (prompt.code !== 0) throw resultError(prompt, "Could not send the initial aside prompt");
229
+ }
230
+
231
+ notify(
232
+ ctx,
233
+ `Created a new ${topology} aside in ${target.paneId}${options.prompt ? " and sent its prompt" : ""}.`,
234
+ "info",
235
+ );
236
+ } catch (error) {
237
+ if (target) {
238
+ try {
239
+ await cleanupTarget(pi, target);
240
+ } catch {
241
+ // The original failure is more useful than best-effort cleanup failure.
242
+ }
243
+ }
244
+ notify(ctx, error instanceof Error ? error.message : String(error), "error");
245
+ }
246
+ }
247
+
248
+ export default function herdrAside(pi: ExtensionAPI): void {
249
+ pi.registerCommand("aside", {
250
+ description: "Fork the current Pi session into a new Herdr split, tab, or workspace.",
251
+ getArgumentCompletions: (prefix) => {
252
+ const items = [
253
+ { value: "h", label: "h", description: "horizontal split" },
254
+ { value: "v", label: "v", description: "vertical split" },
255
+ { value: "t", label: "t", description: "new tab" },
256
+ { value: "w", label: "w", description: "new workspace" },
257
+ ];
258
+ const token = prefix.trim();
259
+ return token.includes(" ") ? null : items.filter((item) => item.value.startsWith(token));
260
+ },
261
+ handler: async (args, ctx) => {
262
+ const parsed = parseAsideArgs(args);
263
+ await spawnAsideFork(pi, ctx, parsed.topology, parsed.options);
264
+ },
265
+ });
266
+ }