toolroll 0.9.61 → 0.9.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +51 -3
  3. package/dist/agent-fence.d.ts +155 -10
  4. package/dist/agent-fence.js +473 -38
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +21 -21
  7. package/dist/browser-workspace.d.ts +28 -0
  8. package/dist/chat-actions.js +2 -2
  9. package/dist/check-levels.js +3 -0
  10. package/dist/claim.js +1 -1
  11. package/dist/cli-help.d.ts +1 -1
  12. package/dist/cli-help.js +1 -0
  13. package/dist/cli.js +31 -11
  14. package/dist/contracts/cli.d.ts +8 -0
  15. package/dist/contracts/cli.js +3 -1
  16. package/dist/contracts/console-api.d.ts +19 -1
  17. package/dist/contracts/console-api.js +2 -1
  18. package/dist/contracts/integration-metadata.d.ts +2 -1
  19. package/dist/contracts/integration-metadata.js +2 -1
  20. package/dist/desktop-update.d.ts +1 -1
  21. package/dist/dispatch.js +7 -4
  22. package/dist/doctor.d.ts +53 -0
  23. package/dist/doctor.js +427 -0
  24. package/dist/evidence-pack.d.ts +1 -1
  25. package/dist/evidence-pack.js +2 -2
  26. package/dist/exec.d.ts +3 -0
  27. package/dist/exec.js +5 -4
  28. package/dist/flow-code.d.ts +2 -2
  29. package/dist/flow-code.js +4 -15
  30. package/dist/flow-engine.js +11 -6
  31. package/dist/flow-triggers.d.ts +8 -4
  32. package/dist/flow-triggers.js +45 -25
  33. package/dist/flow-unattended-words.d.ts +28 -0
  34. package/dist/flow-unattended-words.js +50 -0
  35. package/dist/flow-unattended.d.ts +227 -0
  36. package/dist/flow-unattended.js +776 -0
  37. package/dist/flows-cli.d.ts +28 -1
  38. package/dist/flows-cli.js +55 -10
  39. package/dist/flows-ui.d.ts +2 -1
  40. package/dist/flows-ui.js +4 -1
  41. package/dist/google-mail.d.ts +1 -0
  42. package/dist/google-mail.js +61 -18
  43. package/dist/integrations-ui.d.ts +2 -1
  44. package/dist/integrations-ui.js +6 -3
  45. package/dist/integrations.d.ts +17 -3
  46. package/dist/integrations.js +38 -18
  47. package/dist/invoke.js +300 -168
  48. package/dist/landed-cleanup.d.ts +26 -12
  49. package/dist/landed-cleanup.js +199 -85
  50. package/dist/lead-contract.d.ts +1 -1
  51. package/dist/lead-contract.js +3 -3
  52. package/dist/lead-tools.js +11 -3
  53. package/dist/mcp-connect.d.ts +8 -7
  54. package/dist/mcp-connect.js +49 -51
  55. package/dist/oauth-run-token.d.ts +51 -0
  56. package/dist/oauth-run-token.js +127 -0
  57. package/dist/operate.js +74 -17
  58. package/dist/plan-auto.d.ts +11 -0
  59. package/dist/plan-auto.js +62 -11
  60. package/dist/process-recovery-eligibility.d.ts +1 -1
  61. package/dist/project-access.d.ts +3 -0
  62. package/dist/project-access.js +3 -0
  63. package/dist/project-admission.d.ts +10 -6
  64. package/dist/project-admission.js +33 -11
  65. package/dist/project-delete-ui.d.ts +1 -0
  66. package/dist/project-delete-ui.js +4 -1
  67. package/dist/project-delete.d.ts +3 -1
  68. package/dist/project-delete.js +10 -4
  69. package/dist/project-file.d.ts +3 -0
  70. package/dist/project-file.js +16 -0
  71. package/dist/project-tools.d.ts +99 -25
  72. package/dist/project-tools.js +394 -98
  73. package/dist/provider-home.d.ts +22 -0
  74. package/dist/provider-home.js +99 -0
  75. package/dist/provider.d.ts +17 -3
  76. package/dist/provider.js +34 -10
  77. package/dist/remote-main.d.ts +61 -0
  78. package/dist/remote-main.js +179 -0
  79. package/dist/scope.d.ts +1 -1
  80. package/dist/serve.js +11 -3
  81. package/dist/server/flows.js +27 -5
  82. package/dist/server/render-tasks.d.ts +3 -0
  83. package/dist/server/render-tasks.js +3 -0
  84. package/dist/server/route-table.js +3 -0
  85. package/dist/server/runtime.d.ts +1 -1
  86. package/dist/server/settings.js +14 -5
  87. package/dist/server/task-views.js +2 -0
  88. package/dist/storage-sweep-child.js +3 -1
  89. package/dist/storage-sweep-process.d.ts +14 -0
  90. package/dist/storage-sweep-process.js +37 -3
  91. package/dist/storage-sweep-schedule.d.ts +30 -0
  92. package/dist/storage-sweep-schedule.js +164 -0
  93. package/dist/storage-sweep.d.ts +6 -5
  94. package/dist/storage-sweep.js +10 -70
  95. package/dist/storage-ui.d.ts +2 -0
  96. package/dist/storage-ui.js +7 -4
  97. package/dist/store.d.ts +75 -7
  98. package/dist/store.js +241 -17
  99. package/dist/subagent-tools.d.ts +1 -1
  100. package/dist/surface.js +10 -6
  101. package/dist/task-base.d.ts +35 -0
  102. package/dist/task-base.js +51 -0
  103. package/dist/teardown-git.d.ts +14 -0
  104. package/dist/teardown-git.js +119 -0
  105. package/dist/tool-broker.d.ts +37 -0
  106. package/dist/tool-broker.js +369 -0
  107. package/dist/tool-launcher.js +5 -4
  108. package/dist/tool-secret-store.d.ts +170 -0
  109. package/dist/tool-secret-store.js +834 -0
  110. package/dist/toolroll-update.js +4 -2
  111. package/dist/tools-ui.d.ts +7 -1
  112. package/dist/tools-ui.js +17 -8
  113. package/dist/work-index.js +5 -3
  114. package/dist/worktree.d.ts +64 -6
  115. package/dist/worktree.js +171 -35
  116. package/package.json +3 -1
  117. package/scripts/provider-canary.mjs +210 -31
package/dist/worktree.js CHANGED
@@ -23,15 +23,16 @@
23
23
  * over `.git` internals corrupts both.
24
24
  */
25
25
  import { createHash, randomBytes } from "node:crypto";
26
- import { existsSync, readFileSync, writeFileSync, rmSync, realpathSync, mkdirSync, readdirSync, renameSync } from "node:fs";
26
+ import { existsSync, lstatSync, readFileSync, writeFileSync, rmSync, realpathSync, mkdirSync, readdirSync, renameSync } from "node:fs";
27
27
  import { hostname } from "node:os";
28
- import { dirname, join } from "node:path";
28
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
29
29
  import { run } from "./exec.js";
30
30
  import { currentBootId, provenDeadByBootChange } from "./boot-identity.js";
31
31
  import { HANDOFF_PREFIX, MAILBOX_SUFFIX, looksLikeProtocolFile, readMailbox } from "./evidence.js";
32
32
  import { parseHandoff } from "./decision.js";
33
33
  import { diskBytes } from "./storage.js";
34
34
  import { markNeverIndex } from "./never-index.js";
35
+ import { teardownGit } from "./teardown-git.js";
35
36
  /**
36
37
  * The note a lease leaves in the checkout, naming the process holding it.
37
38
  *
@@ -40,6 +41,13 @@ import { markNeverIndex } from "./never-index.js";
40
41
  * something is in there.
41
42
  */
42
43
  export const MARKER = ".standing-orders-lease";
44
+ /**
45
+ * The reflog message of a branch Toolroll created, naming it: its creation entry is the proof, later, that the branch
46
+ * is Toolroll's and how old it is. A branch copied or renamed from it names the old branch, so it proves nothing.
47
+ */
48
+ export const branchCreatedMessage = (branch) => `Toolroll created branch ${branch}`;
49
+ /** Scouts' disposable branches: never landed work. */
50
+ export const SCOUT_PREFIX = "toolroll-scout/";
43
51
  /** Checkouts this process is leasing or removing right now: a lease and storage retention never overlap on one. */
44
52
  const busy = new Map();
45
53
  // Outside the checkout so removal cannot erase the reservation before git and the row update finish.
@@ -122,6 +130,31 @@ export function worktreePath(root, repo, branch) {
122
130
  .slice(0, 8);
123
131
  return join(root, safeSegment(basenameOf(repo)), `${safeSegment(branch)}-${digest}`);
124
132
  }
133
+ /** The pool root a checkout at `path` of `repo` was made under (worktreePath's layout), or null for one it didn't make. */
134
+ export function poolRootOf(path, repo) {
135
+ const project = dirname(path);
136
+ return basename(project) === safeSegment(basenameOf(repo)) && /-[0-9a-f]{8}$/.test(basename(path)) ? dirname(project) : null;
137
+ }
138
+ /** Inspect names, not their destinations, before the plane writes into a pool. */
139
+ function safePoolPath(root, target) {
140
+ const base = resolve(root);
141
+ const rel = relative(base, resolve(target));
142
+ if (rel.startsWith("..") || rel.startsWith(sep))
143
+ return false;
144
+ let path = base;
145
+ for (const component of ["", ...rel.split(sep)]) {
146
+ path = join(path, component);
147
+ try {
148
+ if (!lstatSync(path).isDirectory())
149
+ return false;
150
+ }
151
+ catch (error) {
152
+ if (error.code !== "ENOENT")
153
+ return false;
154
+ }
155
+ }
156
+ return true;
157
+ }
125
158
  function normalisePath(path) {
126
159
  return path.replace(/\\/g, "/").replace(/\/+$/, "");
127
160
  }
@@ -203,20 +236,21 @@ export function worktreeProcessOccupancy(path) {
203
236
  * reset or clean a tree saves this first.
204
237
  */
205
238
  export async function saveWorkPatch(runner, path, file, header) {
206
- const status = await runner(GIT, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
239
+ const git = teardownGit(runner);
240
+ const status = await git(path, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=all"]);
207
241
  if (status.code !== 0)
208
242
  return { ok: false, message: `${path} could not be inspected before its work was saved` };
209
243
  const untracked = status.stdout
210
244
  .split("\n")
211
245
  .filter(line => line.startsWith("?? ") && !line.trimEnd().endsWith(MARKER))
212
246
  .map(line => line.slice(3).trim());
213
- const tracked = await runner(GIT, [...READ_ONLY, "diff", "--binary", "HEAD"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
247
+ const tracked = await git(path, [...READ_ONLY, "diff", "--binary", "--ignore-submodules=all", "HEAD"]);
214
248
  if (tracked.code !== 0)
215
249
  return { ok: false, message: `${path}: git diff failed while saving its work (${firstLine(tracked.stderr)})` };
216
250
  const parts = [tracked.stdout];
217
251
  for (const one of untracked) {
218
252
  // --no-index exits 1 when the sides differ; that is the expected answer.
219
- const diff = await runner(GIT, [...READ_ONLY, "diff", "--binary", "--no-index", "--", "/dev/null", one], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
253
+ const diff = await git(path, [...READ_ONLY, "diff", "--binary", "--no-index", "--ignore-submodules=all", "--", "/dev/null", one]);
220
254
  if (diff.code > 1)
221
255
  return { ok: false, message: `${path}: ${one} could not be captured (${firstLine(diff.stderr)})` };
222
256
  parts.push(diff.stdout);
@@ -297,6 +331,8 @@ export class WorktreePool {
297
331
  }
298
332
  async lease(request) {
299
333
  const target = worktreePath(this.options.root, request.repo, request.branch);
334
+ if (!safePoolPath(this.options.root, target))
335
+ return { ok: false, reason: "git", message: "The checkout pool contains a symlink or an unreadable folder." };
300
336
  if (busy.has(target))
301
337
  return { ok: false, reason: "in-use", message: `${target} is busy; try again in a moment` };
302
338
  const release = reserveCheckout(this.store, target);
@@ -324,6 +360,9 @@ export class WorktreePool {
324
360
  };
325
361
  }
326
362
  const path = worktreePath(this.options.root, request.repo, request.branch);
363
+ const unsafe = () => ({ ok: false, reason: "git", message: "The checkout pool contains a symlink or an unreadable folder." });
364
+ if (!safePoolPath(this.options.root, path))
365
+ return unsafe();
327
366
  const existing = this.store.getWorktree(path);
328
367
  // The machine outranks the database here. A row can say "released" while a
329
368
  // process that never got to write its own ending is still working in the
@@ -401,23 +440,46 @@ export class WorktreePool {
401
440
  const fromBase = request.base !== undefined && !branchExists;
402
441
  // Spotlight leaves the checkouts alone: the marker sits in the pool and the project's folder, never inside a
403
442
  // checkout, where a build's `git add -A` would commit it (never-index.ts).
443
+ if (!safePoolPath(this.options.root, path))
444
+ return unsafe();
404
445
  try {
405
446
  mkdirSync(dirname(path), { recursive: true });
406
447
  }
407
- catch { /* git reports it */ }
448
+ catch {
449
+ return unsafe();
450
+ }
408
451
  markNeverIndex(this.options.root);
409
452
  markNeverIndex(dirname(path));
410
- const add = await this.git(request.repo, [
411
- "worktree",
412
- "add",
413
- ...(fromBase ? ["-b", request.branch] : []),
414
- path,
415
- ...(fromBase ? [request.base] : [request.branch]),
416
- ]);
453
+ // A new branch is created first, on its own, so its reflog's first entry says Toolroll made it and when.
454
+ let made = null;
455
+ if (fromBase) {
456
+ const base = await this.git(request.repo, ["rev-parse", "--verify", "--quiet", `${request.base}^{commit}`], READ_ONLY);
457
+ const oid = base.stdout.trim();
458
+ if (base.code !== 0 || !/^[0-9a-f]{40,64}$/.test(oid))
459
+ return { ok: false, reason: "git", message: `git couldn't find ${request.base}` };
460
+ const branch = await this.git(request.repo, ["update-ref", "--create-reflog", "-m", branchCreatedMessage(request.branch), `refs/heads/${request.branch}`, oid, "0".repeat(oid.length)]);
461
+ if (branch.code !== 0)
462
+ return { ok: false, reason: "git", message: firstLine(branch.stderr) };
463
+ made = oid;
464
+ }
465
+ if (!safePoolPath(this.options.root, path))
466
+ return unsafe();
467
+ const add = await this.git(request.repo, ["worktree", "add", path, request.branch]);
417
468
  if (add.code !== 0) {
469
+ if (made !== null)
470
+ await this.teardown(request.repo, ["update-ref", "-d", `refs/heads/${request.branch}`, made]);
418
471
  return { ok: false, reason: "git", message: firstLine(add.stderr) };
419
472
  }
420
473
  }
474
+ // Fresh base, inside the same reservation as everything above: nothing else leases, prunes or slims this
475
+ // checkout between the inspection and the move.
476
+ let base;
477
+ if (request.freshBase !== undefined && request.taskRef !== undefined) {
478
+ const fresh = await this.freshen(path, request.branch, request.taskRef, request.freshBase, request.now);
479
+ if (!fresh.ok)
480
+ return { ok: false, reason: "git", message: fresh.message };
481
+ base = fresh.standing;
482
+ }
421
483
  const row = {
422
484
  path,
423
485
  repo: request.repo,
@@ -453,8 +515,50 @@ export class WorktreePool {
453
515
  ...(reclaimed === undefined ? {} : { reclaimed }),
454
516
  ...(resumedFromRun === undefined ? {} : { resumedFromRun }),
455
517
  ...(recoveryKind === undefined ? {} : { recoveryKind }),
518
+ ...(base === undefined ? {} : { base }),
456
519
  };
457
520
  }
521
+ /**
522
+ * Move an untouched checkout to current main, or say how far behind it is. Untouched means all three: on its own
523
+ * branch, HEAD still the recorded base (or, with none recorded, a commit main already has), and nothing changed or
524
+ * untracked. The move is `git reset --keep`, which refuses rather than overwrite anything; HEAD and the tree are read
525
+ * again right before and after it, and any surprise fails the lease with the checkout left for a person.
526
+ */
527
+ async freshen(path, branch, taskRef, fresh, now) {
528
+ const read = async (args) => {
529
+ const out = await this.runner(GIT, [...READ_ONLY, ...args], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
530
+ return out.code === 0 ? out.stdout.trim() : null;
531
+ };
532
+ const main = await read(["rev-parse", "--verify", "--quiet", `${fresh.main}^{commit}`]);
533
+ const head = await read(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
534
+ if (main === null || head === null)
535
+ return { ok: true, standing: null };
536
+ const count = await read(["rev-list", "--count", `${head}..${main}`]);
537
+ const behind = count === null ? NaN : Number(count);
538
+ if (!Number.isSafeInteger(behind))
539
+ return { ok: true, standing: null };
540
+ const stay = (kept) => ({ ok: true, standing: { head, main, behind, moved: null, kept } });
541
+ if (behind === 0)
542
+ return stay(null);
543
+ if (await read(["symbolic-ref", "-q", "HEAD"]) !== `refs/heads/${branch}`)
544
+ return stay("not on its branch");
545
+ if (fresh.recordedBase !== null && fresh.recordedBase !== head)
546
+ return stay("has commits");
547
+ const contained = await this.runner(GIT, [...READ_ONLY, "merge-base", "--is-ancestor", head, main], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
548
+ if (contained.code !== 0)
549
+ return stay("has commits");
550
+ if (await this.isDirty(path) !== false)
551
+ return stay("has changes");
552
+ // Recorded before git moves it: a move that then fails leaves the next attempt's own base as the branch's origin.
553
+ this.store.recordBranchBaseMove(taskRef, branch, head, main, now);
554
+ const moved = await this.runner(GIT, ["reset", "--keep", main], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
555
+ const after = await read(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
556
+ const branchNow = await read(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}^{commit}`]);
557
+ if (moved.code !== 0 || after !== main || branchNow !== main || await this.isDirty(path) !== false) {
558
+ return { ok: false, message: `${path} could not be moved to current main (${firstLine(moved.stderr) || "it changed during the move"}) — leaving it for a person` };
559
+ }
560
+ return { ok: true, standing: { head: main, main, behind: 0, moved: { from: head, behind }, kept: null } };
561
+ }
458
562
  /**
459
563
  * A crash can strand either a finished handoff or a partial source draft.
460
564
  * The expired lease can no longer authorize a commit, so this never ingests
@@ -526,10 +630,10 @@ export class WorktreePool {
526
630
  }
527
631
  /** Back to HEAD, untracked gone, our lease note kept; proven clean after. */
528
632
  async resetTree(path) {
529
- const checkout = await this.runner(GIT, ["checkout", "--", "."], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
633
+ const checkout = await this.teardown(path, ["checkout", "--", "."]);
530
634
  if (checkout.code !== 0)
531
635
  return { ok: false, message: `${path}: git checkout failed while resetting (${firstLine(checkout.stderr)})` };
532
- const clean = await this.runner(GIT, ["clean", "-fd", "-e", MARKER], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
636
+ const clean = await this.teardown(path, ["clean", "-fd", "-e", MARKER]);
533
637
  if (clean.code !== 0)
534
638
  return { ok: false, message: `${path}: git clean failed while resetting (${firstLine(clean.stderr)})` };
535
639
  const dirty = await this.isDirty(path);
@@ -617,17 +721,17 @@ export class WorktreePool {
617
721
  const occupied = this.inUse(path);
618
722
  if (occupied.held)
619
723
  return { ok: false, message: `${path} is being used by process ${occupied.by} — leaving it alone` };
620
- const removed = await this.git(existing.repo, ["worktree", "remove", "--force", path]);
724
+ const removed = await this.teardown(existing.repo, ["worktree", "remove", "--force", path]);
621
725
  if (removed.code !== 0)
622
726
  return { ok: false, message: `git worktree remove: ${removed.stderr.trim() || `exit ${removed.code}`}` };
623
727
  // The branch is disposable too; a failure here is bookkeeping, not custody.
624
- await this.git(existing.repo, ["branch", "-D", existing.branch]);
728
+ await this.teardown(existing.repo, ["branch", "-D", existing.branch]);
625
729
  this.store.forgetWorktree(path);
626
730
  void now;
627
731
  return { ok: true };
628
732
  }
629
733
  async orphans(repo) {
630
- const listed = await this.git(repo, ["worktree", "list", "--porcelain"], READ_ONLY);
734
+ const listed = await this.teardown(repo, ["worktree", "list", "--porcelain"]);
631
735
  // A listing that failed is not a listing that came back empty. Treating
632
736
  // it as empty would report every stored row as missing — and anything
633
737
  // acting on that answer would erase real bookkeeping over a git hiccup.
@@ -724,14 +828,14 @@ export class WorktreePool {
724
828
  /**
725
829
  * Whether a let-go checkout could go without losing anything: clean (build output that .gitignore hides is not
726
830
  * work, and neither are the files Toolroll itself wrote in there), and everything its HEAD reaches on a branch, tag
727
- * or remote. Reads only.
831
+ * or remote. Reads only, through `git` when given (teardown's hardened git).
728
832
  */
729
- async inspect(path) {
730
- const dirty = await this.isDirty(path, true);
833
+ async inspect(path, git = this.teardown) {
834
+ const dirty = await this.isDirty(path, true, git);
731
835
  if (dirty !== false)
732
836
  return dirty === null ? "unreadable" : "has changes";
733
837
  // Commits only this checkout's HEAD reaches (a detached HEAD moved on) exist nowhere else.
734
- const own = await this.runner(GIT, [...READ_ONLY, "rev-list", "-1", "HEAD", "--not", "--branches", "--tags", "--remotes"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
838
+ const own = await git(path, [...READ_ONLY, "rev-list", "-1", "HEAD", "--not", "--branches", "--tags", "--remotes"]);
735
839
  if (own.code !== 0)
736
840
  return "unreadable";
737
841
  return own.stdout.trim() === "" ? "clean" : "has commits";
@@ -739,10 +843,12 @@ export class WorktreePool {
739
843
  /**
740
844
  * Keep storage in check: remove the working copies nobody needs any more. Of `rows`, a checkout goes when it is
741
845
  * let go, nothing holds it, `wanted` says it may go (asked again right before it goes, so a task coming back or a
742
- * lease since keeps it) and `inspect` finds it clean. Only the working copy goes: its branch, and so every commit,
743
- * stays, and a later lease of the same branch makes a new one. At most `max` go per pass.
846
+ * lease since keeps it) and `inspect` finds it clean, then clean still right before it goes (git refuses an edit
847
+ * made after that). Only the working copy goes: its branch, and so every commit,
848
+ * stays, and a later lease of the same branch makes a new one. At most `max` go per pass. Every git command goes
849
+ * through `git` when given: landed cleanup passes its hardened teardown git (teardown-git.ts).
744
850
  */
745
- async prune(rows, wanted, max = Infinity) {
851
+ async prune(rows, wanted, max = Infinity, git = this.teardown) {
746
852
  const removed = [];
747
853
  const kept = [];
748
854
  const idle = (row, releasedAt) => row !== null && row.runner === null && row.releasedAt === releasedAt && !this.inUse(row.path).held;
@@ -756,7 +862,7 @@ export class WorktreePool {
756
862
  continue;
757
863
  if (!existsSync(row.path) || !idle(row, row.releasedAt))
758
864
  continue;
759
- const found = await this.inspect(row.path);
865
+ const found = await this.inspect(row.path, git);
760
866
  if (found !== "clean") {
761
867
  kept.push({ path: row.path, why: found });
762
868
  continue;
@@ -776,9 +882,9 @@ export class WorktreePool {
776
882
  const allowed = wanted(now, true);
777
883
  if (!(typeof allowed === "boolean" ? allowed : await allowed))
778
884
  continue;
779
- const gone = await this.removeWorkingCopy(row);
780
- if (!gone) {
781
- kept.push({ path: row.path, why: "git refused" });
885
+ const gone = await this.removeCleanCopy(row, git);
886
+ if (gone !== "removed") {
887
+ kept.push({ path: row.path, why: gone });
782
888
  continue;
783
889
  }
784
890
  removed.push(row);
@@ -800,7 +906,7 @@ export class WorktreePool {
800
906
  const idle = (now) => now !== null && now.runner === null && now.releasedAt !== null && now.releasedAt === row.releasedAt && !this.inUse(row.path).held;
801
907
  if (busy.has(row.path) || !existsSync(row.path) || !idle(this.store.getWorktree(row.path)))
802
908
  return { ok: false, message: `${row.path} is in use` };
803
- const listed = await this.runner(GIT, [...READ_ONLY, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], { cwd: row.path, timeoutMs: WORKTREE_TIMEOUT_MS });
909
+ const listed = await this.teardown(row.path, [...READ_ONLY, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"]);
804
910
  if (listed.code !== 0)
805
911
  return { ok: false, message: `git couldn't list ${row.path}` };
806
912
  const drop = listed.stdout.split("\0").filter(one => one.endsWith("/")).map(one => one.slice(0, -1))
@@ -871,7 +977,8 @@ export class WorktreePool {
871
977
  busy.delete(path);
872
978
  }
873
979
  }
874
- /** Remove one idle checkout's working copy (the caller holds it in `busy`) and forget its row. */
980
+ /** Throw away one idle checkout's working copy, changes and all (the caller holds it in `busy`), and forget its row.
981
+ * Only `discardChanges`, where losing them is what a person asked for. */
875
982
  async removeWorkingCopy(row) {
876
983
  // Held while it goes: another process's lease sees this process in it (the busy map is this process's only).
877
984
  const note = join(row.path, MARKER);
@@ -881,17 +988,44 @@ export class WorktreePool {
881
988
  catch {
882
989
  return false;
883
990
  }
884
- const gone = await this.git(row.repo, ["worktree", "remove", "--force", row.path]);
991
+ const gone = await this.teardown(row.repo, ["worktree", "remove", "--force", row.path]);
885
992
  if (gone.code !== 0) {
886
993
  rmSync(note, { force: true });
887
994
  return false;
888
995
  }
996
+ this.forgetRemoved(row);
997
+ return true;
998
+ }
999
+ /**
1000
+ * Remove one idle checkout's working copy only while it is still clean (the caller holds it in `busy`, and its
1001
+ * reservation outside the checkout holds it for other processes): inspected once more right before it goes, then
1002
+ * removed without --force, so git itself refuses an edit made since. Only Toolroll's own files go first, because
1003
+ * git's check would count them.
1004
+ */
1005
+ async removeCleanCopy(row, git) {
1006
+ const found = await this.inspect(row.path, git);
1007
+ if (found !== "clean")
1008
+ return found;
1009
+ const status = await git(row.path, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=all"]);
1010
+ if (status.code !== 0)
1011
+ return "unreadable";
1012
+ for (const line of status.stdout.split("\n")) {
1013
+ const name = line.startsWith("?? ") ? line.slice(3).trim() : "";
1014
+ if (name === MARKER || (name !== "" && looksLikeProtocolFile(name)))
1015
+ rmSync(join(row.path, name), { recursive: true, force: true });
1016
+ }
1017
+ const gone = await git(row.repo, ["worktree", "remove", row.path]);
1018
+ if (gone.code !== 0)
1019
+ return (await this.isDirty(row.path, false, git)) === false ? "git refused" : "has changes";
1020
+ this.forgetRemoved(row);
1021
+ return "removed";
1022
+ }
1023
+ forgetRemoved(row) {
889
1024
  this.store.transact(() => {
890
1025
  const still = this.store.getWorktree(row.path);
891
1026
  if (still !== null && still.runner === null && still.releasedAt === row.releasedAt)
892
1027
  this.store.forgetWorktree(row.path);
893
1028
  });
894
- return true;
895
1029
  }
896
1030
  /**
897
1031
  * Whether anybody's work is in there. null means we could not tell, which is
@@ -900,8 +1034,8 @@ export class WorktreePool {
900
1034
  * `--untracked-files=all` and no `--ignored`: build output that .gitignore
901
1035
  * hides is not work, but a file somebody dropped in and never staged is.
902
1036
  */
903
- async isDirty(path, ignoreOwnFiles = false) {
904
- const status = await this.runner(GIT, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
1037
+ async isDirty(path, ignoreOwnFiles = false, git = this.teardown) {
1038
+ const status = await git(path, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=all"]);
905
1039
  if (status.code !== 0)
906
1040
  return null;
907
1041
  // Our own lease note is not the operator's work. Without this the marker
@@ -915,6 +1049,8 @@ export class WorktreePool {
915
1049
  .filter(line => line.trim() !== "")
916
1050
  .some(line => !line.trimEnd().endsWith(MARKER) && !(ignoreOwnFiles && line.startsWith("?? ") && looksLikeProtocolFile(line.slice(3).trim())));
917
1051
  }
1052
+ /** All disposal and inspection paths share the hardened teardown boundary. */
1053
+ teardown = (cwd, args) => teardownGit(this.runner)(cwd, args);
918
1054
  git(cwd, args, prefix = []) {
919
1055
  return this.runner(GIT, [...prefix, ...args], { cwd, timeoutMs: WORKTREE_TIMEOUT_MS });
920
1056
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.61",
3
+ "version": "0.9.63",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"
@@ -8,6 +8,8 @@
8
8
  "scripts": {
9
9
  "build": "tsc -p tsconfig.build.json && node scripts/browser-build.mjs && node scripts/postbuild.mjs",
10
10
  "test": "vitest run",
11
+ "test:attacks": "vitest run --config test/attacks/vitest.config.ts",
12
+ "check:quick": "node scripts/quick-check.mjs",
11
13
  "dev": "tsx src/cli.ts",
12
14
  "typecheck": "tsc --noEmit && tsc -p tsconfig.browser.json",
13
15
  "certify:provider": "npm run build && node scripts/provider-canary.mjs",