@cruxy/cli 1.2.1 → 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.
- package/dist/agent/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +20 -1
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +171 -69
- package/dist/approval/classify.js +204 -0
- package/dist/approval/policy.js +41 -3
- package/dist/approval/prompt.js +49 -22
- package/dist/checkpoint/gate.js +12 -0
- package/dist/cli/commands/run.js +374 -227
- package/dist/cli/commands/usage.js +45 -45
- package/dist/cli/onboard.js +2 -1
- package/dist/cli/program.js +60 -18
- package/dist/cli/repl.js +67 -249
- package/dist/cli/session-commands.js +755 -0
- package/dist/cli/session-factory.js +198 -76
- package/dist/cli/suggest.js +77 -0
- package/dist/components/fuzzy.js +3 -3
- package/dist/components/input.js +17 -2
- package/dist/components/keys.js +27 -3
- package/dist/components/select.js +3 -3
- package/dist/config/project.js +53 -1
- package/dist/config/schema.js +49 -16
- package/dist/jobs/log-renderer.js +47 -0
- package/dist/onboarding/steps.js +13 -22
- package/dist/plan/approve.js +36 -24
- package/dist/plan/execute.js +9 -7
- package/dist/plan/render.js +10 -23
- package/dist/plan/service.js +4 -1
- package/dist/render/capabilities.js +30 -1
- package/dist/render/context-view.js +106 -0
- package/dist/render/diff.js +198 -12
- package/dist/render/index.js +31 -5
- package/dist/render/plain-renderer.js +38 -2
- package/dist/render/plan-view.js +108 -0
- package/dist/render/resize.js +7 -2
- package/dist/render/status-view.js +66 -0
- package/dist/render/test-view.js +89 -0
- package/dist/render/tty-renderer.js +40 -0
- package/dist/routing/index.js +1 -0
- package/dist/routing/router.js +13 -4
- package/dist/routing/session-model.js +109 -0
- package/dist/routing/types.js +14 -0
- package/dist/session/export.js +88 -0
- package/dist/session/index.js +20 -0
- package/dist/session/list.js +137 -0
- package/dist/session/log.js +137 -0
- package/dist/session/paths.js +73 -0
- package/dist/session/replay.js +169 -0
- package/dist/session/resume.js +128 -0
- package/dist/session/types.js +223 -0
- package/dist/subagent/orchestrator.js +23 -0
- package/dist/testing/run-tests-tool.js +8 -0
- package/dist/tools/registry.js +3 -3
- package/dist/tui/app.js +385 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +63 -0
- package/dist/tui/index.js +10 -0
- package/dist/tui/layout.js +269 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +776 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/usage/collect.js +6 -6
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +5 -2
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +50 -4
- package/package.json +1 -1
- package/dist/usage/cost.js +0 -29
package/dist/cli/commands/run.js
CHANGED
|
@@ -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(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
235
|
+
logger,
|
|
180
236
|
});
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
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
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
432
|
+
function printRunUsage(record) {
|
|
282
433
|
if (record.entries.length === 0)
|
|
283
434
|
return;
|
|
284
435
|
const t = themeForColor(shouldUseColor(process.stdout));
|
|
285
|
-
|
|
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
|
}
|