@agimon-ai/doompi-git 0.0.1-alpha.4 → 0.0.1-alpha.41

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/README.md +33 -1
  2. package/dist/apiContracts.cjs +387 -0
  3. package/dist/apiContracts.cjs.map +1 -0
  4. package/dist/apiContracts.d.mts +53 -0
  5. package/dist/apiContracts.d.mts.map +1 -0
  6. package/dist/apiContracts.mjs +382 -0
  7. package/dist/apiContracts.mjs.map +1 -0
  8. package/dist/extensions/pi.cjs +105 -1
  9. package/dist/extensions/pi.cjs.map +1 -0
  10. package/dist/extensions/pi.d.mts +6 -2
  11. package/dist/extensions/pi.d.mts.map +1 -0
  12. package/dist/extensions/pi.mjs +100 -1
  13. package/dist/extensions/pi.mjs.map +1 -0
  14. package/dist/extensions/server.cjs +1828 -0
  15. package/dist/extensions/server.cjs.map +1 -0
  16. package/dist/extensions/server.d.mts +5 -0
  17. package/dist/extensions/server.d.mts.map +1 -0
  18. package/dist/extensions/server.mjs +1820 -0
  19. package/dist/extensions/server.mjs.map +1 -0
  20. package/dist/extensions/web.mjs +2641 -0
  21. package/dist/extensions/web.mjs.map +1 -0
  22. package/dist/gitDependencies-CtVubYvU.mjs +22 -0
  23. package/dist/gitDependencies-CtVubYvU.mjs.map +1 -0
  24. package/dist/gitDependencies-Dqsz2HjK.cjs +33 -0
  25. package/dist/gitDependencies-Dqsz2HjK.cjs.map +1 -0
  26. package/dist/index-D79MSCee.d.mts +145 -0
  27. package/dist/index-D79MSCee.d.mts.map +1 -0
  28. package/dist/index.cjs +4 -1
  29. package/dist/index.d.mts +2 -5
  30. package/dist/index.mjs +2 -1
  31. package/dist/runWorktree-BE795LxL.cjs +374 -0
  32. package/dist/runWorktree-BE795LxL.cjs.map +1 -0
  33. package/dist/runWorktree-SPzJvIBt.mjs +303 -0
  34. package/dist/runWorktree-SPzJvIBt.mjs.map +1 -0
  35. package/dist/worktreeOperations-BYmVU0UV.cjs +1350 -0
  36. package/dist/worktreeOperations-BYmVU0UV.cjs.map +1 -0
  37. package/dist/worktreeOperations-BrWG2muV.mjs +1194 -0
  38. package/dist/worktreeOperations-BrWG2muV.mjs.map +1 -0
  39. package/llms.txt +4 -0
  40. package/package.json +56 -41
  41. package/src/prompts/doompi-use-git/SKILL.md +26 -3
  42. package/dist/_virtual/_rolldown/runtime.cjs +0 -1
  43. package/dist/adapters/channel/worktreeChannel.cjs +0 -2
  44. package/dist/adapters/channel/worktreeChannel.cjs.map +0 -1
  45. package/dist/adapters/channel/worktreeChannel.d.cts +0 -40
  46. package/dist/adapters/channel/worktreeChannel.d.cts.map +0 -1
  47. package/dist/adapters/channel/worktreeChannel.d.mts +0 -40
  48. package/dist/adapters/channel/worktreeChannel.d.mts.map +0 -1
  49. package/dist/adapters/channel/worktreeChannel.mjs +0 -2
  50. package/dist/adapters/channel/worktreeChannel.mjs.map +0 -1
  51. package/dist/adapters/filesystem/atomicJson.cjs +0 -2
  52. package/dist/adapters/filesystem/atomicJson.cjs.map +0 -1
  53. package/dist/adapters/filesystem/atomicJson.mjs +0 -2
  54. package/dist/adapters/filesystem/atomicJson.mjs.map +0 -1
  55. package/dist/adapters/filesystem/paths.cjs +0 -2
  56. package/dist/adapters/filesystem/paths.cjs.map +0 -1
  57. package/dist/adapters/filesystem/paths.mjs +0 -2
  58. package/dist/adapters/filesystem/paths.mjs.map +0 -1
  59. package/dist/adapters/hub/hubClient.cjs +0 -2
  60. package/dist/adapters/hub/hubClient.cjs.map +0 -1
  61. package/dist/adapters/hub/hubClient.d.cts +0 -1
  62. package/dist/adapters/hub/hubClient.d.mts +0 -1
  63. package/dist/adapters/hub/hubClient.mjs +0 -2
  64. package/dist/adapters/hub/hubClient.mjs.map +0 -1
  65. package/dist/adapters/pi/extension.cjs +0 -2
  66. package/dist/adapters/pi/extension.cjs.map +0 -1
  67. package/dist/adapters/pi/extension.d.cts +0 -9
  68. package/dist/adapters/pi/extension.d.cts.map +0 -1
  69. package/dist/adapters/pi/extension.d.mts +0 -9
  70. package/dist/adapters/pi/extension.d.mts.map +0 -1
  71. package/dist/adapters/pi/extension.mjs +0 -2
  72. package/dist/adapters/pi/extension.mjs.map +0 -1
  73. package/dist/adapters/pi/extensions/runWorktreeTool.cjs +0 -15
  74. package/dist/adapters/pi/extensions/runWorktreeTool.cjs.map +0 -1
  75. package/dist/adapters/pi/extensions/runWorktreeTool.mjs +0 -15
  76. package/dist/adapters/pi/extensions/runWorktreeTool.mjs.map +0 -1
  77. package/dist/adapters/web/worktreesChannel.cjs +0 -2
  78. package/dist/adapters/web/worktreesChannel.cjs.map +0 -1
  79. package/dist/adapters/web/worktreesChannel.d.cts +0 -21
  80. package/dist/adapters/web/worktreesChannel.d.cts.map +0 -1
  81. package/dist/adapters/web/worktreesChannel.d.mts +0 -21
  82. package/dist/adapters/web/worktreesChannel.d.mts.map +0 -1
  83. package/dist/adapters/web/worktreesChannel.mjs +0 -2
  84. package/dist/adapters/web/worktreesChannel.mjs.map +0 -1
  85. package/dist/adapters/worktree/dependencyInstall.cjs +0 -2
  86. package/dist/adapters/worktree/dependencyInstall.cjs.map +0 -1
  87. package/dist/adapters/worktree/dependencyInstall.mjs +0 -2
  88. package/dist/adapters/worktree/dependencyInstall.mjs.map +0 -1
  89. package/dist/adapters/worktree/gitCli.cjs +0 -4
  90. package/dist/adapters/worktree/gitCli.cjs.map +0 -1
  91. package/dist/adapters/worktree/gitCli.mjs +0 -4
  92. package/dist/adapters/worktree/gitCli.mjs.map +0 -1
  93. package/dist/adapters/worktree/repositoryIdentity.cjs +0 -2
  94. package/dist/adapters/worktree/repositoryIdentity.cjs.map +0 -1
  95. package/dist/adapters/worktree/repositoryIdentity.mjs +0 -2
  96. package/dist/adapters/worktree/repositoryIdentity.mjs.map +0 -1
  97. package/dist/adapters/worktree/worktreeOperations.cjs +0 -2
  98. package/dist/adapters/worktree/worktreeOperations.cjs.map +0 -1
  99. package/dist/adapters/worktree/worktreeOperations.d.cts +0 -32
  100. package/dist/adapters/worktree/worktreeOperations.d.cts.map +0 -1
  101. package/dist/adapters/worktree/worktreeOperations.d.mts +0 -32
  102. package/dist/adapters/worktree/worktreeOperations.d.mts.map +0 -1
  103. package/dist/adapters/worktree/worktreeOperations.mjs +0 -2
  104. package/dist/adapters/worktree/worktreeOperations.mjs.map +0 -1
  105. package/dist/adapters/worktree/worktreeRegistry.cjs +0 -2
  106. package/dist/adapters/worktree/worktreeRegistry.cjs.map +0 -1
  107. package/dist/adapters/worktree/worktreeRegistry.mjs +0 -2
  108. package/dist/adapters/worktree/worktreeRegistry.mjs.map +0 -1
  109. package/dist/commands/doomGitCommand.cjs +0 -2
  110. package/dist/commands/doomGitCommand.cjs.map +0 -1
  111. package/dist/commands/doomGitCommand.mjs +0 -2
  112. package/dist/commands/doomGitCommand.mjs.map +0 -1
  113. package/dist/container/index.cjs +0 -2
  114. package/dist/container/index.cjs.map +0 -1
  115. package/dist/container/index.d.cts +0 -6
  116. package/dist/container/index.d.cts.map +0 -1
  117. package/dist/container/index.d.mts +0 -6
  118. package/dist/container/index.d.mts.map +0 -1
  119. package/dist/container/index.mjs +0 -2
  120. package/dist/container/index.mjs.map +0 -1
  121. package/dist/extensions/pi.d.cts +0 -2
  122. package/dist/hubApi.cjs +0 -2
  123. package/dist/hubApi.cjs.map +0 -1
  124. package/dist/hubApi.d.cts +0 -6
  125. package/dist/hubApi.d.cts.map +0 -1
  126. package/dist/hubApi.d.mts +0 -6
  127. package/dist/hubApi.d.mts.map +0 -1
  128. package/dist/hubApi.mjs +0 -2
  129. package/dist/hubApi.mjs.map +0 -1
  130. package/dist/index.d.cts +0 -5
  131. package/dist/schemas/runWorktreeTool.cjs +0 -2
  132. package/dist/schemas/runWorktreeTool.cjs.map +0 -1
  133. package/dist/schemas/runWorktreeTool.mjs +0 -2
  134. package/dist/schemas/runWorktreeTool.mjs.map +0 -1
  135. package/dist/services/extensionService.cjs +0 -2
  136. package/dist/services/extensionService.cjs.map +0 -1
  137. package/dist/services/extensionService.d.cts +0 -8
  138. package/dist/services/extensionService.d.cts.map +0 -1
  139. package/dist/services/extensionService.d.mts +0 -8
  140. package/dist/services/extensionService.d.mts.map +0 -1
  141. package/dist/services/extensionService.mjs +0 -2
  142. package/dist/services/extensionService.mjs.map +0 -1
  143. package/dist/services/support/errors.cjs +0 -2
  144. package/dist/services/support/errors.cjs.map +0 -1
  145. package/dist/services/support/errors.mjs +0 -2
  146. package/dist/services/support/errors.mjs.map +0 -1
  147. package/dist/services/worktreeNaming.cjs +0 -2
  148. package/dist/services/worktreeNaming.cjs.map +0 -1
  149. package/dist/services/worktreeNaming.d.cts +0 -27
  150. package/dist/services/worktreeNaming.d.cts.map +0 -1
  151. package/dist/services/worktreeNaming.d.mts +0 -27
  152. package/dist/services/worktreeNaming.d.mts.map +0 -1
  153. package/dist/services/worktreeNaming.mjs +0 -2
  154. package/dist/services/worktreeNaming.mjs.map +0 -1
  155. package/dist/types/extension.d.cts +0 -17
  156. package/dist/types/extension.d.cts.map +0 -1
  157. package/dist/types/extension.d.mts +0 -17
  158. package/dist/types/extension.d.mts.map +0 -1
  159. package/dist/types/webWorktrees.cjs +0 -2
  160. package/dist/types/webWorktrees.cjs.map +0 -1
  161. package/dist/types/webWorktrees.mjs +0 -2
  162. package/dist/types/webWorktrees.mjs.map +0 -1
  163. package/dist/types/worktreeRegistry.d.cts +0 -41
  164. package/dist/types/worktreeRegistry.d.cts.map +0 -1
  165. package/dist/types/worktreeRegistry.d.mts +0 -41
  166. package/dist/types/worktreeRegistry.d.mts.map +0 -1
  167. package/dist/webHub.cjs +0 -1
  168. package/dist/webHub.d.cts +0 -2
  169. package/dist/webHub.d.mts +0 -2
  170. package/dist/webHub.mjs +0 -1
  171. package/src/exports/webClient.ts +0 -1
  172. package/src/types/webWorktrees.ts +0 -55
  173. package/src/types/worktreeRegistry.ts +0 -69
  174. package/src/web/components/RunWorktreeToolMessage.tsx +0 -37
  175. package/src/web/components/WorktreesActivitySection.tsx +0 -64
  176. package/src/web/components/WorktreesPanel.tsx +0 -125
  177. package/src/web/components/toolRenderers.ts +0 -7
  178. package/src/web/index.ts +0 -30
  179. package/src/web/stores/worktreesActivityStore.ts +0 -124
  180. package/src/web/tsconfig.json +0 -22
@@ -0,0 +1,1194 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { removeSyncLocation, resolveWorktreeSyncLocation } from "@agimon-ai/doompi-core/syncLocation";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import { isDeepStrictEqual } from "node:util";
8
+ import { setTimeout } from "node:timers/promises";
9
+ //#region src/services/gitCli/index.ts
10
+ /**
11
+ * Git, as this package needs it.
12
+ *
13
+ * The timeouts are not uniform because the operations are not comparable. A
14
+ * read answers in milliseconds; `worktree add` copies an entire checkout and
15
+ * `worktree remove` deletes one, both bounded by the filesystem rather than by
16
+ * git, and both minutes rather than seconds on a large repository or on
17
+ * Windows. A single timeout would either fail honest work or hang on a wedged
18
+ * one.
19
+ */
20
+ const READ_TIMEOUT_MS = 3e4;
21
+ const ADD_TIMEOUT_MS = 3e5;
22
+ const REMOVE_TIMEOUT_MS = 3e5;
23
+ const PRUNE_TIMEOUT_MS = 15e3;
24
+ /** fetch and push: bounded by the network, so long enough for a slow link, short enough to notice a dead one. */
25
+ const NETWORK_TIMEOUT_MS = 12e4;
26
+ /** A rebase replays commits locally; a large branch on a slow disk takes minutes, never hours. */
27
+ const REBASE_TIMEOUT_MS = 3e5;
28
+ const MAX_OUTPUT_BYTES = 1e6;
29
+ /** Enough for any branch picker; a repository with more refs lists its most recent ones. */
30
+ const MAX_LISTED_REFS = 2e3;
31
+ const LOCAL_PREFIX = "refs/heads/";
32
+ const REMOTE_PREFIX = "refs/remotes/";
33
+ const HEAD_SUFFIX = "/HEAD";
34
+ /**
35
+ * Runs git and reports the outcome without ever throwing on a non-zero exit.
36
+ *
37
+ * LC_ALL is pinned because callers decide what to do by matching git's own
38
+ * wording, and a translated message would silently turn a recognised
39
+ * condition into an unrecognised failure.
40
+ *
41
+ * `env` replaces the environment for this child only. That is how remote auth
42
+ * reaches fetch and push without ever touching the hub's own process.env,
43
+ * which every session started afterwards would inherit.
44
+ */
45
+ function runGit(cwd, args, options) {
46
+ return new Promise((resolve) => {
47
+ execFile("git", [...args], {
48
+ cwd,
49
+ timeout: options.timeout,
50
+ maxBuffer: options.maxBuffer ?? MAX_OUTPUT_BYTES,
51
+ env: {
52
+ ...options.env ?? process.env,
53
+ LC_ALL: "C"
54
+ }
55
+ }, (error, stdout, stderr) => {
56
+ const failure = error;
57
+ resolve({
58
+ code: failure && typeof failure.code === "number" ? failure.code : failure ? 1 : 0,
59
+ stdout: String(stdout),
60
+ stderr: String(stderr),
61
+ timedOut: failure?.killed === true && failure.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
62
+ overflow: failure?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
63
+ });
64
+ });
65
+ });
66
+ }
67
+ function git(cwd, args, timeout) {
68
+ return runGit(cwd, args, { timeout });
69
+ }
70
+ /**
71
+ * The message a failed git call is allowed to surface.
72
+ *
73
+ * Raw stderr never travels: it can carry a remote URL with a token in it, and
74
+ * this text ends up in a tool result the model reads and may repeat.
75
+ */
76
+ function failure(operation, result) {
77
+ return /* @__PURE__ */ new Error(`git ${operation} failed (exit ${String(result.code)}, ${String(result.stderr.length)} bytes of stderr)`);
78
+ }
79
+ /** Git's own words for "that worktree is not there any more". */
80
+ function missingWorktree(stderr) {
81
+ const normalized = stderr.toLowerCase();
82
+ return normalized.includes("is not a working tree") || normalized.includes("cannot remove working tree");
83
+ }
84
+ function createWorktreeGit() {
85
+ const prune = async (repositoryRoot) => {
86
+ await git(repositoryRoot, ["worktree", "prune"], PRUNE_TIMEOUT_MS);
87
+ };
88
+ return {
89
+ async addWorktree({ repositoryRoot, path, branch, baseRef }) {
90
+ const result = await git(repositoryRoot, [
91
+ "worktree",
92
+ "add",
93
+ "-b",
94
+ branch,
95
+ path,
96
+ baseRef
97
+ ], ADD_TIMEOUT_MS);
98
+ if (result.code !== 0) throw failure("worktree add", result);
99
+ },
100
+ async addExistingWorktree({ repositoryRoot, path, branch, remote }) {
101
+ const result = await git(repositoryRoot, remote === void 0 ? [
102
+ "worktree",
103
+ "add",
104
+ path,
105
+ branch
106
+ ] : [
107
+ "worktree",
108
+ "add",
109
+ "--track",
110
+ "-b",
111
+ branch,
112
+ path,
113
+ `${remote}/${branch}`
114
+ ], ADD_TIMEOUT_MS);
115
+ if (result.code !== 0) throw failure("worktree add", result);
116
+ },
117
+ async listBranches(repositoryRoot) {
118
+ const refs = await git(repositoryRoot, [
119
+ "for-each-ref",
120
+ "--sort=-committerdate",
121
+ `--count=${String(MAX_LISTED_REFS)}`,
122
+ "--format=%(refname)%09%(worktreepath)%09%(symref)",
123
+ "refs/heads",
124
+ "refs/remotes"
125
+ ], READ_TIMEOUT_MS);
126
+ if (refs.code !== 0) throw failure("for-each-ref", refs);
127
+ const local = [];
128
+ const remotes = [];
129
+ for (const line of refs.stdout.split("\n")) {
130
+ const [ref = "", worktreePath = "", symref = ""] = line.split(" ");
131
+ if (ref === "" || symref !== "") continue;
132
+ if (ref.startsWith(LOCAL_PREFIX)) {
133
+ const name = ref.slice(11);
134
+ local.push(worktreePath === "" ? { name } : {
135
+ name,
136
+ checkedOutAt: worktreePath
137
+ });
138
+ } else if (ref.startsWith(REMOTE_PREFIX) && !ref.endsWith(HEAD_SUFFIX)) {
139
+ const rest = ref.slice(13);
140
+ const separator = rest.indexOf("/");
141
+ if (separator > 0) remotes.push({
142
+ remote: rest.slice(0, separator),
143
+ name: rest.slice(separator + 1)
144
+ });
145
+ }
146
+ }
147
+ const localNames = new Set(local.map((branch) => branch.name));
148
+ const head = await git(repositoryRoot, [
149
+ "symbolic-ref",
150
+ "--quiet",
151
+ "--short",
152
+ "HEAD"
153
+ ], READ_TIMEOUT_MS);
154
+ const current = head.code === 0 && head.stdout.trim() !== "" ? head.stdout.trim() : void 0;
155
+ const origin = await git(repositoryRoot, [
156
+ "symbolic-ref",
157
+ "--quiet",
158
+ "--short",
159
+ "refs/remotes/origin/HEAD"
160
+ ], READ_TIMEOUT_MS);
161
+ const defaultBase = origin.code === 0 && origin.stdout.trim() !== "" ? origin.stdout.trim() : current;
162
+ return {
163
+ ...current === void 0 ? {} : { current },
164
+ ...defaultBase === void 0 ? {} : { defaultBase },
165
+ local,
166
+ remote: remotes.filter((branch) => !localNames.has(branch.name))
167
+ };
168
+ },
169
+ async removeWorktree({ repositoryRoot, path, force }) {
170
+ const result = await git(repositoryRoot, [
171
+ "worktree",
172
+ "remove",
173
+ ...force ? ["--force"] : [],
174
+ path
175
+ ], REMOVE_TIMEOUT_MS);
176
+ if (result.code === 0) return;
177
+ if (missingWorktree(result.stderr) && !fs.existsSync(path)) {
178
+ await prune(repositoryRoot);
179
+ return;
180
+ }
181
+ throw failure("worktree remove", result);
182
+ },
183
+ async deleteBranch({ repositoryRoot, branch }) {
184
+ return (await git(repositoryRoot, [
185
+ "branch",
186
+ "-d",
187
+ branch
188
+ ], READ_TIMEOUT_MS)).code === 0;
189
+ },
190
+ pruneWorktrees: prune,
191
+ async listWorktreePaths(repositoryRoot) {
192
+ const result = await git(repositoryRoot, [
193
+ "worktree",
194
+ "list",
195
+ "--porcelain"
196
+ ], READ_TIMEOUT_MS);
197
+ if (result.code !== 0) return [];
198
+ return result.stdout.split("\n").filter((line) => line.startsWith("worktree ")).map((line) => line.slice(9).trim()).filter((line) => line !== "");
199
+ },
200
+ async dirtyFiles(path) {
201
+ const result = await git(path, ["status", "--porcelain"], READ_TIMEOUT_MS);
202
+ if (result.code !== 0) return [];
203
+ return result.stdout.split("\n").map((line) => line.slice(3).trim()).filter((line) => line !== "");
204
+ },
205
+ async repositoryRoot(cwd) {
206
+ const result = await git(cwd, ["rev-parse", "--show-toplevel"], READ_TIMEOUT_MS);
207
+ if (result.code !== 0) return void 0;
208
+ const root = result.stdout.trim();
209
+ return root === "" ? void 0 : root;
210
+ },
211
+ async currentBranch(cwd) {
212
+ const result = await git(cwd, [
213
+ "rev-parse",
214
+ "--abbrev-ref",
215
+ "HEAD"
216
+ ], READ_TIMEOUT_MS);
217
+ if (result.code !== 0) return void 0;
218
+ const branch = result.stdout.trim();
219
+ return branch === "" ? void 0 : branch;
220
+ },
221
+ async remoteBaseRef(cwd) {
222
+ const upstream = await git(cwd, [
223
+ "rev-parse",
224
+ "--abbrev-ref",
225
+ "--symbolic-full-name",
226
+ "@{upstream}"
227
+ ], READ_TIMEOUT_MS);
228
+ if (upstream.code === 0) {
229
+ const branch = upstream.stdout.trim();
230
+ if (branch !== "") return branch;
231
+ }
232
+ const origin = await git(cwd, [
233
+ "symbolic-ref",
234
+ "--quiet",
235
+ "--short",
236
+ "refs/remotes/origin/HEAD"
237
+ ], READ_TIMEOUT_MS);
238
+ if (origin.code === 0) {
239
+ const branch = origin.stdout.trim();
240
+ if (branch !== "") return branch;
241
+ }
242
+ const defaults = await git(cwd, [
243
+ "for-each-ref",
244
+ "--format=%(symref:short)",
245
+ "refs/remotes"
246
+ ], READ_TIMEOUT_MS);
247
+ if (defaults.code !== 0) return void 0;
248
+ return defaults.stdout.split("\n").map((line) => line.trim()).find((line) => line !== "");
249
+ },
250
+ async mergeBranch({ repositoryRoot, branch, message }) {
251
+ const result = await git(repositoryRoot, [
252
+ "merge",
253
+ "--no-ff",
254
+ ...message === void 0 ? [] : ["-m", message],
255
+ branch
256
+ ], READ_TIMEOUT_MS);
257
+ if (result.code === 0) return;
258
+ throw failure("merge", result);
259
+ }
260
+ };
261
+ }
262
+ //#endregion
263
+ //#region src/services/compositionMirror/index.ts
264
+ /**
265
+ * Giving a new worktree the build output its parent checkout already has.
266
+ *
267
+ * DESIGN PATTERNS:
268
+ * - Mirrors, never builds. Everything here is a copy or a symlink of something
269
+ * the parent checkout already produced, so it costs seconds and cannot fail
270
+ * the way a package manager or a compiler can.
271
+ * - `node_modules` is linked, `dist` is copied. The module tree is gigabytes
272
+ * and read-only in practice, so a link is right; build output is small and a
273
+ * link would send a build run inside the worktree straight into the parent's
274
+ * directory.
275
+ * - Absent parent output means nothing to do. A repository with no
276
+ * `node_modules` at its root is either not a JS project or not installed, and
277
+ * either way this has no opinion about it, so a Rust, Go or Python worktree
278
+ * pays nothing.
279
+ *
280
+ * WHY THIS EXISTS:
281
+ * A DoomPi session composes the extensions its repository's config names, and
282
+ * this repository names its own workspace packages by relative path. Those
283
+ * packages declare `pi.extensions` under `dist/`, which git does not track, so
284
+ * a fresh worktree resolves none of them and the session dies on start. The
285
+ * parent checkout has already built exactly what the child needs.
286
+ *
287
+ * AVOID:
288
+ * - Recursing into `node_modules`. The scan is bounded and skips it; without
289
+ * that this walks a gigabyte to find nothing.
290
+ * - Treating a mirror failure as fatal. The worktree is good either way; only
291
+ * the session's composition is poorer for it.
292
+ */
293
+ /** Directories that are build output worth copying into a new worktree. */
294
+ const OUTPUT_DIRECTORY = "dist";
295
+ const MODULES_DIRECTORY = "node_modules";
296
+ const GIT_DIRECTORY = ".git";
297
+ /**
298
+ * How deep the scan goes.
299
+ *
300
+ * Four levels reaches `layers/<group>/<package>/dist` in this repository's
301
+ * layout, and a workspace that buries a package deeper than that gets a
302
+ * partial mirror rather than a slow one.
303
+ */
304
+ const MAX_DEPTH = 4;
305
+ function scan(root) {
306
+ const found = {
307
+ outputs: [],
308
+ modules: []
309
+ };
310
+ const walk = (relative, depth) => {
311
+ let entries;
312
+ try {
313
+ entries = fs.readdirSync(path.join(root, relative), { withFileTypes: true });
314
+ } catch {
315
+ return;
316
+ }
317
+ for (const entry of entries) {
318
+ const child = relative === "" ? entry.name : `${relative}/${entry.name}`;
319
+ if (entry.name === GIT_DIRECTORY) continue;
320
+ if (entry.name === MODULES_DIRECTORY) {
321
+ try {
322
+ if (entry.isDirectory() || entry.isSymbolicLink() && fs.statSync(path.join(root, child)).isDirectory()) found.modules.push(child);
323
+ } catch {}
324
+ continue;
325
+ }
326
+ if (!entry.isDirectory()) continue;
327
+ if (entry.name === OUTPUT_DIRECTORY) {
328
+ found.outputs.push(child);
329
+ continue;
330
+ }
331
+ if (depth < MAX_DEPTH) walk(child, depth + 1);
332
+ }
333
+ };
334
+ walk("", 1);
335
+ return found;
336
+ }
337
+ /**
338
+ * Copies the parent's build output into a worktree and links its modules.
339
+ *
340
+ * Individual failures are counted out rather than thrown: a mirror that lands
341
+ * partly is still better than none, and the caller has a worktree either way.
342
+ */
343
+ function mirrorComposition(source, target) {
344
+ if (!fs.existsSync(path.join(source, MODULES_DIRECTORY))) return {
345
+ kind: "skipped",
346
+ reason: "no-modules"
347
+ };
348
+ const found = scan(source);
349
+ let copied = 0;
350
+ let linked = 0;
351
+ for (const relative of found.modules) {
352
+ const destination = path.join(target, relative);
353
+ if (fs.existsSync(destination)) continue;
354
+ try {
355
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
356
+ fs.symlinkSync(fs.realpathSync(path.join(source, relative)), destination, "dir");
357
+ linked += 1;
358
+ } catch {}
359
+ }
360
+ for (const relative of found.outputs) {
361
+ const destination = path.join(target, relative);
362
+ if (fs.existsSync(destination)) continue;
363
+ try {
364
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
365
+ fs.cpSync(path.join(source, relative), destination, { recursive: true });
366
+ copied += 1;
367
+ } catch {}
368
+ }
369
+ return {
370
+ kind: "mirrored",
371
+ copied,
372
+ linked
373
+ };
374
+ }
375
+ //#endregion
376
+ //#region src/services/errors/index.ts
377
+ var DoomGitExpectedError = class extends Error {
378
+ code;
379
+ retryable;
380
+ recovery;
381
+ constructor(code, message, retryable, recovery) {
382
+ super(`[${code}] ${message}\nRecovery: ${recovery}`);
383
+ this.code = code;
384
+ this.retryable = retryable;
385
+ this.recovery = recovery;
386
+ this.name = "DoomGitExpectedError";
387
+ }
388
+ };
389
+ /** Raised when a worktree action needs the canonical hub but it is not injected. */
390
+ var HubUnavailableError = class extends Error {
391
+ constructor(message) {
392
+ super(message);
393
+ this.name = "HubUnavailableError";
394
+ }
395
+ };
396
+ function invalidRequest(message, recovery) {
397
+ return new DoomGitExpectedError("invalid_request", message, false, recovery);
398
+ }
399
+ //#endregion
400
+ //#region src/services/atomicJson/index.ts
401
+ const OWNER_ONLY_FILE = 384;
402
+ /**
403
+ * Writes JSON so a reader never sees a half-written file.
404
+ *
405
+ * The registry is read by the same process that writes it, and by a later
406
+ * process after a crash. A plain write can be interrupted between the truncate
407
+ * and the last byte, which leaves a file that parses as nothing and loses every
408
+ * record in it. Writing beside the target and renaming makes the swap atomic on
409
+ * the same filesystem, so a reader sees either the old file or the new one.
410
+ */
411
+ function writeJsonAtomic(file, value) {
412
+ const directory = path.dirname(file);
413
+ fs.mkdirSync(directory, { recursive: true });
414
+ const temporary = path.join(directory, `.${path.basename(file)}.${String(process.pid)}.tmp`);
415
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: OWNER_ONLY_FILE });
416
+ fs.renameSync(temporary, file);
417
+ }
418
+ /**
419
+ * Reads JSON, treating every failure as absent.
420
+ *
421
+ * A missing file and an unparseable one mean the same thing to every caller
422
+ * here: there is nothing to act on. Distinguishing them would only offer a
423
+ * choice no caller wants to make.
424
+ */
425
+ function readJson(file) {
426
+ try {
427
+ return JSON.parse(fs.readFileSync(file, "utf8"));
428
+ } catch {
429
+ return;
430
+ }
431
+ }
432
+ //#endregion
433
+ //#region src/services/repositoryIdentity/index.ts
434
+ const ID_LENGTH = 12;
435
+ const MAX_LABEL_LENGTH = 32;
436
+ /**
437
+ * The git directory shared by a repository and all of its worktrees.
438
+ *
439
+ * A normal checkout has a `.git` directory. A linked worktree has a `.git`
440
+ * FILE pointing at `<common>/worktrees/<name>`, whose `commondir` points back
441
+ * at the shared directory. Following that chain is what makes every worktree of
442
+ * one repository agree on the repository's identity, which matters here because
443
+ * the registry is per repository and must be found from inside a worktree too.
444
+ * A session may run in a subdirectory, so the nearest enclosing checkout is used.
445
+ */
446
+ function gitCommonDirectory(directory) {
447
+ let root = path.resolve(directory);
448
+ let marker = path.join(root, ".git");
449
+ let stat;
450
+ for (;;) try {
451
+ stat = fs.statSync(marker);
452
+ break;
453
+ } catch {
454
+ const parent = path.dirname(root);
455
+ if (parent === root) return void 0;
456
+ root = parent;
457
+ marker = path.join(root, ".git");
458
+ }
459
+ if (stat.isDirectory()) return marker;
460
+ let pointer;
461
+ try {
462
+ pointer = fs.readFileSync(marker, "utf8").trim();
463
+ } catch {
464
+ return;
465
+ }
466
+ if (!pointer.startsWith("gitdir:")) return void 0;
467
+ const gitDir = path.resolve(root, pointer.slice(7).trim());
468
+ try {
469
+ const common = fs.readFileSync(path.join(gitDir, "commondir"), "utf8").trim();
470
+ return path.resolve(gitDir, common);
471
+ } catch {
472
+ return gitDir;
473
+ }
474
+ }
475
+ /** A directory name that is recognisable to a person and safe on a filesystem. */
476
+ function repositoryLabel(root) {
477
+ const label = path.basename(root).toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, MAX_LABEL_LENGTH);
478
+ return label === "" ? "repository" : label;
479
+ }
480
+ /**
481
+ * A stable id for the repository a directory belongs to.
482
+ *
483
+ * Keyed on the common git directory rather than the given path, so the main
484
+ * checkout and every worktree of it resolve to one id and therefore to one
485
+ * registry. Falling back to the path keeps a non-repository directory usable
486
+ * instead of failing, though nothing here should reach that case.
487
+ */
488
+ function repositoryId(root) {
489
+ const common = gitCommonDirectory(root);
490
+ const token = common === void 0 ? `root:${path.resolve(root)}` : `git:${common}`;
491
+ return createHash("sha256").update(token).digest("hex").slice(0, ID_LENGTH);
492
+ }
493
+ /** A short, collision-resistant suffix for one worktree directory. */
494
+ function shortId(seed) {
495
+ return createHash("sha256").update(seed).digest("hex").slice(0, 8);
496
+ }
497
+ //#endregion
498
+ //#region src/services/paths/index.ts
499
+ /**
500
+ * Where this package keeps its own state.
501
+ *
502
+ * Outside the repository on purpose. A worktree directory inside the checkout
503
+ * would be found by every glob the repository's own tooling runs, and would
504
+ * resolve workspace imports against the parent checkout rather than the
505
+ * worktree, so a session there would build the wrong source while appearing to
506
+ * work.
507
+ */
508
+ function doomGitRoot(homeDir = os.homedir()) {
509
+ return path.join(homeDir, ".pi", ".doom", "git");
510
+ }
511
+ /** The directory every worktree for every repository is created under. */
512
+ function worktreesRoot(homeDir = os.homedir()) {
513
+ return path.join(doomGitRoot(homeDir), "worktrees");
514
+ }
515
+ /**
516
+ * Remote auth for every workspace, one entry per workspace root.
517
+ *
518
+ * Outside every repository, so a token can never be committed, and readable
519
+ * only by the owner. It holds secrets, which no other file here does.
520
+ */
521
+ function credentialsFile(homeDir = os.homedir()) {
522
+ return path.join(doomGitRoot(homeDir), "credentials.json");
523
+ }
524
+ /** One registry file per repository, keyed by the shared git directory. */
525
+ function registryFile(repositoryRoot, homeDir = os.homedir()) {
526
+ const id = repositoryId(repositoryRoot);
527
+ const root = path.join(doomGitRoot(homeDir), "registry");
528
+ const canonical = path.join(root, id, "worktrees.json");
529
+ if (fs.existsSync(canonical)) return canonical;
530
+ let names;
531
+ try {
532
+ names = fs.readdirSync(root);
533
+ } catch {
534
+ return canonical;
535
+ }
536
+ const legacyFiles = names.filter((entry) => entry.endsWith(`--${id}`)).sort().map((entry) => path.join(root, entry, "worktrees.json")).filter((file) => fs.existsSync(file));
537
+ if (legacyFiles.length === 0) return canonical;
538
+ const entries = /* @__PURE__ */ new Map();
539
+ for (const file of legacyFiles) {
540
+ const legacy = readJson(file);
541
+ if (legacy?.version !== 1 || !Array.isArray(legacy.entries)) continue;
542
+ for (const value of legacy.entries) {
543
+ if (typeof value !== "object" || value === null || !("id" in value) || typeof value.id !== "string" || value.id === "") continue;
544
+ const entry = value;
545
+ const existing = entries.get(entry.id);
546
+ if (existing !== void 0 && !isDeepStrictEqual(existing, entry)) throw new Error(`Conflicting legacy worktree registry entries for '${entry.id}'.`);
547
+ entries.set(entry.id, entry);
548
+ }
549
+ }
550
+ const temporary = `${canonical}.${randomUUID()}.migration`;
551
+ writeJsonAtomic(temporary, {
552
+ version: 1,
553
+ entries: [...entries.values()]
554
+ });
555
+ try {
556
+ fs.linkSync(temporary, canonical);
557
+ } catch (error) {
558
+ if (error.code !== "EEXIST") throw error;
559
+ } finally {
560
+ fs.rmSync(temporary);
561
+ }
562
+ return canonical;
563
+ }
564
+ //#endregion
565
+ //#region src/services/worktreeEvents/index.ts
566
+ const GIT_WORKTREE_LIFECYCLE_EVENT = "git_worktree_lifecycle";
567
+ const GIT_WORKTREE_MESSAGE_EVENT = "git_worktree_message";
568
+ /**
569
+ * A session's checkout may have changed: a tool call ended, or the agent
570
+ * settled. Carries nothing but its version; the '# diff' channel recomputes.
571
+ */
572
+ const GIT_SESSION_CHANGED_EVENT = "git_session_changed";
573
+ const MAX_WORKTREE_MESSAGE_BYTES = 65536;
574
+ function isMessageParty(value) {
575
+ return value === "parent" || value === "child";
576
+ }
577
+ function parseMessage(value) {
578
+ if (typeof value !== "object" || value === null) return void 0;
579
+ const record = value;
580
+ if (record.version !== 1) return void 0;
581
+ if (typeof record.worktreeId !== "string" || record.worktreeId === "") return void 0;
582
+ if (typeof record.fromSessionId !== "string" || record.fromSessionId === "") return void 0;
583
+ if (!isMessageParty(record.from)) return void 0;
584
+ if (typeof record.text !== "string" || record.text === "") return void 0;
585
+ if (Buffer.byteLength(record.text, "utf8") > 65536) return void 0;
586
+ if (typeof record.sentAt !== "string" || record.sentAt === "") return void 0;
587
+ return record;
588
+ }
589
+ function createWorktreeMessageInbox(directEvents, sessionId) {
590
+ if (sessionId === "") throw new Error("The Git message service requires a session identity.");
591
+ const messages = /* @__PURE__ */ new Map();
592
+ const unsubscribe = directEvents.subscribe(GIT_WORKTREE_MESSAGE_EVENT, sessionId, (payload) => {
593
+ const message = parseMessage(payload);
594
+ if (message === void 0) return;
595
+ const inbox = messages.get(message.worktreeId) ?? [];
596
+ if (inbox.length >= 100) inbox.shift();
597
+ inbox.push(message);
598
+ messages.set(message.worktreeId, inbox);
599
+ });
600
+ let closed = false;
601
+ return {
602
+ send(targetSessionId, message) {
603
+ if (closed) throw new Error("The Git message service is closed.");
604
+ directEvents.publish(GIT_WORKTREE_MESSAGE_EVENT, targetSessionId, message);
605
+ },
606
+ receive(worktreeId) {
607
+ if (closed) return [];
608
+ const inbox = messages.get(worktreeId);
609
+ if (inbox === void 0) return [];
610
+ messages.delete(worktreeId);
611
+ return inbox;
612
+ },
613
+ close() {
614
+ if (closed) return;
615
+ closed = true;
616
+ unsubscribe();
617
+ messages.clear();
618
+ }
619
+ };
620
+ }
621
+ function isWorktreeLifecycleEvent(value) {
622
+ if (typeof value !== "object" || value === null) return false;
623
+ const record = value;
624
+ return record.version === 1 && typeof record.repositoryRoot === "string" && record.repositoryRoot !== "";
625
+ }
626
+ function isSessionChangedEvent(value) {
627
+ return typeof value === "object" && value !== null && value.version === 1;
628
+ }
629
+ //#endregion
630
+ //#region src/services/worktreeNaming/index.ts
631
+ const MAX_SLUG_LENGTH = 64;
632
+ /** Anything git forbids in a ref, plus a leading `-` that `worktree add` would read as an option. */
633
+ const INVALID_REF = /(^[-/.])|[\s~^:?*[\\]|\.\.|@\{|\/\/|\/\.|\.lock$|\/$|\.$/u;
634
+ const MAX_REF_LENGTH = 255;
635
+ const FIRST_PRINTABLE = 32;
636
+ const DELETE = 127;
637
+ /**
638
+ * A branch name reduced to one safe path segment.
639
+ *
640
+ * Slashes are the point: `wt/fix-auth` is a perfectly good branch and a
641
+ * terrible directory name, because the nested directory it implies is not the
642
+ * one the worktree lives in.
643
+ */
644
+ function branchSlug(branch) {
645
+ const slug = branch.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, MAX_SLUG_LENGTH).replace(/-+$/gu, "");
646
+ return slug === "" ? "worktree" : slug;
647
+ }
648
+ /**
649
+ * The directory for one worktree.
650
+ *
651
+ * The short id disambiguates two branches whose slugs collide, such as
652
+ * `feature/x` and `feature_x`, which would otherwise want the same directory.
653
+ */
654
+ function worktreeDirectory(input) {
655
+ const repository = `${input.repositoryLabel}--${input.repositoryId}`;
656
+ return `${input.worktreesRoot}/${repository}/${branchSlug(input.branch)}--${input.shortId}`;
657
+ }
658
+ /** Why a worktree cannot be created, or undefined when it can. */
659
+ function refuseSpawn(input) {
660
+ if (input.branch.trim() === "") return "A branch name is required.";
661
+ const live = input.existing.find((record) => record.branch === input.branch && record.status !== "orphaned");
662
+ if (live) return `Branch ${input.branch} already has a worktree (${live.id}).`;
663
+ }
664
+ /** Why a worktree cannot be closed, or undefined when it can. */
665
+ function refuseClose(input) {
666
+ if (!input.record) return "No worktree with that id. Call list to see the current ids.";
667
+ if (input.dirtyFiles.length === 0 || input.force) return void 0;
668
+ const names = input.dirtyFiles.slice(0, 10).join(", ");
669
+ const more = input.dirtyFiles.length > 10 ? `, and ${String(input.dirtyFiles.length - 10)} more` : "";
670
+ return `${String(input.dirtyFiles.length)} uncommitted file(s): ${names}${more}. Pass force: true to discard them.`;
671
+ }
672
+ /**
673
+ * Marks records whose process or directory is gone as orphaned.
674
+ *
675
+ * Nothing is deleted here. A doompi-git orphan owns a directory that may hold
676
+ * uncommitted work, so losing the record would lose the only pointer to it.
677
+ * Deletion is always a separate, named decision.
678
+ */
679
+ function reconcile(records, probe) {
680
+ const changed = [];
681
+ return {
682
+ records: records.map((record) => {
683
+ if (record.status === "orphaned") return record;
684
+ if (!(!probe.exists(record.path) || !probe.alive(record))) return record;
685
+ changed.push(record.id);
686
+ return {
687
+ ...record,
688
+ status: "orphaned"
689
+ };
690
+ }),
691
+ changed
692
+ };
693
+ }
694
+ /**
695
+ * What a prune would do, decided before anything is destroyed.
696
+ *
697
+ * Separating the plan from the execution is what makes dryRun honest and lets
698
+ * the destructive half be reviewed on its own.
699
+ */
700
+ function planPrune(input) {
701
+ const plan = {
702
+ remove: [],
703
+ forget: [],
704
+ untracked: [],
705
+ keptDirty: [],
706
+ keptBranches: []
707
+ };
708
+ const known = new Set(input.records.map((record) => record.path));
709
+ const listed = new Set(input.gitPaths);
710
+ for (const record of input.records) {
711
+ if (record.status !== "orphaned") continue;
712
+ if (!listed.has(record.path)) {
713
+ plan.forget.push(record);
714
+ continue;
715
+ }
716
+ if ((input.dirtyByPath.get(record.path) ?? []).length > 0) {
717
+ plan.keptDirty.push(record);
718
+ continue;
719
+ }
720
+ plan.remove.push(record);
721
+ }
722
+ for (const path of input.gitPaths) if (path.startsWith(`${input.worktreesRoot}/`) && !known.has(path)) plan.untracked.push(path);
723
+ return plan;
724
+ }
725
+ /** Whether a user-supplied branch or base ref is safe to hand to git as a positional argument. */
726
+ function validRefName(name) {
727
+ if (name === "" || name === "@" || name.length > MAX_REF_LENGTH || INVALID_REF.test(name)) return false;
728
+ for (const character of name) {
729
+ const code = character.charCodeAt(0);
730
+ if (code < FIRST_PRINTABLE || code === DELETE) return false;
731
+ }
732
+ return true;
733
+ }
734
+ //#endregion
735
+ //#region src/services/worktreeRegistry/index.ts
736
+ const LOCK_WAIT_MS = 5e3;
737
+ const LOCK_RETRY_MS = 25;
738
+ function isRecord(value) {
739
+ if (typeof value !== "object" || value === null) return false;
740
+ const record = value;
741
+ return record.version === 1 && typeof record.id === "string" && record.id !== "" && typeof record.path === "string" && typeof record.branch === "string" && typeof record.sessionId === "string";
742
+ }
743
+ /**
744
+ * Repository mutations hold an exclusive directory lock from the read through
745
+ * the final commit. This works across independently loaded packages and hosts.
746
+ * A crashed owner leaves a visible lock, never a lease another writer may steal.
747
+ */
748
+ function createWorktreeRegistry(file) {
749
+ return {
750
+ list() {
751
+ const parsed = readJson(file);
752
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.entries)) return [];
753
+ return parsed.entries.filter(isRecord);
754
+ },
755
+ replace(entries) {
756
+ writeJsonAtomic(file, {
757
+ version: 1,
758
+ entries
759
+ });
760
+ },
761
+ async transaction(operation, signal) {
762
+ const lock = `${file}.lock`;
763
+ fs.mkdirSync(path.dirname(file), { recursive: true });
764
+ const deadline = Date.now() + LOCK_WAIT_MS;
765
+ for (;;) {
766
+ signal?.throwIfAborted();
767
+ try {
768
+ fs.mkdirSync(lock, { mode: 448 });
769
+ break;
770
+ } catch (error) {
771
+ if (error.code !== "EEXIST") throw error;
772
+ if (Date.now() >= deadline) throw new DoomGitExpectedError("registry_busy", "Another worktree operation holds the repository registry.", true, "Retry after it finishes. After a host crash, inspect and remove the abandoned worktrees.json.lock directory.");
773
+ await setTimeout(LOCK_RETRY_MS, void 0, { signal });
774
+ }
775
+ }
776
+ try {
777
+ fs.writeFileSync(path.join(lock, "owner.json"), JSON.stringify({
778
+ pid: process.pid,
779
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
780
+ }), { mode: 384 });
781
+ signal?.throwIfAborted();
782
+ return await operation();
783
+ } finally {
784
+ fs.rmSync(lock, { recursive: true });
785
+ }
786
+ }
787
+ };
788
+ }
789
+ //#endregion
790
+ //#region src/services/worktreeOperations/index.ts
791
+ const GIT_WORKTREE_DELEGATION_KIND = "git.worktree.delegation";
792
+ const GIT_WORKTREE_MESSAGE_KIND = "git.worktree.message";
793
+ const GIT_WORKTREE_REPORT_KIND = "git.worktree.report";
794
+ function createWorktreeOperations(deps) {
795
+ const { git } = deps;
796
+ const sessionService = deps.sessionService;
797
+ const mirror = deps.mirror ?? mirrorComposition;
798
+ const removeStorage = deps.cleanupStorage ?? ((repositoryRoot, worktreeRoot) => {
799
+ removeSyncLocation(resolveWorktreeSyncLocation(repositoryRoot, worktreeRoot, deps.homeDir));
800
+ });
801
+ const cleanupStorage = (repositoryRoot, worktreeRoot) => {
802
+ try {
803
+ removeStorage(repositoryRoot, worktreeRoot);
804
+ } catch (error) {
805
+ throw new DoomGitExpectedError("worktree_cleanup_failed", `The worktree checkout was removed, but its generated storage could not be deleted (${error instanceof Error ? error.message : String(error)}).`, true, "Retry close or prune after fixing the storage permissions.");
806
+ }
807
+ };
808
+ const now = deps.now ?? (() => /* @__PURE__ */ new Date());
809
+ const requireSessionService = () => {
810
+ if (sessionService === void 0) throw new HubUnavailableError("The cockpit session service is unavailable. Start the cockpit and try again.");
811
+ return sessionService;
812
+ };
813
+ const requireMessageInbox = () => {
814
+ if (deps.messageInbox === void 0) throw new HubUnavailableError("The Git message service is unavailable. Start the cockpit and try again.");
815
+ return deps.messageInbox;
816
+ };
817
+ const readSessionDelivery = () => deps.sessionDelivery?.();
818
+ const requireSessionDelivery = () => {
819
+ const delivery = readSessionDelivery();
820
+ if (delivery === void 0) throw new HubUnavailableError("The Session delivery service is unavailable. Start the cockpit and try again.");
821
+ return delivery;
822
+ };
823
+ const probe = {
824
+ alive: (record) => requireSessionService().isLive(record.sessionId),
825
+ exists: (path) => fs.existsSync(path)
826
+ };
827
+ /**
828
+ * Everything an action needs about the repository it is acting on.
829
+ *
830
+ * Resolved per call rather than cached: a session's cwd is stable, but the
831
+ * worktree registry is shared and another process may have written it since
832
+ * the last call.
833
+ */
834
+ const resolve = async (context) => {
835
+ const root = await git.repositoryRoot(context.cwd);
836
+ if (root === void 0) throw new DoomGitExpectedError("not_a_repository", `${context.cwd} is not inside a git repository.`, false, "Run this from a repository checkout.");
837
+ const store = createWorktreeRegistry(registryFile(root, deps.homeDir));
838
+ return {
839
+ root,
840
+ store,
841
+ records: store.list()
842
+ };
843
+ };
844
+ const require = (records, id) => {
845
+ const record = records.find((entry) => entry.id === id);
846
+ if (record === void 0) throw new DoomGitExpectedError("worktree_not_found", `No worktree with id ${id}.`, false, "Call list to see the current ids.");
847
+ return record;
848
+ };
849
+ /**
850
+ * Refuses to destroy or merge a worktree another live session is using.
851
+ *
852
+ * `parentSessionId` records who asked for the worktree. Two sessions in one
853
+ * checkout share this registry, so without the check either could close the
854
+ * other's work in progress by id.
855
+ *
856
+ * Once that session is gone there is no owner left to protect, and refusing
857
+ * would strand the worktree: nothing else could ever close it.
858
+ */
859
+ const requireOwner = (context, record, action) => {
860
+ if (record.parentSessionId === context.sessionId) return;
861
+ if (record.parentSessionId === void 0 || !requireSessionService().isLive(record.parentSessionId)) return;
862
+ throw new DoomGitExpectedError("worktree_not_owned", `Worktree ${record.id} belongs to another session.`, false, `Ask the session that created it to ${action} it, or close that session first.`);
863
+ };
864
+ const peerFor = (context, record) => {
865
+ if (record.parentSessionId === void 0) throw new DoomGitExpectedError("worktree_peer_unavailable", `Worktree ${record.id} was opened on its own and has no parent session to message.`, false, "Message a worktree this session created.");
866
+ if (context.sessionId === record.parentSessionId) return {
867
+ sessionId: record.sessionId,
868
+ party: "parent"
869
+ };
870
+ if (context.sessionId === record.sessionId) return {
871
+ sessionId: record.parentSessionId,
872
+ party: "child"
873
+ };
874
+ throw new DoomGitExpectedError("worktree_not_owned", `Worktree ${record.id} belongs to another session.`, false, "Use a worktree owned by this session.");
875
+ };
876
+ const requirePeerLive = (peerSessionId) => {
877
+ if (!requireSessionService().isLive(peerSessionId)) throw new DoomGitExpectedError("worktree_peer_unavailable", "The other worktree session is no longer live.", false, "Start a new worktree session before sending a message.");
878
+ };
879
+ const operations = {
880
+ async spawn(context, request, options) {
881
+ const parentSessionId = context.sessionId;
882
+ const parented = parentSessionId === void 0 ? {} : { parentSessionId };
883
+ const { root, store, records } = await resolve(context);
884
+ const reservations = request.reservationId === void 0 ? void 0 : requireSessionService().reservations;
885
+ if (request.reservationId !== void 0 && (!reservations || request.task !== void 0)) throw new DoomGitExpectedError("invalid_request", "Reserved worktree sessions require host setup and cannot start a task.", false, "Create the session from its pending setup card.");
886
+ const reserved = request.reservationId === void 0 ? void 0 : reservations.read(request.reservationId, parentSessionId);
887
+ const existing = reserved === void 0 ? void 0 : records.find((record) => record.sessionId === reserved.sessionId);
888
+ if (existing) {
889
+ const expectedPath = worktreeDirectory({
890
+ worktreesRoot: worktreesRoot(deps.homeDir),
891
+ repositoryLabel: repositoryLabel(root),
892
+ repositoryId: repositoryId(root),
893
+ branch: request.branch,
894
+ shortId: shortId(reserved.sessionId)
895
+ });
896
+ if (existing.parentSessionId !== parentSessionId || existing.repositoryRoot !== root || existing.branch !== request.branch || existing.path !== expectedPath || existing.path !== reserved?.cwd || request.baseRef !== void 0 && existing.baseRef !== request.baseRef || !fs.existsSync(existing.path) || !(await git.listWorktreePaths(root)).includes(existing.path) || await git.repositoryRoot(existing.path) !== existing.path || await git.currentBranch(existing.path) !== request.branch) throw new DoomGitExpectedError("invalid_request", "The reserved checkout no longer matches this setup.", false, "Inspect the existing branch and checkout; they will not be replaced.");
897
+ if (!requireSessionService().isLive(existing.sessionId)) {
898
+ if ((await requireSessionService().create({
899
+ cwd: existing.path,
900
+ name: request.name ?? request.branch,
901
+ parentSessionId,
902
+ sessionProvenance: "worktree",
903
+ reservationId: request.reservationId,
904
+ ...options?.signal === void 0 ? {} : { signal: options.signal }
905
+ })).sessionId !== existing.sessionId) throw new Error("The host did not use the reserved session identity.");
906
+ }
907
+ await reservations.complete(request.reservationId, parentSessionId);
908
+ return existing;
909
+ }
910
+ const refusal = refuseSpawn({
911
+ branch: request.branch,
912
+ existing: records
913
+ });
914
+ if (refusal !== void 0) throw new DoomGitExpectedError("worktree_exists", refusal, false, "Pick another branch name, or close it.");
915
+ const taskDelivery = request.task === void 0 ? void 0 : requireSessionDelivery();
916
+ const existingBranch = request.checkout === "existing";
917
+ if (existingBranch) {
918
+ const branches = await git.listBranches(root);
919
+ const local = branches.local.find((branch) => branch.name === request.branch);
920
+ if (!(request.remote === void 0 ? local !== void 0 && local.checkedOutAt === void 0 : local === void 0 && branches.remote.some((branch) => branch.remote === request.remote && branch.name === request.branch))) throw new DoomGitExpectedError("invalid_request", local?.checkedOutAt === void 0 ? `The branch ${request.branch} is not available to check out.` : `The branch ${request.branch} is already checked out at ${local.checkedOutAt}.`, false, "Pick another branch, or create a new one.");
921
+ } else if ((await git.listBranches(root)).local.some((branch) => branch.name === request.branch)) throw new DoomGitExpectedError("invalid_request", `The branch ${request.branch} already exists.`, false, "Pick another name, or open it as an existing branch.");
922
+ const baseRef = existingBranch ? request.remote === void 0 ? request.branch : `${request.remote}/${request.branch}` : request.baseRef ?? await git.remoteBaseRef(root);
923
+ if (baseRef === void 0) throw new DoomGitExpectedError("git_failed", "No remote base branch is available for this repository.", false, "Fetch a remote branch or pass an explicit baseRef.");
924
+ const id = shortId(reserved?.sessionId ?? randomUUID());
925
+ let path = worktreeDirectory({
926
+ worktreesRoot: worktreesRoot(deps.homeDir),
927
+ repositoryLabel: repositoryLabel(root),
928
+ repositoryId: repositoryId(root),
929
+ branch: request.branch,
930
+ shortId: id
931
+ });
932
+ if (request.reservationId !== void 0) path = (await reservations.prepare(request.reservationId, parentSessionId, path)).cwd;
933
+ const recovering = reserved?.cwd !== void 0 && fs.existsSync(path);
934
+ if (recovering && (!(await git.listWorktreePaths(root)).includes(path) || await git.currentBranch(path) !== request.branch)) throw new DoomGitExpectedError("invalid_request", "The reserved checkout no longer matches this setup.", false, "Inspect the existing directory; it will not be replaced.");
935
+ const cancelled = () => options?.signal?.aborted === true;
936
+ const rollback = async () => {
937
+ if (reserved) return ` The reserved checkout at ${path} was retained for recovery.`;
938
+ await git.removeWorktree({
939
+ repositoryRoot: root,
940
+ path,
941
+ force: true
942
+ }).catch(() => void 0);
943
+ if (existingBranch && request.remote === void 0) return "";
944
+ return await git.deleteBranch({
945
+ repositoryRoot: root,
946
+ branch: request.branch
947
+ }).catch(() => false) ? "" : ` The branch ${request.branch} has work on it and was kept.`;
948
+ };
949
+ options?.onProgress?.(`creating branch ${request.branch}\u2026`);
950
+ if (!recovering) {
951
+ if (existingBranch) await git.addExistingWorktree({
952
+ repositoryRoot: root,
953
+ path,
954
+ branch: request.branch,
955
+ ...request.remote === void 0 ? {} : { remote: request.remote }
956
+ });
957
+ else await git.addWorktree({
958
+ repositoryRoot: root,
959
+ path,
960
+ branch: request.branch,
961
+ baseRef
962
+ });
963
+ }
964
+ if (cancelled()) throw new DoomGitExpectedError("spawn_cancelled", `Cancelled before the session started.${await rollback()}`, false, reserved ? "Retry setup with the same branch to recover the reserved checkout." : "The worktree was removed; run it again when you are ready.");
965
+ options?.onProgress?.("mirroring build output…");
966
+ if (!recovering) mirror(root, path);
967
+ options?.onProgress?.("starting session…");
968
+ let sessionId;
969
+ try {
970
+ sessionId = (await requireSessionService().create({
971
+ cwd: path,
972
+ name: request.name ?? request.branch,
973
+ ...parented,
974
+ sessionProvenance: "worktree",
975
+ ...options?.signal === void 0 ? {} : { signal: options.signal },
976
+ ...request.reservationId === void 0 ? {} : { reservationId: request.reservationId }
977
+ })).sessionId;
978
+ if (reserved && sessionId !== reserved.sessionId) throw new Error("The host did not use the reserved session identity.");
979
+ } catch (error) {
980
+ const kept = await rollback();
981
+ if (cancelled()) throw new DoomGitExpectedError("spawn_cancelled", `Cancelled while the session was starting.${kept}`, false, reserved ? "Retry setup with the same branch to recover the reserved checkout." : "The worktree was removed; run it again when you are ready.");
982
+ if (error instanceof HubUnavailableError) throw new DoomGitExpectedError("hub_unavailable", `${error.message}${kept}`, true, "Start the cockpit and try again.");
983
+ throw error;
984
+ }
985
+ const record = {
986
+ version: 1,
987
+ id,
988
+ branch: request.branch,
989
+ baseRef,
990
+ path,
991
+ repositoryRoot: root,
992
+ sessionId,
993
+ ...parented,
994
+ status: "running",
995
+ createdAt: now().toISOString()
996
+ };
997
+ try {
998
+ store.replace([...records, record]);
999
+ } catch (error) {
1000
+ await requireSessionService().close(sessionId).catch(() => void 0);
1001
+ const kept = await rollback();
1002
+ throw new DoomGitExpectedError("registry_write_failed", `The worktree was created but the registry could not be written (${error.message}).${kept}`, true, "Check the registry file for permissions or free space, then try again.");
1003
+ }
1004
+ if (request.task !== void 0 && taskDelivery !== void 0) try {
1005
+ const receipt = await taskDelivery.deliver({
1006
+ recipientKey: sessionId,
1007
+ kind: GIT_WORKTREE_DELEGATION_KIND,
1008
+ prompt: request.task,
1009
+ metadata: {
1010
+ worktreeId: record.id,
1011
+ fromRole: "parent",
1012
+ text: request.task,
1013
+ sentAt: record.createdAt
1014
+ }
1015
+ });
1016
+ const admission = await taskDelivery.waitForAdmission(receipt.deliveryId);
1017
+ if (admission !== "admitted") throw new DoomGitExpectedError("task_delivery_failed", `Worktree ${record.id} was created and task delivery ${receipt.deliveryId} was persisted, but admission ${admission === "recovery_required" ? "requires recovery" : "was not confirmed before the timeout"}.`, true, admission === "recovery_required" ? `Call messages from worktree ${record.id} to recover the task in the recipient session. Do not resend it automatically.` : `Check delivery ${receipt.deliveryId} in the sender outbox or call messages from worktree ${record.id} in the recipient session before deciding whether to resend.`);
1018
+ } catch (error) {
1019
+ if (error instanceof DoomGitExpectedError) throw error;
1020
+ throw new DoomGitExpectedError("task_delivery_failed", `Worktree ${record.id} was created, but task delivery failed (${String(error)}).`, true, `Inspect worktree ${record.id} and the sender outbox before using send to deliver the task. Do not recreate the worktree.`);
1021
+ }
1022
+ if (request.reservationId !== void 0) await reservations.complete(request.reservationId, parentSessionId);
1023
+ return record;
1024
+ },
1025
+ async close(context, id, force) {
1026
+ const { root, store, records } = await resolve(context);
1027
+ const record = require(records, id);
1028
+ requireOwner(context, record, "close");
1029
+ const refusal = refuseClose({
1030
+ record,
1031
+ dirtyFiles: fs.existsSync(record.path) ? await git.dirtyFiles(record.path) : [],
1032
+ force
1033
+ });
1034
+ if (refusal !== void 0) throw new DoomGitExpectedError("worktree_dirty", refusal, false, "Commit the work, or pass force: true.");
1035
+ await requireSessionService().close(record.sessionId);
1036
+ await git.removeWorktree({
1037
+ repositoryRoot: root,
1038
+ path: record.path,
1039
+ force
1040
+ });
1041
+ cleanupStorage(root, record.path);
1042
+ store.replace(records.filter((entry) => entry.id !== id));
1043
+ return record;
1044
+ },
1045
+ async list(context) {
1046
+ const { store, records } = await resolve(context);
1047
+ const outcome = reconcile(records, probe);
1048
+ if (outcome.changed.length > 0) store.replace(outcome.records);
1049
+ return outcome.records;
1050
+ },
1051
+ async status(context, id) {
1052
+ const { records } = await resolve(context);
1053
+ const record = require(records, id);
1054
+ return {
1055
+ record,
1056
+ dirtyFiles: fs.existsSync(record.path) ? await git.dirtyFiles(record.path) : []
1057
+ };
1058
+ },
1059
+ async merge(context, id, message) {
1060
+ const { root, records } = await resolve(context);
1061
+ const record = require(records, id);
1062
+ requireOwner(context, record, "merge");
1063
+ const parentDirty = await git.dirtyFiles(root);
1064
+ if (parentDirty.length > 0) throw new DoomGitExpectedError("worktree_dirty", `The parent checkout has ${String(parentDirty.length)} uncommitted file(s): ${parentDirty.slice(0, 10).join(", ")}.`, false, "Commit or stash the parent checkout first.");
1065
+ await git.mergeBranch({
1066
+ repositoryRoot: root,
1067
+ branch: record.branch,
1068
+ ...message === void 0 ? {} : { message }
1069
+ });
1070
+ return record;
1071
+ },
1072
+ /** Sends only between the parent session and its own worktree session. */
1073
+ async send(context, id, message) {
1074
+ if (Buffer.byteLength(message, "utf8") > 65536) throw new DoomGitExpectedError("message_too_large", `A message may not exceed ${String(MAX_WORKTREE_MESSAGE_BYTES)} bytes.`, false, "Send a shorter message.");
1075
+ const { records } = await resolve(context);
1076
+ const record = require(records, id);
1077
+ const peer = peerFor(context, record);
1078
+ requirePeerLive(peer.sessionId);
1079
+ const sentAt = (/* @__PURE__ */ new Date()).toISOString();
1080
+ const delivery = readSessionDelivery();
1081
+ if (delivery !== void 0) {
1082
+ const kind = peer.party === "parent" ? GIT_WORKTREE_MESSAGE_KIND : GIT_WORKTREE_REPORT_KIND;
1083
+ const receipt = await delivery.deliver({
1084
+ recipientKey: peer.sessionId,
1085
+ kind,
1086
+ prompt: peer.party === "parent" ? `Parent message for worktree ${record.id}:\n\n${message}` : `Report from worktree ${record.id}:\n\n${message}`,
1087
+ metadata: {
1088
+ worktreeId: record.id,
1089
+ fromRole: peer.party,
1090
+ text: message,
1091
+ sentAt
1092
+ }
1093
+ });
1094
+ const admission = await delivery.waitForAdmission(receipt.deliveryId);
1095
+ if (admission !== "admitted") throw new DoomGitExpectedError("message_delivery_failed", `Worktree ${record.id} persisted message delivery ${receipt.deliveryId}, but admission ${admission === "recovery_required" ? "requires recovery" : "was not confirmed before the timeout"}.`, true, admission === "recovery_required" ? `Call messages from the recipient session to recover delivery ${receipt.deliveryId}. Do not resend it automatically.` : `Check delivery ${receipt.deliveryId} in the sender outbox or call messages from the recipient session before deciding whether to resend.`);
1096
+ return;
1097
+ }
1098
+ requireMessageInbox().send(peer.sessionId, {
1099
+ version: 1,
1100
+ worktreeId: record.id,
1101
+ fromSessionId: context.sessionId,
1102
+ from: peer.party,
1103
+ text: message,
1104
+ sentAt
1105
+ });
1106
+ },
1107
+ async messages(context, id) {
1108
+ const { records } = await resolve(context);
1109
+ const record = require(records, id);
1110
+ const peer = peerFor(context, record);
1111
+ const delivery = readSessionDelivery();
1112
+ if (delivery === void 0) return requireMessageInbox().receive(record.id);
1113
+ return delivery.inbox({
1114
+ consumed: false,
1115
+ metadata: { worktreeId: record.id }
1116
+ }).filter((entry) => entry.senderKey === peer.sessionId && (entry.state === "admitted" || entry.state === "recovery_required") && (peer.party === "parent" ? entry.kind === GIT_WORKTREE_REPORT_KIND : entry.kind === GIT_WORKTREE_MESSAGE_KIND || entry.kind === GIT_WORKTREE_DELEGATION_KIND)).map((entry) => {
1117
+ delivery.consume(entry.deliveryId);
1118
+ return {
1119
+ version: 1,
1120
+ worktreeId: record.id,
1121
+ fromSessionId: entry.senderKey,
1122
+ from: peer.party === "parent" ? "child" : "parent",
1123
+ text: entry.state === "recovery_required" ? `[Recovery required: prompt admission was not confirmed] ${entry.metadata.text ?? entry.prompt}` : entry.metadata.text ?? entry.prompt,
1124
+ sentAt: entry.metadata.sentAt ?? ""
1125
+ };
1126
+ });
1127
+ },
1128
+ async prune(context, dryRun) {
1129
+ const { root, store, records } = await resolve(context);
1130
+ const reconciled = reconcile(records, probe);
1131
+ const gitPaths = await git.listWorktreePaths(root);
1132
+ const dirtyByPath = /* @__PURE__ */ new Map();
1133
+ for (const record of reconciled.records) if (record.status === "orphaned" && fs.existsSync(record.path)) dirtyByPath.set(record.path, await git.dirtyFiles(record.path));
1134
+ const plan = planPrune({
1135
+ records: reconciled.records,
1136
+ gitPaths,
1137
+ worktreesRoot: worktreesRoot(deps.homeDir),
1138
+ dirtyByPath
1139
+ });
1140
+ if (dryRun) {
1141
+ store.replace(reconciled.records);
1142
+ return plan;
1143
+ }
1144
+ for (const record of plan.remove) {
1145
+ await git.removeWorktree({
1146
+ repositoryRoot: root,
1147
+ path: record.path,
1148
+ force: false
1149
+ });
1150
+ cleanupStorage(root, record.path);
1151
+ }
1152
+ for (const record of plan.forget) cleanupStorage(root, record.path);
1153
+ for (const record of [...plan.remove, ...plan.forget]) if (!await git.deleteBranch({
1154
+ repositoryRoot: root,
1155
+ branch: record.branch
1156
+ }).catch(() => false)) plan.keptBranches.push(record.branch);
1157
+ const dropped = new Set([...plan.remove, ...plan.forget].map((record) => record.id));
1158
+ store.replace(reconciled.records.filter((record) => !dropped.has(record.id)));
1159
+ return plan;
1160
+ }
1161
+ };
1162
+ const locked = async (context, operation, signal) => {
1163
+ try {
1164
+ const { store } = await resolve(context);
1165
+ return await store.transaction(operation, signal);
1166
+ } catch (error) {
1167
+ if (signal?.aborted && !(error instanceof DoomGitExpectedError)) throw new DoomGitExpectedError("spawn_cancelled", "The worktree operation was cancelled.", false, "Inspect its recorded state before retrying.");
1168
+ if ([
1169
+ "ENOTDIR",
1170
+ "EACCES",
1171
+ "EROFS",
1172
+ "ENOSPC"
1173
+ ].includes(String(error.code))) throw new DoomGitExpectedError("registry_write_failed", "The worktree registry is not writable.", true, "Check permissions and free space before retrying.");
1174
+ throw error;
1175
+ }
1176
+ };
1177
+ return {
1178
+ ...operations,
1179
+ spawn: (context, request, options) => {
1180
+ if (sessionService === void 0) return Promise.reject(new DoomGitExpectedError("hub_unavailable", "The cockpit session service is unavailable.", true, "Use the cockpit worktree tool after its Git server facet is available."));
1181
+ if (!validRefName(request.branch) || request.baseRef !== void 0 && !validRefName(request.baseRef)) return Promise.reject(new DoomGitExpectedError("invalid_request", "That is not a valid branch name.", false, "Pick another name."));
1182
+ if (request.reservationId !== void 0 && context.sessionId === void 0) return Promise.reject(new DoomGitExpectedError("invalid_request", "A reserved worktree session requires its owning parent.", false, "Create the session from its pending setup card."));
1183
+ return locked(context, () => operations.spawn(context, request, options), options?.signal);
1184
+ },
1185
+ close: (context, id, force) => locked(context, () => operations.close(context, id, force)),
1186
+ list: (context) => locked(context, () => operations.list(context)),
1187
+ merge: (context, id, message) => locked(context, () => operations.merge(context, id, message)),
1188
+ prune: (context, dryRun) => locked(context, () => operations.prune(context, dryRun))
1189
+ };
1190
+ }
1191
+ //#endregion
1192
+ export { NETWORK_TIMEOUT_MS as _, GIT_WORKTREE_LIFECYCLE_EVENT as a, createWorktreeGit as b, isWorktreeLifecycleEvent as c, registryFile as d, readJson as f, invalidRequest as g, HubUnavailableError as h, GIT_SESSION_CHANGED_EVENT as i, credentialsFile as l, DoomGitExpectedError as m, createWorktreeRegistry as n, createWorktreeMessageInbox as o, writeJsonAtomic as p, validRefName as r, isSessionChangedEvent as s, createWorktreeOperations as t, doomGitRoot as u, READ_TIMEOUT_MS as v, runGit as x, REBASE_TIMEOUT_MS as y };
1193
+
1194
+ //# sourceMappingURL=worktreeOperations-BrWG2muV.mjs.map