@cruxy/cli 0.8.0 → 0.9.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 +40 -13
- package/dist/agent/loop.d.ts +28 -1
- package/dist/agent/loop.js +36 -4
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +8 -0
- package/dist/approval/classify.js +26 -0
- package/dist/checkpoint/capture.d.ts +17 -0
- package/dist/checkpoint/capture.js +73 -0
- package/dist/checkpoint/git-store.d.ts +61 -0
- package/dist/checkpoint/git-store.js +171 -0
- package/dist/checkpoint/index.d.ts +6 -0
- package/dist/checkpoint/index.js +6 -0
- package/dist/checkpoint/restore.d.ts +23 -0
- package/dist/checkpoint/restore.js +195 -0
- package/dist/checkpoint/service.d.ts +80 -0
- package/dist/checkpoint/service.js +276 -0
- package/dist/checkpoint/shadow-store.d.ts +23 -0
- package/dist/checkpoint/shadow-store.js +93 -0
- package/dist/checkpoint/types.d.ts +117 -0
- package/dist/checkpoint/types.js +18 -0
- package/dist/cli/commands/checkpoint.d.ts +7 -0
- package/dist/cli/commands/checkpoint.js +31 -0
- package/dist/cli/commands/rollback.d.ts +10 -0
- package/dist/cli/commands/rollback.js +51 -0
- package/dist/cli/commands/run.js +10 -2
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.d.ts +2 -1
- package/dist/cli/repl.js +6 -3
- package/dist/cli/session-factory.d.ts +14 -1
- package/dist/cli/session-factory.js +87 -22
- package/dist/config/schema.d.ts +133 -0
- package/dist/config/schema.js +40 -0
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +86 -0
- package/dist/errors/types.d.ts +7 -0
- package/dist/errors/types.js +16 -0
- package/dist/indexing/walker.d.ts +11 -0
- package/dist/indexing/walker.js +11 -6
- package/dist/plan/execute.d.ts +8 -0
- package/dist/plan/execute.js +36 -22
- package/dist/plan/service.js +5 -1
- package/dist/plan/submit-plan.d.ts +4 -4
- package/dist/render/diff.js +27 -0
- package/dist/render/index.d.ts +2 -1
- package/dist/render/index.js +1 -0
- package/dist/render/plain-renderer.d.ts +7 -1
- package/dist/render/plain-renderer.js +26 -0
- package/dist/render/state.d.ts +31 -0
- package/dist/render/state.js +83 -0
- package/dist/render/tty-renderer.d.ts +41 -5
- package/dist/render/tty-renderer.js +150 -23
- package/dist/render/types.d.ts +85 -1
- package/dist/subagent/budget.d.ts +34 -0
- package/dist/subagent/budget.js +57 -0
- package/dist/subagent/index.d.ts +5 -0
- package/dist/subagent/index.js +5 -0
- package/dist/subagent/orchestrator.d.ts +67 -0
- package/dist/subagent/orchestrator.js +241 -0
- package/dist/subagent/registry-scope.d.ts +28 -0
- package/dist/subagent/registry-scope.js +63 -0
- package/dist/subagent/spawn-tool.d.ts +29 -0
- package/dist/subagent/spawn-tool.js +94 -0
- package/dist/subagent/types.d.ts +55 -0
- package/dist/subagent/types.js +1 -0
- package/dist/tools/types.d.ts +20 -2
- package/package.json +1 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working-tree checkpoints (C.32): a complete snapshot of everything the agent
|
|
3
|
+
* could touch, captured before a run's first mutation, so `cruxy rollback` can
|
|
4
|
+
* undo the entire run — creates, edits, deletes — in one gated operation.
|
|
5
|
+
*
|
|
6
|
+
* Two layers, deliberately separated:
|
|
7
|
+
* • {@link CheckpointStore} — the *content substrate* (where file bytes
|
|
8
|
+
* live), swappable like VectorStore: git-object-backed in a repo, a
|
|
9
|
+
* content-addressed shadow copy otherwise.
|
|
10
|
+
* • The manifest — uniform JSON under `.cruxy/checkpoints/`, owned by the
|
|
11
|
+
* service regardless of substrate, so list/prune/rollback never care which
|
|
12
|
+
* store wrote a checkpoint.
|
|
13
|
+
*
|
|
14
|
+
* Explicit boundary: checkpoints cover WORKING-TREE file state only. They can
|
|
15
|
+
* never undo git commits, pushes, or PRs made during a run (C.15) — that is
|
|
16
|
+
* stated in the rollback preview, not silently implied.
|
|
17
|
+
*/
|
|
18
|
+
/** A file selected for capture: project-relative POSIX path + where it is on disk. */
|
|
19
|
+
export interface CaptureFile {
|
|
20
|
+
path: string;
|
|
21
|
+
absPath: string;
|
|
22
|
+
}
|
|
23
|
+
/** Which content substrate a checkpoint was written with. */
|
|
24
|
+
export type CheckpointStoreKind = "git" | "shadow";
|
|
25
|
+
/**
|
|
26
|
+
* One captured file in a checkpoint manifest. `oid` is the content address in
|
|
27
|
+
* the store that wrote it: a git blob sha-1 for the git store, a sha-256 for
|
|
28
|
+
* the shadow store. `mode` preserves the executable bit across restore.
|
|
29
|
+
*/
|
|
30
|
+
export interface FileEntry {
|
|
31
|
+
path: string;
|
|
32
|
+
mode: "100644" | "100755";
|
|
33
|
+
oid: string;
|
|
34
|
+
}
|
|
35
|
+
/** A persisted checkpoint: identity, provenance, and the full pre-run manifest. */
|
|
36
|
+
export interface Checkpoint {
|
|
37
|
+
/** Stable id, e.g. `ck-20260703T141530-a4f2`. */
|
|
38
|
+
id: string;
|
|
39
|
+
/** ISO-8601 creation time. */
|
|
40
|
+
createdAt: string;
|
|
41
|
+
/** One line describing the run this checkpoint protects. */
|
|
42
|
+
runSummary: string;
|
|
43
|
+
store: CheckpointStoreKind;
|
|
44
|
+
/** Every file that existed (and was capturable) before the run's first mutation. */
|
|
45
|
+
files: FileEntry[];
|
|
46
|
+
/**
|
|
47
|
+
* Project-relative paths the tracked run mutated through file tools, recorded
|
|
48
|
+
* as the run proceeds. At rollback time, a difference on a path NOT in this
|
|
49
|
+
* list is an *external* change — surfaced loudly in the preview.
|
|
50
|
+
*/
|
|
51
|
+
touchedPaths: string[];
|
|
52
|
+
/**
|
|
53
|
+
* True when the run executed approved shell commands: a shell command can
|
|
54
|
+
* touch any path, so per-file attribution becomes impossible and the preview
|
|
55
|
+
* says so instead of guessing.
|
|
56
|
+
*/
|
|
57
|
+
hasShellMutations: boolean;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The content substrate seam. Implementations persist and retrieve file
|
|
61
|
+
* *contents* only — manifests are the service's job.
|
|
62
|
+
*
|
|
63
|
+
* THE invariant (git store): snapshot/read must never disturb user-visible git
|
|
64
|
+
* state — HEAD, the index, the stash, or any ref. The git store self-checks
|
|
65
|
+
* this at runtime and fails loudly on drift.
|
|
66
|
+
*/
|
|
67
|
+
export interface CheckpointStore {
|
|
68
|
+
readonly kind: CheckpointStoreKind;
|
|
69
|
+
/** Persist the current contents of `files`; returns the manifest entries. */
|
|
70
|
+
snapshot(files: CaptureFile[]): Promise<FileEntry[]>;
|
|
71
|
+
/** The content address `snapshot` would give this buffer (for diffing). */
|
|
72
|
+
hashContent(content: Buffer): string;
|
|
73
|
+
/** Read one captured file's bytes back. Throws CRUXY_E_CHECKPOINT_FAILED if gone. */
|
|
74
|
+
readContent(entry: FileEntry): Promise<Buffer>;
|
|
75
|
+
/**
|
|
76
|
+
* Best-effort GC after prune: drop stored content whose oid is no longer
|
|
77
|
+
* referenced by any surviving manifest. The git store is a no-op (dangling
|
|
78
|
+
* objects belong to git's own gc); the shadow store sweeps its object pool.
|
|
79
|
+
*/
|
|
80
|
+
collect(referenced: ReadonlySet<string>): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
/** What rollback will do to one path. */
|
|
83
|
+
export type RollbackAction =
|
|
84
|
+
/** The run deleted it — recreate from the checkpoint. */
|
|
85
|
+
"recreate"
|
|
86
|
+
/** The run edited it — revert content (and mode) to the checkpoint. */
|
|
87
|
+
| "revert"
|
|
88
|
+
/** The run created it — delete it. */
|
|
89
|
+
| "delete";
|
|
90
|
+
export interface RollbackEntry {
|
|
91
|
+
path: string;
|
|
92
|
+
action: RollbackAction;
|
|
93
|
+
/**
|
|
94
|
+
* True when this path changed since the checkpoint but the tracked run never
|
|
95
|
+
* touched it — concurrent user (or other-tool) work that rollback would
|
|
96
|
+
* clobber. Always surfaced in the preview; never overwritten silently.
|
|
97
|
+
*/
|
|
98
|
+
external: boolean;
|
|
99
|
+
/** Checkpoint-side content to restore (recreate/revert; absent for delete). */
|
|
100
|
+
entry?: FileEntry;
|
|
101
|
+
}
|
|
102
|
+
/** The computed diff between the current working tree and a checkpoint. */
|
|
103
|
+
export interface RollbackPlan {
|
|
104
|
+
checkpoint: Checkpoint;
|
|
105
|
+
/** Sorted by path; empty means the tree already matches the checkpoint. */
|
|
106
|
+
entries: RollbackEntry[];
|
|
107
|
+
/** The `external: true` paths, for the preview's warning block. */
|
|
108
|
+
externalPaths: string[];
|
|
109
|
+
/** Mirrors {@link Checkpoint.hasShellMutations}: attribution is unknowable. */
|
|
110
|
+
attributionUnknown: boolean;
|
|
111
|
+
}
|
|
112
|
+
/** Counts of what a successful rollback actually did. */
|
|
113
|
+
export interface RollbackApplied {
|
|
114
|
+
recreated: number;
|
|
115
|
+
reverted: number;
|
|
116
|
+
deleted: number;
|
|
117
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working-tree checkpoints (C.32): a complete snapshot of everything the agent
|
|
3
|
+
* could touch, captured before a run's first mutation, so `cruxy rollback` can
|
|
4
|
+
* undo the entire run — creates, edits, deletes — in one gated operation.
|
|
5
|
+
*
|
|
6
|
+
* Two layers, deliberately separated:
|
|
7
|
+
* • {@link CheckpointStore} — the *content substrate* (where file bytes
|
|
8
|
+
* live), swappable like VectorStore: git-object-backed in a repo, a
|
|
9
|
+
* content-addressed shadow copy otherwise.
|
|
10
|
+
* • The manifest — uniform JSON under `.cruxy/checkpoints/`, owned by the
|
|
11
|
+
* service regardless of substrate, so list/prune/rollback never care which
|
|
12
|
+
* store wrote a checkpoint.
|
|
13
|
+
*
|
|
14
|
+
* Explicit boundary: checkpoints cover WORKING-TREE file state only. They can
|
|
15
|
+
* never undo git commits, pushes, or PRs made during a run (C.15) — that is
|
|
16
|
+
* stated in the rollback preview, not silently implied.
|
|
17
|
+
*/
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `cruxy checkpoint` (C.32) — inspect the working-tree snapshots that back
|
|
4
|
+
* `cruxy rollback`. Creation is automatic (before a run's first mutation);
|
|
5
|
+
* this command only lists.
|
|
6
|
+
*/
|
|
7
|
+
export declare function checkpointCommand(): Command;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { loadConfig } from "../../config/index.js";
|
|
4
|
+
import { CheckpointService } from "../../checkpoint/index.js";
|
|
5
|
+
import { logger } from "../../utils/logger.js";
|
|
6
|
+
/**
|
|
7
|
+
* `cruxy checkpoint` (C.32) — inspect the working-tree snapshots that back
|
|
8
|
+
* `cruxy rollback`. Creation is automatic (before a run's first mutation);
|
|
9
|
+
* this command only lists.
|
|
10
|
+
*/
|
|
11
|
+
export function checkpointCommand() {
|
|
12
|
+
const cmd = new Command("checkpoint").description("working-tree checkpoints — the undo units behind `cruxy rollback`");
|
|
13
|
+
cmd
|
|
14
|
+
.command("list")
|
|
15
|
+
.description("list saved checkpoints, newest first")
|
|
16
|
+
.action(async () => {
|
|
17
|
+
const { config } = loadConfig();
|
|
18
|
+
const service = new CheckpointService({ root: process.cwd(), config });
|
|
19
|
+
const checkpoints = await service.list();
|
|
20
|
+
if (checkpoints.length === 0) {
|
|
21
|
+
logger.print(pc.dim("no checkpoints yet — one is created automatically before an agent run's first file change"));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const c of checkpoints) {
|
|
25
|
+
const files = `${c.files.length} file${c.files.length === 1 ? "" : "s"}`;
|
|
26
|
+
logger.print(`${pc.cyan(c.id)} ${pc.dim(c.createdAt)} ${pc.dim(`[${c.store}]`)} ${files} ${c.runSummary}`);
|
|
27
|
+
}
|
|
28
|
+
logger.print(pc.dim(`\nrestore one with \`cruxy rollback <id>\` (or \`cruxy rollback\` for the newest)`));
|
|
29
|
+
});
|
|
30
|
+
return cmd;
|
|
31
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* `cruxy rollback [id]` (C.32) — restore the working tree to a checkpoint,
|
|
4
|
+
* undoing everything an agent run changed (creates, edits, deletes) in one
|
|
5
|
+
* operation. Destructive by definition, so it is preview-first and gated
|
|
6
|
+
* through U.3 at the destructive tier, ungrantable; non-TTY is refused with a
|
|
7
|
+
* coded error before anything is computed. Out of scope, stated in the
|
|
8
|
+
* preview: commits, pushes, and PRs made during the run are not undone.
|
|
9
|
+
*/
|
|
10
|
+
export declare function rollbackCommand(): Command;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { loadConfig } from "../../config/index.js";
|
|
4
|
+
import { CheckpointService } from "../../checkpoint/index.js";
|
|
5
|
+
import { ApprovalService, defaultPromptIO } from "../../approval/index.js";
|
|
6
|
+
import { rollbackApprovalRequired, shouldUseColor, } from "../../errors/index.js";
|
|
7
|
+
import { logger } from "../../utils/logger.js";
|
|
8
|
+
/**
|
|
9
|
+
* `cruxy rollback [id]` (C.32) — restore the working tree to a checkpoint,
|
|
10
|
+
* undoing everything an agent run changed (creates, edits, deletes) in one
|
|
11
|
+
* operation. Destructive by definition, so it is preview-first and gated
|
|
12
|
+
* through U.3 at the destructive tier, ungrantable; non-TTY is refused with a
|
|
13
|
+
* coded error before anything is computed. Out of scope, stated in the
|
|
14
|
+
* preview: commits, pushes, and PRs made during the run are not undone.
|
|
15
|
+
*/
|
|
16
|
+
export function rollbackCommand() {
|
|
17
|
+
return new Command("rollback")
|
|
18
|
+
.description("restore the working tree to a checkpoint, undoing an agent run's file changes")
|
|
19
|
+
.argument("[id]", "checkpoint id (defaults to the most recent)")
|
|
20
|
+
.action(async (id) => {
|
|
21
|
+
const interactive = Boolean(process.stdin.isTTY);
|
|
22
|
+
// Refuse before touching anything: rollback is a deliberate, interactive
|
|
23
|
+
// act. There is no flag to bypass this, by design.
|
|
24
|
+
if (!interactive)
|
|
25
|
+
throw rollbackApprovalRequired();
|
|
26
|
+
const { config } = loadConfig();
|
|
27
|
+
const root = process.cwd();
|
|
28
|
+
const service = new CheckpointService({ root, config });
|
|
29
|
+
const approval = new ApprovalService({
|
|
30
|
+
cwd: root,
|
|
31
|
+
interactive,
|
|
32
|
+
io: defaultPromptIO(shouldUseColor()),
|
|
33
|
+
});
|
|
34
|
+
const result = await service.rollback(id, {
|
|
35
|
+
requestApproval: (action) => approval.requestApproval(action),
|
|
36
|
+
interactive,
|
|
37
|
+
});
|
|
38
|
+
if (result.kind === "noop") {
|
|
39
|
+
logger.print(pc.dim(`working tree already matches checkpoint ${result.checkpoint.id} — nothing to roll back`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (result.kind === "rejected") {
|
|
43
|
+
logger.print(pc.dim("rollback declined — nothing was changed"));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const { recreated, reverted, deleted } = result.applied;
|
|
47
|
+
logger.print(`${pc.green("✓")} restored checkpoint ${pc.cyan(result.checkpoint.id)} — ` +
|
|
48
|
+
`${reverted} reverted, ${recreated} recreated, ${deleted} deleted`);
|
|
49
|
+
logger.print(pc.dim("note: commits, pushes, and PRs made during the run are not undone"));
|
|
50
|
+
});
|
|
51
|
+
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -4,6 +4,7 @@ import { logger } from "../../utils/logger.js";
|
|
|
4
4
|
import { loadConfig, resolveApiKey } from "../../config/index.js";
|
|
5
5
|
import { authMissingKey, usageError } from "../../errors/index.js";
|
|
6
6
|
import { createRenderer } from "../../render/index.js";
|
|
7
|
+
import { CheckpointService } from "../../checkpoint/index.js";
|
|
7
8
|
import { runInteractive } from "../repl.js";
|
|
8
9
|
import { buildAgentSession } from "../session-factory.js";
|
|
9
10
|
import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
|
|
@@ -53,11 +54,18 @@ export function runCommand() {
|
|
|
53
54
|
// One renderer for the whole run (U.2): the streaming path and the
|
|
54
55
|
// approval prompt's status-suspend hook must share the same live region.
|
|
55
56
|
const renderer = createRenderer();
|
|
56
|
-
|
|
57
|
+
// Checkpoint-before-first-mutation (C.32): the service is latched per run
|
|
58
|
+
// and fires from the approval seam inside the session, so one instance
|
|
59
|
+
// covers the one-shot path, every REPL turn, and plan-mode execution.
|
|
60
|
+
const checkpoints = config.checkpoint.enabled
|
|
61
|
+
? new CheckpointService({ root: process.cwd(), config })
|
|
62
|
+
: undefined;
|
|
63
|
+
const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints);
|
|
57
64
|
if (interactive) {
|
|
58
|
-
await runInteractive(session, undefined, renderer);
|
|
65
|
+
await runInteractive(session, undefined, renderer, checkpoints);
|
|
59
66
|
return;
|
|
60
67
|
}
|
|
68
|
+
checkpoints?.beginRun(prompt);
|
|
61
69
|
// One-shot: a single turn, then exit. Preserves scripting/pipe use.
|
|
62
70
|
// Assistant text streams to stdout delta by delta (same as the REPL);
|
|
63
71
|
// piped output degrades to the plain renderer (no ANSI, chrome on stderr).
|
package/dist/cli/program.js
CHANGED
|
@@ -10,6 +10,8 @@ import { skillsCommand } from "./commands/skills.js";
|
|
|
10
10
|
import { prCommand } from "./commands/pr.js";
|
|
11
11
|
import { loginCommand } from "./commands/login.js";
|
|
12
12
|
import { initCommand } from "./commands/init.js";
|
|
13
|
+
import { checkpointCommand } from "./commands/checkpoint.js";
|
|
14
|
+
import { rollbackCommand } from "./commands/rollback.js";
|
|
13
15
|
import { loadConfig } from "../config/index.js";
|
|
14
16
|
import { maybeRunOnboarding } from "./onboard.js";
|
|
15
17
|
export function buildProgram() {
|
|
@@ -36,6 +38,8 @@ export function buildProgram() {
|
|
|
36
38
|
program.addCommand(prCommand());
|
|
37
39
|
program.addCommand(loginCommand());
|
|
38
40
|
program.addCommand(initCommand());
|
|
41
|
+
program.addCommand(checkpointCommand());
|
|
42
|
+
program.addCommand(rollbackCommand());
|
|
39
43
|
// Default action: bare `cruxy` -> entrypoint. An unrecognized first operand
|
|
40
44
|
// means an unknown command (Commander runs the default action with it as an
|
|
41
45
|
// operand rather than erroring), so reject it as a usage error.
|
package/dist/cli/repl.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Readable, Writable } from "node:stream";
|
|
2
2
|
import type { Session } from "../agent/index.js";
|
|
3
|
+
import type { CheckpointService } from "../checkpoint/index.js";
|
|
3
4
|
import { type StreamRenderer } from "../render/index.js";
|
|
4
5
|
/** The stdin/stdout pair the REPL reads from and prompts on. Injectable for tests. */
|
|
5
6
|
export interface ReplIO {
|
|
@@ -17,4 +18,4 @@ export interface ReplIO {
|
|
|
17
18
|
* live region, anything else the plain append-only renderer); `cruxy run`
|
|
18
19
|
* passes its own so the approval prompt's status-suspend hook shares it.
|
|
19
20
|
*/
|
|
20
|
-
export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer): Promise<void>;
|
|
21
|
+
export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer, checkpoints?: CheckpointService): Promise<void>;
|
package/dist/cli/repl.js
CHANGED
|
@@ -74,16 +74,16 @@ function printReplError(err) {
|
|
|
74
74
|
* live region, anything else the plain append-only renderer); `cruxy run`
|
|
75
75
|
* passes its own so the approval prompt's status-suspend hook shares it.
|
|
76
76
|
*/
|
|
77
|
-
export async function runInteractive(session, io = defaultIO(), renderer = createRenderer(io.output, process.stderr)) {
|
|
77
|
+
export async function runInteractive(session, io = defaultIO(), renderer = createRenderer(io.output, process.stderr), checkpoints) {
|
|
78
78
|
logger.print(pc.dim("interactive session — /help for commands, /exit or Ctrl+D to quit"));
|
|
79
79
|
try {
|
|
80
|
-
await replLoop(session, io, renderer);
|
|
80
|
+
await replLoop(session, io, renderer, checkpoints);
|
|
81
81
|
}
|
|
82
82
|
finally {
|
|
83
83
|
renderer.close();
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
-
async function replLoop(session, io, renderer) {
|
|
86
|
+
async function replLoop(session, io, renderer, checkpoints) {
|
|
87
87
|
for (;;) {
|
|
88
88
|
const line = await readLine(io, PROMPT);
|
|
89
89
|
// EOF / Ctrl+D.
|
|
@@ -138,6 +138,9 @@ async function replLoop(session, io, renderer) {
|
|
|
138
138
|
// line. Errors (provider/API failures) log and return to the prompt rather
|
|
139
139
|
// than killing the REPL.
|
|
140
140
|
try {
|
|
141
|
+
// Each REPL turn is its own undo unit (C.32): a fresh checkpoint latch,
|
|
142
|
+
// so `cruxy rollback` reverts exactly one turn's mutations.
|
|
143
|
+
checkpoints?.beginRun(trimmed);
|
|
141
144
|
await session.send(line, renderer);
|
|
142
145
|
}
|
|
143
146
|
catch (err) {
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import type { ApprovalDecision } from "../approval/index.js";
|
|
3
|
+
import type { CheckpointService } from "../checkpoint/index.js";
|
|
2
4
|
import type { StreamRenderer } from "../render/index.js";
|
|
5
|
+
import { type ApproveAction } from "../tools/index.js";
|
|
3
6
|
import { Session } from "../agent/index.js";
|
|
7
|
+
/**
|
|
8
|
+
* Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
|
|
9
|
+
* whole point: a tool mutates only *after* `requestApproval` resolves, so
|
|
10
|
+
* snapshotting after an `allow` decision but before returning it means the
|
|
11
|
+
* checkpoint always lands before the run's first mutation — and a denied
|
|
12
|
+
* action never creates one. The same seam records which paths the run touched
|
|
13
|
+
* (file actions) or that attribution is lost (shell), for rollback's
|
|
14
|
+
* external-change detection.
|
|
15
|
+
*/
|
|
16
|
+
export declare function withCheckpointGate(requestApproval: (action: ApproveAction) => Promise<ApprovalDecision>, checkpoints: CheckpointService | undefined, cwd: string): (action: ApproveAction) => Promise<ApprovalDecision>;
|
|
4
17
|
/**
|
|
5
18
|
* Build a ready-to-run agent {@link Session} from a resolved key — the wiring
|
|
6
19
|
* shared by `cruxy run` and the onboarding first-win task (so they can't drift).
|
|
@@ -10,4 +23,4 @@ import { Session } from "../agent/index.js";
|
|
|
10
23
|
* over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
|
|
11
24
|
* approves → executes. Plan mode is fully opt-in; the default path is unchanged.
|
|
12
25
|
*/
|
|
13
|
-
export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer): Session;
|
|
26
|
+
export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService): Session;
|
|
@@ -2,16 +2,19 @@ import { createProvider } from "@cruxy/sdk";
|
|
|
2
2
|
import { loadProjectInstructions } from "../config/index.js";
|
|
3
3
|
import { logger } from "../utils/logger.js";
|
|
4
4
|
import { getGitInfo } from "../utils/git.js";
|
|
5
|
-
import { ApprovalService, InteractivePolicy, SessionAllowlist, defaultPromptIO, } from "../approval/index.js";
|
|
5
|
+
import { ApprovalService, InteractivePolicy, SessionAllowlist, classify, defaultPromptIO, } from "../approval/index.js";
|
|
6
6
|
import { shouldUseColor } from "../errors/index.js";
|
|
7
7
|
import { buildDefaultRegistry } from "../tools/index.js";
|
|
8
8
|
import { Session } from "../agent/index.js";
|
|
9
9
|
import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
|
|
10
|
+
import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
|
|
10
11
|
/**
|
|
11
|
-
* Wrap a PromptIO so the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* the
|
|
12
|
+
* Wrap a PromptIO so the live region yields before any prompt text lands
|
|
13
|
+
* (U.2/U.4): the prompt writes to stderr while the status line owns the last
|
|
14
|
+
* stdout row of the same terminal. Entering the `awaiting-approval` phase
|
|
15
|
+
* hides the live line (the prompt IS the visible state) while keeping the
|
|
16
|
+
* step-progress register intact, so the line comes back with full context on
|
|
17
|
+
* the next transition after the user decides.
|
|
15
18
|
*/
|
|
16
19
|
function suspendStatusOnPrompt(io, renderer) {
|
|
17
20
|
if (!renderer)
|
|
@@ -19,11 +22,58 @@ function suspendStatusOnPrompt(io, renderer) {
|
|
|
19
22
|
return {
|
|
20
23
|
...io,
|
|
21
24
|
write: (text) => {
|
|
22
|
-
renderer.
|
|
25
|
+
renderer.setPhase({ kind: "awaiting-approval" });
|
|
23
26
|
io.write(text);
|
|
24
27
|
},
|
|
25
28
|
};
|
|
26
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Restore the live line once an approval request fully settles (U.4). The
|
|
32
|
+
* settle point must be the service call, not the prompt's key read: the
|
|
33
|
+
* prompt writes a trailing newline AFTER the read, which re-enters
|
|
34
|
+
* `awaiting-approval` — resolving here is the first moment no more prompt
|
|
35
|
+
* bytes can follow. Fires on every decision (prompted or not); the renderer
|
|
36
|
+
* treats it as a no-op unless a prompt actually displaced the line.
|
|
37
|
+
*/
|
|
38
|
+
function resumeLineAfterApproval(requestApproval, renderer) {
|
|
39
|
+
if (!renderer)
|
|
40
|
+
return requestApproval;
|
|
41
|
+
return async (action) => {
|
|
42
|
+
try {
|
|
43
|
+
return await requestApproval(action);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
renderer.promptResolved();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
|
|
52
|
+
* whole point: a tool mutates only *after* `requestApproval` resolves, so
|
|
53
|
+
* snapshotting after an `allow` decision but before returning it means the
|
|
54
|
+
* checkpoint always lands before the run's first mutation — and a denied
|
|
55
|
+
* action never creates one. The same seam records which paths the run touched
|
|
56
|
+
* (file actions) or that attribution is lost (shell), for rollback's
|
|
57
|
+
* external-change detection.
|
|
58
|
+
*/
|
|
59
|
+
export function withCheckpointGate(requestApproval, checkpoints, cwd) {
|
|
60
|
+
if (!checkpoints)
|
|
61
|
+
return requestApproval;
|
|
62
|
+
return async (action) => {
|
|
63
|
+
const decision = await requestApproval(action);
|
|
64
|
+
if (!decision.allow)
|
|
65
|
+
return decision;
|
|
66
|
+
const request = classify(action, cwd);
|
|
67
|
+
if (request.tier === "read")
|
|
68
|
+
return decision;
|
|
69
|
+
await checkpoints.ensureCheckpoint();
|
|
70
|
+
if (action.kind === "shell")
|
|
71
|
+
await checkpoints.recordShellMutation();
|
|
72
|
+
else
|
|
73
|
+
await checkpoints.recordTouched([...request.targets]);
|
|
74
|
+
return decision;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
27
77
|
/**
|
|
28
78
|
* Build a ready-to-run agent {@link Session} from a resolved key — the wiring
|
|
29
79
|
* shared by `cruxy run` and the onboarding first-win task (so they can't drift).
|
|
@@ -33,7 +83,7 @@ function suspendStatusOnPrompt(io, renderer) {
|
|
|
33
83
|
* over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
|
|
34
84
|
* approves → executes. Plan mode is fully opt-in; the default path is unchanged.
|
|
35
85
|
*/
|
|
36
|
-
export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer) {
|
|
86
|
+
export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints) {
|
|
37
87
|
const provider = createProvider({
|
|
38
88
|
provider: config.model.provider,
|
|
39
89
|
apiKey,
|
|
@@ -45,10 +95,35 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
45
95
|
const execRegistry = buildDefaultRegistry();
|
|
46
96
|
const git = getGitInfo(cwd);
|
|
47
97
|
const projectInstructions = loadProjectInstructions(cwd);
|
|
98
|
+
// One io shared by every prompt in the session (plan approval, the U.3 gate,
|
|
99
|
+
// and any gate inside a subagent), so they all coordinate with the same live
|
|
100
|
+
// region. The full wrapper stack around an ApprovalService is factored here
|
|
101
|
+
// because subagents must get the *identical* stack over a FRESH service: same
|
|
102
|
+
// prompt + same checkpoint hook, but a new (empty) session allowlist — a
|
|
103
|
+
// grant in the parent never silently widens a child's authority.
|
|
104
|
+
const io = suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer);
|
|
105
|
+
const gate = (approval) => withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, cwd);
|
|
106
|
+
// Subagent orchestration (C.14): spawn_subagent goes on the main registry
|
|
107
|
+
// only when depth allows (maxDepth 0 disables the feature structurally).
|
|
108
|
+
// Registered before the plan wiring so plan-mode execution steps can
|
|
109
|
+
// dispatch subagents too; the propose phase filters it out (read-only).
|
|
110
|
+
const orchestrator = new SubagentOrchestrator({
|
|
111
|
+
provider,
|
|
112
|
+
config,
|
|
113
|
+
parentRegistry: execRegistry,
|
|
114
|
+
cwd,
|
|
115
|
+
logger,
|
|
116
|
+
git,
|
|
117
|
+
projectInstructions,
|
|
118
|
+
renderer,
|
|
119
|
+
makeChildApproval: () => gate(new ApprovalService({ cwd, interactive: ttyInteractive, io })),
|
|
120
|
+
});
|
|
121
|
+
if (config.subagent.maxDepth > 0) {
|
|
122
|
+
execRegistry.register(makeSpawnSubagentTool(orchestrator, 0));
|
|
123
|
+
}
|
|
48
124
|
if (planMode) {
|
|
49
|
-
// One
|
|
125
|
+
// One allowlist shared by the plan-approval prompt and the per-action
|
|
50
126
|
// gate, so a grant recorded during execution is honored by U.3's own check.
|
|
51
|
-
const io = suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer);
|
|
52
127
|
const allowlist = new SessionAllowlist();
|
|
53
128
|
const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io));
|
|
54
129
|
const approval = new ApprovalService({
|
|
@@ -57,12 +132,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
57
132
|
policy: planPolicy,
|
|
58
133
|
io,
|
|
59
134
|
});
|
|
60
|
-
const ctx = {
|
|
61
|
-
cwd,
|
|
62
|
-
config,
|
|
63
|
-
logger,
|
|
64
|
-
requestApproval: (action) => approval.requestApproval(action),
|
|
65
|
-
};
|
|
135
|
+
const ctx = { cwd, config, logger, requestApproval: gate(approval) };
|
|
66
136
|
const planRunner = ({ messages, projectInstructions, renderer: turnRenderer, }) => runPlanSession({
|
|
67
137
|
provider,
|
|
68
138
|
config,
|
|
@@ -90,14 +160,9 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
90
160
|
const approval = new ApprovalService({
|
|
91
161
|
cwd,
|
|
92
162
|
interactive: ttyInteractive,
|
|
93
|
-
io
|
|
163
|
+
io,
|
|
94
164
|
});
|
|
95
|
-
const ctx = {
|
|
96
|
-
cwd,
|
|
97
|
-
config,
|
|
98
|
-
logger,
|
|
99
|
-
requestApproval: (action) => approval.requestApproval(action),
|
|
100
|
-
};
|
|
165
|
+
const ctx = { cwd, config, logger, requestApproval: gate(approval) };
|
|
101
166
|
return new Session({
|
|
102
167
|
provider,
|
|
103
168
|
registry: execRegistry,
|