the-campfire 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 (180) hide show
  1. package/bin/cli.ts +182 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
  4. package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
  5. package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
  6. package/dist/assets/arc-D9SEA8dX.js +1 -0
  7. package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
  8. package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
  9. package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
  10. package/dist/assets/channel-CxhcHn7S.js +1 -0
  11. package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
  12. package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
  13. package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
  14. package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
  15. package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
  16. package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
  17. package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
  18. package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
  19. package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
  20. package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
  21. package/dist/assets/clone-C6kt1rE7.js +1 -0
  22. package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
  23. package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  24. package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
  25. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  26. package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
  27. package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
  28. package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
  29. package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
  30. package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
  31. package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
  32. package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
  33. package/dist/assets/graph-DKRbrxcs.js +1 -0
  34. package/dist/assets/index-Byjg9zgI.css +32 -0
  35. package/dist/assets/index-Cwj3m0hT.js +284 -0
  36. package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
  37. package/dist/assets/init-Gi6I4Gst.js +1 -0
  38. package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
  39. package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
  40. package/dist/assets/katex-BlHpptmG.js +261 -0
  41. package/dist/assets/layout-DnxSwLDJ.js +1 -0
  42. package/dist/assets/linear-CWbbIT8l.js +1 -0
  43. package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
  44. package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
  45. package/dist/assets/ordinal-BENe2yWM.js +1 -0
  46. package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
  47. package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
  48. package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
  49. package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
  50. package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
  51. package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
  52. package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
  53. package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
  54. package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
  55. package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
  56. package/dist/favicon.svg +3 -0
  57. package/dist/fonts/Geist-Variable.woff2 +0 -0
  58. package/dist/fonts/GeistMono-Variable.woff2 +0 -0
  59. package/dist/icon-192.png +0 -0
  60. package/dist/icon-512.png +0 -0
  61. package/dist/index.html +33 -0
  62. package/dist/logo-codex.svg +14 -0
  63. package/dist/logo.svg +3 -0
  64. package/dist/manifest.json +26 -0
  65. package/dist/og-image.png +0 -0
  66. package/dist/sw.js +130 -0
  67. package/package.json +92 -0
  68. package/server/ADAPTERS.md +121 -0
  69. package/server/adapter-registry-types.ts +28 -0
  70. package/server/adapter-registry.ts +293 -0
  71. package/server/adapter-types.ts +51 -0
  72. package/server/agent-executor.ts +249 -0
  73. package/server/agent-mcp-bridge.ts +148 -0
  74. package/server/agent-mcp-stdio.ts +171 -0
  75. package/server/agent-mcp-tools.ts +76 -0
  76. package/server/agent-store.ts +183 -0
  77. package/server/agent-types.ts +79 -0
  78. package/server/aider-adapter.ts +525 -0
  79. package/server/auth.ts +153 -0
  80. package/server/auto-namer.ts +132 -0
  81. package/server/capability-discovery.ts +431 -0
  82. package/server/claude-container-auth.ts +40 -0
  83. package/server/claude-stdio-adapter.ts +452 -0
  84. package/server/clawhub-export.ts +218 -0
  85. package/server/cli-launcher.ts +1613 -0
  86. package/server/codex-adapter.ts +1987 -0
  87. package/server/codex-container-auth.ts +40 -0
  88. package/server/codex-home.ts +26 -0
  89. package/server/collective-intelligence.ts +401 -0
  90. package/server/commands-discovery.ts +160 -0
  91. package/server/constants.ts +5 -0
  92. package/server/container-manager.ts +328 -0
  93. package/server/cron-scheduler.ts +243 -0
  94. package/server/cron-store.ts +149 -0
  95. package/server/cron-types.ts +63 -0
  96. package/server/deliberation-engine.ts +323 -0
  97. package/server/embedding.ts +89 -0
  98. package/server/env-manager.ts +146 -0
  99. package/server/environment-detector.ts +106 -0
  100. package/server/environment-rules.ts +106 -0
  101. package/server/gallery-store.ts +216 -0
  102. package/server/gallery-types.ts +52 -0
  103. package/server/gallery-votes.ts +101 -0
  104. package/server/git-utils.ts +384 -0
  105. package/server/github-pr.ts +379 -0
  106. package/server/goose-adapter.ts +929 -0
  107. package/server/image-pull-manager.ts +144 -0
  108. package/server/index.ts +363 -0
  109. package/server/linear-project-manager.ts +78 -0
  110. package/server/moltbook-client.ts +160 -0
  111. package/server/openclaw-adapter.ts +916 -0
  112. package/server/openclaw-channel/channel.ts +188 -0
  113. package/server/openclaw-channel/index.ts +68 -0
  114. package/server/openclaw-channel/package.json +19 -0
  115. package/server/openclaw-channel/types.ts +93 -0
  116. package/server/opencode-adapter.ts +951 -0
  117. package/server/openhands-adapter.ts +775 -0
  118. package/server/orchestrator-executor.ts +184 -0
  119. package/server/orchestrator-store.ts +106 -0
  120. package/server/orchestrator-types.ts +86 -0
  121. package/server/path-resolver.ts +168 -0
  122. package/server/pr-poller.ts +162 -0
  123. package/server/proactive-keepalive.ts +121 -0
  124. package/server/prompt-manager.ts +103 -0
  125. package/server/protocol-monitor.ts +197 -0
  126. package/server/race-controller.ts +295 -0
  127. package/server/race-store.ts +82 -0
  128. package/server/recorder.ts +359 -0
  129. package/server/recording-hub/compat-validator.ts +122 -0
  130. package/server/recording-hub/diagnostics.ts +289 -0
  131. package/server/recording-hub/hub-routes.ts +146 -0
  132. package/server/recording-hub/hub-store.ts +224 -0
  133. package/server/replay.ts +78 -0
  134. package/server/routes/adapter-routes.ts +31 -0
  135. package/server/routes/agent-mcp-routes.ts +35 -0
  136. package/server/routes/agent-routes.ts +185 -0
  137. package/server/routes/auth-routes.ts +101 -0
  138. package/server/routes/ci-routes.ts +165 -0
  139. package/server/routes/commands-routes.ts +94 -0
  140. package/server/routes/cron-routes.ts +100 -0
  141. package/server/routes/env-routes.ts +50 -0
  142. package/server/routes/folder-routes.ts +56 -0
  143. package/server/routes/fs-routes.ts +403 -0
  144. package/server/routes/gallery-routes.ts +410 -0
  145. package/server/routes/git-routes.ts +108 -0
  146. package/server/routes/index.ts +62 -0
  147. package/server/routes/linear-routes.ts +254 -0
  148. package/server/routes/monitor-routes.ts +11 -0
  149. package/server/routes/orchestrator-routes.ts +210 -0
  150. package/server/routes/prompt-routes.ts +47 -0
  151. package/server/routes/race-routes.ts +103 -0
  152. package/server/routes/recording-routes.ts +101 -0
  153. package/server/routes/route-deps.ts +32 -0
  154. package/server/routes/session-routes.ts +560 -0
  155. package/server/routes/settings-routes.ts +108 -0
  156. package/server/routes/skills-routes.ts +52 -0
  157. package/server/routes/system-routes.ts +243 -0
  158. package/server/routes/webhook-routes.ts +135 -0
  159. package/server/routes.ts +44 -0
  160. package/server/security-middleware.ts +100 -0
  161. package/server/semantic-memory.ts +389 -0
  162. package/server/service.ts +733 -0
  163. package/server/session-folders.ts +107 -0
  164. package/server/session-git-info.ts +89 -0
  165. package/server/session-linear-issues.ts +94 -0
  166. package/server/session-names.ts +67 -0
  167. package/server/session-store.ts +169 -0
  168. package/server/session-types.ts +415 -0
  169. package/server/settings-manager.ts +120 -0
  170. package/server/shared-context.ts +401 -0
  171. package/server/skills-manager.ts +250 -0
  172. package/server/sub-session-manager.ts +263 -0
  173. package/server/terminal-manager.ts +185 -0
  174. package/server/update-checker.ts +114 -0
  175. package/server/usage-limits.ts +192 -0
  176. package/server/webhook-manager.ts +228 -0
  177. package/server/webhook-store.ts +168 -0
  178. package/server/webhook-types.ts +66 -0
  179. package/server/worktree-tracker.ts +84 -0
  180. package/server/ws-bridge.ts +2322 -0
@@ -0,0 +1,101 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { createHash } from "node:crypto";
5
+
6
+ // ─── Paths ──────────────────────────────────────────────────────────────────
7
+
8
+ const GALLERY_DIR = join(homedir(), ".campfire", "gallery");
9
+ const VOTES_FILE = join(GALLERY_DIR, "votes.json");
10
+
11
+ type VoteDirection = 1 | -1;
12
+ type VotesData = Record<string, Record<string, VoteDirection>>;
13
+
14
+ function ensureDir(): void {
15
+ mkdirSync(GALLERY_DIR, { recursive: true });
16
+ }
17
+
18
+ // ─── Load / Save ────────────────────────────────────────────────────────────
19
+
20
+ function loadVotes(): VotesData {
21
+ try {
22
+ if (!existsSync(VOTES_FILE)) return {};
23
+ const raw = readFileSync(VOTES_FILE, "utf-8");
24
+ return JSON.parse(raw);
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ function saveVotes(data: VotesData): void {
31
+ ensureDir();
32
+ writeFileSync(VOTES_FILE, JSON.stringify(data, null, 2), "utf-8");
33
+ }
34
+
35
+ // ─── Public API ─────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Hash an IP address for anonymous vote deduplication.
39
+ * Uses SHA-256 so raw IPs are never stored.
40
+ */
41
+ export function getVoterHash(ip: string): string {
42
+ return createHash("sha256").update(ip).digest("hex").slice(0, 16);
43
+ }
44
+
45
+ /**
46
+ * Record a vote (upvote=1, downvote=-1).
47
+ * If the voter already voted the same direction, the vote is removed (toggle).
48
+ * If the voter voted the opposite direction, the vote is flipped.
49
+ * Returns the new total vote count for the entry.
50
+ */
51
+ export function recordVote(
52
+ entryId: string,
53
+ voterId: string,
54
+ direction: VoteDirection,
55
+ ): number {
56
+ const data = loadVotes();
57
+ if (!data[entryId]) {
58
+ data[entryId] = {};
59
+ }
60
+
61
+ const existing = data[entryId][voterId];
62
+ if (existing === direction) {
63
+ // Toggle off: remove vote
64
+ delete data[entryId][voterId];
65
+ } else {
66
+ // New vote or flip direction
67
+ data[entryId][voterId] = direction;
68
+ }
69
+
70
+ saveVotes(data);
71
+ return getVoteCount(entryId, data);
72
+ }
73
+
74
+ /**
75
+ * Get the total vote count for an entry (sum of all +1/-1 votes).
76
+ */
77
+ export function getVoteCount(entryId: string, data?: VotesData): number {
78
+ const votes = data || loadVotes();
79
+ const entryVotes = votes[entryId];
80
+ if (!entryVotes) return 0;
81
+ return Object.values(entryVotes).reduce((sum, v) => sum + v, 0);
82
+ }
83
+
84
+ /**
85
+ * Check if a voter has already voted on an entry.
86
+ */
87
+ export function hasVoted(entryId: string, voterId: string): VoteDirection | null {
88
+ const data = loadVotes();
89
+ return data[entryId]?.[voterId] ?? null;
90
+ }
91
+
92
+ /**
93
+ * Remove all votes for a gallery entry (called on entry deletion).
94
+ */
95
+ export function removeEntryVotes(entryId: string): void {
96
+ const data = loadVotes();
97
+ if (data[entryId]) {
98
+ delete data[entryId];
99
+ saveVotes(data);
100
+ }
101
+ }
@@ -0,0 +1,384 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { join, basename } from "node:path";
4
+ import { homedir } from "node:os";
5
+
6
+ // ─── Types ──────────────────────────────────────────────────────────────────
7
+
8
+ export interface GitRepoInfo {
9
+ repoRoot: string;
10
+ repoName: string;
11
+ currentBranch: string;
12
+ defaultBranch: string;
13
+ isWorktree: boolean;
14
+ }
15
+
16
+ export interface GitBranchInfo {
17
+ name: string;
18
+ isCurrent: boolean;
19
+ isRemote: boolean;
20
+ worktreePath: string | null;
21
+ ahead: number;
22
+ behind: number;
23
+ }
24
+
25
+ export interface GitWorktreeInfo {
26
+ path: string;
27
+ branch: string;
28
+ head: string;
29
+ isMainWorktree: boolean;
30
+ isDirty: boolean;
31
+ }
32
+
33
+ export interface WorktreeCreateResult {
34
+ worktreePath: string;
35
+ /** The conceptual branch the user selected */
36
+ branch: string;
37
+ /** The actual git branch in the worktree (may be e.g. `main-wt-2` for duplicate sessions) */
38
+ actualBranch: string;
39
+ isNew: boolean;
40
+ }
41
+
42
+ // ─── Paths ──────────────────────────────────────────────────────────────────
43
+
44
+ const WORKTREES_BASE = join(homedir(), ".campfire", "worktrees");
45
+
46
+ function sanitizeBranch(branch: string): string {
47
+ return branch.replace(/\//g, "--");
48
+ }
49
+
50
+ function worktreeDir(repoName: string, branch: string): string {
51
+ return join(WORKTREES_BASE, repoName, sanitizeBranch(branch));
52
+ }
53
+
54
+ // ─── Helpers ────────────────────────────────────────────────────────────────
55
+
56
+ function git(cmd: string, cwd: string): string {
57
+ return execSync(`git ${cmd}`, {
58
+ cwd,
59
+ encoding: "utf-8",
60
+ timeout: 10_000,
61
+ stdio: ["pipe", "pipe", "pipe"],
62
+ }).trim();
63
+ }
64
+
65
+ function gitSafe(cmd: string, cwd: string): string | null {
66
+ try {
67
+ return git(cmd, cwd);
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ // ─── Functions ──────────────────────────────────────────────────────────────
74
+
75
+ export function getRepoInfo(cwd: string): GitRepoInfo | null {
76
+ const repoRoot = gitSafe("rev-parse --show-toplevel", cwd);
77
+ if (!repoRoot) return null;
78
+
79
+ const currentBranch = gitSafe("rev-parse --abbrev-ref HEAD", cwd) || "HEAD";
80
+ const gitDir = gitSafe("rev-parse --git-dir", cwd) || "";
81
+ // A linked worktree's .git dir is inside the main repo's .git/worktrees/
82
+ const isWorktree = gitDir.includes("/worktrees/");
83
+
84
+ const defaultBranch = resolveDefaultBranch(repoRoot);
85
+
86
+ return {
87
+ repoRoot,
88
+ repoName: basename(repoRoot),
89
+ currentBranch,
90
+ defaultBranch,
91
+ isWorktree,
92
+ };
93
+ }
94
+
95
+ function resolveDefaultBranch(repoRoot: string): string {
96
+ // Try origin HEAD
97
+ const originRef = gitSafe("symbolic-ref refs/remotes/origin/HEAD", repoRoot);
98
+ if (originRef) {
99
+ return originRef.replace("refs/remotes/origin/", "");
100
+ }
101
+ // Fallback: check if main or master exists
102
+ const branches = gitSafe("branch --list main master", repoRoot) || "";
103
+ if (branches.includes("main")) return "main";
104
+ if (branches.includes("master")) return "master";
105
+ // Last resort
106
+ return "main";
107
+ }
108
+
109
+ export function listBranches(repoRoot: string): GitBranchInfo[] {
110
+ // Get worktree mappings first
111
+ const worktrees = listWorktrees(repoRoot);
112
+ const worktreeByBranch = new Map<string, string>();
113
+ for (const wt of worktrees) {
114
+ if (wt.branch) worktreeByBranch.set(wt.branch, wt.path);
115
+ }
116
+
117
+ const result: GitBranchInfo[] = [];
118
+
119
+ // Local branches
120
+ const localRaw = gitSafe(
121
+ "for-each-ref '--format=%(refname:short)%09%(HEAD)' refs/heads/",
122
+ repoRoot,
123
+ );
124
+ if (localRaw) {
125
+ for (const line of localRaw.split("\n")) {
126
+ if (!line.trim()) continue;
127
+ const [name, head] = line.split("\t");
128
+ const isCurrent = head?.trim() === "*";
129
+ const { ahead, behind } = getBranchStatus(repoRoot, name);
130
+ result.push({
131
+ name,
132
+ isCurrent,
133
+ isRemote: false,
134
+ worktreePath: worktreeByBranch.get(name) || null,
135
+ ahead,
136
+ behind,
137
+ });
138
+ }
139
+ }
140
+
141
+ // Remote branches (only those without a local counterpart)
142
+ const localNames = new Set(result.map((b) => b.name));
143
+ const remoteRaw = gitSafe(
144
+ "for-each-ref '--format=%(refname:short)' refs/remotes/origin/",
145
+ repoRoot,
146
+ );
147
+ if (remoteRaw) {
148
+ for (const line of remoteRaw.split("\n")) {
149
+ const full = line.trim();
150
+ if (!full || full === "origin/HEAD") continue;
151
+ const name = full.replace("origin/", "");
152
+ if (localNames.has(name)) continue;
153
+ result.push({
154
+ name,
155
+ isCurrent: false,
156
+ isRemote: true,
157
+ worktreePath: null,
158
+ ahead: 0,
159
+ behind: 0,
160
+ });
161
+ }
162
+ }
163
+
164
+ return result;
165
+ }
166
+
167
+ export function listWorktrees(repoRoot: string): GitWorktreeInfo[] {
168
+ const raw = gitSafe("worktree list --porcelain", repoRoot);
169
+ if (!raw) return [];
170
+
171
+ const worktrees: GitWorktreeInfo[] = [];
172
+ let current: Partial<GitWorktreeInfo> = {};
173
+
174
+ for (const line of raw.split("\n")) {
175
+ if (line.startsWith("worktree ")) {
176
+ if (current.path) {
177
+ worktrees.push(current as GitWorktreeInfo);
178
+ }
179
+ current = { path: line.slice(9), isDirty: false, isMainWorktree: false };
180
+ } else if (line.startsWith("HEAD ")) {
181
+ current.head = line.slice(5);
182
+ } else if (line.startsWith("branch ")) {
183
+ current.branch = line.slice(7).replace("refs/heads/", "");
184
+ } else if (line === "bare") {
185
+ current.isMainWorktree = true;
186
+ } else if (line === "") {
187
+ // End of entry — check if main worktree (first one is always main)
188
+ if (worktrees.length === 0 && current.path) {
189
+ current.isMainWorktree = true;
190
+ }
191
+ }
192
+ }
193
+ // Push last entry
194
+ if (current.path) {
195
+ if (worktrees.length === 0) current.isMainWorktree = true;
196
+ worktrees.push(current as GitWorktreeInfo);
197
+ }
198
+
199
+ // Check dirty status for each worktree
200
+ for (const wt of worktrees) {
201
+ wt.isDirty = isWorktreeDirty(wt.path);
202
+ }
203
+
204
+ return worktrees;
205
+ }
206
+
207
+ export function ensureWorktree(
208
+ repoRoot: string,
209
+ branchName: string,
210
+ options?: { baseBranch?: string; createBranch?: boolean; forceNew?: boolean },
211
+ ): WorktreeCreateResult {
212
+ const repoName = basename(repoRoot);
213
+
214
+ // Check if a worktree already exists for this branch
215
+ const existing = listWorktrees(repoRoot);
216
+ const found = existing.find((wt) => wt.branch === branchName);
217
+
218
+ if (found && !options?.forceNew) {
219
+ // Don't reuse the main worktree — it's the original repo checkout
220
+ if (!found.isMainWorktree) {
221
+ return { worktreePath: found.path, branch: branchName, actualBranch: branchName, isNew: false };
222
+ }
223
+ }
224
+
225
+ // Find a unique path: append random 4-digit suffix if the base path is taken
226
+ const basePath = worktreeDir(repoName, branchName);
227
+ let targetPath = basePath;
228
+ for (let attempt = 0; attempt < 100 && existsSync(targetPath); attempt++) {
229
+ const suffix = Math.floor(1000 + Math.random() * 9000);
230
+ targetPath = `${basePath}-${suffix}`;
231
+ }
232
+ if (existsSync(targetPath)) {
233
+ targetPath = `${basePath}-${Date.now()}`;
234
+ }
235
+
236
+ // Ensure parent directory exists
237
+ mkdirSync(join(WORKTREES_BASE, repoName), { recursive: true });
238
+
239
+ // A worktree already exists for this branch — create a new uniquely-named
240
+ // branch so multiple sessions can work on the same branch independently.
241
+ if (found) {
242
+ const commitHash = git("rev-parse HEAD", found.path);
243
+ const uniqueBranch = generateUniqueWorktreeBranch(repoRoot, branchName);
244
+ git(`worktree add -b ${uniqueBranch} "${targetPath}" ${commitHash}`, repoRoot);
245
+ return { worktreePath: targetPath, branch: branchName, actualBranch: uniqueBranch, isNew: false };
246
+ }
247
+
248
+ // Check if branch already exists locally or on remote
249
+ const branchExists =
250
+ gitSafe(`rev-parse --verify refs/heads/${branchName}`, repoRoot) !== null;
251
+ const remoteBranchExists =
252
+ gitSafe(`rev-parse --verify refs/remotes/origin/${branchName}`, repoRoot) !== null;
253
+
254
+ if (branchExists) {
255
+ if (options?.forceNew) {
256
+ // Create a uniquely-named branch so multiple sessions can work independently
257
+ const commitHash = git(`rev-parse refs/heads/${branchName}`, repoRoot);
258
+ const uniqueBranch = generateUniqueWorktreeBranch(repoRoot, branchName);
259
+ git(`worktree add -b ${uniqueBranch} "${targetPath}" ${commitHash}`, repoRoot);
260
+ return { worktreePath: targetPath, branch: branchName, actualBranch: uniqueBranch, isNew: false };
261
+ }
262
+ // Worktree add with existing local branch
263
+ git(`worktree add "${targetPath}" ${branchName}`, repoRoot);
264
+ return { worktreePath: targetPath, branch: branchName, actualBranch: branchName, isNew: false };
265
+ }
266
+
267
+ if (remoteBranchExists) {
268
+ if (options?.forceNew) {
269
+ const uniqueBranch = generateUniqueWorktreeBranch(repoRoot, branchName);
270
+ git(`worktree add -b ${uniqueBranch} "${targetPath}" origin/${branchName}`, repoRoot);
271
+ return { worktreePath: targetPath, branch: branchName, actualBranch: uniqueBranch, isNew: false };
272
+ }
273
+ // Create local tracking branch from remote
274
+ git(`worktree add -b ${branchName} "${targetPath}" origin/${branchName}`, repoRoot);
275
+ return { worktreePath: targetPath, branch: branchName, actualBranch: branchName, isNew: false };
276
+ }
277
+
278
+ if (options?.createBranch !== false) {
279
+ // Create new branch from base
280
+ const base = options?.baseBranch || resolveDefaultBranch(repoRoot);
281
+ git(`worktree add -b ${branchName} "${targetPath}" ${base}`, repoRoot);
282
+ return { worktreePath: targetPath, branch: branchName, actualBranch: branchName, isNew: true };
283
+ }
284
+
285
+ throw new Error(`Branch "${branchName}" does not exist and createBranch is false`);
286
+ }
287
+
288
+ /**
289
+ * Generate a unique branch name for a campfire-managed worktree.
290
+ * Pattern: `{branch}-wt-{random4digit}` (e.g. `main-wt-8374`).
291
+ * Uses random suffixes to avoid collisions with leftover branches.
292
+ */
293
+ export function generateUniqueWorktreeBranch(repoRoot: string, baseBranch: string): string {
294
+ for (let attempt = 0; attempt < 100; attempt++) {
295
+ const suffix = Math.floor(1000 + Math.random() * 9000);
296
+ const candidate = `${baseBranch}-wt-${suffix}`;
297
+ if (gitSafe(`rev-parse --verify refs/heads/${candidate}`, repoRoot) === null) {
298
+ return candidate;
299
+ }
300
+ }
301
+ // Fallback: use timestamp if all random attempts collide (extremely unlikely)
302
+ return `${baseBranch}-wt-${Date.now()}`;
303
+ }
304
+
305
+ export function removeWorktree(
306
+ repoRoot: string,
307
+ worktreePath: string,
308
+ options?: { force?: boolean; branchToDelete?: string },
309
+ ): { removed: boolean; reason?: string } {
310
+ if (!existsSync(worktreePath)) {
311
+ // Already gone, clean up git's reference
312
+ gitSafe("worktree prune", repoRoot);
313
+ if (options?.branchToDelete) {
314
+ gitSafe(`branch -D ${options.branchToDelete}`, repoRoot);
315
+ }
316
+ return { removed: true };
317
+ }
318
+
319
+ if (!options?.force && isWorktreeDirty(worktreePath)) {
320
+ return {
321
+ removed: false,
322
+ reason: "Worktree has uncommitted changes. Use force to remove anyway.",
323
+ };
324
+ }
325
+
326
+ try {
327
+ const forceFlag = options?.force ? " --force" : "";
328
+ git(`worktree remove "${worktreePath}"${forceFlag}`, repoRoot);
329
+ // Clean up the campfire-managed branch after worktree removal
330
+ if (options?.branchToDelete) {
331
+ gitSafe(`branch -D ${options.branchToDelete}`, repoRoot);
332
+ }
333
+ return { removed: true };
334
+ } catch (e: unknown) {
335
+ return {
336
+ removed: false,
337
+ reason: e instanceof Error ? e.message : String(e),
338
+ };
339
+ }
340
+ }
341
+
342
+ export function isWorktreeDirty(worktreePath: string): boolean {
343
+ if (!existsSync(worktreePath)) return false;
344
+ const status = gitSafe("status --porcelain", worktreePath);
345
+ return status !== null && status.length > 0;
346
+ }
347
+
348
+ export function gitFetch(cwd: string): { success: boolean; output: string } {
349
+ try {
350
+ const output = git("fetch --prune", cwd);
351
+ return { success: true, output };
352
+ } catch (e: unknown) {
353
+ return { success: false, output: e instanceof Error ? e.message : String(e) };
354
+ }
355
+ }
356
+
357
+ export function gitPull(
358
+ cwd: string,
359
+ ): { success: boolean; output: string } {
360
+ try {
361
+ const output = git("pull", cwd);
362
+ return { success: true, output };
363
+ } catch (e: unknown) {
364
+ return { success: false, output: e instanceof Error ? e.message : String(e) };
365
+ }
366
+ }
367
+
368
+
369
+ export function checkoutBranch(cwd: string, branchName: string): void {
370
+ git(`checkout ${branchName}`, cwd);
371
+ }
372
+
373
+ export function getBranchStatus(
374
+ repoRoot: string,
375
+ branchName: string,
376
+ ): { ahead: number; behind: number } {
377
+ const raw = gitSafe(
378
+ `rev-list --left-right --count origin/${branchName}...${branchName}`,
379
+ repoRoot,
380
+ );
381
+ if (!raw) return { ahead: 0, behind: 0 };
382
+ const [behind, ahead] = raw.split(/\s+/).map(Number);
383
+ return { ahead: ahead || 0, behind: behind || 0 };
384
+ }