agentbox-app 0.1.12 → 0.1.13

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 (214) hide show
  1. package/main/acp.mjs +296 -0
  2. package/main/agent-homes.mjs +158 -0
  3. package/main/agent-sessions.mjs +57 -20
  4. package/main/agent-updates.mjs +9 -22
  5. package/main/analytics.mjs +30 -0
  6. package/main/claude-bin.mjs +45 -311
  7. package/main/claude-input.mjs +9 -3
  8. package/main/claude-launch-env.mjs +54 -0
  9. package/main/claude-models.mjs +39 -7
  10. package/main/claude-plan.mjs +16 -16
  11. package/main/claude-usage.mjs +82 -25
  12. package/main/codex-bin.mjs +51 -249
  13. package/main/codex-session.mjs +38 -3
  14. package/main/codex-threads.mjs +41 -8
  15. package/main/codex-watch.mjs +67 -3
  16. package/main/config.mjs +26 -4
  17. package/main/corner-tag.mjs +489 -0
  18. package/main/crash-report.mjs +26 -3
  19. package/main/engine-bin.mjs +595 -0
  20. package/main/engine-setup.mjs +4 -15
  21. package/main/feedback.mjs +11 -6
  22. package/main/harnesses/claude-stream.mjs +208 -0
  23. package/main/harnesses/claude.mjs +84 -0
  24. package/main/harnesses/codex.mjs +65 -0
  25. package/main/harnesses/common.mjs +78 -0
  26. package/main/harnesses/instructions-file.mjs +116 -0
  27. package/main/harnesses/opencode.mjs +234 -0
  28. package/main/harnesses.mjs +65 -0
  29. package/main/importable-threads.mjs +38 -0
  30. package/main/ipc.mjs +165 -31
  31. package/main/local-app-frames.mjs +82 -0
  32. package/main/main.mjs +100 -4
  33. package/main/memory-gate-history.mjs +51 -12
  34. package/main/memory-gate-server.mjs +67 -11
  35. package/main/memory-gate.mjs +49 -8
  36. package/main/memory-log.mjs +124 -0
  37. package/main/notify.mjs +47 -4
  38. package/main/opencode-server.mjs +271 -0
  39. package/main/opencode.mjs +287 -0
  40. package/main/phone-link.mjs +551 -0
  41. package/main/relay-address.mjs +28 -0
  42. package/main/relay-client.mjs +246 -0
  43. package/main/row-label.mjs +6 -6
  44. package/main/runs-on.mjs +32 -35
  45. package/main/serve.mjs +113 -9
  46. package/main/settings.mjs +148 -10
  47. package/main/shell-path.mjs +86 -0
  48. package/main/ship-queue.mjs +24 -6
  49. package/main/sign-in-files.mjs +3 -7
  50. package/main/source-updater.mjs +27 -1
  51. package/main/start-hold.mjs +73 -0
  52. package/main/store.mjs +1 -1
  53. package/main/supervisor.mjs +832 -428
  54. package/main/task-commands.mjs +13 -17
  55. package/main/task-folders-offthread.mjs +6 -1
  56. package/main/task-folders.mjs +490 -98
  57. package/main/task-terminals.mjs +37 -7
  58. package/main/updater.mjs +71 -9
  59. package/main/worktree-include.mjs +494 -0
  60. package/package.json +10 -1
  61. package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
  62. package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
  63. package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
  64. package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
  65. package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
  66. package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
  67. package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
  68. package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
  69. package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
  70. package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
  71. package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
  72. package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
  73. package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
  74. package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
  75. package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
  76. package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
  77. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
  78. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
  79. package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
  80. package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
  81. package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
  82. package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  83. package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
  84. package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  85. package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
  86. package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
  87. package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
  88. package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
  89. package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
  90. package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
  91. package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
  92. package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
  93. package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
  94. package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
  95. package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
  96. package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
  97. package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
  98. package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
  99. package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
  100. package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
  101. package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
  102. package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
  103. package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
  104. package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
  105. package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
  106. package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
  107. package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
  108. package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
  109. package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  110. package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
  111. package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
  112. package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
  113. package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
  114. package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
  115. package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
  116. package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
  117. package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
  118. package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
  119. package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
  120. package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
  121. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
  122. package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
  123. package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
  124. package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
  125. package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
  126. package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
  127. package/renderer/dist/corner-tag.html +13 -0
  128. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  129. package/renderer/dist/emulator/data/compression/README.md +11 -0
  130. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  131. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  132. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  133. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  134. package/renderer/dist/emulator/data/cores/README.md +22 -0
  135. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  136. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  137. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  138. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  139. package/renderer/dist/emulator/data/cores/package.json +65 -0
  140. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  141. package/renderer/dist/emulator/data/emulator.css +1614 -0
  142. package/renderer/dist/emulator/data/loader.js +170 -0
  143. package/renderer/dist/emulator/data/localization/README.md +63 -0
  144. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  145. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  146. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  147. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  148. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  149. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  150. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  151. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  152. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  153. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  154. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  155. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  156. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  157. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  158. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  159. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  160. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  161. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  162. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  163. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  164. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  165. package/renderer/dist/emulator/data/src/compression.js +142 -0
  166. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  167. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  168. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  169. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  170. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  171. package/renderer/dist/emulator/data/src/storage.js +125 -0
  172. package/renderer/dist/emulator/data/version.json +5 -0
  173. package/renderer/dist/emulator/host.js +297 -0
  174. package/renderer/dist/emulator/index.html +28 -0
  175. package/renderer/dist/emulator/provenance.json +6 -0
  176. package/renderer/dist/index.html +16 -3
  177. package/scripts/approval-server.sh +24 -5
  178. package/scripts/before-publish.mjs +12 -10
  179. package/shared/agent-import.mjs +2 -0
  180. package/shared/agents.d.mts +1 -0
  181. package/shared/bridge-map.mjs +18 -0
  182. package/shared/claude-commands.generated.mjs +6 -4
  183. package/shared/claude-models.generated.mjs +6 -4
  184. package/shared/corner-tag.d.mts +14 -0
  185. package/shared/corner-tag.mjs +141 -0
  186. package/shared/engines.mjs +20 -5
  187. package/shared/harness-definitions.mjs +103 -0
  188. package/shared/notify-rules.d.mts +25 -0
  189. package/shared/notify-rules.mjs +19 -0
  190. package/shared/provider-commands.mjs +10 -2
  191. package/shared/rank.d.mts +2 -0
  192. package/shared/rank.mjs +50 -0
  193. package/shared/relay-address.mjs +6 -0
  194. package/shared/relay-crypto.mjs +165 -0
  195. package/shared/runs-on.d.mts +0 -10
  196. package/shared/runs-on.mjs +25 -49
  197. package/shared/shortcuts.mjs +10 -4
  198. package/shared/spawn-trouble.mjs +57 -6
  199. package/shared/terminal-input.d.mts +1 -0
  200. package/shared/terminal-input.mjs +30 -0
  201. package/shared/terminal-keys.d.mts +1 -0
  202. package/shared/terminal-keys.mjs +16 -0
  203. package/shared/terminal-size.d.mts +4 -0
  204. package/shared/terminal-size.mjs +29 -0
  205. package/shared/usage.d.mts +3 -0
  206. package/shared/usage.mjs +7 -0
  207. package/shared/where-it-runs-from.d.mts +43 -0
  208. package/shared/where-it-runs-from.mjs +155 -0
  209. package/shared/work-items.mjs +25 -1
  210. package/shared/work-lines.mjs +1 -1
  211. package/shared/xterm-invalid-range.d.mts +2 -0
  212. package/shared/xterm-invalid-range.mjs +58 -0
  213. package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
  214. package/renderer/dist/assets/index-ClBXnq3B.css +0 -32
@@ -34,12 +34,36 @@
34
34
  // the lock carries the owning pid and a release from anyone else checks
35
35
  // whether that process is still alive.
36
36
  //
37
+ // WHICH MEANS THE FOLDER HAS AN OWNER, AND TAKING IT IS AN ANSWER (2026-10-07).
38
+ // It used to be a side effect that could not fail: `lock` only wrote a lock
39
+ // when there was none, so the folder a restarted app picked up still named the
40
+ // pid of the app that had exited, and anybody reading that lock found a dead
41
+ // owner and was free to delete the folder a live agent was standing in. A live
42
+ // FOREIGN lock did not stop reuse either, so two app processes over one
43
+ // checkout — one from the app folder, one from a checkout, which happens here
44
+ // daily — both got the same folder and both were told it was theirs.
45
+ //
46
+ // So `acquireTaskFolder` answers: taken, or refused and by whom. Taking over a
47
+ // lock whose owner is gone is a deliberate step that WRITES the new owner. A
48
+ // lock somebody put on by hand carries no pid of ours and is nobody's to take.
49
+ // The half-built copy at `.building-<id>` carries the same ownership, so one
50
+ // app's build is never swept away by the other's.
51
+ //
52
+ // AND THE FOLDER IS THE COMMIT PLUS WHAT THE REPOSITORY ASKED FOR (2026-10-07,
53
+ // w-5952e6de3e). A worktree holds tracked content and nothing else, so every
54
+ // file a repository needs and git does not track was missing: measured that day,
55
+ // five of the seven task folders then live had no `zero.config.json`, the app's
56
+ // own config, and ran on fallback defaults. The list of local files to carry is
57
+ // `.worktreeinclude` at the repository root, read by `main/worktree-include.mjs`,
58
+ // which is the same file Conductor and Claude Code read for the same purpose.
59
+ //
37
60
  // Branches are never deleted here. Every commit survives the folder, so the
38
61
  // worst this can do is make somebody run `git worktree add` again.
39
62
  import { execFileSync } from 'node:child_process';
40
63
  import fs from 'node:fs';
41
64
  import path from 'node:path';
42
65
  import { nameSlug } from '../shared/product-name.mjs';
66
+ import { carryLocalFiles, copyTree, onlyTrackedContent, trackedTopLevel } from './worktree-include.mjs';
43
67
 
44
68
  /** Where Claude Code puts its own, so both engines and a human agree. */
45
69
  const WORKTREES = ['.claude', 'worktrees'];
@@ -57,15 +81,27 @@ const git = (cwd, args) => execFileSync('git', args, {
57
81
  cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
58
82
  }).trim();
59
83
 
84
+ /**
85
+ * What is in a directory. One we cannot even read is reported as holding
86
+ * something, because the only decision taken on this answer is whether the
87
+ * directory is safe to delete, and "we could not look" is not a yes.
88
+ */
89
+ function tryRead(dir) {
90
+ try { return fs.readdirSync(dir); } catch { return ['we could not look']; }
91
+ }
92
+
60
93
  /** Git that answers rather than throws, for the questions where "no" is fine. */
61
94
  function tryGit(cwd, args) {
62
95
  try { return { ok: true, out: git(cwd, args) }; }
63
96
  catch (error) { return { ok: false, out: String(error?.stderr ?? error?.message ?? '').trim() }; }
64
97
  }
65
98
 
99
+ /** `safeTaskName`'s question, asked of a folder already on the disk without throwing. */
100
+ const couldBeATaskName = (name) => SAFE_NAME.test(name) && String(name).length <= 64;
101
+
66
102
  export function safeTaskName(id) {
67
103
  const name = String(id ?? '');
68
- if (!SAFE_NAME.test(name) || name.length > 64) {
104
+ if (!couldBeATaskName(name)) {
69
105
  throw Error(`Not a name a task folder may be given: ${JSON.stringify(id)}`);
70
106
  }
71
107
  return name;
@@ -161,8 +197,74 @@ function registered(repoPath) {
161
197
  const record = (repoPath, folder) => registered(repoPath).find((w) => w.path === folder) ?? null;
162
198
 
163
199
  /**
164
- * The folder this task works in, made if it is not there yet. those
165
- * sessions keep running exactly where they ran before.
200
+ * WHO HOLDS THIS FOLDER, which is the one question every answer here is built
201
+ * on. Four answers and they are not the same shape:
202
+ *
203
+ * no lock at all free, and whoever asks may write their own
204
+ * our own pid already ours
205
+ * a lock with no pid of SOMEBODY ELSE'S, for good: a person who ran
206
+ * ours in it `git worktree lock` meant it, and there is no
207
+ * process to find dead
208
+ * another pid, alive theirs while it lives
209
+ * another pid, gone free, but only through a takeover that writes
210
+ * the new owner (`stale`)
211
+ */
212
+ function holder(root, folder, pid) {
213
+ const held = record(root, folder)?.lock;
214
+ if (held === undefined) return { ok: true, heldBy: null, free: true };
215
+ const owner = pidInReason(held);
216
+ if (owner === pid) return { ok: true, heldBy: pid };
217
+ if (owner === null) return { ok: false, reason: 'locked', heldBy: null };
218
+ if (alive(owner)) return { ok: false, reason: 'held', heldBy: owner };
219
+ return { ok: true, heldBy: owner, stale: true };
220
+ }
221
+
222
+ /**
223
+ * TAKE THE FOLDER, OR SAY WHO HAS IT. Never a side effect: the caller gets
224
+ * `taken` either way, and a refusal names the process in the way.
225
+ *
226
+ * A takeover writes the new owner, because the pid in that lock is the whole
227
+ * of the protection `releaseTaskFolder` and `parkTaskFolder` have. It is read
228
+ * back afterwards rather than assumed: `git worktree unlock` and `lock` are two
229
+ * commands, so two processes taking over the same dead owner's folder at the
230
+ * same moment is possible, and the one that did not end up in the lock has to
231
+ * hear that it did not get the folder.
232
+ */
233
+ export function acquireTaskFolder(root, folder, { pid = process.pid } = {}) {
234
+ const held = holder(root, folder, pid);
235
+ if (!held.ok) return { taken: false, reason: held.reason, heldBy: held.heldBy };
236
+ if (!held.free && !held.stale) return { taken: true, heldBy: pid };
237
+
238
+ if (held.stale) tryGit(root, ['worktree', 'unlock', folder]);
239
+ tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
240
+
241
+ const after = record(root, folder)?.lock;
242
+ const now = pidInReason(after);
243
+ if (now === pid) return { taken: true, heldBy: pid };
244
+ if (after === undefined) return { taken: false, reason: 'refused', heldBy: null };
245
+ return { taken: false, reason: now ? 'held' : 'locked', heldBy: now ?? null };
246
+ }
247
+
248
+ /**
249
+ * The folder this task works in, TAKEN by this process, and made if it is not
250
+ * there yet. Either `{ taken: true, path, branch, created, heldBy }`, or
251
+ * `{ taken: false, reason, heldBy, path: null }` when somebody else has it:
252
+ *
253
+ * held another live process is working in it
254
+ * building another live process is part way through making it
255
+ * locked a person locked it by hand, so it is not ours to take
256
+ * in-the-way something that is not a task folder sits at that path
257
+ *
258
+ * `path: null` on every refusal, because there is no folder the asker may work
259
+ * in and a caller reaching for `.path` should get nothing rather than the
260
+ * folder somebody else is standing in.
261
+ *
262
+ * `how` SAYS WHICH OF THE TWO WAYS MADE IT: `clone` for the block-sharing copy,
263
+ * `checkout` for the ordinary one, `kept` for a folder that was already there.
264
+ * It is reported because the two ways are not equally good and a test that only
265
+ * checks the RESULT cannot tell them apart: the clone could break for good and
266
+ * every assertion about the folder's contents would still pass. Codex made that
267
+ * point reviewing this file on 2026-10-07 and it was right.
166
268
  */
167
269
  export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = true } = {}) {
168
270
  const name = safeTaskName(id);
@@ -170,10 +272,29 @@ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = tr
170
272
  if (!root) return null;
171
273
  const folder = taskFolderPath(root, name);
172
274
  const branch = taskBranch(name);
275
+ const refused = (held) => ({ taken: false, reason: held.reason, heldBy: held.heldBy ?? null, path: null });
173
276
 
174
277
  if (fs.existsSync(folder) && record(root, folder)) {
175
- lock(root, folder, pid);
176
- return { path: folder, branch: record(root, folder)?.branch ?? branch, created: false };
278
+ const held = acquireTaskFolder(root, folder, { pid });
279
+ if (!held.taken) return refused(held);
280
+ return {
281
+ taken: true, heldBy: pid, path: folder, created: false, how: 'kept',
282
+ branch: record(root, folder)?.branch ?? branch,
283
+ };
284
+ }
285
+
286
+ // A DIRECTORY GIT KNOWS NOTHING ABOUT, sitting where this folder goes. The
287
+ // move below cannot be trusted to refuse it: measured 2026-10-07 on git
288
+ // 2.39.5, `worktree move` onto an existing directory behaves like `mv` and
289
+ // moves INTO it, so the finished copy landed at `<folder>/.building-<id>` and
290
+ // the session would have started in a directory holding almost none of the
291
+ // code. Whatever it is, it is not ours to delete and not ours to fill — an
292
+ // EMPTY one excepted, since there is nothing there to lose and a stray empty
293
+ // directory would otherwise wedge the row for good.
294
+ if (fs.existsSync(folder)) {
295
+ const empty = tryRead(folder).length === 0;
296
+ if (!empty) return refused({ reason: 'in-the-way', heldBy: null });
297
+ try { fs.rmdirSync(folder); } catch { return refused({ reason: 'in-the-way', heldBy: null }); }
177
298
  }
178
299
 
179
300
  // A registration whose directory somebody deleted by hand would refuse the
@@ -187,47 +308,133 @@ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = tr
187
308
  // EVERY STEP BELOW HAPPENS SOMEWHERE ELSE, and the folder appears at its own
188
309
  // path in one move at the end. `buildingFolderPath` is why. A build the app
189
310
  // died in the middle of leaves an unfinished copy there, which nobody wants
190
- // and which would refuse the next attempt, so it goes first.
311
+ // and which would refuse the next attempt, so it goes first — unless it
312
+ // belongs to an app process that is still building it.
191
313
  const staging = buildingFolderPath(root, name);
192
- clearUnfinished(root, staging);
314
+ const cleared = clearUnfinished(root, staging, pid);
315
+ if (!cleared.cleared) return refused({ reason: cleared.reason, heldBy: cleared.heldBy });
193
316
 
194
- const branchExists = tryGit(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).ok;
317
+ const hasBranch = () => tryGit(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).ok;
318
+ const branchExists = hasBranch();
195
319
  const from = branchExists ? branch : baseRef(root);
196
- if (!cloneCheckout(root, staging, branch, from, branchExists)) {
197
- // The fast path may have created the branch and then failed the copy.
198
- // The branch stays: this file never deletes one. The slow path uses it
199
- // when it is already there, which is what `worktree add -b` would refuse.
200
- const existsNow = tryGit(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).ok;
201
- const add = existsNow
202
- ? tryGit(root, ['worktree', 'add', staging, branch])
203
- : tryGit(root, ['worktree', 'add', '-b', branch, staging, baseRef(root)]);
320
+ let how = 'clone';
321
+ if (!cloneCheckout(root, staging, branch, from, branchExists, pid)) {
322
+ how = 'checkout';
323
+ // THE BRANCH IS ASKED ABOUT AGAIN, AND THAT IS NOT BELT AND BRACES.
324
+ //
325
+ // The clone's first act is `worktree add --no-checkout -b`, which CREATES
326
+ // the branch; the copy and the verification come after. So a clone that
327
+ // got past its own door and then failed has left the branch behind, on
328
+ // purpose: `clearUnfinished` keeps it, because a branch outlives every
329
+ // folder and may already carry commits.
330
+ //
331
+ // Reusing the answer from before the clone is how that became the worst
332
+ // failure in this file. `-b` a second time is refused ("a branch named ...
333
+ // already exists"), this function throws, and main/supervisor.mjs catches
334
+ // the throw in two places and hands the session the SHARED CHECKOUT with
335
+ // nothing but a console line. One recoverable copy failure, and two agents
336
+ // are editing the same files. Found 2026-10-07 reviewing with Codex
337
+ // (w-5952e6de3e); the trigger is reproduced in
338
+ // tests/a-task-whose-first-attempt-failed-still-gets-its-own-folder.
339
+ //
340
+ // ATTACH, NEVER `-B` AND NEVER RESET. Whatever is on that branch is work.
341
+ //
342
+ // Found twice, independently: the Linux work on main reached the same line
343
+ // from the other direction, because `cp` there fails differently.
344
+ const add = hasBranch()
345
+ ? addOwned(root, staging, [staging, branch], pid)
346
+ : addOwned(root, staging, ['-b', branch, staging, baseRef(root)], pid);
204
347
  if (!add.ok) throw Error(`Could not make a folder for ${name}: ${add.out}`);
205
- if (dependencies) cloneDependencies(root, staging);
348
+ }
349
+
350
+ // AND THE LOCAL FILES THE REPOSITORY ASKS FOR, THE SAME WAY WHICHEVER PATH RAN.
351
+ //
352
+ // This is the half that was missing, and the half people were complaining
353
+ // about. The two paths used to disagree: the clone carried every untracked file
354
+ // in the checkout, the ordinary checkout carried `node_modules` and nothing
355
+ // else, and which one a task got depended on whether somebody happened to have
356
+ // an edit open. `.worktreeinclude` makes it one answer, written down, in the
357
+ // repository, in a file Conductor and Claude Code already read.
358
+ //
359
+ // It runs in STAGING, before the folder exists at its own path, so a folder
360
+ // nobody can see yet is the one that is incomplete. A file that was named and
361
+ // cannot be given THROWS, and the staging copy goes with it.
362
+ //
363
+ // REFUSING THE WHOLE FOLDER IS THE REPORT. There was a version of this that
364
+ // made the folder anyway and returned the list of what it had refused, and
365
+ // nothing in the app read that list: a folder starting without its
366
+ // dependencies, with nobody told, is the fault this was written to end, so it
367
+ // cannot be how this fails. The throw reaches `_couldNotGetAFolder` in
368
+ // main/supervisor.mjs, which puts every path and reason on the row where
369
+ // somebody will read it, and the task waits instead of running wrong.
370
+ if (dependencies) {
371
+ try { carryLocalFiles(root, staging); }
372
+ catch (error) { clearUnfinished(root, staging); throw Error(`Could not make a folder for ${name}: ${error.message}`); }
206
373
  }
207
374
 
208
375
  // `worktree move` rather than a rename, so git's own record of where this
209
- // worktree lives moves with it. It refuses if anything is already at the
210
- // destination, which is the last guard against walking over a live folder.
211
- const moved = tryGit(root, ['worktree', 'move', staging, folder]);
376
+ // worktree lives moves with it. Twice forced, because a locked worktree is
377
+ // exactly what this one is and git refuses to move one otherwise; the lock
378
+ // itself survives the move (git 2.39.5, measured 2026-10-07), which is what
379
+ // keeps the folder owned from the moment it exists to the moment it is
380
+ // handed over. The destination was checked above and is checked again here,
381
+ // since the whole build has run since.
382
+ if (fs.existsSync(folder)) {
383
+ clearUnfinished(root, staging, pid);
384
+ return refused({ reason: 'in-the-way', heldBy: null });
385
+ }
386
+ const moved = tryGit(root, ['worktree', 'move', '--force', '--force', staging, folder]);
212
387
  if (!moved.ok) {
213
- clearUnfinished(root, staging);
388
+ clearUnfinished(root, staging, pid);
214
389
  throw Error(`Could not make a folder for ${name}: ${moved.out}`);
215
390
  }
216
391
 
217
- lock(root, folder, pid);
218
- return { path: folder, branch, created: true };
392
+ const held = acquireTaskFolder(root, folder, { pid });
393
+ if (!held.taken) return refused(held);
394
+ return { taken: true, heldBy: pid, path: folder, branch, created: true, how };
219
395
  }
220
396
 
221
397
  /**
222
- * Throw away an unfinished copy. Nothing is ever lost here: a staging folder
223
- * has never been handed to anybody, so the most it can hold is a checkout that
224
- * did not finish. The branch is left alone, as everywhere else in this file.
398
+ * `git worktree add`, LOCKED IN THE SAME CALL, so the half-built copy is owned
399
+ * from the moment it exists rather than a moment later. That moment is the one
400
+ * two app processes over one checkout used to collide in. `--reason` on `add`
401
+ * arrived in git 2.32; on an older one the lock is written straight after,
402
+ * which narrows the window rather than closing it and is no reason to refuse
403
+ * the folder.
225
404
  */
226
- function clearUnfinished(root, staging) {
227
- if (!fs.existsSync(staging) && !record(root, staging)) return;
405
+ function addOwned(root, folder, rest, pid) {
406
+ const owned = tryGit(root, ['worktree', 'add', '--lock', '--reason', lockReason(pid), ...rest]);
407
+ if (owned.ok) return owned;
408
+ const added = tryGit(root, ['worktree', 'add', ...rest]);
409
+ if (added.ok) tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
410
+ return added;
411
+ }
412
+
413
+ /**
414
+ * Throw away an unfinished copy, UNLESS SOMEBODY IS STILL MAKING IT. Nothing is
415
+ * ever lost in the clearing: a staging folder has never been handed to anybody,
416
+ * so the most it can hold is a checkout that did not finish. The branch is left
417
+ * alone, as everywhere else in this file.
418
+ *
419
+ * The refusal is the part that was missing. Inside one process the folder
420
+ * thread serialises these, so a staging folder on disk really was abandoned.
421
+ * Across two it was not, and here that is routine: one app runs from the app
422
+ * folder and one from a checkout. Since the local files started being carried
423
+ * in, the build is slower, so the window is wider.
424
+ */
425
+ function clearUnfinished(root, staging, pid = process.pid) {
426
+ if (!fs.existsSync(staging) && !record(root, staging)) return { cleared: true };
427
+ const held = holder(root, staging, pid);
428
+ if (!held.ok) {
429
+ return { cleared: false, reason: held.reason === 'held' ? 'building' : held.reason, heldBy: held.heldBy };
430
+ }
431
+ // Unlocked first: `worktree remove` refuses a locked worktree even with one
432
+ // `--force`, and this one is ours or its owner is gone.
433
+ tryGit(root, ['worktree', 'unlock', staging]);
228
434
  tryGit(root, ['worktree', 'remove', '--force', staging]);
229
435
  try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* already gone */ }
230
436
  tryGit(root, ['worktree', 'prune']);
437
+ return { cleared: true };
231
438
  }
232
439
 
233
440
  /**
@@ -249,20 +456,13 @@ function clearUnfinished(root, staging) {
249
456
  * ordinary checkout runs. A fast folder that is subtly not the code would be
250
457
  * far worse than a slow one.
251
458
  *
252
- * THE COPY IS THE PLATFORM'S OWN CLONE. macOS `cp -c` is APFS clonefile.
253
- * GNU `cp -a --reflink=auto` shares blocks where the filesystem can and
254
- * copies where it cannot. `-c` is not an option there, and a copy that
255
- * failed used to leave the branch the fast path had just created, so the
256
- * ordinary checkout then died with "a branch named … already exists".
459
+ * THE COPY IS THE PLATFORM'S OWN CLONE, and it lives in
460
+ * `main/worktree-include.mjs` because both files need it and that one does not
461
+ * import this one. macOS `cp -c` is APFS clonefile; GNU `cp -a --reflink=auto`
462
+ * shares blocks where the filesystem can and copies where it cannot, and `-c`
463
+ * is not an option there at all.
257
464
  */
258
- function copyTree(from, to) {
259
- const args = process.platform === 'darwin'
260
- ? ['-c', '-R', from, to]
261
- : ['-a', '--reflink=auto', from, to];
262
- execFileSync('cp', args, { stdio: ['ignore', 'ignore', 'pipe'] });
263
- }
264
-
265
- function cloneCheckout(root, folder, branch, from, branchExists) {
465
+ function cloneCheckout(root, folder, branch, from, branchExists, pid = process.pid) {
266
466
  // TRACKED CONTENT ONLY, on both sides of this. Her checkout permanently
267
467
  // carries a couple of untracked files (a scratch `:memory:.ses`, a folder of
268
468
  // screenshots somebody left), and counting those as dirty turned the fast
@@ -276,18 +476,37 @@ function cloneCheckout(root, folder, branch, from, branchExists) {
276
476
  if (!clean.ok || clean.out.length > 0) return false;
277
477
 
278
478
  const add = branchExists
279
- ? tryGit(root, ['worktree', 'add', '--no-checkout', folder, branch])
280
- : tryGit(root, ['worktree', 'add', '--no-checkout', '-b', branch, folder, from]);
479
+ ? addOwned(root, folder, ['--no-checkout', folder, branch], pid)
480
+ : addOwned(root, folder, ['--no-checkout', '-b', branch, folder, from], pid);
281
481
  if (!add.ok) return false;
282
482
 
283
483
  try {
284
- for (const entry of fs.readdirSync(root)) {
285
- if (entry === '.git' || entry === WORKTREES[0]) continue;
286
- copyTree(path.join(root, entry), folder);
484
+ // THE ENTRIES THAT HOLD TRACKED CONTENT, AND NOT SIMPLY EVERYTHING.
485
+ //
486
+ // It used to be every top-level entry except `.git` and `.claude`, which had
487
+ // two faults. The small one: every untracked file in the checkout rode along,
488
+ // so this path and the ordinary one produced different folders. The large
489
+ // one: skipping `.claude` WHOLESALE means a repository that commits a file
490
+ // under it (a `.claude/settings.json`, which is ordinary) failed this
491
+ // function's own verification every single time, because the clone was
492
+ // missing a tracked file. That failure is how a task ended up in the shared
493
+ // checkout; see the comment in `ensureTaskFolder` about asking for the
494
+ // branch twice.
495
+ const tracked = trackedTopLevel(root);
496
+ // A repository with a submodule is the ordinary checkout's business: a
497
+ // gitlink is a pointer, not a directory of files, and `worktree move`
498
+ // refuses a worktree holding one.
499
+ if (tracked.submodules) throw Error('this repository has submodules');
500
+ for (const entry of tracked.entries) {
501
+ if (entry === '.git') continue;
502
+ copyIn(root, folder, entry);
287
503
  }
288
504
  if (!tryGit(folder, ['reset', '-q', 'HEAD']).ok) throw Error('the index would not fill in');
289
505
  const after = tryGit(folder, ['status', '--porcelain', '--untracked-files=no']);
290
506
  if (!after.ok || after.out.length > 0) throw Error('the clone did not match the commit');
507
+ // An ignored file inside a tracked directory came with it (`renderer/dist`).
508
+ // Cleared here, and whatever is genuinely wanted arrives by name afterwards.
509
+ onlyTrackedContent(folder);
291
510
  return true;
292
511
  } catch {
293
512
  // Back to nothing, so the ordinary path starts from a clean sheet. What is
@@ -295,11 +514,30 @@ function cloneCheckout(root, folder, branch, from, branchExists) {
295
514
  // `folder` is the staging path, which no session has ever been given. It
296
515
  // used to be the real one, and a `remove --force` of that is how a running
297
516
  // agent lost the ground under it.
298
- clearUnfinished(root, folder);
517
+ clearUnfinished(root, folder, pid);
299
518
  return false;
300
519
  }
301
520
  }
302
521
 
522
+ /**
523
+ * One top-level entry, cloned into the new folder.
524
+ *
525
+ * `.claude` IS THE ONE ENTRY THAT CANNOT BE COPIED WHOLE, because the folders
526
+ * every other task is working in live inside it. Its children are copied one by
527
+ * one instead, with `worktrees` left out, so a repository that tracks a file
528
+ * under `.claude` gets it and no task ever receives a copy of another task's
529
+ * work.
530
+ */
531
+ function copyIn(root, folder, entry) {
532
+ if (entry !== WORKTREES[0]) { copyTree(path.join(root, entry), folder); return; }
533
+ const to = path.join(folder, entry);
534
+ fs.mkdirSync(to, { recursive: true });
535
+ for (const child of fs.readdirSync(path.join(root, entry))) {
536
+ if (child === WORKTREES[1]) continue;
537
+ copyTree(path.join(root, entry, child), to);
538
+ }
539
+ }
540
+
303
541
  /**
304
542
  * Task folders live inside the repository, where the checkout would otherwise
305
543
  * report them as untracked and every review card built in the shared checkout
@@ -319,32 +557,14 @@ function hideFromTheCheckout(root) {
319
557
  } catch { /* an unwritable git dir is not a reason to refuse the folder */ }
320
558
  }
321
559
 
322
- /**
323
- * Dependencies, cloned rather than copied. Measured 2026-09-22: `cp -c` of this
324
- * repository's 929 MB node_modules took 2.9 seconds and moved the volume's free
325
- * space by nothing at all, because APFS shares the blocks until something
326
- * writes. A plain copy is a minute and a real gigabyte, which is most of what
327
- * the 26 GB on her Mac was. Failure here is never fatal: the folder is still a
328
- * good folder, it just has to install for itself.
329
- */
330
- function cloneDependencies(root, folder) {
331
- const from = path.join(root, 'node_modules');
332
- const to = path.join(folder, 'node_modules');
333
- if (!fs.existsSync(from) || fs.existsSync(to)) return false;
334
- try {
335
- copyTree(from, to);
336
- return true;
337
- } catch {
338
- try { fs.rmSync(to, { recursive: true, force: true }); } catch { /* nothing to undo */ }
339
- return false;
340
- }
341
- }
342
-
343
- function lock(root, folder, pid) {
344
- const held = record(root, folder)?.lock;
345
- if (held !== undefined && pidInReason(held) !== pid) return;
346
- if (held === undefined) tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
347
- }
560
+ // DEPENDENCIES USED TO BE SPECIAL-CASED HERE, and now they are one line of a
561
+ // list the repository writes: `main/worktree-include.mjs`, whose default when a
562
+ // repository says nothing is `/node_modules`, so the behaviour this paragraph
563
+ // used to describe is still the behaviour. The measurement that justified it
564
+ // stands and belongs with the code that does it now: cloning this repository's
565
+ // 929 MB of dependencies took 2.9 seconds on 2026-09-22 and moved the volume's
566
+ // free space by nothing at all, because APFS shares the blocks until something
567
+ // writes.
348
568
 
349
569
  /** Is there anything in here that deleting the folder would lose? */
350
570
  export function folderState(root, folder) {
@@ -356,21 +576,46 @@ export function folderState(root, folder) {
356
576
  return { dirty, merged };
357
577
  }
358
578
 
579
+ /**
580
+ * THE FOLDER A CALL IS ABOUT IS THE ONE IT WAS HANDED, NEVER ONE REBUILT FROM A
581
+ * BASENAME (w-330eea6c66).
582
+ *
583
+ * `releaseTaskFolder` and `parkTaskFolder` used to take a task id and work out
584
+ * `.claude/worktrees/<id>` from it. That is correct while the only caller is a
585
+ * row's own session, and it is the bug the moment anything passes on a folder it
586
+ * found: the sweep walks a list of folders, and a stray whose basename happened
587
+ * to read like a task id would have had the app committing in, and deleting,
588
+ * whatever sat at the reconstructed path instead.
589
+ *
590
+ * So a caller holding a path says so, and a path that is not this task's own
591
+ * folder is refused rather than quietly swapped for one. Called with no path at
592
+ * all it answers exactly as it always did, which is every ordinary call.
593
+ */
594
+ function ownFolder(root, name, given) {
595
+ const mine = taskFolderPath(root, name);
596
+ if (given === null || given === undefined) return mine;
597
+ return real(given) === mine ? mine : null;
598
+ }
599
+
359
600
  /**
360
601
  * Give the folder back when the session is done with it. Removal is the rare
361
602
  * case on purpose: it happens when the work is already in main and nothing is
362
603
  * uncommitted. Every other answer names what is in the way, so the app can say
363
604
  * it rather than leave another invisible folder on her disk.
364
605
  */
365
- export function releaseTaskFolder(dir, id, { pid = process.pid } = {}) {
606
+ export function releaseTaskFolder(dir, id, { pid = process.pid, path: given = null } = {}) {
366
607
  const name = safeTaskName(id);
367
608
  const root = repoRoot(dir);
368
609
  if (!root) return { removed: false, reason: 'no-repo' };
369
- const folder = taskFolderPath(root, name);
610
+ const folder = ownFolder(root, name, given);
611
+ if (!folder) return { removed: false, reason: 'not-ours', path: given };
370
612
  if (!fs.existsSync(folder)) return { removed: false, reason: 'gone' };
371
613
 
372
- const holder = pidInReason(record(root, folder)?.lock);
373
- if (holder && holder !== pid && alive(holder)) return { removed: false, reason: 'held', path: folder };
614
+ // Whose folder this is, asked in the one place that knows (`holder`). A lock
615
+ // naming a process that has exited is not a claim on anything, and sweeping
616
+ // those is most of what this function is for.
617
+ const held = holder(root, folder, pid);
618
+ if (!held.ok) return { removed: false, reason: held.reason, heldBy: held.heldBy, path: folder };
374
619
 
375
620
  const { dirty, merged } = folderState(root, folder);
376
621
  if (dirty) return { removed: false, reason: 'uncommitted', path: folder };
@@ -387,6 +632,14 @@ export function releaseTaskFolder(dir, id, { pid = process.pid } = {}) {
387
632
  /** The subject line that marks a commit as work parked rather than finished. */
388
633
  export const PARKED = 'Parked when the task was closed';
389
634
 
635
+ /**
636
+ * And the one under it, which holds what was STAGED when the task was closed.
637
+ * Two commits rather than one because `git add -A` replaces the index with the
638
+ * working tree, so a single commit cannot carry both and the staged arrangement
639
+ * was the half that went.
640
+ */
641
+ export const PARKED_INDEX = 'Parked when the task was closed (what was staged)';
642
+
390
643
  /**
391
644
  * CLOSING A TASK PARKS ITS FOLDER, AND PARKING LOSES NOTHING.
392
645
  *
@@ -413,25 +666,46 @@ export const PARKED = 'Parked when the task was closed';
413
666
  * wrote itself goes. That is the same boundary as removing any worktree, and the
414
667
  * alternative is committing secrets onto a branch.
415
668
  */
416
- export function parkTaskFolder(dir, id, { pid = process.pid } = {}) {
669
+ export function parkTaskFolder(dir, id, { pid = process.pid, path: given = null } = {}) {
417
670
  const name = safeTaskName(id);
418
671
  const root = repoRoot(dir);
419
672
  if (!root) return { parked: false, reason: 'no-repo' };
420
- const folder = taskFolderPath(root, name);
673
+ // `ownFolder`, and never the basename of whatever we were pointed at: parking
674
+ // commits and then removes, so being wrong here costs somebody their folder.
675
+ const folder = ownFolder(root, name, given);
676
+ if (!folder) return { parked: false, reason: 'not-ours', path: given };
421
677
  if (!fs.existsSync(folder)) return { parked: false, reason: 'gone' };
422
678
 
423
- const holder = pidInReason(record(root, folder)?.lock);
424
- if (holder && holder !== pid && alive(holder)) return { parked: false, reason: 'held', path: folder };
679
+ const owner = holder(root, folder, pid);
680
+ if (!owner.ok) return { parked: false, reason: owner.reason, heldBy: owner.heldBy, path: folder };
425
681
 
426
682
  const { dirty } = folderState(root, folder);
427
683
  let kept = 'nothing uncommitted';
428
684
  if (dirty) {
685
+ // TWO COMMITS, BECAUSE THE INDEX IS WORK TOO. `git add -A` replaces the
686
+ // index with the working tree, so parking used to flatten them into one:
687
+ // stage version A of a file, edit the working copy to B, park, and B was
688
+ // all that came back. The staged arrangement an agent had built — a
689
+ // half-prepared commit, a rename, a file added but not yet written to —
690
+ // was gone, and the round trip was documented as keeping it.
691
+ //
692
+ // So the index is committed FIRST, exactly as it stands, and the working
693
+ // tree second. The two trees are then both on the branch, and
694
+ // `restoreTaskFolder` unwinds them into the index and the working tree they
695
+ // came from. `--allow-empty` because an index that matches HEAD is the
696
+ // ordinary case and is still the thing to come back to.
697
+ // AND AN INDEX THAT WILL NOT COMMIT ON ITS OWN IS NOT A REASON TO REFUSE.
698
+ // A merge or rebase an agent left mid-flight has unmerged paths, which git
699
+ // will not commit, and the `add -A` below is what resolves them. Losing the
700
+ // index there is exactly the old behaviour, and the old behaviour is better
701
+ // than stranding the folder.
702
+ tryGit(folder, ['commit', '--no-verify', '--allow-empty', '-q', '-m', PARKED_INDEX]);
429
703
  const staged = tryGit(folder, ['add', '-A']);
430
704
  // `--no-verify`, because a repository's own commit hooks are written for a
431
705
  // person finishing a piece of work, and this is the app tidying up after
432
706
  // one. A hook that refuses here would strand the folder it is protecting.
433
- const held = tryGit(folder, ['commit', '--no-verify', '-q', '-m', PARKED]);
434
- if (!staged.ok || !held.ok) return { parked: false, reason: 'refused', path: folder, detail: held.out };
707
+ const tree = tryGit(folder, ['commit', '--no-verify', '--allow-empty', '-q', '-m', PARKED]);
708
+ if (!staged.ok || !tree.ok) return { parked: false, reason: 'refused', path: folder, detail: tree.out };
435
709
  kept = 'work in progress';
436
710
  }
437
711
 
@@ -442,46 +716,164 @@ export function parkTaskFolder(dir, id, { pid = process.pid } = {}) {
442
716
  }
443
717
 
444
718
  /**
445
- * The way back. Makes the folder again off the branch, and when the last commit
446
- * on it is a parked one, takes that commit apart so the work is uncommitted
447
- * exactly as it was. Undoing it rather than leaving it is what stops a row she
448
- * opens and closes five times from growing five commits nobody wrote.
719
+ * The way back. Makes the folder again off the branch, and when the last
720
+ * commits on it are parked ones, takes them apart so the work is uncommitted
721
+ * exactly as it was: THE WORKING TREE FROM ONE AND THE INDEX FROM THE OTHER.
722
+ *
723
+ * `reset HEAD~1` is mixed, so it leaves the files alone and fills the index
724
+ * in from the commit below — which is the index as it was parked.
725
+ * `reset --soft HEAD~1` then puts HEAD back where it started and touches
726
+ * neither, so the branch grows nothing and both halves survive.
727
+ *
728
+ * A FOLDER PARKED BEFORE THIS LANDED has one commit and no index commit under
729
+ * it, and still has to come back: there are parked branches on this Mac from
730
+ * the old shape. One mixed reset is what it always was.
449
731
  */
450
732
  export function restoreTaskFolder(dir, id, { pid = process.pid } = {}) {
451
733
  const made = ensureTaskFolder(dir, id, { pid });
452
- if (!made) return null;
734
+ if (!made || made.taken === false) return made ?? null;
453
735
  const last = tryGit(made.path, ['log', '-1', '--format=%s']);
454
- if (last.ok && last.out === PARKED) tryGit(made.path, ['reset', '-q', 'HEAD~1']);
736
+ if (!last.ok || last.out !== PARKED) return made;
737
+ const under = tryGit(made.path, ['log', '-1', '--format=%s', 'HEAD~1']);
738
+ tryGit(made.path, ['reset', '-q', 'HEAD~1']);
739
+ if (under.ok && under.out === PARKED_INDEX) tryGit(made.path, ['reset', '--soft', '-q', 'HEAD~1']);
455
740
  return made;
456
741
  }
457
742
 
743
+ /**
744
+ * EVERY COPY OF THIS PROJECT ON THE DISK, WHOEVER MADE IT. INVENTORY, NOT
745
+ * OWNERSHIP, AND THE TWO BEING ONE QUESTION IS WHAT STRANDED 740 MB.
746
+ *
747
+ * MEASURED 2026-10-07 (w-5952e6de3e). Thirteen worktrees of this repository
748
+ * existed outside `.claude/worktrees/`: nine beside the checkout as
749
+ * `agentbox-team-<name>`, one under `/private/tmp`, one as `wt-<task id>`. About
750
+ * 740 MB of tracked files, every one made by hand by some session rather than by
751
+ * the app. `listTaskFolders` filtered on the folder's parent being the app's own
752
+ * home, so not one of them could be listed, shown or swept: the same
753
+ * invisibility that stranded 26 GB on 2026-09-22, in a new place. One of them had
754
+ * `node_modules` symlinked back to the shared checkout, so an `npm install`
755
+ * inside it rewrote every other folder's dependencies.
756
+ *
757
+ * AND WIDENING THAT FILTER WOULD HAVE BEEN WORSE THAN THE BUG, which is why this
758
+ * is a second question rather than a looser version of the first one. The
759
+ * supervisor feeds the list into automatic parking, and parking rebuilt
760
+ * `.claude/worktrees/<id>` out of the folder's BASENAME rather than using the
761
+ * path it was handed. A folder nobody's row owns passes every eligibility check
762
+ * parking has, so a wider list would have had the app committing to a branch it
763
+ * invented and deleting a folder somebody made on purpose.
764
+ *
765
+ * So: this answers what is there, and `ours` answers what the app may act on.
766
+ * Only the second is ever parked or removed, and the first is what lets a person
767
+ * see what is holding their disk.
768
+ *
769
+ * `ours` IS PROOF BY LOCATION, and the location is the app's own. Nothing but
770
+ * `ensureTaskFolder` puts a folder at `<primary>/.claude/worktrees/<task name>`,
771
+ * so a folder there was made here. It is deliberately not inferred from the lock
772
+ * (dropped on the way to every removal) or from a marker file (which an agent
773
+ * working in the folder could delete, turning a folder the app made into one it
774
+ * will never reclaim).
775
+ */
776
+ export function inventoryWorktrees(dir) {
777
+ const root = repoRoot(dir);
778
+ if (!root) return [];
779
+ const list = registered(root);
780
+ // GIT LISTS THE MAIN WORKTREE FIRST, and the home hangs off that one rather
781
+ // than off whichever folder we were asked from: `rev-parse --show-toplevel`
782
+ // inside a linked worktree answers with that worktree, so deriving the home
783
+ // from it would call every real task folder a stray.
784
+ const primary = real(list[0]?.path ?? root);
785
+ const home = path.join(primary, ...WORKTREES);
786
+ return list.map((w) => {
787
+ const at = real(w.path);
788
+ const name = path.basename(at);
789
+ const unfinished = name.startsWith(BUILDING);
790
+ const missing = !fs.existsSync(at);
791
+ const holder = pidInReason(w.lock);
792
+ return {
793
+ path: at,
794
+ // What the registration spells it, which on a Mac can be the other side of
795
+ // a symlink: `/tmp/x` against `/private/tmp/x`.
796
+ registeredAs: w.path,
797
+ primary: at === primary,
798
+ branch: w.branch ?? '',
799
+ lock: w.lock ?? null,
800
+ heldBy: holder && alive(holder) ? holder : null,
801
+ // A folder still being built is the app's and is wanted by nobody: it has
802
+ // no row standing in it, so it may never be listed, shown or swept.
803
+ unfinished,
804
+ missing,
805
+ prunable: !!w.prunable,
806
+ // WHERE THE APP PUTS ITS OWN, whether or not anything is there now. Kept
807
+ // apart from `ours` because a registration whose folder has gone is still
808
+ // the app's bookkeeping to tidy, and is nothing anybody may act on.
809
+ atOurPath: path.dirname(at) === home && couldBeATaskName(name),
810
+ ours: !unfinished && !missing && !w.prunable
811
+ && path.dirname(at) === home && couldBeATaskName(name),
812
+ };
813
+ });
814
+ }
815
+
816
+ /**
817
+ * Copies of this project the app did not make, so a person can see what is
818
+ * holding their disk. The checkout itself is not one of them, and neither is a
819
+ * registration pointing at nothing: that is a different fact with its own field
820
+ * and its own answer, `pruneMissingWorktrees`.
821
+ */
822
+ export function strayWorktrees(dir) {
823
+ return inventoryWorktrees(dir)
824
+ .filter((w) => !w.primary && !w.ours && !w.unfinished && !w.missing && !w.prunable);
825
+ }
826
+
827
+ /**
828
+ * REGISTRATIONS POINTING AT NOTHING, DROPPED. One of the thirteen lived under
829
+ * `/private/tmp`, which macOS may purge from under it, and git goes on holding
830
+ * the registration: that is what refuses the next `worktree add` at that path
831
+ * with "already registered".
832
+ *
833
+ * Nothing can be lost here, by git's own definition of `prune`: it only ever
834
+ * drops the bookkeeping for a worktree whose directory is already gone, and the
835
+ * branch it stood on survives, as every branch survives everything in this file.
836
+ * A folder of OURS is unlocked first, because `prune` skips a locked worktree and
837
+ * the app locks every folder it makes, which would otherwise leave the
838
+ * registration of a deleted folder wedged there forever.
839
+ */
840
+ export function pruneMissingWorktrees(dir) {
841
+ const root = repoRoot(dir);
842
+ if (!root) return [];
843
+ const gone = inventoryWorktrees(dir).filter((w) => !w.primary && (w.missing || w.prunable));
844
+ if (!gone.length) return [];
845
+ for (const w of gone) if (w.atOurPath) tryGit(root, ['worktree', 'unlock', w.path]);
846
+ tryGit(root, ['worktree', 'prune']);
847
+ const left = new Set(inventoryWorktrees(dir).map((w) => w.path));
848
+ return gone.map((w) => w.path).filter((at) => !left.has(at));
849
+ }
850
+
458
851
  /**
459
852
  * Every task folder in this repository and what would be lost by deleting it.
460
853
  * This is what the app reads to show her a folder that is holding work, which
461
854
  * is the half that did not exist: hers held 26 GB and she had never seen one.
855
+ *
856
+ * THE OWNERSHIP VIEW, and the only list anything automatic is allowed to act on.
857
+ * `inventoryWorktrees` above is the wider one, and the comment there is why they
858
+ * are not the same list.
462
859
  */
463
860
  export function listTaskFolders(dir) {
464
861
  const root = repoRoot(dir);
465
862
  if (!root) return [];
466
- const home = path.join(root, ...WORKTREES);
467
- return registered(root)
468
- // A folder still being built is not one of hers: it has no row standing in
469
- // it and nothing in it is wanted, so it may never be listed, shown or swept.
470
- .filter((w) => path.dirname(w.path) === home && !w.prunable
471
- && !path.basename(w.path).startsWith(BUILDING))
863
+ return inventoryWorktrees(dir)
864
+ .filter((w) => w.ours)
472
865
  .map((w) => {
473
866
  const { dirty, merged } = folderState(root, w.path);
474
867
  let touchedAt = null;
475
868
  try { touchedAt = fs.statSync(w.path).mtimeMs; } catch { /* it can vanish under us */ }
476
- const holder = pidInReason(w.lock);
477
869
  return {
478
870
  id: path.basename(w.path),
479
871
  path: w.path,
480
- branch: w.branch ?? '',
872
+ branch: w.branch,
481
873
  dirty,
482
874
  merged,
483
875
  touchedAt,
484
- heldBy: holder && alive(holder) ? holder : null,
876
+ heldBy: w.heldBy,
485
877
  };
486
878
  });
487
879
  }