@cruxy/cli 1.10.0 → 1.11.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
@@ -219,6 +219,7 @@ once. Reduced motion is also implied by screen-reader mode.
219
219
  | Variable | Effect |
220
220
  | --------------------- | ------------------------------------------------------------------------------ |
221
221
  | `CRUXY_NO_ALT_SCREEN` | Keep the full-screen TUI in the normal buffer instead of the alternate screen. |
222
+ | `CRUXY_NO_MOUSE` | Leave the mouse to the terminal — the wheel scrolls the window, not the pane. |
222
223
 
223
224
  By default the TUI runs on the terminal's alternate screen — the second buffer
224
225
  `less` and `vim` use. Leaving it restores the normal buffer byte for byte, so
@@ -237,6 +238,17 @@ prefer your shell to keep the frame. It changes nothing else about the TUI, and
237
238
  it is ignored where the TUI does not run at all (a pipe, a screen reader,
238
239
  `TERM=dumb`).
239
240
 
241
+ ### The mouse
242
+
243
+ While the TUI is active it turns mouse reporting on, so the wheel scrolls the
244
+ conversation (or the selected view) the way Page Up and Page Down do, instead
245
+ of scrolling the terminal window over a buffer that has no scrollback. The
246
+ trade is the one `less` and `vim` make: **native text selection needs Shift
247
+ (Option in Terminal.app) while the TUI is active.** If you select text
248
+ constantly, set `CRUXY_NO_MOUSE=1` — the wheel goes back to the terminal, and
249
+ nothing else about the TUI changes. It is implied off wherever the alternate
250
+ screen is off, because the two are turned on and released as one.
251
+
240
252
  However cruxy exits — quit, `kill -TERM`, a hangup when the window closes, an
241
253
  uncaught error — the terminal is handed back: the frame erased, the cursor
242
254
  shown, the alternate screen left.
@@ -272,7 +284,7 @@ branch on them:
272
284
  | `0` | success | — |
273
285
  | `1` | internal | `CRUXY_E_INTERNAL` |
274
286
  | `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH`, `CRUXY_E_PLAN_INVALID`, `CRUXY_E_PLAN_REVISION_LIMIT`, `CRUXY_E_CHECKPOINT_NOT_FOUND` |
275
- | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
287
+ | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID`, `CRUXY_E_CONFIG_NOT_FOUND` |
276
288
  | `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
277
289
  | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
278
290
  | `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` |
@@ -41,6 +41,11 @@ export function readContext(messages, budget) {
41
41
  // reserve covers the system prompt and tool schemas that `estimateTokens`
42
42
  // never sees, and omitting it here would under-report by ~4.5k tokens and let
43
43
  // the panel read "comfortable" while the seam was about to compact.
44
+ //
45
+ // It is an ALLOWANCE, not a measurement, and every renderer of `used` has
46
+ // to be able to say so — hence `reserve` travels with the reading. It is
47
+ // fixed by config: it does not grow when CRUXY.md, recalled memory, LSP,
48
+ // web or MCP tool schemas grow the actual request.
44
49
  const used = estimateTokens(messages) + budget.reserveTokens;
45
50
  const total = budget.maxTokens;
46
51
  return {
@@ -48,6 +53,7 @@ export function readContext(messages, budget) {
48
53
  total,
49
54
  fraction: total <= 0 ? 1 : Math.min(1, Math.max(0, used / total)),
50
55
  compactAt: Math.round(budget.compactThreshold * total),
56
+ reserve: budget.reserveTokens,
51
57
  };
52
58
  }
53
59
  /**
@@ -617,6 +617,17 @@ export class Session {
617
617
  // its effort can land. That matters more here than on the main loop: a
618
618
  // summarize call reasoning at `high` is exactly the kind of silent spend a
619
619
  // per-request record exists to expose.
620
+ //
621
+ // IT LANDS, BUT NOT UNDER THIS CLASS. No `origin` is passed here, so the
622
+ // entry takes the turn collector's default `"turn"` — which is correct
623
+ // (`UsageEntry.origin` names the main loop and its compaction as one thing,
624
+ // so `runCount` keeps meaning "turns") and which also means the effort
625
+ // above is filed indistinguishably from the triggering turn's own. The
626
+ // record exposes the SPEND; it does not attribute it to `summarize`.
627
+ // cli#195 wanted exactly that attribution, to justify a per-task-class
628
+ // effort floor from collected data, and closed when it turned out the shape
629
+ // of the record — not the size of the sample — withholds it. Anything that
630
+ // needs to isolate compaction's effort has to change the record first.
620
631
  onRequestUsage?.({
621
632
  tier: servedTier ?? routed?.tier,
622
633
  ...(routingMode !== undefined ? { routingMode } : {}),
@@ -94,7 +94,11 @@ export const COMMAND_CATALOG = [
94
94
  args: "[<n> | off]",
95
95
  },
96
96
  { name: "/jobs", summary: "list background jobs and their status" },
97
- { name: "/logs", summary: "show a background job's log", args: "<id>" },
97
+ {
98
+ name: "/logs",
99
+ summary: "show a background job's log (falls back to a retained one on disk)",
100
+ args: "<id>",
101
+ },
98
102
  { name: "/cancel", summary: "cancel a background job", args: "<id>" },
99
103
  {
100
104
  name: "/add-root",
@@ -2,7 +2,7 @@ import { Command } from "commander";
2
2
  import { configKeyUnknown, shouldUseColor } from "../../errors/index.js";
3
3
  import { themeForColor } from "../../theme/index.js";
4
4
  import { logger } from "../../utils/logger.js";
5
- import { loadConfig, getPath, setValue, initConfig, globalConfigPath, findProjectConfig, } from "../../config/index.js";
5
+ import { cliConfigPathInEffect, loadConfig, getPath, setValue, initConfig, globalConfigPath, findProjectConfig, } from "../../config/index.js";
6
6
  export function configCommand() {
7
7
  const cmd = new Command("config").description("manage cruxy configuration");
8
8
  cmd
@@ -44,8 +44,23 @@ export function configCommand() {
44
44
  .action(() => {
45
45
  const t = themeForColor(shouldUseColor(process.stdout));
46
46
  const project = findProjectConfig();
47
- logger.print(`${t.strong("global:")} ${globalConfigPath()}`);
48
- logger.print(`${t.strong("project:")} ${project ?? t.muted("(none found)")}`);
47
+ // Read the flag rather than calling `loadConfig`, deliberately: this is
48
+ // the command you run WHEN the config is broken, so it must stay total —
49
+ // loading here would make `cruxy config path` fail for exactly the
50
+ // invalid or unreadable file the user is trying to locate.
51
+ const explicit = cliConfigPathInEffect();
52
+ logger.print(`${t.strong("global:")} ${globalConfigPath()}`);
53
+ logger.print(`${t.strong("project:")} ${project === null
54
+ ? t.muted("(none found)")
55
+ : explicit === null
56
+ ? project
57
+ : // Discovery still FOUND it; the explicit file replaced it. Saying
58
+ // so beats hiding the line, which would read as "no project
59
+ // config exists" and send someone hunting for a file they have.
60
+ `${project} ${t.muted("(superseded by --config)")}`}`);
61
+ if (explicit !== null) {
62
+ logger.print(`${t.strong("explicit:")} ${explicit}`);
63
+ }
49
64
  });
50
65
  cmd
51
66
  .command("init")
@@ -0,0 +1,149 @@
1
+ import { Command } from "commander";
2
+ import { shouldUseColor, usageError } from "../../errors/index.js";
3
+ import { INTERRUPTED, jobLogFilesByRecency, matchJobLogs, readJobLog, readJobLogTerminal, } from "../../jobs/log-store.js";
4
+ import { relativeAge, shortId } from "../../session/index.js";
5
+ import { themeForColor } from "../../theme/index.js";
6
+ import { formatBytes } from "../../utils/disk.js";
7
+ import { logger } from "../../utils/logger.js";
8
+ /**
9
+ * `cruxy logs` (#172 item 1) — read back what a background job said.
10
+ *
11
+ * ## Why this had to be a command and not just a nicer `/logs`
12
+ *
13
+ * Both existing readers — the in-session `/logs <id>` and the Tasks view — go
14
+ * through `JobManager.logs()` to the in-memory ring buffer, so both need a live
15
+ * `JobManager` holding the live job map. `requireJob` throws
16
+ * `CRUXY_E_JOB_NOT_FOUND` on anything else, which means neither surface can so
17
+ * much as NAME a job from a session that has ended. Persisting the log without
18
+ * a reader that works out of process would have been a writer talking to
19
+ * itself: the entire case this exists for is the session that is gone.
20
+ *
21
+ * ## It is a post-mortem. It is not a job that survived
22
+ *
23
+ * Say this plainly wherever a user can see it, because the alternative reading
24
+ * is the first bug someone files. A background job is an in-process agent loop;
25
+ * `run.ts` calls `cancelAll("session exit")` on the way out and `orphan.test.ts`
26
+ * pins that this kill-tree's the whole process group. Nothing resumes, nothing
27
+ * reattaches, and no job is running behind this command. What survived is the
28
+ * transcript of what the job said before it stopped.
29
+ *
30
+ * Shaped after `cruxy sessions`, and not by coincidence: same tree, same
31
+ * per-project scoping via `projectDir`, same cheap `readdir`+`stat` scan that
32
+ * bounds work before any file is opened, and the same id-prefix matcher, so an
33
+ * id that names a thing in one command names it in the other.
34
+ */
35
+ export function logsCommand() {
36
+ const cmd = new Command("logs")
37
+ .argument("[id]", "job id, or an unambiguous prefix")
38
+ // The one-line description is bounded to 80 chars by the U.8 voice rule, so
39
+ // the caveat that actually matters lives in the help body below, where it
40
+ // has room to be unambiguous rather than merely short.
41
+ .description("read a finished background job's log — a post-mortem")
42
+ .addHelpText("after", "\nA background job never outlives the session that dispatched it: leaving a\n" +
43
+ "session cancels every live job and kills its process tree. These logs are\n" +
44
+ "what a job SAID before it stopped — nothing here is running, and nothing\n" +
45
+ "resumes. Failed and interrupted runs are kept; a job that finished cleanly\n" +
46
+ "has its log swept at the next start.\n")
47
+ .option("--all", "list every retained log, not just the newest 20")
48
+ .action((id, opts) => {
49
+ const t = themeForColor(shouldUseColor(process.stdout));
50
+ const cwd = process.cwd();
51
+ const refs = jobLogFilesByRecency(cwd);
52
+ if (id === undefined) {
53
+ listLogs(refs, Boolean(opts.all), t);
54
+ return;
55
+ }
56
+ const matches = matchJobLogs(refs, id);
57
+ if (matches.length > 1) {
58
+ throw usageError(`\`${id}\` matches more than one job log`, [
59
+ `did you mean one of: ${matches.map((m) => m.jobKey).join(", ")}?`,
60
+ ]);
61
+ }
62
+ if (matches.length === 0) {
63
+ throw usageError(`no retained log for job \`${id}\` in this project`, [
64
+ "run `cruxy logs` to see what is here",
65
+ "logs are per-directory; check you are in the right one",
66
+ "a job that finished cleanly has its log swept — only failed and interrupted runs are kept",
67
+ ]);
68
+ }
69
+ const log = readJobLog(matches[0].file);
70
+ if (log === null) {
71
+ throw usageError(`\`${matches[0].file}\` is not a readable job log`, [
72
+ "it may have been truncated before its first line was written",
73
+ ]);
74
+ }
75
+ printLog(log, t);
76
+ });
77
+ return cmd;
78
+ }
79
+ /** The listing: one row per retained log, newest first. */
80
+ function listLogs(refs, all, t) {
81
+ if (refs.length === 0) {
82
+ logger.print(t.muted("no retained background-job logs for this project"));
83
+ printPolicy(t);
84
+ return;
85
+ }
86
+ // Bounded to 20 unless asked, matching `cruxy sessions`. The per-row cost
87
+ // here is a TAIL read and not a full one — the listing shows a status, not a
88
+ // transcript, and `readJobLogTerminal` is the whole of what that needs.
89
+ const shown = all ? refs : refs.slice(0, 20);
90
+ const total = refs.reduce((n, r) => n + r.size, 0);
91
+ for (const ref of shown) {
92
+ const status = readJobLogTerminal(ref.file);
93
+ logger.print(`${t.accent(ref.jobKey.padEnd(16))} ${t.muted(shortId(ref.sessionKey).padEnd(8))} ` +
94
+ // PAD FIRST, COLOUR SECOND. `padEnd` counts the escape bytes a themed
95
+ // string carries, so colouring before padding makes every coloured
96
+ // column a different width from the plain ones beside it.
97
+ `${statusText(status.padEnd(11), t)} ` +
98
+ `${t.muted(relativeAge(new Date(ref.mtimeMs).toISOString()).padEnd(9))} ` +
99
+ `${t.muted(formatBytes(ref.size).padStart(9))}`);
100
+ }
101
+ if (shown.length < refs.length) {
102
+ logger.print(t.muted(`\n… and ${refs.length - shown.length} more — pass \`--all\` to list every one`));
103
+ }
104
+ logger.print(t.muted(`\n${refs.length} log${refs.length === 1 ? "" : "s"}, ${formatBytes(total)}`));
105
+ printPolicy(t);
106
+ }
107
+ /** One log, in full. */
108
+ function printLog(log, t) {
109
+ logger.print(`${t.strong(log.jobId)} ${statusText(log.status, t)} ${t.muted(log.label)}`);
110
+ logger.print(t.muted(`session ${shortId(log.sessionId)} · started ${relativeAge(log.startedAt)}` +
111
+ (log.endedAt !== undefined
112
+ ? ` · ended ${relativeAge(log.endedAt)}`
113
+ : "") +
114
+ (log.iterations !== undefined ? ` · ${log.iterations} turn(s)` : "")));
115
+ if (log.error !== undefined)
116
+ logger.print(t.danger(log.error));
117
+ logger.print("");
118
+ for (const line of log.lines) {
119
+ logger.print(line.stream === "err" ? t.danger(line.text) : line.text);
120
+ }
121
+ // The truncation notice belongs AFTER the lines, because that is where the
122
+ // truncation happened. Note what is NOT printed here: `/logs`'s "… N earlier
123
+ // line(s) rolled off". That sentence is true of the ring buffer and false of
124
+ // this file — streaming means everything up to the cap is present — and
125
+ // `PersistedJobLog` has no `dropped` field precisely so it cannot be said.
126
+ if (log.truncatedAt !== undefined) {
127
+ logger.print(t.muted(`\n… log capped at ${log.truncatedAt} lines (\`jobs.logFileLines\`) — later output was not recorded`));
128
+ }
129
+ if (log.status === INTERRUPTED) {
130
+ logger.print(t.muted("\n(interrupted — this log has no terminal record, so the process was " +
131
+ "killed while the job was still running; the job did not survive it)"));
132
+ }
133
+ }
134
+ /**
135
+ * A status, coloured by whether anyone needs to look at it. Accepts an
136
+ * already-padded string: see the call site for why the padding cannot come
137
+ * after the colour.
138
+ */
139
+ function statusText(status, t) {
140
+ const bare = status.trim();
141
+ if (bare === "failed" || bare === INTERRUPTED)
142
+ return t.danger(status);
143
+ return t.accent(status);
144
+ }
145
+ /** What this directory keeps, so a listing explains itself. */
146
+ function printPolicy(t) {
147
+ logger.print(t.muted("keeping failed and interrupted runs; a job that finished cleanly has its log swept at the next start"));
148
+ logger.print(t.muted("these are post-mortems — a background job never outlives the session that dispatched it"));
149
+ }
@@ -1,7 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { createProvider } from "@cruxy/sdk";
3
3
  import { logger } from "../../utils/logger.js";
4
- import { loadConfig, resolveApiKey } from "../../config/index.js";
4
+ import { apiKeyEnvVar, loadConfig, resolveApiKey } from "../../config/index.js";
5
5
  import { authMissingKey, shouldUseColor } from "../../errors/index.js";
6
6
  import { themeForColor } from "../../theme/index.js";
7
7
  import { ApprovalService, defaultPromptIO, InteractivePolicy, SessionAllowlist, } from "../../approval/index.js";
@@ -92,12 +92,3 @@ export function prCommand() {
92
92
  logger.print(t.accent(outcome.url));
93
93
  });
94
94
  }
95
- /** Environment variable that holds the API key for a provider. */
96
- function apiKeyEnvVar(provider) {
97
- switch (provider) {
98
- case "openai":
99
- return "OPENAI_API_KEY";
100
- default:
101
- return "CRUXY_API_KEY";
102
- }
103
- }
@@ -12,7 +12,7 @@ import { SessionLog, listSessions, loadResume, resolveSessionId, resumePicker, s
12
12
  * offer rows a later prune has already marked expendable (#257).
13
13
  */
14
14
  export const SIDEBAR_SESSIONS = 10;
15
- import { classifyCredentialLifetime, globalDir, loadConfig, readCredentialMeta, resolveApiKey, } from "../../config/index.js";
15
+ import { apiKeyEnvVar, classifyCredentialLifetime, configSourceFile, globalDir, loadConfig, readCredentialMeta, resolveApiKey, } from "../../config/index.js";
16
16
  import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
17
17
  import { createRenderer } from "../../render/index.js";
18
18
  import { themeForColor } from "../../theme/index.js";
@@ -24,7 +24,7 @@ import { DEFAULT_MODE, } from "../../agent/index.js";
24
24
  import { runInteractive } from "../repl.js";
25
25
  import { ContextGauge, createKeyLease, createGitView, createOverviewView, createPermissionsView, createSettingsView, createTasksView, runTui, TuiRenderer, WorkspaceDiskCache, WorkspaceGitCache, } from "../../tui/index.js";
26
26
  import { buildAgentSession } from "../session-factory.js";
27
- import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
27
+ import { maybeRunOnboarding } from "../onboard.js";
28
28
  import { resetLspServices } from "../../lsp/index.js";
29
29
  import { connectMcpTools, deferredSiblingServers, resetMcpServices, } from "../../mcp/index.js";
30
30
  import { defaultPromptIO } from "../../approval/index.js";
@@ -135,7 +135,10 @@ export async function executeRun(promptParts, opts) {
135
135
  const { config, sources } = loaded;
136
136
  let apiKey = resolveApiKey(config.model.provider);
137
137
  logger.info(t.muted(`model: ${config.model.provider}/${config.model.model}`));
138
- logger.info(t.muted(`config: ${sources.project ?? sources.global ?? "defaults"}`));
138
+ // `configSourceFile`, not `project ?? global`: with `--config` in play there
139
+ // is no project layer, so the old fallback printed the GLOBAL path — or
140
+ // "defaults" — for a file it had just loaded (#289).
141
+ logger.info(t.muted(`config: ${configSourceFile(sources) ?? "defaults"}`));
139
142
  // Multi-root honesty (JC-6/C.26): reads fan every root. With per-root
140
143
  // checkpoints active (C.26 step 3), writes fan every root too — each is
141
144
  // checkpointed and rollback-able. With checkpoints DISABLED, a non-primary
@@ -88,7 +88,8 @@ export function sessionsCommand() {
88
88
  const cutoff = now - config.sessions.maxAgeDays * 24 * 60 * 60 * 1000;
89
89
  const doomed = sessionFilesByRecency(cwd).filter((ref, i) => ref.mtimeMs < cutoff || i >= config.sessions.retention);
90
90
  if (doomed.length === 0) {
91
- logger.print(t.muted("nothing to prune"));
91
+ logger.print(t.muted("no sessions to prune"));
92
+ printJobLogCaveat(t);
92
93
  return;
93
94
  }
94
95
  for (const ref of doomed) {
@@ -97,6 +98,7 @@ export function sessionsCommand() {
97
98
  }
98
99
  logger.print(t.muted(`\n${doomed.length} session${doomed.length === 1 ? "" : "s"}, ` +
99
100
  `${formatBytes(doomed.reduce((n, f) => n + f.size, 0))} — run without \`--dry-run\` to delete`));
101
+ printJobLogCaveat(t);
100
102
  return;
101
103
  }
102
104
  // No `activeSessionId`: there is no session open in this process. That is
@@ -104,12 +106,23 @@ export function sessionsCommand() {
104
106
  // from `SessionLog.open`.
105
107
  const result = pruneSessions(cwd, { sessions: config.sessions });
106
108
  if (result.removed.length === 0) {
107
- logger.print(t.muted("nothing to prune"));
109
+ logger.print(t.muted("no sessions to prune"));
108
110
  }
109
111
  else {
110
112
  logger.print(`${t.success("pruned")} ${result.removed.length} session${result.removed.length === 1 ? "" : "s"} ` +
111
113
  `(${formatBytes(result.bytesFreed)}), ${result.kept} kept`);
112
114
  }
115
+ // REPORTED SEPARATELY, and reported at all, because this run deletes
116
+ // them: `pruneSessions` sweeps the job logs a session no longer needs
117
+ // (#172 item 1), and a prune that removed files while printing "nothing
118
+ // to prune" would be exactly the unauditable retention `cruxy sessions`
119
+ // exists to prevent. Counted apart from sessions rather than folded in —
120
+ // a job log is not a conversation, and one number for both would make it
121
+ // impossible to tell which was taken.
122
+ if (result.jobLogsRemoved > 0) {
123
+ logger.print(`${t.success("swept")} ${result.jobLogsRemoved} background-job log${result.jobLogsRemoved === 1 ? "" : "s"} ` +
124
+ `(${formatBytes(result.jobLogBytesFreed)})`);
125
+ }
113
126
  if (result.failed > 0) {
114
127
  logger.warn(`${result.failed} session file${result.failed === 1 ? "" : "s"} could not be deleted`);
115
128
  }
@@ -146,6 +159,18 @@ export function sessionsCommand() {
146
159
  });
147
160
  return cmd;
148
161
  }
162
+ /**
163
+ * What the dry run does NOT show.
164
+ *
165
+ * The preview re-derives the doomed sessions from the same two bounds without
166
+ * going near a delete, which is the point of it — but the job-log sweep lives
167
+ * inside `pruneSessions`, so there is no way to preview it that does not either
168
+ * duplicate its rules or run them. Saying so is better than a preview that
169
+ * quietly under-reports what the real run will remove.
170
+ */
171
+ function printJobLogCaveat(t) {
172
+ logger.print(t.muted("\nbackground-job logs are swept by the same run and are not previewed here — `cruxy logs` lists them"));
173
+ }
149
174
  /** The bounds in force, so a listing explains itself without a config read. */
150
175
  function printPolicy(sessions, t) {
151
176
  if (!sessions.enabled) {
@@ -9,15 +9,6 @@ import { buildAgentSession } from "./session-factory.js";
9
9
  * `run`/`program`/`login`/`init` thin: they decide *when* to onboard; this builds
10
10
  * the production wiring (live validation, credential persistence, first-win run).
11
11
  */
12
- /** The env var that holds the key for a provider (for the fail-loud message). */
13
- export function apiKeyEnvVar(provider) {
14
- switch (provider) {
15
- case "openai":
16
- return "OPENAI_API_KEY";
17
- default:
18
- return "CRUXY_API_KEY";
19
- }
20
- }
21
12
  /** Run one first-win task end-to-end against the just-saved key. */
22
13
  export async function runFirstWinTask(config, cwd, prompt) {
23
14
  const apiKey = resolveApiKey(config.model.provider);
@@ -22,8 +22,9 @@ import { memoryCommand } from "./commands/memory.js";
22
22
  import { limitsCommand } from "./commands/limits.js";
23
23
  import { usageCommand } from "./commands/usage.js";
24
24
  import { mcpCommand } from "./commands/mcp.js";
25
+ import { logsCommand } from "./commands/logs.js";
25
26
  import { sessionsCommand } from "./commands/sessions.js";
26
- import { loadConfig } from "../config/index.js";
27
+ import { loadConfig, setCliConfigPath } from "../config/index.js";
27
28
  import { maybeRunOnboarding } from "./onboard.js";
28
29
  export function buildProgram() {
29
30
  const program = new Command();
@@ -36,12 +37,24 @@ export function buildProgram() {
36
37
  .option("--verbose", "shorthand for --log-level debug")
37
38
  .option("--resume [id]", "resume a saved session by id; omit the id to pick from recent sessions");
38
39
  // Apply global options as early as possible.
40
+ //
41
+ // This hook is registered on the ROOT program, so `thisCommand` is the root
42
+ // whichever subcommand is running, and it fires before every action handler —
43
+ // including the root's own. That makes it the one place a global option can
44
+ // be applied once and be true for the whole invocation.
39
45
  program.hook("preAction", (thisCommand) => {
40
46
  const opts = thisCommand.opts();
41
47
  if (opts.verbose)
42
48
  logger.setLevel("debug");
43
49
  else if (opts.logLevel)
44
50
  logger.setLevel(opts.logLevel);
51
+ // `--config` was parsed and dropped here until #289: the flag reached
52
+ // `GlobalOpts` and `--help` and nothing else, so a user pointing it at a
53
+ // file got default behaviour and no signal. Recording it makes every
54
+ // `loadConfig()` in this process resolve that file — including the reads
55
+ // that cannot be passed one (see `setCliConfigPath`). Cleared when absent,
56
+ // so a program rebuilt in-process does not inherit a stale path.
57
+ setCliConfigPath(opts.config ?? null);
45
58
  });
46
59
  program.addCommand(runCommand());
47
60
  program.addCommand(configCommand());
@@ -59,6 +72,7 @@ export function buildProgram() {
59
72
  program.addCommand(limitsCommand());
60
73
  program.addCommand(mcpCommand());
61
74
  program.addCommand(sessionsCommand());
75
+ program.addCommand(logsCommand());
62
76
  // Default action: bare `cruxy` opens the TUI; `cruxy <message>` opens it and
63
77
  // runs that message as the first turn. Operands reach here only when they
64
78
  // matched no subcommand, so a bare token is a MESSAGE by default — only a
@@ -14,7 +14,8 @@ import { compactTokens } from "../render/units.js";
14
14
  import { renderUnifiedDiff } from "../render/index.js";
15
15
  import { permissionsReportLines } from "../render/permissions-view.js";
16
16
  import { sessionStatusLines } from "../render/status-view.js";
17
- import { defaultExportName, exportMarkdown } from "../session/index.js";
17
+ import { INTERRUPTED, jobLogFilesByRecency, matchJobLogs, readJobLog, } from "../jobs/log-store.js";
18
+ import { defaultExportName, exportMarkdown, shortId, } from "../session/index.js";
18
19
  import { readDiskCapacitySync } from "../utils/disk.js";
19
20
  import { getGitInfo } from "../utils/git.js";
20
21
  import { currentBranch, diffAgainst, hasChanges } from "../vcs/git.js";
@@ -22,7 +23,7 @@ import { MODEL_CHOICES, describeModelChoice, parseModelChoice, } from "../routin
22
23
  import { loadUsage, runCountLabel, selectRuns, usageReport, } from "../usage/index.js";
23
24
  import { runGatedShell } from "../tools/shell/exec.js";
24
25
  import { addRootToWorkspace } from "../workspace/index.js";
25
- import { formatError, fromUnknown, isVerbose } from "../errors/index.js";
26
+ import { CruxyError, ErrorCode, formatError, fromUnknown, isVerbose, } from "../errors/index.js";
26
27
  /**
27
28
  * Re-exported so the many callers that already reach for the catalogue through
28
29
  * this module keep working; the data itself lives in the leaf `command-catalog`
@@ -62,6 +63,10 @@ export async function dispatchCommand(input, ctx) {
62
63
  return { kind: "exit" };
63
64
  if (trimmed === "/clear") {
64
65
  session.clear();
66
+ // The screen too, where the shell owns one. A history reset that left the
67
+ // old transcript on screen above its own confirmation read as a reset that
68
+ // had not happened — the context WAS empty; only the screen said otherwise.
69
+ out.clear?.();
65
70
  out.print(t.muted("history cleared"));
66
71
  return { kind: "handled" };
67
72
  }
@@ -910,6 +915,8 @@ function handleJobLogs(input, ctx) {
910
915
  try {
911
916
  const log = jobs.logs(id);
912
917
  if (log.dropped > 0) {
918
+ // TRUE OF THE RING BUFFER, and said only here. The persisted log below
919
+ // streams, so this sentence is false about a file — see `JobLog.dropped`.
913
920
  out.print(t.muted(`… ${log.dropped} earlier line(s) rolled off`));
914
921
  }
915
922
  for (const line of log.lines) {
@@ -918,9 +925,49 @@ function handleJobLogs(input, ctx) {
918
925
  out.print(t.muted(`(${log.status})`));
919
926
  }
920
927
  catch (err) {
928
+ // FALL BACK TO DISK on an id this session has never heard of (#172 item 1).
929
+ // The live map only holds jobs THIS session dispatched, so after a
930
+ // `--resume` the ids a user is most likely to type are exactly the ones
931
+ // that miss — the job ran in the process that is now gone. Only
932
+ // JOB_NOT_FOUND falls through: a real failure must still fail loud.
933
+ if (CruxyError.is(err) &&
934
+ err.code === ErrorCode.JobNotFound &&
935
+ printPersistedJobLog(id, ctx)) {
936
+ return;
937
+ }
921
938
  printCommandError(out, err);
922
939
  }
923
940
  }
941
+ /**
942
+ * Print a job log read back from disk, or report that there is none.
943
+ *
944
+ * Returns false when nothing on disk matches, so the caller can surface the
945
+ * original live-lookup error rather than replacing a precise "no such job in
946
+ * this session" with a vaguer one.
947
+ */
948
+ function printPersistedJobLog(id, ctx) {
949
+ const { out } = ctx;
950
+ const t = out.theme;
951
+ const matches = matchJobLogs(jobLogFilesByRecency(process.cwd()), id);
952
+ if (matches.length !== 1)
953
+ return false;
954
+ const log = readJobLog(matches[0].file);
955
+ if (log === null)
956
+ return false;
957
+ out.print(t.muted(`(from disk — job ${log.jobId} ran in session ${shortId(log.sessionId)})`));
958
+ for (const line of log.lines) {
959
+ out.print(out.fit(line.stream === "err" ? t.danger(line.text) : line.text));
960
+ }
961
+ // No "rolled off" line: this came from the file, which holds everything up to
962
+ // the cap. The cap is the only thing worth reporting, and only if it was hit.
963
+ if (log.truncatedAt !== undefined) {
964
+ out.print(t.muted(`… log capped at ${log.truncatedAt} lines (\`jobs.logFileLines\`) — later output was not recorded`));
965
+ }
966
+ out.print(t.muted(log.status === INTERRUPTED
967
+ ? "(interrupted — no terminal record; the process was killed mid-job)"
968
+ : `(${log.status})`));
969
+ return true;
970
+ }
924
971
  /** Cancel a job (`/cancel <id>`). */
925
972
  async function handleJobCancel(input, ctx) {
926
973
  const { out, session } = ctx;
@@ -75,7 +75,7 @@ export async function readSingleKey(stdin = process.stdin) {
75
75
  const keys = createKeyReader(stdin);
76
76
  keys.begin();
77
77
  try {
78
- const key = await keys.read();
78
+ const key = await readAnswerKey(keys);
79
79
  switch (key.kind) {
80
80
  case "char":
81
81
  return key.char;
@@ -89,6 +89,23 @@ export async function readSingleKey(stdin = process.stdin) {
89
89
  keys.restore();
90
90
  }
91
91
  }
92
+ /**
93
+ * The next key that can be an ANSWER — a wheel notch is not one.
94
+ *
95
+ * Every single-key prompt maps "anything unmapped" to the safe default (deny,
96
+ * cancel, no). That is right for an arrow or a function key, and wrong for the
97
+ * mouse wheel: with mouse reporting on (the TUI turns it on), a reader who
98
+ * scrolls back to check what a prompt is about would answer it "no" by doing
99
+ * so. The wheel is a scroll, never a reply, so it is skipped here and the
100
+ * prompt keeps waiting.
101
+ */
102
+ export async function readAnswerKey(keys) {
103
+ for (;;) {
104
+ const key = await keys.read();
105
+ if (key.kind !== "wheel-up" && key.kind !== "wheel-down")
106
+ return key;
107
+ }
108
+ }
92
109
  /** The real environment: frames to stderr, keys from stdin, caps from stderr. */
93
110
  export function defaultComponentIO() {
94
111
  // Detection resolves both axes (stderr for output, stdin for input) and their