@cruxy/cli 1.7.0 → 1.8.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 +9 -0
- package/dist/agent/permissions.js +72 -0
- package/dist/agent/session.js +30 -1
- package/dist/approval/classify.js +194 -44
- package/dist/approval/policy.js +142 -5
- package/dist/approval/prompt.js +52 -7
- package/dist/budget/session-budget.js +43 -7
- package/dist/checkpoint/capture.js +9 -12
- package/dist/checkpoint/coverage.js +70 -0
- package/dist/cli/command-catalog.js +5 -0
- package/dist/cli/commands/run.js +6 -1
- package/dist/cli/session-commands.js +59 -0
- package/dist/cli/session-factory.js +4 -0
- package/dist/errors/constructors.js +94 -1
- package/dist/errors/types.js +17 -0
- package/dist/indexing/walker.js +7 -2
- package/dist/limits/index.js +1 -1
- package/dist/limits/reduce.js +46 -4
- package/dist/mcp/bounds.js +21 -3
- package/dist/plan/policy.js +22 -6
- package/dist/render/permissions-view.js +88 -0
- package/dist/render/state.js +22 -3
- package/dist/subagent/orchestrator.js +157 -13
- package/dist/subagent/spawn-tool.js +30 -9
- package/dist/tools/file/apply-patch.js +164 -70
- package/dist/tools/index.js +1 -0
- package/dist/tools/schema-depth.js +67 -0
- package/dist/tui/index.js +1 -0
- package/dist/tui/limits-panel.js +6 -0
- package/dist/tui/panels.js +20 -1
- package/dist/tui/permissions-view.js +30 -0
- package/dist/tui/renderer.js +47 -15
- package/dist/usage/collect.js +3 -0
- package/dist/usage/types.js +10 -0
- package/package.json +2 -2
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider's tool-schema depth bound, and the counter that measures against
|
|
3
|
+
* it. ONE home for both, because there are two consumers that must agree: the
|
|
4
|
+
* CI gate on our own built-ins (`schema-depth.test.ts`) and the runtime bound on
|
|
5
|
+
* third-party MCP schemas (`../mcp/bounds.ts`). A counter that disagreed with
|
|
6
|
+
* the bound, or two copies of either drifting apart, would mean a schema that
|
|
7
|
+
* passes here and dies on the wire.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Nesting depth of the JSON Schema we put on the wire, counted the way the
|
|
11
|
+
* provider counts it: every container (object or array) is one level, scalars
|
|
12
|
+
* are free. So `{}` is 1, `{a: {}}` is 2, `{a: [{}]}` is 3.
|
|
13
|
+
*
|
|
14
|
+
* This is NOT the schema's semantic depth — JSON Schema's own wrapper keys
|
|
15
|
+
* (`properties`, `items`, `anyOf`) are containers too, so each semantic level a
|
|
16
|
+
* schema author writes costs TWO levels here, and a union costs two more on top
|
|
17
|
+
* (the `anyOf` array plus its member). That doubling is why a schema that reads
|
|
18
|
+
* as five levels deep to a human renders as eleven.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately ITERATIVE. The obvious recursive form is a stack-overflow vector
|
|
21
|
+
* on the MCP path: `mcp.maxSchemaBytes` is the only thing bounding a third-party
|
|
22
|
+
* schema before it reaches this counter, and that setting has no upper bound in
|
|
23
|
+
* config (`config/schema.ts`), so a user who raises it hands a hostile server a
|
|
24
|
+
* crash. An explicit stack cannot blow up, whatever the cap is set to.
|
|
25
|
+
*/
|
|
26
|
+
export function schemaDepth(node) {
|
|
27
|
+
const isContainer = (n) => Array.isArray(n) || (typeof n === "object" && n !== null);
|
|
28
|
+
// Scalars are free — a non-container root is depth 0, not 1.
|
|
29
|
+
if (!isContainer(node))
|
|
30
|
+
return 0;
|
|
31
|
+
let deepest = 0;
|
|
32
|
+
const pending = [{ node, level: 1 }];
|
|
33
|
+
while (pending.length > 0) {
|
|
34
|
+
// Non-null: guarded by the loop condition.
|
|
35
|
+
const { node: current, level } = pending.pop();
|
|
36
|
+
if (level > deepest)
|
|
37
|
+
deepest = level;
|
|
38
|
+
// Arrays contribute their items, objects their values; scalar children are
|
|
39
|
+
// never pushed, which is exactly "scalars are free".
|
|
40
|
+
const children = Array.isArray(current)
|
|
41
|
+
? current
|
|
42
|
+
: Object.values(current);
|
|
43
|
+
for (const child of children) {
|
|
44
|
+
if (isContainer(child))
|
|
45
|
+
pending.push({ node: child, level: level + 1 });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return deepest;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The provider rejects a tool schema nested 8 or more levels deep, and it
|
|
52
|
+
* rejects the whole REQUEST — one over-deep tool takes down every turn of every
|
|
53
|
+
* session, which is what shipped in 1.7.0. So the bound belongs in CI, not in a
|
|
54
|
+
* field report: any built-in whose rendered schema reaches 8 fails there.
|
|
55
|
+
*
|
|
56
|
+
* THIS NUMBER IS NOT OURS TO CHOOSE. It mirrors `maxSchemaDepth` in the gateway
|
|
57
|
+
* (cruxy-ai/api, `internal/httpx/structured.go`), which its tool validator
|
|
58
|
+
* applies to every `parameters` schema before any upstream call. Raising it here
|
|
59
|
+
* does not raise it there — it only moves the failure from a red CI run to every
|
|
60
|
+
* user's terminal. If a schema cannot fit, the schema changes.
|
|
61
|
+
*
|
|
62
|
+
* The bound is EXCLUSIVE: a schema is safe at `MAX_SCHEMA_DEPTH - 1` and dies at
|
|
63
|
+
* `MAX_SCHEMA_DEPTH`. Both consumers must spell that the same way — the CI gate
|
|
64
|
+
* asserts `depth < MAX_SCHEMA_DEPTH`, the MCP bound trips on
|
|
65
|
+
* `depth >= MAX_SCHEMA_DEPTH`.
|
|
66
|
+
*/
|
|
67
|
+
export const MAX_SCHEMA_DEPTH = 8;
|
package/dist/tui/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { ToolVersions, parseVersion, } from "./tool-versions.js";
|
|
|
5
5
|
export { GitStatusCache, WorkspaceGitCache, } from "./git-status.js";
|
|
6
6
|
export { WorkspaceDiskCache } from "./disk-status.js";
|
|
7
7
|
export { createOverviewView } from "./overview.js";
|
|
8
|
+
export { createPermissionsView } from "./permissions-view.js";
|
|
8
9
|
export { createGitView, gitViewLines, GIT_VIEW_MAX_FILES, } from "./git-view.js";
|
|
9
10
|
export { createTasksView, tasksViewLines, TASKS_VIEW_MAX_JOBS, TASKS_VIEW_TAIL, } from "./tasks-view.js";
|
|
10
11
|
export { createSettingsView, settingsViewLines, formatSettingValue, } from "./settings-view.js";
|
package/dist/tui/limits-panel.js
CHANGED
|
@@ -101,6 +101,12 @@ const STALE_AFTER_MS = 5 * 60_000;
|
|
|
101
101
|
* cap on every self-serve tier, is the window actually about to refuse the next
|
|
102
102
|
* request. The bar is the thing that stops you, and the line under it names
|
|
103
103
|
* which window that is so the figure is never ambiguous.
|
|
104
|
+
*
|
|
105
|
+
* `bindingWindow` HERE AND `scarcestWindow` FOR ADMISSION, deliberately not one
|
|
106
|
+
* selector for both (cli#212). A bar states a proportion, so the window it draws
|
|
107
|
+
* must be the one whose proportion is the warning; an admission check spends
|
|
108
|
+
* tokens, so it must take the smaller REMAINDER, which is sometimes the other
|
|
109
|
+
* window. `limits/reduce.ts` works the disagreement through.
|
|
104
110
|
*/
|
|
105
111
|
function poolLines(theme, pool, now) {
|
|
106
112
|
const binding = bindingWindow(pool.monthly, pool.burst);
|
package/dist/tui/panels.js
CHANGED
|
@@ -135,12 +135,24 @@ function describeRoutingMode(mode) {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
/**
|
|
138
|
-
* The model panel's body (P4 track 4): which tier is actually running, and
|
|
138
|
+
* The model panel's body (P4 track 4): which tier is actually running, why, and
|
|
139
|
+
* — since the effort readout — how hard it reasoned.
|
|
139
140
|
*
|
|
140
141
|
* BEFORE THE FIRST TURN there is no served tier — the gateway has not answered
|
|
141
142
|
* — so the panel shows the CONFIGURED value and says it is unresolved. Blank
|
|
142
143
|
* would be worse than useless here: `auto` is the default, and a user looking
|
|
143
144
|
* at an empty model panel cannot tell configuration from breakage.
|
|
145
|
+
*
|
|
146
|
+
* EFFORT GETS ITS OWN ROW rather than sharing the mode's, for the reason
|
|
147
|
+
* `gitPanelLines` gives above: at {@link RAIL_COLS} the two do not fit
|
|
148
|
+
* together. "downgraded (budget)" is already 19 of 24 columns, so pairing them
|
|
149
|
+
* would truncate the effort away in exactly the case — a downgrade — where a
|
|
150
|
+
* user most wants to see what they actually got. A panel that grows by one row
|
|
151
|
+
* is handled honestly by `stackPanels`; a value clipped mid-word is not.
|
|
152
|
+
*
|
|
153
|
+
* The row is present only when the gateway reported an effort. Absence here is
|
|
154
|
+
* the gateway saying nothing, which is not a fact about the request and does not
|
|
155
|
+
* earn a row; a reported `none` IS a fact about the request and gets one.
|
|
144
156
|
*/
|
|
145
157
|
export function modelPanelLines(theme, state) {
|
|
146
158
|
const { configured, served } = state;
|
|
@@ -154,6 +166,13 @@ export function modelPanelLines(theme, state) {
|
|
|
154
166
|
served.mode === "auto_degraded"
|
|
155
167
|
? theme.warning(describeRoutingMode(served.mode))
|
|
156
168
|
: theme.muted(describeRoutingMode(served.mode)),
|
|
169
|
+
// Labelled, like every other rail row: a bare `high` under a tier name says
|
|
170
|
+
// nothing about what is high. Muted throughout — effort is reporting, not a
|
|
171
|
+
// condition to act on, and styling a value the user did not choose as a
|
|
172
|
+
// warning would imply otherwise.
|
|
173
|
+
...(served.reasoningEffort !== undefined
|
|
174
|
+
? [theme.muted(`effort ${served.reasoningEffort}`)]
|
|
175
|
+
: []),
|
|
157
176
|
];
|
|
158
177
|
}
|
|
159
178
|
/**
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { permissionsReport } from "../agent/permissions.js";
|
|
2
|
+
import { permissionsReportLines } from "../render/permissions-view.js";
|
|
3
|
+
/**
|
|
4
|
+
* The Permissions view (cli#196) — the second caller of `permissionsReport`,
|
|
5
|
+
* and as thin as the Overview view is, for the same reason: the screen is
|
|
6
|
+
* already built, this only gives it somewhere to LIVE.
|
|
7
|
+
*
|
|
8
|
+
* A command prints once into the scrollback and is gone by the next turn, which
|
|
9
|
+
* is the wrong lifetime for this particular fact. A standing grant is standing:
|
|
10
|
+
* it outlives the turn that made it, and the whole failure it guards against is
|
|
11
|
+
* a user forgetting what they already said yes to. A view is what lets "what am
|
|
12
|
+
* I currently allowing" be a thing you can look at rather than a thing you have
|
|
13
|
+
* to remember to ask.
|
|
14
|
+
*
|
|
15
|
+
* No `refresh` and no `live`. Every value is a field read over in-memory state
|
|
16
|
+
* — the mode and an array — so `lines` may recompute it at paint time, and the
|
|
17
|
+
* two things that move it (a grant taken at a prompt, a `/permissions revoke`)
|
|
18
|
+
* are both events the renderer already repaints for. A pulse would be a
|
|
19
|
+
* standing cost for a screen that changes a handful of times a session.
|
|
20
|
+
*
|
|
21
|
+
* READ-ONLY, deliberately. Revoking is `/permissions revoke <n>`: a view takes
|
|
22
|
+
* no input, and the key lease belongs to overlays.
|
|
23
|
+
*/
|
|
24
|
+
export function createPermissionsView(session) {
|
|
25
|
+
return {
|
|
26
|
+
id: "permissions",
|
|
27
|
+
label: "permissions",
|
|
28
|
+
lines: (theme, cols) => permissionsReportLines(permissionsReport(session), theme, cols),
|
|
29
|
+
};
|
|
30
|
+
}
|
package/dist/tui/renderer.js
CHANGED
|
@@ -275,11 +275,17 @@ export class TuiRenderer {
|
|
|
275
275
|
* Attach the limits cache (P9). Set after construction like the context gauge,
|
|
276
276
|
* because the credential it reads with is resolved alongside the session.
|
|
277
277
|
*
|
|
278
|
-
* NO PROBE HAPPENS HERE. The first read
|
|
279
|
-
* actually shows the panel — the rule the tool probes
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
278
|
+
* NO PROBE HAPPENS HERE. The first read this class kicks off is deferred to
|
|
279
|
+
* the first paint that actually shows the panel — the rule the tool probes
|
|
280
|
+
* already follow — because a panel is a status surface and does not get to
|
|
281
|
+
* spend a round trip on someone who is not looking at it.
|
|
282
|
+
*
|
|
283
|
+
* That is a statement about THIS class only, and it stopped being a statement
|
|
284
|
+
* about the process (cli#212): the same cache is the session's admission
|
|
285
|
+
* denominator, and `Session.send` refreshes it every turn whether or not any
|
|
286
|
+
* panel is open. Opening `limits` on turn ten therefore shows a live figure
|
|
287
|
+
* rather than a first probe — the reading was already being kept current for
|
|
288
|
+
* a reason that has nothing to do with the rail.
|
|
283
289
|
*/
|
|
284
290
|
attachLimits(limits) {
|
|
285
291
|
this.limits = limits;
|
|
@@ -714,7 +720,13 @@ export class TuiRenderer {
|
|
|
714
720
|
servedRouting(routing) {
|
|
715
721
|
if (this.closed)
|
|
716
722
|
return;
|
|
717
|
-
|
|
723
|
+
// Every field the panel and the status line actually draw is compared. A
|
|
724
|
+
// partial check here is not a missed repaint, it is a WRONG one held: the
|
|
725
|
+
// effort would keep reporting the previous request's value while the tier
|
|
726
|
+
// beside it described this one, and the two would be read as one statement.
|
|
727
|
+
const unchanged = this.served?.tier === routing.tier &&
|
|
728
|
+
this.served?.mode === routing.mode &&
|
|
729
|
+
this.served?.reasoningEffort === routing.reasoningEffort;
|
|
718
730
|
if (unchanged)
|
|
719
731
|
return;
|
|
720
732
|
this.served = routing;
|
|
@@ -750,14 +762,21 @@ export class TuiRenderer {
|
|
|
750
762
|
this.refreshViews();
|
|
751
763
|
}
|
|
752
764
|
/**
|
|
753
|
-
*
|
|
754
|
-
*
|
|
765
|
+
* REPAINT the headroom panel once the turn's reading lands (P9) — no longer
|
|
766
|
+
* the thing that decides whether the reading happens at all (cli#212).
|
|
767
|
+
*
|
|
768
|
+
* `Session.send` now drives `refresh` on every turn, because the reading is
|
|
769
|
+
* the denominator fan-out admission divides by and not merely a figure on a
|
|
770
|
+
* panel. That retires the argument this method used to make for skipping the
|
|
771
|
+
* probe while the panel is closed: a user who hides the figures still spends
|
|
772
|
+
* the window, and the check that bounds them still has to read it.
|
|
755
773
|
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
* the
|
|
774
|
+
* What is left is a repaint hook, so the open-panel gate stays — there is
|
|
775
|
+
* nothing to repaint for a panel nobody is looking at. Calling `refresh` is
|
|
776
|
+
* how it waits: the cache coalesces onto the turn's in-flight request, so this
|
|
777
|
+
* resolves exactly when that one lands rather than starting a second. Same
|
|
778
|
+
* rules as the git probe otherwise — never awaited, and a failure leaves the
|
|
779
|
+
* last good reading standing.
|
|
761
780
|
*/
|
|
762
781
|
refreshLimits() {
|
|
763
782
|
const limits = this.limits;
|
|
@@ -1111,13 +1130,26 @@ export class TuiRenderer {
|
|
|
1111
1130
|
return phase;
|
|
1112
1131
|
if (this.modelPanelVisible(width)) {
|
|
1113
1132
|
// Rebuilt rather than destructured, so the fields kept are stated rather
|
|
1114
|
-
// than implied: only the tier
|
|
1133
|
+
// than implied: only the tier and the effort go, the token counts stay.
|
|
1134
|
+
// Effort travels with the tier because it is part of the same claim — the
|
|
1135
|
+
// panel says both, and a line reporting `effort high` next to a suppressed
|
|
1136
|
+
// tier would be an orphaned qualifier for a fact on another surface.
|
|
1115
1137
|
return phase.tokens === undefined
|
|
1116
1138
|
? { kind: "thinking" }
|
|
1117
1139
|
: { kind: "thinking", tokens: phase.tokens };
|
|
1118
1140
|
}
|
|
1141
|
+
// The status line is the only surface left, so it carries both. `effort`
|
|
1142
|
+
// has no asked-for counterpart to fall back to the way `tier` does: the
|
|
1143
|
+
// client never chooses one, so it exists only if the gateway reported it.
|
|
1119
1144
|
const tier = this.served?.tier ?? phase.tier;
|
|
1120
|
-
|
|
1145
|
+
const effort = this.served?.reasoningEffort;
|
|
1146
|
+
if (tier === undefined && effort === undefined)
|
|
1147
|
+
return phase;
|
|
1148
|
+
return {
|
|
1149
|
+
...phase,
|
|
1150
|
+
...(tier !== undefined ? { tier } : {}),
|
|
1151
|
+
...(effort !== undefined ? { effort } : {}),
|
|
1152
|
+
};
|
|
1121
1153
|
}
|
|
1122
1154
|
statusLine(width) {
|
|
1123
1155
|
if (this.rawStatus !== null)
|
package/dist/usage/collect.js
CHANGED
package/dist/usage/types.js
CHANGED
|
@@ -100,6 +100,16 @@ export const UsageEntrySchema = z
|
|
|
100
100
|
* whether that was routing working as asked or a budget downgrade.
|
|
101
101
|
*/
|
|
102
102
|
routingMode: z.string().optional(),
|
|
103
|
+
/**
|
|
104
|
+
* The reasoning effort the gateway says it applied, verbatim from
|
|
105
|
+
* `routing.reasoning_effort`. Recorded, not displayed — like
|
|
106
|
+
* {@link routingMode}, this is forensic: it is what makes two requests on
|
|
107
|
+
* the same tier costing very different amounts explainable after the fact.
|
|
108
|
+
*
|
|
109
|
+
* Absent means the gateway reported nothing, NOT that no reasoning ran; a
|
|
110
|
+
* gateway saying so writes the literal `"none"`.
|
|
111
|
+
*/
|
|
112
|
+
reasoningEffort: z.string().optional(),
|
|
103
113
|
/** ISO-8601 timestamp the request completed. */
|
|
104
114
|
at: z.string(),
|
|
105
115
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cruxy/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "an agentic coding CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"undici": "^6.21.0",
|
|
37
37
|
"zod": "^3.23.8",
|
|
38
38
|
"zod-to-json-schema": "^3.23.5",
|
|
39
|
-
"@cruxy/sdk": "0.
|
|
39
|
+
"@cruxy/sdk": "0.6.0"
|
|
40
40
|
},
|
|
41
41
|
"optionalDependencies": {
|
|
42
42
|
"better-sqlite3": "^12.11.1"
|