@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.
- package/README.md +1 -1
- package/dist/agent/loop.js +16 -1
- package/dist/agent/session.js +62 -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/commands/limits.js +76 -0
- package/dist/cli/commands/login.js +18 -5
- package/dist/cli/commands/pr.js +10 -1
- package/dist/cli/commands/rollback.js +10 -2
- package/dist/cli/commands/run.js +55 -5
- package/dist/cli/commands/sessions.js +156 -0
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.js +25 -0
- package/dist/cli/session-factory.js +31 -10
- package/dist/config/credential-lifetime.js +42 -0
- package/dist/config/credentials.js +66 -0
- package/dist/config/schema.js +141 -9
- package/dist/constants.js +12 -2
- package/dist/errors/boundary.js +4 -4
- package/dist/errors/constructors.js +136 -57
- package/dist/errors/types.js +18 -0
- package/dist/index.js +27 -1
- package/dist/jobs/manager.js +269 -17
- package/dist/limits/cache.js +21 -5
- package/dist/mcp/client.js +16 -0
- package/dist/onboarding/flow.js +121 -6
- package/dist/onboarding/steps.js +112 -0
- package/dist/render/limits-report.js +213 -0
- package/dist/render/limits-view.js +125 -0
- 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 +106 -0
- package/dist/session/resume.js +5 -0
- package/dist/subagent/orchestrator.js +71 -31
- package/dist/subagent/spawn-tool.js +11 -4
- package/dist/tools/schema-depth.js +18 -0
- package/dist/tui/limits-panel.js +62 -30
- package/dist/usage/collect.js +20 -1
- package/dist/usage/summary.js +48 -1
- package/dist/usage/types.js +27 -0
- package/package.json +2 -2
package/dist/jobs/manager.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { runAgent } from "../agent/loop.js";
|
|
2
|
-
import { ApprovalService, classify, serializeGate, } from "../approval/index.js";
|
|
2
|
+
import { ApprovalService, classify, InteractivePolicy, serializeGate, SessionAllowlist, } from "../approval/index.js";
|
|
3
3
|
import { CheckpointGate, withCheckpointGate } from "../checkpoint/index.js";
|
|
4
|
-
import {
|
|
4
|
+
import { compactTokens } from "../render/units.js";
|
|
5
|
+
import { CruxyError, ErrorCode, approvalRequired, jobLimitExceeded, jobNotFound, jobsDisabled, messageOf, poolDenial, sessionBudgetExhausted, } from "../errors/index.js";
|
|
6
|
+
import { resolveTaskModel } from "../routing/index.js";
|
|
7
|
+
import { UNRESOLVED_TIER } from "../budget/index.js";
|
|
8
|
+
import { UsageCollector } from "../usage/index.js";
|
|
5
9
|
import { Budget, resolveBudget } from "../agent/budget.js";
|
|
6
10
|
import { scopeRegistry } from "../subagent/index.js";
|
|
7
11
|
import { Workspace } from "../workspace/index.js";
|
|
@@ -10,6 +14,33 @@ import { JobLogRenderer } from "./log-renderer.js";
|
|
|
10
14
|
import { isTerminal } from "./types.js";
|
|
11
15
|
/** Longest task excerpt kept as a job label (display, not record). */
|
|
12
16
|
const LABEL_MAX = 60;
|
|
17
|
+
/**
|
|
18
|
+
* THE ADMISSION FLOOR, in whole requests' worth of fixed payload (cli#245).
|
|
19
|
+
*
|
|
20
|
+
* `Budget.exceeded` is checked BEFORE each model turn against the run's
|
|
21
|
+
* cumulative tokens, so a job's first request always goes out whatever its
|
|
22
|
+
* ceiling is: at iteration 0 the usage is zero and no cap can trip. A ceiling
|
|
23
|
+
* the pool narrowed down to a few hundred tokens therefore buys exactly one
|
|
24
|
+
* request — the system prompt and the whole tool catalogue re-sent, real
|
|
25
|
+
* weighted tokens drawn from a window that is already refusing — and then stops
|
|
26
|
+
* the run at the next boundary with `token cap reached`, which `statusFor` maps
|
|
27
|
+
* to `failed`. The job dispatches, the model is told it started, and it dies a
|
|
28
|
+
* second later having produced nothing and spent something.
|
|
29
|
+
*
|
|
30
|
+
* TWO, not one, and `context.reserveTokens` (4500 by default) is the unit
|
|
31
|
+
* because it is the figure this repo already keeps for exactly this quantity:
|
|
32
|
+
* "the built system prompt plus the default tool catalogue". One request's worth
|
|
33
|
+
* buys a job a single model turn it cannot act on — it can call a tool and never
|
|
34
|
+
* see the result. Two is the smallest ceiling under which a job can complete one
|
|
35
|
+
* act-and-report cycle, which is the least a background job can do and still be
|
|
36
|
+
* worth the tokens. It is a FLOOR and not a promise: input is re-sent every
|
|
37
|
+
* turn, so two turns cost more than twice the fixed payload.
|
|
38
|
+
*
|
|
39
|
+
* Derived from config rather than a literal, so it tracks the prompt: a build
|
|
40
|
+
* whose catalogue grows raises the floor with it. A config that sets
|
|
41
|
+
* `reserveTokens` to 0 states that there is no fixed payload, and floors nothing.
|
|
42
|
+
*/
|
|
43
|
+
const MIN_JOB_REQUESTS = 2;
|
|
13
44
|
/**
|
|
14
45
|
* Session-scoped background jobs (C.28). The main agent dispatches a job with
|
|
15
46
|
* `run_in_background`; it runs the SAME agent loop as a subagent but CONCURRENTLY
|
|
@@ -32,6 +63,8 @@ export class JobManager {
|
|
|
32
63
|
idFactory;
|
|
33
64
|
now;
|
|
34
65
|
runAgentFn;
|
|
66
|
+
/** The last pool denial a job died on, until the foreground takes it. */
|
|
67
|
+
poolDenied;
|
|
35
68
|
seq = 0;
|
|
36
69
|
constructor(deps) {
|
|
37
70
|
this.deps = deps;
|
|
@@ -43,9 +76,21 @@ export class JobManager {
|
|
|
43
76
|
}
|
|
44
77
|
/**
|
|
45
78
|
* Dispatch a background job (the `run_in_background` seam). Fails loud when the
|
|
46
|
-
* feature is disabled, when the live-job ceiling is reached,
|
|
47
|
-
* root is unknown
|
|
48
|
-
*
|
|
79
|
+
* feature is disabled, when the live-job ceiling is reached, when a named
|
|
80
|
+
* root is unknown, or when the session's weighted budget will not cover the
|
|
81
|
+
* job — all BEFORE the job is registered, so a refused dispatch leaves no
|
|
82
|
+
* ghost. Returns a view immediately; the job runs asynchronously.
|
|
83
|
+
*
|
|
84
|
+
* ADMISSION HAPPENS HERE, NOT IN `execute` (cli#245). A queued job can sit
|
|
85
|
+
* behind the shared semaphore for a long time, so "does it fit now" and "does
|
|
86
|
+
* it fit when it runs" are genuinely different questions when the pool is
|
|
87
|
+
* moving — and this is the only one of the two the model can be told the
|
|
88
|
+
* answer to. `dispatch` is synchronous and already throws three coded
|
|
89
|
+
* refusals that `dispatch-tool.ts` converts into a tool error; a budget
|
|
90
|
+
* refusal is that same shape, needing no new mechanism. `execute` has no
|
|
91
|
+
* error channel back to the model at all — only `job.status`, read by a human
|
|
92
|
+
* later — and by then the turn that dispatched has long returned, having
|
|
93
|
+
* already been told the job started.
|
|
49
94
|
*/
|
|
50
95
|
dispatch(spec) {
|
|
51
96
|
const { config } = this.deps;
|
|
@@ -58,7 +103,13 @@ export class JobManager {
|
|
|
58
103
|
// Resolve (and validate) the job's scope up front — an unknown root name
|
|
59
104
|
// throws CRUXY_E_ROOT_UNKNOWN here, which the dispatch tool surfaces to the
|
|
60
105
|
// model, rather than failing silently inside the background run.
|
|
106
|
+
//
|
|
107
|
+
// Before the budget check, deliberately, and for the reason the fan-out
|
|
108
|
+
// seam orders its two checks the same way: a job naming a root that does
|
|
109
|
+
// not exist is MALFORMED, and answering it with "your budget is used up"
|
|
110
|
+
// would report a ceiling instead of the typo.
|
|
61
111
|
const scope = this.jobScope(spec.root);
|
|
112
|
+
const limits = this.admit(spec);
|
|
62
113
|
const id = this.idFactory();
|
|
63
114
|
const checkpoints = config.checkpoint.enabled
|
|
64
115
|
? new CheckpointGate({
|
|
@@ -78,6 +129,7 @@ export class JobManager {
|
|
|
78
129
|
controller: new AbortController(),
|
|
79
130
|
checkpoints,
|
|
80
131
|
scope,
|
|
132
|
+
limits,
|
|
81
133
|
holdsSlot: false,
|
|
82
134
|
};
|
|
83
135
|
this.jobs.set(id, job);
|
|
@@ -177,6 +229,14 @@ export class JobManager {
|
|
|
177
229
|
/** Drive one job to completion, mapping every outcome onto its status. */
|
|
178
230
|
async execute(job) {
|
|
179
231
|
const signal = job.controller.signal;
|
|
232
|
+
// Per-request usage for THIS job, collected exactly as the parent's turn and
|
|
233
|
+
// a subagent collect their own. Needed rather than `result.usage` for two
|
|
234
|
+
// reasons: the weighted arithmetic is per-tier, and a run that THREW has no
|
|
235
|
+
// result at all while still having spent whatever it spent before it died.
|
|
236
|
+
// Origin `"job"` on every entry (cli#244): this record is published to the
|
|
237
|
+
// store in its own right, so its entries have to say what they were.
|
|
238
|
+
const usage = new UsageCollector(undefined, "job");
|
|
239
|
+
const startedAt = new Date().toISOString();
|
|
180
240
|
try {
|
|
181
241
|
// Wait for an execution permit — a queued job holds none until a slot frees
|
|
182
242
|
// (the shared cap, contended with subagents). This is the queued→running
|
|
@@ -192,7 +252,7 @@ export class JobManager {
|
|
|
192
252
|
// NEVER again (a pause is not a checkpoint boundary), so pre- and post-pause
|
|
193
253
|
// mutations coalesce and `cruxy rollback <id>` reverts the whole job.
|
|
194
254
|
job.checkpoints?.beginRun(job.spec.task, job.id);
|
|
195
|
-
const result = await this.runAgentFn(this.runArgs(job, signal));
|
|
255
|
+
const result = await this.runAgentFn(this.runArgs(job, signal, usage));
|
|
196
256
|
job.iterations = result.iterations;
|
|
197
257
|
job.usage = result.usage;
|
|
198
258
|
job.summary = lastAssistantText(result.messages);
|
|
@@ -213,8 +273,31 @@ export class JobManager {
|
|
|
213
273
|
job.error = `${err.code}: ${err.title}`;
|
|
214
274
|
}
|
|
215
275
|
else {
|
|
276
|
+
// THE WEIGHTED POOL REFUSED THIS JOB (429 `budget_exhausted`), and it is
|
|
277
|
+
// not a per-job outcome (cli#245). Two things used to be lost here, and
|
|
278
|
+
// they are the same two the fan-out seam recovered in cli#243.
|
|
279
|
+
//
|
|
280
|
+
// First the FACTS: `messageOf` flattened the typed error to a string, so
|
|
281
|
+
// `window`, `resetAt` and `miraAvailable` went with it — the last of
|
|
282
|
+
// which is the only one that unblocks someone now rather than telling
|
|
283
|
+
// them when to come back — and a job that died on a 429 read like any
|
|
284
|
+
// other failure in `/jobs`.
|
|
285
|
+
//
|
|
286
|
+
// Second the AUDIENCE. A pool denial is a statement about a denominator
|
|
287
|
+
// this job shares with the foreground turn, so the foreground is
|
|
288
|
+
// entitled to it: without the latch the job dies quietly off-screen and
|
|
289
|
+
// the user walks into their own refusal on the next turn, having been
|
|
290
|
+
// told nothing. It is the same "N denials for one fact" argument #243
|
|
291
|
+
// made across siblings, carried across the job/foreground boundary.
|
|
292
|
+
const denial = poolDenial(err);
|
|
216
293
|
job.status = "failed";
|
|
217
|
-
job.error =
|
|
294
|
+
job.error = denial
|
|
295
|
+
? denialLine(denial)
|
|
296
|
+
: `${ErrorCode.SubagentFailed}: ${messageOf(err) ?? "unknown error"}`;
|
|
297
|
+
if (denial) {
|
|
298
|
+
this.poolDenied = denial;
|
|
299
|
+
this.abortSiblings(job, denial);
|
|
300
|
+
}
|
|
218
301
|
}
|
|
219
302
|
}
|
|
220
303
|
finally {
|
|
@@ -222,21 +305,90 @@ export class JobManager {
|
|
|
222
305
|
this.deps.semaphore.release();
|
|
223
306
|
job.holdsSlot = false;
|
|
224
307
|
}
|
|
308
|
+
// JOB SPEND IS SESSION SPEND (cli#245), and in the `finally` because the
|
|
309
|
+
// tokens are spent on every path out of here — a job that failed, was
|
|
310
|
+
// cancelled at session exit, or died on a 429 still drew whatever it drew
|
|
311
|
+
// before it stopped. Without this the admission check above reads a
|
|
312
|
+
// numerator its own dispatches never move, and a job running across an
|
|
313
|
+
// idle period stays invisible to `/budget` for the rest of the session.
|
|
314
|
+
//
|
|
315
|
+
// AND NOW ALSO WRITTEN TO THE USAGE STORE (cli#244 answered the question
|
|
316
|
+
// this call site used to defer). One record, handed to both sinks, so
|
|
317
|
+
// `/budget` and `/usage` can never disagree about what this job drew.
|
|
318
|
+
//
|
|
319
|
+
// `job.id` is the run id — stable, already unique, and the thing the user
|
|
320
|
+
// saw in `/jobs`, so a record in the store can be traced back to the job
|
|
321
|
+
// that made it rather than to an anonymous UUID.
|
|
322
|
+
//
|
|
323
|
+
// The session id is read HERE rather than at construction: see the note on
|
|
324
|
+
// `sessionId`. Without it the record persists and then hides from every
|
|
325
|
+
// session-scoped read of the very store it is in.
|
|
326
|
+
const record = usage.toRecord(job.id, this.deps.sessionId?.(), startedAt);
|
|
327
|
+
this.deps.budget?.record(record);
|
|
328
|
+
// Empty records are not published. A job that was cancelled before it
|
|
329
|
+
// reached the model spent nothing, and a run of no requests is not a run —
|
|
330
|
+
// it would only pad `runCount` and shorten retention for nothing.
|
|
331
|
+
if (record.entries.length > 0)
|
|
332
|
+
this.deps.onRunUsage?.(record);
|
|
225
333
|
this.log(job, "out", `job ${job.status}`);
|
|
226
334
|
}
|
|
227
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* The pool denial a job died on, handed over ONCE (cli#245).
|
|
338
|
+
*
|
|
339
|
+
* A latch rather than a push: the foreground drains it where it is idle —
|
|
340
|
+
* between turns, next to the pending-approval drain — so a background failure
|
|
341
|
+
* never writes into a live region a turn is painting, and never lands
|
|
342
|
+
* mid-stream in a shell that is reading a line.
|
|
343
|
+
*
|
|
344
|
+
* The newest denial wins. Two jobs refused by one exhausted window are one
|
|
345
|
+
* fact, and printing it twice is the flood #243 aborted a batch to avoid.
|
|
346
|
+
*/
|
|
347
|
+
takePoolDenial() {
|
|
348
|
+
const denial = this.poolDenied;
|
|
349
|
+
this.poolDenied = undefined;
|
|
350
|
+
return denial;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* ONE POOL DENIAL STOPS EVERY OTHER LIVE JOB (cli#245) — the batch abort #243
|
|
354
|
+
* made across siblings, carried to the job seam, because the argument is
|
|
355
|
+
* identical and the denominator is literally the same one.
|
|
356
|
+
*
|
|
357
|
+
* A 429 is not a fact about the job that received it. It is a fact about a
|
|
358
|
+
* window every live job draws on, so the other jobs are not in a different
|
|
359
|
+
* situation — they are in the same one, a few seconds behind. Left running,
|
|
360
|
+
* each spends its way to its own refusal: three concurrent jobs discovering
|
|
361
|
+
* one exhausted window produce three failed jobs, three wasted partial runs,
|
|
362
|
+
* and three denials for a single fact. Aborting makes the FIRST denial the
|
|
363
|
+
* session's answer, which is what {@link takePoolDenial} then reports once.
|
|
364
|
+
*
|
|
365
|
+
* `abort` and not a status write, so a job stops at its next TURN boundary
|
|
366
|
+
* exactly as `cruxy cancel` stops it: the in-flight request completes,
|
|
367
|
+
* whatever checkpoint it took survives for rollback, and its slot is released
|
|
368
|
+
* through the same `finally`. Aborted jobs land on the `signal.aborted` branch
|
|
369
|
+
* above and end `cancelled` — honest, and distinct from the one job that
|
|
370
|
+
* actually was refused, which ends `failed` carrying the denial. They do not
|
|
371
|
+
* re-latch: the one denial stays the one denial.
|
|
372
|
+
*
|
|
373
|
+
* Queued jobs are aborted too. A job still waiting for a slot has spent
|
|
374
|
+
* nothing yet, and dispatching it into a window that just refused its sibling
|
|
375
|
+
* is the clearest waste of the set.
|
|
376
|
+
*/
|
|
377
|
+
abortSiblings(source, denial) {
|
|
378
|
+
for (const job of this.jobs.values()) {
|
|
379
|
+
if (job === source || isTerminal(job.status))
|
|
380
|
+
continue;
|
|
381
|
+
this.log(job, "out", `cancelling: ${denial.code} — job ${source.id} was refused by the weighted pool`);
|
|
382
|
+
this.abort(job);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
228
385
|
/** Build the {@link runAgent} args for a job: scoped registry, budget, ctx. */
|
|
229
|
-
runArgs(job, signal) {
|
|
386
|
+
runArgs(job, signal, usage) {
|
|
230
387
|
const { deps } = this;
|
|
231
388
|
const registry = scopeRegistry(deps.parentRegistry, job.spec.tools);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
: {}),
|
|
236
|
-
...(job.spec.budget?.maxTokens !== undefined
|
|
237
|
-
? { maxTokens: job.spec.budget.maxTokens }
|
|
238
|
-
: {}),
|
|
239
|
-
}));
|
|
389
|
+
// The caps admission settled at dispatch — resolved there, not here, so a
|
|
390
|
+
// ceiling the budget cut is the ceiling the run actually executes under.
|
|
391
|
+
const budget = new Budget(job.limits);
|
|
240
392
|
const ctx = {
|
|
241
393
|
cwd: job.scope.cwd,
|
|
242
394
|
workspace: job.scope.workspace,
|
|
@@ -262,6 +414,7 @@ export class JobManager {
|
|
|
262
414
|
router: deps.router,
|
|
263
415
|
taskClass: "subagent",
|
|
264
416
|
signal,
|
|
417
|
+
onRequestUsage: (req) => usage.record(req),
|
|
265
418
|
};
|
|
266
419
|
}
|
|
267
420
|
/**
|
|
@@ -277,10 +430,18 @@ export class JobManager {
|
|
|
277
430
|
makeJobApproval(job) {
|
|
278
431
|
const { approvalQueue, semaphore, approvalMutex, foregroundInteractive } = this.deps;
|
|
279
432
|
const cwd = job.scope.cwd;
|
|
433
|
+
// THE session allowlist a job's grants actually accumulate in (cli#251).
|
|
434
|
+
// A job has no factory-built allowlist to share — a child gets the parent's
|
|
435
|
+
// MODE but not its grants (P5 track 3) — so "allow for this session" taken
|
|
436
|
+
// inside a job lands here and is spent by this policy alone, for this job's
|
|
437
|
+
// lifetime. The service used to build this line for us when no policy was
|
|
438
|
+
// passed, which meant the single call site where the allowlist is
|
|
439
|
+
// load-bearing looked identical to the ones where it is inert. Written out
|
|
440
|
+
// so the ownership is readable here rather than inferred from an absence.
|
|
280
441
|
const interactive = new ApprovalService({
|
|
281
442
|
cwd,
|
|
282
443
|
interactive: foregroundInteractive,
|
|
283
|
-
|
|
444
|
+
policy: new InteractivePolicy(new SessionAllowlist(), this.deps.promptIO),
|
|
284
445
|
});
|
|
285
446
|
// The foreground-serviced decision: the interactive U.3 prompt, wrapped in
|
|
286
447
|
// THIS job's checkpoint hook, serialized on the SHARED approval mutex — so a
|
|
@@ -321,6 +482,83 @@ export class JobManager {
|
|
|
321
482
|
}
|
|
322
483
|
};
|
|
323
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* Ask the session budget whether this job fits, and return the caps it may run
|
|
487
|
+
* under (P10 track 3 / cli#245).
|
|
488
|
+
*
|
|
489
|
+
* THE VERDICT HAS ONLY TWO SHAPES HERE, because a job is dispatched ONE at a
|
|
490
|
+
* time. `spawnMany` can be told "three of your five fit" and hands the parent
|
|
491
|
+
* a positional `not-admitted` result for each of the two that did not; a
|
|
492
|
+
* single dispatch has no such slot and needs none — `admit` with `count: 1`
|
|
493
|
+
* either allows, or narrows to that one run with its token ceiling cut to
|
|
494
|
+
* what the allowance covers, or refuses outright — and a narrowed ceiling
|
|
495
|
+
* below the floor (see {@link MIN_JOB_REQUESTS}) collapses into that last
|
|
496
|
+
* one, because a job cannot use it.
|
|
497
|
+
*
|
|
498
|
+
* The refusal is a THROW, so it lands on `dispatch`'s existing coded-error
|
|
499
|
+
* path and reaches the model through the tool: the same treatment as
|
|
500
|
+
* `jobsDisabled` / `jobLimitExceeded` / an unknown root.
|
|
501
|
+
*
|
|
502
|
+
* Tier: a job routes on the `subagent` task class, exactly as a child does. An
|
|
503
|
+
* unresolved tier still draws on the pool, so it is weighed at the worst case
|
|
504
|
+
* rather than skipped; no router at all means a bring-your-own provider whose
|
|
505
|
+
* tokens never touch the weighted pool, and nothing here bounds them.
|
|
506
|
+
*/
|
|
507
|
+
admit(spec) {
|
|
508
|
+
const { budget, config, logger } = this.deps;
|
|
509
|
+
const resolved = resolveBudget(config.subagent.defaultBudget, {
|
|
510
|
+
...(spec.budget?.maxIterations !== undefined
|
|
511
|
+
? { maxIterations: spec.budget.maxIterations }
|
|
512
|
+
: {}),
|
|
513
|
+
...(spec.budget?.maxTokens !== undefined
|
|
514
|
+
? { maxTokens: spec.budget.maxTokens }
|
|
515
|
+
: {}),
|
|
516
|
+
});
|
|
517
|
+
if (!budget)
|
|
518
|
+
return resolved;
|
|
519
|
+
const verdict = budget.admit({
|
|
520
|
+
count: 1,
|
|
521
|
+
perRunTokens: resolved.maxTokens,
|
|
522
|
+
tier: this.jobTier(),
|
|
523
|
+
});
|
|
524
|
+
if (verdict.kind === "refused")
|
|
525
|
+
throw sessionBudgetExhausted(verdict.reason);
|
|
526
|
+
if (verdict.kind === "allow" || verdict.maxTokens <= 0)
|
|
527
|
+
return resolved;
|
|
528
|
+
// A NARROWED CEILING BELOW THE FLOOR IS A REFUSAL (cli#245). `admit` narrows
|
|
529
|
+
// rather than refuses so that a user with real headroom is never left unable
|
|
530
|
+
// to ask anything at all, and for the turn that is exactly right: the user is
|
|
531
|
+
// present, sees the warning, and can shorten the question or raise
|
|
532
|
+
// `/budget`. A job has none of that. It runs after the turn returned, with
|
|
533
|
+
// nobody watching, on a task the model already wrote — so the same narrowing
|
|
534
|
+
// that keeps a turn useful hands a job a ceiling it cannot start under, and
|
|
535
|
+
// the honest answer is to say so at the seam where the model can still act
|
|
536
|
+
// on it rather than to dispatch a run that fails a second later.
|
|
537
|
+
//
|
|
538
|
+
// Refused HERE and not in the shared rule on purpose: `admit` is one object
|
|
539
|
+
// answering three callers, and only this one is unattended.
|
|
540
|
+
const floor = this.deps.config.context.reserveTokens * MIN_JOB_REQUESTS;
|
|
541
|
+
if (verdict.maxTokens < floor) {
|
|
542
|
+
throw sessionBudgetExhausted(`${verdict.reason}; a background job needs at least ` +
|
|
543
|
+
`${compactTokens(floor)} tokens to complete one act-and-report cycle, ` +
|
|
544
|
+
`so it was refused rather than dispatched to fail on its first turn`);
|
|
545
|
+
}
|
|
546
|
+
// Narrowed: the one run is admitted with a cut ceiling. `Math.min` because
|
|
547
|
+
// `admit` may only ever narrow — a granted figure above the resolved cap
|
|
548
|
+
// would raise a ceiling the config and the spec already set.
|
|
549
|
+
logger.warn(verdict.reason);
|
|
550
|
+
return {
|
|
551
|
+
...resolved,
|
|
552
|
+
maxTokens: Math.min(resolved.maxTokens, verdict.maxTokens),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/** The tier a job would route to, or the unresolved/no-pool markers. */
|
|
556
|
+
jobTier() {
|
|
557
|
+
const router = this.deps.router;
|
|
558
|
+
if (!router)
|
|
559
|
+
return undefined; // not a cruxy request — no weighted pool
|
|
560
|
+
return resolveTaskModel(router, "subagent").tier ?? UNRESOLVED_TIER;
|
|
561
|
+
}
|
|
324
562
|
/** Resolve a job's scope from an optional root name (fail-loud on unknown). */
|
|
325
563
|
jobScope(rootName) {
|
|
326
564
|
if (rootName === undefined) {
|
|
@@ -370,6 +608,20 @@ export class JobManager {
|
|
|
370
608
|
};
|
|
371
609
|
}
|
|
372
610
|
}
|
|
611
|
+
/**
|
|
612
|
+
* A job's `error` line for a pool denial, with every fact the 429 carried.
|
|
613
|
+
*
|
|
614
|
+
* `job.error` is a string and a `JobView` is what `/jobs` and the Tasks view
|
|
615
|
+
* read, so the typed error cannot be handed over whole here — but its next
|
|
616
|
+
* steps are exactly where `miraAvailable` and `resetAt` were rendered into
|
|
617
|
+
* words, and dropping them is what made a denial indistinguishable from a
|
|
618
|
+
* crash. The typed object itself still survives, on the latch the foreground
|
|
619
|
+
* drains (see {@link JobManager.takePoolDenial}).
|
|
620
|
+
*/
|
|
621
|
+
function denialLine(denial) {
|
|
622
|
+
const steps = denial.nextSteps.join("; ");
|
|
623
|
+
return `${denial.code}: ${denial.title}${steps ? ` — ${steps}` : ""}`;
|
|
624
|
+
}
|
|
373
625
|
/** One-line task excerpt for a job label. */
|
|
374
626
|
function taskLabel(task) {
|
|
375
627
|
const flat = task.replace(/\s+/g, " ").trim();
|
package/dist/limits/cache.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyCredentialLifetime } from "../config/credential-lifetime.js";
|
|
1
2
|
import { reduceLimits } from "./reduce.js";
|
|
2
3
|
/**
|
|
3
4
|
* How long a probe's answer is treated as current. The pool moves when the user
|
|
@@ -9,6 +10,7 @@ const MIN_INTERVAL_MS = 15_000;
|
|
|
9
10
|
export class LimitsCache {
|
|
10
11
|
probe;
|
|
11
12
|
now;
|
|
13
|
+
credentialExpiresAt;
|
|
12
14
|
minIntervalMs;
|
|
13
15
|
state;
|
|
14
16
|
/** The refresh in flight, so concurrent triggers share one request. */
|
|
@@ -23,6 +25,7 @@ export class LimitsCache {
|
|
|
23
25
|
probe, opts = {}) {
|
|
24
26
|
this.probe = probe;
|
|
25
27
|
this.now = opts.now ?? Date.now;
|
|
28
|
+
this.credentialExpiresAt = opts.credentialExpiresAt;
|
|
26
29
|
this.minIntervalMs = opts.minIntervalMs ?? MIN_INTERVAL_MS;
|
|
27
30
|
this.state = probe
|
|
28
31
|
? { status: "pending" }
|
|
@@ -76,12 +79,15 @@ export class LimitsCache {
|
|
|
76
79
|
// moment ago; only report an error when there is nothing else to say.
|
|
77
80
|
if (this.state.status === "ready")
|
|
78
81
|
return;
|
|
79
|
-
this.state = {
|
|
82
|
+
this.state = {
|
|
83
|
+
status: "error",
|
|
84
|
+
reason: classify(err, this.credentialExpiresAt, this.now()),
|
|
85
|
+
};
|
|
80
86
|
}
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
/**
|
|
84
|
-
* Why the probe failed, in the
|
|
90
|
+
* Why the probe failed, in the distinctions a user can act on.
|
|
85
91
|
*
|
|
86
92
|
* Matched on the SDK's error class names rather than `instanceof`, so this stays
|
|
87
93
|
* a pure classification with no import of the transport into a module the TUI
|
|
@@ -89,11 +95,21 @@ export class LimitsCache {
|
|
|
89
95
|
* serve `/limits` at all — an older deployment, or a base URL pointed somewhere
|
|
90
96
|
* else entirely — and telling that user "you are offline" would send them
|
|
91
97
|
* debugging a network that is working fine.
|
|
98
|
+
*
|
|
99
|
+
* A 401 SPLITS ON EVIDENCE THE SERVER DOES NOT PROVIDE. The auth gate refuses an
|
|
100
|
+
* expired credential with exactly the 401 a wrong one gets, and says nothing
|
|
101
|
+
* about which — deliberately, so a prober cannot learn that a key was ever
|
|
102
|
+
* valid. So the split is made from the expiry the CLI recorded at login, and
|
|
103
|
+
* `expired` is claimed ONLY when that stored timestamp has actually passed:
|
|
104
|
+
* absence, or a timestamp this build cannot parse, leaves the answer as
|
|
105
|
+
* `unauthenticated` rather than guessing at a lifetime nobody stated.
|
|
92
106
|
*/
|
|
93
|
-
function classify(err) {
|
|
107
|
+
function classify(err, credentialExpiresAt, now) {
|
|
94
108
|
const e = err;
|
|
95
|
-
if (e?.name === "AuthError")
|
|
96
|
-
|
|
109
|
+
if (e?.name === "AuthError") {
|
|
110
|
+
const lifetime = classifyCredentialLifetime(credentialExpiresAt?.(), now);
|
|
111
|
+
return lifetime.state === "expired" ? "expired" : "unauthenticated";
|
|
112
|
+
}
|
|
97
113
|
if (e?.status === 404 || e?.status === 501)
|
|
98
114
|
return "unsupported";
|
|
99
115
|
return "unreachable";
|
package/dist/mcp/client.js
CHANGED
|
@@ -10,6 +10,22 @@ import { APP_NAME, APP_VERSION } from "../constants.js";
|
|
|
10
10
|
*/
|
|
11
11
|
/** The MCP protocol revision cruxy advertises. */
|
|
12
12
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
13
|
+
/**
|
|
14
|
+
* The three fields cruxy accepts from an advertised tool — and, by omission, the
|
|
15
|
+
* enforcement point for everything it refuses.
|
|
16
|
+
*
|
|
17
|
+
* `annotations` IS DELIBERATELY ABSENT. MCP lets a server describe its own tool
|
|
18
|
+
* with `readOnlyHint` / `destructiveHint` / `idempotentHint`, and zod strips
|
|
19
|
+
* unknown keys, so declaring only these three drops the block here at the wire
|
|
20
|
+
* boundary rather than carrying it inward for someone downstream to be tempted
|
|
21
|
+
* by. The spec's own rule is that clients "MUST consider tool annotations to be
|
|
22
|
+
* untrusted unless they come from trusted servers", and cruxy's MCP trust binds
|
|
23
|
+
* the INVOCATION rather than the code (`mcp/trust.ts`), so it cannot claim that
|
|
24
|
+
* exemption. Adding a field here would not merely widen a parse — it would put a
|
|
25
|
+
* server-controlled boolean within reach of the approval gate. The full argument
|
|
26
|
+
* is at `mcpRequest` in `approval/classify.ts`; `annotations-untrusted.test.ts`
|
|
27
|
+
* pins the property at both layers.
|
|
28
|
+
*/
|
|
13
29
|
const RawToolSchema = z.object({
|
|
14
30
|
name: z.string().min(1),
|
|
15
31
|
description: z.string().optional(),
|
package/dist/onboarding/flow.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { AuthError, NetworkError, createProvider } from "@cruxy/sdk";
|
|
1
|
+
import { AuthError, DeviceLoginClient, NetworkError, createProvider, } from "@cruxy/sdk";
|
|
2
2
|
import { themeForColor } from "../theme/index.js";
|
|
3
|
-
import { resolveApiKey, writeCredential } from "../config/index.js";
|
|
3
|
+
import { apiKeyEnvVar, readCredential, resolveApiKey, writeCredential, writeCredentialWithMeta, } from "../config/index.js";
|
|
4
4
|
import { newOnboardingState, readOnboardingState, writeOnboardingState, } from "./detect.js";
|
|
5
|
-
import { acquireKeyStep, firstWinStep, scaffoldStep } from "./steps.js";
|
|
5
|
+
import { acquireKeyStep, deviceLoginStep, firstWinStep, scaffoldStep, } from "./steps.js";
|
|
6
6
|
/**
|
|
7
7
|
* Orchestrate the onboarding steps (U.6) — resumable and idempotent. The key
|
|
8
8
|
* step is skipped when a key already resolves; the completion marker is written
|
|
@@ -17,14 +17,27 @@ export async function runOnboarding(opts) {
|
|
|
17
17
|
let apiKey = deps.resolveApiKey(provider);
|
|
18
18
|
// ── key (mandatory; skipped if already resolvable unless forceKey) ─────────
|
|
19
19
|
if (!apiKey || opts.forceKey) {
|
|
20
|
-
|
|
20
|
+
warnAboutOverwrite(io, deps);
|
|
21
|
+
// Device login is the default; paste stays reachable behind `--paste`, and
|
|
22
|
+
// is the only path when there is no device flow to run (a non-cruxy
|
|
23
|
+
// provider, whose keys these gateway routes know nothing about).
|
|
24
|
+
const useDevice = !opts.preferPaste && deps.deviceLogin !== undefined;
|
|
25
|
+
const result = useDevice
|
|
26
|
+
? await deviceLoginStep(io, deps, provider)
|
|
27
|
+
: await acquireKeyStep(io, deps, provider);
|
|
21
28
|
if (result.status === "aborted") {
|
|
22
29
|
return { completed: false, aborted: true };
|
|
23
30
|
}
|
|
24
31
|
if (result.status !== "ok") {
|
|
25
|
-
// Failed (unreachable / rejected) — surface guidance, no marker.
|
|
32
|
+
// Failed (unreachable / rejected / declined) — surface guidance, no marker.
|
|
26
33
|
if (result.message)
|
|
27
34
|
io.write(`${t.muted(result.message)}\n`);
|
|
35
|
+
if (useDevice) {
|
|
36
|
+
// The paste path exists for exactly the cases the device flow cannot
|
|
37
|
+
// serve, and someone who has just watched it fail is precisely who needs
|
|
38
|
+
// to know it is there.
|
|
39
|
+
io.write(t.muted("if you can't approve in a browser, `cruxy login --paste` takes a key directly.\n"));
|
|
40
|
+
}
|
|
28
41
|
return { completed: false, aborted: false };
|
|
29
42
|
}
|
|
30
43
|
apiKey = result.apiKey;
|
|
@@ -46,14 +59,53 @@ export async function runOnboarding(opts) {
|
|
|
46
59
|
io.write(`${t.success(t.strong(`${t.glyph.success} all set`))} — happy hacking.\n`);
|
|
47
60
|
return { completed: true, aborted: false, apiKey };
|
|
48
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Say, BEFORE anything is overwritten, the two things a login silently does.
|
|
64
|
+
*
|
|
65
|
+
* Both are facts a user cannot see and would otherwise discover the hard way:
|
|
66
|
+
* that this replaces a saved credential with no undo, and that an exported key
|
|
67
|
+
* will go on winning over whatever is saved here, so a perfectly successful
|
|
68
|
+
* login can change nothing at all about the next request.
|
|
69
|
+
*/
|
|
70
|
+
function warnAboutOverwrite(io, deps) {
|
|
71
|
+
const t = themeForColor(io.color);
|
|
72
|
+
const status = deps.credentialStatus?.();
|
|
73
|
+
if (!status)
|
|
74
|
+
return;
|
|
75
|
+
if (status.storedKey) {
|
|
76
|
+
io.write(t.muted("\nthis replaces the credential currently saved in ~/.cruxy — there is no undo.\n"));
|
|
77
|
+
}
|
|
78
|
+
if (status.shadowingEnvVar) {
|
|
79
|
+
io.write(t.warning(`\n${status.shadowingEnvVar} is set in your environment, and the environment always wins over the saved credential.\n`) +
|
|
80
|
+
t.muted(`whatever you sign in with will be saved, but your requests will keep using ${status.shadowingEnvVar} until you unset it.\n`));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
49
83
|
/**
|
|
50
84
|
* Build the production {@link OnboardingDeps}: live gateway validation, the
|
|
51
85
|
* credentials store, real state persistence, and a wall-clock timestamp.
|
|
52
86
|
*/
|
|
53
87
|
export function createDefaultDeps(opts) {
|
|
88
|
+
const provider = opts.config.model.provider;
|
|
89
|
+
// ONLY ON THE CRUXY PROVIDER, on the same reasoning `run.ts` uses to gate the
|
|
90
|
+
// limits probe: `/device/*` are cruxy gateway routes, and a
|
|
91
|
+
// bring-your-own-provider setup has no notion of them. Pointing them at
|
|
92
|
+
// someone else's base URL would be a request to a stranger — so there is no
|
|
93
|
+
// device flow to offer, and the key step falls through to the paste prompt.
|
|
94
|
+
const deviceLogin = provider === "cruxy"
|
|
95
|
+
? (io) => runDeviceLogin(opts.config, io)
|
|
96
|
+
: undefined;
|
|
54
97
|
return {
|
|
55
|
-
validateKey: (
|
|
98
|
+
validateKey: (p, apiKey) => validateKeyLive(p, apiKey, opts.config),
|
|
56
99
|
writeCredential,
|
|
100
|
+
writeCredentialWithMeta,
|
|
101
|
+
...(deviceLogin ? { deviceLogin } : {}),
|
|
102
|
+
credentialStatus: () => {
|
|
103
|
+
const envVar = apiKeyEnvVar(provider);
|
|
104
|
+
return {
|
|
105
|
+
storedKey: readCredential(provider) !== undefined,
|
|
106
|
+
...(process.env[envVar] ? { shadowingEnvVar: envVar } : {}),
|
|
107
|
+
};
|
|
108
|
+
},
|
|
57
109
|
resolveApiKey,
|
|
58
110
|
readState: () => readOnboardingState(),
|
|
59
111
|
writeState: (state) => writeOnboardingState(state),
|
|
@@ -62,6 +114,69 @@ export function createDefaultDeps(opts) {
|
|
|
62
114
|
now: () => new Date().toISOString(),
|
|
63
115
|
};
|
|
64
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Drive one real device login against the gateway.
|
|
119
|
+
*
|
|
120
|
+
* Everything that can go wrong on the wire collapses to `unreachable` here,
|
|
121
|
+
* because from the user's seat there is one answer to all of it — try again —
|
|
122
|
+
* and the flow's own outcomes (denied, expired, invalid) are the states that
|
|
123
|
+
* genuinely differ. The credential is returned, never written: persisting is the
|
|
124
|
+
* step's job, so there is exactly one place that decides what lands in the store.
|
|
125
|
+
*/
|
|
126
|
+
async function runDeviceLogin(config, io) {
|
|
127
|
+
const client = new DeviceLoginClient({ gatewayUrl: config.cruxy.gatewayUrl });
|
|
128
|
+
let session;
|
|
129
|
+
try {
|
|
130
|
+
session = await client.start();
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
return { status: "unreachable", message: unreachableMessage(err) };
|
|
134
|
+
}
|
|
135
|
+
io.prompt({
|
|
136
|
+
userCode: session.userCode,
|
|
137
|
+
verificationUri: session.verificationUri,
|
|
138
|
+
...(session.verificationUriComplete !== undefined
|
|
139
|
+
? { verificationUriComplete: session.verificationUriComplete }
|
|
140
|
+
: {}),
|
|
141
|
+
expiresInMs: session.expiresInMs,
|
|
142
|
+
});
|
|
143
|
+
let outcome;
|
|
144
|
+
try {
|
|
145
|
+
outcome = await client.poll(session, {
|
|
146
|
+
onProgress: (p) => io.waiting({
|
|
147
|
+
remainingMs: p.remainingMs,
|
|
148
|
+
...(p.throttled !== undefined ? { throttled: p.throttled } : {}),
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
return { status: "unreachable", message: unreachableMessage(err) };
|
|
154
|
+
}
|
|
155
|
+
switch (outcome.status) {
|
|
156
|
+
case "approved":
|
|
157
|
+
return {
|
|
158
|
+
status: "ok",
|
|
159
|
+
apiKey: outcome.credential.accessToken,
|
|
160
|
+
...(outcome.credential.expiresAt !== undefined
|
|
161
|
+
? { expiresAt: outcome.credential.expiresAt }
|
|
162
|
+
: {}),
|
|
163
|
+
...(outcome.credential.keyId !== undefined
|
|
164
|
+
? { keyId: outcome.credential.keyId }
|
|
165
|
+
: {}),
|
|
166
|
+
};
|
|
167
|
+
case "denied":
|
|
168
|
+
return { status: "denied" };
|
|
169
|
+
case "expired":
|
|
170
|
+
return { status: "expired" };
|
|
171
|
+
case "invalid":
|
|
172
|
+
return { status: "invalid" };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function unreachableMessage(err) {
|
|
176
|
+
return err instanceof NetworkError
|
|
177
|
+
? "couldn't reach the gateway — check your connection and run `cruxy login` again"
|
|
178
|
+
: "the gateway answered unexpectedly — run `cruxy login` to try again";
|
|
179
|
+
}
|
|
65
180
|
/**
|
|
66
181
|
* Validate a key with one cheap live call: start a 1-token stream and look at the
|
|
67
182
|
* first event. `AuthError` ⇒ invalid (bad key), `NetworkError` ⇒ unreachable;
|