@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
@@ -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;
@@ -307,13 +391,47 @@ export const JobsConfigSchema = z
307
391
  */
308
392
  maxJobs: z.number().int().positive().default(5),
309
393
  /**
310
- * How many of a job's most-recent log lines are retained in its ring buffer
311
- * for `cruxy logs <id>`. Bounded so a chatty job can't grow memory without
312
- * limit; older lines roll off oldest-first. Default 1000.
394
+ * How many of a job's most-recent log lines are retained in its IN-MEMORY
395
+ * ring buffer, which is what `/logs <id>` and the Tasks view read while the
396
+ * session is alive. Bounded so a chatty job can't grow memory without
397
+ * limit; older lines roll off oldest-first and `/logs` says how many.
398
+ * Default 1000.
399
+ *
400
+ * This is no longer the only copy: {@link logFileLines} bounds the
401
+ * persisted one, which `cruxy logs <id>` reads back after the fact.
313
402
  */
314
403
  logBufferLines: z.number().int().positive().default(1000),
404
+ /**
405
+ * How many lines of a job's output are written to its file under
406
+ * `~/.cruxy/projects/<project>/subagents/` (#172 item 1). Past this the log
407
+ * records one honest `truncated` marker and stops; the job runs on.
408
+ *
409
+ * WHY A SECOND, LARGER BOUND rather than reusing {@link logBufferLines}.
410
+ * The file exists precisely to remove the ring buffer's drop-on-overflow,
411
+ * so a file bounded at the buffer's size would persist the same truncated
412
+ * tail and buy nothing — which is why this is FLOORED at `logBufferLines`
413
+ * rather than allowed to sink below it (see the refinement below).
414
+ *
415
+ * WHY IT IS BOUNDED AT ALL. `sessions.retention` bounds how many session-
416
+ * shaped things the tree keeps; nothing bounds how big ONE of them gets,
417
+ * and a background job is the first writer here that can produce unbounded
418
+ * output unattended — it runs a full agent loop off screen, and a job stuck
419
+ * in a tool-call loop emits lines for as long as its budget lasts with
420
+ * nobody watching. Streaming that to disk uncapped is the second unbounded
421
+ * writer under `~/.cruxy` that #257 exists to prevent.
422
+ *
423
+ * 20000 is 20x the in-memory buffer: roughly 2 MB for a worst-case job, and
424
+ * the sweep drops a `done` job's log at the next start, so the steady-state
425
+ * cost of the default is near zero.
426
+ */
427
+ logFileLines: z.number().int().positive().default(20000),
315
428
  })
316
- .strict();
429
+ .strict()
430
+ .refine((j) => j.logFileLines >= j.logBufferLines, {
431
+ message: "jobs.logFileLines must be at least jobs.logBufferLines — the persisted log " +
432
+ "exists to remove the ring buffer's drop-on-overflow, and a file that kept " +
433
+ "fewer lines than memory would make persisting strictly worse than not persisting",
434
+ });
317
435
  /**
318
436
  * Sandbox / container execution (C.16): defense-in-depth beneath the U.3 gate.
319
437
  * When enabled, `run_command` and `run_tests` execute inside an isolated,
@@ -377,17 +495,25 @@ export const HooksConfigSchema = z
377
495
  /**
378
496
  * Multi-model routing (C.30): map declared task classes to tiers so mechanical
379
497
  * work runs on a cheap tier and hard reasoning on a strong one. Fully opt-in —
380
- * with an empty `map` and no `default`, every task class routes to the session's
381
- * single tier and behavior is unchanged (routing stays inert until configured).
382
- * Only tier names appear here; upstream model names never do (U.8). Keys are the
383
- * fixed {@link TASK_CLASSES}, so a mistyped class is rejected at config load.
498
+ * with an empty `map` and no `default` the whole table is inert and nothing
499
+ * about a session changes. Only tier names appear here; upstream model names
500
+ * never do (U.8). Keys are the fixed {@link TASK_CLASSES}, so a mistyped class
501
+ * is rejected at config load.
502
+ *
503
+ * A PARTIAL TABLE STAYS PARTIAL. Writing `map` without `default` routes exactly
504
+ * the classes named and leaves every other one to the gateway (`auto`) — it does
505
+ * not pin them to some tier chosen on your behalf. The exception is a
506
+ * `model.model` that names a tier: that is a session-wide model choice, and the
507
+ * unnamed classes inherit it rather than being handed back to the gateway.
384
508
  */
385
509
  export const RoutingConfigSchema = z
386
510
  .object({
387
- /** Tier for any task class not in `map`. Unset → the tier implied by
388
- * `model.model` (a real tier, or the auto-fallback tier). */
511
+ /** Tier for any task class not in `map`. Unset → the tier `model.model`
512
+ * names, if it names one; otherwise the class is not routed here at all and
513
+ * goes out as `auto` for the gateway to route. */
389
514
  default: z.enum(MODEL_TIERS).optional(),
390
- /** Per-task-class tier overrides; anything omitted takes `default`. */
515
+ /** Per-task-class tier overrides; anything omitted takes `default`, or
516
+ * `auto` when there is no `default` to take. */
391
517
  map: z.record(z.enum(TASK_CLASSES), z.enum(MODEL_TIERS)).default({}),
392
518
  })
393
519
  .strict();
@@ -517,7 +643,11 @@ export const UsageConfigSchema = z
517
643
  * arguments over the network (https + cert-validated + pinned to a public IP; http
518
644
  * only for a loopback dev server) and treats its responses as untrusted data. A
519
645
  * 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`).
646
+ * change re-gates. NEITHER transport's fingerprint covers the server's CODE
647
+ * both hash the invocation (command/args/env, url, credential ref, header names),
648
+ * so a trusted server that later ships different code is still trusted. Trust
649
+ * means "I accept running this", not "this is still what I trusted"; see
650
+ * `mcp/types.ts`.
521
651
  */
522
652
  export const McpServerSchema = z
523
653
  .object({
@@ -577,6 +707,40 @@ function isHttps(url) {
577
707
  * model names scrubbed, and results are NEVER persisted. The tool list a server
578
708
  * advertises is bounded (count + per-tool description/schema size) so a hostile
579
709
  * server can't blow the context budget.
710
+ *
711
+ * THE THREE BOUNDS HAVE CEILINGS OF THEIR OWN (#237). Each was `positive()` with
712
+ * no maximum, which made the protection exactly as good as the number the user
713
+ * typed: `maxSchemaBytes: 100000000` validated cleanly, and a cap whose docstring
714
+ * says a hostile server "can't flood context" then does not do that. The failure
715
+ * moved downstream to a provider request-size rejection or a blown context
716
+ * budget — a much worse place to find out than config load, where the answer is
717
+ * one line.
718
+ *
719
+ * These ceilings are a SANITY BOUND, NOT A PROTOCOL LIMIT, and the difference
720
+ * matters because `MAX_SCHEMA_DEPTH` (`tools/schema-depth.ts`) bounds the same
721
+ * schemas and is the opposite kind of number. That one mirrors a real gateway
722
+ * constraint: exceed it and the
723
+ * whole request fails, so it is not ours to choose and must never be raised to
724
+ * make a config work. These three mirror nothing upstream — no external validator
725
+ * enforces them and no request breaks at 65 KiB. They are a statement about what
726
+ * configuration is worth honoring, so they are picked to be obviously generous
727
+ * (8x, 16x, and 8x the shipped defaults) rather than tight. Different numbers in
728
+ * the same spirit would be just as correct; having no number at all was not.
729
+ *
730
+ * Rejected rather than silently clamped, on `SESSION_RETENTION_FLOOR`'s rule: a
731
+ * config that says 100000000 and behaves as 65536 is a config that lies. The cost
732
+ * is that an over-ceiling value is a hard `loadConfig` error, and the schema is
733
+ * `.strict()`, so it fails the whole CLI rather than just MCP. That is affordable
734
+ * HERE and would not be everywhere: these keys are documented nowhere, are not
735
+ * env-settable, and `initConfig` writes the defaults — so every generated config
736
+ * passes, and `setValue` rejects an over-ceiling `cruxy config set` at the write
737
+ * with the path and the bound named. Contrast `UsageConfigSchema`, which refuses
738
+ * the same hard error for the opposite reason: those keys were a documented
739
+ * feature people really had set, so rejecting them would hand a working user a
740
+ * CLI that will not start.
741
+ *
742
+ * The two timeouts above are deliberately left unbounded: a long timeout costs
743
+ * patience rather than memory, and a user may have a legitimately slow server.
580
744
  */
581
745
  export const McpConfigSchema = z
582
746
  .object({
@@ -592,15 +756,24 @@ export const McpConfigSchema = z
592
756
  /** Fail a single `tools/call` if the server does not respond within this many
593
757
  * ms — the connection is kept, only the one call errors. */
594
758
  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),
759
+ /**
760
+ * Max tools accepted from ONE server; extras are dropped with a visible note
761
+ * (a hostile server can't advertise thousands of tools to flood context).
762
+ *
763
+ * PER SERVER, which is not the number that decides the context budget. Ten
764
+ * trusted servers at the default advertise up to 320 tools between them, and
765
+ * nothing caps that sum — this bound stops one server flooding the list, not
766
+ * a large `servers` map adding up. Configuring many servers is a deliberate
767
+ * act with a visible cost, so it is bounded by the user rather than here.
768
+ */
769
+ maxToolsPerServer: z.number().int().positive().max(256).default(32),
598
770
  /** 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),
771
+ * with a visible marker. A description past the 16 KiB ceiling is not one. */
772
+ maxDescriptionChars: z.number().int().positive().max(16_384).default(1024),
601
773
  /** 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),
774
+ * over-cap schema is replaced with a permissive one and a visible note. One
775
+ * tool's schema at the 64 KiB ceiling is already ~16k tokens of context. */
776
+ maxSchemaBytes: z.number().int().positive().max(65_536).default(8192),
604
777
  })
605
778
  .strict();
606
779
  /**
@@ -660,6 +833,7 @@ export const CruxyConfigSchema = z
660
833
  index: IndexConfigSchema.default({}),
661
834
  lsp: LspConfigSchema.default({}),
662
835
  checkpoint: CheckpointConfigSchema.default({}),
836
+ sessions: SessionsConfigSchema.default({}),
663
837
  subagent: SubagentConfigSchema.default({}),
664
838
  jobs: JobsConfigSchema.default({}),
665
839
  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
@@ -109,14 +109,47 @@ export function configParse(path, underlying) {
109
109
  meta: { path },
110
110
  });
111
111
  }
112
+ /**
113
+ * `--config <path>` named a file that does not exist.
114
+ *
115
+ * Loud by construction: the alternative is falling back to discovery or to
116
+ * defaults, which produces exactly the behaviour the user would have got
117
+ * without the flag, and so is indistinguishable from success (#289). Raised
118
+ * only for an EXPLICIT path — a missing global or project config is ordinary.
119
+ */
120
+ export function configNotFound(path) {
121
+ return new CruxyError({
122
+ code: ErrorCode.ConfigNotFound,
123
+ title: `config file not found: ${path}`,
124
+ cause: "`--config` named a file that does not exist",
125
+ nextSteps: [
126
+ "check the path (it is resolved as given, relative to the current directory)",
127
+ "omit `--config` to use the discovered project config, or `~/.cruxy/config.json`",
128
+ ],
129
+ meta: { path },
130
+ });
131
+ }
112
132
  export function configInvalid(issues, path) {
113
133
  return new CruxyError({
114
134
  code: ErrorCode.ConfigInvalid,
115
- title: "the configuration is invalid",
135
+ // NAME THE FILE, as `configParse` above already does. The path was carried
136
+ // in `meta` alone, and the terminal formatter renders title/cause/steps/code
137
+ // — not meta — so the caller's choice of which file to blame reached nobody
138
+ // and "correct the reported field(s)" left the user to guess between the
139
+ // global, project and explicit configs (#289).
140
+ //
141
+ // It is the HIGHEST-PRECEDENCE file in effect, not necessarily the one
142
+ // holding the bad key: the config is a merge, and a resolved value cannot
143
+ // be traced to a layer from here. Hence "start with" and the pointer to
144
+ // `config path` — an honest lead, not a claim about which line to edit.
145
+ title: path
146
+ ? `the configuration is invalid (${path})`
147
+ : "the configuration is invalid",
116
148
  cause: issues,
117
149
  nextSteps: [
118
150
  "correct the reported field(s)",
119
- "see valid keys with `cruxy config list`",
151
+ ...(path ? [`start with ${path}, the last file merged`] : []),
152
+ "see valid keys with `cruxy config list`, or the files in effect with `cruxy config path`",
120
153
  ],
121
154
  meta: path ? { path } : undefined,
122
155
  });
@@ -245,16 +278,32 @@ export function apiError(underlying) {
245
278
  * moment", and they point at the issue tracker with a code to quote, because a
246
279
  * report is the one action that actually moves this forward.
247
280
  *
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.
281
+ * WHEN A TOOL IS AT FAULT the gateway now says so in fields rather than in
282
+ * prose. A schema over one of its caps answers `invalid_tool_schema` carrying
283
+ * `tool`, `bound` and `limit`, which the SDK reads onto
284
+ * {@link ToolSchemaRejectedError} so the tool name, the cap that was hit and
285
+ * the number to fit under are all read off the structured half of a contract
286
+ * whose `code` MAY NOT change, rather than parsed back out of English that MAY.
287
+ *
288
+ * That name is the single most useful token in the whole error: it turns "cruxy
289
+ * is broken" into a filed issue someone can act on, and `bound`/`limit` turn the
290
+ * report into one a fix can start from. All three ride in `meta` for exactly
291
+ * that reason, and all three are optional — the gateway omits them when no cap
292
+ * was reached, and an older one omits them entirely.
293
+ *
294
+ * The name is still passed through the model-name scrub before it is shown. It
295
+ * is our own harness's function name and cannot plausibly be an upstream model
296
+ * id, but the U.8 gag holding BY CONSTRUCTION on every path out of here is worth
297
+ * more than the one call it costs — this used to be true only because the name
298
+ * was cut out of an already-scrubbed string.
253
299
  */
254
300
  export function apiRequestRejected(underlying) {
255
301
  const status = underlying instanceof ApiError ? underlying.status : undefined;
256
302
  const cause = scrubbedMessageOf(underlying);
257
- const tool = toolNamedIn(cause);
303
+ const rejected = underlying instanceof ToolSchemaRejectedError ? underlying : undefined;
304
+ const tool = rejected?.tool === undefined ? undefined : scrubModelNames(rejected.tool);
305
+ const bound = rejected?.bound;
306
+ const limit = rejected?.limit;
258
307
  return new CruxyError({
259
308
  code: ErrorCode.ApiRequestRejected,
260
309
  title: tool
@@ -273,46 +322,11 @@ export function apiRequestRejected(underlying) {
273
322
  meta: {
274
323
  ...(status !== undefined ? { status } : {}),
275
324
  ...(tool !== undefined ? { tool } : {}),
325
+ ...(bound !== undefined ? { bound } : {}),
326
+ ...(limit !== undefined ? { limit } : {}),
276
327
  },
277
328
  });
278
329
  }
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
330
  export function apiRateLimit(underlying) {
317
331
  const retryAfterMs = underlying instanceof RateLimitError ? underlying.retryAfterMs : undefined;
318
332
  return new CruxyError({
@@ -1604,3 +1618,27 @@ export function classifyProviderError(underlying, ctx = {}) {
1604
1618
  return apiError(underlying);
1605
1619
  return null;
1606
1620
  }
1621
+ /**
1622
+ * The typed pool denial behind an error, or `null` if it is not one.
1623
+ *
1624
+ * LIVES HERE BECAUSE IT HAS TWO CALLERS (cli#245). It was written for the
1625
+ * fan-out seam (cli#243) and is needed byte-for-byte by the background-job
1626
+ * executor, which flattened the same 429 into a message string and lost
1627
+ * `window`, `resetAt` and `miraAvailable` — the three fields that make a denial
1628
+ * actionable, and the last of which is the only one that unblocks someone now.
1629
+ * A second copy of this in `jobs/` would be two places that decide what a pool
1630
+ * denial is, and they would disagree the first time the SDK grows a class.
1631
+ *
1632
+ * Two shapes reach here and both are the same fact: the raw SDK
1633
+ * `BudgetExhaustedError` from the run's own request, and — when a caller that
1634
+ * already converted one re-throws — the {@link CruxyError} it produced. Mapping
1635
+ * goes through {@link classifyProviderError} so there is still ONE place that
1636
+ * knows which SDK class means what.
1637
+ */
1638
+ export function poolDenial(err) {
1639
+ if (CruxyError.is(err)) {
1640
+ return err.code === ErrorCode.BudgetExhausted ? err : null;
1641
+ }
1642
+ const typed = classifyProviderError(err);
1643
+ return typed?.code === ErrorCode.BudgetExhausted ? typed : null;
1644
+ }
@@ -29,6 +29,11 @@ export const ErrorCode = {
29
29
  // config (exit 3)
30
30
  ConfigParse: "CRUXY_E_CONFIG_PARSE",
31
31
  ConfigInvalid: "CRUXY_E_CONFIG_INVALID",
32
+ /** A config file named with `--config` does not exist. DISTINCT from
33
+ * {@link ConfigParse}: nothing was malformed, the file simply is not there,
34
+ * and the advice is to check the path rather than the JSON. Only an EXPLICIT
35
+ * path can raise it — a missing global or project file is the normal case. */
36
+ ConfigNotFound: "CRUXY_E_CONFIG_NOT_FOUND",
32
37
  // auth (exit 4)
33
38
  AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY",
34
39
  AuthInvalid: "CRUXY_E_AUTH_INVALID",
@@ -311,6 +316,7 @@ const EXIT_CODES = {
311
316
  [ErrorCode.RoutingTierUnavailable]: 2,
312
317
  [ErrorCode.ConfigParse]: 3,
313
318
  [ErrorCode.ConfigInvalid]: 3,
319
+ [ErrorCode.ConfigNotFound]: 3,
314
320
  [ErrorCode.AuthMissingKey]: 4,
315
321
  [ErrorCode.AuthInvalid]: 4,
316
322
  [ErrorCode.AuthExpired]: 4,
@@ -1,5 +1,6 @@
1
1
  export * from "./types.js";
2
2
  export * from "./log-buffer.js";
3
+ export * from "./log-store.js";
3
4
  export * from "./log-renderer.js";
4
5
  export * from "./approval-queue.js";
5
6
  export * from "./manager.js";
@@ -18,9 +18,14 @@ const OFFSCREEN_CAPS = {
18
18
  };
19
19
  /**
20
20
  * A {@link StreamRenderer} for a background job (C.28) that captures activity into
21
- * the job's log buffer and writes NOTHING to any terminal — a job runs
21
+ * the job's log sink and writes NOTHING to any terminal — a job runs
22
22
  * non-interactively, off screen, and its foreground session owns the terminal.
23
- * `cruxy logs <id>` (a REPL `/logs`) reads back what was captured here.
23
+ *
24
+ * What was captured here is read back from two different copies: the in-session
25
+ * `/logs <id>` and the Tasks view read the ring buffer while the session lives,
26
+ * and `cruxy logs <id>` reads the persisted file afterwards (#172 item 1).
27
+ * Everything this renderer emits goes through the one `JobManager.log` sink, so
28
+ * both copies see exactly the same lines.
24
29
  *
25
30
  * Assistant text is accumulated and flushed a line at a time on `endSegment`;
26
31
  * committed chrome notes and tool-call completions are captured verbatim. The
@@ -94,9 +99,9 @@ export class JobLogRenderer {
94
99
  this.planSteps = steps.map((s) => ({ ...s }));
95
100
  }
96
101
  /**
97
- * A job's test run is exactly the kind of outcome `cruxy logs` exists to
98
- * show. Plain text, no theme glyphs, matching this log's `[ok]`/`[fail]`
99
- * style — and no count this renderer was not given.
102
+ * A job's test run is exactly the kind of outcome a job log exists to show.
103
+ * Plain text, no theme glyphs, matching this log's `[ok]`/`[fail]` style —
104
+ * and no count this renderer was not given.
100
105
  */
101
106
  testResult(report) {
102
107
  const counted = report.total !== undefined