toolroll 0.8.1 → 0.8.2

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/dist/prompt.js CHANGED
@@ -5,22 +5,51 @@
5
5
  * gets the same immediate usage refusal as always, because a scheduler
6
6
  * hanging on "password:" at 3am is the one bug this module must not have.
7
7
  */
8
+ import { spawnSync } from "node:child_process";
9
+ import { basename } from "node:path";
8
10
  import { createInterface } from "node:readline";
9
11
  /** Whether asking is even possible: a human on both ends of the pipe. */
10
12
  export function interactive() {
11
13
  return process.stdin.isTTY === true && process.stdout.isTTY === true;
12
14
  }
13
- /** Variables a coding agent sets for the commands it runs. CODEX_HOME and CODEX_API_KEY are a person's own settings. */
14
- const AGENT_VARIABLES = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "GEMINI_CLI", "CURSOR_AGENT", "OPENCODE"];
15
- const PERSON_CODEX_VARIABLES = new Set(["CODEX_HOME", "CODEX_API_KEY"]);
15
+ /** Variables a coding agent sets for the commands it runs. Only Codex's own CODEX_ names: a person exports others
16
+ * (CODEX_HOME, CODEX_API_KEY, a profile) for themselves. */
17
+ const AGENT_VARIABLES = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "GEMINI_CLI", "CURSOR_AGENT", "OPENCODE", "GOOSE_TERMINAL", "AIDER_CHAT",
18
+ "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_THREAD_ID", "CODEX_CI", "CODEX_MANAGED_BY_NPM", "CODEX_MANAGED_BY_BUN"];
19
+ /** Agents known by the program running this command, for those that set no variable of their own. */
20
+ const AGENT_PROGRAMS = new Set(["claude", "codex", "gemini", "cursor-agent", "opencode", "aider", "copilot", "amp", "goose", "crush", "qwen"]);
21
+ /** The program names of this process's ancestors, nearest first: each one's command and, for a script run by node
22
+ * or python, the script's name. Empty where `ps` cannot say. */
23
+ export function ancestorPrograms() {
24
+ if (process.platform === "win32")
25
+ return [];
26
+ const names = [];
27
+ let pid = process.ppid;
28
+ for (let depth = 0; depth < 12 && pid > 1; depth++) {
29
+ const answered = spawnSync("/bin/ps", ["-o", "ppid=,args=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 });
30
+ const found = answered.status === 0 ? /^\s*(\d+)\s+(.*)$/.exec(answered.stdout.trim()) : null;
31
+ if (!found)
32
+ break;
33
+ const words = found[2].split(/\s+/);
34
+ const program = basename(words[0] ?? "");
35
+ names.push(program);
36
+ if (/^(node|bun|deno|python[\d.]*)$/.test(program) && words[1] !== undefined && !words[1].startsWith("-"))
37
+ names.push(basename(words[1]));
38
+ pid = Number(found[1]);
39
+ }
40
+ return names;
41
+ }
16
42
  /**
17
43
  * Whether a coding agent is running this command. An agent's shell can be a
18
44
  * real terminal, yet whatever is printed lands in its transcript and nobody
19
- * types at the prompt, so it is treated like no terminal at all.
45
+ * types at the prompt, so it is treated like no terminal at all. The agent is
46
+ * known by a variable it sets or, for one that sets none, by the program above
47
+ * this one. `programs` is read only for this process's own environment.
20
48
  */
21
- export function underAgent(env = process.env) {
22
- return Object.entries(env).some(([name, value]) => value !== undefined && value !== ""
23
- && (AGENT_VARIABLES.includes(name) || (name.startsWith("CODEX_") && !PERSON_CODEX_VARIABLES.has(name))));
49
+ export function underAgent(env = process.env, programs = env === process.env ? ancestorPrograms : () => []) {
50
+ if (AGENT_VARIABLES.some(name => env[name] !== undefined && env[name] !== ""))
51
+ return true;
52
+ return programs().some(name => AGENT_PROGRAMS.has(name.replace(/\.(?:js|mjs|cjs|py|exe)$/, "")));
24
53
  }
25
54
  export function ask(question) {
26
55
  const rl = createInterface({ input: process.stdin, output: process.stdout });
package/dist/serve.js CHANGED
@@ -206,6 +206,9 @@ import { BACKUP_EVERY_HOURS, MAX_KEEP, backupFolderOf, backupNow, checkBackupFol
206
206
  import { prometheusMetrics } from "./metrics.js";
207
207
  import { SPEND_CSS, spendCsv, spendHtml } from "./spend-ui.js";
208
208
  import { RETENTION_CSS, retentionHtml } from "./retention-ui.js";
209
+ import { STORAGE_CSS, storageHtml } from "./storage-ui.js";
210
+ import { bytesWords, parseCleanup } from "./storage.js";
211
+ import { checkoutPlan, cleanCheckouts, discardCheckout, previewDigest } from "./checkout-cleanup.js";
209
212
  import { UPDATES_CSS, newerThan, updateStepsHtml, updatesHtml, updatesScript } from "./toolroll-update-ui.js";
210
213
  import { abandonRuntimeUpdate, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal, currentRuntime } from "./toolroll-update.js";
211
214
  import { latestVersionNow } from "./releases.js";
@@ -393,6 +396,8 @@ export function createDecisionServer(options) {
393
396
  }
394
397
  }
395
398
  const clock = options.clock ?? (() => new Date());
399
+ /** Task checkouts, for Settings → Storage: the pool's own root, or the folder beside the database. */
400
+ const storagePool = (databaseFile) => new WorktreePool(store, { root: options.poolRoot ?? join(dirname(databaseFile), "worktrees") });
396
401
  const install = installMethod(options.installBin);
397
402
  /** What the last daily check found (the cache only, never the network), or nothing while checks are off. */
398
403
  const updateFacts = () => {
@@ -1411,7 +1416,7 @@ export function createDecisionServer(options) {
1411
1416
  !url.searchParams.has('task') && !url.searchParams.has('result') && !url.searchParams.has('request')) {
1412
1417
  const key = createHash('sha256').update(JSON.stringify([workspaceIncarnation, who.name,
1413
1418
  who.session.generation, who.session.csrf, who.session.project, who.session.projectRevision,
1414
- admissionList(), url.pathname + url.search])).digest('hex');
1419
+ admissionList(), url.pathname + url.search, localSignIn?.signedIn ?? null])).digest('hex');
1415
1420
  const revision = workspaceRevision.current(), now = clock();
1416
1421
  const prior = workspaceValidators.get(key);
1417
1422
  if (prior && prior.revision === revision && prior.expiresAt > now.getTime() && request.headers['if-none-match'] === prior.etag) {
@@ -3635,7 +3640,7 @@ export function createDecisionServer(options) {
3635
3640
  const current = options.updates?.current ?? PACKAGE_VERSION;
3636
3641
  // While update checks are off the page asks npm nothing until Check now.
3637
3642
  const checksOff = updateChecksOff(process.env, options.configDir ?? dirname(databaseFile)).off && url.searchParams.get("check") !== "now";
3638
- const view = { current, latest: active ? { version: current } : checksOff ? { off: true } : await latestReleaseFor(options.updates?.latest), method: options.updates?.method ?? installMethod(), journal: status.journal, running: status.running, whatsNew: status.whatsNew, csrf: who.session.csrf };
3643
+ const view = { current, latest: active ? { version: current } : checksOff ? { off: true } : await latestReleaseFor(options.updates?.latest), method: options.updates?.method ?? installMethod(), journal: status.journal, running: status.running, whatsNew: status.whatsNew, rollbackTo: status.lastUpdate?.to === current ? status.lastUpdate.from : null, csrf: who.session.csrf };
3639
3644
  return sendScreen(response, 200, screen("Updates", `<p><a href="/settings">Settings</a></p><h1>Updates</h1>${updatesHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings"), ...(active ? { functional: { script: updatesScript(), fetches: true } } : {}) }));
3640
3645
  }
3641
3646
  // v105: Settings → Retention, how long each kind of data is kept. An instance operator's page.
@@ -3645,6 +3650,15 @@ export function createDecisionServer(options) {
3645
3650
  const view = { periods: store.retentionPeriods(), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
3646
3651
  return sendScreen(response, 200, screen("Retention", `<p><a href="/settings">Settings</a></p><h1>Retention</h1>${retentionHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
3647
3652
  }
3653
+ // Settings → Storage: what task checkouts use, cleaning up now (the preview behind the password), and when a
3654
+ // finished task's clean checkout goes by itself. An instance operator's page.
3655
+ if (url.pathname === "/settings/storage") {
3656
+ const databaseFile = store.databaseFile();
3657
+ if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
3658
+ return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
3659
+ const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
3660
+ return sendScreen(response, 200, screen("Storage", `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${storageHtml({ plan, csrf: who.session.csrf }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
3661
+ }
3648
3662
  // Sprint 8: Settings → Backups, how the last backup went and the schedule. An instance operator's page.
3649
3663
  if (url.pathname === "/settings/backups") {
3650
3664
  const databaseFile = store.databaseFile();
@@ -3873,17 +3887,19 @@ export function createDecisionServer(options) {
3873
3887
  /**
3874
3888
  * Whether a coding agent is signed in where the work runs. This machine's
3875
3889
  * own checks (the ones Settings makes) speak only for a worker on this
3876
- * machine; a worker elsewhere speaks through its own readiness report, and
3890
+ * machine, or for the first one while none is registered yet (it is started
3891
+ * here); a worker elsewhere speaks through its own readiness report, and
3877
3892
  * the console never claims to have checked that machine (finding 15).
3878
3893
  * Nothing here waits on a probe: this machine's answer is the last one
3879
- * seen, refreshed in the background.
3894
+ * seen, refreshed in the background, and null until the first one is in.
3880
3895
  */
3881
3896
  function agentSignedIn(now) {
3882
3897
  const here = hostname();
3883
- const workers = store.listRunners().filter(one => one.retiredAt === null && runnerAlive(one, now));
3884
- if ((options.localRunner !== undefined || workers.some(one => one.host === here)) && localAgentSignedIn())
3898
+ const registered = store.listRunners().filter(one => one.retiredAt === null);
3899
+ const workers = registered.filter(one => runnerAlive(one, now));
3900
+ if (workers.some(one => one.host !== here && store.providerReadiness(one.name).some(seen => seen.state === "ready")))
3885
3901
  return true;
3886
- return workers.some(one => one.host !== here && store.providerReadiness(one.name).some(seen => seen.state === "ready"));
3902
+ return options.localRunner !== undefined || registered.length === 0 || workers.some(one => one.host === here) ? localAgentSignedIn() : false;
3887
3903
  }
3888
3904
  let localSignIn = null;
3889
3905
  let localSignInChecking = false;
@@ -3895,7 +3911,7 @@ export function createDecisionServer(options) {
3895
3911
  .catch(() => { })
3896
3912
  .finally(() => { localSignInChecking = false; });
3897
3913
  }
3898
- return localSignIn?.signedIn ?? false;
3914
+ return localSignIn?.signedIn ?? null;
3899
3915
  }
3900
3916
  /** First tasks per project: the last ones found (safe generic ones until then), re-read in the background at most
3901
3917
  * every ten minutes. A render never waits on `gh` or `git grep`. */
@@ -3923,7 +3939,7 @@ export function createDecisionServer(options) {
3923
3939
  return {
3924
3940
  steps,
3925
3941
  suggestions: taskFiled || repo === null ? [] : firstTasksFor(repo),
3926
- sandbox: steps.find(one => one.key === "agent")?.done === false ? SANDBOX_COMMAND : null,
3942
+ sandbox: steps.find(one => one.key === "agent" && !one.done && !one.checking) ? SANDBOX_COMMAND : null,
3927
3943
  };
3928
3944
  }
3929
3945
  // ---- fleet chat (v13) ----------------------------------------------------
@@ -6620,6 +6636,38 @@ export function createDecisionServer(options) {
6620
6636
  store.setRetentionPeriod(one.kind, one.days ?? null, who.name, now);
6621
6637
  return back("said", changed.length === 0 ? "Nothing changed." : "Saved. The next daily sweep uses these periods.");
6622
6638
  }
6639
+ // Settings → Storage: when a finished task's clean checkout goes, cleaning up what the preview showed, and throwing
6640
+ // away a checkout kept for its changes. Each takes the password; the ledger keeps every change and removal.
6641
+ if (url.pathname === "/settings/storage" || url.pathname === "/settings/storage/clean" || url.pathname === "/settings/storage/discard") {
6642
+ const databaseFile = store.databaseFile();
6643
+ if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
6644
+ return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
6645
+ const back = (key, words) => redirect(response, `/settings/storage?${key}=${encodeURIComponent(words)}`);
6646
+ const what = url.pathname.endsWith("/clean") ? "clean up" : url.pathname.endsWith("/discard") ? "discard a checkout" : "change checkout cleanup";
6647
+ if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
6648
+ return back("problem", `Enter your Toolroll password to ${what}.`);
6649
+ if (url.pathname === "/settings/storage") {
6650
+ const cleanup = parseCleanup(body.get("cleanup") ?? "");
6651
+ if (cleanup === undefined)
6652
+ return back("problem", "Choose when a finished task's checkout is removed.");
6653
+ const before = store.checkoutCleanup();
6654
+ store.setCheckoutCleanup(cleanup, who.name, now);
6655
+ return back("said", cleanup === before ? "Nothing changed." : "Saved.");
6656
+ }
6657
+ const pool = storagePool(databaseFile);
6658
+ if (url.pathname.endsWith("/clean")) {
6659
+ // Only what the preview showed goes: when that changed since, the person looks again.
6660
+ const plan = await checkoutPlan(store, pool, clock(), { manual: true });
6661
+ if (plan.go.length === 0)
6662
+ return back("said", "Nothing to clean up.");
6663
+ if (body.get("preview") !== previewDigest(plan))
6664
+ return back("problem", "What a clean-up would remove changed since you looked. Check the list again.");
6665
+ const done = await cleanCheckouts(store, pool, clock, { manual: true, actor: who.name, only: new Set(plan.go.map(one => one.path)) });
6666
+ return back("said", done.removed.length === 0 ? "Nothing was removed: those checkouts are no longer ready to go." : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}. Their branches stay.`);
6667
+ }
6668
+ const done = await discardCheckout(store, pool, body.get("path") ?? "", now, who.name);
6669
+ return done.ok ? back("said", `Discarded, about ${bytesWords(done.bytes)}. Its branch stays.`) : back("problem", done.message);
6670
+ }
6623
6671
  // Sprint 8: back up now. An instance operator; the outcome is kept like a scheduled one's.
6624
6672
  if (url.pathname === "/settings/backups/now") {
6625
6673
  const databaseFile = store.databaseFile();
@@ -14066,7 +14114,7 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
14066
14114
  const THEME_CONTROLS_CSS = `.task-repo select{width:100%;min-height:2.75rem;font-size:1rem}.task-repo-add{margin:.35rem .1rem .5rem}.task-repo-add a{display:inline-flex;align-items:center;min-height:2.25rem}details.result-request-open.result-request-form>summary{border:0;background:transparent;padding:.5rem 0;min-height:2.75rem;font-weight:600;display:list-item;list-style:revert}details.result-request-open.result-request-form>summary::-webkit-details-marker{display:revert}form.js-autosave button[type=submit]{display:none}.provider-row{border-bottom:1px solid var(--so-line);padding:.35rem 0}.provider-row:first-of-type{border-top:1px solid var(--so-line)}.provider-head{display:flex;align-items:center;gap:.75rem;margin:.4rem 0 0}.provider-status{display:inline-flex;align-items:center;gap:.4rem;color:var(--so-muted);font-size:.875rem}.provider-status i{width:.5rem;height:.5rem;border-radius:50%;background:var(--so-muted)}.provider-status--ok i{background:var(--so-success)}.provider-status--warn i{background:var(--so-attention)}.provider-status--off i{background:transparent;border:1.5px solid var(--so-muted)}details.provider-manage>summary{cursor:pointer;color:var(--so-accent-text);font-size:.875rem;min-height:2.5rem;display:list-item;padding-block:.5rem}.card.props .row{display:grid;gap:.1rem;margin:0 0 .75rem}.card.props .row>.meta{display:block;font-size:.75rem}.card.props .row>.meta::first-letter{text-transform:uppercase}.card.props .row>.mono{font-family:var(--font-sans);font-size:.875rem}.card.props .row>.mono .seal{font-family:var(--font-mono);font-size:.8125rem}details.evidence-files{margin:1rem 0}details.evidence-files>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:600}details.evidence-files ul{list-style:none;margin:0;padding:0}details.evidence-files li{display:flex;justify-content:space-between;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--so-line)}.result-action .result-feedback-link{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;text-decoration:none}@media(hover:hover) and (pointer:fine){.result-action .result-feedback-link:hover{background:var(--so-raised)}}.so-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.verdict{margin:.5rem 0 .75rem}.verdict-chips{display:flex;flex-wrap:wrap;gap:.4rem;list-style:none;padding:0;margin:0}.verdict-chip{display:inline-flex;align-items:center;gap:.3rem;min-height:1.75rem;padding:.2rem .65rem;border-radius:999px;font-size:.8125rem;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.verdict-chip svg{width:.9rem;height:.9rem}.verdict-chip--success{background:var(--so-success-soft);color:var(--so-success)}.verdict-chip--danger{background:var(--so-danger-soft);color:var(--so-danger)}.verdict-chip--warning{background:var(--so-warning-soft);color:var(--so-warning)}.verdict-chip--info{background:var(--so-info-soft);color:var(--so-info)}.verdict-by{margin:.4rem 0 0}details.result-request-open{margin:.5rem 0}details.result-request-open>summary{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;cursor:pointer;list-style:none}details.result-request-open>summary::-webkit-details-marker{display:none}details.result-request-open[open]>summary{margin-bottom:.75rem}.settings-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr));gap:.5rem;margin:0 0 2rem}.settings-tiles a{display:flex;align-items:center;gap:.6rem;min-height:3rem;padding:.65rem .8rem;border:1px solid var(--so-line);border-radius:.625rem;background:var(--so-paper);color:var(--so-ink);text-decoration:none;font-weight:550;font-size:.875rem}@media(hover:hover) and (pointer:fine){.settings-tiles a:hover{border-color:var(--so-input-line);background:var(--so-raised)}}.settings-tiles svg{width:1.1rem;height:1.1rem;flex-shrink:0;color:var(--so-accent-text)}details.settings-more{margin:.25rem 0 1.25rem}details.settings-more>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:550}details.settings-more>summary .meta{font-weight:400;margin-left:.35rem}.settings-changed{margin-top:-.25rem}.appearance{margin:0 0 28px}.appearance h2{margin:0 0 10px}.theme-switch{display:inline-flex;flex-wrap:nowrap;max-width:100%;gap:4px;padding:4px;margin:0;border:1px solid var(--so-line);border-radius:10px;background:var(--so-raised)}.theme-switch .theme-choice,.so-native-region .theme-switch .theme-choice{flex:1 1 0;width:auto;white-space:nowrap;min-height:40px;padding:8px 16px;border:0;border-radius:7px;background:transparent;color:var(--so-muted);font:inherit;font-weight:550;box-shadow:none;cursor:pointer}@media(hover:hover) and (pointer:fine){.theme-switch .theme-choice:hover{color:var(--so-ink)}}.theme-switch .theme-choice[aria-pressed="true"]{background:var(--so-paper);color:var(--so-ink);box-shadow:0 1px 2px rgb(0 0 0 / .1)}.appearance .meta{margin:8px 0 0}@media(max-width:600px){.theme-switch .theme-choice{min-height:44px}}.update-notes{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;max-height:18rem;overflow:auto}`;
14067
14115
  /** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
14068
14116
  export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
14069
- const WORKSPACE_STYLE = styleAsset(STYLE + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + LEAD_CONTEXT_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
14117
+ const WORKSPACE_STYLE = styleAsset(STYLE + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + LEAD_CONTEXT_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
14070
14118
  /**
14071
14119
  * The board's liveness: fetch this page's own fragment on a timer and swap
14072
14120
  * it in place — no flicker, no scroll reset, no long-lived stream to manage,
@@ -14725,7 +14773,7 @@ function inboxPage(chrome, data) {
14725
14773
  `<p><strong>Get to your first result</strong></p>` +
14726
14774
  data.wizard
14727
14775
  .map(step => `<p class="row" data-step="${step.key}"><span class="mono" aria-hidden="true">${step.done ? "\u2713" : "\u25cb"}</span> <strong>${escape(step.title)}</strong>` +
14728
- (step.action === null ? ` <span class="meta">done</span>`
14776
+ (step.checking ? ` <span class="meta">checking…</span>` : step.action === null ? ` <span class="meta">done</span>`
14729
14777
  : step.action.kind === "link" ? ` <a href="${escape(step.action.href)}">${escape(step.action.label)}</a>`
14730
14778
  : ` <code>${escape(step.action.command)}</code>`) +
14731
14779
  `</p>`)
@@ -21810,6 +21858,7 @@ const SETTINGS_TILE_ICONS = [
21810
21858
  ["/settings/approval", "Approval rules", `<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/>`],
21811
21859
  ["/settings/monitoring", "Monitoring", `<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>`],
21812
21860
  ["/settings/retention", "Retention", `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`],
21861
+ ["/settings/storage", "Storage", `<path d="M22 12H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M6 16h.01M10 16h.01"/>`],
21813
21862
  ["/settings/updates", "Updates", `<path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/>`],
21814
21863
  ["/settings/backups", "Backups", `<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/>`],
21815
21864
  ["/settings/data", "Data", `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>`],
@@ -0,0 +1,10 @@
1
+ import { type CheckoutPlan } from "./checkout-cleanup.js";
2
+ export declare const STORAGE_CSS: string;
3
+ export type StorageView = {
4
+ plan: CheckoutPlan;
5
+ csrf: string;
6
+ };
7
+ export declare function storageHtml(view: StorageView, notice: {
8
+ said?: string | null;
9
+ problem?: string | null;
10
+ }): string;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Settings → Storage: how much space task checkouts use, what a clean-up would free now, a Clean up button whose
3
+ * preview (what goes, what stays and why, with paths) sits behind the password, and when a finished task's clean
4
+ * checkout goes by itself. An instance operator's page.
5
+ */
6
+ import { homedir } from "node:os";
7
+ import { basename } from "node:path";
8
+ import { previewDigest, whyWords } from "./checkout-cleanup.js";
9
+ import { CLEANUP_CHOICES, bytesWords } from "./storage.js";
10
+ const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
11
+ const plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
12
+ export const STORAGE_CSS = `.storage{max-width:720px;min-width:0;overflow-wrap:anywhere}.storage h2{font-size:1.0625rem;margin:24px 0 4px}` +
13
+ `.storage .storage-state{display:grid;gap:12px}.storage .storage-state h2{margin:0}.storage .storage-state p{margin:0}` +
14
+ `.storage details.clean-up,.storage details.discard{border:0;padding:0;margin:0;background:transparent}` +
15
+ `.storage details.clean-up>summary{list-style:none;display:inline-flex;align-items:center;min-height:2.125rem;padding:.35rem .75rem;border-radius:calc(var(--radius) - 3px);` +
16
+ `background:var(--primary);color:var(--primary-foreground);font-weight:600;font-size:.8125rem;cursor:pointer;white-space:nowrap}` +
17
+ `.storage details.clean-up>summary::-webkit-details-marker{display:none}.storage details.clean-up[open]>summary{background:var(--card);color:var(--foreground);border:1px solid var(--input)}` +
18
+ `.storage .preview{display:grid;gap:12px;margin-top:12px;padding-top:12px;border-top:1px solid var(--border)}.storage .preview h3{font-size:.875rem;margin:0}` +
19
+ `.storage ul.checkouts{list-style:none;padding:0;margin:0}` +
20
+ `.storage ul.checkouts li{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 12px;padding:8px 0;border-top:1px solid var(--border);font-size:.8125rem}` +
21
+ `.storage ul.checkouts .name{font-weight:500;min-width:0}.storage ul.checkouts .size{font-variant-numeric:tabular-nums;white-space:nowrap}` +
22
+ `.storage ul.checkouts .path{grid-column:1/-1;font-family:var(--font-mono, ui-monospace, monospace);font-size:.6875rem;color:var(--muted-foreground);word-break:break-all}` +
23
+ `.storage ul.checkouts .why{grid-column:1/-1}` +
24
+ `.storage details.discard{grid-column:1/-1}.storage details.discard>summary{cursor:pointer;color:var(--destructive);min-height:2.25rem;display:inline-flex;align-items:center}` +
25
+ `.storage form{display:grid;gap:10px;margin:0}.storage label{display:grid;gap:4px;font-size:.8125rem;font-weight:500;max-width:20rem}` +
26
+ `.storage select,.storage input[type=password]{width:100%;box-sizing:border-box;min-height:2.125rem}.storage form button{justify-self:start;white-space:nowrap}` +
27
+ `.storage form.setting button[type=submit],.storage .preview form.remove button[type=submit]{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary);font-weight:600}` +
28
+ `@media (max-width:900px){.storage details.clean-up>summary,.storage form button{min-height:44px}.storage label{max-width:none}.storage select,.storage input[type=password]{min-height:44px;font-size:16px}.storage .preview form.remove button{width:100%}}`;
29
+ function item(one, csrf) {
30
+ const discard = one.why === "has changes"
31
+ ? `<details class="discard"><summary>Discard</summary><form method="post" action="/settings/storage/discard"><input type="hidden" name="csrf" value="${e(csrf)}"><input type="hidden" name="path" value="${e(one.path)}">` +
32
+ `<p class="meta">Throws away this checkout and its uncommitted changes. Its branch stays.</p>` +
33
+ `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><button type="submit" class="danger">Discard changes</button></form></details>`
34
+ : "";
35
+ // The task it was for, then where it is (the home folder as ~), then why it stays.
36
+ const home = homedir();
37
+ const where = home !== "" && one.path.startsWith(`${home}/`) ? `~${one.path.slice(home.length)}` : one.path;
38
+ return `<li data-checkout="${e(one.path)}"><span class="name">${e(one.taskId ?? basename(one.path))}</span><span class="size">${e(bytesWords(one.bytes))}</span>` +
39
+ `<span class="path">${e(where)}</span>` +
40
+ (one.why === null ? "" : `<span class="why">${e(whyWords(one)[0].toUpperCase() + whyWords(one).slice(1))}</span>`) + discard + `</li>`;
41
+ }
42
+ export function storageHtml(view, notice) {
43
+ const { plan, csrf } = view;
44
+ const note = notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
45
+ const hidden = `<input type="hidden" name="csrf" value="${e(csrf)}">`;
46
+ const facts = [plural(plan.count, "checkout"), `${plan.waitingReview} waiting for review`, `${plan.withChanges} kept for their changes`].join(" · ");
47
+ const frees = plan.go.length === 0 ? "Nothing to clean up now." : `Cleaning up now frees about ${bytesWords(plan.freeBytes)}.`;
48
+ const remove = plan.go.length === 0 ? "" :
49
+ `<form method="post" action="/settings/storage/clean" class="remove">${hidden}<input type="hidden" name="preview" value="${e(previewDigest(plan))}">` +
50
+ `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
51
+ `<button type="submit">Remove ${plural(plan.go.length, "checkout")}</button></form>`;
52
+ const preview = `<details class="clean-up" data-clean-up${notice.problem?.startsWith("Enter your Toolroll password to clean") ? " open" : ""}><summary>Clean up</summary><div class="preview">` +
53
+ (plan.go.length === 0 ? `<p>Nothing to remove now.</p>` :
54
+ `<h3>Removes ${plural(plan.go.length, "checkout")}, about ${e(bytesWords(plan.freeBytes))}</h3><p class="meta">Their branches stay, so nothing committed is lost.</p>` +
55
+ `<ul class="checkouts" data-goes>${plan.go.map(one => item(one, csrf)).join("")}</ul>`) +
56
+ (plan.stay.length === 0 ? "" : `<h3>Stays (${plan.stay.length})</h3><ul class="checkouts" data-stays>${plan.stay.map(one => item(one, csrf)).join("")}</ul>`) +
57
+ remove + `</div></details>`;
58
+ const options = CLEANUP_CHOICES.map(one => `<option value="${one.value}"${one.value === plan.cleanup ? " selected" : ""}>${e(one.label)}</option>`).join("");
59
+ return `<section class="storage">${note}` +
60
+ `<div class="card storage-state"><h2 data-checkout-bytes="${plan.totalBytes}">Checkouts use ${e(bytesWords(plan.totalBytes))}</h2><p class="meta">${e(facts)}</p>` +
61
+ `<p data-clean-bytes="${plan.freeBytes}">${e(frees)}</p>${preview}</div>` +
62
+ `<h2>Automatic cleanup</h2>` +
63
+ `<form method="post" action="/settings/storage" class="setting">${hidden}` +
64
+ `<label>Remove a finished task's clean checkout<select name="cleanup">${options}</select></label>` +
65
+ `<p class="meta">Its branch always stays. Checkouts with changes, commits on no branch, in use, or waiting for review are kept.</p>` +
66
+ `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">Save</button></form></section>`;
67
+ }
package/dist/storage.d.ts CHANGED
@@ -1,4 +1,18 @@
1
1
  import type { Store } from "./store.js";
2
+ /** Settings → Storage: when a finished task's clean checkout is removed. One row, or none (the default). */
3
+ export declare const CHECKOUT_CLEANUP_SCHEMA = "\nCREATE TABLE IF NOT EXISTS checkout_cleanup_setting (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n cleanup TEXT NOT NULL CHECK (cleanup IN ('finished', '2d', '7d', 'never')),\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n";
4
+ export type CheckoutCleanup = "finished" | "2d" | "7d" | "never";
5
+ export declare const DEFAULT_CLEANUP: CheckoutCleanup;
6
+ export declare const CLEANUP_CHOICES: readonly {
7
+ value: CheckoutCleanup;
8
+ label: string;
9
+ days: number | null;
10
+ }[];
11
+ /** `finished` (or complete, cancelled), `2d`, `7d` (or week, 1w), `never` (or off); undefined when it's none of those. */
12
+ export declare function parseCleanup(text: string): CheckoutCleanup | undefined;
13
+ export declare function cleanupWords(cleanup: CheckoutCleanup): string;
14
+ /** Bytes each path holds on disk: one `du` for them all (fast on a 300 MB checkout), a walk where it can't answer. */
15
+ export declare function diskBytes(paths: readonly string[]): Map<string, number>;
2
16
  export type StorageLine = {
3
17
  what: string;
4
18
  bytes: number;
@@ -7,7 +21,7 @@ export type StorageLine = {
7
21
  };
8
22
  /** Bytes a tree holds on disk (allocated blocks; links not followed). */
9
23
  export declare function treeBytes(path: string): number;
10
- export declare function storageReport(store: Store, databaseFile: string, now: Date, keepMs: number): {
24
+ export declare function storageReport(store: Store, databaseFile: string, checkoutNote?: string): {
11
25
  folder: string;
12
26
  total: number;
13
27
  lines: StorageLine[];
package/dist/storage.js CHANGED
@@ -2,11 +2,67 @@
2
2
  * Where Toolroll's disk goes (`toolroll storage`): the folder
3
3
  * beside the database, by kind, and what storage retention takes care of.
4
4
  * Build checkouts and staged releases are the big ones; the worker removes a
5
- * finished task's clean checkout two days after it was let go, and a deploy
6
- * keeps its last few releases.
5
+ * finished task's clean checkout as the checkout cleanup setting says
6
+ * (checkout-cleanup.ts), and a deploy keeps its last few releases.
7
7
  */
8
+ import { spawnSync } from "node:child_process";
8
9
  import { lstatSync, readdirSync } from "node:fs";
9
10
  import { basename, dirname, join } from "node:path";
11
+ /** Settings → Storage: when a finished task's clean checkout is removed. One row, or none (the default). */
12
+ export const CHECKOUT_CLEANUP_SCHEMA = `
13
+ CREATE TABLE IF NOT EXISTS checkout_cleanup_setting (
14
+ id INTEGER PRIMARY KEY CHECK (id = 1),
15
+ cleanup TEXT NOT NULL CHECK (cleanup IN ('finished', '2d', '7d', 'never')),
16
+ updated_by TEXT NOT NULL,
17
+ updated_at TEXT NOT NULL
18
+ );
19
+ `;
20
+ export const DEFAULT_CLEANUP = "finished";
21
+ export const CLEANUP_CHOICES = [
22
+ { value: "finished", label: "When its task is complete or cancelled", days: 0 },
23
+ { value: "2d", label: "2 days after that", days: 2 },
24
+ { value: "7d", label: "A week after that", days: 7 },
25
+ { value: "never", label: "Never (only when you clean up)", days: null },
26
+ ];
27
+ /** `finished` (or complete, cancelled), `2d`, `7d` (or week, 1w), `never` (or off); undefined when it's none of those. */
28
+ export function parseCleanup(text) {
29
+ const value = text.trim().toLowerCase().replace(/\s+/g, " ");
30
+ if (["finished", "complete", "completed", "cancelled", "done", "0", "0d"].includes(value))
31
+ return "finished";
32
+ if (["2d", "2", "2 days", "2days"].includes(value))
33
+ return "2d";
34
+ if (["7d", "7", "7 days", "7days", "week", "a week", "1w"].includes(value))
35
+ return "7d";
36
+ if (["never", "off"].includes(value))
37
+ return "never";
38
+ return undefined;
39
+ }
40
+ export function cleanupWords(cleanup) {
41
+ return cleanup === "finished" ? "when its task is complete or cancelled"
42
+ : cleanup === "never" ? "never (only when you clean up)"
43
+ : `${cleanup === "2d" ? "2 days" : "a week"} after its task is complete or cancelled`;
44
+ }
45
+ /** Bytes each path holds on disk: one `du` for them all (fast on a 300 MB checkout), a walk where it can't answer. */
46
+ export function diskBytes(paths) {
47
+ const sizes = new Map();
48
+ if (paths.length === 0)
49
+ return sizes;
50
+ if (process.platform !== "win32") {
51
+ for (let at = 0; at < paths.length; at += 200) {
52
+ const batch = paths.slice(at, at + 200);
53
+ const du = spawnSync("du", ["-sk", ...batch], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
54
+ for (const line of (du.stdout ?? "").split("\n")) {
55
+ const match = /^(\d+)\t(.+)$/.exec(line);
56
+ if (match !== null && batch.includes(match[2]))
57
+ sizes.set(match[2], Number(match[1]) * 1024);
58
+ }
59
+ }
60
+ }
61
+ for (const path of paths)
62
+ if (!sizes.has(path))
63
+ sizes.set(path, treeBytes(path));
64
+ return sizes;
65
+ }
10
66
  /** Bytes a tree holds on disk (allocated blocks; links not followed). */
11
67
  export function treeBytes(path) {
12
68
  let total = 0;
@@ -35,7 +91,7 @@ export function treeBytes(path) {
35
91
  }
36
92
  return total;
37
93
  }
38
- export function storageReport(store, databaseFile, now, keepMs) {
94
+ export function storageReport(store, databaseFile, checkoutNote) {
39
95
  const folder = dirname(databaseFile);
40
96
  const base = basename(databaseFile);
41
97
  let names = [];
@@ -45,7 +101,8 @@ export function storageReport(store, databaseFile, now, keepMs) {
45
101
  catch {
46
102
  names = [];
47
103
  }
48
- const sizes = new Map(names.map(name => [name, treeBytes(join(folder, name))]));
104
+ const measured = diskBytes(names.map(name => join(folder, name)));
105
+ const sizes = new Map(names.map(name => [name, measured.get(join(folder, name)) ?? 0]));
49
106
  const take = (match) => {
50
107
  let bytes = 0;
51
108
  for (const [name, size] of sizes)
@@ -56,7 +113,6 @@ export function storageReport(store, databaseFile, now, keepMs) {
56
113
  return bytes;
57
114
  };
58
115
  const checkouts = store.listWorktrees();
59
- const due = checkouts.filter(row => row.releasedAt !== null && row.runner === null && now.getTime() - Date.parse(row.releasedAt) >= keepMs).length;
60
116
  let staged = 0;
61
117
  try {
62
118
  staged = readdirSync(join(folder, "staged-upgrades")).length;
@@ -66,8 +122,7 @@ export function storageReport(store, databaseFile, now, keepMs) {
66
122
  }
67
123
  const lines = [
68
124
  { what: "Database", bytes: take(name => name.startsWith(base)) },
69
- { what: "Build checkouts", bytes: take(name => name === "worktrees"), count: checkouts.length,
70
- note: due === 0 ? "none due for removal" : `${due} let go over ${Math.round(keepMs / 86_400_000)} days ago: removed on the next pass if their task is finished and they're clean` },
125
+ { what: "Build checkouts", bytes: take(name => name === "worktrees"), count: checkouts.length, ...(checkoutNote === undefined ? {} : { note: checkoutNote }) },
71
126
  { what: "Staged releases", bytes: take(name => name === "staged-upgrades"), count: staged, note: "a deploy keeps the release it installed, the one before and the newest few" },
72
127
  { what: "Evidence", bytes: take(name => name === "evidence"), note: "the audit record of every run; kept" },
73
128
  { what: "Backups", bytes: take(name => name === "backups") },
package/dist/store.d.ts CHANGED
@@ -16,6 +16,7 @@ import type { LimitReading, LimitWindow } from "./provider-limits.js";
16
16
  import { underCeiling, type OrgPolicy, type SavedPolicy } from "./policy.js";
17
17
  import { type Billing, type Budget, type BudgetAgent, type BudgetHold, type BudgetScope, type BudgetState, type SpendItem } from "./spend.js";
18
18
  import { type RetentionKind, type RetentionPeriods } from "./retention.js";
19
+ import { type CheckoutCleanup } from "./storage.js";
19
20
  import { type LedgerChainReport } from "./ledger-chain.js";
20
21
  import { type ApprovalGate, type ApprovalRules, type ApproverKind, type Filer } from "./approval-policy.js";
21
22
  export type BackupSettings = {
@@ -2645,11 +2646,6 @@ export declare class Store {
2645
2646
  getWorktree(path: string): WorktreeRow | null;
2646
2647
  /** Drop a row whose directory is gone; a lease over nothing only refuses work. */
2647
2648
  forgetWorktree(path: string): void;
2648
- /** The branches storage retention keeps a repository's checkouts of: every task not done or cancelled (a failed
2649
- * one included: it can be requeued), and unplaced ones (they're dispatched anywhere); and a done task whose result
2650
- * was marked complete (the completion names its family's root; its run names the version), or a release candidate
2651
- * done, within `resultMs` (a deploy installs the build in its checkout). Each task's own branch and every branch its runs used. */
2652
- keptBranches(repo: string, now: Date, resultMs: number): string[];
2653
2649
  /**
2654
2650
  * Approve one repo's setup, revoking any predecessor in the same
2655
2651
  * transaction — exactly one live setup per repo, enforced by the partial
@@ -5068,6 +5064,10 @@ export declare class Store {
5068
5064
  retentionPeriods(): RetentionPeriods;
5069
5065
  /** Set how long one kind is kept (null: forever); the ledger keeps before → after. */
5070
5066
  setRetentionPeriod(kind: RetentionKind, days: number | null, by: string, now: Date): RetentionPeriods;
5067
+ /** Settings → Storage: when a finished task's clean checkout is removed (the default until someone chooses). */
5068
+ checkoutCleanup(): CheckoutCleanup;
5069
+ /** Choose when a finished task's clean checkout is removed; the ledger keeps before → after. */
5070
+ setCheckoutCleanup(cleanup: CheckoutCleanup, by: string, now: Date): CheckoutCleanup;
5071
5071
  /** v105: keep a provider's latest reading of its plan's usage windows (a window it no longer reports is dropped). */
5072
5072
  recordProviderLimits(reading: LimitReading, now: Date): void;
5073
5073
  /** The latest reading of every provider's plan windows, shortest window first. */
package/dist/store.js CHANGED
@@ -67,9 +67,10 @@ import { POLICY_SCHEMA, agentRefusal, approvalRefusal, attendedRefusal, policyPa
67
67
  import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
68
68
  import { SPEND_SCHEMA, billingOf, budgetStates, canPrice, claudeMachineBilling, countsToward, filersOf, monthOf, priceWork, seenBilling as seenBillingOf, spendItems, teammateFilers, usd } from "./spend.js";
69
69
  import { FOREVER, RETENTION_SCHEMA, periodWords } from "./retention.js";
70
+ import { CHECKOUT_CLEANUP_SCHEMA, DEFAULT_CLEANUP, cleanupWords } from "./storage.js";
70
71
  import { IN_RANGE, LEDGER_CHAIN_SCHEMA, safeWhole, sealLedger, verifyLedgerChain } from "./ledger-chain.js";
71
72
  import { NO_RULES, filerFor, isProtectedWork, protectedChanges, rulesWords } from "./approval-policy.js";
72
- import { BRANCH_PREFIXES, configBase, envValue, namedPath } from "./names.js";
73
+ import { configBase, envValue, namedPath } from "./names.js";
73
74
  // v60 fenced older readers before the chat action cards; v61 adds notification
74
75
  // provenance and Telegram destination receipts, and readers below v61 refuse it.
75
76
  // v62 adds the durable Telegram conversation queue and proposal-card tokens,
@@ -3695,6 +3696,7 @@ function initializeStore(db, file) {
3695
3696
  // The sign-in pause the dispatch gate last left this task waiting on (null: none), for the work index.
3696
3697
  addColumn(db, "task_ref", "auth_wait_pause", "INTEGER REFERENCES provider_auth_pause(id)");
3697
3698
  db.exec(RETENTION_SCHEMA);
3699
+ db.exec(CHECKOUT_CLEANUP_SCHEMA);
3698
3700
  db.exec(BACKUP_SCHEMA);
3699
3701
  // Sprint 8: the organisation policy (one row, or none: nothing restricted).
3700
3702
  db.exec(POLICY_SCHEMA);
@@ -9805,21 +9807,6 @@ export class Store {
9805
9807
  forgetWorktree(path) {
9806
9808
  this.db.prepare("DELETE FROM worktree WHERE path = ?").run(path);
9807
9809
  }
9808
- /** The branches storage retention keeps a repository's checkouts of: every task not done or cancelled (a failed
9809
- * one included: it can be requeued), and unplaced ones (they're dispatched anywhere); and a done task whose result
9810
- * was marked complete (the completion names its family's root; its run names the version), or a release candidate
9811
- * done, within `resultMs` (a deploy installs the build in its checkout). Each task's own branch and every branch its runs used. */
9812
- keptBranches(repo, now, resultMs) {
9813
- const since = new Date(now.getTime() - resultMs).toISOString();
9814
- const tasks = `SELECT r.id AS ref, r.external_id AS id FROM task_ref r JOIN task t ON t.id = r.external_id LEFT JOIN task_scope s ON s.task_id = t.id
9815
- WHERE (r.repo = ? OR r.repo IS NULL) AND (t.state NOT IN ('done', 'cancelled') OR (t.state = 'done' AND (
9816
- (s.candidate IS NOT NULL AND t.updated_at >= ?) OR EXISTS (SELECT 1 FROM action_ledger l JOIN run ON run.id = l.run_id
9817
- WHERE run.task_ref = r.id AND l.action = 'assignment handoff checked' AND l.at >= ?))))`;
9818
- const own = this.db.prepare(tasks).all(repo, since, since).flatMap(row => BRANCH_PREFIXES.map(prefix => `${prefix}${String(row["id"])}`));
9819
- const used = this.db.prepare(`SELECT DISTINCT run.branch AS branch FROM run WHERE run.branch IS NOT NULL AND run.task_ref IN (SELECT ref FROM (${tasks}))`).all(repo, since, since)
9820
- .map(row => String(row["branch"]));
9821
- return [...new Set([...own, ...used])];
9822
- }
9823
9810
  // ---- worktree setup (M5.7) ---------------------------------------------
9824
9811
  /**
9825
9812
  * Approve one repo's setup, revoking any predecessor in the same
@@ -16243,6 +16230,21 @@ export class Store {
16243
16230
  return this.retentionPeriods();
16244
16231
  });
16245
16232
  }
16233
+ /** Settings → Storage: when a finished task's clean checkout is removed (the default until someone chooses). */
16234
+ checkoutCleanup() {
16235
+ const row = this.db.prepare("SELECT cleanup FROM checkout_cleanup_setting WHERE id = 1").get();
16236
+ return row === undefined ? DEFAULT_CLEANUP : String(row["cleanup"]);
16237
+ }
16238
+ /** Choose when a finished task's clean checkout is removed; the ledger keeps before → after. */
16239
+ setCheckoutCleanup(cleanup, by, now) {
16240
+ return this.transact(() => {
16241
+ const before = this.checkoutCleanup();
16242
+ this.db.prepare("INSERT INTO checkout_cleanup_setting (id, cleanup, updated_by, updated_at) VALUES (1, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET cleanup = excluded.cleanup, updated_by = excluded.updated_by, updated_at = excluded.updated_at")
16243
+ .run(cleanup, by, now.toISOString());
16244
+ this.recordPolicy(by, null, "checkout cleanup changed", cleanupWords(before), cleanupWords(cleanup), now);
16245
+ return this.checkoutCleanup();
16246
+ });
16247
+ }
16246
16248
  /** v105: keep a provider's latest reading of its plan's usage windows (a window it no longer reports is dropped). */
16247
16249
  recordProviderLimits(reading, now) {
16248
16250
  this.transact(() => {
@@ -24,6 +24,8 @@ export type UpdatesView = {
24
24
  version: string;
25
25
  notes: string[];
26
26
  } | null;
27
+ /** The version `toolroll update --rollback` returns to: the last completed update's, while it is what runs. */
28
+ rollbackTo: string | null;
27
29
  csrf: string;
28
30
  };
29
31
  /** The steps, each done, current, failed or waiting. Also the live region's fragment. */
@@ -70,7 +70,7 @@ export function updatesHtml(view, notice) {
70
70
  `<div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button>` +
71
71
  `<button type="submit" name="when" value="when-idle">When idle</button><button type="submit" name="when" value="tonight">Tonight (03:00)</button></div></form>`
72
72
  : "";
73
- const rollback = j?.kind === "update" && j.phase === "complete" ? `<p class="meta">To go back to ${e(j.from.version)}: <code>toolroll update --rollback</code></p>` : "";
73
+ const rollback = view.rollbackTo !== null ? `<p class="meta">To go back to ${e(view.rollbackTo)}: <code>toolroll update --rollback</code></p>` : "";
74
74
  return `<section class="updates">${note}${whatsNew}${j ? outcomeHtml(j) : ""}<div class="card" data-update-state="${newer ? "available" : "current"}">${state}${form}${rollback}</div></section>`;
75
75
  }
76
76
  export function newerThan(a, b) {