@henryqw/pi-herdr-clone 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@henryqw/pi-herdr-clone`
2
2
 
3
- Pi extension that clones the current conversation path into a new Pi process in a new tab of the current Herdr workspace. Requires Pi Coding Agent 0.84.x (minimum 0.84.2) and a Pi session running inside Herdr.
3
+ Pi extension that clones the current conversation path into a new Pi process in a new tab of the current Herdr workspace, or into a new Herdr Git worktree workspace. Requires Pi Coding Agent 0.84.x (minimum 0.84.2) and a Pi session running inside Herdr.
4
4
 
5
5
  ## Install
6
6
 
@@ -8,25 +8,37 @@ Pi extension that clones the current conversation path into a new Pi process in
8
8
  pi install npm:@henryqw/pi-herdr-clone
9
9
  ```
10
10
 
11
- Remove with:
11
+ ## Use
12
12
 
13
- ```bash
14
- pi remove npm:@henryqw/pi-herdr-clone
15
- ```
13
+ | Surface | Type | Purpose |
14
+ | --- | --- | --- |
15
+ | `/clone-tab` | command | Clone the current conversation into a new tab of the current Herdr workspace. |
16
+ | `/clone-worktree` | command | Clone the current conversation into a new Herdr Git worktree workspace. |
17
+
18
+ Both commands wait until Pi is idle, then validate the current Herdr pane (`HERDR_ENV=1`, `HERDR_PANE_ID`), the persisted session file, and the current session leaf. They copy only the active root-to-leaf path into a new persisted session file: sibling branches are excluded and the original Pi session is not switched. Neither command has configuration.
19
+
20
+ ### `/clone-tab` behavior
16
21
 
17
- ## `/clone-tab`
22
+ 1. Creates an unfocused Herdr tab in the current workspace with the current working directory.
23
+ 2. Starts Pi in the tab's root pane with `--session <absolute-clone-file>`.
24
+ 3. Focuses the new tab after Pi starts successfully.
18
25
 
19
- The command waits until Pi is idle, then:
26
+ ### `/clone-worktree` behavior
20
27
 
21
- 1. Validates the current Herdr pane and resolves its live workspace.
22
- 2. Copies only the current Pi session path into a new persisted session file. Sibling branches are excluded and the original Pi session is not switched.
23
- 3. Creates an unfocused Herdr tab in that workspace with the current working directory.
24
- 4. Starts Pi in the tab's root pane with `--session <absolute-clone-file>`.
25
- 5. Focuses the new tab after Pi starts successfully.
28
+ 1. Creates a Git worktree-backed workspace with `herdr worktree create --workspace <current-workspace> --no-focus`; Herdr creates the branch from `HEAD` unless the name exists, checks out the worktree under its configured `worktrees.directory`, and opens it as a grouped workspace.
29
+ 2. Copies the active path into a clone session stamped with the fresh checkout path as its working directory.
30
+ 3. Starts Pi in the new workspace's root pane (whose shell runs inside the checkout) with `--session <absolute-clone-file>`.
31
+ 4. Focuses the new tab after Pi starts successfully.
26
32
 
27
- The command requires `HERDR_ENV=1`, `HERDR_PANE_ID`, an existing persisted session file, and a current session leaf. It has no configuration or worktree behavior.
33
+ ### Failure semantics
28
34
 
29
- If tab creation itself fails, the cloned session file is removed. If Herdr creates the tab but the response is incomplete, or once agent start is attempted, the tab and session file are retained because the launch outcome can be unknown; the error reports any known IDs for recovery. A later focus failure is shown as a warning and does not report the already-started clone as failed.
35
+ If target creation fails outright, no clone session is kept or created. A killed or incomplete creation response is ambiguous because Herdr may have retained partial state; the error reports every identifier returned so far and suggests inspecting `herdr workspace list`. Once agent start is attempted, the target tab, panes, and session file are retained because the launch outcome can be unknown; the error reports any known IDs for recovery. A later focus failure is shown as a warning and does not report the already-started clone as failed.
36
+
37
+ ## Remove
38
+
39
+ ```bash
40
+ pi remove npm:@henryqw/pi-herdr-clone
41
+ ```
30
42
 
31
43
  ## Development
32
44
 
@@ -5,12 +5,24 @@ import { setTimeout as delay } from "node:timers/promises";
5
5
  import {
6
6
  SessionManager,
7
7
  type ExtensionAPI,
8
+ type ExtensionCommandContext,
8
9
  } from "@earendil-works/pi-coding-agent";
9
- import { createHerdrClient, herdrCommandFailure, hasHerdrErrorCode, withWorktreeLock, type HerdrExecResult } from "@henryqw/pi-herdr";
10
+ import {
11
+ createHerdrClient,
12
+ herdrCommandFailure,
13
+ hasHerdrErrorCode,
14
+ withWorktreeLock,
15
+ type HerdrClient,
16
+ type HerdrExecResult,
17
+ } from "@henryqw/pi-herdr";
10
18
 
11
19
  type WorkspaceInfo = {
12
20
  workspace_id?: unknown;
13
- worktree?: { checkout_path?: unknown } | null;
21
+ worktree?: {
22
+ checkout_path?: unknown;
23
+ repo_root?: unknown;
24
+ is_linked_worktree?: unknown;
25
+ } | null;
14
26
  };
15
27
 
16
28
  const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error);
@@ -20,6 +32,121 @@ function requiredString(value: unknown, label: string): string {
20
32
  return value;
21
33
  }
22
34
 
35
+ type SourceContext = {
36
+ sessionFile: string;
37
+ leafId: string;
38
+ workspaceId: string;
39
+ checkout: string | undefined;
40
+ repoRoot: string | undefined;
41
+ isLinkedWorktree: boolean;
42
+ };
43
+
44
+ async function resolveSource(
45
+ commandName: string,
46
+ herdr: HerdrClient<{ cwd: string }>,
47
+ ctx: ExtensionCommandContext,
48
+ ): Promise<SourceContext> {
49
+ if (process.env.HERDR_ENV !== "1") {
50
+ throw new Error(`/${commandName} requires the current Pi session inside Herdr (HERDR_ENV=1).`);
51
+ }
52
+ const requestedPaneId = requiredString(process.env.HERDR_PANE_ID, "HERDR_PANE_ID");
53
+ const currentSessionFile = requiredString(
54
+ ctx.sessionManager.getSessionFile(),
55
+ "Persisted Pi session file",
56
+ );
57
+ const leafId = requiredString(ctx.sessionManager.getLeafId(), "Current Pi session leaf");
58
+ const sessionFile = resolve(currentSessionFile);
59
+ let sourceStat;
60
+ try {
61
+ sourceStat = await stat(sessionFile);
62
+ } catch (error) {
63
+ throw new Error(`Persisted Pi session file does not exist: ${sessionFile}`, { cause: error });
64
+ }
65
+ if (!sourceStat.isFile()) throw new Error(`Persisted Pi session path is not a file: ${sessionFile}`);
66
+
67
+ const paneResponse = await herdr.json(["pane", "get", requestedPaneId], { cwd: ctx.cwd });
68
+ const pane = (paneResponse as { result?: { pane?: { pane_id?: unknown; workspace_id?: unknown } } }).result?.pane;
69
+ requiredString(pane?.pane_id, "Herdr pane response pane_id");
70
+ const workspaceId = requiredString(pane?.workspace_id, "Herdr pane response workspace_id");
71
+ const workspaceResponse = await herdr.json(["workspace", "get", workspaceId], { cwd: ctx.cwd });
72
+ const workspace = (workspaceResponse as { result?: { workspace?: WorkspaceInfo } }).result?.workspace;
73
+ if (requiredString(workspace?.workspace_id, "Herdr workspace response workspace_id") !== workspaceId) {
74
+ throw new Error(`Herdr workspace response did not match ${workspaceId}.`);
75
+ }
76
+ const checkout = workspace?.worktree == null
77
+ ? undefined
78
+ : requiredString(workspace.worktree.checkout_path, "Herdr workspace response checkout_path");
79
+ const repoRoot = typeof workspace?.worktree?.repo_root === "string" && workspace.worktree.repo_root.trim()
80
+ ? workspace.worktree.repo_root
81
+ : undefined;
82
+ const isLinkedWorktree = workspace?.worktree?.is_linked_worktree === true;
83
+ if (isLinkedWorktree && !repoRoot) {
84
+ throw new Error("Herdr workspace response is missing worktree.repo_root for a linked worktree.");
85
+ }
86
+ return { sessionFile, leafId, workspaceId, checkout, repoRoot, isLinkedWorktree };
87
+ }
88
+
89
+ async function createBranchedClone(
90
+ ctx: ExtensionCommandContext,
91
+ source: SourceContext,
92
+ cwd: string,
93
+ ): Promise<string> {
94
+ const session = SessionManager.open(source.sessionFile, ctx.sessionManager.getSessionDir(), cwd);
95
+ const createdClone = session.createBranchedSession(source.leafId);
96
+ if (!createdClone) throw new Error("Pi did not create a persisted clone session file.");
97
+ const cloneFile = resolve(createdClone);
98
+ let cloneStat;
99
+ try {
100
+ cloneStat = await stat(cloneFile);
101
+ } catch (error) {
102
+ throw new Error(`Pi clone session file was not created: ${cloneFile}`, { cause: error });
103
+ }
104
+ if (!cloneStat.isFile()) throw new Error(`Pi clone session path is not a file: ${cloneFile}`);
105
+ return cloneFile;
106
+ }
107
+
108
+ async function discardCloneOrAggregate(cloneFile: string, error: Error): Promise<never> {
109
+ try {
110
+ await unlink(cloneFile);
111
+ } catch (cleanupError) {
112
+ throw new AggregateError(
113
+ [error, cleanupError],
114
+ `${error.message} Clone cleanup also failed for ${cloneFile}: ${errorMessage(cleanupError)}`,
115
+ );
116
+ }
117
+ throw error;
118
+ }
119
+
120
+ async function launchCloneAgent(
121
+ herdr: HerdrClient<{ cwd: string }>,
122
+ ctx: ExtensionCommandContext,
123
+ rootPaneId: string,
124
+ cloneFile: string,
125
+ retained: string,
126
+ ): Promise<string> {
127
+ const agentName = `clone-${randomUUID().replaceAll("-", "").slice(0, 24)}`;
128
+ const startArgs = [
129
+ "agent", "start", agentName, "--kind", "pi", "--pane", rootPaneId,
130
+ "--", "--session", cloneFile,
131
+ ];
132
+ try {
133
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
134
+ const result = await herdr.exec(startArgs, { cwd: ctx.cwd });
135
+ if (result.code === 0 && !result.killed) return agentName;
136
+ if (!hasHerdrErrorCode(result, "agent_pane_busy") || attempt === 5) {
137
+ throw new Error(herdrCommandFailure(startArgs, result));
138
+ }
139
+ await delay(250);
140
+ }
141
+ throw new Error("Herdr agent start retry loop exited unexpectedly.");
142
+ } catch (error) {
143
+ throw new Error(
144
+ `Clone launch could not be confirmed after starting agent ${agentName}; retained ${retained}: ${errorMessage(error)}`,
145
+ { cause: error },
146
+ );
147
+ }
148
+ }
149
+
23
150
  export default function herdrCloneExtension(pi: ExtensionAPI): void {
24
151
  const herdr = createHerdrClient<{ cwd: string }>((command, args, options) =>
25
152
  pi.exec(command, [...args], options));
@@ -28,67 +155,17 @@ export default function herdrCloneExtension(pi: ExtensionAPI): void {
28
155
  description: "Clone the current conversation path into a new Herdr tab",
29
156
  handler: async (_args, ctx) => {
30
157
  await ctx.waitForIdle();
31
-
32
- if (process.env.HERDR_ENV !== "1") {
33
- throw new Error("/clone-tab requires the current Pi session inside Herdr (HERDR_ENV=1).");
34
- }
35
- const requestedPaneId = requiredString(process.env.HERDR_PANE_ID, "HERDR_PANE_ID");
36
- const currentSessionFile = requiredString(
37
- ctx.sessionManager.getSessionFile(),
38
- "Persisted Pi session file",
39
- );
40
- const leafId = requiredString(ctx.sessionManager.getLeafId(), "Current Pi session leaf");
41
- const sessionFile = resolve(currentSessionFile);
42
- let sourceStat;
43
- try {
44
- sourceStat = await stat(sessionFile);
45
- } catch (error) {
46
- throw new Error(`Persisted Pi session file does not exist: ${sessionFile}`, { cause: error });
47
- }
48
- if (!sourceStat.isFile()) throw new Error(`Persisted Pi session path is not a file: ${sessionFile}`);
49
-
50
- const paneResponse = await herdr.json(["pane", "get", requestedPaneId], { cwd: ctx.cwd });
51
- const pane = (paneResponse as { result?: { pane?: { pane_id?: unknown; workspace_id?: unknown } } }).result?.pane;
52
- requiredString(pane?.pane_id, "Herdr pane response pane_id");
53
- const workspaceId = requiredString(pane?.workspace_id, "Herdr pane response workspace_id");
54
- const workspaceResponse = await herdr.json(["workspace", "get", workspaceId], { cwd: ctx.cwd });
55
- const workspace = (workspaceResponse as { result?: { workspace?: WorkspaceInfo } }).result?.workspace;
56
- if (requiredString(workspace?.workspace_id, "Herdr workspace response workspace_id") !== workspaceId) {
57
- throw new Error(`Herdr workspace response did not match ${workspaceId}.`);
58
- }
59
- const checkout = workspace?.worktree == null
60
- ? undefined
61
- : requiredString(workspace.worktree.checkout_path, "Herdr workspace response checkout_path");
158
+ const source = await resolveSource("clone-tab", herdr, ctx);
62
159
  const mutate = async (): Promise<{ createdTab: HerdrExecResult; cloneFile: string }> => {
63
- const session = SessionManager.open(sessionFile, ctx.sessionManager.getSessionDir(), ctx.cwd);
64
- const createdClone = session.createBranchedSession(leafId);
65
- if (!createdClone) throw new Error("Pi did not create a persisted clone session file.");
66
- const cloneFile = resolve(createdClone);
67
- let cloneStat;
68
- try {
69
- cloneStat = await stat(cloneFile);
70
- } catch (error) {
71
- throw new Error(`Pi clone session file was not created: ${cloneFile}`, { cause: error });
72
- }
73
- if (!cloneStat.isFile()) throw new Error(`Pi clone session path is not a file: ${cloneFile}`);
74
-
75
- const tabCreateArgs = ["tab", "create", "--workspace", workspaceId, "--cwd", ctx.cwd, "--no-focus"] as const;
160
+ const cloneFile = await createBranchedClone(ctx, source, ctx.cwd);
161
+ const tabCreateArgs = ["tab", "create", "--workspace", source.workspaceId, "--cwd", ctx.cwd, "--no-focus"] as const;
76
162
  const createdTab = await herdr.exec(tabCreateArgs, { cwd: ctx.cwd });
77
163
  if (createdTab.code !== 0 || createdTab.killed) {
78
- const createError = new Error(herdrCommandFailure(tabCreateArgs, createdTab));
79
- try {
80
- await unlink(cloneFile);
81
- } catch (cleanupError) {
82
- throw new AggregateError(
83
- [createError, cleanupError],
84
- `${createError.message} Clone cleanup also failed for ${cloneFile}: ${errorMessage(cleanupError)}`,
85
- );
86
- }
87
- throw createError;
164
+ await discardCloneOrAggregate(cloneFile, new Error(herdrCommandFailure(tabCreateArgs, createdTab)));
88
165
  }
89
166
  return { createdTab, cloneFile };
90
167
  };
91
- const { createdTab, cloneFile } = checkout ? await withWorktreeLock(checkout, mutate) : await mutate();
168
+ const { createdTab, cloneFile } = source.checkout ? await withWorktreeLock(source.checkout, mutate) : await mutate();
92
169
  let tabId: string | undefined;
93
170
  let rootPaneId: string | undefined;
94
171
  try {
@@ -107,38 +184,124 @@ export default function herdrCloneExtension(pi: ExtensionAPI): void {
107
184
  { cause: error },
108
185
  );
109
186
  }
110
- const agentName = `clone-${randomUUID().replaceAll("-", "").slice(0, 24)}`;
111
- const startArgs = [
112
- "agent", "start", agentName, "--kind", "pi", "--pane", rootPaneId,
113
- "--", "--session", cloneFile,
114
- ];
187
+ const agentName = await launchCloneAgent(herdr, ctx, rootPaneId!, cloneFile, `Herdr tab ${tabId}, root pane ${rootPaneId}, and session ${cloneFile}`);
188
+
189
+ try {
190
+ await herdr.run(["tab", "focus", tabId!], { cwd: ctx.cwd });
191
+ } catch (error) {
192
+ ctx.ui.notify(
193
+ `Clone agent ${agentName} started in Herdr tab ${tabId} (root pane ${rootPaneId}), but focus failed: ${errorMessage(error)}`,
194
+ "warning",
195
+ );
196
+ return;
197
+ }
198
+ ctx.ui.notify(
199
+ `Cloned current conversation into Herdr tab ${tabId} (root pane ${rootPaneId}, agent ${agentName}).`,
200
+ "info",
201
+ );
202
+ },
203
+ });
204
+
205
+ pi.registerCommand("clone-worktree", {
206
+ description: "Clone the current conversation into Pi in a new Herdr Git worktree",
207
+ handler: async (_args, ctx) => {
208
+ await ctx.waitForIdle();
209
+ const source = await resolveSource("clone-worktree", herdr, ctx);
210
+
211
+ // Herdr only creates worktrees from the repo parent workspace; running
212
+ // /clone-worktree inside a linked worktree must retarget the parent.
213
+ let targetWorkspaceId = source.workspaceId;
214
+ if (source.isLinkedWorktree) {
215
+ const listing = await herdr.json(["workspace", "list"], { cwd: ctx.cwd });
216
+ const result = (listing as { result?: { workspaces?: WorkspaceInfo[] } }).result;
217
+ const parent = (Array.isArray(result?.workspaces) ? result.workspaces : []).find((entry) =>
218
+ entry.worktree != null && !entry.worktree.is_linked_worktree &&
219
+ entry.worktree.checkout_path === source.repoRoot);
220
+ targetWorkspaceId = typeof parent?.workspace_id === "string"
221
+ ? parent.workspace_id
222
+ : requiredString(undefined, `Repo parent workspace for ${source.repoRoot} in herdr workspace list`);
223
+ }
224
+ // Create the worktree before the clone so the session header can be
225
+ // stamped with the fresh checkout cwd. A killed or incomplete create is
226
+ // ambiguous: Herdr may have retained partial worktree state.
227
+ const worktreeCreateArgs = ["worktree", "create", "--workspace", targetWorkspaceId, "--no-focus"] as const;
228
+ const createdWorktree = await herdr.exec(worktreeCreateArgs, { cwd: ctx.cwd });
229
+ if (createdWorktree.code !== 0 && !createdWorktree.killed) {
230
+ throw new Error(herdrCommandFailure(worktreeCreateArgs, createdWorktree));
231
+ }
232
+
233
+ let workspaceId: string | undefined;
234
+ let tabId: string | undefined;
235
+ let rootPaneId: string | undefined;
236
+ let checkoutPath: string | undefined;
115
237
  try {
116
- for (let attempt = 1; attempt <= 5; attempt += 1) {
117
- const result = await herdr.exec(startArgs, { cwd: ctx.cwd });
118
- if (result.code === 0 && !result.killed) break;
119
- if (!hasHerdrErrorCode(result, "agent_pane_busy") || attempt === 5) {
120
- throw new Error(herdrCommandFailure(startArgs, result));
121
- }
122
- await delay(250);
238
+ const response: unknown = JSON.parse(createdWorktree.stdout);
239
+ if (!response || typeof response !== "object" || Array.isArray(response)) {
240
+ throw new Error("Herdr worktree create returned invalid JSON");
241
+ }
242
+ const result = (response as {
243
+ result?: {
244
+ workspace?: { workspace_id?: unknown };
245
+ tab?: { tab_id?: unknown };
246
+ root_pane?: { pane_id?: unknown };
247
+ worktree?: { checkout_path?: unknown };
248
+ };
249
+ }).result;
250
+ // Collect every returned identifier before validating so recovery
251
+ // keeps all known IDs even when an earlier field is missing.
252
+ workspaceId = typeof result?.workspace?.workspace_id === "string" ? result.workspace.workspace_id : undefined;
253
+ tabId = typeof result?.tab?.tab_id === "string" ? result.tab.tab_id : undefined;
254
+ rootPaneId = typeof result?.root_pane?.pane_id === "string" ? result.root_pane.pane_id : undefined;
255
+ checkoutPath = typeof result?.worktree?.checkout_path === "string" ? result.worktree.checkout_path : undefined;
256
+ const missing = [
257
+ [workspaceId, "workspace_id"],
258
+ [tabId, "tab_id"],
259
+ [rootPaneId, "root_pane.pane_id"],
260
+ [checkoutPath, "worktree.checkout_path"],
261
+ ].filter(([value]) => !value).map(([, label]) => label);
262
+ if (missing.length > 0) {
263
+ throw new Error(`Herdr worktree create response is missing ${missing.join(", ")}.`);
123
264
  }
265
+ } catch (error) {
266
+ const known = [
267
+ workspaceId && `workspace ${workspaceId}`,
268
+ tabId && `tab ${tabId}`,
269
+ rootPaneId && `root pane ${rootPaneId}`,
270
+ checkoutPath && `checkout ${checkoutPath}`,
271
+ ].filter(Boolean).join(", ");
272
+ throw new Error(
273
+ `Clone could not be confirmed after creating a Herdr worktree${known ? ` (${known})` : ""}; Herdr may have retained a partial worktree workspace, inspect herdr workspace list: ${errorMessage(error)}`,
274
+ { cause: error },
275
+ );
276
+ }
277
+
278
+ let cloneFile: string;
279
+ try {
280
+ cloneFile = source.checkout
281
+ ? await withWorktreeLock(source.checkout, () => createBranchedClone(ctx, source, checkoutPath!))
282
+ : await createBranchedClone(ctx, source, checkoutPath!);
124
283
  } catch (error) {
125
284
  throw new Error(
126
- `Clone launch could not be confirmed after starting agent ${agentName}; retained Herdr tab ${tabId}, root pane ${rootPaneId}, and session ${cloneFile}: ${errorMessage(error)}`,
285
+ `Clone session could not be created for Herdr worktree workspace ${workspaceId} (tab ${tabId}, checkout ${checkoutPath}); retained worktree without a clone session: ${errorMessage(error)}`,
127
286
  { cause: error },
128
287
  );
129
288
  }
289
+ const agentName = await launchCloneAgent(
290
+ herdr, ctx, rootPaneId!, cloneFile,
291
+ `Herdr workspace ${workspaceId}, tab ${tabId}, root pane ${rootPaneId}, and session ${cloneFile}`,
292
+ );
130
293
 
131
294
  try {
132
- await herdr.run(["tab", "focus", tabId], { cwd: ctx.cwd });
295
+ await herdr.run(["tab", "focus", tabId!], { cwd: ctx.cwd });
133
296
  } catch (error) {
134
297
  ctx.ui.notify(
135
- `Clone agent ${agentName} started in Herdr tab ${tabId} (root pane ${rootPaneId}), but focus failed: ${errorMessage(error)}`,
298
+ `Clone agent ${agentName} started in Herdr worktree workspace ${workspaceId} (tab ${tabId}, checkout ${checkoutPath}), but focus failed: ${errorMessage(error)}`,
136
299
  "warning",
137
300
  );
138
301
  return;
139
302
  }
140
303
  ctx.ui.notify(
141
- `Cloned current conversation into Herdr tab ${tabId} (root pane ${rootPaneId}, agent ${agentName}).`,
304
+ `Cloned current conversation into Herdr worktree workspace ${workspaceId} (tab ${tabId}, checkout ${checkoutPath}, agent ${agentName}).`,
142
305
  "info",
143
306
  );
144
307
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-herdr-clone",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Clone the current Pi conversation path into a new Herdr tab.",
5
5
  "keywords": [
6
6
  "pi-package",