@cruxy/cli 0.24.0 → 0.26.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/session.d.ts +13 -0
- package/dist/agent/session.js +6 -0
- package/dist/approval/prompt.d.ts +7 -1
- package/dist/approval/prompt.js +52 -17
- package/dist/checkpoint/gate-hook.d.ts +28 -0
- package/dist/checkpoint/gate-hook.js +98 -0
- package/dist/checkpoint/gate.d.ts +7 -1
- package/dist/checkpoint/gate.js +8 -2
- package/dist/checkpoint/index.d.ts +1 -0
- package/dist/checkpoint/index.js +1 -0
- package/dist/cli/commands/rollback.d.ts +4 -1
- package/dist/cli/commands/rollback.js +16 -9
- package/dist/cli/commands/run.js +12 -0
- package/dist/cli/commands/skills.js +10 -2
- package/dist/cli/repl.d.ts +1 -1
- package/dist/cli/repl.js +113 -1
- package/dist/cli/session-factory.d.ts +4 -12
- package/dist/cli/session-factory.js +50 -96
- package/dist/components/frame.d.ts +6 -3
- package/dist/components/frame.js +21 -23
- package/dist/components/fuzzy.js +5 -1
- package/dist/components/select.js +4 -1
- package/dist/config/schema.d.ts +86 -0
- package/dist/config/schema.js +41 -0
- package/dist/errors/constructors.d.ts +18 -0
- package/dist/errors/constructors.js +49 -0
- package/dist/errors/types.d.ts +13 -0
- package/dist/errors/types.js +21 -0
- package/dist/jobs/approval-queue.d.ts +85 -0
- package/dist/jobs/approval-queue.js +96 -0
- package/dist/jobs/dispatch-tool.d.ts +34 -0
- package/dist/jobs/dispatch-tool.js +96 -0
- package/dist/jobs/index.d.ts +6 -0
- package/dist/jobs/index.js +6 -0
- package/dist/jobs/log-buffer.d.ts +31 -0
- package/dist/jobs/log-buffer.js +30 -0
- package/dist/jobs/log-renderer.d.ts +32 -0
- package/dist/jobs/log-renderer.js +70 -0
- package/dist/jobs/manager.d.ts +139 -0
- package/dist/jobs/manager.js +397 -0
- package/dist/jobs/types.d.ts +81 -0
- package/dist/jobs/types.js +10 -0
- package/dist/render/capabilities.d.ts +11 -0
- package/dist/render/capabilities.js +19 -3
- package/dist/render/diff.d.ts +1 -1
- package/dist/render/diff.js +23 -7
- package/dist/render/index.d.ts +4 -2
- package/dist/render/index.js +8 -2
- package/dist/render/layout.d.ts +59 -0
- package/dist/render/layout.js +158 -0
- package/dist/render/resize.d.ts +36 -0
- package/dist/render/resize.js +45 -0
- package/dist/render/state.d.ts +13 -0
- package/dist/render/state.js +38 -0
- package/dist/render/tty-renderer.d.ts +8 -0
- package/dist/render/tty-renderer.js +36 -11
- package/dist/render/types.d.ts +15 -1
- package/dist/subagent/orchestrator.d.ts +9 -0
- package/dist/subagent/orchestrator.js +6 -1
- package/dist/subagent/semaphore.d.ts +40 -11
- package/dist/subagent/semaphore.js +23 -26
- package/package.json +1 -1
package/dist/cli/repl.js
CHANGED
|
@@ -5,10 +5,14 @@ import { runGatedShell } from "../tools/shell/exec.js";
|
|
|
5
5
|
import { addRootToWorkspace } from "../workspace/index.js";
|
|
6
6
|
import { themeForColor } from "../theme/index.js";
|
|
7
7
|
import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
|
|
8
|
-
import { createRenderer } from "../render/index.js";
|
|
8
|
+
import { createRenderer, fit, resolveColumns, } from "../render/index.js";
|
|
9
9
|
import { logger } from "../utils/logger.js";
|
|
10
10
|
/** The REPL prompts on stdout; its chrome resolves against stdout's color. */
|
|
11
11
|
const theme = themeForColor(shouldUseColor(process.stdout));
|
|
12
|
+
/** Fit a committed REPL line to stdout's current width (U.12), id/status-first. */
|
|
13
|
+
function fitOut(line) {
|
|
14
|
+
return fit(line, resolveColumns(process.stdout), theme.glyph.ellipsis);
|
|
15
|
+
}
|
|
12
16
|
const PROMPT = `${theme.accent("cruxy")} ${theme.muted(theme.glyph.caret)} `;
|
|
13
17
|
/**
|
|
14
18
|
* The REPL's slash commands — the autocomplete candidate set (U.7). Keep in
|
|
@@ -20,6 +24,9 @@ export const REPL_COMMANDS = [
|
|
|
20
24
|
"/compact",
|
|
21
25
|
"/reload",
|
|
22
26
|
"/plan",
|
|
27
|
+
"/jobs",
|
|
28
|
+
"/logs",
|
|
29
|
+
"/cancel",
|
|
23
30
|
"/exit",
|
|
24
31
|
"/quit",
|
|
25
32
|
];
|
|
@@ -29,6 +36,9 @@ const HELP = `Commands:
|
|
|
29
36
|
/compact summarize older history to free up context now
|
|
30
37
|
/reload re-read project instructions (CRUXY.md)
|
|
31
38
|
/plan toggle plan mode (propose a plan before executing)
|
|
39
|
+
/jobs list background jobs and their status
|
|
40
|
+
/logs <id> show a background job's log
|
|
41
|
+
/cancel <id> cancel a background job
|
|
32
42
|
/exit, /quit leave cruxy
|
|
33
43
|
Ctrl+D leave cruxy`;
|
|
34
44
|
const defaultIO = () => ({
|
|
@@ -148,6 +158,91 @@ async function handleAddRoot(input, session) {
|
|
|
148
158
|
printReplError(err);
|
|
149
159
|
}
|
|
150
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Service any background-job approvals that piled up (C.28) — the auto-surface
|
|
163
|
+
* point. Called when the foreground is idle (between turns), so a paused job's
|
|
164
|
+
* gated action is decided through the SAME U.3 prompt as a foreground one, one at
|
|
165
|
+
* a time. No pending → a no-op (nothing printed). The job resumes on approval.
|
|
166
|
+
*/
|
|
167
|
+
async function drainJobApprovals(session) {
|
|
168
|
+
const jobs = session.jobs;
|
|
169
|
+
if (!jobs || !jobs.hasPendingApprovals())
|
|
170
|
+
return;
|
|
171
|
+
logger.print(theme.muted(`\n${theme.glyph.bullet} a background job needs your approval:`));
|
|
172
|
+
await jobs.serviceApprovals();
|
|
173
|
+
}
|
|
174
|
+
/** Render the background-job list (`/jobs`). */
|
|
175
|
+
function handleJobsList(session) {
|
|
176
|
+
const jobs = session.jobs;
|
|
177
|
+
if (!jobs) {
|
|
178
|
+
logger.print(theme.muted("background jobs are disabled — enable with `cruxy config set jobs.enabled true`"));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const list = jobs.list();
|
|
182
|
+
if (list.length === 0) {
|
|
183
|
+
logger.print(theme.muted("no background jobs this session"));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
for (const j of list) {
|
|
187
|
+
const status = j.status === "failed" ? theme.danger(j.status) : theme.accent(j.status);
|
|
188
|
+
const pending = j.pendingApproval
|
|
189
|
+
? theme.muted(` — needs approval: ${j.pendingApproval}`)
|
|
190
|
+
: "";
|
|
191
|
+
const err = j.error ? theme.muted(` (${j.error})`) : "";
|
|
192
|
+
// Fit id-first so the job id + status always survive; the label/notes tail
|
|
193
|
+
// truncates with an honest ellipsis at narrow width (U.12).
|
|
194
|
+
logger.print(fitOut(`${theme.strong(j.id)} ${status} ${j.label}${pending}${err}`));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Print one job's log (`/logs <id>`). */
|
|
198
|
+
function handleJobLogs(input, session) {
|
|
199
|
+
const jobs = session.jobs;
|
|
200
|
+
if (!jobs) {
|
|
201
|
+
logger.print(theme.muted("background jobs are disabled"));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const id = input.slice("/logs".length).trim();
|
|
205
|
+
if (!id) {
|
|
206
|
+
logger.print(theme.muted("usage: /logs <id>"));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const log = jobs.logs(id);
|
|
211
|
+
if (log.dropped > 0) {
|
|
212
|
+
logger.print(theme.muted(`… ${log.dropped} earlier line(s) rolled off`));
|
|
213
|
+
}
|
|
214
|
+
for (const line of log.lines) {
|
|
215
|
+
const text = line.stream === "err" ? theme.danger(line.text) : line.text;
|
|
216
|
+
logger.print(fitOut(text));
|
|
217
|
+
}
|
|
218
|
+
logger.print(theme.muted(`(${log.status})`));
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
printReplError(err);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Cancel a job (`/cancel <id>`). */
|
|
225
|
+
async function handleJobCancel(input, session) {
|
|
226
|
+
const jobs = session.jobs;
|
|
227
|
+
if (!jobs) {
|
|
228
|
+
logger.print(theme.muted("background jobs are disabled"));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const id = input.slice("/cancel".length).trim();
|
|
232
|
+
if (!id) {
|
|
233
|
+
logger.print(theme.muted("usage: /cancel <id>"));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const cancelled = jobs.cancel(id);
|
|
238
|
+
logger.print(theme.muted(cancelled
|
|
239
|
+
? `cancelling ${id} (its process tree is killed; any checkpoint survives for rollback)`
|
|
240
|
+
: `${id} is already finished`));
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
printReplError(err);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
151
246
|
/**
|
|
152
247
|
* Drive an interactive multi-turn session: prompt, read a line, dispatch slash
|
|
153
248
|
* commands or run a turn, repeat. Assistant text and tool-call progress stream
|
|
@@ -170,6 +265,11 @@ export async function runInteractive(session, io = defaultIO(), renderer = creat
|
|
|
170
265
|
}
|
|
171
266
|
async function replLoop(session, io, renderer, checkpoints, slashCommands = []) {
|
|
172
267
|
for (;;) {
|
|
268
|
+
// Auto-surface (C.28): before prompting, service any background-job approvals
|
|
269
|
+
// that piled up while the last turn ran — one at a time, through the same U.3
|
|
270
|
+
// prompt. Done here (foreground idle, no readline interface live) so a job's
|
|
271
|
+
// prompt never contends with the line reader.
|
|
272
|
+
await drainJobApprovals(session);
|
|
173
273
|
const line = await readLine(io, PROMPT);
|
|
174
274
|
// EOF / Ctrl+D.
|
|
175
275
|
if (line === null) {
|
|
@@ -217,6 +317,18 @@ async function replLoop(session, io, renderer, checkpoints, slashCommands = [])
|
|
|
217
317
|
logger.print(HELP);
|
|
218
318
|
continue;
|
|
219
319
|
}
|
|
320
|
+
if (trimmed === "/jobs") {
|
|
321
|
+
handleJobsList(session);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (trimmed === "/logs" || trimmed.startsWith("/logs ")) {
|
|
325
|
+
handleJobLogs(trimmed, session);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (trimmed === "/cancel" || trimmed.startsWith("/cancel ")) {
|
|
329
|
+
await handleJobCancel(trimmed, session);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
220
332
|
if (trimmed === "/add-root" || trimmed.startsWith("/add-root ")) {
|
|
221
333
|
await handleAddRoot(trimmed, session);
|
|
222
334
|
continue;
|
|
@@ -1,21 +1,12 @@
|
|
|
1
1
|
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
-
import
|
|
2
|
+
import { withCheckpointGate } from "../checkpoint/index.js";
|
|
3
3
|
import type { CheckpointGate } from "../checkpoint/index.js";
|
|
4
|
+
export { withCheckpointGate };
|
|
4
5
|
import type { SandboxService } from "../sandbox/index.js";
|
|
5
6
|
import type { StreamRenderer } from "../render/index.js";
|
|
6
|
-
import { ToolRegistry, type
|
|
7
|
+
import { ToolRegistry, type Tool } from "../tools/index.js";
|
|
7
8
|
import { Session, type LifecycleHookRunner } from "../agent/index.js";
|
|
8
9
|
import type { Workspace } from "../workspace/index.js";
|
|
9
|
-
/**
|
|
10
|
-
* Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
|
|
11
|
-
* whole point: a tool mutates only *after* `requestApproval` resolves, so
|
|
12
|
-
* snapshotting after an `allow` decision but before returning it means the
|
|
13
|
-
* checkpoint always lands before the run's first mutation — and a denied
|
|
14
|
-
* action never creates one. The same seam records which paths the run touched
|
|
15
|
-
* (file actions) or that attribution is lost (shell), for rollback's
|
|
16
|
-
* external-change detection.
|
|
17
|
-
*/
|
|
18
|
-
export declare function withCheckpointGate(requestApproval: (action: ApproveAction) => Promise<ApprovalDecision>, gate: CheckpointGate | undefined, ws: Workspace): (action: ApproveAction) => Promise<ApprovalDecision>;
|
|
19
10
|
/**
|
|
20
11
|
* Register every CONDITIONALLY-enabled runtime tool onto `registry`, in the fixed
|
|
21
12
|
* order the model sees them: `remember` (memory), the four LSP tools, the two web
|
|
@@ -37,6 +28,7 @@ export declare function registerRuntimeTools(registry: ToolRegistry, config: Cru
|
|
|
37
28
|
mcpTools?: Tool[];
|
|
38
29
|
spawnTool?: Tool;
|
|
39
30
|
spawnManyTool?: Tool;
|
|
31
|
+
jobTool?: Tool;
|
|
40
32
|
}): void;
|
|
41
33
|
/**
|
|
42
34
|
* Build a ready-to-run agent {@link Session} from a resolved key — the wiring
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import { createProvider } from "@cruxy/sdk";
|
|
3
2
|
import { loadProjectInstructions } from "../config/index.js";
|
|
4
3
|
import { logger } from "../utils/logger.js";
|
|
5
4
|
import { getGitInfo } from "../utils/git.js";
|
|
6
|
-
import { ApprovalMutex, ApprovalService, InteractivePolicy, SessionAllowlist,
|
|
5
|
+
import { ApprovalMutex, ApprovalService, InteractivePolicy, SessionAllowlist, defaultPromptIO, serializeGate, } from "../approval/index.js";
|
|
6
|
+
import { withCheckpointGate } from "../checkpoint/index.js";
|
|
7
|
+
// Re-exported for back-compat: the checkpoint hook moved to the checkpoint
|
|
8
|
+
// package (so the subagent orchestrator and C.28 jobs can compose it without
|
|
9
|
+
// importing the CLI layer). Existing imports of `withCheckpointGate` from the
|
|
10
|
+
// session factory keep working.
|
|
11
|
+
export { withCheckpointGate };
|
|
7
12
|
import { shouldUseColor } from "../errors/index.js";
|
|
8
13
|
import { buildDefaultRegistry, } from "../tools/index.js";
|
|
9
14
|
import { Session, } from "../agent/index.js";
|
|
@@ -13,7 +18,8 @@ import { MemoryService, buildMultiRootRecallBlock, rememberTool, } from "../memo
|
|
|
13
18
|
import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
|
|
14
19
|
import { createWebSearchTool, createWebFetchTool } from "../web/index.js";
|
|
15
20
|
import { appendRun } from "../usage/index.js";
|
|
16
|
-
import { SubagentOrchestrator, makeSpawnSubagentTool, makeSpawnSubagentsTool, } from "../subagent/index.js";
|
|
21
|
+
import { Semaphore, SubagentOrchestrator, makeSpawnSubagentTool, makeSpawnSubagentsTool, } from "../subagent/index.js";
|
|
22
|
+
import { ApprovalQueue, JobManager, makeRunInBackgroundTool, } from "../jobs/index.js";
|
|
17
23
|
/**
|
|
18
24
|
* Wrap a PromptIO so the live region yields before any prompt text lands
|
|
19
25
|
* (U.2/U.4): the prompt writes to stderr while the status line owns the last
|
|
@@ -53,99 +59,6 @@ function resumeLineAfterApproval(requestApproval, renderer) {
|
|
|
53
59
|
}
|
|
54
60
|
};
|
|
55
61
|
}
|
|
56
|
-
/**
|
|
57
|
-
* Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
|
|
58
|
-
* whole point: a tool mutates only *after* `requestApproval` resolves, so
|
|
59
|
-
* snapshotting after an `allow` decision but before returning it means the
|
|
60
|
-
* checkpoint always lands before the run's first mutation — and a denied
|
|
61
|
-
* action never creates one. The same seam records which paths the run touched
|
|
62
|
-
* (file actions) or that attribution is lost (shell), for rollback's
|
|
63
|
-
* external-change detection.
|
|
64
|
-
*/
|
|
65
|
-
export function withCheckpointGate(requestApproval, gate, ws) {
|
|
66
|
-
if (!gate)
|
|
67
|
-
return requestApproval;
|
|
68
|
-
return async (action) => {
|
|
69
|
-
const decision = await requestApproval(action);
|
|
70
|
-
if (!decision.allow)
|
|
71
|
-
return decision;
|
|
72
|
-
const request = classify(action, ws.primary().absPath);
|
|
73
|
-
if (request.tier === "read")
|
|
74
|
-
return decision;
|
|
75
|
-
if (action.kind === "shell" || action.kind === "test") {
|
|
76
|
-
// JC-β residual: non-primary shell/test are Step 5, so they are still
|
|
77
|
-
// hard-attributed to the primary root regardless of `action.root` (which
|
|
78
|
-
// those tools populate as the seam). They can mutate files we cannot
|
|
79
|
-
// attribute (scripts, snapshot writers) — record the lost attribution.
|
|
80
|
-
const root = ws.primary();
|
|
81
|
-
const svc = gate.serviceFor(root.name, root.absPath);
|
|
82
|
-
const checkpoint = await svc.ensureCheckpoint();
|
|
83
|
-
await svc.recordShellMutation();
|
|
84
|
-
if (checkpoint) {
|
|
85
|
-
await gate.recordMember(root.name, root.absPath, checkpoint.id);
|
|
86
|
-
}
|
|
87
|
-
return decision;
|
|
88
|
-
}
|
|
89
|
-
if (action.kind === "vcs") {
|
|
90
|
-
// C.26 Step 4: a PR now names its root (⚖︎#11), so the checkpoint is
|
|
91
|
-
// attributed to THAT selected root — its git commit stages/lands in that
|
|
92
|
-
// root's working tree, never the primary's. `recordShellMutation` because a
|
|
93
|
-
// `git add -A` + commit mutates the tree opaquely (no per-file attribution).
|
|
94
|
-
// Fall back to the primary only if a root name is somehow absent (defensive).
|
|
95
|
-
const root = (action.root ? ws.tryRootByName(action.root) : undefined) ??
|
|
96
|
-
ws.primary();
|
|
97
|
-
const svc = gate.serviceFor(root.name, root.absPath);
|
|
98
|
-
const checkpoint = await svc.ensureCheckpoint();
|
|
99
|
-
await svc.recordShellMutation();
|
|
100
|
-
if (checkpoint) {
|
|
101
|
-
await gate.recordMember(root.name, root.absPath, checkpoint.id);
|
|
102
|
-
}
|
|
103
|
-
return decision;
|
|
104
|
-
}
|
|
105
|
-
// File actions (write/edit/patch): attribute each RESOLVED target to its root
|
|
106
|
-
// (JC-G — post-confinement truth) and checkpoint every touched root. A patch
|
|
107
|
-
// may span roots; each root gets its own checkpoint + set member.
|
|
108
|
-
for (const [rootName, group] of attributeFileTargets(action, ws)) {
|
|
109
|
-
const svc = gate.serviceFor(rootName, group.rootAbsPath);
|
|
110
|
-
const checkpoint = await svc.ensureCheckpoint();
|
|
111
|
-
await svc.recordTouched(group.paths);
|
|
112
|
-
if (checkpoint) {
|
|
113
|
-
await gate.recordMember(rootName, group.rootAbsPath, checkpoint.id);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return decision;
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Group a file action's resolved absolute targets by the root that contains each
|
|
121
|
-
* (JC-G). write/edit carry an already-absolute `path`; patch preview paths are
|
|
122
|
-
* relative to the PRIMARY cwd (`path.relative(ctx.cwd, abs)` in apply_patch), so
|
|
123
|
-
* we reconstruct the absolute path from the primary root rather than trusting
|
|
124
|
-
* classify's `targets` — which also correctly handles a patch spanning roots.
|
|
125
|
-
*/
|
|
126
|
-
function attributeFileTargets(action, ws) {
|
|
127
|
-
const abs = [];
|
|
128
|
-
if (action.kind === "write" || action.kind === "edit") {
|
|
129
|
-
if (action.path)
|
|
130
|
-
abs.push(action.path);
|
|
131
|
-
}
|
|
132
|
-
else if (action.kind === "patch" && action.preview?.type === "patch") {
|
|
133
|
-
for (const file of action.preview.files) {
|
|
134
|
-
abs.push(path.resolve(ws.primary().absPath, file.path));
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
const byRoot = new Map();
|
|
138
|
-
for (const target of abs) {
|
|
139
|
-
const root = ws.rootContaining(target);
|
|
140
|
-
const group = byRoot.get(root.name) ?? {
|
|
141
|
-
rootAbsPath: root.absPath,
|
|
142
|
-
paths: [],
|
|
143
|
-
};
|
|
144
|
-
group.paths.push(target);
|
|
145
|
-
byRoot.set(root.name, group);
|
|
146
|
-
}
|
|
147
|
-
return byRoot;
|
|
148
|
-
}
|
|
149
62
|
/**
|
|
150
63
|
* Register every CONDITIONALLY-enabled runtime tool onto `registry`, in the fixed
|
|
151
64
|
* order the model sees them: `remember` (memory), the four LSP tools, the two web
|
|
@@ -198,6 +111,11 @@ export function registerRuntimeTools(registry, config, opts = {}) {
|
|
|
198
111
|
if (opts.spawnManyTool)
|
|
199
112
|
registry.register(opts.spawnManyTool);
|
|
200
113
|
}
|
|
114
|
+
// Background jobs (C.28): the non-blocking `run_in_background` dispatch tool,
|
|
115
|
+
// registered only when the feature is enabled (off by default). Bound to the
|
|
116
|
+
// session's job manager by the caller.
|
|
117
|
+
if (config.jobs.enabled && opts.jobTool)
|
|
118
|
+
registry.register(opts.jobTool);
|
|
201
119
|
}
|
|
202
120
|
/**
|
|
203
121
|
* Build a ready-to-run agent {@link Session} from a resolved key — the wiring
|
|
@@ -326,6 +244,13 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
326
244
|
// Read-tier actions bypass it (see serializeGate), so a parallel read fan-out is
|
|
327
245
|
// never stalled behind an unrelated pending prompt.
|
|
328
246
|
const approvalMutex = new ApprovalMutex();
|
|
247
|
+
// The ONE execution semaphore for the whole session (C.28 + C.33): subagent
|
|
248
|
+
// fan-out AND background jobs draw permits from THIS instance, so
|
|
249
|
+
// `subagent.maxConcurrency` bounds their COMBINED concurrency — not one cap
|
|
250
|
+
// each. The ONE pending-approval queue background jobs produce onto is created
|
|
251
|
+
// here too, so foreground servicing and job production share it.
|
|
252
|
+
const executionSemaphore = new Semaphore(config.subagent.maxConcurrency);
|
|
253
|
+
const approvalQueue = new ApprovalQueue();
|
|
329
254
|
const gate = (approval) => serializeGate(withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, workspace), approvalMutex, cwd);
|
|
330
255
|
// Subagent orchestration (C.14): spawn_subagent goes on the main registry
|
|
331
256
|
// only when depth allows (maxDepth 0 disables the feature structurally).
|
|
@@ -344,8 +269,34 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
344
269
|
renderer,
|
|
345
270
|
sandbox,
|
|
346
271
|
checkpointsActive,
|
|
272
|
+
executionSemaphore,
|
|
347
273
|
makeChildApproval: () => gate(new ApprovalService({ cwd, interactive: ttyInteractive, io })),
|
|
348
274
|
});
|
|
275
|
+
// Background jobs (C.28): the session-scoped manager the `run_in_background`
|
|
276
|
+
// tool dispatches onto. Built only when enabled. It shares the SAME execution
|
|
277
|
+
// semaphore, approval queue, and approval mutex as the foreground/subagents —
|
|
278
|
+
// one system, one cap, one queue — and each job gets its OWN checkpoint gate
|
|
279
|
+
// (keyed by job id) so `cruxy rollback <id>` isolates a job.
|
|
280
|
+
const jobManager = config.jobs.enabled
|
|
281
|
+
? new JobManager({
|
|
282
|
+
config,
|
|
283
|
+
provider,
|
|
284
|
+
router,
|
|
285
|
+
parentRegistry: execRegistry,
|
|
286
|
+
cwd,
|
|
287
|
+
workspace,
|
|
288
|
+
logger,
|
|
289
|
+
git,
|
|
290
|
+
projectInstructions,
|
|
291
|
+
sandbox,
|
|
292
|
+
semaphore: executionSemaphore,
|
|
293
|
+
approvalQueue,
|
|
294
|
+
approvalMutex,
|
|
295
|
+
foregroundInteractive: ttyInteractive,
|
|
296
|
+
promptIO: io,
|
|
297
|
+
})
|
|
298
|
+
: undefined;
|
|
299
|
+
const jobTool = jobManager ? makeRunInBackgroundTool(jobManager) : undefined;
|
|
349
300
|
// Now that the orchestrator exists, register every conditionally-enabled tool
|
|
350
301
|
// (remember / LSP / web / MCP / spawn_subagent) through the one seam the JC-B
|
|
351
302
|
// allowlist test also uses — order preserved, behaviour byte-identical.
|
|
@@ -359,6 +310,7 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
359
310
|
mcpTools,
|
|
360
311
|
spawnTool,
|
|
361
312
|
spawnManyTool,
|
|
313
|
+
jobTool,
|
|
362
314
|
});
|
|
363
315
|
if (planMode) {
|
|
364
316
|
// One allowlist shared by the plan-approval prompt and the per-action
|
|
@@ -409,6 +361,7 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
409
361
|
hooks,
|
|
410
362
|
router,
|
|
411
363
|
onRunUsage,
|
|
364
|
+
jobs: jobManager,
|
|
412
365
|
});
|
|
413
366
|
}
|
|
414
367
|
const approval = new ApprovalService({
|
|
@@ -436,5 +389,6 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
436
389
|
hooks,
|
|
437
390
|
router,
|
|
438
391
|
onRunUsage,
|
|
392
|
+
jobs: jobManager,
|
|
439
393
|
});
|
|
440
394
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { stripAnsi } from "../render/layout.js";
|
|
1
2
|
import type { RenderCapabilities } from "../render/index.js";
|
|
2
|
-
/** The visible text of a possibly-styled row. */
|
|
3
|
-
export
|
|
3
|
+
/** The visible text of a possibly-styled row (re-exported from the U.12 home). */
|
|
4
|
+
export { stripAnsi };
|
|
4
5
|
/**
|
|
5
6
|
* The transient multi-line region interactive components draw into (U.7) —
|
|
6
7
|
* the multi-row analog of the TTY renderer's single managed status line, with
|
|
@@ -12,6 +13,8 @@ export declare function stripAnsi(text: string): string;
|
|
|
12
13
|
* soft-wrap; wrapped rows would break erasure and leave artifacts.
|
|
13
14
|
* - `clear()` removes the frame entirely — after a component resolves, the
|
|
14
15
|
* screen holds zero leftover bytes from the interaction.
|
|
16
|
+
* - On resize (U.12) the frame reflows its current rows in place at the new
|
|
17
|
+
* width; committed output above is untouched. `clear()` also unsubscribes.
|
|
15
18
|
*
|
|
16
19
|
* Requires cursor control (`caps.cursor`); components guard on that before
|
|
17
20
|
* constructing one.
|
|
@@ -19,7 +22,7 @@ export declare function stripAnsi(text: string): string;
|
|
|
19
22
|
export interface Frame {
|
|
20
23
|
/** Repaint the frame with these rows (erases the previous paint first). */
|
|
21
24
|
render(lines: string[]): void;
|
|
22
|
-
/** Erase the frame completely. Idempotent. */
|
|
25
|
+
/** Erase the frame completely and release the resize subscription. Idempotent. */
|
|
23
26
|
clear(): void;
|
|
24
27
|
}
|
|
25
28
|
export declare function createFrame(write: (text: string) => void, caps: RenderCapabilities): Frame;
|
package/dist/components/frame.js
CHANGED
|
@@ -1,32 +1,22 @@
|
|
|
1
|
+
import { fit, stripAnsi } from "../render/layout.js";
|
|
1
2
|
import { resolveTheme } from "../theme/index.js";
|
|
2
3
|
/** Erase the current line and return the cursor to column 0 (same as U.2). */
|
|
3
4
|
const CLEAR_LINE = "\r\x1b[2K";
|
|
4
5
|
/** Move the cursor up one row. */
|
|
5
6
|
const CURSOR_UP = "\x1b[1A";
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
const SGR = /\x1b\[[0-9;]*m/g;
|
|
9
|
-
/** The visible text of a possibly-styled row. */
|
|
10
|
-
export function stripAnsi(text) {
|
|
11
|
-
return text.replace(SGR, "");
|
|
12
|
-
}
|
|
7
|
+
/** The visible text of a possibly-styled row (re-exported from the U.12 home). */
|
|
8
|
+
export { stripAnsi };
|
|
13
9
|
export function createFrame(write, caps) {
|
|
14
10
|
let drawn = 0;
|
|
11
|
+
let lastLines = [];
|
|
15
12
|
const ellipsis = resolveTheme(caps).glyph.ellipsis;
|
|
16
13
|
/**
|
|
17
14
|
* Truncate to width-1 (cursor rests after the last cell; a full-width row
|
|
18
15
|
* would auto-wrap on some terminals). Width is measured on VISIBLE
|
|
19
|
-
* characters — rows may carry ANSI color
|
|
20
|
-
* styled
|
|
21
|
-
* dropped rather than risking a cut escape sequence).
|
|
16
|
+
* characters (U.12 {@link fit}) — rows may carry ANSI color; a row that fits
|
|
17
|
+
* passes through styled, an overflowing row is truncated on its visible text.
|
|
22
18
|
*/
|
|
23
|
-
const
|
|
24
|
-
const room = Math.max(1, caps.width - 1);
|
|
25
|
-
const plain = stripAnsi(line);
|
|
26
|
-
if (plain.length <= room)
|
|
27
|
-
return line;
|
|
28
|
-
return plain.slice(0, room - 1) + ellipsis;
|
|
29
|
-
};
|
|
19
|
+
const fitRow = (line) => fit(line, Math.max(1, caps.width - 1), ellipsis);
|
|
30
20
|
const erase = () => {
|
|
31
21
|
if (drawn === 0)
|
|
32
22
|
return;
|
|
@@ -38,14 +28,22 @@ export function createFrame(write, caps) {
|
|
|
38
28
|
write(out);
|
|
39
29
|
drawn = 0;
|
|
40
30
|
};
|
|
31
|
+
const paint = (lines) => {
|
|
32
|
+
erase();
|
|
33
|
+
lastLines = lines;
|
|
34
|
+
if (lines.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
write(lines.map(fitRow).join("\n"));
|
|
37
|
+
drawn = lines.length;
|
|
38
|
+
};
|
|
39
|
+
// Reflow the live frame at the new width; committed output above is immutable.
|
|
40
|
+
const unsubscribe = caps.onResize?.(() => paint(lastLines)) ?? null;
|
|
41
41
|
return {
|
|
42
|
-
render
|
|
42
|
+
render: paint,
|
|
43
|
+
clear() {
|
|
43
44
|
erase();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
write(lines.map(fit).join("\n"));
|
|
47
|
-
drawn = lines.length;
|
|
45
|
+
lastLines = [];
|
|
46
|
+
unsubscribe?.();
|
|
48
47
|
},
|
|
49
|
-
clear: erase,
|
|
50
48
|
};
|
|
51
49
|
}
|
package/dist/components/fuzzy.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fitMiddle } from "../render/layout.js";
|
|
1
2
|
import { resolveTheme } from "../theme/index.js";
|
|
2
3
|
import { createFrame } from "./frame.js";
|
|
3
4
|
import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
|
|
@@ -117,7 +118,10 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
|
|
|
117
118
|
for (const [i, row] of visible.entries()) {
|
|
118
119
|
const selected = top + i === cursor;
|
|
119
120
|
const marker = selected ? t.accent(g.pointer) : " ";
|
|
120
|
-
|
|
121
|
+
// Middle-truncate so a long hit keeps its basename at narrow width
|
|
122
|
+
// (U.12); the match highlight survives when the label fits and is
|
|
123
|
+
// dropped (plain text) only when it must truncate — identity over decor.
|
|
124
|
+
const label = fitMiddle(highlightMatch(row.label, row.match.positions, t), Math.max(1, io.caps.width - 2), g.ellipsis);
|
|
121
125
|
lines.push(`${marker} ${selected ? label : t.muted(label)}`);
|
|
122
126
|
}
|
|
123
127
|
const hidden = ranked.length - visible.length;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fitMiddle } from "../render/layout.js";
|
|
1
2
|
import { resolveTheme } from "../theme/index.js";
|
|
2
3
|
import { createFrame } from "./frame.js";
|
|
3
4
|
import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
|
|
@@ -29,7 +30,9 @@ export async function selectList(items, opts = {}, io = defaultComponentIO()) {
|
|
|
29
30
|
for (const [i, item] of visible.entries()) {
|
|
30
31
|
const selected = top + i === cursor;
|
|
31
32
|
const marker = selected ? t.accent(g.pointer) : " ";
|
|
32
|
-
|
|
33
|
+
// Middle-truncate the label so a long path keeps its basename (identity)
|
|
34
|
+
// at narrow width (U.12); reserve the marker + space.
|
|
35
|
+
const label = fitMiddle(toLabel(item), Math.max(1, io.caps.width - 2), g.ellipsis);
|
|
33
36
|
lines.push(`${marker} ${selected ? label : t.muted(label)}`);
|
|
34
37
|
}
|
|
35
38
|
const hidden = items.length - visible.length;
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -291,6 +291,53 @@ export declare const SubagentConfigSchema: z.ZodObject<{
|
|
|
291
291
|
maxIterations?: number | undefined;
|
|
292
292
|
} | undefined;
|
|
293
293
|
}>;
|
|
294
|
+
/**
|
|
295
|
+
* Session-scoped background jobs (C.28): non-interactive orchestration the main
|
|
296
|
+
* agent dispatches with `run_in_background`, running CONCURRENTLY with the
|
|
297
|
+
* foreground session but bound to it — nothing survives session exit (NOT a
|
|
298
|
+
* daemon). A job hitting a gated action enqueues an approval request into the one
|
|
299
|
+
* foreground queue and pauses until a human services it; a paused job releases
|
|
300
|
+
* its execution slot.
|
|
301
|
+
*
|
|
302
|
+
* Two distinct ceilings, stated explicitly because they bound different things:
|
|
303
|
+
* • {@link maxJobs} — how many background JOBS may exist at once (queued +
|
|
304
|
+
* running + paused). A dispatch past it is refused (`CRUXY_E_JOB_LIMIT`).
|
|
305
|
+
* • the shared execution cap is `subagent.maxConcurrency` (default 3) — how many
|
|
306
|
+
* runs (subagents AND jobs, combined) may EXECUTE at once. It is NOT
|
|
307
|
+
* duplicated here: jobs and subagents draw from the one semaphore. So up to
|
|
308
|
+
* `maxJobs` jobs can be alive while only `maxConcurrency` execute; the rest
|
|
309
|
+
* wait for a slot (or are paused on a human).
|
|
310
|
+
*/
|
|
311
|
+
export declare const JobsConfigSchema: z.ZodObject<{
|
|
312
|
+
/**
|
|
313
|
+
* Master switch. When true, the `run_in_background` tool is registered so the
|
|
314
|
+
* agent can dispatch background jobs. OFF by default — background work is an
|
|
315
|
+
* opt-in capability, and a session that never enables it behaves exactly as
|
|
316
|
+
* before (no tool, no manager, no queue).
|
|
317
|
+
*/
|
|
318
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
319
|
+
/**
|
|
320
|
+
* Ceiling on live background jobs (queued + running + paused). Distinct from
|
|
321
|
+
* the shared execution cap (`subagent.maxConcurrency`): this bounds how many
|
|
322
|
+
* jobs can be OUTSTANDING, not how many run at once. Default 5.
|
|
323
|
+
*/
|
|
324
|
+
maxJobs: z.ZodDefault<z.ZodNumber>;
|
|
325
|
+
/**
|
|
326
|
+
* How many of a job's most-recent log lines are retained in its ring buffer
|
|
327
|
+
* for `cruxy logs <id>`. Bounded so a chatty job can't grow memory without
|
|
328
|
+
* limit; older lines roll off oldest-first. Default 1000.
|
|
329
|
+
*/
|
|
330
|
+
logBufferLines: z.ZodDefault<z.ZodNumber>;
|
|
331
|
+
}, "strict", z.ZodTypeAny, {
|
|
332
|
+
maxJobs: number;
|
|
333
|
+
enabled: boolean;
|
|
334
|
+
logBufferLines: number;
|
|
335
|
+
}, {
|
|
336
|
+
maxJobs?: number | undefined;
|
|
337
|
+
enabled?: boolean | undefined;
|
|
338
|
+
logBufferLines?: number | undefined;
|
|
339
|
+
}>;
|
|
340
|
+
export type JobsConfig = z.infer<typeof JobsConfigSchema>;
|
|
294
341
|
/**
|
|
295
342
|
* Sandbox / container execution (C.16): defense-in-depth beneath the U.3 gate.
|
|
296
343
|
* When enabled, `run_command` and `run_tests` execute inside an isolated,
|
|
@@ -1081,6 +1128,35 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1081
1128
|
maxIterations?: number | undefined;
|
|
1082
1129
|
} | undefined;
|
|
1083
1130
|
}>>;
|
|
1131
|
+
jobs: z.ZodDefault<z.ZodObject<{
|
|
1132
|
+
/**
|
|
1133
|
+
* Master switch. When true, the `run_in_background` tool is registered so the
|
|
1134
|
+
* agent can dispatch background jobs. OFF by default — background work is an
|
|
1135
|
+
* opt-in capability, and a session that never enables it behaves exactly as
|
|
1136
|
+
* before (no tool, no manager, no queue).
|
|
1137
|
+
*/
|
|
1138
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
1139
|
+
/**
|
|
1140
|
+
* Ceiling on live background jobs (queued + running + paused). Distinct from
|
|
1141
|
+
* the shared execution cap (`subagent.maxConcurrency`): this bounds how many
|
|
1142
|
+
* jobs can be OUTSTANDING, not how many run at once. Default 5.
|
|
1143
|
+
*/
|
|
1144
|
+
maxJobs: z.ZodDefault<z.ZodNumber>;
|
|
1145
|
+
/**
|
|
1146
|
+
* How many of a job's most-recent log lines are retained in its ring buffer
|
|
1147
|
+
* for `cruxy logs <id>`. Bounded so a chatty job can't grow memory without
|
|
1148
|
+
* limit; older lines roll off oldest-first. Default 1000.
|
|
1149
|
+
*/
|
|
1150
|
+
logBufferLines: z.ZodDefault<z.ZodNumber>;
|
|
1151
|
+
}, "strict", z.ZodTypeAny, {
|
|
1152
|
+
maxJobs: number;
|
|
1153
|
+
enabled: boolean;
|
|
1154
|
+
logBufferLines: number;
|
|
1155
|
+
}, {
|
|
1156
|
+
maxJobs?: number | undefined;
|
|
1157
|
+
enabled?: boolean | undefined;
|
|
1158
|
+
logBufferLines?: number | undefined;
|
|
1159
|
+
}>>;
|
|
1084
1160
|
test: z.ZodDefault<z.ZodObject<{
|
|
1085
1161
|
/** Explicit test command (overrides package.json detection). */
|
|
1086
1162
|
command: z.ZodOptional<z.ZodString>;
|
|
@@ -1532,6 +1608,11 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1532
1608
|
idleTimeout: number;
|
|
1533
1609
|
maxResults: number;
|
|
1534
1610
|
};
|
|
1611
|
+
jobs: {
|
|
1612
|
+
maxJobs: number;
|
|
1613
|
+
enabled: boolean;
|
|
1614
|
+
logBufferLines: number;
|
|
1615
|
+
};
|
|
1535
1616
|
test: {
|
|
1536
1617
|
maxIterations: number;
|
|
1537
1618
|
captureBytes: number;
|
|
@@ -1677,6 +1758,11 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1677
1758
|
idleTimeout?: number | undefined;
|
|
1678
1759
|
maxResults?: number | undefined;
|
|
1679
1760
|
} | undefined;
|
|
1761
|
+
jobs?: {
|
|
1762
|
+
maxJobs?: number | undefined;
|
|
1763
|
+
enabled?: boolean | undefined;
|
|
1764
|
+
logBufferLines?: number | undefined;
|
|
1765
|
+
} | undefined;
|
|
1680
1766
|
test?: {
|
|
1681
1767
|
command?: string | undefined;
|
|
1682
1768
|
maxIterations?: number | undefined;
|