@cruxy/cli 1.9.0 → 1.10.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/loop.js +16 -1
- package/dist/agent/session.js +62 -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/commands/limits.js +76 -0
- package/dist/cli/commands/pr.js +10 -1
- package/dist/cli/commands/rollback.js +10 -2
- package/dist/cli/commands/run.js +19 -3
- package/dist/cli/commands/sessions.js +156 -0
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.js +25 -0
- package/dist/cli/session-factory.js +31 -10
- package/dist/config/schema.js +141 -9
- package/dist/constants.js +12 -2
- package/dist/errors/constructors.js +49 -44
- package/dist/jobs/manager.js +269 -17
- 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/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 +106 -0
- package/dist/session/resume.js +5 -0
- package/dist/subagent/orchestrator.js +71 -31
- 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 +27 -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
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/cli/commands/pr.js
CHANGED
|
@@ -4,7 +4,7 @@ import { logger } from "../../utils/logger.js";
|
|
|
4
4
|
import { 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,
|
|
@@ -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,7 +5,13 @@ 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
|
-
|
|
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;
|
|
9
15
|
import { classifyCredentialLifetime, 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";
|
|
@@ -340,11 +346,21 @@ export async function executeRun(promptParts, opts) {
|
|
|
340
346
|
model: config.model.model,
|
|
341
347
|
logger,
|
|
342
348
|
currentRunId: () => checkpoints?.currentRunId(),
|
|
349
|
+
// Persistence and retention (#257). `enabled: false` makes this null and
|
|
350
|
+
// the session runs in memory; retention is enforced once, here, as the
|
|
351
|
+
// log opens — the same position `CheckpointService` prunes from.
|
|
352
|
+
sessions: config.sessions,
|
|
343
353
|
...(resumed ? { file: resumed.session.file } : {}),
|
|
344
354
|
}) ?? undefined;
|
|
345
355
|
// The TUI sidebar lists the same tree the `--resume` picker reads (P2).
|
|
346
|
-
//
|
|
347
|
-
//
|
|
356
|
+
//
|
|
357
|
+
// A FRESH SESSION IS NOT IN ITS OWN SIDEBAR UNTIL TURN 1 (#257). This used to
|
|
358
|
+
// be populated after the log was opened precisely so the running session
|
|
359
|
+
// listed itself; the meta line is buffered now, so there is nothing on disk
|
|
360
|
+
// to list until something is said. That is the more honest reading — the
|
|
361
|
+
// sidebar and the `--resume` picker show the same tree, and a session with no
|
|
362
|
+
// conversation in it is not one the picker would offer either. The active id
|
|
363
|
+
// is still passed, so the row marks itself the moment it appears.
|
|
348
364
|
if (renderer instanceof TuiRenderer) {
|
|
349
365
|
renderer.setSessions(listSessions(primaryRoot, SIDEBAR_SESSIONS), sessionId);
|
|
350
366
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { unlinkSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { loadConfig } from "../../config/index.js";
|
|
5
|
+
import { shouldUseColor, usageError } from "../../errors/index.js";
|
|
6
|
+
import { SESSION_FILE_EXT, matchSessionRefs, listSessionRefs, pruneSessions, sessionFilesByRecency, shortId, summarizeSession, relativeAge, } from "../../session/index.js";
|
|
7
|
+
import { themeForColor } from "../../theme/index.js";
|
|
8
|
+
import { formatBytes } from "../../utils/disk.js";
|
|
9
|
+
import { logger } from "../../utils/logger.js";
|
|
10
|
+
/**
|
|
11
|
+
* `cruxy sessions` (#257) — see and bound what `~/.cruxy/projects/<project>/`
|
|
12
|
+
* is holding.
|
|
13
|
+
*
|
|
14
|
+
* WHY THIS EXISTS AT ALL, when retention already runs on its own. Retention
|
|
15
|
+
* that only ever fires implicitly is retention nobody can audit before it eats
|
|
16
|
+
* something: the first prune after upgrading from a build that had none can
|
|
17
|
+
* remove months of sessions, and "trust me, the right ones went" is not a thing
|
|
18
|
+
* a user can check. `list` shows what is there and which rows the current
|
|
19
|
+
* bounds would take; `prune` runs the same policy on demand; `rm` is the
|
|
20
|
+
* escape hatch for one specific session.
|
|
21
|
+
*
|
|
22
|
+
* BYTES ARE REPORTED, NEVER PRUNED ON. `statSync` hands the size over for free
|
|
23
|
+
* in the same scan that orders by mtime, and seeing where the disk went is
|
|
24
|
+
* exactly what a report is for. Making it a BOUND is the part that would be
|
|
25
|
+
* wrong — whether your session survives should not depend on how chatty an
|
|
26
|
+
* unrelated one was.
|
|
27
|
+
*/
|
|
28
|
+
export function sessionsCommand() {
|
|
29
|
+
const cmd = new Command("sessions").description("manage this project's saved sessions — list, prune, delete");
|
|
30
|
+
cmd
|
|
31
|
+
.command("list", { isDefault: true })
|
|
32
|
+
.description("list saved sessions, newest first")
|
|
33
|
+
.option("--all", "list every session, not just the newest 20")
|
|
34
|
+
.action((opts) => {
|
|
35
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
36
|
+
const { config } = loadConfig();
|
|
37
|
+
const cwd = process.cwd();
|
|
38
|
+
const files = sessionFilesByRecency(cwd);
|
|
39
|
+
if (files.length === 0) {
|
|
40
|
+
logger.print(t.muted("no saved sessions for this project"));
|
|
41
|
+
printPolicy(config.sessions, t);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// The listing is the one place that pays for a full read per row — it is
|
|
45
|
+
// showing titles and turn counts, which is the cost `summarizeSession`
|
|
46
|
+
// exists to charge. Bounded to 20 unless asked, for the same reason the
|
|
47
|
+
// picker is bounded to 10.
|
|
48
|
+
const shown = opts.all ? files : files.slice(0, 20);
|
|
49
|
+
const total = files.reduce((n, f) => n + f.size, 0);
|
|
50
|
+
for (const ref of shown) {
|
|
51
|
+
const summary = summarizeSession(ref.file);
|
|
52
|
+
const id = summary
|
|
53
|
+
? shortId(summary.sessionId)
|
|
54
|
+
: shortId(path.basename(ref.file, SESSION_FILE_EXT));
|
|
55
|
+
// A file with no readable meta is SHOWN, not hidden. It is occupying
|
|
56
|
+
// disk and it is a thing prune will consider; a report that silently
|
|
57
|
+
// omitted it would be lying about what is there.
|
|
58
|
+
const title = summary ? summary.title : t.muted("(unreadable)");
|
|
59
|
+
const turns = summary
|
|
60
|
+
? `${summary.turns} turn${summary.turns === 1 ? "" : "s"}`
|
|
61
|
+
: "—";
|
|
62
|
+
logger.print(`${t.accent(id.padEnd(8))} ${t.muted(relativeAge(new Date(ref.mtimeMs).toISOString()).padEnd(9))} ` +
|
|
63
|
+
`${t.muted(formatBytes(ref.size).padStart(9))} ${t.muted(turns.padEnd(8))} ${title}`);
|
|
64
|
+
}
|
|
65
|
+
if (shown.length < files.length) {
|
|
66
|
+
logger.print(t.muted(`\n… and ${files.length - shown.length} more — pass \`--all\` to list every one`));
|
|
67
|
+
}
|
|
68
|
+
logger.print(t.muted(`\n${files.length} session${files.length === 1 ? "" : "s"}, ${formatBytes(total)}`));
|
|
69
|
+
printPolicy(config.sessions, t);
|
|
70
|
+
});
|
|
71
|
+
cmd
|
|
72
|
+
.command("prune")
|
|
73
|
+
.description("clear sessions past sessions.retention / sessions.maxAgeDays")
|
|
74
|
+
.option("-n, --dry-run", "show what would be deleted, delete nothing")
|
|
75
|
+
.action((opts) => {
|
|
76
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
77
|
+
const { config } = loadConfig();
|
|
78
|
+
const cwd = process.cwd();
|
|
79
|
+
if (!config.sessions.enabled) {
|
|
80
|
+
logger.print(t.muted("session persistence is off (`sessions.enabled = false`) — nothing is being recorded to prune"));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (opts.dryRun) {
|
|
84
|
+
// The dry run must not go anywhere near `unlinkSync`, so it re-derives
|
|
85
|
+
// the doomed set from the SAME ordering and the same two bounds rather
|
|
86
|
+
// than sharing a code path with a delete in it behind a flag.
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const cutoff = now - config.sessions.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
89
|
+
const doomed = sessionFilesByRecency(cwd).filter((ref, i) => ref.mtimeMs < cutoff || i >= config.sessions.retention);
|
|
90
|
+
if (doomed.length === 0) {
|
|
91
|
+
logger.print(t.muted("nothing to prune"));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for (const ref of doomed) {
|
|
95
|
+
logger.print(`${t.muted("would delete")} ${shortId(path.basename(ref.file, SESSION_FILE_EXT))} ` +
|
|
96
|
+
`${t.muted(relativeAge(new Date(ref.mtimeMs).toISOString()))} ${t.muted(formatBytes(ref.size))}`);
|
|
97
|
+
}
|
|
98
|
+
logger.print(t.muted(`\n${doomed.length} session${doomed.length === 1 ? "" : "s"}, ` +
|
|
99
|
+
`${formatBytes(doomed.reduce((n, f) => n + f.size, 0))} — run without \`--dry-run\` to delete`));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// No `activeSessionId`: there is no session open in this process. That is
|
|
103
|
+
// the whole difference between running prune from here and running it
|
|
104
|
+
// from `SessionLog.open`.
|
|
105
|
+
const result = pruneSessions(cwd, { sessions: config.sessions });
|
|
106
|
+
if (result.removed.length === 0) {
|
|
107
|
+
logger.print(t.muted("nothing to prune"));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
logger.print(`${t.success("pruned")} ${result.removed.length} session${result.removed.length === 1 ? "" : "s"} ` +
|
|
111
|
+
`(${formatBytes(result.bytesFreed)}), ${result.kept} kept`);
|
|
112
|
+
}
|
|
113
|
+
if (result.failed > 0) {
|
|
114
|
+
logger.warn(`${result.failed} session file${result.failed === 1 ? "" : "s"} could not be deleted`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
cmd
|
|
118
|
+
.command("rm <id>")
|
|
119
|
+
.description("forget one session by id (or unambiguous id prefix)")
|
|
120
|
+
.action((id) => {
|
|
121
|
+
const t = themeForColor(shouldUseColor(process.stdout));
|
|
122
|
+
const cwd = process.cwd();
|
|
123
|
+
// Resolved through the SAME matcher `--resume` uses, so the id that names
|
|
124
|
+
// a session to resume is the id that names it to delete. An ambiguous
|
|
125
|
+
// prefix is refused rather than resolved to "the newest" — this is the
|
|
126
|
+
// one command here that cannot be undone.
|
|
127
|
+
const matches = matchSessionRefs(listSessionRefs(cwd), id);
|
|
128
|
+
if (matches.length > 1) {
|
|
129
|
+
throw usageError(`\`${id}\` matches more than one session`, [
|
|
130
|
+
`did you mean one of: ${matches.map((m) => shortId(m.sessionId)).join(", ")}?`,
|
|
131
|
+
]);
|
|
132
|
+
}
|
|
133
|
+
if (matches.length === 0) {
|
|
134
|
+
throw usageError(`no session \`${id}\` in this project`, [
|
|
135
|
+
"run `cruxy sessions` to see what is here",
|
|
136
|
+
"sessions are per-directory; check you are in the right one",
|
|
137
|
+
]);
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
unlinkSync(matches[0].file);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
throw usageError(`could not delete session ${shortId(matches[0].sessionId)}`, [`${matches[0].file}: ${err.message}`]);
|
|
144
|
+
}
|
|
145
|
+
logger.print(`${t.success("deleted")} session ${shortId(matches[0].sessionId)}`);
|
|
146
|
+
});
|
|
147
|
+
return cmd;
|
|
148
|
+
}
|
|
149
|
+
/** The bounds in force, so a listing explains itself without a config read. */
|
|
150
|
+
function printPolicy(sessions, t) {
|
|
151
|
+
if (!sessions.enabled) {
|
|
152
|
+
logger.print(t.muted("persistence is off (`sessions.enabled = false`) — nothing new is recorded"));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
logger.print(t.muted(`keeping the newest ${sessions.retention}, and anything touched in the last ${sessions.maxAgeDays} days`));
|
|
156
|
+
}
|
package/dist/cli/program.js
CHANGED
|
@@ -19,8 +19,10 @@ import { rollbackCommand } from "./commands/rollback.js";
|
|
|
19
19
|
import { testCommand } from "./commands/test.js";
|
|
20
20
|
import { hooksCommand } from "./commands/hooks.js";
|
|
21
21
|
import { memoryCommand } from "./commands/memory.js";
|
|
22
|
+
import { limitsCommand } from "./commands/limits.js";
|
|
22
23
|
import { usageCommand } from "./commands/usage.js";
|
|
23
24
|
import { mcpCommand } from "./commands/mcp.js";
|
|
25
|
+
import { sessionsCommand } from "./commands/sessions.js";
|
|
24
26
|
import { loadConfig } from "../config/index.js";
|
|
25
27
|
import { maybeRunOnboarding } from "./onboard.js";
|
|
26
28
|
export function buildProgram() {
|
|
@@ -54,7 +56,9 @@ export function buildProgram() {
|
|
|
54
56
|
program.addCommand(hooksCommand());
|
|
55
57
|
program.addCommand(memoryCommand());
|
|
56
58
|
program.addCommand(usageCommand());
|
|
59
|
+
program.addCommand(limitsCommand());
|
|
57
60
|
program.addCommand(mcpCommand());
|
|
61
|
+
program.addCommand(sessionsCommand());
|
|
58
62
|
// Default action: bare `cruxy` opens the TUI; `cruxy <message>` opens it and
|
|
59
63
|
// runs that message as the first turn. Operands reach here only when they
|
|
60
64
|
// matched no subcommand, so a bare token is a MESSAGE by default — only a
|
package/dist/cli/repl.js
CHANGED
|
@@ -116,6 +116,28 @@ async function drainJobApprovals(session) {
|
|
|
116
116
|
logger.print(theme.muted(`\n${theme.glyph.bullet} a background job needs your approval:`));
|
|
117
117
|
await jobs.serviceApprovals();
|
|
118
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Surface a pool denial a background job died on (cli#245) — the same idle
|
|
121
|
+
* point the approval drain uses, and for the same reason: the foreground owns
|
|
122
|
+
* the terminal here, with no live region painting and no readline interface
|
|
123
|
+
* holding stdin.
|
|
124
|
+
*
|
|
125
|
+
* WHY THE FOREGROUND IS TOLD AT ALL. A 429 is a statement about a denominator
|
|
126
|
+
* the job shares with this session's own next turn, so the job's death is
|
|
127
|
+
* already the answer to a question the user is about to ask. Without this the
|
|
128
|
+
* job fails off-screen — `/jobs` would show it, if the user thought to look —
|
|
129
|
+
* and the next turn walks into its own refusal, having been told nothing.
|
|
130
|
+
* Printed through the shared error formatter so `window`, when it recovers, and
|
|
131
|
+
* whether mira is still open all survive, exactly as they do on the foreground
|
|
132
|
+
* path.
|
|
133
|
+
*/
|
|
134
|
+
function drainJobPoolDenial(session) {
|
|
135
|
+
const denial = session.jobs?.takePoolDenial();
|
|
136
|
+
if (!denial)
|
|
137
|
+
return;
|
|
138
|
+
logger.print(theme.muted(`\n${theme.glyph.bullet} a background job stopped:`));
|
|
139
|
+
printCommandError(replOutput, denial);
|
|
140
|
+
}
|
|
119
141
|
/**
|
|
120
142
|
* Drive an interactive multi-turn session: prompt, read a line, dispatch slash
|
|
121
143
|
* commands or run a turn, repeat. Assistant text and tool-call progress stream
|
|
@@ -143,6 +165,9 @@ async function replLoop(session, io, renderer, checkpoints, slashCommands = [])
|
|
|
143
165
|
// prompt. Done here (foreground idle, no readline interface live) so a job's
|
|
144
166
|
// prompt never contends with the line reader.
|
|
145
167
|
await drainJobApprovals(session);
|
|
168
|
+
// Same point, same reason: a job the weighted pool refused (cli#245). The
|
|
169
|
+
// next turn is about to draw on the window that just refused it.
|
|
170
|
+
drainJobPoolDenial(session);
|
|
146
171
|
const line = await readLine(io, PROMPT);
|
|
147
172
|
// EOF / Ctrl+D.
|
|
148
173
|
if (line === null) {
|
|
@@ -365,7 +365,8 @@ opts = {}) {
|
|
|
365
365
|
const executionSemaphore = new Semaphore(config.subagent.maxConcurrency);
|
|
366
366
|
// The ONE weighted-token budget for the whole session (P10 track 3 / cli#212).
|
|
367
367
|
// `/budget` reads and sets it; `Session` narrows each turn's token guard by it;
|
|
368
|
-
// the orchestrator refuses to dispatch a fan-out it cannot
|
|
368
|
+
// the orchestrator refuses to dispatch a fan-out (or a single spawn) it cannot
|
|
369
|
+
// cover; the job manager refuses a background job it cannot. Four
|
|
369
370
|
// consumers, one object — a session cap and a fan-out bound that could disagree
|
|
370
371
|
// would be the failure this exists to prevent. Its server denominator is
|
|
371
372
|
// attached later (see `attachLimits`), because the limits cache does not exist
|
|
@@ -378,6 +379,21 @@ opts = {}) {
|
|
|
378
379
|
// Outside `resumeLineAfterApproval`, so the live region is restored
|
|
379
380
|
// before a preview block is committed into it.
|
|
380
381
|
previewSilentApprovals(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), renderer), checkpoints, workspace), approvalMutex, cwd);
|
|
382
|
+
// THE LATE-BINDING HANDLE FOR THE SESSION BUILT AT THE END OF THIS FUNCTION.
|
|
383
|
+
//
|
|
384
|
+
// Declared here, above the first construction that needs it, rather than down
|
|
385
|
+
// beside the plan wiring it was written for. Three things now late-bind to the
|
|
386
|
+
// session — the approval policy's live mode read, and (cli#244) the job
|
|
387
|
+
// manager's session id — and the session cannot exist yet because it needs the
|
|
388
|
+
// ctx, the orchestrator and the job manager that are built between here and
|
|
389
|
+
// there.
|
|
390
|
+
//
|
|
391
|
+
// `autoApprove`: the policy has to read the LIVE mode, since the user can
|
|
392
|
+
// leave auto-approve between two actions of a single turn. Before the session
|
|
393
|
+
// exists there is nothing to approve, so the `false` fallback is a closed door
|
|
394
|
+
// rather than a gap.
|
|
395
|
+
const holder = {};
|
|
396
|
+
const autoApprove = () => holder.session?.getAutoApprove() ?? false;
|
|
381
397
|
// Subagent orchestration (C.14): spawn_subagent goes on the main registry
|
|
382
398
|
// only when depth allows (maxDepth 0 disables the feature structurally).
|
|
383
399
|
// Registered before the plan wiring so plan-mode execution steps can
|
|
@@ -406,7 +422,6 @@ opts = {}) {
|
|
|
406
422
|
makeChildApproval: () => gate(new ApprovalService({
|
|
407
423
|
cwd,
|
|
408
424
|
interactive: ttyInteractive,
|
|
409
|
-
io,
|
|
410
425
|
policy: new InteractivePolicy(new SessionAllowlist(), io, autoApprove),
|
|
411
426
|
})),
|
|
412
427
|
});
|
|
@@ -432,6 +447,20 @@ opts = {}) {
|
|
|
432
447
|
approvalMutex,
|
|
433
448
|
foregroundInteractive: ttyInteractive,
|
|
434
449
|
promptIO: io,
|
|
450
|
+
// The SAME budget the turn and the fan-out seam ask (cli#245). A job
|
|
451
|
+
// drawing on the weighted pool with no admission check was the last
|
|
452
|
+
// surface running `runAgent` that neither asked it nor moved it.
|
|
453
|
+
budget: sessionBudget,
|
|
454
|
+
// ...and the SAME usage sink the session's turns publish through
|
|
455
|
+
// (cli#244), so a job's spend is counted by `/usage` and not only by
|
|
456
|
+
// `/budget`. A job gets a record of its own because it can outlive the
|
|
457
|
+
// turn that launched it; see the note on `JobManagerDeps.onRunUsage`.
|
|
458
|
+
onRunUsage,
|
|
459
|
+
// Read lazily: the session is constructed below and does not exist yet.
|
|
460
|
+
// Without this the job's record persists with no `sessionId` and is then
|
|
461
|
+
// invisible to in-session `/usage` and to `--session` — on disk, and
|
|
462
|
+
// unreadable by the two surfaces most likely to look for it.
|
|
463
|
+
sessionId: () => holder.session?.sessionId,
|
|
435
464
|
})
|
|
436
465
|
: undefined;
|
|
437
466
|
const jobTool = jobManager ? makeRunInBackgroundTool(jobManager) : undefined;
|
|
@@ -465,19 +494,11 @@ opts = {}) {
|
|
|
465
494
|
// One allowlist shared by the plan-approval prompt and the per-action gate, so
|
|
466
495
|
// a grant recorded during execution is honored by U.3's own check.
|
|
467
496
|
const allowlist = new SessionAllowlist();
|
|
468
|
-
// Late-bound to the session constructed below. The policy has to read the LIVE
|
|
469
|
-
// mode — the user can leave auto-approve between two actions of a single turn
|
|
470
|
-
// — and the session cannot exist yet because it needs the ctx this policy is
|
|
471
|
-
// wired into. Before it exists there is nothing to approve, so the `false`
|
|
472
|
-
// fallback is a closed door rather than a gap.
|
|
473
|
-
const holder = {};
|
|
474
|
-
const autoApprove = () => holder.session?.getAutoApprove() ?? false;
|
|
475
497
|
const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io, autoApprove));
|
|
476
498
|
const approval = new ApprovalService({
|
|
477
499
|
cwd,
|
|
478
500
|
interactive: ttyInteractive,
|
|
479
501
|
policy: planPolicy,
|
|
480
|
-
io,
|
|
481
502
|
});
|
|
482
503
|
const ctx = {
|
|
483
504
|
cwd,
|