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.
- 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/agent-sessions.mjs +282 -268
- package/main/agents.mjs +179 -466
- package/main/analytics.mjs +12 -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-input.mjs +48 -4
- package/main/claude-plan.mjs +48 -0
- package/main/claude-session-open.mjs +28 -0
- package/main/claude-usage-process.mjs +40 -0
- package/main/claude-usage.mjs +18 -40
- 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-models.mjs +20 -2
- package/main/codex-session.mjs +212 -507
- package/main/codex-threads-worker.mjs +12 -0
- package/main/codex-threads.mjs +71 -3
- package/main/codex-usage-file.mjs +5 -3
- package/main/codex-usage.mjs +56 -7
- package/main/codex-watch.mjs +17 -3
- package/main/codex.mjs +95 -363
- package/main/config.mjs +11 -1
- package/main/corner-tag.mjs +340 -19
- package/main/dev-window.mjs +1 -1
- package/main/engine-bin.mjs +232 -282
- package/main/engine-setup.mjs +21 -9
- package/main/feedback.mjs +3 -25
- 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.mjs +32 -11
- package/main/harnesses/codex.mjs +9 -2
- package/main/importable-threads.mjs +64 -6
- package/main/ipc.mjs +362 -111
- package/main/leftovers.mjs +117 -25
- package/main/liquid-glass.mjs +26 -0
- package/main/live-replies.mjs +10 -4
- package/main/main-went-red.mjs +330 -0
- package/main/main.mjs +199 -84
- package/main/memory-gate-history.mjs +36 -10
- package/main/memory-gate-server.mjs +62 -0
- package/main/memory-gate.mjs +14 -1
- 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 +24 -8
- package/main/phone-alerts.mjs +117 -0
- package/main/phone-inbox.mjs +625 -0
- package/main/phone-link.mjs +29 -9
- 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-client.mjs +69 -1
- package/main/repeats.mjs +10 -4
- package/main/row-label.mjs +12 -41
- package/main/run-change.mjs +13 -0
- package/main/run-ledger.mjs +135 -0
- package/main/save-attachments.mjs +41 -0
- package/main/serve.mjs +45 -3
- package/main/settings.mjs +220 -44
- package/main/shell-path.mjs +185 -37
- package/main/ship-conflict-brief.mjs +119 -0
- package/main/ship-queue.mjs +115 -19
- package/main/source-updater.mjs +18 -10
- package/main/stage-attachment.mjs +68 -0
- package/main/stall-watch.mjs +50 -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 +226 -52
- package/main/supervisor.mjs +3103 -341
- package/main/task-folders.mjs +430 -30
- package/main/task-terminals.mjs +110 -34
- 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/webview-guard.mjs +45 -0
- package/main/worktree-include.mjs +90 -219
- 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 +21 -5
- package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
- package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
- package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
- package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
- package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
- package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
- package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
- package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
- package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
- package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
- package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
- package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
- package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
- package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
- package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
- package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
- 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-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
- package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
- package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
- package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
- package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
- package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
- package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
- package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
- package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
- package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
- package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
- package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
- package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
- package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
- package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
- package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
- package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
- package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
- package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
- package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
- package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
- package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
- package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
- package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
- package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
- package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
- package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
- package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
- package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
- package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
- package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
- package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
- package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
- package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
- package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
- package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
- package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
- package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
- package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
- package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
- package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
- package/renderer/dist/corner-tag.html +3 -3
- package/renderer/dist/index.html +3 -3
- package/scripts/after-install.mjs +30 -8
- 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 +60 -34
- 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 +27 -1
- package/shared/claude-code-shape.mjs +6 -0
- 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 +23 -1
- package/shared/corner-tag.mjs +284 -29
- package/shared/dead-run-trace.mjs +19 -2
- package/shared/engines.mjs +70 -309
- 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 +8 -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 +6 -0
- package/shared/notify-rules.mjs +36 -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/rank.d.mts +2 -1
- package/shared/rank.mjs +32 -19
- 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 -3
- package/shared/runs-on.mjs +8 -5
- package/shared/shipped-note.d.mts +4 -0
- package/shared/shipped-note.mjs +6 -0
- package/shared/shortcuts.mjs +14 -6
- package/shared/spawn-trouble.d.mts +4 -0
- package/shared/spawn-trouble.mjs +443 -40
- 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/thread-cards.mjs +4 -1
- package/shared/usage.d.mts +9 -0
- package/shared/usage.mjs +49 -3
- package/shared/websites.d.mts +15 -0
- package/shared/websites.mjs +61 -0
- package/shared/work-items.mjs +127 -30
- package/shared/work-lines.mjs +92 -244
- package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
- package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
- package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
package/main/task-folders.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
479
|
+
if (held.stale) tryGit(root, ['worktree', 'unlock', folder]);
|
|
480
|
+
const locked = tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
|
|
240
481
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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,
|
|
293
|
-
//
|
|
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
|
|
296
|
-
if (!
|
|
297
|
-
try {
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
770
|
-
*
|
|
771
|
-
*
|
|
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
|
}
|