@cruxy/cli 0.7.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 (78) hide show
  1. package/README.md +46 -13
  2. package/dist/agent/loop.d.ts +35 -6
  3. package/dist/agent/loop.js +84 -10
  4. package/dist/agent/prompts.d.ts +2 -0
  5. package/dist/agent/prompts.js +8 -0
  6. package/dist/agent/session.d.ts +6 -4
  7. package/dist/agent/session.js +6 -5
  8. package/dist/approval/classify.js +26 -0
  9. package/dist/approval/prompt.d.ts +9 -0
  10. package/dist/approval/prompt.js +2 -77
  11. package/dist/checkpoint/capture.d.ts +17 -0
  12. package/dist/checkpoint/capture.js +73 -0
  13. package/dist/checkpoint/git-store.d.ts +61 -0
  14. package/dist/checkpoint/git-store.js +171 -0
  15. package/dist/checkpoint/index.d.ts +6 -0
  16. package/dist/checkpoint/index.js +6 -0
  17. package/dist/checkpoint/restore.d.ts +23 -0
  18. package/dist/checkpoint/restore.js +195 -0
  19. package/dist/checkpoint/service.d.ts +80 -0
  20. package/dist/checkpoint/service.js +276 -0
  21. package/dist/checkpoint/shadow-store.d.ts +23 -0
  22. package/dist/checkpoint/shadow-store.js +93 -0
  23. package/dist/checkpoint/types.d.ts +117 -0
  24. package/dist/checkpoint/types.js +18 -0
  25. package/dist/cli/commands/checkpoint.d.ts +7 -0
  26. package/dist/cli/commands/checkpoint.js +31 -0
  27. package/dist/cli/commands/rollback.d.ts +10 -0
  28. package/dist/cli/commands/rollback.js +51 -0
  29. package/dist/cli/commands/run.js +24 -10
  30. package/dist/cli/onboard.js +9 -4
  31. package/dist/cli/program.js +4 -0
  32. package/dist/cli/repl.d.ts +10 -4
  33. package/dist/cli/repl.js +26 -12
  34. package/dist/cli/session-factory.d.ts +15 -1
  35. package/dist/cli/session-factory.js +104 -18
  36. package/dist/config/schema.d.ts +133 -0
  37. package/dist/config/schema.js +40 -0
  38. package/dist/errors/constructors.d.ts +25 -0
  39. package/dist/errors/constructors.js +86 -0
  40. package/dist/errors/types.d.ts +7 -0
  41. package/dist/errors/types.js +16 -0
  42. package/dist/indexing/walker.d.ts +11 -0
  43. package/dist/indexing/walker.js +11 -6
  44. package/dist/plan/execute.d.ts +8 -0
  45. package/dist/plan/execute.js +36 -22
  46. package/dist/plan/service.d.ts +2 -1
  47. package/dist/plan/service.js +7 -3
  48. package/dist/plan/submit-plan.d.ts +4 -4
  49. package/dist/render/capabilities.d.ts +12 -0
  50. package/dist/render/capabilities.js +27 -0
  51. package/dist/render/diff.d.ts +19 -0
  52. package/dist/render/diff.js +107 -0
  53. package/dist/render/highlight.d.ts +47 -0
  54. package/dist/render/highlight.js +265 -0
  55. package/dist/render/index.d.ts +15 -0
  56. package/dist/render/index.js +21 -0
  57. package/dist/render/plain-renderer.d.ts +38 -0
  58. package/dist/render/plain-renderer.js +87 -0
  59. package/dist/render/state.d.ts +31 -0
  60. package/dist/render/state.js +83 -0
  61. package/dist/render/tty-renderer.d.ts +83 -0
  62. package/dist/render/tty-renderer.js +276 -0
  63. package/dist/render/types.d.ts +160 -0
  64. package/dist/render/types.js +1 -0
  65. package/dist/subagent/budget.d.ts +34 -0
  66. package/dist/subagent/budget.js +57 -0
  67. package/dist/subagent/index.d.ts +5 -0
  68. package/dist/subagent/index.js +5 -0
  69. package/dist/subagent/orchestrator.d.ts +67 -0
  70. package/dist/subagent/orchestrator.js +241 -0
  71. package/dist/subagent/registry-scope.d.ts +28 -0
  72. package/dist/subagent/registry-scope.js +63 -0
  73. package/dist/subagent/spawn-tool.d.ts +29 -0
  74. package/dist/subagent/spawn-tool.js +94 -0
  75. package/dist/subagent/types.d.ts +55 -0
  76. package/dist/subagent/types.js +1 -0
  77. package/dist/tools/types.d.ts +20 -2
  78. package/package.json +1 -1
package/README.md CHANGED
@@ -58,6 +58,12 @@ export CRUXY_API_KEY=cxy_live_... # …or just use an env var (always wins)
58
58
  validated, and never auto-executed.
59
59
  - **Agent** — streaming output, multi-turn interactive sessions, context
60
60
  compaction, and awareness of git state and project instructions (`CRUXY.md`).
61
+ - **Streaming render** — flicker-free live output: a single in-place status line
62
+ (spinner while the model thinks / tools run), append-only committed text,
63
+ syntax-highlighted code fences, tool-call notes, and diffs drawn by the same
64
+ renderer as the approval prompt. Degrades cleanly: piped/CI output is plain
65
+ append-only text with zero ANSI (chrome on stderr), `NO_COLOR` drops color,
66
+ and `CRUXY_NO_SPINNER=1` stills the animation.
61
67
  - **Plan mode** (opt-in: `cruxy run --plan`, `/plan`, or `agent.planMode`) — the
62
68
  agent proposes a structured, step-by-step plan; you approve it once, then it
63
69
  executes with live per-step status. Approving consents to the _shape_ of the
@@ -110,6 +116,33 @@ before anything runs. On a protected branch (`main`/`master`/configured via
110
116
  `git.protectedBranches`) cruxy branches off first; the base defaults to
111
117
  `git.defaultBase`, then the repo's default branch, then `main`.
112
118
 
119
+ ### Checkpoints & rollback
120
+
121
+ Before an agent run's first file mutation, cruxy snapshots the working tree
122
+ (tracked + untracked non-ignored files; gitignored paths and the secrets
123
+ denylist are never captured). `cruxy rollback` undoes the whole run — creates,
124
+ edits, deletes — in one operation:
125
+
126
+ ```bash
127
+ cruxy checkpoint list # saved checkpoints, newest first
128
+ cruxy rollback # restore the most recent checkpoint
129
+ cruxy rollback <id> # restore a specific one
130
+ ```
131
+
132
+ Rollback is destructive, so it previews exactly what will change (including
133
+ anything that changed _outside_ the run — surfaced, never silently clobbered)
134
+ and always asks for approval; it cannot be session-granted and refuses to run
135
+ non-interactively (`CRUXY_E_ROLLBACK_APPROVAL_REQUIRED`).
136
+
137
+ In a git repo, snapshots go into the git object database via a temporary index
138
+ — HEAD, your index, the stash, and every ref are untouched, and nothing shows
139
+ up in `git status`. Outside a repo, a content-addressed shadow copy under
140
+ `.cruxy/checkpoints/` is used. Retention is bounded (`checkpoint.retention`,
141
+ default 10; disable with `checkpoint.enabled = false`).
142
+
143
+ **Boundary:** checkpoints cover working-tree files only. Commits, pushes, and
144
+ PRs made during a run are never undone — the rollback preview says so.
145
+
113
146
  ## Errors & exit codes
114
147
 
115
148
  Every user-facing error prints a title, the cause (when known), concrete next
@@ -118,19 +151,19 @@ steps, and a stable code (e.g. `CRUXY_E_GATEWAY_UNREACHABLE`). Pass `--verbose`
118
151
  `NO_COLOR` disables color. Exit codes are stable per category, so scripts can
119
152
  branch on them:
120
153
 
121
- | Exit | Category | Example codes |
122
- | ---- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
- | `0` | success | — |
124
- | `1` | internal | `CRUXY_E_INTERNAL` |
125
- | `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH`, `CRUXY_E_PLAN_INVALID`, `CRUXY_E_PLAN_REVISION_LIMIT` |
126
- | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
127
- | `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
128
- | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
129
- | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
130
- | `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE` |
131
- | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
132
- | `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
133
- | `10` | approval | `CRUXY_E_APPROVAL_REQUIRED`, `CRUXY_E_PLAN_APPROVAL_REQUIRED` |
154
+ | Exit | Category | Example codes |
155
+ | ---- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
156
+ | `0` | success | — |
157
+ | `1` | internal | `CRUXY_E_INTERNAL` |
158
+ | `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH`, `CRUXY_E_PLAN_INVALID`, `CRUXY_E_PLAN_REVISION_LIMIT`, `CRUXY_E_CHECKPOINT_NOT_FOUND` |
159
+ | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
160
+ | `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
161
+ | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
162
+ | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
163
+ | `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE`, `CRUXY_E_CHECKPOINT_FAILED` |
164
+ | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
165
+ | `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
166
+ | `10` | approval | `CRUXY_E_APPROVAL_REQUIRED`, `CRUXY_E_PLAN_APPROVAL_REQUIRED`, `CRUXY_E_ROLLBACK_APPROVAL_REQUIRED` |
134
167
 
135
168
  The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
136
169
  provider-agnostic, built over `fetch`, with no vendor SDKs.
@@ -1,5 +1,6 @@
1
1
  import type { Message, Provider, Usage } from "@cruxy/sdk";
2
2
  import type { CruxyConfig } from "../config/index.js";
3
+ import type { StreamRenderer } from "../render/index.js";
3
4
  import type { ToolContext } from "../tools/index.js";
4
5
  import { ToolRegistry } from "../tools/index.js";
5
6
  export interface RunAgentArgs {
@@ -17,12 +18,13 @@ export interface RunAgentArgs {
17
18
  /** Ambient capabilities handed to each tool. */
18
19
  ctx: ToolContext;
19
20
  /**
20
- * Optional sink for assistant text as it streams: called per text delta, then
21
- * once with a lone "\n" to close each non-empty text segment on its own line.
22
- * When set, `runAgent` streams live and does not buffer-print the turn (the
23
- * caller renders); when omitted, behavior is unchanged (one buffered print).
21
+ * The render seam (U.2): assistant text streams through `renderer.write`
22
+ * delta by delta (each non-empty segment closed via `endSegment`), and
23
+ * tool-call progress is surfaced as transient `status` + committed `note`
24
+ * lines. When omitted, behavior is unchanged (one buffered print per turn,
25
+ * no tool-call chrome). The loop never touches stdout directly.
24
26
  */
25
- onText?: (delta: string) => void;
27
+ renderer?: StreamRenderer;
26
28
  /** Git context (branch + dirty) for the system prompt's Environment section. */
27
29
  git?: {
28
30
  branch: string;
@@ -32,6 +34,31 @@ export interface RunAgentArgs {
32
34
  projectInstructions?: string | null;
33
35
  /** Plan mode's propose phase (C.31): inject the plan-first system directive. */
34
36
  planMode?: boolean;
37
+ /** Subagent runs (C.14): inject the bounded-subtask system directive. */
38
+ subagent?: boolean;
39
+ /**
40
+ * Optional hard budget (C.14): checked before every model turn; a non-null
41
+ * reason stops the loop with `stop: "budget"` and the partial history. The
42
+ * in-flight turn (model call + its tool executions) always completes, so
43
+ * histories stay coherent — overshoot is bounded by one turn.
44
+ */
45
+ budget?: LoopBudget;
46
+ }
47
+ /**
48
+ * The budget seam for {@link runAgent}: implementations track their own caps
49
+ * (iterations, tokens, wall clock — see `subagent/budget.ts`); the loop only
50
+ * asks "may I start another turn?". Kept a one-method interface so future
51
+ * callers (C.22 cost tracking) can slot in without touching the loop again.
52
+ */
53
+ export interface LoopBudget {
54
+ /**
55
+ * Return a human-readable reason to stop *before* the next model turn, or
56
+ * `null` to continue. `iterations` = model turns completed so far.
57
+ */
58
+ exceeded(state: {
59
+ iterations: number;
60
+ usage: Usage;
61
+ }): string | null;
35
62
  }
36
63
  export interface AgentResult {
37
64
  /** The full conversation, including assistant tool calls and tool results. */
@@ -39,7 +66,9 @@ export interface AgentResult {
39
66
  /** Number of model turns consumed. */
40
67
  iterations: number;
41
68
  /** Why the loop ended. */
42
- stop: "completed" | "max_iterations";
69
+ stop: "completed" | "max_iterations" | "budget";
70
+ /** Which cap tripped, when `stop === "budget"` (from {@link LoopBudget}). */
71
+ stopReason?: string;
43
72
  /** Accumulated token usage (stashed for cost tracking in C.22). */
44
73
  usage: Usage;
45
74
  }
@@ -10,10 +10,23 @@ import { buildSystemPrompt } from "./prompts.js";
10
10
  * silently running tool-less.
11
11
  */
12
12
  export async function runAgent(args) {
13
- const { provider, registry, config, ctx } = args;
13
+ const { provider, config, renderer } = args;
14
14
  if (!provider.supportsTools) {
15
15
  throw providerUnsupported(config.model.provider);
16
16
  }
17
+ renderer?.beginTurn();
18
+ try {
19
+ return await driveLoop(args, renderer);
20
+ }
21
+ finally {
22
+ // Always leave the terminal clean: no orphaned status line, no held text —
23
+ // even when a provider error aborts the turn mid-stream.
24
+ renderer?.endTurn();
25
+ }
26
+ }
27
+ /** The body of {@link runAgent}, split out so turn cleanup lives in one finally. */
28
+ async function driveLoop(args, renderer) {
29
+ const { provider, registry, config, ctx } = args;
17
30
  const { logger } = ctx;
18
31
  // Work on a copy so we never mutate the caller's array as a side effect; the
19
32
  // extended history is returned for the caller to adopt.
@@ -33,15 +46,38 @@ export async function runAgent(args) {
33
46
  git: args.git ?? null,
34
47
  projectInstructions: args.projectInstructions ?? null,
35
48
  planMode: args.planMode ?? false,
49
+ subagent: args.subagent ?? false,
36
50
  });
37
51
  let iterations = 0;
38
52
  for (let i = 0; i < maxIterations; i++) {
53
+ // Budget check before committing to another model turn (C.14): a tripped
54
+ // cap returns the history as it stands — always at a clean turn boundary,
55
+ // because the previous iteration fully resolved its tool calls.
56
+ const budgetReason = args.budget?.exceeded({ iterations, usage }) ?? null;
57
+ if (budgetReason !== null) {
58
+ return {
59
+ messages,
60
+ iterations,
61
+ stop: "budget",
62
+ stopReason: budgetReason,
63
+ usage,
64
+ };
65
+ }
39
66
  iterations = i + 1;
40
67
  const tools = registry.toToolSpecs();
41
68
  // ── Consume one model turn ──────────────────────────────────────────────
42
69
  let turnText = "";
43
70
  const pending = new Map();
44
71
  const toolUses = [];
72
+ // Live progress while waiting on the model; dismissed by the first delta.
73
+ // Token context is whatever the loop has actually accumulated (U.4): zero
74
+ // on the first turn → no figure shown, never a fabricated number.
75
+ renderer?.setPhase({
76
+ kind: "thinking",
77
+ tokens: usage.input_tokens + usage.output_tokens > 0
78
+ ? { input: usage.input_tokens, output: usage.output_tokens }
79
+ : undefined,
80
+ });
45
81
  for await (const ev of provider.stream({
46
82
  system,
47
83
  messages,
@@ -50,7 +86,7 @@ export async function runAgent(args) {
50
86
  switch (ev.type) {
51
87
  case "text_delta":
52
88
  turnText += ev.text;
53
- args.onText?.(ev.text);
89
+ renderer?.write(ev.text);
54
90
  break;
55
91
  case "tool_use_start":
56
92
  pending.set(ev.index, { id: ev.id, name: ev.name });
@@ -83,13 +119,13 @@ export async function runAgent(args) {
83
119
  }
84
120
  // ── Record the assistant turn ───────────────────────────────────────────
85
121
  if (turnText) {
86
- // Streaming (onText set): the text already reached the user delta by delta,
87
- // so close the segment with a single newline through the *same* sinkno
88
- // separate buffered print racing the stream so tool output, the next
89
- // turn, or an approval prompt starts on its own line. Otherwise render the
90
- // whole buffered block (no-callback path, unchanged).
91
- if (args.onText)
92
- args.onText("\n");
122
+ // Streaming (renderer set): the text already reached the user delta by
123
+ // delta, so close the segment through the *same* rendererit flushes any
124
+ // held partial line and terminates with one newline, so tool output, the
125
+ // next turn, or an approval prompt starts on its own line. Otherwise
126
+ // render the whole buffered block (no-renderer path, unchanged).
127
+ if (renderer)
128
+ renderer.endSegment();
93
129
  else
94
130
  logger.print(turnText);
95
131
  }
@@ -105,13 +141,51 @@ export async function runAgent(args) {
105
141
  // ── Execute each tool call, collecting one tool_result per call ──────────
106
142
  const toolResults = [];
107
143
  for (const call of toolUses) {
108
- toolResults.push(await runToolCall(call, registry, ctx));
144
+ const label = describeToolCall(call);
145
+ // Semantic lifecycle (U.4): start paints the live state (+ elapsed on
146
+ // long calls), end commits the ✓/✗ trail note. Same information as the
147
+ // old status/note pair, now typed and duration-aware.
148
+ renderer?.toolLifecycle({ event: "start", label });
149
+ const result = await runToolCall(call, registry, ctx);
150
+ renderer?.toolLifecycle({ event: "end", label, ok: !result.is_error });
151
+ toolResults.push(result);
109
152
  }
110
153
  messages.push({ role: "user", content: toolResults });
111
154
  }
112
155
  logger.warn(`reached maxIterations (${maxIterations}) without completing`);
113
156
  return { messages, iterations, stop: "max_iterations", usage };
114
157
  }
158
+ /** Input keys worth surfacing in tool-call chrome, in preference order. */
159
+ const HINT_KEYS = [
160
+ "path",
161
+ "file_path",
162
+ "command",
163
+ "pattern",
164
+ "query",
165
+ "url",
166
+ "task",
167
+ ];
168
+ /** Longest hint shown before truncation — chrome, not information of record. */
169
+ const HINT_MAX = 60;
170
+ /**
171
+ * A short human label for a tool call ("read_file src/x.ts"): the tool name
172
+ * plus the first recognizable scalar argument, if any. Best-effort — unknown
173
+ * shapes fall back to the bare name.
174
+ */
175
+ function describeToolCall(call) {
176
+ const input = call.input;
177
+ if (typeof input === "object" && input !== null) {
178
+ for (const key of HINT_KEYS) {
179
+ const value = input[key];
180
+ if (typeof value === "string" && value !== "") {
181
+ const flat = value.replace(/\s+/g, " ").trim();
182
+ const hint = flat.length > HINT_MAX ? flat.slice(0, HINT_MAX - 1) + "…" : flat;
183
+ return `${call.name} ${hint}`;
184
+ }
185
+ }
186
+ }
187
+ return call.name;
188
+ }
115
189
  /**
116
190
  * Dispatch a single reassembled tool call to its tool and shape the outcome as
117
191
  * a `tool_result` block. Unknown tools and invalid arguments become `is_error`
@@ -30,6 +30,8 @@ export interface PromptContext {
30
30
  projectInstructions?: string | null;
31
31
  /** Plan mode's propose phase (C.31): inject the plan-first directive. */
32
32
  planMode?: boolean;
33
+ /** Subagent run (C.14): inject the bounded-subtask directive. */
34
+ subagent?: boolean;
33
35
  }
34
36
  /** Assemble the full system prompt for a session. */
35
37
  export declare function buildSystemPrompt(ctx: PromptContext): string;
@@ -10,6 +10,12 @@
10
10
  const PLAN_MODE_SECTION = `## Plan mode
11
11
  You are in plan mode. Do NOT edit files, run commands, or take any side-effecting action yet.
12
12
  First investigate with the read-only tools if you need to, then call \`submit_plan\` with an ordered list of steps — each with a title, a one-line rationale, and a kind (read | mutate | destructive). Cover the whole task; keep steps concrete and minimal. After you call \`submit_plan\`, stop and end your turn — the user reviews and approves the plan before you execute it.`;
13
+ /** The subagent directive (C.14), injected only into a spawned subagent's runs. */
14
+ const SUBAGENT_SECTION = `## Subagent context
15
+ You are a subagent: a scoped worker handling ONE bounded subtask for a parent agent, under a hard iteration and token budget. Only your final message is returned to the parent — its transcript does not include your intermediate steps. Therefore:
16
+ - Stay strictly within the given subtask; do not expand scope or start follow-on work.
17
+ - Work efficiently — prefer few, well-chosen tool calls over exhaustive exploration.
18
+ - End with a concise, self-contained summary of what you found or changed (concrete file paths, identifiers, outcomes). That summary IS your deliverable.`;
13
19
  /**
14
20
  * The static core of cruxy's behaviour. Phrased as direct instruction to the
15
21
  * model. Keep this tight — every line earns its place; vague prose dilutes the
@@ -80,6 +86,8 @@ export function buildSystemPrompt(ctx) {
80
86
  const sections = [core, renderEnvironment(ctx), renderTools(ctx.tools)];
81
87
  if (ctx.planMode)
82
88
  sections.push(PLAN_MODE_SECTION);
89
+ if (ctx.subagent)
90
+ sections.push(SUBAGENT_SECTION);
83
91
  if (ctx.projectInstructions?.trim()) {
84
92
  sections.push(`## Project instructions\nThe following came from this project's configuration; honor it unless it conflicts with the rules above:\n\n${ctx.projectInstructions.trim()}`);
85
93
  }
@@ -1,5 +1,6 @@
1
1
  import type { Message, Provider, Usage } from "@cruxy/sdk";
2
2
  import type { CruxyConfig } from "../config/index.js";
3
+ import type { StreamRenderer } from "../render/index.js";
3
4
  import type { ToolContext } from "../tools/index.js";
4
5
  import type { ToolRegistry } from "../tools/index.js";
5
6
  import { type AgentResult } from "./loop.js";
@@ -12,7 +13,7 @@ import { type AgentResult } from "./loop.js";
12
13
  export type PlanRunner = (args: {
13
14
  messages: Message[];
14
15
  projectInstructions: string | null;
15
- onText?: (delta: string) => void;
16
+ renderer?: StreamRenderer;
16
17
  }) => Promise<AgentResult>;
17
18
  export interface SessionArgs {
18
19
  /** A constructed provider to stream from. */
@@ -76,10 +77,11 @@ export declare class Session {
76
77
  * the threshold, drive the agent loop over the full history, adopt the
77
78
  * extended history, and accumulate usage. Returns the turn's `AgentResult`.
78
79
  *
79
- * `onText`, when supplied, receives assistant text deltas as they stream so the
80
- * caller can render them live (see the REPL); history is unaffected.
80
+ * `renderer`, when supplied, receives assistant text deltas and tool-call
81
+ * progress as they stream so the caller sees the turn live (see the REPL);
82
+ * history is unaffected.
81
83
  */
82
- send(userPrompt: string, onText?: (delta: string) => void): Promise<AgentResult>;
84
+ send(userPrompt: string, renderer?: StreamRenderer): Promise<AgentResult>;
83
85
  /**
84
86
  * Re-read project instructions (CRUXY.md / AGENTS.md) from the working
85
87
  * directory so edits take effect without restarting. Returns the new text, or
@@ -74,10 +74,11 @@ export class Session {
74
74
  * the threshold, drive the agent loop over the full history, adopt the
75
75
  * extended history, and accumulate usage. Returns the turn's `AgentResult`.
76
76
  *
77
- * `onText`, when supplied, receives assistant text deltas as they stream so the
78
- * caller can render them live (see the REPL); history is unaffected.
77
+ * `renderer`, when supplied, receives assistant text deltas and tool-call
78
+ * progress as they stream so the caller sees the turn live (see the REPL);
79
+ * history is unaffected.
79
80
  */
80
- async send(userPrompt, onText) {
81
+ async send(userPrompt, renderer) {
81
82
  this.messages.push({ role: "user", content: userPrompt });
82
83
  // Compact *before* the agent call so the turn runs against a bounded history.
83
84
  await this.maybeCompact();
@@ -88,7 +89,7 @@ export class Session {
88
89
  ? await this.args.planRunner({
89
90
  messages: this.messages,
90
91
  projectInstructions: this.projectInstructions,
91
- onText,
92
+ renderer,
92
93
  })
93
94
  : await runAgent({
94
95
  messages: this.messages,
@@ -96,7 +97,7 @@ export class Session {
96
97
  // After the spread so a mid-session `/reload` wins over the initial value.
97
98
  projectInstructions: this.projectInstructions,
98
99
  planMode: false, // the plan directive belongs only to the runner's propose phase
99
- onText,
100
+ renderer,
100
101
  });
101
102
  this.messages = result.messages;
102
103
  this.usage.input_tokens += result.usage.input_tokens;
@@ -18,6 +18,8 @@ export function classify(action, cwd) {
18
18
  return shellRequest(action, root);
19
19
  case "vcs":
20
20
  return vcsRequest(action, root);
21
+ case "rollback":
22
+ return rollbackRequest(action, root);
21
23
  default:
22
24
  return {
23
25
  action,
@@ -85,6 +87,30 @@ function vcsRequest(action, root) {
85
87
  cwd: root,
86
88
  };
87
89
  }
90
+ // ── rollback (restore checkpoint) ──────────────────────────────────────────────
91
+ /**
92
+ * A checkpoint restore (C.32): overwrite the working tree with a pre-run
93
+ * snapshot. Always `destructive` (it reverts edits, deletes created files, and
94
+ * recreates deleted ones in one operation) and never session-grantable — scope
95
+ * `none`, so every rollback is a deliberate, one-off approval. The preview
96
+ * carries the full blast radius; the summary names the checkpoint.
97
+ */
98
+ function rollbackRequest(action, root) {
99
+ const preview = action.preview?.type === "rollback" ? action.preview : undefined;
100
+ const summary = preview
101
+ ? `rollback: restore checkpoint ${preview.checkpointId} (${preview.files.length} file${preview.files.length === 1 ? "" : "s"})`
102
+ : "rollback: restore a checkpoint";
103
+ return {
104
+ action,
105
+ tier: "destructive",
106
+ scope: { kind: "none" },
107
+ summary,
108
+ targets: preview
109
+ ? preview.files.map((f) => path.resolve(root, f.path))
110
+ : [],
111
+ cwd: root,
112
+ };
113
+ }
88
114
  // ── file (write / edit / patch) ────────────────────────────────────────────────
89
115
  function fileRequest(action, tier, root) {
90
116
  const targets = fileTargets(action, root);
@@ -1,4 +1,13 @@
1
1
  import type { ApprovalRequest } from "./types.js";
2
+ /**
3
+ * The interactive prompt: render a pending action (a real diff for file edits,
4
+ * the exact command + cwd for shell) and read a 4-way choice. Rendering is data
5
+ * → string so it's testable; color is gated on `io.color` (NO_COLOR / non-TTY
6
+ * aware). **Default-deny**: EOF / Ctrl-C / any unrecognized key → reject.
7
+ *
8
+ * Diff/preview rendering is the shared implementation in `render/diff.ts` —
9
+ * the streaming path and this prompt draw the same bytes for the same change.
10
+ */
2
11
  /** The four user choices (plus the implicit default-deny). */
3
12
  export type PromptChoice = {
4
13
  kind: "once";
@@ -1,7 +1,6 @@
1
1
  import path from "node:path";
2
2
  import pc from "picocolors";
3
- /** Cap on rendered preview lines before collapsing the rest. */
4
- const PREVIEW_MAX_LINES = 40;
3
+ import { renderActionPreview } from "../render/diff.js";
5
4
  /**
6
5
  * Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
7
6
  * read a follow-up line (reason / instruction). Anything else — including EOF —
@@ -53,7 +52,7 @@ function detail(request, c) {
53
52
  ` ${c.dim(`in ${request.cwd}`)}`,
54
53
  ].join("\n");
55
54
  }
56
- return renderPreview(request.action.preview, c);
55
+ return renderActionPreview(request.action.preview, c);
57
56
  }
58
57
  /** The choices line, including a short label of what an `a` grant would cover. */
59
58
  function choices(scope, c) {
@@ -71,80 +70,6 @@ function scopeLabel(scope) {
71
70
  return `changes under ${path.basename(scope.root)}/`;
72
71
  return null;
73
72
  }
74
- // ── diff rendering (shared with the old C.6 renderer) ──────────────────────────
75
- function diffLines(oldStr, newStr, c) {
76
- const removed = oldStr.split("\n").map((l) => c.red(`- ${l}`));
77
- const added = newStr.split("\n").map((l) => c.green(`+ ${l}`));
78
- return [...removed, ...added];
79
- }
80
- function renderPatchFiles(files, c) {
81
- const out = [];
82
- for (const file of files) {
83
- if (file.op === "delete") {
84
- out.push(c.red(`delete ${file.path}`));
85
- }
86
- else if (file.op === "create") {
87
- out.push(c.green(`create ${file.path}`));
88
- out.push(...file.lines.map((l) => c.green(`+ ${l}`)));
89
- if (file.omittedLines > 0)
90
- out.push(c.dim(` ...${file.omittedLines} more lines`));
91
- }
92
- else {
93
- out.push(c.yellow(`update ${file.path}`));
94
- for (const hunk of file.hunks)
95
- out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
96
- }
97
- }
98
- return out;
99
- }
100
- /** Render a `vcs` pull-request publish plan: branch, commit, and PR body. */
101
- function renderPrPreview(preview, c) {
102
- const out = [];
103
- out.push(`${c.bold("branch")} ${c.green(preview.branch)} → ${preview.base}`);
104
- out.push("");
105
- out.push(c.bold("commit"));
106
- out.push(` ${preview.commitSubject}`);
107
- for (const line of bodyLines(preview.commitBody))
108
- out.push(c.dim(` ${line}`));
109
- out.push("");
110
- out.push(`${c.bold("pull request")} ${preview.prTitle}`);
111
- for (const line of bodyLines(preview.prBody))
112
- out.push(c.dim(` ${line}`));
113
- return out;
114
- }
115
- /** Split a multi-line body into trimmed-of-trailing lines, dropping a trailing blank. */
116
- function bodyLines(body) {
117
- const lines = body.replace(/\s+$/, "").split("\n");
118
- return lines.length === 1 && lines[0] === "" ? [] : lines;
119
- }
120
- function renderPreview(preview, c) {
121
- if (!preview)
122
- return "";
123
- let lines;
124
- if (preview.type === "edit") {
125
- lines = diffLines(preview.oldStr, preview.newStr, c);
126
- }
127
- else if (preview.type === "patch") {
128
- lines = renderPatchFiles(preview.files, c);
129
- }
130
- else if (preview.type === "pr") {
131
- lines = renderPrPreview(preview, c);
132
- }
133
- else {
134
- const header = preview.exists
135
- ? c.yellow("OVERWRITE existing")
136
- : c.green("create");
137
- const body = preview.lines.map((l) => ` ${l}`);
138
- if (preview.omittedLines > 0)
139
- body.push(c.dim(` ...${preview.omittedLines} more lines`));
140
- lines = [header, ...body];
141
- }
142
- if (lines.length > PREVIEW_MAX_LINES) {
143
- const hidden = lines.length - PREVIEW_MAX_LINES;
144
- lines = [...lines.slice(0, PREVIEW_MAX_LINES), c.dim(`...${hidden} more`)];
145
- }
146
- return lines.map((l) => ` ${l}`).join("\n");
147
- }
148
73
  // ── default stdin-backed PromptIO ──────────────────────────────────────────────
149
74
  /** Build the real PromptIO: prompt to stderr, read keys/lines from stdin. */
150
75
  export function defaultPromptIO(color) {
@@ -0,0 +1,17 @@
1
+ import type { CaptureFile } from "./types.js";
2
+ /**
3
+ * Snapshot-scope enumeration (C.32): every regular file the agent could touch —
4
+ * tracked + untracked-non-ignored — and nothing it must never see:
5
+ * • gitignored paths (they are not the run's undo unit and may be huge),
6
+ * • the C.17 secrets denylist ({@link isSecretPath} — a checkpoint must never
7
+ * copy a secret into `.cruxy/` or the git object DB),
8
+ * • `.cruxy/` itself (a checkpoint of the checkpoints would recurse),
9
+ * • symlinks and other non-regular files (restore writes plain files only).
10
+ *
11
+ * In a git repo the file list comes from `git ls-files` (read-only), which
12
+ * honors `.gitignore`, `.git/info/exclude`, and the user's global excludes
13
+ * exactly. Outside a repo, the indexing walker enumerates with its gitignore
14
+ * emulation — with binaries included and no size cap, because a snapshot that
15
+ * skips files cannot restore them.
16
+ */
17
+ export declare function captureFiles(root: string, gitWorkTree: boolean): Promise<CaptureFile[]>;
@@ -0,0 +1,73 @@
1
+ import { promises as fsp } from "node:fs";
2
+ import path from "node:path";
3
+ import { runGitCapture } from "../vcs/git.js";
4
+ import { isSecretPath, walkRepo } from "../indexing/walker.js";
5
+ import { GLOBAL_DIR_NAME } from "../constants.js";
6
+ /**
7
+ * Snapshot-scope enumeration (C.32): every regular file the agent could touch —
8
+ * tracked + untracked-non-ignored — and nothing it must never see:
9
+ * • gitignored paths (they are not the run's undo unit and may be huge),
10
+ * • the C.17 secrets denylist ({@link isSecretPath} — a checkpoint must never
11
+ * copy a secret into `.cruxy/` or the git object DB),
12
+ * • `.cruxy/` itself (a checkpoint of the checkpoints would recurse),
13
+ * • symlinks and other non-regular files (restore writes plain files only).
14
+ *
15
+ * In a git repo the file list comes from `git ls-files` (read-only), which
16
+ * honors `.gitignore`, `.git/info/exclude`, and the user's global excludes
17
+ * exactly. Outside a repo, the indexing walker enumerates with its gitignore
18
+ * emulation — with binaries included and no size cap, because a snapshot that
19
+ * skips files cannot restore them.
20
+ */
21
+ export async function captureFiles(root, gitWorkTree) {
22
+ const absRoot = path.resolve(root);
23
+ const candidates = gitWorkTree
24
+ ? await gitCandidates(absRoot)
25
+ : await walkerCandidates(absRoot);
26
+ const files = [];
27
+ for (const relPath of candidates) {
28
+ if (relPath === "" || isExcluded(relPath))
29
+ continue;
30
+ const absPath = path.join(absRoot, ...relPath.split("/"));
31
+ // lstat: a symlink must be seen as a symlink, not its target.
32
+ let stat;
33
+ try {
34
+ stat = await fsp.lstat(absPath);
35
+ }
36
+ catch {
37
+ continue; // listed but gone (e.g. tracked file deleted from the worktree)
38
+ }
39
+ if (!stat.isFile())
40
+ continue;
41
+ files.push({ path: relPath, absPath });
42
+ }
43
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
44
+ return files;
45
+ }
46
+ /** Never capture cruxy's own state dir or a secret-bearing path. */
47
+ function isExcluded(relPath) {
48
+ return (relPath === GLOBAL_DIR_NAME ||
49
+ relPath.startsWith(`${GLOBAL_DIR_NAME}/`) ||
50
+ isSecretPath(relPath));
51
+ }
52
+ /** Tracked + untracked-non-ignored, straight from git (paths relative to root). */
53
+ async function gitCandidates(absRoot) {
54
+ const res = runGitCapture(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], absRoot);
55
+ if (!res.ok) {
56
+ // The caller decided this is a work tree; a failing ls-files means git is
57
+ // in a state we can't reason about — let the store fallback handle it.
58
+ throw new Error(`git ls-files failed: ${res.stderr.trim()}`);
59
+ }
60
+ // -z output: NUL-separated, no quoting, trailing NUL yields one empty entry.
61
+ return [...new Set(res.stdout.split("\0"))];
62
+ }
63
+ /** Walker enumeration for non-git dirs: gitignore-style ignores, no content filters. */
64
+ async function walkerCandidates(absRoot) {
65
+ const out = [];
66
+ for await (const entry of walkRepo(absRoot, {
67
+ maxFileBytes: Number.MAX_SAFE_INTEGER,
68
+ includeBinary: true,
69
+ })) {
70
+ out.push(entry.relPath);
71
+ }
72
+ return out;
73
+ }