tau-all-agent 0.1.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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/extensions/answer.ts +601 -0
  4. package/extensions/branch-term.ts +405 -0
  5. package/extensions/btw.ts +444 -0
  6. package/extensions/ghostty.ts +301 -0
  7. package/extensions/git-diff-stats.ts +277 -0
  8. package/extensions/git-pr-status.ts +286 -0
  9. package/extensions/insights.ts +2367 -0
  10. package/extensions/interlude.ts +144 -0
  11. package/extensions/loop.ts +529 -0
  12. package/extensions/memory.ts +1889 -0
  13. package/extensions/notify.ts +161 -0
  14. package/extensions/openai-fast.ts +227 -0
  15. package/extensions/openai-verbosity.ts +223 -0
  16. package/extensions/review.ts +4347 -0
  17. package/extensions/sandbox/index.ts +2578 -0
  18. package/extensions/telegram/README.md +81 -0
  19. package/extensions/telegram/daemon.mjs +1942 -0
  20. package/extensions/telegram/index.ts +1073 -0
  21. package/extensions/telegram/message-text.d.mts +1 -0
  22. package/extensions/telegram/message-text.mjs +16 -0
  23. package/extensions/usage/anthropic.ts +198 -0
  24. package/extensions/usage/github-copilot.ts +204 -0
  25. package/extensions/usage/google-gemini-cli.ts +232 -0
  26. package/extensions/usage/index.ts +2310 -0
  27. package/extensions/usage/minimax.ts +208 -0
  28. package/extensions/usage/openai-codex.ts +180 -0
  29. package/extensions/usage/openrouter.ts +168 -0
  30. package/extensions/usage/providers.ts +26 -0
  31. package/extensions/usage/shared.ts +156 -0
  32. package/extensions/usage/types.ts +53 -0
  33. package/extensions/usage/zai.ts +186 -0
  34. package/extensions/websearch/README.md +68 -0
  35. package/extensions/websearch/browser/chromium.ts +330 -0
  36. package/extensions/websearch/browser/discovery.ts +75 -0
  37. package/extensions/websearch/browser/firefox.ts +150 -0
  38. package/extensions/websearch/browser/sqlite.ts +38 -0
  39. package/extensions/websearch/config.ts +76 -0
  40. package/extensions/websearch/index.ts +312 -0
  41. package/extensions/websearch/normalize.ts +155 -0
  42. package/extensions/websearch/providers/anthropic.pi.ts +139 -0
  43. package/extensions/websearch/providers/gemini.browser.ts +185 -0
  44. package/extensions/websearch/providers/gemini.pi.ts +108 -0
  45. package/extensions/websearch/providers/openai-codex.browser.ts +77 -0
  46. package/extensions/websearch/providers/openai-codex.pi.ts +49 -0
  47. package/extensions/websearch/providers/openai-codex.shared.ts +123 -0
  48. package/extensions/websearch/providers/pi-model.shared.ts +86 -0
  49. package/extensions/websearch/providers/search-prompt.shared.ts +17 -0
  50. package/extensions/websearch/providers/shared.ts +76 -0
  51. package/extensions/websearch/types.ts +52 -0
  52. package/extensions/worktree.ts +2223 -0
  53. package/package.json +76 -0
  54. package/skills/browser-tools/SKILL.md +253 -0
  55. package/skills/browser-tools/scripts/browser-content.js +100 -0
  56. package/skills/browser-tools/scripts/browser-cookies.js +33 -0
  57. package/skills/browser-tools/scripts/browser-dismiss-cookies.js +455 -0
  58. package/skills/browser-tools/scripts/browser-eval.js +75 -0
  59. package/skills/browser-tools/scripts/browser-logs-tail.js +89 -0
  60. package/skills/browser-tools/scripts/browser-nav.js +28 -0
  61. package/skills/browser-tools/scripts/browser-net-summary.js +115 -0
  62. package/skills/browser-tools/scripts/browser-pick.js +133 -0
  63. package/skills/browser-tools/scripts/browser-screenshot.js +18 -0
  64. package/skills/browser-tools/scripts/browser-start.js +281 -0
  65. package/skills/browser-tools/scripts/browser-watch.js +321 -0
  66. package/skills/browser-tools/scripts/utils.js +60 -0
  67. package/skills/git-clean-history/SKILL.md +45 -0
  68. package/skills/git-commit/SKILL.md +55 -0
  69. package/skills/homeassistant-ops/SKILL.md +184 -0
  70. package/skills/homeassistant-ops/references/api.md +64 -0
  71. package/skills/homeassistant-ops/references/id_conventions.md +71 -0
  72. package/skills/homeassistant-ops/references/playbook.md +150 -0
  73. package/skills/homeassistant-ops/scripts/ha_ops.js +2942 -0
  74. package/skills/openscad/SKILL.md +149 -0
  75. package/skills/openscad/examples/parametric_box.scad +92 -0
  76. package/skills/openscad/examples/phone_stand.scad +95 -0
  77. package/skills/openscad/scripts/common.sh +49 -0
  78. package/skills/openscad/scripts/export-stl.sh +56 -0
  79. package/skills/openscad/scripts/extract-params.sh +147 -0
  80. package/skills/openscad/scripts/multi-preview.sh +68 -0
  81. package/skills/openscad/scripts/preview.sh +74 -0
  82. package/skills/openscad/scripts/render-with-params.sh +91 -0
  83. package/skills/openscad/scripts/validate.sh +46 -0
  84. package/skills/oracle/SKILL.md +64 -0
  85. package/skills/oracle/scripts/oracle +249 -0
  86. package/skills/oracle/scripts/oracle-bundle +227 -0
  87. package/skills/sentry/SKILL.md +198 -0
  88. package/skills/sentry/lib/auth.js +99 -0
  89. package/skills/sentry/scripts/fetch-event.js +325 -0
  90. package/skills/sentry/scripts/fetch-issue.js +368 -0
  91. package/skills/sentry/scripts/list-issues.js +245 -0
  92. package/skills/sentry/scripts/search-events.js +291 -0
  93. package/skills/sentry/scripts/search-logs.js +234 -0
  94. package/skills/update-changelog/SKILL.md +135 -0
  95. package/skills/web-design/SKILL.md +117 -0
@@ -0,0 +1,2223 @@
1
+ /**
2
+ * Worktree Extension
3
+ *
4
+ * Commands:
5
+ * - /worktree new <branch> [--from <ref>]
6
+ * Creates a new worktree at ../<project>-<branch-normalized> and prints manual open instructions.
7
+ *
8
+ * - /worktree switch <branch> [--from <ref>]
9
+ * Switches in-place to an existing worktree, or creates it first when missing.
10
+ *
11
+ * - /worktree archive <branch>
12
+ * Removes the worktree for <branch> and deletes the local branch if it's pushed (has upstream).
13
+ *
14
+ * - /worktree clean
15
+ * Archives all worktrees whose checked out branch has an upstream.
16
+ *
17
+ * - /worktree list
18
+ * Lists worktrees and their status.
19
+ */
20
+
21
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
22
+ import {
23
+ CURRENT_SESSION_VERSION,
24
+ DynamicBorder,
25
+ SessionManager,
26
+ highlightCode,
27
+ type SessionHeader,
28
+ } from "@mariozechner/pi-coding-agent";
29
+ import {
30
+ Box,
31
+ Container,
32
+ type SelectItem,
33
+ SelectList,
34
+ Text,
35
+ matchesKey,
36
+ } from "@mariozechner/pi-tui";
37
+ import { spawnSync } from "node:child_process";
38
+ import { randomUUID } from "node:crypto";
39
+ import * as fs from "node:fs";
40
+ import * as path from "node:path";
41
+
42
+ const STATUS_KEY = "0-worktree";
43
+ const MANUAL_OPEN_MESSAGE_TYPE = "worktree-open-command";
44
+ const RESTORE_STASH_MESSAGE_TYPE = "worktree-restore-command";
45
+ const SCRIPT_RERUN_MESSAGE_TYPE = "worktree-script-rerun-command";
46
+ const MANUAL_OPEN_INTRO =
47
+ "Worktree ready. Open it in a separate terminal or tmux pane with this command";
48
+
49
+ type PromptStatus = "completed" | "error";
50
+
51
+ async function withPromptSignal<T>(pi: ExtensionAPI, run: () => Promise<T>): Promise<T> {
52
+ pi.events.emit("ui:prompt_start", { source: "worktree" });
53
+
54
+ let status: PromptStatus = "completed";
55
+ try {
56
+ return await run();
57
+ } catch (error) {
58
+ status = "error";
59
+ throw error;
60
+ } finally {
61
+ pi.events.emit("ui:prompt_end", { source: "worktree", status });
62
+ }
63
+ }
64
+
65
+ const FETCH_TIMEOUT_MS = 60_000;
66
+ const STATUS_SPINNER_INTERVAL_MS = 80;
67
+ const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
68
+
69
+ // fs.copyFileSync mode: attempt COW (reflink/clonefile) then fall back to regular
70
+ // copy, and fail if the destination already exists (skip-on-exist for idempotency).
71
+ const COPYFILE_COW_EXCL = fs.constants.COPYFILE_FICLONE | fs.constants.COPYFILE_EXCL;
72
+
73
+ type Subcommand = "new" | "switch" | "archive" | "clean" | "list";
74
+
75
+ type DirtyAction = "skip" | "stash" | "force" | "prompt";
76
+
77
+ type DirtyState = "clean" | "dirty" | "unknown";
78
+
79
+ interface WorktreeInfo {
80
+ path: string;
81
+ head?: string;
82
+ branchRef?: string;
83
+ detached?: boolean;
84
+ locked?: boolean;
85
+ lockedReason?: string;
86
+ }
87
+
88
+ interface RepoInfo {
89
+ /** Root of the main/original worktree (parent of the common .git dir) */
90
+ mainRoot: string;
91
+ /** Root of the current worktree */
92
+ currentRoot: string;
93
+ /** Directory name of mainRoot (used for naming worktrees) */
94
+ projectName: string;
95
+ /** Parent directory where worktrees will be created */
96
+ parentDir: string;
97
+ }
98
+
99
+ interface ArchiveOutcome {
100
+ branch: string;
101
+ worktreePath: string;
102
+ removed: boolean;
103
+ branchDeleted: boolean;
104
+ skippedReason?: string;
105
+ }
106
+
107
+ interface SwitchMainResult {
108
+ proceed: boolean;
109
+ switched: boolean;
110
+ stashSpec?: string;
111
+ }
112
+
113
+ interface SetupAction {
114
+ label: string;
115
+ command: string;
116
+ source: string;
117
+ }
118
+
119
+ type CommandMessageDetails = {
120
+ intro: string;
121
+ command: string;
122
+ copiedToClipboard: boolean;
123
+ };
124
+
125
+ function tokenizeArgs(args: string): string[] {
126
+ const trimmed = args.trim();
127
+ if (!trimmed) return [];
128
+ return trimmed.split(/\s+/g).filter(Boolean);
129
+ }
130
+
131
+ function normalizeBranchForPath(branch: string): string {
132
+ return (
133
+ branch
134
+ .trim()
135
+ .replace(/^refs\/heads\//, "")
136
+ .toLowerCase()
137
+ // Only allow lowercase letters, numbers and dashes in the final path segment.
138
+ .replace(/[^a-z0-9]+/g, "-")
139
+ .replace(/-+/g, "-")
140
+ .replace(/^-+/, "")
141
+ .replace(/-+$/, "")
142
+ );
143
+ }
144
+
145
+ function stripRefsHeadsPrefix(branch: string): string {
146
+ return branch.trim().replace(/^refs\/heads\//, "");
147
+ }
148
+
149
+ function shellQuote(value: string): string {
150
+ if (value.length === 0) return "''";
151
+ return `'${value.replace(/'/g, "'\\''")}'`;
152
+ }
153
+
154
+ function escapeForDoubleQuotes(value: string): string {
155
+ return value.replace(/(["\\$`!])/g, "\\$1");
156
+ }
157
+
158
+ function shellQuoteCompact(value: string): string {
159
+ const home = process.env.HOME;
160
+ if (!home) return shellQuote(value);
161
+
162
+ if (value === home) return "$HOME";
163
+
164
+ if (value.startsWith(`${home}/`)) {
165
+ const suffix = value.slice(home.length + 1);
166
+ return `"$HOME/${escapeForDoubleQuotes(suffix)}"`;
167
+ }
168
+
169
+ return shellQuote(value);
170
+ }
171
+
172
+ function runClipboardCommand(command: string, args: string[], text: string): boolean {
173
+ try {
174
+ const result = spawnSync(command, args, {
175
+ input: text,
176
+ stdio: ["pipe", "ignore", "ignore"],
177
+ timeout: 3000,
178
+ });
179
+ return result.status === 0;
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ function copyToClipboard(text: string): boolean {
186
+ if (process.platform === "darwin") {
187
+ return runClipboardCommand("pbcopy", [], text);
188
+ }
189
+
190
+ if (process.platform === "win32") {
191
+ return runClipboardCommand("clip", [], text);
192
+ }
193
+
194
+ if (process.platform === "linux") {
195
+ return (
196
+ runClipboardCommand("wl-copy", [], text) ||
197
+ runClipboardCommand("xclip", ["-selection", "clipboard"], text) ||
198
+ runClipboardCommand("xsel", ["--clipboard", "--input"], text)
199
+ );
200
+ }
201
+
202
+ return false;
203
+ }
204
+
205
+ function formatCommandMessageIntro(intro: string, copiedToClipboard: boolean): string {
206
+ return `${intro}${copiedToClipboard ? " (copied to clipboard)" : ""}:`;
207
+ }
208
+
209
+ function buildManualOpenCommand(targetPath: string): string {
210
+ return `cd ${shellQuoteCompact(targetPath)} && pi`;
211
+ }
212
+
213
+ function showCommandMessage(
214
+ pi: ExtensionAPI,
215
+ ctx: ExtensionCommandContext,
216
+ customType: string,
217
+ intro: string,
218
+ command: string,
219
+ ): void {
220
+ const copiedToClipboard = copyToClipboard(command);
221
+ if (!ctx.hasUI) {
222
+ console.log(formatCommandMessageIntro(intro, copiedToClipboard));
223
+ console.log(command);
224
+ return;
225
+ }
226
+
227
+ pi.sendMessage({
228
+ customType,
229
+ content: intro,
230
+ display: true,
231
+ details: {
232
+ intro,
233
+ command,
234
+ copiedToClipboard,
235
+ } satisfies CommandMessageDetails,
236
+ });
237
+ }
238
+
239
+ function showManualOpenCommand(
240
+ pi: ExtensionAPI,
241
+ ctx: ExtensionCommandContext,
242
+ command: string,
243
+ ): void {
244
+ showCommandMessage(pi, ctx, MANUAL_OPEN_MESSAGE_TYPE, MANUAL_OPEN_INTRO, command);
245
+ }
246
+
247
+ function showRestoreStashCommand(
248
+ pi: ExtensionAPI,
249
+ ctx: ExtensionCommandContext,
250
+ targetPath: string,
251
+ stashSpec: string,
252
+ ): void {
253
+ showCommandMessage(
254
+ pi,
255
+ ctx,
256
+ RESTORE_STASH_MESSAGE_TYPE,
257
+ `Main-worktree changes were stashed as ${stashSpec}. Restore them in the new worktree with this command`,
258
+ `cd ${shellQuoteCompact(targetPath)} && git stash apply ${shellQuote(stashSpec)}`,
259
+ );
260
+ }
261
+
262
+ function showScriptRerunCommand(
263
+ pi: ExtensionAPI,
264
+ ctx: ExtensionCommandContext,
265
+ label: string,
266
+ exitCode: number,
267
+ worktreeRoot: string,
268
+ command: string,
269
+ ): void {
270
+ showCommandMessage(
271
+ pi,
272
+ ctx,
273
+ SCRIPT_RERUN_MESSAGE_TYPE,
274
+ `${label} failed (exit ${exitCode}). Re-run it manually with this command`,
275
+ `cd ${shellQuoteCompact(worktreeRoot)} && ${command}`,
276
+ );
277
+ }
278
+
279
+ function realpathOrResolve(p: string): string {
280
+ try {
281
+ return fs.realpathSync(p);
282
+ } catch {
283
+ return path.resolve(p);
284
+ }
285
+ }
286
+
287
+ function isSameOrInsidePath(childPath: string, parentPath: string): boolean {
288
+ const rel = path.relative(realpathOrResolve(parentPath), realpathOrResolve(childPath));
289
+ return rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel));
290
+ }
291
+
292
+ function describePhaseScript(phase: "setup" | "archive", label: string): string {
293
+ const suffix = ` ${phase}`;
294
+ if (label.toLowerCase().endsWith(suffix)) {
295
+ const source = label.slice(0, -suffix.length).trim();
296
+ if (source.length > 0) {
297
+ return `${phase} for ${source}`;
298
+ }
299
+ }
300
+ return label;
301
+ }
302
+
303
+ function formatRunningScriptStatusText(phase: "setup" | "archive", label: string): string {
304
+ return `running ${describePhaseScript(phase, label)}`;
305
+ }
306
+
307
+ function formatFinishedScriptNotificationText(phase: "setup" | "archive", label: string): string {
308
+ return `Finished ${describePhaseScript(phase, label)}`;
309
+ }
310
+
311
+ async function withSpinnerStatus<T>(
312
+ ctx: ExtensionCommandContext,
313
+ initialText: string,
314
+ fn: (setStatusText: (text: string) => void) => Promise<T>,
315
+ ): Promise<T> {
316
+ if (!ctx.hasUI) return fn(() => {});
317
+
318
+ let frame = 0;
319
+ let text = initialText;
320
+ const render = () => {
321
+ ctx.ui.setStatus(STATUS_KEY, `${STATUS_SPINNER_FRAMES[frame]} ${text}`);
322
+ };
323
+ const setStatusText = (nextText: string) => {
324
+ text = nextText;
325
+ render();
326
+ };
327
+
328
+ render();
329
+ const timer = setInterval(() => {
330
+ frame = (frame + 1) % STATUS_SPINNER_FRAMES.length;
331
+ render();
332
+ }, STATUS_SPINNER_INTERVAL_MS);
333
+
334
+ try {
335
+ return await fn(setStatusText);
336
+ } finally {
337
+ clearInterval(timer);
338
+ ctx.ui.setStatus(STATUS_KEY, undefined);
339
+ }
340
+ }
341
+
342
+ async function git(
343
+ pi: ExtensionAPI,
344
+ cwd: string,
345
+ args: string[],
346
+ options?: { timeout?: number; signal?: AbortSignal },
347
+ ) {
348
+ return pi.exec("git", args, { cwd, ...options });
349
+ }
350
+
351
+ async function mustGitStdout(
352
+ pi: ExtensionAPI,
353
+ cwd: string,
354
+ args: string[],
355
+ errorPrefix: string,
356
+ ): Promise<string> {
357
+ const result = await git(pi, cwd, args);
358
+ if (result.code !== 0) {
359
+ const details = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n");
360
+ throw new Error(`${errorPrefix}${details ? `\n${details}` : ""}`);
361
+ }
362
+ return result.stdout;
363
+ }
364
+
365
+ async function getRepoInfo(pi: ExtensionAPI, cwd: string): Promise<RepoInfo> {
366
+ const currentRoot = (
367
+ await mustGitStdout(
368
+ pi,
369
+ cwd,
370
+ ["rev-parse", "--path-format=absolute", "--show-toplevel"],
371
+ "Not a git repository",
372
+ )
373
+ ).trim();
374
+
375
+ const commonDir = (
376
+ await mustGitStdout(
377
+ pi,
378
+ cwd,
379
+ ["rev-parse", "--path-format=absolute", "--git-common-dir"],
380
+ "Not a git repository",
381
+ )
382
+ ).trim();
383
+
384
+ const mainRoot = path.dirname(commonDir);
385
+ const projectName = path.basename(mainRoot);
386
+ const parentDir = path.dirname(mainRoot);
387
+
388
+ return { mainRoot, currentRoot, projectName, parentDir };
389
+ }
390
+
391
+ function parseWorktreeListPorcelain(stdout: string): WorktreeInfo[] {
392
+ const lines = stdout.split("\n");
393
+ const worktrees: WorktreeInfo[] = [];
394
+
395
+ let current: Partial<WorktreeInfo> | null = null;
396
+ for (const rawLine of lines) {
397
+ const line = rawLine.trimEnd();
398
+
399
+ if (line.trim() === "") {
400
+ if (current?.path) {
401
+ worktrees.push(current as WorktreeInfo);
402
+ }
403
+ current = null;
404
+ continue;
405
+ }
406
+
407
+ if (line.startsWith("worktree ")) {
408
+ if (current?.path) {
409
+ worktrees.push(current as WorktreeInfo);
410
+ }
411
+ current = { path: line.slice("worktree ".length).trim() };
412
+ continue;
413
+ }
414
+
415
+ if (!current) continue;
416
+
417
+ if (line.startsWith("HEAD ")) {
418
+ current.head = line.slice("HEAD ".length).trim();
419
+ continue;
420
+ }
421
+
422
+ if (line.startsWith("branch ")) {
423
+ current.branchRef = line.slice("branch ".length).trim();
424
+ continue;
425
+ }
426
+
427
+ if (line === "detached") {
428
+ current.detached = true;
429
+ continue;
430
+ }
431
+
432
+ if (line === "locked") {
433
+ current.locked = true;
434
+ continue;
435
+ }
436
+
437
+ if (line.startsWith("locked ")) {
438
+ current.locked = true;
439
+ current.lockedReason = line.slice("locked ".length).trim();
440
+ continue;
441
+ }
442
+ }
443
+
444
+ if (current?.path) {
445
+ worktrees.push(current as WorktreeInfo);
446
+ }
447
+
448
+ return worktrees;
449
+ }
450
+
451
+ async function pruneWorktrees(pi: ExtensionAPI, repoRoot: string): Promise<void> {
452
+ // Best-effort: remove stale worktree entries.
453
+ // Locked worktrees are not pruned.
454
+ await git(pi, repoRoot, ["worktree", "prune"]);
455
+ }
456
+
457
+ async function listWorktrees(pi: ExtensionAPI, repoRoot: string): Promise<WorktreeInfo[]> {
458
+ const stdout = await mustGitStdout(
459
+ pi,
460
+ repoRoot,
461
+ ["worktree", "list", "--porcelain"],
462
+ "Failed to list worktrees",
463
+ );
464
+ return parseWorktreeListPorcelain(stdout);
465
+ }
466
+
467
+ function branchNameFromRef(ref: string): string {
468
+ return ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
469
+ }
470
+
471
+ async function isDirty(pi: ExtensionAPI, cwd: string): Promise<boolean> {
472
+ const result = await git(pi, cwd, ["status", "--porcelain"]);
473
+ if (result.code !== 0) return false;
474
+ return result.stdout.trim().length > 0;
475
+ }
476
+
477
+ async function getDirtyState(pi: ExtensionAPI, cwd: string): Promise<DirtyState> {
478
+ const result = await git(pi, cwd, ["status", "--porcelain"]);
479
+ if (result.code !== 0) return "unknown";
480
+ return result.stdout.trim().length > 0 ? "dirty" : "clean";
481
+ }
482
+
483
+ async function stashAll(pi: ExtensionAPI, cwd: string, message: string): Promise<void> {
484
+ const result = await git(pi, cwd, ["stash", "push", "-u", "-m", message]);
485
+ if (result.code !== 0) {
486
+ const details = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n");
487
+ throw new Error(`Failed to stash changes${details ? `\n${details}` : ""}`);
488
+ }
489
+ }
490
+
491
+ async function getLatestStashRevision(pi: ExtensionAPI, cwd: string): Promise<string | undefined> {
492
+ const result = await git(pi, cwd, ["rev-parse", "--verify", "--quiet", "stash@{0}"]);
493
+ if (result.code !== 0) return undefined;
494
+ const revision = result.stdout.trim();
495
+ return revision.length > 0 ? revision : undefined;
496
+ }
497
+
498
+ async function localBranchExists(
499
+ pi: ExtensionAPI,
500
+ repoRoot: string,
501
+ branch: string,
502
+ ): Promise<boolean> {
503
+ const result = await git(pi, repoRoot, [
504
+ "show-ref",
505
+ "--verify",
506
+ "--quiet",
507
+ `refs/heads/${branch}`,
508
+ ]);
509
+ return result.code === 0;
510
+ }
511
+
512
+ async function getUpstream(
513
+ pi: ExtensionAPI,
514
+ repoRoot: string,
515
+ branch: string,
516
+ ): Promise<string | null> {
517
+ const result = await git(pi, repoRoot, [
518
+ "for-each-ref",
519
+ "--format=%(upstream:short)",
520
+ `refs/heads/${branch}`,
521
+ ]);
522
+ if (result.code !== 0) return null;
523
+ const upstream = result.stdout.trim();
524
+ return upstream.length > 0 ? upstream : null;
525
+ }
526
+
527
+ async function getAheadBehind(
528
+ pi: ExtensionAPI,
529
+ repoRoot: string,
530
+ branch: string,
531
+ upstream: string,
532
+ ): Promise<{ ahead: number; behind: number } | null> {
533
+ const result = await git(pi, repoRoot, [
534
+ "rev-list",
535
+ "--left-right",
536
+ "--count",
537
+ `${branch}...${upstream}`,
538
+ ]);
539
+ if (result.code !== 0) return null;
540
+
541
+ const parts = result.stdout.trim().split(/\s+/g);
542
+ const ahead = Number.parseInt(parts[0] ?? "", 10);
543
+ const behind = Number.parseInt(parts[1] ?? "", 10);
544
+
545
+ if (!Number.isFinite(ahead) || !Number.isFinite(behind)) return null;
546
+ return { ahead, behind };
547
+ }
548
+
549
+ async function getDefaultMainBranch(pi: ExtensionAPI, repoRoot: string): Promise<string> {
550
+ const remoteHead = await git(pi, repoRoot, [
551
+ "symbolic-ref",
552
+ "--quiet",
553
+ "--short",
554
+ "refs/remotes/origin/HEAD",
555
+ ]);
556
+ if (remoteHead.code === 0) {
557
+ const full = remoteHead.stdout.trim();
558
+ if (full.startsWith("origin/")) {
559
+ const candidate = full.slice("origin/".length);
560
+ if (await localBranchExists(pi, repoRoot, candidate)) return candidate;
561
+ return candidate;
562
+ }
563
+ }
564
+
565
+ for (const candidate of ["main", "master", "trunk"]) {
566
+ if (await localBranchExists(pi, repoRoot, candidate)) return candidate;
567
+ }
568
+
569
+ return "main";
570
+ }
571
+
572
+ function defaultWorktreePath(repo: RepoInfo, branch: string): string | null {
573
+ const normalized = normalizeBranchForPath(branch);
574
+ if (!normalized) return null;
575
+ return path.join(repo.parentDir, `${repo.projectName}-${normalized}`);
576
+ }
577
+
578
+ function isDirectory(p: string): boolean {
579
+ try {
580
+ return fs.statSync(p).isDirectory();
581
+ } catch {
582
+ return false;
583
+ }
584
+ }
585
+
586
+ function isFile(p: string): boolean {
587
+ try {
588
+ return fs.statSync(p).isFile();
589
+ } catch {
590
+ return false;
591
+ }
592
+ }
593
+
594
+ type WorktreePathState = "ok" | "missing" | "invalid-path" | "inaccessible";
595
+
596
+ function getWorktreePathState(p: string): WorktreePathState {
597
+ try {
598
+ const stat = fs.statSync(p);
599
+ return stat.isDirectory() ? "ok" : "invalid-path";
600
+ } catch (err) {
601
+ const code = (err as NodeJS.ErrnoException | null | undefined)?.code;
602
+ if (code === "ENOENT" || code === "ENOTDIR") return "missing";
603
+ if (code === "EACCES" || code === "EPERM") return "inaccessible";
604
+ return "inaccessible";
605
+ }
606
+ }
607
+
608
+ function worktreeAtPath(
609
+ worktrees: WorktreeInfo[],
610
+ candidatePath: string,
611
+ ): WorktreeInfo | undefined {
612
+ const resolved = realpathOrResolve(candidatePath);
613
+ return worktrees.find((w) => realpathOrResolve(w.path) === resolved);
614
+ }
615
+
616
+ function pathExistsAndIsNotEmptyDir(p: string): boolean {
617
+ if (!fs.existsSync(p)) return false;
618
+ if (!isDirectory(p)) return true;
619
+
620
+ try {
621
+ return fs.readdirSync(p).length > 0;
622
+ } catch {
623
+ return true;
624
+ }
625
+ }
626
+
627
+ async function resolveWorktreePath(
628
+ pi: ExtensionAPI,
629
+ ctx: ExtensionCommandContext,
630
+ repo: RepoInfo,
631
+ worktrees: WorktreeInfo[],
632
+ branch: string,
633
+ ): Promise<string | null> {
634
+ const defaultCandidate = defaultWorktreePath(repo, branch);
635
+ let candidate: string;
636
+
637
+ if (!defaultCandidate) {
638
+ if (!ctx.hasUI) {
639
+ throw new Error(`Branch name cannot be normalized to a directory name: ${branch}`);
640
+ }
641
+
642
+ const suggested = path.join(repo.parentDir, `${repo.projectName}-worktree`);
643
+ ctx.ui.notify(
644
+ `Branch name "${branch}" normalizes to an empty directory name. Please choose a worktree directory.`,
645
+ "warning",
646
+ );
647
+ const input = await withPromptSignal(pi, () =>
648
+ ctx.ui.input("Enter worktree directory", suggested),
649
+ );
650
+ if (!input) return null;
651
+ candidate = path.isAbsolute(input) ? input : path.resolve(repo.currentRoot, input);
652
+ } else {
653
+ candidate = defaultCandidate;
654
+ }
655
+
656
+ while (true) {
657
+ const existingWt = worktreeAtPath(worktrees, candidate);
658
+ const nonEmpty = pathExistsAndIsNotEmptyDir(candidate);
659
+
660
+ if (!existingWt && !nonEmpty) return candidate;
661
+
662
+ const reason = existingWt
663
+ ? `Path is already used by an existing worktree (${existingWt.branchRef ? branchNameFromRef(existingWt.branchRef) : "detached"})`
664
+ : "Path already exists and is not empty";
665
+
666
+ if (!ctx.hasUI) {
667
+ throw new Error(`${reason}: ${candidate}`);
668
+ }
669
+
670
+ ctx.ui.notify(`${reason}: ${candidate}`, "warning");
671
+ const input = await withPromptSignal(pi, () =>
672
+ ctx.ui.input("Enter a different worktree directory", candidate),
673
+ );
674
+ if (!input) return null;
675
+
676
+ candidate = path.isAbsolute(input) ? input : path.resolve(repo.currentRoot, input);
677
+ }
678
+ }
679
+
680
+ function asNonEmptyString(value: unknown): string | null {
681
+ if (typeof value !== "string") return null;
682
+ const trimmed = value.trim();
683
+ return trimmed.length > 0 ? trimmed : null;
684
+ }
685
+
686
+ /** Coerce a config value (string or string[]) into a single shell command, or null. */
687
+ function asCommand(value: unknown): string | null {
688
+ if (typeof value === "string") return asNonEmptyString(value);
689
+ if (Array.isArray(value)) {
690
+ const parts = value.filter((c): c is string => typeof c === "string" && c.trim().length > 0);
691
+ return parts.length > 0 ? parts.join(" && ") : null;
692
+ }
693
+ return null;
694
+ }
695
+
696
+ function readJsonFile(filePath: string): unknown | null {
697
+ try {
698
+ const raw = fs.readFileSync(filePath, "utf8");
699
+ return JSON.parse(raw);
700
+ } catch {
701
+ return null;
702
+ }
703
+ }
704
+
705
+ function inferSetupActions(worktreeRoot: string): SetupAction[] {
706
+ const actions: SetupAction[] = [];
707
+
708
+ // Conductor: conductor.json → scripts.setup
709
+ // https://docs.conductor.build/core/conductor-json
710
+ const conductorConfigPath = path.join(worktreeRoot, "conductor.json");
711
+ const conductorConfig = readJsonFile(conductorConfigPath) as any;
712
+ const conductorSetup = asNonEmptyString(conductorConfig?.scripts?.setup);
713
+ if (conductorSetup) {
714
+ actions.push({ label: "Conductor setup", command: conductorSetup, source: "conductor.json" });
715
+ }
716
+
717
+ // 1Code: .1code/worktree.json → setup-worktree (or setup-worktree-unix / setup-worktree-windows)
718
+ // Values can be a string or string[]. Generic key takes priority, then platform-specific.
719
+ // https://github.com/21st-dev/1code → src/main/lib/git/worktree-config.ts
720
+ const oneCodeConfigPath = path.join(worktreeRoot, ".1code", "worktree.json");
721
+ const oneCodeConfig = readJsonFile(oneCodeConfigPath) as any;
722
+ if (oneCodeConfig) {
723
+ const platformKey =
724
+ process.platform === "win32" ? "setup-worktree-windows" : "setup-worktree-unix";
725
+ const oneCodeSetup =
726
+ asCommand(oneCodeConfig["setup-worktree"]) ?? asCommand(oneCodeConfig[platformKey]);
727
+ if (oneCodeSetup) {
728
+ actions.push({ label: "1Code setup", command: oneCodeSetup, source: ".1code/worktree.json" });
729
+ }
730
+ }
731
+
732
+ // Cursor: .cursor/worktrees.json → same schema as 1Code
733
+ // https://cursor.com/docs/configuration/worktrees
734
+ const cursorConfigPath = path.join(worktreeRoot, ".cursor", "worktrees.json");
735
+ const cursorConfig = readJsonFile(cursorConfigPath) as any;
736
+ if (cursorConfig) {
737
+ const platformKey =
738
+ process.platform === "win32" ? "setup-worktree-windows" : "setup-worktree-unix";
739
+ const cursorSetup =
740
+ asCommand(cursorConfig["setup-worktree"]) ?? asCommand(cursorConfig[platformKey]);
741
+ if (cursorSetup) {
742
+ actions.push({
743
+ label: "Cursor setup",
744
+ command: cursorSetup,
745
+ source: ".cursor/worktrees.json",
746
+ });
747
+ }
748
+ }
749
+
750
+ // Superset: .superset/config.json → setup (string[])
751
+ const supersetConfigPath = path.join(worktreeRoot, ".superset", "config.json");
752
+ const supersetConfig = readJsonFile(supersetConfigPath) as any;
753
+ const supersetSetup = asCommand(supersetConfig?.setup);
754
+ if (supersetSetup) {
755
+ actions.push({
756
+ label: "Superset setup",
757
+ command: supersetSetup,
758
+ source: ".superset/config.json",
759
+ });
760
+ }
761
+
762
+ // CCPM: .claude/scripts/{bootstrap,setup,init}.sh
763
+ // https://github.com/elysenko/ccpm
764
+ const ccpmDir = path.join(worktreeRoot, ".claude", "scripts");
765
+ if (fs.existsSync(ccpmDir) && isDirectory(ccpmDir)) {
766
+ for (const script of ["bootstrap.sh", "setup.sh", "init.sh"]) {
767
+ if (isFile(path.join(ccpmDir, script))) {
768
+ const relScript = `./.claude/scripts/${script}`;
769
+ actions.push({
770
+ label: `CCPM: .claude/scripts/${script}`,
771
+ command: `bash ${shellQuote(relScript)}`,
772
+ source: `.claude/scripts/${script}`,
773
+ });
774
+ }
775
+ }
776
+ }
777
+
778
+ return actions;
779
+ }
780
+
781
+ function inferArchiveActions(worktreeRoot: string): SetupAction[] {
782
+ const actions: SetupAction[] = [];
783
+
784
+ // Conductor: conductor.json → scripts.archive
785
+ // https://docs.conductor.build/core/conductor-json
786
+ const conductorConfigPath = path.join(worktreeRoot, "conductor.json");
787
+ const conductorConfig = readJsonFile(conductorConfigPath) as any;
788
+ const conductorArchive = asNonEmptyString(conductorConfig?.scripts?.archive);
789
+ if (conductorArchive) {
790
+ actions.push({
791
+ label: "Conductor archive",
792
+ command: conductorArchive,
793
+ source: "conductor.json",
794
+ });
795
+ }
796
+
797
+ // Superset: .superset/config.json → teardown (string[])
798
+ const supersetConfigPath = path.join(worktreeRoot, ".superset", "config.json");
799
+ const supersetConfig = readJsonFile(supersetConfigPath) as any;
800
+ const supersetTeardown = asCommand(supersetConfig?.teardown);
801
+ if (supersetTeardown) {
802
+ actions.push({
803
+ label: "Superset teardown",
804
+ command: supersetTeardown,
805
+ source: ".superset/config.json",
806
+ });
807
+ }
808
+
809
+ return actions;
810
+ }
811
+
812
+ /**
813
+ * Parse a .worktreeinclude file into glob patterns suitable for path.matchesGlob().
814
+ *
815
+ * .worktreeinclude uses gitignore syntax. We implement a practical subset:
816
+ * - Blank lines and # comments are skipped
817
+ * - ! prefix negates a pattern
818
+ * - Trailing / marks directory-only patterns
819
+ * - Leading / anchors to the worktree root
820
+ * - Patterns without internal / match at any depth (prepend **​/)
821
+ *
822
+ * Full gitignore supports character classes ([a-z]), escaped characters, etc.
823
+ * This simplified parser covers the patterns seen in practice (.worktreeinclude
824
+ * files typically list build artifact directories like target/, node_modules/).
825
+ * Using path.matchesGlob() (built into Node 22) avoids external dependencies.
826
+ */
827
+ function parseWorktreeIncludePatterns(content: string): Array<{ glob: string; negate: boolean }> {
828
+ return content
829
+ .split("\n")
830
+ .map((line) => line.trim())
831
+ .filter((line) => line && !line.startsWith("#"))
832
+ .map((pattern) => {
833
+ const negate = pattern.startsWith("!");
834
+ const raw = negate ? pattern.slice(1) : pattern;
835
+
836
+ let glob: string;
837
+ if (raw.startsWith("/")) {
838
+ // Leading / anchors to root — strip it (paths from ls-files are already root-relative)
839
+ glob = raw.slice(1);
840
+ } else {
841
+ // If no internal / (ignoring trailing /), match at any depth per gitignore spec
842
+ const withoutTrailing = raw.endsWith("/") ? raw.slice(0, -1) : raw;
843
+ if (!withoutTrailing.includes("/")) {
844
+ glob = `**/${raw}`;
845
+ } else {
846
+ glob = raw;
847
+ }
848
+ }
849
+ return { glob, negate };
850
+ });
851
+ }
852
+
853
+ function matchesWorktreeInclude(
854
+ entry: string,
855
+ patterns: Array<{ glob: string; negate: boolean }>,
856
+ ): boolean {
857
+ // Normalize trailing slashes: git ls-files --directory appends / to directories,
858
+ // and patterns may or may not have trailing /. Strip both for matching since
859
+ // --directory already ensures we only get directory entries for directory patterns.
860
+ const normalizedEntry = entry.replace(/\/$/, "");
861
+ let matched = false;
862
+ for (const { glob, negate } of patterns) {
863
+ const normalizedGlob = glob.replace(/\/$/, "");
864
+ if (path.matchesGlob(normalizedEntry, normalizedGlob)) {
865
+ matched = !negate;
866
+ }
867
+ }
868
+ return matched;
869
+ }
870
+
871
+ /**
872
+ * Recursively copy a directory using per-file COW (reflink/clonefile) where the
873
+ * filesystem supports it, falling back to regular copy otherwise. Existing files
874
+ * are silently skipped for idempotent re-runs.
875
+ *
876
+ * This matches worktrunk's copy_dir_recursive: per-file reflink spreads I/O
877
+ * operations over time rather than issuing them in a single burst (macOS
878
+ * clonefile on large directories can saturate disk I/O and block interactive
879
+ * processes). Symlinks are preserved (re-created, not followed).
880
+ */
881
+ function copyDirRecursive(src: string, dest: string): void {
882
+ fs.mkdirSync(dest, { recursive: true });
883
+
884
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
885
+ const srcPath = path.join(src, entry.name);
886
+ const destPath = path.join(dest, entry.name);
887
+
888
+ if (entry.isSymbolicLink()) {
889
+ try {
890
+ const target = fs.readlinkSync(srcPath);
891
+ fs.symlinkSync(target, destPath);
892
+ } catch (err) {
893
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
894
+ }
895
+ } else if (entry.isDirectory()) {
896
+ copyDirRecursive(srcPath, destPath);
897
+ } else {
898
+ try {
899
+ fs.copyFileSync(srcPath, destPath, COPYFILE_COW_EXCL);
900
+ } catch (err) {
901
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
902
+ }
903
+ }
904
+ }
905
+ }
906
+
907
+ async function applyWorktreeInclude(
908
+ pi: ExtensionAPI,
909
+ ctx: ExtensionCommandContext,
910
+ sourceRoot: string,
911
+ destRoot: string,
912
+ worktrees: WorktreeInfo[],
913
+ ): Promise<void> {
914
+ if (!ctx.hasUI) return;
915
+
916
+ const includeFile = path.join(sourceRoot, ".worktreeinclude");
917
+ if (!isFile(includeFile)) return;
918
+
919
+ let content: string;
920
+ try {
921
+ content = fs.readFileSync(includeFile, "utf8");
922
+ } catch {
923
+ return;
924
+ }
925
+
926
+ const patterns = parseWorktreeIncludePatterns(content);
927
+ if (patterns.length === 0) return;
928
+
929
+ // List gitignored entries in the source worktree.
930
+ // --directory stops at directory boundaries (avoids listing thousands of files in target/).
931
+ const lsResult = await git(pi, sourceRoot, [
932
+ "ls-files",
933
+ "--ignored",
934
+ "--exclude-standard",
935
+ "-o",
936
+ "--directory",
937
+ "--no-empty-directory",
938
+ ]);
939
+ if (lsResult.code !== 0) return;
940
+
941
+ const ignoredEntries = lsResult.stdout
942
+ .split("\n")
943
+ .map((line) => line.trim())
944
+ .filter(Boolean);
945
+
946
+ // Filter to entries matching .worktreeinclude patterns.
947
+ let entriesToCopy = ignoredEntries.filter((entry) => matchesWorktreeInclude(entry, patterns));
948
+
949
+ // Exclude entries that contain other worktrees (prevents recursive copying when
950
+ // worktrees are nested inside the source, e.g., worktree-path = ".worktrees/...").
951
+ const worktreePaths = worktrees.map((wt) => realpathOrResolve(wt.path));
952
+ const sourceRealpath = realpathOrResolve(sourceRoot);
953
+ entriesToCopy = entriesToCopy.filter((entry) => {
954
+ const entryAbs = realpathOrResolve(path.join(sourceRoot, entry.replace(/\/$/, "")));
955
+ return !worktreePaths.some(
956
+ (wtPath) => wtPath !== sourceRealpath && isSameOrInsidePath(wtPath, entryAbs),
957
+ );
958
+ });
959
+
960
+ if (entriesToCopy.length === 0) return;
961
+
962
+ const listing = entriesToCopy.map((e) => ` ${e}`).join("\n");
963
+ const ok = await withPromptSignal(pi, () =>
964
+ ctx.ui.confirm(
965
+ "Copy cached files from main worktree?",
966
+ `Found .worktreeinclude. Copy these gitignored entries:\n\n${listing}`,
967
+ ),
968
+ );
969
+ if (!ok) return;
970
+
971
+ await withSpinnerStatus(ctx, "copying cached files", async () => {
972
+ for (const entry of entriesToCopy) {
973
+ const src = path.join(sourceRoot, entry.replace(/\/$/, ""));
974
+ const dest = path.join(destRoot, entry.replace(/\/$/, ""));
975
+
976
+ // Validate paths stay within their respective roots (defense against
977
+ // crafted .gitignore + .worktreeinclude producing ../ components).
978
+ if (!isSameOrInsidePath(src, sourceRoot) || !isSameOrInsidePath(dest, destRoot)) continue;
979
+
980
+ if (!fs.existsSync(src)) continue;
981
+
982
+ try {
983
+ const srcStat = fs.lstatSync(src);
984
+ if (srcStat.isDirectory()) {
985
+ copyDirRecursive(src, dest);
986
+ } else if (srcStat.isSymbolicLink()) {
987
+ const target = fs.readlinkSync(src);
988
+ const destParent = path.dirname(dest);
989
+ if (!fs.existsSync(destParent)) {
990
+ fs.mkdirSync(destParent, { recursive: true });
991
+ }
992
+ try {
993
+ fs.symlinkSync(target, dest);
994
+ } catch (err) {
995
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
996
+ }
997
+ } else {
998
+ const destParent = path.dirname(dest);
999
+ if (!fs.existsSync(destParent)) {
1000
+ fs.mkdirSync(destParent, { recursive: true });
1001
+ }
1002
+ try {
1003
+ fs.copyFileSync(src, dest, COPYFILE_COW_EXCL);
1004
+ } catch (err) {
1005
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
1006
+ }
1007
+ }
1008
+ } catch (err) {
1009
+ const message = err instanceof Error ? err.message : String(err);
1010
+ ctx.ui.notify(`Failed to copy ${entry}: ${message}`, "warning");
1011
+ }
1012
+ }
1013
+ });
1014
+
1015
+ ctx.ui.notify("Cached files copied", "info");
1016
+ }
1017
+
1018
+ async function runProjectScripts(
1019
+ pi: ExtensionAPI,
1020
+ ctx: ExtensionCommandContext,
1021
+ worktreeRoot: string,
1022
+ phase: "setup" | "archive",
1023
+ actions: SetupAction[],
1024
+ ): Promise<void> {
1025
+ if (!ctx.hasUI || actions.length === 0) return;
1026
+
1027
+ let chosen: SetupAction | undefined;
1028
+
1029
+ if (actions.length === 1) {
1030
+ const action = actions[0];
1031
+ const ok = await withPromptSignal(pi, () =>
1032
+ ctx.ui.confirm(`Run worktree ${phase}?`, `${action.label}\n\nCommand:\n${action.command}`),
1033
+ );
1034
+ if (!ok) return;
1035
+ chosen = action;
1036
+ } else {
1037
+ const options = ["Skip", ...actions.map((a) => `${a.label} (${a.source})`)];
1038
+ const choice = await withPromptSignal(pi, () =>
1039
+ ctx.ui.select(`Choose ${phase} script to run`, options),
1040
+ );
1041
+ if (!choice || choice === "Skip") return;
1042
+
1043
+ const idx = options.indexOf(choice) - 1;
1044
+ const selectedAction = actions[idx];
1045
+ if (!selectedAction) return;
1046
+
1047
+ const ok = await withPromptSignal(pi, () =>
1048
+ ctx.ui.confirm(
1049
+ `Run worktree ${phase}?`,
1050
+ `${selectedAction.label}\n\nCommand:\n${selectedAction.command}`,
1051
+ ),
1052
+ );
1053
+ if (!ok) return;
1054
+
1055
+ chosen = selectedAction;
1056
+ }
1057
+
1058
+ if (!chosen) return;
1059
+
1060
+ const label = phase[0].toUpperCase() + phase.slice(1);
1061
+ const statusText = formatRunningScriptStatusText(phase, chosen.label);
1062
+
1063
+ await withSpinnerStatus(ctx, statusText, async () => {
1064
+ const result = await pi.exec("bash", ["-c", chosen.command], { cwd: worktreeRoot });
1065
+ if (result.code !== 0) {
1066
+ showScriptRerunCommand(pi, ctx, label, result.code, worktreeRoot, chosen.command);
1067
+ throw new Error(`${label} failed (exit ${result.code}).`);
1068
+ }
1069
+ });
1070
+
1071
+ ctx.ui.notify(formatFinishedScriptNotificationText(phase, chosen.label), "info");
1072
+ }
1073
+
1074
+ async function ensureCanPrompt(ctx: ExtensionCommandContext, message: string): Promise<boolean> {
1075
+ if (ctx.hasUI) return true;
1076
+ // No UI - can't safely proceed with anything requiring confirmation
1077
+ // (still return false so callers can abort gracefully)
1078
+ console.error(message);
1079
+ return false;
1080
+ }
1081
+
1082
+ async function getHeadBranch(pi: ExtensionAPI, cwd: string): Promise<string | null> {
1083
+ const result = await git(pi, cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
1084
+ if (result.code !== 0) return null;
1085
+ const name = result.stdout.trim();
1086
+ if (!name || name === "HEAD") return null;
1087
+ return name;
1088
+ }
1089
+
1090
+ function worktreeForBranch(worktrees: WorktreeInfo[], branch: string): WorktreeInfo | undefined {
1091
+ return worktrees.find((w) => w.branchRef === `refs/heads/${branch}`);
1092
+ }
1093
+
1094
+ type ParsedWorktreeTargetArgs = {
1095
+ branch: string;
1096
+ fromRef?: string;
1097
+ };
1098
+
1099
+ async function parseWorktreeTargetArgs(
1100
+ pi: ExtensionAPI,
1101
+ ctx: ExtensionCommandContext,
1102
+ args: string,
1103
+ usage: string,
1104
+ ): Promise<ParsedWorktreeTargetArgs | null> {
1105
+ const tokens = tokenizeArgs(args);
1106
+ let branch = tokens[0];
1107
+ if (!branch || branch.startsWith("-")) {
1108
+ if (!ctx.hasUI) {
1109
+ throw new Error(usage);
1110
+ }
1111
+ const input = await withPromptSignal(pi, () => ctx.ui.input("Branch name"));
1112
+ if (!input) return null;
1113
+ branch = input.trim();
1114
+ }
1115
+
1116
+ branch = stripRefsHeadsPrefix(branch);
1117
+ if (!branch || branch.startsWith("-")) {
1118
+ if (ctx.hasUI) ctx.ui.notify("Invalid branch name", "warning");
1119
+ else throw new Error("Invalid branch name");
1120
+ return null;
1121
+ }
1122
+
1123
+ let fromRef: string | undefined;
1124
+ for (let i = 1; i < tokens.length; i++) {
1125
+ const token = tokens[i]!;
1126
+ if (token === "--from") {
1127
+ const next = tokens[i + 1];
1128
+ if (!next) {
1129
+ if (ctx.hasUI) ctx.ui.notify(usage, "warning");
1130
+ else throw new Error(usage);
1131
+ return null;
1132
+ }
1133
+ fromRef = next;
1134
+ i += 1;
1135
+ continue;
1136
+ }
1137
+ if (token.startsWith("--from=")) {
1138
+ const value = token.slice("--from=".length).trim();
1139
+ if (!value) {
1140
+ if (ctx.hasUI) ctx.ui.notify(usage, "warning");
1141
+ else throw new Error(usage);
1142
+ return null;
1143
+ }
1144
+ fromRef = value;
1145
+ continue;
1146
+ }
1147
+
1148
+ if (ctx.hasUI) ctx.ui.notify(usage, "warning");
1149
+ else throw new Error(usage);
1150
+ return null;
1151
+ }
1152
+
1153
+ return { branch, fromRef };
1154
+ }
1155
+
1156
+ async function maybeSwitchMainToDefaultBranch(
1157
+ pi: ExtensionAPI,
1158
+ ctx: ExtensionCommandContext,
1159
+ repo: RepoInfo,
1160
+ defaultBranch: string,
1161
+ reason: string,
1162
+ ): Promise<SwitchMainResult> {
1163
+ const current = await getHeadBranch(pi, repo.mainRoot);
1164
+ if (!current || current === defaultBranch) {
1165
+ return { proceed: true, switched: false };
1166
+ }
1167
+
1168
+ if (!ctx.hasUI) {
1169
+ throw new Error(
1170
+ `Cannot proceed without UI: need to checkout ${defaultBranch} in main worktree to ${reason}.`,
1171
+ );
1172
+ }
1173
+
1174
+ const ok = await withPromptSignal(pi, () =>
1175
+ ctx.ui.confirm(
1176
+ "Switch main worktree?",
1177
+ `Main worktree is on ${current}. Checkout ${defaultBranch} to ${reason}?`,
1178
+ ),
1179
+ );
1180
+ if (!ok) return { proceed: false, switched: false };
1181
+
1182
+ let stashSpec: string | undefined;
1183
+ if (await isDirty(pi, repo.mainRoot)) {
1184
+ const choice = await withPromptSignal(pi, () =>
1185
+ ctx.ui.select("Main worktree has uncommitted changes", [
1186
+ "Stash changes (including untracked) and continue",
1187
+ "Cancel",
1188
+ ]),
1189
+ );
1190
+ if (!choice || choice.startsWith("Cancel")) {
1191
+ return { proceed: false, switched: false };
1192
+ }
1193
+
1194
+ await stashAll(
1195
+ pi,
1196
+ repo.mainRoot,
1197
+ `worktree: stash before switching main worktree to ${defaultBranch}`,
1198
+ );
1199
+ stashSpec = (await getLatestStashRevision(pi, repo.mainRoot)) ?? "stash@{0}";
1200
+ }
1201
+
1202
+ const checkout = await git(pi, repo.mainRoot, ["checkout", defaultBranch]);
1203
+ if (checkout.code !== 0) {
1204
+ const details = [checkout.stdout.trim(), checkout.stderr.trim()].filter(Boolean).join("\n");
1205
+ throw new Error(
1206
+ `Failed to checkout ${defaultBranch} in main worktree${details ? `\n${details}` : ""}`,
1207
+ );
1208
+ }
1209
+
1210
+ return {
1211
+ proceed: true,
1212
+ switched: true,
1213
+ stashSpec,
1214
+ };
1215
+ }
1216
+
1217
+ async function createWorktree(
1218
+ pi: ExtensionAPI,
1219
+ ctx: ExtensionCommandContext,
1220
+ repo: RepoInfo,
1221
+ worktrees: WorktreeInfo[],
1222
+ branch: string,
1223
+ fromRef?: string,
1224
+ ): Promise<string | null> {
1225
+ const targetPath = await resolveWorktreePath(pi, ctx, repo, worktrees, branch);
1226
+ if (!targetPath) return null;
1227
+
1228
+ const branchExists = await localBranchExists(pi, repo.mainRoot, branch);
1229
+ if (branchExists && fromRef) {
1230
+ if (ctx.hasUI) {
1231
+ const ok = await withPromptSignal(pi, () =>
1232
+ ctx.ui.confirm(
1233
+ "Branch exists",
1234
+ `Branch ${branch} already exists.\n\nContinuing will use the existing branch at its current state; --from (${fromRef}) will have no effect.\n\nContinue?`,
1235
+ ),
1236
+ );
1237
+ if (!ok) return null;
1238
+ } else {
1239
+ console.error(`Branch ${branch} already exists. Ignoring --from ${fromRef}.`);
1240
+ }
1241
+ }
1242
+
1243
+ let addArgs: string[];
1244
+ if (branchExists) {
1245
+ addArgs = ["worktree", "add", targetPath, branch];
1246
+ } else {
1247
+ const baseCommit = (
1248
+ await mustGitStdout(
1249
+ pi,
1250
+ repo.currentRoot,
1251
+ ["rev-parse", fromRef ?? "HEAD"],
1252
+ `Failed to resolve base ref: ${fromRef ?? "HEAD"}`,
1253
+ )
1254
+ ).trim();
1255
+ addArgs = ["worktree", "add", "-b", branch, targetPath, baseCommit];
1256
+ }
1257
+
1258
+ const add = await git(pi, repo.mainRoot, addArgs);
1259
+ if (add.code !== 0) {
1260
+ const details = [add.stdout.trim(), add.stderr.trim()].filter(Boolean).join("\n");
1261
+ throw new Error(`Failed to create worktree${details ? `\n${details}` : ""}`);
1262
+ }
1263
+
1264
+ if (ctx.hasUI) {
1265
+ ctx.ui.notify(`Worktree created: ${targetPath}`, "info");
1266
+ }
1267
+
1268
+ const currentWorktrees = await listWorktrees(pi, repo.mainRoot);
1269
+ await applyWorktreeInclude(pi, ctx, repo.mainRoot, targetPath, currentWorktrees);
1270
+ await runProjectScripts(pi, ctx, targetPath, "setup", inferSetupActions(targetPath));
1271
+
1272
+ return targetPath;
1273
+ }
1274
+
1275
+ function hasValidSessionFile(sessionFile: string): boolean {
1276
+ if (!fs.existsSync(sessionFile)) return false;
1277
+
1278
+ try {
1279
+ const firstLine = fs.readFileSync(sessionFile, "utf8").split("\n", 1)[0]?.trim();
1280
+ if (!firstLine) return false;
1281
+
1282
+ const header = JSON.parse(firstLine) as Partial<SessionHeader>;
1283
+ return header.type === "session" && typeof header.id === "string";
1284
+ } catch {
1285
+ return false;
1286
+ }
1287
+ }
1288
+
1289
+ function createFreshSessionFile(targetCwd: string, sessionDir: string): string {
1290
+ fs.mkdirSync(sessionDir, { recursive: true });
1291
+
1292
+ const sessionId = randomUUID();
1293
+ const timestamp = new Date().toISOString();
1294
+ const fileTimestamp = timestamp.replace(/[:.]/g, "-");
1295
+ const sessionFile = path.join(sessionDir, `${fileTimestamp}_${sessionId}.jsonl`);
1296
+ const header: SessionHeader = {
1297
+ type: "session",
1298
+ version: CURRENT_SESSION_VERSION,
1299
+ id: sessionId,
1300
+ timestamp,
1301
+ cwd: targetCwd,
1302
+ };
1303
+
1304
+ fs.writeFileSync(sessionFile, `${JSON.stringify(header)}\n`);
1305
+ return sessionFile;
1306
+ }
1307
+
1308
+ async function switchToWorktree(
1309
+ pi: ExtensionAPI,
1310
+ ctx: ExtensionCommandContext,
1311
+ targetPath: string,
1312
+ currentWorktreeRoot?: string,
1313
+ ): Promise<void> {
1314
+ const pathState = getWorktreePathState(targetPath);
1315
+ if (pathState !== "ok") {
1316
+ const message = `Cannot switch to worktree at ${targetPath}: ${pathState}`;
1317
+ if (ctx.hasUI) ctx.ui.notify(message, "error");
1318
+ else throw new Error(message);
1319
+ return;
1320
+ }
1321
+
1322
+ const currentRoot = currentWorktreeRoot ?? ctx.cwd;
1323
+ if (realpathOrResolve(targetPath) === realpathOrResolve(currentRoot)) {
1324
+ if (ctx.hasUI) ctx.ui.notify("Already in this worktree", "info");
1325
+ return;
1326
+ }
1327
+
1328
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
1329
+ if (!currentSessionFile) {
1330
+ const manualCommand = buildManualOpenCommand(targetPath);
1331
+ const message = "Current session is ephemeral; cannot switch worktrees in-place.";
1332
+ if (ctx.hasUI) {
1333
+ ctx.ui.notify(message, "warning");
1334
+ showManualOpenCommand(pi, ctx, manualCommand);
1335
+ } else {
1336
+ showManualOpenCommand(pi, ctx, manualCommand);
1337
+ throw new Error(message);
1338
+ }
1339
+ return;
1340
+ }
1341
+
1342
+ const sessionDir = ctx.sessionManager.getSessionDir();
1343
+ const hasAssistantReply = ctx.sessionManager
1344
+ .getEntries()
1345
+ .some((entry) => entry.type === "message" && entry.message.role === "assistant");
1346
+
1347
+ let sessionFile: string;
1348
+ if (hasValidSessionFile(currentSessionFile)) {
1349
+ const nextSession = SessionManager.forkFrom(currentSessionFile, targetPath, sessionDir);
1350
+ const forkedSessionFile = nextSession.getSessionFile();
1351
+ if (!forkedSessionFile) {
1352
+ throw new Error(`Failed to create a session for worktree: ${targetPath}`);
1353
+ }
1354
+ sessionFile = forkedSessionFile;
1355
+ } else {
1356
+ if (hasAssistantReply) {
1357
+ throw new Error(`Current session file is missing or invalid: ${currentSessionFile}`);
1358
+ }
1359
+
1360
+ const message = "Current session has no persisted history yet. Switching with a fresh session.";
1361
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
1362
+ else console.log(message);
1363
+
1364
+ sessionFile = createFreshSessionFile(targetPath, sessionDir);
1365
+ }
1366
+
1367
+ const result = await ctx.switchSession(sessionFile);
1368
+ if (result.cancelled) {
1369
+ try {
1370
+ fs.rmSync(sessionFile, { force: true });
1371
+ } catch {
1372
+ // Best effort: avoid leaving behind an unused forked session file.
1373
+ }
1374
+
1375
+ if (ctx.hasUI) {
1376
+ ctx.ui.notify("Cancelled", "warning");
1377
+ return;
1378
+ }
1379
+
1380
+ throw new Error("Switch cancelled");
1381
+ }
1382
+ }
1383
+
1384
+ async function handleNew(
1385
+ pi: ExtensionAPI,
1386
+ ctx: ExtensionCommandContext,
1387
+ args: string,
1388
+ ): Promise<void> {
1389
+ const parsed = await parseWorktreeTargetArgs(
1390
+ pi,
1391
+ ctx,
1392
+ args,
1393
+ "Usage: /worktree new <branch> [--from <ref>]",
1394
+ );
1395
+ if (!parsed) return;
1396
+
1397
+ const result = await withSpinnerStatus(ctx, `creating worktree: ${parsed.branch}`, async () => {
1398
+ const repo = await getRepoInfo(pi, ctx.cwd);
1399
+ const defaultMain = await getDefaultMainBranch(pi, repo.mainRoot);
1400
+ await pruneWorktrees(pi, repo.mainRoot);
1401
+ const worktrees = await listWorktrees(pi, repo.mainRoot);
1402
+ const existing = worktreeForBranch(worktrees, parsed.branch);
1403
+ if (existing && existing.path !== repo.mainRoot) {
1404
+ const alreadyHere = realpathOrResolve(existing.path) === realpathOrResolve(repo.currentRoot);
1405
+ const message = alreadyHere
1406
+ ? `Already in the worktree for branch ${parsed.branch}: ${existing.path}`
1407
+ : `Worktree already exists for branch ${parsed.branch}: ${existing.path}`;
1408
+ if (ctx.hasUI) {
1409
+ ctx.ui.notify(message, alreadyHere ? "info" : "warning");
1410
+ if (!alreadyHere) {
1411
+ ctx.ui.notify(`Use /worktree switch ${parsed.branch} to move into it.`, "info");
1412
+ }
1413
+ return null;
1414
+ }
1415
+ throw new Error(alreadyHere ? message : `${message}. Use /worktree switch ${parsed.branch}.`);
1416
+ }
1417
+
1418
+ let stashSpec: string | undefined;
1419
+ if (existing?.path === repo.mainRoot) {
1420
+ if (parsed.branch === defaultMain) {
1421
+ const message = `Branch ${parsed.branch} is already checked out in the main worktree (${repo.mainRoot}); can't create another worktree for it.`;
1422
+ if (ctx.hasUI) {
1423
+ ctx.ui.notify(message, "warning");
1424
+ return null;
1425
+ }
1426
+ throw new Error(message);
1427
+ }
1428
+
1429
+ const mainSwitchResult = await maybeSwitchMainToDefaultBranch(
1430
+ pi,
1431
+ ctx,
1432
+ repo,
1433
+ defaultMain,
1434
+ `free branch ${parsed.branch}`,
1435
+ );
1436
+ if (!mainSwitchResult.proceed) {
1437
+ if (ctx.hasUI) ctx.ui.notify("Cancelled", "warning");
1438
+ return null;
1439
+ }
1440
+ stashSpec = mainSwitchResult.stashSpec;
1441
+ if (mainSwitchResult.switched && ctx.hasUI) {
1442
+ ctx.ui.notify(
1443
+ `Switched main worktree (${repo.mainRoot}) from ${parsed.branch} to ${defaultMain} to free the branch.`,
1444
+ "info",
1445
+ );
1446
+ if (stashSpec) {
1447
+ ctx.ui.notify(
1448
+ `Changes in the main worktree were stashed before switching to ${defaultMain}.`,
1449
+ "warning",
1450
+ );
1451
+ }
1452
+ }
1453
+ }
1454
+
1455
+ const targetPath = await createWorktree(
1456
+ pi,
1457
+ ctx,
1458
+ repo,
1459
+ worktrees,
1460
+ parsed.branch,
1461
+ parsed.fromRef,
1462
+ );
1463
+ if (!targetPath) return null;
1464
+ return { targetPath, stashSpec } satisfies { targetPath: string; stashSpec?: string };
1465
+ });
1466
+
1467
+ if (!result) return;
1468
+
1469
+ if (result.stashSpec) {
1470
+ showRestoreStashCommand(pi, ctx, result.targetPath, result.stashSpec);
1471
+ }
1472
+
1473
+ showManualOpenCommand(pi, ctx, buildManualOpenCommand(result.targetPath));
1474
+ }
1475
+
1476
+ async function handleSwitch(
1477
+ pi: ExtensionAPI,
1478
+ ctx: ExtensionCommandContext,
1479
+ args: string,
1480
+ ): Promise<void> {
1481
+ const parsed = await parseWorktreeTargetArgs(
1482
+ pi,
1483
+ ctx,
1484
+ args,
1485
+ "Usage: /worktree switch <branch> [--from <ref>]",
1486
+ );
1487
+ if (!parsed) return;
1488
+
1489
+ const result = await withSpinnerStatus(ctx, `preparing worktree: ${parsed.branch}`, async () => {
1490
+ const repo = await getRepoInfo(pi, ctx.cwd);
1491
+ await pruneWorktrees(pi, repo.mainRoot);
1492
+ const worktrees = await listWorktrees(pi, repo.mainRoot);
1493
+ const existing = worktreeForBranch(worktrees, parsed.branch);
1494
+ const targetPath =
1495
+ existing?.path ??
1496
+ (await createWorktree(pi, ctx, repo, worktrees, parsed.branch, parsed.fromRef));
1497
+ if (!targetPath) return null;
1498
+ return { targetPath, currentRoot: repo.currentRoot } satisfies {
1499
+ targetPath: string;
1500
+ currentRoot: string;
1501
+ };
1502
+ });
1503
+
1504
+ if (!result) return;
1505
+ await switchToWorktree(pi, ctx, result.targetPath, result.currentRoot);
1506
+ }
1507
+
1508
+ async function archiveWorktree(
1509
+ pi: ExtensionAPI,
1510
+ ctx: ExtensionCommandContext,
1511
+ repo: RepoInfo,
1512
+ branch: string,
1513
+ dirtyAction: DirtyAction,
1514
+ defaultMain: string,
1515
+ worktree?: WorktreeInfo,
1516
+ ): Promise<ArchiveOutcome> {
1517
+ const wt = worktree ?? worktreeForBranch(await listWorktrees(pi, repo.mainRoot), branch);
1518
+ if (!wt) {
1519
+ return {
1520
+ branch,
1521
+ worktreePath: "",
1522
+ removed: false,
1523
+ branchDeleted: false,
1524
+ skippedReason: "no-worktree",
1525
+ };
1526
+ }
1527
+
1528
+ if (wt.path === repo.mainRoot) {
1529
+ return {
1530
+ branch,
1531
+ worktreePath: wt.path,
1532
+ removed: false,
1533
+ branchDeleted: false,
1534
+ skippedReason: "main-worktree",
1535
+ };
1536
+ }
1537
+
1538
+ if (wt.locked) {
1539
+ const reason = wt.lockedReason ? `locked: ${wt.lockedReason}` : "locked";
1540
+ return {
1541
+ branch,
1542
+ worktreePath: wt.path,
1543
+ removed: false,
1544
+ branchDeleted: false,
1545
+ skippedReason: reason,
1546
+ };
1547
+ }
1548
+
1549
+ if (isSameOrInsidePath(ctx.cwd, wt.path)) {
1550
+ return {
1551
+ branch,
1552
+ worktreePath: wt.path,
1553
+ removed: false,
1554
+ branchDeleted: false,
1555
+ skippedReason: "current-cwd",
1556
+ };
1557
+ }
1558
+
1559
+ const dirty = await isDirty(pi, wt.path);
1560
+ let force = false;
1561
+
1562
+ if (dirty) {
1563
+ let effectiveAction: DirtyAction = dirtyAction;
1564
+
1565
+ if (dirtyAction === "prompt") {
1566
+ if (!ctx.hasUI) {
1567
+ throw new Error(`Cannot archive dirty worktree without UI: ${wt.path}`);
1568
+ }
1569
+
1570
+ const choice = await withPromptSignal(pi, () =>
1571
+ ctx.ui.select(`Worktree has uncommitted changes: ${wt.path}`, [
1572
+ "Stash changes (including untracked) and archive",
1573
+ "Force remove (lose changes)",
1574
+ "Cancel",
1575
+ ]),
1576
+ );
1577
+
1578
+ if (!choice || choice === "Cancel") {
1579
+ return {
1580
+ branch,
1581
+ worktreePath: wt.path,
1582
+ removed: false,
1583
+ branchDeleted: false,
1584
+ skippedReason: "cancelled",
1585
+ };
1586
+ }
1587
+
1588
+ effectiveAction = choice.startsWith("Stash") ? "stash" : "force";
1589
+ }
1590
+
1591
+ if (effectiveAction === "skip") {
1592
+ return {
1593
+ branch,
1594
+ worktreePath: wt.path,
1595
+ removed: false,
1596
+ branchDeleted: false,
1597
+ skippedReason: "dirty",
1598
+ };
1599
+ }
1600
+
1601
+ if (effectiveAction === "stash") {
1602
+ await stashAll(pi, wt.path, `worktree: stash before archiving ${branch}`);
1603
+ }
1604
+
1605
+ if (effectiveAction === "force") {
1606
+ force = true;
1607
+ }
1608
+ }
1609
+
1610
+ await runProjectScripts(pi, ctx, wt.path, "archive", inferArchiveActions(wt.path));
1611
+
1612
+ const removeArgs = force
1613
+ ? ["worktree", "remove", "--force", wt.path]
1614
+ : ["worktree", "remove", wt.path];
1615
+ const remove = await git(pi, repo.mainRoot, removeArgs);
1616
+ if (remove.code !== 0) {
1617
+ const details = [remove.stdout.trim(), remove.stderr.trim()].filter(Boolean).join("\n");
1618
+ throw new Error(`Failed to remove worktree ${wt.path}${details ? `\n${details}` : ""}`);
1619
+ }
1620
+
1621
+ const upstream = await getUpstream(pi, repo.mainRoot, branch);
1622
+ let branchDeleted = false;
1623
+
1624
+ // Never delete the default branch automatically.
1625
+ if (branch === defaultMain) {
1626
+ return {
1627
+ branch,
1628
+ worktreePath: wt.path,
1629
+ removed: true,
1630
+ branchDeleted: false,
1631
+ };
1632
+ }
1633
+
1634
+ if (upstream) {
1635
+ const aheadBehind = await getAheadBehind(pi, repo.mainRoot, branch, upstream);
1636
+
1637
+ if (aheadBehind && aheadBehind.ahead > 0) {
1638
+ const ahead = aheadBehind.ahead;
1639
+
1640
+ // Branch has commits not on upstream (not fully pushed)
1641
+ if (ctx.hasUI) {
1642
+ const ok = await withPromptSignal(pi, () =>
1643
+ ctx.ui.confirm(
1644
+ "Delete local branch?",
1645
+ `Branch ${branch} is ahead of ${upstream} by ${ahead} commit(s). Delete it anyway?`,
1646
+ ),
1647
+ );
1648
+ if (ok) {
1649
+ const del = await git(pi, repo.mainRoot, ["branch", "-D", branch]);
1650
+ branchDeleted = del.code === 0;
1651
+ }
1652
+ }
1653
+ } else if (!aheadBehind) {
1654
+ // Couldn't determine ahead/behind; be conservative.
1655
+ if (ctx.hasUI) {
1656
+ const ok = await withPromptSignal(pi, () =>
1657
+ ctx.ui.confirm(
1658
+ "Delete local branch?",
1659
+ `Branch ${branch} has an upstream (${upstream}), but I couldn't determine if it's fully pushed. Delete it anyway?`,
1660
+ ),
1661
+ );
1662
+ if (ok) {
1663
+ const del = await git(pi, repo.mainRoot, ["branch", "-D", branch]);
1664
+ branchDeleted = del.code === 0;
1665
+ }
1666
+ }
1667
+ } else {
1668
+ // Fully pushed (or behind) - safe to delete locally.
1669
+ // Use -d (not -D) as an extra safety net (refuses if the branch isn't merged into the current HEAD).
1670
+ const del = await git(pi, repo.mainRoot, ["branch", "-d", branch]);
1671
+ branchDeleted = del.code === 0;
1672
+
1673
+ if (!branchDeleted && ctx.hasUI) {
1674
+ const details = [del.stdout.trim(), del.stderr.trim()].filter(Boolean).join("\n");
1675
+ const ok = await withPromptSignal(pi, () =>
1676
+ ctx.ui.confirm(
1677
+ "Force delete local branch?",
1678
+ `git branch -d ${branch} failed.${details ? `\n\n${details}` : ""}\n\nThis usually means the branch isn't merged into the main worktree's current branch.\n\nThe branch appears fully pushed to ${upstream}. Force delete it with -D?`,
1679
+ ),
1680
+ );
1681
+ if (ok) {
1682
+ const forceDel = await git(pi, repo.mainRoot, ["branch", "-D", branch]);
1683
+ branchDeleted = forceDel.code === 0;
1684
+ if (!branchDeleted) {
1685
+ const forceDetails = [forceDel.stdout.trim(), forceDel.stderr.trim()]
1686
+ .filter(Boolean)
1687
+ .join("\n");
1688
+ const firstLine = (forceDetails || "unknown error").split("\n")[0] || "unknown error";
1689
+ ctx.ui.notify(`Failed to delete branch ${branch}: ${firstLine}`, "error");
1690
+ }
1691
+ }
1692
+ }
1693
+ }
1694
+ } else {
1695
+ if (ctx.hasUI) {
1696
+ const ok = await withPromptSignal(pi, () =>
1697
+ ctx.ui.confirm(
1698
+ "Delete local branch?",
1699
+ `Branch ${branch} has no upstream. Delete it anyway?`,
1700
+ ),
1701
+ );
1702
+ if (ok) {
1703
+ const del = await git(pi, repo.mainRoot, ["branch", "-D", branch]);
1704
+ branchDeleted = del.code === 0;
1705
+ }
1706
+ }
1707
+ }
1708
+
1709
+ return {
1710
+ branch,
1711
+ worktreePath: wt.path,
1712
+ removed: true,
1713
+ branchDeleted,
1714
+ };
1715
+ }
1716
+
1717
+ async function handleArchive(
1718
+ pi: ExtensionAPI,
1719
+ ctx: ExtensionCommandContext,
1720
+ args: string,
1721
+ ): Promise<void> {
1722
+ const tokens = tokenizeArgs(args);
1723
+ let branch = tokens[0];
1724
+ if (!branch || branch.startsWith("-")) {
1725
+ if (!ctx.hasUI) return;
1726
+ const input = await withPromptSignal(pi, () => ctx.ui.input("Branch name"));
1727
+ if (!input) return;
1728
+ branch = input.trim();
1729
+ }
1730
+
1731
+ branch = stripRefsHeadsPrefix(branch);
1732
+ if (!branch || branch.startsWith("-")) {
1733
+ if (ctx.hasUI) ctx.ui.notify("Invalid branch name", "warning");
1734
+ return;
1735
+ }
1736
+
1737
+ await withSpinnerStatus(ctx, `archiving ${branch}`, async () => {
1738
+ const repo = await getRepoInfo(pi, ctx.cwd);
1739
+ const defaultMain = await getDefaultMainBranch(pi, repo.mainRoot);
1740
+
1741
+ await pruneWorktrees(pi, repo.mainRoot);
1742
+ const outcome = await archiveWorktree(pi, ctx, repo, branch, "prompt", defaultMain);
1743
+
1744
+ if (!ctx.hasUI) return;
1745
+
1746
+ if (outcome.removed) {
1747
+ ctx.ui.notify(
1748
+ `Archived ${branch} (${outcome.branchDeleted ? "branch deleted" : "branch kept"})`,
1749
+ "info",
1750
+ );
1751
+ return;
1752
+ }
1753
+
1754
+ if (outcome.skippedReason === "cancelled") {
1755
+ ctx.ui.notify("Cancelled", "warning");
1756
+ return;
1757
+ }
1758
+
1759
+ if (outcome.skippedReason === "no-worktree") {
1760
+ ctx.ui.notify(`No worktree found for branch: ${branch}`, "warning");
1761
+ return;
1762
+ }
1763
+
1764
+ if (outcome.skippedReason === "main-worktree") {
1765
+ ctx.ui.notify(
1766
+ `Branch ${branch} is checked out in the main worktree; can't archive it. Use /worktree new ${branch} first.`,
1767
+ "warning",
1768
+ );
1769
+ return;
1770
+ }
1771
+
1772
+ if (outcome.skippedReason === "current-cwd") {
1773
+ ctx.ui.notify(
1774
+ `Can't archive the worktree you're currently in. cd elsewhere and retry: ${outcome.worktreePath}`,
1775
+ "warning",
1776
+ );
1777
+ return;
1778
+ }
1779
+
1780
+ ctx.ui.notify(`Skipped ${branch} (${outcome.skippedReason ?? "unknown"})`, "warning");
1781
+ });
1782
+ }
1783
+
1784
+ async function handleClean(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
1785
+ if (!(await ensureCanPrompt(ctx, "Cannot clean without UI"))) return;
1786
+
1787
+ await withSpinnerStatus(ctx, "cleaning pushed worktrees", async (setStatusText) => {
1788
+ const repo = await getRepoInfo(pi, ctx.cwd);
1789
+ const defaultMain = await getDefaultMainBranch(pi, repo.mainRoot);
1790
+
1791
+ // Ensure upstream info is up to date before deciding what's "pushed".
1792
+ const remotes = await git(pi, repo.mainRoot, ["remote"]);
1793
+ if (remotes.code === 0 && remotes.stdout.trim().length > 0) {
1794
+ setStatusText("fetching remotes...");
1795
+ const fetch = await git(pi, repo.mainRoot, ["fetch", "--all", "--prune"], {
1796
+ timeout: FETCH_TIMEOUT_MS,
1797
+ });
1798
+ setStatusText("cleaning pushed worktrees");
1799
+
1800
+ if (fetch.killed || fetch.code !== 0) {
1801
+ const details = [fetch.stdout.trim(), fetch.stderr.trim()].filter(Boolean).join("\n");
1802
+ throw new Error(
1803
+ `git fetch failed${
1804
+ fetch.killed ? ` (timed out after ${Math.round(FETCH_TIMEOUT_MS / 1000)}s)` : ""
1805
+ }${details ? `\n${details}` : ""}`,
1806
+ );
1807
+ }
1808
+ }
1809
+
1810
+ await pruneWorktrees(pi, repo.mainRoot);
1811
+ const worktrees = await listWorktrees(pi, repo.mainRoot);
1812
+
1813
+ const candidates: Array<{ branch: string; worktree: WorktreeInfo; dirty: boolean }> = [];
1814
+ const locked: ArchiveOutcome[] = [];
1815
+
1816
+ for (const wt of worktrees) {
1817
+ if (!wt.branchRef || wt.detached) continue;
1818
+ if (wt.path === repo.mainRoot) continue;
1819
+ if (isSameOrInsidePath(ctx.cwd, wt.path)) continue;
1820
+
1821
+ const branch = branchNameFromRef(wt.branchRef);
1822
+ const upstream = await getUpstream(pi, repo.mainRoot, branch);
1823
+ if (!upstream) continue;
1824
+
1825
+ if (wt.locked) {
1826
+ locked.push({
1827
+ branch,
1828
+ worktreePath: wt.path,
1829
+ removed: false,
1830
+ branchDeleted: false,
1831
+ skippedReason: wt.lockedReason ? `locked: ${wt.lockedReason}` : "locked",
1832
+ });
1833
+ continue;
1834
+ }
1835
+
1836
+ candidates.push({ branch, worktree: wt, dirty: await isDirty(pi, wt.path) });
1837
+ }
1838
+
1839
+ if (candidates.length === 0) {
1840
+ if (locked.length === 0) {
1841
+ ctx.ui.notify("No pushed worktrees to archive", "info");
1842
+ return;
1843
+ }
1844
+
1845
+ const lockedList = locked
1846
+ .map((o) => `${o.branch} (${o.skippedReason ?? "unknown"})`)
1847
+ .join(", ");
1848
+ ctx.ui.notify(`No removable pushed worktrees. Locked: ${lockedList}`, "warning");
1849
+ return;
1850
+ }
1851
+
1852
+ let dirtyAction: DirtyAction = "skip";
1853
+ const dirtyCount = candidates.filter((c) => c.dirty).length;
1854
+
1855
+ if (dirtyCount > 0) {
1856
+ const choice = await withPromptSignal(pi, () =>
1857
+ ctx.ui.select(
1858
+ `Found ${candidates.length} pushed worktree(s): ${candidates.length - dirtyCount} clean, ${dirtyCount} dirty`,
1859
+ [
1860
+ "Archive clean only (skip dirty)",
1861
+ "Stash dirty (including untracked) and archive all",
1862
+ "Force remove dirty and archive all (lose changes)",
1863
+ "Cancel",
1864
+ ],
1865
+ ),
1866
+ );
1867
+
1868
+ if (!choice || choice === "Cancel") {
1869
+ ctx.ui.notify("Cancelled", "warning");
1870
+ return;
1871
+ }
1872
+
1873
+ dirtyAction = choice.startsWith("Stash")
1874
+ ? "stash"
1875
+ : choice.startsWith("Force")
1876
+ ? "force"
1877
+ : "skip";
1878
+ } else {
1879
+ const ok = await withPromptSignal(pi, () =>
1880
+ ctx.ui.confirm(
1881
+ "Archive pushed worktrees?",
1882
+ `Archive ${candidates.length} pushed worktree(s)?`,
1883
+ ),
1884
+ );
1885
+ if (!ok) {
1886
+ ctx.ui.notify("Cancelled", "warning");
1887
+ return;
1888
+ }
1889
+ dirtyAction = "stash";
1890
+ }
1891
+
1892
+ const archived: ArchiveOutcome[] = [];
1893
+ const skipped: ArchiveOutcome[] = [...locked];
1894
+
1895
+ for (const c of candidates) {
1896
+ const outcome = await archiveWorktree(
1897
+ pi,
1898
+ ctx,
1899
+ repo,
1900
+ c.branch,
1901
+ dirtyAction,
1902
+ defaultMain,
1903
+ c.worktree,
1904
+ );
1905
+ if (outcome.removed) archived.push(outcome);
1906
+ else skipped.push(outcome);
1907
+ }
1908
+
1909
+ if (ctx.hasUI) {
1910
+ const parts = [`Archived ${archived.length} worktree(s)`];
1911
+ if (skipped.length > 0) {
1912
+ const skippedList = skipped
1913
+ .map((o) => `${o.branch} (${o.skippedReason ?? "unknown"})`)
1914
+ .join(", ");
1915
+ parts.push(`Skipped ${skipped.length}: ${skippedList}`);
1916
+ }
1917
+ ctx.ui.notify(parts.join(". "), archived.length > 0 ? "info" : "warning");
1918
+ }
1919
+ });
1920
+ }
1921
+
1922
+ interface WorktreeDisplayItem {
1923
+ wt: WorktreeInfo;
1924
+ branch: string;
1925
+ isCurrent: boolean;
1926
+ isMain: boolean;
1927
+ status: DirtyState;
1928
+ pathState: WorktreePathState;
1929
+ tracked: boolean;
1930
+ }
1931
+
1932
+ async function gatherWorktreeDisplayItems(
1933
+ pi: ExtensionAPI,
1934
+ repo: RepoInfo,
1935
+ worktrees: WorktreeInfo[],
1936
+ ): Promise<WorktreeDisplayItem[]> {
1937
+ const currentReal = realpathOrResolve(repo.currentRoot);
1938
+ const mainReal = realpathOrResolve(repo.mainRoot);
1939
+ const items: WorktreeDisplayItem[] = [];
1940
+
1941
+ for (const wt of worktrees) {
1942
+ const wtReal = realpathOrResolve(wt.path);
1943
+ const isCurrent = wtReal === currentReal;
1944
+ const isMain = wtReal === mainReal;
1945
+
1946
+ let branch = "unknown";
1947
+ let tracked = false;
1948
+ if (wt.branchRef) {
1949
+ branch = branchNameFromRef(wt.branchRef);
1950
+ tracked = (await getUpstream(pi, repo.mainRoot, branch)) !== null;
1951
+ } else if (wt.detached) {
1952
+ branch = wt.head ? `detached@${wt.head.slice(0, 7)}` : "detached";
1953
+ }
1954
+
1955
+ const pathState = getWorktreePathState(wt.path);
1956
+ const status = pathState === "ok" ? await getDirtyState(pi, wt.path) : "unknown";
1957
+
1958
+ items.push({ wt, branch, isCurrent, isMain, status, pathState, tracked });
1959
+ }
1960
+
1961
+ return items;
1962
+ }
1963
+
1964
+ function collapsePath(p: string): string {
1965
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
1966
+ if (home && p.startsWith(home)) return "~" + p.slice(home.length);
1967
+ return p;
1968
+ }
1969
+
1970
+ function formatWorktreeLabel(item: WorktreeDisplayItem, theme: Theme): string {
1971
+ const parts: string[] = [];
1972
+
1973
+ // Branch name: green + bold for current, plain for others (like git branch).
1974
+ // Dirty marker after name (git-style): branch* for dirty, branch for clean.
1975
+ const dirty = item.status === "dirty" ? " *" : "";
1976
+ const name = `${item.branch}${dirty}`;
1977
+ parts.push(item.isCurrent ? theme.fg("success", theme.bold(name)) : name);
1978
+
1979
+ // Tracking.
1980
+ if (item.tracked) {
1981
+ parts.push(theme.fg("dim", "↑"));
1982
+ }
1983
+
1984
+ // Tags.
1985
+ if (item.isMain) {
1986
+ parts.push(theme.fg("muted", "[primary]"));
1987
+ }
1988
+ if (item.wt.locked) {
1989
+ parts.push(
1990
+ theme.fg("error", item.wt.lockedReason ? `locked: ${item.wt.lockedReason}` : "locked"),
1991
+ );
1992
+ }
1993
+ if (item.pathState !== "ok") {
1994
+ parts.push(theme.fg("error", item.pathState));
1995
+ }
1996
+
1997
+ // Path last — truncates naturally when terminal is narrow.
1998
+ parts.push(theme.fg("dim", collapsePath(item.wt.path)));
1999
+
2000
+ return parts.join(" ");
2001
+ }
2002
+
2003
+ async function handleList(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
2004
+ const { repo, items } = await withSpinnerStatus(ctx, "listing worktrees", async () => {
2005
+ const repo = await getRepoInfo(pi, ctx.cwd);
2006
+ const worktrees = await listWorktrees(pi, repo.mainRoot);
2007
+ const items = await gatherWorktreeDisplayItems(pi, repo, worktrees);
2008
+ return { repo, items };
2009
+ });
2010
+
2011
+ if (!ctx.hasUI) {
2012
+ for (const item of items) {
2013
+ const dirty = item.status === "dirty" ? " *" : "";
2014
+ const meta: string[] = [];
2015
+ if (item.tracked) meta.push("↑");
2016
+ if (item.isMain) meta.push("[primary]");
2017
+ if (item.pathState !== "ok") meta.push(item.pathState);
2018
+ if (item.wt.locked)
2019
+ meta.push(item.wt.lockedReason ? `locked:${item.wt.lockedReason}` : "locked");
2020
+ const suffix = meta.length > 0 ? ` ${meta.join(" ")}` : "";
2021
+ console.log(` ${item.branch}${dirty}${suffix} ${collapsePath(item.wt.path)}`);
2022
+ }
2023
+ return;
2024
+ }
2025
+
2026
+ const theme = ctx.ui.theme;
2027
+
2028
+ const selectItems: SelectItem[] = items.map((item) => ({
2029
+ value: item.branch,
2030
+ label: formatWorktreeLabel(item, theme),
2031
+ }));
2032
+
2033
+ const itemByValue = new Map(items.map((item) => [item.branch, item]));
2034
+
2035
+ type ListResult = { action: "switch" | "archive"; item: WorktreeDisplayItem } | null;
2036
+
2037
+ const result = await withPromptSignal(pi, () =>
2038
+ ctx.ui.custom<ListResult>((tui, theme, _kb, done) => {
2039
+ const container = new Container();
2040
+ container.addChild(new DynamicBorder((s: string) => theme.fg("borderMuted", s)));
2041
+
2042
+ const selectList = new SelectList(selectItems, Math.min(selectItems.length, 15), {
2043
+ selectedPrefix: (t) => theme.fg("accent", t),
2044
+ selectedText: (t) => theme.fg("accent", t),
2045
+ description: (t) => t,
2046
+ scrollInfo: (t) => theme.fg("dim", t),
2047
+ noMatch: (t) => theme.fg("warning", t),
2048
+ });
2049
+ selectList.onSelect = (si) => {
2050
+ const item = itemByValue.get(si.value);
2051
+ if (item) done({ action: "switch", item });
2052
+ else done(null);
2053
+ };
2054
+ selectList.onCancel = () => done(null);
2055
+ container.addChild(selectList);
2056
+
2057
+ container.addChild(
2058
+ new Text(theme.fg("dim", " ↑↓ navigate enter switch a archive esc close"), 0, 0),
2059
+ );
2060
+ container.addChild(new DynamicBorder((s: string) => theme.fg("borderMuted", s)));
2061
+
2062
+ return {
2063
+ render: (w) => container.render(w),
2064
+ invalidate: () => container.invalidate(),
2065
+ handleInput: (data) => {
2066
+ if (matchesKey(data, "a")) {
2067
+ const si = selectList.getSelectedItem();
2068
+ if (si) {
2069
+ const item = itemByValue.get(si.value);
2070
+ if (item) {
2071
+ done({ action: "archive", item });
2072
+ return;
2073
+ }
2074
+ }
2075
+ }
2076
+ selectList.handleInput(data);
2077
+ tui.requestRender();
2078
+ },
2079
+ };
2080
+ }),
2081
+ );
2082
+
2083
+ if (!result) return;
2084
+
2085
+ if (result.action === "switch") {
2086
+ await switchToWorktree(pi, ctx, result.item.wt.path, repo.currentRoot);
2087
+ return;
2088
+ }
2089
+
2090
+ if (result.action === "archive") {
2091
+ await withSpinnerStatus(ctx, `archiving ${result.item.branch}`, async () => {
2092
+ const defaultMain = await getDefaultMainBranch(pi, repo.mainRoot);
2093
+ await archiveWorktree(
2094
+ pi,
2095
+ ctx,
2096
+ repo,
2097
+ result.item.branch,
2098
+ "prompt",
2099
+ defaultMain,
2100
+ result.item.wt,
2101
+ );
2102
+ });
2103
+ }
2104
+ }
2105
+
2106
+ function parseSubcommand(args: string): { subcommand: Subcommand | null; rest: string } {
2107
+ const tokens = tokenizeArgs(args);
2108
+ const sub = tokens[0] as Subcommand | undefined;
2109
+ if (sub === "new" || sub === "switch" || sub === "archive" || sub === "clean" || sub === "list") {
2110
+ return { subcommand: sub, rest: tokens.slice(1).join(" ") };
2111
+ }
2112
+ return { subcommand: null, rest: args };
2113
+ }
2114
+
2115
+ export default function worktreeExtension(pi: ExtensionAPI) {
2116
+ for (const customType of [
2117
+ MANUAL_OPEN_MESSAGE_TYPE,
2118
+ RESTORE_STASH_MESSAGE_TYPE,
2119
+ SCRIPT_RERUN_MESSAGE_TYPE,
2120
+ ]) {
2121
+ pi.registerMessageRenderer(customType, (message, _options, theme) => {
2122
+ const details = message.details as CommandMessageDetails | undefined;
2123
+ if (!details || typeof details.intro !== "string" || typeof details.command !== "string") {
2124
+ return new Text(typeof message.content === "string" ? message.content : "", 0, 0);
2125
+ }
2126
+
2127
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
2128
+ box.addChild(
2129
+ new Text(
2130
+ [
2131
+ theme.fg(
2132
+ "customMessageText",
2133
+ formatCommandMessageIntro(details.intro, details.copiedToClipboard),
2134
+ ),
2135
+ "",
2136
+ highlightCode(details.command, "bash").join("\n"),
2137
+ ].join("\n"),
2138
+ 0,
2139
+ 0,
2140
+ ),
2141
+ );
2142
+ return box;
2143
+ });
2144
+ }
2145
+
2146
+ pi.registerCommand("worktree", {
2147
+ description: "Create, switch, and manage git worktrees",
2148
+ getArgumentCompletions: (argumentPrefix) => {
2149
+ const prefix = argumentPrefix ?? "";
2150
+ const tokens = tokenizeArgs(prefix);
2151
+ if (tokens.length === 0) {
2152
+ return [
2153
+ { value: "new ", label: "new" },
2154
+ { value: "switch ", label: "switch" },
2155
+ { value: "archive ", label: "archive" },
2156
+ { value: "clean", label: "clean" },
2157
+ { value: "list", label: "list" },
2158
+ ];
2159
+ }
2160
+
2161
+ if (tokens.length === 1 && !prefix.endsWith(" ")) {
2162
+ const subcommands = ["new", "switch", "archive", "clean", "list"];
2163
+ return subcommands
2164
+ .filter((s) => s.startsWith(tokens[0]))
2165
+ .map((s) => ({
2166
+ value: s + (s === "new" || s === "switch" || s === "archive" ? " " : ""),
2167
+ label: s,
2168
+ }));
2169
+ }
2170
+
2171
+ // Keep it minimal and fast (no git calls here; completions must be sync)
2172
+ return null;
2173
+ },
2174
+ handler: async (args, ctx) => {
2175
+ const { subcommand, rest } = parseSubcommand(args);
2176
+ if (!subcommand) {
2177
+ if (ctx.hasUI) {
2178
+ ctx.ui.notify(
2179
+ "Usage: /worktree new <branch> [--from <ref>] | /worktree switch <branch> [--from <ref>] | /worktree archive <branch> | /worktree clean | /worktree list",
2180
+ "info",
2181
+ );
2182
+ }
2183
+ return;
2184
+ }
2185
+
2186
+ if (!ctx.isIdle()) {
2187
+ if (ctx.hasUI) ctx.ui.notify("Queued /worktree", "info");
2188
+ await ctx.waitForIdle();
2189
+ }
2190
+
2191
+ try {
2192
+ if (subcommand === "new") {
2193
+ await handleNew(pi, ctx, rest);
2194
+ return;
2195
+ }
2196
+
2197
+ if (subcommand === "switch") {
2198
+ await handleSwitch(pi, ctx, rest);
2199
+ return;
2200
+ }
2201
+
2202
+ if (subcommand === "archive") {
2203
+ await handleArchive(pi, ctx, rest);
2204
+ return;
2205
+ }
2206
+
2207
+ if (subcommand === "clean") {
2208
+ await handleClean(pi, ctx);
2209
+ return;
2210
+ }
2211
+
2212
+ if (subcommand === "list") {
2213
+ await handleList(pi, ctx);
2214
+ return;
2215
+ }
2216
+ } catch (err) {
2217
+ const message = err instanceof Error ? err.message : String(err);
2218
+ if (ctx.hasUI) ctx.ui.notify(message, "error");
2219
+ else throw err;
2220
+ }
2221
+ },
2222
+ });
2223
+ }