@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,128 @@
1
+ import { selectList } from "../components/index.js";
2
+ import { usageError } from "../errors/index.js";
3
+ import { findSession, isAmbiguous, listSessions } from "./list.js";
4
+ import { replaySession } from "./replay.js";
5
+ /** How many sessions the bare-`--resume` picker offers. */
6
+ export const PICKER_LIMIT = 10;
7
+ /** Short, stable id form — enough to identify a session, short enough to type. */
8
+ export function shortId(sessionId) {
9
+ return sessionId.slice(0, 8);
10
+ }
11
+ /** `2h ago`, `3d ago` — relative age for the picker and the sidebar. */
12
+ export function relativeAge(iso, now = Date.now()) {
13
+ const then = Date.parse(iso);
14
+ if (Number.isNaN(then))
15
+ return "unknown";
16
+ const seconds = Math.max(0, Math.floor((now - then) / 1000));
17
+ if (seconds < 60)
18
+ return "just now";
19
+ const minutes = Math.floor(seconds / 60);
20
+ if (minutes < 60)
21
+ return `${minutes}m ago`;
22
+ const hours = Math.floor(minutes / 60);
23
+ if (hours < 24)
24
+ return `${hours}h ago`;
25
+ return `${Math.floor(hours / 24)}d ago`;
26
+ }
27
+ /** One picker/sidebar row: `3f2a1b0c 2h ago fix the failing test (4 turns)`. */
28
+ export function describeSession(s, now = Date.now()) {
29
+ const turns = `${s.turns} turn${s.turns === 1 ? "" : "s"}`;
30
+ return `${shortId(s.sessionId)} ${relativeAge(s.updatedAt, now)} ${s.title} (${turns})`;
31
+ }
32
+ /**
33
+ * Compare the session's recorded directory with the one we are resuming into.
34
+ * Returns a warning to print, or null when they agree.
35
+ *
36
+ * This is ruling 4's "warn loudly, never silently mismatch". The history is
37
+ * full of file paths, diffs and tool results that only mean anything relative
38
+ * to the directory they were produced in; replaying it somewhere else does not
39
+ * corrupt anything, but it does mean the model is reasoning about a tree that
40
+ * is not the one in front of it. So the resume PROCEEDS — the user asked for
41
+ * it, and refusing a resume because a repo moved would be worse — but it says
42
+ * exactly what is off.
43
+ */
44
+ export function cwdMismatchWarning(state, cwd) {
45
+ if (state.meta.cwd === cwd)
46
+ return null;
47
+ return (`this session was recorded in ${state.meta.cwd}, but you are in ${cwd} — ` +
48
+ `its history refers to files and paths from the original directory`);
49
+ }
50
+ /**
51
+ * Load one session and collect its warnings. Throws a usage error when the
52
+ * file cannot be replayed at all (no meta line) — an unreadable session is
53
+ * worth failing loudly on, unlike an individual torn line.
54
+ */
55
+ export function loadResume(session, cwd) {
56
+ let state;
57
+ try {
58
+ state = replaySession(session.file);
59
+ }
60
+ catch (err) {
61
+ throw usageError(`could not resume session ${shortId(session.sessionId)}`, [
62
+ `the log at ${session.file} is not readable: ${err.message}`,
63
+ "start a new session with `cruxy`",
64
+ ]);
65
+ }
66
+ const warnings = [];
67
+ const mismatch = cwdMismatchWarning(state, cwd);
68
+ if (mismatch)
69
+ warnings.push(mismatch);
70
+ if (state.skipped > 0) {
71
+ warnings.push(`${state.skipped} unreadable line${state.skipped === 1 ? "" : "s"} in the session log were skipped — ` +
72
+ `the restored history may be incomplete`);
73
+ }
74
+ return { session, state, warnings };
75
+ }
76
+ /**
77
+ * Resolve `--resume <id>`. Fails loud on an unknown or ambiguous id rather than
78
+ * silently starting a new session — the user named something specific.
79
+ */
80
+ export function resumeById(cwd, id) {
81
+ if (isAmbiguous(cwd, id)) {
82
+ const matches = listSessions(cwd)
83
+ .filter((s) => s.sessionId.startsWith(id))
84
+ .map((s) => shortId(s.sessionId));
85
+ throw usageError(`\`${id}\` matches more than one session`, [
86
+ `did you mean one of: ${matches.join(", ")}?`,
87
+ "run `cruxy --resume` to pick from a list",
88
+ ]);
89
+ }
90
+ const found = findSession(cwd, id);
91
+ if (!found) {
92
+ throw usageError(`no session \`${id}\` in this project`, [
93
+ "run `cruxy --resume` to pick from recent sessions",
94
+ "sessions are per-directory; check you are in the right one",
95
+ ]);
96
+ }
97
+ return loadResume(found, cwd);
98
+ }
99
+ /**
100
+ * Bare `--resume`: pick from the most recent sessions, or start a new one.
101
+ * Returns null when the user chose "new session" (or cancelled) — the caller
102
+ * then proceeds exactly as an unresumed run.
103
+ */
104
+ export async function resumePicker(cwd, opts = {}) {
105
+ const sessions = listSessions(cwd, PICKER_LIMIT);
106
+ if (sessions.length === 0) {
107
+ opts.logger?.info("no saved sessions for this project — starting a new one");
108
+ return null;
109
+ }
110
+ const now = opts.now ?? Date.now();
111
+ const rows = [
112
+ ...sessions.map((session) => ({ kind: "session", session })),
113
+ { kind: "new" },
114
+ ];
115
+ const picked = await selectList(rows, {
116
+ title: "resume a session",
117
+ toLabel: (row) => row.kind === "new"
118
+ ? "+ new session"
119
+ : describeSession(row.session, now),
120
+ // Non-interactive with no id named: starting fresh is the safe default,
121
+ // never an arbitrary session picked on the user's behalf.
122
+ defaultValue: { kind: "new" },
123
+ nonInteractiveHint: ["pass an id: `cruxy --resume <id>`"],
124
+ }, opts.io);
125
+ if (picked.kind === "cancelled" || picked.value.kind === "new")
126
+ return null;
127
+ return loadResume(picked.value.session, cwd);
128
+ }
@@ -0,0 +1,223 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Session persistence (P2): the on-disk shape of a conversation.
4
+ *
5
+ * The file is an APPEND-ONLY event log (JSONL), not a snapshot of the message
6
+ * array. That distinction is the whole design:
7
+ *
8
+ * - `Session.messages` is rewritten in place — compaction replaces an older
9
+ * prefix with a synthetic summary pair (see `agent/session.ts`). A snapshot
10
+ * format would have to rewrite the whole file on every turn, which is O(n²)
11
+ * in turns and loses the history of what was compacted away.
12
+ * - An event log records the rewrite as an EVENT (`compaction`, carrying what
13
+ * it replaced), so the file only ever grows, and replay reconstructs the
14
+ * current array exactly. What the model can still see and what the user
15
+ * actually said stay separately recoverable.
16
+ *
17
+ * Replay is a fold over the events; see `replay.ts`.
18
+ */
19
+ /**
20
+ * Bump when the event shapes change incompatibly. Present on the `meta` line
21
+ * only — every subsequent line is self-describing via `kind`.
22
+ */
23
+ export const SESSION_FILE_VERSION = 1;
24
+ /**
25
+ * FORWARD COMPATIBILITY — why every schema below is `.passthrough()`.
26
+ *
27
+ * This is the same decision, for the same recorded reason, as
28
+ * `usage/types.ts`: a `.strict()` schema means the first field a NEWER cruxy
29
+ * writes makes an OLDER binary reject the line. For usage that silently lost
30
+ * accounting; here it would silently lose a user's CONVERSATION, which is
31
+ * strictly worse. Downgrading a CLI, or running two versions against one home
32
+ * directory, is ordinary — losing a session to it is not acceptable.
33
+ *
34
+ * `.passthrough()` also PRESERVES unknown keys rather than stripping them, so
35
+ * anything that reads and re-emits an event hands a newer CLI's fields back
36
+ * intact.
37
+ *
38
+ * The tradeoff accepted, identically: a typo'd key is no longer a parse error.
39
+ * Worth it — every field the code actually reads is still fully validated, and
40
+ * a malformed LINE is skipped rather than taking the session down (see
41
+ * `replay.ts`).
42
+ */
43
+ // ── message shapes (mirrors @cruxy/sdk, validated on the way back in) ────────
44
+ /**
45
+ * The SDK's content blocks, restated as schemas.
46
+ *
47
+ * These must round-trip EXACTLY. `tool_use` / `tool_result` pairing is
48
+ * load-bearing: the provider rejects a history where a `tool_use` has no
49
+ * matching `tool_result`, which is the entire reason `Session.findCut` walks
50
+ * back to a real user prompt before compacting. A replay that dropped, merged
51
+ * or reordered a block would produce a history that fails on the next turn —
52
+ * so nothing here is lossy, and unknown block types are preserved rather than
53
+ * filtered (a newer CLI's block must survive an older one reading the file).
54
+ */
55
+ export const TextBlockSchema = z
56
+ .object({ type: z.literal("text"), text: z.string() })
57
+ .passthrough();
58
+ export const ToolUseBlockSchema = z
59
+ .object({
60
+ type: z.literal("tool_use"),
61
+ id: z.string(),
62
+ name: z.string(),
63
+ input: z.unknown(),
64
+ })
65
+ .passthrough();
66
+ export const ToolResultBlockSchema = z
67
+ .object({
68
+ type: z.literal("tool_result"),
69
+ tool_use_id: z.string(),
70
+ content: z.string(),
71
+ is_error: z.boolean().optional(),
72
+ })
73
+ .passthrough();
74
+ /**
75
+ * A block of a kind this build knows, OR any other object carrying a string
76
+ * `type`. The fallback arm is deliberate: a newer cruxy that adds a block kind
77
+ * must not have its sessions truncated by an older one. The block is carried
78
+ * through untouched and handed back to the provider as-is.
79
+ */
80
+ export const ContentBlockSchema = z.union([
81
+ TextBlockSchema,
82
+ ToolUseBlockSchema,
83
+ ToolResultBlockSchema,
84
+ z.object({ type: z.string() }).passthrough(),
85
+ ]);
86
+ export const MessageSchema = z
87
+ .object({
88
+ role: z.enum(["user", "assistant"]),
89
+ content: z.union([z.string(), z.array(ContentBlockSchema)]),
90
+ })
91
+ .passthrough();
92
+ // ── events ───────────────────────────────────────────────────────────────────
93
+ /** One declared workspace root, recorded so a resume can validate against it. */
94
+ export const RootRefSchema = z
95
+ .object({ name: z.string(), path: z.string() })
96
+ .passthrough();
97
+ /**
98
+ * The first line of every session file: everything needed to identify the
99
+ * session and to decide whether resuming it HERE is safe.
100
+ *
101
+ * `cwd` and `roots` are metadata, never restored. Resuming a session recorded
102
+ * in another directory would silently point a history full of file paths, diffs
103
+ * and tool results at an unrelated tree — so the resume path compares and warns
104
+ * loudly rather than quietly proceeding (see `replay.ts`/`resume.ts`).
105
+ */
106
+ export const SessionMetaSchema = z
107
+ .object({
108
+ kind: z.literal("meta"),
109
+ version: z.literal(SESSION_FILE_VERSION),
110
+ sessionId: z.string(),
111
+ startedAt: z.string(),
112
+ /** The primary root at session start — the directory history refers to. */
113
+ cwd: z.string(),
114
+ /** Every declared root (C.26). Multi-root sessions key on the primary, so
115
+ * this is what makes the asymmetry visible rather than silently dropped. */
116
+ roots: z.array(RootRefSchema).default([]),
117
+ cliVersion: z.string().optional(),
118
+ provider: z.string().optional(),
119
+ model: z.string().optional(),
120
+ })
121
+ .passthrough();
122
+ /**
123
+ * Messages appended since the last event. The ONLY growth path — every turn's
124
+ * user prompt, assistant blocks and tool results arrive through here.
125
+ *
126
+ * `runId` is the CheckpointGate's run id for the turn (see `gate.ts`), NOT a
127
+ * second id minted here: `cruxy rollback <id>` and this log must agree on what
128
+ * a turn is, or the undo unit means two different things. It is absent when
129
+ * checkpoints are disabled — the only case where no run id exists at all.
130
+ */
131
+ export const AppendEventSchema = z
132
+ .object({
133
+ kind: z.literal("append"),
134
+ at: z.string(),
135
+ runId: z.string().optional(),
136
+ messages: z.array(MessageSchema),
137
+ })
138
+ .passthrough();
139
+ /**
140
+ * A compaction: the oldest `replaced` messages were folded into `summary`
141
+ * (the synthetic user/assistant pair carrying `COMPACTION_MARKER`).
142
+ *
143
+ * Recording the COUNT plus the replacement — rather than rewriting the array —
144
+ * is what keeps the file append-only. The replaced messages remain earlier in
145
+ * the log, so the full conversation is still recoverable even though the model
146
+ * can no longer see it.
147
+ */
148
+ export const CompactionEventSchema = z
149
+ .object({
150
+ kind: z.literal("compaction"),
151
+ at: z.string(),
152
+ runId: z.string().optional(),
153
+ /** How many messages from the head were folded away. */
154
+ replaced: z.number().int().nonnegative(),
155
+ /** What replaced them (the synthetic pair). */
156
+ summary: z.array(MessageSchema),
157
+ })
158
+ .passthrough();
159
+ /** `/clear`: history dropped, session kept. Replay resets to an empty array. */
160
+ export const ClearEventSchema = z
161
+ .object({ kind: z.literal("clear"), at: z.string() })
162
+ .passthrough();
163
+ /**
164
+ * `/plan` toggled. The last one wins on replay.
165
+ *
166
+ * Superseded by {@link SessionModeEventSchema} (P5 track 3) and still READ, never
167
+ * written: sessions recorded before modes existed carry these, and dropping the
168
+ * case would silently resume them in manual. Expand-contract — the new writer
169
+ * emits `mode`, the reader understands both, and a pre-P5 log keeps meaning what
170
+ * it meant.
171
+ */
172
+ export const PlanModeEventSchema = z
173
+ .object({
174
+ kind: z.literal("plan-mode"),
175
+ at: z.string(),
176
+ enabled: z.boolean(),
177
+ })
178
+ .passthrough();
179
+ /**
180
+ * The session mode changed (P5 track 3). The last one wins on replay, exactly
181
+ * like the event it replaces.
182
+ *
183
+ * `mode` is a plain string here rather than an enum so an unknown value — a log
184
+ * written by a newer build that added a mode — parses instead of poisoning the
185
+ * whole line. The fold decides what to do with one it does not recognise.
186
+ */
187
+ export const SessionModeEventSchema = z
188
+ .object({
189
+ kind: z.literal("mode"),
190
+ at: z.string(),
191
+ mode: z.string(),
192
+ })
193
+ .passthrough();
194
+ /**
195
+ * One turn's token usage, as the provider reported it. Summed on replay to
196
+ * restore `Session.usage`.
197
+ *
198
+ * This is a COPY, deliberately. `~/.cruxy/usage/runs.json` keeps only the last
199
+ * 50 runs (`usage/store.ts`), while sessions are kept indefinitely — so a
200
+ * session will routinely outlive its own usage records. Without this the
201
+ * restored `Session.usage` would silently read 0 for an older conversation.
202
+ * `runId` still points at the usage store for the richer per-tier/cost
203
+ * breakdown WHEN it is still there; nothing here assumes it is.
204
+ */
205
+ export const UsageEventSchema = z
206
+ .object({
207
+ kind: z.literal("usage"),
208
+ at: z.string(),
209
+ runId: z.string().optional(),
210
+ inputTokens: z.number().int().nonnegative(),
211
+ outputTokens: z.number().int().nonnegative(),
212
+ })
213
+ .passthrough();
214
+ /** Every event, discriminated on `kind`. */
215
+ export const SessionEventSchema = z.discriminatedUnion("kind", [
216
+ SessionMetaSchema,
217
+ AppendEventSchema,
218
+ CompactionEventSchema,
219
+ ClearEventSchema,
220
+ PlanModeEventSchema,
221
+ SessionModeEventSchema,
222
+ UsageEventSchema,
223
+ ]);
@@ -437,6 +437,29 @@ class SubagentRenderer {
437
437
  }
438
438
  /** The plan executor owns the progress register (C.31) — never the child. */
439
439
  progress() { }
440
+ /**
441
+ * Same ownership rule for the plan checklist (P3): a child running its own
442
+ * task must never overwrite the parent's plan on screen.
443
+ */
444
+ setPlan() { }
445
+ /**
446
+ * Forwarded: a child's test run is a real outcome the user should see, unlike
447
+ * its assistant text. The label is not prefixed — the report carries the
448
+ * command it ran, which already identifies it.
449
+ */
450
+ /**
451
+ * NOT forwarded, deliberately. A subagent may run on a different tier from
452
+ * the main loop (C.30 routes by task class), and the parent's model panel and
453
+ * header describe the SESSION's tier. Forwarding would let a subagent's tier
454
+ * overwrite it and linger after the subagent finished — the header would
455
+ * report a tier the conversation is not running on.
456
+ */
457
+ servedRouting() {
458
+ // no-op
459
+ }
460
+ testResult(report) {
461
+ this.inner.testResult(report);
462
+ }
440
463
  toolLifecycle(event) {
441
464
  this.inner.toolLifecycle({
442
465
  ...event,
@@ -144,6 +144,14 @@ export function makeRunTestsTool(deps = {}) {
144
144
  shell: ctx.config.shell,
145
145
  });
146
146
  budget.record(result.passed);
147
+ // The structured result goes to the renderer here, from the same object
148
+ // the payload below is built from — never from re-reading that payload.
149
+ try {
150
+ deps.onResult?.(result, resolved);
151
+ }
152
+ catch {
153
+ // A renderer problem is not a test-run problem.
154
+ }
147
155
  const payload = renderResult(result, resolved, {
148
156
  run: result.passed ? 0 : budget.spent,
149
157
  max,
@@ -3,7 +3,7 @@ import { listFilesTool } from "./list-files.js";
3
3
  import { gitStatusTool } from "./git-status.js";
4
4
  import { readFileTool, writeFileTool, editFileTool, applyPatchTool, globTool, grepFilesTool, } from "./file/index.js";
5
5
  import { runCommandTool } from "./shell/index.js";
6
- import { makeRunTestsTool } from "../testing/run-tests-tool.js";
6
+ import { makeRunTestsTool, } from "../testing/run-tests-tool.js";
7
7
  import { searchCodebaseTool } from "./search-codebase.js";
8
8
  import { listSkillsTool } from "./list-skills.js";
9
9
  import { loadSkillTool } from "./load-skill.js";
@@ -55,7 +55,7 @@ function toInputSchema(schema) {
55
55
  return json;
56
56
  }
57
57
  /** Build the default registry with every built-in tool registered. */
58
- export function buildDefaultRegistry() {
58
+ export function buildDefaultRegistry(opts = {}) {
59
59
  const registry = new ToolRegistry();
60
60
  registry.register(listFilesTool);
61
61
  registry.register(readFileTool);
@@ -67,7 +67,7 @@ export function buildDefaultRegistry() {
67
67
  registry.register(gitStatusTool);
68
68
  registry.register(runCommandTool);
69
69
  // A fresh tool per registry — its iteration budget (C.13) is session-scoped.
70
- registry.register(makeRunTestsTool());
70
+ registry.register(makeRunTestsTool(opts.onTestResult ? { onResult: opts.onTestResult } : {}));
71
71
  registry.register(searchCodebaseTool);
72
72
  registry.register(listSkillsTool);
73
73
  registry.register(loadSkillTool);