@cruxy/cli 0.8.0 → 0.10.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 (83) 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/approval/prompt.js +4 -27
  8. package/dist/checkpoint/capture.d.ts +17 -0
  9. package/dist/checkpoint/capture.js +73 -0
  10. package/dist/checkpoint/git-store.d.ts +61 -0
  11. package/dist/checkpoint/git-store.js +171 -0
  12. package/dist/checkpoint/index.d.ts +6 -0
  13. package/dist/checkpoint/index.js +6 -0
  14. package/dist/checkpoint/restore.d.ts +23 -0
  15. package/dist/checkpoint/restore.js +195 -0
  16. package/dist/checkpoint/service.d.ts +80 -0
  17. package/dist/checkpoint/service.js +276 -0
  18. package/dist/checkpoint/shadow-store.d.ts +23 -0
  19. package/dist/checkpoint/shadow-store.js +93 -0
  20. package/dist/checkpoint/types.d.ts +117 -0
  21. package/dist/checkpoint/types.js +18 -0
  22. package/dist/cli/commands/checkpoint.d.ts +7 -0
  23. package/dist/cli/commands/checkpoint.js +31 -0
  24. package/dist/cli/commands/rollback.d.ts +10 -0
  25. package/dist/cli/commands/rollback.js +96 -0
  26. package/dist/cli/commands/run.js +10 -2
  27. package/dist/cli/program.js +4 -0
  28. package/dist/cli/repl.d.ts +7 -1
  29. package/dist/cli/repl.js +23 -3
  30. package/dist/cli/session-factory.d.ts +14 -1
  31. package/dist/cli/session-factory.js +87 -22
  32. package/dist/components/autocomplete.d.ts +32 -0
  33. package/dist/components/autocomplete.js +50 -0
  34. package/dist/components/frame.d.ts +25 -0
  35. package/dist/components/frame.js +49 -0
  36. package/dist/components/fuzzy.d.ts +61 -0
  37. package/dist/components/fuzzy.js +174 -0
  38. package/dist/components/index.d.ts +6 -0
  39. package/dist/components/index.js +6 -0
  40. package/dist/components/input.d.ts +78 -0
  41. package/dist/components/input.js +111 -0
  42. package/dist/components/keys.d.ts +48 -0
  43. package/dist/components/keys.js +105 -0
  44. package/dist/components/select.d.ts +28 -0
  45. package/dist/components/select.js +69 -0
  46. package/dist/config/schema.d.ts +133 -0
  47. package/dist/config/schema.js +40 -0
  48. package/dist/errors/constructors.d.ts +32 -0
  49. package/dist/errors/constructors.js +101 -0
  50. package/dist/errors/types.d.ts +8 -0
  51. package/dist/errors/types.js +18 -0
  52. package/dist/indexing/walker.d.ts +11 -0
  53. package/dist/indexing/walker.js +11 -6
  54. package/dist/onboarding/io.d.ts +3 -2
  55. package/dist/onboarding/io.js +35 -81
  56. package/dist/plan/execute.d.ts +8 -0
  57. package/dist/plan/execute.js +36 -22
  58. package/dist/plan/service.js +5 -1
  59. package/dist/plan/submit-plan.d.ts +4 -4
  60. package/dist/render/diff.js +27 -0
  61. package/dist/render/index.d.ts +2 -1
  62. package/dist/render/index.js +1 -0
  63. package/dist/render/plain-renderer.d.ts +7 -1
  64. package/dist/render/plain-renderer.js +26 -0
  65. package/dist/render/state.d.ts +31 -0
  66. package/dist/render/state.js +83 -0
  67. package/dist/render/tty-renderer.d.ts +41 -5
  68. package/dist/render/tty-renderer.js +150 -23
  69. package/dist/render/types.d.ts +85 -1
  70. package/dist/subagent/budget.d.ts +34 -0
  71. package/dist/subagent/budget.js +57 -0
  72. package/dist/subagent/index.d.ts +5 -0
  73. package/dist/subagent/index.js +5 -0
  74. package/dist/subagent/orchestrator.d.ts +67 -0
  75. package/dist/subagent/orchestrator.js +241 -0
  76. package/dist/subagent/registry-scope.d.ts +28 -0
  77. package/dist/subagent/registry-scope.js +63 -0
  78. package/dist/subagent/spawn-tool.d.ts +29 -0
  79. package/dist/subagent/spawn-tool.js +94 -0
  80. package/dist/subagent/types.d.ts +55 -0
  81. package/dist/subagent/types.js +1 -0
  82. package/dist/tools/types.d.ts +20 -2
  83. package/package.json +1 -1
@@ -23,6 +23,21 @@ export function usageError(title, nextSteps) {
23
23
  nextSteps: nextSteps ?? ["run `cruxy --help` for usage"],
24
24
  });
25
25
  }
26
+ /**
27
+ * An interactive component (picker, fuzzy finder) was needed but stdin is not
28
+ * an interactive terminal and the caller supplied no default (U.7). Never
29
+ * silently picks an option, never blocks on a pipe — same discipline as the
30
+ * approval/onboarding layers.
31
+ */
32
+ export function interactiveRequired(what, alternatives = []) {
33
+ return new CruxyError({
34
+ code: ErrorCode.InteractiveRequired,
35
+ title: `${what} needs an interactive terminal`,
36
+ cause: "stdin is not a TTY (or the terminal cannot render an interactive picker)",
37
+ nextSteps: ["run cruxy in an interactive terminal", ...alternatives],
38
+ meta: { what },
39
+ });
40
+ }
26
41
  export function configKeyUnknown(key) {
27
42
  return new CruxyError({
28
43
  code: ErrorCode.ConfigKeyUnknown,
@@ -335,6 +350,92 @@ export function planApprovalRequired() {
335
350
  ],
336
351
  });
337
352
  }
353
+ // ── checkpoint + rollback (exit 7 / 2 / 10) ───────────────────────────────────
354
+ /**
355
+ * Creating, reading, or restoring a working-tree checkpoint failed (C.32).
356
+ * Fail-loud by design: an agent run never mutates files without its undo
357
+ * protection unless the user explicitly disables it.
358
+ */
359
+ export function checkpointFailed(reason, underlying) {
360
+ return new CruxyError({
361
+ code: ErrorCode.CheckpointFailed,
362
+ title: "the working-tree checkpoint operation failed",
363
+ cause: reason,
364
+ nextSteps: [
365
+ "re-run with --verbose for details",
366
+ "if this happened during rollback, re-run `cruxy rollback <id>` — it recomputes from disk and is safe to retry",
367
+ "set `checkpoint.enabled = false` in config to run without undo protection (not recommended)",
368
+ ],
369
+ underlying,
370
+ meta: { reason },
371
+ });
372
+ }
373
+ /** The requested checkpoint id doesn't exist (or no checkpoints exist at all). */
374
+ export function checkpointNotFound(id) {
375
+ return new CruxyError({
376
+ code: ErrorCode.CheckpointNotFound,
377
+ title: id
378
+ ? `checkpoint "${id}" not found`
379
+ : "no checkpoints exist in this project",
380
+ cause: id
381
+ ? "no manifest with that id under .cruxy/checkpoints/"
382
+ : "a checkpoint is created automatically before an agent run's first file change",
383
+ nextSteps: [
384
+ "run `cruxy checkpoint list` to see the saved checkpoints",
385
+ "checkpoints are pruned by retention — adjust `checkpoint.retention` in config to keep more",
386
+ ],
387
+ meta: { id },
388
+ });
389
+ }
390
+ /**
391
+ * Rollback needs interactive approval but cruxy is running non-interactively.
392
+ * Restoring is destructive and deliberate — there is no auto-rollback path, ever.
393
+ */
394
+ export function rollbackApprovalRequired() {
395
+ return new CruxyError({
396
+ code: ErrorCode.RollbackApprovalRequired,
397
+ title: "rollback needs your approval, but cruxy is running non-interactively",
398
+ cause: "restoring a checkpoint overwrites working-tree files and can only be confirmed in an interactive terminal",
399
+ nextSteps: [
400
+ "run `cruxy rollback` in an interactive terminal to review the preview and confirm",
401
+ ],
402
+ });
403
+ }
404
+ // ── subagent (exit 2 / 11) ────────────────────────────────────────────────────
405
+ /**
406
+ * A subagent spawn was attempted past the configured nesting cap (C.14). The
407
+ * spawn tool is structurally withheld at the cap, so reaching this means the
408
+ * orchestrator seam was driven directly — fail loud, never spawn.
409
+ */
410
+ export function subagentDepthExceeded(depth, maxDepth) {
411
+ return new CruxyError({
412
+ code: ErrorCode.SubagentDepthExceeded,
413
+ title: `subagent nesting depth ${depth + 1} exceeds the configured cap (${maxDepth})`,
414
+ cause: "subagents may not spawn subagents beyond subagent.maxDepth",
415
+ nextSteps: [
416
+ "raise `subagent.maxDepth` in config if deeper nesting is intended",
417
+ "or restructure the task so the parent dispatches the subtasks directly",
418
+ ],
419
+ meta: { depth, maxDepth },
420
+ });
421
+ }
422
+ /**
423
+ * A subagent run failed outright (provider error, tool crash) before producing
424
+ * a result. Normally folded into the structured `SubagentResult` the parent
425
+ * reasons over; thrown only when the orchestrator itself cannot proceed.
426
+ */
427
+ export function subagentFailed(underlying) {
428
+ return new CruxyError({
429
+ code: ErrorCode.SubagentFailed,
430
+ title: "the subagent run failed",
431
+ cause: messageOf(underlying),
432
+ nextSteps: [
433
+ "re-run with --verbose for details",
434
+ "retry the task, or narrow the subagent's scope",
435
+ ],
436
+ underlying,
437
+ });
438
+ }
338
439
  // ── internal (exit 1) ─────────────────────────────────────────────────────────
339
440
  export function internal(underlying) {
340
441
  return new CruxyError({
@@ -14,11 +14,13 @@
14
14
  export declare const ErrorCode: {
15
15
  readonly Internal: "CRUXY_E_INTERNAL";
16
16
  readonly Usage: "CRUXY_E_USAGE";
17
+ readonly InteractiveRequired: "CRUXY_E_INTERACTIVE_REQUIRED";
17
18
  readonly ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN";
18
19
  readonly ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED";
19
20
  readonly GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH";
20
21
  readonly PlanInvalid: "CRUXY_E_PLAN_INVALID";
21
22
  readonly PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT";
23
+ readonly CheckpointNotFound: "CRUXY_E_CHECKPOINT_NOT_FOUND";
22
24
  readonly ConfigParse: "CRUXY_E_CONFIG_PARSE";
23
25
  readonly ConfigInvalid: "CRUXY_E_CONFIG_INVALID";
24
26
  readonly AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY";
@@ -34,6 +36,7 @@ export declare const ErrorCode: {
34
36
  readonly FileNotFound: "CRUXY_E_FILE_NOT_FOUND";
35
37
  readonly PermissionDenied: "CRUXY_E_PERMISSION_DENIED";
36
38
  readonly PathEscape: "CRUXY_E_PATH_ESCAPE";
39
+ readonly CheckpointFailed: "CRUXY_E_CHECKPOINT_FAILED";
37
40
  readonly IndexEmbedderUnavailable: "CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE";
38
41
  readonly IndexStoreUnavailable: "CRUXY_E_INDEX_STORE_UNAVAILABLE";
39
42
  readonly IndexFailed: "CRUXY_E_INDEX_FAILED";
@@ -41,6 +44,11 @@ export declare const ErrorCode: {
41
44
  readonly SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND";
42
45
  readonly ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED";
43
46
  readonly PlanApprovalRequired: "CRUXY_E_PLAN_APPROVAL_REQUIRED";
47
+ readonly RollbackApprovalRequired: "CRUXY_E_ROLLBACK_APPROVAL_REQUIRED";
48
+ readonly SubagentDepthExceeded: "CRUXY_E_SUBAGENT_DEPTH_EXCEEDED";
49
+ /** Carried inside a SubagentResult (informational) — never fatal by itself. */
50
+ readonly SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET";
51
+ readonly SubagentFailed: "CRUXY_E_SUBAGENT_FAILED";
44
52
  };
45
53
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
46
54
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -16,11 +16,13 @@ export const ErrorCode = {
16
16
  Internal: "CRUXY_E_INTERNAL",
17
17
  // usage (exit 2)
18
18
  Usage: "CRUXY_E_USAGE",
19
+ InteractiveRequired: "CRUXY_E_INTERACTIVE_REQUIRED",
19
20
  ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN",
20
21
  ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED",
21
22
  GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH",
22
23
  PlanInvalid: "CRUXY_E_PLAN_INVALID",
23
24
  PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT",
25
+ CheckpointNotFound: "CRUXY_E_CHECKPOINT_NOT_FOUND",
24
26
  // config (exit 3)
25
27
  ConfigParse: "CRUXY_E_CONFIG_PARSE",
26
28
  ConfigInvalid: "CRUXY_E_CONFIG_INVALID",
@@ -41,6 +43,7 @@ export const ErrorCode = {
41
43
  FileNotFound: "CRUXY_E_FILE_NOT_FOUND",
42
44
  PermissionDenied: "CRUXY_E_PERMISSION_DENIED",
43
45
  PathEscape: "CRUXY_E_PATH_ESCAPE",
46
+ CheckpointFailed: "CRUXY_E_CHECKPOINT_FAILED",
44
47
  // index (exit 8)
45
48
  IndexEmbedderUnavailable: "CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE",
46
49
  IndexStoreUnavailable: "CRUXY_E_INDEX_STORE_UNAVAILABLE",
@@ -51,6 +54,12 @@ export const ErrorCode = {
51
54
  // approval (exit 10)
52
55
  ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED",
53
56
  PlanApprovalRequired: "CRUXY_E_PLAN_APPROVAL_REQUIRED",
57
+ RollbackApprovalRequired: "CRUXY_E_ROLLBACK_APPROVAL_REQUIRED",
58
+ // subagent (exit 2 / 11)
59
+ SubagentDepthExceeded: "CRUXY_E_SUBAGENT_DEPTH_EXCEEDED",
60
+ /** Carried inside a SubagentResult (informational) — never fatal by itself. */
61
+ SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET",
62
+ SubagentFailed: "CRUXY_E_SUBAGENT_FAILED",
54
63
  };
55
64
  /**
56
65
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -59,11 +68,13 @@ export const ErrorCode = {
59
68
  const EXIT_CODES = {
60
69
  [ErrorCode.Internal]: 1,
61
70
  [ErrorCode.Usage]: 2,
71
+ [ErrorCode.InteractiveRequired]: 2,
62
72
  [ErrorCode.ConfigKeyUnknown]: 2,
63
73
  [ErrorCode.ProviderUnsupported]: 2,
64
74
  [ErrorCode.GitProtectedBranch]: 2,
65
75
  [ErrorCode.PlanInvalid]: 2,
66
76
  [ErrorCode.PlanRevisionLimit]: 2,
77
+ [ErrorCode.CheckpointNotFound]: 2,
67
78
  [ErrorCode.ConfigParse]: 3,
68
79
  [ErrorCode.ConfigInvalid]: 3,
69
80
  [ErrorCode.AuthMissingKey]: 4,
@@ -79,6 +90,7 @@ const EXIT_CODES = {
79
90
  [ErrorCode.FileNotFound]: 7,
80
91
  [ErrorCode.PermissionDenied]: 7,
81
92
  [ErrorCode.PathEscape]: 7,
93
+ [ErrorCode.CheckpointFailed]: 7,
82
94
  [ErrorCode.IndexEmbedderUnavailable]: 8,
83
95
  [ErrorCode.IndexStoreUnavailable]: 8,
84
96
  [ErrorCode.IndexFailed]: 8,
@@ -86,6 +98,12 @@ const EXIT_CODES = {
86
98
  [ErrorCode.SkillNotFound]: 9,
87
99
  [ErrorCode.ApprovalRequired]: 10,
88
100
  [ErrorCode.PlanApprovalRequired]: 10,
101
+ [ErrorCode.RollbackApprovalRequired]: 10,
102
+ // Depth-exceed is a misuse of the spawn seam (usage); the other two surface
103
+ // inside a SubagentResult and only exit the process if thrown directly.
104
+ [ErrorCode.SubagentDepthExceeded]: 2,
105
+ [ErrorCode.SubagentBudget]: 11,
106
+ [ErrorCode.SubagentFailed]: 11,
89
107
  };
90
108
  /** The process exit code for an error code (defaults to 1 for safety). */
91
109
  export function exitCodeFor(code) {
@@ -18,7 +18,18 @@ export interface WalkOptions {
18
18
  maxFileBytes: number;
19
19
  /** Ignore-file names to honor, relative to each directory (gitignore syntax). */
20
20
  ignoreFileNames?: string[];
21
+ /**
22
+ * Yield binary files too (default false). The index never wants them; the
23
+ * checkpoint capturer (C.32) does — a snapshot must be complete to restore.
24
+ */
25
+ includeBinary?: boolean;
21
26
  }
27
+ /**
28
+ * The C.17 secrets denylist test, shared beyond the index: the checkpoint
29
+ * capturer (C.32) applies the exact same rule so a working-tree snapshot can
30
+ * never contain a secret-bearing file either.
31
+ */
32
+ export declare function isSecretPath(relPath: string): boolean;
22
33
  /**
23
34
  * A compiled set of gitignore-style patterns, matched against paths relative to
24
35
  * the file's own directory. Supports comments, blank lines, `!` negation,
@@ -16,7 +16,12 @@ const SECRET_PATTERNS = [
16
16
  /(^|\/)\.(npmrc|netrc|pgpass)$/i, // credential dotfiles
17
17
  /(^|\/)\.aws\/credentials$/i,
18
18
  ];
19
- function isSecretPath(relPath) {
19
+ /**
20
+ * The C.17 secrets denylist test, shared beyond the index: the checkpoint
21
+ * capturer (C.32) applies the exact same rule so a working-tree snapshot can
22
+ * never contain a secret-bearing file either.
23
+ */
24
+ export function isSecretPath(relPath) {
20
25
  return SECRET_PATTERNS.some((re) => re.test(relPath));
21
26
  }
22
27
  /**
@@ -121,9 +126,9 @@ async function isBinaryFile(absPath) {
121
126
  export async function* walkRepo(root, opts) {
122
127
  const absRoot = path.resolve(root);
123
128
  const ignoreFileNames = opts.ignoreFileNames ?? DEFAULT_IGNORE_FILES;
124
- yield* walkDir(absRoot, absRoot, [], ignoreFileNames, opts.maxFileBytes);
129
+ yield* walkDir(absRoot, absRoot, [], ignoreFileNames, opts);
125
130
  }
126
- async function* walkDir(dir, root, parentStack, ignoreFileNames, maxFileBytes) {
131
+ async function* walkDir(dir, root, parentStack, ignoreFileNames, opts) {
127
132
  const scope = await loadIgnoreScope(dir, ignoreFileNames);
128
133
  const stack = scope ? [...parentStack, scope] : parentStack;
129
134
  let entries;
@@ -147,7 +152,7 @@ async function* walkDir(dir, root, parentStack, ignoreFileNames, maxFileBytes) {
147
152
  if (isIgnored(stack, absPath, isDir))
148
153
  continue;
149
154
  if (isDir) {
150
- yield* walkDir(absPath, root, stack, ignoreFileNames, maxFileBytes);
155
+ yield* walkDir(absPath, root, stack, ignoreFileNames, opts);
151
156
  continue;
152
157
  }
153
158
  let size;
@@ -157,9 +162,9 @@ async function* walkDir(dir, root, parentStack, ignoreFileNames, maxFileBytes) {
157
162
  catch {
158
163
  continue;
159
164
  }
160
- if (size > maxFileBytes)
165
+ if (size > opts.maxFileBytes)
161
166
  continue;
162
- if (await isBinaryFile(absPath))
167
+ if (!opts.includeBinary && (await isBinaryFile(absPath)))
163
168
  continue;
164
169
  yield { relPath, absPath, size };
165
170
  }
@@ -2,7 +2,8 @@ import type { OnboardingIO } from "./types.js";
2
2
  /**
3
3
  * The real stdin/stderr-backed {@link OnboardingIO}. Prompts go to stderr (stdout
4
4
  * stays clean for piping); the secret reader echoes `*` per keystroke and never
5
- * the real character. All readers restore cooked mode on the way out, even on
6
- * Ctrl-C / EOF — the terminal is never left in raw mode.
5
+ * the real character. Raw-mode key handling is the shared U.7 reader
6
+ * (`components/input.ts`) — the single owner of `setRawMode` so cooked mode
7
+ * is restored on every exit path, Ctrl-C / EOF included.
7
8
  */
8
9
  export declare function defaultOnboardingIO(color?: boolean): OnboardingIO;
@@ -1,54 +1,21 @@
1
+ import { createKeyReader, readSingleKey } from "../components/input.js";
1
2
  import { shouldUseColor } from "../errors/index.js";
2
3
  /**
3
4
  * The real stdin/stderr-backed {@link OnboardingIO}. Prompts go to stderr (stdout
4
5
  * stays clean for piping); the secret reader echoes `*` per keystroke and never
5
- * the real character. All readers restore cooked mode on the way out, even on
6
- * Ctrl-C / EOF — the terminal is never left in raw mode.
6
+ * the real character. Raw-mode key handling is the shared U.7 reader
7
+ * (`components/input.ts`) — the single owner of `setRawMode` so cooked mode
8
+ * is restored on every exit path, Ctrl-C / EOF included.
7
9
  */
8
10
  export function defaultOnboardingIO(color = shouldUseColor()) {
9
11
  return {
10
12
  write: (text) => void process.stderr.write(text),
11
13
  readLine: readLineFromStdin,
12
- readKey: readKeyFromStdin,
14
+ readKey: () => readSingleKey(),
13
15
  readSecret: readSecretFromStdin,
14
16
  color,
15
17
  };
16
18
  }
17
- const CTRL_C = 0x03;
18
- const CTRL_D = 0x04;
19
- const BACKSPACE = 0x08;
20
- const DELETE = 0x7f;
21
- const LF = 0x0a;
22
- const CR = 0x0d;
23
- /** Read one keypress in raw mode; "" on EOF / Ctrl-C / Ctrl-D. Restores cooked mode. */
24
- function readKeyFromStdin() {
25
- const stdin = process.stdin;
26
- return new Promise((resolve) => {
27
- const cleanup = () => {
28
- stdin.removeListener("data", onData);
29
- stdin.removeListener("end", onEnd);
30
- if (stdin.isTTY)
31
- stdin.setRawMode(false);
32
- stdin.pause();
33
- };
34
- const onData = (buf) => {
35
- cleanup();
36
- const code = buf[0];
37
- resolve(code === CTRL_C || code === CTRL_D
38
- ? ""
39
- : buf.toString("utf8").slice(0, 1));
40
- };
41
- const onEnd = () => {
42
- cleanup();
43
- resolve("");
44
- };
45
- if (stdin.isTTY)
46
- stdin.setRawMode(true);
47
- stdin.resume();
48
- stdin.once("data", onData);
49
- stdin.once("end", onEnd);
50
- });
51
- }
52
19
  /** Read one line in cooked mode; "" on EOF. */
53
20
  function readLineFromStdin() {
54
21
  const stdin = process.stdin;
@@ -80,54 +47,41 @@ function readLineFromStdin() {
80
47
  }
81
48
  /**
82
49
  * Read a secret with no echo: each printable keystroke shows a `*`, backspace
83
- * erases one, Enter submits, Ctrl-C / Ctrl-D / EOF resolve "" (abort). The real
84
- * characters are never written anywhere.
50
+ * erases one, Enter submits, Ctrl-C / Ctrl-D / EOF resolve "" (abort). The
51
+ * real characters are never written anywhere. Built on the shared key reader,
52
+ * which also keeps arrow/escape sequences from leaking into the secret.
85
53
  */
86
- function readSecretFromStdin() {
87
- const stdin = process.stdin;
54
+ async function readSecretFromStdin() {
88
55
  const out = process.stderr;
89
- return new Promise((resolve) => {
90
- let buf = "";
91
- let done = false;
92
- const cleanup = () => {
93
- stdin.removeListener("data", onData);
94
- stdin.removeListener("end", onEnd);
95
- if (stdin.isTTY)
96
- stdin.setRawMode(false);
97
- stdin.pause();
98
- };
99
- const finish = (value) => {
100
- if (done)
101
- return;
102
- done = true;
103
- cleanup();
104
- out.write("\n");
105
- resolve(value);
106
- };
107
- const onData = (chunk) => {
108
- for (const byte of chunk) {
109
- if (byte === CR || byte === LF)
110
- return finish(buf); // Enter → submit
111
- if (byte === CTRL_C || byte === CTRL_D)
112
- return finish(""); // abort
113
- if (byte === DELETE || byte === BACKSPACE) {
56
+ const keys = createKeyReader(process.stdin);
57
+ keys.begin();
58
+ let buf = "";
59
+ try {
60
+ for (;;) {
61
+ const key = await keys.read();
62
+ switch (key.kind) {
63
+ case "enter":
64
+ return buf;
65
+ case "ctrl-c":
66
+ case "eof":
67
+ return "";
68
+ case "backspace":
114
69
  if (buf.length > 0) {
115
70
  buf = buf.slice(0, -1);
116
71
  out.write("\b \b"); // erase one star
117
72
  }
118
- continue;
119
- }
120
- if (byte < 0x20)
121
- continue; // ignore other control chars
122
- buf += String.fromCharCode(byte);
123
- out.write("*");
73
+ break;
74
+ case "char":
75
+ buf += key.char;
76
+ out.write("*");
77
+ break;
78
+ default:
79
+ break; // arrows / tab / escape: ignored, never echoed
124
80
  }
125
- };
126
- const onEnd = () => finish("");
127
- if (stdin.isTTY)
128
- stdin.setRawMode(true);
129
- stdin.resume();
130
- stdin.on("data", onData);
131
- stdin.once("end", onEnd);
132
- });
81
+ }
82
+ }
83
+ finally {
84
+ keys.restore();
85
+ out.write("\n");
86
+ }
133
87
  }
@@ -1,4 +1,5 @@
1
1
  import type { PromptIO } from "../approval/index.js";
2
+ import type { StreamRenderer } from "../render/index.js";
2
3
  import type { Plan, PlanExecutionResult, PlanStep } from "./types.js";
3
4
  /**
4
5
  * Execute an approved plan step-by-step (C.31): mark each step `running`, run it,
@@ -16,5 +17,12 @@ export interface ExecuteDeps {
16
17
  */
17
18
  runStep: (step: PlanStep) => Promise<void>;
18
19
  io: PromptIO;
20
+ /**
21
+ * Live step progress (U.4): each step feeds `[i/n] title` to the renderer's
22
+ * progress register, straight from this walk of `plan.steps` — the same
23
+ * statuses the committed trail renders, never recomputed. Optional so the
24
+ * executor stays drivable without a terminal (tests, future CI mode).
25
+ */
26
+ renderer?: StreamRenderer;
19
27
  }
20
28
  export declare function executePlan(plan: Plan, deps: ExecuteDeps): Promise<PlanExecutionResult>;
@@ -2,30 +2,44 @@ import { CruxyError } from "../errors/index.js";
2
2
  import { promptContinueAfterFailure } from "./approve.js";
3
3
  import { renderStepStatus } from "./render.js";
4
4
  export async function executePlan(plan, deps) {
5
- const { runStep, io } = deps;
6
- for (const step of plan.steps) {
7
- step.status = "running";
8
- io.write(renderStepStatus(step, io.color) + "\n");
9
- try {
10
- await runStep(step);
11
- step.status = "done";
5
+ const { runStep, io, renderer } = deps;
6
+ try {
7
+ for (const [index, step] of plan.steps.entries()) {
8
+ step.status = "running";
9
+ // Live: "[2/5] title · working…" until the step's agent turn takes over.
10
+ renderer?.progress({
11
+ step: index + 1,
12
+ of: plan.steps.length,
13
+ title: step.title,
14
+ });
15
+ renderer?.setPhase({ kind: "executing-step" });
12
16
  io.write(renderStepStatus(step, io.color) + "\n");
13
- }
14
- catch (err) {
15
- step.status = "failed";
16
- io.write(renderStepStatus(step, io.color) + "\n");
17
- // Surface the failure via the U.5 shape when we have it.
18
- const detail = err instanceof CruxyError
19
- ? `${err.title}${err.cause ? ` — ${err.cause}` : ""}`
20
- : err.message;
21
- io.write(` ${detail}\n`);
22
- const cont = await promptContinueAfterFailure(io);
23
- if (!cont) {
24
- return { completed: false, halted: true, failedStepId: step.id };
17
+ try {
18
+ await runStep(step);
19
+ step.status = "done";
20
+ io.write(renderStepStatus(step, io.color) + "\n");
21
+ }
22
+ catch (err) {
23
+ step.status = "failed";
24
+ io.write(renderStepStatus(step, io.color) + "\n");
25
+ // Surface the failure via the U.5 shape when we have it.
26
+ const detail = err instanceof CruxyError
27
+ ? `${err.title}${err.cause ? ` — ${err.cause}` : ""}`
28
+ : err.message;
29
+ io.write(` ${detail}\n`);
30
+ const cont = await promptContinueAfterFailure(io);
31
+ if (!cont) {
32
+ return { completed: false, halted: true, failedStepId: step.id };
33
+ }
34
+ // User chose to continue despite the failure; move to the next step.
25
35
  }
26
- // User chose to continue despite the failure; move to the next step.
27
36
  }
37
+ const completed = plan.steps.every((s) => s.status === "done");
38
+ return { completed, halted: false };
39
+ }
40
+ finally {
41
+ // The plan owns the progress register; release it on every exit path so
42
+ // no stale "[i/n]" prefix outlives the run.
43
+ renderer?.progress(null);
28
44
  }
29
- const completed = plan.steps.every((s) => s.status === "done");
30
- return { completed, halted: false };
31
45
  }
@@ -108,7 +108,11 @@ export async function runPlanSession(args) {
108
108
  renderer: args.renderer,
109
109
  }));
110
110
  };
111
- await executePlan(plan, { runStep, io: args.io });
111
+ await executePlan(plan, {
112
+ runStep,
113
+ io: args.io,
114
+ renderer: args.renderer,
115
+ });
112
116
  return finish();
113
117
  }
114
118
  feedback = decision.feedback;
@@ -7,24 +7,24 @@ declare const parameters: z.ZodObject<{
7
7
  rationale: z.ZodString;
8
8
  kind: z.ZodEnum<["read", "mutate", "destructive"]>;
9
9
  }, "strip", z.ZodTypeAny, {
10
- title: string;
11
10
  kind: "read" | "mutate" | "destructive";
11
+ title: string;
12
12
  rationale: string;
13
13
  }, {
14
- title: string;
15
14
  kind: "read" | "mutate" | "destructive";
15
+ title: string;
16
16
  rationale: string;
17
17
  }>, "many">;
18
18
  }, "strip", z.ZodTypeAny, {
19
19
  steps: {
20
- title: string;
21
20
  kind: "read" | "mutate" | "destructive";
21
+ title: string;
22
22
  rationale: string;
23
23
  }[];
24
24
  }, {
25
25
  steps: {
26
- title: string;
27
26
  kind: "read" | "mutate" | "destructive";
27
+ title: string;
28
28
  rationale: string;
29
29
  }[];
30
30
  }>;
@@ -40,6 +40,30 @@ function renderPrPreview(preview, c) {
40
40
  out.push(c.dim(` ${line}`));
41
41
  return out;
42
42
  }
43
+ /**
44
+ * Render a `rollback` restore plan (C.32). Header, warnings, and the
45
+ * out-of-scope note come **before** the file diffs on purpose: the global
46
+ * {@link PREVIEW_MAX_LINES} collapse trims from the tail, and the blast-radius
47
+ * warnings must never be the part that gets hidden.
48
+ */
49
+ function renderRollbackPreview(preview, c) {
50
+ const out = [];
51
+ out.push(`${c.bold("restore checkpoint")} ${c.cyan(preview.checkpointId)} ${c.dim(`(${preview.createdAt})`)}`);
52
+ if (preview.runSummary)
53
+ out.push(c.dim(`run: ${preview.runSummary}`));
54
+ out.push(c.dim("working-tree files only — commits, pushes, and PRs made during the run are not undone"));
55
+ if (preview.externalPaths.length > 0) {
56
+ out.push(c.red(c.bold("changed outside this run — rollback will overwrite these too:")));
57
+ for (const p of preview.externalPaths)
58
+ out.push(c.red(`! ${p}`));
59
+ }
60
+ if (preview.attributionUnknown) {
61
+ out.push(c.yellow("this run executed shell commands; some changes below may not have been made by the run"));
62
+ }
63
+ out.push("");
64
+ out.push(...renderPatchFiles(preview.files, c));
65
+ return out;
66
+ }
43
67
  /** Split a multi-line body into trimmed-of-trailing lines, dropping a trailing blank. */
44
68
  function bodyLines(body) {
45
69
  const lines = body.replace(/\s+$/, "").split("\n");
@@ -63,6 +87,9 @@ export function renderActionPreview(preview, c) {
63
87
  else if (preview.type === "pr") {
64
88
  lines = renderPrPreview(preview, c);
65
89
  }
90
+ else if (preview.type === "rollback") {
91
+ lines = renderRollbackPreview(preview, c);
92
+ }
66
93
  else {
67
94
  const header = preview.exists
68
95
  ? c.yellow("OVERWRITE existing")
@@ -1,6 +1,7 @@
1
1
  import type { RenderStream, StreamRenderer } from "./types.js";
2
- export type { RenderCapabilities, RenderStream, StreamRenderer, } from "./types.js";
2
+ export type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, TokenUsage, ToolLifecycleEvent, } from "./types.js";
3
3
  export { detectCapabilities } from "./capabilities.js";
4
+ export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
4
5
  export { renderActionPreview, PREVIEW_MAX_LINES, type Colors } from "./diff.js";
5
6
  export { createStreamHighlighter, defaultLineHighlighter, type HighlightCarry, type LineHighlighter, type StreamHighlighter, } from "./highlight.js";
6
7
  export { PlainRenderer } from "./plain-renderer.js";
@@ -2,6 +2,7 @@ import { detectCapabilities } from "./capabilities.js";
2
2
  import { PlainRenderer } from "./plain-renderer.js";
3
3
  import { TtyRenderer } from "./tty-renderer.js";
4
4
  export { detectCapabilities } from "./capabilities.js";
5
+ export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
5
6
  export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
6
7
  export { createStreamHighlighter, defaultLineHighlighter, } from "./highlight.js";
7
8
  export { PlainRenderer } from "./plain-renderer.js";
@@ -1,5 +1,5 @@
1
1
  import type { ActionPreview } from "../tools/types.js";
2
- import type { RenderCapabilities, RenderStream, StreamRenderer } from "./types.js";
2
+ import type { RenderCapabilities, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
3
3
  /**
4
4
  * The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
5
5
  * cursor-control sequences ever, and no color unless the capabilities say so
@@ -19,6 +19,8 @@ export declare class PlainRenderer implements StreamRenderer {
19
19
  /** Per-turn leading-newline trim; also tells endSegment whether to newline. */
20
20
  private print;
21
21
  private wroteInSegment;
22
+ /** In-flight tool call (serial by contract) for the end-note duration. */
23
+ private toolStart;
22
24
  constructor(caps: RenderCapabilities, out: RenderStream, err: RenderStream);
23
25
  private newPrinter;
24
26
  beginTurn(): void;
@@ -27,6 +29,10 @@ export declare class PlainRenderer implements StreamRenderer {
27
29
  note(text: string): void;
28
30
  preview(preview: ActionPreview): void;
29
31
  status(): void;
32
+ setPhase(): void;
33
+ progress(): void;
34
+ toolLifecycle(event: ToolLifecycleEvent): void;
35
+ promptResolved(): void;
30
36
  endTurn(): void;
31
37
  close(): void;
32
38
  }