@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.
- package/README.md +2 -2
- package/dist/agent/loop.js +16 -1
- package/dist/agent/session.js +73 -9
- package/dist/approval/classify.js +170 -40
- package/dist/approval/prompt.js +52 -6
- package/dist/approval/service.js +1 -11
- package/dist/budget/session-budget.js +10 -1
- package/dist/checkpoint/coverage.js +147 -4
- package/dist/cli/command-catalog.js +5 -1
- package/dist/cli/commands/config.js +18 -3
- package/dist/cli/commands/limits.js +76 -0
- package/dist/cli/commands/logs.js +149 -0
- package/dist/cli/commands/pr.js +11 -11
- package/dist/cli/commands/rollback.js +10 -2
- package/dist/cli/commands/run.js +25 -6
- package/dist/cli/commands/sessions.js +181 -0
- package/dist/cli/onboard.js +0 -9
- package/dist/cli/program.js +19 -1
- package/dist/cli/repl.js +25 -0
- package/dist/cli/session-commands.js +45 -2
- package/dist/cli/session-factory.js +31 -10
- package/dist/config/manager.js +91 -11
- package/dist/config/schema.js +194 -20
- package/dist/constants.js +12 -2
- package/dist/errors/constructors.js +84 -46
- package/dist/errors/types.js +6 -0
- package/dist/jobs/index.js +1 -0
- package/dist/jobs/log-renderer.js +10 -5
- package/dist/jobs/log-store.js +505 -0
- package/dist/jobs/manager.js +338 -18
- package/dist/mcp/client.js +16 -0
- package/dist/render/limits-report.js +213 -0
- package/dist/render/limits-view.js +125 -0
- package/dist/routing/index.js +1 -1
- package/dist/routing/router.js +34 -14
- package/dist/routing/types.js +0 -2
- package/dist/sandbox/service.js +9 -0
- package/dist/sandbox/types.js +15 -0
- package/dist/session/index.js +3 -1
- package/dist/session/list.js +20 -6
- package/dist/session/log.js +120 -21
- package/dist/session/prune.js +166 -0
- package/dist/session/resume.js +5 -0
- package/dist/subagent/orchestrator.js +87 -34
- package/dist/subagent/spawn-tool.js +11 -4
- package/dist/tools/schema-depth.js +18 -0
- package/dist/tui/limits-panel.js +53 -30
- package/dist/usage/collect.js +20 -1
- package/dist/usage/summary.js +48 -1
- package/dist/usage/types.js +52 -0
- 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
|
+
}
|
package/dist/cli/onboard.js
CHANGED
|
@@ -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);
|
package/dist/cli/program.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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,
|
package/dist/config/manager.js
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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
|
|
296
|
+
return API_KEY_ENV_VAR[provider];
|
|
217
297
|
}
|