@henryqw/pi-herdr-tools 1.0.0
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/LICENSE +21 -0
- package/README.md +204 -0
- package/docs/btw-merge-sequence.html +156 -0
- package/docs/btw-merge-sequence.svg +138 -0
- package/docs/clone-flow.html +170 -0
- package/docs/clone-flow.svg +131 -0
- package/docs/done-flow.html +212 -0
- package/docs/done-flow.svg +166 -0
- package/docs/workflow-overview.html +114 -0
- package/docs/workflow-overview.svg +105 -0
- package/extensions/index.ts +12 -0
- package/internal/btw.ts +836 -0
- package/internal/clone-tab.ts +405 -0
- package/internal/config.ts +103 -0
- package/internal/context-store.ts +286 -0
- package/internal/core.ts +411 -0
- package/internal/done.ts +99 -0
- package/internal/merge.ts +317 -0
- package/internal/rename.ts +442 -0
- package/internal/router.ts +42 -0
- package/package.json +36 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import {
|
|
6
|
+
CURRENT_SESSION_VERSION,
|
|
7
|
+
SessionManager,
|
|
8
|
+
type ExtensionAPI,
|
|
9
|
+
type ExtensionCommandContext,
|
|
10
|
+
type SessionHeader,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import {
|
|
13
|
+
createHerdrClient,
|
|
14
|
+
herdrCommandFailure,
|
|
15
|
+
startPiAgent,
|
|
16
|
+
withWorktreeLock,
|
|
17
|
+
type HerdrClient,
|
|
18
|
+
type HerdrExecResult,
|
|
19
|
+
} from "@henryqw/pi-herdr";
|
|
20
|
+
|
|
21
|
+
type WorkspaceInfo = {
|
|
22
|
+
workspace_id?: unknown;
|
|
23
|
+
worktree?: {
|
|
24
|
+
checkout_path?: unknown;
|
|
25
|
+
repo_root?: unknown;
|
|
26
|
+
is_linked_worktree?: unknown;
|
|
27
|
+
} | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
31
|
+
|
|
32
|
+
function requiredString(value: unknown, label: string): string {
|
|
33
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is missing.`);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type SourceContext = {
|
|
38
|
+
sessionFile: string;
|
|
39
|
+
leafId: string;
|
|
40
|
+
workspaceId: string;
|
|
41
|
+
checkout: string | undefined;
|
|
42
|
+
repoRoot: string | undefined;
|
|
43
|
+
isLinkedWorktree: boolean;
|
|
44
|
+
/** False while Pi has not flushed the session yet (no conversation message on disk). */
|
|
45
|
+
persisted: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
async function resolveSource(
|
|
49
|
+
commandName: string,
|
|
50
|
+
herdr: HerdrClient<{ cwd: string }>,
|
|
51
|
+
ctx: ExtensionCommandContext,
|
|
52
|
+
): Promise<SourceContext> {
|
|
53
|
+
if (process.env.HERDR_ENV !== "1") {
|
|
54
|
+
throw new Error(`/${commandName} requires the current Pi session inside Herdr (HERDR_ENV=1).`);
|
|
55
|
+
}
|
|
56
|
+
const requestedPaneId = requiredString(process.env.HERDR_PANE_ID, "HERDR_PANE_ID");
|
|
57
|
+
const currentSessionFile = requiredString(
|
|
58
|
+
ctx.sessionManager.getSessionFile(),
|
|
59
|
+
"Persisted Pi session file",
|
|
60
|
+
);
|
|
61
|
+
const leafId = requiredString(ctx.sessionManager.getLeafId(), "Current Pi session leaf");
|
|
62
|
+
const sessionFile = resolve(currentSessionFile);
|
|
63
|
+
let persisted = true;
|
|
64
|
+
try {
|
|
65
|
+
if (!(await stat(sessionFile)).isFile()) throw new Error(`Persisted Pi session path is not a file: ${sessionFile}`);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
// Pi defers all session-file writes until the first conversation message
|
|
68
|
+
// (user message since 0.99, first assistant reply before), so a fresh
|
|
69
|
+
// session can have live entries but no file on disk.
|
|
70
|
+
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error;
|
|
71
|
+
persisted = false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const paneResponse = await herdr.json(["pane", "get", requestedPaneId], { cwd: ctx.cwd });
|
|
75
|
+
const pane = (paneResponse as { result?: { pane?: { pane_id?: unknown; workspace_id?: unknown } } }).result?.pane;
|
|
76
|
+
requiredString(pane?.pane_id, "Herdr pane response pane_id");
|
|
77
|
+
const workspaceId = requiredString(pane?.workspace_id, "Herdr pane response workspace_id");
|
|
78
|
+
const workspaceResponse = await herdr.json(["workspace", "get", workspaceId], { cwd: ctx.cwd });
|
|
79
|
+
const workspace = (workspaceResponse as { result?: { workspace?: WorkspaceInfo } }).result?.workspace;
|
|
80
|
+
if (requiredString(workspace?.workspace_id, "Herdr workspace response workspace_id") !== workspaceId) {
|
|
81
|
+
throw new Error(`Herdr workspace response did not match ${workspaceId}.`);
|
|
82
|
+
}
|
|
83
|
+
const checkout = workspace?.worktree == null
|
|
84
|
+
? undefined
|
|
85
|
+
: requiredString(workspace.worktree.checkout_path, "Herdr workspace response checkout_path");
|
|
86
|
+
const repoRoot = typeof workspace?.worktree?.repo_root === "string" && workspace.worktree.repo_root.trim()
|
|
87
|
+
? workspace.worktree.repo_root
|
|
88
|
+
: undefined;
|
|
89
|
+
const isLinkedWorktree = workspace?.worktree?.is_linked_worktree === true;
|
|
90
|
+
if (isLinkedWorktree && !repoRoot) {
|
|
91
|
+
throw new Error("Herdr workspace response is missing worktree.repo_root for a linked worktree.");
|
|
92
|
+
}
|
|
93
|
+
return { sessionFile, leafId, workspaceId, checkout, repoRoot, isLinkedWorktree, persisted };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Mirror of SessionManager.createBranchedSession for sessions Pi has not
|
|
97
|
+
// flushed to disk yet: serialize the live active path under a fresh header.
|
|
98
|
+
async function writeCloneFromLiveState(
|
|
99
|
+
ctx: ExtensionCommandContext,
|
|
100
|
+
source: SourceContext,
|
|
101
|
+
cwd: string,
|
|
102
|
+
): Promise<string> {
|
|
103
|
+
const sessionId = randomUUID();
|
|
104
|
+
const timestamp = new Date().toISOString();
|
|
105
|
+
const header: SessionHeader = {
|
|
106
|
+
type: "session",
|
|
107
|
+
version: CURRENT_SESSION_VERSION,
|
|
108
|
+
id: sessionId,
|
|
109
|
+
timestamp,
|
|
110
|
+
cwd,
|
|
111
|
+
parentSession: source.sessionFile,
|
|
112
|
+
};
|
|
113
|
+
const entries = ctx.sessionManager.getBranch(source.leafId);
|
|
114
|
+
if (entries.length === 0) throw new Error("Pi session has no entries to clone.");
|
|
115
|
+
const cloneFile = join(
|
|
116
|
+
ctx.sessionManager.getSessionDir(),
|
|
117
|
+
`${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`,
|
|
118
|
+
);
|
|
119
|
+
await writeFile(cloneFile, [JSON.stringify(header), ...entries.map((entry) => JSON.stringify(entry))].join("\n") + "\n");
|
|
120
|
+
return cloneFile;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function createBranchedClone(
|
|
124
|
+
ctx: ExtensionCommandContext,
|
|
125
|
+
source: SourceContext,
|
|
126
|
+
cwd: string,
|
|
127
|
+
): Promise<string> {
|
|
128
|
+
if (!source.persisted) return await writeCloneFromLiveState(ctx, source, cwd);
|
|
129
|
+
const session = SessionManager.open(source.sessionFile, ctx.sessionManager.getSessionDir(), cwd);
|
|
130
|
+
const createdClone = session.createBranchedSession(source.leafId);
|
|
131
|
+
if (!createdClone) throw new Error("Pi did not create a persisted clone session file.");
|
|
132
|
+
const cloneFile = resolve(createdClone);
|
|
133
|
+
try {
|
|
134
|
+
if ((await stat(cloneFile)).isFile()) return cloneFile;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new Error(`Pi clone session file was not created: ${cloneFile}`, { cause: error });
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`Pi clone session path is not a file: ${cloneFile}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A worktree layout plugin (e.g. herdr-plus) can start its own agent in a new
|
|
142
|
+
// worktree's root pane right after creation. Give it a moment to appear so the
|
|
143
|
+
// clone can move to its own tab instead of failing with agent_pane_busy.
|
|
144
|
+
async function waitForRootPaneAgent(
|
|
145
|
+
herdr: HerdrClient<{ cwd: string }>,
|
|
146
|
+
ctx: ExtensionCommandContext,
|
|
147
|
+
paneId: string,
|
|
148
|
+
): Promise<string | undefined> {
|
|
149
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
150
|
+
const response = await herdr.json(["pane", "get", paneId], { cwd: ctx.cwd });
|
|
151
|
+
const agent = (response as { result?: { pane?: { agent?: unknown } } }).result?.pane?.agent;
|
|
152
|
+
if (typeof agent === "string" && agent.trim()) return agent;
|
|
153
|
+
await delay(250);
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseCreatedTab(createdTab: HerdrExecResult, workspaceId: string): { tabId: string; rootPaneId: string } {
|
|
159
|
+
let tabId: string | undefined;
|
|
160
|
+
let rootPaneId: string | undefined;
|
|
161
|
+
try {
|
|
162
|
+
const response: unknown = JSON.parse(createdTab.stdout);
|
|
163
|
+
if (!response || typeof response !== "object" || Array.isArray(response)) {
|
|
164
|
+
throw new Error("Herdr tab create returned invalid JSON");
|
|
165
|
+
}
|
|
166
|
+
const result = (response as { result?: { tab?: { tab_id?: unknown }; root_pane?: { pane_id?: unknown } } }).result;
|
|
167
|
+
tabId = typeof result?.tab?.tab_id === "string" && result.tab.tab_id.trim() ? result.tab.tab_id : undefined;
|
|
168
|
+
rootPaneId = typeof result?.root_pane?.pane_id === "string" && result.root_pane.pane_id.trim()
|
|
169
|
+
? result.root_pane.pane_id
|
|
170
|
+
: undefined;
|
|
171
|
+
const missing = [!tabId && "tab_id", !rootPaneId && "root_pane.pane_id"].filter(Boolean).join(", ");
|
|
172
|
+
if (missing) throw new Error(`Herdr tab create response is missing ${missing}.`);
|
|
173
|
+
return { tabId: tabId!, rootPaneId: rootPaneId! };
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const retained = [`workspace ${workspaceId}`, tabId && `tab ${tabId}`, rootPaneId && `root pane ${rootPaneId}`]
|
|
176
|
+
.filter(Boolean).join(", ");
|
|
177
|
+
throw new Error(`Herdr tab create response could not be parsed; retained ${retained}: ${errorMessage(error)}`, { cause: error });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function discardCloneOrAggregate(cloneFile: string, error: Error): Promise<never> {
|
|
182
|
+
try {
|
|
183
|
+
await unlink(cloneFile);
|
|
184
|
+
} catch (cleanupError) {
|
|
185
|
+
throw new AggregateError(
|
|
186
|
+
[error, cleanupError],
|
|
187
|
+
`${error.message} Clone cleanup also failed for ${cloneFile}: ${errorMessage(cleanupError)}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function launchCloneAgent(
|
|
194
|
+
herdr: HerdrClient<{ cwd: string }>,
|
|
195
|
+
ctx: ExtensionCommandContext,
|
|
196
|
+
rootPaneId: string,
|
|
197
|
+
cloneFile: string,
|
|
198
|
+
retained: string,
|
|
199
|
+
onPaneBusy?: () => Promise<{ rootPaneId: string; retained: string }>,
|
|
200
|
+
): Promise<string> {
|
|
201
|
+
const agentName = `clone-${randomUUID().replaceAll("-", "").slice(0, 24)}`;
|
|
202
|
+
try {
|
|
203
|
+
const result = await startPiAgent(herdr, {
|
|
204
|
+
name: agentName,
|
|
205
|
+
pane: rootPaneId,
|
|
206
|
+
args: ["--session", cloneFile],
|
|
207
|
+
options: { cwd: ctx.cwd },
|
|
208
|
+
onPaneBusy: onPaneBusy
|
|
209
|
+
? async () => {
|
|
210
|
+
const next = await onPaneBusy!();
|
|
211
|
+
rootPaneId = next.rootPaneId;
|
|
212
|
+
retained = next.retained;
|
|
213
|
+
return rootPaneId;
|
|
214
|
+
}
|
|
215
|
+
: undefined,
|
|
216
|
+
});
|
|
217
|
+
if (result.code === 0 && !result.killed) return agentName;
|
|
218
|
+
const startArgs = [
|
|
219
|
+
"agent", "start", agentName, "--kind", "pi", "--pane", rootPaneId, "--", "--session", cloneFile,
|
|
220
|
+
];
|
|
221
|
+
throw new Error(herdrCommandFailure(startArgs, result));
|
|
222
|
+
} catch (error) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`Clone launch could not be confirmed after starting agent ${agentName}; retained ${retained}: ${errorMessage(error)}`,
|
|
225
|
+
{ cause: error },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export default function herdrCloneExtension(pi: ExtensionAPI): void {
|
|
231
|
+
const herdr = createHerdrClient<{ cwd: string }>(pi.exec.bind(pi));
|
|
232
|
+
|
|
233
|
+
pi.registerCommand("clone-tab", {
|
|
234
|
+
description: "Clone the current conversation path into a new Herdr tab",
|
|
235
|
+
handler: async (_args, ctx) => {
|
|
236
|
+
const source = await resolveSource("clone-tab", herdr, ctx);
|
|
237
|
+
const mutate = async (): Promise<{ createdTab: HerdrExecResult; cloneFile: string }> => {
|
|
238
|
+
const cloneFile = await createBranchedClone(ctx, source, ctx.cwd);
|
|
239
|
+
const tabCreateArgs = ["tab", "create", "--workspace", source.workspaceId, "--cwd", ctx.cwd, "--no-focus"] as const;
|
|
240
|
+
const createdTab = await herdr.exec(tabCreateArgs, { cwd: ctx.cwd });
|
|
241
|
+
if (createdTab.code !== 0 || createdTab.killed) {
|
|
242
|
+
await discardCloneOrAggregate(cloneFile, new Error(herdrCommandFailure(tabCreateArgs, createdTab)));
|
|
243
|
+
}
|
|
244
|
+
return { createdTab, cloneFile };
|
|
245
|
+
};
|
|
246
|
+
const { createdTab, cloneFile } = source.checkout ? await withWorktreeLock(source.checkout, mutate) : await mutate();
|
|
247
|
+
let tab: ReturnType<typeof parseCreatedTab>;
|
|
248
|
+
try {
|
|
249
|
+
tab = parseCreatedTab(createdTab, source.workspaceId);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`Clone launch could not be confirmed after creating a Herdr tab; retained session ${cloneFile}: ${errorMessage(error)}`,
|
|
253
|
+
{ cause: error },
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const { tabId, rootPaneId } = tab;
|
|
257
|
+
const agentName = await launchCloneAgent(herdr, ctx, rootPaneId, cloneFile, `Herdr tab ${tabId}, root pane ${rootPaneId}, and session ${cloneFile}`);
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
await herdr.run(["tab", "focus", tabId], { cwd: ctx.cwd });
|
|
261
|
+
} catch (error) {
|
|
262
|
+
ctx.ui.notify(
|
|
263
|
+
`Clone agent ${agentName} started in Herdr tab ${tabId} (root pane ${rootPaneId}), but focus failed: ${errorMessage(error)}`,
|
|
264
|
+
"warning",
|
|
265
|
+
);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
ctx.ui.notify(
|
|
269
|
+
`Cloned current conversation into Herdr tab ${tabId} (root pane ${rootPaneId}, agent ${agentName}).`,
|
|
270
|
+
"info",
|
|
271
|
+
);
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
pi.registerCommand("clone-worktree", {
|
|
276
|
+
description: "Clone the current conversation into Pi in a new Herdr Git worktree",
|
|
277
|
+
handler: async (_args, ctx) => {
|
|
278
|
+
const source = await resolveSource("clone-worktree", herdr, ctx);
|
|
279
|
+
|
|
280
|
+
// Herdr only creates worktrees from the repo parent workspace; running
|
|
281
|
+
// /clone-worktree inside a linked worktree must retarget the parent.
|
|
282
|
+
let targetWorkspaceId = source.workspaceId;
|
|
283
|
+
if (source.isLinkedWorktree) {
|
|
284
|
+
const listing = await herdr.json(["workspace", "list"], { cwd: ctx.cwd });
|
|
285
|
+
const result = (listing as { result?: { workspaces?: WorkspaceInfo[] } }).result;
|
|
286
|
+
const parent = (Array.isArray(result?.workspaces) ? result.workspaces : []).find((entry) =>
|
|
287
|
+
entry.worktree != null && !entry.worktree.is_linked_worktree &&
|
|
288
|
+
entry.worktree.checkout_path === source.repoRoot);
|
|
289
|
+
targetWorkspaceId = typeof parent?.workspace_id === "string"
|
|
290
|
+
? parent.workspace_id
|
|
291
|
+
: requiredString(undefined, `Repo parent workspace for ${source.repoRoot} in herdr workspace list`);
|
|
292
|
+
}
|
|
293
|
+
// Create the worktree before the clone so the session header can be
|
|
294
|
+
// stamped with the fresh checkout cwd. A killed or incomplete create is
|
|
295
|
+
// ambiguous: Herdr may have retained partial worktree state.
|
|
296
|
+
const worktreeCreateArgs = ["worktree", "create", "--workspace", targetWorkspaceId, "--no-focus"] as const;
|
|
297
|
+
const createdWorktree = await herdr.exec(worktreeCreateArgs, { cwd: ctx.cwd });
|
|
298
|
+
if (createdWorktree.code !== 0 && !createdWorktree.killed) {
|
|
299
|
+
throw new Error(herdrCommandFailure(worktreeCreateArgs, createdWorktree));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let workspaceId: string | undefined;
|
|
303
|
+
let tabId: string | undefined;
|
|
304
|
+
let rootPaneId: string | undefined;
|
|
305
|
+
let checkoutPath: string | undefined;
|
|
306
|
+
try {
|
|
307
|
+
const response: unknown = JSON.parse(createdWorktree.stdout);
|
|
308
|
+
if (!response || typeof response !== "object" || Array.isArray(response)) {
|
|
309
|
+
throw new Error("Herdr worktree create returned invalid JSON");
|
|
310
|
+
}
|
|
311
|
+
// Herdr nests checkout_path under workspace.worktree; the top-level
|
|
312
|
+
// result.worktree uses `path` instead.
|
|
313
|
+
const result = (response as {
|
|
314
|
+
result?: {
|
|
315
|
+
workspace?: { workspace_id?: unknown; worktree?: { checkout_path?: unknown } };
|
|
316
|
+
tab?: { tab_id?: unknown };
|
|
317
|
+
root_pane?: { pane_id?: unknown };
|
|
318
|
+
};
|
|
319
|
+
}).result;
|
|
320
|
+
// Collect every returned identifier before validating so recovery
|
|
321
|
+
// keeps all known IDs even when an earlier field is missing.
|
|
322
|
+
workspaceId = typeof result?.workspace?.workspace_id === "string" ? result.workspace.workspace_id : undefined;
|
|
323
|
+
tabId = typeof result?.tab?.tab_id === "string" ? result.tab.tab_id : undefined;
|
|
324
|
+
rootPaneId = typeof result?.root_pane?.pane_id === "string" ? result.root_pane.pane_id : undefined;
|
|
325
|
+
checkoutPath = typeof result?.workspace?.worktree?.checkout_path === "string" && result.workspace.worktree.checkout_path.trim()
|
|
326
|
+
? result.workspace.worktree.checkout_path
|
|
327
|
+
: undefined;
|
|
328
|
+
if (createdWorktree.killed) {
|
|
329
|
+
throw new Error("Herdr worktree create was killed before completion.");
|
|
330
|
+
}
|
|
331
|
+
const missing = [
|
|
332
|
+
[workspaceId, "workspace_id"],
|
|
333
|
+
[tabId, "tab_id"],
|
|
334
|
+
[rootPaneId, "root_pane.pane_id"],
|
|
335
|
+
[checkoutPath, "workspace.worktree.checkout_path"],
|
|
336
|
+
].filter(([value]) => !value).map(([, label]) => label);
|
|
337
|
+
if (missing.length > 0) {
|
|
338
|
+
throw new Error(`Herdr worktree create response is missing ${missing.join(", ")}.`);
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
const known = [
|
|
342
|
+
workspaceId && `workspace ${workspaceId}`,
|
|
343
|
+
tabId && `tab ${tabId}`,
|
|
344
|
+
rootPaneId && `root pane ${rootPaneId}`,
|
|
345
|
+
checkoutPath && `checkout ${checkoutPath}`,
|
|
346
|
+
].filter(Boolean).join(", ");
|
|
347
|
+
throw new Error(
|
|
348
|
+
`Clone could not be confirmed after creating a Herdr worktree${known ? ` (${known})` : ""}; Herdr may have retained a partial worktree workspace, inspect herdr workspace list: ${errorMessage(error)}`,
|
|
349
|
+
{ cause: error },
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// A layout plugin may have claimed the root pane; park the clone in its
|
|
354
|
+
// own tab so both agents coexist.
|
|
355
|
+
let targetTabId = tabId!;
|
|
356
|
+
let targetPaneId = rootPaneId!;
|
|
357
|
+
const createCloneTab = async (): Promise<void> => {
|
|
358
|
+
const cloneTabArgs = ["tab", "create", "--workspace", workspaceId!, "--cwd", checkoutPath!, "--no-focus"] as const;
|
|
359
|
+
const createdTab = await herdr.exec(cloneTabArgs, { cwd: ctx.cwd });
|
|
360
|
+
if (createdTab.code !== 0 || createdTab.killed) {
|
|
361
|
+
throw new Error(herdrCommandFailure(cloneTabArgs, createdTab));
|
|
362
|
+
}
|
|
363
|
+
({ tabId: targetTabId, rootPaneId: targetPaneId } = parseCreatedTab(createdTab, workspaceId!));
|
|
364
|
+
};
|
|
365
|
+
if (await waitForRootPaneAgent(herdr, ctx, rootPaneId!)) await createCloneTab();
|
|
366
|
+
|
|
367
|
+
let cloneFile: string;
|
|
368
|
+
try {
|
|
369
|
+
cloneFile = source.checkout
|
|
370
|
+
? await withWorktreeLock(source.checkout, () => createBranchedClone(ctx, source, checkoutPath!))
|
|
371
|
+
: await createBranchedClone(ctx, source, checkoutPath!);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`Clone session could not be created for Herdr worktree workspace ${workspaceId} (tab ${tabId}, checkout ${checkoutPath}); retained worktree without a clone session: ${errorMessage(error)}`,
|
|
375
|
+
{ cause: error },
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
const agentName = await launchCloneAgent(
|
|
379
|
+
herdr, ctx, targetPaneId, cloneFile,
|
|
380
|
+
`Herdr workspace ${workspaceId}, tab ${targetTabId}, root pane ${targetPaneId}, and session ${cloneFile}`,
|
|
381
|
+
targetPaneId === rootPaneId ? async () => {
|
|
382
|
+
await createCloneTab();
|
|
383
|
+
return {
|
|
384
|
+
rootPaneId: targetPaneId,
|
|
385
|
+
retained: `Herdr workspace ${workspaceId}, tab ${targetTabId}, root pane ${targetPaneId}, and session ${cloneFile}`,
|
|
386
|
+
};
|
|
387
|
+
} : undefined,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
await herdr.run(["tab", "focus", targetTabId], { cwd: ctx.cwd });
|
|
392
|
+
} catch (error) {
|
|
393
|
+
ctx.ui.notify(
|
|
394
|
+
`Clone agent ${agentName} started in Herdr worktree workspace ${workspaceId} (tab ${targetTabId}, checkout ${checkoutPath}), but focus failed: ${errorMessage(error)}`,
|
|
395
|
+
"warning",
|
|
396
|
+
);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
ctx.ui.notify(
|
|
400
|
+
`Cloned current conversation into Herdr worktree workspace ${workspaceId} (tab ${targetTabId}, checkout ${checkoutPath}, agent ${agentName}).`,
|
|
401
|
+
"info",
|
|
402
|
+
);
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createConfigStore } from "@henryqw/pi-config-store";
|
|
2
|
+
|
|
3
|
+
export const TOOL_MODES = ["inherit", "all", "read-only", "none"] as const;
|
|
4
|
+
export type BtwToolMode = (typeof TOOL_MODES)[number];
|
|
5
|
+
export type BtwSplit = "right" | "down";
|
|
6
|
+
|
|
7
|
+
export type BtwConfig = {
|
|
8
|
+
autoSubmit: boolean;
|
|
9
|
+
tools: BtwToolMode;
|
|
10
|
+
split: BtwSplit;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_CONFIG: Readonly<BtwConfig> = Object.freeze({
|
|
14
|
+
autoSubmit: false,
|
|
15
|
+
tools: "inherit",
|
|
16
|
+
split: "right",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseConfig(value: unknown): BtwConfig {
|
|
24
|
+
if (!isRecord(value)) throw new Error("/btw config must be a JSON object");
|
|
25
|
+
const allowedKeys = new Set(["autoSubmit", "tools", "split"]);
|
|
26
|
+
for (const key of Object.keys(value)) {
|
|
27
|
+
if (!allowedKeys.has(key)) throw new Error(`unknown config key: ${key}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const config = { ...DEFAULT_CONFIG };
|
|
31
|
+
if ("autoSubmit" in value) {
|
|
32
|
+
if (typeof value.autoSubmit !== "boolean") throw new Error("autoSubmit must be true or false");
|
|
33
|
+
config.autoSubmit = value.autoSubmit;
|
|
34
|
+
}
|
|
35
|
+
if ("tools" in value) {
|
|
36
|
+
if (!TOOL_MODES.includes(value.tools as BtwToolMode)) {
|
|
37
|
+
throw new Error("tools must be inherit, all, read-only, or none");
|
|
38
|
+
}
|
|
39
|
+
config.tools = value.tools as BtwToolMode;
|
|
40
|
+
}
|
|
41
|
+
if ("split" in value) {
|
|
42
|
+
if (value.split !== "right" && value.split !== "down") {
|
|
43
|
+
throw new Error("split must be right or down");
|
|
44
|
+
}
|
|
45
|
+
config.split = value.split;
|
|
46
|
+
}
|
|
47
|
+
return config;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatConfig(config: BtwConfig): string {
|
|
51
|
+
return [
|
|
52
|
+
`auto-submit: ${config.autoSubmit ? "on" : "off"}`,
|
|
53
|
+
`tools: ${config.tools}`,
|
|
54
|
+
`split: ${config.split}`,
|
|
55
|
+
].join(" · ");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const CONFIG_COMMAND_USAGE =
|
|
59
|
+
"/btw config [auto-submit on|off | tools inherit|all|read-only|none | split right|down | reset]";
|
|
60
|
+
|
|
61
|
+
export type ConfigCommandResult = {
|
|
62
|
+
action: "show" | "save";
|
|
63
|
+
config: BtwConfig;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function applyConfigCommand(current: BtwConfig, input: string): ConfigCommandResult {
|
|
67
|
+
const trimmed = input.trim();
|
|
68
|
+
if (!trimmed || trimmed === "show") return { action: "show", config: current };
|
|
69
|
+
|
|
70
|
+
const [key, value, ...extra] = trimmed.split(/\s+/);
|
|
71
|
+
if (!key || !value || extra.length > 0) throw new Error(CONFIG_COMMAND_USAGE);
|
|
72
|
+
const config = { ...current };
|
|
73
|
+
|
|
74
|
+
switch (key) {
|
|
75
|
+
case "auto-submit":
|
|
76
|
+
if (value !== "on" && value !== "off") throw new Error(CONFIG_COMMAND_USAGE);
|
|
77
|
+
config.autoSubmit = value === "on";
|
|
78
|
+
break;
|
|
79
|
+
case "tools":
|
|
80
|
+
if (!TOOL_MODES.includes(value as BtwToolMode)) {
|
|
81
|
+
throw new Error(CONFIG_COMMAND_USAGE);
|
|
82
|
+
}
|
|
83
|
+
config.tools = value as BtwToolMode;
|
|
84
|
+
break;
|
|
85
|
+
case "split":
|
|
86
|
+
if (value !== "right" && value !== "down") throw new Error(CONFIG_COMMAND_USAGE);
|
|
87
|
+
config.split = value;
|
|
88
|
+
break;
|
|
89
|
+
default:
|
|
90
|
+
throw new Error(CONFIG_COMMAND_USAGE);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { action: "save", config };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createBtwConfigStore(agentDir?: string) {
|
|
97
|
+
return createConfigStore<BtwConfig>({
|
|
98
|
+
extensionId: "pi-herdr-btw",
|
|
99
|
+
agentDir,
|
|
100
|
+
defaults: () => ({ ...DEFAULT_CONFIG }),
|
|
101
|
+
parse: parseConfig,
|
|
102
|
+
});
|
|
103
|
+
}
|