@cruxy/cli 1.5.0 → 1.7.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 +116 -0
- package/dist/agent/session.js +76 -7
- package/dist/budget/index.js +9 -0
- package/dist/budget/session-budget.js +223 -0
- package/dist/checkpoint/diff.js +130 -0
- package/dist/checkpoint/git-store.js +52 -0
- package/dist/checkpoint/index.js +2 -0
- package/dist/checkpoint/run-rollback.js +100 -0
- package/dist/cli/command-catalog.js +144 -0
- package/dist/cli/commands/hooks.js +1 -1
- package/dist/cli/commands/rollback.js +21 -57
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/test.js +28 -16
- package/dist/cli/session-commands.js +315 -69
- package/dist/cli/session-factory.js +13 -0
- package/dist/components/frame.js +39 -1
- package/dist/errors/constructors.js +43 -4
- package/dist/errors/types.js +15 -0
- package/dist/hooks/config.js +18 -0
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/router.js +1 -1
- package/dist/hooks/service.js +4 -4
- package/dist/hooks/slash.js +10 -26
- package/dist/lsp/index.js +1 -1
- package/dist/lsp/registry.js +28 -10
- package/dist/memory/secrets.js +43 -0
- package/dist/plan/service.js +26 -1
- package/dist/plan/submit-plan.js +11 -0
- package/dist/render/capabilities.js +9 -2
- package/dist/render/context-view.js +2 -2
- package/dist/render/index.js +6 -1
- package/dist/render/plan-view.js +1 -1
- package/dist/render/status-view.js +5 -5
- package/dist/render/units.js +22 -0
- package/dist/session/index.js +1 -0
- package/dist/session/log.js +19 -0
- package/dist/session/redact.js +74 -0
- package/dist/session/replay.js +16 -0
- package/dist/session/resume.js +8 -0
- package/dist/session/types.js +38 -0
- package/dist/subagent/orchestrator.js +82 -5
- package/dist/theme/resolve.js +1 -0
- package/dist/tui/app.js +25 -7
- package/dist/tui/approval-overlay.js +7 -1
- package/dist/tui/index.js +3 -2
- package/dist/tui/layout.js +7 -2
- package/dist/tui/limits-panel.js +6 -14
- package/dist/tui/mode-ring.js +84 -0
- package/dist/tui/palette.js +11 -19
- package/dist/tui/renderer.js +145 -4
- package/dist/tui/restore.js +137 -0
- package/dist/tui/supports.js +22 -0
- package/dist/tui/tool-versions.js +119 -18
- package/dist/usage/weighted.js +14 -0
- package/dist/utils/disk.js +11 -3
- package/package.json +2 -2
package/dist/hooks/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { isReservedSlash } from "../cli/command-catalog.js";
|
|
3
4
|
import { globalDir } from "../config/paths.js";
|
|
4
5
|
import { COMMANDS_DIR_NAME, GLOBAL_DIR_NAME, HOOKS_FILE_NAME, } from "../constants.js";
|
|
5
6
|
import { defaultBlocking, HOOK_SOURCE_PRECEDENCE, HookSpecSchema, SlashFrontmatterSchema, } from "./types.js";
|
|
@@ -143,6 +144,23 @@ async function scanCommandsDir(source, dir, out, errors) {
|
|
|
143
144
|
});
|
|
144
145
|
continue;
|
|
145
146
|
}
|
|
147
|
+
// A reserved name is refused HERE, at load, and excluded from the catalog
|
|
148
|
+
// (P10 track 0). Both halves matter. Loading it and letting it lose the
|
|
149
|
+
// race later is what shipped before: `commands/status.md` parsed, appeared
|
|
150
|
+
// in `cruxy hooks list` and in the palette, and could not run, because the
|
|
151
|
+
// shells dispatch `/status` themselves several branches before the custom
|
|
152
|
+
// catalogue is consulted. Nothing said so at load and nothing said so at
|
|
153
|
+
// use. Keeping it in the catalog with a warning would fix half of that and
|
|
154
|
+
// leave the palette advertising a command that does nothing.
|
|
155
|
+
if (isReservedSlash(name)) {
|
|
156
|
+
errors.push({
|
|
157
|
+
source,
|
|
158
|
+
file,
|
|
159
|
+
name,
|
|
160
|
+
message: `"/${name}" is a built-in command and cannot be overridden — rename this file`,
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
146
164
|
let text;
|
|
147
165
|
try {
|
|
148
166
|
text = await fs.readFile(file, "utf8");
|
package/dist/hooks/index.js
CHANGED
|
@@ -2,6 +2,6 @@ export { HOOK_EVENTS, HOOK_SOURCE_PRECEDENCE, HookSpecSchema, HooksFileSchema, S
|
|
|
2
2
|
export { defaultHookSources, loadHookCatalog, } from "./config.js";
|
|
3
3
|
export { fileTrustStore, fingerprintHooks, isTrusted, memoryTrustStore, trustPath, } from "./trust.js";
|
|
4
4
|
export { HookRunner, } from "./runner.js";
|
|
5
|
-
export {
|
|
5
|
+
export { expandTemplate, resolveSlash } from "./slash.js";
|
|
6
6
|
export { buildHooksService, } from "./service.js";
|
|
7
7
|
export { buildHooksRouter, } from "./router.js";
|
package/dist/hooks/router.js
CHANGED
|
@@ -79,7 +79,7 @@ export async function buildHooksRouter(opts) {
|
|
|
79
79
|
for (const root of opts.workspace.roots()) {
|
|
80
80
|
const catalog = await loadCatalog(root);
|
|
81
81
|
for (const err of catalog.errors) {
|
|
82
|
-
opts.logger.warn(`ignoring
|
|
82
|
+
opts.logger.warn(`ignoring ${err.source} hook/command "${err.name}" in ${root.name}: ${err.message}`);
|
|
83
83
|
}
|
|
84
84
|
// Each root's runner owns ONLY its project hooks. User hooks are global —
|
|
85
85
|
// loaded once (from the primary, whose `sources.user` is identical for every
|
package/dist/hooks/service.js
CHANGED
|
@@ -5,15 +5,15 @@ import { defaultHookSources, loadHookCatalog, } from "./config.js";
|
|
|
5
5
|
import { HookRunner } from "./runner.js";
|
|
6
6
|
import { fileTrustStore } from "./trust.js";
|
|
7
7
|
/**
|
|
8
|
-
* Load the catalog and assemble the {@link HooksService}.
|
|
9
|
-
*
|
|
10
|
-
* valid ones proceed.
|
|
8
|
+
* Load the catalog and assemble the {@link HooksService}. Rejected definitions
|
|
9
|
+
* — malformed, or a command name the shells reserve — are surfaced (never
|
|
10
|
+
* eval'd, never silently dropped) through the logger; the valid ones proceed.
|
|
11
11
|
*/
|
|
12
12
|
export async function buildHooksService(opts) {
|
|
13
13
|
const sources = opts.sources ?? defaultHookSources(opts.cwd);
|
|
14
14
|
const catalog = await loadHookCatalog(sources);
|
|
15
15
|
for (const err of catalog.errors) {
|
|
16
|
-
opts.logger.warn(`ignoring
|
|
16
|
+
opts.logger.warn(`ignoring ${err.source} hook/command "${err.name}": ${err.message}`);
|
|
17
17
|
}
|
|
18
18
|
const trust = opts.trust ?? fileTrustStore();
|
|
19
19
|
const runner = new HookRunner({
|
package/dist/hooks/slash.js
CHANGED
|
@@ -1,29 +1,13 @@
|
|
|
1
|
+
import { isReservedSlash } from "../cli/command-catalog.js";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"help",
|
|
11
|
-
"clear",
|
|
12
|
-
"compact",
|
|
13
|
-
"reload",
|
|
14
|
-
"plan",
|
|
15
|
-
"exit",
|
|
16
|
-
"quit",
|
|
17
|
-
];
|
|
18
|
-
const BUILTINS = new Set(BUILTIN_SLASH_COMMANDS);
|
|
19
|
-
/** Is `name` (no leading slash) a reserved builtin? */
|
|
20
|
-
export function isBuiltinSlash(name) {
|
|
21
|
-
return BUILTINS.has(name);
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Resolve one input line. Non-slash input and unknown names → `none`. Builtins
|
|
25
|
-
* short-circuit to `builtin` BEFORE the custom catalog is consulted, so a custom
|
|
26
|
-
* command named after a builtin is inert (surfaced separately at load time).
|
|
3
|
+
* Resolve one input line. Non-slash input and unknown names → `none`. Reserved
|
|
4
|
+
* names short-circuit to `builtin` BEFORE the custom catalog is consulted.
|
|
5
|
+
*
|
|
6
|
+
* This is now belt-and-braces rather than the only guard: the loader refuses a
|
|
7
|
+
* command file whose name is reserved, so the colliding spec should never reach
|
|
8
|
+
* this catalogue at all. It stays because a caller may assemble a spec list by
|
|
9
|
+
* some other route, and the failure mode this prevents — a project-authored
|
|
10
|
+
* `shell` command answering to `/clear` — is not one to leave to a single check.
|
|
27
11
|
*/
|
|
28
12
|
export function resolveSlash(line, commands) {
|
|
29
13
|
const trimmed = line.trim();
|
|
@@ -34,7 +18,7 @@ export function resolveSlash(line, commands) {
|
|
|
34
18
|
const args = space === -1 ? "" : trimmed.slice(space + 1).trim();
|
|
35
19
|
if (name === "")
|
|
36
20
|
return { kind: "none" };
|
|
37
|
-
if (
|
|
21
|
+
if (isReservedSlash(name))
|
|
38
22
|
return { kind: "builtin", name };
|
|
39
23
|
const spec = commands.find((c) => c.name === name);
|
|
40
24
|
if (!spec)
|
package/dist/lsp/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export * from "./types.js";
|
|
2
|
-
export { DEFAULT_SPECS, EXT_TO_LANGUAGE, LspRegistry, binaryOnPath, languageForFile, } from "./registry.js";
|
|
2
|
+
export { DEFAULT_SPECS, EXT_TO_LANGUAGE, LspRegistry, binaryOnPath, languageForFile, resolveBinary, } from "./registry.js";
|
|
3
3
|
export { StdioTransport, TransportTimeoutError, killTree, } from "./transport.js";
|
|
4
4
|
export { Server } from "./server.js";
|
|
5
5
|
export { LspPool } from "./pool.js";
|
package/dist/lsp/registry.js
CHANGED
|
@@ -58,27 +58,45 @@ export function languageForFile(filePath) {
|
|
|
58
58
|
return EXT_TO_LANGUAGE[ext] ?? null;
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
61
|
+
* Resolve `command` to an executable FILE, the way a shell would, or `null`.
|
|
62
|
+
*
|
|
62
63
|
* An absolute/relative path is tested directly; a bare name is searched across
|
|
63
|
-
* `PATH
|
|
64
|
-
*
|
|
64
|
+
* `PATH`. On win32 each candidate is also tried with every `PATHEXT` suffix,
|
|
65
|
+
* which is the only way `pnpm` finds `pnpm.cmd` — Windows installs almost every
|
|
66
|
+
* npm-shipped tool as a `.cmd` shim, and a lookup that tries the bare name alone
|
|
67
|
+
* reports a globally installed binary as absent.
|
|
68
|
+
*
|
|
69
|
+
* Pure filesystem probing — never spawns. THE ONLY PATH RESOLVER in the CLI
|
|
70
|
+
* besides `tools/shell/resolve-shell.ts`'s win32 shell hunt, deliberately: a
|
|
71
|
+
* third one would be a third place for the PATHEXT rule to be forgotten.
|
|
65
72
|
*/
|
|
66
|
-
export
|
|
73
|
+
export function resolveBinary(command, dirs = (process.env.PATH ?? "")
|
|
74
|
+
.split(path.delimiter)
|
|
75
|
+
.filter(Boolean)) {
|
|
76
|
+
// An explicit path names the file outright — nothing to search.
|
|
67
77
|
if (command.includes(path.sep) || command.includes("/")) {
|
|
68
|
-
return isExecutable(command);
|
|
78
|
+
return isExecutable(command) ? command : null;
|
|
69
79
|
}
|
|
70
|
-
|
|
80
|
+
// PATHEXT only, never a bare `command` fallback on win32: a directory that
|
|
81
|
+
// holds `pnpm.cmd` usually holds an extensionless `pnpm` beside it (the sh
|
|
82
|
+
// shim npm installs for Git Bash), and that one cannot be spawned there.
|
|
71
83
|
const exts = process.platform === "win32"
|
|
72
84
|
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
|
|
73
85
|
: [""];
|
|
74
86
|
for (const dir of dirs) {
|
|
75
87
|
for (const ext of exts) {
|
|
76
|
-
|
|
77
|
-
|
|
88
|
+
const candidate = path.join(dir, command + ext);
|
|
89
|
+
if (isExecutable(candidate))
|
|
90
|
+
return candidate;
|
|
78
91
|
}
|
|
79
92
|
}
|
|
80
|
-
return
|
|
81
|
-
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Default binary-presence check — {@link resolveBinary} reduced to a yes/no.
|
|
97
|
+
* Injectable via {@link LspRegistry} so tests can force present/absent.
|
|
98
|
+
*/
|
|
99
|
+
export const binaryOnPath = (command) => resolveBinary(command) !== null;
|
|
82
100
|
function isExecutable(candidate) {
|
|
83
101
|
try {
|
|
84
102
|
const stat = fs.statSync(candidate);
|
package/dist/memory/secrets.js
CHANGED
|
@@ -59,3 +59,46 @@ export function containsSecret(text) {
|
|
|
59
59
|
}
|
|
60
60
|
return { secret: false };
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* The global twins of {@link SECRET_PATTERNS}, built once.
|
|
64
|
+
*
|
|
65
|
+
* Detection asks "is there one?" and can stop at the first hit; redaction has to
|
|
66
|
+
* replace EVERY occurrence, which needs the `g` flag — and a `g` regex carries
|
|
67
|
+
* `lastIndex` state that would make `containsSecret` return alternating answers
|
|
68
|
+
* for the same input. So the two uses get their own objects rather than sharing
|
|
69
|
+
* one and remembering to reset it.
|
|
70
|
+
*/
|
|
71
|
+
const GLOBAL_PATTERNS = SECRET_PATTERNS.map(({ kind, re }) => ({ kind, re: new RegExp(re.source, `${re.flags}g`) }));
|
|
72
|
+
/**
|
|
73
|
+
* Replace every recognised secret in `text` with an opaque marker (P10 track 5).
|
|
74
|
+
*
|
|
75
|
+
* THE SAME DENYLIST as detection, deliberately — a `/redact` that used a
|
|
76
|
+
* different pattern set from the one that refuses a memory write would give two
|
|
77
|
+
* different answers about what counts as a secret, and the weaker of the two
|
|
78
|
+
* would be the one a user found out about.
|
|
79
|
+
*
|
|
80
|
+
* The marker is fixed text with no quotes, backslashes or control characters, so
|
|
81
|
+
* a redacted string survives being embedded in JSON unchanged — which is what
|
|
82
|
+
* lets a `tool_use` input be redacted by round-tripping through its serialised
|
|
83
|
+
* form rather than by walking an arbitrary shape.
|
|
84
|
+
*
|
|
85
|
+
* Everything the pattern matched goes, including a `api_key =` prefix on the
|
|
86
|
+
* generic assignment rule. Keeping the field name would read better and would
|
|
87
|
+
* leak the shape of the thing next to a marker announcing that something was
|
|
88
|
+
* there; the marker's `kind` already says as much as is safe to say.
|
|
89
|
+
*/
|
|
90
|
+
export function redactSecrets(text) {
|
|
91
|
+
const kinds = [];
|
|
92
|
+
let count = 0;
|
|
93
|
+
let out = text;
|
|
94
|
+
for (const { kind, re } of GLOBAL_PATTERNS) {
|
|
95
|
+
re.lastIndex = 0;
|
|
96
|
+
out = out.replace(re, () => {
|
|
97
|
+
count++;
|
|
98
|
+
if (!kinds.includes(kind))
|
|
99
|
+
kinds.push(kind);
|
|
100
|
+
return `[redacted ${kind}]`;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { text: out, kinds, count };
|
|
104
|
+
}
|
package/dist/plan/service.js
CHANGED
|
@@ -10,6 +10,9 @@ import { makeSubmitPlanTool } from "./submit-plan.js";
|
|
|
10
10
|
* read-only + `submit_plan` registry so the agent cannot act before approval;
|
|
11
11
|
* execution runs with the full registry, one step at a time, per-action U.3
|
|
12
12
|
* gating intact.
|
|
13
|
+
*
|
|
14
|
+
* A propose phase that ends with NO plan is a completed conversational turn, not
|
|
15
|
+
* an error — see the `!holder.plan` branch below.
|
|
13
16
|
*/
|
|
14
17
|
/** Default cap on plan revisions before failing loud. */
|
|
15
18
|
export const MAX_PLAN_REVISIONS = 3;
|
|
@@ -81,9 +84,31 @@ export async function runPlanSession(args) {
|
|
|
81
84
|
onRequestUsage: args.onRequestUsage,
|
|
82
85
|
}));
|
|
83
86
|
if (!holder.plan) {
|
|
84
|
-
|
|
87
|
+
// NO PLAN IS NOT A FAILURE. The propose phase's registry is read-only plus
|
|
88
|
+
// `submit_plan`, so a turn that ends without a plan has structurally not
|
|
89
|
+
// ACTED — it has ANSWERED. "what does this function do?" is a perfectly
|
|
90
|
+
// ordinary thing to type while a planning mode is on, and the model
|
|
91
|
+
// replying to it is the correct outcome, not a malformed plan.
|
|
92
|
+
//
|
|
93
|
+
// Throwing here was also silently LOSING that reply. The session records
|
|
94
|
+
// the user's turn before the runner is called and adopts the returned
|
|
95
|
+
// history after it returns, so an exception left the question on disk with
|
|
96
|
+
// no answer beside it — a transcript that is wrong, not merely unhelpful.
|
|
97
|
+
// Returning the accumulated history keeps the answer in both.
|
|
98
|
+
//
|
|
99
|
+
// This holds on a revision pass too: a model that answers a piece of
|
|
100
|
+
// feedback with a question instead of a new plan is asking the user
|
|
101
|
+
// something, and the user can now answer it on the next turn.
|
|
102
|
+
return finish();
|
|
85
103
|
}
|
|
86
104
|
const plan = holder.plan;
|
|
105
|
+
// The one thing that IS invalid: a plan object with nothing executable in
|
|
106
|
+
// it. `submit_plan` rejects this to the model's face, so reaching it means
|
|
107
|
+
// something set the holder directly — fail loud rather than run an empty
|
|
108
|
+
// approval prompt over zero steps.
|
|
109
|
+
if (plan.steps.length === 0) {
|
|
110
|
+
throw planInvalid("submit_plan produced a plan with no steps");
|
|
111
|
+
}
|
|
87
112
|
const decision = await promptPlanDecision(plan, args.io);
|
|
88
113
|
if (decision.kind === "abort") {
|
|
89
114
|
// Committed output goes through the renderer, not the prompt io (P3):
|
package/dist/plan/submit-plan.js
CHANGED
|
@@ -38,6 +38,17 @@ export function makeSubmitPlanTool(holder) {
|
|
|
38
38
|
if (input.steps.length === 0) {
|
|
39
39
|
return { ok: false, error: "a plan must have at least one step" };
|
|
40
40
|
}
|
|
41
|
+
// `min(1)` counts characters, so a title of `" "` clears zod and then
|
|
42
|
+
// trims to nothing — a step that renders as an empty row in the approval
|
|
43
|
+
// prompt and as an empty instruction during execution. Rejected the same
|
|
44
|
+
// way as any other tool-input error, so the model resubmits.
|
|
45
|
+
const blank = input.steps.findIndex((s) => s.title.trim() === "" || s.rationale.trim() === "");
|
|
46
|
+
if (blank !== -1) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
error: `step ${blank + 1} has a blank title or rationale — every step needs both`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
41
52
|
const steps = input.steps.map((s, i) => ({
|
|
42
53
|
id: String(i + 1),
|
|
43
54
|
title: s.title.trim(),
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { shouldUseColor } from "../errors/index.js";
|
|
2
2
|
import { detectScreenReader, detectUnicode } from "../theme/index.js";
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Set-and-non-empty (the NO_COLOR convention): any non-empty value counts.
|
|
5
|
+
*
|
|
6
|
+
* Exported so every cruxy env flag reads the SAME rule. The README states it
|
|
7
|
+
* once for all of them — `CRUXY_ASCII=false` enables ASCII — and a flag that
|
|
8
|
+
* quietly parsed its value instead would make that documentation wrong for one
|
|
9
|
+
* variable with nothing to point at.
|
|
10
|
+
*/
|
|
11
|
+
export function isSet(value) {
|
|
5
12
|
return value !== undefined && value !== "";
|
|
6
13
|
}
|
|
7
14
|
/** Fallback width when the terminal reports none (non-TTY, pipe, unknown). */
|
|
@@ -51,8 +51,8 @@ export function contextReportLines(report, t, width = Infinity) {
|
|
|
51
51
|
// The headline, worded exactly as the panel words it — same estimate, same
|
|
52
52
|
// caveats, so the detail view can never read as the more authoritative one.
|
|
53
53
|
lines.push(`${t.strong(`${approx(reading.used)} / ${formatTokens(reading.total)} budget`)} ` +
|
|
54
|
-
t.muted(`(estimated
|
|
55
|
-
lines.push(t.muted(`${report.messages} message${report.messages === 1 ? "" : "s"}
|
|
54
|
+
t.muted(`(estimated${t.sep}budget is a local setting, not the model's window)`));
|
|
55
|
+
lines.push(t.muted(`${report.messages} message${report.messages === 1 ? "" : "s"}${t.sep}compacts above ${approx(reading.compactAt)}`));
|
|
56
56
|
// ── where the tokens are ──────────────────────────────────────────────────
|
|
57
57
|
const historyTokens = report.parts.reduce((sum, p) => sum + p.tokens, 0);
|
|
58
58
|
lines.push("");
|
package/dist/render/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { TtyRenderer } from "./tty-renderer.js";
|
|
|
8
8
|
import { TuiRenderer } from "../tui/renderer.js";
|
|
9
9
|
import { GitStatusCache } from "../tui/git-status.js";
|
|
10
10
|
import { ToolVersions } from "../tui/tool-versions.js";
|
|
11
|
-
import { supportsTui } from "../tui/supports.js";
|
|
11
|
+
import { supportsTui, usesAltScreen } from "../tui/supports.js";
|
|
12
12
|
export { detectCapabilities, detectReducedMotion, resolveColumns, resolveRows, DEFAULT_COLUMNS, DEFAULT_ROWS, } from "./capabilities.js";
|
|
13
13
|
export { attachResize, processResizeSignal, } from "./resize.js";
|
|
14
14
|
export { fit, fitMiddle, reflow, stripAnsi, visibleWidth, kvStack, MIN_VALUE_COLS, } from "./layout.js";
|
|
@@ -53,6 +53,11 @@ export function createRenderer(out = process.stdout, err = process.stderr, env =
|
|
|
53
53
|
// Constructed, not started: `ToolVersions` probes nothing until the
|
|
54
54
|
// renderer paints the panel, so this costs an object and no subprocess.
|
|
55
55
|
tools: new ToolVersions(),
|
|
56
|
+
// Resolved against THIS factory's env (Q4 track 3), not `process.env`.
|
|
57
|
+
// The renderer's own default reads the real environment, which is right
|
|
58
|
+
// for a direct construction and wrong here: an injected env exists
|
|
59
|
+
// precisely so a caller can describe a terminal that is not this one.
|
|
60
|
+
altScreen: usesAltScreen(caps, env),
|
|
56
61
|
});
|
|
57
62
|
}
|
|
58
63
|
return caps.cursor
|
package/dist/render/plan-view.js
CHANGED
|
@@ -61,7 +61,7 @@ export function planChecklist(steps, t, maxRows = Infinity, width = Infinity) {
|
|
|
61
61
|
return [];
|
|
62
62
|
const done = steps.filter((s) => s.status === "done").length;
|
|
63
63
|
const failed = steps.filter((s) => s.status === "failed").length;
|
|
64
|
-
const header = t.strong(`plan ${done}/${steps.length}${failed > 0 ? t.danger(
|
|
64
|
+
const header = t.strong(`plan ${done}/${steps.length}${failed > 0 ? t.danger(`${t.sep}${failed} failed`) : ""}`);
|
|
65
65
|
const finish = (lines) => Number.isFinite(width)
|
|
66
66
|
? lines.map((l) => fit(l, width, t.glyph.ellipsis))
|
|
67
67
|
: lines;
|
|
@@ -40,7 +40,7 @@ function groupLocations(locations) {
|
|
|
40
40
|
*/
|
|
41
41
|
function diskLines(locations, t) {
|
|
42
42
|
return groupLocations(locations).map((group, i) => {
|
|
43
|
-
const text = formatCapacity(group.capacity);
|
|
43
|
+
const text = formatCapacity(group.capacity, t.glyph.sep);
|
|
44
44
|
const level = capacityLevel(group.capacity);
|
|
45
45
|
const value = level === "critical"
|
|
46
46
|
? t.danger(text)
|
|
@@ -53,7 +53,7 @@ function diskLines(locations, t) {
|
|
|
53
53
|
/** The full `/status` block as lines to print. */
|
|
54
54
|
export function sessionStatusLines(status, t, width = Infinity) {
|
|
55
55
|
const lines = [t.heading("status")];
|
|
56
|
-
lines.push(row("session", `${status.sessionId.slice(0, 8)} ${t.muted(
|
|
56
|
+
lines.push(row("session", `${status.sessionId.slice(0, 8)} ${t.muted(`${t.glyph.sep} ${status.turns} turn${status.turns === 1 ? "" : "s"}`)}`, t));
|
|
57
57
|
// The mode leads the safety half, and carries its description rather than its
|
|
58
58
|
// name alone. The objection that removed the auto-approve config flag was that
|
|
59
59
|
// it disarmed the gate with nothing on screen saying so; a status screen
|
|
@@ -68,7 +68,7 @@ export function sessionStatusLines(status, t, width = Infinity) {
|
|
|
68
68
|
if (status.context) {
|
|
69
69
|
const { used, total, compactAt } = status.context;
|
|
70
70
|
lines.push(row("context", `${t.strong(`~${formatTokens(used)} / ${formatTokens(total)} budget`)} ` +
|
|
71
|
-
t.muted(
|
|
71
|
+
t.muted(`${t.glyph.sep} compacts above ~${formatTokens(compactAt)}`), t));
|
|
72
72
|
}
|
|
73
73
|
// A sandbox that is ON but whose runtime we cannot name is reported as on
|
|
74
74
|
// WITHOUT a name, rather than omitted — the safety-relevant half is that it
|
|
@@ -89,9 +89,9 @@ export function sessionStatusLines(status, t, width = Infinity) {
|
|
|
89
89
|
const { total, running, needingApproval } = status.jobs;
|
|
90
90
|
const detail = total === 0
|
|
91
91
|
? t.muted("none this session")
|
|
92
|
-
: `${total}
|
|
92
|
+
: `${total}${t.sep}${running} running` +
|
|
93
93
|
(needingApproval > 0
|
|
94
|
-
? t.warning(
|
|
94
|
+
? t.warning(`${t.sep}${needingApproval} awaiting approval`)
|
|
95
95
|
: "");
|
|
96
96
|
lines.push(row("jobs", detail, t));
|
|
97
97
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display units shared by every surface that shows a token figure (P10 track 3).
|
|
3
|
+
*
|
|
4
|
+
* `compactTokens` lived in `tui/limits-panel.ts` while it was the rail's alone.
|
|
5
|
+
* `/budget` states the same quantity — weighted tokens — against the same server
|
|
6
|
+
* windows, and the two must not round differently: a panel reading `8.5M` beside
|
|
7
|
+
* a command reading `8500000` is one fact wearing two faces.
|
|
8
|
+
*/
|
|
9
|
+
/** 14_000_000 → "14M", 8_700_000 → "8.7M", 125_000 → "125k", 900 → "900". */
|
|
10
|
+
export function compactTokens(n) {
|
|
11
|
+
const abs = Math.abs(n);
|
|
12
|
+
if (abs >= 1_000_000)
|
|
13
|
+
return `${trimZero(n / 1_000_000)}M`;
|
|
14
|
+
if (abs >= 1_000)
|
|
15
|
+
return `${trimZero(n / 1_000)}k`;
|
|
16
|
+
return `${Math.round(n)}`;
|
|
17
|
+
}
|
|
18
|
+
/** One decimal, but only when it says something: 8.7 stays, 14.0 becomes 14. */
|
|
19
|
+
function trimZero(n) {
|
|
20
|
+
const one = n.toFixed(1);
|
|
21
|
+
return one.endsWith(".0") ? one.slice(0, -2) : one;
|
|
22
|
+
}
|
package/dist/session/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { PROJECTS_DIR_NAME, RESERVED_SUBDIRS, SESSION_FILE_EXT, projectDir, proj
|
|
|
15
15
|
export { SessionLog } from "./log.js";
|
|
16
16
|
export { defaultExportName, exportMarkdown, } from "./export.js";
|
|
17
17
|
export { foldEvents, readEvents, readMeta, replaySession } from "./replay.js";
|
|
18
|
+
export { redactMessages } from "./redact.js";
|
|
18
19
|
export { findSession, isAmbiguous, listSessions, summarizeSession, } from "./list.js";
|
|
19
20
|
export { cwdMismatchWarning, describeSession, loadResume, relativeAge, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
|
|
20
21
|
export { SESSION_FILE_VERSION, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
package/dist/session/log.js
CHANGED
|
@@ -92,6 +92,25 @@ export class SessionLog {
|
|
|
92
92
|
outputTokens,
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* `/redact` (P10 track 5): from here on, replay masks every recognised secret
|
|
97
|
+
* in the history recorded SO FAR.
|
|
98
|
+
*
|
|
99
|
+
* The one writer in this class that changes how EARLIER lines are read. It
|
|
100
|
+
* does not touch them — see the class header on why this file is only ever
|
|
101
|
+
* appended to — and it deliberately records no secret material: `kinds` and
|
|
102
|
+
* `count` say what was found, which is all a reader could already work out by
|
|
103
|
+
* running the same detector.
|
|
104
|
+
*/
|
|
105
|
+
redact(kinds, count) {
|
|
106
|
+
this.write({
|
|
107
|
+
kind: "redact",
|
|
108
|
+
at: new Date().toISOString(),
|
|
109
|
+
...this.runId(),
|
|
110
|
+
kinds,
|
|
111
|
+
count,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
95
114
|
runId() {
|
|
96
115
|
const id = this.currentRunId?.();
|
|
97
116
|
return id === undefined ? {} : { runId: id };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { redactSecrets } from "../memory/secrets.js";
|
|
2
|
+
/**
|
|
3
|
+
* Mask every recognised secret across a whole message array.
|
|
4
|
+
*
|
|
5
|
+
* Returns a NEW array (and new blocks) rather than mutating: the caller's array
|
|
6
|
+
* is the one the recorder's watermark is measured against, and replacing it
|
|
7
|
+
* wholesale is what `Session` already does after every turn.
|
|
8
|
+
*/
|
|
9
|
+
export function redactMessages(messages) {
|
|
10
|
+
const kinds = [];
|
|
11
|
+
let count = 0;
|
|
12
|
+
const scrub = (text) => {
|
|
13
|
+
const r = redactSecrets(text);
|
|
14
|
+
count += r.count;
|
|
15
|
+
for (const k of r.kinds)
|
|
16
|
+
if (!kinds.includes(k))
|
|
17
|
+
kinds.push(k);
|
|
18
|
+
return r.text;
|
|
19
|
+
};
|
|
20
|
+
const out = messages.map((message) => {
|
|
21
|
+
if (typeof message.content === "string") {
|
|
22
|
+
return { ...message, content: scrub(message.content) };
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
...message,
|
|
26
|
+
content: message.content.map((block) => scrubBlock(block, scrub)),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
return { messages: out, kinds, count };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One content block.
|
|
33
|
+
*
|
|
34
|
+
* `tool_use.input` is arbitrary JSON and is where the highest-risk material
|
|
35
|
+
* actually lands — a `run_command` whose argv carries a token, an HTTP tool with
|
|
36
|
+
* an auth header. It is redacted by round-tripping through its serialised form
|
|
37
|
+
* rather than by walking an unknown shape: the marker contains no character JSON
|
|
38
|
+
* escapes, so the string stays parseable, and a parse failure (which should be
|
|
39
|
+
* impossible) leaves the block untouched rather than corrupting a history the
|
|
40
|
+
* provider has to accept on the next turn.
|
|
41
|
+
*
|
|
42
|
+
* An unknown block kind is passed through unchanged. A newer cruxy's block must
|
|
43
|
+
* survive an older one reading the file (`ContentBlockSchema` says so), and
|
|
44
|
+
* inventing a redaction for a shape this build cannot interpret would be the
|
|
45
|
+
* lossy behaviour that rule exists to prevent.
|
|
46
|
+
*/
|
|
47
|
+
function scrubBlock(block, scrub) {
|
|
48
|
+
if (block.type === "text")
|
|
49
|
+
return { ...block, text: scrub(block.text) };
|
|
50
|
+
if (block.type === "tool_result") {
|
|
51
|
+
return { ...block, content: scrub(block.content) };
|
|
52
|
+
}
|
|
53
|
+
if (block.type === "tool_use") {
|
|
54
|
+
if (block.input === undefined)
|
|
55
|
+
return block;
|
|
56
|
+
let serialised;
|
|
57
|
+
try {
|
|
58
|
+
serialised = JSON.stringify(block.input);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return block; // not serialisable — leave it exactly as it was
|
|
62
|
+
}
|
|
63
|
+
const scrubbed = scrub(serialised);
|
|
64
|
+
if (scrubbed === serialised)
|
|
65
|
+
return block;
|
|
66
|
+
try {
|
|
67
|
+
return { ...block, input: JSON.parse(scrubbed) };
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return block;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return block;
|
|
74
|
+
}
|
package/dist/session/replay.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { modeFromFlags } from "../agent/mode.js";
|
|
3
|
+
import { redactMessages } from "./redact.js";
|
|
3
4
|
import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
4
5
|
/**
|
|
5
6
|
* Replay: fold an append-only event log back into the state a session needs to
|
|
@@ -74,6 +75,7 @@ export function foldEvents(events, skipped = 0) {
|
|
|
74
75
|
// timestamp.
|
|
75
76
|
let planMode = false;
|
|
76
77
|
let mode = null;
|
|
78
|
+
let redactions = 0;
|
|
77
79
|
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
78
80
|
for (const event of events) {
|
|
79
81
|
switch (event.kind) {
|
|
@@ -101,6 +103,19 @@ export function foldEvents(events, skipped = 0) {
|
|
|
101
103
|
usage.input_tokens += event.inputTokens;
|
|
102
104
|
usage.output_tokens += event.outputTokens;
|
|
103
105
|
break;
|
|
106
|
+
case "redact":
|
|
107
|
+
// Exactly what `Session.redact` did in memory: mask every recognised
|
|
108
|
+
// secret across the history AS IT STANDS. Position in the fold is the
|
|
109
|
+
// whole meaning of the event — messages appended afterwards are not
|
|
110
|
+
// covered, because at the moment the user asked, they did not exist.
|
|
111
|
+
//
|
|
112
|
+
// The event's own `kinds`/`count` are deliberately not consulted: they
|
|
113
|
+
// record what the writing build found, and this build's denylist may
|
|
114
|
+
// have grown since. Re-running the detector is what makes a redaction
|
|
115
|
+
// get stronger over time rather than being frozen at write time.
|
|
116
|
+
messages = redactMessages(messages).messages;
|
|
117
|
+
redactions++;
|
|
118
|
+
break;
|
|
104
119
|
case "meta":
|
|
105
120
|
break;
|
|
106
121
|
}
|
|
@@ -114,6 +129,7 @@ export function foldEvents(events, skipped = 0) {
|
|
|
114
129
|
// could be on, so nothing is being inferred.
|
|
115
130
|
mode: mode ?? modeFromFlags(planMode, false),
|
|
116
131
|
skipped,
|
|
132
|
+
redactions,
|
|
117
133
|
};
|
|
118
134
|
}
|
|
119
135
|
/** Read and parse a session file into its events, counting unusable lines. */
|
package/dist/session/resume.js
CHANGED
|
@@ -71,6 +71,14 @@ export function loadResume(session, cwd) {
|
|
|
71
71
|
warnings.push(`${state.skipped} unreadable line${state.skipped === 1 ? "" : "s"} in the session log were skipped — ` +
|
|
72
72
|
`the restored history may be incomplete`);
|
|
73
73
|
}
|
|
74
|
+
if (state.redactions > 0) {
|
|
75
|
+
// Said on resume because the alternative is a user finding `[redacted …]`
|
|
76
|
+
// in a transcript and not knowing whether cruxy did it or the model wrote
|
|
77
|
+
// it. The second clause is the part that is easy to leave out and matters
|
|
78
|
+
// most: `/redact` never rewrote the file it is being replayed from.
|
|
79
|
+
warnings.push(`this session was redacted ${state.redactions === 1 ? "once" : `${state.redactions} times`} — ` +
|
|
80
|
+
`secrets are masked in the restored history, but the original text is still in ${session.file}`);
|
|
81
|
+
}
|
|
74
82
|
return { session, state, warnings };
|
|
75
83
|
}
|
|
76
84
|
/**
|
package/dist/session/types.js
CHANGED
|
@@ -211,6 +211,43 @@ export const UsageEventSchema = z
|
|
|
211
211
|
outputTokens: z.number().int().nonnegative(),
|
|
212
212
|
})
|
|
213
213
|
.passthrough();
|
|
214
|
+
/**
|
|
215
|
+
* `/redact` (P10 track 5): every recognised secret in the history SO FAR is
|
|
216
|
+
* masked from this point in the fold onwards.
|
|
217
|
+
*
|
|
218
|
+
* A NEW EVENT RATHER THAN A REWRITE, and that is the entire design. Editing the
|
|
219
|
+
* earlier lines would mean re-serialising the file, which costs the two
|
|
220
|
+
* properties this format exists for — O(1) writes per turn, and a crash costing
|
|
221
|
+
* a torn last line instead of a conversation (see the module header, and
|
|
222
|
+
* `log.ts` on why temp-then-rename is wrong here). So a redaction changes how
|
|
223
|
+
* earlier lines are READ, and the earlier lines stay exactly as they were.
|
|
224
|
+
*
|
|
225
|
+
* THE EVENT CARRIES NO SECRET, deliberately: recording the strings to remove
|
|
226
|
+
* would write them into the log in order to say they should not be there. The
|
|
227
|
+
* fold re-derives the spans by re-running the detector, which is deterministic
|
|
228
|
+
* over the same messages and gives a reader nothing it could not already see.
|
|
229
|
+
*
|
|
230
|
+
* `kinds` and `count` are what the pass FOUND — a record for the user, not an
|
|
231
|
+
* instruction to the fold. The fold ignores them; a build whose denylist has
|
|
232
|
+
* since grown will legitimately mask more than the number written here, and
|
|
233
|
+
* treating the count as authoritative would cap it at what an older build knew.
|
|
234
|
+
*
|
|
235
|
+
* An older cruxy that has never heard of this kind SKIPS the line (`replay.ts`)
|
|
236
|
+
* and shows the unredacted history. That is the honest failure for a
|
|
237
|
+
* forward-compatibility rule that must not lose conversations, and it is why the
|
|
238
|
+
* command says the file itself still holds the raw text.
|
|
239
|
+
*/
|
|
240
|
+
export const RedactEventSchema = z
|
|
241
|
+
.object({
|
|
242
|
+
kind: z.literal("redact"),
|
|
243
|
+
at: z.string(),
|
|
244
|
+
runId: z.string().optional(),
|
|
245
|
+
/** Secret kinds the pass matched, for the record. Not read by the fold. */
|
|
246
|
+
kinds: z.array(z.string()).default([]),
|
|
247
|
+
/** Spans replaced at write time. Not read by the fold. */
|
|
248
|
+
count: z.number().int().nonnegative().default(0),
|
|
249
|
+
})
|
|
250
|
+
.passthrough();
|
|
214
251
|
/** Every event, discriminated on `kind`. */
|
|
215
252
|
export const SessionEventSchema = z.discriminatedUnion("kind", [
|
|
216
253
|
SessionMetaSchema,
|
|
@@ -220,4 +257,5 @@ export const SessionEventSchema = z.discriminatedUnion("kind", [
|
|
|
220
257
|
PlanModeEventSchema,
|
|
221
258
|
SessionModeEventSchema,
|
|
222
259
|
UsageEventSchema,
|
|
260
|
+
RedactEventSchema,
|
|
223
261
|
]);
|