heyhank 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 (199) hide show
  1. package/README.md +40 -0
  2. package/bin/cli.ts +168 -0
  3. package/bin/ctl.ts +528 -0
  4. package/bin/generate-token.ts +28 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
  7. package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
  8. package/dist/assets/CronManager-DDbz-yiT.js +1 -0
  9. package/dist/assets/HelpPage-DMfkzERp.js +1 -0
  10. package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
  11. package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
  12. package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
  13. package/dist/assets/Playground-Fc5cdc5p.js +109 -0
  14. package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
  15. package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
  16. package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
  17. package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
  18. package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
  19. package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
  20. package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
  21. package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
  22. package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
  23. package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
  24. package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
  25. package/dist/assets/index-C8M_PUmX.css +32 -0
  26. package/dist/assets/index-CEqZnThB.js +204 -0
  27. package/dist/assets/sw-register-LSSpj6RU.js +1 -0
  28. package/dist/assets/time-ago-B6r_l9u1.js +1 -0
  29. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  30. package/dist/favicon-32-original.png +0 -0
  31. package/dist/favicon-32.png +0 -0
  32. package/dist/favicon.ico +0 -0
  33. package/dist/favicon.svg +8 -0
  34. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  35. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  36. package/dist/heyhank-mascot-poster.png +0 -0
  37. package/dist/heyhank-mascot.mp4 +0 -0
  38. package/dist/heyhank-mascot.webm +0 -0
  39. package/dist/icon-192-original.png +0 -0
  40. package/dist/icon-192.png +0 -0
  41. package/dist/icon-512-original.png +0 -0
  42. package/dist/icon-512.png +0 -0
  43. package/dist/index.html +21 -0
  44. package/dist/logo-192.png +0 -0
  45. package/dist/logo-512.png +0 -0
  46. package/dist/logo-codex.svg +14 -0
  47. package/dist/logo-docker.svg +4 -0
  48. package/dist/logo-original.png +0 -0
  49. package/dist/logo.png +0 -0
  50. package/dist/logo.svg +14 -0
  51. package/dist/manifest.json +24 -0
  52. package/dist/push-sw.js +34 -0
  53. package/dist/sw.js +1 -0
  54. package/dist/workbox-d2a0910a.js +1 -0
  55. package/package.json +109 -0
  56. package/server/agent-cron-migrator.ts +85 -0
  57. package/server/agent-executor.ts +357 -0
  58. package/server/agent-store.ts +185 -0
  59. package/server/agent-timeout.ts +107 -0
  60. package/server/agent-types.ts +122 -0
  61. package/server/ai-validation-settings.ts +37 -0
  62. package/server/ai-validator.ts +181 -0
  63. package/server/anthropic-provider-migration.ts +48 -0
  64. package/server/assistant-store.ts +272 -0
  65. package/server/auth-manager.ts +150 -0
  66. package/server/auto-approve.ts +153 -0
  67. package/server/auto-namer.ts +36 -0
  68. package/server/backend-adapter.ts +54 -0
  69. package/server/cache-headers.ts +61 -0
  70. package/server/calendar-service.ts +434 -0
  71. package/server/claude-adapter.ts +889 -0
  72. package/server/claude-container-auth.ts +30 -0
  73. package/server/claude-session-discovery.ts +157 -0
  74. package/server/claude-session-history.ts +410 -0
  75. package/server/cli-launcher.ts +1303 -0
  76. package/server/codex-adapter.ts +3027 -0
  77. package/server/codex-container-auth.ts +24 -0
  78. package/server/codex-home.ts +27 -0
  79. package/server/codex-ws-proxy.cjs +226 -0
  80. package/server/commands-discovery.ts +81 -0
  81. package/server/constants.ts +7 -0
  82. package/server/container-manager.ts +1053 -0
  83. package/server/cost-tracker.ts +222 -0
  84. package/server/cron-scheduler.ts +243 -0
  85. package/server/cron-store.ts +148 -0
  86. package/server/cron-types.ts +63 -0
  87. package/server/email-service.ts +354 -0
  88. package/server/env-manager.ts +161 -0
  89. package/server/event-bus-types.ts +75 -0
  90. package/server/event-bus.ts +124 -0
  91. package/server/execution-store.ts +170 -0
  92. package/server/federation/node-connection.ts +190 -0
  93. package/server/federation/node-manager.ts +366 -0
  94. package/server/federation/node-store.ts +86 -0
  95. package/server/federation/node-types.ts +121 -0
  96. package/server/fs-utils.ts +15 -0
  97. package/server/git-utils.ts +421 -0
  98. package/server/github-pr.ts +379 -0
  99. package/server/google-media.ts +342 -0
  100. package/server/image-pull-manager.ts +279 -0
  101. package/server/index.ts +491 -0
  102. package/server/internal-ai.ts +237 -0
  103. package/server/kill-switch.ts +99 -0
  104. package/server/llm-providers.ts +342 -0
  105. package/server/logger.ts +259 -0
  106. package/server/mcp-registry.ts +401 -0
  107. package/server/message-bus.ts +271 -0
  108. package/server/message-delivery.ts +128 -0
  109. package/server/metrics-collector.ts +350 -0
  110. package/server/metrics-types.ts +108 -0
  111. package/server/middleware/managed-auth.ts +195 -0
  112. package/server/novnc-proxy.ts +99 -0
  113. package/server/path-resolver.ts +186 -0
  114. package/server/paths.ts +13 -0
  115. package/server/pr-poller.ts +162 -0
  116. package/server/prompt-manager.ts +211 -0
  117. package/server/protocol/claude-upstream/README.md +19 -0
  118. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  119. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  120. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  121. package/server/protocol/codex-upstream/README.md +18 -0
  122. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  123. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  124. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  125. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  126. package/server/protocol-monitor.ts +50 -0
  127. package/server/provider-manager.ts +111 -0
  128. package/server/provider-registry.ts +393 -0
  129. package/server/push-notifications.ts +221 -0
  130. package/server/recorder.ts +374 -0
  131. package/server/recording-hub/compat-validator.ts +284 -0
  132. package/server/recording-hub/diagnostics.ts +299 -0
  133. package/server/recording-hub/hub-config.ts +19 -0
  134. package/server/recording-hub/hub-routes.ts +236 -0
  135. package/server/recording-hub/hub-store.ts +265 -0
  136. package/server/recording-hub/replay-adapter.ts +207 -0
  137. package/server/relay-client.ts +320 -0
  138. package/server/reminder-scheduler.ts +38 -0
  139. package/server/replay.ts +78 -0
  140. package/server/routes/agent-routes.ts +264 -0
  141. package/server/routes/assistant-routes.ts +90 -0
  142. package/server/routes/cron-routes.ts +103 -0
  143. package/server/routes/env-routes.ts +95 -0
  144. package/server/routes/federation-routes.ts +76 -0
  145. package/server/routes/fs-routes.ts +622 -0
  146. package/server/routes/git-routes.ts +97 -0
  147. package/server/routes/llm-routes.ts +166 -0
  148. package/server/routes/media-routes.ts +135 -0
  149. package/server/routes/metrics-routes.ts +13 -0
  150. package/server/routes/platform-routes.ts +1379 -0
  151. package/server/routes/prompt-routes.ts +67 -0
  152. package/server/routes/provider-routes.ts +109 -0
  153. package/server/routes/sandbox-routes.ts +127 -0
  154. package/server/routes/settings-routes.ts +285 -0
  155. package/server/routes/skills-routes.ts +100 -0
  156. package/server/routes/socialmedia-routes.ts +208 -0
  157. package/server/routes/system-routes.ts +228 -0
  158. package/server/routes/tailscale-routes.ts +22 -0
  159. package/server/routes/telephony-routes.ts +259 -0
  160. package/server/routes.ts +1379 -0
  161. package/server/sandbox-manager.ts +168 -0
  162. package/server/service.ts +718 -0
  163. package/server/session-creation-service.ts +457 -0
  164. package/server/session-git-info.ts +104 -0
  165. package/server/session-names.ts +67 -0
  166. package/server/session-orchestrator.ts +824 -0
  167. package/server/session-state-machine.ts +207 -0
  168. package/server/session-store.ts +146 -0
  169. package/server/session-types.ts +511 -0
  170. package/server/settings-manager.ts +149 -0
  171. package/server/shared-context.ts +157 -0
  172. package/server/socialmedia/adapter.ts +15 -0
  173. package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
  174. package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
  175. package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
  176. package/server/socialmedia/manager.ts +227 -0
  177. package/server/socialmedia/store.ts +98 -0
  178. package/server/socialmedia/types.ts +89 -0
  179. package/server/tailscale-manager.ts +451 -0
  180. package/server/telephony/audio-bridge.ts +331 -0
  181. package/server/telephony/call-manager.ts +457 -0
  182. package/server/telephony/call-types.ts +108 -0
  183. package/server/telephony/telephony-store.ts +119 -0
  184. package/server/terminal-manager.ts +240 -0
  185. package/server/update-checker.ts +192 -0
  186. package/server/usage-limits.ts +225 -0
  187. package/server/web-push.d.ts +51 -0
  188. package/server/worktree-tracker.ts +84 -0
  189. package/server/ws-auth.ts +41 -0
  190. package/server/ws-bridge-browser-ingest.ts +72 -0
  191. package/server/ws-bridge-browser.ts +112 -0
  192. package/server/ws-bridge-cli-ingest.ts +81 -0
  193. package/server/ws-bridge-codex.ts +266 -0
  194. package/server/ws-bridge-controls.ts +20 -0
  195. package/server/ws-bridge-persist.ts +66 -0
  196. package/server/ws-bridge-publish.ts +79 -0
  197. package/server/ws-bridge-replay.ts +61 -0
  198. package/server/ws-bridge-types.ts +121 -0
  199. package/server/ws-bridge.ts +1240 -0
@@ -0,0 +1,421 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { join, basename } from "node:path";
4
+ import { HEYHANK_HOME } from "./paths.js";
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(HEYHANK_HOME, "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 — prefer remote ref (up-to-date after fetch)
280
+ // over the potentially stale local ref
281
+ const base = options?.baseBranch || resolveDefaultBranch(repoRoot);
282
+ const remoteRef = `origin/${base}`;
283
+ const startPoint =
284
+ gitSafe(`rev-parse --verify refs/remotes/${remoteRef}`, repoRoot) !== null
285
+ ? remoteRef
286
+ : base;
287
+ git(`worktree add -b ${branchName} "${targetPath}" ${startPoint}`, repoRoot);
288
+ return { worktreePath: targetPath, branch: branchName, actualBranch: branchName, isNew: true };
289
+ }
290
+
291
+ throw new Error(`Branch "${branchName}" does not exist and createBranch is false`);
292
+ }
293
+
294
+ /**
295
+ * Generate a unique branch name for a HeyHank-managed worktree.
296
+ * Pattern: `{branch}-wt-{random4digit}` (e.g. `main-wt-8374`).
297
+ * Uses random suffixes to avoid collisions with leftover branches.
298
+ */
299
+ export function generateUniqueWorktreeBranch(repoRoot: string, baseBranch: string): string {
300
+ for (let attempt = 0; attempt < 100; attempt++) {
301
+ const suffix = Math.floor(1000 + Math.random() * 9000);
302
+ const candidate = `${baseBranch}-wt-${suffix}`;
303
+ if (gitSafe(`rev-parse --verify refs/heads/${candidate}`, repoRoot) === null) {
304
+ return candidate;
305
+ }
306
+ }
307
+ // Fallback: use timestamp if all random attempts collide (extremely unlikely)
308
+ return `${baseBranch}-wt-${Date.now()}`;
309
+ }
310
+
311
+ export function removeWorktree(
312
+ repoRoot: string,
313
+ worktreePath: string,
314
+ options?: { force?: boolean; branchToDelete?: string },
315
+ ): { removed: boolean; reason?: string } {
316
+ if (!existsSync(worktreePath)) {
317
+ // Already gone, clean up git's reference
318
+ gitSafe("worktree prune", repoRoot);
319
+ if (options?.branchToDelete) {
320
+ gitSafe(`branch -D ${options.branchToDelete}`, repoRoot);
321
+ }
322
+ return { removed: true };
323
+ }
324
+
325
+ if (!options?.force && isWorktreeDirty(worktreePath)) {
326
+ return {
327
+ removed: false,
328
+ reason: "Worktree has uncommitted changes. Use force to remove anyway.",
329
+ };
330
+ }
331
+
332
+ try {
333
+ const forceFlag = options?.force ? " --force" : "";
334
+ git(`worktree remove "${worktreePath}"${forceFlag}`, repoRoot);
335
+ // Clean up the HeyHank-managed branch after worktree removal
336
+ if (options?.branchToDelete) {
337
+ gitSafe(`branch -D ${options.branchToDelete}`, repoRoot);
338
+ }
339
+ return { removed: true };
340
+ } catch (e: unknown) {
341
+ return {
342
+ removed: false,
343
+ reason: e instanceof Error ? e.message : String(e),
344
+ };
345
+ }
346
+ }
347
+
348
+ export function isWorktreeDirty(worktreePath: string): boolean {
349
+ if (!existsSync(worktreePath)) return false;
350
+ const status = gitSafe("status --porcelain", worktreePath);
351
+ return status !== null && status.length > 0;
352
+ }
353
+
354
+ export function gitFetch(cwd: string): { success: boolean; output: string } {
355
+ try {
356
+ const output = git("fetch --prune", cwd);
357
+ return { success: true, output };
358
+ } catch (e: unknown) {
359
+ return { success: false, output: e instanceof Error ? e.message : String(e) };
360
+ }
361
+ }
362
+
363
+ export function gitPull(
364
+ cwd: string,
365
+ ): { success: boolean; output: string } {
366
+ try {
367
+ const output = git("pull", cwd);
368
+ return { success: true, output };
369
+ } catch (e: unknown) {
370
+ return { success: false, output: e instanceof Error ? e.message : String(e) };
371
+ }
372
+ }
373
+
374
+
375
+ export function checkoutBranch(cwd: string, branchName: string): void {
376
+ git(`checkout ${branchName}`, cwd);
377
+ }
378
+
379
+ /**
380
+ * Checkout an existing branch, or create a new one from origin/{defaultBranch}
381
+ * (falling back to local defaultBranch if no remote ref exists).
382
+ */
383
+ export function checkoutOrCreateBranch(
384
+ cwd: string,
385
+ branchName: string,
386
+ options?: { createBranch?: boolean; defaultBranch?: string },
387
+ ): { created: boolean } {
388
+ // Try regular checkout first (works for existing local and remote-tracking branches)
389
+ const checkoutResult = gitSafe(`checkout ${branchName}`, cwd);
390
+ if (checkoutResult !== null) {
391
+ return { created: false };
392
+ }
393
+
394
+ // Branch doesn't exist — create it if allowed
395
+ if (!options?.createBranch) {
396
+ throw new Error(`Branch "${branchName}" does not exist. Pass createBranch to create it.`);
397
+ }
398
+
399
+ const base = options.defaultBranch || resolveDefaultBranch(cwd);
400
+ // Prefer remote ref (up-to-date after fetch) over potentially stale local ref
401
+ const remoteRef = `origin/${base}`;
402
+ const startPoint =
403
+ gitSafe(`rev-parse --verify refs/remotes/${remoteRef}`, cwd) !== null
404
+ ? remoteRef
405
+ : base;
406
+ git(`checkout -b ${branchName} ${startPoint}`, cwd);
407
+ return { created: true };
408
+ }
409
+
410
+ export function getBranchStatus(
411
+ repoRoot: string,
412
+ branchName: string,
413
+ ): { ahead: number; behind: number } {
414
+ const raw = gitSafe(
415
+ `rev-list --left-right --count origin/${branchName}...${branchName}`,
416
+ repoRoot,
417
+ );
418
+ if (!raw) return { ahead: 0, behind: 0 };
419
+ const [behind, ahead] = raw.split(/\s+/).map(Number);
420
+ return { ahead: ahead || 0, behind: behind || 0 };
421
+ }