@cruxy/cli 1.11.0 → 1.11.2
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 +12 -0
- package/dist/agent/context.js +6 -0
- package/dist/checkpoint/service.js +44 -3
- package/dist/cli/commands/sessions.js +8 -0
- package/dist/cli/session-commands.js +4 -0
- package/dist/components/input.js +18 -1
- package/dist/components/keys.js +66 -3
- package/dist/config/schema.js +8 -1
- package/dist/errors/constructors.js +15 -6
- package/dist/errors/types.js +7 -0
- package/dist/indexing/embedder.js +34 -11
- package/dist/indexing/model-cache.js +399 -0
- package/dist/render/context-view.js +12 -3
- package/dist/render/index.js +2 -1
- package/dist/session/index.js +1 -0
- package/dist/session/log.js +66 -0
- package/dist/session/owner.js +123 -0
- package/dist/session/prune.js +11 -0
- package/dist/session/resume.js +22 -3
- package/dist/subagent/orchestrator.js +2 -2
- package/dist/subagent/registry-scope.js +28 -5
- package/dist/tools/file/apply-patch.js +49 -23
- package/dist/tools/file/edit-file.js +15 -1
- package/dist/tools/file/snapshot.js +63 -0
- package/dist/tools/file/write-file.js +26 -5
- package/dist/tui/app.js +30 -7
- package/dist/tui/approval-overlay.js +4 -1
- package/dist/tui/layout.js +25 -1
- package/dist/tui/panels.js +44 -6
- package/dist/tui/renderer.js +187 -14
- package/dist/tui/supports.js +15 -0
- package/dist/utils/logger.js +52 -6
- package/dist/utils/process-owner.js +107 -0
- package/package.json +3 -2
package/dist/session/prune.js
CHANGED
|
@@ -4,6 +4,7 @@ import { INTERRUPTED, idKey, jobLogFilesByRecency, readJobLogTerminal, sessionKe
|
|
|
4
4
|
import { SESSION_RETENTION_FLOOR, } from "../config/index.js";
|
|
5
5
|
import { sessionFilesByRecency } from "./list.js";
|
|
6
6
|
import { SESSION_FILE_EXT } from "./paths.js";
|
|
7
|
+
import { removeOwnerFile, sessionHeldBy } from "./owner.js";
|
|
7
8
|
/** `<sessionId>.jsonl` → `<sessionId>`. */
|
|
8
9
|
function idOf(file) {
|
|
9
10
|
return path.basename(file, SESSION_FILE_EXT);
|
|
@@ -59,6 +60,15 @@ export function pruneSessions(cwd, opts) {
|
|
|
59
60
|
result.kept++;
|
|
60
61
|
continue;
|
|
61
62
|
}
|
|
63
|
+
// A session ANOTHER live cruxy has open is spared for the same reason our
|
|
64
|
+
// own is (P1): its writer would recreate the file on its next append with
|
|
65
|
+
// no `meta` line, and that session would be unloadable from then on. The
|
|
66
|
+
// stamp is pid + start-time, so a crashed owner's file is prunable again
|
|
67
|
+
// the moment it is looked at — nothing outlives the crash.
|
|
68
|
+
if (sessionHeldBy(ref.file)) {
|
|
69
|
+
result.kept++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
62
72
|
const tooOld = ref.mtimeMs < cutoff;
|
|
63
73
|
const beyondCap = index >= retention;
|
|
64
74
|
if (!tooOld && !beyondCap) {
|
|
@@ -67,6 +77,7 @@ export function pruneSessions(cwd, opts) {
|
|
|
67
77
|
}
|
|
68
78
|
try {
|
|
69
79
|
unlinkSync(ref.file);
|
|
80
|
+
removeOwnerFile(ref.file); // a stale stamp has nothing left to own
|
|
70
81
|
result.removed.push({ ...ref, sessionId });
|
|
71
82
|
result.bytesFreed += ref.size;
|
|
72
83
|
}
|
package/dist/session/resume.js
CHANGED
|
@@ -2,6 +2,7 @@ import { selectList } from "../components/index.js";
|
|
|
2
2
|
import { usageError } from "../errors/index.js";
|
|
3
3
|
import { listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
|
|
4
4
|
import { replaySession } from "./replay.js";
|
|
5
|
+
import { describeHolder, ownerFile, sessionHeldBy } from "./owner.js";
|
|
5
6
|
/** How many sessions the bare-`--resume` picker offers. */
|
|
6
7
|
export const PICKER_LIMIT = 10;
|
|
7
8
|
/** Short, stable id form — enough to identify a session, short enough to type. */
|
|
@@ -75,6 +76,18 @@ export function priorDirectoriesWarning(state, cwd) {
|
|
|
75
76
|
* worth failing loudly on, unlike an individual torn line.
|
|
76
77
|
*/
|
|
77
78
|
export function loadResume(session, cwd) {
|
|
79
|
+
// OWNERSHIP (P1). Asked BEFORE the replay, and answered by the OS rather
|
|
80
|
+
// than by a file's presence: a stamp whose process is gone is not a holder.
|
|
81
|
+
// Two processes appending to one log produce a transcript that replays
|
|
82
|
+
// cleanly and means nothing, so this is a refusal, not a warning.
|
|
83
|
+
const holder = sessionHeldBy(session.file);
|
|
84
|
+
if (holder) {
|
|
85
|
+
throw usageError(`session ${shortId(session.sessionId)} is open in another cruxy (${describeHolder(holder)})`, [
|
|
86
|
+
"continue it there, or quit that cruxy and resume here",
|
|
87
|
+
"start a new session with `cruxy`",
|
|
88
|
+
`if that process is not a cruxy, remove ${ownerFile(session.file)}`,
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
78
91
|
let state;
|
|
79
92
|
try {
|
|
80
93
|
state = replaySession(session.file);
|
|
@@ -189,9 +202,15 @@ export async function resumePicker(cwd, opts = {}) {
|
|
|
189
202
|
];
|
|
190
203
|
const picked = await selectList(rows, {
|
|
191
204
|
title: "resume a session",
|
|
192
|
-
toLabel: (row) =>
|
|
193
|
-
|
|
194
|
-
|
|
205
|
+
toLabel: (row) => {
|
|
206
|
+
if (row.kind === "new")
|
|
207
|
+
return "+ new session";
|
|
208
|
+
const label = describeSession(row.session, now);
|
|
209
|
+
// A row another live cruxy holds is offered — the user may want to
|
|
210
|
+
// see it — but says so, so picking it is not a surprise refusal.
|
|
211
|
+
const holder = sessionHeldBy(row.session.file);
|
|
212
|
+
return holder ? `${label} · open in another cruxy` : label;
|
|
213
|
+
},
|
|
195
214
|
// Non-interactive with no id named: starting fresh is the safe default,
|
|
196
215
|
// never an arbitrary session picked on the user's behalf.
|
|
197
216
|
defaultValue: { kind: "new" },
|
|
@@ -7,7 +7,7 @@ import { UNRESOLVED_TIER, } from "../budget/index.js";
|
|
|
7
7
|
import { resolveTaskModel } from "../routing/index.js";
|
|
8
8
|
import { Workspace } from "../workspace/index.js";
|
|
9
9
|
import { Budget, resolveBudget } from "../agent/budget.js";
|
|
10
|
-
import {
|
|
10
|
+
import { isWriteTool, scopeRegistry } from "./registry-scope.js";
|
|
11
11
|
import { Semaphore } from "./semaphore.js";
|
|
12
12
|
import { makeSpawnSubagentTool } from "./spawn-tool.js";
|
|
13
13
|
/** Longest task excerpt shown in render chrome — display, not record. */
|
|
@@ -556,7 +556,7 @@ function taskLabel(task) {
|
|
|
556
556
|
/** A child that holds any mutating tool — the disjoint-scope check's unit. A
|
|
557
557
|
* spec with no `tools` gets the default READ-ONLY set, so it is never a writer. */
|
|
558
558
|
function isWriter(spec) {
|
|
559
|
-
return (spec.tools ?? []).some(
|
|
559
|
+
return (spec.tools ?? []).some(isWriteTool);
|
|
560
560
|
}
|
|
561
561
|
/**
|
|
562
562
|
* The tokens a child is KNOWN to have spent, or `undefined` when no request
|
|
@@ -17,8 +17,20 @@ export const SPAWN_SUBAGENTS_TOOL_NAME = "spawn_subagents";
|
|
|
17
17
|
/**
|
|
18
18
|
* Mutating tools (C.33): a child holding ANY of these is a "writer" for the
|
|
19
19
|
* disjoint-scope check. Two writers in one parallel batch must target distinct
|
|
20
|
-
* roots, or the batch is refused pre-dispatch.
|
|
21
|
-
*
|
|
20
|
+
* roots, or the batch is refused pre-dispatch.
|
|
21
|
+
*
|
|
22
|
+
* This is the set of REGISTERED tool names that gate on `ctx.requestApproval`
|
|
23
|
+
* and act on a workspace root: file writes, shell/test, the PR tool, and the
|
|
24
|
+
* background-job dispatcher (a job is a whole agent run that may be granted
|
|
25
|
+
* any of the others). `registry-scope.test.ts` pins every name here to a tool
|
|
26
|
+
* that actually exists — until P1 this set named three tools that never did
|
|
27
|
+
* (`git_commit`, `git_branch`, `open_pr`) and omitted `create_pull_request`,
|
|
28
|
+
* so a child holding the PR tool was never counted as a writer and two of them
|
|
29
|
+
* could be dispatched against one root.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately NOT here: `remember` (writes the memory store, not a root —
|
|
32
|
+
* root-disjointness says nothing about it) and the spawn tools (stripped from
|
|
33
|
+
* every child scope). MCP tools are covered by prefix in {@link isWriteTool}.
|
|
22
34
|
*/
|
|
23
35
|
export const SUBAGENT_WRITE_TOOLS = new Set([
|
|
24
36
|
"write_file",
|
|
@@ -26,10 +38,21 @@ export const SUBAGENT_WRITE_TOOLS = new Set([
|
|
|
26
38
|
"apply_patch",
|
|
27
39
|
"run_command",
|
|
28
40
|
"run_tests",
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"open_pr",
|
|
41
|
+
"create_pull_request",
|
|
42
|
+
"run_in_background",
|
|
32
43
|
]);
|
|
44
|
+
/** The wire-name prefix every MCP-proxied tool carries (see `mcp/adapter.ts`). */
|
|
45
|
+
const MCP_TOOL_PREFIX = "mcp__";
|
|
46
|
+
/**
|
|
47
|
+
* Whether granting `name` to a child makes it a writer. The named set above,
|
|
48
|
+
* plus every MCP tool: the adapter gates each call on approval precisely
|
|
49
|
+
* because a server's tool can mutate anything, and the classifier never lets
|
|
50
|
+
* the server declare itself read-only — so the disjointness check must not
|
|
51
|
+
* either.
|
|
52
|
+
*/
|
|
53
|
+
export function isWriteTool(name) {
|
|
54
|
+
return SUBAGENT_WRITE_TOOLS.has(name) || name.startsWith(MCP_TOOL_PREFIX);
|
|
55
|
+
}
|
|
33
56
|
/**
|
|
34
57
|
* The default child toolset: read-only investigation plus skills. Mirrors the
|
|
35
58
|
* C.31 propose-phase set — no writes, no shell, no VCS unless the parent
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { resolveToolPath, toPosix } from "./paths.js";
|
|
5
5
|
import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
|
|
6
|
+
import { changedSince, snapshotFile, snapshotOf, } from "./snapshot.js";
|
|
6
7
|
/** How many leading lines of a created file the approval preview shows. */
|
|
7
8
|
const PREVIEW_LINES = 20;
|
|
8
9
|
/**
|
|
@@ -162,6 +163,20 @@ export const applyPatchTool = {
|
|
|
162
163
|
if (!decision.allow) {
|
|
163
164
|
return { ok: false, error: decision.feedback ?? "patch denied" };
|
|
164
165
|
}
|
|
166
|
+
// The approval covered THESE files in THE states they were read in. Any
|
|
167
|
+
// path that moved during the wait — rewritten, deleted, or created by
|
|
168
|
+
// something else — voids the whole patch: nothing is applied, so the model
|
|
169
|
+
// re-reads and resubmits rather than landing a half-stale patch (P1).
|
|
170
|
+
// Every drifted path is named, not just the first: a retry that knows one
|
|
171
|
+
// of two moved re-reads one file and trips over the other.
|
|
172
|
+
const drifted = [];
|
|
173
|
+
for (const p of planned) {
|
|
174
|
+
const moved = await changedSince(p.abs, p.approved, p.rel);
|
|
175
|
+
if (moved)
|
|
176
|
+
drifted.push(moved);
|
|
177
|
+
}
|
|
178
|
+
if (drifted.length > 0)
|
|
179
|
+
return { ok: false, error: drifted.join("\n") };
|
|
165
180
|
// Validation passed and the user approved; apply everything. A mid-apply I/O
|
|
166
181
|
// failure is rare but reported with what already landed.
|
|
167
182
|
const applied = [];
|
|
@@ -195,28 +210,44 @@ async function openTrack(i, op, abs, ctx) {
|
|
|
195
210
|
// messages), consistent with every other path tool — see {@link toPosix}.
|
|
196
211
|
const rel = toPosix(path.relative(ctx.cwd, abs));
|
|
197
212
|
const base = { abs, rel, firstOp: i, hunks: [] };
|
|
198
|
-
if (op.type === "create") {
|
|
199
|
-
|
|
200
|
-
|
|
213
|
+
if (op.type === "create" || op.type === "delete") {
|
|
214
|
+
let approved;
|
|
215
|
+
try {
|
|
216
|
+
approved = await snapshotFile(abs);
|
|
201
217
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
218
|
+
catch (err) {
|
|
219
|
+
return { ok: false, error: opError(i, op, err.message) };
|
|
220
|
+
}
|
|
221
|
+
if (op.type === "create") {
|
|
222
|
+
if (approved.kind === "present") {
|
|
223
|
+
return { ok: false, error: opError(i, op, "file already exists") };
|
|
224
|
+
}
|
|
225
|
+
const content = op.content ?? "";
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
track: {
|
|
229
|
+
...base,
|
|
230
|
+
kind: "create",
|
|
231
|
+
content,
|
|
232
|
+
eol: detectEol(content),
|
|
233
|
+
approved,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (approved.kind === "absent") {
|
|
210
238
|
return { ok: false, error: opError(i, op, "file not found") };
|
|
211
239
|
}
|
|
212
240
|
return {
|
|
213
241
|
ok: true,
|
|
214
|
-
track: { ...base, kind: "delete", content: "", eol: "\n" },
|
|
242
|
+
track: { ...base, kind: "delete", content: "", eol: "\n", approved },
|
|
215
243
|
};
|
|
216
244
|
}
|
|
217
245
|
let content;
|
|
246
|
+
let approved;
|
|
218
247
|
try {
|
|
219
|
-
|
|
248
|
+
const bytes = await fs.readFile(abs);
|
|
249
|
+
approved = snapshotOf(bytes);
|
|
250
|
+
content = bytes.toString("utf8");
|
|
220
251
|
}
|
|
221
252
|
catch (err) {
|
|
222
253
|
if (err.code === "ENOENT") {
|
|
@@ -229,6 +260,7 @@ async function openTrack(i, op, abs, ctx) {
|
|
|
229
260
|
kind: "update",
|
|
230
261
|
content,
|
|
231
262
|
eol: detectEol(content),
|
|
263
|
+
approved,
|
|
232
264
|
};
|
|
233
265
|
const failure = applyHunk(i, op, track);
|
|
234
266
|
return failure ? { ok: false, error: failure } : { ok: true, track };
|
|
@@ -261,12 +293,12 @@ function applyHunk(i, op, track) {
|
|
|
261
293
|
}
|
|
262
294
|
/** Collapse a finished track into the single write it represents. */
|
|
263
295
|
function toPlanned(track) {
|
|
264
|
-
const { kind, abs, rel, content, hunks } = track;
|
|
296
|
+
const { kind, abs, rel, content, hunks, approved } = track;
|
|
265
297
|
if (kind === "delete")
|
|
266
|
-
return { op: "delete", abs, rel };
|
|
298
|
+
return { op: "delete", abs, rel, approved };
|
|
267
299
|
if (kind === "create")
|
|
268
|
-
return { op: "create", abs, rel, content };
|
|
269
|
-
return { op: "update", abs, rel, content, hunks };
|
|
300
|
+
return { op: "create", abs, rel, content, approved };
|
|
301
|
+
return { op: "update", abs, rel, content, hunks, approved };
|
|
270
302
|
}
|
|
271
303
|
/** Shape a planned op into its approval-preview form. */
|
|
272
304
|
function toPreview(p) {
|
|
@@ -287,9 +319,3 @@ function toPreview(p) {
|
|
|
287
319
|
function opError(i, op, reason) {
|
|
288
320
|
return `operation ${i + 1} (${op.type} ${op.path}): ${reason}`;
|
|
289
321
|
}
|
|
290
|
-
async function exists(abs) {
|
|
291
|
-
return fs
|
|
292
|
-
.access(abs)
|
|
293
|
-
.then(() => true)
|
|
294
|
-
.catch(() => false);
|
|
295
|
-
}
|
|
@@ -2,10 +2,15 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { resolveToolPath } from "./paths.js";
|
|
4
4
|
import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
|
|
5
|
+
import { changedSince, snapshotOf } from "./snapshot.js";
|
|
5
6
|
/**
|
|
6
7
|
* Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
|
|
7
8
|
* The uniqueness requirement is checked before approval so the model can fix an
|
|
8
9
|
* ambiguous match without burning a prompt; gated on `ctx.approve` before writing.
|
|
10
|
+
*
|
|
11
|
+
* The bytes read before approval are the state the approval is granted
|
|
12
|
+
* against; the write is refused if the file is no longer in that state when
|
|
13
|
+
* the write is about to happen (P1 — see `snapshot.ts`).
|
|
9
14
|
*/
|
|
10
15
|
export const editFileTool = {
|
|
11
16
|
name: "edit_file",
|
|
@@ -29,8 +34,11 @@ export const editFileTool = {
|
|
|
29
34
|
return { ok: false, error: err.message };
|
|
30
35
|
}
|
|
31
36
|
let content;
|
|
37
|
+
let approvedState;
|
|
32
38
|
try {
|
|
33
|
-
|
|
39
|
+
const bytes = await fs.readFile(abs);
|
|
40
|
+
approvedState = snapshotOf(bytes);
|
|
41
|
+
content = bytes.toString("utf8");
|
|
34
42
|
}
|
|
35
43
|
catch (err) {
|
|
36
44
|
if (err.code === "ENOENT") {
|
|
@@ -64,6 +72,12 @@ export const editFileTool = {
|
|
|
64
72
|
const updated = content.slice(0, match.start) +
|
|
65
73
|
applyEol(input.new_str, detectEol(content)) +
|
|
66
74
|
content.slice(match.end);
|
|
75
|
+
// The approval covered a diff against the bytes read above. Anything that
|
|
76
|
+
// changed the file during the wait makes `updated` a splice of stale
|
|
77
|
+
// content — refuse rather than overwrite what is there now (P1).
|
|
78
|
+
const moved = await changedSince(abs, approvedState, input.path);
|
|
79
|
+
if (moved)
|
|
80
|
+
return { ok: false, error: moved };
|
|
67
81
|
try {
|
|
68
82
|
await fs.writeFile(abs, updated, "utf8");
|
|
69
83
|
return { ok: true, output: `edited ${input.path}` };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { ErrorCode } from "../../errors/index.js";
|
|
4
|
+
/** Snapshot the bytes a tool has ALREADY read (no second read). */
|
|
5
|
+
export function snapshotOf(bytes) {
|
|
6
|
+
return {
|
|
7
|
+
kind: "present",
|
|
8
|
+
digest: createHash("sha256").update(bytes).digest("hex"),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Read `abs` and snapshot it. A missing path is a legitimate state (`absent`)
|
|
13
|
+
* — it is what `write_file` and a patch `create` are approved against. Every
|
|
14
|
+
* other failure (a directory at the path, EACCES) propagates: the caller
|
|
15
|
+
* cannot build a truthful preview of a file it cannot read.
|
|
16
|
+
*/
|
|
17
|
+
export async function snapshotFile(abs) {
|
|
18
|
+
try {
|
|
19
|
+
return snapshotOf(await fs.readFile(abs));
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code === "ENOENT") {
|
|
23
|
+
return { kind: "absent" };
|
|
24
|
+
}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Re-read `abs` and compare it with the state the approval was granted
|
|
30
|
+
* against. Returns `null` when the file is exactly as it was, otherwise the
|
|
31
|
+
* refusal to hand back as the tool's error — one sentence on what moved, and
|
|
32
|
+
* the same next step every time: read it again and retry.
|
|
33
|
+
*
|
|
34
|
+
* Call this AFTER approval and IMMEDIATELY before the write, with nothing
|
|
35
|
+
* awaited in between — the point is to make the gap as small as the platform
|
|
36
|
+
* allows, not to check early and then wait.
|
|
37
|
+
*/
|
|
38
|
+
export async function changedSince(abs, approved, rel) {
|
|
39
|
+
let now;
|
|
40
|
+
try {
|
|
41
|
+
now = await snapshotFile(abs);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return refusal(rel, `could not be re-read before the approved write (${err.message})`);
|
|
45
|
+
}
|
|
46
|
+
if (approved.kind === "absent") {
|
|
47
|
+
return now.kind === "absent"
|
|
48
|
+
? null
|
|
49
|
+
: refusal(rel, "was created by something else after it was approved as a new file");
|
|
50
|
+
}
|
|
51
|
+
if (now.kind === "absent") {
|
|
52
|
+
return refusal(rel, "was deleted after it was read");
|
|
53
|
+
}
|
|
54
|
+
if (now.digest !== approved.digest) {
|
|
55
|
+
return refusal(rel, "changed on disk after it was read");
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/** The one refusal shape: what moved, that nothing was written, what to do. */
|
|
60
|
+
function refusal(rel, what) {
|
|
61
|
+
return (`${ErrorCode.FileChangedSinceRead}: ${rel} ${what}, so the approval no longer ` +
|
|
62
|
+
`covers this write; nothing was written — read the file again and retry`);
|
|
63
|
+
}
|
|
@@ -2,11 +2,18 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { resolveToolPath } from "./paths.js";
|
|
5
|
+
import { changedSince, snapshotFile } from "./snapshot.js";
|
|
5
6
|
/** How many leading lines of new content the approval preview shows. */
|
|
6
7
|
const PREVIEW_LINES = 20;
|
|
7
8
|
/**
|
|
8
9
|
* Create or overwrite a file within the project root. Gated on `ctx.approve`
|
|
9
10
|
* before anything is written.
|
|
11
|
+
*
|
|
12
|
+
* The target is snapshotted before approval — its current bytes, or its
|
|
13
|
+
* absence — and the write is refused if that state has moved by the time the
|
|
14
|
+
* write is about to happen (P1 — see `snapshot.ts`). This tool used to have
|
|
15
|
+
* no read at all: an "overwrite" approved against one version of a file would
|
|
16
|
+
* land on whatever version was there by the time the user pressed `y`.
|
|
10
17
|
*/
|
|
11
18
|
export const writeFileTool = {
|
|
12
19
|
name: "write_file",
|
|
@@ -25,11 +32,19 @@ export const writeFileTool = {
|
|
|
25
32
|
catch (err) {
|
|
26
33
|
return { ok: false, error: err.message };
|
|
27
34
|
}
|
|
28
|
-
// Does the target already exist? Drives create-vs-overwrite in the preview
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
// Does the target already exist? Drives create-vs-overwrite in the preview,
|
|
36
|
+
// and its bytes (or absence) are the state the approval is granted against.
|
|
37
|
+
let approvedState;
|
|
38
|
+
try {
|
|
39
|
+
approvedState = await snapshotFile(abs);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: `cannot read ${input.path}: ${err.message}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const exists = approvedState.kind === "present";
|
|
33
48
|
// First N lines of the new content, with a count of what's omitted.
|
|
34
49
|
const allLines = input.content.split("\n");
|
|
35
50
|
const lines = allLines.slice(0, PREVIEW_LINES);
|
|
@@ -46,6 +61,12 @@ export const writeFileTool = {
|
|
|
46
61
|
error: decision.feedback ?? `write to ${input.path} denied`,
|
|
47
62
|
};
|
|
48
63
|
}
|
|
64
|
+
// A create approved against "no file here" must not overwrite a file that
|
|
65
|
+
// appeared during the wait; an overwrite approved against one version must
|
|
66
|
+
// not land on another (P1).
|
|
67
|
+
const moved = await changedSince(abs, approvedState, input.path);
|
|
68
|
+
if (moved)
|
|
69
|
+
return { ok: false, error: moved };
|
|
49
70
|
try {
|
|
50
71
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
51
72
|
await fs.writeFile(abs, input.content, "utf8");
|
package/dist/tui/app.js
CHANGED
|
@@ -3,13 +3,14 @@ import { completeLine } from "../components/autocomplete.js";
|
|
|
3
3
|
import { SHARED_COMMANDS, SHARED_HELP, announceMode, dispatchCommand, } from "../cli/session-commands.js";
|
|
4
4
|
import { TUI_ONLY_COMMANDS } from "../cli/command-catalog.js";
|
|
5
5
|
import { selectList } from "../components/select.js";
|
|
6
|
-
import { viewLabel, viewOrder } from "./views.js";
|
|
6
|
+
import { CONVERSATION_VIEW, viewLabel, viewOrder } from "./views.js";
|
|
7
7
|
import { canOverlay, createKeyLease, createOverlayIO, } from "./overlay.js";
|
|
8
8
|
import { ModeRing } from "./mode-ring.js";
|
|
9
9
|
import { openPalette } from "./palette.js";
|
|
10
10
|
import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
|
|
11
11
|
import { CLOSABLE_PANELS, columnOf, RAIL_PANELS, } from "./layout.js";
|
|
12
12
|
import { COLUMN_LABELS, PANEL_LABELS } from "./panels.js";
|
|
13
|
+
import { WHEEL_LINES } from "./renderer.js";
|
|
13
14
|
/**
|
|
14
15
|
* The TUI's input loop (P1) — the piece that replaces `repl.ts`'s readline
|
|
15
16
|
* loop. It owns exactly two things: the edit buffer and command dispatch.
|
|
@@ -59,7 +60,10 @@ const HELP = [
|
|
|
59
60
|
" Ctrl+K open the command palette",
|
|
60
61
|
" Ctrl+B focus the sidebar nav (arrows switch view, Esc leaves)",
|
|
61
62
|
" Shift+Tab cycle mode (manual · auto-approve · plan · full-auto)",
|
|
62
|
-
" PgUp / PgDn scroll the pane
|
|
63
|
+
" PgUp / PgDn scroll the pane (the mouse wheel does too; to select",
|
|
64
|
+
" text hold Shift — Option in Terminal.app — or set",
|
|
65
|
+
" CRUXY_NO_MOUSE=1 to give the wheel back to the terminal)",
|
|
66
|
+
" Esc back to the live view, then back to the conversation",
|
|
63
67
|
" Ctrl+D leave cruxy",
|
|
64
68
|
];
|
|
65
69
|
/**
|
|
@@ -177,6 +181,10 @@ async function readLine(keys, renderer, hooks) {
|
|
|
177
181
|
// answering into a pane they cannot see is the one case where holding
|
|
178
182
|
// still is wrong.
|
|
179
183
|
renderer.scrollToLive();
|
|
184
|
+
// And clears the last command's output from under a view (cli#7b):
|
|
185
|
+
// what this line produces must not stack beneath what the last one
|
|
186
|
+
// did. It is all still in the conversation.
|
|
187
|
+
renderer.dismissNotices();
|
|
180
188
|
paint();
|
|
181
189
|
return text;
|
|
182
190
|
}
|
|
@@ -282,12 +290,23 @@ async function readLine(keys, renderer, hooks) {
|
|
|
282
290
|
// schedules its own repaint for the rows that did.
|
|
283
291
|
renderer.scrollPage(key.kind === "page-up" ? 1 : -1);
|
|
284
292
|
break;
|
|
293
|
+
case "wheel-up":
|
|
294
|
+
case "wheel-down":
|
|
295
|
+
// The mouse wheel (cli#1) is the same scroll at a finer grain. It
|
|
296
|
+
// only arrives because the renderer turned mouse reporting on; the
|
|
297
|
+
// terminal would otherwise have scrolled its own window (Terminal.app)
|
|
298
|
+
// or sent arrows this loop ignores (iTerm2).
|
|
299
|
+
renderer.scrollBy(key.kind === "wheel-up" ? WHEEL_LINES : -WHEEL_LINES);
|
|
300
|
+
break;
|
|
285
301
|
case "escape":
|
|
286
|
-
// Esc
|
|
287
|
-
// notice names this key
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
302
|
+
// Esc peels one layer (cli#7b): out of scrollback first — a mode
|
|
303
|
+
// needs a visible exit, and the notice names this key — and, at the
|
|
304
|
+
// live tail, out of a view and back to the conversation. Two presses
|
|
305
|
+
// from anywhere reach the home position; each one is a step the
|
|
306
|
+
// screen can show. Inert at the conversation's live tail, so the
|
|
307
|
+
// binding stays free for whatever a later track wants it to mean.
|
|
308
|
+
if (!renderer.scrollToLive())
|
|
309
|
+
renderer.setView(CONVERSATION_VIEW);
|
|
291
310
|
break;
|
|
292
311
|
case "ctrl-b":
|
|
293
312
|
// Take the keyboard to the sidebar nav (P7 track 2). Refused when the
|
|
@@ -485,6 +504,10 @@ export async function runTui(session, renderer, opts = {}) {
|
|
|
485
504
|
print: (line = "") => renderer.println(line),
|
|
486
505
|
theme: renderer.theme,
|
|
487
506
|
fit: (line) => line,
|
|
507
|
+
// `/clear` (cli#2): the TUI owns its scrollback, so a cleared history is
|
|
508
|
+
// also a cleared screen. The REPL has no equivalent — its transcript is the
|
|
509
|
+
// terminal's own scrollback, which is not the CLI's to erase.
|
|
510
|
+
clear: () => renderer.clearScrollback(),
|
|
488
511
|
};
|
|
489
512
|
// The mode ring (Q5). The chip follows every press; the session is told once,
|
|
490
513
|
// when the presses stop — see `mode-ring.ts` for why passing through a mode is
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readAnswerKey } from "../components/input.js";
|
|
1
2
|
import { themeForColor } from "../theme/index.js";
|
|
2
3
|
/**
|
|
3
4
|
* The approval prompt as an in-viewport modal (P5 track 2).
|
|
@@ -88,7 +89,9 @@ export function createOverlayPromptIO(surface, lease, color) {
|
|
|
88
89
|
paint();
|
|
89
90
|
},
|
|
90
91
|
async readKey() {
|
|
91
|
-
|
|
92
|
+
// A wheel notch scrolls the conversation behind the drawer; it is not an
|
|
93
|
+
// answer, and mapping it to "" would deny the action being asked about.
|
|
94
|
+
const key = keys === null ? await read(keys) : await readAnswerKey(keys);
|
|
92
95
|
return keyToChar(key);
|
|
93
96
|
},
|
|
94
97
|
/**
|
package/dist/tui/layout.js
CHANGED
|
@@ -124,6 +124,28 @@ export function fitBlock(lines, rows, width) {
|
|
|
124
124
|
out.push(" ".repeat(Math.max(0, width)));
|
|
125
125
|
return out;
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Take the FIRST `rows` lines of a block and pad it to exactly that many rows —
|
|
129
|
+
* the mirror of {@link fitBlock}, for a column whose top is the part that must
|
|
130
|
+
* survive.
|
|
131
|
+
*
|
|
132
|
+
* The sidebar is that column (cli#7a). It opens with the view nav: a heading
|
|
133
|
+
* and one row per view, fixed-height, and NAVIGABLE — Ctrl+B puts the keyboard
|
|
134
|
+
* on it, and the arrows move a pointer down its rows. `fitBlock`'s tail rule
|
|
135
|
+
* applied to that column ate the heading and the first four view rows at 30
|
|
136
|
+
* rows, which left Ctrl+B moving a pointer the screen never showed. Nothing
|
|
137
|
+
* at the bottom of the sidebar is worth that: the session list below the nav
|
|
138
|
+
* is composed to its own budget by the renderer (see `sidebarLines`), so what
|
|
139
|
+
* reaches here already fits, and when it does not — a terminal too short for
|
|
140
|
+
* the nav itself — the rows to lose are the last ones, not the first.
|
|
141
|
+
*/
|
|
142
|
+
export function fitHead(lines, rows, width) {
|
|
143
|
+
const head = lines.length > rows ? lines.slice(0, Math.max(0, rows)) : lines;
|
|
144
|
+
const out = head.map((l) => padTo(l, width));
|
|
145
|
+
while (out.length < rows)
|
|
146
|
+
out.push(" ".repeat(Math.max(0, width)));
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
127
149
|
/**
|
|
128
150
|
* Window `rows` lines out of a block, `offset` display lines up from the end
|
|
129
151
|
* (P7 track 1). `offset === 0` is the tail view {@link fitBlock} gives, and the
|
|
@@ -293,8 +315,10 @@ export function composeScreen(vm, width, height, open, theme) {
|
|
|
293
315
|
const drawer = fitOverlay(vm.overlay ?? [], overlayRows(height));
|
|
294
316
|
const columnRows = Math.max(1, rows - drawer.length);
|
|
295
317
|
const columns = [];
|
|
318
|
+
// HEAD-fitted, not tail: the sidebar is a nav over a list, and the nav is the
|
|
319
|
+
// part that has to be on screen — see `fitHead`. `main` stays a tail view.
|
|
296
320
|
if (budget.sidebar > 0)
|
|
297
|
-
columns.push(
|
|
321
|
+
columns.push(fitHead(vm.sidebar, columnRows, budget.sidebar));
|
|
298
322
|
columns.push(fitBlock(vm.main, columnRows, budget.main));
|
|
299
323
|
if (budget.rail > 0)
|
|
300
324
|
columns.push(fitBlock(vm.rail, columnRows, budget.rail));
|
package/dist/tui/panels.js
CHANGED
|
@@ -50,22 +50,44 @@ function title(text, theme) {
|
|
|
50
50
|
* The column is narrow (18 columns), so each session takes two lines: its short
|
|
51
51
|
* id and age, then its title. The layout truncates per line, which keeps the id
|
|
52
52
|
* — the part you would type into `--resume` — always fully visible.
|
|
53
|
+
*
|
|
54
|
+
* `rows` is the HEIGHT budget (cli#7a), and the list is fitted to it here the
|
|
55
|
+
* way `stackPanels` fits the rail: whole sessions, newest first, and a count of
|
|
56
|
+
* what did not fit rather than a session cut in half or silently absent. The
|
|
57
|
+
* list sits under the view nav, which is fixed-height and keyboard-driven, so
|
|
58
|
+
* it is the list that yields — and because it is newest-first, the rows to
|
|
59
|
+
* yield are at the END. The old tail-fit lost the nav heading and the newest
|
|
60
|
+
* sessions at once, which is the wrong end of both blocks.
|
|
53
61
|
*/
|
|
54
|
-
export function sidebarLines(theme, sessions = [], activeSessionId, now = Date.now()) {
|
|
62
|
+
export function sidebarLines(theme, sessions = [], activeSessionId, now = Date.now(), rows = Infinity) {
|
|
63
|
+
if (rows <= 0)
|
|
64
|
+
return [];
|
|
55
65
|
const lines = title("sessions", theme);
|
|
56
66
|
if (sessions.length === 0) {
|
|
57
67
|
lines.push(theme.muted("no saved sessions"));
|
|
58
68
|
lines.push(theme.muted("for this project yet."));
|
|
59
|
-
return lines;
|
|
69
|
+
return lines.slice(0, rows);
|
|
60
70
|
}
|
|
61
|
-
|
|
71
|
+
// Rows left for sessions after the title. Each session costs two, and when
|
|
72
|
+
// not all fit, one row is charged for the notice BEFORE deciding how many do
|
|
73
|
+
// — reserving it afterwards could evict a session just counted as shown.
|
|
74
|
+
const room = Math.max(0, rows - lines.length);
|
|
75
|
+
const fitsWhole = sessions.length * 2 <= room;
|
|
76
|
+
const shown = fitsWhole
|
|
77
|
+
? sessions.length
|
|
78
|
+
: Math.max(0, Math.floor((room - 1) / 2));
|
|
79
|
+
for (const s of sessions.slice(0, shown)) {
|
|
62
80
|
const active = s.sessionId === activeSessionId;
|
|
63
81
|
const mark = active ? theme.accent(theme.glyph.pointer) : " ";
|
|
64
82
|
const head = `${mark} ${shortId(s.sessionId)} ${relativeAge(s.updatedAt, now)}`;
|
|
65
83
|
lines.push(active ? theme.strong(head) : head);
|
|
66
84
|
lines.push(theme.muted(` ${s.title}`));
|
|
67
85
|
}
|
|
68
|
-
|
|
86
|
+
if (!fitsWhole && room > 0) {
|
|
87
|
+
const hidden = sessions.length - shown;
|
|
88
|
+
lines.push(theme.muted(`${theme.glyph.ellipsis}${hidden} more session${hidden === 1 ? "" : "s"}`));
|
|
89
|
+
}
|
|
90
|
+
return lines.slice(0, rows);
|
|
69
91
|
}
|
|
70
92
|
/** A panel with no state yet: says so, rather than inventing a plausible value. */
|
|
71
93
|
function pending(theme) {
|
|
@@ -236,19 +258,35 @@ function shortTokens(n) {
|
|
|
236
258
|
*
|
|
237
259
|
* The compaction threshold is shown because it is the only actionable thing
|
|
238
260
|
* here: it says when the CLI will start folding history away.
|
|
261
|
+
*
|
|
262
|
+
* THE ALLOWANCE IS NAMED (cli#4). `used` includes `context.reserveTokens` — a
|
|
263
|
+
* fixed 4,500 by default — because the compaction seam adds it, and the panel
|
|
264
|
+
* must measure what the seam measures. But shown as a bare figure it read as
|
|
265
|
+
* consumption: an empty session opened at "~5k / 100k", as if something had
|
|
266
|
+
* already been spent. Nothing had. The constant is a fair size for what it
|
|
267
|
+
* stands in for (the system prompt plus the default tool catalogue measure
|
|
268
|
+
* about 3.7k), and it is an ALLOWANCE, not a reading: it does not move when
|
|
269
|
+
* CRUXY.md, memory recall, LSP, web or MCP schemas make the real request
|
|
270
|
+
* larger. The second row says so, in the room a 24-column strip has.
|
|
239
271
|
*/
|
|
240
272
|
export function contextPanelLines(theme, reading) {
|
|
241
273
|
if (reading === undefined) {
|
|
242
274
|
return [theme.muted(`measuring${theme.glyph.ellipsis}`)];
|
|
243
275
|
}
|
|
244
|
-
const { used, total, compactAt } = reading;
|
|
276
|
+
const { used, total, compactAt, reserve } = reading;
|
|
245
277
|
const figure = `~${shortTokens(used)} / ${shortTokens(total)} budget`;
|
|
246
278
|
// Past the threshold the next turn compacts, which is worth flagging — but as
|
|
247
279
|
// a statement of what happens next, not as an alarm about a guessed number.
|
|
248
280
|
// Compared on `used`, not the clamped fraction: the clamp is for display, and
|
|
249
281
|
// a history that has overrun the budget must not compare as merely "at" it.
|
|
250
282
|
const style = used >= compactAt ? theme.warning : theme.strong;
|
|
251
|
-
return [
|
|
283
|
+
return [
|
|
284
|
+
style(figure),
|
|
285
|
+
...(reserve === undefined || reserve <= 0
|
|
286
|
+
? []
|
|
287
|
+
: [theme.muted(`incl. ~${shortTokens(reserve)} allowance`)]),
|
|
288
|
+
theme.muted(`compacts at ${shortTokens(compactAt)}`),
|
|
289
|
+
];
|
|
252
290
|
}
|
|
253
291
|
/** The opening lines of the main column, before any turn has run. */
|
|
254
292
|
export function mainWelcome(theme, hint) {
|