@cruxy/cli 1.6.0 → 1.7.1
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 +117 -1
- package/dist/agent/session.js +1 -5
- package/dist/components/frame.js +39 -1
- package/dist/errors/constructors.js +115 -5
- package/dist/errors/types.js +17 -0
- package/dist/lsp/index.js +1 -1
- package/dist/lsp/registry.js +28 -10
- package/dist/plan/service.js +26 -1
- package/dist/plan/submit-plan.js +11 -0
- package/dist/render/capabilities.js +9 -2
- package/dist/render/index.js +6 -1
- package/dist/tools/file/apply-patch.js +164 -70
- package/dist/tui/app.js +18 -3
- package/dist/tui/index.js +3 -2
- package/dist/tui/mode-ring.js +84 -0
- package/dist/tui/renderer.js +145 -4
- package/dist/tui/restore.js +137 -0
- package/dist/tui/supports.js +22 -0
- package/dist/tui/tool-versions.js +119 -18
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -143,6 +143,122 @@ default 10; disable with `checkpoint.enabled = false`).
|
|
|
143
143
|
**Boundary:** checkpoints cover working-tree files only. Commits, pushes, and
|
|
144
144
|
PRs made during a run are never undone — the rollback preview says so.
|
|
145
145
|
|
|
146
|
+
## Terminal & accessibility
|
|
147
|
+
|
|
148
|
+
Cruxy probes the terminal once per run and reduces it to a handful of
|
|
149
|
+
capabilities — color, unicode, cursor control, motion, screen-reader mode, size
|
|
150
|
+
— which every rendered surface resolves a theme from. That is why degradation is
|
|
151
|
+
consistent: there is one place that decides, not one decision per panel.
|
|
152
|
+
|
|
153
|
+
Each axis has an environment override, and **the axes are independent**. Turning
|
|
154
|
+
color off does not change which glyphs are used; asking for ASCII glyphs does not
|
|
155
|
+
turn color off. A `NO_COLOR` terminal still gets `✓`, a colored `CRUXY_ASCII`
|
|
156
|
+
terminal gets a green `[ok]`.
|
|
157
|
+
|
|
158
|
+
### Color
|
|
159
|
+
|
|
160
|
+
| Variable | Effect |
|
|
161
|
+
| ------------- | ---------------------------------------------------------------- |
|
|
162
|
+
| `NO_COLOR` | No ANSI color anywhere. Not muted color — **zero** escape bytes. |
|
|
163
|
+
| `FORCE_COLOR` | Keep color even when stdout is a pipe or a file. |
|
|
164
|
+
|
|
165
|
+
Color is on by default only when the stream is a TTY. `TERM=dumb` forces it off
|
|
166
|
+
regardless.
|
|
167
|
+
|
|
168
|
+
### Glyphs
|
|
169
|
+
|
|
170
|
+
| Variable | Effect |
|
|
171
|
+
| ------------- | ----------------------------------------------------------------------------------------- |
|
|
172
|
+
| `CRUXY_ASCII` | Use the ASCII glyph table: `[ok]` `[x]` `[ ]` `->` `...` in place of `✓` `✗` `○` `→` `…`. |
|
|
173
|
+
|
|
174
|
+
Also implied by `TERM=dumb`. Piped output keeps unicode — writing `✓` to a file
|
|
175
|
+
is fine, and forcing ASCII there would change long-standing behaviour for
|
|
176
|
+
everything that already parses cruxy's output.
|
|
177
|
+
|
|
178
|
+
### Screen readers
|
|
179
|
+
|
|
180
|
+
| Variable | Effect |
|
|
181
|
+
| --------------------- | ---------------------------------------------- |
|
|
182
|
+
| `CRUXY_SCREEN_READER` | Screen-reader mode. |
|
|
183
|
+
| `ACCESSIBLE` | The same thing, under the ecosystem-wide name. |
|
|
184
|
+
|
|
185
|
+
In this mode:
|
|
186
|
+
|
|
187
|
+
- **status glyphs become words** — `✓ read_file` is announced as `done
|
|
188
|
+
read_file`, and `◐` / `○` become `working` / `pending`, so a status mark is
|
|
189
|
+
never read out as bare punctuation;
|
|
190
|
+
- **decorative marks collapse to nothing** — the `↻` cache marker, the text
|
|
191
|
+
cursor bar, and the cells of a progress bar all render as empty, because a run
|
|
192
|
+
of block characters announces as noise and the percentage beside it already
|
|
193
|
+
carries the whole meaning;
|
|
194
|
+
- **animation is off** — screen-reader mode implies reduced motion, since a
|
|
195
|
+
spinner has no live region to animate into.
|
|
196
|
+
|
|
197
|
+
**It is opt-in and never inferred.** Cruxy has no way to detect a screen reader,
|
|
198
|
+
and inferring one from a non-TTY stdout would reword every piped and CI run in
|
|
199
|
+
the world:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
export CRUXY_SCREEN_READER=1
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Motion
|
|
206
|
+
|
|
207
|
+
| Variable | Effect |
|
|
208
|
+
| ---------------------- | ---------------------------------------------- |
|
|
209
|
+
| `NO_MOTION` | The ecosystem-wide reduced-motion signal. |
|
|
210
|
+
| `CRUXY_REDUCED_MOTION` | The explicit cruxy knob. |
|
|
211
|
+
| `CRUXY_NO_SPINNER` | Kept as an alias; flows through the same axis. |
|
|
212
|
+
|
|
213
|
+
Any of them disables the frame clock outright — nothing is scheduled and no
|
|
214
|
+
frame ever ticks, so every animation collapses to its static end-state, drawn
|
|
215
|
+
once. Reduced motion is also implied by screen-reader mode.
|
|
216
|
+
|
|
217
|
+
### The alternate screen
|
|
218
|
+
|
|
219
|
+
| Variable | Effect |
|
|
220
|
+
| --------------------- | ------------------------------------------------------------------------------ |
|
|
221
|
+
| `CRUXY_NO_ALT_SCREEN` | Keep the full-screen TUI in the normal buffer instead of the alternate screen. |
|
|
222
|
+
|
|
223
|
+
By default the TUI runs on the terminal's alternate screen — the second buffer
|
|
224
|
+
`less` and `vim` use. Leaving it restores the normal buffer byte for byte, so
|
|
225
|
+
the prompt you started cruxy from comes back exactly as it was, with no
|
|
226
|
+
frame-shaped hole in your scrollback.
|
|
227
|
+
|
|
228
|
+
On a clean exit the last dozen lines of the conversation are echoed into the
|
|
229
|
+
normal buffer, so the session survives in the shell's scrollback rather than
|
|
230
|
+
being discarded with the alternate screen. The full transcript is the session
|
|
231
|
+
log; this is the tail.
|
|
232
|
+
|
|
233
|
+
The opt-out exists because the alternate screen is not universally available or
|
|
234
|
+
wanted — multiplexers and emulators can be configured to refuse it, some
|
|
235
|
+
capture-and-replay tooling reads only the normal buffer, and you may simply
|
|
236
|
+
prefer your shell to keep the frame. It changes nothing else about the TUI, and
|
|
237
|
+
it is ignored where the TUI does not run at all (a pipe, a screen reader,
|
|
238
|
+
`TERM=dumb`).
|
|
239
|
+
|
|
240
|
+
However cruxy exits — quit, `kill -TERM`, a hangup when the window closes, an
|
|
241
|
+
uncaught error — the terminal is handed back: the frame erased, the cursor
|
|
242
|
+
shown, the alternate screen left.
|
|
243
|
+
|
|
244
|
+
### Size
|
|
245
|
+
|
|
246
|
+
| Variable | Effect |
|
|
247
|
+
| --------- | -------------------------------------------- |
|
|
248
|
+
| `COLUMNS` | Terminal width, overriding what it reports. |
|
|
249
|
+
| `LINES` | Terminal height, overriding what it reports. |
|
|
250
|
+
|
|
251
|
+
These win over the stream's own values, which is what makes
|
|
252
|
+
`COLUMNS=100 cruxy …` work in CI where the terminal reports no size at all. They
|
|
253
|
+
fall back to 80 × 24. Unlike the flags above they take a **positive integer**; a
|
|
254
|
+
value that is not one is ignored rather than treated as "set".
|
|
255
|
+
|
|
256
|
+
### The "set" convention
|
|
257
|
+
|
|
258
|
+
Every variable except `COLUMNS` / `LINES` follows the `NO_COLOR` rule: **any
|
|
259
|
+
non-empty value counts as set.** `CRUXY_ASCII=1` and `CRUXY_ASCII=false` both
|
|
260
|
+
enable ASCII glyphs — to turn one off, unset it or set it to the empty string.
|
|
261
|
+
|
|
146
262
|
## Errors & exit codes
|
|
147
263
|
|
|
148
264
|
Every user-facing error prints a title, the cause (when known), concrete next
|
|
@@ -159,7 +275,7 @@ branch on them:
|
|
|
159
275
|
| `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
|
|
160
276
|
| `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
|
|
161
277
|
| `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
|
|
162
|
-
| `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API`
|
|
278
|
+
| `6` | api | `CRUXY_E_API`, `CRUXY_E_API_REQUEST_REJECTED`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
|
|
163
279
|
| `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE`, `CRUXY_E_CHECKPOINT_FAILED` |
|
|
164
280
|
| `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_EMBEDDER_DOWNLOAD_FAILED`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
|
|
165
281
|
| `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
|
package/dist/agent/session.js
CHANGED
|
@@ -10,7 +10,7 @@ import { UsageCollector, accumulateCacheTokens, } from "../usage/index.js";
|
|
|
10
10
|
import { Budget } from "./budget.js";
|
|
11
11
|
import { estimateTokens, findCut } from "./context.js";
|
|
12
12
|
import { runAgent, } from "./loop.js";
|
|
13
|
-
import { DEFAULT_MODE, modeAutoApproves, modePlans,
|
|
13
|
+
import { DEFAULT_MODE, modeAutoApproves, modePlans, parseMode, } from "./mode.js";
|
|
14
14
|
import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
|
|
15
15
|
/**
|
|
16
16
|
* Re-exported from `agent/context.ts`, where the estimate now lives beside the
|
|
@@ -189,10 +189,6 @@ export class Session {
|
|
|
189
189
|
this.args.recorder?.mode(this.mode);
|
|
190
190
|
return this.mode;
|
|
191
191
|
}
|
|
192
|
-
/** Advance one step around the mode ring (Shift+Tab). Returns the new mode. */
|
|
193
|
-
cycleMode() {
|
|
194
|
-
return this.setMode(nextMode(this.mode));
|
|
195
|
-
}
|
|
196
192
|
/** Whether this session proposes a plan before executing (C.31). */
|
|
197
193
|
getPlanMode() {
|
|
198
194
|
return modePlans(this.mode);
|
package/dist/components/frame.js
CHANGED
|
@@ -4,9 +4,15 @@ import { resolveTheme } from "../theme/index.js";
|
|
|
4
4
|
const CLEAR_LINE = "\r\x1b[2K";
|
|
5
5
|
/** Move the cursor up one row. */
|
|
6
6
|
const CURSOR_UP = "\x1b[1A";
|
|
7
|
+
/** Erase the whole row the cursor is on, wherever the cursor is in it. */
|
|
8
|
+
const ERASE_ROW = "\x1b[2K";
|
|
9
|
+
/** Park the cursor at row 1, column 1. */
|
|
10
|
+
const CURSOR_HOME = "\x1b[H";
|
|
11
|
+
/** Park the cursor at the start of an absolute row (1-based). */
|
|
12
|
+
const cursorToRow = (row) => `\x1b[${row};1H`;
|
|
7
13
|
/** The visible text of a possibly-styled row (re-exported from the U.12 home). */
|
|
8
14
|
export { stripAnsi };
|
|
9
|
-
export function createFrame(write, caps) {
|
|
15
|
+
export function createFrame(write, caps, opts = {}) {
|
|
10
16
|
let drawn = 0;
|
|
11
17
|
let lastLines = [];
|
|
12
18
|
const ellipsis = resolveTheme(caps).glyph.ellipsis;
|
|
@@ -20,6 +26,16 @@ export function createFrame(write, caps) {
|
|
|
20
26
|
const erase = () => {
|
|
21
27
|
if (drawn === 0)
|
|
22
28
|
return;
|
|
29
|
+
if (opts.absolute) {
|
|
30
|
+
// Every row named, then home — nothing is inferred from where the cursor
|
|
31
|
+
// happens to be, which is the whole point of the mode.
|
|
32
|
+
let out = "";
|
|
33
|
+
for (let row = 1; row <= drawn; row++)
|
|
34
|
+
out += cursorToRow(row) + ERASE_ROW;
|
|
35
|
+
write(out + CURSOR_HOME);
|
|
36
|
+
drawn = 0;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
23
39
|
// Cursor sits at the end of the last drawn row: clear it, then walk up
|
|
24
40
|
// clearing each prior row, ending at column 0 of the first frame row.
|
|
25
41
|
let out = CLEAR_LINE;
|
|
@@ -28,7 +44,29 @@ export function createFrame(write, caps) {
|
|
|
28
44
|
write(out);
|
|
29
45
|
drawn = 0;
|
|
30
46
|
};
|
|
47
|
+
const paintAbsolute = (lines) => {
|
|
48
|
+
// NO ERASE PASS. Each row is cleared as part of being rewritten, so the
|
|
49
|
+
// screen never passes through a blank intermediate state — which is both
|
|
50
|
+
// one fewer write and one fewer chance to flicker.
|
|
51
|
+
let out = "";
|
|
52
|
+
for (const [index, line] of lines.entries()) {
|
|
53
|
+
out += cursorToRow(index + 1) + ERASE_ROW + fitRow(line);
|
|
54
|
+
}
|
|
55
|
+
// Rows the previous paint used and this one does not. A shorter frame must
|
|
56
|
+
// not leave its own tail on screen.
|
|
57
|
+
for (let row = lines.length + 1; row <= drawn; row++) {
|
|
58
|
+
out += cursorToRow(row) + ERASE_ROW;
|
|
59
|
+
}
|
|
60
|
+
if (out !== "")
|
|
61
|
+
write(out);
|
|
62
|
+
drawn = lines.length;
|
|
63
|
+
};
|
|
31
64
|
const paint = (lines) => {
|
|
65
|
+
if (opts.absolute) {
|
|
66
|
+
lastLines = lines;
|
|
67
|
+
paintAbsolute(lines);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
32
70
|
erase();
|
|
33
71
|
lastLines = lines;
|
|
34
72
|
if (lines.length === 0)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApiError, AuthError, BudgetExhaustedError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
|
|
1
|
+
import { ApiError, AuthError, BudgetExhaustedError, InvalidRequestError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
|
|
2
2
|
import { scrubModelNames } from "../brand/index.js";
|
|
3
3
|
import { CruxyError, ErrorCode } from "./types.js";
|
|
4
4
|
/**
|
|
@@ -199,6 +199,91 @@ export function apiError(underlying) {
|
|
|
199
199
|
meta: status ? { status } : undefined,
|
|
200
200
|
});
|
|
201
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* The gateway REJECTED what we sent (400/422) — it never reached a model.
|
|
204
|
+
*
|
|
205
|
+
* Everything else `classifyProviderError` produces says, in one wording or
|
|
206
|
+
* another, "wait and try again", because everything else IS a condition that
|
|
207
|
+
* passes. This one is not, and saying so is the entire point. The gateway parsed
|
|
208
|
+
* the request, found it invalid, and answered before any upstream call. The
|
|
209
|
+
* payload is built from this build's own code — the tool harness, the message
|
|
210
|
+
* shaping — so it is deterministic: a retry sends byte-identical content and
|
|
211
|
+
* earns a byte-identical refusal. There is no outage, nothing recovers on a
|
|
212
|
+
* clock, and no amount of patience helps.
|
|
213
|
+
*
|
|
214
|
+
* It follows that this is a DEFECT IN CRUXY and the only thing that fixes it is
|
|
215
|
+
* a code change. So the next steps say that instead of a soothing "retry in a
|
|
216
|
+
* moment", and they point at the issue tracker with a code to quote, because a
|
|
217
|
+
* report is the one action that actually moves this forward.
|
|
218
|
+
*
|
|
219
|
+
* The gateway's message names the offending tool when a tool is at fault (its
|
|
220
|
+
* validator emits `tool "<name>": ...`), and that name is the single most useful
|
|
221
|
+
* token in the whole error — it turns "cruxy is broken" into a filed issue
|
|
222
|
+
* someone can act on. It is lifted out of the SCRUBBED message, never the raw
|
|
223
|
+
* one, so the U.8 gag can never be undone by this path.
|
|
224
|
+
*/
|
|
225
|
+
export function apiRequestRejected(underlying) {
|
|
226
|
+
const status = underlying instanceof ApiError ? underlying.status : undefined;
|
|
227
|
+
const cause = scrubbedMessageOf(underlying);
|
|
228
|
+
const tool = toolNamedIn(cause);
|
|
229
|
+
return new CruxyError({
|
|
230
|
+
code: ErrorCode.ApiRequestRejected,
|
|
231
|
+
title: tool
|
|
232
|
+
? `the provider rejected this request — the \`${tool}\` tool definition cruxy sent is invalid`
|
|
233
|
+
: "the provider rejected this request — cruxy sent something invalid",
|
|
234
|
+
cause,
|
|
235
|
+
nextSteps: [
|
|
236
|
+
// First, because it pre-empts the reflex the other API errors trained.
|
|
237
|
+
"retrying will NOT help: the same request would be sent again and refused identically",
|
|
238
|
+
"this is a defect in cruxy, not a provider outage — nothing recovers on its own",
|
|
239
|
+
tool
|
|
240
|
+
? `report it at ${ISSUE_URL} with this code and the tool name \`${tool}\``
|
|
241
|
+
: `report it at ${ISSUE_URL} with this code and the cause line above`,
|
|
242
|
+
],
|
|
243
|
+
underlying,
|
|
244
|
+
meta: {
|
|
245
|
+
...(status !== undefined ? { status } : {}),
|
|
246
|
+
...(tool !== undefined ? { tool } : {}),
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The tool name in a gateway rejection, if it named one.
|
|
252
|
+
*
|
|
253
|
+
* The gateway's tool-schema validator prefixes its complaint with the offending
|
|
254
|
+
* function — `tool "apply_patch": parameters nests deeper than 8 levels` — so
|
|
255
|
+
* one quoted token after the word `tool` is the whole pattern. Anything else
|
|
256
|
+
* yields `undefined` and the caller falls back to generic wording: a WRONG tool
|
|
257
|
+
* name in a bug report is worse than none, so this never guesses.
|
|
258
|
+
*
|
|
259
|
+
* ── TEMPORARY COUPLING, AND IT IS THE WRONG KIND ────────────────────────────
|
|
260
|
+
*
|
|
261
|
+
* This reads the gateway's `error` MESSAGE, and the gateway's own contract
|
|
262
|
+
* (cruxy-ai/api, `internal/httpx/errcode.go`) says the message MAY change while
|
|
263
|
+
* the `code` MAY NOT. So this parses the half that is explicitly allowed to move
|
|
264
|
+
* under us — the exact coupling the code/message split exists to prevent.
|
|
265
|
+
*
|
|
266
|
+
* It is deliberate and bounded: today the code is the generic `invalid_request`,
|
|
267
|
+
* shared with bad JSON, a missing field and an unknown model, so the message is
|
|
268
|
+
* the ONLY thing distinguishing "this build's tool harness is permanently
|
|
269
|
+
* unusable" from "this one request was malformed". The tool name is the single
|
|
270
|
+
* most actionable token in the error and it is worth having; a regex that fails
|
|
271
|
+
* closed is the cheapest way to have it.
|
|
272
|
+
*
|
|
273
|
+
* Failing closed is what makes the risk acceptable. If the gateway rewords, this
|
|
274
|
+
* returns `undefined`, the caller drops to generic wording, and the error is
|
|
275
|
+
* still correct — less specific, never wrong. Nothing downstream branches on it.
|
|
276
|
+
*
|
|
277
|
+
* The real fix is server-side and filed as cruxy-ai/api#183: a distinct 400 code
|
|
278
|
+
* for harness-bound rejections (the `invalid_schema` precedent already exists
|
|
279
|
+
* for `response_format`), with the tool name as a STRUCTURED FIELD rather than a
|
|
280
|
+
* message prefix. When that lands, match on the code, read the field, and delete
|
|
281
|
+
* this function — do not "improve" the regex.
|
|
282
|
+
*/
|
|
283
|
+
function toolNamedIn(message) {
|
|
284
|
+
const match = /\btool "([^"]+)"/.exec(message ?? "");
|
|
285
|
+
return match?.[1];
|
|
286
|
+
}
|
|
202
287
|
export function apiRateLimit(underlying) {
|
|
203
288
|
const retryAfterMs = underlying instanceof RateLimitError ? underlying.retryAfterMs : undefined;
|
|
204
289
|
return new CruxyError({
|
|
@@ -657,15 +742,29 @@ export function gitPushFailed(branch, stderr) {
|
|
|
657
742
|
});
|
|
658
743
|
}
|
|
659
744
|
// ── plan mode (exit 2 / 10) ───────────────────────────────────────────────────
|
|
660
|
-
/**
|
|
745
|
+
/**
|
|
746
|
+
* The agent's proposed plan was MALFORMED (plan mode, C.31).
|
|
747
|
+
*
|
|
748
|
+
* Malformed, and only malformed. A turn that ends with no plan at all is not
|
|
749
|
+
* this: the propose phase is read-only plus `submit_plan`, so the model cannot
|
|
750
|
+
* have acted, and a plain answer to a plain question is an ordinary completed
|
|
751
|
+
* turn — `runPlanSession` returns it rather than raising here.
|
|
752
|
+
*
|
|
753
|
+
* The next steps say `/mode manual` because that is the lever a user in a
|
|
754
|
+
* planning mode actually has. Shift+Tab is how most people arrive at `plan` and
|
|
755
|
+
* `full-auto` in the first place, and there is no flag on the running session to
|
|
756
|
+
* take back; `--plan` only chooses what mode a fresh `cruxy run` STARTS in, so it
|
|
757
|
+
* is named last and only for that case.
|
|
758
|
+
*/
|
|
661
759
|
export function planInvalid(reason) {
|
|
662
760
|
return new CruxyError({
|
|
663
761
|
code: ErrorCode.PlanInvalid,
|
|
664
762
|
title: "the agent did not produce a valid plan",
|
|
665
763
|
cause: reason,
|
|
666
764
|
nextSteps: [
|
|
667
|
-
"retry the task —
|
|
668
|
-
"or
|
|
765
|
+
"retry the task — a plan needs at least one step, each with a title and a rationale",
|
|
766
|
+
"or leave planning for this session: `/mode manual` (Shift+Tab cycles the same ring)",
|
|
767
|
+
"for a one-shot `cruxy run`, drop `--plan` / `agent.planMode` to execute directly",
|
|
669
768
|
],
|
|
670
769
|
meta: { reason },
|
|
671
770
|
});
|
|
@@ -678,7 +777,10 @@ export function planRevisionLimit(limit) {
|
|
|
678
777
|
cause: "the revision limit was reached without an approved plan",
|
|
679
778
|
nextSteps: [
|
|
680
779
|
"restate the task more concretely, or split it into smaller tasks",
|
|
681
|
-
|
|
780
|
+
// Same lever as planInvalid's, for the same reason: the session was most
|
|
781
|
+
// likely cycled into a planning mode with Shift+Tab, and there is no flag
|
|
782
|
+
// on it to drop.
|
|
783
|
+
"or leave planning for this session: `/mode manual` (Shift+Tab cycles the same ring)",
|
|
682
784
|
],
|
|
683
785
|
meta: { limit },
|
|
684
786
|
});
|
|
@@ -1416,6 +1518,14 @@ export function classifyProviderError(underlying) {
|
|
|
1416
1518
|
if (underlying instanceof BudgetExhaustedError) {
|
|
1417
1519
|
return budgetExhausted(underlying);
|
|
1418
1520
|
}
|
|
1521
|
+
// Also before the `ApiError` base. Without this arm a rejected request lands
|
|
1522
|
+
// on the generic `apiError`, whose only next step is "retry in a moment; if it
|
|
1523
|
+
// persists, check the provider's status" — advice that is wrong twice over for
|
|
1524
|
+
// a 400: retrying re-sends the identical payload, and the provider's status
|
|
1525
|
+
// page has nothing to say about a request it correctly refused.
|
|
1526
|
+
if (underlying instanceof InvalidRequestError) {
|
|
1527
|
+
return apiRequestRejected(underlying);
|
|
1528
|
+
}
|
|
1419
1529
|
if (underlying instanceof ApiError)
|
|
1420
1530
|
return apiError(underlying);
|
|
1421
1531
|
return null;
|
package/dist/errors/types.js
CHANGED
|
@@ -44,6 +44,18 @@ export const ErrorCode = {
|
|
|
44
44
|
GitPushFailed: "CRUXY_E_GIT_PUSH_FAILED",
|
|
45
45
|
// api (exit 6)
|
|
46
46
|
Api: "CRUXY_E_API",
|
|
47
|
+
/**
|
|
48
|
+
* The gateway REJECTED the request (400/422) rather than failing to serve it.
|
|
49
|
+
*
|
|
50
|
+
* A DISTINCT CODE from {@link Api} because the two are opposite kinds of fact
|
|
51
|
+
* and take opposite advice. `Api` covers a provider that could not serve a
|
|
52
|
+
* valid request — a condition, which passes, so "retry in a moment" is sound.
|
|
53
|
+
* This one covers a request the gateway parsed, judged invalid, and refused
|
|
54
|
+
* before any upstream call. The payload is deterministic: a retry sends
|
|
55
|
+
* identical bytes and earns an identical refusal. Telling someone to wait out
|
|
56
|
+
* a rejection is telling them to wait for a defect in cruxy to fix itself.
|
|
57
|
+
*/
|
|
58
|
+
ApiRequestRejected: "CRUXY_E_API_REQUEST_REJECTED",
|
|
47
59
|
ApiRateLimit: "CRUXY_E_API_RATE_LIMIT",
|
|
48
60
|
ApiOverloaded: "CRUXY_E_API_OVERLOADED",
|
|
49
61
|
BudgetExhausted: "CRUXY_E_BUDGET_EXHAUSTED",
|
|
@@ -289,6 +301,11 @@ const EXIT_CODES = {
|
|
|
289
301
|
[ErrorCode.GatewayUnreachable]: 5,
|
|
290
302
|
[ErrorCode.GitPushFailed]: 5,
|
|
291
303
|
[ErrorCode.Api]: 6,
|
|
304
|
+
// Still the API exit class: the failure arrived from the gateway, and a script
|
|
305
|
+
// wrapping cruxy should treat it the same way it treats any other API failure.
|
|
306
|
+
// The difference this code carries is what a HUMAN should do about it, which
|
|
307
|
+
// is the next steps, not the exit status.
|
|
308
|
+
[ErrorCode.ApiRequestRejected]: 6,
|
|
292
309
|
[ErrorCode.ApiRateLimit]: 6,
|
|
293
310
|
[ErrorCode.ApiOverloaded]: 6,
|
|
294
311
|
[ErrorCode.BudgetExhausted]: 6,
|
package/dist/lsp/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
|
-
export { DEFAULT_SPECS, EXT_TO_LANGUAGE, LspRegistry, binaryOnPath, languageForFile, } from "./registry.js";
|
|
2
|
+
export { DEFAULT_SPECS, EXT_TO_LANGUAGE, LspRegistry, binaryOnPath, languageForFile, resolveBinary, } from "./registry.js";
|
|
3
3
|
export { StdioTransport, TransportTimeoutError, killTree, } from "./transport.js";
|
|
4
4
|
export { Server } from "./server.js";
|
|
5
5
|
export { LspPool } from "./pool.js";
|
package/dist/lsp/registry.js
CHANGED
|
@@ -58,27 +58,45 @@ export function languageForFile(filePath) {
|
|
|
58
58
|
return EXT_TO_LANGUAGE[ext] ?? null;
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
61
|
+
* Resolve `command` to an executable FILE, the way a shell would, or `null`.
|
|
62
|
+
*
|
|
62
63
|
* An absolute/relative path is tested directly; a bare name is searched across
|
|
63
|
-
* `PATH
|
|
64
|
-
*
|
|
64
|
+
* `PATH`. On win32 each candidate is also tried with every `PATHEXT` suffix,
|
|
65
|
+
* which is the only way `pnpm` finds `pnpm.cmd` — Windows installs almost every
|
|
66
|
+
* npm-shipped tool as a `.cmd` shim, and a lookup that tries the bare name alone
|
|
67
|
+
* reports a globally installed binary as absent.
|
|
68
|
+
*
|
|
69
|
+
* Pure filesystem probing — never spawns. THE ONLY PATH RESOLVER in the CLI
|
|
70
|
+
* besides `tools/shell/resolve-shell.ts`'s win32 shell hunt, deliberately: a
|
|
71
|
+
* third one would be a third place for the PATHEXT rule to be forgotten.
|
|
65
72
|
*/
|
|
66
|
-
export
|
|
73
|
+
export function resolveBinary(command, dirs = (process.env.PATH ?? "")
|
|
74
|
+
.split(path.delimiter)
|
|
75
|
+
.filter(Boolean)) {
|
|
76
|
+
// An explicit path names the file outright — nothing to search.
|
|
67
77
|
if (command.includes(path.sep) || command.includes("/")) {
|
|
68
|
-
return isExecutable(command);
|
|
78
|
+
return isExecutable(command) ? command : null;
|
|
69
79
|
}
|
|
70
|
-
|
|
80
|
+
// PATHEXT only, never a bare `command` fallback on win32: a directory that
|
|
81
|
+
// holds `pnpm.cmd` usually holds an extensionless `pnpm` beside it (the sh
|
|
82
|
+
// shim npm installs for Git Bash), and that one cannot be spawned there.
|
|
71
83
|
const exts = process.platform === "win32"
|
|
72
84
|
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
|
|
73
85
|
: [""];
|
|
74
86
|
for (const dir of dirs) {
|
|
75
87
|
for (const ext of exts) {
|
|
76
|
-
|
|
77
|
-
|
|
88
|
+
const candidate = path.join(dir, command + ext);
|
|
89
|
+
if (isExecutable(candidate))
|
|
90
|
+
return candidate;
|
|
78
91
|
}
|
|
79
92
|
}
|
|
80
|
-
return
|
|
81
|
-
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Default binary-presence check — {@link resolveBinary} reduced to a yes/no.
|
|
97
|
+
* Injectable via {@link LspRegistry} so tests can force present/absent.
|
|
98
|
+
*/
|
|
99
|
+
export const binaryOnPath = (command) => resolveBinary(command) !== null;
|
|
82
100
|
function isExecutable(candidate) {
|
|
83
101
|
try {
|
|
84
102
|
const stat = fs.statSync(candidate);
|
package/dist/plan/service.js
CHANGED
|
@@ -10,6 +10,9 @@ import { makeSubmitPlanTool } from "./submit-plan.js";
|
|
|
10
10
|
* read-only + `submit_plan` registry so the agent cannot act before approval;
|
|
11
11
|
* execution runs with the full registry, one step at a time, per-action U.3
|
|
12
12
|
* gating intact.
|
|
13
|
+
*
|
|
14
|
+
* A propose phase that ends with NO plan is a completed conversational turn, not
|
|
15
|
+
* an error — see the `!holder.plan` branch below.
|
|
13
16
|
*/
|
|
14
17
|
/** Default cap on plan revisions before failing loud. */
|
|
15
18
|
export const MAX_PLAN_REVISIONS = 3;
|
|
@@ -81,9 +84,31 @@ export async function runPlanSession(args) {
|
|
|
81
84
|
onRequestUsage: args.onRequestUsage,
|
|
82
85
|
}));
|
|
83
86
|
if (!holder.plan) {
|
|
84
|
-
|
|
87
|
+
// NO PLAN IS NOT A FAILURE. The propose phase's registry is read-only plus
|
|
88
|
+
// `submit_plan`, so a turn that ends without a plan has structurally not
|
|
89
|
+
// ACTED — it has ANSWERED. "what does this function do?" is a perfectly
|
|
90
|
+
// ordinary thing to type while a planning mode is on, and the model
|
|
91
|
+
// replying to it is the correct outcome, not a malformed plan.
|
|
92
|
+
//
|
|
93
|
+
// Throwing here was also silently LOSING that reply. The session records
|
|
94
|
+
// the user's turn before the runner is called and adopts the returned
|
|
95
|
+
// history after it returns, so an exception left the question on disk with
|
|
96
|
+
// no answer beside it — a transcript that is wrong, not merely unhelpful.
|
|
97
|
+
// Returning the accumulated history keeps the answer in both.
|
|
98
|
+
//
|
|
99
|
+
// This holds on a revision pass too: a model that answers a piece of
|
|
100
|
+
// feedback with a question instead of a new plan is asking the user
|
|
101
|
+
// something, and the user can now answer it on the next turn.
|
|
102
|
+
return finish();
|
|
85
103
|
}
|
|
86
104
|
const plan = holder.plan;
|
|
105
|
+
// The one thing that IS invalid: a plan object with nothing executable in
|
|
106
|
+
// it. `submit_plan` rejects this to the model's face, so reaching it means
|
|
107
|
+
// something set the holder directly — fail loud rather than run an empty
|
|
108
|
+
// approval prompt over zero steps.
|
|
109
|
+
if (plan.steps.length === 0) {
|
|
110
|
+
throw planInvalid("submit_plan produced a plan with no steps");
|
|
111
|
+
}
|
|
87
112
|
const decision = await promptPlanDecision(plan, args.io);
|
|
88
113
|
if (decision.kind === "abort") {
|
|
89
114
|
// Committed output goes through the renderer, not the prompt io (P3):
|
package/dist/plan/submit-plan.js
CHANGED
|
@@ -38,6 +38,17 @@ export function makeSubmitPlanTool(holder) {
|
|
|
38
38
|
if (input.steps.length === 0) {
|
|
39
39
|
return { ok: false, error: "a plan must have at least one step" };
|
|
40
40
|
}
|
|
41
|
+
// `min(1)` counts characters, so a title of `" "` clears zod and then
|
|
42
|
+
// trims to nothing — a step that renders as an empty row in the approval
|
|
43
|
+
// prompt and as an empty instruction during execution. Rejected the same
|
|
44
|
+
// way as any other tool-input error, so the model resubmits.
|
|
45
|
+
const blank = input.steps.findIndex((s) => s.title.trim() === "" || s.rationale.trim() === "");
|
|
46
|
+
if (blank !== -1) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
error: `step ${blank + 1} has a blank title or rationale — every step needs both`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
41
52
|
const steps = input.steps.map((s, i) => ({
|
|
42
53
|
id: String(i + 1),
|
|
43
54
|
title: s.title.trim(),
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { shouldUseColor } from "../errors/index.js";
|
|
2
2
|
import { detectScreenReader, detectUnicode } from "../theme/index.js";
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Set-and-non-empty (the NO_COLOR convention): any non-empty value counts.
|
|
5
|
+
*
|
|
6
|
+
* Exported so every cruxy env flag reads the SAME rule. The README states it
|
|
7
|
+
* once for all of them — `CRUXY_ASCII=false` enables ASCII — and a flag that
|
|
8
|
+
* quietly parsed its value instead would make that documentation wrong for one
|
|
9
|
+
* variable with nothing to point at.
|
|
10
|
+
*/
|
|
11
|
+
export function isSet(value) {
|
|
5
12
|
return value !== undefined && value !== "";
|
|
6
13
|
}
|
|
7
14
|
/** Fallback width when the terminal reports none (non-TTY, pipe, unknown). */
|
package/dist/render/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { TtyRenderer } from "./tty-renderer.js";
|
|
|
8
8
|
import { TuiRenderer } from "../tui/renderer.js";
|
|
9
9
|
import { GitStatusCache } from "../tui/git-status.js";
|
|
10
10
|
import { ToolVersions } from "../tui/tool-versions.js";
|
|
11
|
-
import { supportsTui } from "../tui/supports.js";
|
|
11
|
+
import { supportsTui, usesAltScreen } from "../tui/supports.js";
|
|
12
12
|
export { detectCapabilities, detectReducedMotion, resolveColumns, resolveRows, DEFAULT_COLUMNS, DEFAULT_ROWS, } from "./capabilities.js";
|
|
13
13
|
export { attachResize, processResizeSignal, } from "./resize.js";
|
|
14
14
|
export { fit, fitMiddle, reflow, stripAnsi, visibleWidth, kvStack, MIN_VALUE_COLS, } from "./layout.js";
|
|
@@ -53,6 +53,11 @@ export function createRenderer(out = process.stdout, err = process.stderr, env =
|
|
|
53
53
|
// Constructed, not started: `ToolVersions` probes nothing until the
|
|
54
54
|
// renderer paints the panel, so this costs an object and no subprocess.
|
|
55
55
|
tools: new ToolVersions(),
|
|
56
|
+
// Resolved against THIS factory's env (Q4 track 3), not `process.env`.
|
|
57
|
+
// The renderer's own default reads the real environment, which is right
|
|
58
|
+
// for a direct construction and wrong here: an injected env exists
|
|
59
|
+
// precisely so a caller can describe a terminal that is not this one.
|
|
60
|
+
altScreen: usesAltScreen(caps, env),
|
|
56
61
|
});
|
|
57
62
|
}
|
|
58
63
|
return caps.cursor
|