@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 +5 -0
- package/extensions/clone-tab.ts +48 -11
- package/package.json +1 -1
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
|
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,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"
|
|
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(", ")}.`);
|