@cruxy/cli 1.9.0 → 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 (38) 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/pr.js +10 -1
  11. package/dist/cli/commands/rollback.js +10 -2
  12. package/dist/cli/commands/run.js +19 -3
  13. package/dist/cli/commands/sessions.js +156 -0
  14. package/dist/cli/program.js +4 -0
  15. package/dist/cli/repl.js +25 -0
  16. package/dist/cli/session-factory.js +31 -10
  17. package/dist/config/schema.js +141 -9
  18. package/dist/constants.js +12 -2
  19. package/dist/errors/constructors.js +49 -44
  20. package/dist/jobs/manager.js +269 -17
  21. package/dist/mcp/client.js +16 -0
  22. package/dist/render/limits-report.js +213 -0
  23. package/dist/render/limits-view.js +125 -0
  24. package/dist/sandbox/service.js +9 -0
  25. package/dist/sandbox/types.js +15 -0
  26. package/dist/session/index.js +3 -1
  27. package/dist/session/list.js +20 -6
  28. package/dist/session/log.js +120 -21
  29. package/dist/session/prune.js +106 -0
  30. package/dist/session/resume.js +5 -0
  31. package/dist/subagent/orchestrator.js +71 -31
  32. package/dist/subagent/spawn-tool.js +11 -4
  33. package/dist/tools/schema-depth.js +18 -0
  34. package/dist/tui/limits-panel.js +53 -30
  35. package/dist/usage/collect.js +20 -1
  36. package/dist/usage/summary.js +48 -1
  37. package/dist/usage/types.js +27 -0
  38. package/package.json +2 -2
@@ -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,4 +1,4 @@
1
- import { ApiError, AuthError, BudgetExhaustedError, InvalidRequestError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
1
+ import { ApiError, AuthError, BudgetExhaustedError, InvalidRequestError, NetworkError, OverloadedError, RateLimitError, ToolSchemaRejectedError, } from "@cruxy/sdk";
2
2
  import { scrubModelNames } from "../brand/index.js";
3
3
  // Imported from the leaf module DIRECTLY, not via `config/index.js`:
4
4
  // `config/credentials.ts` imports this file, so going through the barrel would
@@ -245,16 +245,32 @@ export function apiError(underlying) {
245
245
  * moment", and they point at the issue tracker with a code to quote, because a
246
246
  * report is the one action that actually moves this forward.
247
247
  *
248
- * The gateway's message names the offending tool when a tool is at fault (its
249
- * validator emits `tool "<name>": ...`), and that name is the single most useful
250
- * token in the whole error it turns "cruxy is broken" into a filed issue
251
- * someone can act on. It is lifted out of the SCRUBBED message, never the raw
252
- * one, so the U.8 gag can never be undone by this path.
248
+ * WHEN A TOOL IS AT FAULT the gateway now says so in fields rather than in
249
+ * prose. A schema over one of its caps answers `invalid_tool_schema` carrying
250
+ * `tool`, `bound` and `limit`, which the SDK reads onto
251
+ * {@link ToolSchemaRejectedError} so the tool name, the cap that was hit and
252
+ * the number to fit under are all read off the structured half of a contract
253
+ * whose `code` MAY NOT change, rather than parsed back out of English that MAY.
254
+ *
255
+ * That name is the single most useful token in the whole error: it turns "cruxy
256
+ * is broken" into a filed issue someone can act on, and `bound`/`limit` turn the
257
+ * report into one a fix can start from. All three ride in `meta` for exactly
258
+ * that reason, and all three are optional — the gateway omits them when no cap
259
+ * was reached, and an older one omits them entirely.
260
+ *
261
+ * The name is still passed through the model-name scrub before it is shown. It
262
+ * is our own harness's function name and cannot plausibly be an upstream model
263
+ * id, but the U.8 gag holding BY CONSTRUCTION on every path out of here is worth
264
+ * more than the one call it costs — this used to be true only because the name
265
+ * was cut out of an already-scrubbed string.
253
266
  */
254
267
  export function apiRequestRejected(underlying) {
255
268
  const status = underlying instanceof ApiError ? underlying.status : undefined;
256
269
  const cause = scrubbedMessageOf(underlying);
257
- const tool = toolNamedIn(cause);
270
+ const rejected = underlying instanceof ToolSchemaRejectedError ? underlying : undefined;
271
+ const tool = rejected?.tool === undefined ? undefined : scrubModelNames(rejected.tool);
272
+ const bound = rejected?.bound;
273
+ const limit = rejected?.limit;
258
274
  return new CruxyError({
259
275
  code: ErrorCode.ApiRequestRejected,
260
276
  title: tool
@@ -273,46 +289,11 @@ export function apiRequestRejected(underlying) {
273
289
  meta: {
274
290
  ...(status !== undefined ? { status } : {}),
275
291
  ...(tool !== undefined ? { tool } : {}),
292
+ ...(bound !== undefined ? { bound } : {}),
293
+ ...(limit !== undefined ? { limit } : {}),
276
294
  },
277
295
  });
278
296
  }
279
- /**
280
- * The tool name in a gateway rejection, if it named one.
281
- *
282
- * The gateway's tool-schema validator prefixes its complaint with the offending
283
- * function — `tool "apply_patch": parameters nests deeper than 8 levels` — so
284
- * one quoted token after the word `tool` is the whole pattern. Anything else
285
- * yields `undefined` and the caller falls back to generic wording: a WRONG tool
286
- * name in a bug report is worse than none, so this never guesses.
287
- *
288
- * ── TEMPORARY COUPLING, AND IT IS THE WRONG KIND ────────────────────────────
289
- *
290
- * This reads the gateway's `error` MESSAGE, and the gateway's own contract
291
- * (cruxy-ai/api, `internal/httpx/errcode.go`) says the message MAY change while
292
- * the `code` MAY NOT. So this parses the half that is explicitly allowed to move
293
- * under us — the exact coupling the code/message split exists to prevent.
294
- *
295
- * It is deliberate and bounded: today the code is the generic `invalid_request`,
296
- * shared with bad JSON, a missing field and an unknown model, so the message is
297
- * the ONLY thing distinguishing "this build's tool harness is permanently
298
- * unusable" from "this one request was malformed". The tool name is the single
299
- * most actionable token in the error and it is worth having; a regex that fails
300
- * closed is the cheapest way to have it.
301
- *
302
- * Failing closed is what makes the risk acceptable. If the gateway rewords, this
303
- * returns `undefined`, the caller drops to generic wording, and the error is
304
- * still correct — less specific, never wrong. Nothing downstream branches on it.
305
- *
306
- * The real fix is server-side and filed as cruxy-ai/api#183: a distinct 400 code
307
- * for harness-bound rejections (the `invalid_schema` precedent already exists
308
- * for `response_format`), with the tool name as a STRUCTURED FIELD rather than a
309
- * message prefix. When that lands, match on the code, read the field, and delete
310
- * this function — do not "improve" the regex.
311
- */
312
- function toolNamedIn(message) {
313
- const match = /\btool "([^"]+)"/.exec(message ?? "");
314
- return match?.[1];
315
- }
316
297
  export function apiRateLimit(underlying) {
317
298
  const retryAfterMs = underlying instanceof RateLimitError ? underlying.retryAfterMs : undefined;
318
299
  return new CruxyError({
@@ -1604,3 +1585,27 @@ export function classifyProviderError(underlying, ctx = {}) {
1604
1585
  return apiError(underlying);
1605
1586
  return null;
1606
1587
  }
1588
+ /**
1589
+ * The typed pool denial behind an error, or `null` if it is not one.
1590
+ *
1591
+ * LIVES HERE BECAUSE IT HAS TWO CALLERS (cli#245). It was written for the
1592
+ * fan-out seam (cli#243) and is needed byte-for-byte by the background-job
1593
+ * executor, which flattened the same 429 into a message string and lost
1594
+ * `window`, `resetAt` and `miraAvailable` — the three fields that make a denial
1595
+ * actionable, and the last of which is the only one that unblocks someone now.
1596
+ * A second copy of this in `jobs/` would be two places that decide what a pool
1597
+ * denial is, and they would disagree the first time the SDK grows a class.
1598
+ *
1599
+ * Two shapes reach here and both are the same fact: the raw SDK
1600
+ * `BudgetExhaustedError` from the run's own request, and — when a caller that
1601
+ * already converted one re-throws — the {@link CruxyError} it produced. Mapping
1602
+ * goes through {@link classifyProviderError} so there is still ONE place that
1603
+ * knows which SDK class means what.
1604
+ */
1605
+ export function poolDenial(err) {
1606
+ if (CruxyError.is(err)) {
1607
+ return err.code === ErrorCode.BudgetExhausted ? err : null;
1608
+ }
1609
+ const typed = classifyProviderError(err);
1610
+ return typed?.code === ErrorCode.BudgetExhausted ? typed : null;
1611
+ }