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/CHANGELOG.md +40 -0
- package/README.md +5 -5
- package/dist/agent-onboard.d.ts +45 -18
- package/dist/agent-onboard.js +146 -57
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +14 -13
- package/dist/checkout-cleanup.d.ts +84 -0
- package/dist/checkout-cleanup.js +245 -0
- package/dist/daemon.d.ts +6 -2
- package/dist/daemon.js +6 -5
- package/dist/first-run.d.ts +8 -2
- package/dist/first-run.js +12 -8
- package/dist/install-method.d.ts +1 -1
- package/dist/install-method.js +4 -0
- package/dist/mcp.js +26 -0
- package/dist/operate.d.ts +2 -4
- package/dist/operate.js +167 -30
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +41 -0
- package/dist/serve.js +103 -28
- package/dist/storage-ui.d.ts +10 -0
- package/dist/storage-ui.js +67 -0
- package/dist/storage.d.ts +15 -1
- package/dist/storage.js +62 -7
- package/dist/store.d.ts +6 -6
- package/dist/store.js +23 -20
- package/dist/toolroll-update-cli.js +7 -3
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +7 -6
- package/dist/toolroll-update.d.ts +69 -15
- package/dist/toolroll-update.js +572 -110
- package/dist/worktree.d.ts +24 -10
- package/dist/worktree.js +84 -44
- package/package.json +1 -1
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
|
-
|
|
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
|
|
4317
|
-
//
|
|
4318
|
-
const pruned = await
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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 => {
|