@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.
Files changed (66) hide show
  1. package/README.md +40 -13
  2. package/dist/agent/loop.d.ts +28 -1
  3. package/dist/agent/loop.js +36 -4
  4. package/dist/agent/prompts.d.ts +2 -0
  5. package/dist/agent/prompts.js +8 -0
  6. package/dist/approval/classify.js +26 -0
  7. package/dist/checkpoint/capture.d.ts +17 -0
  8. package/dist/checkpoint/capture.js +73 -0
  9. package/dist/checkpoint/git-store.d.ts +61 -0
  10. package/dist/checkpoint/git-store.js +171 -0
  11. package/dist/checkpoint/index.d.ts +6 -0
  12. package/dist/checkpoint/index.js +6 -0
  13. package/dist/checkpoint/restore.d.ts +23 -0
  14. package/dist/checkpoint/restore.js +195 -0
  15. package/dist/checkpoint/service.d.ts +80 -0
  16. package/dist/checkpoint/service.js +276 -0
  17. package/dist/checkpoint/shadow-store.d.ts +23 -0
  18. package/dist/checkpoint/shadow-store.js +93 -0
  19. package/dist/checkpoint/types.d.ts +117 -0
  20. package/dist/checkpoint/types.js +18 -0
  21. package/dist/cli/commands/checkpoint.d.ts +7 -0
  22. package/dist/cli/commands/checkpoint.js +31 -0
  23. package/dist/cli/commands/rollback.d.ts +10 -0
  24. package/dist/cli/commands/rollback.js +51 -0
  25. package/dist/cli/commands/run.js +10 -2
  26. package/dist/cli/program.js +4 -0
  27. package/dist/cli/repl.d.ts +2 -1
  28. package/dist/cli/repl.js +6 -3
  29. package/dist/cli/session-factory.d.ts +14 -1
  30. package/dist/cli/session-factory.js +87 -22
  31. package/dist/config/schema.d.ts +133 -0
  32. package/dist/config/schema.js +40 -0
  33. package/dist/errors/constructors.d.ts +25 -0
  34. package/dist/errors/constructors.js +86 -0
  35. package/dist/errors/types.d.ts +7 -0
  36. package/dist/errors/types.js +16 -0
  37. package/dist/indexing/walker.d.ts +11 -0
  38. package/dist/indexing/walker.js +11 -6
  39. package/dist/plan/execute.d.ts +8 -0
  40. package/dist/plan/execute.js +36 -22
  41. package/dist/plan/service.js +5 -1
  42. package/dist/plan/submit-plan.d.ts +4 -4
  43. package/dist/render/diff.js +27 -0
  44. package/dist/render/index.d.ts +2 -1
  45. package/dist/render/index.js +1 -0
  46. package/dist/render/plain-renderer.d.ts +7 -1
  47. package/dist/render/plain-renderer.js +26 -0
  48. package/dist/render/state.d.ts +31 -0
  49. package/dist/render/state.js +83 -0
  50. package/dist/render/tty-renderer.d.ts +41 -5
  51. package/dist/render/tty-renderer.js +150 -23
  52. package/dist/render/types.d.ts +85 -1
  53. package/dist/subagent/budget.d.ts +34 -0
  54. package/dist/subagent/budget.js +57 -0
  55. package/dist/subagent/index.d.ts +5 -0
  56. package/dist/subagent/index.js +5 -0
  57. package/dist/subagent/orchestrator.d.ts +67 -0
  58. package/dist/subagent/orchestrator.js +241 -0
  59. package/dist/subagent/registry-scope.d.ts +28 -0
  60. package/dist/subagent/registry-scope.js +63 -0
  61. package/dist/subagent/spawn-tool.d.ts +29 -0
  62. package/dist/subagent/spawn-tool.js +94 -0
  63. package/dist/subagent/types.d.ts +55 -0
  64. package/dist/subagent/types.js +1 -0
  65. package/dist/tools/types.d.ts +20 -2
  66. 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
+ }
@@ -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
- const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer);
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).
@@ -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.
@@ -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 renderer's transient status line is erased before any
12
- * prompt text lands (U.2): the approval prompt writes to stderr while the
13
- * spinner owns the last stdout row of the same terminal clearing first keeps
14
- * the prompt from tearing through the live region.
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.status(null);
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 io + allowlist shared by the plan-approval prompt and the per-action
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: suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer),
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,