@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 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` |
@@ -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, nextMode, parseMode, } from "./mode.js";
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);
@@ -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
- /** The agent's proposed plan was missing or malformed (plan mode, C.31). */
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 — the model must call `submit_plan` with at least one step",
668
- "or run without `--plan` to execute directly",
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
- "run without `--plan` to execute directly",
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;
@@ -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";
@@ -58,27 +58,45 @@ export function languageForFile(filePath) {
58
58
  return EXT_TO_LANGUAGE[ext] ?? null;
59
59
  }
60
60
  /**
61
- * Default binary-presence check: resolves `command` the way a shell would.
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` (with `PATHEXT` on win32). Pure filesystem probing never spawns.
64
- * Injectable via {@link LspRegistry} so tests can force present/absent.
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 const binaryOnPath = (command) => {
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
- const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
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
- if (isExecutable(path.join(dir, command + ext)))
77
- return true;
88
+ const candidate = path.join(dir, command + ext);
89
+ if (isExecutable(candidate))
90
+ return candidate;
78
91
  }
79
92
  }
80
- return false;
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);
@@ -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
- throw planInvalid("the model ended its turn without calling submit_plan");
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):
@@ -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
- /** Set-and-non-empty (the NO_COLOR convention): any non-empty value counts. */
4
- function isSet(value) {
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). */
@@ -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