@cruxy/cli 1.8.1 → 1.10.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 (47) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/loop.js +16 -1
  3. package/dist/agent/session.js +62 -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/commands/limits.js +76 -0
  10. package/dist/cli/commands/login.js +18 -5
  11. package/dist/cli/commands/pr.js +10 -1
  12. package/dist/cli/commands/rollback.js +10 -2
  13. package/dist/cli/commands/run.js +55 -5
  14. package/dist/cli/commands/sessions.js +156 -0
  15. package/dist/cli/program.js +4 -0
  16. package/dist/cli/repl.js +25 -0
  17. package/dist/cli/session-factory.js +31 -10
  18. package/dist/config/credential-lifetime.js +42 -0
  19. package/dist/config/credentials.js +66 -0
  20. package/dist/config/schema.js +141 -9
  21. package/dist/constants.js +12 -2
  22. package/dist/errors/boundary.js +4 -4
  23. package/dist/errors/constructors.js +136 -57
  24. package/dist/errors/types.js +18 -0
  25. package/dist/index.js +27 -1
  26. package/dist/jobs/manager.js +269 -17
  27. package/dist/limits/cache.js +21 -5
  28. package/dist/mcp/client.js +16 -0
  29. package/dist/onboarding/flow.js +121 -6
  30. package/dist/onboarding/steps.js +112 -0
  31. package/dist/render/limits-report.js +213 -0
  32. package/dist/render/limits-view.js +125 -0
  33. package/dist/sandbox/service.js +9 -0
  34. package/dist/sandbox/types.js +15 -0
  35. package/dist/session/index.js +3 -1
  36. package/dist/session/list.js +20 -6
  37. package/dist/session/log.js +120 -21
  38. package/dist/session/prune.js +106 -0
  39. package/dist/session/resume.js +5 -0
  40. package/dist/subagent/orchestrator.js +71 -31
  41. package/dist/subagent/spawn-tool.js +11 -4
  42. package/dist/tools/schema-depth.js +18 -0
  43. package/dist/tui/limits-panel.js +62 -30
  44. package/dist/usage/collect.js +20 -1
  45. package/dist/usage/summary.js +48 -1
  46. package/dist/usage/types.js +27 -0
  47. 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
+ }
@@ -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
+ }
@@ -5,15 +5,27 @@ import { logger } from "../../utils/logger.js";
5
5
  import { loadConfig } from "../../config/index.js";
6
6
  import { createDefaultDeps, defaultOnboardingIO, runOnboarding, } from "../../onboarding/index.js";
7
7
  /**
8
- * `cruxy login` — set or replace the API key on demand (U.6). Runs the key step
9
- * of onboarding (always, even if a key already resolves — this is how you
10
- * re-key / switch), validates it live, and persists it to the credentials store.
8
+ * `cruxy login` — sign in and persist a credential on demand (U.6). Runs the key
9
+ * step of onboarding (always, even if a key already resolves — this is how you
10
+ * re-key / switch) and persists the result to the credentials store.
11
11
  * Non-interactive invocations fail loud rather than hang.
12
+ *
13
+ * THE DEFAULT IS THE DEVICE FLOW: approve in a browser, and the gateway mints a
14
+ * subscription-bucket credential that draws on the plan pool the same human's
15
+ * web chat and desktop already use. A pasted key cannot be one — the admin
16
+ * issue-key route it comes from refuses to produce that kind — so the paste path
17
+ * is kept as a fallback rather than the front door.
18
+ *
19
+ * `--paste` keeps that fallback reachable, because the device flow cannot serve
20
+ * every case: an air-gapped machine has no browser to approve in, and someone
21
+ * deliberately using a long-lived admin key does not want a 90-day device
22
+ * credential written over it.
12
23
  */
13
24
  export function loginCommand() {
14
25
  return new Command("login")
15
- .description("set or replace your API key (validated, saved to ~/.cruxy)")
16
- .action(async () => {
26
+ .description("set or replace your cruxy credential (saved to ~/.cruxy)")
27
+ .option("--paste", "paste an existing API key instead of approving in a browser")
28
+ .action(async (opts) => {
17
29
  const t = themeForColor(shouldUseColor(process.stdout));
18
30
  if (!process.stdin.isTTY) {
19
31
  logger.print(t.muted("cruxy login is interactive — run it in a terminal, or export your key as an environment variable."));
@@ -27,6 +39,7 @@ export function loginCommand() {
27
39
  forceKey: true,
28
40
  offerFirstWin: false,
29
41
  offerScaffold: false,
42
+ preferPaste: opts.paste === true,
30
43
  io: defaultOnboardingIO(),
31
44
  deps: createDefaultDeps({ config, cwd: process.cwd() }),
32
45
  });
@@ -4,7 +4,7 @@ import { logger } from "../../utils/logger.js";
4
4
  import { 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,
@@ -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 { globalDir, loadConfig, 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 { classifyCredentialLifetime, 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";
@@ -340,11 +346,21 @@ export async function executeRun(promptParts, opts) {
340
346
  model: config.model.model,
341
347
  logger,
342
348
  currentRunId: () => checkpoints?.currentRunId(),
349
+ // Persistence and retention (#257). `enabled: false` makes this null and
350
+ // the session runs in memory; retention is enforced once, here, as the
351
+ // log opens — the same position `CheckpointService` prunes from.
352
+ sessions: config.sessions,
343
353
  ...(resumed ? { file: resumed.session.file } : {}),
344
354
  }) ?? undefined;
345
355
  // 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.
356
+ //
357
+ // A FRESH SESSION IS NOT IN ITS OWN SIDEBAR UNTIL TURN 1 (#257). This used to
358
+ // be populated after the log was opened precisely so the running session
359
+ // listed itself; the meta line is buffered now, so there is nothing on disk
360
+ // to list until something is said. That is the more honest reading — the
361
+ // sidebar and the `--resume` picker show the same tree, and a session with no
362
+ // conversation in it is not one the picker would offer either. The active id
363
+ // is still passed, so the row marks itself the moment it appears.
348
364
  if (renderer instanceof TuiRenderer) {
349
365
  renderer.setSessions(listSessions(primaryRoot, SIDEBAR_SESSIONS), sessionId);
350
366
  }
@@ -388,13 +404,26 @@ export async function executeRun(promptParts, opts) {
388
404
  // The cache is still attached without a key so the panel can say "not signed
389
405
  // in" — a fact worth stating, and one that costs no request to establish.
390
406
  const limitsKey = config.model.provider === "cruxy" ? apiKey : undefined;
407
+ // The stored expiry of the credential in use, read lazily. It is what lets a
408
+ // 401 be reported as "your sign-in expired" instead of "your key is wrong" —
409
+ // the gateway refuses both identically and will not say which.
410
+ const credentialExpiresAt = () => readCredentialMeta(config.model.provider)?.expiresAt;
391
411
  const limits = new LimitsCache(limitsKey === undefined
392
412
  ? undefined
393
413
  : (signal) => new LimitsClient({
394
414
  apiKey: limitsKey,
395
415
  gatewayUrl: config.cruxy.gatewayUrl,
396
- }).read(signal));
416
+ }).read(signal), { credentialExpiresAt });
397
417
  tui?.attachLimits(limits);
418
+ // Say it BEFORE the session starts, while a re-login is a 30-second detour
419
+ // rather than an interruption. A device credential is minted with a finite
420
+ // lifetime and the gateway echoes it back precisely so a client can do this;
421
+ // the alternative is the user meeting their expiry mid-turn, as a 401.
422
+ //
423
+ // Only when we actually know: an absent or unparseable expiry classifies as
424
+ // `unknown` and says nothing, which is the right answer for every pasted key
425
+ // and every credential stored before expiries were recorded.
426
+ noteCredentialExpiry(credentialExpiresAt());
398
427
  // The SAME cache backs the session's weighted-token budget (P10 track 3), so
399
428
  // the rail's headroom bar and `/budget`'s server line are one reading rather
400
429
  // than two probes that can disagree — and so admission control at the fan-out
@@ -535,3 +564,24 @@ function printRunUsage(record) {
535
564
  const t = themeForColor(shouldUseColor(process.stdout));
536
565
  logger.print(renderSummary(summarizeRuns([record]), t));
537
566
  }
567
+ /**
568
+ * Warn, once per run and before the session starts, that the stored credential
569
+ * is about to expire.
570
+ *
571
+ * ONLY IN THE `expiring` WINDOW. `live` is silent because a warning 80 days out
572
+ * is noise that trains people to ignore the line, and `expired` is silent here
573
+ * because a request is about to fail with a far better message than a preamble
574
+ * could give — `authExpired` names the date and the fix. `unknown` is silent
575
+ * because it is the honest state for every pasted key and every credential
576
+ * stored before expiries were recorded, and nagging those users about a lifetime
577
+ * nobody ever stated is exactly the false alarm this whole distinction exists to
578
+ * avoid.
579
+ */
580
+ function noteCredentialExpiry(expiresAt) {
581
+ const lifetime = classifyCredentialLifetime(expiresAt);
582
+ if (lifetime.state !== "expiring")
583
+ return;
584
+ const t = themeForColor(shouldUseColor(process.stdout));
585
+ const days = Math.max(1, Math.round(lifetime.msRemaining / 86_400_000));
586
+ logger.print(t.warning(`your cruxy sign-in expires in ${days} day${days === 1 ? "" : "s"}`) + t.muted(" — run `cruxy login` to renew it"));
587
+ }
@@ -0,0 +1,156 @@
1
+ import { unlinkSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { Command } from "commander";
4
+ import { loadConfig } from "../../config/index.js";
5
+ import { shouldUseColor, usageError } from "../../errors/index.js";
6
+ import { SESSION_FILE_EXT, matchSessionRefs, listSessionRefs, pruneSessions, sessionFilesByRecency, shortId, summarizeSession, relativeAge, } from "../../session/index.js";
7
+ import { themeForColor } from "../../theme/index.js";
8
+ import { formatBytes } from "../../utils/disk.js";
9
+ import { logger } from "../../utils/logger.js";
10
+ /**
11
+ * `cruxy sessions` (#257) — see and bound what `~/.cruxy/projects/<project>/`
12
+ * is holding.
13
+ *
14
+ * WHY THIS EXISTS AT ALL, when retention already runs on its own. Retention
15
+ * that only ever fires implicitly is retention nobody can audit before it eats
16
+ * something: the first prune after upgrading from a build that had none can
17
+ * remove months of sessions, and "trust me, the right ones went" is not a thing
18
+ * a user can check. `list` shows what is there and which rows the current
19
+ * bounds would take; `prune` runs the same policy on demand; `rm` is the
20
+ * escape hatch for one specific session.
21
+ *
22
+ * BYTES ARE REPORTED, NEVER PRUNED ON. `statSync` hands the size over for free
23
+ * in the same scan that orders by mtime, and seeing where the disk went is
24
+ * exactly what a report is for. Making it a BOUND is the part that would be
25
+ * wrong — whether your session survives should not depend on how chatty an
26
+ * unrelated one was.
27
+ */
28
+ export function sessionsCommand() {
29
+ const cmd = new Command("sessions").description("manage this project's saved sessions — list, prune, delete");
30
+ cmd
31
+ .command("list", { isDefault: true })
32
+ .description("list saved sessions, newest first")
33
+ .option("--all", "list every session, not just the newest 20")
34
+ .action((opts) => {
35
+ const t = themeForColor(shouldUseColor(process.stdout));
36
+ const { config } = loadConfig();
37
+ const cwd = process.cwd();
38
+ const files = sessionFilesByRecency(cwd);
39
+ if (files.length === 0) {
40
+ logger.print(t.muted("no saved sessions for this project"));
41
+ printPolicy(config.sessions, t);
42
+ return;
43
+ }
44
+ // The listing is the one place that pays for a full read per row — it is
45
+ // showing titles and turn counts, which is the cost `summarizeSession`
46
+ // exists to charge. Bounded to 20 unless asked, for the same reason the
47
+ // picker is bounded to 10.
48
+ const shown = opts.all ? files : files.slice(0, 20);
49
+ const total = files.reduce((n, f) => n + f.size, 0);
50
+ for (const ref of shown) {
51
+ const summary = summarizeSession(ref.file);
52
+ const id = summary
53
+ ? shortId(summary.sessionId)
54
+ : shortId(path.basename(ref.file, SESSION_FILE_EXT));
55
+ // A file with no readable meta is SHOWN, not hidden. It is occupying
56
+ // disk and it is a thing prune will consider; a report that silently
57
+ // omitted it would be lying about what is there.
58
+ const title = summary ? summary.title : t.muted("(unreadable)");
59
+ const turns = summary
60
+ ? `${summary.turns} turn${summary.turns === 1 ? "" : "s"}`
61
+ : "—";
62
+ logger.print(`${t.accent(id.padEnd(8))} ${t.muted(relativeAge(new Date(ref.mtimeMs).toISOString()).padEnd(9))} ` +
63
+ `${t.muted(formatBytes(ref.size).padStart(9))} ${t.muted(turns.padEnd(8))} ${title}`);
64
+ }
65
+ if (shown.length < files.length) {
66
+ logger.print(t.muted(`\n… and ${files.length - shown.length} more — pass \`--all\` to list every one`));
67
+ }
68
+ logger.print(t.muted(`\n${files.length} session${files.length === 1 ? "" : "s"}, ${formatBytes(total)}`));
69
+ printPolicy(config.sessions, t);
70
+ });
71
+ cmd
72
+ .command("prune")
73
+ .description("clear sessions past sessions.retention / sessions.maxAgeDays")
74
+ .option("-n, --dry-run", "show what would be deleted, delete nothing")
75
+ .action((opts) => {
76
+ const t = themeForColor(shouldUseColor(process.stdout));
77
+ const { config } = loadConfig();
78
+ const cwd = process.cwd();
79
+ if (!config.sessions.enabled) {
80
+ logger.print(t.muted("session persistence is off (`sessions.enabled = false`) — nothing is being recorded to prune"));
81
+ return;
82
+ }
83
+ if (opts.dryRun) {
84
+ // The dry run must not go anywhere near `unlinkSync`, so it re-derives
85
+ // the doomed set from the SAME ordering and the same two bounds rather
86
+ // than sharing a code path with a delete in it behind a flag.
87
+ const now = Date.now();
88
+ const cutoff = now - config.sessions.maxAgeDays * 24 * 60 * 60 * 1000;
89
+ const doomed = sessionFilesByRecency(cwd).filter((ref, i) => ref.mtimeMs < cutoff || i >= config.sessions.retention);
90
+ if (doomed.length === 0) {
91
+ logger.print(t.muted("nothing to prune"));
92
+ return;
93
+ }
94
+ for (const ref of doomed) {
95
+ logger.print(`${t.muted("would delete")} ${shortId(path.basename(ref.file, SESSION_FILE_EXT))} ` +
96
+ `${t.muted(relativeAge(new Date(ref.mtimeMs).toISOString()))} ${t.muted(formatBytes(ref.size))}`);
97
+ }
98
+ logger.print(t.muted(`\n${doomed.length} session${doomed.length === 1 ? "" : "s"}, ` +
99
+ `${formatBytes(doomed.reduce((n, f) => n + f.size, 0))} — run without \`--dry-run\` to delete`));
100
+ return;
101
+ }
102
+ // No `activeSessionId`: there is no session open in this process. That is
103
+ // the whole difference between running prune from here and running it
104
+ // from `SessionLog.open`.
105
+ const result = pruneSessions(cwd, { sessions: config.sessions });
106
+ if (result.removed.length === 0) {
107
+ logger.print(t.muted("nothing to prune"));
108
+ }
109
+ else {
110
+ logger.print(`${t.success("pruned")} ${result.removed.length} session${result.removed.length === 1 ? "" : "s"} ` +
111
+ `(${formatBytes(result.bytesFreed)}), ${result.kept} kept`);
112
+ }
113
+ if (result.failed > 0) {
114
+ logger.warn(`${result.failed} session file${result.failed === 1 ? "" : "s"} could not be deleted`);
115
+ }
116
+ });
117
+ cmd
118
+ .command("rm <id>")
119
+ .description("forget one session by id (or unambiguous id prefix)")
120
+ .action((id) => {
121
+ const t = themeForColor(shouldUseColor(process.stdout));
122
+ const cwd = process.cwd();
123
+ // Resolved through the SAME matcher `--resume` uses, so the id that names
124
+ // a session to resume is the id that names it to delete. An ambiguous
125
+ // prefix is refused rather than resolved to "the newest" — this is the
126
+ // one command here that cannot be undone.
127
+ const matches = matchSessionRefs(listSessionRefs(cwd), id);
128
+ if (matches.length > 1) {
129
+ throw usageError(`\`${id}\` matches more than one session`, [
130
+ `did you mean one of: ${matches.map((m) => shortId(m.sessionId)).join(", ")}?`,
131
+ ]);
132
+ }
133
+ if (matches.length === 0) {
134
+ throw usageError(`no session \`${id}\` in this project`, [
135
+ "run `cruxy sessions` to see what is here",
136
+ "sessions are per-directory; check you are in the right one",
137
+ ]);
138
+ }
139
+ try {
140
+ unlinkSync(matches[0].file);
141
+ }
142
+ catch (err) {
143
+ throw usageError(`could not delete session ${shortId(matches[0].sessionId)}`, [`${matches[0].file}: ${err.message}`]);
144
+ }
145
+ logger.print(`${t.success("deleted")} session ${shortId(matches[0].sessionId)}`);
146
+ });
147
+ return cmd;
148
+ }
149
+ /** The bounds in force, so a listing explains itself without a config read. */
150
+ function printPolicy(sessions, t) {
151
+ if (!sessions.enabled) {
152
+ logger.print(t.muted("persistence is off (`sessions.enabled = false`) — nothing new is recorded"));
153
+ return;
154
+ }
155
+ logger.print(t.muted(`keeping the newest ${sessions.retention}, and anything touched in the last ${sessions.maxAgeDays} days`));
156
+ }
@@ -19,8 +19,10 @@ import { rollbackCommand } from "./commands/rollback.js";
19
19
  import { testCommand } from "./commands/test.js";
20
20
  import { hooksCommand } from "./commands/hooks.js";
21
21
  import { memoryCommand } from "./commands/memory.js";
22
+ import { limitsCommand } from "./commands/limits.js";
22
23
  import { usageCommand } from "./commands/usage.js";
23
24
  import { mcpCommand } from "./commands/mcp.js";
25
+ import { sessionsCommand } from "./commands/sessions.js";
24
26
  import { loadConfig } from "../config/index.js";
25
27
  import { maybeRunOnboarding } from "./onboard.js";
26
28
  export function buildProgram() {
@@ -54,7 +56,9 @@ export function buildProgram() {
54
56
  program.addCommand(hooksCommand());
55
57
  program.addCommand(memoryCommand());
56
58
  program.addCommand(usageCommand());
59
+ program.addCommand(limitsCommand());
57
60
  program.addCommand(mcpCommand());
61
+ program.addCommand(sessionsCommand());
58
62
  // Default action: bare `cruxy` opens the TUI; `cruxy <message>` opens it and
59
63
  // runs that message as the first turn. Operands reach here only when they
60
64
  // matched no subcommand, so a bare token is a MESSAGE by default — only a
package/dist/cli/repl.js CHANGED
@@ -116,6 +116,28 @@ async function drainJobApprovals(session) {
116
116
  logger.print(theme.muted(`\n${theme.glyph.bullet} a background job needs your approval:`));
117
117
  await jobs.serviceApprovals();
118
118
  }
119
+ /**
120
+ * Surface a pool denial a background job died on (cli#245) — the same idle
121
+ * point the approval drain uses, and for the same reason: the foreground owns
122
+ * the terminal here, with no live region painting and no readline interface
123
+ * holding stdin.
124
+ *
125
+ * WHY THE FOREGROUND IS TOLD AT ALL. A 429 is a statement about a denominator
126
+ * the job shares with this session's own next turn, so the job's death is
127
+ * already the answer to a question the user is about to ask. Without this the
128
+ * job fails off-screen — `/jobs` would show it, if the user thought to look —
129
+ * and the next turn walks into its own refusal, having been told nothing.
130
+ * Printed through the shared error formatter so `window`, when it recovers, and
131
+ * whether mira is still open all survive, exactly as they do on the foreground
132
+ * path.
133
+ */
134
+ function drainJobPoolDenial(session) {
135
+ const denial = session.jobs?.takePoolDenial();
136
+ if (!denial)
137
+ return;
138
+ logger.print(theme.muted(`\n${theme.glyph.bullet} a background job stopped:`));
139
+ printCommandError(replOutput, denial);
140
+ }
119
141
  /**
120
142
  * Drive an interactive multi-turn session: prompt, read a line, dispatch slash
121
143
  * commands or run a turn, repeat. Assistant text and tool-call progress stream
@@ -143,6 +165,9 @@ async function replLoop(session, io, renderer, checkpoints, slashCommands = [])
143
165
  // prompt. Done here (foreground idle, no readline interface live) so a job's
144
166
  // prompt never contends with the line reader.
145
167
  await drainJobApprovals(session);
168
+ // Same point, same reason: a job the weighted pool refused (cli#245). The
169
+ // next turn is about to draw on the window that just refused it.
170
+ drainJobPoolDenial(session);
146
171
  const line = await readLine(io, PROMPT);
147
172
  // EOF / Ctrl+D.
148
173
  if (line === null) {