@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
@@ -365,7 +365,8 @@ opts = {}) {
365
365
  const executionSemaphore = new Semaphore(config.subagent.maxConcurrency);
366
366
  // The ONE weighted-token budget for the whole session (P10 track 3 / cli#212).
367
367
  // `/budget` reads and sets it; `Session` narrows each turn's token guard by it;
368
- // the orchestrator refuses to dispatch a fan-out it cannot cover. Three
368
+ // the orchestrator refuses to dispatch a fan-out (or a single spawn) it cannot
369
+ // cover; the job manager refuses a background job it cannot. Four
369
370
  // consumers, one object — a session cap and a fan-out bound that could disagree
370
371
  // would be the failure this exists to prevent. Its server denominator is
371
372
  // attached later (see `attachLimits`), because the limits cache does not exist
@@ -378,6 +379,21 @@ opts = {}) {
378
379
  // Outside `resumeLineAfterApproval`, so the live region is restored
379
380
  // before a preview block is committed into it.
380
381
  previewSilentApprovals(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), renderer), checkpoints, workspace), approvalMutex, cwd);
382
+ // THE LATE-BINDING HANDLE FOR THE SESSION BUILT AT THE END OF THIS FUNCTION.
383
+ //
384
+ // Declared here, above the first construction that needs it, rather than down
385
+ // beside the plan wiring it was written for. Three things now late-bind to the
386
+ // session — the approval policy's live mode read, and (cli#244) the job
387
+ // manager's session id — and the session cannot exist yet because it needs the
388
+ // ctx, the orchestrator and the job manager that are built between here and
389
+ // there.
390
+ //
391
+ // `autoApprove`: the policy has to read the LIVE mode, since the user can
392
+ // leave auto-approve between two actions of a single turn. Before the session
393
+ // exists there is nothing to approve, so the `false` fallback is a closed door
394
+ // rather than a gap.
395
+ const holder = {};
396
+ const autoApprove = () => holder.session?.getAutoApprove() ?? false;
381
397
  // Subagent orchestration (C.14): spawn_subagent goes on the main registry
382
398
  // only when depth allows (maxDepth 0 disables the feature structurally).
383
399
  // Registered before the plan wiring so plan-mode execution steps can
@@ -406,7 +422,6 @@ opts = {}) {
406
422
  makeChildApproval: () => gate(new ApprovalService({
407
423
  cwd,
408
424
  interactive: ttyInteractive,
409
- io,
410
425
  policy: new InteractivePolicy(new SessionAllowlist(), io, autoApprove),
411
426
  })),
412
427
  });
@@ -432,6 +447,20 @@ opts = {}) {
432
447
  approvalMutex,
433
448
  foregroundInteractive: ttyInteractive,
434
449
  promptIO: io,
450
+ // The SAME budget the turn and the fan-out seam ask (cli#245). A job
451
+ // drawing on the weighted pool with no admission check was the last
452
+ // surface running `runAgent` that neither asked it nor moved it.
453
+ budget: sessionBudget,
454
+ // ...and the SAME usage sink the session's turns publish through
455
+ // (cli#244), so a job's spend is counted by `/usage` and not only by
456
+ // `/budget`. A job gets a record of its own because it can outlive the
457
+ // turn that launched it; see the note on `JobManagerDeps.onRunUsage`.
458
+ onRunUsage,
459
+ // Read lazily: the session is constructed below and does not exist yet.
460
+ // Without this the job's record persists with no `sessionId` and is then
461
+ // invisible to in-session `/usage` and to `--session` — on disk, and
462
+ // unreadable by the two surfaces most likely to look for it.
463
+ sessionId: () => holder.session?.sessionId,
435
464
  })
436
465
  : undefined;
437
466
  const jobTool = jobManager ? makeRunInBackgroundTool(jobManager) : undefined;
@@ -465,19 +494,11 @@ opts = {}) {
465
494
  // One allowlist shared by the plan-approval prompt and the per-action gate, so
466
495
  // a grant recorded during execution is honored by U.3's own check.
467
496
  const allowlist = new SessionAllowlist();
468
- // Late-bound to the session constructed below. The policy has to read the LIVE
469
- // mode — the user can leave auto-approve between two actions of a single turn
470
- // — and the session cannot exist yet because it needs the ctx this policy is
471
- // wired into. Before it exists there is nothing to approve, so the `false`
472
- // fallback is a closed door rather than a gap.
473
- const holder = {};
474
- const autoApprove = () => holder.session?.getAutoApprove() ?? false;
475
497
  const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io, autoApprove));
476
498
  const approval = new ApprovalService({
477
499
  cwd,
478
500
  interactive: ttyInteractive,
479
501
  policy: planPolicy,
480
- io,
481
502
  });
482
503
  const ctx = {
483
504
  cwd,
@@ -0,0 +1,42 @@
1
+ /**
2
+ * How close a stored credential is to the end of its life — a pure function of a
3
+ * timestamp and a clock.
4
+ *
5
+ * IT LIVES IN ITS OWN MODULE, AND IMPORTS NOTHING, so that `errors/` can reach
6
+ * it. `config/credentials.ts` imports `errors/` (for the unprotected-store
7
+ * refusal), so an error constructor importing the credentials store back would
8
+ * close a cycle. The rule this module needs to state is not about the store at
9
+ * all — it is arithmetic on one timestamp — so it sits below both and each side
10
+ * imports it directly.
11
+ *
12
+ * ONE CLASSIFIER, because every caller that acts on expiry must reach the same
13
+ * verdict from the same value: the 401 classifier deciding whether to say "your
14
+ * key is wrong" or "your login expired", the pre-session nudge deciding whether
15
+ * to warn, and the limits panel deciding what to render. Three private
16
+ * `Date.parse` comparisons would be three chances to disagree about whether a
17
+ * credential is dead.
18
+ */
19
+ /**
20
+ * How long before expiry a credential is worth mentioning.
21
+ *
22
+ * Seven days against the gateway's 90-day device-key lifetime: long enough that
23
+ * someone who uses cruxy weekly sees the notice at least once before anything
24
+ * breaks, short enough that it is not background noise for the other 83 days.
25
+ */
26
+ export const CREDENTIAL_EXPIRY_WARNING_MS = 7 * 24 * 60 * 60 * 1000;
27
+ export function classifyCredentialLifetime(expiresAt, now = Date.now(), warnWithinMs = CREDENTIAL_EXPIRY_WARNING_MS) {
28
+ if (expiresAt === undefined || expiresAt === "")
29
+ return { state: "unknown" };
30
+ const at = Date.parse(expiresAt);
31
+ // A timestamp this build cannot read is a thing we do not know, not a thing
32
+ // that has happened.
33
+ if (Number.isNaN(at))
34
+ return { state: "unknown" };
35
+ const msRemaining = at - now;
36
+ if (msRemaining <= 0)
37
+ return { state: "expired", expiresAt };
38
+ if (msRemaining <= warnWithinMs) {
39
+ return { state: "expiring", expiresAt, msRemaining };
40
+ }
41
+ return { state: "live", expiresAt, msRemaining };
42
+ }
@@ -3,8 +3,13 @@ import { dirname, join } from "node:path";
3
3
  import { CREDENTIALS_FILE_NAME } from "../constants.js";
4
4
  import { credentialsUnprotected } from "../errors/index.js";
5
5
  import { logger } from "../utils/logger.js";
6
+ import { classifyCredentialLifetime, } from "./credential-lifetime.js";
6
7
  import { enforceOwnerOnly, isOwnerOnly } from "./owner-only.js";
7
8
  import { globalDir } from "./paths.js";
9
+ // Re-exported so `config/index.js` remains the one import site for credential
10
+ // concerns; the classifier itself lives in a leaf module that imports nothing,
11
+ // because `errors/` needs it too and this file imports `errors/`.
12
+ export { classifyCredentialLifetime, CREDENTIAL_EXPIRY_WARNING_MS, } from "./credential-lifetime.js";
8
13
  /**
9
14
  * The credentials store (U.6) — the one place a provider API key is persisted.
10
15
  * It lives **outside** `config.json` on purpose: config is secret-free by design
@@ -89,15 +94,76 @@ export function writeMcpCredential(ref, token, file = credentialsPath()) {
89
94
  store.mcp[ref] = token;
90
95
  }, "mcp");
91
96
  }
97
+ /**
98
+ * What we know about the stored key for `provider`, or `undefined`.
99
+ *
100
+ * Tolerant of a store written by a newer build or corrupted by hand: an entry
101
+ * that is not an object, or whose fields are the wrong type, reads as absent
102
+ * rather than throwing. This backs an expiry warning, and a warning that can
103
+ * crash the CLI is worse than no warning. Never throws.
104
+ */
105
+ export function readCredentialMeta(provider, file = credentialsPath()) {
106
+ const store = readStore(file);
107
+ const raw = store?.keyMeta?.[provider];
108
+ if (raw === null || typeof raw !== "object")
109
+ return undefined;
110
+ const entry = raw;
111
+ const meta = {};
112
+ if (typeof entry.expiresAt === "string" && entry.expiresAt !== "") {
113
+ meta.expiresAt = entry.expiresAt;
114
+ }
115
+ if (typeof entry.keyId === "string" && entry.keyId !== "") {
116
+ meta.keyId = entry.keyId;
117
+ }
118
+ if (entry.source === "device" || entry.source === "paste") {
119
+ meta.source = entry.source;
120
+ }
121
+ return meta;
122
+ }
92
123
  /**
93
124
  * Persist `key` for `provider`, merging into any existing store. Written
94
125
  * owner-only (see {@link writeInto}); refuses loudly if that can't be enforced.
126
+ *
127
+ * A bare write CLEARS any metadata held for that provider, because a write with
128
+ * nothing to say about the key's lifetime is a statement that we do not know it.
129
+ * The alternative — leaving whatever was there — is the one way this store can
130
+ * lie: paste an eternal admin key over a slot a device login used, and the
131
+ * stale 90-day expiry would have us announce that a perfectly good credential
132
+ * had died. Key and metadata therefore only ever move together, through
133
+ * {@link writeCredentialWithMeta}.
95
134
  */
96
135
  export function writeCredential(provider, key, file = credentialsPath()) {
136
+ writeCredentialWithMeta(provider, key, {}, file);
137
+ }
138
+ /** Persist `key` for `provider` together with what is known about it. */
139
+ export function writeCredentialWithMeta(provider, key, meta, file = credentialsPath()) {
97
140
  writeInto(file, (store) => {
98
141
  store.keys[provider] = key;
142
+ const entry = {};
143
+ if (meta.expiresAt)
144
+ entry.expiresAt = meta.expiresAt;
145
+ if (meta.keyId)
146
+ entry.keyId = meta.keyId;
147
+ if (meta.source)
148
+ entry.source = meta.source;
149
+ if (Object.keys(entry).length === 0) {
150
+ // Nothing known. Drop any prior entry rather than keeping a fact about
151
+ // a key that no longer exists in this slot.
152
+ if (store.keyMeta) {
153
+ delete store.keyMeta[provider];
154
+ if (Object.keys(store.keyMeta).length === 0)
155
+ delete store.keyMeta;
156
+ }
157
+ return;
158
+ }
159
+ store.keyMeta ??= {};
160
+ store.keyMeta[provider] = entry;
99
161
  }, "provider");
100
162
  }
163
+ /** The lifetime of the credential currently stored for `provider`. */
164
+ export function credentialLifetime(provider, now = Date.now(), file = credentialsPath()) {
165
+ return classifyCredentialLifetime(readCredentialMeta(provider, file)?.expiresAt, now);
166
+ }
101
167
  /**
102
168
  * Merge `mutate` into the store and persist it owner-only. The one write path
103
169
  * shared by every credential namespace, and the single place the owner-only
@@ -2,7 +2,24 @@ import { z } from "zod";
2
2
  import { LOG_LEVELS } from "../utils/logger.js";
3
3
  import { MODEL_TIERS } from "../brand/voice.js";
4
4
  import { TASK_CLASSES } from "../routing/types.js";
5
- export const ProviderSchema = z.enum(["cruxy", "openai", "custom"]);
5
+ /**
6
+ * The backends a session can run on. ONE, deliberately.
7
+ *
8
+ * This used to be `["cruxy", "openai", "custom"]`. Neither extra value could
9
+ * ever produce a working session: `createProvider` implements `cruxy` and
10
+ * throws {@link NotImplementedError} for anything else, so `provider: "openai"`
11
+ * passed config validation and then failed at session start — a promise the
12
+ * schema made and the SDK refused. `OpenAICompatibleProvider`, the only code
13
+ * that could have honoured it, was removed in #260; "reserved for the future"
14
+ * is not a claim this enum can support when the implementation it named is
15
+ * gone.
16
+ *
17
+ * Rejecting them HERE is the point: a bad provider is now a config-load error
18
+ * that names the valid values, instead of a NotImplementedError thrown after
19
+ * onboarding has already run. Adding a backend means adding the value back
20
+ * together with the provider that serves it — never ahead of it.
21
+ */
22
+ export const ProviderSchema = z.enum(["cruxy"]);
6
23
  export const ModelConfigSchema = z
7
24
  .object({
8
25
  provider: ProviderSchema.default("cruxy"),
@@ -220,6 +237,73 @@ export const CheckpointConfigSchema = z
220
237
  retention: z.number().int().positive().default(10),
221
238
  })
222
239
  .strict();
240
+ /**
241
+ * The floor under `sessions.retention`.
242
+ *
243
+ * The `--resume` picker (`PICKER_LIMIT`) and the TUI sidebar
244
+ * (`SIDEBAR_SESSIONS`) each offer ten rows. A retention below that would let
245
+ * both surfaces list sessions a later prune has already decided are
246
+ * expendable — offering a row and then deleting it is worse than never showing
247
+ * it. `session/retention-floor.test.ts` pins this against the two constants
248
+ * themselves, so raising either surface without raising the floor fails.
249
+ *
250
+ * Rejected rather than silently raised: a config that says 5 and behaves as 10
251
+ * is a config that lies, and `loadConfig` already treats a bad value as a hard
252
+ * error everywhere else.
253
+ */
254
+ export const SESSION_RETENTION_FLOOR = 10;
255
+ /**
256
+ * Session persistence and retention (#257).
257
+ *
258
+ * The stores either side of this one have always bounded themselves —
259
+ * checkpoints at 10, run history at 50 — and sessions did not. Every session
260
+ * file ever written was still on disk, and nothing could turn recording off.
261
+ *
262
+ * WHY AGE IS THE PRIMARY BOUND AND COUNT IS THE BACKSTOP. "Old sessions I will
263
+ * never resume" is how people actually think about this, and once a session
264
+ * that recorded nothing stops being written at all (the lazy-meta change that
265
+ * had to land first), every remaining file is a real conversation for which age
266
+ * is the honest signal. The count cap stays because age alone does not protect
267
+ * a directory where someone runs forty sessions a day.
268
+ *
269
+ * WHY NOT BYTES. `statSync` hands us the size for free, so cost is not the
270
+ * objection — meaning is. Pruning on bytes makes whether YOUR session survives
271
+ * depend on how chatty an unrelated one was, which is not a rule anyone can
272
+ * hold in their head. `cruxy sessions` reports bytes, because seeing where the
273
+ * disk went is exactly what a report is for; nothing prunes on them.
274
+ *
275
+ * PER PROJECT, matching `projectDir` and how listing already works. A global
276
+ * cap would need the cross-project scan #172 item 2 declined.
277
+ */
278
+ export const SessionsConfigSchema = z
279
+ .object({
280
+ /**
281
+ * Master switch. When false nothing is recorded: no file, no `--resume`,
282
+ * no sidebar. The session runs entirely in memory.
283
+ */
284
+ enabled: z.boolean().default(true),
285
+ /**
286
+ * How many sessions to keep per project; older ones are pruned oldest-first.
287
+ * Never below {@link SESSION_RETENTION_FLOOR} — see the note there.
288
+ */
289
+ retention: z
290
+ .number()
291
+ .int()
292
+ .min(SESSION_RETENTION_FLOOR, {
293
+ message: `sessions.retention must be at least ${SESSION_RETENTION_FLOOR} — ` +
294
+ `the --resume picker and the TUI sidebar both offer that many rows, and ` +
295
+ `a lower retention would list sessions that are already marked for deletion`,
296
+ })
297
+ .default(50),
298
+ /**
299
+ * Prune sessions untouched for this many days. Measured on the file's
300
+ * MTIME, never on `meta.startedAt`: a conversation begun forty days ago and
301
+ * resumed this morning is live, and an age check on when it BEGAN would
302
+ * delete it out from under the user.
303
+ */
304
+ maxAgeDays: z.number().int().positive().default(30),
305
+ })
306
+ .strict();
223
307
  /**
224
308
  * Test-execution loop (C.13): how the agent runs the project's test suite and
225
309
  * iterates on failures. The command is detected from package.json when unset;
@@ -517,7 +601,11 @@ export const UsageConfigSchema = z
517
601
  * arguments over the network (https + cert-validated + pinned to a public IP; http
518
602
  * only for a loopback dev server) and treats its responses as untrusted data. A
519
603
  * url's trust also binds its resolved IP set at trust time, so a later IP-set
520
- * change re-gates (weaker than a local binary fingerprint see `mcp/types.ts`).
604
+ * change re-gates. NEITHER transport's fingerprint covers the server's CODE
605
+ * both hash the invocation (command/args/env, url, credential ref, header names),
606
+ * so a trusted server that later ships different code is still trusted. Trust
607
+ * means "I accept running this", not "this is still what I trusted"; see
608
+ * `mcp/types.ts`.
521
609
  */
522
610
  export const McpServerSchema = z
523
611
  .object({
@@ -577,6 +665,40 @@ function isHttps(url) {
577
665
  * model names scrubbed, and results are NEVER persisted. The tool list a server
578
666
  * advertises is bounded (count + per-tool description/schema size) so a hostile
579
667
  * server can't blow the context budget.
668
+ *
669
+ * THE THREE BOUNDS HAVE CEILINGS OF THEIR OWN (#237). Each was `positive()` with
670
+ * no maximum, which made the protection exactly as good as the number the user
671
+ * typed: `maxSchemaBytes: 100000000` validated cleanly, and a cap whose docstring
672
+ * says a hostile server "can't flood context" then does not do that. The failure
673
+ * moved downstream to a provider request-size rejection or a blown context
674
+ * budget — a much worse place to find out than config load, where the answer is
675
+ * one line.
676
+ *
677
+ * These ceilings are a SANITY BOUND, NOT A PROTOCOL LIMIT, and the difference
678
+ * matters because `MAX_SCHEMA_DEPTH` (`tools/schema-depth.ts`) bounds the same
679
+ * schemas and is the opposite kind of number. That one mirrors a real gateway
680
+ * constraint: exceed it and the
681
+ * whole request fails, so it is not ours to choose and must never be raised to
682
+ * make a config work. These three mirror nothing upstream — no external validator
683
+ * enforces them and no request breaks at 65 KiB. They are a statement about what
684
+ * configuration is worth honoring, so they are picked to be obviously generous
685
+ * (8x, 16x, and 8x the shipped defaults) rather than tight. Different numbers in
686
+ * the same spirit would be just as correct; having no number at all was not.
687
+ *
688
+ * Rejected rather than silently clamped, on `SESSION_RETENTION_FLOOR`'s rule: a
689
+ * config that says 100000000 and behaves as 65536 is a config that lies. The cost
690
+ * is that an over-ceiling value is a hard `loadConfig` error, and the schema is
691
+ * `.strict()`, so it fails the whole CLI rather than just MCP. That is affordable
692
+ * HERE and would not be everywhere: these keys are documented nowhere, are not
693
+ * env-settable, and `initConfig` writes the defaults — so every generated config
694
+ * passes, and `setValue` rejects an over-ceiling `cruxy config set` at the write
695
+ * with the path and the bound named. Contrast `UsageConfigSchema`, which refuses
696
+ * the same hard error for the opposite reason: those keys were a documented
697
+ * feature people really had set, so rejecting them would hand a working user a
698
+ * CLI that will not start.
699
+ *
700
+ * The two timeouts above are deliberately left unbounded: a long timeout costs
701
+ * patience rather than memory, and a user may have a legitimately slow server.
580
702
  */
581
703
  export const McpConfigSchema = z
582
704
  .object({
@@ -592,15 +714,24 @@ export const McpConfigSchema = z
592
714
  /** Fail a single `tools/call` if the server does not respond within this many
593
715
  * ms — the connection is kept, only the one call errors. */
594
716
  requestTimeout: z.number().int().positive().default(30000),
595
- /** Max tools accepted from ONE server; extras are dropped with a visible note
596
- * (a hostile server can't advertise thousands of tools to flood context). */
597
- maxToolsPerServer: z.number().int().positive().default(32),
717
+ /**
718
+ * Max tools accepted from ONE server; extras are dropped with a visible note
719
+ * (a hostile server can't advertise thousands of tools to flood context).
720
+ *
721
+ * PER SERVER, which is not the number that decides the context budget. Ten
722
+ * trusted servers at the default advertise up to 320 tools between them, and
723
+ * nothing caps that sum — this bound stops one server flooding the list, not
724
+ * a large `servers` map adding up. Configuring many servers is a deliberate
725
+ * act with a visible cost, so it is bounded by the user rather than here.
726
+ */
727
+ maxToolsPerServer: z.number().int().positive().max(256).default(32),
598
728
  /** Max characters kept from a single tool's description; the rest is truncated
599
- * with a visible marker. */
600
- maxDescriptionChars: z.number().int().positive().default(1024),
729
+ * with a visible marker. A description past the 16 KiB ceiling is not one. */
730
+ maxDescriptionChars: z.number().int().positive().max(16_384).default(1024),
601
731
  /** Max bytes kept from a single tool's advertised JSON input schema; an
602
- * over-cap schema is replaced with a permissive one and a visible note. */
603
- maxSchemaBytes: z.number().int().positive().default(8192),
732
+ * over-cap schema is replaced with a permissive one and a visible note. One
733
+ * tool's schema at the 64 KiB ceiling is already ~16k tokens of context. */
734
+ maxSchemaBytes: z.number().int().positive().max(65_536).default(8192),
604
735
  })
605
736
  .strict();
606
737
  /**
@@ -660,6 +791,7 @@ export const CruxyConfigSchema = z
660
791
  index: IndexConfigSchema.default({}),
661
792
  lsp: LspConfigSchema.default({}),
662
793
  checkpoint: CheckpointConfigSchema.default({}),
794
+ sessions: SessionsConfigSchema.default({}),
663
795
  subagent: SubagentConfigSchema.default({}),
664
796
  jobs: JobsConfigSchema.default({}),
665
797
  test: TestConfigSchema.default({}),
package/dist/constants.js CHANGED
@@ -26,8 +26,18 @@ export const CONFIG_FILE_NAME = "config.json";
26
26
  export const CREDENTIALS_FILE_NAME = "credentials.json";
27
27
  /** Onboarding state + completion marker under the global dir (U.6). */
28
28
  export const ONBOARDING_FILE_NAME = "onboarding.json";
29
- /** Where a user creates a Cruxy gateway key (printed during onboarding). */
30
- export const CREATE_KEY_URL = "https://app.cruxy.in";
29
+ /**
30
+ * Where a user creates a Cruxy gateway key (printed during onboarding).
31
+ *
32
+ * `cruxy.ai`, not `app.cruxy.in` — the app moved and the old host now only
33
+ * `308`s here. Printed verbatim into the terminal, where a redirect buys
34
+ * nothing: a URL a user copies by hand or reads aloud should be the one that
35
+ * serves the page, and this one outlived the redirect that was covering for it.
36
+ *
37
+ * Deliberately NOT `api.cruxy.in`. The gateway did not move, and nothing in
38
+ * this package that names it should be changed alongside this.
39
+ */
40
+ export const CREATE_KEY_URL = "https://cruxy.ai";
31
41
  /** Project-level config filenames, checked in order. */
32
42
  export const PROJECT_CONFIG_FILENAMES = [
33
43
  "cruxy.config.json",
@@ -1,5 +1,5 @@
1
1
  import { CommanderError } from "commander";
2
- import { classifyProviderError, internal, usageError } from "./constructors.js";
2
+ import { classifyProviderError, internal, usageError, } from "./constructors.js";
3
3
  import { shouldUseColor, terminalFormatter } from "./format.js";
4
4
  import { CruxyError } from "./types.js";
5
5
  /**
@@ -16,13 +16,13 @@ import { CruxyError } from "./types.js";
16
16
  * - a known provider/transport error → its mapped code;
17
17
  * - anything else → CRUXY_E_INTERNAL, preserving the original as `underlying`.
18
18
  */
19
- export function fromUnknown(err) {
19
+ export function fromUnknown(err, ctx = {}) {
20
20
  if (err instanceof CruxyError)
21
21
  return err;
22
22
  if (err instanceof CommanderError) {
23
23
  return usageError(stripErrorPrefix(err.message));
24
24
  }
25
- return classifyProviderError(err) ?? internal(err);
25
+ return classifyProviderError(err, ctx) ?? internal(err);
26
26
  }
27
27
  /** A Commander "error" that's actually success (`--help`, `--version`). */
28
28
  export function isCommanderSuccess(err) {
@@ -34,7 +34,7 @@ export function isCommanderSuccess(err) {
34
34
  * touching the real process.
35
35
  */
36
36
  export function handleFatal(err, opts = {}) {
37
- const cruxy = fromUnknown(err);
37
+ const cruxy = fromUnknown(err, opts.context ?? {});
38
38
  const verbose = opts.verbose ?? isVerbose();
39
39
  const color = opts.color ?? shouldUseColor();
40
40
  const formatter = opts.formatter ?? terminalFormatter;