@cruxy/cli 1.10.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 +1 -1
- package/dist/agent/session.js +11 -0
- package/dist/cli/command-catalog.js +5 -1
- package/dist/cli/commands/config.js +18 -3
- package/dist/cli/commands/logs.js +149 -0
- package/dist/cli/commands/pr.js +1 -10
- package/dist/cli/commands/run.js +6 -3
- package/dist/cli/commands/sessions.js +27 -2
- package/dist/cli/onboard.js +0 -9
- package/dist/cli/program.js +15 -1
- package/dist/cli/session-commands.js +45 -2
- package/dist/config/manager.js +91 -11
- package/dist/config/schema.js +53 -11
- package/dist/errors/constructors.js +35 -2
- 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 +69 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/router.js +34 -14
- package/dist/routing/types.js +0 -2
- package/dist/session/prune.js +83 -23
- package/dist/subagent/orchestrator.js +16 -3
- package/dist/usage/types.js +25 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -272,7 +272,7 @@ branch on them:
|
|
|
272
272
|
| `0` | success | — |
|
|
273
273
|
| `1` | internal | `CRUXY_E_INTERNAL` |
|
|
274
274
|
| `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`
|
|
275
|
+
| `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID`, `CRUXY_E_CONFIG_NOT_FOUND` |
|
|
276
276
|
| `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
|
|
277
277
|
| `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
|
|
278
278
|
| `6` | api | `CRUXY_E_API`, `CRUXY_E_API_REQUEST_REJECTED`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
|
package/dist/agent/session.js
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
48
|
-
|
|
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
|
+
}
|
package/dist/cli/commands/pr.js
CHANGED
|
@@ -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
|
-
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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) {
|
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
|
@@ -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 {
|
|
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;
|
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
|
}
|