@henryqw/pi-herdr-clone 0.2.0 → 0.2.2

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
@@ -26,9 +26,11 @@ Both commands wait until Pi is idle, then validate the current Herdr pane (`HERD
26
26
  ### `/clone-worktree` behavior
27
27
 
28
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.
29
+ 2. Waits briefly for any worktree-layout plugin (for example `herdr-plus`) to start its own agent in the new workspace's root pane.
30
+ 3. If the root pane is occupied, creates an additional unfocused tab in the new workspace with the checkout as its working directory, so the plugin's agent and the clone coexist. Otherwise the clone uses the root pane.
31
+ 4. Copies the active path into a clone session stamped with the fresh checkout path as its working directory.
32
+ 5. Starts Pi in the chosen pane with `--session <absolute-clone-file>`.
33
+ 6. Focuses the clone's tab after Pi starts successfully.
32
34
 
33
35
  ### Failure semantics
34
36
 
@@ -18,7 +18,11 @@ import {
18
18
 
19
19
  type WorkspaceInfo = {
20
20
  workspace_id?: unknown;
21
- worktree?: { checkout_path?: unknown } | null;
21
+ worktree?: {
22
+ checkout_path?: unknown;
23
+ repo_root?: unknown;
24
+ is_linked_worktree?: unknown;
25
+ } | null;
22
26
  };
23
27
 
24
28
  const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error);
@@ -33,6 +37,8 @@ type SourceContext = {
33
37
  leafId: string;
34
38
  workspaceId: string;
35
39
  checkout: string | undefined;
40
+ repoRoot: string | undefined;
41
+ isLinkedWorktree: boolean;
36
42
  };
37
43
 
38
44
  async function resolveSource(
@@ -70,7 +76,14 @@ async function resolveSource(
70
76
  const checkout = workspace?.worktree == null
71
77
  ? undefined
72
78
  : requiredString(workspace.worktree.checkout_path, "Herdr workspace response checkout_path");
73
- return { sessionFile, leafId, workspaceId, checkout };
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 };
74
87
  }
75
88
 
76
89
  async function createBranchedClone(
@@ -92,6 +105,46 @@ async function createBranchedClone(
92
105
  return cloneFile;
93
106
  }
94
107
 
108
+ // A worktree layout plugin (e.g. herdr-plus) can start its own agent in a new
109
+ // worktree's root pane right after creation. Give it a moment to appear so the
110
+ // clone can move to its own tab instead of failing with agent_pane_busy.
111
+ async function waitForRootPaneAgent(
112
+ herdr: HerdrClient<{ cwd: string }>,
113
+ ctx: ExtensionCommandContext,
114
+ paneId: string,
115
+ ): Promise<string | undefined> {
116
+ for (let attempt = 0; attempt < 12; attempt += 1) {
117
+ const response = await herdr.json(["pane", "get", paneId], { cwd: ctx.cwd });
118
+ const agent = (response as { result?: { pane?: { agent?: unknown } } }).result?.pane?.agent;
119
+ if (typeof agent === "string" && agent.trim()) return agent;
120
+ await delay(250);
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ function parseCreatedTab(createdTab: HerdrExecResult, workspaceId: string): { tabId: string; rootPaneId: string } {
126
+ let tabId: string | undefined;
127
+ let rootPaneId: string | undefined;
128
+ try {
129
+ const response: unknown = JSON.parse(createdTab.stdout);
130
+ if (!response || typeof response !== "object" || Array.isArray(response)) {
131
+ throw new Error("Herdr tab create returned invalid JSON");
132
+ }
133
+ const result = (response as { result?: { tab?: { tab_id?: unknown }; root_pane?: { pane_id?: unknown } } }).result;
134
+ tabId = typeof result?.tab?.tab_id === "string" && result.tab.tab_id.trim() ? result.tab.tab_id : undefined;
135
+ rootPaneId = typeof result?.root_pane?.pane_id === "string" && result.root_pane.pane_id.trim()
136
+ ? result.root_pane.pane_id
137
+ : undefined;
138
+ const missing = [!tabId && "tab_id", !rootPaneId && "root_pane.pane_id"].filter(Boolean).join(", ");
139
+ if (missing) throw new Error(`Herdr tab create response is missing ${missing}.`);
140
+ return { tabId: tabId!, rootPaneId: rootPaneId! };
141
+ } catch (error) {
142
+ const retained = [`workspace ${workspaceId}`, tabId && `tab ${tabId}`, rootPaneId && `root pane ${rootPaneId}`]
143
+ .filter(Boolean).join(", ");
144
+ throw new Error(`Herdr tab create response could not be parsed; retained ${retained}: ${errorMessage(error)}`, { cause: error });
145
+ }
146
+ }
147
+
95
148
  async function discardCloneOrAggregate(cloneFile: string, error: Error): Promise<never> {
96
149
  try {
97
150
  await unlink(cloneFile);
@@ -110,16 +163,22 @@ async function launchCloneAgent(
110
163
  rootPaneId: string,
111
164
  cloneFile: string,
112
165
  retained: string,
166
+ onPaneBusy?: () => Promise<{ rootPaneId: string; retained: string }>,
113
167
  ): Promise<string> {
114
168
  const agentName = `clone-${randomUUID().replaceAll("-", "").slice(0, 24)}`;
115
- const startArgs = [
116
- "agent", "start", agentName, "--kind", "pi", "--pane", rootPaneId,
117
- "--", "--session", cloneFile,
118
- ];
119
169
  try {
120
170
  for (let attempt = 1; attempt <= 5; attempt += 1) {
171
+ const startArgs = [
172
+ "agent", "start", agentName, "--kind", "pi", "--pane", rootPaneId,
173
+ "--", "--session", cloneFile,
174
+ ];
121
175
  const result = await herdr.exec(startArgs, { cwd: ctx.cwd });
122
176
  if (result.code === 0 && !result.killed) return agentName;
177
+ if (hasHerdrErrorCode(result, "agent_pane_busy") && onPaneBusy) {
178
+ ({ rootPaneId, retained } = await onPaneBusy());
179
+ onPaneBusy = undefined;
180
+ continue;
181
+ }
123
182
  if (!hasHerdrErrorCode(result, "agent_pane_busy") || attempt === 5) {
124
183
  throw new Error(herdrCommandFailure(startArgs, result));
125
184
  }
@@ -195,10 +254,23 @@ export default function herdrCloneExtension(pi: ExtensionAPI): void {
195
254
  await ctx.waitForIdle();
196
255
  const source = await resolveSource("clone-worktree", herdr, ctx);
197
256
 
257
+ // Herdr only creates worktrees from the repo parent workspace; running
258
+ // /clone-worktree inside a linked worktree must retarget the parent.
259
+ let targetWorkspaceId = source.workspaceId;
260
+ if (source.isLinkedWorktree) {
261
+ const listing = await herdr.json(["workspace", "list"], { cwd: ctx.cwd });
262
+ const result = (listing as { result?: { workspaces?: WorkspaceInfo[] } }).result;
263
+ const parent = (Array.isArray(result?.workspaces) ? result.workspaces : []).find((entry) =>
264
+ entry.worktree != null && !entry.worktree.is_linked_worktree &&
265
+ entry.worktree.checkout_path === source.repoRoot);
266
+ targetWorkspaceId = typeof parent?.workspace_id === "string"
267
+ ? parent.workspace_id
268
+ : requiredString(undefined, `Repo parent workspace for ${source.repoRoot} in herdr workspace list`);
269
+ }
198
270
  // Create the worktree before the clone so the session header can be
199
271
  // stamped with the fresh checkout cwd. A killed or incomplete create is
200
272
  // ambiguous: Herdr may have retained partial worktree state.
201
- const worktreeCreateArgs = ["worktree", "create", "--workspace", source.workspaceId, "--no-focus"] as const;
273
+ const worktreeCreateArgs = ["worktree", "create", "--workspace", targetWorkspaceId, "--no-focus"] as const;
202
274
  const createdWorktree = await herdr.exec(worktreeCreateArgs, { cwd: ctx.cwd });
203
275
  if (createdWorktree.code !== 0 && !createdWorktree.killed) {
204
276
  throw new Error(herdrCommandFailure(worktreeCreateArgs, createdWorktree));
@@ -249,6 +321,20 @@ export default function herdrCloneExtension(pi: ExtensionAPI): void {
249
321
  );
250
322
  }
251
323
 
324
+ // A layout plugin may have claimed the root pane; park the clone in its
325
+ // own tab so both agents coexist.
326
+ let targetTabId = tabId!;
327
+ let targetPaneId = rootPaneId!;
328
+ const createCloneTab = async (): Promise<void> => {
329
+ const cloneTabArgs = ["tab", "create", "--workspace", workspaceId!, "--cwd", checkoutPath!, "--no-focus"] as const;
330
+ const createdTab = await herdr.exec(cloneTabArgs, { cwd: ctx.cwd });
331
+ if (createdTab.code !== 0 || createdTab.killed) {
332
+ throw new Error(herdrCommandFailure(cloneTabArgs, createdTab));
333
+ }
334
+ ({ tabId: targetTabId, rootPaneId: targetPaneId } = parseCreatedTab(createdTab, workspaceId!));
335
+ };
336
+ if (await waitForRootPaneAgent(herdr, ctx, rootPaneId!)) await createCloneTab();
337
+
252
338
  let cloneFile: string;
253
339
  try {
254
340
  cloneFile = source.checkout
@@ -261,21 +347,28 @@ export default function herdrCloneExtension(pi: ExtensionAPI): void {
261
347
  );
262
348
  }
263
349
  const agentName = await launchCloneAgent(
264
- herdr, ctx, rootPaneId!, cloneFile,
265
- `Herdr workspace ${workspaceId}, tab ${tabId}, root pane ${rootPaneId}, and session ${cloneFile}`,
350
+ herdr, ctx, targetPaneId, cloneFile,
351
+ `Herdr workspace ${workspaceId}, tab ${targetTabId}, root pane ${targetPaneId}, and session ${cloneFile}`,
352
+ targetPaneId === rootPaneId ? async () => {
353
+ await createCloneTab();
354
+ return {
355
+ rootPaneId: targetPaneId,
356
+ retained: `Herdr workspace ${workspaceId}, tab ${targetTabId}, root pane ${targetPaneId}, and session ${cloneFile}`,
357
+ };
358
+ } : undefined,
266
359
  );
267
360
 
268
361
  try {
269
- await herdr.run(["tab", "focus", tabId!], { cwd: ctx.cwd });
362
+ await herdr.run(["tab", "focus", targetTabId], { cwd: ctx.cwd });
270
363
  } catch (error) {
271
364
  ctx.ui.notify(
272
- `Clone agent ${agentName} started in Herdr worktree workspace ${workspaceId} (tab ${tabId}, checkout ${checkoutPath}), but focus failed: ${errorMessage(error)}`,
365
+ `Clone agent ${agentName} started in Herdr worktree workspace ${workspaceId} (tab ${targetTabId}, checkout ${checkoutPath}), but focus failed: ${errorMessage(error)}`,
273
366
  "warning",
274
367
  );
275
368
  return;
276
369
  }
277
370
  ctx.ui.notify(
278
- `Cloned current conversation into Herdr worktree workspace ${workspaceId} (tab ${tabId}, checkout ${checkoutPath}, agent ${agentName}).`,
371
+ `Cloned current conversation into Herdr worktree workspace ${workspaceId} (tab ${targetTabId}, checkout ${checkoutPath}, agent ${agentName}).`,
279
372
  "info",
280
373
  );
281
374
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-herdr-clone",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Clone the current Pi conversation path into a new Herdr tab.",
5
5
  "keywords": [
6
6
  "pi-package",