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.
- package/README.md +24 -9
- package/bin/agentbox.mjs +23 -1
- package/briefs/message-rules.md +36 -8
- package/briefs/worker.md +46 -23
- package/main/account-discovery.mjs +122 -30
- package/main/account-tooling.mjs +29 -1
- package/main/account-usage.mjs +46 -0
- package/main/acp.mjs +296 -0
- package/main/agent-homes.mjs +158 -0
- package/main/agent-sessions.mjs +311 -260
- package/main/agent-updates.mjs +9 -22
- package/main/agents.mjs +179 -466
- package/main/analytics.mjs +42 -2
- package/main/answer-modes.mjs +103 -37
- package/main/approval-prompt-core.mjs +197 -0
- package/main/approval-prompt-server.mjs +4 -138
- package/main/approvals.mjs +101 -297
- package/main/artifact-path.mjs +47 -18
- package/main/claude-bin.mjs +45 -311
- package/main/claude-input.mjs +57 -7
- package/main/claude-launch-env.mjs +54 -0
- package/main/claude-models.mjs +39 -7
- package/main/claude-plan.mjs +64 -16
- package/main/claude-session-open.mjs +28 -0
- package/main/claude-usage-process.mjs +40 -0
- package/main/claude-usage.mjs +94 -59
- package/main/cloud/account.mjs +179 -0
- package/main/cloud/aws.mjs +127 -0
- package/main/cloud/browser-return.mjs +58 -0
- package/main/cloud/clients.mjs +15 -0
- package/main/cloud/codex-cloud.mjs +53 -0
- package/main/cloud/config.mjs +107 -0
- package/main/cloud/docs.mjs +219 -0
- package/main/cloud/exec.mjs +27 -0
- package/main/cloud/host.mjs +165 -0
- package/main/cloud/kit.mjs +80 -0
- package/main/cloud/logins.mjs +543 -0
- package/main/cloud/managed.mjs +115 -0
- package/main/cloud/marker.mjs +104 -0
- package/main/cloud/org.mjs +53 -0
- package/main/cloud/reuse.mjs +208 -0
- package/main/cloud/run-cli.mjs +177 -0
- package/main/cloud/run.mjs +1934 -0
- package/main/cloud/usage.mjs +76 -0
- package/main/cloud-permission.mjs +51 -0
- package/main/codex-account.mjs +78 -0
- package/main/codex-app-server.mjs +276 -409
- package/main/codex-approvals.mjs +148 -433
- package/main/codex-bin.mjs +51 -249
- package/main/codex-models.mjs +20 -2
- package/main/codex-session.mjs +246 -506
- package/main/codex-threads-worker.mjs +12 -0
- package/main/codex-threads.mjs +112 -11
- package/main/codex-usage-file.mjs +5 -3
- package/main/codex-usage.mjs +56 -7
- package/main/codex-watch.mjs +83 -5
- package/main/codex.mjs +95 -363
- package/main/config.mjs +37 -5
- package/main/corner-tag.mjs +810 -0
- package/main/crash-report.mjs +26 -3
- package/main/dev-window.mjs +1 -1
- package/main/engine-bin.mjs +545 -0
- package/main/engine-setup.mjs +25 -24
- package/main/feedback.mjs +14 -31
- package/main/folder-pictures.mjs +2 -2
- package/main/freeze-recorder-thread.mjs +86 -0
- package/main/freeze-recorder.mjs +146 -0
- package/main/fresh-user.mjs +20 -9
- package/main/git-change-offthread.mjs +17 -3
- package/main/git-change-thread.mjs +5 -2
- package/main/harnesses/claude-stream.mjs +208 -0
- package/main/harnesses/claude.mjs +105 -0
- package/main/harnesses/codex.mjs +72 -0
- package/main/harnesses/common.mjs +78 -0
- package/main/harnesses/instructions-file.mjs +116 -0
- package/main/harnesses/opencode.mjs +234 -0
- package/main/harnesses.mjs +65 -0
- package/main/importable-threads.mjs +96 -0
- package/main/ipc.mjs +520 -135
- package/main/leftovers.mjs +117 -25
- package/main/liquid-glass.mjs +26 -0
- package/main/live-replies.mjs +10 -4
- package/main/local-app-frames.mjs +82 -0
- package/main/main-went-red.mjs +330 -0
- package/main/main.mjs +248 -37
- package/main/memory-gate-history.mjs +85 -20
- package/main/memory-gate-server.mjs +129 -11
- package/main/memory-gate.mjs +62 -8
- package/main/memory-log.mjs +124 -0
- package/main/menu.mjs +4 -2
- package/main/message-priority.mjs +2 -2
- package/main/mic.mjs +38 -0
- package/main/native/liquid-glass/build/liquid-glass.node +0 -0
- package/main/new-chats-watch.mjs +110 -0
- package/main/new-user-window.mjs +25 -0
- package/main/notify.mjs +64 -5
- package/main/opencode-server.mjs +271 -0
- package/main/opencode.mjs +287 -0
- package/main/phone-alerts.mjs +117 -0
- package/main/phone-inbox.mjs +625 -0
- package/main/phone-link.mjs +571 -0
- package/main/phone-uploads.mjs +476 -0
- package/main/picture-shape.mjs +114 -0
- package/main/quit-sequence.mjs +143 -0
- package/main/refresh-agent-models.mjs +15 -2
- package/main/relay-address.mjs +28 -0
- package/main/relay-client.mjs +314 -0
- package/main/repeats.mjs +10 -4
- package/main/row-label.mjs +18 -47
- package/main/run-change.mjs +13 -0
- package/main/run-ledger.mjs +135 -0
- package/main/runs-on.mjs +32 -35
- package/main/save-attachments.mjs +41 -0
- package/main/serve.mjs +155 -9
- package/main/settings.mjs +364 -50
- package/main/shell-path.mjs +234 -0
- package/main/ship-conflict-brief.mjs +119 -0
- package/main/ship-queue.mjs +134 -20
- package/main/sign-in-files.mjs +3 -7
- package/main/source-updater.mjs +45 -11
- package/main/stage-attachment.mjs +68 -0
- package/main/stall-watch.mjs +50 -0
- package/main/start-hold.mjs +73 -0
- package/main/store/home.mjs +87 -232
- package/main/store/paths.mjs +11 -0
- package/main/store/teammates-files.mjs +99 -0
- package/main/store/work-items.mjs +231 -13
- package/main/store.mjs +227 -53
- package/main/supervisor.mjs +3865 -699
- package/main/task-commands.mjs +13 -17
- package/main/task-folders-offthread.mjs +6 -1
- package/main/task-folders.mjs +897 -105
- package/main/task-terminals.mjs +147 -41
- package/main/team/index.mjs +114 -10
- package/main/team/memory-cloud.mjs +183 -1
- package/main/team/room-hours.mjs +48 -0
- package/main/team/room-setup.mjs +31 -0
- package/main/team/rooms-memory.mjs +454 -0
- package/main/team/rooms-shape.mjs +42 -0
- package/main/team/rooms-supabase.mjs +324 -0
- package/main/team/rooms.mjs +575 -0
- package/main/team/session.mjs +96 -26
- package/main/team/supabase-backend.mjs +180 -0
- package/main/team/sync.mjs +109 -1
- package/main/teammates.mjs +580 -0
- package/main/terminal-host.mjs +143 -0
- package/main/terminal-hosts.mjs +261 -0
- package/main/transcript-search.mjs +80 -0
- package/main/updater.mjs +71 -9
- package/main/webview-guard.mjs +45 -0
- package/main/worktree-include.mjs +365 -0
- package/mcp/core/documents.mjs +3 -1
- package/mcp/core/repeats.mjs +88 -0
- package/mcp/core/teammates.mjs +222 -0
- package/mcp/core/work.mjs +76 -4
- package/mcp/stdio.mjs +24 -11
- package/mcp/tools.mjs +116 -8
- package/package.json +30 -5
- package/renderer/dist/assets/abnfDiagram-YUKMZFIV-CoWB6YUK.js +1 -0
- package/renderer/dist/assets/arc-Ch0NrCul.js +1 -0
- package/renderer/dist/assets/architectureDiagram-47P4ROYG-CXz9M5tQ.js +36 -0
- package/renderer/dist/assets/blockDiagram-E7TT5TSB-DchGUuHH.js +140 -0
- package/renderer/dist/assets/c4Diagram-CRA5TL53-Cip-zNyr.js +38 -0
- package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
- package/renderer/dist/assets/chunk-2Q5K7J3B-BoXid_2r.js +1 -0
- package/renderer/dist/assets/chunk-5VM5RSS4-Cpez1rRz.js +15 -0
- package/renderer/dist/assets/chunk-7TKQ45FW-DKA84YkE.js +1 -0
- package/renderer/dist/assets/chunk-AW2ZBBNX-Dg-SumPG.js +88 -0
- package/renderer/dist/assets/chunk-FVRAUYC3-06TCvPm1.js +2 -0
- package/renderer/dist/assets/chunk-HTAEGDNF-CMEBy1Td.js +1 -0
- package/renderer/dist/assets/chunk-JWPE2WC7-BJ2ObmV-.js +1 -0
- package/renderer/dist/assets/chunk-KQW6MTUR-CGWOYXUE.js +1 -0
- package/renderer/dist/assets/chunk-XXDRQBXY-CiZp07XC.js +1 -0
- package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-DJNIX719.js +217 -0
- package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
- package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
- package/renderer/dist/assets/cose-bilkent-JH36ORCC-CbkyZOUH.js +1 -0
- package/renderer/dist/assets/cynefin-EF2NZ3EQ-78iOroZO.js +141 -0
- package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-GcCopMs7.js +62 -0
- package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
- package/renderer/dist/assets/dagre-W4DXFKR2-DHLlXLkz.js +4 -0
- package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/renderer/dist/assets/diagram-2UJZ2QOL-DuZSUHcH.js +200 -0
- package/renderer/dist/assets/diagram-OVF4WLC6-Ckxu3cqH.js +24 -0
- package/renderer/dist/assets/diagram-PFPMY2P6-CsTw9Ycn.js +30 -0
- package/renderer/dist/assets/diagram-UMYRVEAY-CTXd8qVg.js +24 -0
- package/renderer/dist/assets/diagram-YEKJPTXX-CJs1T_9m.js +41 -0
- package/renderer/dist/assets/diagram-ZIFT7M5P-CvjI23mf.js +3 -0
- package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-d_IeAzOJ.js +1 -0
- package/renderer/dist/assets/elk-IJKZMXRS-zo9KFP3N.js +27 -0
- package/renderer/dist/assets/erDiagram-O2IAPWRE-DqfYf5Dt.js +99 -0
- package/renderer/dist/assets/flowDiagram-OXPTDLAJ-DAWsMLmo.js +213 -0
- package/renderer/dist/assets/ganttDiagram-R7TSEDQI-4ml_bhwR.js +292 -0
- package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-B_WcqSId.js +106 -0
- package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
- package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-CI-jYqzc.js +2 -0
- package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
- package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-B56VWRlV.js +70 -0
- package/renderer/dist/assets/journeyDiagram-COXZFDF6-D1-p09Lr.js +139 -0
- package/renderer/dist/assets/kanban-definition-P3RFRI5V-Cv3w7JoN.js +89 -0
- package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
- package/renderer/dist/assets/linear-C35V2Spi.js +1 -0
- package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
- package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
- package/renderer/dist/assets/mermaid.core-BzGT5Vo9.js +314 -0
- package/renderer/dist/assets/mindmap-definition-LZFPQGKD-BZUfuymC.js +96 -0
- package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/renderer/dist/assets/pegDiagram-BGZESJAR-DFh9XNGe.js +1 -0
- package/renderer/dist/assets/pieDiagram-CAPJLFHJ-C65tPVsy.js +39 -0
- package/renderer/dist/assets/product-name-CSNW6d7s.js +40 -0
- package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-Cushj_nk.js +7 -0
- package/renderer/dist/assets/railroadDiagram-SM67HX2B-DZGebGx5.js +1 -0
- package/renderer/dist/assets/requirementDiagram-X7JNWC4A-DpAB0MNo.js +84 -0
- package/renderer/dist/assets/sankeyDiagram-UM26HJRW-D-29rHra.js +40 -0
- package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-FfOQAZol.js +169 -0
- package/renderer/dist/assets/sizeCapture-INFHLROL-C_n6fDBY.js +1 -0
- package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-BZjenzJG.js +291 -0
- package/renderer/dist/assets/swimlanes-N4OXWK64-DxaMU9vX.js +1 -0
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
- package/renderer/dist/assets/timeline-definition-YOQAKGHF-DTg5HJKz.js +120 -0
- package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-Bc3MZDsl.js +370 -0
- package/renderer/dist/assets/vennDiagram-BLWOH2XV-GIVGk9Id.js +34 -0
- package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-NmJnbQOg.js +78 -0
- package/renderer/dist/assets/xychartDiagram-TAQBALBS-p-kfGfnW.js +7 -0
- package/renderer/dist/corner-tag.html +13 -0
- package/renderer/dist/emulator/audio-rules.mjs +74 -0
- package/renderer/dist/emulator/data/compression/README.md +11 -0
- package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
- package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
- package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +22 -0
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +65 -0
- package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
- package/renderer/dist/emulator/data/emulator.css +1614 -0
- package/renderer/dist/emulator/data/loader.js +170 -0
- package/renderer/dist/emulator/data/localization/README.md +63 -0
- package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
- package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
- package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
- package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
- package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
- package/renderer/dist/emulator/data/localization/en-US.json +339 -0
- package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
- package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
- package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
- package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
- package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
- package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
- package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
- package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
- package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
- package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
- package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
- package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
- package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
- package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
- package/renderer/dist/emulator/data/src/GameManager.js +464 -0
- package/renderer/dist/emulator/data/src/compression.js +142 -0
- package/renderer/dist/emulator/data/src/emulator.js +6158 -0
- package/renderer/dist/emulator/data/src/gamepad.js +167 -0
- package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
- package/renderer/dist/emulator/data/src/shaders.js +236 -0
- package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
- package/renderer/dist/emulator/data/src/storage.js +125 -0
- package/renderer/dist/emulator/data/version.json +5 -0
- package/renderer/dist/emulator/host.js +297 -0
- package/renderer/dist/emulator/index.html +28 -0
- package/renderer/dist/emulator/provenance.json +6 -0
- package/renderer/dist/index.html +16 -3
- package/scripts/after-install.mjs +30 -8
- package/scripts/approval-server.sh +24 -5
- package/scripts/before-publish.mjs +12 -10
- package/scripts/cloud-shim.pl +57 -0
- package/shared/account-names.d.mts +6 -0
- package/shared/account-names.mjs +15 -0
- package/shared/agent-hours.mjs +136 -0
- package/shared/agent-import.mjs +62 -34
- package/shared/agents.d.mts +1 -0
- package/shared/agents.mjs +126 -522
- package/shared/analytics-events.mjs +12 -0
- package/shared/approval-card.mjs +36 -0
- package/shared/bridge-map.mjs +45 -1
- package/shared/claude-code-shape.mjs +6 -0
- package/shared/claude-commands.generated.mjs +6 -4
- package/shared/claude-models.generated.mjs +6 -4
- package/shared/claude-usage.d.mts +1 -0
- package/shared/claude-usage.mjs +42 -1
- package/shared/cloud-hand-back.d.mts +7 -0
- package/shared/cloud-hand-back.mjs +20 -0
- package/shared/cloud-kit.d.mts +219 -0
- package/shared/cloud-kit.mjs +341 -0
- package/shared/cloud-login-name.mjs +4 -0
- package/shared/cloud-start.mjs +17 -0
- package/shared/cloud-versions.mjs +23 -0
- package/shared/codex-import.mjs +2 -2
- package/shared/codex-modes.d.mts +22 -9
- package/shared/codex-modes.mjs +67 -11
- package/shared/codex-usage.mjs +108 -2
- package/shared/corner-tag.d.mts +36 -0
- package/shared/corner-tag.mjs +396 -0
- package/shared/dead-run-trace.mjs +19 -2
- package/shared/engines.mjs +76 -300
- package/shared/feedback.d.mts +7 -0
- package/shared/feedback.mjs +25 -0
- package/shared/first-run-practice.mjs +17 -24
- package/shared/harness-definitions.mjs +111 -0
- package/shared/instruction-defaults.json +2 -2
- package/shared/key-caps.d.mts +14 -0
- package/shared/key-caps.mjs +68 -0
- package/shared/message-edits.d.mts +7 -0
- package/shared/message-edits.mjs +35 -0
- package/shared/move-thread.d.mts +24 -0
- package/shared/move-thread.mjs +112 -0
- package/shared/named-threads.mjs +72 -0
- package/shared/not-shipped.d.mts +22 -0
- package/shared/not-shipped.mjs +89 -0
- package/shared/notify-rules.d.mts +31 -0
- package/shared/notify-rules.mjs +55 -8
- package/shared/phone-files.mjs +28 -0
- package/shared/phone-settings.mjs +118 -0
- package/shared/phone-snapshot.d.mts +16 -0
- package/shared/phone-snapshot.mjs +348 -0
- package/shared/picture-formats.d.mts +1 -0
- package/shared/picture-formats.mjs +13 -0
- package/shared/provider-commands.mjs +10 -2
- package/shared/rank.d.mts +4 -1
- package/shared/rank.mjs +82 -19
- package/shared/relay-address.mjs +6 -0
- package/shared/relay-crypto.mjs +165 -0
- package/shared/repeats.d.mts +8 -0
- package/shared/repeats.mjs +33 -2
- package/shared/room-cards.mjs +167 -0
- package/shared/room-links.d.mts +4 -0
- package/shared/room-links.mjs +21 -0
- package/shared/runs-on.d.mts +3 -13
- package/shared/runs-on.mjs +31 -52
- package/shared/shipped-note.d.mts +4 -0
- package/shared/shipped-note.mjs +6 -0
- package/shared/shortcuts.mjs +24 -10
- package/shared/spawn-trouble.d.mts +4 -0
- package/shared/spawn-trouble.mjs +484 -30
- package/shared/team-rules.mjs +9 -2
- package/shared/teammates.d.mts +19 -0
- package/shared/teammates.mjs +756 -0
- package/shared/terminal-focus.d.mts +1 -0
- package/shared/terminal-focus.mjs +13 -0
- package/shared/terminal-input.d.mts +1 -0
- package/shared/terminal-input.mjs +30 -0
- package/shared/terminal-keys.d.mts +1 -0
- package/shared/terminal-keys.mjs +16 -0
- package/shared/terminal-size.d.mts +4 -0
- package/shared/terminal-size.mjs +29 -0
- package/shared/thread-cards.mjs +4 -1
- package/shared/usage.d.mts +12 -0
- package/shared/usage.mjs +56 -3
- package/shared/websites.d.mts +15 -0
- package/shared/websites.mjs +61 -0
- package/shared/where-it-runs-from.d.mts +43 -0
- package/shared/where-it-runs-from.mjs +155 -0
- package/shared/work-items.mjs +150 -29
- package/shared/work-lines.mjs +93 -245
- package/shared/xterm-invalid-range.d.mts +2 -0
- package/shared/xterm-invalid-range.mjs +58 -0
- package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
- package/renderer/dist/assets/index-ClBXnq3B.css +0 -32
package/main/task-folders.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (!
|
|
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
|
-
*
|
|
165
|
-
*
|
|
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
|
-
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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.
|
|
210
|
-
//
|
|
211
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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
|
|
227
|
-
|
|
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
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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
|
|
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
|
-
?
|
|
280
|
-
:
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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 =
|
|
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
|
-
|
|
373
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
424
|
-
if (
|
|
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
|
|
434
|
-
if (!staged.ok || !
|
|
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 =
|
|
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
|
|
446
|
-
* on it
|
|
447
|
-
* exactly as it was
|
|
448
|
-
*
|
|
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
|
|
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
|
-
|
|
467
|
-
|
|
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:
|
|
1276
|
+
heldBy: w.heldBy,
|
|
485
1277
|
};
|
|
486
1278
|
});
|
|
487
1279
|
}
|