@cruxy/cli 1.2.0 → 1.3.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 (77) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +41 -2
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +185 -72
  7. package/dist/approval/classify.js +204 -0
  8. package/dist/approval/policy.js +41 -3
  9. package/dist/approval/prompt.js +49 -22
  10. package/dist/checkpoint/gate.js +12 -0
  11. package/dist/cli/commands/run.js +374 -227
  12. package/dist/cli/commands/usage.js +45 -45
  13. package/dist/cli/onboard.js +2 -1
  14. package/dist/cli/program.js +60 -18
  15. package/dist/cli/repl.js +67 -249
  16. package/dist/cli/session-commands.js +755 -0
  17. package/dist/cli/session-factory.js +198 -76
  18. package/dist/cli/suggest.js +77 -0
  19. package/dist/components/fuzzy.js +3 -3
  20. package/dist/components/input.js +17 -2
  21. package/dist/components/keys.js +27 -3
  22. package/dist/components/select.js +3 -3
  23. package/dist/config/project.js +53 -1
  24. package/dist/config/schema.js +49 -16
  25. package/dist/jobs/log-renderer.js +47 -0
  26. package/dist/onboarding/steps.js +13 -22
  27. package/dist/plan/approve.js +36 -24
  28. package/dist/plan/execute.js +9 -7
  29. package/dist/plan/render.js +10 -23
  30. package/dist/plan/service.js +4 -1
  31. package/dist/render/capabilities.js +30 -1
  32. package/dist/render/context-view.js +106 -0
  33. package/dist/render/diff.js +198 -12
  34. package/dist/render/index.js +31 -5
  35. package/dist/render/plain-renderer.js +38 -2
  36. package/dist/render/plan-view.js +108 -0
  37. package/dist/render/resize.js +7 -2
  38. package/dist/render/status-view.js +66 -0
  39. package/dist/render/test-view.js +89 -0
  40. package/dist/render/tty-renderer.js +40 -0
  41. package/dist/routing/index.js +1 -0
  42. package/dist/routing/router.js +13 -4
  43. package/dist/routing/session-model.js +109 -0
  44. package/dist/routing/types.js +14 -0
  45. package/dist/session/export.js +88 -0
  46. package/dist/session/index.js +20 -0
  47. package/dist/session/list.js +137 -0
  48. package/dist/session/log.js +137 -0
  49. package/dist/session/paths.js +73 -0
  50. package/dist/session/replay.js +169 -0
  51. package/dist/session/resume.js +128 -0
  52. package/dist/session/types.js +223 -0
  53. package/dist/subagent/orchestrator.js +23 -0
  54. package/dist/testing/run-tests-tool.js +8 -0
  55. package/dist/tools/registry.js +3 -3
  56. package/dist/tui/app.js +385 -0
  57. package/dist/tui/approval-overlay.js +160 -0
  58. package/dist/tui/context-gauge.js +48 -0
  59. package/dist/tui/git-status.js +63 -0
  60. package/dist/tui/index.js +10 -0
  61. package/dist/tui/layout.js +269 -0
  62. package/dist/tui/overlay.js +105 -0
  63. package/dist/tui/palette.js +73 -0
  64. package/dist/tui/panels.js +235 -0
  65. package/dist/tui/renderer.js +776 -0
  66. package/dist/tui/supports.js +20 -0
  67. package/dist/tui/tool-versions.js +129 -0
  68. package/dist/usage/collect.js +21 -3
  69. package/dist/usage/index.js +10 -2
  70. package/dist/usage/report.js +76 -0
  71. package/dist/usage/store.js +7 -1
  72. package/dist/usage/summary.js +106 -17
  73. package/dist/usage/types.js +73 -4
  74. package/dist/usage/weighted.js +77 -0
  75. package/dist/utils/git.js +50 -4
  76. package/package.json +2 -2
  77. package/dist/usage/cost.js +0 -29
@@ -1,5 +1,9 @@
1
1
  import { Command } from "commander";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { logger } from "../../utils/logger.js";
4
+ import { SessionLog, listSessions, resumeById, resumePicker, shortId, } from "../../session/index.js";
5
+ /** Sessions shown in the TUI sidebar — the same depth as the resume picker. */
6
+ const SIDEBAR_SESSIONS = 10;
3
7
  import { loadConfig, resolveApiKey } from "../../config/index.js";
4
8
  import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
5
9
  import { createRenderer } from "../../render/index.js";
@@ -8,7 +12,9 @@ import { summarizeRuns, renderSummary, } from "../../usage/index.js";
8
12
  import { CheckpointGate } from "../../checkpoint/index.js";
9
13
  import { SandboxService } from "../../sandbox/index.js";
10
14
  import { buildHooksService, buildHooksRouter } from "../../hooks/index.js";
15
+ import { DEFAULT_MODE, } from "../../agent/index.js";
11
16
  import { runInteractive } from "../repl.js";
17
+ import { ContextGauge, createKeyLease, runTui, TuiRenderer, } from "../../tui/index.js";
12
18
  import { buildAgentSession } from "../session-factory.js";
13
19
  import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
14
20
  import { resetLspServices } from "../../lsp/index.js";
@@ -35,256 +41,397 @@ export function runCommand() {
35
41
  .option("--plan", "plan mode: propose a step-by-step plan for approval before executing")
36
42
  .option("--sandbox", "run shell + test commands inside an isolated container (fails loud if no runtime)")
37
43
  .option("--root <spec>", "declare a workspace root (repeatable): name=path or path; the first is primary", collectRoot, [])
38
- .action(async (promptParts, opts) => {
39
- const prompt = promptParts.join(" ").trim();
40
- const t = themeForColor(shouldUseColor(process.stdout));
41
- const interactive = prompt === "";
42
- // No prompt and stdin isn't a terminal: there's no way to read input and
43
- // nothing to do fail fast instead of hanging on a line that never comes.
44
- if (interactive && !process.stdin.isTTY) {
45
- throw usageError("cruxy run needs a prompt when stdin is not a terminal", ['provide a task, e.g. cruxy run "fix the failing test"']);
44
+ .action((promptParts, opts) => executeRun(promptParts, opts));
45
+ }
46
+ /**
47
+ * The `run` pipeline, extracted from the command action so the bare-`cruxy`
48
+ * entry can reuse it verbatim (P1). `cruxy run` calls it with `tui: false`, so
49
+ * that path is byte-for-byte what it was; the TUI entry differs only in the
50
+ * renderer it asks for and the loop it hands the session to.
51
+ */
52
+ export async function executeRun(promptParts, opts) {
53
+ const prompt = promptParts.join(" ").trim();
54
+ const t = themeForColor(shouldUseColor(process.stdout));
55
+ const invokedAs = opts.commandName ?? "cruxy run";
56
+ const interactive = prompt === "";
57
+ // No prompt and stdin isn't a terminal: there's no way to read input and
58
+ // nothing to do — fail fast instead of hanging on a line that never comes.
59
+ //
60
+ // `--resume` reaches here too, and gets its own wording: restoring a session
61
+ // with nothing to say to it is a no-op, and the fix is a message rather than
62
+ // a different id. A resume WITH a message is fine and never lands here — it
63
+ // continues the restored history as a one-shot.
64
+ if (interactive && !process.stdin.isTTY) {
65
+ throw usageError(opts.resume !== undefined
66
+ ? `${invokedAs} --resume needs a terminal, or a message to continue with`
67
+ : `${invokedAs} needs a prompt when stdin is not a terminal`, opts.resume !== undefined
68
+ ? [
69
+ `add a message, e.g. ${invokedAs} --resume <id> "keep going"`,
70
+ "or run it in a terminal to continue interactively",
71
+ ]
72
+ : [`provide a task, e.g. ${invokedAs} "fix the failing test"`]);
73
+ }
74
+ const { config, sources } = loadConfig();
75
+ let apiKey = resolveApiKey(config.model.provider);
76
+ // Declared workspace roots (C.26). This is the ONE place `run` reads the
77
+ // process working directory — the invocation directory is the base for
78
+ // resolving `--root` paths and the sole root when none are declared. Every
79
+ // subsystem below derives its cwd from the WORKSPACE (`primaryRoot`), never
80
+ // re-reads the invocation directory, so nothing can silently split-brain to
81
+ // a different dir than the roots the tools see (the guard test pins this).
82
+ const invocationCwd = process.cwd();
83
+ // No --root → a trivial single-root workspace (byte-identical to pre-C.26).
84
+ // --root builds a genuine multi-root Workspace and fails fast HERE — a
85
+ // missing / non-dir / overlapping root (CRUXY_E_ROOT_OVERLAP) throws before
86
+ // onboarding or the session starts, never a half-built session.
87
+ const workspace = opts.root.length
88
+ ? await buildWorkspace(opts.root, { cwd: invocationCwd })
89
+ : sessionWorkspace(invocationCwd);
90
+ const primaryRoot = workspace.primary().absPath;
91
+ logger.info(t.muted(`model: ${config.model.provider}/${config.model.model}`));
92
+ logger.info(t.muted(`config: ${sources.project ?? sources.global ?? "defaults"}`));
93
+ // Multi-root honesty (JC-6/C.26): reads fan every root. With per-root
94
+ // checkpoints active (C.26 step 3), writes fan every root too — each is
95
+ // checkpointed and rollback-able. With checkpoints DISABLED, a non-primary
96
+ // write is still refused (CRUXY_E_MULTIROOT_WRITE_DEFERRED) rather than left
97
+ // un-restorable. Hooks + MCP still scope to the primary this release. Named
98
+ // at start, never a silent primary-default.
99
+ if (workspace.isMultiRoot) {
100
+ const names = workspace
101
+ .roots()
102
+ .map((r) => r.name)
103
+ .join(", ");
104
+ const writes = config.checkpoint.enabled
105
+ ? "writes fan all roots (each checkpointed)"
106
+ : "writes scope to primary (checkpoints disabled)";
107
+ logger.info(t.muted(`roots: ${workspace.roots().length} (${names}); primary ${workspace.primary().name} — ` +
108
+ `reads fan all roots; ${writes}; hooks fan per trusted root; MCP loads from primary only this release`));
109
+ }
110
+ // First-run with no key (and a TTY) → guided onboarding instead of the
111
+ // dead-end auth error. The first-win demo is offered only in the no-prompt
112
+ // (REPL) path; with a real prompt, that prompt IS the first win.
113
+ if (!apiKey) {
114
+ const onboarding = maybeRunOnboarding(config, {
115
+ ttyInteractive: Boolean(process.stdin.isTTY),
116
+ offerFirstWin: interactive,
117
+ cwd: primaryRoot,
118
+ });
119
+ if (onboarding === null) {
120
+ // Not a first run (non-TTY, or onboarded then key removed) → fail loud.
121
+ throw authMissingKey(config.model.provider, apiKeyEnvVar(config.model.provider));
46
122
  }
47
- const { config, sources } = loadConfig();
48
- let apiKey = resolveApiKey(config.model.provider);
49
- // Declared workspace roots (C.26). This is the ONE place `run` reads the
50
- // process working directory the invocation directory is the base for
51
- // resolving `--root` paths and the sole root when none are declared. Every
52
- // subsystem below derives its cwd from the WORKSPACE (`primaryRoot`), never
53
- // re-reads the invocation directory, so nothing can silently split-brain to
54
- // a different dir than the roots the tools see (the guard test pins this).
55
- const invocationCwd = process.cwd();
56
- // No --root → a trivial single-root workspace (byte-identical to pre-C.26).
57
- // --root builds a genuine multi-root Workspace and fails fast HERE — a
58
- // missing / non-dir / overlapping root (CRUXY_E_ROOT_OVERLAP) throws before
59
- // onboarding or the session starts, never a half-built session.
60
- const workspace = opts.root.length
61
- ? await buildWorkspace(opts.root, { cwd: invocationCwd })
62
- : sessionWorkspace(invocationCwd);
63
- const primaryRoot = workspace.primary().absPath;
64
- logger.info(t.muted(`model: ${config.model.provider}/${config.model.model}`));
65
- logger.info(t.muted(`config: ${sources.project ?? sources.global ?? "defaults"}`));
66
- // Multi-root honesty (JC-6/C.26): reads fan every root. With per-root
67
- // checkpoints active (C.26 step 3), writes fan every root too — each is
68
- // checkpointed and rollback-able. With checkpoints DISABLED, a non-primary
69
- // write is still refused (CRUXY_E_MULTIROOT_WRITE_DEFERRED) rather than left
70
- // un-restorable. Hooks + MCP still scope to the primary this release. Named
71
- // at start, never a silent primary-default.
72
- if (workspace.isMultiRoot) {
73
- const names = workspace
74
- .roots()
75
- .map((r) => r.name)
76
- .join(", ");
77
- const writes = config.checkpoint.enabled
78
- ? "writes fan all roots (each checkpointed)"
79
- : "writes scope to primary (checkpoints disabled)";
80
- logger.info(t.muted(`roots: ${workspace.roots().length} (${names}); primary ${workspace.primary().name} — ` +
81
- `reads fan all roots; ${writes}; hooks fan per trusted root; MCP loads from primary only this release`));
123
+ const result = await onboarding;
124
+ if (!result.completed) {
125
+ // Aborted/failed mid-setup guidance already shown; exit cleanly.
126
+ logger.print(t.muted("run `cruxy login` to finish setup."));
127
+ return;
82
128
  }
83
- // First-run with no key (and a TTY) → guided onboarding instead of the
84
- // dead-end auth error. The first-win demo is offered only in the no-prompt
85
- // (REPL) path; with a real prompt, that prompt IS the first win.
129
+ apiKey = result.apiKey ?? resolveApiKey(config.model.provider);
86
130
  if (!apiKey) {
87
- const onboarding = maybeRunOnboarding(config, {
88
- ttyInteractive: Boolean(process.stdin.isTTY),
89
- offerFirstWin: interactive,
90
- cwd: primaryRoot,
91
- });
92
- if (onboarding === null) {
93
- // Not a first run (non-TTY, or onboarded then key removed) → fail loud.
94
- throw authMissingKey(config.model.provider, apiKeyEnvVar(config.model.provider));
95
- }
96
- const result = await onboarding;
97
- if (!result.completed) {
98
- // Aborted/failed mid-setup — guidance already shown; exit cleanly.
99
- logger.print(t.muted("run `cruxy login` to finish setup."));
100
- return;
101
- }
102
- apiKey = result.apiKey ?? resolveApiKey(config.model.provider);
103
- if (!apiKey) {
104
- throw authMissingKey(config.model.provider, apiKeyEnvVar(config.model.provider));
105
- }
131
+ throw authMissingKey(config.model.provider, apiKeyEnvVar(config.model.provider));
106
132
  }
107
- // Plan mode is opt-in: --plan flag overrides the config default.
108
- const planMode = opts.plan ?? config.agent.planMode;
109
- // One renderer for the whole run (U.2): the streaming path and the
110
- // approval prompt's status-suspend hook must share the same live region.
111
- const renderer = createRenderer();
112
- // Checkpoint-before-first-mutation (C.32/C.26): the gate owns one
113
- // CheckpointService PER touched root (lazy) plus the run's CheckpointSet,
114
- // and fires from the approval seam inside the session so one instance
115
- // covers the one-shot path, every REPL turn, plan-mode execution, and
116
- // subagents (they share this gate, so their writes join the run's set).
117
- const checkpoints = config.checkpoint.enabled
118
- ? new CheckpointGate({ config, primaryRoot })
119
- : undefined;
120
- // Sandbox (C.16): opt-in via --sandbox or sandbox.enabled. Resolving the
121
- // service probes the runtime and THROWS CRUXY_E_SANDBOX_UNAVAILABLE if it
122
- // is missing — fail loud here, before the agent starts, rather than
123
- // silently running un-sandboxed. When off, ctx.sandbox stays undefined and
124
- // execution runs on the host, unchanged.
125
- const sandboxEnabled = opts.sandbox ?? config.sandbox.enabled;
126
- const sandbox = sandboxEnabled
127
- ? await SandboxService.create({
128
- config,
129
- cwd: primaryRoot,
130
- // R5 (C.26): the command's root (primary — shell/test attribute to
131
- // it this release) mounts read-write; every OTHER declared root
132
- // mounts read-only. Cross-root write is only ever granted per-root
133
- // via a named escalation, never blanket.
134
- siblingRoots: workspace
135
- .roots()
136
- .filter((r) => !r.primary)
137
- .map((r) => r.absPath),
138
- reporter: renderer,
139
- })
140
- : undefined;
141
- if (sandbox) {
142
- logger.info(t.muted(`sandbox: ${sandbox.runtimeName} (network ${config.sandbox.network})`));
133
+ }
134
+ // Resume (P2), resolved BEFORE the renderer and the session: `--resume <id>`
135
+ // restores that session directly and fails loud on an unknown or ambiguous
136
+ // id; bare `--resume` offers the last ten plus "new session", and a decline
137
+ // (or a cancel) falls through to a fresh session with no further ceremony.
138
+ //
139
+ // Warnings a cwd mismatch, or lines the replay could not read — are printed
140
+ // here, before any turn runs, because they change what the restored history
141
+ // means. Resuming still proceeds: the user asked for it, and refusing because
142
+ // a repo moved would be worse than saying so plainly.
143
+ let resumed = null;
144
+ if (opts.resume !== undefined && opts.resume !== false) {
145
+ resumed =
146
+ typeof opts.resume === "string" && opts.resume !== ""
147
+ ? resumeById(primaryRoot, opts.resume)
148
+ : await resumePicker(primaryRoot, { logger });
149
+ if (resumed) {
150
+ for (const warning of resumed.warnings)
151
+ logger.warn(warning);
152
+ logger.info(t.muted(`resumed session ${shortId(resumed.session.sessionId)} — ${resumed.session.turns} turn(s), ${resumed.state.messages.length} message(s)`));
143
153
  }
144
- // Hooks + custom slash commands (C.19). Single-root uses the unchanged
145
- // service (byte-identical: one runner, lazy trust prompt). Multi-root
146
- // (C.26 step 5) builds ONE runner per root behind a HookRouter tool
147
- // events fire the acting root's hooks, lifecycle events fan every trusted
148
- // root. Untrusted roots are skipped and NAMED here (never a silent loss).
149
- let hooksRunner;
150
- let hookCommands;
151
- if (workspace.isMultiRoot) {
152
- const router = await buildHooksRouter({ workspace, config, logger });
153
- hooksRunner = router.runner;
154
- hookCommands = router.commands;
155
- for (const u of router.untrustedHookRoots) {
156
- logger.info(t.muted(`hooks: root "${u.name}" has ${u.count} untrusted hook${u.count === 1 ? "" : "s"} — ` +
157
- `not firing (run \`cruxy hooks trust ${u.absPath}\`)`));
158
- }
159
- }
160
- else {
161
- const hooksService = await buildHooksService({
162
- cwd: primaryRoot,
163
- config,
164
- interactive: Boolean(process.stdin.isTTY),
165
- logger,
166
- });
167
- hooksRunner = hooksService.runner;
168
- hookCommands = hooksService.commands;
154
+ }
155
+ // The mode a fresh session STARTS in (P5 track 3). `--plan` and the config
156
+ // default still mean plan mode; they now name a point in the mode ring rather
157
+ // than flipping a boolean, and the user can cycle away from it at runtime. A
158
+ // resumed session's recorded mode is restored by the Session itself, so this
159
+ // only decides the starting value for a fresh one.
160
+ const startMode = (opts.plan ?? config.agent.planMode) ? "plan" : DEFAULT_MODE;
161
+ // One renderer for the whole run (U.2): the streaming path and the
162
+ // approval prompt's status-suspend hook must share the same live region.
163
+ // `tui` is a request the factory may decline (pipe / screen reader /
164
+ // dumb terminal), in which case this is the unchanged live-line or
165
+ // append-only renderer.
166
+ const renderer = createRenderer(process.stdout, process.stderr, process.env, {
167
+ tui: opts.tui ?? false,
168
+ headerRight: `${config.model.provider}/${config.model.model}`,
169
+ // The model panel and the composed header (P4 track 4) have been reaching
170
+ // for these since #181 and NOTHING supplied them: `provider`/`model` were
171
+ // never passed from here, so the panel rendered "not wired yet" for the life
172
+ // of every real session while the header showed the static string above,
173
+ // frozen at whatever config said. Both surfaces were built and tested; only
174
+ // the two arguments were missing. P6 track 1 needs them live anyway.
175
+ provider: config.model.provider,
176
+ model: config.model.model,
177
+ // The WORKSPACE root, not the invocation cwd — the same root the session,
178
+ // checkpoints and tools work against, so the rail reports on the repo the
179
+ // run is actually changing. A multi-root workspace reports its primary.
180
+ gitCwd: primaryRoot,
181
+ });
182
+ // Checkpoint-before-first-mutation (C.32/C.26): the gate owns one
183
+ // CheckpointService PER touched root (lazy) plus the run's CheckpointSet,
184
+ // and fires from the approval seam inside the session — so one instance
185
+ // covers the one-shot path, every REPL turn, plan-mode execution, and
186
+ // subagents (they share this gate, so their writes join the run's set).
187
+ const checkpoints = config.checkpoint.enabled
188
+ ? new CheckpointGate({ config, primaryRoot })
189
+ : undefined;
190
+ // Sandbox (C.16): opt-in via --sandbox or sandbox.enabled. Resolving the
191
+ // service probes the runtime and THROWS CRUXY_E_SANDBOX_UNAVAILABLE if it
192
+ // is missing — fail loud here, before the agent starts, rather than
193
+ // silently running un-sandboxed. When off, ctx.sandbox stays undefined and
194
+ // execution runs on the host, unchanged.
195
+ const sandboxEnabled = opts.sandbox ?? config.sandbox.enabled;
196
+ const sandbox = sandboxEnabled
197
+ ? await SandboxService.create({
198
+ config,
199
+ cwd: primaryRoot,
200
+ // R5 (C.26): the command's root (primary — shell/test attribute to
201
+ // it this release) mounts read-write; every OTHER declared root
202
+ // mounts read-only. Cross-root write is only ever granted per-root
203
+ // via a named escalation, never blanket.
204
+ siblingRoots: workspace
205
+ .roots()
206
+ .filter((r) => !r.primary)
207
+ .map((r) => r.absPath),
208
+ reporter: renderer,
209
+ })
210
+ : undefined;
211
+ if (sandbox) {
212
+ logger.info(t.muted(`sandbox: ${sandbox.runtimeName} (network ${config.sandbox.network})`));
213
+ }
214
+ // Hooks + custom slash commands (C.19). Single-root uses the unchanged
215
+ // service (byte-identical: one runner, lazy trust prompt). Multi-root
216
+ // (C.26 step 5) builds ONE runner per root behind a HookRouter — tool
217
+ // events fire the acting root's hooks, lifecycle events fan every trusted
218
+ // root. Untrusted roots are skipped and NAMED here (never a silent loss).
219
+ let hooksRunner;
220
+ let hookCommands;
221
+ if (workspace.isMultiRoot) {
222
+ const router = await buildHooksRouter({ workspace, config, logger });
223
+ hooksRunner = router.runner;
224
+ hookCommands = router.commands;
225
+ for (const u of router.untrustedHookRoots) {
226
+ logger.info(t.muted(`hooks: root "${u.name}" has ${u.count} untrusted hook${u.count === 1 ? "" : "s"} — ` +
227
+ `not firing (run \`cruxy hooks trust ${u.absPath}\`)`));
169
228
  }
170
- // MCP servers (C.27): connect + trust-gate BEFORE building the session so
171
- // the tool catalogue is complete when the model first runs. Off by
172
- // default (no servers connect). A non-interactive run with an untrusted
173
- // config THROWS CRUXY_E_MCP_UNTRUSTED here — before any server spawns.
174
- const mcp = await connectMcpTools({
229
+ }
230
+ else {
231
+ const hooksService = await buildHooksService({
175
232
  cwd: primaryRoot,
176
233
  config,
177
- logger,
178
234
  interactive: Boolean(process.stdin.isTTY),
179
- io: defaultPromptIO(shouldUseColor()),
235
+ logger,
180
236
  });
181
- // MCP is primary-root only this release (JC-D). Name each sibling-root
182
- // server individually — never one generic line, never a silent drop —
183
- // so a user who declared `github` in a sibling knows exactly why its
184
- // tools aren't present (and that root-qualified names are a follow-up).
185
- if (workspace.isMultiRoot) {
186
- for (const d of deferredSiblingServers(workspace)) {
187
- logger.info(t.muted(`mcp: server "${d.server}" declared in ${d.root} not loaded (primary-root MCP only this release)`));
188
- }
237
+ hooksRunner = hooksService.runner;
238
+ hookCommands = hooksService.commands;
239
+ }
240
+ // MCP servers (C.27): connect + trust-gate BEFORE building the session so
241
+ // the tool catalogue is complete when the model first runs. Off by
242
+ // default (no servers connect). A non-interactive run with an untrusted
243
+ // config THROWS CRUXY_E_MCP_UNTRUSTED here before any server spawns.
244
+ const mcp = await connectMcpTools({
245
+ cwd: primaryRoot,
246
+ config,
247
+ logger,
248
+ interactive: Boolean(process.stdin.isTTY),
249
+ io: defaultPromptIO(shouldUseColor()),
250
+ });
251
+ // MCP is primary-root only this release (JC-D). Name each sibling-root
252
+ // server individually — never one generic line, never a silent drop —
253
+ // so a user who declared `github` in a sibling knows exactly why its
254
+ // tools aren't present (and that root-qualified names are a follow-up).
255
+ if (workspace.isMultiRoot) {
256
+ for (const d of deferredSiblingServers(workspace)) {
257
+ logger.info(t.muted(`mcp: server "${d.server}" declared in ${d.root} not loaded (primary-root MCP only this release)`));
189
258
  }
190
- // One-shot vs REPL turn ceiling. A one-shot run caps at
191
- // `agent.maxIterationsOneShot` (default 40): a headless run can't be
192
- // resumed by a human, so hitting the cap abandons the whole task — it
193
- // needs headroom above the interactive cap to clear legit long work,
194
- // while still bounding a runaway (and it now exits non-zero, see below).
195
- // The REPL keeps `agent.maxIterations` (a soft checkpoint). Keyed on the
196
- // one-shot MODE, never on TTY, so `cruxy run "task"` behaves the same
197
- // piped into CI or run in a terminal.
198
- const sessionConfig = interactive
199
- ? config
200
- : {
201
- ...config,
202
- agent: {
203
- ...config.agent,
204
- maxIterations: config.agent.maxIterationsOneShot,
259
+ }
260
+ // Did the factory actually give us the TUI? Asking the renderer beats
261
+ // re-deriving it: `opts.tui` is only a REQUEST, and `supportsTui` may
262
+ // have declined it (screen reader, piped stdin, TERM=dumb). A declined
263
+ // TUI must behave exactly like the REPL, which is what this yields.
264
+ const tui = renderer instanceof TuiRenderer ? renderer : null;
265
+ // A TUI session is multi-turn even when it opened with a message, so it
266
+ // takes the interactive ceiling, not the one-shot one.
267
+ const multiTurn = interactive || tui !== null;
268
+ // One-shot vs REPL turn ceiling. A one-shot run caps at
269
+ // `agent.maxIterationsOneShot` (default 40): a headless run can't be
270
+ // resumed by a human, so hitting the cap abandons the whole task — it
271
+ // needs headroom above the interactive cap to clear legit long work,
272
+ // while still bounding a runaway (and it now exits non-zero, see below).
273
+ // The REPL keeps `agent.maxIterations` (a soft checkpoint). Keyed on the
274
+ // one-shot MODE, never on TTY, so `cruxy run "task"` behaves the same
275
+ // piped into CI or run in a terminal.
276
+ const sessionConfig = multiTurn
277
+ ? config
278
+ : {
279
+ ...config,
280
+ agent: {
281
+ ...config.agent,
282
+ maxIterations: config.agent.maxIterationsOneShot,
283
+ },
284
+ };
285
+ // Session persistence (P2). The log is opened AFTER resume resolution so a
286
+ // restored session continues writing to its own file rather than starting a
287
+ // second one, and it borrows the CheckpointGate's per-turn run id rather than
288
+ // minting one — `cruxy rollback <id>` and the transcript must agree on what a
289
+ // turn is. Opening can fail (unwritable home); that degrades to running
290
+ // without persistence, never to failing the run.
291
+ const restore = resumed?.state;
292
+ const sessionId = restore?.meta.sessionId ?? randomUUID();
293
+ const recorder = SessionLog.open({
294
+ sessionId,
295
+ cwd: primaryRoot,
296
+ roots: workspace.roots().map((r) => ({ name: r.name, path: r.absPath })),
297
+ provider: config.model.provider,
298
+ model: config.model.model,
299
+ logger,
300
+ currentRunId: () => checkpoints?.currentRunId(),
301
+ ...(resumed ? { file: resumed.session.file } : {}),
302
+ }) ?? undefined;
303
+ // The TUI sidebar lists the same tree the `--resume` picker reads (P2).
304
+ // Populated AFTER the log is opened so the running session is itself listed —
305
+ // its meta line has to exist before `listSessions` can see it.
306
+ if (renderer instanceof TuiRenderer) {
307
+ renderer.setSessions(listSessions(primaryRoot, SIDEBAR_SESSIONS), sessionId);
308
+ }
309
+ // ONE key reader for the whole TUI session (P5): the input loop and the
310
+ // approval prompt's modal both lease it, so the two can never be live on
311
+ // stdin at the same time. Built here — above both consumers — because it is
312
+ // the shared thing, and created only on the TUI path; every other shell keeps
313
+ // the stderr prompt and its own reader.
314
+ const keyLease = tui ? createKeyLease(process.stdin) : undefined;
315
+ const session = buildAgentSession(sessionConfig, apiKey, workspace, Boolean(process.stdin.isTTY), startMode, renderer, checkpoints, sandbox, hooksRunner, mcp.tools, {
316
+ recorder,
317
+ ...(keyLease ? { keyLease } : {}),
318
+ ...(restore
319
+ ? {
320
+ restore: {
321
+ messages: restore.messages,
322
+ usage: restore.usage,
323
+ sessionId: restore.meta.sessionId,
324
+ mode: restore.mode,
205
325
  },
206
- };
207
- const session = buildAgentSession(sessionConfig, apiKey, workspace, Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox, hooksRunner, mcp.tools);
208
- if (interactive) {
209
- try {
210
- await runInteractive(session, undefined, renderer, checkpoints, hookCommands);
211
- }
212
- finally {
213
- // Background jobs (C.28): cancel every live job on session exit —
214
- // kill-tree'ing each job's process group (no orphan) and reporting an
215
- // honest "N job(s) cancelled". A paused job is cancelled too, but any
216
- // checkpoint it took survives for review/rollback.
217
- const cancelled = (await session.jobs?.cancelAll("session exit")) ?? 0;
218
- if (cancelled > 0) {
219
- logger.print(t.muted(`${cancelled} background job${cancelled === 1 ? "" : "s"} cancelled on exit`));
220
- }
221
- // LSP (C.12) + MCP (C.27): gracefully shut down any external server
222
- // processes spawned during the session (the shared process-exit
223
- // kill-tree is the fail-safe for a hard kill).
224
- await resetLspServices();
225
- await resetMcpServices();
226
326
  }
227
- return;
228
- }
229
- checkpoints?.beginRun(prompt);
230
- // One-shot: a single turn, then exit. Preserves scripting/pipe use.
231
- // Assistant text streams to stdout delta by delta (same as the REPL);
232
- // piped output degrades to the plain renderer (no ANSI, chrome on stderr).
233
- logger.print(`${t.accent("cruxy")} ${t.muted(t.glyph.caret)} ${prompt}\n`);
234
- // Provider/network/auth failures propagate to the top-level boundary,
235
- // which classifies them (e.g. CRUXY_E_GATEWAY_UNREACHABLE) and exits with
236
- // the matching code a one-shot run must fail non-zero on error.
237
- let result;
327
+ : {}),
328
+ });
329
+ // The context rail panel (P4 track 3). Attached AFTER the session exists,
330
+ // because the gauge measures that session's live history — and deliberately
331
+ // reads `session.messages`, never `session.usage`, which is cumulative across
332
+ // turns and would never fall when a compaction frees the window.
333
+ tui?.attachContext(new ContextGauge(() => session.messages, config.context));
334
+ // The model panel's configured value becomes LIVE (P6 track 1) — the same
335
+ // object `/model` moves, so the panel, the header and the status line cannot
336
+ // disagree about what this session is set to. Absent on a bring-your-own
337
+ // provider, where the constructor's static value stands and `/model` declines.
338
+ if (session.model)
339
+ tui?.attachModel(session.model);
340
+ if (multiTurn) {
238
341
  try {
239
- result = await session.send(prompt, renderer);
240
- logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
241
- `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
342
+ // Same session, same renderer seam, two shells: the TUI when the
343
+ // entry asked for it and the environment allowed it, the readline
344
+ // REPL otherwise. `cruxy run` never reaches the first branch.
345
+ if (tui) {
346
+ await runTui(session, tui, {
347
+ initialMessage: prompt,
348
+ checkpoints,
349
+ // The TUI reached P5 without these (P5 track 5): every custom slash
350
+ // command a project defines was unreachable from the default shell.
351
+ slashCommands: hookCommands,
352
+ ...(keyLease ? { lease: keyLease } : {}),
353
+ });
354
+ }
355
+ else {
356
+ await runInteractive(session, undefined, renderer, checkpoints, hookCommands);
357
+ }
242
358
  }
243
359
  finally {
244
- renderer.close();
245
- // Background jobs (C.28): a one-shot run has no between-turns servicing
246
- // loop, so cancel every live job on exit (kill-tree, no orphan) rather
247
- // than leave one paused forever. Checkpoints taken survive for rollback.
248
- await session.jobs?.cancelAll("session exit");
360
+ // Tear the shell down FIRST: the TUI owns the whole viewport, so
361
+ // every line below would land inside (and be erased with) the frame
362
+ // unless the screen is released before anything else prints. The
363
+ // REPL path closes its own renderer inside `runInteractive`.
364
+ tui?.close();
365
+ // Background jobs (C.28): cancel every live job on session exit —
366
+ // kill-tree'ing each job's process group (no orphan) and reporting an
367
+ // honest "N job(s) cancelled". A paused job is cancelled too, but any
368
+ // checkpoint it took survives for review/rollback.
369
+ const cancelled = (await session.jobs?.cancelAll("session exit")) ?? 0;
370
+ if (cancelled > 0) {
371
+ logger.print(t.muted(`${cancelled} background job${cancelled === 1 ? "" : "s"} cancelled on exit`));
372
+ }
249
373
  // LSP (C.12) + MCP (C.27): gracefully shut down any external server
250
- // processes spawned during the run (the shared process-exit kill-tree
251
- // is the fail-safe for a hard kill).
374
+ // processes spawned during the session (the shared process-exit
375
+ // kill-tree is the fail-safe for a hard kill).
252
376
  await resetLspServices();
253
377
  await resetMcpServices();
254
378
  }
255
- // End-of-run usage summary (C.22): honest tokens + per-tier breakdown +
256
- // cost (only when priced). Printed after the live region is torn down, for
257
- // BOTH a completed run and one that gave up below — the tokens burned are
258
- // useful either way. A thrown run (provider error) propagates past it.
259
- if (config.usage.enabled && session.lastRun) {
260
- printRunUsage(session.lastRun, config);
261
- }
262
- // Fail loud on a non-completed stop (#3/#5): a one-shot run that hit the
263
- // iteration cap or a token budget (or was cancelled) MUST exit non-zero —
264
- // otherwise CI reads a gave-up run as success. The partial history already
265
- // streamed to stdout stands; the boundary prints the coded reason to stderr
266
- // and exits with CRUXY_E_AGENT_INCOMPLETE's code. `result` is always set
267
- // here a thrown send would have propagated past this point.
268
- if (result && result.stop !== "completed") {
269
- throw agentIncomplete({
270
- stop: result.stop,
271
- iterations: result.iterations,
272
- reason: result.stopReason,
273
- // The effective one-shot cap (agent.maxIterationsOneShot), so the
274
- // message names the limit the run actually hit.
275
- maxIterations: sessionConfig.agent.maxIterations,
276
- });
277
- }
278
- });
379
+ return;
380
+ }
381
+ checkpoints?.beginRun(prompt);
382
+ // One-shot: a single turn, then exit. Preserves scripting/pipe use.
383
+ // Assistant text streams to stdout delta by delta (same as the REPL);
384
+ // piped output degrades to the plain renderer (no ANSI, chrome on stderr).
385
+ logger.print(`${t.accent("cruxy")} ${t.muted(t.glyph.caret)} ${prompt}\n`);
386
+ // Provider/network/auth failures propagate to the top-level boundary,
387
+ // which classifies them (e.g. CRUXY_E_GATEWAY_UNREACHABLE) and exits with
388
+ // the matching code a one-shot run must fail non-zero on error.
389
+ let result;
390
+ try {
391
+ result = await session.send(prompt, renderer);
392
+ logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
393
+ `tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
394
+ }
395
+ finally {
396
+ renderer.close();
397
+ // Background jobs (C.28): a one-shot run has no between-turns servicing
398
+ // loop, so cancel every live job on exit (kill-tree, no orphan) rather
399
+ // than leave one paused forever. Checkpoints taken survive for rollback.
400
+ await session.jobs?.cancelAll("session exit");
401
+ // LSP (C.12) + MCP (C.27): gracefully shut down any external server
402
+ // processes spawned during the run (the shared process-exit kill-tree
403
+ // is the fail-safe for a hard kill).
404
+ await resetLspServices();
405
+ await resetMcpServices();
406
+ }
407
+ // End-of-run usage summary (C.22): honest tokens + per-tier breakdown +
408
+ // cost (only when priced). Printed after the live region is torn down, for
409
+ // BOTH a completed run and one that gave up below — the tokens burned are
410
+ // useful either way. A thrown run (provider error) propagates past it.
411
+ if (config.usage.enabled && session.lastRun) {
412
+ printRunUsage(session.lastRun);
413
+ }
414
+ // Fail loud on a non-completed stop (#3/#5): a one-shot run that hit the
415
+ // iteration cap or a token budget (or was cancelled) MUST exit non-zero —
416
+ // otherwise CI reads a gave-up run as success. The partial history already
417
+ // streamed to stdout stands; the boundary prints the coded reason to stderr
418
+ // and exits with CRUXY_E_AGENT_INCOMPLETE's code. `result` is always set
419
+ // here — a thrown send would have propagated past this point.
420
+ if (result && result.stop !== "completed") {
421
+ throw agentIncomplete({
422
+ stop: result.stop,
423
+ iterations: result.iterations,
424
+ reason: result.stopReason,
425
+ // The effective one-shot cap (agent.maxIterationsOneShot), so the
426
+ // message names the limit the run actually hit.
427
+ maxIterations: sessionConfig.agent.maxIterations,
428
+ });
429
+ }
279
430
  }
280
431
  /** Render the just-finished run's usage as a single themed line (C.22). */
281
- function printRunUsage(record, config) {
432
+ function printRunUsage(record) {
282
433
  if (record.entries.length === 0)
283
434
  return;
284
435
  const t = themeForColor(shouldUseColor(process.stdout));
285
- const summary = summarizeRuns([record], {
286
- prices: config.usage.prices,
287
- currency: config.usage.currency,
288
- });
289
- logger.print(renderSummary(summary, t));
436
+ logger.print(renderSummary(summarizeRuns([record]), t));
290
437
  }