@henryqw/pi-herdr-clone 0.2.4 → 0.2.6

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
@@ -2,6 +2,11 @@
2
2
 
3
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
+ ## Why
6
+
7
+ - **Created for**: Spawning a new Pi process that continues the current conversation, either as a workspace tab or in a fresh Git worktree.
8
+ - **Advantage**: Clones copy only the active root-to-leaf session path, leaving sibling branches and the original session untouched.
9
+
5
10
  ## Install
6
11
 
7
12
  ```bash
@@ -1,11 +1,13 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { stat, unlink } from "node:fs/promises";
3
- import { resolve } from "node:path";
2
+ import { stat, unlink, writeFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
5
  import {
6
+ CURRENT_SESSION_VERSION,
6
7
  SessionManager,
7
8
  type ExtensionAPI,
8
9
  type ExtensionCommandContext,
10
+ type SessionHeader,
9
11
  } from "@earendil-works/pi-coding-agent";
10
12
  import {
11
13
  createHerdrClient,
@@ -39,6 +41,8 @@ type SourceContext = {
39
41
  checkout: string | undefined;
40
42
  repoRoot: string | undefined;
41
43
  isLinkedWorktree: boolean;
44
+ /** False while Pi has not flushed the session yet (no assistant entry on disk). */
45
+ persisted: boolean;
42
46
  };
43
47
 
44
48
  async function resolveSource(
@@ -56,13 +60,15 @@ async function resolveSource(
56
60
  );
57
61
  const leafId = requiredString(ctx.sessionManager.getLeafId(), "Current Pi session leaf");
58
62
  const sessionFile = resolve(currentSessionFile);
59
- let sourceStat;
63
+ let persisted = true;
60
64
  try {
61
- sourceStat = await stat(sessionFile);
65
+ if (!(await stat(sessionFile)).isFile()) throw new Error(`Persisted Pi session path is not a file: ${sessionFile}`);
62
66
  } catch (error) {
63
- throw new Error(`Persisted Pi session file does not exist: ${sessionFile}`, { cause: error });
67
+ // Pi defers all session-file writes until the first assistant message
68
+ // completes, so a fresh session mid-first-turn has no file on disk.
69
+ if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error;
70
+ persisted = false;
64
71
  }
65
- if (!sourceStat.isFile()) throw new Error(`Persisted Pi session path is not a file: ${sessionFile}`);
66
72
 
67
73
  const paneResponse = await herdr.json(["pane", "get", requestedPaneId], { cwd: ctx.cwd });
68
74
  const pane = (paneResponse as { result?: { pane?: { pane_id?: unknown; workspace_id?: unknown } } }).result?.pane;
@@ -83,7 +89,34 @@ async function resolveSource(
83
89
  if (isLinkedWorktree && !repoRoot) {
84
90
  throw new Error("Herdr workspace response is missing worktree.repo_root for a linked worktree.");
85
91
  }
86
- return { sessionFile, leafId, workspaceId, checkout, repoRoot, isLinkedWorktree };
92
+ return { sessionFile, leafId, workspaceId, checkout, repoRoot, isLinkedWorktree, persisted };
93
+ }
94
+
95
+ // Mirror of SessionManager.createBranchedSession for sessions Pi has not
96
+ // flushed to disk yet: serialize the live active path under a fresh header.
97
+ async function writeCloneFromLiveState(
98
+ ctx: ExtensionCommandContext,
99
+ source: SourceContext,
100
+ cwd: string,
101
+ ): Promise<string> {
102
+ const sessionId = randomUUID();
103
+ const timestamp = new Date().toISOString();
104
+ const header: SessionHeader = {
105
+ type: "session",
106
+ version: CURRENT_SESSION_VERSION,
107
+ id: sessionId,
108
+ timestamp,
109
+ cwd,
110
+ parentSession: source.sessionFile,
111
+ };
112
+ const entries = ctx.sessionManager.getBranch(source.leafId);
113
+ if (entries.length === 0) throw new Error("Pi session has no entries to clone.");
114
+ const cloneFile = join(
115
+ ctx.sessionManager.getSessionDir(),
116
+ `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`,
117
+ );
118
+ await writeFile(cloneFile, [JSON.stringify(header), ...entries.map((entry) => JSON.stringify(entry))].join("\n") + "\n");
119
+ return cloneFile;
87
120
  }
88
121
 
89
122
  async function createBranchedClone(
@@ -91,6 +124,7 @@ async function createBranchedClone(
91
124
  source: SourceContext,
92
125
  cwd: string,
93
126
  ): Promise<string> {
127
+ if (!source.persisted) return await writeCloneFromLiveState(ctx, source, cwd);
94
128
  const session = SessionManager.open(source.sessionFile, ctx.sessionManager.getSessionDir(), cwd);
95
129
  const createdClone = session.createBranchedSession(source.leafId);
96
130
  if (!createdClone) throw new Error("Pi did not create a persisted clone session file.");
@@ -276,12 +310,13 @@ export default function herdrCloneExtension(pi: ExtensionAPI): void {
276
310
  if (!response || typeof response !== "object" || Array.isArray(response)) {
277
311
  throw new Error("Herdr worktree create returned invalid JSON");
278
312
  }
313
+ // Herdr nests checkout_path under workspace.worktree; the top-level
314
+ // result.worktree uses `path` instead.
279
315
  const result = (response as {
280
316
  result?: {
281
- workspace?: { workspace_id?: unknown };
317
+ workspace?: { workspace_id?: unknown; worktree?: { checkout_path?: unknown } };
282
318
  tab?: { tab_id?: unknown };
283
319
  root_pane?: { pane_id?: unknown };
284
- worktree?: { checkout_path?: unknown };
285
320
  };
286
321
  }).result;
287
322
  // Collect every returned identifier before validating so recovery
@@ -289,12 +324,14 @@ export default function herdrCloneExtension(pi: ExtensionAPI): void {
289
324
  workspaceId = typeof result?.workspace?.workspace_id === "string" ? result.workspace.workspace_id : undefined;
290
325
  tabId = typeof result?.tab?.tab_id === "string" ? result.tab.tab_id : undefined;
291
326
  rootPaneId = typeof result?.root_pane?.pane_id === "string" ? result.root_pane.pane_id : undefined;
292
- checkoutPath = typeof result?.worktree?.checkout_path === "string" ? result.worktree.checkout_path : undefined;
327
+ checkoutPath = typeof result?.workspace?.worktree?.checkout_path === "string" && result.workspace.worktree.checkout_path.trim()
328
+ ? result.workspace.worktree.checkout_path
329
+ : undefined;
293
330
  const missing = [
294
331
  [workspaceId, "workspace_id"],
295
332
  [tabId, "tab_id"],
296
333
  [rootPaneId, "root_pane.pane_id"],
297
- [checkoutPath, "worktree.checkout_path"],
334
+ [checkoutPath, "workspace.worktree.checkout_path"],
298
335
  ].filter(([value]) => !value).map(([, label]) => label);
299
336
  if (missing.length > 0) {
300
337
  throw new Error(`Herdr worktree create response is missing ${missing.join(", ")}.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-herdr-clone",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Clone the current Pi conversation path into a new Herdr tab.",
5
5
  "keywords": [
6
6
  "pi-package",