@cruxy/cli 1.2.1 → 1.4.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 (85) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +20 -1
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +171 -69
  7. package/dist/agent/status.js +56 -0
  8. package/dist/approval/classify.js +204 -0
  9. package/dist/approval/policy.js +41 -3
  10. package/dist/approval/prompt.js +49 -22
  11. package/dist/checkpoint/gate.js +12 -0
  12. package/dist/cli/commands/run.js +401 -227
  13. package/dist/cli/commands/usage.js +45 -45
  14. package/dist/cli/onboard.js +2 -1
  15. package/dist/cli/program.js +60 -18
  16. package/dist/cli/repl.js +67 -249
  17. package/dist/cli/session-commands.js +717 -0
  18. package/dist/cli/session-factory.js +198 -76
  19. package/dist/cli/suggest.js +77 -0
  20. package/dist/components/fuzzy.js +3 -3
  21. package/dist/components/input.js +17 -2
  22. package/dist/components/keys.js +65 -3
  23. package/dist/components/select.js +3 -3
  24. package/dist/config/effective.js +225 -0
  25. package/dist/config/index.js +1 -0
  26. package/dist/config/manager.js +50 -20
  27. package/dist/config/project.js +53 -1
  28. package/dist/config/schema.js +49 -16
  29. package/dist/jobs/log-renderer.js +47 -0
  30. package/dist/onboarding/steps.js +13 -22
  31. package/dist/plan/approve.js +36 -24
  32. package/dist/plan/execute.js +9 -7
  33. package/dist/plan/render.js +10 -23
  34. package/dist/plan/service.js +4 -1
  35. package/dist/render/capabilities.js +30 -1
  36. package/dist/render/context-view.js +106 -0
  37. package/dist/render/diff.js +204 -12
  38. package/dist/render/index.js +31 -5
  39. package/dist/render/plain-renderer.js +38 -2
  40. package/dist/render/plan-view.js +108 -0
  41. package/dist/render/resize.js +7 -2
  42. package/dist/render/status-view.js +66 -0
  43. package/dist/render/test-view.js +89 -0
  44. package/dist/render/tty-renderer.js +40 -0
  45. package/dist/routing/index.js +1 -0
  46. package/dist/routing/router.js +13 -4
  47. package/dist/routing/session-model.js +109 -0
  48. package/dist/routing/types.js +14 -0
  49. package/dist/session/export.js +88 -0
  50. package/dist/session/index.js +20 -0
  51. package/dist/session/list.js +137 -0
  52. package/dist/session/log.js +137 -0
  53. package/dist/session/paths.js +73 -0
  54. package/dist/session/replay.js +169 -0
  55. package/dist/session/resume.js +128 -0
  56. package/dist/session/types.js +223 -0
  57. package/dist/subagent/orchestrator.js +23 -0
  58. package/dist/testing/run-tests-tool.js +8 -0
  59. package/dist/tools/registry.js +3 -3
  60. package/dist/tui/app.js +508 -0
  61. package/dist/tui/approval-overlay.js +160 -0
  62. package/dist/tui/context-gauge.js +48 -0
  63. package/dist/tui/git-status.js +108 -0
  64. package/dist/tui/git-view.js +121 -0
  65. package/dist/tui/index.js +15 -0
  66. package/dist/tui/layout.js +314 -0
  67. package/dist/tui/overlay.js +105 -0
  68. package/dist/tui/overview.js +49 -0
  69. package/dist/tui/palette.js +73 -0
  70. package/dist/tui/panels.js +235 -0
  71. package/dist/tui/renderer.js +1121 -0
  72. package/dist/tui/settings-view.js +282 -0
  73. package/dist/tui/supports.js +20 -0
  74. package/dist/tui/tasks-view.js +215 -0
  75. package/dist/tui/tool-versions.js +129 -0
  76. package/dist/tui/views.js +66 -0
  77. package/dist/usage/collect.js +6 -6
  78. package/dist/usage/index.js +10 -2
  79. package/dist/usage/report.js +76 -0
  80. package/dist/usage/summary.js +106 -17
  81. package/dist/usage/types.js +5 -2
  82. package/dist/usage/weighted.js +77 -0
  83. package/dist/utils/git.js +163 -4
  84. package/package.json +1 -1
  85. package/dist/usage/cost.js +0 -29
@@ -0,0 +1,56 @@
1
+ import { readContext } from "./context.js";
2
+ import { modeDescription } from "./mode.js";
3
+ /** Count real user turns: `role: "user"` with STRING content — tool results
4
+ * are also role "user" but carry blocks, so this counts what the human said. */
5
+ function userTurns(session) {
6
+ return session.messages.filter((m) => m.role === "user" && typeof m.content === "string").length;
7
+ }
8
+ /** Everything `/status` and the Overview view show, from live session state. */
9
+ export function buildSessionStatus(session, git,
10
+ /**
11
+ * The tier the gateway last said actually SERVED a request, when the caller
12
+ * is somewhere that knows it. Only the TUI renderer tracks this — it arrives
13
+ * on the stream's routing frame — so `/status`, which has no renderer, omits
14
+ * it and shows the configured value alone.
15
+ */
16
+ servedTier) {
17
+ const toolCtx = session.toolContext;
18
+ const config = toolCtx.config;
19
+ const mode = session.getMode();
20
+ const roots = toolCtx.workspace.roots().map((r) => {
21
+ const info = git(r.absPath);
22
+ return {
23
+ name: r.name,
24
+ path: r.absPath,
25
+ primary: r.primary,
26
+ // Spread rather than assigned, so "not probed" stays ABSENT rather than
27
+ // becoming an explicit `undefined` the renderer would have to re-check.
28
+ ...(info === undefined ? {} : { git: info }),
29
+ };
30
+ });
31
+ const jobs = session.jobs?.list();
32
+ return {
33
+ sessionId: session.sessionId,
34
+ turns: userTurns(session),
35
+ mode,
36
+ modeDescription: modeDescription(mode),
37
+ ...(session.model ? { model: session.model.current() } : {}),
38
+ ...(servedTier === undefined ? {} : { servedTier }),
39
+ provider: config.model.provider,
40
+ roots,
41
+ context: readContext(session.messages, config.context),
42
+ sandboxEnabled: Boolean(toolCtx.sandbox),
43
+ ...(toolCtx.sandbox ? { sandboxRuntime: toolCtx.sandbox.runtimeName } : {}),
44
+ checkpoints: Boolean(toolCtx.checkpointsActive),
45
+ ...(jobs
46
+ ? {
47
+ jobs: {
48
+ total: jobs.length,
49
+ running: jobs.filter((j) => j.status === "running").length,
50
+ needingApproval: jobs.filter((j) => j.pendingApproval).length,
51
+ },
52
+ }
53
+ : {}),
54
+ tools: session.toolRegistry.list().length,
55
+ };
56
+ }
@@ -4,6 +4,10 @@ import path from "node:path";
4
4
  * {@link Scope}. The cardinal rule: **anything unrecognized is `destructive`**
5
5
  * (most-restrictive) so a future/unknown action can never slip through as
6
6
  * read-only or low-risk.
7
+ *
8
+ * The same pass sets `irreversible` — the separate, stricter axis that bounds
9
+ * auto-approve. See {@link ApprovalRequest.irreversible} for why the tier cannot
10
+ * do that job, and the `── reversibility` section below for the rules.
7
11
  */
8
12
  export function classify(action, cwd) {
9
13
  const root = path.resolve(cwd);
@@ -32,9 +36,182 @@ export function classify(action, cwd) {
32
36
  summary: `perform an unrecognized action (${describeKind(action)})`,
33
37
  targets: [],
34
38
  cwd: root,
39
+ irreversible: "this action kind is unrecognized, so nothing can bound what it changes",
35
40
  };
36
41
  }
37
42
  }
43
+ // ── reversibility (P5 track 3) ────────────────────────────────────────────────
44
+ /**
45
+ * What the checkpoint actually captures, because every rule below is derived
46
+ * from it rather than from intuition about which verbs sound scary:
47
+ *
48
+ * • `capture.ts` enumerates with `git ls-files --cached --others
49
+ * --exclude-standard` — tracked + untracked-non-ignored files **under one
50
+ * root**. So: nothing outside a declared root, and nothing gitignored
51
+ * (`node_modules/`, build output, `.env`).
52
+ * • `git-store.ts` states it outright: "no ref is ever created or moved, and
53
+ * HEAD / the index / the stash are never written." `.git/` is never
54
+ * enumerated at all. So: **no git history, refs, branches, or stash.**
55
+ * • Nothing off-disk. A checkpoint cannot un-send a push, un-publish a
56
+ * package, or un-call an HTTP endpoint.
57
+ *
58
+ * Everything a checkpoint DOES cover, it covers completely: `withCheckpointGate`
59
+ * snapshots the whole root before the first mutation of a run, so arbitrary
60
+ * in-root file damage — including `rm -rf` of the workspace — is restorable.
61
+ * That is why the rules key on *escape* (out of the root, into `.git`, onto the
62
+ * network) rather than on how destructive an in-root command looks.
63
+ *
64
+ * ── Residual risk, stated plainly ──
65
+ * No static analysis can prove a shell command's effects. {@link ESCAPES_ROOT}
66
+ * is a denylist, and denylists lose: `node deploy.js` or an unlisted CLI can
67
+ * still reach the network. It is a belt. The braces are the two rules that fail
68
+ * *closed* — an unprovable command (any shell metacharacter) and any argument
69
+ * resolving outside the root are both irreversible without consulting any list
70
+ * — plus the pre-command checkpoint, which bounds the on-disk half absolutely.
71
+ * Auto-approve is a deliberate, per-session, visibly-announced choice; this is
72
+ * the ceiling on that choice, not a sandbox.
73
+ */
74
+ /**
75
+ * `git` subcommands that only read. Everything else `git` does is irreversible
76
+ * by construction — it writes refs, HEAD, the index, or the stash, none of which
77
+ * the checkpoint captures. An allowlist, not a denylist, so `push --force`,
78
+ * `reset --hard`, `rebase`, `filter-branch`, `branch -D`, `tag -d`, and every
79
+ * future ref-mutating subcommand are caught without being enumerated.
80
+ */
81
+ const GIT_READ_ONLY = new Set([
82
+ "blame",
83
+ "cat-file",
84
+ "describe",
85
+ "diff",
86
+ "for-each-ref",
87
+ "grep",
88
+ "log",
89
+ "ls-files",
90
+ "rev-list",
91
+ "rev-parse",
92
+ "shortlog",
93
+ "show",
94
+ "status",
95
+ ]);
96
+ /**
97
+ * Package-manager subcommands whose effects stay in the checkpointed tree.
98
+ * `run`/`test` execute project scripts — arbitrary code, but code whose on-disk
99
+ * damage the pre-command checkpoint restores. `install` is NOT here: it writes
100
+ * `node_modules/`, which is gitignored and therefore never captured, and reaches
101
+ * a registry. `publish`, `link`, `login`, `config` escape outright.
102
+ */
103
+ const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
104
+ const PACKAGE_MANAGER_LOCAL = new Set(["run", "test"]);
105
+ /**
106
+ * Programs whose primary purpose is to reach something the checkpoint cannot
107
+ * restore — the network, another host, a registry, cloud state, or the machine
108
+ * itself. Not exhaustive by construction (see the residual-risk note above);
109
+ * these are the ones an agent loop actually reaches for.
110
+ */
111
+ const ESCAPES_ROOT = new Map([
112
+ ["curl", "it sends network requests, which no checkpoint can recall"],
113
+ ["wget", "it sends network requests, which no checkpoint can recall"],
114
+ ["ssh", "it runs commands on another host, outside any checkpoint"],
115
+ ["scp", "it copies to another host, outside any checkpoint"],
116
+ ["sftp", "it transfers to another host, outside any checkpoint"],
117
+ ["rsync", "it can write outside the workspace and to other hosts"],
118
+ ["gh", "it acts on GitHub under your identity; remote state is not captured"],
119
+ [
120
+ "glab",
121
+ "it acts on GitLab under your identity; remote state is not captured",
122
+ ],
123
+ ["docker", "it changes container and image state outside the workspace"],
124
+ ["kubectl", "it changes live cluster state, which is not captured"],
125
+ ["helm", "it changes live cluster state, which is not captured"],
126
+ ["terraform", "it changes real infrastructure, which is not captured"],
127
+ ["ansible", "it changes remote hosts, which are not captured"],
128
+ ["aws", "it changes cloud account state, which is not captured"],
129
+ ["gcloud", "it changes cloud account state, which is not captured"],
130
+ ["az", "it changes cloud account state, which is not captured"],
131
+ ["sudo", "it runs with elevated privileges, outside every guarantee here"],
132
+ ["doas", "it runs with elevated privileges, outside every guarantee here"],
133
+ ["su", "it runs as another user, outside every guarantee here"],
134
+ ["systemctl", "it changes system service state, which is not captured"],
135
+ ["launchctl", "it changes system service state, which is not captured"],
136
+ ["shutdown", "it halts the machine"],
137
+ ["reboot", "it restarts the machine"],
138
+ ["dd", "it writes raw devices, which no checkpoint can restore"],
139
+ ["mkfs", "it formats a filesystem, which no checkpoint can restore"],
140
+ ["diskutil", "it changes disk state, which no checkpoint can restore"],
141
+ ]);
142
+ /**
143
+ * Why the checkpoint cannot restore `command`, or `null` when it can.
144
+ *
145
+ * Three gates, in order of how hard they fail closed. The first two need no
146
+ * list to be correct; the third is the belt described above.
147
+ */
148
+ function shellIrreversibility(command, root) {
149
+ const tokens = commandTokens(command);
150
+ if (tokens === null) {
151
+ // Deny-by-default, and the same proof `commandTokens` already gates session
152
+ // grants on: with a pipe, redirect, substitution, glob, or `~`, we cannot
153
+ // say what runs, so we cannot say what it touches.
154
+ return "the command uses shell features we cannot analyze, so its effects cannot be bounded";
155
+ }
156
+ const program = path.basename(tokens[0]);
157
+ const args = tokens.slice(1);
158
+ // The first argument that is not an option — the subcommand, for the
159
+ // multiplexers below.
160
+ const subcommand = args.find((a) => !a.startsWith("-"));
161
+ if (program === "git" && !(subcommand && GIT_READ_ONLY.has(subcommand))) {
162
+ return "git writes refs, HEAD, the index, or the stash — none of which the checkpoint captures";
163
+ }
164
+ if (PACKAGE_MANAGERS.has(program) &&
165
+ !(subcommand && PACKAGE_MANAGER_LOCAL.has(subcommand))) {
166
+ return `\`${program}${subcommand ? ` ${subcommand}` : ""}\` reaches a registry or gitignored install state, which the checkpoint does not capture`;
167
+ }
168
+ const escapes = ESCAPES_ROOT.get(program);
169
+ if (escapes)
170
+ return `\`${program}\` is not checkpointable — ${escapes}`;
171
+ const outside = args.find((a) => escapesRoot(a, root));
172
+ if (outside) {
173
+ return `\`${outside}\` resolves outside the workspace root, which the checkpoint does not capture`;
174
+ }
175
+ return null;
176
+ }
177
+ /**
178
+ * Whether a command argument names a path the checkpoint would not cover.
179
+ *
180
+ * Handles `--flag=<path>` as well as a bare path, because the flag form is how
181
+ * an out-of-root target most often sneaks past a naive check. Absolute paths and
182
+ * anything containing `..` are resolved against the root and tested; a plain
183
+ * relative token cannot escape, so it is left alone. (`~` and `*` never reach
184
+ * here — {@link commandTokens} already rejects the whole command for those.)
185
+ */
186
+ function escapesRoot(arg, root) {
187
+ const eq = arg.indexOf("=");
188
+ const candidate = eq === -1 ? arg : arg.slice(eq + 1);
189
+ if (candidate === "")
190
+ return false;
191
+ const looksLikePath = candidate.startsWith("/") || candidate.split("/").includes("..");
192
+ if (!looksLikePath)
193
+ return false;
194
+ return !isInside(root, path.resolve(root, candidate));
195
+ }
196
+ /**
197
+ * Why the checkpoint cannot restore a file action, or `null` when it can.
198
+ *
199
+ * A delete is not special here — `patchHasDelete` raises the *tier* because a
200
+ * delete deserves a louder prompt, but a deleted file inside the root is
201
+ * restored from the snapshot exactly like an edited one. What matters is
202
+ * containment, and that every target is known: an action carrying no resolved
203
+ * target cannot be shown to be covered, so it is not.
204
+ */
205
+ function fileIrreversibility(targets, root) {
206
+ if (targets.length === 0) {
207
+ return "the action names no resolvable target, so the checkpoint cannot be shown to cover it";
208
+ }
209
+ const outside = targets.find((t) => !isInside(root, t));
210
+ if (outside) {
211
+ return `\`${outside}\` is outside the workspace root, which the checkpoint does not capture`;
212
+ }
213
+ return null;
214
+ }
38
215
  // ── shell ─────────────────────────────────────────────────────────────────────
39
216
  // Any shell control/expansion character means we cannot prove the command is a
40
217
  // single simple invocation. Covers && || ; | ` $ ( ) { } * ? ~ ! # ' " \ < > and
@@ -71,6 +248,7 @@ function shellRequest(action, root) {
71
248
  summary: `run: ${command}`,
72
249
  targets: [],
73
250
  cwd: root,
251
+ irreversible: shellIrreversibility(command, root),
74
252
  };
75
253
  }
76
254
  // ── test (run the project's test suite, C.13) ──────────────────────────────────
@@ -92,6 +270,16 @@ function testRequest(action, root) {
92
270
  summary: `run tests: ${command}`,
93
271
  targets: [],
94
272
  cwd: root,
273
+ // A test command comes from the project's own package.json and runs after
274
+ // the pre-command checkpoint, so its on-disk effects are restorable — this
275
+ // is the one destructive-tier action auto-approve is meant to cover, and
276
+ // what keeps `full-auto` able to verify the code it just wrote. It is still
277
+ // held to the SAME shell rules: a test script that force-pushes or curls is
278
+ // no more restorable for being called "test". An empty command names
279
+ // nothing to reason about.
280
+ irreversible: command === ""
281
+ ? "the test action names no command, so its effects cannot be bounded"
282
+ : shellIrreversibility(command, root),
95
283
  };
96
284
  }
97
285
  // ── vcs (open pull request) ─────────────────────────────────────────────────────
@@ -110,6 +298,7 @@ function vcsRequest(action, root) {
110
298
  summary: `open PR: ${title}`,
111
299
  targets: [],
112
300
  cwd: root,
301
+ irreversible: "opening a PR commits and pushes under your identity; the checkpoint captures working-tree files, never commits, refs, or anything already on the remote",
113
302
  };
114
303
  }
115
304
  // ── rollback (restore checkpoint) ──────────────────────────────────────────────
@@ -137,6 +326,7 @@ function rollbackRequest(action, root) {
137
326
  `(${fileCount} file${fileCount === 1 ? "" : "s"})`,
138
327
  targets: [],
139
328
  cwd: root,
329
+ irreversible: ROLLBACK_IRREVERSIBLE,
140
330
  };
141
331
  }
142
332
  const preview = action.preview?.type === "rollback" ? action.preview : undefined;
@@ -152,8 +342,17 @@ function rollbackRequest(action, root) {
152
342
  ? preview.files.map((f) => path.resolve(root, f.path))
153
343
  : [],
154
344
  cwd: root,
345
+ irreversible: ROLLBACK_IRREVERSIBLE,
155
346
  };
156
347
  }
348
+ /**
349
+ * A rollback is the one action the checkpoint system cannot protect you from:
350
+ * `withCheckpointGate` takes no snapshot for `kind: "rollback"`, so restoring a
351
+ * checkpoint discards whatever the tree currently holds with nothing to undo it
352
+ * with. It is never auto-approved — the mode whose safety net IS the checkpoint
353
+ * must not be able to silently spend it.
354
+ */
355
+ const ROLLBACK_IRREVERSIBLE = "a rollback overwrites the working tree and is not itself checkpointed, so the current state would be unrecoverable";
157
356
  // ── mcp (call an external MCP server's tool, C.27) ──────────────────────────────
158
357
  /**
159
358
  * An MCP tool call. Always `destructive` — the tool is arbitrary code in a
@@ -176,6 +375,10 @@ function mcpRequest(action, root) {
176
375
  summary: `call MCP tool ${tool || "(unknown)"} on server ${server || "(unknown)"}`,
177
376
  targets: [],
178
377
  cwd: root,
378
+ // Same reasoning as the tier: the server cannot vouch for itself. A
379
+ // `readOnlyHint` is a claim by the thing being gated, and the checkpoint
380
+ // cannot reach whatever it did anyway.
381
+ irreversible: "an MCP tool runs unsandboxed in an external server, so its effects are outside the checkpoint entirely",
179
382
  };
180
383
  }
181
384
  // ── file (write / edit / patch) ────────────────────────────────────────────────
@@ -188,6 +391,7 @@ function fileRequest(action, tier, root) {
188
391
  summary: fileSummary(action, targets, root),
189
392
  targets,
190
393
  cwd: root,
394
+ irreversible: fileIrreversibility(targets, root),
191
395
  };
192
396
  }
193
397
  /** Absolute target paths for a file action (write/edit: one; patch: many). */
@@ -75,20 +75,58 @@ export function scopeCovers(scope, request) {
75
75
  export class InteractivePolicy {
76
76
  allowlist;
77
77
  io;
78
- constructor(allowlist, io) {
78
+ autoApprove;
79
+ constructor(allowlist, io,
80
+ /**
81
+ * Whether the session is in an auto-approving mode (P5 track 3).
82
+ *
83
+ * A THUNK, not a boolean: the mode is runtime state the user can change at
84
+ * any point, including between two actions of the same turn. Reading it
85
+ * once at construction would let a session that has been switched back to
86
+ * `manual` keep auto-approving for the rest of its life — a stale copy of
87
+ * exactly the fact this must never be wrong about.
88
+ *
89
+ * Omitted → never auto-approves, which is every non-interactive caller.
90
+ */
91
+ autoApprove = () => false) {
79
92
  this.allowlist = allowlist;
80
93
  this.io = io;
94
+ this.autoApprove = autoApprove;
81
95
  }
82
96
  async decide(request) {
97
+ // A grant the allowlist answers shows the user nothing (P3) — left
98
+ // unflagged so a caller can render the change instead.
83
99
  if (this.allowlist.allows(request))
84
100
  return { allow: true };
101
+ // Auto-approve: allowed without a prompt, and deliberately NOT flagged as
102
+ // `prompted` — so `previewSilentApprovals` renders the diff. An unattended
103
+ // mode that showed nothing would let a session's whole body of work land
104
+ // invisibly, which is a different failure from the one it is meant to save
105
+ // the user: skipping the QUESTION is the point, skipping the RECORD is not.
106
+ //
107
+ // THE CEILING (P5 track 3). Auto-approve suppresses the prompt for
108
+ // reversible actions ONLY. An action the run's checkpoint cannot restore —
109
+ // a delete outside the workspace, a force-push, a history rewrite, an MCP
110
+ // call, a rollback, anything unprovable — falls through to the prompt even
111
+ // here, and even unattended.
112
+ //
113
+ // This is the line the product position rests on. "Only change what was
114
+ // asked, no silent deletions" is not a claim a mode may switch off:
115
+ // suppressing prompts is a convenience, and suppressing the last gate before
116
+ // irreversible damage is a different thing wearing the same word. Note the
117
+ // gate is `request.irreversible`, NOT `request.tier` — the tier is an
118
+ // unprovability signal (every shell command is `destructive`, `ls`
119
+ // included), so gating on it would make the mode either useless or
120
+ // dishonest. See `ApprovalRequest.irreversible`.
121
+ if (this.autoApprove() && !request.irreversible)
122
+ return { allow: true };
85
123
  const choice = await promptForApproval(request, this.io);
86
124
  switch (choice.kind) {
87
125
  case "once":
88
- return { allow: true };
126
+ return { allow: true, prompted: true };
89
127
  case "session":
90
128
  this.allowlist.grant(request);
91
- return { allow: true };
129
+ return { allow: true, prompted: true };
92
130
  case "reject":
93
131
  return choice.reason
94
132
  ? {
@@ -10,29 +10,39 @@ import { themeForColor } from "../theme/index.js";
10
10
  * is a reject.
11
11
  */
12
12
  export async function promptForApproval(request, io) {
13
- io.write(render(request, io.color, io.columns ?? resolveColumns()));
14
- const key = (await io.readKey()).toLowerCase();
15
- io.write("\n");
16
- switch (key) {
17
- case "y":
18
- return { kind: "once" };
19
- case "a":
20
- return { kind: "session" };
21
- case "n": {
22
- io.write(" reason (optional, sent to the agent): ");
23
- const reason = (await io.readLine()).trim();
24
- return reason ? { kind: "reject", reason } : { kind: "reject" };
25
- }
26
- case "t": {
27
- io.write(" what should the agent do instead? ");
28
- const instruction = (await io.readLine()).trim();
29
- return instruction
30
- ? { kind: "instruct", instruction }
31
- : { kind: "reject" };
13
+ // The terminal is about to belong to this prompt: yield the live region
14
+ // BEFORE the question lands, or it would be painted into a region the next
15
+ // repaint erases (U.2/U.4). `finally` gives it back on every path, including
16
+ // the default-deny ones.
17
+ io.beginPrompt?.();
18
+ try {
19
+ io.write(render(request, io.color, io.columns ?? resolveColumns()));
20
+ const key = (await io.readKey()).toLowerCase();
21
+ io.write("\n");
22
+ switch (key) {
23
+ case "y":
24
+ return { kind: "once" };
25
+ case "a":
26
+ return { kind: "session" };
27
+ case "n": {
28
+ io.write(" reason (optional, sent to the agent): ");
29
+ const reason = (await io.readLine()).trim();
30
+ return reason ? { kind: "reject", reason } : { kind: "reject" };
31
+ }
32
+ case "t": {
33
+ io.write(" what should the agent do instead? ");
34
+ const instruction = (await io.readLine()).trim();
35
+ return instruction
36
+ ? { kind: "instruct", instruction }
37
+ : { kind: "reject" };
38
+ }
39
+ default:
40
+ // n/a key, empty, EOF, Ctrl-C → default-deny.
41
+ return { kind: "reject" };
32
42
  }
33
- default:
34
- // n/a key, empty, EOF, Ctrl-C → default-deny.
35
- return { kind: "reject" };
43
+ }
44
+ finally {
45
+ io.endPrompt?.();
36
46
  }
37
47
  }
38
48
  /** Render the full prompt block: header, detail (diff or command+cwd), choices. */
@@ -48,10 +58,27 @@ export function render(request, color, columns = resolveColumns()) {
48
58
  const label = tierLabel(request.tier, t);
49
59
  const lines = [];
50
60
  lines.push(...header(request.summary, mark, label, t, columns));
61
+ lines.push(...whyAsking(request.irreversible, t, columns));
51
62
  lines.push(detail(request, t, columns));
52
63
  lines.push(choices(request.scope, t));
53
64
  return lines.filter((l) => l !== "").join("\n") + " ";
54
65
  }
66
+ /**
67
+ * The one-clause reason the checkpoint cannot restore this action, when there is
68
+ * one. Rendered for every mode, not just the auto ones — the prompt has no way
69
+ * to know the session's mode, and a user in `manual` benefits from the same
70
+ * sentence.
71
+ *
72
+ * It earns its line in auto-approve, where an unexplained prompt is actively
73
+ * misleading: the user asked not to be asked, so the only useful thing to say is
74
+ * why this one is different. Reflowed, never truncated — a half-sentence about
75
+ * irreversibility is worse than none.
76
+ */
77
+ function whyAsking(irreversible, t, width) {
78
+ if (!irreversible)
79
+ return [];
80
+ return reflow(`no undo: ${irreversible}`, Math.max(1, width - 2)).map((l) => t.muted(` ${l}`));
81
+ }
55
82
  /**
56
83
  * The header, width-aware (U.12). Wide: the one inline line `! cruxy wants to
57
84
  * <summary> (destructive)`. Narrow: the risk (mark + tier label) stands on its
@@ -81,6 +81,18 @@ export class CheckpointGate {
81
81
  });
82
82
  await writeSet(this.primaryRoot, this.set);
83
83
  }
84
+ /**
85
+ * The current run's id, or undefined before the first {@link beginRun}.
86
+ *
87
+ * This is the ONE per-turn id in the CLI, and the session log (P2) borrows it
88
+ * rather than minting a second: `cruxy rollback <id>` reverts a turn's
89
+ * mutations and the session log records that turn's messages, so if the two
90
+ * used different ids the undo unit would mean two different things depending
91
+ * on which surface you asked. Exposed read-only — only `beginRun` sets it.
92
+ */
93
+ currentRunId() {
94
+ return this.runId ?? undefined;
95
+ }
84
96
  /** Root names that got a per-root service this process (inspection/tests). */
85
97
  get touchedRoots() {
86
98
  return [...this.services.keys()];