@henryqw/pi-herdr-clone 0.1.2 → 0.2.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/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,8 +5,21 @@ 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 } 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";
18
+
19
+ type WorkspaceInfo = {
20
+ workspace_id?: unknown;
21
+ worktree?: { checkout_path?: unknown } | null;
22
+ };
10
23
 
11
24
  const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error);
12
25
 
@@ -15,6 +28,112 @@ function requiredString(value: unknown, label: string): string {
15
28
  return value;
16
29
  }
17
30
 
31
+ type SourceContext = {
32
+ sessionFile: string;
33
+ leafId: string;
34
+ workspaceId: string;
35
+ checkout: string | undefined;
36
+ };
37
+
38
+ async function resolveSource(
39
+ commandName: string,
40
+ herdr: HerdrClient<{ cwd: string }>,
41
+ ctx: ExtensionCommandContext,
42
+ ): Promise<SourceContext> {
43
+ if (process.env.HERDR_ENV !== "1") {
44
+ throw new Error(`/${commandName} requires the current Pi session inside Herdr (HERDR_ENV=1).`);
45
+ }
46
+ const requestedPaneId = requiredString(process.env.HERDR_PANE_ID, "HERDR_PANE_ID");
47
+ const currentSessionFile = requiredString(
48
+ ctx.sessionManager.getSessionFile(),
49
+ "Persisted Pi session file",
50
+ );
51
+ const leafId = requiredString(ctx.sessionManager.getLeafId(), "Current Pi session leaf");
52
+ const sessionFile = resolve(currentSessionFile);
53
+ let sourceStat;
54
+ try {
55
+ sourceStat = await stat(sessionFile);
56
+ } catch (error) {
57
+ throw new Error(`Persisted Pi session file does not exist: ${sessionFile}`, { cause: error });
58
+ }
59
+ if (!sourceStat.isFile()) throw new Error(`Persisted Pi session path is not a file: ${sessionFile}`);
60
+
61
+ const paneResponse = await herdr.json(["pane", "get", requestedPaneId], { cwd: ctx.cwd });
62
+ const pane = (paneResponse as { result?: { pane?: { pane_id?: unknown; workspace_id?: unknown } } }).result?.pane;
63
+ requiredString(pane?.pane_id, "Herdr pane response pane_id");
64
+ const workspaceId = requiredString(pane?.workspace_id, "Herdr pane response workspace_id");
65
+ const workspaceResponse = await herdr.json(["workspace", "get", workspaceId], { cwd: ctx.cwd });
66
+ const workspace = (workspaceResponse as { result?: { workspace?: WorkspaceInfo } }).result?.workspace;
67
+ if (requiredString(workspace?.workspace_id, "Herdr workspace response workspace_id") !== workspaceId) {
68
+ throw new Error(`Herdr workspace response did not match ${workspaceId}.`);
69
+ }
70
+ const checkout = workspace?.worktree == null
71
+ ? undefined
72
+ : requiredString(workspace.worktree.checkout_path, "Herdr workspace response checkout_path");
73
+ return { sessionFile, leafId, workspaceId, checkout };
74
+ }
75
+
76
+ async function createBranchedClone(
77
+ ctx: ExtensionCommandContext,
78
+ source: SourceContext,
79
+ cwd: string,
80
+ ): Promise<string> {
81
+ const session = SessionManager.open(source.sessionFile, ctx.sessionManager.getSessionDir(), cwd);
82
+ const createdClone = session.createBranchedSession(source.leafId);
83
+ if (!createdClone) throw new Error("Pi did not create a persisted clone session file.");
84
+ const cloneFile = resolve(createdClone);
85
+ let cloneStat;
86
+ try {
87
+ cloneStat = await stat(cloneFile);
88
+ } catch (error) {
89
+ throw new Error(`Pi clone session file was not created: ${cloneFile}`, { cause: error });
90
+ }
91
+ if (!cloneStat.isFile()) throw new Error(`Pi clone session path is not a file: ${cloneFile}`);
92
+ return cloneFile;
93
+ }
94
+
95
+ async function discardCloneOrAggregate(cloneFile: string, error: Error): Promise<never> {
96
+ try {
97
+ await unlink(cloneFile);
98
+ } catch (cleanupError) {
99
+ throw new AggregateError(
100
+ [error, cleanupError],
101
+ `${error.message} Clone cleanup also failed for ${cloneFile}: ${errorMessage(cleanupError)}`,
102
+ );
103
+ }
104
+ throw error;
105
+ }
106
+
107
+ async function launchCloneAgent(
108
+ herdr: HerdrClient<{ cwd: string }>,
109
+ ctx: ExtensionCommandContext,
110
+ rootPaneId: string,
111
+ cloneFile: string,
112
+ retained: string,
113
+ ): Promise<string> {
114
+ const agentName = `clone-${randomUUID().replaceAll("-", "").slice(0, 24)}`;
115
+ const startArgs = [
116
+ "agent", "start", agentName, "--kind", "pi", "--pane", rootPaneId,
117
+ "--", "--session", cloneFile,
118
+ ];
119
+ try {
120
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
121
+ const result = await herdr.exec(startArgs, { cwd: ctx.cwd });
122
+ if (result.code === 0 && !result.killed) return agentName;
123
+ if (!hasHerdrErrorCode(result, "agent_pane_busy") || attempt === 5) {
124
+ throw new Error(herdrCommandFailure(startArgs, result));
125
+ }
126
+ await delay(250);
127
+ }
128
+ throw new Error("Herdr agent start retry loop exited unexpectedly.");
129
+ } catch (error) {
130
+ throw new Error(
131
+ `Clone launch could not be confirmed after starting agent ${agentName}; retained ${retained}: ${errorMessage(error)}`,
132
+ { cause: error },
133
+ );
134
+ }
135
+ }
136
+
18
137
  export default function herdrCloneExtension(pi: ExtensionAPI): void {
19
138
  const herdr = createHerdrClient<{ cwd: string }>((command, args, options) =>
20
139
  pi.exec(command, [...args], options));
@@ -23,57 +142,17 @@ export default function herdrCloneExtension(pi: ExtensionAPI): void {
23
142
  description: "Clone the current conversation path into a new Herdr tab",
24
143
  handler: async (_args, ctx) => {
25
144
  await ctx.waitForIdle();
26
-
27
- if (process.env.HERDR_ENV !== "1") {
28
- throw new Error("/clone-tab requires the current Pi session inside Herdr (HERDR_ENV=1).");
29
- }
30
- const requestedPaneId = requiredString(process.env.HERDR_PANE_ID, "HERDR_PANE_ID");
31
- const currentSessionFile = requiredString(
32
- ctx.sessionManager.getSessionFile(),
33
- "Persisted Pi session file",
34
- );
35
- const leafId = requiredString(ctx.sessionManager.getLeafId(), "Current Pi session leaf");
36
- const sessionFile = resolve(currentSessionFile);
37
- let sourceStat;
38
- try {
39
- sourceStat = await stat(sessionFile);
40
- } catch (error) {
41
- throw new Error(`Persisted Pi session file does not exist: ${sessionFile}`, { cause: error });
42
- }
43
- if (!sourceStat.isFile()) throw new Error(`Persisted Pi session path is not a file: ${sessionFile}`);
44
-
45
- const paneResponse = await herdr.json(["pane", "get", requestedPaneId], { cwd: ctx.cwd });
46
- const pane = (paneResponse as { result?: { pane?: { pane_id?: unknown; workspace_id?: unknown } } }).result?.pane;
47
- requiredString(pane?.pane_id, "Herdr pane response pane_id");
48
- const workspaceId = requiredString(pane?.workspace_id, "Herdr pane response workspace_id");
49
-
50
- const session = SessionManager.open(sessionFile, ctx.sessionManager.getSessionDir(), ctx.cwd);
51
- const createdClone = session.createBranchedSession(leafId);
52
- if (!createdClone) throw new Error("Pi did not create a persisted clone session file.");
53
- const cloneFile = resolve(createdClone);
54
- let cloneStat;
55
- try {
56
- cloneStat = await stat(cloneFile);
57
- } catch (error) {
58
- throw new Error(`Pi clone session file was not created: ${cloneFile}`, { cause: error });
59
- }
60
- if (!cloneStat.isFile()) throw new Error(`Pi clone session path is not a file: ${cloneFile}`);
61
-
62
- const tabCreateArgs = ["tab", "create", "--workspace", workspaceId, "--cwd", ctx.cwd, "--no-focus"] as const;
63
- const createdTab = await herdr.exec(tabCreateArgs, { cwd: ctx.cwd });
64
- if (createdTab.code !== 0 || createdTab.killed) {
65
- const createError = new Error(herdrCommandFailure(tabCreateArgs, createdTab));
66
- try {
67
- await unlink(cloneFile);
68
- } catch (cleanupError) {
69
- throw new AggregateError(
70
- [createError, cleanupError],
71
- `${createError.message} Clone cleanup also failed for ${cloneFile}: ${errorMessage(cleanupError)}`,
72
- );
145
+ const source = await resolveSource("clone-tab", herdr, ctx);
146
+ const mutate = async (): Promise<{ createdTab: HerdrExecResult; cloneFile: string }> => {
147
+ const cloneFile = await createBranchedClone(ctx, source, ctx.cwd);
148
+ const tabCreateArgs = ["tab", "create", "--workspace", source.workspaceId, "--cwd", ctx.cwd, "--no-focus"] as const;
149
+ const createdTab = await herdr.exec(tabCreateArgs, { cwd: ctx.cwd });
150
+ if (createdTab.code !== 0 || createdTab.killed) {
151
+ await discardCloneOrAggregate(cloneFile, new Error(herdrCommandFailure(tabCreateArgs, createdTab)));
73
152
  }
74
- throw createError;
75
- }
76
-
153
+ return { createdTab, cloneFile };
154
+ };
155
+ const { createdTab, cloneFile } = source.checkout ? await withWorktreeLock(source.checkout, mutate) : await mutate();
77
156
  let tabId: string | undefined;
78
157
  let rootPaneId: string | undefined;
79
158
  try {
@@ -92,38 +171,111 @@ export default function herdrCloneExtension(pi: ExtensionAPI): void {
92
171
  { cause: error },
93
172
  );
94
173
  }
95
- const agentName = `clone-${randomUUID().replaceAll("-", "").slice(0, 24)}`;
96
- const startArgs = [
97
- "agent", "start", agentName, "--kind", "pi", "--pane", rootPaneId,
98
- "--", "--session", cloneFile,
99
- ];
174
+ const agentName = await launchCloneAgent(herdr, ctx, rootPaneId!, cloneFile, `Herdr tab ${tabId}, root pane ${rootPaneId}, and session ${cloneFile}`);
175
+
100
176
  try {
101
- for (let attempt = 1; attempt <= 5; attempt += 1) {
102
- const result = await herdr.exec(startArgs, { cwd: ctx.cwd });
103
- if (result.code === 0 && !result.killed) break;
104
- if (!hasHerdrErrorCode(result, "agent_pane_busy") || attempt === 5) {
105
- throw new Error(herdrCommandFailure(startArgs, result));
106
- }
107
- await delay(250);
177
+ await herdr.run(["tab", "focus", tabId!], { cwd: ctx.cwd });
178
+ } catch (error) {
179
+ ctx.ui.notify(
180
+ `Clone agent ${agentName} started in Herdr tab ${tabId} (root pane ${rootPaneId}), but focus failed: ${errorMessage(error)}`,
181
+ "warning",
182
+ );
183
+ return;
184
+ }
185
+ ctx.ui.notify(
186
+ `Cloned current conversation into Herdr tab ${tabId} (root pane ${rootPaneId}, agent ${agentName}).`,
187
+ "info",
188
+ );
189
+ },
190
+ });
191
+
192
+ pi.registerCommand("clone-worktree", {
193
+ description: "Clone the current conversation into Pi in a new Herdr Git worktree",
194
+ handler: async (_args, ctx) => {
195
+ await ctx.waitForIdle();
196
+ const source = await resolveSource("clone-worktree", herdr, ctx);
197
+
198
+ // Create the worktree before the clone so the session header can be
199
+ // stamped with the fresh checkout cwd. A killed or incomplete create is
200
+ // ambiguous: Herdr may have retained partial worktree state.
201
+ const worktreeCreateArgs = ["worktree", "create", "--workspace", source.workspaceId, "--no-focus"] as const;
202
+ const createdWorktree = await herdr.exec(worktreeCreateArgs, { cwd: ctx.cwd });
203
+ if (createdWorktree.code !== 0 && !createdWorktree.killed) {
204
+ throw new Error(herdrCommandFailure(worktreeCreateArgs, createdWorktree));
205
+ }
206
+
207
+ let workspaceId: string | undefined;
208
+ let tabId: string | undefined;
209
+ let rootPaneId: string | undefined;
210
+ let checkoutPath: string | undefined;
211
+ try {
212
+ const response: unknown = JSON.parse(createdWorktree.stdout);
213
+ if (!response || typeof response !== "object" || Array.isArray(response)) {
214
+ throw new Error("Herdr worktree create returned invalid JSON");
215
+ }
216
+ const result = (response as {
217
+ result?: {
218
+ workspace?: { workspace_id?: unknown };
219
+ tab?: { tab_id?: unknown };
220
+ root_pane?: { pane_id?: unknown };
221
+ worktree?: { checkout_path?: unknown };
222
+ };
223
+ }).result;
224
+ // Collect every returned identifier before validating so recovery
225
+ // keeps all known IDs even when an earlier field is missing.
226
+ workspaceId = typeof result?.workspace?.workspace_id === "string" ? result.workspace.workspace_id : undefined;
227
+ tabId = typeof result?.tab?.tab_id === "string" ? result.tab.tab_id : undefined;
228
+ rootPaneId = typeof result?.root_pane?.pane_id === "string" ? result.root_pane.pane_id : undefined;
229
+ checkoutPath = typeof result?.worktree?.checkout_path === "string" ? result.worktree.checkout_path : undefined;
230
+ const missing = [
231
+ [workspaceId, "workspace_id"],
232
+ [tabId, "tab_id"],
233
+ [rootPaneId, "root_pane.pane_id"],
234
+ [checkoutPath, "worktree.checkout_path"],
235
+ ].filter(([value]) => !value).map(([, label]) => label);
236
+ if (missing.length > 0) {
237
+ throw new Error(`Herdr worktree create response is missing ${missing.join(", ")}.`);
108
238
  }
239
+ } catch (error) {
240
+ const known = [
241
+ workspaceId && `workspace ${workspaceId}`,
242
+ tabId && `tab ${tabId}`,
243
+ rootPaneId && `root pane ${rootPaneId}`,
244
+ checkoutPath && `checkout ${checkoutPath}`,
245
+ ].filter(Boolean).join(", ");
246
+ throw new Error(
247
+ `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)}`,
248
+ { cause: error },
249
+ );
250
+ }
251
+
252
+ let cloneFile: string;
253
+ try {
254
+ cloneFile = source.checkout
255
+ ? await withWorktreeLock(source.checkout, () => createBranchedClone(ctx, source, checkoutPath!))
256
+ : await createBranchedClone(ctx, source, checkoutPath!);
109
257
  } catch (error) {
110
258
  throw new Error(
111
- `Clone launch could not be confirmed after starting agent ${agentName}; retained Herdr tab ${tabId}, root pane ${rootPaneId}, and session ${cloneFile}: ${errorMessage(error)}`,
259
+ `Clone session could not be created for Herdr worktree workspace ${workspaceId} (tab ${tabId}, checkout ${checkoutPath}); retained worktree without a clone session: ${errorMessage(error)}`,
112
260
  { cause: error },
113
261
  );
114
262
  }
263
+ const agentName = await launchCloneAgent(
264
+ herdr, ctx, rootPaneId!, cloneFile,
265
+ `Herdr workspace ${workspaceId}, tab ${tabId}, root pane ${rootPaneId}, and session ${cloneFile}`,
266
+ );
115
267
 
116
268
  try {
117
- await herdr.run(["tab", "focus", tabId], { cwd: ctx.cwd });
269
+ await herdr.run(["tab", "focus", tabId!], { cwd: ctx.cwd });
118
270
  } catch (error) {
119
271
  ctx.ui.notify(
120
- `Clone agent ${agentName} started in Herdr tab ${tabId} (root pane ${rootPaneId}), but focus failed: ${errorMessage(error)}`,
272
+ `Clone agent ${agentName} started in Herdr worktree workspace ${workspaceId} (tab ${tabId}, checkout ${checkoutPath}), but focus failed: ${errorMessage(error)}`,
121
273
  "warning",
122
274
  );
123
275
  return;
124
276
  }
125
277
  ctx.ui.notify(
126
- `Cloned current conversation into Herdr tab ${tabId} (root pane ${rootPaneId}, agent ${agentName}).`,
278
+ `Cloned current conversation into Herdr worktree workspace ${workspaceId} (tab ${tabId}, checkout ${checkoutPath}, agent ${agentName}).`,
127
279
  "info",
128
280
  );
129
281
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-herdr-clone",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Clone the current Pi conversation path into a new Herdr tab.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -28,7 +28,7 @@
28
28
  "@earendil-works/pi-coding-agent": "^0.84.2"
29
29
  },
30
30
  "dependencies": {
31
- "@henryqw/pi-herdr": "^0.1.1"
31
+ "@henryqw/pi-herdr": "^0.2.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",