@cruxy/cli 1.7.1 → 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.
@@ -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";
@@ -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);
@@ -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 why.
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
+ }
@@ -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 is deferred to the first paint that
279
- * actually shows the panel — the rule the tool probes already follow — so a
280
- * user who keeps `limits` closed, or who runs a one-shot that never paints a
281
- * rail, never makes the request at all. The panel is a status surface; it does
282
- * not get to spend a round trip on someone who is not looking at it.
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
- const unchanged = this.served?.tier === routing.tier && this.served?.mode === routing.mode;
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
- * Re-read the account's headroom after a turn (P9) — the one moment it is
754
- * KNOWN to have moved, because this process just spent some of it.
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
- * Same rules as the git probe: never awaited, a failure leaves the last good
757
- * reading standing, and the cache coalesces. Unlike git it is also floored by
758
- * a minimum interval, because this one crosses the network and unlike git it
759
- * is skipped entirely while the panel is closed, since a user who has hidden
760
- * the figures has no use for the request that fetches them.
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 goes, the token counts stay.
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
- return tier === undefined ? phase : { ...phase, tier };
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)
@@ -72,6 +72,9 @@ export class UsageCollector {
72
72
  ...(req.routingMode !== undefined
73
73
  ? { routingMode: req.routingMode }
74
74
  : {}),
75
+ ...(req.reasoningEffort !== undefined
76
+ ? { reasoningEffort: req.reasoningEffort }
77
+ : {}),
75
78
  at: this.now(),
76
79
  });
77
80
  }
@@ -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.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {