agentbox-app 0.1.11 → 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 (239) hide show
  1. package/README.md +24 -16
  2. package/bin/agentbox.mjs +18 -9
  3. package/briefs/message-rules.md +15 -4
  4. package/main/account-tooling.mjs +2 -1
  5. package/main/acp.mjs +296 -0
  6. package/main/agent-homes.mjs +158 -0
  7. package/main/agent-sessions.mjs +57 -20
  8. package/main/agent-updates.mjs +9 -20
  9. package/main/analytics.mjs +30 -0
  10. package/main/approvals.mjs +14 -5
  11. package/main/claude-bin.mjs +45 -310
  12. package/main/claude-input.mjs +9 -3
  13. package/main/claude-launch-env.mjs +54 -0
  14. package/main/claude-models.mjs +39 -7
  15. package/main/claude-plan.mjs +14 -2
  16. package/main/claude-usage.mjs +82 -25
  17. package/main/codex-bin.mjs +51 -248
  18. package/main/codex-launch-env.mjs +31 -0
  19. package/main/codex-session.mjs +38 -3
  20. package/main/codex-threads.mjs +41 -8
  21. package/main/codex-watch.mjs +67 -3
  22. package/main/config.mjs +40 -13
  23. package/main/corner-tag.mjs +489 -0
  24. package/main/crash-report.mjs +26 -3
  25. package/main/demo.mjs +17 -14
  26. package/main/engine-bin.mjs +595 -0
  27. package/main/engine-setup.mjs +27 -27
  28. package/main/feedback.mjs +11 -6
  29. package/main/fresh-user.mjs +1 -1
  30. package/main/git-change.mjs +8 -2
  31. package/main/harnesses/claude-stream.mjs +208 -0
  32. package/main/harnesses/claude.mjs +84 -0
  33. package/main/harnesses/codex.mjs +65 -0
  34. package/main/harnesses/common.mjs +78 -0
  35. package/main/harnesses/instructions-file.mjs +116 -0
  36. package/main/harnesses/opencode.mjs +234 -0
  37. package/main/harnesses.mjs +65 -0
  38. package/main/importable-threads.mjs +38 -0
  39. package/main/instruction-settings.mjs +47 -11
  40. package/main/ipc.mjs +175 -22
  41. package/main/local-app-frames.mjs +82 -0
  42. package/main/machine.mjs +1 -1
  43. package/main/main.mjs +120 -5
  44. package/main/memory-gate-history.mjs +51 -12
  45. package/main/memory-gate-server.mjs +67 -11
  46. package/main/memory-gate.mjs +49 -8
  47. package/main/memory-log.mjs +124 -0
  48. package/main/notify.mjs +48 -5
  49. package/main/opencode-server.mjs +271 -0
  50. package/main/opencode.mjs +287 -0
  51. package/main/phone-link.mjs +551 -0
  52. package/main/refresh-agent-models.mjs +2 -1
  53. package/main/relay-address.mjs +28 -0
  54. package/main/relay-client.mjs +246 -0
  55. package/main/row-label.mjs +8 -7
  56. package/main/runs-on.mjs +90 -0
  57. package/main/serve.mjs +124 -10
  58. package/main/settings.mjs +171 -11
  59. package/main/shell-path.mjs +86 -0
  60. package/main/ship-queue.mjs +25 -7
  61. package/main/sign-in-files.mjs +3 -7
  62. package/main/source-updater.mjs +27 -1
  63. package/main/start-hold.mjs +73 -0
  64. package/main/store/home.mjs +37 -5
  65. package/main/store/project.mjs +15 -19
  66. package/main/store.mjs +10 -4
  67. package/main/supervisor.mjs +1030 -447
  68. package/main/task-commands.mjs +13 -17
  69. package/main/task-folders-offthread.mjs +6 -1
  70. package/main/task-folders.mjs +491 -82
  71. package/main/task-terminals.mjs +49 -8
  72. package/main/team/index.mjs +2 -2
  73. package/main/team/projects.mjs +1 -1
  74. package/main/updater.mjs +71 -9
  75. package/main/worktree-include.mjs +494 -0
  76. package/mcp/core/page.mjs +1 -1
  77. package/mcp/core/work.mjs +34 -3
  78. package/mcp/tools.mjs +2 -1
  79. package/package.json +10 -1
  80. package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
  81. package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
  82. package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
  83. package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
  84. package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
  85. package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
  86. package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
  87. package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
  88. package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
  89. package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
  90. package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
  91. package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
  92. package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
  93. package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
  94. package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
  95. package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
  96. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
  97. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
  98. package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
  99. package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
  100. package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
  101. package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  102. package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
  103. package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  104. package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
  105. package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
  106. package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
  107. package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
  108. package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
  109. package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
  110. package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
  111. package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
  112. package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
  113. package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
  114. package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
  115. package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
  116. package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
  117. package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
  118. package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
  119. package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
  120. package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
  121. package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
  122. package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
  123. package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
  124. package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
  125. package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
  126. package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
  127. package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
  128. package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  129. package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
  130. package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
  131. package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
  132. package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
  133. package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
  134. package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
  135. package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
  136. package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
  137. package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
  138. package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
  139. package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
  140. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
  141. package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
  142. package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
  143. package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
  144. package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
  145. package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
  146. package/renderer/dist/corner-tag.html +13 -0
  147. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  148. package/renderer/dist/emulator/data/compression/README.md +11 -0
  149. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  150. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  151. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  152. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  153. package/renderer/dist/emulator/data/cores/README.md +22 -0
  154. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  155. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  156. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  157. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  158. package/renderer/dist/emulator/data/cores/package.json +65 -0
  159. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  160. package/renderer/dist/emulator/data/emulator.css +1614 -0
  161. package/renderer/dist/emulator/data/loader.js +170 -0
  162. package/renderer/dist/emulator/data/localization/README.md +63 -0
  163. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  164. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  165. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  166. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  167. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  168. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  169. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  170. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  171. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  172. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  173. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  174. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  175. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  176. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  177. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  178. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  179. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  180. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  181. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  182. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  183. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  184. package/renderer/dist/emulator/data/src/compression.js +142 -0
  185. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  186. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  187. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  188. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  189. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  190. package/renderer/dist/emulator/data/src/storage.js +125 -0
  191. package/renderer/dist/emulator/data/version.json +5 -0
  192. package/renderer/dist/emulator/host.js +297 -0
  193. package/renderer/dist/emulator/index.html +28 -0
  194. package/renderer/dist/emulator/provenance.json +6 -0
  195. package/renderer/dist/index.html +16 -3
  196. package/scripts/after-install.mjs +14 -0
  197. package/scripts/approval-server.sh +31 -7
  198. package/scripts/before-publish.mjs +12 -10
  199. package/shared/agent-import.mjs +3 -1
  200. package/shared/agents.d.mts +1 -0
  201. package/shared/bridge-map.mjs +19 -0
  202. package/shared/claude-commands.generated.mjs +8 -6
  203. package/shared/claude-models.generated.mjs +10 -8
  204. package/shared/codex-modes.mjs +1 -1
  205. package/shared/corner-tag.d.mts +14 -0
  206. package/shared/corner-tag.mjs +141 -0
  207. package/shared/demo-world.mjs +1 -1
  208. package/shared/engines.mjs +20 -5
  209. package/shared/fresh-user-home.mjs +12 -1
  210. package/shared/harness-definitions.mjs +103 -0
  211. package/shared/instruction-defaults.json +1 -1
  212. package/shared/notify-rules.d.mts +25 -0
  213. package/shared/notify-rules.mjs +19 -0
  214. package/shared/project-folder-check.mjs +3 -3
  215. package/shared/provider-commands.mjs +10 -2
  216. package/shared/rank.d.mts +2 -0
  217. package/shared/rank.mjs +50 -0
  218. package/shared/relay-address.mjs +6 -0
  219. package/shared/relay-crypto.mjs +165 -0
  220. package/shared/runs-on.d.mts +10 -0
  221. package/shared/runs-on.mjs +55 -0
  222. package/shared/shortcuts.mjs +11 -5
  223. package/shared/spawn-trouble.mjs +58 -7
  224. package/shared/terminal-input.d.mts +1 -0
  225. package/shared/terminal-input.mjs +30 -0
  226. package/shared/terminal-keys.d.mts +1 -0
  227. package/shared/terminal-keys.mjs +16 -0
  228. package/shared/terminal-size.d.mts +4 -0
  229. package/shared/terminal-size.mjs +29 -0
  230. package/shared/usage.d.mts +3 -0
  231. package/shared/usage.mjs +7 -0
  232. package/shared/where-it-runs-from.d.mts +43 -0
  233. package/shared/where-it-runs-from.mjs +155 -0
  234. package/shared/work-items.mjs +28 -2
  235. package/shared/work-lines.mjs +1 -1
  236. package/shared/xterm-invalid-range.d.mts +2 -0
  237. package/shared/xterm-invalid-range.mjs +58 -0
  238. package/renderer/dist/assets/index-B-2Az952.css +0 -32
  239. package/renderer/dist/assets/index-D6qncgai.js +0 -1168
@@ -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,43 +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
- const add = branchExists
198
- ? tryGit(root, ['worktree', 'add', staging, branch])
199
- : 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);
200
347
  if (!add.ok) throw Error(`Could not make a folder for ${name}: ${add.out}`);
201
- 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}`); }
202
373
  }
203
374
 
204
375
  // `worktree move` rather than a rename, so git's own record of where this
205
- // worktree lives moves with it. It refuses if anything is already at the
206
- // destination, which is the last guard against walking over a live folder.
207
- 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]);
208
387
  if (!moved.ok) {
209
- clearUnfinished(root, staging);
388
+ clearUnfinished(root, staging, pid);
210
389
  throw Error(`Could not make a folder for ${name}: ${moved.out}`);
211
390
  }
212
391
 
213
- lock(root, folder, pid);
214
- 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 };
215
395
  }
216
396
 
217
397
  /**
218
- * Throw away an unfinished copy. Nothing is ever lost here: a staging folder
219
- * has never been handed to anybody, so the most it can hold is a checkout that
220
- * 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.
221
404
  */
222
- function clearUnfinished(root, staging) {
223
- 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]);
224
434
  tryGit(root, ['worktree', 'remove', '--force', staging]);
225
435
  try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* already gone */ }
226
436
  tryGit(root, ['worktree', 'prune']);
437
+ return { cleared: true };
227
438
  }
228
439
 
229
440
  /**
@@ -244,8 +455,14 @@ function clearUnfinished(root, staging) {
244
455
  * commit, down to an empty `git status`, the folder is thrown away and the
245
456
  * ordinary checkout runs. A fast folder that is subtly not the code would be
246
457
  * far worse than a slow one.
458
+ *
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.
247
464
  */
248
- function cloneCheckout(root, folder, branch, from, branchExists) {
465
+ function cloneCheckout(root, folder, branch, from, branchExists, pid = process.pid) {
249
466
  // TRACKED CONTENT ONLY, on both sides of this. Her checkout permanently
250
467
  // carries a couple of untracked files (a scratch `:memory:.ses`, a folder of
251
468
  // screenshots somebody left), and counting those as dirty turned the fast
@@ -259,18 +476,37 @@ function cloneCheckout(root, folder, branch, from, branchExists) {
259
476
  if (!clean.ok || clean.out.length > 0) return false;
260
477
 
261
478
  const add = branchExists
262
- ? tryGit(root, ['worktree', 'add', '--no-checkout', folder, branch])
263
- : 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);
264
481
  if (!add.ok) return false;
265
482
 
266
483
  try {
267
- for (const entry of fs.readdirSync(root)) {
268
- if (entry === '.git' || entry === WORKTREES[0]) continue;
269
- execFileSync('cp', ['-c', '-R', path.join(root, entry), folder], { stdio: ['ignore', 'ignore', 'pipe'] });
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);
270
503
  }
271
504
  if (!tryGit(folder, ['reset', '-q', 'HEAD']).ok) throw Error('the index would not fill in');
272
505
  const after = tryGit(folder, ['status', '--porcelain', '--untracked-files=no']);
273
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);
274
510
  return true;
275
511
  } catch {
276
512
  // Back to nothing, so the ordinary path starts from a clean sheet. What is
@@ -278,11 +514,30 @@ function cloneCheckout(root, folder, branch, from, branchExists) {
278
514
  // `folder` is the staging path, which no session has ever been given. It
279
515
  // used to be the real one, and a `remove --force` of that is how a running
280
516
  // agent lost the ground under it.
281
- clearUnfinished(root, folder);
517
+ clearUnfinished(root, folder, pid);
282
518
  return false;
283
519
  }
284
520
  }
285
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
+
286
541
  /**
287
542
  * Task folders live inside the repository, where the checkout would otherwise
288
543
  * report them as untracked and every review card built in the shared checkout
@@ -302,32 +557,14 @@ function hideFromTheCheckout(root) {
302
557
  } catch { /* an unwritable git dir is not a reason to refuse the folder */ }
303
558
  }
304
559
 
305
- /**
306
- * Dependencies, cloned rather than copied. Measured 2026-09-22: `cp -c` of this
307
- * repository's 929 MB node_modules took 2.9 seconds and moved the volume's free
308
- * space by nothing at all, because APFS shares the blocks until something
309
- * writes. A plain copy is a minute and a real gigabyte, which is most of what
310
- * the 26 GB on her Mac was. Failure here is never fatal: the folder is still a
311
- * good folder, it just has to install for itself.
312
- */
313
- function cloneDependencies(root, folder) {
314
- const from = path.join(root, 'node_modules');
315
- const to = path.join(folder, 'node_modules');
316
- if (!fs.existsSync(from) || fs.existsSync(to)) return false;
317
- try {
318
- execFileSync('cp', ['-c', '-R', from, to], { stdio: ['ignore', 'ignore', 'pipe'] });
319
- return true;
320
- } catch {
321
- try { fs.rmSync(to, { recursive: true, force: true }); } catch { /* nothing to undo */ }
322
- return false;
323
- }
324
- }
325
-
326
- function lock(root, folder, pid) {
327
- const held = record(root, folder)?.lock;
328
- if (held !== undefined && pidInReason(held) !== pid) return;
329
- if (held === undefined) tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
330
- }
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.
331
568
 
332
569
  /** Is there anything in here that deleting the folder would lose? */
333
570
  export function folderState(root, folder) {
@@ -339,21 +576,46 @@ export function folderState(root, folder) {
339
576
  return { dirty, merged };
340
577
  }
341
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
+
342
600
  /**
343
601
  * Give the folder back when the session is done with it. Removal is the rare
344
602
  * case on purpose: it happens when the work is already in main and nothing is
345
603
  * uncommitted. Every other answer names what is in the way, so the app can say
346
604
  * it rather than leave another invisible folder on her disk.
347
605
  */
348
- export function releaseTaskFolder(dir, id, { pid = process.pid } = {}) {
606
+ export function releaseTaskFolder(dir, id, { pid = process.pid, path: given = null } = {}) {
349
607
  const name = safeTaskName(id);
350
608
  const root = repoRoot(dir);
351
609
  if (!root) return { removed: false, reason: 'no-repo' };
352
- const folder = taskFolderPath(root, name);
610
+ const folder = ownFolder(root, name, given);
611
+ if (!folder) return { removed: false, reason: 'not-ours', path: given };
353
612
  if (!fs.existsSync(folder)) return { removed: false, reason: 'gone' };
354
613
 
355
- const holder = pidInReason(record(root, folder)?.lock);
356
- 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 };
357
619
 
358
620
  const { dirty, merged } = folderState(root, folder);
359
621
  if (dirty) return { removed: false, reason: 'uncommitted', path: folder };
@@ -370,6 +632,14 @@ export function releaseTaskFolder(dir, id, { pid = process.pid } = {}) {
370
632
  /** The subject line that marks a commit as work parked rather than finished. */
371
633
  export const PARKED = 'Parked when the task was closed';
372
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
+
373
643
  /**
374
644
  * CLOSING A TASK PARKS ITS FOLDER, AND PARKING LOSES NOTHING.
375
645
  *
@@ -396,25 +666,46 @@ export const PARKED = 'Parked when the task was closed';
396
666
  * wrote itself goes. That is the same boundary as removing any worktree, and the
397
667
  * alternative is committing secrets onto a branch.
398
668
  */
399
- export function parkTaskFolder(dir, id, { pid = process.pid } = {}) {
669
+ export function parkTaskFolder(dir, id, { pid = process.pid, path: given = null } = {}) {
400
670
  const name = safeTaskName(id);
401
671
  const root = repoRoot(dir);
402
672
  if (!root) return { parked: false, reason: 'no-repo' };
403
- 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 };
404
677
  if (!fs.existsSync(folder)) return { parked: false, reason: 'gone' };
405
678
 
406
- const holder = pidInReason(record(root, folder)?.lock);
407
- 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 };
408
681
 
409
682
  const { dirty } = folderState(root, folder);
410
683
  let kept = 'nothing uncommitted';
411
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]);
412
703
  const staged = tryGit(folder, ['add', '-A']);
413
704
  // `--no-verify`, because a repository's own commit hooks are written for a
414
705
  // person finishing a piece of work, and this is the app tidying up after
415
706
  // one. A hook that refuses here would strand the folder it is protecting.
416
- const held = tryGit(folder, ['commit', '--no-verify', '-q', '-m', PARKED]);
417
- 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 };
418
709
  kept = 'work in progress';
419
710
  }
420
711
 
@@ -425,46 +716,164 @@ export function parkTaskFolder(dir, id, { pid = process.pid } = {}) {
425
716
  }
426
717
 
427
718
  /**
428
- * The way back. Makes the folder again off the branch, and when the last commit
429
- * on it is a parked one, takes that commit apart so the work is uncommitted
430
- * exactly as it was. Undoing it rather than leaving it is what stops a row she
431
- * 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.
432
731
  */
433
732
  export function restoreTaskFolder(dir, id, { pid = process.pid } = {}) {
434
733
  const made = ensureTaskFolder(dir, id, { pid });
435
- if (!made) return null;
734
+ if (!made || made.taken === false) return made ?? null;
436
735
  const last = tryGit(made.path, ['log', '-1', '--format=%s']);
437
- 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']);
438
740
  return made;
439
741
  }
440
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
+
441
851
  /**
442
852
  * Every task folder in this repository and what would be lost by deleting it.
443
853
  * This is what the app reads to show her a folder that is holding work, which
444
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.
445
859
  */
446
860
  export function listTaskFolders(dir) {
447
861
  const root = repoRoot(dir);
448
862
  if (!root) return [];
449
- const home = path.join(root, ...WORKTREES);
450
- return registered(root)
451
- // A folder still being built is not one of hers: it has no row standing in
452
- // it and nothing in it is wanted, so it may never be listed, shown or swept.
453
- .filter((w) => path.dirname(w.path) === home && !w.prunable
454
- && !path.basename(w.path).startsWith(BUILDING))
863
+ return inventoryWorktrees(dir)
864
+ .filter((w) => w.ours)
455
865
  .map((w) => {
456
866
  const { dirty, merged } = folderState(root, w.path);
457
867
  let touchedAt = null;
458
868
  try { touchedAt = fs.statSync(w.path).mtimeMs; } catch { /* it can vanish under us */ }
459
- const holder = pidInReason(w.lock);
460
869
  return {
461
870
  id: path.basename(w.path),
462
871
  path: w.path,
463
- branch: w.branch ?? '',
872
+ branch: w.branch,
464
873
  dirty,
465
874
  merged,
466
875
  touchedAt,
467
- heldBy: holder && alive(holder) ? holder : null,
876
+ heldBy: w.heldBy,
468
877
  };
469
878
  });
470
879
  }