@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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { usedFraction } from "../limits/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* A metered credential's one missing capability, stated as availability
|
|
4
|
+
* (cli#263).
|
|
5
|
+
*
|
|
6
|
+
* KEYED OFF `bucket`, NOT off a missing credential expiry. The two look
|
|
7
|
+
* interchangeable and answer different questions: `bucket === "apikey"` says
|
|
8
|
+
* *this credential type cannot have a pool*, while a missing `keyMeta.expiresAt`
|
|
9
|
+
* says *we do not know this key's lifetime*. Absence is load-bearing for the
|
|
10
|
+
* second — every pre-existing key has no entry, and an admin-minted key
|
|
11
|
+
* genuinely never expires, so `config/credentials.ts` rests on absence meaning
|
|
12
|
+
* "unknown lifetime, never expired". Reusing it here would overload the one
|
|
13
|
+
* invariant the expiry design is built on, to answer a question `bucket` already
|
|
14
|
+
* answers exactly.
|
|
15
|
+
*
|
|
16
|
+
* AND IT IS AN OFFER, NOT A CORRECTION. `--paste` exists for the air-gapped
|
|
17
|
+
* machine and the deliberate long-lived admin key, and those users are on
|
|
18
|
+
* `apikey` on purpose. "Your key is wrong" would be false for them; a line that
|
|
19
|
+
* says what logging in ADDS is true for everyone and safely ignorable by anyone
|
|
20
|
+
* who does not want it. It is also not a start-up nag: `cli/commands/run.ts`
|
|
21
|
+
* owns the pre-session line for the expiry warning, and a second one there would
|
|
22
|
+
* teach people to skip both.
|
|
23
|
+
*
|
|
24
|
+
* Returns `undefined` the moment the bucket is anything else — including an
|
|
25
|
+
* `apikey` whose response this build could not read, where the honest answer is
|
|
26
|
+
* to advise nothing rather than to advise from a shape we did not understand.
|
|
27
|
+
*/
|
|
28
|
+
export function poolAvailabilityNote(reading) {
|
|
29
|
+
if (reading.bucket !== "apikey")
|
|
30
|
+
return undefined;
|
|
31
|
+
if (reading.budget.kind !== "metered")
|
|
32
|
+
return undefined;
|
|
33
|
+
return "cruxy login adds a pool";
|
|
34
|
+
}
|
|
35
|
+
/** The tone a window's state maps to. `low` and `blocked` are different acts. */
|
|
36
|
+
function toneFor(state) {
|
|
37
|
+
if (state === "blocked")
|
|
38
|
+
return "danger";
|
|
39
|
+
if (state === "low")
|
|
40
|
+
return "warn";
|
|
41
|
+
return "normal";
|
|
42
|
+
}
|
|
43
|
+
/** A capped window, or nothing — never a fraction over a cap that isn't one. */
|
|
44
|
+
function toMeter(key, window, poolState) {
|
|
45
|
+
if (!window)
|
|
46
|
+
return undefined;
|
|
47
|
+
const fraction = usedFraction(window);
|
|
48
|
+
if (fraction === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
return { key, window, fraction, tone: toneFor(window.state ?? poolState) };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve one reading into {@link LimitsSummary}.
|
|
54
|
+
*
|
|
55
|
+
* Pure and synchronous: every value is read off the reading it is handed. The
|
|
56
|
+
* network already happened in `limits/cache.ts`, and the tri-state that wraps
|
|
57
|
+
* this (`pending` / `error` / `ready`) is deliberately NOT reduced here — a
|
|
58
|
+
* summary is what a *reading* means, and a caller with no reading has a
|
|
59
|
+
* different thing to say, in words that differ per surface.
|
|
60
|
+
*/
|
|
61
|
+
export function buildLimitsSummary(reading, now) {
|
|
62
|
+
const budget = reading.budget;
|
|
63
|
+
const windows = [];
|
|
64
|
+
let noAllowance;
|
|
65
|
+
switch (budget.kind) {
|
|
66
|
+
case "pool": {
|
|
67
|
+
const month = toMeter("month", budget.monthly, budget.state);
|
|
68
|
+
const burst = toMeter("burst", budget.burst, budget.state);
|
|
69
|
+
if (month)
|
|
70
|
+
windows.push(month);
|
|
71
|
+
if (burst)
|
|
72
|
+
windows.push(burst);
|
|
73
|
+
// Unreachable through `reduceLimits` (an enforced pool with no readable
|
|
74
|
+
// window reduces to `unknown`), but the type permits it and inventing a
|
|
75
|
+
// fraction is the one thing no surface here may do while being defensive.
|
|
76
|
+
if (windows.length === 0)
|
|
77
|
+
noAllowance = "figures not reported";
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
// Enterprise: a POSITIVE statement that there is no ceiling. Distinct from
|
|
81
|
+
// `unknown` below, and the distinction is the one that matters most in this
|
|
82
|
+
// file — "we lost track of your ceiling" rendered as "you have none" tells
|
|
83
|
+
// someone they are safe to spend at the exact moment we cannot say.
|
|
84
|
+
case "unenforced":
|
|
85
|
+
noAllowance = "pool not enforced";
|
|
86
|
+
break;
|
|
87
|
+
case "metered":
|
|
88
|
+
noAllowance = "no pool cap";
|
|
89
|
+
break;
|
|
90
|
+
case "credits":
|
|
91
|
+
// A dollar pool, and the only allowance that is not weighted tokens. It
|
|
92
|
+
// stays off `windows` for that reason: those are token windows, and a
|
|
93
|
+
// renderer that iterated them would print dollars in the tokens' unit.
|
|
94
|
+
break;
|
|
95
|
+
case "unknown":
|
|
96
|
+
noAllowance = "budget not reported";
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
const spendCaps = [];
|
|
100
|
+
if (reading.keySpendCap)
|
|
101
|
+
spendCaps.push({ label: "key", cap: reading.keySpendCap });
|
|
102
|
+
if (reading.workspaceSpendCap)
|
|
103
|
+
spendCaps.push({ label: "ws", cap: reading.workspaceSpendCap });
|
|
104
|
+
const note = poolAvailabilityNote(reading);
|
|
105
|
+
return {
|
|
106
|
+
tier: reading.tier,
|
|
107
|
+
bucket: reading.bucket,
|
|
108
|
+
budget,
|
|
109
|
+
windows,
|
|
110
|
+
...(noAllowance !== undefined ? { noAllowance } : {}),
|
|
111
|
+
notes: note ? [note] : [],
|
|
112
|
+
spendCaps,
|
|
113
|
+
// Only where it is the whole story. A rate limit beside a real allowance
|
|
114
|
+
// reads as part of it; beside a metered key's absence, it is the ceiling.
|
|
115
|
+
...(budget.kind === "metered" && reading.chat
|
|
116
|
+
? {
|
|
117
|
+
rpm: {
|
|
118
|
+
remaining: reading.chat.perKey.remaining,
|
|
119
|
+
limit: reading.chat.perKey.limit,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
: {}),
|
|
123
|
+
ageMs: now - reading.readAt,
|
|
124
|
+
};
|
|
125
|
+
}
|
package/dist/sandbox/service.js
CHANGED
|
@@ -53,6 +53,15 @@ export class SandboxService {
|
|
|
53
53
|
* (surfacing the pull via U.4), then delegates to the runtime. A container
|
|
54
54
|
* that fails to start throws a coded error; an ordinary non-zero command exit
|
|
55
55
|
* comes back as a normal {@link ExecResult} — exit code is truth.
|
|
56
|
+
*
|
|
57
|
+
* ONE POLICY PER SESSION, not per action. `SandboxRuntime.exec` takes a policy
|
|
58
|
+
* argument and this one does not: {@link create} resolved it once from config
|
|
59
|
+
* + cwd and every call here runs under that same {@link IsolationPolicy}. So a
|
|
60
|
+
* caller cannot tighten the box for a particular command — no argument does
|
|
61
|
+
* it, and reaching around to the runtime would bypass the image memoization.
|
|
62
|
+
* Per-action confinement is a new seam here, not a parameter that already
|
|
63
|
+
* exists; recorded because the shape of `SandboxRuntime.exec` suggests
|
|
64
|
+
* otherwise at a glance.
|
|
56
65
|
*/
|
|
57
66
|
async exec(command, opts) {
|
|
58
67
|
await this.ensureImage();
|
package/dist/sandbox/types.js
CHANGED
|
@@ -9,6 +9,21 @@
|
|
|
9
9
|
* sandbox contains what an approved command is able to do; it does not replace
|
|
10
10
|
* the decision to run it.
|
|
11
11
|
*
|
|
12
|
+
* ── It bounds the EXECUTION surface, and only that ──
|
|
13
|
+
* Worth saying in the words that get misread: this is not "the session runs
|
|
14
|
+
* confined." `ToolContext.sandbox` is consulted at exactly two call sites —
|
|
15
|
+
* `tools/shell/exec.ts` (`execShell`) and `testing/run-tests-tool.ts` — so
|
|
16
|
+
* `run_command` and `run_tests` are the whole of what a container ever bounds.
|
|
17
|
+
* `write_file`, `edit_file`, and `apply_patch` call the filesystem directly and
|
|
18
|
+
* run on the HOST in every mode, sandbox or no sandbox; their bound is the C.26
|
|
19
|
+
* path confinement plus the C.32 checkpoint, not this boundary.
|
|
20
|
+
*
|
|
21
|
+
* That distinction is load-bearing wherever a rule is stated in terms of
|
|
22
|
+
* confinement: a slogan like "an auto-approving mode runs confined" reads as
|
|
23
|
+
* covering file writes and does not. See the cli#194 section in
|
|
24
|
+
* `approval/classify.ts` for what a mode rule keyed on this boundary would and
|
|
25
|
+
* would not have bought.
|
|
26
|
+
*
|
|
12
27
|
* Execution is abstracted behind {@link SandboxRuntime} (Docker ships; podman /
|
|
13
28
|
* none slot in without touching call sites), and the neutral {@link ExecResult}
|
|
14
29
|
* matches what host execution conceptually returns so `run_command`/`run_tests`
|
package/dist/session/index.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - `log.ts` — the writer: one line per event, `0600`, non-fatal on failure;
|
|
9
9
|
* - `replay.ts` — the fold back to state, tolerant of torn/unknown lines;
|
|
10
10
|
* - `list.ts` — what the picker and the TUI sidebar both read;
|
|
11
|
+
* - `prune.ts` — retention: what the tree is allowed to keep (#257);
|
|
11
12
|
* - `resume.ts` — `--resume <id>` and the bare-`--resume` picker;
|
|
12
13
|
* - `paths.ts` — the layout, including the subtrees reserved for P3+.
|
|
13
14
|
*/
|
|
@@ -16,6 +17,7 @@ export { SessionLog } from "./log.js";
|
|
|
16
17
|
export { defaultExportName, exportMarkdown, } from "./export.js";
|
|
17
18
|
export { foldEvents, readEvents, readMeta, replaySession } from "./replay.js";
|
|
18
19
|
export { redactMessages } from "./redact.js";
|
|
19
|
-
export { findSession, isAmbiguous, listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
|
|
20
|
+
export { findSession, isAmbiguous, listSessionRefs, listSessions, matchSessionRefs, sessionFilesByRecency, summarizeSession, } from "./list.js";
|
|
21
|
+
export { pruneSessions, } from "./prune.js";
|
|
20
22
|
export { cwdMismatchWarning, describeSession, loadResume, priorDirectoriesWarning, relativeAge, resolveSessionId, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
|
|
21
23
|
export { KNOWN_EVENT_KINDS, SESSION_FILE_VERSION, ResumedEventSchema, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
package/dist/session/list.js
CHANGED
|
@@ -31,7 +31,7 @@ function toTitle(text) {
|
|
|
31
31
|
* picker asks for ten". It was not. {@link listSessions} summarized EVERY file
|
|
32
32
|
* and sliced afterwards, so the limit bounded the rows and not the work; a
|
|
33
33
|
* project of 200 sessions paid 200 full parses to show 10. The limit now bounds
|
|
34
|
-
* the work (see {@link
|
|
34
|
+
* the work (see {@link sessionFilesByRecency}), which is what finally makes that
|
|
35
35
|
* sentence true. A sidecar index is still the answer if per-file cost ever
|
|
36
36
|
* stops being acceptable — but the ordering fix had to come first, because an
|
|
37
37
|
* index would have made the same mistake faster.
|
|
@@ -108,8 +108,19 @@ export function summarizeSession(file) {
|
|
|
108
108
|
* Missing directory → empty list (not an error: no sessions yet is normal). A
|
|
109
109
|
* file that vanishes between the `readdir` and the `stat` is skipped rather
|
|
110
110
|
* than throwing — listing races an active session by definition.
|
|
111
|
+
*
|
|
112
|
+
* EXPORTED for `prune.ts` (#257), which needs exactly this and nothing more:
|
|
113
|
+
* mtime ordering is what both bounds consume, and a second scan in the pruner
|
|
114
|
+
* would reintroduce the duplication #255 removed. It is the ONLY directory walk
|
|
115
|
+
* over a project's sessions — keep it that way.
|
|
116
|
+
*
|
|
117
|
+
* The `.jsonl` filter and the `isFile` test are load-bearing beyond tidiness.
|
|
118
|
+
* `subagents/` lives in this same directory (reserved by `paths.ts` for #172
|
|
119
|
+
* item 1), `statSync` succeeds on a directory, and everything this returns is
|
|
120
|
+
* something prune will consider deleting. Anything added here is added to that
|
|
121
|
+
* set.
|
|
111
122
|
*/
|
|
112
|
-
function
|
|
123
|
+
export function sessionFilesByRecency(cwd) {
|
|
113
124
|
const dir = projectDir(cwd);
|
|
114
125
|
let names;
|
|
115
126
|
try {
|
|
@@ -124,7 +135,10 @@ function sessionRefsByRecency(cwd) {
|
|
|
124
135
|
continue;
|
|
125
136
|
const file = path.join(dir, name);
|
|
126
137
|
try {
|
|
127
|
-
|
|
138
|
+
const stat = statSync(file);
|
|
139
|
+
if (!stat.isFile())
|
|
140
|
+
continue;
|
|
141
|
+
files.push({ file, mtimeMs: stat.mtimeMs, size: stat.size });
|
|
128
142
|
}
|
|
129
143
|
catch {
|
|
130
144
|
continue; // deleted mid-listing
|
|
@@ -140,7 +154,7 @@ function sessionRefsByRecency(cwd) {
|
|
|
140
154
|
* capped at `limit`.
|
|
141
155
|
*
|
|
142
156
|
* `limit` bounds the WORK, not just the rows. Files are ordered by mtime first
|
|
143
|
-
* (cheap — see {@link
|
|
157
|
+
* (cheap — see {@link sessionFilesByRecency}) and summarized one at a time until
|
|
144
158
|
* `limit` valid summaries exist, so the picker asking for ten reads ten files
|
|
145
159
|
* and not two hundred.
|
|
146
160
|
*
|
|
@@ -153,7 +167,7 @@ function sessionRefsByRecency(cwd) {
|
|
|
153
167
|
*/
|
|
154
168
|
export function listSessions(cwd, limit = Infinity) {
|
|
155
169
|
const summaries = [];
|
|
156
|
-
for (const { file } of
|
|
170
|
+
for (const { file } of sessionFilesByRecency(cwd)) {
|
|
157
171
|
if (summaries.length >= limit)
|
|
158
172
|
break;
|
|
159
173
|
const summary = summarizeSession(file);
|
|
@@ -178,7 +192,7 @@ export function listSessions(cwd, limit = Infinity) {
|
|
|
178
192
|
*/
|
|
179
193
|
export function listSessionRefs(cwd) {
|
|
180
194
|
const refs = [];
|
|
181
|
-
for (const { file, mtimeMs } of
|
|
195
|
+
for (const { file, mtimeMs } of sessionFilesByRecency(cwd)) {
|
|
182
196
|
const meta = readMeta(file);
|
|
183
197
|
if (meta)
|
|
184
198
|
refs.push({ sessionId: meta.sessionId, file, mtimeMs });
|
package/dist/session/log.js
CHANGED
|
@@ -1,28 +1,46 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { APP_VERSION } from "../constants.js";
|
|
4
|
+
import { formatBytes } from "../utils/disk.js";
|
|
4
5
|
import { sessionFile } from "./paths.js";
|
|
6
|
+
import { pruneSessions } from "./prune.js";
|
|
5
7
|
import { SESSION_FILE_VERSION, } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* How many pruned sessions is worth telling the user about unprompted.
|
|
10
|
+
*
|
|
11
|
+
* Retention on an existing install is a one-off cliff: every build so far had
|
|
12
|
+
* none, so the first prune after upgrading can remove months at once. That
|
|
13
|
+
* should not happen silently. Steady-state prunes remove one or two files and
|
|
14
|
+
* stay at `debug`, where they belong.
|
|
15
|
+
*/
|
|
16
|
+
const PRUNE_NOTICE_THRESHOLD = 10;
|
|
6
17
|
export class SessionLog {
|
|
7
18
|
file;
|
|
8
19
|
logger;
|
|
9
20
|
currentRunId;
|
|
10
21
|
/** Set once a write fails: the log goes inert rather than warning per turn. */
|
|
11
22
|
broken = false;
|
|
23
|
+
/**
|
|
24
|
+
* A NEW session's `meta` line, held until the session records something.
|
|
25
|
+
* Null on a reopen (the file already has its meta) and null again the moment
|
|
26
|
+
* it is flushed — see {@link write}.
|
|
27
|
+
*/
|
|
28
|
+
pendingMeta = null;
|
|
12
29
|
constructor(file, opts) {
|
|
13
30
|
this.file = file;
|
|
14
31
|
this.logger = opts.logger;
|
|
15
32
|
this.currentRunId = opts.currentRunId;
|
|
16
33
|
}
|
|
17
34
|
/**
|
|
18
|
-
* Open
|
|
35
|
+
* Open a session log. WRITES NOTHING for a new session — see the note on the
|
|
36
|
+
* class about why the file no longer exists before the conversation does.
|
|
19
37
|
*
|
|
20
|
-
* A NEW
|
|
21
|
-
* still does NOT get a second `meta` — that ruling is unchanged
|
|
22
|
-
* reason this method has always branched: `meta` describes where
|
|
23
|
-
* conversation began, and a second copy written from wherever it
|
|
24
|
-
* would make "the session's directory" ambiguous. Replay takes
|
|
25
|
-
* `meta`, so a duplicate could only ever mislead a later reader.
|
|
38
|
+
* A NEW session gets its `meta` line BUFFERED. Reopening an existing one (a
|
|
39
|
+
* `--resume`) still does NOT get a second `meta` — that ruling is unchanged
|
|
40
|
+
* and is the reason this method has always branched: `meta` describes where
|
|
41
|
+
* and when the conversation began, and a second copy written from wherever it
|
|
42
|
+
* was resumed would make "the session's directory" ambiguous. Replay takes
|
|
43
|
+
* the first `meta`, so a duplicate could only ever mislead a later reader.
|
|
26
44
|
*
|
|
27
45
|
* What a reopen gets instead is a `resumed` event (#172 item 2). It carries
|
|
28
46
|
* the directory this run is in, which is the fact that used to vanish: a
|
|
@@ -31,16 +49,33 @@ export class SessionLog {
|
|
|
31
49
|
* file forgot immediately. `meta` stays singular and authoritative; the
|
|
32
50
|
* reopen is a separate kind, and no reader can confuse the two.
|
|
33
51
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
52
|
+
* The reopen write stays EAGER, and deliberately: a reopen is a fact about a
|
|
53
|
+
* conversation that already exists on disk, and `tree.test.ts` pins that it
|
|
54
|
+
* lifts a resumed session to the top of the picker with nothing said to it.
|
|
55
|
+
* Deferring it would quietly undo that.
|
|
56
|
+
*
|
|
57
|
+
* WHAT A FRESH OPEN NO LONGER TELLS YOU. It used to return `null` when the
|
|
58
|
+
* meta write failed, which is how the caller learned up front that
|
|
59
|
+
* persistence was dead. Nothing is written now, so there is nothing to fail:
|
|
60
|
+
* an unwritable home surfaces on the FIRST append instead, as one warning,
|
|
61
|
+
* and the session continues in memory. That is not a new tolerance — it is
|
|
62
|
+
* exactly how the reopen path has always behaved ("a session that cannot
|
|
63
|
+
* record its own reopen is not a session worth refusing to continue"), now
|
|
64
|
+
* applied to both branches rather than one.
|
|
65
|
+
*
|
|
66
|
+
* The `| null` in the signature is kept for the next thing to land here — a
|
|
67
|
+
* `sessions.enabled: false` that declines to record at all (#257) — rather
|
|
68
|
+
* than churning every call site twice.
|
|
40
69
|
*/
|
|
41
70
|
static open(opts) {
|
|
71
|
+
// `sessions.enabled: false` — record nothing. The session runs in memory,
|
|
72
|
+
// which is the same degraded mode an unwritable home produces, reached on
|
|
73
|
+
// purpose. Every consumer already treats a missing recorder as normal.
|
|
74
|
+
if (opts.sessions?.enabled === false)
|
|
75
|
+
return null;
|
|
42
76
|
const file = opts.file ?? sessionFile(opts.cwd, opts.sessionId);
|
|
43
77
|
const log = new SessionLog(file, opts);
|
|
78
|
+
log.pruneOnce(opts);
|
|
44
79
|
if (hasContent(file)) {
|
|
45
80
|
log.write({
|
|
46
81
|
kind: "resumed",
|
|
@@ -51,7 +86,10 @@ export class SessionLog {
|
|
|
51
86
|
});
|
|
52
87
|
return log;
|
|
53
88
|
}
|
|
54
|
-
|
|
89
|
+
// Buffered, not written. `startedAt` is stamped HERE rather than at flush:
|
|
90
|
+
// it means "when this conversation began", and the session began when it
|
|
91
|
+
// was opened, not when the user got around to saying something.
|
|
92
|
+
log.pendingMeta = {
|
|
55
93
|
kind: "meta",
|
|
56
94
|
version: SESSION_FILE_VERSION,
|
|
57
95
|
sessionId: opts.sessionId,
|
|
@@ -61,8 +99,8 @@ export class SessionLog {
|
|
|
61
99
|
cliVersion: APP_VERSION,
|
|
62
100
|
...(opts.provider !== undefined ? { provider: opts.provider } : {}),
|
|
63
101
|
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
64
|
-
}
|
|
65
|
-
return
|
|
102
|
+
};
|
|
103
|
+
return log;
|
|
66
104
|
}
|
|
67
105
|
/** Messages appended to the history since the last event. */
|
|
68
106
|
append(messages) {
|
|
@@ -99,8 +137,11 @@ export class SessionLog {
|
|
|
99
137
|
}
|
|
100
138
|
/**
|
|
101
139
|
* One turn's token usage. Copied here rather than referenced, because the
|
|
102
|
-
* usage store keeps only its newest 50 runs while
|
|
103
|
-
*
|
|
140
|
+
* usage store keeps only its newest 50 runs while a session keeps its own
|
|
141
|
+
* for as long as the session survives `sessions.retention` — see the note on
|
|
142
|
+
* `UsageEventSchema`. (That used to say "indefinitely", which was true until
|
|
143
|
+
* #257 gave sessions a bound; the two stores still expire independently, and
|
|
144
|
+
* the copy is still what makes a restored session's numbers whole.)
|
|
104
145
|
*/
|
|
105
146
|
usage(inputTokens, outputTokens) {
|
|
106
147
|
this.write({
|
|
@@ -130,18 +171,76 @@ export class SessionLog {
|
|
|
130
171
|
count,
|
|
131
172
|
});
|
|
132
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Enforce retention for this project, once, as this session opens.
|
|
176
|
+
*
|
|
177
|
+
* ORDERING: before the `resumed` write and before any meta flush, so the
|
|
178
|
+
* count cap is applied to the sessions that were there BEFORE this one and a
|
|
179
|
+
* fresh session never has to compete with itself for a slot. On a resume the
|
|
180
|
+
* file already exists and is explicitly excluded by id.
|
|
181
|
+
*
|
|
182
|
+
* Skipped entirely when the `file` seam is in use — see the note on
|
|
183
|
+
* {@link SessionLogOptions.sessions}.
|
|
184
|
+
*/
|
|
185
|
+
pruneOnce(opts) {
|
|
186
|
+
if (!opts.sessions || opts.file !== undefined)
|
|
187
|
+
return;
|
|
188
|
+
let result;
|
|
189
|
+
try {
|
|
190
|
+
result = pruneSessions(opts.cwd, {
|
|
191
|
+
sessions: opts.sessions,
|
|
192
|
+
activeSessionId: opts.sessionId,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
// Retention is housekeeping. It does not get to fail a session.
|
|
197
|
+
this.logger?.debug(`session retention skipped: ${err.message}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (result.removed.length === 0)
|
|
201
|
+
return;
|
|
202
|
+
const line = `pruned ${result.removed.length} session${result.removed.length === 1 ? "" : "s"} ` +
|
|
203
|
+
`(${formatBytes(result.bytesFreed)}) past sessions.retention/maxAgeDays`;
|
|
204
|
+
if (result.removed.length >= PRUNE_NOTICE_THRESHOLD) {
|
|
205
|
+
this.logger?.info(`${line} — see \`cruxy sessions\``);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
this.logger?.debug(line);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
133
211
|
runId() {
|
|
134
212
|
const id = this.currentRunId?.();
|
|
135
213
|
return id === undefined ? {} : { runId: id };
|
|
136
214
|
}
|
|
137
215
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
216
|
+
* Record one event, flushing a buffered `meta` ahead of it.
|
|
217
|
+
*
|
|
218
|
+
* This is where a new session's file comes into existence: the first event to
|
|
219
|
+
* reach here brings the `meta` line with it, so the on-disk order is exactly
|
|
220
|
+
* what it always was and no reader can observe the difference.
|
|
221
|
+
*
|
|
222
|
+
* `pendingMeta` is cleared BEFORE the write is attempted. If the meta write
|
|
223
|
+
* fails, `broken` latches and every later event is refused — leaving the meta
|
|
224
|
+
* queued for a retry would let it land AFTER events it is supposed to
|
|
225
|
+
* precede, which is the one ordering the format depends on.
|
|
141
226
|
*/
|
|
142
227
|
write(event) {
|
|
143
228
|
if (this.broken)
|
|
144
229
|
return false;
|
|
230
|
+
const meta = this.pendingMeta;
|
|
231
|
+
if (meta !== null) {
|
|
232
|
+
this.pendingMeta = null;
|
|
233
|
+
if (!this.writeLine(meta))
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
return this.writeLine(event);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Append one event as a single line. Returns whether it landed. The first
|
|
240
|
+
* failure warns and latches `broken`, so a persistent problem (a full disk)
|
|
241
|
+
* produces one diagnostic rather than one per turn.
|
|
242
|
+
*/
|
|
243
|
+
writeLine(event) {
|
|
145
244
|
try {
|
|
146
245
|
mkdirSync(path.dirname(this.file), { recursive: true });
|
|
147
246
|
// `mode` applies only when the file is created — 0600 from the first
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { unlinkSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { SESSION_RETENTION_FLOOR, } from "../config/index.js";
|
|
4
|
+
import { sessionFilesByRecency } from "./list.js";
|
|
5
|
+
import { SESSION_FILE_EXT } from "./paths.js";
|
|
6
|
+
/** `<sessionId>.jsonl` → `<sessionId>`. */
|
|
7
|
+
function idOf(file) {
|
|
8
|
+
return path.basename(file, SESSION_FILE_EXT);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Enforce `sessions.retention` and `sessions.maxAgeDays` on one project.
|
|
12
|
+
*
|
|
13
|
+
* Age is the primary bound and is measured on MTIME, never on
|
|
14
|
+
* `meta.startedAt`: a conversation begun forty days ago and resumed this
|
|
15
|
+
* morning is live, and an age check on when it BEGAN would delete it out from
|
|
16
|
+
* under the user. mtime is also the field the scan already carries, so the
|
|
17
|
+
* correct rule is the cheap one.
|
|
18
|
+
*
|
|
19
|
+
* The count cap is the backstop, applied by position in the same
|
|
20
|
+
* newest-first ordering. It exists because age alone does not protect a
|
|
21
|
+
* directory where someone runs forty sessions a day.
|
|
22
|
+
*
|
|
23
|
+
* Never throws. A file that cannot be unlinked is counted in `failed` and the
|
|
24
|
+
* sweep continues — retention failing is not a reason to take a session down,
|
|
25
|
+
* exactly as a failed session write is not.
|
|
26
|
+
*/
|
|
27
|
+
export function pruneSessions(cwd, opts) {
|
|
28
|
+
const result = {
|
|
29
|
+
removed: [],
|
|
30
|
+
kept: 0,
|
|
31
|
+
bytesFreed: 0,
|
|
32
|
+
failed: 0,
|
|
33
|
+
};
|
|
34
|
+
if (!opts.sessions.enabled)
|
|
35
|
+
return result;
|
|
36
|
+
// Defensive, mirroring `usage/store.ts`: the schema rejects anything below
|
|
37
|
+
// the floor, but this is also reachable with a hand-built config.
|
|
38
|
+
const retention = Math.max(SESSION_RETENTION_FLOOR, Math.floor(opts.sessions.retention));
|
|
39
|
+
const now = opts.now ?? Date.now();
|
|
40
|
+
const cutoff = now - opts.sessions.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
41
|
+
// Newest first. Every file here ends in `.jsonl` and is a regular file — see
|
|
42
|
+
// the note on the scan about why `subagents/` depends on that.
|
|
43
|
+
const files = sessionFilesByRecency(cwd);
|
|
44
|
+
for (const [index, ref] of files.entries()) {
|
|
45
|
+
const sessionId = idOf(ref.file);
|
|
46
|
+
// WHY THE FILENAME IS TRUSTED HERE, when `listSessionRefs` refuses to.
|
|
47
|
+
// That refusal is about what may be OFFERED: a session resolvable by
|
|
48
|
+
// filename but unloadable for want of a meta line is a `--resume` that
|
|
49
|
+
// fails after the user picked it. This is the opposite question. Being
|
|
50
|
+
// conservative about what to DELETE and permissive about what to offer are
|
|
51
|
+
// opposite failure modes, and the cheap answer is the safe one on this
|
|
52
|
+
// side — at worst a filename that does not match the meta spares a file
|
|
53
|
+
// that could have gone. Do not "fix" this to `readMeta`; that puts a full
|
|
54
|
+
// read of every session back on the startup path.
|
|
55
|
+
if (sessionId === opts.activeSessionId) {
|
|
56
|
+
result.kept++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const tooOld = ref.mtimeMs < cutoff;
|
|
60
|
+
const beyondCap = index >= retention;
|
|
61
|
+
if (!tooOld && !beyondCap) {
|
|
62
|
+
result.kept++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
unlinkSync(ref.file);
|
|
67
|
+
result.removed.push({ ...ref, sessionId });
|
|
68
|
+
result.bytesFreed += ref.size;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
result.failed++;
|
|
72
|
+
result.kept++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
sweepOrphanedJobLogs();
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Drop retained job logs whose owning session has just been pruned.
|
|
80
|
+
*
|
|
81
|
+
* DELIBERATELY EMPTY, and reserved rather than forgotten. #172 item 1 will
|
|
82
|
+
* write background-job output to the `subagents/` subtree `paths.ts` reserves
|
|
83
|
+
* under this same project directory, and #257 exists because that issue asked
|
|
84
|
+
* "whether the retained-on-disk log is pruned with the session or independently"
|
|
85
|
+
* and found no answer. The answer is HERE, and it is: with the session, keyed on
|
|
86
|
+
* the session.
|
|
87
|
+
*
|
|
88
|
+
* That makes a job log content owned by its session in exactly the way a
|
|
89
|
+
* checkpoint's shadow objects are content owned by its manifest — so this is
|
|
90
|
+
* `CheckpointService.prune`'s second phase, the mark-and-sweep that runs after
|
|
91
|
+
* the doomed manifests are gone. Filling it in needs one thing the writer does
|
|
92
|
+
* not have yet: a job log has to record which session owns it, in its path or
|
|
93
|
+
* its first line.
|
|
94
|
+
*
|
|
95
|
+
* The hook is here so item 1 fills it rather than inventing a parallel
|
|
96
|
+
* retention policy alongside this one. Whatever lands must not `rm -r` this
|
|
97
|
+
* directory and must not assume every entry is a session's — `subagents/` is a
|
|
98
|
+
* sibling of the session files, not one of them.
|
|
99
|
+
*
|
|
100
|
+
* It takes nothing today because it does nothing; item 1 will want the `cwd`
|
|
101
|
+
* and the {@link PruneResult} whose `removed` names the sessions whose logs are
|
|
102
|
+
* now orphaned.
|
|
103
|
+
*/
|
|
104
|
+
function sweepOrphanedJobLogs() {
|
|
105
|
+
// #172 item 1.
|
|
106
|
+
}
|
package/dist/session/resume.js
CHANGED
|
@@ -152,6 +152,11 @@ export function resolveSessionId(cwd, id) {
|
|
|
152
152
|
throw usageError(`no session \`${id}\` in this project`, [
|
|
153
153
|
"run `cruxy --resume` to pick from recent sessions",
|
|
154
154
|
"sessions are per-directory; check you are in the right one",
|
|
155
|
+
// #257: retention can now delete a session the user still remembers the
|
|
156
|
+
// id of, and a pruned session must not read as a bug. The checkpoint
|
|
157
|
+
// equivalent (`errors/constructors.ts`, CRUXY_E_CHECKPOINT_NOT_FOUND)
|
|
158
|
+
// says the same thing for the same reason.
|
|
159
|
+
"old sessions are pruned by retention — adjust `sessions.retention` / `sessions.maxAgeDays` in config to keep more",
|
|
155
160
|
]);
|
|
156
161
|
}
|
|
157
162
|
return summary;
|