@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.
- package/README.md +2 -2
- package/dist/agent/loop.js +16 -1
- package/dist/agent/session.js +73 -9
- package/dist/approval/classify.js +170 -40
- package/dist/approval/prompt.js +52 -6
- package/dist/approval/service.js +1 -11
- package/dist/budget/session-budget.js +10 -1
- package/dist/checkpoint/coverage.js +147 -4
- package/dist/cli/command-catalog.js +5 -1
- package/dist/cli/commands/config.js +18 -3
- package/dist/cli/commands/limits.js +76 -0
- package/dist/cli/commands/logs.js +149 -0
- package/dist/cli/commands/pr.js +11 -11
- package/dist/cli/commands/rollback.js +10 -2
- package/dist/cli/commands/run.js +25 -6
- package/dist/cli/commands/sessions.js +181 -0
- package/dist/cli/onboard.js +0 -9
- package/dist/cli/program.js +19 -1
- package/dist/cli/repl.js +25 -0
- package/dist/cli/session-commands.js +45 -2
- package/dist/cli/session-factory.js +31 -10
- package/dist/config/manager.js +91 -11
- package/dist/config/schema.js +194 -20
- package/dist/constants.js +12 -2
- package/dist/errors/constructors.js +84 -46
- package/dist/errors/types.js +6 -0
- package/dist/jobs/index.js +1 -0
- package/dist/jobs/log-renderer.js +10 -5
- package/dist/jobs/log-store.js +505 -0
- package/dist/jobs/manager.js +338 -18
- package/dist/mcp/client.js +16 -0
- package/dist/render/limits-report.js +213 -0
- package/dist/render/limits-view.js +125 -0
- package/dist/routing/index.js +1 -1
- package/dist/routing/router.js +34 -14
- package/dist/routing/types.js +0 -2
- package/dist/sandbox/service.js +9 -0
- package/dist/sandbox/types.js +15 -0
- package/dist/session/index.js +3 -1
- package/dist/session/list.js +20 -6
- package/dist/session/log.js +120 -21
- package/dist/session/prune.js +166 -0
- package/dist/session/resume.js +5 -0
- package/dist/subagent/orchestrator.js +87 -34
- package/dist/subagent/spawn-tool.js +11 -4
- package/dist/tools/schema-depth.js +18 -0
- package/dist/tui/limits-panel.js +53 -30
- package/dist/usage/collect.js +20 -1
- package/dist/usage/summary.js +48 -1
- package/dist/usage/types.js +52 -0
- package/package.json +2 -2
package/dist/config/schema.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
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
|
|
388
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
596
|
-
*
|
|
597
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
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
|
|
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
|
+
}
|
package/dist/errors/types.js
CHANGED
|
@@ -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,
|
package/dist/jobs/index.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|