@cruxy/cli 1.10.0 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/dist/agent/context.js +6 -0
- 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 +49 -2
- package/dist/components/input.js +18 -1
- package/dist/components/keys.js +66 -3
- package/dist/config/manager.js +91 -11
- package/dist/config/schema.js +61 -12
- 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/render/context-view.js +12 -3
- package/dist/render/index.js +2 -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/tui/app.js +30 -7
- package/dist/tui/approval-overlay.js +4 -1
- package/dist/tui/layout.js +25 -1
- package/dist/tui/panels.js +44 -6
- package/dist/tui/renderer.js +187 -14
- package/dist/tui/supports.js +15 -0
- package/dist/usage/types.js +25 -0
- package/dist/utils/logger.js +52 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -219,6 +219,7 @@ once. Reduced motion is also implied by screen-reader mode.
|
|
|
219
219
|
| Variable | Effect |
|
|
220
220
|
| --------------------- | ------------------------------------------------------------------------------ |
|
|
221
221
|
| `CRUXY_NO_ALT_SCREEN` | Keep the full-screen TUI in the normal buffer instead of the alternate screen. |
|
|
222
|
+
| `CRUXY_NO_MOUSE` | Leave the mouse to the terminal — the wheel scrolls the window, not the pane. |
|
|
222
223
|
|
|
223
224
|
By default the TUI runs on the terminal's alternate screen — the second buffer
|
|
224
225
|
`less` and `vim` use. Leaving it restores the normal buffer byte for byte, so
|
|
@@ -237,6 +238,17 @@ prefer your shell to keep the frame. It changes nothing else about the TUI, and
|
|
|
237
238
|
it is ignored where the TUI does not run at all (a pipe, a screen reader,
|
|
238
239
|
`TERM=dumb`).
|
|
239
240
|
|
|
241
|
+
### The mouse
|
|
242
|
+
|
|
243
|
+
While the TUI is active it turns mouse reporting on, so the wheel scrolls the
|
|
244
|
+
conversation (or the selected view) the way Page Up and Page Down do, instead
|
|
245
|
+
of scrolling the terminal window over a buffer that has no scrollback. The
|
|
246
|
+
trade is the one `less` and `vim` make: **native text selection needs Shift
|
|
247
|
+
(Option in Terminal.app) while the TUI is active.** If you select text
|
|
248
|
+
constantly, set `CRUXY_NO_MOUSE=1` — the wheel goes back to the terminal, and
|
|
249
|
+
nothing else about the TUI changes. It is implied off wherever the alternate
|
|
250
|
+
screen is off, because the two are turned on and released as one.
|
|
251
|
+
|
|
240
252
|
However cruxy exits — quit, `kill -TERM`, a hangup when the window closes, an
|
|
241
253
|
uncaught error — the terminal is handed back: the frame erased, the cursor
|
|
242
254
|
shown, the alternate screen left.
|
|
@@ -272,7 +284,7 @@ branch on them:
|
|
|
272
284
|
| `0` | success | — |
|
|
273
285
|
| `1` | internal | `CRUXY_E_INTERNAL` |
|
|
274
286
|
| `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH`, `CRUXY_E_PLAN_INVALID`, `CRUXY_E_PLAN_REVISION_LIMIT`, `CRUXY_E_CHECKPOINT_NOT_FOUND` |
|
|
275
|
-
| `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID`
|
|
287
|
+
| `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID`, `CRUXY_E_CONFIG_NOT_FOUND` |
|
|
276
288
|
| `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
|
|
277
289
|
| `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
|
|
278
290
|
| `6` | api | `CRUXY_E_API`, `CRUXY_E_API_REQUEST_REJECTED`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
|
package/dist/agent/context.js
CHANGED
|
@@ -41,6 +41,11 @@ export function readContext(messages, budget) {
|
|
|
41
41
|
// reserve covers the system prompt and tool schemas that `estimateTokens`
|
|
42
42
|
// never sees, and omitting it here would under-report by ~4.5k tokens and let
|
|
43
43
|
// the panel read "comfortable" while the seam was about to compact.
|
|
44
|
+
//
|
|
45
|
+
// It is an ALLOWANCE, not a measurement, and every renderer of `used` has
|
|
46
|
+
// to be able to say so — hence `reserve` travels with the reading. It is
|
|
47
|
+
// fixed by config: it does not grow when CRUXY.md, recalled memory, LSP,
|
|
48
|
+
// web or MCP tool schemas grow the actual request.
|
|
44
49
|
const used = estimateTokens(messages) + budget.reserveTokens;
|
|
45
50
|
const total = budget.maxTokens;
|
|
46
51
|
return {
|
|
@@ -48,6 +53,7 @@ export function readContext(messages, budget) {
|
|
|
48
53
|
total,
|
|
49
54
|
fraction: total <= 0 ? 1 : Math.min(1, Math.max(0, used / total)),
|
|
50
55
|
compactAt: Math.round(budget.compactThreshold * total),
|
|
56
|
+
reserve: budget.reserveTokens,
|
|
51
57
|
};
|
|
52
58
|
}
|
|
53
59
|
/**
|
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`
|
|
@@ -62,6 +63,10 @@ export async function dispatchCommand(input, ctx) {
|
|
|
62
63
|
return { kind: "exit" };
|
|
63
64
|
if (trimmed === "/clear") {
|
|
64
65
|
session.clear();
|
|
66
|
+
// The screen too, where the shell owns one. A history reset that left the
|
|
67
|
+
// old transcript on screen above its own confirmation read as a reset that
|
|
68
|
+
// had not happened — the context WAS empty; only the screen said otherwise.
|
|
69
|
+
out.clear?.();
|
|
65
70
|
out.print(t.muted("history cleared"));
|
|
66
71
|
return { kind: "handled" };
|
|
67
72
|
}
|
|
@@ -910,6 +915,8 @@ function handleJobLogs(input, ctx) {
|
|
|
910
915
|
try {
|
|
911
916
|
const log = jobs.logs(id);
|
|
912
917
|
if (log.dropped > 0) {
|
|
918
|
+
// TRUE OF THE RING BUFFER, and said only here. The persisted log below
|
|
919
|
+
// streams, so this sentence is false about a file — see `JobLog.dropped`.
|
|
913
920
|
out.print(t.muted(`… ${log.dropped} earlier line(s) rolled off`));
|
|
914
921
|
}
|
|
915
922
|
for (const line of log.lines) {
|
|
@@ -918,9 +925,49 @@ function handleJobLogs(input, ctx) {
|
|
|
918
925
|
out.print(t.muted(`(${log.status})`));
|
|
919
926
|
}
|
|
920
927
|
catch (err) {
|
|
928
|
+
// FALL BACK TO DISK on an id this session has never heard of (#172 item 1).
|
|
929
|
+
// The live map only holds jobs THIS session dispatched, so after a
|
|
930
|
+
// `--resume` the ids a user is most likely to type are exactly the ones
|
|
931
|
+
// that miss — the job ran in the process that is now gone. Only
|
|
932
|
+
// JOB_NOT_FOUND falls through: a real failure must still fail loud.
|
|
933
|
+
if (CruxyError.is(err) &&
|
|
934
|
+
err.code === ErrorCode.JobNotFound &&
|
|
935
|
+
printPersistedJobLog(id, ctx)) {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
921
938
|
printCommandError(out, err);
|
|
922
939
|
}
|
|
923
940
|
}
|
|
941
|
+
/**
|
|
942
|
+
* Print a job log read back from disk, or report that there is none.
|
|
943
|
+
*
|
|
944
|
+
* Returns false when nothing on disk matches, so the caller can surface the
|
|
945
|
+
* original live-lookup error rather than replacing a precise "no such job in
|
|
946
|
+
* this session" with a vaguer one.
|
|
947
|
+
*/
|
|
948
|
+
function printPersistedJobLog(id, ctx) {
|
|
949
|
+
const { out } = ctx;
|
|
950
|
+
const t = out.theme;
|
|
951
|
+
const matches = matchJobLogs(jobLogFilesByRecency(process.cwd()), id);
|
|
952
|
+
if (matches.length !== 1)
|
|
953
|
+
return false;
|
|
954
|
+
const log = readJobLog(matches[0].file);
|
|
955
|
+
if (log === null)
|
|
956
|
+
return false;
|
|
957
|
+
out.print(t.muted(`(from disk — job ${log.jobId} ran in session ${shortId(log.sessionId)})`));
|
|
958
|
+
for (const line of log.lines) {
|
|
959
|
+
out.print(out.fit(line.stream === "err" ? t.danger(line.text) : line.text));
|
|
960
|
+
}
|
|
961
|
+
// No "rolled off" line: this came from the file, which holds everything up to
|
|
962
|
+
// the cap. The cap is the only thing worth reporting, and only if it was hit.
|
|
963
|
+
if (log.truncatedAt !== undefined) {
|
|
964
|
+
out.print(t.muted(`… log capped at ${log.truncatedAt} lines (\`jobs.logFileLines\`) — later output was not recorded`));
|
|
965
|
+
}
|
|
966
|
+
out.print(t.muted(log.status === INTERRUPTED
|
|
967
|
+
? "(interrupted — no terminal record; the process was killed mid-job)"
|
|
968
|
+
: `(${log.status})`));
|
|
969
|
+
return true;
|
|
970
|
+
}
|
|
924
971
|
/** Cancel a job (`/cancel <id>`). */
|
|
925
972
|
async function handleJobCancel(input, ctx) {
|
|
926
973
|
const { out, session } = ctx;
|
package/dist/components/input.js
CHANGED
|
@@ -75,7 +75,7 @@ export async function readSingleKey(stdin = process.stdin) {
|
|
|
75
75
|
const keys = createKeyReader(stdin);
|
|
76
76
|
keys.begin();
|
|
77
77
|
try {
|
|
78
|
-
const key = await keys
|
|
78
|
+
const key = await readAnswerKey(keys);
|
|
79
79
|
switch (key.kind) {
|
|
80
80
|
case "char":
|
|
81
81
|
return key.char;
|
|
@@ -89,6 +89,23 @@ export async function readSingleKey(stdin = process.stdin) {
|
|
|
89
89
|
keys.restore();
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* The next key that can be an ANSWER — a wheel notch is not one.
|
|
94
|
+
*
|
|
95
|
+
* Every single-key prompt maps "anything unmapped" to the safe default (deny,
|
|
96
|
+
* cancel, no). That is right for an arrow or a function key, and wrong for the
|
|
97
|
+
* mouse wheel: with mouse reporting on (the TUI turns it on), a reader who
|
|
98
|
+
* scrolls back to check what a prompt is about would answer it "no" by doing
|
|
99
|
+
* so. The wheel is a scroll, never a reply, so it is skipped here and the
|
|
100
|
+
* prompt keeps waiting.
|
|
101
|
+
*/
|
|
102
|
+
export async function readAnswerKey(keys) {
|
|
103
|
+
for (;;) {
|
|
104
|
+
const key = await keys.read();
|
|
105
|
+
if (key.kind !== "wheel-up" && key.kind !== "wheel-down")
|
|
106
|
+
return key;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
92
109
|
/** The real environment: frames to stderr, keys from stdin, caps from stderr. */
|
|
93
110
|
export function defaultComponentIO() {
|
|
94
111
|
// Detection resolves both axes (stderr for output, stdin for input) and their
|