@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.
- 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/agent/status.js +56 -0
- 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 +401 -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 +717 -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 +65 -3
- package/dist/components/select.js +3 -3
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- 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 +204 -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 +508 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +108 -0
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +15 -0
- package/dist/tui/layout.js +314 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/overview.js +49 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +1121 -0
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/tui/views.js +66 -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 +163 -4
- package/package.json +1 -1
- package/dist/usage/cost.js +0 -29
|
@@ -10,16 +10,39 @@ import { withCheckpointGate } from "../checkpoint/index.js";
|
|
|
10
10
|
// session factory keep working.
|
|
11
11
|
export { withCheckpointGate };
|
|
12
12
|
import { shouldUseColor } from "../errors/index.js";
|
|
13
|
+
// The concrete TUI modules, not `tui/index.js`: `render/index.js` already
|
|
14
|
+
// imports `tui/renderer.js`, and routing this through the barrel would pull the
|
|
15
|
+
// whole TUI surface into a module the one-shot path also loads.
|
|
16
|
+
import { createOverlayPromptIO } from "../tui/approval-overlay.js";
|
|
17
|
+
import { canOverlay } from "../tui/overlay.js";
|
|
18
|
+
import { TuiRenderer } from "../tui/renderer.js";
|
|
13
19
|
import { buildDefaultRegistry, } from "../tools/index.js";
|
|
14
|
-
import { Session, } from "../agent/index.js";
|
|
20
|
+
import { DEFAULT_MODE, Session, } from "../agent/index.js";
|
|
15
21
|
import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
|
|
16
|
-
import { routerForConfig } from "../routing/index.js";
|
|
22
|
+
import { parseModelChoice, routerForConfig, SessionModel, } from "../routing/index.js";
|
|
17
23
|
import { MemoryService, buildMultiRootRecallBlock, rememberTool, } from "../memory/index.js";
|
|
18
24
|
import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
|
|
19
25
|
import { createWebSearchTool, createWebFetchTool } from "../web/index.js";
|
|
20
26
|
import { appendRun } from "../usage/index.js";
|
|
21
27
|
import { Semaphore, SubagentOrchestrator, makeSpawnSubagentTool, makeSpawnSubagentsTool, } from "../subagent/index.js";
|
|
22
28
|
import { ApprovalQueue, JobManager, makeRunInBackgroundTool, } from "../jobs/index.js";
|
|
29
|
+
/**
|
|
30
|
+
* Where this session's prompts are drawn (P5 track 2).
|
|
31
|
+
*
|
|
32
|
+
* The TUI gets an in-viewport modal — but only with a lease in hand. Both
|
|
33
|
+
* conditions are load-bearing and neither implies the other: without the TUI
|
|
34
|
+
* there is no drawer to paint into, and without the lease the modal would open
|
|
35
|
+
* a second raw-mode reader on the stdin the input loop is already holding,
|
|
36
|
+
* which is the contention the seam exists to prevent. Either missing, and the
|
|
37
|
+
* unchanged stderr prompt is the correct answer rather than a degraded one.
|
|
38
|
+
*/
|
|
39
|
+
function promptIOFor(renderer, lease) {
|
|
40
|
+
const color = shouldUseColor();
|
|
41
|
+
if (renderer instanceof TuiRenderer && lease && canOverlay(renderer)) {
|
|
42
|
+
return createOverlayPromptIO(renderer, lease, color);
|
|
43
|
+
}
|
|
44
|
+
return defaultPromptIO(color);
|
|
45
|
+
}
|
|
23
46
|
/**
|
|
24
47
|
* Wrap a PromptIO so the live region yields before any prompt text lands
|
|
25
48
|
* (U.2/U.4): the prompt writes to stderr while the status line owns the last
|
|
@@ -27,25 +50,51 @@ import { ApprovalQueue, JobManager, makeRunInBackgroundTool, } from "../jobs/ind
|
|
|
27
50
|
* hides the live line (the prompt IS the visible state) while keeping the
|
|
28
51
|
* step-progress register intact, so the line comes back with full context on
|
|
29
52
|
* the next transition after the user decides.
|
|
53
|
+
*
|
|
54
|
+
* Under the TUI (P5 track 2) the phase no longer hides anything — the prompt is
|
|
55
|
+
* an overlay drawer composed into the paint — but it still displaces the live
|
|
56
|
+
* state and stops the clock, so this wrapper is unchanged and still the one
|
|
57
|
+
* place the phase is entered.
|
|
58
|
+
*
|
|
59
|
+
* The trigger is `beginPrompt`, NOT `write`. This io is shared by every prompt
|
|
60
|
+
* in the session AND by write-only callers that never read — the plan
|
|
61
|
+
* executor's per-step trail, and its "plan aborted" line. Yielding on `write`
|
|
62
|
+
* treated those as pending approvals, which under the full-viewport TUI meant
|
|
63
|
+
* `TuiRenderer.paintNow` cleared the entire frame on every plan step: plan mode
|
|
64
|
+
* and the TUI together blanked the screen and never repainted until the next
|
|
65
|
+
* phase change. Only a caller that is actually about to read may release the
|
|
66
|
+
* screen, and it says so.
|
|
67
|
+
*
|
|
68
|
+
* `endPrompt` is the paired closer, and it is not optional in practice: the
|
|
69
|
+
* plan prompts had nothing that gave the screen back, so aborting a plan left
|
|
70
|
+
* the TUI painting an empty frame until some later turn happened to change the
|
|
71
|
+
* phase. Every prompt now brackets itself in a `finally`, so even a default-deny
|
|
72
|
+
* on EOF restores the live region.
|
|
30
73
|
*/
|
|
31
|
-
function suspendStatusOnPrompt(io, renderer) {
|
|
74
|
+
export function suspendStatusOnPrompt(io, renderer) {
|
|
32
75
|
if (!renderer)
|
|
33
76
|
return io;
|
|
34
77
|
return {
|
|
35
78
|
...io,
|
|
36
|
-
|
|
79
|
+
beginPrompt: () => {
|
|
80
|
+
io.beginPrompt?.();
|
|
37
81
|
renderer.setPhase({ kind: "awaiting-approval" });
|
|
38
|
-
|
|
82
|
+
},
|
|
83
|
+
endPrompt: () => {
|
|
84
|
+
io.endPrompt?.();
|
|
85
|
+
renderer.promptResolved();
|
|
39
86
|
},
|
|
40
87
|
};
|
|
41
88
|
}
|
|
42
89
|
/**
|
|
43
|
-
* Restore the live line once an approval request
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `
|
|
47
|
-
*
|
|
48
|
-
*
|
|
90
|
+
* Restore the live line once an approval request settles (U.4).
|
|
91
|
+
*
|
|
92
|
+
* A BACKSTOP, not the primary path: `promptForApproval` now brackets itself
|
|
93
|
+
* with `beginPrompt`/`endPrompt`, so the prompted path has already restored the
|
|
94
|
+
* line by the time this runs. It stays because this wraps the whole approval
|
|
95
|
+
* SERVICE — which can settle a request without ever prompting (a session grant,
|
|
96
|
+
* a policy auto-allow) — and because `promptResolved` is a no-op unless a
|
|
97
|
+
* prompt actually displaced the line, so a second call costs nothing.
|
|
49
98
|
*/
|
|
50
99
|
function resumeLineAfterApproval(requestApproval, renderer) {
|
|
51
100
|
if (!renderer)
|
|
@@ -59,6 +108,31 @@ function resumeLineAfterApproval(requestApproval, renderer) {
|
|
|
59
108
|
}
|
|
60
109
|
};
|
|
61
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Show the diff for an action that was allowed WITHOUT the user being shown it
|
|
113
|
+
* (P3) — a session grant the allowlist answered, or a read-tier pass.
|
|
114
|
+
*
|
|
115
|
+
* This is the missing caller for `StreamRenderer.preview`. The seam has been
|
|
116
|
+
* implemented on every renderer since U.2 and nothing ever originated a call:
|
|
117
|
+
* the approval prompt draws its own diff straight to the io, so a PROMPTED
|
|
118
|
+
* action is already on screen and calling `preview` there would show it twice.
|
|
119
|
+
* The gap is the other path — once the user picks "allow this session", every
|
|
120
|
+
* later edit was applied with nothing rendered at all. That is what this fills.
|
|
121
|
+
*
|
|
122
|
+
* Only fires on an allow that carries no preview of its own, and only when the
|
|
123
|
+
* action actually has one, so read-tier passes (which carry none) stay silent.
|
|
124
|
+
*/
|
|
125
|
+
export function previewSilentApprovals(requestApproval, renderer) {
|
|
126
|
+
if (!renderer)
|
|
127
|
+
return requestApproval;
|
|
128
|
+
return async (action) => {
|
|
129
|
+
const decision = await requestApproval(action);
|
|
130
|
+
if (decision.allow && !decision.prompted && action.preview) {
|
|
131
|
+
renderer.preview(action.preview);
|
|
132
|
+
}
|
|
133
|
+
return decision;
|
|
134
|
+
};
|
|
135
|
+
}
|
|
62
136
|
/**
|
|
63
137
|
* Register every CONDITIONALLY-enabled runtime tool onto `registry`, in the fixed
|
|
64
138
|
* order the model sees them: `remember` (memory), the four LSP tools, the two web
|
|
@@ -122,11 +196,16 @@ export function registerRuntimeTools(registry, config, opts = {}) {
|
|
|
122
196
|
* shared by `cruxy run` and the onboarding first-win task (so they can't drift).
|
|
123
197
|
* The approval gate's interactivity tracks the TTY, exactly as in `run`.
|
|
124
198
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
199
|
+
* The {@link PlanExecutionPolicy} over the shared U.3 allowlist and the
|
|
200
|
+
* `planRunner` are wired UNCONDITIONALLY (P5 track 3), so a session can be
|
|
201
|
+
* switched into a planning mode at runtime. `mode` decides only whether they
|
|
202
|
+
* engage; a manual session behaves exactly as before.
|
|
128
203
|
*/
|
|
129
|
-
export function buildAgentSession(config, apiKey, workspace, ttyInteractive,
|
|
204
|
+
export function buildAgentSession(config, apiKey, workspace, ttyInteractive, mode = DEFAULT_MODE, renderer, checkpoints, sandbox, hooks, mcpTools = [],
|
|
205
|
+
// An options bag rather than two more positional parameters — this signature
|
|
206
|
+
// is already at ten, and P2's two additions are both optional and unrelated
|
|
207
|
+
// to each other.
|
|
208
|
+
opts = {}) {
|
|
130
209
|
// The workspace is the single source of truth for "which roots" (C.26). Every
|
|
131
210
|
// primary-scoped subsystem below (git context, project instructions, memory,
|
|
132
211
|
// checkpoints, subagents, the approval gate) derives its cwd from the primary
|
|
@@ -142,10 +221,24 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
142
221
|
temperature: config.model.temperature,
|
|
143
222
|
gatewayUrl: config.cruxy.gatewayUrl,
|
|
144
223
|
});
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
224
|
+
// The session's model choice, and the router everything selects through
|
|
225
|
+
// (C.30 + P6 track 1). One object shared by the main loop, subagents, plan
|
|
226
|
+
// mode and background jobs — which is what makes `/model` reach all of them
|
|
227
|
+
// without re-wiring any: they hold a reference, not a resolved tier.
|
|
228
|
+
//
|
|
229
|
+
// Built ONLY for the cruxy gateway, and the omission is the honest answer
|
|
230
|
+
// rather than a gap. Tiers are a gateway concept; a bring-your-own provider's
|
|
231
|
+
// `model.model` is an upstream id this CLI must not even name (U.8), so there
|
|
232
|
+
// is no menu to offer and `router` stays `undefined` exactly as before.
|
|
233
|
+
//
|
|
234
|
+
// The starting choice is `config.model.model` when it names one — so a user
|
|
235
|
+
// who pinned `kavi` in config opens on `kavi`, and `/model auto` moves them to
|
|
236
|
+
// server-side routing rather than back to that pin. Anything else (an unknown
|
|
237
|
+
// string) falls back to `auto`, which is also the schema default.
|
|
238
|
+
const sessionModel = config.model.provider === "cruxy"
|
|
239
|
+
? new SessionModel(parseModelChoice(config.model.model) ?? "auto", routerForConfig(config))
|
|
240
|
+
: undefined;
|
|
241
|
+
const router = sessionModel;
|
|
149
242
|
// Usage telemetry (C.22): when enabled, each run's usage record is persisted
|
|
150
243
|
// to the LOCAL store. Best-effort and non-fatal — a corrupt/unwritable store
|
|
151
244
|
// is downgraded to a warning (CRUXY_E_USAGE_READ) and NEVER takes a run down.
|
|
@@ -159,7 +252,26 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
159
252
|
logger.warn(`${error.code}: ${error.title} — ${error.cause}`);
|
|
160
253
|
}
|
|
161
254
|
: undefined;
|
|
162
|
-
|
|
255
|
+
// The `run_tests` side channel (P3): the structured outcome the tool already
|
|
256
|
+
// built, handed to the renderer to draw. Every field it needs is copied
|
|
257
|
+
// across as-is — nothing is derived here, so an absent `total` stays absent
|
|
258
|
+
// rather than becoming a number the parsers refused to claim.
|
|
259
|
+
const execRegistry = buildDefaultRegistry({
|
|
260
|
+
onTestResult: renderer
|
|
261
|
+
? (result, command) => renderer.testResult({
|
|
262
|
+
passed: result.passed,
|
|
263
|
+
command: command.command,
|
|
264
|
+
durationMs: result.durationMs,
|
|
265
|
+
...(result.total !== undefined ? { total: result.total } : {}),
|
|
266
|
+
failures: result.failures.map((f) => ({
|
|
267
|
+
name: f.name,
|
|
268
|
+
...(f.file !== undefined ? { file: f.file } : {}),
|
|
269
|
+
...(f.line !== undefined ? { line: f.line } : {}),
|
|
270
|
+
})),
|
|
271
|
+
outputTruncated: result.outputTruncated,
|
|
272
|
+
})
|
|
273
|
+
: undefined,
|
|
274
|
+
});
|
|
163
275
|
const git = getGitInfo(cwd);
|
|
164
276
|
const projectInstructions = loadProjectInstructions(cwd);
|
|
165
277
|
// Persistent memory (C.29): build the recall block ONCE at session start (the
|
|
@@ -233,7 +345,7 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
233
345
|
// because subagents must get the *identical* stack over a FRESH service: same
|
|
234
346
|
// prompt + same checkpoint hook, but a new (empty) session allowlist — a
|
|
235
347
|
// grant in the parent never silently widens a child's authority.
|
|
236
|
-
const io = suspendStatusOnPrompt(
|
|
348
|
+
const io = suspendStatusOnPrompt(promptIOFor(renderer, opts.keyLease), renderer);
|
|
237
349
|
// The C.26 coupling: `checkpointsActive` is true exactly when a per-root gate is
|
|
238
350
|
// wired, and it is set on the SAME ctx whose `requestApproval` IS that gate — so
|
|
239
351
|
// lifting the non-primary-write refusal and capturing the write are one decision.
|
|
@@ -251,7 +363,10 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
251
363
|
// here too, so foreground servicing and job production share it.
|
|
252
364
|
const executionSemaphore = new Semaphore(config.subagent.maxConcurrency);
|
|
253
365
|
const approvalQueue = new ApprovalQueue();
|
|
254
|
-
const gate = (approval) => serializeGate(withCheckpointGate(
|
|
366
|
+
const gate = (approval) => serializeGate(withCheckpointGate(
|
|
367
|
+
// Outside `resumeLineAfterApproval`, so the live region is restored
|
|
368
|
+
// before a preview block is committed into it.
|
|
369
|
+
previewSilentApprovals(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), renderer), checkpoints, workspace), approvalMutex, cwd);
|
|
255
370
|
// Subagent orchestration (C.14): spawn_subagent goes on the main registry
|
|
256
371
|
// only when depth allows (maxDepth 0 disables the feature structurally).
|
|
257
372
|
// Registered before the plan wiring so plan-mode execution steps can
|
|
@@ -270,7 +385,18 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
270
385
|
sandbox,
|
|
271
386
|
checkpointsActive,
|
|
272
387
|
executionSemaphore,
|
|
273
|
-
|
|
388
|
+
// A child gets the parent's MODE but not its grants (P5 track 3). The two
|
|
389
|
+
// are different kinds of thing: a scoped session grant is consent to one
|
|
390
|
+
// command in one root, and widening it to a child would be authority the
|
|
391
|
+
// user never gave — whereas auto-approve is a standing instruction about
|
|
392
|
+
// how this session runs, and a subagent that stopped to ask would strand an
|
|
393
|
+
// unattended run on a prompt nobody is watching.
|
|
394
|
+
makeChildApproval: () => gate(new ApprovalService({
|
|
395
|
+
cwd,
|
|
396
|
+
interactive: ttyInteractive,
|
|
397
|
+
io,
|
|
398
|
+
policy: new InteractivePolicy(new SessionAllowlist(), io, autoApprove),
|
|
399
|
+
})),
|
|
274
400
|
});
|
|
275
401
|
// Background jobs (C.28): the session-scoped manager the `run_in_background`
|
|
276
402
|
// tool dispatches onto. Built only when enabled. It shares the SAME execution
|
|
@@ -312,61 +438,33 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
312
438
|
spawnManyTool,
|
|
313
439
|
jobTool,
|
|
314
440
|
});
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
ctx,
|
|
339
|
-
execRegistry,
|
|
340
|
-
planPolicy,
|
|
341
|
-
io,
|
|
342
|
-
interactive: ttyInteractive,
|
|
343
|
-
messages,
|
|
344
|
-
git,
|
|
345
|
-
projectInstructions,
|
|
346
|
-
recalledMemory: turnMemory,
|
|
347
|
-
renderer: turnRenderer,
|
|
348
|
-
router,
|
|
349
|
-
onRequestUsage,
|
|
350
|
-
});
|
|
351
|
-
return new Session({
|
|
352
|
-
provider,
|
|
353
|
-
registry: execRegistry,
|
|
354
|
-
config,
|
|
355
|
-
ctx,
|
|
356
|
-
git,
|
|
357
|
-
projectInstructions,
|
|
358
|
-
recalledMemory,
|
|
359
|
-
planMode: true,
|
|
360
|
-
planRunner,
|
|
361
|
-
hooks,
|
|
362
|
-
router,
|
|
363
|
-
onRunUsage,
|
|
364
|
-
jobs: jobManager,
|
|
365
|
-
});
|
|
366
|
-
}
|
|
441
|
+
// The plan wiring is built ALWAYS, not only when the session STARTS in a
|
|
442
|
+
// planning mode (P5 track 3). It used to be gated on `--plan`, and that had a
|
|
443
|
+
// consequence nobody had written down: `setPlanMode` refuses to engage without
|
|
444
|
+
// a wired runner, so `/plan` in a session launched without the flag silently
|
|
445
|
+
// did nothing at all. A mode the user can cycle into at runtime cannot have
|
|
446
|
+
// its machinery decided at construction.
|
|
447
|
+
//
|
|
448
|
+
// Building it unconditionally is behaviour-preserving for a manual session:
|
|
449
|
+
// `PlanExecutionPolicy` with no safe-step grants enabled is a pass-through to
|
|
450
|
+
// the interactive policy, and `planRunner` is only ever *called* by a
|
|
451
|
+
// planning mode.
|
|
452
|
+
//
|
|
453
|
+
// One allowlist shared by the plan-approval prompt and the per-action gate, so
|
|
454
|
+
// a grant recorded during execution is honored by U.3's own check.
|
|
455
|
+
const allowlist = new SessionAllowlist();
|
|
456
|
+
// Late-bound to the session constructed below. The policy has to read the LIVE
|
|
457
|
+
// mode — the user can leave auto-approve between two actions of a single turn
|
|
458
|
+
// — and the session cannot exist yet because it needs the ctx this policy is
|
|
459
|
+
// wired into. Before it exists there is nothing to approve, so the `false`
|
|
460
|
+
// fallback is a closed door rather than a gap.
|
|
461
|
+
const holder = {};
|
|
462
|
+
const autoApprove = () => holder.session?.getAutoApprove() ?? false;
|
|
463
|
+
const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io, autoApprove));
|
|
367
464
|
const approval = new ApprovalService({
|
|
368
465
|
cwd,
|
|
369
466
|
interactive: ttyInteractive,
|
|
467
|
+
policy: planPolicy,
|
|
370
468
|
io,
|
|
371
469
|
});
|
|
372
470
|
const ctx = {
|
|
@@ -378,7 +476,23 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
378
476
|
checkpointsActive,
|
|
379
477
|
sandbox,
|
|
380
478
|
};
|
|
381
|
-
|
|
479
|
+
const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, }) => runPlanSession({
|
|
480
|
+
provider,
|
|
481
|
+
config,
|
|
482
|
+
ctx,
|
|
483
|
+
execRegistry,
|
|
484
|
+
planPolicy,
|
|
485
|
+
io,
|
|
486
|
+
interactive: ttyInteractive,
|
|
487
|
+
messages,
|
|
488
|
+
git,
|
|
489
|
+
projectInstructions,
|
|
490
|
+
recalledMemory: turnMemory,
|
|
491
|
+
renderer: turnRenderer,
|
|
492
|
+
router,
|
|
493
|
+
onRequestUsage,
|
|
494
|
+
});
|
|
495
|
+
holder.session = new Session({
|
|
382
496
|
provider,
|
|
383
497
|
registry: execRegistry,
|
|
384
498
|
config,
|
|
@@ -386,9 +500,17 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
386
500
|
git,
|
|
387
501
|
projectInstructions,
|
|
388
502
|
recalledMemory,
|
|
503
|
+
mode,
|
|
504
|
+
planRunner,
|
|
389
505
|
hooks,
|
|
390
506
|
router,
|
|
507
|
+
// The same object as `router`, under the handle `/model` mutates. See the
|
|
508
|
+
// note on `SessionArgs.model`.
|
|
509
|
+
...(sessionModel ? { model: sessionModel } : {}),
|
|
391
510
|
onRunUsage,
|
|
392
511
|
jobs: jobManager,
|
|
512
|
+
recorder: opts.recorder,
|
|
513
|
+
restore: opts.restore,
|
|
393
514
|
});
|
|
515
|
+
return holder.session;
|
|
394
516
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Did you mean …?" for bare operands (P1 entry).
|
|
3
|
+
*
|
|
4
|
+
* Bare `cruxy <token>` is a message — `cruxy refactor`, `cruxy fix`. But a
|
|
5
|
+
* mistyped subcommand (`cruxy confgi`) arrives in exactly the same shape, and
|
|
6
|
+
* silently sending it to the model would spend a turn on nonsense. So the
|
|
7
|
+
* DEFAULT is "message", and only a token that is a near-miss for a real
|
|
8
|
+
* subcommand is refused.
|
|
9
|
+
*
|
|
10
|
+
* Edit distance, not the fuzzy subsequence scorer in `components/fuzzy.ts`:
|
|
11
|
+
* that scorer answers "does the query appear in order inside the label?", which
|
|
12
|
+
* is the wrong question here. `confgi` is not a subsequence of `config` (there
|
|
13
|
+
* is no `i` after the `g`), so the fuzzy path would let the commonest kind of
|
|
14
|
+
* typo — a transposition — straight through.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Levenshtein distance, bounded: returns `max + 1` as soon as the true distance
|
|
18
|
+
* is known to exceed `max`. The bound matters because the caller only ever asks
|
|
19
|
+
* "is this within 1 or 2?", and the early exit makes a far-apart pair cheap.
|
|
20
|
+
*/
|
|
21
|
+
export function editDistance(a, b, max = Infinity) {
|
|
22
|
+
if (a === b)
|
|
23
|
+
return 0;
|
|
24
|
+
if (Math.abs(a.length - b.length) > max)
|
|
25
|
+
return max + 1;
|
|
26
|
+
// Single row, rolled forward — the full matrix is never needed.
|
|
27
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
28
|
+
for (let i = 1; i <= a.length; i++) {
|
|
29
|
+
const row = [i];
|
|
30
|
+
let best = i;
|
|
31
|
+
for (let j = 1; j <= b.length; j++) {
|
|
32
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
33
|
+
const value = Math.min(prev[j] + 1, // deletion
|
|
34
|
+
row[j - 1] + 1, // insertion
|
|
35
|
+
prev[j - 1] + cost);
|
|
36
|
+
row.push(value);
|
|
37
|
+
if (value < best)
|
|
38
|
+
best = value;
|
|
39
|
+
}
|
|
40
|
+
// Every remaining row can only grow the running minimum, so once the whole
|
|
41
|
+
// row is past the bound the answer is settled.
|
|
42
|
+
if (best > max)
|
|
43
|
+
return max + 1;
|
|
44
|
+
prev = row;
|
|
45
|
+
}
|
|
46
|
+
return prev[b.length];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* How wrong a token may be and still read as a typo rather than a message.
|
|
50
|
+
*
|
|
51
|
+
* Short tokens get one edit: at four or five characters, two edits reaches too
|
|
52
|
+
* many unrelated words, and a short word is far more likely to be a real
|
|
53
|
+
* instruction (`fix`, `test`, `plan`) than a fumbled command. Six or more
|
|
54
|
+
* characters gets two, which is what catches a transposition (`confgi` →
|
|
55
|
+
* `config`, distance 2) without pulling in genuine tasks (`refactor` is 4+ edits
|
|
56
|
+
* from every command name).
|
|
57
|
+
*/
|
|
58
|
+
export function typoThreshold(length) {
|
|
59
|
+
return length <= 5 ? 1 : 2;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The closest subcommand to `token`, or `null` when it is far enough from all of
|
|
63
|
+
* them to be a message. Ties break on the earlier command in `commands`, which
|
|
64
|
+
* is registration order — deterministic, never arbitrary.
|
|
65
|
+
*/
|
|
66
|
+
export function suggestCommand(token, commands) {
|
|
67
|
+
const max = typoThreshold(token.length);
|
|
68
|
+
let best = null;
|
|
69
|
+
for (const name of commands) {
|
|
70
|
+
const distance = editDistance(token.toLowerCase(), name.toLowerCase(), max);
|
|
71
|
+
if (distance > max)
|
|
72
|
+
continue;
|
|
73
|
+
if (best === null || distance < best.distance)
|
|
74
|
+
best = { name, distance };
|
|
75
|
+
}
|
|
76
|
+
return best?.name ?? null;
|
|
77
|
+
}
|
package/dist/components/fuzzy.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { fitMiddle } from "../render/layout.js";
|
|
2
2
|
import { resolveTheme } from "../theme/index.js";
|
|
3
|
-
import {
|
|
4
|
-
import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
|
|
3
|
+
import { componentFrame, defaultComponentIO, resolveNonInteractive, } from "./input.js";
|
|
5
4
|
/** Word-boundary characters that earn the boundary bonus for the NEXT char. */
|
|
6
5
|
const SEPARATORS = new Set(["/", "-", "_", ".", " "]);
|
|
7
6
|
/** Per-character score weights — the entire ranking formula. */
|
|
@@ -98,7 +97,8 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
|
|
|
98
97
|
const t = resolveTheme(io.caps);
|
|
99
98
|
const g = t.glyph;
|
|
100
99
|
const maxVisible = opts.maxVisible ?? 10;
|
|
101
|
-
|
|
100
|
+
// The host's region when there is one (the TUI overlay), else its own.
|
|
101
|
+
const frame = componentFrame(io);
|
|
102
102
|
let query = "";
|
|
103
103
|
let cursor = 0; // index into the ranked results
|
|
104
104
|
const paint = (ranked) => {
|
package/dist/components/input.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { interactiveRequired } from "../errors/index.js";
|
|
2
|
-
|
|
2
|
+
// The concrete modules, NOT `render/index.js`: that barrel pulls in
|
|
3
|
+
// `createRenderer`, which imports `tui/renderer.js`, which is where the overlay
|
|
4
|
+
// seam (P5 track 1) lives — and a component importing the TUI that hosts it is
|
|
5
|
+
// a cycle that leaves this module half-initialized at import time.
|
|
6
|
+
import { detectCapabilities } from "../render/capabilities.js";
|
|
7
|
+
import { createFrame } from "./frame.js";
|
|
3
8
|
import { decodeKeys } from "./keys.js";
|
|
4
9
|
/** Build the real reader over `stdin` (or an injected fake in tests). */
|
|
5
10
|
export function createKeyReader(stdin = process.stdin) {
|
|
@@ -86,10 +91,12 @@ export async function readSingleKey(stdin = process.stdin) {
|
|
|
86
91
|
}
|
|
87
92
|
/** The real environment: frames to stderr, keys from stdin, caps from stderr. */
|
|
88
93
|
export function defaultComponentIO() {
|
|
94
|
+
// Detection resolves both axes (stderr for output, stdin for input) and their
|
|
95
|
+
// conjunction — so `interactive` is read, not recomputed here.
|
|
89
96
|
const caps = detectCapabilities(process.stderr);
|
|
90
97
|
return {
|
|
91
98
|
caps,
|
|
92
|
-
interactive:
|
|
99
|
+
interactive: caps.interactive,
|
|
93
100
|
write: (text) => void process.stderr.write(text),
|
|
94
101
|
keys: createKeyReader(process.stdin),
|
|
95
102
|
};
|
|
@@ -109,3 +116,11 @@ export function resolveNonInteractive(io, what, defaultValue, alternatives = [])
|
|
|
109
116
|
}
|
|
110
117
|
throw interactiveRequired(what, alternatives);
|
|
111
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* The transient region a component should draw into: the host's, when one is
|
|
121
|
+
* offered (the TUI overlay), otherwise its own stderr frame. One line, shared,
|
|
122
|
+
* so no component has to know which world it is running in.
|
|
123
|
+
*/
|
|
124
|
+
export function componentFrame(io) {
|
|
125
|
+
return io.makeFrame ? io.makeFrame() : createFrame(io.write, io.caps);
|
|
126
|
+
}
|
package/dist/components/keys.js
CHANGED
|
@@ -3,14 +3,34 @@
|
|
|
3
3
|
* transformation — no terminal, no state — so every mapping row is directly
|
|
4
4
|
* unit-testable. The stateful raw-mode plumbing lives in `input.ts`.
|
|
5
5
|
*/
|
|
6
|
+
const CTRL_B = 0x02;
|
|
6
7
|
const CTRL_C = 0x03;
|
|
7
8
|
const CTRL_D = 0x04;
|
|
9
|
+
const CTRL_K = 0x0b;
|
|
8
10
|
const BACKSPACE = 0x08;
|
|
9
11
|
const TAB = 0x09;
|
|
10
12
|
const LF = 0x0a;
|
|
11
13
|
const CR = 0x0d;
|
|
12
14
|
const ESC = 0x1b;
|
|
13
15
|
const DELETE = 0x7f;
|
|
16
|
+
/**
|
|
17
|
+
* CSI final byte `Z` — CBT, "cursor backward tabulation". Every terminal that
|
|
18
|
+
* distinguishes Shift+Tab at all sends this and sends it for nothing else, so
|
|
19
|
+
* unlike the arrows it is safe to accept with OR without parameters.
|
|
20
|
+
*/
|
|
21
|
+
const CBT = 0x5a;
|
|
22
|
+
/**
|
|
23
|
+
* CSI final byte `~` — the "tilde sequence" family, where the NUMERIC PARAMETER
|
|
24
|
+
* carries the identity (`5` = Page Up, `6` = Page Down) rather than the final
|
|
25
|
+
* byte. That inverts the arrow rule below: for `~` the parameter must be read,
|
|
26
|
+
* not rejected, so these are matched on the leading number instead.
|
|
27
|
+
*/
|
|
28
|
+
const TILDE = 0x7e;
|
|
29
|
+
/** Leading `~`-sequence parameter → key. `ESC [ 5 ~` / `ESC [ 6 ~`. */
|
|
30
|
+
const TILDES = {
|
|
31
|
+
5: "page-up",
|
|
32
|
+
6: "page-down",
|
|
33
|
+
};
|
|
14
34
|
/** CSI final byte → arrow key, for `ESC [ <final>` sequences. */
|
|
15
35
|
const ARROWS = {
|
|
16
36
|
0x41: "up", // A
|
|
@@ -23,9 +43,9 @@ const ARROWS = {
|
|
|
23
43
|
* keys per chunk; all are returned in order.
|
|
24
44
|
*
|
|
25
45
|
* Escape handling is deliberately simple: `ESC [ A..D` decodes to an arrow,
|
|
26
|
-
* any other CSI sequence (`ESC [ …final`) is
|
|
27
|
-
* must not leak garbage chars into a query), and
|
|
28
|
-
* `escape`. Terminals send arrow sequences atomically in practice; a sequence
|
|
46
|
+
* `ESC [ … Z` decodes to Shift+Tab, any other CSI sequence (`ESC [ …final`) is
|
|
47
|
+
* swallowed whole (unmapped keys must not leak garbage chars into a query), and
|
|
48
|
+
* a lone ESC decodes to `escape`. Terminals send arrow sequences atomically in practice; a sequence
|
|
29
49
|
* split across chunks degrades to `escape` + literal chars, which is safe
|
|
30
50
|
* (escape cancels).
|
|
31
51
|
*/
|
|
@@ -42,6 +62,38 @@ export function decodeKeys(chunk) {
|
|
|
42
62
|
while (j < buf.length && buf[j] >= 0x20 && buf[j] <= 0x3f)
|
|
43
63
|
j++;
|
|
44
64
|
if (j < buf.length) {
|
|
65
|
+
if (buf[j] === CBT) {
|
|
66
|
+
// Shift+Tab, in BOTH its forms: bare `ESC [ Z`, and the
|
|
67
|
+
// parameterized `ESC [ 1 ; 2 Z` some terminals send when a modifier
|
|
68
|
+
// map is active. The `j === i + 2` guard below deliberately rejects
|
|
69
|
+
// parameterized sequences — it exists so a modifier-arrow cannot act
|
|
70
|
+
// as a plain arrow — but `Z` has exactly one meaning whatever its
|
|
71
|
+
// parameters, so applying that guard here would silently swallow
|
|
72
|
+
// half the terminals that can send this key at all.
|
|
73
|
+
keys.push({ kind: "shift-tab" });
|
|
74
|
+
i = j + 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (buf[j] === TILDE) {
|
|
78
|
+
// `ESC [ <n> ~`. The number is the key, so it is read rather than
|
|
79
|
+
// rejected — the opposite of the arrow rule below, and for the same
|
|
80
|
+
// underlying reason: match on whatever byte actually carries the
|
|
81
|
+
// identity. A modifier (`ESC [ 5 ; 2 ~`) still means Page Up, so
|
|
82
|
+
// only the parameter BEFORE the first `;` is parsed; an unmapped
|
|
83
|
+
// number (`3` = Delete) falls through and is swallowed like any
|
|
84
|
+
// other unbound sequence.
|
|
85
|
+
let n = 0;
|
|
86
|
+
let digits = 0;
|
|
87
|
+
for (let k = i + 2; k < j && buf[k] >= 0x30 && buf[k] <= 0x39; k++) {
|
|
88
|
+
n = n * 10 + (buf[k] - 0x30);
|
|
89
|
+
digits++;
|
|
90
|
+
}
|
|
91
|
+
const tilde = digits > 0 ? TILDES[n] : undefined;
|
|
92
|
+
if (tilde)
|
|
93
|
+
keys.push({ kind: tilde });
|
|
94
|
+
i = j + 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
45
97
|
// Only a bare `ESC [ <final>` maps to an arrow; parameterized
|
|
46
98
|
// sequences (modifier arrows, Home/End variants) are swallowed —
|
|
47
99
|
// an unmapped combo must do nothing, not act as a plain arrow.
|
|
@@ -60,6 +112,11 @@ export function decodeKeys(chunk) {
|
|
|
60
112
|
i++;
|
|
61
113
|
continue;
|
|
62
114
|
}
|
|
115
|
+
if (byte === CTRL_B) {
|
|
116
|
+
keys.push({ kind: "ctrl-b" });
|
|
117
|
+
i++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
63
120
|
if (byte === CTRL_C) {
|
|
64
121
|
keys.push({ kind: "ctrl-c" });
|
|
65
122
|
i++;
|
|
@@ -88,6 +145,11 @@ export function decodeKeys(chunk) {
|
|
|
88
145
|
i++;
|
|
89
146
|
continue;
|
|
90
147
|
}
|
|
148
|
+
if (byte === CTRL_K) {
|
|
149
|
+
keys.push({ kind: "ctrl-k" });
|
|
150
|
+
i++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
91
153
|
if (byte < 0x20) {
|
|
92
154
|
// Other control chars: ignore (same rule as the secret reader).
|
|
93
155
|
i++;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { fitMiddle } from "../render/layout.js";
|
|
2
2
|
import { resolveTheme } from "../theme/index.js";
|
|
3
|
-
import {
|
|
4
|
-
import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
|
|
3
|
+
import { componentFrame, defaultComponentIO, resolveNonInteractive, } from "./input.js";
|
|
5
4
|
/**
|
|
6
5
|
* Pick one item: ↑/↓ move (wrapping past either end), Enter selects the
|
|
7
6
|
* highlighted item, Esc / Ctrl-C / EOF cancel. Renders a transient frame via
|
|
@@ -19,7 +18,8 @@ export async function selectList(items, opts = {}, io = defaultComponentIO()) {
|
|
|
19
18
|
const t = resolveTheme(io.caps);
|
|
20
19
|
const g = t.glyph;
|
|
21
20
|
const maxVisible = opts.maxVisible ?? 10;
|
|
22
|
-
|
|
21
|
+
// The host's region when there is one (the TUI overlay), else its own.
|
|
22
|
+
const frame = componentFrame(io);
|
|
23
23
|
let cursor = Math.min(Math.max(opts.initialIndex ?? 0, 0), items.length - 1);
|
|
24
24
|
const paint = () => {
|
|
25
25
|
const lines = [];
|