toolroll 0.8.0 → 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/operate.js CHANGED
@@ -3,7 +3,8 @@ import { parseProtectedPaths } from "./approval-policy.js";
3
3
  import { rulesSummary } from "./approval-rules-ui.js";
4
4
  import { evidencePack, exportDay, ledgerExportChunks, standaloneEvidenceHtml } from "./evidence-pack.js";
5
5
  import { matchesOutsideCheckpoint } from "./ledger-chain.js";
6
- import { bytesWords, storageReport } from "./storage.js";
6
+ import { CLEANUP_CHOICES, bytesWords, cleanupWords, parseCleanup, storageReport } from "./storage.js";
7
+ import { checkoutPlan, cleanCheckouts, discardCheckout, whyWords } from "./checkout-cleanup.js";
7
8
  import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodWords, retentionPlan, sweepWords } from "./retention.js";
8
9
  import { checkPolicy, parseList, policyParts } from "./policy.js";
9
10
  import { billingOf, budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, teammateNames, usd as spendUsd } from "./spend.js";
@@ -74,7 +75,7 @@ import { sweepLiveLogs } from "./live.js";
74
75
  import { configPath, addRepos, removeRepos, updateRepos, loadRepos, loadProjectRegistry, updateProjectRegistry } from "./repos.js";
75
76
  import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
76
77
  import { pushPass } from "./push.js";
77
- import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
78
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
78
79
  import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
79
80
  import { createServer as createNetServer } from "node:net";
80
81
  import { spawn as spawnChild } from "node:child_process";
@@ -128,7 +129,7 @@ import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLeg
128
129
  import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
129
130
  import { scanRepo } from "./capscan.js";
130
131
  import { computeGaps, describeCapability } from "./gaps.js";
131
- import { ask, askHidden, confirm, interactive } from "./prompt.js";
132
+ import { ask, askHidden, confirm, interactive, underAgent } from "./prompt.js";
132
133
  import { runMateCli, answerContextLines } from "./mate-cli.js";
133
134
  import { confirmCoordinatorProposal, dismissCoordinatorProposal } from "./mate-doors.js";
134
135
  import { verifyApproverByPassword } from "./principal.js";
@@ -193,10 +194,6 @@ import { PACKAGE_VERSION } from "./version.js";
193
194
  * caller deciding whether to try the next task or wake somebody up.
194
195
  */
195
196
  export const EXIT = { ok: 0, failed: 1, usage: 2, refused: 3 };
196
- /** Storage retention: how long a finished task's clean checkout is kept after it was let go, and a result marked
197
- * complete's or a release candidate's after it was (its checkout holds the build a deploy installs). */
198
- export const CHECKOUT_KEEP_MS = 2 * 24 * 60 * 60_000;
199
- export const RESULT_KEEP_MS = 7 * 24 * 60 * 60_000;
200
197
  const STATES = ["queued", "running", "done", "failed", "cancelled"];
201
198
  export const OPERATE_HELP = `toolroll — operating the queue
202
199
 
@@ -213,6 +210,9 @@ export const OPERATE_HELP = `toolroll — operating the queue
213
210
  --not-requester on|off, --protect none|project, --protect-paths "a/**,b/**"
214
211
  toolroll task evidence <id> the task's evidence pack as JSON (--html for a printable page; --out <file>)
215
212
  toolroll storage where the disk goes: database, build checkouts, releases, evidence
213
+ toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay); --yes removes them
214
+ toolroll storage cleanup <when> remove a finished task's clean checkout: finished|2d|7d|never (instance operator)
215
+ toolroll storage discard <path> --yes throw away a checkout kept for its changes; its branch stays
216
216
  toolroll monitoring where the audit stream and traces go, and how each destination is doing
217
217
  toolroll check-progress <run> current or final approved check progress
218
218
  toolroll spend [--month YYYY-MM] [--csv] what agent work cost, by project, person, teammate and model
@@ -823,13 +823,8 @@ async function dispatch(command, positional, flags, context) {
823
823
  return exportCommand(positional, flags, context);
824
824
  case "policy":
825
825
  return policyCommand(positional, flags, context);
826
- case "storage": {
827
- const report = storageReport(context.store, context.databaseFile, context.clock(), CHECKOUT_KEEP_MS);
828
- return succeed(context.write, context.json, "storage", report, () => [
829
- `${report.folder}: ${bytesWords(report.total)}`,
830
- ...report.lines.map(one => ` ${one.what.padEnd(17)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
831
- ]);
832
- }
826
+ case "storage":
827
+ return storageCommand(positional, flags, context);
833
828
  case "monitoring": {
834
829
  // Where the audit stream and traces go, and how each destination is doing. Settings → Monitoring changes it.
835
830
  const settings = readMonitoring(dirname(context.databaseFile));
@@ -4313,13 +4308,9 @@ async function reconcileCommand(flags, context) {
4313
4308
  reaped: reaped.map(claim => claim.leaseId),
4314
4309
  });
4315
4310
  }
4316
- // Storage retention: finished work's clean checkouts go two days after they were let go (their branches stay); a
4317
- // result marked complete, or a release candidate, a week after (a deploy installs the build in its checkout).
4318
- const pruned = await worktrees.prune(repo, clock(), CHECKOUT_KEEP_MS, () => store.keptBranches(repo, clock(), RESULT_KEEP_MS));
4319
- for (const row of pruned.removed) {
4320
- store.recordAction({ at: clock().toISOString(), actor: "worker", repo, taskId: row.taskRef === null ? null : store.externalIdFor(row.taskRef), runId: null,
4321
- action: "checkout removed", outcome: "removed", source: "work", detail: `${basename(row.path)} (released ${row.releasedAt?.slice(0, 10) ?? "?"})` });
4322
- }
4311
+ // Checkout cleanup (Settings → Storage): finished work's clean checkouts go as the setting says (by default when
4312
+ // the task is complete or cancelled); their branches stay. Each removal is in the ledger.
4313
+ const pruned = await cleanCheckouts(store, worktrees, clock, { manual: false, actor: "worker", repo });
4323
4314
  // Retention (Settings → Retention): once a day, what's older than its setting goes; one ledger entry says what.
4324
4315
  let retention = null;
4325
4316
  try {
@@ -4341,7 +4332,7 @@ async function reconcileCommand(flags, context) {
4341
4332
  reaped: reaped.map(claim => claim.leaseId),
4342
4333
  adopted: adoption.adopted,
4343
4334
  forgotten: adoption.forgotten,
4344
- checkoutsRemoved: pruned.removed.map(row => row.path),
4335
+ checkoutsRemoved: pruned.removed.map(one => one.path),
4345
4336
  checkoutsKept: pruned.kept,
4346
4337
  liveViewsSwept: liveSwept.removed.length,
4347
4338
  retention: retention === null ? null : { counts: retention.counts, freedBytes: retention.freed },
@@ -4352,7 +4343,7 @@ async function reconcileCommand(flags, context) {
4352
4343
  ...(reaped.length === 0 ? [] : [`Reaped ${reaped.length} expired lease(s).`]),
4353
4344
  ...adoption.adopted.map(path => `Adopted ${path} — released, unverified, somebody should look.`),
4354
4345
  ...adoption.forgotten.map(path => `Forgot ${path} — its directory is gone.`),
4355
- ...(pruned.removed.length === 0 ? [] : [`Removed ${pruned.removed.length} checkout(s) nobody has used for two days; their branches stay.`]),
4346
+ ...(pruned.removed.length === 0 ? [] : [`Removed ${pruned.removed.length} finished task checkout(s), about ${bytesWords(pruned.freed)}; their branches stay.`]),
4356
4347
  ...(liveSwept.removed.length === 0 ? [] : [`Cleared ${liveSwept.removed.length} finished live view(s).`]),
4357
4348
  ...(retention === null || retained.length === 0 ? [] : [`Retention removed ${sweepWords(retention.counts, retention.freed)}.`]),
4358
4349
  ]);
@@ -7665,6 +7656,38 @@ function openBrowser(url) {
7665
7656
  // A browser that will not open is a URL the greeting already printed.
7666
7657
  }
7667
7658
  }
7659
+ /** Where `up` last served its console, kept beside the database so `onboard` names the same address. */
7660
+ const UP_CONSOLE_FILE = "up-console.json";
7661
+ function recordUpConsoleUrl(dir, url, now) {
7662
+ try {
7663
+ const file = join(dir, UP_CONSOLE_FILE), temporary = `${file}.${process.pid}.tmp`;
7664
+ writeFileSync(temporary, `${JSON.stringify({ url, at: now.toISOString() })}\n`, { mode: 0o600 });
7665
+ renameSync(temporary, file);
7666
+ }
7667
+ catch {
7668
+ // A display fact: a failed write only leaves onboard on the default port.
7669
+ }
7670
+ }
7671
+ export function readUpConsoleUrl(dir) {
7672
+ try {
7673
+ const parsed = JSON.parse(readFileSync(join(dir, UP_CONSOLE_FILE), "utf8"));
7674
+ return typeof parsed.url === "string" && /^https?:\/\/[^\s]+$/.test(parsed.url) ? parsed.url.replace(/\/+$/, "") : null;
7675
+ }
7676
+ catch {
7677
+ return null;
7678
+ }
7679
+ }
7680
+ /** How onboard says to sign in: the saved login file only while it still works (as `up` advertises it), else the
7681
+ * account and its password; null only when there is no account yet. Never the password itself. */
7682
+ function onboardLogin(store, loginFile) {
7683
+ const names = store.listApprovers().map(one => one.name);
7684
+ const saved = readLoginFile(loginFile);
7685
+ if (names.length === 0)
7686
+ return saved === null ? null : { account: saved.name, file: loginFile }; // the next `up` adopts it
7687
+ if (saved !== null && authenticateApprover(store, saved.name, saved.password).ok)
7688
+ return { account: saved.name, file: loginFile };
7689
+ return { account: names.length === 1 ? names[0] : null, file: null };
7690
+ }
7668
7691
  /**
7669
7692
  * `onboard`: the agent that installed Toolroll becomes its lead. The work
7670
7693
  * lives in agent-onboard.ts; this wires it to the installation: the same
@@ -7679,8 +7702,11 @@ async function onboardCommand(flags, context) {
7679
7702
  if (portGiven !== undefined && (!Number.isInteger(port) || port < 1 || port >= 65536)) {
7680
7703
  return fail(write, json, "onboard", "usage", "--port is a whole number under 65536", EXIT.usage);
7681
7704
  }
7705
+ // The console where `up` last served, unless --port says otherwise.
7706
+ const served = portGiven === undefined ? readUpConsoleUrl(dirname(context.databaseFile)) : null;
7682
7707
  const home = seams.home ?? homedir();
7683
7708
  const env = seams.env ?? process.env;
7709
+ const loginFile = join(dirname(context.databaseFile), UP_LOGIN_FILE);
7684
7710
  const gitRun = context.gitRunner ?? ((file, args, opts) => run(file, [...args], { ...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }), timeoutMs: 10_000 }));
7685
7711
  const registryPath = registryPathOf(context);
7686
7712
  const checker = createConnectionChecker({ home, env });
@@ -7690,18 +7716,31 @@ async function onboardCommand(flags, context) {
7690
7716
  yes: flag(flags, "yes"),
7691
7717
  remove: flag(flags, "remove"),
7692
7718
  agentFlag: text(flags, "agent"),
7693
- url: `http://127.0.0.1:${port}`,
7694
- loginFile: join(dirname(context.databaseFile), UP_LOGIN_FILE),
7719
+ url: served ?? `http://127.0.0.1:${port}`,
7720
+ login: onboardLogin(store, loginFile),
7695
7721
  home,
7722
+ worktrees: join(dirname(context.databaseFile), "worktrees"),
7696
7723
  env,
7697
7724
  version: PACKAGE_VERSION,
7698
7725
  cwd: seams.cwd ?? process.cwd(),
7699
- interactive: seams.interactive ?? interactive(),
7726
+ // An agent's shell may be a terminal, but nobody answers y/N there.
7727
+ interactive: seams.interactive ?? (interactive() && !underAgent(env)),
7700
7728
  confirm: seams.confirm ?? (async (question) => /^y(es)?$/i.test(await ask(`${question} `))),
7701
7729
  findRepo: seams.findRepo ?? (async (cwd) => {
7702
7730
  const top = await gitRun("git", ["rev-parse", "--show-toplevel"], { cwd });
7703
- return top.code === 0 ? canonicalProject(top.stdout.trim()) : null;
7731
+ const topPath = top.code === 0 ? canonicalProject(top.stdout.trim()) : null;
7732
+ if (topPath === null)
7733
+ return null;
7734
+ // A linked worktree's project is its main checkout: the folder whose .git is the common one.
7735
+ const common = await gitRun("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd });
7736
+ const commonDir = common.code === 0 ? common.stdout.trim() : "";
7737
+ const main = basename(commonDir) === ".git" ? canonicalProject(dirname(commonDir)) : null;
7738
+ return { top: topPath, main: main ?? topPath };
7704
7739
  }),
7740
+ enrolled: async (repo) => {
7741
+ const registry = await loadProjectRegistry(registryPath);
7742
+ return !("error" in registry) && registry.repos.includes(repo);
7743
+ },
7705
7744
  enroll: async (repo) => {
7706
7745
  const before = await loadProjectRegistry(registryPath);
7707
7746
  const enrolled = await updateRepos(registryPath, repos => addRepos(repos, [repo]));
@@ -7839,7 +7878,10 @@ async function upCommand(flags, context) {
7839
7878
  if (!reserved) {
7840
7879
  return fail(write, json, "up", "port-busy", `port ${port} is taken — another \`up\` or \`serve\` may already be running; stop it, or pick a different --port`, EXIT.refused);
7841
7880
  }
7842
- const canPrompt = !json && process.stdin.isTTY === true && process.stdout.isTTY === true;
7881
+ // Under a coding agent nothing is asked and no secret is printed, even in a real terminal: what is shown lands in
7882
+ // its transcript.
7883
+ const agentRunning = underAgent(context.upSeams?.env ?? process.env);
7884
+ const canPrompt = !json && !agentRunning && process.stdin.isTTY === true && process.stdout.isTTY === true;
7843
7885
  let runnerName = "";
7844
7886
  let runnerToken = "";
7845
7887
  let approver = null;
@@ -8152,8 +8194,12 @@ async function upCommand(flags, context) {
8152
8194
  // 9. Readiness, then the ONE startup envelope / greeting (finding 11/20).
8153
8195
  const approverPlan2 = approver;
8154
8196
  const url = console_.url;
8155
- const terminal = context.upSeams?.terminal ?? process.stdout.isTTY === true;
8156
- const upHandoff = buildHandoff({ url, loginFile: approverPlan2.passwordFile, account: approverPlan2.passwordFile === null ? approverPlan2.approver : loginAccount(approverPlan2.passwordFile) ?? approverPlan2.approver });
8197
+ const terminal = !agentRunning && (context.upSeams?.terminal ?? process.stdout.isTTY === true);
8198
+ const upHandoff = buildHandoff({
8199
+ url,
8200
+ login: { account: approverPlan2.passwordFile === null ? approverPlan2.approver : loginAccount(approverPlan2.passwordFile) ?? approverPlan2.approver, file: approverPlan2.passwordFile },
8201
+ });
8202
+ recordUpConsoleUrl(dirname(context.databaseFile), url, clock());
8157
8203
  if (fatal === null) {
8158
8204
  if (json) {
8159
8205
  write(envelopeJson({
@@ -11268,6 +11314,97 @@ async function retentionCommand(positional, flags, context) {
11268
11314
  const periods = store.setRetentionPeriod(kind, days, acting.name, context.clock());
11269
11315
  return succeed(context.write, context.json, command, { periods }, () => [`${label(kind)}: kept ${days === null ? "forever" : `for ${periodWords(days)}`}.`]);
11270
11316
  }
11317
+ /** `storage`, `storage cleanup [<when>]`, `storage clean [--yes]`, `storage discard <path> --yes`: where the disk goes,
11318
+ * and task checkouts: when a finished task's clean checkout is removed, cleaning up now, and throwing away one kept for
11319
+ * its changes. Changing anything takes an instance operator. */
11320
+ async function storageCommand(positional, flags, context) {
11321
+ const [action, ...rest] = positional;
11322
+ const command = `storage ${action ?? ""}`.trim();
11323
+ const store = context.store;
11324
+ const changes = (action === "cleanup" && rest.length > 0) || (action === "clean" && flags.has("yes")) || action === "discard";
11325
+ const allowed = new Set([...(changes ? ["as", "token", "token-file", "token-env"] : []), ...(action === "clean" || action === "discard" ? ["yes"] : []), "db", "json"]);
11326
+ for (const name of flags.keys())
11327
+ if (!allowed.has(name))
11328
+ return fail(context.write, context.json, command, "usage", `--${name} is not a ${command} option.`, EXIT.usage);
11329
+ const pool = new WorktreePool(store, { root: join(dirname(context.databaseFile), "worktrees"), ...(context.gitRunner === undefined ? {} : { runner: context.gitRunner }) });
11330
+ const operator = async () => {
11331
+ const acting = await askCredentials(flags, context);
11332
+ const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
11333
+ return acting !== null && verified !== null && verified.ok && store.isInstanceOperator(acting.name) ? acting.name : null;
11334
+ };
11335
+ const refused = (what) => fail(context.write, context.json, command, "refused", `An instance operator ${what}: pass --as and --token (or use the remembered login).`, EXIT.refused);
11336
+ const summary = (plan) => [
11337
+ `Checkouts: ${plan.count}, ${bytesWords(plan.totalBytes)}. ${plan.waitingReview} waiting for review, ${plan.withChanges} kept because they have changes.`,
11338
+ plan.go.length === 0 ? "A clean up now would free nothing." : `A clean up now would free about ${bytesWords(plan.freeBytes)} (${plan.go.length} checkout${plan.go.length === 1 ? "" : "s"}): toolroll storage clean`,
11339
+ `A finished task's clean checkout is removed ${cleanupWords(plan.cleanup)}; its branch stays.`,
11340
+ ];
11341
+ const itemLine = (one) => ` ${bytesWords(one.bytes).padStart(8)} ${one.path}${one.why === null ? "" : ` (${whyWords(one)})`}`;
11342
+ if (action === undefined) {
11343
+ const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
11344
+ const report = storageReport(store, context.databaseFile, plan.go.length === 0 ? "nothing to clean up" : `about ${bytesWords(plan.freeBytes)} to clean up`);
11345
+ return succeed(context.write, context.json, "storage", { ...report, checkouts: checkoutSummary(plan) }, () => [
11346
+ `${report.folder}: ${bytesWords(report.total)}`,
11347
+ ...report.lines.map(one => ` ${one.what.padEnd(17)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
11348
+ ...summary(plan),
11349
+ ]);
11350
+ }
11351
+ if (action === "cleanup") {
11352
+ if (rest.length === 0) {
11353
+ const cleanup = store.checkoutCleanup();
11354
+ return succeed(context.write, context.json, command, { cleanup }, () => [`A finished task's clean checkout is removed ${cleanupWords(cleanup)}; its branch stays.`]);
11355
+ }
11356
+ const cleanup = rest.length === 1 ? parseCleanup(rest[0]) : undefined;
11357
+ if (cleanup === undefined)
11358
+ return fail(context.write, context.json, command, "usage", `Use storage cleanup <${CLEANUP_CHOICES.map(one => one.value).join("|")}>.`, EXIT.usage);
11359
+ const actor = await operator();
11360
+ if (actor === null)
11361
+ return refused("sets checkout cleanup");
11362
+ const saved = store.setCheckoutCleanup(cleanup, actor, context.clock());
11363
+ return succeed(context.write, context.json, command, { cleanup: saved }, () => [`Saved. A finished task's clean checkout is removed ${cleanupWords(saved)}; its branch stays.`]);
11364
+ }
11365
+ if (action === "clean") {
11366
+ if (rest.length > 0)
11367
+ return fail(context.write, context.json, command, "usage", "Use storage clean to preview, then storage clean --yes.", EXIT.usage);
11368
+ if (!flags.has("yes")) {
11369
+ const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
11370
+ return succeed(context.write, context.json, command, { preview: true, go: plan.go, stay: plan.stay, freeBytes: plan.freeBytes }, () => [
11371
+ ...(plan.go.length === 0 ? ["Nothing to clean up."] : [`Would remove ${plan.go.length} checkout${plan.go.length === 1 ? "" : "s"}, freeing about ${bytesWords(plan.freeBytes)} (their branches stay):`, ...plan.go.map(itemLine)]),
11372
+ ...(plan.stay.length === 0 ? [] : [`Stays (${plan.stay.length}):`, ...plan.stay.map(itemLine)]),
11373
+ plan.go.length === 0 ? "Nothing was removed." : "Nothing was removed. Run toolroll storage clean --yes to remove them.",
11374
+ ]);
11375
+ }
11376
+ const actor = await operator();
11377
+ if (actor === null)
11378
+ return refused("cleans up checkouts");
11379
+ const done = await cleanCheckouts(store, pool, context.clock, { manual: true, actor });
11380
+ return succeed(context.write, context.json, command, { removed: done.removed, kept: done.kept, freedBytes: done.freed }, () => [
11381
+ done.removed.length === 0 ? "Nothing to clean up." : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}; their branches stay.`,
11382
+ ...done.removed.map(itemLine),
11383
+ ...done.kept.map(one => ` kept ${one.path} (${whyWords({ why: one.why })})`),
11384
+ ]);
11385
+ }
11386
+ if (action === "discard") {
11387
+ const [path, ...extra] = rest;
11388
+ if (path === undefined || extra.length > 0)
11389
+ return fail(context.write, context.json, command, "usage", "Use storage discard <path> --yes.", EXIT.usage);
11390
+ const target = resolve(path);
11391
+ if (store.getWorktree(target) === null)
11392
+ return fail(context.write, context.json, command, "not-found", `${target} is not a task checkout. toolroll storage clean lists them.`, EXIT.refused);
11393
+ if (!flags.has("yes"))
11394
+ return fail(context.write, context.json, command, "usage", `This throws away ${target} and its uncommitted changes (its branch stays). Add --yes to discard it.`, EXIT.usage);
11395
+ const actor = await operator();
11396
+ if (actor === null)
11397
+ return refused("discards a checkout");
11398
+ const done = await discardCheckout(store, pool, target, context.clock(), actor);
11399
+ if (!done.ok)
11400
+ return fail(context.write, context.json, command, "refused", done.message, EXIT.refused);
11401
+ return succeed(context.write, context.json, command, { discarded: target, freedBytes: done.bytes }, () => [`Discarded ${target}, about ${bytesWords(done.bytes)}; its branch stays.`]);
11402
+ }
11403
+ return fail(context.write, context.json, command, "usage", "Use storage, storage cleanup <when>, storage clean [--yes] or storage discard <path> --yes.", EXIT.usage);
11404
+ }
11405
+ function checkoutSummary(plan) {
11406
+ return { count: plan.count, totalBytes: plan.totalBytes, waitingReview: plan.waitingReview, withChanges: plan.withChanges, cleanBytes: plan.freeBytes, cleanCount: plan.go.length, cleanup: plan.cleanup };
11407
+ }
11271
11408
  /** `policy show | set` (sprint 8): the organisation policy. Setting it takes an instance operator's credentials; each rule
11272
11409
  * that changed is in the ledger, before → after. A flag left out keeps its rule; "any" lifts it. */
11273
11410
  async function policyCommand(positional, flags, context) {
package/dist/prompt.d.ts CHANGED
@@ -7,6 +7,17 @@
7
7
  */
8
8
  /** Whether asking is even possible: a human on both ends of the pipe. */
9
9
  export declare function interactive(): boolean;
10
+ /** The program names of this process's ancestors, nearest first: each one's command and, for a script run by node
11
+ * or python, the script's name. Empty where `ps` cannot say. */
12
+ export declare function ancestorPrograms(): string[];
13
+ /**
14
+ * Whether a coding agent is running this command. An agent's shell can be a
15
+ * real terminal, yet whatever is printed lands in its transcript and nobody
16
+ * types at the prompt, so it is treated like no terminal at all. The agent is
17
+ * known by a variable it sets or, for one that sets none, by the program above
18
+ * this one. `programs` is read only for this process's own environment.
19
+ */
20
+ export declare function underAgent(env?: Record<string, string | undefined>, programs?: () => readonly string[]): boolean;
10
21
  export declare function ask(question: string): Promise<string>;
11
22
  /** Like ask, but what is typed never echoes — passwords live here. */
12
23
  /**
package/dist/prompt.js CHANGED
@@ -5,11 +5,52 @@
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
  }
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
+ }
42
+ /**
43
+ * Whether a coding agent is running this command. An agent's shell can be a
44
+ * real terminal, yet whatever is printed lands in its transcript and nobody
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.
48
+ */
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)$/, "")));
53
+ }
13
54
  export function ask(question) {
14
55
  const rl = createInterface({ input: process.stdin, output: process.stdout });
15
56
  return new Promise(resolve => rl.question(question, answer => {