agentbox-app 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/main/acp.mjs +296 -0
- package/main/agent-homes.mjs +158 -0
- package/main/agent-sessions.mjs +57 -20
- package/main/agent-updates.mjs +9 -22
- package/main/analytics.mjs +30 -0
- package/main/claude-bin.mjs +45 -311
- package/main/claude-input.mjs +9 -3
- package/main/claude-launch-env.mjs +54 -0
- package/main/claude-models.mjs +39 -7
- package/main/claude-plan.mjs +16 -16
- package/main/claude-usage.mjs +82 -25
- package/main/codex-bin.mjs +51 -249
- package/main/codex-session.mjs +38 -3
- package/main/codex-threads.mjs +41 -8
- package/main/codex-watch.mjs +67 -3
- package/main/config.mjs +26 -4
- package/main/corner-tag.mjs +489 -0
- package/main/crash-report.mjs +26 -3
- package/main/engine-bin.mjs +595 -0
- package/main/engine-setup.mjs +4 -15
- package/main/feedback.mjs +11 -6
- package/main/harnesses/claude-stream.mjs +208 -0
- package/main/harnesses/claude.mjs +84 -0
- package/main/harnesses/codex.mjs +65 -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 +38 -0
- package/main/ipc.mjs +165 -31
- package/main/local-app-frames.mjs +82 -0
- package/main/main.mjs +100 -4
- package/main/memory-gate-history.mjs +51 -12
- package/main/memory-gate-server.mjs +67 -11
- package/main/memory-gate.mjs +49 -8
- package/main/memory-log.mjs +124 -0
- package/main/notify.mjs +47 -4
- package/main/opencode-server.mjs +271 -0
- package/main/opencode.mjs +287 -0
- package/main/phone-link.mjs +551 -0
- package/main/relay-address.mjs +28 -0
- package/main/relay-client.mjs +246 -0
- package/main/row-label.mjs +6 -6
- package/main/runs-on.mjs +32 -35
- package/main/serve.mjs +113 -9
- package/main/settings.mjs +148 -10
- package/main/shell-path.mjs +86 -0
- package/main/ship-queue.mjs +24 -6
- package/main/sign-in-files.mjs +3 -7
- package/main/source-updater.mjs +27 -1
- package/main/start-hold.mjs +73 -0
- package/main/store.mjs +1 -1
- package/main/supervisor.mjs +832 -428
- package/main/task-commands.mjs +13 -17
- package/main/task-folders-offthread.mjs +6 -1
- package/main/task-folders.mjs +490 -98
- package/main/task-terminals.mjs +37 -7
- package/main/updater.mjs +71 -9
- package/main/worktree-include.mjs +494 -0
- package/package.json +10 -1
- package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
- package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
- package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
- package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
- package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
- package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
- package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
- package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
- package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
- package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
- package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
- package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
- package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
- package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
- package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
- package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
- package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
- package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
- package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
- package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
- package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
- package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
- package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
- package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
- package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
- package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
- package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
- package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
- package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
- package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
- package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
- package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
- package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
- package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
- package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
- package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
- package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
- package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
- package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
- package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
- package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
- package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
- package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
- package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
- package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
- package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
- package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
- package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
- package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
- package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
- package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
- package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
- package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
- package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
- package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
- package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
- package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
- package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
- package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
- package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
- package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.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/approval-server.sh +24 -5
- package/scripts/before-publish.mjs +12 -10
- package/shared/agent-import.mjs +2 -0
- package/shared/agents.d.mts +1 -0
- package/shared/bridge-map.mjs +18 -0
- package/shared/claude-commands.generated.mjs +6 -4
- package/shared/claude-models.generated.mjs +6 -4
- package/shared/corner-tag.d.mts +14 -0
- package/shared/corner-tag.mjs +141 -0
- package/shared/engines.mjs +20 -5
- package/shared/harness-definitions.mjs +103 -0
- package/shared/notify-rules.d.mts +25 -0
- package/shared/notify-rules.mjs +19 -0
- package/shared/provider-commands.mjs +10 -2
- package/shared/rank.d.mts +2 -0
- package/shared/rank.mjs +50 -0
- package/shared/relay-address.mjs +6 -0
- package/shared/relay-crypto.mjs +165 -0
- package/shared/runs-on.d.mts +0 -10
- package/shared/runs-on.mjs +25 -49
- package/shared/shortcuts.mjs +10 -4
- package/shared/spawn-trouble.mjs +57 -6
- 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/usage.d.mts +3 -0
- package/shared/usage.mjs +7 -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 +25 -1
- package/shared/work-lines.mjs +1 -1
- 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
65
|
import { nameSlug } from '../shared/product-name.mjs';
|
|
66
|
+
import { carryLocalFiles, copyTree, onlyTrackedContent, trackedTopLevel } from './worktree-include.mjs';
|
|
43
67
|
|
|
44
68
|
/** Where Claude Code puts its own, so both engines and a human agree. */
|
|
45
69
|
const WORKTREES = ['.claude', 'worktrees'];
|
|
@@ -57,15 +81,27 @@ const git = (cwd, args) => execFileSync('git', args, {
|
|
|
57
81
|
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
58
82
|
}).trim();
|
|
59
83
|
|
|
84
|
+
/**
|
|
85
|
+
* What is in a directory. One we cannot even read is reported as holding
|
|
86
|
+
* something, because the only decision taken on this answer is whether the
|
|
87
|
+
* directory is safe to delete, and "we could not look" is not a yes.
|
|
88
|
+
*/
|
|
89
|
+
function tryRead(dir) {
|
|
90
|
+
try { return fs.readdirSync(dir); } catch { return ['we could not look']; }
|
|
91
|
+
}
|
|
92
|
+
|
|
60
93
|
/** Git that answers rather than throws, for the questions where "no" is fine. */
|
|
61
94
|
function tryGit(cwd, args) {
|
|
62
95
|
try { return { ok: true, out: git(cwd, args) }; }
|
|
63
96
|
catch (error) { return { ok: false, out: String(error?.stderr ?? error?.message ?? '').trim() }; }
|
|
64
97
|
}
|
|
65
98
|
|
|
99
|
+
/** `safeTaskName`'s question, asked of a folder already on the disk without throwing. */
|
|
100
|
+
const couldBeATaskName = (name) => SAFE_NAME.test(name) && String(name).length <= 64;
|
|
101
|
+
|
|
66
102
|
export function safeTaskName(id) {
|
|
67
103
|
const name = String(id ?? '');
|
|
68
|
-
if (!
|
|
104
|
+
if (!couldBeATaskName(name)) {
|
|
69
105
|
throw Error(`Not a name a task folder may be given: ${JSON.stringify(id)}`);
|
|
70
106
|
}
|
|
71
107
|
return name;
|
|
@@ -161,8 +197,74 @@ function registered(repoPath) {
|
|
|
161
197
|
const record = (repoPath, folder) => registered(repoPath).find((w) => w.path === folder) ?? null;
|
|
162
198
|
|
|
163
199
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
200
|
+
* WHO HOLDS THIS FOLDER, which is the one question every answer here is built
|
|
201
|
+
* on. Four answers and they are not the same shape:
|
|
202
|
+
*
|
|
203
|
+
* no lock at all free, and whoever asks may write their own
|
|
204
|
+
* our own pid already ours
|
|
205
|
+
* a lock with no pid of SOMEBODY ELSE'S, for good: a person who ran
|
|
206
|
+
* ours in it `git worktree lock` meant it, and there is no
|
|
207
|
+
* process to find dead
|
|
208
|
+
* another pid, alive theirs while it lives
|
|
209
|
+
* another pid, gone free, but only through a takeover that writes
|
|
210
|
+
* the new owner (`stale`)
|
|
211
|
+
*/
|
|
212
|
+
function holder(root, folder, pid) {
|
|
213
|
+
const held = record(root, folder)?.lock;
|
|
214
|
+
if (held === undefined) return { ok: true, heldBy: null, free: true };
|
|
215
|
+
const owner = pidInReason(held);
|
|
216
|
+
if (owner === pid) return { ok: true, heldBy: pid };
|
|
217
|
+
if (owner === null) return { ok: false, reason: 'locked', heldBy: null };
|
|
218
|
+
if (alive(owner)) return { ok: false, reason: 'held', heldBy: owner };
|
|
219
|
+
return { ok: true, heldBy: owner, stale: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* TAKE THE FOLDER, OR SAY WHO HAS IT. Never a side effect: the caller gets
|
|
224
|
+
* `taken` either way, and a refusal names the process in the way.
|
|
225
|
+
*
|
|
226
|
+
* A takeover writes the new owner, because the pid in that lock is the whole
|
|
227
|
+
* of the protection `releaseTaskFolder` and `parkTaskFolder` have. It is read
|
|
228
|
+
* back afterwards rather than assumed: `git worktree unlock` and `lock` are two
|
|
229
|
+
* commands, so two processes taking over the same dead owner's folder at the
|
|
230
|
+
* same moment is possible, and the one that did not end up in the lock has to
|
|
231
|
+
* hear that it did not get the folder.
|
|
232
|
+
*/
|
|
233
|
+
export function acquireTaskFolder(root, folder, { pid = process.pid } = {}) {
|
|
234
|
+
const held = holder(root, folder, pid);
|
|
235
|
+
if (!held.ok) return { taken: false, reason: held.reason, heldBy: held.heldBy };
|
|
236
|
+
if (!held.free && !held.stale) return { taken: true, heldBy: pid };
|
|
237
|
+
|
|
238
|
+
if (held.stale) tryGit(root, ['worktree', 'unlock', folder]);
|
|
239
|
+
tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
|
|
240
|
+
|
|
241
|
+
const after = record(root, folder)?.lock;
|
|
242
|
+
const now = pidInReason(after);
|
|
243
|
+
if (now === pid) return { taken: true, heldBy: pid };
|
|
244
|
+
if (after === undefined) return { taken: false, reason: 'refused', heldBy: null };
|
|
245
|
+
return { taken: false, reason: now ? 'held' : 'locked', heldBy: now ?? null };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The folder this task works in, TAKEN by this process, and made if it is not
|
|
250
|
+
* there yet. Either `{ taken: true, path, branch, created, heldBy }`, or
|
|
251
|
+
* `{ taken: false, reason, heldBy, path: null }` when somebody else has it:
|
|
252
|
+
*
|
|
253
|
+
* held another live process is working in it
|
|
254
|
+
* building another live process is part way through making it
|
|
255
|
+
* locked a person locked it by hand, so it is not ours to take
|
|
256
|
+
* in-the-way something that is not a task folder sits at that path
|
|
257
|
+
*
|
|
258
|
+
* `path: null` on every refusal, because there is no folder the asker may work
|
|
259
|
+
* in and a caller reaching for `.path` should get nothing rather than the
|
|
260
|
+
* folder somebody else is standing in.
|
|
261
|
+
*
|
|
262
|
+
* `how` SAYS WHICH OF THE TWO WAYS MADE IT: `clone` for the block-sharing copy,
|
|
263
|
+
* `checkout` for the ordinary one, `kept` for a folder that was already there.
|
|
264
|
+
* It is reported because the two ways are not equally good and a test that only
|
|
265
|
+
* checks the RESULT cannot tell them apart: the clone could break for good and
|
|
266
|
+
* every assertion about the folder's contents would still pass. Codex made that
|
|
267
|
+
* point reviewing this file on 2026-10-07 and it was right.
|
|
166
268
|
*/
|
|
167
269
|
export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = true } = {}) {
|
|
168
270
|
const name = safeTaskName(id);
|
|
@@ -170,10 +272,29 @@ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = tr
|
|
|
170
272
|
if (!root) return null;
|
|
171
273
|
const folder = taskFolderPath(root, name);
|
|
172
274
|
const branch = taskBranch(name);
|
|
275
|
+
const refused = (held) => ({ taken: false, reason: held.reason, heldBy: held.heldBy ?? null, path: null });
|
|
173
276
|
|
|
174
277
|
if (fs.existsSync(folder) && record(root, folder)) {
|
|
175
|
-
|
|
176
|
-
|
|
278
|
+
const held = acquireTaskFolder(root, folder, { pid });
|
|
279
|
+
if (!held.taken) return refused(held);
|
|
280
|
+
return {
|
|
281
|
+
taken: true, heldBy: pid, path: folder, created: false, how: 'kept',
|
|
282
|
+
branch: record(root, folder)?.branch ?? branch,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// A DIRECTORY GIT KNOWS NOTHING ABOUT, sitting where this folder goes. The
|
|
287
|
+
// move below cannot be trusted to refuse it: measured 2026-10-07 on git
|
|
288
|
+
// 2.39.5, `worktree move` onto an existing directory behaves like `mv` and
|
|
289
|
+
// moves INTO it, so the finished copy landed at `<folder>/.building-<id>` and
|
|
290
|
+
// the session would have started in a directory holding almost none of the
|
|
291
|
+
// code. Whatever it is, it is not ours to delete and not ours to fill — an
|
|
292
|
+
// EMPTY one excepted, since there is nothing there to lose and a stray empty
|
|
293
|
+
// directory would otherwise wedge the row for good.
|
|
294
|
+
if (fs.existsSync(folder)) {
|
|
295
|
+
const empty = tryRead(folder).length === 0;
|
|
296
|
+
if (!empty) return refused({ reason: 'in-the-way', heldBy: null });
|
|
297
|
+
try { fs.rmdirSync(folder); } catch { return refused({ reason: 'in-the-way', heldBy: null }); }
|
|
177
298
|
}
|
|
178
299
|
|
|
179
300
|
// A registration whose directory somebody deleted by hand would refuse the
|
|
@@ -187,47 +308,133 @@ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = tr
|
|
|
187
308
|
// EVERY STEP BELOW HAPPENS SOMEWHERE ELSE, and the folder appears at its own
|
|
188
309
|
// path in one move at the end. `buildingFolderPath` is why. A build the app
|
|
189
310
|
// died in the middle of leaves an unfinished copy there, which nobody wants
|
|
190
|
-
// and which would refuse the next attempt, so it goes first
|
|
311
|
+
// and which would refuse the next attempt, so it goes first — unless it
|
|
312
|
+
// belongs to an app process that is still building it.
|
|
191
313
|
const staging = buildingFolderPath(root, name);
|
|
192
|
-
clearUnfinished(root, staging);
|
|
314
|
+
const cleared = clearUnfinished(root, staging, pid);
|
|
315
|
+
if (!cleared.cleared) return refused({ reason: cleared.reason, heldBy: cleared.heldBy });
|
|
193
316
|
|
|
194
|
-
const
|
|
317
|
+
const hasBranch = () => tryGit(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).ok;
|
|
318
|
+
const branchExists = hasBranch();
|
|
195
319
|
const from = branchExists ? branch : baseRef(root);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
320
|
+
let how = 'clone';
|
|
321
|
+
if (!cloneCheckout(root, staging, branch, from, branchExists, pid)) {
|
|
322
|
+
how = 'checkout';
|
|
323
|
+
// THE BRANCH IS ASKED ABOUT AGAIN, AND THAT IS NOT BELT AND BRACES.
|
|
324
|
+
//
|
|
325
|
+
// The clone's first act is `worktree add --no-checkout -b`, which CREATES
|
|
326
|
+
// the branch; the copy and the verification come after. So a clone that
|
|
327
|
+
// got past its own door and then failed has left the branch behind, on
|
|
328
|
+
// purpose: `clearUnfinished` keeps it, because a branch outlives every
|
|
329
|
+
// folder and may already carry commits.
|
|
330
|
+
//
|
|
331
|
+
// Reusing the answer from before the clone is how that became the worst
|
|
332
|
+
// failure in this file. `-b` a second time is refused ("a branch named ...
|
|
333
|
+
// already exists"), this function throws, and main/supervisor.mjs catches
|
|
334
|
+
// the throw in two places and hands the session the SHARED CHECKOUT with
|
|
335
|
+
// nothing but a console line. One recoverable copy failure, and two agents
|
|
336
|
+
// are editing the same files. Found 2026-10-07 reviewing with Codex
|
|
337
|
+
// (w-5952e6de3e); the trigger is reproduced in
|
|
338
|
+
// tests/a-task-whose-first-attempt-failed-still-gets-its-own-folder.
|
|
339
|
+
//
|
|
340
|
+
// ATTACH, NEVER `-B` AND NEVER RESET. Whatever is on that branch is work.
|
|
341
|
+
//
|
|
342
|
+
// Found twice, independently: the Linux work on main reached the same line
|
|
343
|
+
// from the other direction, because `cp` there fails differently.
|
|
344
|
+
const add = hasBranch()
|
|
345
|
+
? addOwned(root, staging, [staging, branch], pid)
|
|
346
|
+
: addOwned(root, staging, ['-b', branch, staging, baseRef(root)], pid);
|
|
204
347
|
if (!add.ok) throw Error(`Could not make a folder for ${name}: ${add.out}`);
|
|
205
|
-
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// AND THE LOCAL FILES THE REPOSITORY ASKS FOR, THE SAME WAY WHICHEVER PATH RAN.
|
|
351
|
+
//
|
|
352
|
+
// This is the half that was missing, and the half people were complaining
|
|
353
|
+
// about. The two paths used to disagree: the clone carried every untracked file
|
|
354
|
+
// in the checkout, the ordinary checkout carried `node_modules` and nothing
|
|
355
|
+
// else, and which one a task got depended on whether somebody happened to have
|
|
356
|
+
// an edit open. `.worktreeinclude` makes it one answer, written down, in the
|
|
357
|
+
// repository, in a file Conductor and Claude Code already read.
|
|
358
|
+
//
|
|
359
|
+
// It runs in STAGING, before the folder exists at its own path, so a folder
|
|
360
|
+
// nobody can see yet is the one that is incomplete. A file that was named and
|
|
361
|
+
// cannot be given THROWS, and the staging copy goes with it.
|
|
362
|
+
//
|
|
363
|
+
// REFUSING THE WHOLE FOLDER IS THE REPORT. There was a version of this that
|
|
364
|
+
// made the folder anyway and returned the list of what it had refused, and
|
|
365
|
+
// nothing in the app read that list: a folder starting without its
|
|
366
|
+
// dependencies, with nobody told, is the fault this was written to end, so it
|
|
367
|
+
// cannot be how this fails. The throw reaches `_couldNotGetAFolder` in
|
|
368
|
+
// main/supervisor.mjs, which puts every path and reason on the row where
|
|
369
|
+
// somebody will read it, and the task waits instead of running wrong.
|
|
370
|
+
if (dependencies) {
|
|
371
|
+
try { carryLocalFiles(root, staging); }
|
|
372
|
+
catch (error) { clearUnfinished(root, staging); throw Error(`Could not make a folder for ${name}: ${error.message}`); }
|
|
206
373
|
}
|
|
207
374
|
|
|
208
375
|
// `worktree move` rather than a rename, so git's own record of where this
|
|
209
|
-
// worktree lives moves with it.
|
|
210
|
-
//
|
|
211
|
-
|
|
376
|
+
// worktree lives moves with it. Twice forced, because a locked worktree is
|
|
377
|
+
// exactly what this one is and git refuses to move one otherwise; the lock
|
|
378
|
+
// itself survives the move (git 2.39.5, measured 2026-10-07), which is what
|
|
379
|
+
// keeps the folder owned from the moment it exists to the moment it is
|
|
380
|
+
// handed over. The destination was checked above and is checked again here,
|
|
381
|
+
// since the whole build has run since.
|
|
382
|
+
if (fs.existsSync(folder)) {
|
|
383
|
+
clearUnfinished(root, staging, pid);
|
|
384
|
+
return refused({ reason: 'in-the-way', heldBy: null });
|
|
385
|
+
}
|
|
386
|
+
const moved = tryGit(root, ['worktree', 'move', '--force', '--force', staging, folder]);
|
|
212
387
|
if (!moved.ok) {
|
|
213
|
-
clearUnfinished(root, staging);
|
|
388
|
+
clearUnfinished(root, staging, pid);
|
|
214
389
|
throw Error(`Could not make a folder for ${name}: ${moved.out}`);
|
|
215
390
|
}
|
|
216
391
|
|
|
217
|
-
|
|
218
|
-
|
|
392
|
+
const held = acquireTaskFolder(root, folder, { pid });
|
|
393
|
+
if (!held.taken) return refused(held);
|
|
394
|
+
return { taken: true, heldBy: pid, path: folder, branch, created: true, how };
|
|
219
395
|
}
|
|
220
396
|
|
|
221
397
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
398
|
+
* `git worktree add`, LOCKED IN THE SAME CALL, so the half-built copy is owned
|
|
399
|
+
* from the moment it exists rather than a moment later. That moment is the one
|
|
400
|
+
* two app processes over one checkout used to collide in. `--reason` on `add`
|
|
401
|
+
* arrived in git 2.32; on an older one the lock is written straight after,
|
|
402
|
+
* which narrows the window rather than closing it and is no reason to refuse
|
|
403
|
+
* the folder.
|
|
225
404
|
*/
|
|
226
|
-
function
|
|
227
|
-
|
|
405
|
+
function addOwned(root, folder, rest, pid) {
|
|
406
|
+
const owned = tryGit(root, ['worktree', 'add', '--lock', '--reason', lockReason(pid), ...rest]);
|
|
407
|
+
if (owned.ok) return owned;
|
|
408
|
+
const added = tryGit(root, ['worktree', 'add', ...rest]);
|
|
409
|
+
if (added.ok) tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
|
|
410
|
+
return added;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Throw away an unfinished copy, UNLESS SOMEBODY IS STILL MAKING IT. Nothing is
|
|
415
|
+
* ever lost in the clearing: a staging folder has never been handed to anybody,
|
|
416
|
+
* so the most it can hold is a checkout that did not finish. The branch is left
|
|
417
|
+
* alone, as everywhere else in this file.
|
|
418
|
+
*
|
|
419
|
+
* The refusal is the part that was missing. Inside one process the folder
|
|
420
|
+
* thread serialises these, so a staging folder on disk really was abandoned.
|
|
421
|
+
* Across two it was not, and here that is routine: one app runs from the app
|
|
422
|
+
* folder and one from a checkout. Since the local files started being carried
|
|
423
|
+
* in, the build is slower, so the window is wider.
|
|
424
|
+
*/
|
|
425
|
+
function clearUnfinished(root, staging, pid = process.pid) {
|
|
426
|
+
if (!fs.existsSync(staging) && !record(root, staging)) return { cleared: true };
|
|
427
|
+
const held = holder(root, staging, pid);
|
|
428
|
+
if (!held.ok) {
|
|
429
|
+
return { cleared: false, reason: held.reason === 'held' ? 'building' : held.reason, heldBy: held.heldBy };
|
|
430
|
+
}
|
|
431
|
+
// Unlocked first: `worktree remove` refuses a locked worktree even with one
|
|
432
|
+
// `--force`, and this one is ours or its owner is gone.
|
|
433
|
+
tryGit(root, ['worktree', 'unlock', staging]);
|
|
228
434
|
tryGit(root, ['worktree', 'remove', '--force', staging]);
|
|
229
435
|
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* already gone */ }
|
|
230
436
|
tryGit(root, ['worktree', 'prune']);
|
|
437
|
+
return { cleared: true };
|
|
231
438
|
}
|
|
232
439
|
|
|
233
440
|
/**
|
|
@@ -249,20 +456,13 @@ function clearUnfinished(root, staging) {
|
|
|
249
456
|
* ordinary checkout runs. A fast folder that is subtly not the code would be
|
|
250
457
|
* far worse than a slow one.
|
|
251
458
|
*
|
|
252
|
-
* THE COPY IS THE PLATFORM'S OWN CLONE
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
459
|
+
* THE COPY IS THE PLATFORM'S OWN CLONE, and it lives in
|
|
460
|
+
* `main/worktree-include.mjs` because both files need it and that one does not
|
|
461
|
+
* import this one. macOS `cp -c` is APFS clonefile; GNU `cp -a --reflink=auto`
|
|
462
|
+
* shares blocks where the filesystem can and copies where it cannot, and `-c`
|
|
463
|
+
* is not an option there at all.
|
|
257
464
|
*/
|
|
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) {
|
|
465
|
+
function cloneCheckout(root, folder, branch, from, branchExists, pid = process.pid) {
|
|
266
466
|
// TRACKED CONTENT ONLY, on both sides of this. Her checkout permanently
|
|
267
467
|
// carries a couple of untracked files (a scratch `:memory:.ses`, a folder of
|
|
268
468
|
// screenshots somebody left), and counting those as dirty turned the fast
|
|
@@ -276,18 +476,37 @@ function cloneCheckout(root, folder, branch, from, branchExists) {
|
|
|
276
476
|
if (!clean.ok || clean.out.length > 0) return false;
|
|
277
477
|
|
|
278
478
|
const add = branchExists
|
|
279
|
-
?
|
|
280
|
-
:
|
|
479
|
+
? addOwned(root, folder, ['--no-checkout', folder, branch], pid)
|
|
480
|
+
: addOwned(root, folder, ['--no-checkout', '-b', branch, folder, from], pid);
|
|
281
481
|
if (!add.ok) return false;
|
|
282
482
|
|
|
283
483
|
try {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
484
|
+
// THE ENTRIES THAT HOLD TRACKED CONTENT, AND NOT SIMPLY EVERYTHING.
|
|
485
|
+
//
|
|
486
|
+
// It used to be every top-level entry except `.git` and `.claude`, which had
|
|
487
|
+
// two faults. The small one: every untracked file in the checkout rode along,
|
|
488
|
+
// so this path and the ordinary one produced different folders. The large
|
|
489
|
+
// one: skipping `.claude` WHOLESALE means a repository that commits a file
|
|
490
|
+
// under it (a `.claude/settings.json`, which is ordinary) failed this
|
|
491
|
+
// function's own verification every single time, because the clone was
|
|
492
|
+
// missing a tracked file. That failure is how a task ended up in the shared
|
|
493
|
+
// checkout; see the comment in `ensureTaskFolder` about asking for the
|
|
494
|
+
// branch twice.
|
|
495
|
+
const tracked = trackedTopLevel(root);
|
|
496
|
+
// A repository with a submodule is the ordinary checkout's business: a
|
|
497
|
+
// gitlink is a pointer, not a directory of files, and `worktree move`
|
|
498
|
+
// refuses a worktree holding one.
|
|
499
|
+
if (tracked.submodules) throw Error('this repository has submodules');
|
|
500
|
+
for (const entry of tracked.entries) {
|
|
501
|
+
if (entry === '.git') continue;
|
|
502
|
+
copyIn(root, folder, entry);
|
|
287
503
|
}
|
|
288
504
|
if (!tryGit(folder, ['reset', '-q', 'HEAD']).ok) throw Error('the index would not fill in');
|
|
289
505
|
const after = tryGit(folder, ['status', '--porcelain', '--untracked-files=no']);
|
|
290
506
|
if (!after.ok || after.out.length > 0) throw Error('the clone did not match the commit');
|
|
507
|
+
// An ignored file inside a tracked directory came with it (`renderer/dist`).
|
|
508
|
+
// Cleared here, and whatever is genuinely wanted arrives by name afterwards.
|
|
509
|
+
onlyTrackedContent(folder);
|
|
291
510
|
return true;
|
|
292
511
|
} catch {
|
|
293
512
|
// Back to nothing, so the ordinary path starts from a clean sheet. What is
|
|
@@ -295,11 +514,30 @@ function cloneCheckout(root, folder, branch, from, branchExists) {
|
|
|
295
514
|
// `folder` is the staging path, which no session has ever been given. It
|
|
296
515
|
// used to be the real one, and a `remove --force` of that is how a running
|
|
297
516
|
// agent lost the ground under it.
|
|
298
|
-
clearUnfinished(root, folder);
|
|
517
|
+
clearUnfinished(root, folder, pid);
|
|
299
518
|
return false;
|
|
300
519
|
}
|
|
301
520
|
}
|
|
302
521
|
|
|
522
|
+
/**
|
|
523
|
+
* One top-level entry, cloned into the new folder.
|
|
524
|
+
*
|
|
525
|
+
* `.claude` IS THE ONE ENTRY THAT CANNOT BE COPIED WHOLE, because the folders
|
|
526
|
+
* every other task is working in live inside it. Its children are copied one by
|
|
527
|
+
* one instead, with `worktrees` left out, so a repository that tracks a file
|
|
528
|
+
* under `.claude` gets it and no task ever receives a copy of another task's
|
|
529
|
+
* work.
|
|
530
|
+
*/
|
|
531
|
+
function copyIn(root, folder, entry) {
|
|
532
|
+
if (entry !== WORKTREES[0]) { copyTree(path.join(root, entry), folder); return; }
|
|
533
|
+
const to = path.join(folder, entry);
|
|
534
|
+
fs.mkdirSync(to, { recursive: true });
|
|
535
|
+
for (const child of fs.readdirSync(path.join(root, entry))) {
|
|
536
|
+
if (child === WORKTREES[1]) continue;
|
|
537
|
+
copyTree(path.join(root, entry, child), to);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
303
541
|
/**
|
|
304
542
|
* Task folders live inside the repository, where the checkout would otherwise
|
|
305
543
|
* report them as untracked and every review card built in the shared checkout
|
|
@@ -319,32 +557,14 @@ function hideFromTheCheckout(root) {
|
|
|
319
557
|
} catch { /* an unwritable git dir is not a reason to refuse the folder */ }
|
|
320
558
|
}
|
|
321
559
|
|
|
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
|
-
}
|
|
560
|
+
// DEPENDENCIES USED TO BE SPECIAL-CASED HERE, and now they are one line of a
|
|
561
|
+
// list the repository writes: `main/worktree-include.mjs`, whose default when a
|
|
562
|
+
// repository says nothing is `/node_modules`, so the behaviour this paragraph
|
|
563
|
+
// used to describe is still the behaviour. The measurement that justified it
|
|
564
|
+
// stands and belongs with the code that does it now: cloning this repository's
|
|
565
|
+
// 929 MB of dependencies took 2.9 seconds on 2026-09-22 and moved the volume's
|
|
566
|
+
// free space by nothing at all, because APFS shares the blocks until something
|
|
567
|
+
// writes.
|
|
348
568
|
|
|
349
569
|
/** Is there anything in here that deleting the folder would lose? */
|
|
350
570
|
export function folderState(root, folder) {
|
|
@@ -356,21 +576,46 @@ export function folderState(root, folder) {
|
|
|
356
576
|
return { dirty, merged };
|
|
357
577
|
}
|
|
358
578
|
|
|
579
|
+
/**
|
|
580
|
+
* THE FOLDER A CALL IS ABOUT IS THE ONE IT WAS HANDED, NEVER ONE REBUILT FROM A
|
|
581
|
+
* BASENAME (w-330eea6c66).
|
|
582
|
+
*
|
|
583
|
+
* `releaseTaskFolder` and `parkTaskFolder` used to take a task id and work out
|
|
584
|
+
* `.claude/worktrees/<id>` from it. That is correct while the only caller is a
|
|
585
|
+
* row's own session, and it is the bug the moment anything passes on a folder it
|
|
586
|
+
* found: the sweep walks a list of folders, and a stray whose basename happened
|
|
587
|
+
* to read like a task id would have had the app committing in, and deleting,
|
|
588
|
+
* whatever sat at the reconstructed path instead.
|
|
589
|
+
*
|
|
590
|
+
* So a caller holding a path says so, and a path that is not this task's own
|
|
591
|
+
* folder is refused rather than quietly swapped for one. Called with no path at
|
|
592
|
+
* all it answers exactly as it always did, which is every ordinary call.
|
|
593
|
+
*/
|
|
594
|
+
function ownFolder(root, name, given) {
|
|
595
|
+
const mine = taskFolderPath(root, name);
|
|
596
|
+
if (given === null || given === undefined) return mine;
|
|
597
|
+
return real(given) === mine ? mine : null;
|
|
598
|
+
}
|
|
599
|
+
|
|
359
600
|
/**
|
|
360
601
|
* Give the folder back when the session is done with it. Removal is the rare
|
|
361
602
|
* case on purpose: it happens when the work is already in main and nothing is
|
|
362
603
|
* uncommitted. Every other answer names what is in the way, so the app can say
|
|
363
604
|
* it rather than leave another invisible folder on her disk.
|
|
364
605
|
*/
|
|
365
|
-
export function releaseTaskFolder(dir, id, { pid = process.pid } = {}) {
|
|
606
|
+
export function releaseTaskFolder(dir, id, { pid = process.pid, path: given = null } = {}) {
|
|
366
607
|
const name = safeTaskName(id);
|
|
367
608
|
const root = repoRoot(dir);
|
|
368
609
|
if (!root) return { removed: false, reason: 'no-repo' };
|
|
369
|
-
const folder =
|
|
610
|
+
const folder = ownFolder(root, name, given);
|
|
611
|
+
if (!folder) return { removed: false, reason: 'not-ours', path: given };
|
|
370
612
|
if (!fs.existsSync(folder)) return { removed: false, reason: 'gone' };
|
|
371
613
|
|
|
372
|
-
|
|
373
|
-
|
|
614
|
+
// Whose folder this is, asked in the one place that knows (`holder`). A lock
|
|
615
|
+
// naming a process that has exited is not a claim on anything, and sweeping
|
|
616
|
+
// those is most of what this function is for.
|
|
617
|
+
const held = holder(root, folder, pid);
|
|
618
|
+
if (!held.ok) return { removed: false, reason: held.reason, heldBy: held.heldBy, path: folder };
|
|
374
619
|
|
|
375
620
|
const { dirty, merged } = folderState(root, folder);
|
|
376
621
|
if (dirty) return { removed: false, reason: 'uncommitted', path: folder };
|
|
@@ -387,6 +632,14 @@ export function releaseTaskFolder(dir, id, { pid = process.pid } = {}) {
|
|
|
387
632
|
/** The subject line that marks a commit as work parked rather than finished. */
|
|
388
633
|
export const PARKED = 'Parked when the task was closed';
|
|
389
634
|
|
|
635
|
+
/**
|
|
636
|
+
* And the one under it, which holds what was STAGED when the task was closed.
|
|
637
|
+
* Two commits rather than one because `git add -A` replaces the index with the
|
|
638
|
+
* working tree, so a single commit cannot carry both and the staged arrangement
|
|
639
|
+
* was the half that went.
|
|
640
|
+
*/
|
|
641
|
+
export const PARKED_INDEX = 'Parked when the task was closed (what was staged)';
|
|
642
|
+
|
|
390
643
|
/**
|
|
391
644
|
* CLOSING A TASK PARKS ITS FOLDER, AND PARKING LOSES NOTHING.
|
|
392
645
|
*
|
|
@@ -413,25 +666,46 @@ export const PARKED = 'Parked when the task was closed';
|
|
|
413
666
|
* wrote itself goes. That is the same boundary as removing any worktree, and the
|
|
414
667
|
* alternative is committing secrets onto a branch.
|
|
415
668
|
*/
|
|
416
|
-
export function parkTaskFolder(dir, id, { pid = process.pid } = {}) {
|
|
669
|
+
export function parkTaskFolder(dir, id, { pid = process.pid, path: given = null } = {}) {
|
|
417
670
|
const name = safeTaskName(id);
|
|
418
671
|
const root = repoRoot(dir);
|
|
419
672
|
if (!root) return { parked: false, reason: 'no-repo' };
|
|
420
|
-
|
|
673
|
+
// `ownFolder`, and never the basename of whatever we were pointed at: parking
|
|
674
|
+
// commits and then removes, so being wrong here costs somebody their folder.
|
|
675
|
+
const folder = ownFolder(root, name, given);
|
|
676
|
+
if (!folder) return { parked: false, reason: 'not-ours', path: given };
|
|
421
677
|
if (!fs.existsSync(folder)) return { parked: false, reason: 'gone' };
|
|
422
678
|
|
|
423
|
-
const
|
|
424
|
-
if (
|
|
679
|
+
const owner = holder(root, folder, pid);
|
|
680
|
+
if (!owner.ok) return { parked: false, reason: owner.reason, heldBy: owner.heldBy, path: folder };
|
|
425
681
|
|
|
426
682
|
const { dirty } = folderState(root, folder);
|
|
427
683
|
let kept = 'nothing uncommitted';
|
|
428
684
|
if (dirty) {
|
|
685
|
+
// TWO COMMITS, BECAUSE THE INDEX IS WORK TOO. `git add -A` replaces the
|
|
686
|
+
// index with the working tree, so parking used to flatten them into one:
|
|
687
|
+
// stage version A of a file, edit the working copy to B, park, and B was
|
|
688
|
+
// all that came back. The staged arrangement an agent had built — a
|
|
689
|
+
// half-prepared commit, a rename, a file added but not yet written to —
|
|
690
|
+
// was gone, and the round trip was documented as keeping it.
|
|
691
|
+
//
|
|
692
|
+
// So the index is committed FIRST, exactly as it stands, and the working
|
|
693
|
+
// tree second. The two trees are then both on the branch, and
|
|
694
|
+
// `restoreTaskFolder` unwinds them into the index and the working tree they
|
|
695
|
+
// came from. `--allow-empty` because an index that matches HEAD is the
|
|
696
|
+
// ordinary case and is still the thing to come back to.
|
|
697
|
+
// AND AN INDEX THAT WILL NOT COMMIT ON ITS OWN IS NOT A REASON TO REFUSE.
|
|
698
|
+
// A merge or rebase an agent left mid-flight has unmerged paths, which git
|
|
699
|
+
// will not commit, and the `add -A` below is what resolves them. Losing the
|
|
700
|
+
// index there is exactly the old behaviour, and the old behaviour is better
|
|
701
|
+
// than stranding the folder.
|
|
702
|
+
tryGit(folder, ['commit', '--no-verify', '--allow-empty', '-q', '-m', PARKED_INDEX]);
|
|
429
703
|
const staged = tryGit(folder, ['add', '-A']);
|
|
430
704
|
// `--no-verify`, because a repository's own commit hooks are written for a
|
|
431
705
|
// person finishing a piece of work, and this is the app tidying up after
|
|
432
706
|
// one. A hook that refuses here would strand the folder it is protecting.
|
|
433
|
-
const
|
|
434
|
-
if (!staged.ok || !
|
|
707
|
+
const tree = tryGit(folder, ['commit', '--no-verify', '--allow-empty', '-q', '-m', PARKED]);
|
|
708
|
+
if (!staged.ok || !tree.ok) return { parked: false, reason: 'refused', path: folder, detail: tree.out };
|
|
435
709
|
kept = 'work in progress';
|
|
436
710
|
}
|
|
437
711
|
|
|
@@ -442,46 +716,164 @@ export function parkTaskFolder(dir, id, { pid = process.pid } = {}) {
|
|
|
442
716
|
}
|
|
443
717
|
|
|
444
718
|
/**
|
|
445
|
-
* The way back. Makes the folder again off the branch, and when the last
|
|
446
|
-
* on it
|
|
447
|
-
* exactly as it was
|
|
448
|
-
*
|
|
719
|
+
* The way back. Makes the folder again off the branch, and when the last
|
|
720
|
+
* commits on it are parked ones, takes them apart so the work is uncommitted
|
|
721
|
+
* exactly as it was: THE WORKING TREE FROM ONE AND THE INDEX FROM THE OTHER.
|
|
722
|
+
*
|
|
723
|
+
* `reset HEAD~1` is mixed, so it leaves the files alone and fills the index
|
|
724
|
+
* in from the commit below — which is the index as it was parked.
|
|
725
|
+
* `reset --soft HEAD~1` then puts HEAD back where it started and touches
|
|
726
|
+
* neither, so the branch grows nothing and both halves survive.
|
|
727
|
+
*
|
|
728
|
+
* A FOLDER PARKED BEFORE THIS LANDED has one commit and no index commit under
|
|
729
|
+
* it, and still has to come back: there are parked branches on this Mac from
|
|
730
|
+
* the old shape. One mixed reset is what it always was.
|
|
449
731
|
*/
|
|
450
732
|
export function restoreTaskFolder(dir, id, { pid = process.pid } = {}) {
|
|
451
733
|
const made = ensureTaskFolder(dir, id, { pid });
|
|
452
|
-
if (!made) return null;
|
|
734
|
+
if (!made || made.taken === false) return made ?? null;
|
|
453
735
|
const last = tryGit(made.path, ['log', '-1', '--format=%s']);
|
|
454
|
-
if (last.ok
|
|
736
|
+
if (!last.ok || last.out !== PARKED) return made;
|
|
737
|
+
const under = tryGit(made.path, ['log', '-1', '--format=%s', 'HEAD~1']);
|
|
738
|
+
tryGit(made.path, ['reset', '-q', 'HEAD~1']);
|
|
739
|
+
if (under.ok && under.out === PARKED_INDEX) tryGit(made.path, ['reset', '--soft', '-q', 'HEAD~1']);
|
|
455
740
|
return made;
|
|
456
741
|
}
|
|
457
742
|
|
|
743
|
+
/**
|
|
744
|
+
* EVERY COPY OF THIS PROJECT ON THE DISK, WHOEVER MADE IT. INVENTORY, NOT
|
|
745
|
+
* OWNERSHIP, AND THE TWO BEING ONE QUESTION IS WHAT STRANDED 740 MB.
|
|
746
|
+
*
|
|
747
|
+
* MEASURED 2026-10-07 (w-5952e6de3e). Thirteen worktrees of this repository
|
|
748
|
+
* existed outside `.claude/worktrees/`: nine beside the checkout as
|
|
749
|
+
* `agentbox-team-<name>`, one under `/private/tmp`, one as `wt-<task id>`. About
|
|
750
|
+
* 740 MB of tracked files, every one made by hand by some session rather than by
|
|
751
|
+
* the app. `listTaskFolders` filtered on the folder's parent being the app's own
|
|
752
|
+
* home, so not one of them could be listed, shown or swept: the same
|
|
753
|
+
* invisibility that stranded 26 GB on 2026-09-22, in a new place. One of them had
|
|
754
|
+
* `node_modules` symlinked back to the shared checkout, so an `npm install`
|
|
755
|
+
* inside it rewrote every other folder's dependencies.
|
|
756
|
+
*
|
|
757
|
+
* AND WIDENING THAT FILTER WOULD HAVE BEEN WORSE THAN THE BUG, which is why this
|
|
758
|
+
* is a second question rather than a looser version of the first one. The
|
|
759
|
+
* supervisor feeds the list into automatic parking, and parking rebuilt
|
|
760
|
+
* `.claude/worktrees/<id>` out of the folder's BASENAME rather than using the
|
|
761
|
+
* path it was handed. A folder nobody's row owns passes every eligibility check
|
|
762
|
+
* parking has, so a wider list would have had the app committing to a branch it
|
|
763
|
+
* invented and deleting a folder somebody made on purpose.
|
|
764
|
+
*
|
|
765
|
+
* So: this answers what is there, and `ours` answers what the app may act on.
|
|
766
|
+
* Only the second is ever parked or removed, and the first is what lets a person
|
|
767
|
+
* see what is holding their disk.
|
|
768
|
+
*
|
|
769
|
+
* `ours` IS PROOF BY LOCATION, and the location is the app's own. Nothing but
|
|
770
|
+
* `ensureTaskFolder` puts a folder at `<primary>/.claude/worktrees/<task name>`,
|
|
771
|
+
* so a folder there was made here. It is deliberately not inferred from the lock
|
|
772
|
+
* (dropped on the way to every removal) or from a marker file (which an agent
|
|
773
|
+
* working in the folder could delete, turning a folder the app made into one it
|
|
774
|
+
* will never reclaim).
|
|
775
|
+
*/
|
|
776
|
+
export function inventoryWorktrees(dir) {
|
|
777
|
+
const root = repoRoot(dir);
|
|
778
|
+
if (!root) return [];
|
|
779
|
+
const list = registered(root);
|
|
780
|
+
// GIT LISTS THE MAIN WORKTREE FIRST, and the home hangs off that one rather
|
|
781
|
+
// than off whichever folder we were asked from: `rev-parse --show-toplevel`
|
|
782
|
+
// inside a linked worktree answers with that worktree, so deriving the home
|
|
783
|
+
// from it would call every real task folder a stray.
|
|
784
|
+
const primary = real(list[0]?.path ?? root);
|
|
785
|
+
const home = path.join(primary, ...WORKTREES);
|
|
786
|
+
return list.map((w) => {
|
|
787
|
+
const at = real(w.path);
|
|
788
|
+
const name = path.basename(at);
|
|
789
|
+
const unfinished = name.startsWith(BUILDING);
|
|
790
|
+
const missing = !fs.existsSync(at);
|
|
791
|
+
const holder = pidInReason(w.lock);
|
|
792
|
+
return {
|
|
793
|
+
path: at,
|
|
794
|
+
// What the registration spells it, which on a Mac can be the other side of
|
|
795
|
+
// a symlink: `/tmp/x` against `/private/tmp/x`.
|
|
796
|
+
registeredAs: w.path,
|
|
797
|
+
primary: at === primary,
|
|
798
|
+
branch: w.branch ?? '',
|
|
799
|
+
lock: w.lock ?? null,
|
|
800
|
+
heldBy: holder && alive(holder) ? holder : null,
|
|
801
|
+
// A folder still being built is the app's and is wanted by nobody: it has
|
|
802
|
+
// no row standing in it, so it may never be listed, shown or swept.
|
|
803
|
+
unfinished,
|
|
804
|
+
missing,
|
|
805
|
+
prunable: !!w.prunable,
|
|
806
|
+
// WHERE THE APP PUTS ITS OWN, whether or not anything is there now. Kept
|
|
807
|
+
// apart from `ours` because a registration whose folder has gone is still
|
|
808
|
+
// the app's bookkeeping to tidy, and is nothing anybody may act on.
|
|
809
|
+
atOurPath: path.dirname(at) === home && couldBeATaskName(name),
|
|
810
|
+
ours: !unfinished && !missing && !w.prunable
|
|
811
|
+
&& path.dirname(at) === home && couldBeATaskName(name),
|
|
812
|
+
};
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Copies of this project the app did not make, so a person can see what is
|
|
818
|
+
* holding their disk. The checkout itself is not one of them, and neither is a
|
|
819
|
+
* registration pointing at nothing: that is a different fact with its own field
|
|
820
|
+
* and its own answer, `pruneMissingWorktrees`.
|
|
821
|
+
*/
|
|
822
|
+
export function strayWorktrees(dir) {
|
|
823
|
+
return inventoryWorktrees(dir)
|
|
824
|
+
.filter((w) => !w.primary && !w.ours && !w.unfinished && !w.missing && !w.prunable);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* REGISTRATIONS POINTING AT NOTHING, DROPPED. One of the thirteen lived under
|
|
829
|
+
* `/private/tmp`, which macOS may purge from under it, and git goes on holding
|
|
830
|
+
* the registration: that is what refuses the next `worktree add` at that path
|
|
831
|
+
* with "already registered".
|
|
832
|
+
*
|
|
833
|
+
* Nothing can be lost here, by git's own definition of `prune`: it only ever
|
|
834
|
+
* drops the bookkeeping for a worktree whose directory is already gone, and the
|
|
835
|
+
* branch it stood on survives, as every branch survives everything in this file.
|
|
836
|
+
* A folder of OURS is unlocked first, because `prune` skips a locked worktree and
|
|
837
|
+
* the app locks every folder it makes, which would otherwise leave the
|
|
838
|
+
* registration of a deleted folder wedged there forever.
|
|
839
|
+
*/
|
|
840
|
+
export function pruneMissingWorktrees(dir) {
|
|
841
|
+
const root = repoRoot(dir);
|
|
842
|
+
if (!root) return [];
|
|
843
|
+
const gone = inventoryWorktrees(dir).filter((w) => !w.primary && (w.missing || w.prunable));
|
|
844
|
+
if (!gone.length) return [];
|
|
845
|
+
for (const w of gone) if (w.atOurPath) tryGit(root, ['worktree', 'unlock', w.path]);
|
|
846
|
+
tryGit(root, ['worktree', 'prune']);
|
|
847
|
+
const left = new Set(inventoryWorktrees(dir).map((w) => w.path));
|
|
848
|
+
return gone.map((w) => w.path).filter((at) => !left.has(at));
|
|
849
|
+
}
|
|
850
|
+
|
|
458
851
|
/**
|
|
459
852
|
* Every task folder in this repository and what would be lost by deleting it.
|
|
460
853
|
* This is what the app reads to show her a folder that is holding work, which
|
|
461
854
|
* is the half that did not exist: hers held 26 GB and she had never seen one.
|
|
855
|
+
*
|
|
856
|
+
* THE OWNERSHIP VIEW, and the only list anything automatic is allowed to act on.
|
|
857
|
+
* `inventoryWorktrees` above is the wider one, and the comment there is why they
|
|
858
|
+
* are not the same list.
|
|
462
859
|
*/
|
|
463
860
|
export function listTaskFolders(dir) {
|
|
464
861
|
const root = repoRoot(dir);
|
|
465
862
|
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))
|
|
863
|
+
return inventoryWorktrees(dir)
|
|
864
|
+
.filter((w) => w.ours)
|
|
472
865
|
.map((w) => {
|
|
473
866
|
const { dirty, merged } = folderState(root, w.path);
|
|
474
867
|
let touchedAt = null;
|
|
475
868
|
try { touchedAt = fs.statSync(w.path).mtimeMs; } catch { /* it can vanish under us */ }
|
|
476
|
-
const holder = pidInReason(w.lock);
|
|
477
869
|
return {
|
|
478
870
|
id: path.basename(w.path),
|
|
479
871
|
path: w.path,
|
|
480
|
-
branch: w.branch
|
|
872
|
+
branch: w.branch,
|
|
481
873
|
dirty,
|
|
482
874
|
merged,
|
|
483
875
|
touchedAt,
|
|
484
|
-
heldBy:
|
|
876
|
+
heldBy: w.heldBy,
|
|
485
877
|
};
|
|
486
878
|
});
|
|
487
879
|
}
|