@henryqw/pi-herdr-clone 0.2.5 → 0.2.7

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.
@@ -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,18 +124,17 @@ 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.");
97
131
  const cloneFile = resolve(createdClone);
98
- let cloneStat;
99
132
  try {
100
- cloneStat = await stat(cloneFile);
133
+ if ((await stat(cloneFile)).isFile()) return cloneFile;
101
134
  } catch (error) {
102
135
  throw new Error(`Pi clone session file was not created: ${cloneFile}`, { cause: error });
103
136
  }
104
- if (!cloneStat.isFile()) throw new Error(`Pi clone session path is not a file: ${cloneFile}`);
105
- return cloneFile;
137
+ throw new Error(`Pi clone session path is not a file: ${cloneFile}`);
106
138
  }
107
139
 
108
140
  // A worktree layout plugin (e.g. herdr-plus) can start its own agent in a new
@@ -276,12 +308,13 @@ export default function herdrCloneExtension(pi: ExtensionAPI): void {
276
308
  if (!response || typeof response !== "object" || Array.isArray(response)) {
277
309
  throw new Error("Herdr worktree create returned invalid JSON");
278
310
  }
311
+ // Herdr nests checkout_path under workspace.worktree; the top-level
312
+ // result.worktree uses `path` instead.
279
313
  const result = (response as {
280
314
  result?: {
281
- workspace?: { workspace_id?: unknown };
315
+ workspace?: { workspace_id?: unknown; worktree?: { checkout_path?: unknown } };
282
316
  tab?: { tab_id?: unknown };
283
317
  root_pane?: { pane_id?: unknown };
284
- worktree?: { checkout_path?: unknown };
285
318
  };
286
319
  }).result;
287
320
  // Collect every returned identifier before validating so recovery
@@ -289,12 +322,14 @@ export default function herdrCloneExtension(pi: ExtensionAPI): void {
289
322
  workspaceId = typeof result?.workspace?.workspace_id === "string" ? result.workspace.workspace_id : undefined;
290
323
  tabId = typeof result?.tab?.tab_id === "string" ? result.tab.tab_id : undefined;
291
324
  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;
325
+ checkoutPath = typeof result?.workspace?.worktree?.checkout_path === "string" && result.workspace.worktree.checkout_path.trim()
326
+ ? result.workspace.worktree.checkout_path
327
+ : undefined;
293
328
  const missing = [
294
329
  [workspaceId, "workspace_id"],
295
330
  [tabId, "tab_id"],
296
331
  [rootPaneId, "root_pane.pane_id"],
297
- [checkoutPath, "worktree.checkout_path"],
332
+ [checkoutPath, "workspace.worktree.checkout_path"],
298
333
  ].filter(([value]) => !value).map(([, label]) => label);
299
334
  if (missing.length > 0) {
300
335
  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.5",
3
+ "version": "0.2.7",
4
4
  "description": "Clone the current Pi conversation path into a new Herdr tab.",
5
5
  "keywords": [
6
6
  "pi-package",