@cruxy/cli 1.2.0 → 1.3.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/dist/agent/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +41 -2
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +185 -72
- package/dist/approval/classify.js +204 -0
- package/dist/approval/policy.js +41 -3
- package/dist/approval/prompt.js +49 -22
- package/dist/checkpoint/gate.js +12 -0
- package/dist/cli/commands/run.js +374 -227
- package/dist/cli/commands/usage.js +45 -45
- package/dist/cli/onboard.js +2 -1
- package/dist/cli/program.js +60 -18
- package/dist/cli/repl.js +67 -249
- package/dist/cli/session-commands.js +755 -0
- package/dist/cli/session-factory.js +198 -76
- package/dist/cli/suggest.js +77 -0
- package/dist/components/fuzzy.js +3 -3
- package/dist/components/input.js +17 -2
- package/dist/components/keys.js +27 -3
- package/dist/components/select.js +3 -3
- package/dist/config/project.js +53 -1
- package/dist/config/schema.js +49 -16
- package/dist/jobs/log-renderer.js +47 -0
- package/dist/onboarding/steps.js +13 -22
- package/dist/plan/approve.js +36 -24
- package/dist/plan/execute.js +9 -7
- package/dist/plan/render.js +10 -23
- package/dist/plan/service.js +4 -1
- package/dist/render/capabilities.js +30 -1
- package/dist/render/context-view.js +106 -0
- package/dist/render/diff.js +198 -12
- package/dist/render/index.js +31 -5
- package/dist/render/plain-renderer.js +38 -2
- package/dist/render/plan-view.js +108 -0
- package/dist/render/resize.js +7 -2
- package/dist/render/status-view.js +66 -0
- package/dist/render/test-view.js +89 -0
- package/dist/render/tty-renderer.js +40 -0
- package/dist/routing/index.js +1 -0
- package/dist/routing/router.js +13 -4
- package/dist/routing/session-model.js +109 -0
- package/dist/routing/types.js +14 -0
- package/dist/session/export.js +88 -0
- package/dist/session/index.js +20 -0
- package/dist/session/list.js +137 -0
- package/dist/session/log.js +137 -0
- package/dist/session/paths.js +73 -0
- package/dist/session/replay.js +169 -0
- package/dist/session/resume.js +128 -0
- package/dist/session/types.js +223 -0
- package/dist/subagent/orchestrator.js +23 -0
- package/dist/testing/run-tests-tool.js +8 -0
- package/dist/tools/registry.js +3 -3
- package/dist/tui/app.js +385 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +63 -0
- package/dist/tui/index.js +10 -0
- package/dist/tui/layout.js +269 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +776 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/usage/collect.js +21 -3
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/store.js +7 -1
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +73 -4
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +50 -4
- package/package.json +2 -2
- package/dist/usage/cost.js +0 -29
package/dist/agent/session.js
CHANGED
|
@@ -1,39 +1,19 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { loadProjectInstructions } from "../config/index.js";
|
|
3
|
-
import { resolveTaskModel } from "../routing/index.js";
|
|
3
|
+
import { resolveTaskModel, } from "../routing/index.js";
|
|
4
4
|
import { UsageCollector, accumulateCacheTokens, } from "../usage/index.js";
|
|
5
5
|
import { Budget } from "./budget.js";
|
|
6
|
+
import { estimateTokens, findCut } from "./context.js";
|
|
6
7
|
import { runAgent, } from "./loop.js";
|
|
8
|
+
import { DEFAULT_MODE, modeAutoApproves, modePlans, nextMode, parseMode, } from "./mode.js";
|
|
7
9
|
import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
|
|
8
10
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Re-exported from `agent/context.ts`, where the estimate now lives beside the
|
|
12
|
+
* cut-point search and the budget reading (P6 track 3) — one module for
|
|
13
|
+
* everything that measures the window, so the seam that acts on the numbers and
|
|
14
|
+
* the surfaces that explain them cannot drift.
|
|
13
15
|
*/
|
|
14
|
-
export
|
|
15
|
-
let chars = 0;
|
|
16
|
-
for (const msg of messages) {
|
|
17
|
-
if (typeof msg.content === "string") {
|
|
18
|
-
chars += msg.content.length;
|
|
19
|
-
continue;
|
|
20
|
-
}
|
|
21
|
-
for (const block of msg.content) {
|
|
22
|
-
switch (block.type) {
|
|
23
|
-
case "text":
|
|
24
|
-
chars += block.text.length;
|
|
25
|
-
break;
|
|
26
|
-
case "tool_use":
|
|
27
|
-
chars += block.name.length + JSON.stringify(block.input).length;
|
|
28
|
-
break;
|
|
29
|
-
case "tool_result":
|
|
30
|
-
chars += block.content.length;
|
|
31
|
-
break;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return Math.ceil(chars / 4);
|
|
36
|
-
}
|
|
16
|
+
export { estimateTokens } from "./context.js";
|
|
37
17
|
/**
|
|
38
18
|
* Owns the state of one multi-turn conversation: the running message history and
|
|
39
19
|
* the usage accumulated across turns. Each `send` continues from the prior
|
|
@@ -52,43 +32,150 @@ export class Session {
|
|
|
52
32
|
usage = { input_tokens: 0, output_tokens: 0 };
|
|
53
33
|
/** Stable id for this session (C.22), so a run's usage record groups with the
|
|
54
34
|
* other runs of the same interactive session (`cruxy usage --session`). */
|
|
55
|
-
sessionId
|
|
35
|
+
sessionId;
|
|
56
36
|
/** The most recent run's usage record (C.22) — the one-shot path reads it to
|
|
57
37
|
* print the end-of-run summary. */
|
|
58
38
|
lastRun;
|
|
59
39
|
args;
|
|
60
40
|
/** Mutable so `/reload` can refresh CRUXY.md mid-session. */
|
|
61
41
|
projectInstructions;
|
|
62
|
-
/**
|
|
63
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The session's mode (P5 track 3) — the ONE piece of state saying how much a
|
|
44
|
+
* turn does without asking. Mutable so `/mode` and Shift+Tab can change it
|
|
45
|
+
* mid-session.
|
|
46
|
+
*
|
|
47
|
+
* Plan mode is DERIVED from this, not stored beside it. Before P5 the session
|
|
48
|
+
* held a `planMode` boolean while auto-approve was a (dead) config key, which
|
|
49
|
+
* is two sources of truth for one question and exactly the shape of bug the
|
|
50
|
+
* tier work had just finished removing.
|
|
51
|
+
*/
|
|
52
|
+
mode;
|
|
53
|
+
/**
|
|
54
|
+
* How many messages of the CURRENT history the recorder already holds (P2).
|
|
55
|
+
*
|
|
56
|
+
* This is what lets an append-only log track an array that is rewritten in
|
|
57
|
+
* place. The invariant: a replay of the log so far reproduces exactly the
|
|
58
|
+
* first `recordedCount` messages of the live history. So an append is
|
|
59
|
+
* `slice(recordedCount)`, and a compaction updates the watermark to the
|
|
60
|
+
* post-compaction length rather than re-emitting anything.
|
|
61
|
+
*
|
|
62
|
+
* It is only ever advanced at points where the history is COHERENT — a
|
|
63
|
+
* tool_use and its tool_result are never split across a flush — because the
|
|
64
|
+
* loop calls the compaction seam at the top of an iteration, after the
|
|
65
|
+
* previous iteration fully resolved its tool calls.
|
|
66
|
+
*/
|
|
67
|
+
recordedCount = 0;
|
|
64
68
|
constructor(args) {
|
|
65
69
|
this.args = args;
|
|
66
70
|
this.projectInstructions = args.projectInstructions ?? null;
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
71
|
+
this.mode = this.resolveMode(args.mode ?? DEFAULT_MODE);
|
|
72
|
+
// Resume (P2): adopt the replayed state verbatim. The history is already
|
|
73
|
+
// recorded in the log we are continuing, so the watermark starts at its
|
|
74
|
+
// full length — otherwise the first flush would append the whole restored
|
|
75
|
+
// conversation a second time.
|
|
76
|
+
const restore = args.restore;
|
|
77
|
+
this.sessionId = restore?.sessionId ?? randomUUID();
|
|
78
|
+
if (restore) {
|
|
79
|
+
this.messages = restore.messages;
|
|
80
|
+
this.usage.input_tokens = restore.usage.input_tokens;
|
|
81
|
+
this.usage.output_tokens = restore.usage.output_tokens;
|
|
82
|
+
this.recordedCount = restore.messages.length;
|
|
83
|
+
this.mode = this.resolveMode(restore.mode);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a requested mode to one this session can actually honour.
|
|
88
|
+
*
|
|
89
|
+
* A planning mode needs a wired `planRunner`; without one it degrades to the
|
|
90
|
+
* non-planning mode with the same approval behaviour rather than half-engaging
|
|
91
|
+
* (plan directive injected, nothing orchestrating it). Auto-approve is
|
|
92
|
+
* unaffected — it needs nothing wired.
|
|
93
|
+
*
|
|
94
|
+
* Unknown strings (a journal from a newer build, a hand-edited log) fall back
|
|
95
|
+
* to the default. Failing closed matters here specifically: the fallback must
|
|
96
|
+
* be the mode that asks MORE, never one that asks less.
|
|
97
|
+
*/
|
|
98
|
+
resolveMode(want) {
|
|
99
|
+
const mode = parseMode(String(want)) ?? DEFAULT_MODE;
|
|
100
|
+
if (!modePlans(mode) || this.args.planRunner !== undefined)
|
|
101
|
+
return mode;
|
|
102
|
+
return mode === "full-auto" ? "auto-approve" : "manual";
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Record everything appended to `current` since the last flush.
|
|
106
|
+
*
|
|
107
|
+
* Callers must only pass a history whose first `recordedCount` messages are
|
|
108
|
+
* unchanged — every call site satisfies this because the only operation that
|
|
109
|
+
* rewrites the head is compaction, which updates the watermark itself.
|
|
110
|
+
*/
|
|
111
|
+
flushRecorded(current) {
|
|
112
|
+
if (!this.args.recorder)
|
|
113
|
+
return;
|
|
114
|
+
if (current.length <= this.recordedCount)
|
|
115
|
+
return;
|
|
116
|
+
this.args.recorder.append(current.slice(this.recordedCount));
|
|
117
|
+
this.recordedCount = current.length;
|
|
70
118
|
}
|
|
71
119
|
/** The ambient tool capabilities (gate + sandbox + cwd/config). Exposed so a
|
|
72
120
|
* shell-bound custom slash command (C.19) runs through the SAME gated path. */
|
|
73
121
|
get toolContext() {
|
|
74
122
|
return this.args.ctx;
|
|
75
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* The tool catalogue advertised to the model this session — the one
|
|
126
|
+
* `registerRuntimeTools` built, so it reflects which optional families
|
|
127
|
+
* (memory, LSP, web, MCP, subagents, jobs) are actually enabled. Exposed
|
|
128
|
+
* read-only, for `/status` to count.
|
|
129
|
+
*/
|
|
130
|
+
get toolRegistry() {
|
|
131
|
+
return this.args.registry;
|
|
132
|
+
}
|
|
76
133
|
/** The background-job manager (C.28), or undefined when jobs are disabled.
|
|
77
134
|
* The REPL uses it to service paused-job approvals and drive `/jobs`; `cruxy
|
|
78
135
|
* run` uses it to cancel every live job on exit. */
|
|
79
136
|
get jobs() {
|
|
80
137
|
return this.args.jobs;
|
|
81
138
|
}
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
139
|
+
/**
|
|
140
|
+
* The session's live model choice (P6 track 1), or undefined when tiers do not
|
|
141
|
+
* apply — a bring-your-own provider has no cruxy tiers to choose between, and
|
|
142
|
+
* `/model` says so rather than offering a menu that could not take effect.
|
|
143
|
+
*
|
|
144
|
+
* Exposed as the object rather than proxied through `getModel`/`setModel`
|
|
145
|
+
* accessors, because the renderer needs to SUBSCRIBE to it, not just read it:
|
|
146
|
+
* the model panel and the status line have to react to a change within the
|
|
147
|
+
* same paint, and a getter pair cannot carry that.
|
|
148
|
+
*/
|
|
149
|
+
get model() {
|
|
150
|
+
return this.args.model;
|
|
151
|
+
}
|
|
152
|
+
/** The session's current mode. */
|
|
153
|
+
getMode() {
|
|
154
|
+
return this.mode;
|
|
85
155
|
}
|
|
86
156
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
157
|
+
* Set the mode. Returns the EFFECTIVE mode, which differs from the request
|
|
158
|
+
* when no plan runner is wired — so a caller reports what happened rather
|
|
159
|
+
* than what it asked for.
|
|
89
160
|
*/
|
|
90
|
-
|
|
91
|
-
this.
|
|
161
|
+
setMode(mode) {
|
|
162
|
+
this.mode = this.resolveMode(mode);
|
|
163
|
+
// Record the effective value, not the request: the log must say what the
|
|
164
|
+
// session actually did.
|
|
165
|
+
this.args.recorder?.mode(this.mode);
|
|
166
|
+
return this.mode;
|
|
167
|
+
}
|
|
168
|
+
/** Advance one step around the mode ring (Shift+Tab). Returns the new mode. */
|
|
169
|
+
cycleMode() {
|
|
170
|
+
return this.setMode(nextMode(this.mode));
|
|
171
|
+
}
|
|
172
|
+
/** Whether this session proposes a plan before executing (C.31). */
|
|
173
|
+
getPlanMode() {
|
|
174
|
+
return modePlans(this.mode);
|
|
175
|
+
}
|
|
176
|
+
/** Whether gated actions run without a prompt in this session. */
|
|
177
|
+
getAutoApprove() {
|
|
178
|
+
return modeAutoApproves(this.mode);
|
|
92
179
|
}
|
|
93
180
|
/**
|
|
94
181
|
* Run one user turn: append the prompt, compact if the history has grown past
|
|
@@ -107,6 +194,9 @@ export class Session {
|
|
|
107
194
|
for (const tool of this.args.registry.list())
|
|
108
195
|
tool.onTurnStart?.();
|
|
109
196
|
this.messages.push({ role: "user", content: userPrompt });
|
|
197
|
+
// Record the user's turn before anything can fail (P2): a turn that dies in
|
|
198
|
+
// the provider still leaves what the user asked for on disk.
|
|
199
|
+
this.flushRecorded(this.messages);
|
|
110
200
|
// Usage telemetry (C.22): one collector per run. `onReq` is threaded into
|
|
111
201
|
// every real model request this turn drives — the main loop, compaction, and
|
|
112
202
|
// (in plan mode) the propose + execution steps — so usage is captured exactly
|
|
@@ -137,7 +227,7 @@ export class Session {
|
|
|
137
227
|
// Plan mode (C.31) delegates the whole turn to the injected runner: propose a
|
|
138
228
|
// plan, approve/revise, then execute step-by-step. Falls back to the normal
|
|
139
229
|
// single-shot loop when off or unwired, so existing behavior is untouched.
|
|
140
|
-
const result = this.
|
|
230
|
+
const result = modePlans(this.mode) && this.args.planRunner
|
|
141
231
|
? await this.args.planRunner({
|
|
142
232
|
messages: this.messages,
|
|
143
233
|
projectInstructions: this.projectInstructions,
|
|
@@ -167,6 +257,15 @@ export class Session {
|
|
|
167
257
|
this.messages = result.messages;
|
|
168
258
|
this.usage.input_tokens += result.usage.input_tokens;
|
|
169
259
|
this.usage.output_tokens += result.usage.output_tokens;
|
|
260
|
+
// Persist what the turn produced (P2). The loop's mid-iteration flushes
|
|
261
|
+
// (via the compaction seam) already recorded most of it; this catches the
|
|
262
|
+
// tail after the final iteration. Usage is copied into the session log
|
|
263
|
+
// rather than referenced, because the usage store keeps only its newest 50
|
|
264
|
+
// runs while sessions are kept indefinitely.
|
|
265
|
+
this.flushRecorded(this.messages);
|
|
266
|
+
if (result.usage.input_tokens > 0 || result.usage.output_tokens > 0) {
|
|
267
|
+
this.args.recorder?.usage(result.usage.input_tokens, result.usage.output_tokens);
|
|
268
|
+
}
|
|
170
269
|
// Publish the run's usage record (C.22): stash it for the one-shot summary
|
|
171
270
|
// and hand it to the persistence sink. Building the record never touches the
|
|
172
271
|
// network and never blocks the turn's result.
|
|
@@ -191,6 +290,11 @@ export class Session {
|
|
|
191
290
|
/** Drop the conversation history but keep the session (for `/clear`). */
|
|
192
291
|
clear() {
|
|
193
292
|
this.messages = [];
|
|
293
|
+
// The log keeps every earlier message — `clear` is an event, not an
|
|
294
|
+
// erasure. Replay honours it, so a resumed session starts empty exactly as
|
|
295
|
+
// the live one did, while the transcript of what was said survives.
|
|
296
|
+
this.args.recorder?.clear();
|
|
297
|
+
this.recordedCount = 0;
|
|
194
298
|
}
|
|
195
299
|
/**
|
|
196
300
|
* Compact `this.messages` only when it has grown past threshold, adopting the
|
|
@@ -212,6 +316,16 @@ export class Session {
|
|
|
212
316
|
* unchanged — cheap, no model call.
|
|
213
317
|
*/
|
|
214
318
|
async compactLoopHistory(messages, onRequestUsage) {
|
|
319
|
+
// Flush BEFORE compacting (P2). The loop owns this array and only ever
|
|
320
|
+
// appends to it between calls, so everything since the last flush is a pure
|
|
321
|
+
// append — and it has to be on disk before a compaction event can refer to
|
|
322
|
+
// a prefix length, or the recorded `replaced` count would be measured
|
|
323
|
+
// against a shorter history than the one being compacted.
|
|
324
|
+
//
|
|
325
|
+
// The loop calls this at the TOP of each iteration, after the previous
|
|
326
|
+
// iteration resolved all its tool calls, so the history flushed here is
|
|
327
|
+
// always coherent: no tool_use is ever recorded without its tool_result.
|
|
328
|
+
this.flushRecorded(messages);
|
|
215
329
|
const result = await this.compactIfOverThreshold(messages, onRequestUsage);
|
|
216
330
|
return result.messages;
|
|
217
331
|
}
|
|
@@ -257,7 +371,12 @@ export class Session {
|
|
|
257
371
|
* the summary's usage into the session total.
|
|
258
372
|
*/
|
|
259
373
|
async runCompaction(messages, onRequestUsage) {
|
|
260
|
-
|
|
374
|
+
// Self-contained sync (P2): whatever array is about to be rewritten must be
|
|
375
|
+
// fully recorded first, so the `replaced` count in the compaction event is
|
|
376
|
+
// measured against the same history a replay will have reconstructed. A
|
|
377
|
+
// no-op when the caller already flushed, which every caller does.
|
|
378
|
+
this.flushRecorded(messages);
|
|
379
|
+
const cut = findCut(messages, this.args.config.context.keepRecentMessages);
|
|
261
380
|
if (cut === null)
|
|
262
381
|
return { messages, compacted: null };
|
|
263
382
|
const prefix = messages.slice(0, cut);
|
|
@@ -286,37 +405,20 @@ export class Session {
|
|
|
286
405
|
content: `${COMPACTION_MARKER} Summary of the conversation so far:\n\n${synopsis}`,
|
|
287
406
|
},
|
|
288
407
|
];
|
|
408
|
+
// Record the rewrite as an EVENT (P2): the log stays append-only, and the
|
|
409
|
+
// messages that were folded away remain readable earlier in the file even
|
|
410
|
+
// though the model can no longer see them. The watermark moves to the
|
|
411
|
+
// post-compaction length so the next append is measured against the new
|
|
412
|
+
// array, not the old one.
|
|
413
|
+
if (this.args.recorder) {
|
|
414
|
+
this.args.recorder.compaction(prefix.length, summaryMessages);
|
|
415
|
+
this.recordedCount = summaryMessages.length + kept.length;
|
|
416
|
+
}
|
|
289
417
|
return {
|
|
290
418
|
messages: [...summaryMessages, ...kept],
|
|
291
419
|
compacted: prefix.length,
|
|
292
420
|
};
|
|
293
421
|
}
|
|
294
|
-
/**
|
|
295
|
-
* Choose the boundary between the summarized prefix and the kept-recent tail.
|
|
296
|
-
*
|
|
297
|
-
* Tool-call integrity is the constraint: a `tool_use` (assistant) and its
|
|
298
|
-
* matching `tool_result` (the next user message) must never straddle the cut,
|
|
299
|
-
* or the next provider call breaks. A real user *prompt* (`role:"user"` with
|
|
300
|
-
* string content) only occurs at a completed turn boundary, where every prior
|
|
301
|
-
* tool exchange is already resolved — so the kept region must begin there. The
|
|
302
|
-
* synthetic compaction-summary user message is also string content, so a
|
|
303
|
-
* repeat compaction always finds at least the previous summary as a clean cut.
|
|
304
|
-
*
|
|
305
|
-
* Start from `length - keepRecentMessages` and walk *backwards* to the nearest
|
|
306
|
-
* such prompt: this keeps at least the recent floor and lands clean. Returns
|
|
307
|
-
* the cut index, or `null` if no safe boundary leaves a non-empty prefix
|
|
308
|
-
* (e.g. a single long in-progress turn — nothing safe to compact).
|
|
309
|
-
*/
|
|
310
|
-
findCut(messages) {
|
|
311
|
-
const { keepRecentMessages } = this.args.config.context;
|
|
312
|
-
const start = messages.length - keepRecentMessages;
|
|
313
|
-
for (let i = start; i >= 1; i--) {
|
|
314
|
-
const msg = messages[i];
|
|
315
|
-
if (msg.role === "user" && typeof msg.content === "string")
|
|
316
|
-
return i;
|
|
317
|
-
}
|
|
318
|
-
return null;
|
|
319
|
-
}
|
|
320
422
|
/**
|
|
321
423
|
* Summarize a prefix via a standalone, tool-less provider call over a rendered
|
|
322
424
|
* transcript. Throws on a stream error or empty output so callers fail open.
|
|
@@ -331,14 +433,22 @@ export class Session {
|
|
|
331
433
|
? resolveTaskModel(this.args.router, "summarize")
|
|
332
434
|
: null;
|
|
333
435
|
// Per-request usage capture for telemetry (C.22), same honesty pivot as the
|
|
334
|
-
// main loop: unknown unless a usage event actually arrives.
|
|
436
|
+
// main loop: unknown unless a usage event actually arrives. The served tier
|
|
437
|
+
// is held across the stream for the same reason it is in the loop — it
|
|
438
|
+
// arrives on the opening frame, and is reported after the close.
|
|
335
439
|
let sawUsage = false;
|
|
440
|
+
let servedTier;
|
|
441
|
+
let routingMode;
|
|
336
442
|
for await (const ev of this.args.provider.stream({
|
|
337
443
|
system: SUMMARY_SYSTEM,
|
|
338
444
|
messages: [{ role: "user", content: transcript }],
|
|
339
445
|
...(routed ? { model: routed.model } : {}),
|
|
340
446
|
})) {
|
|
341
447
|
switch (ev.type) {
|
|
448
|
+
case "routing":
|
|
449
|
+
servedTier = ev.routing.tier;
|
|
450
|
+
routingMode = ev.routing.mode;
|
|
451
|
+
break;
|
|
342
452
|
case "text_delta":
|
|
343
453
|
text += ev.text;
|
|
344
454
|
break;
|
|
@@ -354,9 +464,12 @@ export class Session {
|
|
|
354
464
|
break;
|
|
355
465
|
}
|
|
356
466
|
}
|
|
357
|
-
// Attribute this compaction request to the
|
|
467
|
+
// Attribute this compaction request to the tier that served it — the
|
|
468
|
+
// gateway's answer over the `summarize` tier this run asked for, same
|
|
469
|
+
// precedence and same reasoning as the main loop.
|
|
358
470
|
onRequestUsage?.({
|
|
359
|
-
tier: routed?.tier,
|
|
471
|
+
tier: servedTier ?? routed?.tier,
|
|
472
|
+
...(routingMode !== undefined ? { routingMode } : {}),
|
|
360
473
|
usage: sawUsage ? { ...usage } : undefined,
|
|
361
474
|
});
|
|
362
475
|
if (!text.trim())
|
|
@@ -4,6 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
* {@link Scope}. The cardinal rule: **anything unrecognized is `destructive`**
|
|
5
5
|
* (most-restrictive) so a future/unknown action can never slip through as
|
|
6
6
|
* read-only or low-risk.
|
|
7
|
+
*
|
|
8
|
+
* The same pass sets `irreversible` — the separate, stricter axis that bounds
|
|
9
|
+
* auto-approve. See {@link ApprovalRequest.irreversible} for why the tier cannot
|
|
10
|
+
* do that job, and the `── reversibility` section below for the rules.
|
|
7
11
|
*/
|
|
8
12
|
export function classify(action, cwd) {
|
|
9
13
|
const root = path.resolve(cwd);
|
|
@@ -32,9 +36,182 @@ export function classify(action, cwd) {
|
|
|
32
36
|
summary: `perform an unrecognized action (${describeKind(action)})`,
|
|
33
37
|
targets: [],
|
|
34
38
|
cwd: root,
|
|
39
|
+
irreversible: "this action kind is unrecognized, so nothing can bound what it changes",
|
|
35
40
|
};
|
|
36
41
|
}
|
|
37
42
|
}
|
|
43
|
+
// ── reversibility (P5 track 3) ────────────────────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* What the checkpoint actually captures, because every rule below is derived
|
|
46
|
+
* from it rather than from intuition about which verbs sound scary:
|
|
47
|
+
*
|
|
48
|
+
* • `capture.ts` enumerates with `git ls-files --cached --others
|
|
49
|
+
* --exclude-standard` — tracked + untracked-non-ignored files **under one
|
|
50
|
+
* root**. So: nothing outside a declared root, and nothing gitignored
|
|
51
|
+
* (`node_modules/`, build output, `.env`).
|
|
52
|
+
* • `git-store.ts` states it outright: "no ref is ever created or moved, and
|
|
53
|
+
* HEAD / the index / the stash are never written." `.git/` is never
|
|
54
|
+
* enumerated at all. So: **no git history, refs, branches, or stash.**
|
|
55
|
+
* • Nothing off-disk. A checkpoint cannot un-send a push, un-publish a
|
|
56
|
+
* package, or un-call an HTTP endpoint.
|
|
57
|
+
*
|
|
58
|
+
* Everything a checkpoint DOES cover, it covers completely: `withCheckpointGate`
|
|
59
|
+
* snapshots the whole root before the first mutation of a run, so arbitrary
|
|
60
|
+
* in-root file damage — including `rm -rf` of the workspace — is restorable.
|
|
61
|
+
* That is why the rules key on *escape* (out of the root, into `.git`, onto the
|
|
62
|
+
* network) rather than on how destructive an in-root command looks.
|
|
63
|
+
*
|
|
64
|
+
* ── Residual risk, stated plainly ──
|
|
65
|
+
* No static analysis can prove a shell command's effects. {@link ESCAPES_ROOT}
|
|
66
|
+
* is a denylist, and denylists lose: `node deploy.js` or an unlisted CLI can
|
|
67
|
+
* still reach the network. It is a belt. The braces are the two rules that fail
|
|
68
|
+
* *closed* — an unprovable command (any shell metacharacter) and any argument
|
|
69
|
+
* resolving outside the root are both irreversible without consulting any list
|
|
70
|
+
* — plus the pre-command checkpoint, which bounds the on-disk half absolutely.
|
|
71
|
+
* Auto-approve is a deliberate, per-session, visibly-announced choice; this is
|
|
72
|
+
* the ceiling on that choice, not a sandbox.
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* `git` subcommands that only read. Everything else `git` does is irreversible
|
|
76
|
+
* by construction — it writes refs, HEAD, the index, or the stash, none of which
|
|
77
|
+
* the checkpoint captures. An allowlist, not a denylist, so `push --force`,
|
|
78
|
+
* `reset --hard`, `rebase`, `filter-branch`, `branch -D`, `tag -d`, and every
|
|
79
|
+
* future ref-mutating subcommand are caught without being enumerated.
|
|
80
|
+
*/
|
|
81
|
+
const GIT_READ_ONLY = new Set([
|
|
82
|
+
"blame",
|
|
83
|
+
"cat-file",
|
|
84
|
+
"describe",
|
|
85
|
+
"diff",
|
|
86
|
+
"for-each-ref",
|
|
87
|
+
"grep",
|
|
88
|
+
"log",
|
|
89
|
+
"ls-files",
|
|
90
|
+
"rev-list",
|
|
91
|
+
"rev-parse",
|
|
92
|
+
"shortlog",
|
|
93
|
+
"show",
|
|
94
|
+
"status",
|
|
95
|
+
]);
|
|
96
|
+
/**
|
|
97
|
+
* Package-manager subcommands whose effects stay in the checkpointed tree.
|
|
98
|
+
* `run`/`test` execute project scripts — arbitrary code, but code whose on-disk
|
|
99
|
+
* damage the pre-command checkpoint restores. `install` is NOT here: it writes
|
|
100
|
+
* `node_modules/`, which is gitignored and therefore never captured, and reaches
|
|
101
|
+
* a registry. `publish`, `link`, `login`, `config` escape outright.
|
|
102
|
+
*/
|
|
103
|
+
const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
|
|
104
|
+
const PACKAGE_MANAGER_LOCAL = new Set(["run", "test"]);
|
|
105
|
+
/**
|
|
106
|
+
* Programs whose primary purpose is to reach something the checkpoint cannot
|
|
107
|
+
* restore — the network, another host, a registry, cloud state, or the machine
|
|
108
|
+
* itself. Not exhaustive by construction (see the residual-risk note above);
|
|
109
|
+
* these are the ones an agent loop actually reaches for.
|
|
110
|
+
*/
|
|
111
|
+
const ESCAPES_ROOT = new Map([
|
|
112
|
+
["curl", "it sends network requests, which no checkpoint can recall"],
|
|
113
|
+
["wget", "it sends network requests, which no checkpoint can recall"],
|
|
114
|
+
["ssh", "it runs commands on another host, outside any checkpoint"],
|
|
115
|
+
["scp", "it copies to another host, outside any checkpoint"],
|
|
116
|
+
["sftp", "it transfers to another host, outside any checkpoint"],
|
|
117
|
+
["rsync", "it can write outside the workspace and to other hosts"],
|
|
118
|
+
["gh", "it acts on GitHub under your identity; remote state is not captured"],
|
|
119
|
+
[
|
|
120
|
+
"glab",
|
|
121
|
+
"it acts on GitLab under your identity; remote state is not captured",
|
|
122
|
+
],
|
|
123
|
+
["docker", "it changes container and image state outside the workspace"],
|
|
124
|
+
["kubectl", "it changes live cluster state, which is not captured"],
|
|
125
|
+
["helm", "it changes live cluster state, which is not captured"],
|
|
126
|
+
["terraform", "it changes real infrastructure, which is not captured"],
|
|
127
|
+
["ansible", "it changes remote hosts, which are not captured"],
|
|
128
|
+
["aws", "it changes cloud account state, which is not captured"],
|
|
129
|
+
["gcloud", "it changes cloud account state, which is not captured"],
|
|
130
|
+
["az", "it changes cloud account state, which is not captured"],
|
|
131
|
+
["sudo", "it runs with elevated privileges, outside every guarantee here"],
|
|
132
|
+
["doas", "it runs with elevated privileges, outside every guarantee here"],
|
|
133
|
+
["su", "it runs as another user, outside every guarantee here"],
|
|
134
|
+
["systemctl", "it changes system service state, which is not captured"],
|
|
135
|
+
["launchctl", "it changes system service state, which is not captured"],
|
|
136
|
+
["shutdown", "it halts the machine"],
|
|
137
|
+
["reboot", "it restarts the machine"],
|
|
138
|
+
["dd", "it writes raw devices, which no checkpoint can restore"],
|
|
139
|
+
["mkfs", "it formats a filesystem, which no checkpoint can restore"],
|
|
140
|
+
["diskutil", "it changes disk state, which no checkpoint can restore"],
|
|
141
|
+
]);
|
|
142
|
+
/**
|
|
143
|
+
* Why the checkpoint cannot restore `command`, or `null` when it can.
|
|
144
|
+
*
|
|
145
|
+
* Three gates, in order of how hard they fail closed. The first two need no
|
|
146
|
+
* list to be correct; the third is the belt described above.
|
|
147
|
+
*/
|
|
148
|
+
function shellIrreversibility(command, root) {
|
|
149
|
+
const tokens = commandTokens(command);
|
|
150
|
+
if (tokens === null) {
|
|
151
|
+
// Deny-by-default, and the same proof `commandTokens` already gates session
|
|
152
|
+
// grants on: with a pipe, redirect, substitution, glob, or `~`, we cannot
|
|
153
|
+
// say what runs, so we cannot say what it touches.
|
|
154
|
+
return "the command uses shell features we cannot analyze, so its effects cannot be bounded";
|
|
155
|
+
}
|
|
156
|
+
const program = path.basename(tokens[0]);
|
|
157
|
+
const args = tokens.slice(1);
|
|
158
|
+
// The first argument that is not an option — the subcommand, for the
|
|
159
|
+
// multiplexers below.
|
|
160
|
+
const subcommand = args.find((a) => !a.startsWith("-"));
|
|
161
|
+
if (program === "git" && !(subcommand && GIT_READ_ONLY.has(subcommand))) {
|
|
162
|
+
return "git writes refs, HEAD, the index, or the stash — none of which the checkpoint captures";
|
|
163
|
+
}
|
|
164
|
+
if (PACKAGE_MANAGERS.has(program) &&
|
|
165
|
+
!(subcommand && PACKAGE_MANAGER_LOCAL.has(subcommand))) {
|
|
166
|
+
return `\`${program}${subcommand ? ` ${subcommand}` : ""}\` reaches a registry or gitignored install state, which the checkpoint does not capture`;
|
|
167
|
+
}
|
|
168
|
+
const escapes = ESCAPES_ROOT.get(program);
|
|
169
|
+
if (escapes)
|
|
170
|
+
return `\`${program}\` is not checkpointable — ${escapes}`;
|
|
171
|
+
const outside = args.find((a) => escapesRoot(a, root));
|
|
172
|
+
if (outside) {
|
|
173
|
+
return `\`${outside}\` resolves outside the workspace root, which the checkpoint does not capture`;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Whether a command argument names a path the checkpoint would not cover.
|
|
179
|
+
*
|
|
180
|
+
* Handles `--flag=<path>` as well as a bare path, because the flag form is how
|
|
181
|
+
* an out-of-root target most often sneaks past a naive check. Absolute paths and
|
|
182
|
+
* anything containing `..` are resolved against the root and tested; a plain
|
|
183
|
+
* relative token cannot escape, so it is left alone. (`~` and `*` never reach
|
|
184
|
+
* here — {@link commandTokens} already rejects the whole command for those.)
|
|
185
|
+
*/
|
|
186
|
+
function escapesRoot(arg, root) {
|
|
187
|
+
const eq = arg.indexOf("=");
|
|
188
|
+
const candidate = eq === -1 ? arg : arg.slice(eq + 1);
|
|
189
|
+
if (candidate === "")
|
|
190
|
+
return false;
|
|
191
|
+
const looksLikePath = candidate.startsWith("/") || candidate.split("/").includes("..");
|
|
192
|
+
if (!looksLikePath)
|
|
193
|
+
return false;
|
|
194
|
+
return !isInside(root, path.resolve(root, candidate));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Why the checkpoint cannot restore a file action, or `null` when it can.
|
|
198
|
+
*
|
|
199
|
+
* A delete is not special here — `patchHasDelete` raises the *tier* because a
|
|
200
|
+
* delete deserves a louder prompt, but a deleted file inside the root is
|
|
201
|
+
* restored from the snapshot exactly like an edited one. What matters is
|
|
202
|
+
* containment, and that every target is known: an action carrying no resolved
|
|
203
|
+
* target cannot be shown to be covered, so it is not.
|
|
204
|
+
*/
|
|
205
|
+
function fileIrreversibility(targets, root) {
|
|
206
|
+
if (targets.length === 0) {
|
|
207
|
+
return "the action names no resolvable target, so the checkpoint cannot be shown to cover it";
|
|
208
|
+
}
|
|
209
|
+
const outside = targets.find((t) => !isInside(root, t));
|
|
210
|
+
if (outside) {
|
|
211
|
+
return `\`${outside}\` is outside the workspace root, which the checkpoint does not capture`;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
38
215
|
// ── shell ─────────────────────────────────────────────────────────────────────
|
|
39
216
|
// Any shell control/expansion character means we cannot prove the command is a
|
|
40
217
|
// single simple invocation. Covers && || ; | ` $ ( ) { } * ? ~ ! # ' " \ < > and
|
|
@@ -71,6 +248,7 @@ function shellRequest(action, root) {
|
|
|
71
248
|
summary: `run: ${command}`,
|
|
72
249
|
targets: [],
|
|
73
250
|
cwd: root,
|
|
251
|
+
irreversible: shellIrreversibility(command, root),
|
|
74
252
|
};
|
|
75
253
|
}
|
|
76
254
|
// ── test (run the project's test suite, C.13) ──────────────────────────────────
|
|
@@ -92,6 +270,16 @@ function testRequest(action, root) {
|
|
|
92
270
|
summary: `run tests: ${command}`,
|
|
93
271
|
targets: [],
|
|
94
272
|
cwd: root,
|
|
273
|
+
// A test command comes from the project's own package.json and runs after
|
|
274
|
+
// the pre-command checkpoint, so its on-disk effects are restorable — this
|
|
275
|
+
// is the one destructive-tier action auto-approve is meant to cover, and
|
|
276
|
+
// what keeps `full-auto` able to verify the code it just wrote. It is still
|
|
277
|
+
// held to the SAME shell rules: a test script that force-pushes or curls is
|
|
278
|
+
// no more restorable for being called "test". An empty command names
|
|
279
|
+
// nothing to reason about.
|
|
280
|
+
irreversible: command === ""
|
|
281
|
+
? "the test action names no command, so its effects cannot be bounded"
|
|
282
|
+
: shellIrreversibility(command, root),
|
|
95
283
|
};
|
|
96
284
|
}
|
|
97
285
|
// ── vcs (open pull request) ─────────────────────────────────────────────────────
|
|
@@ -110,6 +298,7 @@ function vcsRequest(action, root) {
|
|
|
110
298
|
summary: `open PR: ${title}`,
|
|
111
299
|
targets: [],
|
|
112
300
|
cwd: root,
|
|
301
|
+
irreversible: "opening a PR commits and pushes under your identity; the checkpoint captures working-tree files, never commits, refs, or anything already on the remote",
|
|
113
302
|
};
|
|
114
303
|
}
|
|
115
304
|
// ── rollback (restore checkpoint) ──────────────────────────────────────────────
|
|
@@ -137,6 +326,7 @@ function rollbackRequest(action, root) {
|
|
|
137
326
|
`(${fileCount} file${fileCount === 1 ? "" : "s"})`,
|
|
138
327
|
targets: [],
|
|
139
328
|
cwd: root,
|
|
329
|
+
irreversible: ROLLBACK_IRREVERSIBLE,
|
|
140
330
|
};
|
|
141
331
|
}
|
|
142
332
|
const preview = action.preview?.type === "rollback" ? action.preview : undefined;
|
|
@@ -152,8 +342,17 @@ function rollbackRequest(action, root) {
|
|
|
152
342
|
? preview.files.map((f) => path.resolve(root, f.path))
|
|
153
343
|
: [],
|
|
154
344
|
cwd: root,
|
|
345
|
+
irreversible: ROLLBACK_IRREVERSIBLE,
|
|
155
346
|
};
|
|
156
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* A rollback is the one action the checkpoint system cannot protect you from:
|
|
350
|
+
* `withCheckpointGate` takes no snapshot for `kind: "rollback"`, so restoring a
|
|
351
|
+
* checkpoint discards whatever the tree currently holds with nothing to undo it
|
|
352
|
+
* with. It is never auto-approved — the mode whose safety net IS the checkpoint
|
|
353
|
+
* must not be able to silently spend it.
|
|
354
|
+
*/
|
|
355
|
+
const ROLLBACK_IRREVERSIBLE = "a rollback overwrites the working tree and is not itself checkpointed, so the current state would be unrecoverable";
|
|
157
356
|
// ── mcp (call an external MCP server's tool, C.27) ──────────────────────────────
|
|
158
357
|
/**
|
|
159
358
|
* An MCP tool call. Always `destructive` — the tool is arbitrary code in a
|
|
@@ -176,6 +375,10 @@ function mcpRequest(action, root) {
|
|
|
176
375
|
summary: `call MCP tool ${tool || "(unknown)"} on server ${server || "(unknown)"}`,
|
|
177
376
|
targets: [],
|
|
178
377
|
cwd: root,
|
|
378
|
+
// Same reasoning as the tier: the server cannot vouch for itself. A
|
|
379
|
+
// `readOnlyHint` is a claim by the thing being gated, and the checkpoint
|
|
380
|
+
// cannot reach whatever it did anyway.
|
|
381
|
+
irreversible: "an MCP tool runs unsandboxed in an external server, so its effects are outside the checkpoint entirely",
|
|
179
382
|
};
|
|
180
383
|
}
|
|
181
384
|
// ── file (write / edit / patch) ────────────────────────────────────────────────
|
|
@@ -188,6 +391,7 @@ function fileRequest(action, tier, root) {
|
|
|
188
391
|
summary: fileSummary(action, targets, root),
|
|
189
392
|
targets,
|
|
190
393
|
cwd: root,
|
|
394
|
+
irreversible: fileIrreversibility(targets, root),
|
|
191
395
|
};
|
|
192
396
|
}
|
|
193
397
|
/** Absolute target paths for a file action (write/edit: one; patch: many). */
|