@cruxy/cli 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +2 -2
  2. package/dist/agent/loop.js +16 -1
  3. package/dist/agent/session.js +73 -9
  4. package/dist/approval/classify.js +170 -40
  5. package/dist/approval/prompt.js +52 -6
  6. package/dist/approval/service.js +1 -11
  7. package/dist/budget/session-budget.js +10 -1
  8. package/dist/checkpoint/coverage.js +147 -4
  9. package/dist/cli/command-catalog.js +5 -1
  10. package/dist/cli/commands/config.js +18 -3
  11. package/dist/cli/commands/limits.js +76 -0
  12. package/dist/cli/commands/logs.js +149 -0
  13. package/dist/cli/commands/pr.js +11 -11
  14. package/dist/cli/commands/rollback.js +10 -2
  15. package/dist/cli/commands/run.js +25 -6
  16. package/dist/cli/commands/sessions.js +181 -0
  17. package/dist/cli/onboard.js +0 -9
  18. package/dist/cli/program.js +19 -1
  19. package/dist/cli/repl.js +25 -0
  20. package/dist/cli/session-commands.js +45 -2
  21. package/dist/cli/session-factory.js +31 -10
  22. package/dist/config/manager.js +91 -11
  23. package/dist/config/schema.js +194 -20
  24. package/dist/constants.js +12 -2
  25. package/dist/errors/constructors.js +84 -46
  26. package/dist/errors/types.js +6 -0
  27. package/dist/jobs/index.js +1 -0
  28. package/dist/jobs/log-renderer.js +10 -5
  29. package/dist/jobs/log-store.js +505 -0
  30. package/dist/jobs/manager.js +338 -18
  31. package/dist/mcp/client.js +16 -0
  32. package/dist/render/limits-report.js +213 -0
  33. package/dist/render/limits-view.js +125 -0
  34. package/dist/routing/index.js +1 -1
  35. package/dist/routing/router.js +34 -14
  36. package/dist/routing/types.js +0 -2
  37. package/dist/sandbox/service.js +9 -0
  38. package/dist/sandbox/types.js +15 -0
  39. package/dist/session/index.js +3 -1
  40. package/dist/session/list.js +20 -6
  41. package/dist/session/log.js +120 -21
  42. package/dist/session/prune.js +166 -0
  43. package/dist/session/resume.js +5 -0
  44. package/dist/subagent/orchestrator.js +87 -34
  45. package/dist/subagent/spawn-tool.js +11 -4
  46. package/dist/tools/schema-depth.js +18 -0
  47. package/dist/tui/limits-panel.js +53 -30
  48. package/dist/usage/collect.js +20 -1
  49. package/dist/usage/summary.js +48 -1
  50. package/dist/usage/types.js +52 -0
  51. package/package.json +2 -2
@@ -24,10 +24,13 @@ import { ALWAYS_IGNORE_DIRS, isSecretPath } from "../indexing/walker.js";
24
24
  * `classify` is a pure synchronous function by design (it runs on every action,
25
25
  * including the tier check in `approval/mutex.ts`). So a gitignored build
26
26
  * directory — `rm -rf dist` — is still classified reversible when it is not.
27
- * That residual is deliberate and bounded: the four classes below are the ones
28
- * whose loss is *permanent* (history, secrets, the checkpoint store itself),
29
- * while generic ignored output is regenerable. It is recorded in cli#241 rather
30
- * than papered over.
27
+ *
28
+ * That general residual is **accepted**, not pending: emulating `git
29
+ * check-ignore` from a pure path predicate is the wrong shape for this seam
30
+ * (cli#241 records the argument in full). What is not accepted is the subset
31
+ * whose loss is *permanent* — Terraform state, a local database, a `*.local.*`
32
+ * config — which {@link ceilingExclusion} refuses on the ceiling side without
33
+ * touching this function or the snapshot it filters.
31
34
  */
32
35
  export function captureExclusion(relPath) {
33
36
  const first = relPath.split("/")[0];
@@ -68,3 +71,143 @@ for (const name of ALWAYS_IGNORE_DIRS) {
68
71
  `every path the capturer skips must be one auto-approve refuses to treat as restorable`);
69
72
  }
70
73
  }
74
+ // ── the ceiling-only strictness list (cli#241) ────────────────────────────────
75
+ /**
76
+ * Why the **ceiling** refuses to call `relPath` restorable, or `null` when it has
77
+ * no objection. Same shape as {@link captureExclusion} — pure, synchronous,
78
+ * path-only, no I/O — because `classify` runs it on every action, including the
79
+ * tier check in `approval/mutex.ts`.
80
+ *
81
+ * It is {@link captureExclusion} **plus** {@link CEILING_ONLY}, and therefore
82
+ * strictly stricter. `classify` reads this one; `captureFiles` still reads
83
+ * `captureExclusion`. The two differ by exactly the paths a snapshot should keep
84
+ * taking and auto-approve should stop trusting.
85
+ *
86
+ * Returns a **predicate phrase** — the sentence minus its subject, so the caller
87
+ * supplies the label the user actually typed. The two halves are introduced
88
+ * differently on purpose: a capture exclusion is a fact ("is not captured"),
89
+ * while a ceiling-only class may well be sitting in the snapshot if the repo
90
+ * tracks it, and the prompt must not claim otherwise.
91
+ */
92
+ export function ceilingExclusion(relPath) {
93
+ const notCaptured = captureExclusion(relPath);
94
+ if (notCaptured !== null) {
95
+ return `is not captured by the checkpoint — ${notCaptured}`;
96
+ }
97
+ for (const rule of CEILING_ONLY) {
98
+ if (rule.matches(relPath)) {
99
+ return `is not one the checkpoint can be relied on to restore — ${rule.reason}`;
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+ /**
105
+ * Paths auto-approve refuses to treat as restorable **even though the capturer
106
+ * may well have snapshotted them**. This is the ceiling being stricter than the
107
+ * snapshot, deliberately and in one direction only.
108
+ *
109
+ * ── Why a second list, and not four more entries in `NEVER_CAPTURED_DIRS` ──
110
+ * That map is read by `captureFiles`. Adding `*.tfstate` there would stop the
111
+ * capturer from snapshotting a `terraform.tfstate` a repo tracks — turning a
112
+ * conservative extra prompt into an actual hole in the snapshot, in the name of
113
+ * closing one. The two lists exist because the two questions are different:
114
+ *
115
+ * • `captureExclusion` — "must the snapshot refuse to hold this?" (secrets,
116
+ * `.git/`, the checkpoint store itself). Excluding is a cost paid by restore.
117
+ * • `CEILING_ONLY` — "may auto-approve assume a snapshot can put this back?"
118
+ * Refusing costs one prompt and nothing else.
119
+ *
120
+ * So the safe drift direction is *into* this list, never out of it. The
121
+ * assertions below enforce both halves: `NEVER_CAPTURED ⊆ CEILING` (anything the
122
+ * snapshot skips, the ceiling must also refuse), and each rule here is witnessed
123
+ * to be outside the capture exclusion (anything filed here must genuinely be the
124
+ * ceiling being stricter, not a snapshot exclusion in the wrong file).
125
+ *
126
+ * ── The selection rule: permanent loss, not regenerable output ──
127
+ * The general problem — "is this path gitignored?" — is not decidable from a
128
+ * path alone and stays open (see {@link captureExclusion}). This list does not
129
+ * try to solve it. A class earns a place here only when destroying the file
130
+ * destroys **state that exists nowhere else**: not in a commit, not in a
131
+ * registry, not reproducible by re-running a build. That is what separates these
132
+ * from `dist/`, `.next/`, `coverage/`, `target/` — all of which are just as
133
+ * likely to be gitignored and just as absent from the snapshot, and all of which
134
+ * a build command puts back. The cost of being wrong is the whole selection
135
+ * criterion: a false positive here is one prompt, a false negative is a file
136
+ * nobody can reconstruct.
137
+ *
138
+ * Deliberately NOT here: anything decided by *content*, by repo layout, or by
139
+ * asking git. Each rule below is a fact about a filename.
140
+ */
141
+ const CEILING_ONLY = [
142
+ {
143
+ // `terraform.tfstate`, `.tfstate.backup`, `terraform.tfstate.d/<ws>/...`.
144
+ // Matched on any segment so a workspace directory carries the exclusion to
145
+ // everything under it.
146
+ matches: (relPath) => relPath.split("/").some((seg) => seg.includes(".tfstate")),
147
+ reason: "a Terraform state file is the only record mapping the configuration to the real infrastructure it manages; it cannot be recomputed from the `.tf` files, and losing it orphans live resources",
148
+ witness: "infra/terraform.tfstate",
149
+ },
150
+ {
151
+ matches: (relPath) => isLocalDatabase(basename(relPath)),
152
+ reason: "a local database file holds rows that exist nowhere else — no build regenerates them, no registry re-serves them, and the checkpoint only captures it when the repo happens to track it",
153
+ witness: "data/app.sqlite",
154
+ },
155
+ {
156
+ // `settings.local.json`, `vite.config.local.ts`, `wrangler.local.toml`.
157
+ matches: (relPath) => basename(relPath).includes(".local."),
158
+ reason: "`*.local.*` is the convention for the machine-specific half of a config pair — the copy that is deliberately not in the repo, and so is in no commit and in no snapshot to restore from",
159
+ witness: ".claude/settings.local.json",
160
+ },
161
+ ];
162
+ /** Extensions used by the embedded databases a project keeps in its own tree. */
163
+ const LOCAL_DB_EXTENSIONS = new Set([
164
+ "sqlite",
165
+ "sqlite3",
166
+ "db",
167
+ "db3",
168
+ "duckdb",
169
+ ]);
170
+ /**
171
+ * SQLite writes its rollback journal and WAL/shared-memory files *beside* the
172
+ * database as `<name>-journal` / `-wal` / `-shm`. Deleting one of those loses
173
+ * committed-but-uncheckpointed pages, so they are the same class as the file
174
+ * they sit next to.
175
+ */
176
+ const DB_SIDECAR = /-(wal|shm|journal)$/;
177
+ function isLocalDatabase(base) {
178
+ const name = base.replace(DB_SIDECAR, "");
179
+ const dot = name.lastIndexOf(".");
180
+ return dot > 0 && LOCAL_DB_EXTENSIONS.has(name.slice(dot + 1).toLowerCase());
181
+ }
182
+ /** Last segment of a project-relative POSIX path (the input contract everywhere here). */
183
+ function basename(relPath) {
184
+ return relPath.split("/").pop() ?? "";
185
+ }
186
+ // NEVER_CAPTURED ⊆ CEILING. The same shape as the ALWAYS_IGNORE_DIRS assertion
187
+ // above, one layer out: a path the snapshot cannot hold must be one the ceiling
188
+ // refuses to call restorable. It holds by delegation today — `ceilingExclusion`
189
+ // asks `captureExclusion` first — and is asserted so that it keeps holding if
190
+ // that delegation is ever restructured, reordered, or "optimized" into a single
191
+ // merged list. Drift is permitted in the safe direction only: the ceiling may
192
+ // refuse more than the capturer skips, never less.
193
+ for (const name of NEVER_CAPTURED_DIRS.keys()) {
194
+ if (ceilingExclusion(`${name}/probe`) === null) {
195
+ throw new Error(`checkpoint/coverage.ts: \`${name}/\` is never captured but the auto-approve ceiling ` +
196
+ `would call it restorable — the ceiling must refuse everything the snapshot skips`);
197
+ }
198
+ }
199
+ // …and STRICTLY stricter: every rule here must match its own witness, and that
200
+ // witness must be a path the capturer still snapshots. The second half is the
201
+ // load-bearing one — it fails the build if a class is moved into
202
+ // `NEVER_CAPTURED_DIRS`/`isSecretPath` instead, which would close the prompt by
203
+ // shrinking the snapshot rather than by tightening the ceiling.
204
+ for (const rule of CEILING_ONLY) {
205
+ if (!rule.matches(rule.witness)) {
206
+ throw new Error(`checkpoint/coverage.ts: ceiling-only rule does not match its own witness \`${rule.witness}\``);
207
+ }
208
+ if (captureExclusion(rule.witness) !== null) {
209
+ throw new Error(`checkpoint/coverage.ts: \`${rule.witness}\` is already outside the capture set, so this ` +
210
+ `rule is not ceiling-only — an exclusion the capturer honors belongs in NEVER_CAPTURED_DIRS, ` +
211
+ `and moving it there would stop the snapshot taking a copy the repo may well track`);
212
+ }
213
+ }
@@ -94,7 +94,11 @@ export const COMMAND_CATALOG = [
94
94
  args: "[<n> | off]",
95
95
  },
96
96
  { name: "/jobs", summary: "list background jobs and their status" },
97
- { name: "/logs", summary: "show a background job's log", args: "<id>" },
97
+ {
98
+ name: "/logs",
99
+ summary: "show a background job's log (falls back to a retained one on disk)",
100
+ args: "<id>",
101
+ },
98
102
  { name: "/cancel", summary: "cancel a background job", args: "<id>" },
99
103
  {
100
104
  name: "/add-root",
@@ -2,7 +2,7 @@ import { Command } from "commander";
2
2
  import { configKeyUnknown, shouldUseColor } from "../../errors/index.js";
3
3
  import { themeForColor } from "../../theme/index.js";
4
4
  import { logger } from "../../utils/logger.js";
5
- import { loadConfig, getPath, setValue, initConfig, globalConfigPath, findProjectConfig, } from "../../config/index.js";
5
+ import { cliConfigPathInEffect, loadConfig, getPath, setValue, initConfig, globalConfigPath, findProjectConfig, } from "../../config/index.js";
6
6
  export function configCommand() {
7
7
  const cmd = new Command("config").description("manage cruxy configuration");
8
8
  cmd
@@ -44,8 +44,23 @@ export function configCommand() {
44
44
  .action(() => {
45
45
  const t = themeForColor(shouldUseColor(process.stdout));
46
46
  const project = findProjectConfig();
47
- logger.print(`${t.strong("global:")} ${globalConfigPath()}`);
48
- logger.print(`${t.strong("project:")} ${project ?? t.muted("(none found)")}`);
47
+ // Read the flag rather than calling `loadConfig`, deliberately: this is
48
+ // the command you run WHEN the config is broken, so it must stay total —
49
+ // loading here would make `cruxy config path` fail for exactly the
50
+ // invalid or unreadable file the user is trying to locate.
51
+ const explicit = cliConfigPathInEffect();
52
+ logger.print(`${t.strong("global:")} ${globalConfigPath()}`);
53
+ logger.print(`${t.strong("project:")} ${project === null
54
+ ? t.muted("(none found)")
55
+ : explicit === null
56
+ ? project
57
+ : // Discovery still FOUND it; the explicit file replaced it. Saying
58
+ // so beats hiding the line, which would read as "no project
59
+ // config exists" and send someone hunting for a file they have.
60
+ `${project} ${t.muted("(superseded by --config)")}`}`);
61
+ if (explicit !== null) {
62
+ logger.print(`${t.strong("explicit:")} ${explicit}`);
63
+ }
49
64
  });
50
65
  cmd
51
66
  .command("init")
@@ -0,0 +1,76 @@
1
+ import { Command } from "commander";
2
+ import { LimitsClient } from "@cruxy/sdk";
3
+ import { loadConfig, readCredentialMeta, resolveApiKey, } from "../../config/index.js";
4
+ import { shouldUseColor } from "../../errors/index.js";
5
+ import { LimitsCache } from "../../limits/index.js";
6
+ import { limitsReportLines } from "../../render/limits-report.js";
7
+ import { themeForColor } from "../../theme/index.js";
8
+ import { logger } from "../../utils/logger.js";
9
+ /**
10
+ * `cruxy limits` (cli#138) — what this account may spend, and how much is left,
11
+ * from outside a session.
12
+ *
13
+ * THE GAP THIS CLOSES IS THE SESSION, not the rendering. Both existing surfaces
14
+ * read the same cache and neither can run without one: the P9 panel is drawn by
15
+ * the interactive rail, and `/budget` is a slash command. "How much of my pool
16
+ * is left" was therefore unanswerable from a script, a prompt, or a CI step —
17
+ * for a figure that is about the ACCOUNT and has nothing to do with whether a
18
+ * session happens to be open.
19
+ *
20
+ * IT IS NOT A FLAG ON `cruxy usage`, and the reason is not tidiness. That
21
+ * command reads the local store and transmits nothing, a promise held by a
22
+ * runtime fetch spy and a static source guard. The number this one wants is on
23
+ * the server, so putting it there would mean deleting that guarantee to gain a
24
+ * figure that has its own home — and joining the two numerators would produce a
25
+ * ratio that reads as precise and is not (`render/limits-report.ts` carries the
26
+ * arithmetic). Two commands, two sources, and the boundary is legible from the
27
+ * name.
28
+ *
29
+ * Note for anyone extending this: the no-phone-home guard lists the files it
30
+ * covers by hand, and this one is deliberately absent from that list. That is
31
+ * correct — this command is supposed to make the call — but it does mean a green
32
+ * guard says nothing at all about this file. The boundary is the command split.
33
+ *
34
+ * THE READING COMES THROUGH `LimitsCache`, not through a bare client call, so
35
+ * this command and the rail reduce the same response the same way. The cache's
36
+ * other job — keeping a good reading through a failed refresh — is inert in a
37
+ * one-shot process with nothing prior to keep, which is exactly why the tri-state
38
+ * matters here: with no earlier answer to stand on, a failed probe IS the
39
+ * result, and the report says which failure it was.
40
+ */
41
+ export function limitsCommand() {
42
+ return new Command("limits")
43
+ .description("show your plan's weighted-token pool and what is left of it")
44
+ .action(async () => {
45
+ const t = themeForColor(shouldUseColor(process.stdout));
46
+ const { config } = loadConfig();
47
+ const provider = config.model.provider;
48
+ // ONLY ON THE CRUXY PROVIDER — now guaranteed by the schema rather than
49
+ // re-checked here. `/limits` is a cruxy gateway endpoint, and pointing it
50
+ // at someone else's base URL would be sending their credential to a
51
+ // stranger to ask a question their API was never going to answer. This
52
+ // used to be a runtime guard on `provider !== "cruxy"`; since #260 that
53
+ // is the only value `ProviderSchema` accepts, so a non-cruxy provider is
54
+ // refused at CONFIG LOAD (CRUXY_E_CONFIG_INVALID) — before this or any
55
+ // other command runs. The guarantee got earlier and wider, not weaker.
56
+ //
57
+ // NOTE what neither form covers: `cruxy.gatewayUrl` is configurable, so a
58
+ // cruxy-provider session can still be aimed at a non-cruxy host. That was
59
+ // true of the old guard too — it keyed on the provider id, not the URL.
60
+ const apiKey = resolveApiKey(provider);
61
+ // The stored expiry, read lazily and only while classifying a 401 — it is
62
+ // what lets an aged-out sign-in be reported as expired rather than as a
63
+ // key that was never any good. The gateway refuses both identically and
64
+ // will not say which.
65
+ const cache = new LimitsCache(apiKey === undefined
66
+ ? undefined
67
+ : (signal) => new LimitsClient({
68
+ apiKey,
69
+ gatewayUrl: config.cruxy.gatewayUrl,
70
+ }).read(signal), { credentialExpiresAt: () => readCredentialMeta(provider)?.expiresAt });
71
+ await cache.refresh();
72
+ for (const line of limitsReportLines(t, cache.current())) {
73
+ logger.print(line);
74
+ }
75
+ });
76
+ }
@@ -0,0 +1,149 @@
1
+ import { Command } from "commander";
2
+ import { shouldUseColor, usageError } from "../../errors/index.js";
3
+ import { INTERRUPTED, jobLogFilesByRecency, matchJobLogs, readJobLog, readJobLogTerminal, } from "../../jobs/log-store.js";
4
+ import { relativeAge, shortId } from "../../session/index.js";
5
+ import { themeForColor } from "../../theme/index.js";
6
+ import { formatBytes } from "../../utils/disk.js";
7
+ import { logger } from "../../utils/logger.js";
8
+ /**
9
+ * `cruxy logs` (#172 item 1) — read back what a background job said.
10
+ *
11
+ * ## Why this had to be a command and not just a nicer `/logs`
12
+ *
13
+ * Both existing readers — the in-session `/logs <id>` and the Tasks view — go
14
+ * through `JobManager.logs()` to the in-memory ring buffer, so both need a live
15
+ * `JobManager` holding the live job map. `requireJob` throws
16
+ * `CRUXY_E_JOB_NOT_FOUND` on anything else, which means neither surface can so
17
+ * much as NAME a job from a session that has ended. Persisting the log without
18
+ * a reader that works out of process would have been a writer talking to
19
+ * itself: the entire case this exists for is the session that is gone.
20
+ *
21
+ * ## It is a post-mortem. It is not a job that survived
22
+ *
23
+ * Say this plainly wherever a user can see it, because the alternative reading
24
+ * is the first bug someone files. A background job is an in-process agent loop;
25
+ * `run.ts` calls `cancelAll("session exit")` on the way out and `orphan.test.ts`
26
+ * pins that this kill-tree's the whole process group. Nothing resumes, nothing
27
+ * reattaches, and no job is running behind this command. What survived is the
28
+ * transcript of what the job said before it stopped.
29
+ *
30
+ * Shaped after `cruxy sessions`, and not by coincidence: same tree, same
31
+ * per-project scoping via `projectDir`, same cheap `readdir`+`stat` scan that
32
+ * bounds work before any file is opened, and the same id-prefix matcher, so an
33
+ * id that names a thing in one command names it in the other.
34
+ */
35
+ export function logsCommand() {
36
+ const cmd = new Command("logs")
37
+ .argument("[id]", "job id, or an unambiguous prefix")
38
+ // The one-line description is bounded to 80 chars by the U.8 voice rule, so
39
+ // the caveat that actually matters lives in the help body below, where it
40
+ // has room to be unambiguous rather than merely short.
41
+ .description("read a finished background job's log — a post-mortem")
42
+ .addHelpText("after", "\nA background job never outlives the session that dispatched it: leaving a\n" +
43
+ "session cancels every live job and kills its process tree. These logs are\n" +
44
+ "what a job SAID before it stopped — nothing here is running, and nothing\n" +
45
+ "resumes. Failed and interrupted runs are kept; a job that finished cleanly\n" +
46
+ "has its log swept at the next start.\n")
47
+ .option("--all", "list every retained log, not just the newest 20")
48
+ .action((id, opts) => {
49
+ const t = themeForColor(shouldUseColor(process.stdout));
50
+ const cwd = process.cwd();
51
+ const refs = jobLogFilesByRecency(cwd);
52
+ if (id === undefined) {
53
+ listLogs(refs, Boolean(opts.all), t);
54
+ return;
55
+ }
56
+ const matches = matchJobLogs(refs, id);
57
+ if (matches.length > 1) {
58
+ throw usageError(`\`${id}\` matches more than one job log`, [
59
+ `did you mean one of: ${matches.map((m) => m.jobKey).join(", ")}?`,
60
+ ]);
61
+ }
62
+ if (matches.length === 0) {
63
+ throw usageError(`no retained log for job \`${id}\` in this project`, [
64
+ "run `cruxy logs` to see what is here",
65
+ "logs are per-directory; check you are in the right one",
66
+ "a job that finished cleanly has its log swept — only failed and interrupted runs are kept",
67
+ ]);
68
+ }
69
+ const log = readJobLog(matches[0].file);
70
+ if (log === null) {
71
+ throw usageError(`\`${matches[0].file}\` is not a readable job log`, [
72
+ "it may have been truncated before its first line was written",
73
+ ]);
74
+ }
75
+ printLog(log, t);
76
+ });
77
+ return cmd;
78
+ }
79
+ /** The listing: one row per retained log, newest first. */
80
+ function listLogs(refs, all, t) {
81
+ if (refs.length === 0) {
82
+ logger.print(t.muted("no retained background-job logs for this project"));
83
+ printPolicy(t);
84
+ return;
85
+ }
86
+ // Bounded to 20 unless asked, matching `cruxy sessions`. The per-row cost
87
+ // here is a TAIL read and not a full one — the listing shows a status, not a
88
+ // transcript, and `readJobLogTerminal` is the whole of what that needs.
89
+ const shown = all ? refs : refs.slice(0, 20);
90
+ const total = refs.reduce((n, r) => n + r.size, 0);
91
+ for (const ref of shown) {
92
+ const status = readJobLogTerminal(ref.file);
93
+ logger.print(`${t.accent(ref.jobKey.padEnd(16))} ${t.muted(shortId(ref.sessionKey).padEnd(8))} ` +
94
+ // PAD FIRST, COLOUR SECOND. `padEnd` counts the escape bytes a themed
95
+ // string carries, so colouring before padding makes every coloured
96
+ // column a different width from the plain ones beside it.
97
+ `${statusText(status.padEnd(11), t)} ` +
98
+ `${t.muted(relativeAge(new Date(ref.mtimeMs).toISOString()).padEnd(9))} ` +
99
+ `${t.muted(formatBytes(ref.size).padStart(9))}`);
100
+ }
101
+ if (shown.length < refs.length) {
102
+ logger.print(t.muted(`\n… and ${refs.length - shown.length} more — pass \`--all\` to list every one`));
103
+ }
104
+ logger.print(t.muted(`\n${refs.length} log${refs.length === 1 ? "" : "s"}, ${formatBytes(total)}`));
105
+ printPolicy(t);
106
+ }
107
+ /** One log, in full. */
108
+ function printLog(log, t) {
109
+ logger.print(`${t.strong(log.jobId)} ${statusText(log.status, t)} ${t.muted(log.label)}`);
110
+ logger.print(t.muted(`session ${shortId(log.sessionId)} · started ${relativeAge(log.startedAt)}` +
111
+ (log.endedAt !== undefined
112
+ ? ` · ended ${relativeAge(log.endedAt)}`
113
+ : "") +
114
+ (log.iterations !== undefined ? ` · ${log.iterations} turn(s)` : "")));
115
+ if (log.error !== undefined)
116
+ logger.print(t.danger(log.error));
117
+ logger.print("");
118
+ for (const line of log.lines) {
119
+ logger.print(line.stream === "err" ? t.danger(line.text) : line.text);
120
+ }
121
+ // The truncation notice belongs AFTER the lines, because that is where the
122
+ // truncation happened. Note what is NOT printed here: `/logs`'s "… N earlier
123
+ // line(s) rolled off". That sentence is true of the ring buffer and false of
124
+ // this file — streaming means everything up to the cap is present — and
125
+ // `PersistedJobLog` has no `dropped` field precisely so it cannot be said.
126
+ if (log.truncatedAt !== undefined) {
127
+ logger.print(t.muted(`\n… log capped at ${log.truncatedAt} lines (\`jobs.logFileLines\`) — later output was not recorded`));
128
+ }
129
+ if (log.status === INTERRUPTED) {
130
+ logger.print(t.muted("\n(interrupted — this log has no terminal record, so the process was " +
131
+ "killed while the job was still running; the job did not survive it)"));
132
+ }
133
+ }
134
+ /**
135
+ * A status, coloured by whether anyone needs to look at it. Accepts an
136
+ * already-padded string: see the call site for why the padding cannot come
137
+ * after the colour.
138
+ */
139
+ function statusText(status, t) {
140
+ const bare = status.trim();
141
+ if (bare === "failed" || bare === INTERRUPTED)
142
+ return t.danger(status);
143
+ return t.accent(status);
144
+ }
145
+ /** What this directory keeps, so a listing explains itself. */
146
+ function printPolicy(t) {
147
+ logger.print(t.muted("keeping failed and interrupted runs; a job that finished cleanly has its log swept at the next start"));
148
+ logger.print(t.muted("these are post-mortems — a background job never outlives the session that dispatched it"));
149
+ }
@@ -1,10 +1,10 @@
1
1
  import { Command } from "commander";
2
2
  import { createProvider } from "@cruxy/sdk";
3
3
  import { logger } from "../../utils/logger.js";
4
- import { loadConfig, resolveApiKey } from "../../config/index.js";
4
+ import { apiKeyEnvVar, loadConfig, resolveApiKey } from "../../config/index.js";
5
5
  import { authMissingKey, shouldUseColor } from "../../errors/index.js";
6
6
  import { themeForColor } from "../../theme/index.js";
7
- import { ApprovalService } from "../../approval/index.js";
7
+ import { ApprovalService, defaultPromptIO, InteractivePolicy, SessionAllowlist, } from "../../approval/index.js";
8
8
  import { resolveTaskModel, routerForConfig } from "../../routing/index.js";
9
9
  import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidance, resolveForgeToken, } from "../../vcs/index.js";
10
10
  /**
@@ -48,9 +48,18 @@ export function prCommand() {
48
48
  // resolveForgeToken throws CRUXY_E_FORGE_AUTH (exit 4) if none is found.
49
49
  const forge = createForgeProvider(resolveForgeToken());
50
50
  const guidance = await loadCommitGuidance(cwd);
51
+ // The allowlist here is provably inert, and stated rather than implied
52
+ // (cli#251). `cruxy pr` submits exactly one kind of action — `vcs` — and
53
+ // `vcsRequest` gives every one of them `scope: {kind: "none"}` and an
54
+ // unconditional `irreversible`. So it is never WRITTEN (`grant()` returns
55
+ // early on a `none` scope) and never READ (`allows()` refuses an
56
+ // irreversible request before it matches scopes at all, since cli#240).
57
+ // A fresh one each time is therefore the whole truth about this command:
58
+ // every PR is approved on its own, by design.
51
59
  const approval = new ApprovalService({
52
60
  cwd,
53
61
  interactive: Boolean(process.stdin.isTTY),
62
+ policy: new InteractivePolicy(new SessionAllowlist(), defaultPromptIO(shouldUseColor())),
54
63
  });
55
64
  const service = createPrService({
56
65
  cwd,
@@ -83,12 +92,3 @@ export function prCommand() {
83
92
  logger.print(t.accent(outcome.url));
84
93
  });
85
94
  }
86
- /** Environment variable that holds the API key for a provider. */
87
- function apiKeyEnvVar(provider) {
88
- switch (provider) {
89
- case "openai":
90
- return "OPENAI_API_KEY";
91
- default:
92
- return "CRUXY_API_KEY";
93
- }
94
- }
@@ -3,7 +3,7 @@ import { Command } from "commander";
3
3
  import { themeForColor } from "../../theme/index.js";
4
4
  import { loadConfig } from "../../config/index.js";
5
5
  import { CheckpointService, listSets, rollbackCheckpoint, rollbackSet, } from "../../checkpoint/index.js";
6
- import { ApprovalService, defaultPromptIO } from "../../approval/index.js";
6
+ import { ApprovalService, defaultPromptIO, InteractivePolicy, SessionAllowlist, } from "../../approval/index.js";
7
7
  import { fuzzyFind, selectList } from "../../components/index.js";
8
8
  import { rollbackApprovalRequired, shouldUseColor, } from "../../errors/index.js";
9
9
  import { logger } from "../../utils/logger.js";
@@ -92,10 +92,18 @@ export function rollbackCommand() {
92
92
  const t = themeForColor(shouldUseColor(process.stdout));
93
93
  const { config } = loadConfig();
94
94
  const primaryRoot = process.cwd();
95
+ // Provably inert, and stated rather than implied (cli#251). `cruxy
96
+ // rollback` submits exactly one kind of action — `rollback` — and
97
+ // `rollbackRequest` gives every one of them `scope: {kind: "none"}` and an
98
+ // unconditional `irreversible` (`ROLLBACK_IRREVERSIBLE`). So this
99
+ // allowlist is never WRITTEN (`grant()` returns early on a `none` scope)
100
+ // and never READ (`allows()` refuses an irreversible request before it
101
+ // matches scopes at all, since cli#240). Every restore is a deliberate,
102
+ // one-off approval — which is the property, not an oversight.
95
103
  const approval = new ApprovalService({
96
104
  cwd: primaryRoot,
97
105
  interactive,
98
- io: defaultPromptIO(shouldUseColor()),
106
+ policy: new InteractivePolicy(new SessionAllowlist(), defaultPromptIO(shouldUseColor())),
99
107
  });
100
108
  const deps = {
101
109
  config,
@@ -5,8 +5,14 @@ import { LimitsCache } from "../../limits/index.js";
5
5
  import { logger } from "../../utils/logger.js";
6
6
  import { SessionLog, listSessions, loadResume, resolveSessionId, resumePicker, shortId, } from "../../session/index.js";
7
7
  /** Sessions shown in the TUI sidebar — the same depth as the resume picker. */
8
- const SIDEBAR_SESSIONS = 10;
9
- import { classifyCredentialLifetime, globalDir, loadConfig, readCredentialMeta, resolveApiKey, } from "../../config/index.js";
8
+ /**
9
+ * How many sessions the TUI sidebar lists. Exported so
10
+ * `session/retention-floor.test.ts` can pin `SESSION_RETENTION_FLOOR` against
11
+ * it — raising this surface without raising the floor would let the sidebar
12
+ * offer rows a later prune has already marked expendable (#257).
13
+ */
14
+ export const SIDEBAR_SESSIONS = 10;
15
+ import { apiKeyEnvVar, classifyCredentialLifetime, configSourceFile, globalDir, loadConfig, readCredentialMeta, resolveApiKey, } from "../../config/index.js";
10
16
  import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
11
17
  import { createRenderer } from "../../render/index.js";
12
18
  import { themeForColor } from "../../theme/index.js";
@@ -18,7 +24,7 @@ import { DEFAULT_MODE, } from "../../agent/index.js";
18
24
  import { runInteractive } from "../repl.js";
19
25
  import { ContextGauge, createKeyLease, createGitView, createOverviewView, createPermissionsView, createSettingsView, createTasksView, runTui, TuiRenderer, WorkspaceDiskCache, WorkspaceGitCache, } from "../../tui/index.js";
20
26
  import { buildAgentSession } from "../session-factory.js";
21
- import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
27
+ import { maybeRunOnboarding } from "../onboard.js";
22
28
  import { resetLspServices } from "../../lsp/index.js";
23
29
  import { connectMcpTools, deferredSiblingServers, resetMcpServices, } from "../../mcp/index.js";
24
30
  import { defaultPromptIO } from "../../approval/index.js";
@@ -129,7 +135,10 @@ export async function executeRun(promptParts, opts) {
129
135
  const { config, sources } = loaded;
130
136
  let apiKey = resolveApiKey(config.model.provider);
131
137
  logger.info(t.muted(`model: ${config.model.provider}/${config.model.model}`));
132
- logger.info(t.muted(`config: ${sources.project ?? sources.global ?? "defaults"}`));
138
+ // `configSourceFile`, not `project ?? global`: with `--config` in play there
139
+ // is no project layer, so the old fallback printed the GLOBAL path — or
140
+ // "defaults" — for a file it had just loaded (#289).
141
+ logger.info(t.muted(`config: ${configSourceFile(sources) ?? "defaults"}`));
133
142
  // Multi-root honesty (JC-6/C.26): reads fan every root. With per-root
134
143
  // checkpoints active (C.26 step 3), writes fan every root too — each is
135
144
  // checkpointed and rollback-able. With checkpoints DISABLED, a non-primary
@@ -340,11 +349,21 @@ export async function executeRun(promptParts, opts) {
340
349
  model: config.model.model,
341
350
  logger,
342
351
  currentRunId: () => checkpoints?.currentRunId(),
352
+ // Persistence and retention (#257). `enabled: false` makes this null and
353
+ // the session runs in memory; retention is enforced once, here, as the
354
+ // log opens — the same position `CheckpointService` prunes from.
355
+ sessions: config.sessions,
343
356
  ...(resumed ? { file: resumed.session.file } : {}),
344
357
  }) ?? undefined;
345
358
  // The TUI sidebar lists the same tree the `--resume` picker reads (P2).
346
- // Populated AFTER the log is opened so the running session is itself listed —
347
- // its meta line has to exist before `listSessions` can see it.
359
+ //
360
+ // A FRESH SESSION IS NOT IN ITS OWN SIDEBAR UNTIL TURN 1 (#257). This used to
361
+ // be populated after the log was opened precisely so the running session
362
+ // listed itself; the meta line is buffered now, so there is nothing on disk
363
+ // to list until something is said. That is the more honest reading — the
364
+ // sidebar and the `--resume` picker show the same tree, and a session with no
365
+ // conversation in it is not one the picker would offer either. The active id
366
+ // is still passed, so the row marks itself the moment it appears.
348
367
  if (renderer instanceof TuiRenderer) {
349
368
  renderer.setSessions(listSessions(primaryRoot, SIDEBAR_SESSIONS), sessionId);
350
369
  }