@cruxy/cli 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +41 -2
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +185 -72
  7. package/dist/approval/classify.js +204 -0
  8. package/dist/approval/policy.js +41 -3
  9. package/dist/approval/prompt.js +49 -22
  10. package/dist/checkpoint/gate.js +12 -0
  11. package/dist/cli/commands/run.js +374 -227
  12. package/dist/cli/commands/usage.js +45 -45
  13. package/dist/cli/onboard.js +2 -1
  14. package/dist/cli/program.js +60 -18
  15. package/dist/cli/repl.js +67 -249
  16. package/dist/cli/session-commands.js +755 -0
  17. package/dist/cli/session-factory.js +198 -76
  18. package/dist/cli/suggest.js +77 -0
  19. package/dist/components/fuzzy.js +3 -3
  20. package/dist/components/input.js +17 -2
  21. package/dist/components/keys.js +27 -3
  22. package/dist/components/select.js +3 -3
  23. package/dist/config/project.js +53 -1
  24. package/dist/config/schema.js +49 -16
  25. package/dist/jobs/log-renderer.js +47 -0
  26. package/dist/onboarding/steps.js +13 -22
  27. package/dist/plan/approve.js +36 -24
  28. package/dist/plan/execute.js +9 -7
  29. package/dist/plan/render.js +10 -23
  30. package/dist/plan/service.js +4 -1
  31. package/dist/render/capabilities.js +30 -1
  32. package/dist/render/context-view.js +106 -0
  33. package/dist/render/diff.js +198 -12
  34. package/dist/render/index.js +31 -5
  35. package/dist/render/plain-renderer.js +38 -2
  36. package/dist/render/plan-view.js +108 -0
  37. package/dist/render/resize.js +7 -2
  38. package/dist/render/status-view.js +66 -0
  39. package/dist/render/test-view.js +89 -0
  40. package/dist/render/tty-renderer.js +40 -0
  41. package/dist/routing/index.js +1 -0
  42. package/dist/routing/router.js +13 -4
  43. package/dist/routing/session-model.js +109 -0
  44. package/dist/routing/types.js +14 -0
  45. package/dist/session/export.js +88 -0
  46. package/dist/session/index.js +20 -0
  47. package/dist/session/list.js +137 -0
  48. package/dist/session/log.js +137 -0
  49. package/dist/session/paths.js +73 -0
  50. package/dist/session/replay.js +169 -0
  51. package/dist/session/resume.js +128 -0
  52. package/dist/session/types.js +223 -0
  53. package/dist/subagent/orchestrator.js +23 -0
  54. package/dist/testing/run-tests-tool.js +8 -0
  55. package/dist/tools/registry.js +3 -3
  56. package/dist/tui/app.js +385 -0
  57. package/dist/tui/approval-overlay.js +160 -0
  58. package/dist/tui/context-gauge.js +48 -0
  59. package/dist/tui/git-status.js +63 -0
  60. package/dist/tui/index.js +10 -0
  61. package/dist/tui/layout.js +269 -0
  62. package/dist/tui/overlay.js +105 -0
  63. package/dist/tui/palette.js +73 -0
  64. package/dist/tui/panels.js +235 -0
  65. package/dist/tui/renderer.js +776 -0
  66. package/dist/tui/supports.js +20 -0
  67. package/dist/tui/tool-versions.js +129 -0
  68. package/dist/usage/collect.js +21 -3
  69. package/dist/usage/index.js +10 -2
  70. package/dist/usage/report.js +76 -0
  71. package/dist/usage/store.js +7 -1
  72. package/dist/usage/summary.js +106 -17
  73. package/dist/usage/types.js +73 -4
  74. package/dist/usage/weighted.js +77 -0
  75. package/dist/utils/git.js +50 -4
  76. package/package.json +2 -2
  77. package/dist/usage/cost.js +0 -29
@@ -0,0 +1,178 @@
1
+ import { COMPACTION_MARKER } from "./prompts.js";
2
+ /** Characters a single content block contributes to the estimate. */
3
+ function blockChars(block) {
4
+ switch (block.type) {
5
+ case "text":
6
+ return block.text.length;
7
+ case "tool_use":
8
+ return block.name.length + JSON.stringify(block.input).length;
9
+ case "tool_result":
10
+ return block.content.length;
11
+ }
12
+ }
13
+ /** Characters one message contributes. Block structure and role labels are ignored. */
14
+ export function messageChars(message) {
15
+ if (typeof message.content === "string")
16
+ return message.content.length;
17
+ let chars = 0;
18
+ for (const block of message.content)
19
+ chars += blockChars(block);
20
+ return chars;
21
+ }
22
+ /** Chars → tokens, the one place the chars/4 heuristic is applied. */
23
+ export function charsToTokens(chars) {
24
+ return Math.ceil(chars / 4);
25
+ }
26
+ /**
27
+ * Estimate the token footprint of a message list with a cheap chars/4 heuristic
28
+ * — no tokenizer dependency. Good enough to decide *when* to compact; exact
29
+ * counts are deferred to a later phase. Counts only textual payload (block
30
+ * structure and role labels are negligible and ignored).
31
+ */
32
+ export function estimateTokens(messages) {
33
+ let chars = 0;
34
+ for (const message of messages)
35
+ chars += messageChars(message);
36
+ return charsToTokens(chars);
37
+ }
38
+ /** Compute a reading from a history — pure, and shared by the gauge and `/context`. */
39
+ export function readContext(messages, budget) {
40
+ // Deliberately the SAME expression `compactIfOverThreshold` branches on: the
41
+ // reserve covers the system prompt and tool schemas that `estimateTokens`
42
+ // never sees, and omitting it here would under-report by ~4.5k tokens and let
43
+ // the panel read "comfortable" while the seam was about to compact.
44
+ const used = estimateTokens(messages) + budget.reserveTokens;
45
+ const total = budget.maxTokens;
46
+ return {
47
+ used,
48
+ total,
49
+ fraction: total <= 0 ? 1 : Math.min(1, Math.max(0, used / total)),
50
+ compactAt: Math.round(budget.compactThreshold * total),
51
+ };
52
+ }
53
+ /**
54
+ * Choose the boundary between the summarized prefix and the kept-recent tail.
55
+ *
56
+ * Tool-call integrity is the constraint: a `tool_use` (assistant) and its
57
+ * matching `tool_result` (the next user message) must never straddle the cut,
58
+ * or the next provider call breaks. A real user *prompt* (`role:"user"` with
59
+ * string content) only occurs at a completed turn boundary, where every prior
60
+ * tool exchange is already resolved — so the kept region must begin there. The
61
+ * synthetic compaction-summary user message is also string content, so a repeat
62
+ * compaction always finds at least the previous summary as a clean cut.
63
+ *
64
+ * Start from `length - keepRecentMessages` and walk *backwards* to the nearest
65
+ * such prompt: this keeps at least the recent floor and lands clean. Returns
66
+ * the cut index, or `null` if no safe boundary leaves a non-empty prefix (e.g.
67
+ * a single long in-progress turn — nothing safe to compact).
68
+ *
69
+ * Extracted from `Session` (P6 track 3) so `/context` can report what compaction
70
+ * would do by calling the function that decides what it does. A second
71
+ * implementation for the explanation would be free to be subtly wrong exactly
72
+ * where it mattered.
73
+ */
74
+ export function findCut(messages, keepRecentMessages) {
75
+ const start = messages.length - keepRecentMessages;
76
+ for (let i = start; i >= 1; i--) {
77
+ const message = messages[i];
78
+ if (message.role === "user" && typeof message.content === "string")
79
+ return i;
80
+ }
81
+ return null;
82
+ }
83
+ /** Which part a message belongs to. */
84
+ function classify(message) {
85
+ if (typeof message.content === "string") {
86
+ // The synthetic pair a previous compaction spliced in. Worth its own bucket:
87
+ // "40% of your context is a summary of earlier context" is a different fact
88
+ // from "40% is your prompts", and it is the one that says compaction has
89
+ // already run.
90
+ return message.content.startsWith(COMPACTION_MARKER)
91
+ ? "summaries"
92
+ : "prompts";
93
+ }
94
+ if (message.role === "user")
95
+ return "tool results";
96
+ return message.content.some((b) => b.type === "tool_use")
97
+ ? "tool calls"
98
+ : "assistant";
99
+ }
100
+ /** A one-line excerpt of a message, for recognition rather than reading. */
101
+ function excerpt(message, max = 60) {
102
+ let text;
103
+ if (typeof message.content === "string") {
104
+ text = message.content;
105
+ }
106
+ else {
107
+ const first = message.content[0];
108
+ text =
109
+ first === undefined
110
+ ? ""
111
+ : first.type === "text"
112
+ ? first.text
113
+ : first.type === "tool_use"
114
+ ? first.name
115
+ : first.content;
116
+ }
117
+ const flat = text.replace(/\s+/g, " ").trim();
118
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
119
+ }
120
+ /**
121
+ * Analyse a history against a budget. Pure; no I/O, no theme, no rendering.
122
+ *
123
+ * @param topN how many individual messages to name as the largest contributors.
124
+ */
125
+ export function contextReport(messages, budget, topN = 5) {
126
+ const charsByPart = new Map();
127
+ const contributors = [];
128
+ for (const [index, message] of messages.entries()) {
129
+ const chars = messageChars(message);
130
+ const part = classify(message);
131
+ const bucket = charsByPart.get(part) ?? { chars: 0, messages: 0 };
132
+ bucket.chars += chars;
133
+ bucket.messages += 1;
134
+ charsByPart.set(part, bucket);
135
+ contributors.push({ chars, message, index });
136
+ }
137
+ const parts = [...charsByPart.entries()]
138
+ .map(([part, b]) => ({
139
+ part,
140
+ tokens: charsToTokens(b.chars),
141
+ messages: b.messages,
142
+ }))
143
+ .filter((p) => p.tokens > 0)
144
+ .sort((a, b) => b.tokens - a.tokens);
145
+ const largest = contributors
146
+ .filter((c) => c.chars > 0)
147
+ .sort((a, b) => b.chars - a.chars)
148
+ .slice(0, topN)
149
+ .map((c) => ({
150
+ label: classify(c.message),
151
+ excerpt: excerpt(c.message),
152
+ tokens: charsToTokens(c.chars),
153
+ position: c.index + 1,
154
+ }));
155
+ const cut = findCut(messages, budget.keepRecentMessages);
156
+ const reading = readContext(messages, budget);
157
+ return {
158
+ reading,
159
+ messages: messages.length,
160
+ reserveTokens: budget.reserveTokens,
161
+ parts,
162
+ largest,
163
+ compaction: {
164
+ cut,
165
+ droppedMessages: cut ?? 0,
166
+ droppedTokens: cut === null ? 0 : estimateTokens(messages.slice(0, cut)),
167
+ keptMessages: cut === null ? messages.length : messages.length - cut,
168
+ keptTokens: cut === null
169
+ ? estimateTokens(messages)
170
+ : estimateTokens(messages.slice(cut)),
171
+ // Compared against the UNROUNDED product, exactly as
172
+ // `compactIfOverThreshold` does — `compactAt` is rounded for display, and
173
+ // a report claiming to say what compaction would do must not disagree
174
+ // with the seam over a fraction of a token.
175
+ overThreshold: reading.used > budget.compactThreshold * budget.maxTokens,
176
+ },
177
+ };
178
+ }
@@ -1,3 +1,4 @@
1
1
  export * from "./loop.js";
2
+ export * from "./mode.js";
2
3
  export * from "./session.js";
3
4
  export * from "./prompts.js";
@@ -24,6 +24,11 @@ export async function runAgent(args) {
24
24
  // gateway does not offer throws CRUXY_E_ROUTING_TIER_UNAVAILABLE here — so a
25
25
  // misrouted run never reaches provider.stream (no request sent with the wrong
26
26
  // tier), and never silently falls back to a different one.
27
+ //
28
+ // Resolving per RUN, not per iteration, is still right after P6 track 1 made
29
+ // the choice mutable: a `/model` mid-turn is impossible (the input loop has
30
+ // released stdin and is awaiting `session.send`), and a run that changed model
31
+ // between its own iterations would attribute one history to two tiers.
27
32
  const routed = args.router
28
33
  ? resolveTaskModel(args.router, args.taskClass ?? "main-turn")
29
34
  : null;
@@ -38,7 +43,11 @@ export async function runAgent(args) {
38
43
  }
39
44
  }
40
45
  /** The body of {@link runAgent}, split out so turn cleanup lives in one finally. */
41
- async function driveLoop(args, renderer, routed) {
46
+ async function driveLoop(args, renderer,
47
+ // `tier` is optional because a router may decline and send the request as
48
+ // `auto` — no tier was chosen client-side, so there is none to report until
49
+ // the gateway's routing frame arrives. See `resolveTaskModel`.
50
+ routed) {
42
51
  const { provider, registry, config, ctx } = args;
43
52
  const { logger } = ctx;
44
53
  // Work on a copy so we never mutate the caller's array as a side effect; the
@@ -116,6 +125,12 @@ async function driveLoop(args, renderer, routed) {
116
125
  // true and is recorded as a real 0.
117
126
  let sawUsage = false;
118
127
  const reqUsage = { input_tokens: 0, output_tokens: 0 };
128
+ // The served tier arrives on the stream's OPENING frame, but usage is only
129
+ // reported once the stream has closed — so it is held here across the whole
130
+ // request, exactly like `sawUsage`. Absent for backends that report no
131
+ // routing, which is why the fallback below still exists.
132
+ let servedTier;
133
+ let routingMode;
119
134
  // Live progress while waiting on the model; dismissed by the first delta.
120
135
  // Token context is whatever the loop has actually accumulated (U.4): zero
121
136
  // on the first turn → no figure shown, never a fabricated number.
@@ -133,6 +148,20 @@ async function driveLoop(args, renderer, routed) {
133
148
  ...(routed ? { model: routed.model } : {}),
134
149
  })) {
135
150
  switch (ev.type) {
151
+ case "routing":
152
+ servedTier = ev.routing.tier;
153
+ routingMode = ev.routing.mode;
154
+ // Publish to the renderer HERE, not at the `setPhase` above. That one
155
+ // fires before `provider.stream` is called, so the served tier is not
156
+ // knowable yet and it can only carry `routed?.tier` — what this run
157
+ // ASKED for. This frame is the first moment the backend's answer
158
+ // exists, and it is the answer worth showing: it resolves `auto`, and
159
+ // it reflects a downgrade the client never chose.
160
+ renderer?.servedRouting({
161
+ tier: ev.routing.tier,
162
+ ...(ev.routing.mode !== undefined ? { mode: ev.routing.mode } : {}),
163
+ });
164
+ break;
136
165
  case "text_delta":
137
166
  turnText += ev.text;
138
167
  renderer?.write(ev.text);
@@ -178,8 +207,18 @@ async function driveLoop(args, renderer, routed) {
178
207
  // event arrived, or `undefined` (unknown) when the provider reported none. A
179
208
  // stream that threw above never reaches here, so failed requests aren't
180
209
  // recorded with a misleading zero.
210
+ // THE BACKEND WINS on tier. `routed?.tier` is what this run ASKED for;
211
+ // `servedTier` is what the gateway says actually ran, which is not the same
212
+ // claim — a "auto" request is resolved server-side, and any request can be
213
+ // downgraded under budget pressure. Attributing tokens to the tier we asked
214
+ // for would misreport exactly the case worth knowing about. The client
215
+ // choice remains the fallback for backends that report no routing at all.
216
+ // `routingMode` travels alongside so a difference between the two is
217
+ // explainable after the fact ("auto_degraded") rather than an unexplained
218
+ // tier nobody selected.
181
219
  args.onRequestUsage?.({
182
- tier: routed?.tier,
220
+ tier: servedTier ?? routed?.tier,
221
+ ...(routingMode !== undefined ? { routingMode } : {}),
183
222
  usage: sawUsage ? { ...reqUsage } : undefined,
184
223
  });
185
224
  // ── Record the assistant turn ───────────────────────────────────────────
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Session modes (P5 track 3) — the one piece of state that says how much a turn
3
+ * does without asking.
4
+ *
5
+ * Two facts govern that, and before P5 they were kept in different places and at
6
+ * different lifetimes: plan mode lived on the `Session` (runtime, journaled,
7
+ * restored on resume) while auto-approve was a config key read from disk. The
8
+ * config key was also dead — nothing consumed `agent.autoApprove`, and
9
+ * `ApprovalConfigSchema` in the same file stated flatly that no auto-approve
10
+ * mode exists "not behind a footgun flag". One file promised something another
11
+ * refused to do.
12
+ *
13
+ * A mode is the cross product of the two, named. That is what makes it a
14
+ * SUBSUMPTION rather than a third source of truth: `Session` holds exactly one
15
+ * mode and derives both booleans from it, so there is no state in which plan
16
+ * mode and the mode indicator can disagree.
17
+ *
18
+ * Auto-approve is deliberately a RUNTIME mode and only that — never a config
19
+ * key. A flag on disk silently disarms every approval in every session that
20
+ * loads it, with nothing on screen to say so; a mode is chosen in the session it
21
+ * affects, shown while it is active, and gone when the session ends.
22
+ */
23
+ /**
24
+ * Cycle order, and the order every list of modes uses. Shift+Tab walks this
25
+ * ring; the last entry wraps to the first.
26
+ */
27
+ export const SESSION_MODES = [
28
+ "manual",
29
+ "auto-approve",
30
+ "plan",
31
+ "full-auto",
32
+ ];
33
+ /** The default for a session that says nothing — the pre-P5 behaviour exactly. */
34
+ export const DEFAULT_MODE = "manual";
35
+ /** The next mode in the ring. Total: every mode has a successor. */
36
+ export function nextMode(mode) {
37
+ const i = SESSION_MODES.indexOf(mode);
38
+ return SESSION_MODES[(i + 1) % SESSION_MODES.length];
39
+ }
40
+ /** Whether this mode proposes a plan before executing (C.31). */
41
+ export function modePlans(mode) {
42
+ return mode === "plan" || mode === "full-auto";
43
+ }
44
+ /** Whether this mode allows gated actions without prompting. */
45
+ export function modeAutoApproves(mode) {
46
+ return mode === "auto-approve" || mode === "full-auto";
47
+ }
48
+ /** Short label for the status line and the mode indicator. */
49
+ export const MODE_LABELS = {
50
+ manual: "manual",
51
+ "auto-approve": "auto-approve",
52
+ plan: "plan",
53
+ "full-auto": "full-auto",
54
+ };
55
+ /**
56
+ * One line saying what the mode will actually do, shown when it changes.
57
+ *
58
+ * A mode that skips approvals has to SAY it skips approvals at the moment it is
59
+ * switched on. The whole objection to the config flag was that it disarmed the
60
+ * gate with nothing on screen; a runtime mode that announced itself as vaguely
61
+ * as "auto-approve on" would reproduce the same problem more slowly.
62
+ *
63
+ * These lines must not overstate the mode either. Saying "including destructive
64
+ * ones" when the ceiling stops exactly those would teach the user to expect a
65
+ * silence that never comes — and, worse, teach them the gate is gone when it is
66
+ * not. Each auto line names the suppression AND its limit.
67
+ */
68
+ export function modeDescription(mode) {
69
+ switch (mode) {
70
+ case "manual":
71
+ return "every action asks first";
72
+ case "auto-approve":
73
+ return "reversible actions run WITHOUT asking — irreversible ones still ask";
74
+ case "plan":
75
+ return "propose a plan for approval, then ask before each action";
76
+ case "full-auto":
77
+ return "propose a plan, then run it WITHOUT asking — irreversible actions still ask";
78
+ }
79
+ }
80
+ /**
81
+ * Parse a mode from user input (`/mode <name>`), or null when it names nothing.
82
+ * Never guesses: a typo must not silently arm auto-approve.
83
+ */
84
+ export function parseMode(text) {
85
+ const want = text.trim().toLowerCase();
86
+ return SESSION_MODES.find((m) => m === want) ?? null;
87
+ }
88
+ /**
89
+ * Recover a mode from the two booleans an older session journaled.
90
+ *
91
+ * Sessions recorded before P5 carry `plan-mode` events and nothing else, so
92
+ * auto-approve reads false — which is right: it was not a thing that could be
93
+ * on. This is what lets a pre-P5 session resume into the mode it actually had.
94
+ */
95
+ export function modeFromFlags(plans, autoApproves) {
96
+ if (plans && autoApproves)
97
+ return "full-auto";
98
+ if (plans)
99
+ return "plan";
100
+ if (autoApproves)
101
+ return "auto-approve";
102
+ return "manual";
103
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * cruxy-code agent prompts.
2
+ * cruxy agent prompts.
3
3
  *
4
4
  * These are ORIGINAL prompts written for cruxy — not copied from any other
5
5
  * tool. The system prompt is assembled at runtime from a static core plus a