@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.
- package/extensions/clone-tab.ts +50 -15
- package/package.json +1 -1
package/extensions/clone-tab.ts
CHANGED
|
@@ -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
|
|
63
|
+
let persisted = true;
|
|
60
64
|
try {
|
|
61
|
-
|
|
65
|
+
if (!(await stat(sessionFile)).isFile()) throw new Error(`Persisted Pi session path is not a file: ${sessionFile}`);
|
|
62
66
|
} catch (error) {
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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(", ")}.`);
|