@cruxy/cli 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +2 -2
  2. package/dist/agent/loop.js +16 -1
  3. package/dist/agent/session.js +73 -9
  4. package/dist/approval/classify.js +170 -40
  5. package/dist/approval/prompt.js +52 -6
  6. package/dist/approval/service.js +1 -11
  7. package/dist/budget/session-budget.js +10 -1
  8. package/dist/checkpoint/coverage.js +147 -4
  9. package/dist/cli/command-catalog.js +5 -1
  10. package/dist/cli/commands/config.js +18 -3
  11. package/dist/cli/commands/limits.js +76 -0
  12. package/dist/cli/commands/logs.js +149 -0
  13. package/dist/cli/commands/pr.js +11 -11
  14. package/dist/cli/commands/rollback.js +10 -2
  15. package/dist/cli/commands/run.js +25 -6
  16. package/dist/cli/commands/sessions.js +181 -0
  17. package/dist/cli/onboard.js +0 -9
  18. package/dist/cli/program.js +19 -1
  19. package/dist/cli/repl.js +25 -0
  20. package/dist/cli/session-commands.js +45 -2
  21. package/dist/cli/session-factory.js +31 -10
  22. package/dist/config/manager.js +91 -11
  23. package/dist/config/schema.js +194 -20
  24. package/dist/constants.js +12 -2
  25. package/dist/errors/constructors.js +84 -46
  26. package/dist/errors/types.js +6 -0
  27. package/dist/jobs/index.js +1 -0
  28. package/dist/jobs/log-renderer.js +10 -5
  29. package/dist/jobs/log-store.js +505 -0
  30. package/dist/jobs/manager.js +338 -18
  31. package/dist/mcp/client.js +16 -0
  32. package/dist/render/limits-report.js +213 -0
  33. package/dist/render/limits-view.js +125 -0
  34. package/dist/routing/index.js +1 -1
  35. package/dist/routing/router.js +34 -14
  36. package/dist/routing/types.js +0 -2
  37. package/dist/sandbox/service.js +9 -0
  38. package/dist/sandbox/types.js +15 -0
  39. package/dist/session/index.js +3 -1
  40. package/dist/session/list.js +20 -6
  41. package/dist/session/log.js +120 -21
  42. package/dist/session/prune.js +166 -0
  43. package/dist/session/resume.js +5 -0
  44. package/dist/subagent/orchestrator.js +87 -34
  45. package/dist/subagent/spawn-tool.js +11 -4
  46. package/dist/tools/schema-depth.js +18 -0
  47. package/dist/tui/limits-panel.js +53 -30
  48. package/dist/usage/collect.js +20 -1
  49. package/dist/usage/summary.js +48 -1
  50. package/dist/usage/types.js +52 -0
  51. package/package.json +2 -2
@@ -0,0 +1,181 @@
1
+ import { unlinkSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { Command } from "commander";
4
+ import { loadConfig } from "../../config/index.js";
5
+ import { shouldUseColor, usageError } from "../../errors/index.js";
6
+ import { SESSION_FILE_EXT, matchSessionRefs, listSessionRefs, pruneSessions, sessionFilesByRecency, shortId, summarizeSession, relativeAge, } from "../../session/index.js";
7
+ import { themeForColor } from "../../theme/index.js";
8
+ import { formatBytes } from "../../utils/disk.js";
9
+ import { logger } from "../../utils/logger.js";
10
+ /**
11
+ * `cruxy sessions` (#257) — see and bound what `~/.cruxy/projects/<project>/`
12
+ * is holding.
13
+ *
14
+ * WHY THIS EXISTS AT ALL, when retention already runs on its own. Retention
15
+ * that only ever fires implicitly is retention nobody can audit before it eats
16
+ * something: the first prune after upgrading from a build that had none can
17
+ * remove months of sessions, and "trust me, the right ones went" is not a thing
18
+ * a user can check. `list` shows what is there and which rows the current
19
+ * bounds would take; `prune` runs the same policy on demand; `rm` is the
20
+ * escape hatch for one specific session.
21
+ *
22
+ * BYTES ARE REPORTED, NEVER PRUNED ON. `statSync` hands the size over for free
23
+ * in the same scan that orders by mtime, and seeing where the disk went is
24
+ * exactly what a report is for. Making it a BOUND is the part that would be
25
+ * wrong — whether your session survives should not depend on how chatty an
26
+ * unrelated one was.
27
+ */
28
+ export function sessionsCommand() {
29
+ const cmd = new Command("sessions").description("manage this project's saved sessions — list, prune, delete");
30
+ cmd
31
+ .command("list", { isDefault: true })
32
+ .description("list saved sessions, newest first")
33
+ .option("--all", "list every session, not just the newest 20")
34
+ .action((opts) => {
35
+ const t = themeForColor(shouldUseColor(process.stdout));
36
+ const { config } = loadConfig();
37
+ const cwd = process.cwd();
38
+ const files = sessionFilesByRecency(cwd);
39
+ if (files.length === 0) {
40
+ logger.print(t.muted("no saved sessions for this project"));
41
+ printPolicy(config.sessions, t);
42
+ return;
43
+ }
44
+ // The listing is the one place that pays for a full read per row — it is
45
+ // showing titles and turn counts, which is the cost `summarizeSession`
46
+ // exists to charge. Bounded to 20 unless asked, for the same reason the
47
+ // picker is bounded to 10.
48
+ const shown = opts.all ? files : files.slice(0, 20);
49
+ const total = files.reduce((n, f) => n + f.size, 0);
50
+ for (const ref of shown) {
51
+ const summary = summarizeSession(ref.file);
52
+ const id = summary
53
+ ? shortId(summary.sessionId)
54
+ : shortId(path.basename(ref.file, SESSION_FILE_EXT));
55
+ // A file with no readable meta is SHOWN, not hidden. It is occupying
56
+ // disk and it is a thing prune will consider; a report that silently
57
+ // omitted it would be lying about what is there.
58
+ const title = summary ? summary.title : t.muted("(unreadable)");
59
+ const turns = summary
60
+ ? `${summary.turns} turn${summary.turns === 1 ? "" : "s"}`
61
+ : "—";
62
+ logger.print(`${t.accent(id.padEnd(8))} ${t.muted(relativeAge(new Date(ref.mtimeMs).toISOString()).padEnd(9))} ` +
63
+ `${t.muted(formatBytes(ref.size).padStart(9))} ${t.muted(turns.padEnd(8))} ${title}`);
64
+ }
65
+ if (shown.length < files.length) {
66
+ logger.print(t.muted(`\n… and ${files.length - shown.length} more — pass \`--all\` to list every one`));
67
+ }
68
+ logger.print(t.muted(`\n${files.length} session${files.length === 1 ? "" : "s"}, ${formatBytes(total)}`));
69
+ printPolicy(config.sessions, t);
70
+ });
71
+ cmd
72
+ .command("prune")
73
+ .description("clear sessions past sessions.retention / sessions.maxAgeDays")
74
+ .option("-n, --dry-run", "show what would be deleted, delete nothing")
75
+ .action((opts) => {
76
+ const t = themeForColor(shouldUseColor(process.stdout));
77
+ const { config } = loadConfig();
78
+ const cwd = process.cwd();
79
+ if (!config.sessions.enabled) {
80
+ logger.print(t.muted("session persistence is off (`sessions.enabled = false`) — nothing is being recorded to prune"));
81
+ return;
82
+ }
83
+ if (opts.dryRun) {
84
+ // The dry run must not go anywhere near `unlinkSync`, so it re-derives
85
+ // the doomed set from the SAME ordering and the same two bounds rather
86
+ // than sharing a code path with a delete in it behind a flag.
87
+ const now = Date.now();
88
+ const cutoff = now - config.sessions.maxAgeDays * 24 * 60 * 60 * 1000;
89
+ const doomed = sessionFilesByRecency(cwd).filter((ref, i) => ref.mtimeMs < cutoff || i >= config.sessions.retention);
90
+ if (doomed.length === 0) {
91
+ logger.print(t.muted("no sessions to prune"));
92
+ printJobLogCaveat(t);
93
+ return;
94
+ }
95
+ for (const ref of doomed) {
96
+ logger.print(`${t.muted("would delete")} ${shortId(path.basename(ref.file, SESSION_FILE_EXT))} ` +
97
+ `${t.muted(relativeAge(new Date(ref.mtimeMs).toISOString()))} ${t.muted(formatBytes(ref.size))}`);
98
+ }
99
+ logger.print(t.muted(`\n${doomed.length} session${doomed.length === 1 ? "" : "s"}, ` +
100
+ `${formatBytes(doomed.reduce((n, f) => n + f.size, 0))} — run without \`--dry-run\` to delete`));
101
+ printJobLogCaveat(t);
102
+ return;
103
+ }
104
+ // No `activeSessionId`: there is no session open in this process. That is
105
+ // the whole difference between running prune from here and running it
106
+ // from `SessionLog.open`.
107
+ const result = pruneSessions(cwd, { sessions: config.sessions });
108
+ if (result.removed.length === 0) {
109
+ logger.print(t.muted("no sessions to prune"));
110
+ }
111
+ else {
112
+ logger.print(`${t.success("pruned")} ${result.removed.length} session${result.removed.length === 1 ? "" : "s"} ` +
113
+ `(${formatBytes(result.bytesFreed)}), ${result.kept} kept`);
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
+ }
126
+ if (result.failed > 0) {
127
+ logger.warn(`${result.failed} session file${result.failed === 1 ? "" : "s"} could not be deleted`);
128
+ }
129
+ });
130
+ cmd
131
+ .command("rm <id>")
132
+ .description("forget one session by id (or unambiguous id prefix)")
133
+ .action((id) => {
134
+ const t = themeForColor(shouldUseColor(process.stdout));
135
+ const cwd = process.cwd();
136
+ // Resolved through the SAME matcher `--resume` uses, so the id that names
137
+ // a session to resume is the id that names it to delete. An ambiguous
138
+ // prefix is refused rather than resolved to "the newest" — this is the
139
+ // one command here that cannot be undone.
140
+ const matches = matchSessionRefs(listSessionRefs(cwd), id);
141
+ if (matches.length > 1) {
142
+ throw usageError(`\`${id}\` matches more than one session`, [
143
+ `did you mean one of: ${matches.map((m) => shortId(m.sessionId)).join(", ")}?`,
144
+ ]);
145
+ }
146
+ if (matches.length === 0) {
147
+ throw usageError(`no session \`${id}\` in this project`, [
148
+ "run `cruxy sessions` to see what is here",
149
+ "sessions are per-directory; check you are in the right one",
150
+ ]);
151
+ }
152
+ try {
153
+ unlinkSync(matches[0].file);
154
+ }
155
+ catch (err) {
156
+ throw usageError(`could not delete session ${shortId(matches[0].sessionId)}`, [`${matches[0].file}: ${err.message}`]);
157
+ }
158
+ logger.print(`${t.success("deleted")} session ${shortId(matches[0].sessionId)}`);
159
+ });
160
+ return cmd;
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
+ }
174
+ /** The bounds in force, so a listing explains itself without a config read. */
175
+ function printPolicy(sessions, t) {
176
+ if (!sessions.enabled) {
177
+ logger.print(t.muted("persistence is off (`sessions.enabled = false`) — nothing new is recorded"));
178
+ return;
179
+ }
180
+ logger.print(t.muted(`keeping the newest ${sessions.retention}, and anything touched in the last ${sessions.maxAgeDays} days`));
181
+ }
@@ -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);
@@ -19,9 +19,12 @@ import { rollbackCommand } from "./commands/rollback.js";
19
19
  import { testCommand } from "./commands/test.js";
20
20
  import { hooksCommand } from "./commands/hooks.js";
21
21
  import { memoryCommand } from "./commands/memory.js";
22
+ import { limitsCommand } from "./commands/limits.js";
22
23
  import { usageCommand } from "./commands/usage.js";
23
24
  import { mcpCommand } from "./commands/mcp.js";
24
- import { loadConfig } from "../config/index.js";
25
+ import { logsCommand } from "./commands/logs.js";
26
+ import { sessionsCommand } from "./commands/sessions.js";
27
+ import { loadConfig, setCliConfigPath } from "../config/index.js";
25
28
  import { maybeRunOnboarding } from "./onboard.js";
26
29
  export function buildProgram() {
27
30
  const program = new Command();
@@ -34,12 +37,24 @@ export function buildProgram() {
34
37
  .option("--verbose", "shorthand for --log-level debug")
35
38
  .option("--resume [id]", "resume a saved session by id; omit the id to pick from recent sessions");
36
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.
37
45
  program.hook("preAction", (thisCommand) => {
38
46
  const opts = thisCommand.opts();
39
47
  if (opts.verbose)
40
48
  logger.setLevel("debug");
41
49
  else if (opts.logLevel)
42
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);
43
58
  });
44
59
  program.addCommand(runCommand());
45
60
  program.addCommand(configCommand());
@@ -54,7 +69,10 @@ export function buildProgram() {
54
69
  program.addCommand(hooksCommand());
55
70
  program.addCommand(memoryCommand());
56
71
  program.addCommand(usageCommand());
72
+ program.addCommand(limitsCommand());
57
73
  program.addCommand(mcpCommand());
74
+ program.addCommand(sessionsCommand());
75
+ program.addCommand(logsCommand());
58
76
  // Default action: bare `cruxy` opens the TUI; `cruxy <message>` opens it and
59
77
  // runs that message as the first turn. Operands reach here only when they
60
78
  // matched no subcommand, so a bare token is a MESSAGE by default — only a
package/dist/cli/repl.js CHANGED
@@ -116,6 +116,28 @@ async function drainJobApprovals(session) {
116
116
  logger.print(theme.muted(`\n${theme.glyph.bullet} a background job needs your approval:`));
117
117
  await jobs.serviceApprovals();
118
118
  }
119
+ /**
120
+ * Surface a pool denial a background job died on (cli#245) — the same idle
121
+ * point the approval drain uses, and for the same reason: the foreground owns
122
+ * the terminal here, with no live region painting and no readline interface
123
+ * holding stdin.
124
+ *
125
+ * WHY THE FOREGROUND IS TOLD AT ALL. A 429 is a statement about a denominator
126
+ * the job shares with this session's own next turn, so the job's death is
127
+ * already the answer to a question the user is about to ask. Without this the
128
+ * job fails off-screen — `/jobs` would show it, if the user thought to look —
129
+ * and the next turn walks into its own refusal, having been told nothing.
130
+ * Printed through the shared error formatter so `window`, when it recovers, and
131
+ * whether mira is still open all survive, exactly as they do on the foreground
132
+ * path.
133
+ */
134
+ function drainJobPoolDenial(session) {
135
+ const denial = session.jobs?.takePoolDenial();
136
+ if (!denial)
137
+ return;
138
+ logger.print(theme.muted(`\n${theme.glyph.bullet} a background job stopped:`));
139
+ printCommandError(replOutput, denial);
140
+ }
119
141
  /**
120
142
  * Drive an interactive multi-turn session: prompt, read a line, dispatch slash
121
143
  * commands or run a turn, repeat. Assistant text and tool-call progress stream
@@ -143,6 +165,9 @@ async function replLoop(session, io, renderer, checkpoints, slashCommands = [])
143
165
  // prompt. Done here (foreground idle, no readline interface live) so a job's
144
166
  // prompt never contends with the line reader.
145
167
  await drainJobApprovals(session);
168
+ // Same point, same reason: a job the weighted pool refused (cli#245). The
169
+ // next turn is about to draw on the window that just refused it.
170
+ drainJobPoolDenial(session);
146
171
  const line = await readLine(io, PROMPT);
147
172
  // EOF / Ctrl+D.
148
173
  if (line === null) {
@@ -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`
@@ -910,6 +911,8 @@ function handleJobLogs(input, ctx) {
910
911
  try {
911
912
  const log = jobs.logs(id);
912
913
  if (log.dropped > 0) {
914
+ // TRUE OF THE RING BUFFER, and said only here. The persisted log below
915
+ // streams, so this sentence is false about a file — see `JobLog.dropped`.
913
916
  out.print(t.muted(`… ${log.dropped} earlier line(s) rolled off`));
914
917
  }
915
918
  for (const line of log.lines) {
@@ -918,9 +921,49 @@ function handleJobLogs(input, ctx) {
918
921
  out.print(t.muted(`(${log.status})`));
919
922
  }
920
923
  catch (err) {
924
+ // FALL BACK TO DISK on an id this session has never heard of (#172 item 1).
925
+ // The live map only holds jobs THIS session dispatched, so after a
926
+ // `--resume` the ids a user is most likely to type are exactly the ones
927
+ // that miss — the job ran in the process that is now gone. Only
928
+ // JOB_NOT_FOUND falls through: a real failure must still fail loud.
929
+ if (CruxyError.is(err) &&
930
+ err.code === ErrorCode.JobNotFound &&
931
+ printPersistedJobLog(id, ctx)) {
932
+ return;
933
+ }
921
934
  printCommandError(out, err);
922
935
  }
923
936
  }
937
+ /**
938
+ * Print a job log read back from disk, or report that there is none.
939
+ *
940
+ * Returns false when nothing on disk matches, so the caller can surface the
941
+ * original live-lookup error rather than replacing a precise "no such job in
942
+ * this session" with a vaguer one.
943
+ */
944
+ function printPersistedJobLog(id, ctx) {
945
+ const { out } = ctx;
946
+ const t = out.theme;
947
+ const matches = matchJobLogs(jobLogFilesByRecency(process.cwd()), id);
948
+ if (matches.length !== 1)
949
+ return false;
950
+ const log = readJobLog(matches[0].file);
951
+ if (log === null)
952
+ return false;
953
+ out.print(t.muted(`(from disk — job ${log.jobId} ran in session ${shortId(log.sessionId)})`));
954
+ for (const line of log.lines) {
955
+ out.print(out.fit(line.stream === "err" ? t.danger(line.text) : line.text));
956
+ }
957
+ // No "rolled off" line: this came from the file, which holds everything up to
958
+ // the cap. The cap is the only thing worth reporting, and only if it was hit.
959
+ if (log.truncatedAt !== undefined) {
960
+ out.print(t.muted(`… log capped at ${log.truncatedAt} lines (\`jobs.logFileLines\`) — later output was not recorded`));
961
+ }
962
+ out.print(t.muted(log.status === INTERRUPTED
963
+ ? "(interrupted — no terminal record; the process was killed mid-job)"
964
+ : `(${log.status})`));
965
+ return true;
966
+ }
924
967
  /** Cancel a job (`/cancel <id>`). */
925
968
  async function handleJobCancel(input, ctx) {
926
969
  const { out, session } = ctx;
@@ -365,7 +365,8 @@ opts = {}) {
365
365
  const executionSemaphore = new Semaphore(config.subagent.maxConcurrency);
366
366
  // The ONE weighted-token budget for the whole session (P10 track 3 / cli#212).
367
367
  // `/budget` reads and sets it; `Session` narrows each turn's token guard by it;
368
- // the orchestrator refuses to dispatch a fan-out it cannot cover. Three
368
+ // the orchestrator refuses to dispatch a fan-out (or a single spawn) it cannot
369
+ // cover; the job manager refuses a background job it cannot. Four
369
370
  // consumers, one object — a session cap and a fan-out bound that could disagree
370
371
  // would be the failure this exists to prevent. Its server denominator is
371
372
  // attached later (see `attachLimits`), because the limits cache does not exist
@@ -378,6 +379,21 @@ opts = {}) {
378
379
  // Outside `resumeLineAfterApproval`, so the live region is restored
379
380
  // before a preview block is committed into it.
380
381
  previewSilentApprovals(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), renderer), checkpoints, workspace), approvalMutex, cwd);
382
+ // THE LATE-BINDING HANDLE FOR THE SESSION BUILT AT THE END OF THIS FUNCTION.
383
+ //
384
+ // Declared here, above the first construction that needs it, rather than down
385
+ // beside the plan wiring it was written for. Three things now late-bind to the
386
+ // session — the approval policy's live mode read, and (cli#244) the job
387
+ // manager's session id — and the session cannot exist yet because it needs the
388
+ // ctx, the orchestrator and the job manager that are built between here and
389
+ // there.
390
+ //
391
+ // `autoApprove`: the policy has to read the LIVE mode, since the user can
392
+ // leave auto-approve between two actions of a single turn. Before the session
393
+ // exists there is nothing to approve, so the `false` fallback is a closed door
394
+ // rather than a gap.
395
+ const holder = {};
396
+ const autoApprove = () => holder.session?.getAutoApprove() ?? false;
381
397
  // Subagent orchestration (C.14): spawn_subagent goes on the main registry
382
398
  // only when depth allows (maxDepth 0 disables the feature structurally).
383
399
  // Registered before the plan wiring so plan-mode execution steps can
@@ -406,7 +422,6 @@ opts = {}) {
406
422
  makeChildApproval: () => gate(new ApprovalService({
407
423
  cwd,
408
424
  interactive: ttyInteractive,
409
- io,
410
425
  policy: new InteractivePolicy(new SessionAllowlist(), io, autoApprove),
411
426
  })),
412
427
  });
@@ -432,6 +447,20 @@ opts = {}) {
432
447
  approvalMutex,
433
448
  foregroundInteractive: ttyInteractive,
434
449
  promptIO: io,
450
+ // The SAME budget the turn and the fan-out seam ask (cli#245). A job
451
+ // drawing on the weighted pool with no admission check was the last
452
+ // surface running `runAgent` that neither asked it nor moved it.
453
+ budget: sessionBudget,
454
+ // ...and the SAME usage sink the session's turns publish through
455
+ // (cli#244), so a job's spend is counted by `/usage` and not only by
456
+ // `/budget`. A job gets a record of its own because it can outlive the
457
+ // turn that launched it; see the note on `JobManagerDeps.onRunUsage`.
458
+ onRunUsage,
459
+ // Read lazily: the session is constructed below and does not exist yet.
460
+ // Without this the job's record persists with no `sessionId` and is then
461
+ // invisible to in-session `/usage` and to `--session` — on disk, and
462
+ // unreadable by the two surfaces most likely to look for it.
463
+ sessionId: () => holder.session?.sessionId,
435
464
  })
436
465
  : undefined;
437
466
  const jobTool = jobManager ? makeRunInBackgroundTool(jobManager) : undefined;
@@ -465,19 +494,11 @@ opts = {}) {
465
494
  // One allowlist shared by the plan-approval prompt and the per-action gate, so
466
495
  // a grant recorded during execution is honored by U.3's own check.
467
496
  const allowlist = new SessionAllowlist();
468
- // Late-bound to the session constructed below. The policy has to read the LIVE
469
- // mode — the user can leave auto-approve between two actions of a single turn
470
- // — and the session cannot exist yet because it needs the ctx this policy is
471
- // wired into. Before it exists there is nothing to approve, so the `false`
472
- // fallback is a closed door rather than a gap.
473
- const holder = {};
474
- const autoApprove = () => holder.session?.getAutoApprove() ?? false;
475
497
  const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io, autoApprove));
476
498
  const approval = new ApprovalService({
477
499
  cwd,
478
500
  interactive: ttyInteractive,
479
501
  policy: planPolicy,
480
- io,
481
502
  });
482
503
  const ctx = {
483
504
  cwd,
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
- import { configInvalid, configParse, mcpProjectHeaders, } from "../errors/index.js";
4
- import { CruxyConfigSchema } from "./schema.js";
3
+ import { configInvalid, configNotFound, configParse, mcpProjectHeaders, } from "../errors/index.js";
4
+ import { CruxyConfigSchema, } from "./schema.js";
5
5
  import { globalConfigPath, findProjectConfig } from "./paths.js";
6
6
  import { readCredential } from "./credentials.js";
7
7
  function isPlainObject(v) {
@@ -90,6 +90,39 @@ function envOverrides() {
90
90
  }
91
91
  return out;
92
92
  }
93
+ /**
94
+ * The `--config <path>` this process was invoked with, or null.
95
+ *
96
+ * PROCESS-WIDE RATHER THAN THREADED, because the flag is a property of the
97
+ * INVOCATION: every read of the config in one run must resolve the same file,
98
+ * and there are ~20 `loadConfig()` call sites. Two of them cannot take an
99
+ * argument at all — the credential-expiry probe in `src/index.ts` runs from the
100
+ * error boundary, after the command that would have carried the path has
101
+ * already thrown, and the Settings view's `reload` is a zero-argument callback
102
+ * held for a whole session. Threading would wire the sites that are easy to
103
+ * reach and leave those two reading a *different* config than the command they
104
+ * belong to, which is the same silent divergence this flag was filed for
105
+ * (#289).
106
+ *
107
+ * Set once from the `preAction` hook that already applies the other global
108
+ * options (`cli/program.ts`), alongside `logger.setLevel`. An explicit
109
+ * {@link LoadOptions.configPath} still wins, so a caller that names a file is
110
+ * unaffected.
111
+ */
112
+ let cliConfigPath = null;
113
+ /**
114
+ * Record the `--config <path>` for this process. Returns the previous value so
115
+ * a test can restore it; pass null to clear.
116
+ */
117
+ export function setCliConfigPath(path) {
118
+ const previous = cliConfigPath;
119
+ cliConfigPath = path;
120
+ return previous;
121
+ }
122
+ /** The `--config <path>` in effect for this process, or null. */
123
+ export function cliConfigPathInEffect() {
124
+ return cliConfigPath;
125
+ }
93
126
  /**
94
127
  * Resolution order (later wins):
95
128
  * schema defaults -> global file -> project file (or explicit) -> env vars
@@ -113,12 +146,21 @@ export function loadConfig(opts = {}) {
113
146
  merged = deepMerge(merged, layers.global);
114
147
  sources.global = gPath;
115
148
  }
116
- if (opts.configPath) {
117
- const obj = readJsonFile(opts.configPath);
118
- rejectProjectScopeHeaders(obj, opts.configPath);
149
+ // `--config` (recorded process-wide) unless this caller named a file itself.
150
+ const configPath = opts.configPath ?? cliConfigPath ?? undefined;
151
+ if (configPath) {
152
+ // A named file that is not there is a MISTAKE, never a fall-through to
153
+ // discovery or defaults: silently running the default config for a path
154
+ // someone typed is precisely the failure #289 is about, wearing the other
155
+ // hat. The global and project layers are `existsSync`-guarded above because
156
+ // absence there is the normal case; here absence is the error.
157
+ if (!existsSync(configPath))
158
+ throw configNotFound(configPath);
159
+ const obj = readJsonFile(configPath);
160
+ rejectProjectScopeHeaders(obj, configPath);
119
161
  layers.explicit = obj;
120
162
  merged = deepMerge(merged, obj);
121
- sources.explicit = opts.configPath;
163
+ sources.explicit = configPath;
122
164
  }
123
165
  else {
124
166
  const pPath = findProjectConfig(opts.cwd);
@@ -137,10 +179,28 @@ export function loadConfig(opts = {}) {
137
179
  const issues = result.error.issues
138
180
  .map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
139
181
  .join("\n");
140
- throw configInvalid(issues, sources.project ?? sources.global ?? undefined);
182
+ throw configInvalid(issues, configSourceFile(sources) ?? undefined);
141
183
  }
142
184
  return { config: result.data, sources, layers };
143
185
  }
186
+ /**
187
+ * The file to NAME for the config as a whole — the nearest winning source, in
188
+ * the precedence order the merge itself used.
189
+ *
190
+ * `explicit` COMES FIRST, and that is the whole point: an explicit file
191
+ * REPLACES project discovery, so when `--config` is in play `sources.project`
192
+ * is null and a `project ?? global` fallback silently names the *global* file.
193
+ * A user fixing the field an error reports would then be editing a file that
194
+ * contributed nothing to it (#289).
195
+ *
196
+ * This is the whole-config counterpart to `originFile` in `effective.ts`, which
197
+ * answers the same question per key. It lives here, next to the `sources` it
198
+ * reads, so `loadConfig`'s own error path can use it without importing
199
+ * `effective.ts` (which imports this module).
200
+ */
201
+ export function configSourceFile(sources) {
202
+ return sources.explicit ?? sources.project ?? sources.global ?? null;
203
+ }
144
204
  /** Resolve a dot-path (e.g. "model.temperature") against a config object. */
145
205
  export function getPath(obj, path) {
146
206
  return path
@@ -205,13 +265,33 @@ export function resolveApiKey(provider) {
205
265
  function envApiKey(provider) {
206
266
  return process.env[apiKeyEnvVar(provider)];
207
267
  }
268
+ /**
269
+ * Provider id → the environment variable its API key is read from.
270
+ *
271
+ * A TOTAL RECORD OVER `Provider`, not a switch with a default, because that is
272
+ * what makes the mapping self-maintaining: adding a provider to
273
+ * {@link ProviderSchema} makes this object a compile error until its env var is
274
+ * named. A `default:` arm silently answers `CRUXY_API_KEY` for a provider
275
+ * nobody has thought about yet, which is the failure mode #285 is named for —
276
+ * the copy that drifts is always the one telling the user where to put their
277
+ * key.
278
+ */
279
+ const API_KEY_ENV_VAR = {
280
+ cruxy: "CRUXY_API_KEY",
281
+ };
208
282
  /**
209
283
  * The environment variable a provider's API key is read from — the NAME only,
210
284
  * never the value, so a surface can say where a key would come from without
211
- * ever holding one. Exported so nothing has to restate this mapping: a second
212
- * copy would drift the moment a provider is added, and the copy that drifts is
213
- * always the one telling the user where to put their key.
285
+ * ever holding one.
286
+ *
287
+ * THE one definition. It was exported to prevent restatement and said so at the
288
+ * site, and two copies were written anyway (`cli/onboard.ts`,
289
+ * `cli/commands/pr.ts`), both carrying an `"openai"` branch that #284 had made
290
+ * unreachable — invisible because all three took `string`. The parameter is
291
+ * {@link Provider} now: an id the config layer would refuse no longer type-checks
292
+ * here, so the next such branch is a build failure rather than dead code that
293
+ * reads as live (#285).
214
294
  */
215
295
  export function apiKeyEnvVar(provider) {
216
- return provider === "openai" ? "OPENAI_API_KEY" : "CRUXY_API_KEY";
296
+ return API_KEY_ENV_VAR[provider];
217
297
  }