agentbox-app 0.1.13 → 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 (278) 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/agent-sessions.mjs +282 -268
  9. package/main/agents.mjs +179 -466
  10. package/main/analytics.mjs +12 -2
  11. package/main/answer-modes.mjs +103 -37
  12. package/main/approval-prompt-core.mjs +197 -0
  13. package/main/approval-prompt-server.mjs +4 -138
  14. package/main/approvals.mjs +101 -297
  15. package/main/artifact-path.mjs +47 -18
  16. package/main/claude-input.mjs +48 -4
  17. package/main/claude-plan.mjs +48 -0
  18. package/main/claude-session-open.mjs +28 -0
  19. package/main/claude-usage-process.mjs +40 -0
  20. package/main/claude-usage.mjs +18 -40
  21. package/main/cloud/account.mjs +179 -0
  22. package/main/cloud/aws.mjs +127 -0
  23. package/main/cloud/browser-return.mjs +58 -0
  24. package/main/cloud/clients.mjs +15 -0
  25. package/main/cloud/codex-cloud.mjs +53 -0
  26. package/main/cloud/config.mjs +107 -0
  27. package/main/cloud/docs.mjs +219 -0
  28. package/main/cloud/exec.mjs +27 -0
  29. package/main/cloud/host.mjs +165 -0
  30. package/main/cloud/kit.mjs +80 -0
  31. package/main/cloud/logins.mjs +543 -0
  32. package/main/cloud/managed.mjs +115 -0
  33. package/main/cloud/marker.mjs +104 -0
  34. package/main/cloud/org.mjs +53 -0
  35. package/main/cloud/reuse.mjs +208 -0
  36. package/main/cloud/run-cli.mjs +177 -0
  37. package/main/cloud/run.mjs +1934 -0
  38. package/main/cloud/usage.mjs +76 -0
  39. package/main/cloud-permission.mjs +51 -0
  40. package/main/codex-account.mjs +78 -0
  41. package/main/codex-app-server.mjs +276 -409
  42. package/main/codex-approvals.mjs +148 -433
  43. package/main/codex-models.mjs +20 -2
  44. package/main/codex-session.mjs +212 -507
  45. package/main/codex-threads-worker.mjs +12 -0
  46. package/main/codex-threads.mjs +71 -3
  47. package/main/codex-usage-file.mjs +5 -3
  48. package/main/codex-usage.mjs +56 -7
  49. package/main/codex-watch.mjs +17 -3
  50. package/main/codex.mjs +95 -363
  51. package/main/config.mjs +11 -1
  52. package/main/corner-tag.mjs +340 -19
  53. package/main/dev-window.mjs +1 -1
  54. package/main/engine-bin.mjs +232 -282
  55. package/main/engine-setup.mjs +21 -9
  56. package/main/feedback.mjs +3 -25
  57. package/main/folder-pictures.mjs +2 -2
  58. package/main/freeze-recorder-thread.mjs +86 -0
  59. package/main/freeze-recorder.mjs +146 -0
  60. package/main/fresh-user.mjs +20 -9
  61. package/main/git-change-offthread.mjs +17 -3
  62. package/main/git-change-thread.mjs +5 -2
  63. package/main/harnesses/claude.mjs +32 -11
  64. package/main/harnesses/codex.mjs +9 -2
  65. package/main/importable-threads.mjs +64 -6
  66. package/main/ipc.mjs +362 -111
  67. package/main/leftovers.mjs +117 -25
  68. package/main/liquid-glass.mjs +26 -0
  69. package/main/live-replies.mjs +10 -4
  70. package/main/main-went-red.mjs +330 -0
  71. package/main/main.mjs +199 -84
  72. package/main/memory-gate-history.mjs +36 -10
  73. package/main/memory-gate-server.mjs +62 -0
  74. package/main/memory-gate.mjs +14 -1
  75. package/main/menu.mjs +4 -2
  76. package/main/message-priority.mjs +2 -2
  77. package/main/mic.mjs +38 -0
  78. package/main/native/liquid-glass/build/liquid-glass.node +0 -0
  79. package/main/new-chats-watch.mjs +110 -0
  80. package/main/new-user-window.mjs +25 -0
  81. package/main/notify.mjs +24 -8
  82. package/main/phone-alerts.mjs +117 -0
  83. package/main/phone-inbox.mjs +625 -0
  84. package/main/phone-link.mjs +29 -9
  85. package/main/phone-uploads.mjs +476 -0
  86. package/main/picture-shape.mjs +114 -0
  87. package/main/quit-sequence.mjs +143 -0
  88. package/main/refresh-agent-models.mjs +15 -2
  89. package/main/relay-client.mjs +69 -1
  90. package/main/repeats.mjs +10 -4
  91. package/main/row-label.mjs +12 -41
  92. package/main/run-change.mjs +13 -0
  93. package/main/run-ledger.mjs +135 -0
  94. package/main/save-attachments.mjs +41 -0
  95. package/main/serve.mjs +45 -3
  96. package/main/settings.mjs +220 -44
  97. package/main/shell-path.mjs +185 -37
  98. package/main/ship-conflict-brief.mjs +119 -0
  99. package/main/ship-queue.mjs +115 -19
  100. package/main/source-updater.mjs +18 -10
  101. package/main/stage-attachment.mjs +68 -0
  102. package/main/stall-watch.mjs +50 -0
  103. package/main/store/home.mjs +87 -232
  104. package/main/store/paths.mjs +11 -0
  105. package/main/store/teammates-files.mjs +99 -0
  106. package/main/store/work-items.mjs +231 -13
  107. package/main/store.mjs +226 -52
  108. package/main/supervisor.mjs +3103 -341
  109. package/main/task-folders.mjs +430 -30
  110. package/main/task-terminals.mjs +110 -34
  111. package/main/team/index.mjs +114 -10
  112. package/main/team/memory-cloud.mjs +183 -1
  113. package/main/team/room-hours.mjs +48 -0
  114. package/main/team/room-setup.mjs +31 -0
  115. package/main/team/rooms-memory.mjs +454 -0
  116. package/main/team/rooms-shape.mjs +42 -0
  117. package/main/team/rooms-supabase.mjs +324 -0
  118. package/main/team/rooms.mjs +575 -0
  119. package/main/team/session.mjs +96 -26
  120. package/main/team/supabase-backend.mjs +180 -0
  121. package/main/team/sync.mjs +109 -1
  122. package/main/teammates.mjs +580 -0
  123. package/main/terminal-host.mjs +143 -0
  124. package/main/terminal-hosts.mjs +261 -0
  125. package/main/transcript-search.mjs +80 -0
  126. package/main/webview-guard.mjs +45 -0
  127. package/main/worktree-include.mjs +90 -219
  128. package/mcp/core/documents.mjs +3 -1
  129. package/mcp/core/repeats.mjs +88 -0
  130. package/mcp/core/teammates.mjs +222 -0
  131. package/mcp/core/work.mjs +76 -4
  132. package/mcp/stdio.mjs +24 -11
  133. package/mcp/tools.mjs +116 -8
  134. package/package.json +21 -5
  135. package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
  136. package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
  137. package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
  138. package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
  139. package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
  140. package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
  141. package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
  142. package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
  143. package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
  144. package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
  145. package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
  146. package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
  147. package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
  148. package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
  149. package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
  150. package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
  151. package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
  152. package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
  153. package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
  154. package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
  155. package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
  156. package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
  157. package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
  158. package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
  159. package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
  160. package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
  161. package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
  162. package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
  163. package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
  164. package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
  165. package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
  166. package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
  167. package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
  168. package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
  169. package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
  170. package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
  171. package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
  172. package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
  173. package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
  174. package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
  175. package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
  176. package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
  177. package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
  178. package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
  179. package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
  180. package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
  181. package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
  182. package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
  183. package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
  184. package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
  185. package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
  186. package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
  187. package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
  188. package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
  189. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
  190. package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
  191. package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
  192. package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
  193. package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
  194. package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
  195. package/renderer/dist/corner-tag.html +3 -3
  196. package/renderer/dist/index.html +3 -3
  197. package/scripts/after-install.mjs +30 -8
  198. package/scripts/cloud-shim.pl +57 -0
  199. package/shared/account-names.d.mts +6 -0
  200. package/shared/account-names.mjs +15 -0
  201. package/shared/agent-hours.mjs +136 -0
  202. package/shared/agent-import.mjs +60 -34
  203. package/shared/agents.mjs +126 -522
  204. package/shared/analytics-events.mjs +12 -0
  205. package/shared/approval-card.mjs +36 -0
  206. package/shared/bridge-map.mjs +27 -1
  207. package/shared/claude-code-shape.mjs +6 -0
  208. package/shared/claude-usage.d.mts +1 -0
  209. package/shared/claude-usage.mjs +42 -1
  210. package/shared/cloud-hand-back.d.mts +7 -0
  211. package/shared/cloud-hand-back.mjs +20 -0
  212. package/shared/cloud-kit.d.mts +219 -0
  213. package/shared/cloud-kit.mjs +341 -0
  214. package/shared/cloud-login-name.mjs +4 -0
  215. package/shared/cloud-start.mjs +17 -0
  216. package/shared/cloud-versions.mjs +23 -0
  217. package/shared/codex-import.mjs +2 -2
  218. package/shared/codex-modes.d.mts +22 -9
  219. package/shared/codex-modes.mjs +67 -11
  220. package/shared/codex-usage.mjs +108 -2
  221. package/shared/corner-tag.d.mts +23 -1
  222. package/shared/corner-tag.mjs +284 -29
  223. package/shared/dead-run-trace.mjs +19 -2
  224. package/shared/engines.mjs +70 -309
  225. package/shared/feedback.d.mts +7 -0
  226. package/shared/feedback.mjs +25 -0
  227. package/shared/first-run-practice.mjs +17 -24
  228. package/shared/harness-definitions.mjs +8 -0
  229. package/shared/instruction-defaults.json +2 -2
  230. package/shared/key-caps.d.mts +14 -0
  231. package/shared/key-caps.mjs +68 -0
  232. package/shared/message-edits.d.mts +7 -0
  233. package/shared/message-edits.mjs +35 -0
  234. package/shared/move-thread.d.mts +24 -0
  235. package/shared/move-thread.mjs +112 -0
  236. package/shared/named-threads.mjs +72 -0
  237. package/shared/not-shipped.d.mts +22 -0
  238. package/shared/not-shipped.mjs +89 -0
  239. package/shared/notify-rules.d.mts +6 -0
  240. package/shared/notify-rules.mjs +36 -8
  241. package/shared/phone-files.mjs +28 -0
  242. package/shared/phone-settings.mjs +118 -0
  243. package/shared/phone-snapshot.d.mts +16 -0
  244. package/shared/phone-snapshot.mjs +348 -0
  245. package/shared/picture-formats.d.mts +1 -0
  246. package/shared/picture-formats.mjs +13 -0
  247. package/shared/rank.d.mts +2 -1
  248. package/shared/rank.mjs +32 -19
  249. package/shared/repeats.d.mts +8 -0
  250. package/shared/repeats.mjs +33 -2
  251. package/shared/room-cards.mjs +167 -0
  252. package/shared/room-links.d.mts +4 -0
  253. package/shared/room-links.mjs +21 -0
  254. package/shared/runs-on.d.mts +3 -3
  255. package/shared/runs-on.mjs +8 -5
  256. package/shared/shipped-note.d.mts +4 -0
  257. package/shared/shipped-note.mjs +6 -0
  258. package/shared/shortcuts.mjs +14 -6
  259. package/shared/spawn-trouble.d.mts +4 -0
  260. package/shared/spawn-trouble.mjs +443 -40
  261. package/shared/team-rules.mjs +9 -2
  262. package/shared/teammates.d.mts +19 -0
  263. package/shared/teammates.mjs +756 -0
  264. package/shared/terminal-focus.d.mts +1 -0
  265. package/shared/terminal-focus.mjs +13 -0
  266. package/shared/thread-cards.mjs +4 -1
  267. package/shared/usage.d.mts +9 -0
  268. package/shared/usage.mjs +49 -3
  269. package/shared/websites.d.mts +15 -0
  270. package/shared/websites.mjs +61 -0
  271. package/shared/work-items.mjs +127 -30
  272. package/shared/work-lines.mjs +92 -244
  273. package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
  274. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
  275. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
  276. package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
  277. package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
  278. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
@@ -62,7 +62,7 @@
62
62
  import { execFileSync } from 'node:child_process';
63
63
  import fs from 'node:fs';
64
64
  import path from 'node:path';
65
- import { nameSlug } from '../shared/product-name.mjs';
65
+ import { Name, nameSlug } from '../shared/product-name.mjs';
66
66
  import { carryLocalFiles, copyTree, onlyTrackedContent, trackedTopLevel } from './worktree-include.mjs';
67
67
 
68
68
  /** Where Claude Code puts its own, so both engines and a human agree. */
@@ -77,7 +77,20 @@ const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
77
77
  /** What an unfinished folder is called. A task name may never start with a dot. */
78
78
  const BUILDING = '.building-';
79
79
 
80
- 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
+ ], {
81
94
  cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
82
95
  }).trim();
83
96
 
@@ -90,12 +103,66 @@ function tryRead(dir) {
90
103
  try { return fs.readdirSync(dir); } catch { return ['we could not look']; }
91
104
  }
92
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
+
93
112
  /** Git that answers rather than throws, for the questions where "no" is fine. */
94
113
  function tryGit(cwd, args) {
95
114
  try { return { ok: true, out: git(cwd, args) }; }
96
115
  catch (error) { return { ok: false, out: String(error?.stderr ?? error?.message ?? '').trim() }; }
97
116
  }
98
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
+
99
166
  /** `safeTaskName`'s question, asked of a folder already on the disk without throwing. */
100
167
  const couldBeATaskName = (name) => SAFE_NAME.test(name) && String(name).length <= 64;
101
168
 
@@ -154,6 +221,132 @@ export function repoRoot(dir) {
154
221
  return found.ok && found.out ? found.out : null;
155
222
  }
156
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
+
157
350
  /**
158
351
  * What a new folder starts from: LOCAL main, not the remote. Local main carries
159
352
  * whatever has not been pushed, and on this Mac that is routinely 20+ commits,
@@ -219,6 +412,48 @@ function holder(root, folder, pid) {
219
412
  return { ok: true, heldBy: owner, stale: true };
220
413
  }
221
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
+
222
457
  /**
223
458
  * TAKE THE FOLDER, OR SAY WHO HAS IT. Never a side effect: the caller gets
224
459
  * `taken` either way, and a refusal names the process in the way.
@@ -229,20 +464,24 @@ function holder(root, folder, pid) {
229
464
  * commands, so two processes taking over the same dead owner's folder at the
230
465
  * same moment is possible, and the one that did not end up in the lock has to
231
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.
232
472
  */
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 };
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 };
237
478
 
238
- if (held.stale) tryGit(root, ['worktree', 'unlock', folder]);
239
- tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
479
+ if (held.stale) tryGit(root, ['worktree', 'unlock', folder]);
480
+ const locked = tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
240
481
 
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 };
482
+ const said = whoEndedUpInTheLock({ pid, locked, after: record(root, folder)?.lock, triesLeft });
483
+ if (!said.again) return said;
484
+ }
246
485
  }
247
486
 
248
487
  /**
@@ -266,13 +505,33 @@ export function acquireTaskFolder(root, folder, { pid = process.pid } = {}) {
266
505
  * every assertion about the folder's contents would still pass. Codex made that
267
506
  * point reviewing this file on 2026-10-07 and it was right.
268
507
  */
269
- export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = true } = {}) {
508
+ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = true, again = false } = {}) {
270
509
  const name = safeTaskName(id);
271
- const root = repoRoot(dir);
510
+ const root = repoRoot(dir) ?? startRepository(dir);
272
511
  if (!root) return null;
512
+ firstCommitIfNone(root);
273
513
  const folder = taskFolderPath(root, name);
274
514
  const branch = taskBranch(name);
275
- const refused = (held) => ({ taken: false, reason: held.reason, heldBy: held.heldBy ?? null, path: null });
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 });
276
535
 
277
536
  if (fs.existsSync(folder) && record(root, folder)) {
278
537
  const held = acquireTaskFolder(root, folder, { pid });
@@ -289,12 +548,15 @@ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = tr
289
548
  // moves INTO it, so the finished copy landed at `<folder>/.building-<id>` and
290
549
  // the session would have started in a directory holding almost none of the
291
550
  // 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.
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.
294
553
  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 }); }
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 }); }
298
560
  }
299
561
 
300
562
  // A registration whose directory somebody deleted by hand would refuse the
@@ -344,7 +606,28 @@ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = tr
344
606
  const add = hasBranch()
345
607
  ? addOwned(root, staging, [staging, branch], pid)
346
608
  : addOwned(root, staging, ['-b', branch, staging, baseRef(root)], pid);
347
- if (!add.ok) throw Error(`Could not make a folder for ${name}: ${add.out}`);
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
+ }
348
631
  }
349
632
 
350
633
  // AND THE LOCAL FILES THE REPOSITORY ASKS FOR, THE SAME WAY WHICHEVER PATH RAN.
@@ -380,12 +663,22 @@ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = tr
380
663
  // handed over. The destination was checked above and is checked again here,
381
664
  // since the whole build has run since.
382
665
  if (fs.existsSync(folder)) {
666
+ const finished = somebodyFinishedIt();
383
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();
384
671
  return refused({ reason: 'in-the-way', heldBy: null });
385
672
  }
386
673
  const moved = tryGit(root, ['worktree', 'move', '--force', '--force', staging, folder]);
387
674
  if (!moved.ok) {
388
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 });
389
682
  throw Error(`Could not make a folder for ${name}: ${moved.out}`);
390
683
  }
391
684
 
@@ -401,10 +694,23 @@ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = tr
401
694
  * arrived in git 2.32; on an older one the lock is written straight after,
402
695
  * which narrows the window rather than closing it and is no reason to refuse
403
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.
404
706
  */
405
707
  function addOwned(root, folder, rest, pid) {
406
708
  const owned = tryGit(root, ['worktree', 'add', '--lock', '--reason', lockReason(pid), ...rest]);
407
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
+ }
408
714
  const added = tryGit(root, ['worktree', 'add', ...rest]);
409
715
  if (added.ok) tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
410
716
  return added;
@@ -597,6 +903,15 @@ function ownFolder(root, name, given) {
597
903
  return real(given) === mine ? mine : null;
598
904
  }
599
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
+
600
915
  /**
601
916
  * Give the folder back when the session is done with it. Removal is the rare
602
917
  * case on purpose: it happens when the work is already in main and nothing is
@@ -610,6 +925,7 @@ export function releaseTaskFolder(dir, id, { pid = process.pid, path: given = nu
610
925
  const folder = ownFolder(root, name, given);
611
926
  if (!folder) return { removed: false, reason: 'not-ours', path: given };
612
927
  if (!fs.existsSync(folder)) return { removed: false, reason: 'gone' };
928
+ if (!onItsOwnBranch(root, folder, name)) return { removed: false, reason: 'not-ours', path: folder };
613
929
 
614
930
  // Whose folder this is, asked in the one place that knows (`holder`). A lock
615
931
  // naming a process that has exited is not a claim on anything, and sweeping
@@ -624,11 +940,89 @@ export function releaseTaskFolder(dir, id, { pid = process.pid, path: given = nu
624
940
  tryGit(root, ['worktree', 'unlock', folder]);
625
941
  // No --force: git refuses a second time if anything is uncommitted, which is
626
942
  // the belt under this braces.
627
- const removed = tryGit(root, ['worktree', 'remove', folder]);
943
+ const removed = removeWorktree(root, folder);
628
944
  if (!removed.ok) return { removed: false, reason: 'refused', path: folder, detail: removed.out };
629
945
  return { removed: true, reason: 'merged', path: folder };
630
946
  }
631
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
+
632
1026
  /** The subject line that marks a commit as work parked rather than finished. */
633
1027
  export const PARKED = 'Parked when the task was closed';
634
1028
 
@@ -675,6 +1069,7 @@ export function parkTaskFolder(dir, id, { pid = process.pid, path: given = null
675
1069
  const folder = ownFolder(root, name, given);
676
1070
  if (!folder) return { parked: false, reason: 'not-ours', path: given };
677
1071
  if (!fs.existsSync(folder)) return { parked: false, reason: 'gone' };
1072
+ if (!onItsOwnBranch(root, folder, name)) return { parked: false, reason: 'not-ours', path: folder };
678
1073
 
679
1074
  const owner = holder(root, folder, pid);
680
1075
  if (!owner.ok) return { parked: false, reason: owner.reason, heldBy: owner.heldBy, path: folder };
@@ -699,18 +1094,19 @@ export function parkTaskFolder(dir, id, { pid = process.pid, path: given = null
699
1094
  // will not commit, and the `add -A` below is what resolves them. Losing the
700
1095
  // index there is exactly the old behaviour, and the old behaviour is better
701
1096
  // than stranding the folder.
702
- tryGit(folder, ['commit', '--no-verify', '--allow-empty', '-q', '-m', PARKED_INDEX]);
1097
+ const as = asTheApp(folder);
1098
+ tryGit(folder, [...as, 'commit', '--no-verify', '--allow-empty', '-q', '-m', PARKED_INDEX]);
703
1099
  const staged = tryGit(folder, ['add', '-A']);
704
1100
  // `--no-verify`, because a repository's own commit hooks are written for a
705
1101
  // person finishing a piece of work, and this is the app tidying up after
706
1102
  // one. A hook that refuses here would strand the folder it is protecting.
707
- const tree = tryGit(folder, ['commit', '--no-verify', '--allow-empty', '-q', '-m', PARKED]);
1103
+ const tree = tryGit(folder, [...as, 'commit', '--no-verify', '--allow-empty', '-q', '-m', PARKED]);
708
1104
  if (!staged.ok || !tree.ok) return { parked: false, reason: 'refused', path: folder, detail: tree.out };
709
1105
  kept = 'work in progress';
710
1106
  }
711
1107
 
712
1108
  tryGit(root, ['worktree', 'unlock', folder]);
713
- const removed = tryGit(root, ['worktree', 'remove', folder]);
1109
+ const removed = removeWorktree(root, folder);
714
1110
  if (!removed.ok) return { parked: false, reason: 'refused', path: folder, detail: removed.out };
715
1111
  return { parked: true, kept, path: folder };
716
1112
  }
@@ -766,9 +1162,12 @@ export function restoreTaskFolder(dir, id, { pid = process.pid } = {}) {
766
1162
  * Only the second is ever parked or removed, and the first is what lets a person
767
1163
  * see what is holding their disk.
768
1164
  *
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
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
772
1171
  * (dropped on the way to every removal) or from a marker file (which an agent
773
1172
  * working in the folder could delete, turning a folder the app made into one it
774
1173
  * will never reclaim).
@@ -808,7 +1207,8 @@ export function inventoryWorktrees(dir) {
808
1207
  // the app's bookkeeping to tidy, and is nothing anybody may act on.
809
1208
  atOurPath: path.dirname(at) === home && couldBeATaskName(name),
810
1209
  ours: !unfinished && !missing && !w.prunable
811
- && path.dirname(at) === home && couldBeATaskName(name),
1210
+ && path.dirname(at) === home && couldBeATaskName(name)
1211
+ && String(w.branch ?? '').startsWith(BRANCH_PREFIX),
812
1212
  };
813
1213
  });
814
1214
  }