agentbox-app 0.1.12 → 0.1.16

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 (370) hide show
  1. package/README.md +24 -9
  2. package/bin/agentbox.mjs +23 -1
  3. package/briefs/message-rules.md +36 -8
  4. package/briefs/worker.md +46 -23
  5. package/main/account-discovery.mjs +122 -30
  6. package/main/account-tooling.mjs +29 -1
  7. package/main/account-usage.mjs +46 -0
  8. package/main/acp.mjs +296 -0
  9. package/main/agent-homes.mjs +158 -0
  10. package/main/agent-sessions.mjs +311 -260
  11. package/main/agent-updates.mjs +9 -22
  12. package/main/agents.mjs +179 -466
  13. package/main/analytics.mjs +42 -2
  14. package/main/answer-modes.mjs +103 -37
  15. package/main/approval-prompt-core.mjs +197 -0
  16. package/main/approval-prompt-server.mjs +4 -138
  17. package/main/approvals.mjs +101 -297
  18. package/main/artifact-path.mjs +47 -18
  19. package/main/claude-bin.mjs +45 -311
  20. package/main/claude-input.mjs +57 -7
  21. package/main/claude-launch-env.mjs +54 -0
  22. package/main/claude-models.mjs +39 -7
  23. package/main/claude-plan.mjs +64 -16
  24. package/main/claude-session-open.mjs +28 -0
  25. package/main/claude-usage-process.mjs +40 -0
  26. package/main/claude-usage.mjs +94 -59
  27. package/main/cloud/account.mjs +179 -0
  28. package/main/cloud/aws.mjs +127 -0
  29. package/main/cloud/browser-return.mjs +58 -0
  30. package/main/cloud/clients.mjs +15 -0
  31. package/main/cloud/codex-cloud.mjs +53 -0
  32. package/main/cloud/config.mjs +107 -0
  33. package/main/cloud/docs.mjs +219 -0
  34. package/main/cloud/exec.mjs +27 -0
  35. package/main/cloud/host.mjs +165 -0
  36. package/main/cloud/kit.mjs +80 -0
  37. package/main/cloud/logins.mjs +543 -0
  38. package/main/cloud/managed.mjs +115 -0
  39. package/main/cloud/marker.mjs +104 -0
  40. package/main/cloud/org.mjs +53 -0
  41. package/main/cloud/reuse.mjs +208 -0
  42. package/main/cloud/run-cli.mjs +177 -0
  43. package/main/cloud/run.mjs +1934 -0
  44. package/main/cloud/usage.mjs +76 -0
  45. package/main/cloud-permission.mjs +51 -0
  46. package/main/codex-account.mjs +78 -0
  47. package/main/codex-app-server.mjs +276 -409
  48. package/main/codex-approvals.mjs +148 -433
  49. package/main/codex-bin.mjs +51 -249
  50. package/main/codex-models.mjs +20 -2
  51. package/main/codex-session.mjs +246 -506
  52. package/main/codex-threads-worker.mjs +12 -0
  53. package/main/codex-threads.mjs +112 -11
  54. package/main/codex-usage-file.mjs +5 -3
  55. package/main/codex-usage.mjs +56 -7
  56. package/main/codex-watch.mjs +83 -5
  57. package/main/codex.mjs +95 -363
  58. package/main/config.mjs +37 -5
  59. package/main/corner-tag.mjs +810 -0
  60. package/main/crash-report.mjs +26 -3
  61. package/main/dev-window.mjs +1 -1
  62. package/main/engine-bin.mjs +545 -0
  63. package/main/engine-setup.mjs +25 -24
  64. package/main/feedback.mjs +14 -31
  65. package/main/folder-pictures.mjs +2 -2
  66. package/main/freeze-recorder-thread.mjs +86 -0
  67. package/main/freeze-recorder.mjs +146 -0
  68. package/main/fresh-user.mjs +20 -9
  69. package/main/git-change-offthread.mjs +17 -3
  70. package/main/git-change-thread.mjs +5 -2
  71. package/main/harnesses/claude-stream.mjs +208 -0
  72. package/main/harnesses/claude.mjs +105 -0
  73. package/main/harnesses/codex.mjs +72 -0
  74. package/main/harnesses/common.mjs +78 -0
  75. package/main/harnesses/instructions-file.mjs +116 -0
  76. package/main/harnesses/opencode.mjs +234 -0
  77. package/main/harnesses.mjs +65 -0
  78. package/main/importable-threads.mjs +96 -0
  79. package/main/ipc.mjs +520 -135
  80. package/main/leftovers.mjs +117 -25
  81. package/main/liquid-glass.mjs +26 -0
  82. package/main/live-replies.mjs +10 -4
  83. package/main/local-app-frames.mjs +82 -0
  84. package/main/main-went-red.mjs +330 -0
  85. package/main/main.mjs +248 -37
  86. package/main/memory-gate-history.mjs +85 -20
  87. package/main/memory-gate-server.mjs +129 -11
  88. package/main/memory-gate.mjs +62 -8
  89. package/main/memory-log.mjs +124 -0
  90. package/main/menu.mjs +4 -2
  91. package/main/message-priority.mjs +2 -2
  92. package/main/mic.mjs +38 -0
  93. package/main/native/liquid-glass/build/liquid-glass.node +0 -0
  94. package/main/new-chats-watch.mjs +110 -0
  95. package/main/new-user-window.mjs +25 -0
  96. package/main/notify.mjs +64 -5
  97. package/main/opencode-server.mjs +271 -0
  98. package/main/opencode.mjs +287 -0
  99. package/main/phone-alerts.mjs +117 -0
  100. package/main/phone-inbox.mjs +625 -0
  101. package/main/phone-link.mjs +571 -0
  102. package/main/phone-uploads.mjs +476 -0
  103. package/main/picture-shape.mjs +114 -0
  104. package/main/quit-sequence.mjs +143 -0
  105. package/main/refresh-agent-models.mjs +15 -2
  106. package/main/relay-address.mjs +28 -0
  107. package/main/relay-client.mjs +314 -0
  108. package/main/repeats.mjs +10 -4
  109. package/main/row-label.mjs +18 -47
  110. package/main/run-change.mjs +13 -0
  111. package/main/run-ledger.mjs +135 -0
  112. package/main/runs-on.mjs +32 -35
  113. package/main/save-attachments.mjs +41 -0
  114. package/main/serve.mjs +155 -9
  115. package/main/settings.mjs +364 -50
  116. package/main/shell-path.mjs +234 -0
  117. package/main/ship-conflict-brief.mjs +119 -0
  118. package/main/ship-queue.mjs +134 -20
  119. package/main/sign-in-files.mjs +3 -7
  120. package/main/source-updater.mjs +45 -11
  121. package/main/stage-attachment.mjs +68 -0
  122. package/main/stall-watch.mjs +50 -0
  123. package/main/start-hold.mjs +73 -0
  124. package/main/store/home.mjs +87 -232
  125. package/main/store/paths.mjs +11 -0
  126. package/main/store/teammates-files.mjs +99 -0
  127. package/main/store/work-items.mjs +231 -13
  128. package/main/store.mjs +227 -53
  129. package/main/supervisor.mjs +3865 -699
  130. package/main/task-commands.mjs +13 -17
  131. package/main/task-folders-offthread.mjs +6 -1
  132. package/main/task-folders.mjs +897 -105
  133. package/main/task-terminals.mjs +147 -41
  134. package/main/team/index.mjs +114 -10
  135. package/main/team/memory-cloud.mjs +183 -1
  136. package/main/team/room-hours.mjs +48 -0
  137. package/main/team/room-setup.mjs +31 -0
  138. package/main/team/rooms-memory.mjs +454 -0
  139. package/main/team/rooms-shape.mjs +42 -0
  140. package/main/team/rooms-supabase.mjs +324 -0
  141. package/main/team/rooms.mjs +575 -0
  142. package/main/team/session.mjs +96 -26
  143. package/main/team/supabase-backend.mjs +180 -0
  144. package/main/team/sync.mjs +109 -1
  145. package/main/teammates.mjs +580 -0
  146. package/main/terminal-host.mjs +143 -0
  147. package/main/terminal-hosts.mjs +261 -0
  148. package/main/transcript-search.mjs +80 -0
  149. package/main/updater.mjs +71 -9
  150. package/main/webview-guard.mjs +45 -0
  151. package/main/worktree-include.mjs +365 -0
  152. package/mcp/core/documents.mjs +3 -1
  153. package/mcp/core/repeats.mjs +88 -0
  154. package/mcp/core/teammates.mjs +222 -0
  155. package/mcp/core/work.mjs +76 -4
  156. package/mcp/stdio.mjs +24 -11
  157. package/mcp/tools.mjs +116 -8
  158. package/package.json +30 -5
  159. package/renderer/dist/assets/abnfDiagram-YUKMZFIV-CoWB6YUK.js +1 -0
  160. package/renderer/dist/assets/arc-Ch0NrCul.js +1 -0
  161. package/renderer/dist/assets/architectureDiagram-47P4ROYG-CXz9M5tQ.js +36 -0
  162. package/renderer/dist/assets/blockDiagram-E7TT5TSB-DchGUuHH.js +140 -0
  163. package/renderer/dist/assets/c4Diagram-CRA5TL53-Cip-zNyr.js +38 -0
  164. package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
  165. package/renderer/dist/assets/chunk-2Q5K7J3B-BoXid_2r.js +1 -0
  166. package/renderer/dist/assets/chunk-5VM5RSS4-Cpez1rRz.js +15 -0
  167. package/renderer/dist/assets/chunk-7TKQ45FW-DKA84YkE.js +1 -0
  168. package/renderer/dist/assets/chunk-AW2ZBBNX-Dg-SumPG.js +88 -0
  169. package/renderer/dist/assets/chunk-FVRAUYC3-06TCvPm1.js +2 -0
  170. package/renderer/dist/assets/chunk-HTAEGDNF-CMEBy1Td.js +1 -0
  171. package/renderer/dist/assets/chunk-JWPE2WC7-BJ2ObmV-.js +1 -0
  172. package/renderer/dist/assets/chunk-KQW6MTUR-CGWOYXUE.js +1 -0
  173. package/renderer/dist/assets/chunk-XXDRQBXY-CiZp07XC.js +1 -0
  174. package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-DJNIX719.js +217 -0
  175. package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
  176. package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
  177. package/renderer/dist/assets/cose-bilkent-JH36ORCC-CbkyZOUH.js +1 -0
  178. package/renderer/dist/assets/cynefin-EF2NZ3EQ-78iOroZO.js +141 -0
  179. package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-GcCopMs7.js +62 -0
  180. package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  181. package/renderer/dist/assets/dagre-W4DXFKR2-DHLlXLkz.js +4 -0
  182. package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  183. package/renderer/dist/assets/diagram-2UJZ2QOL-DuZSUHcH.js +200 -0
  184. package/renderer/dist/assets/diagram-OVF4WLC6-Ckxu3cqH.js +24 -0
  185. package/renderer/dist/assets/diagram-PFPMY2P6-CsTw9Ycn.js +30 -0
  186. package/renderer/dist/assets/diagram-UMYRVEAY-CTXd8qVg.js +24 -0
  187. package/renderer/dist/assets/diagram-YEKJPTXX-CJs1T_9m.js +41 -0
  188. package/renderer/dist/assets/diagram-ZIFT7M5P-CvjI23mf.js +3 -0
  189. package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-d_IeAzOJ.js +1 -0
  190. package/renderer/dist/assets/elk-IJKZMXRS-zo9KFP3N.js +27 -0
  191. package/renderer/dist/assets/erDiagram-O2IAPWRE-DqfYf5Dt.js +99 -0
  192. package/renderer/dist/assets/flowDiagram-OXPTDLAJ-DAWsMLmo.js +213 -0
  193. package/renderer/dist/assets/ganttDiagram-R7TSEDQI-4ml_bhwR.js +292 -0
  194. package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-B_WcqSId.js +106 -0
  195. package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
  196. package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-CI-jYqzc.js +2 -0
  197. package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
  198. package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-B56VWRlV.js +70 -0
  199. package/renderer/dist/assets/journeyDiagram-COXZFDF6-D1-p09Lr.js +139 -0
  200. package/renderer/dist/assets/kanban-definition-P3RFRI5V-Cv3w7JoN.js +89 -0
  201. package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
  202. package/renderer/dist/assets/linear-C35V2Spi.js +1 -0
  203. package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
  204. package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
  205. package/renderer/dist/assets/mermaid.core-BzGT5Vo9.js +314 -0
  206. package/renderer/dist/assets/mindmap-definition-LZFPQGKD-BZUfuymC.js +96 -0
  207. package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  208. package/renderer/dist/assets/pegDiagram-BGZESJAR-DFh9XNGe.js +1 -0
  209. package/renderer/dist/assets/pieDiagram-CAPJLFHJ-C65tPVsy.js +39 -0
  210. package/renderer/dist/assets/product-name-CSNW6d7s.js +40 -0
  211. package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-Cushj_nk.js +7 -0
  212. package/renderer/dist/assets/railroadDiagram-SM67HX2B-DZGebGx5.js +1 -0
  213. package/renderer/dist/assets/requirementDiagram-X7JNWC4A-DpAB0MNo.js +84 -0
  214. package/renderer/dist/assets/sankeyDiagram-UM26HJRW-D-29rHra.js +40 -0
  215. package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-FfOQAZol.js +169 -0
  216. package/renderer/dist/assets/sizeCapture-INFHLROL-C_n6fDBY.js +1 -0
  217. package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-BZjenzJG.js +291 -0
  218. package/renderer/dist/assets/swimlanes-N4OXWK64-DxaMU9vX.js +1 -0
  219. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
  220. package/renderer/dist/assets/timeline-definition-YOQAKGHF-DTg5HJKz.js +120 -0
  221. package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-Bc3MZDsl.js +370 -0
  222. package/renderer/dist/assets/vennDiagram-BLWOH2XV-GIVGk9Id.js +34 -0
  223. package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-NmJnbQOg.js +78 -0
  224. package/renderer/dist/assets/xychartDiagram-TAQBALBS-p-kfGfnW.js +7 -0
  225. package/renderer/dist/corner-tag.html +13 -0
  226. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  227. package/renderer/dist/emulator/data/compression/README.md +11 -0
  228. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  229. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  230. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  231. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  232. package/renderer/dist/emulator/data/cores/README.md +22 -0
  233. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  234. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  235. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  236. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  237. package/renderer/dist/emulator/data/cores/package.json +65 -0
  238. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  239. package/renderer/dist/emulator/data/emulator.css +1614 -0
  240. package/renderer/dist/emulator/data/loader.js +170 -0
  241. package/renderer/dist/emulator/data/localization/README.md +63 -0
  242. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  243. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  244. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  245. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  246. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  247. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  248. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  249. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  250. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  251. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  252. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  253. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  254. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  255. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  256. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  257. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  258. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  259. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  260. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  261. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  262. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  263. package/renderer/dist/emulator/data/src/compression.js +142 -0
  264. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  265. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  266. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  267. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  268. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  269. package/renderer/dist/emulator/data/src/storage.js +125 -0
  270. package/renderer/dist/emulator/data/version.json +5 -0
  271. package/renderer/dist/emulator/host.js +297 -0
  272. package/renderer/dist/emulator/index.html +28 -0
  273. package/renderer/dist/emulator/provenance.json +6 -0
  274. package/renderer/dist/index.html +16 -3
  275. package/scripts/after-install.mjs +30 -8
  276. package/scripts/approval-server.sh +24 -5
  277. package/scripts/before-publish.mjs +12 -10
  278. package/scripts/cloud-shim.pl +57 -0
  279. package/shared/account-names.d.mts +6 -0
  280. package/shared/account-names.mjs +15 -0
  281. package/shared/agent-hours.mjs +136 -0
  282. package/shared/agent-import.mjs +62 -34
  283. package/shared/agents.d.mts +1 -0
  284. package/shared/agents.mjs +126 -522
  285. package/shared/analytics-events.mjs +12 -0
  286. package/shared/approval-card.mjs +36 -0
  287. package/shared/bridge-map.mjs +45 -1
  288. package/shared/claude-code-shape.mjs +6 -0
  289. package/shared/claude-commands.generated.mjs +6 -4
  290. package/shared/claude-models.generated.mjs +6 -4
  291. package/shared/claude-usage.d.mts +1 -0
  292. package/shared/claude-usage.mjs +42 -1
  293. package/shared/cloud-hand-back.d.mts +7 -0
  294. package/shared/cloud-hand-back.mjs +20 -0
  295. package/shared/cloud-kit.d.mts +219 -0
  296. package/shared/cloud-kit.mjs +341 -0
  297. package/shared/cloud-login-name.mjs +4 -0
  298. package/shared/cloud-start.mjs +17 -0
  299. package/shared/cloud-versions.mjs +23 -0
  300. package/shared/codex-import.mjs +2 -2
  301. package/shared/codex-modes.d.mts +22 -9
  302. package/shared/codex-modes.mjs +67 -11
  303. package/shared/codex-usage.mjs +108 -2
  304. package/shared/corner-tag.d.mts +36 -0
  305. package/shared/corner-tag.mjs +396 -0
  306. package/shared/dead-run-trace.mjs +19 -2
  307. package/shared/engines.mjs +76 -300
  308. package/shared/feedback.d.mts +7 -0
  309. package/shared/feedback.mjs +25 -0
  310. package/shared/first-run-practice.mjs +17 -24
  311. package/shared/harness-definitions.mjs +111 -0
  312. package/shared/instruction-defaults.json +2 -2
  313. package/shared/key-caps.d.mts +14 -0
  314. package/shared/key-caps.mjs +68 -0
  315. package/shared/message-edits.d.mts +7 -0
  316. package/shared/message-edits.mjs +35 -0
  317. package/shared/move-thread.d.mts +24 -0
  318. package/shared/move-thread.mjs +112 -0
  319. package/shared/named-threads.mjs +72 -0
  320. package/shared/not-shipped.d.mts +22 -0
  321. package/shared/not-shipped.mjs +89 -0
  322. package/shared/notify-rules.d.mts +31 -0
  323. package/shared/notify-rules.mjs +55 -8
  324. package/shared/phone-files.mjs +28 -0
  325. package/shared/phone-settings.mjs +118 -0
  326. package/shared/phone-snapshot.d.mts +16 -0
  327. package/shared/phone-snapshot.mjs +348 -0
  328. package/shared/picture-formats.d.mts +1 -0
  329. package/shared/picture-formats.mjs +13 -0
  330. package/shared/provider-commands.mjs +10 -2
  331. package/shared/rank.d.mts +4 -1
  332. package/shared/rank.mjs +82 -19
  333. package/shared/relay-address.mjs +6 -0
  334. package/shared/relay-crypto.mjs +165 -0
  335. package/shared/repeats.d.mts +8 -0
  336. package/shared/repeats.mjs +33 -2
  337. package/shared/room-cards.mjs +167 -0
  338. package/shared/room-links.d.mts +4 -0
  339. package/shared/room-links.mjs +21 -0
  340. package/shared/runs-on.d.mts +3 -13
  341. package/shared/runs-on.mjs +31 -52
  342. package/shared/shipped-note.d.mts +4 -0
  343. package/shared/shipped-note.mjs +6 -0
  344. package/shared/shortcuts.mjs +24 -10
  345. package/shared/spawn-trouble.d.mts +4 -0
  346. package/shared/spawn-trouble.mjs +484 -30
  347. package/shared/team-rules.mjs +9 -2
  348. package/shared/teammates.d.mts +19 -0
  349. package/shared/teammates.mjs +756 -0
  350. package/shared/terminal-focus.d.mts +1 -0
  351. package/shared/terminal-focus.mjs +13 -0
  352. package/shared/terminal-input.d.mts +1 -0
  353. package/shared/terminal-input.mjs +30 -0
  354. package/shared/terminal-keys.d.mts +1 -0
  355. package/shared/terminal-keys.mjs +16 -0
  356. package/shared/terminal-size.d.mts +4 -0
  357. package/shared/terminal-size.mjs +29 -0
  358. package/shared/thread-cards.mjs +4 -1
  359. package/shared/usage.d.mts +12 -0
  360. package/shared/usage.mjs +56 -3
  361. package/shared/websites.d.mts +15 -0
  362. package/shared/websites.mjs +61 -0
  363. package/shared/where-it-runs-from.d.mts +43 -0
  364. package/shared/where-it-runs-from.mjs +155 -0
  365. package/shared/work-items.mjs +150 -29
  366. package/shared/work-lines.mjs +93 -245
  367. package/shared/xterm-invalid-range.d.mts +2 -0
  368. package/shared/xterm-invalid-range.mjs +58 -0
  369. package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
  370. 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
- import { nameSlug } from '../shared/product-name.mjs';
65
+ import { Name, 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'];
@@ -53,19 +77,98 @@ const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
53
77
  /** What an unfinished folder is called. A task name may never start with a dot. */
54
78
  const BUILDING = '.building-';
55
79
 
56
- const git = (cwd, args) => execFileSync('git', args, {
80
+ // A HOOK RUN BY THE APP IN A TASK FOLDER IS A WORKER'S CODE RUN AS THE APP.
81
+ // core.hooksPath is often the relative 'scripts/hooks', which git resolves
82
+ // against the working tree it runs in, so in a task folder it is that folder's
83
+ // own scripts/hooks -- which a worker edits. parkTaskFolder commits there
84
+ // (post-commit, reference-transaction), so the app's every git in a linked
85
+ // worktree runs hook-free. The primary checkout, where `worktree add` runs its
86
+ // own post-checkout, keeps its hooks: a linked worktree's `.git` is a file.
87
+ const inLinkedWorktree = (cwd) => {
88
+ try { return fs.lstatSync(path.join(cwd, '.git')).isFile(); } catch { return false; }
89
+ };
90
+ const git = (cwd, args) => execFileSync('git', [
91
+ ...(inLinkedWorktree(cwd) ? ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false'] : []),
92
+ ...args,
93
+ ], {
57
94
  cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
58
95
  }).trim();
59
96
 
97
+ /**
98
+ * What is in a directory. One we cannot even read is reported as holding
99
+ * something, because the only decision taken on this answer is whether the
100
+ * directory is safe to delete, and "we could not look" is not a yes.
101
+ */
102
+ function tryRead(dir) {
103
+ try { return fs.readdirSync(dir); } catch { return ['we could not look']; }
104
+ }
105
+
106
+ // Files that say nothing of anyone's work: Finder's, and the one an agent
107
+ // writes when it recreates its deleted folder to keep its shell alive. A
108
+ // folder holding only these is as good as empty.
109
+ const LEFTOVER_MARKERS = new Set(['.DS_Store', '.keep-session']);
110
+ const isPlainFile = (file) => { try { return fs.lstatSync(file).isFile(); } catch { return false; } };
111
+
60
112
  /** Git that answers rather than throws, for the questions where "no" is fine. */
61
113
  function tryGit(cwd, args) {
62
114
  try { return { ok: true, out: git(cwd, args) }; }
63
115
  catch (error) { return { ok: false, out: String(error?.stderr ?? error?.message ?? '').trim() }; }
64
116
  }
65
117
 
118
+ /**
119
+ * DOES THIS FAILED `worktree add` MEAN SOMEBODY ELSE IS MAKING THE FOLDER?
120
+ *
121
+ * Git's wordings for "something is already at that path", measured on git
122
+ * 2.39.5 across six processes racing for one id:
123
+ *
124
+ * fatal: '<staging>' already exists (2026-10-09)
125
+ * fatal: '<staging>' is already registered (2026-10-09)
126
+ * fatal: '<branch>' is already checked out at '<staging>'
127
+ * fatal: '<branch>' is already used by worktree at '<staging>'
128
+ * fatal: '<staging>' is not a working tree (2026-10-17)
129
+ *
130
+ * THE NARROWNESS IS THE POINT, AND IT TOOK TWO GOES. Everything else `worktree
131
+ * add` can fail with -- a full disk, an invalid ref, a branch collision -- is a
132
+ * real failure with a different answer, and reporting one of those as "another
133
+ * session has this" would send whoever reads the row hunting for a session that
134
+ * does not exist. So a wording is not enough on its own: the path git names has
135
+ * to be a STAGING folder. Named anywhere else, the two branch wordings mean the
136
+ * branch is checked out in somebody's finished work, which is a failure a
137
+ * person must act on -- `a-task-folder-is-made-when-the-repository-has-a-
138
+ * failing-post-checkout-hook` is main's test for exactly that, and the first
139
+ * draft of this guard swallowed it.
140
+ *
141
+ * A branch collision is deliberately not matched either: `a branch named ...
142
+ * already exists` has its own road a few lines above (ask again, then attach),
143
+ * and that road is the 2026-10-07 fix which must not be routed around.
144
+ */
145
+ export function somebodyIsBuilding(out) {
146
+ const said = String(out ?? '');
147
+ if (!said) return false;
148
+ // A BRANCH THAT ALREADY EXISTS IS NOT A FOLDER THAT ALREADY EXISTS, and it
149
+ // shares the words. It has its own road a few lines above `addOwned`'s caller
150
+ // -- ask again, then attach -- and that road is the 2026-10-07 fix, so routing
151
+ // around it would hand a session the shared checkout. Checked first, because
152
+ // the phrase it carries would otherwise match the clause below it.
153
+ if (/a branch named/i.test(said)) return false;
154
+ // The marker rather than the path itself, because git prints the path it
155
+ // resolved: on a Mac a staging folder under /tmp comes back under /private.
156
+ if (!said.includes(BUILDING)) return false;
157
+ return /already exists/i.test(said)
158
+ || /already registered/i.test(said)
159
+ || /already used by worktree/i.test(said)
160
+ || /already checked out/i.test(said)
161
+ // Git's bookkeeping for staging caught mid-flight: registered by the winner
162
+ // and already gone from the disk, so the loser's second add finds neither.
163
+ || /is not a working tree/i.test(said);
164
+ }
165
+
166
+ /** `safeTaskName`'s question, asked of a folder already on the disk without throwing. */
167
+ const couldBeATaskName = (name) => SAFE_NAME.test(name) && String(name).length <= 64;
168
+
66
169
  export function safeTaskName(id) {
67
170
  const name = String(id ?? '');
68
- if (!SAFE_NAME.test(name) || name.length > 64) {
171
+ if (!couldBeATaskName(name)) {
69
172
  throw Error(`Not a name a task folder may be given: ${JSON.stringify(id)}`);
70
173
  }
71
174
  return name;
@@ -118,6 +221,132 @@ export function repoRoot(dir) {
118
221
  return found.ok && found.out ? found.out : null;
119
222
  }
120
223
 
224
+ /**
225
+ * A PROJECT FOLDER THAT IS NOT A REPOSITORY IS MADE ONE (2026-10-14).
226
+ *
227
+ * A teammate's first task failed with "no folder could be made" until they ran
228
+ * `git init` and a first commit by hand, and so did every task in any project
229
+ * picked from a plain folder: a task folder is a worktree, and a worktree needs
230
+ * a repository and a commit to start from. New Project even creates the folder
231
+ * when it is missing, so an empty one failed its first task every time.
232
+ *
233
+ * So the app takes the same two steps. Local only: nothing is pushed, and a
234
+ * `.gitignore` the folder already has is respected by the first commit. Inside
235
+ * a repository already, `repoRoot` answers first and this never runs. A folder
236
+ * too big to commit by itself (`FIRST_COMMIT_LIMIT`) is left exactly as it was,
237
+ * the repository just started here removed again, and the refusal says why.
238
+ */
239
+ function startRepository(dir) {
240
+ if (!dir || !fs.existsSync(dir)) return null;
241
+ const why = gitWillNotRun(dir);
242
+ if (why) throw Error(why);
243
+ if (!tryGit(dir, ['init', '-q']).ok) return null;
244
+ const root = repoRoot(dir);
245
+ try { firstCommitIfNone(root); }
246
+ catch (error) { fs.rmSync(path.join(root, '.git'), { recursive: true, force: true }); throw error; }
247
+ return root;
248
+ }
249
+
250
+ /**
251
+ * WHEN GIT ITSELF CANNOT RUN HERE, THE ONE THING TO DO ABOUT IT (2026-10-15).
252
+ *
253
+ * `repoRoot` reads every failure as "not a repository", and two everyday setups
254
+ * fail before git looks at anything: a Mac without the Command Line Tools,
255
+ * whose `/usr/bin/git` only prints "xcrun: error: invalid active developer
256
+ * path", and a repository owned by another account, which git refuses as
257
+ * "dubious ownership". Both ended as "no folder could be made" with a list of
258
+ * causes that named neither, and the second ran `git init` over somebody
259
+ * else's repository on the way. The sentence returned here is what the task's
260
+ * row says, after "could not be given a folder of its own to work in: ".
261
+ */
262
+ function gitWillNotRun(dir) {
263
+ const asked = tryGit(dir, ['rev-parse', '--show-toplevel']);
264
+ if (asked.ok) return null;
265
+ if (/xcrun: error|invalid active developer path|no developer tools were found/i.test(asked.out)) {
266
+ return `git is not set up on this Mac yet. Open Terminal, run xcode-select --install, and reply here once it has finished`;
267
+ }
268
+ if (/\bENOENT\b/.test(asked.out)) {
269
+ return `git is not installed on this computer, and ${Name} needs it to give each task a folder. Install git, then reply here`;
270
+ }
271
+ const owned = /dubious ownership in repository at '([^']+)'/.exec(asked.out);
272
+ if (owned) {
273
+ return `git will not work in ${owned[1]} because that folder belongs to another account on this computer. If it is yours, run git config --global --add safe.directory '${owned[1]}' in Terminal, then reply here`;
274
+ }
275
+ return null;
276
+ }
277
+
278
+ /**
279
+ * HOW MUCH A FIRST COMMIT MAY TAKE ON BY ITSELF. Measured on the founder's
280
+ * store on 2026-10-14: the one project whose folder was not a repository held
281
+ * 18 GB, mostly media libraries, and committing that copies all of it into
282
+ * `.git` and runs for minutes. A code project with its dependencies left in is
283
+ * a few hundred MB.
284
+ */
285
+ const FIRST_COMMIT_LIMIT = 500 * 1024 * 1024;
286
+
287
+ const sizeSaid = (bytes) => (bytes >= 1024 ** 3
288
+ ? `${(bytes / 1024 ** 3).toFixed(1)} GB`
289
+ : `${Math.round(bytes / 1024 ** 2)} MB`);
290
+
291
+ /** What a first commit would take, in bytes, stopping once past the limit. */
292
+ function sizeOfFirstCommit(root) {
293
+ // Its own call rather than `tryGit`, for the buffer: a folder of media lists
294
+ // far past the default megabyte of output.
295
+ const listed = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], {
296
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 256 * 1024 * 1024,
297
+ });
298
+ let total = 0;
299
+ for (const file of listed.split('\0')) {
300
+ if (!file) continue;
301
+ try { total += fs.lstatSync(path.join(root, file)).size; } catch { /* gone since the listing */ }
302
+ if (total > FIRST_COMMIT_LIMIT) break;
303
+ }
304
+ return total;
305
+ }
306
+
307
+ /**
308
+ * The first commit, made only when there is none: a repository with any commit
309
+ * at all is never committed to here, so work somebody has not committed stays
310
+ * theirs. `--allow-empty` because an empty folder is a real first project.
311
+ * Made `asTheApp`, like every commit in this file.
312
+ */
313
+ function firstCommitIfNone(root) {
314
+ if (tryGit(root, ['rev-parse', '--verify', '-q', 'HEAD']).ok) return;
315
+ hideFromTheCheckout(root);
316
+ const size = sizeOfFirstCommit(root);
317
+ if (size > FIRST_COMMIT_LIMIT) {
318
+ throw Error(`this project's folder is not a git repository yet, and at more than ${sizeSaid(size)} it is too big for ${Name} to make one by itself. In a terminal in that folder run git init, add a .gitignore for the big folders, and make a first commit`);
319
+ }
320
+ if (!tryGit(root, ['add', '-A']).ok) return;
321
+ tryGit(root, [...asTheApp(root), 'commit', '-q', '--no-verify', '--allow-empty', '-m', `First commit, made by ${Name} so each task can have a folder of its own`]);
322
+ }
323
+
324
+ /**
325
+ * EVERY COMMIT THE APP MAKES IS MADE WITH NOBODY WATCHING, so it cannot ask
326
+ * anybody anything. Two things a person's git setup can make a commit ask:
327
+ *
328
+ * Who are you. A git with no name or email set refuses with "Please tell me
329
+ * who you are" wherever it cannot make one up, which is any repository with
330
+ * `user.useConfigOnly` and any Linux whose host name has no domain. A park
331
+ * refused that way left the folder on the disk for good (2026-10-15). So
332
+ * whatever is missing is filled in with the app's name, and whatever the
333
+ * person set is kept.
334
+ *
335
+ * A passphrase. With `commit.gpgsign` on, the signer can open a pinentry
336
+ * window or wait on 1Password, and measured on 2026-10-15 with a signer that
337
+ * waits, a park never came back. Folder jobs run one at a time, so that one
338
+ * park left every later task in every project without a folder. Signing is
339
+ * off for the app's commits only; the person's own still sign.
340
+ */
341
+ function asTheApp(cwd) {
342
+ const set = (key) => { const got = tryGit(cwd, ['config', key]); return got.ok && got.out !== ''; };
343
+ return [
344
+ ...(set('user.name') ? [] : ['-c', `user.name=${Name}`]),
345
+ ...(set('user.email') ? [] : ['-c', `user.email=${nameSlug}@localhost`]),
346
+ '-c', 'commit.gpgsign=false',
347
+ ];
348
+ }
349
+
121
350
  /**
122
351
  * What a new folder starts from: LOCAL main, not the remote. Local main carries
123
352
  * whatever has not been pushed, and on this Mac that is routinely 20+ commits,
@@ -161,19 +390,173 @@ function registered(repoPath) {
161
390
  const record = (repoPath, folder) => registered(repoPath).find((w) => w.path === folder) ?? null;
162
391
 
163
392
  /**
164
- * The folder this task works in, made if it is not there yet. those
165
- * sessions keep running exactly where they ran before.
393
+ * WHO HOLDS THIS FOLDER, which is the one question every answer here is built
394
+ * on. Four answers and they are not the same shape:
395
+ *
396
+ * no lock at all free, and whoever asks may write their own
397
+ * our own pid already ours
398
+ * a lock with no pid of SOMEBODY ELSE'S, for good: a person who ran
399
+ * ours in it `git worktree lock` meant it, and there is no
400
+ * process to find dead
401
+ * another pid, alive theirs while it lives
402
+ * another pid, gone free, but only through a takeover that writes
403
+ * the new owner (`stale`)
404
+ */
405
+ function holder(root, folder, pid) {
406
+ const held = record(root, folder)?.lock;
407
+ if (held === undefined) return { ok: true, heldBy: null, free: true };
408
+ const owner = pidInReason(held);
409
+ if (owner === pid) return { ok: true, heldBy: pid };
410
+ if (owner === null) return { ok: false, reason: 'locked', heldBy: null };
411
+ if (alive(owner)) return { ok: false, reason: 'held', heldBy: owner };
412
+ return { ok: true, heldBy: owner, stale: true };
413
+ }
414
+
415
+ /**
416
+ * WHO ENDED UP IN THE LOCK, read off our own `worktree lock` and the lock as it
417
+ * stands a moment later. Four answers and one non-answer:
418
+ *
419
+ * our pid in it ours
420
+ * another pid in it theirs, and the row can name it
421
+ * a lock with no pid a person locked it by hand
422
+ * our lock would not write this machine failing, in git's own words
423
+ * nothing in it at all NOT AN ANSWER YET: ask again
424
+ *
425
+ * THE NON-ANSWER IS THE WHOLE POINT. `unlock` and `lock` are two commands, so
426
+ * while another session takes the same dead owner's folder over, there is a
427
+ * moment when the folder carries no lock at all: ours is gone and theirs is not
428
+ * written yet. A read landing in that moment has nobody to name, and it used to
429
+ * answer `refused`, which reached the row as "its folder is refused" -- not
430
+ * something anybody can act on, and not true either, since that session is
431
+ * about to own it. Asked again, the lock has landed and the answer is real.
432
+ *
433
+ * GIT'S OWN REFUSAL IS READ RATHER THAN COUNTED AS A FAILURE. `is already
434
+ * locked` is a session that got in a moment before us and usually carries its
435
+ * pid, so it asks again too and names them when the asking runs out. Only a
436
+ * lock git would not write for some other reason -- a permission, a full disk,
437
+ * a path it does not know -- is this machine failing, and that one carries
438
+ * git's words out rather than naming a session that does not exist.
439
+ */
440
+ const alreadyLocked = (out) => /is already locked/i.test(String(out ?? ''));
441
+
442
+ export function whoEndedUpInTheLock({ pid, locked, after, triesLeft }) {
443
+ const now = pidInReason(after);
444
+ if (now === pid) return { taken: true, heldBy: pid };
445
+ if (after !== undefined) return { taken: false, reason: now ? 'held' : 'locked', heldBy: now ?? null };
446
+ if (!locked.ok && !alreadyLocked(locked.out)) {
447
+ return { taken: false, reason: 'unlockable', heldBy: null, detail: locked.out };
448
+ }
449
+ if (triesLeft > 0) return { again: true };
450
+ const them = pidInReason(locked.out);
451
+ if (them) return { taken: false, reason: 'held', heldBy: them };
452
+ // Git saw a lock we cannot read a pid out of, which is a person's own.
453
+ if (!locked.ok) return { taken: false, reason: 'locked', heldBy: null };
454
+ return { taken: false, reason: 'held', heldBy: null };
455
+ }
456
+
457
+ /**
458
+ * TAKE THE FOLDER, OR SAY WHO HAS IT. Never a side effect: the caller gets
459
+ * `taken` either way, and a refusal names the process in the way.
460
+ *
461
+ * A takeover writes the new owner, because the pid in that lock is the whole
462
+ * of the protection `releaseTaskFolder` and `parkTaskFolder` have. It is read
463
+ * back afterwards rather than assumed: `git worktree unlock` and `lock` are two
464
+ * commands, so two processes taking over the same dead owner's folder at the
465
+ * same moment is possible, and the one that did not end up in the lock has to
466
+ * hear that it did not get the folder.
467
+ *
468
+ * Asked up to `tries` times, because the read-back can land between another
469
+ * taker's unlock and its lock and see nobody at all. Each try is a fresh
470
+ * `worktree list`, which is the only pause the gap needs: the gap is one git
471
+ * command wide.
472
+ */
473
+ export function acquireTaskFolder(root, folder, { pid = process.pid, tries = 5 } = {}) {
474
+ for (let triesLeft = tries - 1; ; triesLeft -= 1) {
475
+ const held = holder(root, folder, pid);
476
+ if (!held.ok) return { taken: false, reason: held.reason, heldBy: held.heldBy };
477
+ if (!held.free && !held.stale) return { taken: true, heldBy: pid };
478
+
479
+ if (held.stale) tryGit(root, ['worktree', 'unlock', folder]);
480
+ const locked = tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
481
+
482
+ const said = whoEndedUpInTheLock({ pid, locked, after: record(root, folder)?.lock, triesLeft });
483
+ if (!said.again) return said;
484
+ }
485
+ }
486
+
487
+ /**
488
+ * The folder this task works in, TAKEN by this process, and made if it is not
489
+ * there yet. Either `{ taken: true, path, branch, created, heldBy }`, or
490
+ * `{ taken: false, reason, heldBy, path: null }` when somebody else has it:
491
+ *
492
+ * held another live process is working in it
493
+ * building another live process is part way through making it
494
+ * locked a person locked it by hand, so it is not ours to take
495
+ * in-the-way something that is not a task folder sits at that path
496
+ *
497
+ * `path: null` on every refusal, because there is no folder the asker may work
498
+ * in and a caller reaching for `.path` should get nothing rather than the
499
+ * folder somebody else is standing in.
500
+ *
501
+ * `how` SAYS WHICH OF THE TWO WAYS MADE IT: `clone` for the block-sharing copy,
502
+ * `checkout` for the ordinary one, `kept` for a folder that was already there.
503
+ * It is reported because the two ways are not equally good and a test that only
504
+ * checks the RESULT cannot tell them apart: the clone could break for good and
505
+ * every assertion about the folder's contents would still pass. Codex made that
506
+ * point reviewing this file on 2026-10-07 and it was right.
166
507
  */
167
- export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = true } = {}) {
508
+ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = true, again = false } = {}) {
168
509
  const name = safeTaskName(id);
169
- const root = repoRoot(dir);
510
+ const root = repoRoot(dir) ?? startRepository(dir);
170
511
  if (!root) return null;
512
+ firstCommitIfNone(root);
171
513
  const folder = taskFolderPath(root, name);
172
514
  const branch = taskBranch(name);
515
+ // A LOCK GIT WOULD NOT WRITE IS NOT A REFUSAL. Nobody else has the folder,
516
+ // and every reason above names somebody or something; this one names this
517
+ // machine, so it leaves by the road failures leave by, which puts git's words
518
+ // on the row (`_couldNotGetAFolder` in main/supervisor.mjs).
519
+ const refused = (held) => {
520
+ if (held.reason === 'unlockable') throw Error(`Could not take the folder for ${name}: ${held.detail}`);
521
+ return { taken: false, reason: held.reason, heldBy: held.heldBy ?? null, path: null };
522
+ };
523
+
524
+ // SOMEBODY ELSE FINISHED THE FOLDER WHILE WE WERE MAKING IT, which is not the
525
+ // same thing as somebody still making it (w-36c51443df). Measured 2026-10-17,
526
+ // six processes on one id on a busy machine: a loser can get all the way to
527
+ // its `worktree move` and find the winner has already moved its own copy into
528
+ // place, so git refuses with a fatal about staging and nobody is building
529
+ // anything any more. The answer is the road at the top of this function --
530
+ // the folder is there, ask to hold it -- so the three exits below take it
531
+ // rather than inventing a word for a race that is already over. Asked once:
532
+ // a second failure is not a race.
533
+ const somebodyFinishedIt = () => !again && fs.existsSync(folder) && !!record(root, folder);
534
+ const askAgain = () => ensureTaskFolder(dir, id, { pid, dependencies, again: true });
173
535
 
174
536
  if (fs.existsSync(folder) && record(root, folder)) {
175
- lock(root, folder, pid);
176
- return { path: folder, branch: record(root, folder)?.branch ?? branch, created: false };
537
+ const held = acquireTaskFolder(root, folder, { pid });
538
+ if (!held.taken) return refused(held);
539
+ return {
540
+ taken: true, heldBy: pid, path: folder, created: false, how: 'kept',
541
+ branch: record(root, folder)?.branch ?? branch,
542
+ };
543
+ }
544
+
545
+ // A DIRECTORY GIT KNOWS NOTHING ABOUT, sitting where this folder goes. The
546
+ // move below cannot be trusted to refuse it: measured 2026-10-07 on git
547
+ // 2.39.5, `worktree move` onto an existing directory behaves like `mv` and
548
+ // moves INTO it, so the finished copy landed at `<folder>/.building-<id>` and
549
+ // the session would have started in a directory holding almost none of the
550
+ // code. Whatever it is, it is not ours to delete and not ours to fill — an
551
+ // EMPTY one excepted, or one holding only LEFTOVER_MARKERS, since there is
552
+ // nothing there to lose and a stray one would otherwise wedge the row for good.
553
+ if (fs.existsSync(folder)) {
554
+ const left = tryRead(folder);
555
+ if (!left.every((name) => LEFTOVER_MARKERS.has(name) && isPlainFile(path.join(folder, name)))) return refused({ reason: 'in-the-way', heldBy: null });
556
+ try {
557
+ for (const name of left) fs.rmSync(path.join(folder, name));
558
+ fs.rmdirSync(folder);
559
+ } catch { return refused({ reason: 'in-the-way', heldBy: null }); }
177
560
  }
178
561
 
179
562
  // A registration whose directory somebody deleted by hand would refuse the
@@ -187,47 +570,177 @@ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = tr
187
570
  // EVERY STEP BELOW HAPPENS SOMEWHERE ELSE, and the folder appears at its own
188
571
  // path in one move at the end. `buildingFolderPath` is why. A build the app
189
572
  // 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.
573
+ // and which would refuse the next attempt, so it goes first — unless it
574
+ // belongs to an app process that is still building it.
191
575
  const staging = buildingFolderPath(root, name);
192
- clearUnfinished(root, staging);
576
+ const cleared = clearUnfinished(root, staging, pid);
577
+ if (!cleared.cleared) return refused({ reason: cleared.reason, heldBy: cleared.heldBy });
193
578
 
194
- const branchExists = tryGit(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).ok;
579
+ const hasBranch = () => tryGit(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).ok;
580
+ const branchExists = hasBranch();
195
581
  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)]);
204
- if (!add.ok) throw Error(`Could not make a folder for ${name}: ${add.out}`);
205
- if (dependencies) cloneDependencies(root, staging);
582
+ let how = 'clone';
583
+ if (!cloneCheckout(root, staging, branch, from, branchExists, pid)) {
584
+ how = 'checkout';
585
+ // THE BRANCH IS ASKED ABOUT AGAIN, AND THAT IS NOT BELT AND BRACES.
586
+ //
587
+ // The clone's first act is `worktree add --no-checkout -b`, which CREATES
588
+ // the branch; the copy and the verification come after. So a clone that
589
+ // got past its own door and then failed has left the branch behind, on
590
+ // purpose: `clearUnfinished` keeps it, because a branch outlives every
591
+ // folder and may already carry commits.
592
+ //
593
+ // Reusing the answer from before the clone is how that became the worst
594
+ // failure in this file. `-b` a second time is refused ("a branch named ...
595
+ // already exists"), this function throws, and main/supervisor.mjs catches
596
+ // the throw in two places and hands the session the SHARED CHECKOUT with
597
+ // nothing but a console line. One recoverable copy failure, and two agents
598
+ // are editing the same files. Found 2026-10-07 reviewing with Codex
599
+ // (w-5952e6de3e); the trigger is reproduced in
600
+ // tests/a-task-whose-first-attempt-failed-still-gets-its-own-folder.
601
+ //
602
+ // ATTACH, NEVER `-B` AND NEVER RESET. Whatever is on that branch is work.
603
+ //
604
+ // Found twice, independently: the Linux work on main reached the same line
605
+ // from the other direction, because `cp` there fails differently.
606
+ const add = hasBranch()
607
+ ? addOwned(root, staging, [staging, branch], pid)
608
+ : addOwned(root, staging, ['-b', branch, staging, baseRef(root)], pid);
609
+ if (!add.ok) {
610
+ // SOMEBODY GOT HERE FIRST, AND THAT IS A REFUSAL RATHER THAN A FAILURE
611
+ // (w-36c51443df). `clearUnfinished` above answers `building` only when
612
+ // staging is already REGISTERED AND LOCKED, and the window this lands in
613
+ // is narrower than that: between its "nothing there" and this add, another
614
+ // process created the directory. Measured 2026-10-09, six processes on one
615
+ // id: one won and five threw `fatal: '<staging>' already exists`, so the
616
+ // documented `building` reason could not fire in the one case it was
617
+ // written for.
618
+ //
619
+ // The row waited either way -- `_couldNotGetAFolder` sees the throw and
620
+ // says so in plain words -- so this is about what she reads, not about
621
+ // safety. "Another session is building this folder" is both true and
622
+ // something to act on; a git fatal about a dot-prefixed path is neither.
623
+ //
624
+ // AND SOMETIMES THE WINNER HAS ALREADY FINISHED, which `somebodyFinishedIt`
625
+ // above explains: the add was refused because the branch is checked out in
626
+ // the folder the winner has just made.
627
+ if (somebodyFinishedIt()) return askAgain();
628
+ if (somebodyIsBuilding(add.out)) return refused({ reason: 'building', heldBy: null });
629
+ throw Error(`Could not make a folder for ${name}: ${add.out}`);
630
+ }
631
+ }
632
+
633
+ // AND THE LOCAL FILES THE REPOSITORY ASKS FOR, THE SAME WAY WHICHEVER PATH RAN.
634
+ //
635
+ // This is the half that was missing, and the half people were complaining
636
+ // about. The two paths used to disagree: the clone carried every untracked file
637
+ // in the checkout, the ordinary checkout carried `node_modules` and nothing
638
+ // else, and which one a task got depended on whether somebody happened to have
639
+ // an edit open. `.worktreeinclude` makes it one answer, written down, in the
640
+ // repository, in a file Conductor and Claude Code already read.
641
+ //
642
+ // It runs in STAGING, before the folder exists at its own path, so a folder
643
+ // nobody can see yet is the one that is incomplete. A file that was named and
644
+ // cannot be given THROWS, and the staging copy goes with it.
645
+ //
646
+ // REFUSING THE WHOLE FOLDER IS THE REPORT. There was a version of this that
647
+ // made the folder anyway and returned the list of what it had refused, and
648
+ // nothing in the app read that list: a folder starting without its
649
+ // dependencies, with nobody told, is the fault this was written to end, so it
650
+ // cannot be how this fails. The throw reaches `_couldNotGetAFolder` in
651
+ // main/supervisor.mjs, which puts every path and reason on the row where
652
+ // somebody will read it, and the task waits instead of running wrong.
653
+ if (dependencies) {
654
+ try { carryLocalFiles(root, staging); }
655
+ catch (error) { clearUnfinished(root, staging); throw Error(`Could not make a folder for ${name}: ${error.message}`); }
206
656
  }
207
657
 
208
658
  // `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]);
659
+ // worktree lives moves with it. Twice forced, because a locked worktree is
660
+ // exactly what this one is and git refuses to move one otherwise; the lock
661
+ // itself survives the move (git 2.39.5, measured 2026-10-07), which is what
662
+ // keeps the folder owned from the moment it exists to the moment it is
663
+ // handed over. The destination was checked above and is checked again here,
664
+ // since the whole build has run since.
665
+ if (fs.existsSync(folder)) {
666
+ const finished = somebodyFinishedIt();
667
+ clearUnfinished(root, staging, pid);
668
+ // A registered worktree at that path is another session's finished work, not
669
+ // a stray directory in the way, and it has a better answer than `in-the-way`.
670
+ if (finished) return askAgain();
671
+ return refused({ reason: 'in-the-way', heldBy: null });
672
+ }
673
+ const moved = tryGit(root, ['worktree', 'move', '--force', '--force', staging, folder]);
212
674
  if (!moved.ok) {
213
- clearUnfinished(root, staging);
675
+ clearUnfinished(root, staging, pid);
676
+ // THE EXIT THE RACE ACTUALLY TAKES, found 2026-10-17 and missed on 2026-10-09
677
+ // because one run of a race is not a measurement of it: two sessions can both
678
+ // build the same staging copy, and the one that moves second finds the path
679
+ // gone -- `fatal: '<staging>' is not a working tree`.
680
+ if (somebodyFinishedIt()) return askAgain();
681
+ if (somebodyIsBuilding(moved.out)) return refused({ reason: 'building', heldBy: null });
214
682
  throw Error(`Could not make a folder for ${name}: ${moved.out}`);
215
683
  }
216
684
 
217
- lock(root, folder, pid);
218
- return { path: folder, branch, created: true };
685
+ const held = acquireTaskFolder(root, folder, { pid });
686
+ if (!held.taken) return refused(held);
687
+ return { taken: true, heldBy: pid, path: folder, branch, created: true, how };
219
688
  }
220
689
 
221
690
  /**
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.
691
+ * `git worktree add`, LOCKED IN THE SAME CALL, so the half-built copy is owned
692
+ * from the moment it exists rather than a moment later. That moment is the one
693
+ * two app processes over one checkout used to collide in. `--reason` on `add`
694
+ * arrived in git 2.32; on an older one the lock is written straight after,
695
+ * which narrows the window rather than closing it and is no reason to refuse
696
+ * the folder.
697
+ *
698
+ * AN ADD THAT SAYS IT FAILED MAY HAVE MADE THE FOLDER ANYWAY (2026-10-15). git
699
+ * runs the repository's post-checkout hook after the worktree is made and
700
+ * hands back the hook's exit as its own, and git-lfs's hook exits 1 on a
701
+ * machine without git-lfs. Read as a failure, the retry below was refused
702
+ * because the first add had already made the branch, and every task in that
703
+ * repository went without a folder. A checkout that itself fails is undone by
704
+ * git and leaves nothing registered, so a registered folder with a commit in
705
+ * it is one the hook complained about after the fact, and it is ours.
225
706
  */
226
- function clearUnfinished(root, staging) {
227
- if (!fs.existsSync(staging) && !record(root, staging)) return;
707
+ function addOwned(root, folder, rest, pid) {
708
+ const owned = tryGit(root, ['worktree', 'add', '--lock', '--reason', lockReason(pid), ...rest]);
709
+ if (owned.ok) return owned;
710
+ if (record(root, folder) && tryGit(folder, ['rev-parse', '--verify', '-q', 'HEAD']).ok) {
711
+ if (record(root, folder)?.lock === undefined) tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
712
+ return { ok: true, out: owned.out };
713
+ }
714
+ const added = tryGit(root, ['worktree', 'add', ...rest]);
715
+ if (added.ok) tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
716
+ return added;
717
+ }
718
+
719
+ /**
720
+ * Throw away an unfinished copy, UNLESS SOMEBODY IS STILL MAKING IT. Nothing is
721
+ * ever lost in the clearing: a staging folder has never been handed to anybody,
722
+ * so the most it can hold is a checkout that did not finish. The branch is left
723
+ * alone, as everywhere else in this file.
724
+ *
725
+ * The refusal is the part that was missing. Inside one process the folder
726
+ * thread serialises these, so a staging folder on disk really was abandoned.
727
+ * Across two it was not, and here that is routine: one app runs from the app
728
+ * folder and one from a checkout. Since the local files started being carried
729
+ * in, the build is slower, so the window is wider.
730
+ */
731
+ function clearUnfinished(root, staging, pid = process.pid) {
732
+ if (!fs.existsSync(staging) && !record(root, staging)) return { cleared: true };
733
+ const held = holder(root, staging, pid);
734
+ if (!held.ok) {
735
+ return { cleared: false, reason: held.reason === 'held' ? 'building' : held.reason, heldBy: held.heldBy };
736
+ }
737
+ // Unlocked first: `worktree remove` refuses a locked worktree even with one
738
+ // `--force`, and this one is ours or its owner is gone.
739
+ tryGit(root, ['worktree', 'unlock', staging]);
228
740
  tryGit(root, ['worktree', 'remove', '--force', staging]);
229
741
  try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* already gone */ }
230
742
  tryGit(root, ['worktree', 'prune']);
743
+ return { cleared: true };
231
744
  }
232
745
 
233
746
  /**
@@ -249,20 +762,13 @@ function clearUnfinished(root, staging) {
249
762
  * ordinary checkout runs. A fast folder that is subtly not the code would be
250
763
  * far worse than a slow one.
251
764
  *
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".
765
+ * THE COPY IS THE PLATFORM'S OWN CLONE, and it lives in
766
+ * `main/worktree-include.mjs` because both files need it and that one does not
767
+ * import this one. macOS `cp -c` is APFS clonefile; GNU `cp -a --reflink=auto`
768
+ * shares blocks where the filesystem can and copies where it cannot, and `-c`
769
+ * is not an option there at all.
257
770
  */
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) {
771
+ function cloneCheckout(root, folder, branch, from, branchExists, pid = process.pid) {
266
772
  // TRACKED CONTENT ONLY, on both sides of this. Her checkout permanently
267
773
  // carries a couple of untracked files (a scratch `:memory:.ses`, a folder of
268
774
  // screenshots somebody left), and counting those as dirty turned the fast
@@ -276,18 +782,37 @@ function cloneCheckout(root, folder, branch, from, branchExists) {
276
782
  if (!clean.ok || clean.out.length > 0) return false;
277
783
 
278
784
  const add = branchExists
279
- ? tryGit(root, ['worktree', 'add', '--no-checkout', folder, branch])
280
- : tryGit(root, ['worktree', 'add', '--no-checkout', '-b', branch, folder, from]);
785
+ ? addOwned(root, folder, ['--no-checkout', folder, branch], pid)
786
+ : addOwned(root, folder, ['--no-checkout', '-b', branch, folder, from], pid);
281
787
  if (!add.ok) return false;
282
788
 
283
789
  try {
284
- for (const entry of fs.readdirSync(root)) {
285
- if (entry === '.git' || entry === WORKTREES[0]) continue;
286
- copyTree(path.join(root, entry), folder);
790
+ // THE ENTRIES THAT HOLD TRACKED CONTENT, AND NOT SIMPLY EVERYTHING.
791
+ //
792
+ // It used to be every top-level entry except `.git` and `.claude`, which had
793
+ // two faults. The small one: every untracked file in the checkout rode along,
794
+ // so this path and the ordinary one produced different folders. The large
795
+ // one: skipping `.claude` WHOLESALE means a repository that commits a file
796
+ // under it (a `.claude/settings.json`, which is ordinary) failed this
797
+ // function's own verification every single time, because the clone was
798
+ // missing a tracked file. That failure is how a task ended up in the shared
799
+ // checkout; see the comment in `ensureTaskFolder` about asking for the
800
+ // branch twice.
801
+ const tracked = trackedTopLevel(root);
802
+ // A repository with a submodule is the ordinary checkout's business: a
803
+ // gitlink is a pointer, not a directory of files, and `worktree move`
804
+ // refuses a worktree holding one.
805
+ if (tracked.submodules) throw Error('this repository has submodules');
806
+ for (const entry of tracked.entries) {
807
+ if (entry === '.git') continue;
808
+ copyIn(root, folder, entry);
287
809
  }
288
810
  if (!tryGit(folder, ['reset', '-q', 'HEAD']).ok) throw Error('the index would not fill in');
289
811
  const after = tryGit(folder, ['status', '--porcelain', '--untracked-files=no']);
290
812
  if (!after.ok || after.out.length > 0) throw Error('the clone did not match the commit');
813
+ // An ignored file inside a tracked directory came with it (`renderer/dist`).
814
+ // Cleared here, and whatever is genuinely wanted arrives by name afterwards.
815
+ onlyTrackedContent(folder);
291
816
  return true;
292
817
  } catch {
293
818
  // Back to nothing, so the ordinary path starts from a clean sheet. What is
@@ -295,11 +820,30 @@ function cloneCheckout(root, folder, branch, from, branchExists) {
295
820
  // `folder` is the staging path, which no session has ever been given. It
296
821
  // used to be the real one, and a `remove --force` of that is how a running
297
822
  // agent lost the ground under it.
298
- clearUnfinished(root, folder);
823
+ clearUnfinished(root, folder, pid);
299
824
  return false;
300
825
  }
301
826
  }
302
827
 
828
+ /**
829
+ * One top-level entry, cloned into the new folder.
830
+ *
831
+ * `.claude` IS THE ONE ENTRY THAT CANNOT BE COPIED WHOLE, because the folders
832
+ * every other task is working in live inside it. Its children are copied one by
833
+ * one instead, with `worktrees` left out, so a repository that tracks a file
834
+ * under `.claude` gets it and no task ever receives a copy of another task's
835
+ * work.
836
+ */
837
+ function copyIn(root, folder, entry) {
838
+ if (entry !== WORKTREES[0]) { copyTree(path.join(root, entry), folder); return; }
839
+ const to = path.join(folder, entry);
840
+ fs.mkdirSync(to, { recursive: true });
841
+ for (const child of fs.readdirSync(path.join(root, entry))) {
842
+ if (child === WORKTREES[1]) continue;
843
+ copyTree(path.join(root, entry, child), to);
844
+ }
845
+ }
846
+
303
847
  /**
304
848
  * Task folders live inside the repository, where the checkout would otherwise
305
849
  * report them as untracked and every review card built in the shared checkout
@@ -319,32 +863,14 @@ function hideFromTheCheckout(root) {
319
863
  } catch { /* an unwritable git dir is not a reason to refuse the folder */ }
320
864
  }
321
865
 
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
- }
866
+ // DEPENDENCIES USED TO BE SPECIAL-CASED HERE, and now they are one line of a
867
+ // list the repository writes: `main/worktree-include.mjs`, whose default when a
868
+ // repository says nothing is `/node_modules`, so the behaviour this paragraph
869
+ // used to describe is still the behaviour. The measurement that justified it
870
+ // stands and belongs with the code that does it now: cloning this repository's
871
+ // 929 MB of dependencies took 2.9 seconds on 2026-09-22 and moved the volume's
872
+ // free space by nothing at all, because APFS shares the blocks until something
873
+ // writes.
348
874
 
349
875
  /** Is there anything in here that deleting the folder would lose? */
350
876
  export function folderState(root, folder) {
@@ -356,21 +882,56 @@ export function folderState(root, folder) {
356
882
  return { dirty, merged };
357
883
  }
358
884
 
885
+ /**
886
+ * THE FOLDER A CALL IS ABOUT IS THE ONE IT WAS HANDED, NEVER ONE REBUILT FROM A
887
+ * BASENAME (w-330eea6c66).
888
+ *
889
+ * `releaseTaskFolder` and `parkTaskFolder` used to take a task id and work out
890
+ * `.claude/worktrees/<id>` from it. That is correct while the only caller is a
891
+ * row's own session, and it is the bug the moment anything passes on a folder it
892
+ * found: the sweep walks a list of folders, and a stray whose basename happened
893
+ * to read like a task id would have had the app committing in, and deleting,
894
+ * whatever sat at the reconstructed path instead.
895
+ *
896
+ * So a caller holding a path says so, and a path that is not this task's own
897
+ * folder is refused rather than quietly swapped for one. Called with no path at
898
+ * all it answers exactly as it always did, which is every ordinary call.
899
+ */
900
+ function ownFolder(root, name, given) {
901
+ const mine = taskFolderPath(root, name);
902
+ if (given === null || given === undefined) return mine;
903
+ return real(given) === mine ? mine : null;
904
+ }
905
+
906
+ /**
907
+ * AND STILL ON THE TASK'S OWN BRANCH, asked right before anything is committed
908
+ * or removed. The path alone was not proof (GitHub issue #43: another tool's
909
+ * worktree sat at the same path), and a folder an agent moved off its branch
910
+ * would be committed onto the wrong one, or, on no branch at all, onto nothing
911
+ * and then deleted.
912
+ */
913
+ const onItsOwnBranch = (root, folder, name) => record(root, folder)?.branch === taskBranch(name);
914
+
359
915
  /**
360
916
  * Give the folder back when the session is done with it. Removal is the rare
361
917
  * case on purpose: it happens when the work is already in main and nothing is
362
918
  * uncommitted. Every other answer names what is in the way, so the app can say
363
919
  * it rather than leave another invisible folder on her disk.
364
920
  */
365
- export function releaseTaskFolder(dir, id, { pid = process.pid } = {}) {
921
+ export function releaseTaskFolder(dir, id, { pid = process.pid, path: given = null } = {}) {
366
922
  const name = safeTaskName(id);
367
923
  const root = repoRoot(dir);
368
924
  if (!root) return { removed: false, reason: 'no-repo' };
369
- const folder = taskFolderPath(root, name);
925
+ const folder = ownFolder(root, name, given);
926
+ if (!folder) return { removed: false, reason: 'not-ours', path: given };
370
927
  if (!fs.existsSync(folder)) return { removed: false, reason: 'gone' };
928
+ if (!onItsOwnBranch(root, folder, name)) return { removed: false, reason: 'not-ours', path: folder };
371
929
 
372
- const holder = pidInReason(record(root, folder)?.lock);
373
- if (holder && holder !== pid && alive(holder)) return { removed: false, reason: 'held', path: folder };
930
+ // Whose folder this is, asked in the one place that knows (`holder`). A lock
931
+ // naming a process that has exited is not a claim on anything, and sweeping
932
+ // those is most of what this function is for.
933
+ const held = holder(root, folder, pid);
934
+ if (!held.ok) return { removed: false, reason: held.reason, heldBy: held.heldBy, path: folder };
374
935
 
375
936
  const { dirty, merged } = folderState(root, folder);
376
937
  if (dirty) return { removed: false, reason: 'uncommitted', path: folder };
@@ -379,14 +940,100 @@ export function releaseTaskFolder(dir, id, { pid = process.pid } = {}) {
379
940
  tryGit(root, ['worktree', 'unlock', folder]);
380
941
  // No --force: git refuses a second time if anything is uncommitted, which is
381
942
  // the belt under this braces.
382
- const removed = tryGit(root, ['worktree', 'remove', folder]);
943
+ const removed = removeWorktree(root, folder);
383
944
  if (!removed.ok) return { removed: false, reason: 'refused', path: folder, detail: removed.out };
384
945
  return { removed: true, reason: 'merged', path: folder };
385
946
  }
386
947
 
948
+ /**
949
+ * `git worktree remove`, AND A FOLDER HOLDING SUBMODULES TOO, WHEN THAT LOSES
950
+ * NOTHING (2026-10-15).
951
+ *
952
+ * git refuses to remove any worktree with a submodule filled in, clean or not:
953
+ * "working trees containing submodules cannot be moved or removed". A task in a
954
+ * repository with submodules starts with them empty, so an agent's first act is
955
+ * `git submodule update --init`, and from then on park and release were refused
956
+ * on every sweep and that folder was never reclaimed. Emptying the submodules
957
+ * first does not help: git still refuses while their repositories live on
958
+ * under the worktree's own `modules`.
959
+ *
960
+ * The refusal guards one real thing. Those repositories are the only copy of
961
+ * anything committed inside a submodule and not pushed, and `--force` deletes
962
+ * them. So it is forced only once that is provably nothing: no file anywhere in
963
+ * the folder or its submodules uncommitted, no submodule whose repository sits
964
+ * inside its own directory where this cannot look, and every commit in every
965
+ * submodule repository already on one of its remotes. Anything else stays
966
+ * refused, kept and reported as before.
967
+ */
968
+ function removeWorktree(root, folder) {
969
+ const removed = tryGit(root, ['worktree', 'remove', folder]);
970
+ if (removed.ok || !/submodules/.test(removed.out)) return removed;
971
+ if (!submodulesHoldNothingOfTheirOwn(folder)) return removed;
972
+ return tryGit(root, ['worktree', 'remove', '--force', folder]);
973
+ }
974
+
975
+ function submodulesHoldNothingOfTheirOwn(folder) {
976
+ try {
977
+ const status = tryGit(folder, ['status', '--porcelain', '--ignore-submodules=none']);
978
+ if (!status.ok || status.out !== '') return false;
979
+ if (!everySubmoduleKeepsItsRepositoryOutside(folder)) return false;
980
+ const gitDir = tryGit(folder, ['rev-parse', '--absolute-git-dir']);
981
+ if (!gitDir.ok) return false;
982
+ return repositoriesUnder(path.join(gitDir.out, 'modules')).every((repo) => {
983
+ const own = tryGit(repo, ['--git-dir', repo, 'rev-list', '-n', '1', '--all', '--not', '--remotes']);
984
+ return own.ok && own.out === '';
985
+ });
986
+ } catch { return false; }
987
+ }
988
+
989
+ /**
990
+ * A submodule added from a repository already in place keeps that repository
991
+ * in its own `.git` directory rather than under `modules`, and a forced removal
992
+ * deletes it with the files. Asked of every level, since a submodule can hold
993
+ * its own.
994
+ */
995
+ function everySubmoduleKeepsItsRepositoryOutside(at) {
996
+ const staged = tryGit(at, ['ls-files', '-s']);
997
+ if (!staged.ok) return false;
998
+ for (const line of staged.out.split('\n')) {
999
+ if (!line.startsWith('160000 ')) continue;
1000
+ const inner = path.join(at, line.slice(line.indexOf('\t') + 1));
1001
+ const dotGit = path.join(inner, '.git');
1002
+ if (!fs.existsSync(dotGit)) continue;
1003
+ if (fs.lstatSync(dotGit).isDirectory() || !everySubmoduleKeepsItsRepositoryOutside(inner)) return false;
1004
+ }
1005
+ return true;
1006
+ }
1007
+
1008
+ /** Every repository in a `modules` directory, nested ones included. Throws on one it cannot read. */
1009
+ function repositoriesUnder(dir) {
1010
+ if (!fs.existsSync(dir)) return [];
1011
+ const found = [];
1012
+ const walk = (at) => {
1013
+ if (fs.existsSync(path.join(at, 'HEAD')) && fs.existsSync(path.join(at, 'objects'))) {
1014
+ found.push(at);
1015
+ if (fs.existsSync(path.join(at, 'modules'))) walk(path.join(at, 'modules'));
1016
+ return;
1017
+ }
1018
+ for (const entry of fs.readdirSync(at, { withFileTypes: true })) {
1019
+ if (entry.isDirectory()) walk(path.join(at, entry.name));
1020
+ }
1021
+ };
1022
+ walk(dir);
1023
+ return found;
1024
+ }
1025
+
387
1026
  /** The subject line that marks a commit as work parked rather than finished. */
388
1027
  export const PARKED = 'Parked when the task was closed';
389
1028
 
1029
+ /**
1030
+ * And the one under it, which holds what was STAGED when the task was closed.
1031
+ * Two commits rather than one because `git add -A` replaces the index with the
1032
+ * working tree, so a single commit cannot carry both and the staged arrangement
1033
+ * was the half that went.
1034
+ */
1035
+ export const PARKED_INDEX = 'Parked when the task was closed (what was staged)';
1036
+
390
1037
  /**
391
1038
  * CLOSING A TASK PARKS ITS FOLDER, AND PARKING LOSES NOTHING.
392
1039
  *
@@ -413,75 +1060,220 @@ export const PARKED = 'Parked when the task was closed';
413
1060
  * wrote itself goes. That is the same boundary as removing any worktree, and the
414
1061
  * alternative is committing secrets onto a branch.
415
1062
  */
416
- export function parkTaskFolder(dir, id, { pid = process.pid } = {}) {
1063
+ export function parkTaskFolder(dir, id, { pid = process.pid, path: given = null } = {}) {
417
1064
  const name = safeTaskName(id);
418
1065
  const root = repoRoot(dir);
419
1066
  if (!root) return { parked: false, reason: 'no-repo' };
420
- const folder = taskFolderPath(root, name);
1067
+ // `ownFolder`, and never the basename of whatever we were pointed at: parking
1068
+ // commits and then removes, so being wrong here costs somebody their folder.
1069
+ const folder = ownFolder(root, name, given);
1070
+ if (!folder) return { parked: false, reason: 'not-ours', path: given };
421
1071
  if (!fs.existsSync(folder)) return { parked: false, reason: 'gone' };
1072
+ if (!onItsOwnBranch(root, folder, name)) return { parked: false, reason: 'not-ours', path: folder };
422
1073
 
423
- const holder = pidInReason(record(root, folder)?.lock);
424
- if (holder && holder !== pid && alive(holder)) return { parked: false, reason: 'held', path: folder };
1074
+ const owner = holder(root, folder, pid);
1075
+ if (!owner.ok) return { parked: false, reason: owner.reason, heldBy: owner.heldBy, path: folder };
425
1076
 
426
1077
  const { dirty } = folderState(root, folder);
427
1078
  let kept = 'nothing uncommitted';
428
1079
  if (dirty) {
1080
+ // TWO COMMITS, BECAUSE THE INDEX IS WORK TOO. `git add -A` replaces the
1081
+ // index with the working tree, so parking used to flatten them into one:
1082
+ // stage version A of a file, edit the working copy to B, park, and B was
1083
+ // all that came back. The staged arrangement an agent had built — a
1084
+ // half-prepared commit, a rename, a file added but not yet written to —
1085
+ // was gone, and the round trip was documented as keeping it.
1086
+ //
1087
+ // So the index is committed FIRST, exactly as it stands, and the working
1088
+ // tree second. The two trees are then both on the branch, and
1089
+ // `restoreTaskFolder` unwinds them into the index and the working tree they
1090
+ // came from. `--allow-empty` because an index that matches HEAD is the
1091
+ // ordinary case and is still the thing to come back to.
1092
+ // AND AN INDEX THAT WILL NOT COMMIT ON ITS OWN IS NOT A REASON TO REFUSE.
1093
+ // A merge or rebase an agent left mid-flight has unmerged paths, which git
1094
+ // will not commit, and the `add -A` below is what resolves them. Losing the
1095
+ // index there is exactly the old behaviour, and the old behaviour is better
1096
+ // than stranding the folder.
1097
+ const as = asTheApp(folder);
1098
+ tryGit(folder, [...as, 'commit', '--no-verify', '--allow-empty', '-q', '-m', PARKED_INDEX]);
429
1099
  const staged = tryGit(folder, ['add', '-A']);
430
1100
  // `--no-verify`, because a repository's own commit hooks are written for a
431
1101
  // person finishing a piece of work, and this is the app tidying up after
432
1102
  // 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 };
1103
+ const tree = tryGit(folder, [...as, 'commit', '--no-verify', '--allow-empty', '-q', '-m', PARKED]);
1104
+ if (!staged.ok || !tree.ok) return { parked: false, reason: 'refused', path: folder, detail: tree.out };
435
1105
  kept = 'work in progress';
436
1106
  }
437
1107
 
438
1108
  tryGit(root, ['worktree', 'unlock', folder]);
439
- const removed = tryGit(root, ['worktree', 'remove', folder]);
1109
+ const removed = removeWorktree(root, folder);
440
1110
  if (!removed.ok) return { parked: false, reason: 'refused', path: folder, detail: removed.out };
441
1111
  return { parked: true, kept, path: folder };
442
1112
  }
443
1113
 
444
1114
  /**
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.
1115
+ * The way back. Makes the folder again off the branch, and when the last
1116
+ * commits on it are parked ones, takes them apart so the work is uncommitted
1117
+ * exactly as it was: THE WORKING TREE FROM ONE AND THE INDEX FROM THE OTHER.
1118
+ *
1119
+ * `reset HEAD~1` is mixed, so it leaves the files alone and fills the index
1120
+ * in from the commit below — which is the index as it was parked.
1121
+ * `reset --soft HEAD~1` then puts HEAD back where it started and touches
1122
+ * neither, so the branch grows nothing and both halves survive.
1123
+ *
1124
+ * A FOLDER PARKED BEFORE THIS LANDED has one commit and no index commit under
1125
+ * it, and still has to come back: there are parked branches on this Mac from
1126
+ * the old shape. One mixed reset is what it always was.
449
1127
  */
450
1128
  export function restoreTaskFolder(dir, id, { pid = process.pid } = {}) {
451
1129
  const made = ensureTaskFolder(dir, id, { pid });
452
- if (!made) return null;
1130
+ if (!made || made.taken === false) return made ?? null;
453
1131
  const last = tryGit(made.path, ['log', '-1', '--format=%s']);
454
- if (last.ok && last.out === PARKED) tryGit(made.path, ['reset', '-q', 'HEAD~1']);
1132
+ if (!last.ok || last.out !== PARKED) return made;
1133
+ const under = tryGit(made.path, ['log', '-1', '--format=%s', 'HEAD~1']);
1134
+ tryGit(made.path, ['reset', '-q', 'HEAD~1']);
1135
+ if (under.ok && under.out === PARKED_INDEX) tryGit(made.path, ['reset', '--soft', '-q', 'HEAD~1']);
455
1136
  return made;
456
1137
  }
457
1138
 
1139
+ /**
1140
+ * EVERY COPY OF THIS PROJECT ON THE DISK, WHOEVER MADE IT. INVENTORY, NOT
1141
+ * OWNERSHIP, AND THE TWO BEING ONE QUESTION IS WHAT STRANDED 740 MB.
1142
+ *
1143
+ * MEASURED 2026-10-07 (w-5952e6de3e). Thirteen worktrees of this repository
1144
+ * existed outside `.claude/worktrees/`: nine beside the checkout as
1145
+ * `agentbox-team-<name>`, one under `/private/tmp`, one as `wt-<task id>`. About
1146
+ * 740 MB of tracked files, every one made by hand by some session rather than by
1147
+ * the app. `listTaskFolders` filtered on the folder's parent being the app's own
1148
+ * home, so not one of them could be listed, shown or swept: the same
1149
+ * invisibility that stranded 26 GB on 2026-09-22, in a new place. One of them had
1150
+ * `node_modules` symlinked back to the shared checkout, so an `npm install`
1151
+ * inside it rewrote every other folder's dependencies.
1152
+ *
1153
+ * AND WIDENING THAT FILTER WOULD HAVE BEEN WORSE THAN THE BUG, which is why this
1154
+ * is a second question rather than a looser version of the first one. The
1155
+ * supervisor feeds the list into automatic parking, and parking rebuilt
1156
+ * `.claude/worktrees/<id>` out of the folder's BASENAME rather than using the
1157
+ * path it was handed. A folder nobody's row owns passes every eligibility check
1158
+ * parking has, so a wider list would have had the app committing to a branch it
1159
+ * invented and deleting a folder somebody made on purpose.
1160
+ *
1161
+ * So: this answers what is there, and `ours` answers what the app may act on.
1162
+ * Only the second is ever parked or removed, and the first is what lets a person
1163
+ * see what is holding their disk.
1164
+ *
1165
+ * `ours` IS PROOF BY LOCATION AND BRANCH. The location alone is not enough:
1166
+ * Claude Code's desktop app makes its worktrees at that same
1167
+ * `<primary>/.claude/worktrees/<name>`, on `claude/<name>`, and the sweep parked
1168
+ * and removed three of them with live sessions in them (GitHub issue #43). So a
1169
+ * folder there is ours only when it is also on a branch `taskBranch` names; one
1170
+ * on any other branch, or on none, is a stray. It is deliberately not inferred from the lock
1171
+ * (dropped on the way to every removal) or from a marker file (which an agent
1172
+ * working in the folder could delete, turning a folder the app made into one it
1173
+ * will never reclaim).
1174
+ */
1175
+ export function inventoryWorktrees(dir) {
1176
+ const root = repoRoot(dir);
1177
+ if (!root) return [];
1178
+ const list = registered(root);
1179
+ // GIT LISTS THE MAIN WORKTREE FIRST, and the home hangs off that one rather
1180
+ // than off whichever folder we were asked from: `rev-parse --show-toplevel`
1181
+ // inside a linked worktree answers with that worktree, so deriving the home
1182
+ // from it would call every real task folder a stray.
1183
+ const primary = real(list[0]?.path ?? root);
1184
+ const home = path.join(primary, ...WORKTREES);
1185
+ return list.map((w) => {
1186
+ const at = real(w.path);
1187
+ const name = path.basename(at);
1188
+ const unfinished = name.startsWith(BUILDING);
1189
+ const missing = !fs.existsSync(at);
1190
+ const holder = pidInReason(w.lock);
1191
+ return {
1192
+ path: at,
1193
+ // What the registration spells it, which on a Mac can be the other side of
1194
+ // a symlink: `/tmp/x` against `/private/tmp/x`.
1195
+ registeredAs: w.path,
1196
+ primary: at === primary,
1197
+ branch: w.branch ?? '',
1198
+ lock: w.lock ?? null,
1199
+ heldBy: holder && alive(holder) ? holder : null,
1200
+ // A folder still being built is the app's and is wanted by nobody: it has
1201
+ // no row standing in it, so it may never be listed, shown or swept.
1202
+ unfinished,
1203
+ missing,
1204
+ prunable: !!w.prunable,
1205
+ // WHERE THE APP PUTS ITS OWN, whether or not anything is there now. Kept
1206
+ // apart from `ours` because a registration whose folder has gone is still
1207
+ // the app's bookkeeping to tidy, and is nothing anybody may act on.
1208
+ atOurPath: path.dirname(at) === home && couldBeATaskName(name),
1209
+ ours: !unfinished && !missing && !w.prunable
1210
+ && path.dirname(at) === home && couldBeATaskName(name)
1211
+ && String(w.branch ?? '').startsWith(BRANCH_PREFIX),
1212
+ };
1213
+ });
1214
+ }
1215
+
1216
+ /**
1217
+ * Copies of this project the app did not make, so a person can see what is
1218
+ * holding their disk. The checkout itself is not one of them, and neither is a
1219
+ * registration pointing at nothing: that is a different fact with its own field
1220
+ * and its own answer, `pruneMissingWorktrees`.
1221
+ */
1222
+ export function strayWorktrees(dir) {
1223
+ return inventoryWorktrees(dir)
1224
+ .filter((w) => !w.primary && !w.ours && !w.unfinished && !w.missing && !w.prunable);
1225
+ }
1226
+
1227
+ /**
1228
+ * REGISTRATIONS POINTING AT NOTHING, DROPPED. One of the thirteen lived under
1229
+ * `/private/tmp`, which macOS may purge from under it, and git goes on holding
1230
+ * the registration: that is what refuses the next `worktree add` at that path
1231
+ * with "already registered".
1232
+ *
1233
+ * Nothing can be lost here, by git's own definition of `prune`: it only ever
1234
+ * drops the bookkeeping for a worktree whose directory is already gone, and the
1235
+ * branch it stood on survives, as every branch survives everything in this file.
1236
+ * A folder of OURS is unlocked first, because `prune` skips a locked worktree and
1237
+ * the app locks every folder it makes, which would otherwise leave the
1238
+ * registration of a deleted folder wedged there forever.
1239
+ */
1240
+ export function pruneMissingWorktrees(dir) {
1241
+ const root = repoRoot(dir);
1242
+ if (!root) return [];
1243
+ const gone = inventoryWorktrees(dir).filter((w) => !w.primary && (w.missing || w.prunable));
1244
+ if (!gone.length) return [];
1245
+ for (const w of gone) if (w.atOurPath) tryGit(root, ['worktree', 'unlock', w.path]);
1246
+ tryGit(root, ['worktree', 'prune']);
1247
+ const left = new Set(inventoryWorktrees(dir).map((w) => w.path));
1248
+ return gone.map((w) => w.path).filter((at) => !left.has(at));
1249
+ }
1250
+
458
1251
  /**
459
1252
  * Every task folder in this repository and what would be lost by deleting it.
460
1253
  * This is what the app reads to show her a folder that is holding work, which
461
1254
  * is the half that did not exist: hers held 26 GB and she had never seen one.
1255
+ *
1256
+ * THE OWNERSHIP VIEW, and the only list anything automatic is allowed to act on.
1257
+ * `inventoryWorktrees` above is the wider one, and the comment there is why they
1258
+ * are not the same list.
462
1259
  */
463
1260
  export function listTaskFolders(dir) {
464
1261
  const root = repoRoot(dir);
465
1262
  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))
1263
+ return inventoryWorktrees(dir)
1264
+ .filter((w) => w.ours)
472
1265
  .map((w) => {
473
1266
  const { dirty, merged } = folderState(root, w.path);
474
1267
  let touchedAt = null;
475
1268
  try { touchedAt = fs.statSync(w.path).mtimeMs; } catch { /* it can vanish under us */ }
476
- const holder = pidInReason(w.lock);
477
1269
  return {
478
1270
  id: path.basename(w.path),
479
1271
  path: w.path,
480
- branch: w.branch ?? '',
1272
+ branch: w.branch,
481
1273
  dirty,
482
1274
  merged,
483
1275
  touchedAt,
484
- heldBy: holder && alive(holder) ? holder : null,
1276
+ heldBy: w.heldBy,
485
1277
  };
486
1278
  });
487
1279
  }