@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
|
@@ -24,10 +24,13 @@ import { ALWAYS_IGNORE_DIRS, isSecretPath } from "../indexing/walker.js";
|
|
|
24
24
|
* `classify` is a pure synchronous function by design (it runs on every action,
|
|
25
25
|
* including the tier check in `approval/mutex.ts`). So a gitignored build
|
|
26
26
|
* directory — `rm -rf dist` — is still classified reversible when it is not.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
*
|
|
28
|
+
* That general residual is **accepted**, not pending: emulating `git
|
|
29
|
+
* check-ignore` from a pure path predicate is the wrong shape for this seam
|
|
30
|
+
* (cli#241 records the argument in full). What is not accepted is the subset
|
|
31
|
+
* whose loss is *permanent* — Terraform state, a local database, a `*.local.*`
|
|
32
|
+
* config — which {@link ceilingExclusion} refuses on the ceiling side without
|
|
33
|
+
* touching this function or the snapshot it filters.
|
|
31
34
|
*/
|
|
32
35
|
export function captureExclusion(relPath) {
|
|
33
36
|
const first = relPath.split("/")[0];
|
|
@@ -68,3 +71,143 @@ for (const name of ALWAYS_IGNORE_DIRS) {
|
|
|
68
71
|
`every path the capturer skips must be one auto-approve refuses to treat as restorable`);
|
|
69
72
|
}
|
|
70
73
|
}
|
|
74
|
+
// ── the ceiling-only strictness list (cli#241) ────────────────────────────────
|
|
75
|
+
/**
|
|
76
|
+
* Why the **ceiling** refuses to call `relPath` restorable, or `null` when it has
|
|
77
|
+
* no objection. Same shape as {@link captureExclusion} — pure, synchronous,
|
|
78
|
+
* path-only, no I/O — because `classify` runs it on every action, including the
|
|
79
|
+
* tier check in `approval/mutex.ts`.
|
|
80
|
+
*
|
|
81
|
+
* It is {@link captureExclusion} **plus** {@link CEILING_ONLY}, and therefore
|
|
82
|
+
* strictly stricter. `classify` reads this one; `captureFiles` still reads
|
|
83
|
+
* `captureExclusion`. The two differ by exactly the paths a snapshot should keep
|
|
84
|
+
* taking and auto-approve should stop trusting.
|
|
85
|
+
*
|
|
86
|
+
* Returns a **predicate phrase** — the sentence minus its subject, so the caller
|
|
87
|
+
* supplies the label the user actually typed. The two halves are introduced
|
|
88
|
+
* differently on purpose: a capture exclusion is a fact ("is not captured"),
|
|
89
|
+
* while a ceiling-only class may well be sitting in the snapshot if the repo
|
|
90
|
+
* tracks it, and the prompt must not claim otherwise.
|
|
91
|
+
*/
|
|
92
|
+
export function ceilingExclusion(relPath) {
|
|
93
|
+
const notCaptured = captureExclusion(relPath);
|
|
94
|
+
if (notCaptured !== null) {
|
|
95
|
+
return `is not captured by the checkpoint — ${notCaptured}`;
|
|
96
|
+
}
|
|
97
|
+
for (const rule of CEILING_ONLY) {
|
|
98
|
+
if (rule.matches(relPath)) {
|
|
99
|
+
return `is not one the checkpoint can be relied on to restore — ${rule.reason}`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Paths auto-approve refuses to treat as restorable **even though the capturer
|
|
106
|
+
* may well have snapshotted them**. This is the ceiling being stricter than the
|
|
107
|
+
* snapshot, deliberately and in one direction only.
|
|
108
|
+
*
|
|
109
|
+
* ── Why a second list, and not four more entries in `NEVER_CAPTURED_DIRS` ──
|
|
110
|
+
* That map is read by `captureFiles`. Adding `*.tfstate` there would stop the
|
|
111
|
+
* capturer from snapshotting a `terraform.tfstate` a repo tracks — turning a
|
|
112
|
+
* conservative extra prompt into an actual hole in the snapshot, in the name of
|
|
113
|
+
* closing one. The two lists exist because the two questions are different:
|
|
114
|
+
*
|
|
115
|
+
* • `captureExclusion` — "must the snapshot refuse to hold this?" (secrets,
|
|
116
|
+
* `.git/`, the checkpoint store itself). Excluding is a cost paid by restore.
|
|
117
|
+
* • `CEILING_ONLY` — "may auto-approve assume a snapshot can put this back?"
|
|
118
|
+
* Refusing costs one prompt and nothing else.
|
|
119
|
+
*
|
|
120
|
+
* So the safe drift direction is *into* this list, never out of it. The
|
|
121
|
+
* assertions below enforce both halves: `NEVER_CAPTURED ⊆ CEILING` (anything the
|
|
122
|
+
* snapshot skips, the ceiling must also refuse), and each rule here is witnessed
|
|
123
|
+
* to be outside the capture exclusion (anything filed here must genuinely be the
|
|
124
|
+
* ceiling being stricter, not a snapshot exclusion in the wrong file).
|
|
125
|
+
*
|
|
126
|
+
* ── The selection rule: permanent loss, not regenerable output ──
|
|
127
|
+
* The general problem — "is this path gitignored?" — is not decidable from a
|
|
128
|
+
* path alone and stays open (see {@link captureExclusion}). This list does not
|
|
129
|
+
* try to solve it. A class earns a place here only when destroying the file
|
|
130
|
+
* destroys **state that exists nowhere else**: not in a commit, not in a
|
|
131
|
+
* registry, not reproducible by re-running a build. That is what separates these
|
|
132
|
+
* from `dist/`, `.next/`, `coverage/`, `target/` — all of which are just as
|
|
133
|
+
* likely to be gitignored and just as absent from the snapshot, and all of which
|
|
134
|
+
* a build command puts back. The cost of being wrong is the whole selection
|
|
135
|
+
* criterion: a false positive here is one prompt, a false negative is a file
|
|
136
|
+
* nobody can reconstruct.
|
|
137
|
+
*
|
|
138
|
+
* Deliberately NOT here: anything decided by *content*, by repo layout, or by
|
|
139
|
+
* asking git. Each rule below is a fact about a filename.
|
|
140
|
+
*/
|
|
141
|
+
const CEILING_ONLY = [
|
|
142
|
+
{
|
|
143
|
+
// `terraform.tfstate`, `.tfstate.backup`, `terraform.tfstate.d/<ws>/...`.
|
|
144
|
+
// Matched on any segment so a workspace directory carries the exclusion to
|
|
145
|
+
// everything under it.
|
|
146
|
+
matches: (relPath) => relPath.split("/").some((seg) => seg.includes(".tfstate")),
|
|
147
|
+
reason: "a Terraform state file is the only record mapping the configuration to the real infrastructure it manages; it cannot be recomputed from the `.tf` files, and losing it orphans live resources",
|
|
148
|
+
witness: "infra/terraform.tfstate",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
matches: (relPath) => isLocalDatabase(basename(relPath)),
|
|
152
|
+
reason: "a local database file holds rows that exist nowhere else — no build regenerates them, no registry re-serves them, and the checkpoint only captures it when the repo happens to track it",
|
|
153
|
+
witness: "data/app.sqlite",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
// `settings.local.json`, `vite.config.local.ts`, `wrangler.local.toml`.
|
|
157
|
+
matches: (relPath) => basename(relPath).includes(".local."),
|
|
158
|
+
reason: "`*.local.*` is the convention for the machine-specific half of a config pair — the copy that is deliberately not in the repo, and so is in no commit and in no snapshot to restore from",
|
|
159
|
+
witness: ".claude/settings.local.json",
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
/** Extensions used by the embedded databases a project keeps in its own tree. */
|
|
163
|
+
const LOCAL_DB_EXTENSIONS = new Set([
|
|
164
|
+
"sqlite",
|
|
165
|
+
"sqlite3",
|
|
166
|
+
"db",
|
|
167
|
+
"db3",
|
|
168
|
+
"duckdb",
|
|
169
|
+
]);
|
|
170
|
+
/**
|
|
171
|
+
* SQLite writes its rollback journal and WAL/shared-memory files *beside* the
|
|
172
|
+
* database as `<name>-journal` / `-wal` / `-shm`. Deleting one of those loses
|
|
173
|
+
* committed-but-uncheckpointed pages, so they are the same class as the file
|
|
174
|
+
* they sit next to.
|
|
175
|
+
*/
|
|
176
|
+
const DB_SIDECAR = /-(wal|shm|journal)$/;
|
|
177
|
+
function isLocalDatabase(base) {
|
|
178
|
+
const name = base.replace(DB_SIDECAR, "");
|
|
179
|
+
const dot = name.lastIndexOf(".");
|
|
180
|
+
return dot > 0 && LOCAL_DB_EXTENSIONS.has(name.slice(dot + 1).toLowerCase());
|
|
181
|
+
}
|
|
182
|
+
/** Last segment of a project-relative POSIX path (the input contract everywhere here). */
|
|
183
|
+
function basename(relPath) {
|
|
184
|
+
return relPath.split("/").pop() ?? "";
|
|
185
|
+
}
|
|
186
|
+
// NEVER_CAPTURED ⊆ CEILING. The same shape as the ALWAYS_IGNORE_DIRS assertion
|
|
187
|
+
// above, one layer out: a path the snapshot cannot hold must be one the ceiling
|
|
188
|
+
// refuses to call restorable. It holds by delegation today — `ceilingExclusion`
|
|
189
|
+
// asks `captureExclusion` first — and is asserted so that it keeps holding if
|
|
190
|
+
// that delegation is ever restructured, reordered, or "optimized" into a single
|
|
191
|
+
// merged list. Drift is permitted in the safe direction only: the ceiling may
|
|
192
|
+
// refuse more than the capturer skips, never less.
|
|
193
|
+
for (const name of NEVER_CAPTURED_DIRS.keys()) {
|
|
194
|
+
if (ceilingExclusion(`${name}/probe`) === null) {
|
|
195
|
+
throw new Error(`checkpoint/coverage.ts: \`${name}/\` is never captured but the auto-approve ceiling ` +
|
|
196
|
+
`would call it restorable — the ceiling must refuse everything the snapshot skips`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// …and STRICTLY stricter: every rule here must match its own witness, and that
|
|
200
|
+
// witness must be a path the capturer still snapshots. The second half is the
|
|
201
|
+
// load-bearing one — it fails the build if a class is moved into
|
|
202
|
+
// `NEVER_CAPTURED_DIRS`/`isSecretPath` instead, which would close the prompt by
|
|
203
|
+
// shrinking the snapshot rather than by tightening the ceiling.
|
|
204
|
+
for (const rule of CEILING_ONLY) {
|
|
205
|
+
if (!rule.matches(rule.witness)) {
|
|
206
|
+
throw new Error(`checkpoint/coverage.ts: ceiling-only rule does not match its own witness \`${rule.witness}\``);
|
|
207
|
+
}
|
|
208
|
+
if (captureExclusion(rule.witness) !== null) {
|
|
209
|
+
throw new Error(`checkpoint/coverage.ts: \`${rule.witness}\` is already outside the capture set, so this ` +
|
|
210
|
+
`rule is not ceiling-only — an exclusion the capturer honors belongs in NEVER_CAPTURED_DIRS, ` +
|
|
211
|
+
`and moving it there would stop the snapshot taking a copy the repo may well track`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -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,76 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { LimitsClient } from "@cruxy/sdk";
|
|
3
|
+
import { loadConfig, readCredentialMeta, resolveApiKey, } from "../../config/index.js";
|
|
4
|
+
import { shouldUseColor } from "../../errors/index.js";
|
|
5
|
+
import { LimitsCache } from "../../limits/index.js";
|
|
6
|
+
import { limitsReportLines } from "../../render/limits-report.js";
|
|
7
|
+
import { themeForColor } from "../../theme/index.js";
|
|
8
|
+
import { logger } from "../../utils/logger.js";
|
|
9
|
+
/**
|
|
10
|
+
* `cruxy limits` (cli#138) — what this account may spend, and how much is left,
|
|
11
|
+
* from outside a session.
|
|
12
|
+
*
|
|
13
|
+
* THE GAP THIS CLOSES IS THE SESSION, not the rendering. Both existing surfaces
|
|
14
|
+
* read the same cache and neither can run without one: the P9 panel is drawn by
|
|
15
|
+
* the interactive rail, and `/budget` is a slash command. "How much of my pool
|
|
16
|
+
* is left" was therefore unanswerable from a script, a prompt, or a CI step —
|
|
17
|
+
* for a figure that is about the ACCOUNT and has nothing to do with whether a
|
|
18
|
+
* session happens to be open.
|
|
19
|
+
*
|
|
20
|
+
* IT IS NOT A FLAG ON `cruxy usage`, and the reason is not tidiness. That
|
|
21
|
+
* command reads the local store and transmits nothing, a promise held by a
|
|
22
|
+
* runtime fetch spy and a static source guard. The number this one wants is on
|
|
23
|
+
* the server, so putting it there would mean deleting that guarantee to gain a
|
|
24
|
+
* figure that has its own home — and joining the two numerators would produce a
|
|
25
|
+
* ratio that reads as precise and is not (`render/limits-report.ts` carries the
|
|
26
|
+
* arithmetic). Two commands, two sources, and the boundary is legible from the
|
|
27
|
+
* name.
|
|
28
|
+
*
|
|
29
|
+
* Note for anyone extending this: the no-phone-home guard lists the files it
|
|
30
|
+
* covers by hand, and this one is deliberately absent from that list. That is
|
|
31
|
+
* correct — this command is supposed to make the call — but it does mean a green
|
|
32
|
+
* guard says nothing at all about this file. The boundary is the command split.
|
|
33
|
+
*
|
|
34
|
+
* THE READING COMES THROUGH `LimitsCache`, not through a bare client call, so
|
|
35
|
+
* this command and the rail reduce the same response the same way. The cache's
|
|
36
|
+
* other job — keeping a good reading through a failed refresh — is inert in a
|
|
37
|
+
* one-shot process with nothing prior to keep, which is exactly why the tri-state
|
|
38
|
+
* matters here: with no earlier answer to stand on, a failed probe IS the
|
|
39
|
+
* result, and the report says which failure it was.
|
|
40
|
+
*/
|
|
41
|
+
export function limitsCommand() {
|
|
42
|
+
return new Command("limits")
|
|
43
|
+
.description("show your plan's weighted-token pool and what is left of it")
|
|
44
|
+
.action(async () => {
|
|
45
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
46
|
+
const { config } = loadConfig();
|
|
47
|
+
const provider = config.model.provider;
|
|
48
|
+
// ONLY ON THE CRUXY PROVIDER — now guaranteed by the schema rather than
|
|
49
|
+
// re-checked here. `/limits` is a cruxy gateway endpoint, and pointing it
|
|
50
|
+
// at someone else's base URL would be sending their credential to a
|
|
51
|
+
// stranger to ask a question their API was never going to answer. This
|
|
52
|
+
// used to be a runtime guard on `provider !== "cruxy"`; since #260 that
|
|
53
|
+
// is the only value `ProviderSchema` accepts, so a non-cruxy provider is
|
|
54
|
+
// refused at CONFIG LOAD (CRUXY_E_CONFIG_INVALID) — before this or any
|
|
55
|
+
// other command runs. The guarantee got earlier and wider, not weaker.
|
|
56
|
+
//
|
|
57
|
+
// NOTE what neither form covers: `cruxy.gatewayUrl` is configurable, so a
|
|
58
|
+
// cruxy-provider session can still be aimed at a non-cruxy host. That was
|
|
59
|
+
// true of the old guard too — it keyed on the provider id, not the URL.
|
|
60
|
+
const apiKey = resolveApiKey(provider);
|
|
61
|
+
// The stored expiry, read lazily and only while classifying a 401 — it is
|
|
62
|
+
// what lets an aged-out sign-in be reported as expired rather than as a
|
|
63
|
+
// key that was never any good. The gateway refuses both identically and
|
|
64
|
+
// will not say which.
|
|
65
|
+
const cache = new LimitsCache(apiKey === undefined
|
|
66
|
+
? undefined
|
|
67
|
+
: (signal) => new LimitsClient({
|
|
68
|
+
apiKey,
|
|
69
|
+
gatewayUrl: config.cruxy.gatewayUrl,
|
|
70
|
+
}).read(signal), { credentialExpiresAt: () => readCredentialMeta(provider)?.expiresAt });
|
|
71
|
+
await cache.refresh();
|
|
72
|
+
for (const line of limitsReportLines(t, cache.current())) {
|
|
73
|
+
logger.print(line);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -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,10 +1,10 @@
|
|
|
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
|
-
import { ApprovalService } from "../../approval/index.js";
|
|
7
|
+
import { ApprovalService, defaultPromptIO, InteractivePolicy, SessionAllowlist, } from "../../approval/index.js";
|
|
8
8
|
import { resolveTaskModel, routerForConfig } from "../../routing/index.js";
|
|
9
9
|
import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidance, resolveForgeToken, } from "../../vcs/index.js";
|
|
10
10
|
/**
|
|
@@ -48,9 +48,18 @@ export function prCommand() {
|
|
|
48
48
|
// resolveForgeToken throws CRUXY_E_FORGE_AUTH (exit 4) if none is found.
|
|
49
49
|
const forge = createForgeProvider(resolveForgeToken());
|
|
50
50
|
const guidance = await loadCommitGuidance(cwd);
|
|
51
|
+
// The allowlist here is provably inert, and stated rather than implied
|
|
52
|
+
// (cli#251). `cruxy pr` submits exactly one kind of action — `vcs` — and
|
|
53
|
+
// `vcsRequest` gives every one of them `scope: {kind: "none"}` and an
|
|
54
|
+
// unconditional `irreversible`. So it is never WRITTEN (`grant()` returns
|
|
55
|
+
// early on a `none` scope) and never READ (`allows()` refuses an
|
|
56
|
+
// irreversible request before it matches scopes at all, since cli#240).
|
|
57
|
+
// A fresh one each time is therefore the whole truth about this command:
|
|
58
|
+
// every PR is approved on its own, by design.
|
|
51
59
|
const approval = new ApprovalService({
|
|
52
60
|
cwd,
|
|
53
61
|
interactive: Boolean(process.stdin.isTTY),
|
|
62
|
+
policy: new InteractivePolicy(new SessionAllowlist(), defaultPromptIO(shouldUseColor())),
|
|
54
63
|
});
|
|
55
64
|
const service = createPrService({
|
|
56
65
|
cwd,
|
|
@@ -83,12 +92,3 @@ export function prCommand() {
|
|
|
83
92
|
logger.print(t.accent(outcome.url));
|
|
84
93
|
});
|
|
85
94
|
}
|
|
86
|
-
/** Environment variable that holds the API key for a provider. */
|
|
87
|
-
function apiKeyEnvVar(provider) {
|
|
88
|
-
switch (provider) {
|
|
89
|
-
case "openai":
|
|
90
|
-
return "OPENAI_API_KEY";
|
|
91
|
-
default:
|
|
92
|
-
return "CRUXY_API_KEY";
|
|
93
|
-
}
|
|
94
|
-
}
|
|
@@ -3,7 +3,7 @@ import { Command } from "commander";
|
|
|
3
3
|
import { themeForColor } from "../../theme/index.js";
|
|
4
4
|
import { loadConfig } from "../../config/index.js";
|
|
5
5
|
import { CheckpointService, listSets, rollbackCheckpoint, rollbackSet, } from "../../checkpoint/index.js";
|
|
6
|
-
import { ApprovalService, defaultPromptIO } from "../../approval/index.js";
|
|
6
|
+
import { ApprovalService, defaultPromptIO, InteractivePolicy, SessionAllowlist, } from "../../approval/index.js";
|
|
7
7
|
import { fuzzyFind, selectList } from "../../components/index.js";
|
|
8
8
|
import { rollbackApprovalRequired, shouldUseColor, } from "../../errors/index.js";
|
|
9
9
|
import { logger } from "../../utils/logger.js";
|
|
@@ -92,10 +92,18 @@ export function rollbackCommand() {
|
|
|
92
92
|
const t = themeForColor(shouldUseColor(process.stdout));
|
|
93
93
|
const { config } = loadConfig();
|
|
94
94
|
const primaryRoot = process.cwd();
|
|
95
|
+
// Provably inert, and stated rather than implied (cli#251). `cruxy
|
|
96
|
+
// rollback` submits exactly one kind of action — `rollback` — and
|
|
97
|
+
// `rollbackRequest` gives every one of them `scope: {kind: "none"}` and an
|
|
98
|
+
// unconditional `irreversible` (`ROLLBACK_IRREVERSIBLE`). So this
|
|
99
|
+
// allowlist is never WRITTEN (`grant()` returns early on a `none` scope)
|
|
100
|
+
// and never READ (`allows()` refuses an irreversible request before it
|
|
101
|
+
// matches scopes at all, since cli#240). Every restore is a deliberate,
|
|
102
|
+
// one-off approval — which is the property, not an oversight.
|
|
95
103
|
const approval = new ApprovalService({
|
|
96
104
|
cwd: primaryRoot,
|
|
97
105
|
interactive,
|
|
98
|
-
|
|
106
|
+
policy: new InteractivePolicy(new SessionAllowlist(), defaultPromptIO(shouldUseColor())),
|
|
99
107
|
});
|
|
100
108
|
const deps = {
|
|
101
109
|
config,
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -5,8 +5,14 @@ import { LimitsCache } from "../../limits/index.js";
|
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
6
6
|
import { SessionLog, listSessions, loadResume, resolveSessionId, resumePicker, shortId, } from "../../session/index.js";
|
|
7
7
|
/** Sessions shown in the TUI sidebar — the same depth as the resume picker. */
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* How many sessions the TUI sidebar lists. Exported so
|
|
10
|
+
* `session/retention-floor.test.ts` can pin `SESSION_RETENTION_FLOOR` against
|
|
11
|
+
* it — raising this surface without raising the floor would let the sidebar
|
|
12
|
+
* offer rows a later prune has already marked expendable (#257).
|
|
13
|
+
*/
|
|
14
|
+
export const SIDEBAR_SESSIONS = 10;
|
|
15
|
+
import { apiKeyEnvVar, classifyCredentialLifetime, configSourceFile, globalDir, loadConfig, readCredentialMeta, resolveApiKey, } from "../../config/index.js";
|
|
10
16
|
import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
|
|
11
17
|
import { createRenderer } from "../../render/index.js";
|
|
12
18
|
import { themeForColor } from "../../theme/index.js";
|
|
@@ -18,7 +24,7 @@ import { DEFAULT_MODE, } from "../../agent/index.js";
|
|
|
18
24
|
import { runInteractive } from "../repl.js";
|
|
19
25
|
import { ContextGauge, createKeyLease, createGitView, createOverviewView, createPermissionsView, createSettingsView, createTasksView, runTui, TuiRenderer, WorkspaceDiskCache, WorkspaceGitCache, } from "../../tui/index.js";
|
|
20
26
|
import { buildAgentSession } from "../session-factory.js";
|
|
21
|
-
import {
|
|
27
|
+
import { maybeRunOnboarding } from "../onboard.js";
|
|
22
28
|
import { resetLspServices } from "../../lsp/index.js";
|
|
23
29
|
import { connectMcpTools, deferredSiblingServers, resetMcpServices, } from "../../mcp/index.js";
|
|
24
30
|
import { defaultPromptIO } from "../../approval/index.js";
|
|
@@ -129,7 +135,10 @@ export async function executeRun(promptParts, opts) {
|
|
|
129
135
|
const { config, sources } = loaded;
|
|
130
136
|
let apiKey = resolveApiKey(config.model.provider);
|
|
131
137
|
logger.info(t.muted(`model: ${config.model.provider}/${config.model.model}`));
|
|
132
|
-
|
|
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"}`));
|
|
133
142
|
// Multi-root honesty (JC-6/C.26): reads fan every root. With per-root
|
|
134
143
|
// checkpoints active (C.26 step 3), writes fan every root too — each is
|
|
135
144
|
// checkpointed and rollback-able. With checkpoints DISABLED, a non-primary
|
|
@@ -340,11 +349,21 @@ export async function executeRun(promptParts, opts) {
|
|
|
340
349
|
model: config.model.model,
|
|
341
350
|
logger,
|
|
342
351
|
currentRunId: () => checkpoints?.currentRunId(),
|
|
352
|
+
// Persistence and retention (#257). `enabled: false` makes this null and
|
|
353
|
+
// the session runs in memory; retention is enforced once, here, as the
|
|
354
|
+
// log opens — the same position `CheckpointService` prunes from.
|
|
355
|
+
sessions: config.sessions,
|
|
343
356
|
...(resumed ? { file: resumed.session.file } : {}),
|
|
344
357
|
}) ?? undefined;
|
|
345
358
|
// The TUI sidebar lists the same tree the `--resume` picker reads (P2).
|
|
346
|
-
//
|
|
347
|
-
//
|
|
359
|
+
//
|
|
360
|
+
// A FRESH SESSION IS NOT IN ITS OWN SIDEBAR UNTIL TURN 1 (#257). This used to
|
|
361
|
+
// be populated after the log was opened precisely so the running session
|
|
362
|
+
// listed itself; the meta line is buffered now, so there is nothing on disk
|
|
363
|
+
// to list until something is said. That is the more honest reading — the
|
|
364
|
+
// sidebar and the `--resume` picker show the same tree, and a session with no
|
|
365
|
+
// conversation in it is not one the picker would offer either. The active id
|
|
366
|
+
// is still passed, so the row marks itself the moment it appears.
|
|
348
367
|
if (renderer instanceof TuiRenderer) {
|
|
349
368
|
renderer.setSessions(listSessions(primaryRoot, SIDEBAR_SESSIONS), sessionId);
|
|
350
369
|
}
|