@cruxy/cli 0.24.0 → 0.25.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/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/repl.d.ts +1 -1
- package/dist/cli/repl.js +106 -0
- package/dist/cli/session-factory.d.ts +4 -12
- package/dist/cli/session-factory.js +50 -96
- 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/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/agent/session.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { StreamRenderer } from "../render/index.js";
|
|
|
4
4
|
import { type Router } from "../routing/index.js";
|
|
5
5
|
import type { ToolContext } from "../tools/index.js";
|
|
6
6
|
import type { ToolRegistry } from "../tools/index.js";
|
|
7
|
+
import type { JobManager } from "../jobs/index.js";
|
|
7
8
|
import { type RequestUsage, type UsageRecord } from "../usage/index.js";
|
|
8
9
|
import { type AgentResult, type LifecycleHookRunner } from "./loop.js";
|
|
9
10
|
/**
|
|
@@ -67,6 +68,14 @@ export interface SessionArgs {
|
|
|
67
68
|
* persists it locally — it never transmits. Omitted → no persistence.
|
|
68
69
|
*/
|
|
69
70
|
onRunUsage?: (record: UsageRecord) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Session-scoped background jobs (C.28): the manager the `run_in_background`
|
|
73
|
+
* tool dispatches onto. Exposed on the session so the REPL can service pending
|
|
74
|
+
* job approvals between turns and drive `/jobs`/`/logs`/`/cancel`, and so
|
|
75
|
+
* `cruxy run` can cancel every live job on session exit. Present only when
|
|
76
|
+
* `jobs.enabled`; omitted → no background jobs (unchanged behaviour).
|
|
77
|
+
*/
|
|
78
|
+
jobs?: JobManager;
|
|
70
79
|
}
|
|
71
80
|
/**
|
|
72
81
|
* Estimate the token footprint of a message list with a cheap chars/4 heuristic
|
|
@@ -106,6 +115,10 @@ export declare class Session {
|
|
|
106
115
|
/** The ambient tool capabilities (gate + sandbox + cwd/config). Exposed so a
|
|
107
116
|
* shell-bound custom slash command (C.19) runs through the SAME gated path. */
|
|
108
117
|
get toolContext(): ToolContext;
|
|
118
|
+
/** The background-job manager (C.28), or undefined when jobs are disabled.
|
|
119
|
+
* The REPL uses it to service paused-job approvals and drive `/jobs`; `cruxy
|
|
120
|
+
* run` uses it to cancel every live job on exit. */
|
|
121
|
+
get jobs(): JobManager | undefined;
|
|
109
122
|
/** Whether plan mode is currently on. */
|
|
110
123
|
getPlanMode(): boolean;
|
|
111
124
|
/**
|
package/dist/agent/session.js
CHANGED
|
@@ -72,6 +72,12 @@ export class Session {
|
|
|
72
72
|
get toolContext() {
|
|
73
73
|
return this.args.ctx;
|
|
74
74
|
}
|
|
75
|
+
/** The background-job manager (C.28), or undefined when jobs are disabled.
|
|
76
|
+
* The REPL uses it to service paused-job approvals and drive `/jobs`; `cruxy
|
|
77
|
+
* run` uses it to cancel every live job on exit. */
|
|
78
|
+
get jobs() {
|
|
79
|
+
return this.args.jobs;
|
|
80
|
+
}
|
|
75
81
|
/** Whether plan mode is currently on. */
|
|
76
82
|
getPlanMode() {
|
|
77
83
|
return this.planMode;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ApprovalDecision } from "../approval/types.js";
|
|
2
|
+
import type { ApproveAction } from "../tools/types.js";
|
|
3
|
+
import type { Workspace } from "../workspace/index.js";
|
|
4
|
+
import type { CheckpointGate } from "./gate.js";
|
|
5
|
+
/**
|
|
6
|
+
* Wrap an approval gate with the C.32 auto-checkpoint hook. Ordering is the whole
|
|
7
|
+
* point: a tool mutates only *after* `requestApproval` resolves, so snapshotting
|
|
8
|
+
* after an `allow` decision but before returning it means the checkpoint always
|
|
9
|
+
* lands before the run's first mutation — and a denied action never creates one.
|
|
10
|
+
* The same seam records which paths the run touched (file actions) or that
|
|
11
|
+
* attribution is lost (shell), for rollback's external-change detection.
|
|
12
|
+
*
|
|
13
|
+
* Lives in the checkpoint package (not the CLI wiring) so the SAME hook can be
|
|
14
|
+
* composed by the session factory, the subagent orchestrator, AND the C.28 job
|
|
15
|
+
* approval path without any of them importing the others.
|
|
16
|
+
*/
|
|
17
|
+
export declare function withCheckpointGate(requestApproval: (action: ApproveAction) => Promise<ApprovalDecision>, gate: CheckpointGate | undefined, ws: Workspace): (action: ApproveAction) => Promise<ApprovalDecision>;
|
|
18
|
+
/**
|
|
19
|
+
* Group a file action's resolved absolute targets by the root that contains each
|
|
20
|
+
* (JC-G). write/edit carry an already-absolute `path`; patch preview paths are
|
|
21
|
+
* relative to the PRIMARY cwd (`path.relative(ctx.cwd, abs)` in apply_patch), so
|
|
22
|
+
* we reconstruct the absolute path from the primary root rather than trusting
|
|
23
|
+
* classify's `targets` — which also correctly handles a patch spanning roots.
|
|
24
|
+
*/
|
|
25
|
+
export declare function attributeFileTargets(action: ApproveAction, ws: Workspace): Map<string, {
|
|
26
|
+
rootAbsPath: string;
|
|
27
|
+
paths: string[];
|
|
28
|
+
}>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { classify } from "../approval/classify.js";
|
|
3
|
+
/**
|
|
4
|
+
* Wrap an approval gate with the C.32 auto-checkpoint hook. Ordering is the whole
|
|
5
|
+
* point: a tool mutates only *after* `requestApproval` resolves, so snapshotting
|
|
6
|
+
* after an `allow` decision but before returning it means the checkpoint always
|
|
7
|
+
* lands before the run's first mutation — and a denied action never creates one.
|
|
8
|
+
* The same seam records which paths the run touched (file actions) or that
|
|
9
|
+
* attribution is lost (shell), for rollback's external-change detection.
|
|
10
|
+
*
|
|
11
|
+
* Lives in the checkpoint package (not the CLI wiring) so the SAME hook can be
|
|
12
|
+
* composed by the session factory, the subagent orchestrator, AND the C.28 job
|
|
13
|
+
* approval path without any of them importing the others.
|
|
14
|
+
*/
|
|
15
|
+
export function withCheckpointGate(requestApproval, gate, ws) {
|
|
16
|
+
if (!gate)
|
|
17
|
+
return requestApproval;
|
|
18
|
+
return async (action) => {
|
|
19
|
+
const decision = await requestApproval(action);
|
|
20
|
+
if (!decision.allow)
|
|
21
|
+
return decision;
|
|
22
|
+
const request = classify(action, ws.primary().absPath);
|
|
23
|
+
if (request.tier === "read")
|
|
24
|
+
return decision;
|
|
25
|
+
if (action.kind === "shell" || action.kind === "test") {
|
|
26
|
+
// JC-β residual: non-primary shell/test are Step 5, so they are still
|
|
27
|
+
// hard-attributed to the primary root regardless of `action.root` (which
|
|
28
|
+
// those tools populate as the seam). They can mutate files we cannot
|
|
29
|
+
// attribute (scripts, snapshot writers) — record the lost attribution.
|
|
30
|
+
const root = ws.primary();
|
|
31
|
+
const svc = gate.serviceFor(root.name, root.absPath);
|
|
32
|
+
const checkpoint = await svc.ensureCheckpoint();
|
|
33
|
+
await svc.recordShellMutation();
|
|
34
|
+
if (checkpoint) {
|
|
35
|
+
await gate.recordMember(root.name, root.absPath, checkpoint.id);
|
|
36
|
+
}
|
|
37
|
+
return decision;
|
|
38
|
+
}
|
|
39
|
+
if (action.kind === "vcs") {
|
|
40
|
+
// C.26 Step 4: a PR now names its root (⚖︎#11), so the checkpoint is
|
|
41
|
+
// attributed to THAT selected root — its git commit stages/lands in that
|
|
42
|
+
// root's working tree, never the primary's. `recordShellMutation` because a
|
|
43
|
+
// `git add -A` + commit mutates the tree opaquely (no per-file attribution).
|
|
44
|
+
// Fall back to the primary only if a root name is somehow absent (defensive).
|
|
45
|
+
const root = (action.root ? ws.tryRootByName(action.root) : undefined) ??
|
|
46
|
+
ws.primary();
|
|
47
|
+
const svc = gate.serviceFor(root.name, root.absPath);
|
|
48
|
+
const checkpoint = await svc.ensureCheckpoint();
|
|
49
|
+
await svc.recordShellMutation();
|
|
50
|
+
if (checkpoint) {
|
|
51
|
+
await gate.recordMember(root.name, root.absPath, checkpoint.id);
|
|
52
|
+
}
|
|
53
|
+
return decision;
|
|
54
|
+
}
|
|
55
|
+
// File actions (write/edit/patch): attribute each RESOLVED target to its root
|
|
56
|
+
// (JC-G — post-confinement truth) and checkpoint every touched root. A patch
|
|
57
|
+
// may span roots; each root gets its own checkpoint + set member.
|
|
58
|
+
for (const [rootName, group] of attributeFileTargets(action, ws)) {
|
|
59
|
+
const svc = gate.serviceFor(rootName, group.rootAbsPath);
|
|
60
|
+
const checkpoint = await svc.ensureCheckpoint();
|
|
61
|
+
await svc.recordTouched(group.paths);
|
|
62
|
+
if (checkpoint) {
|
|
63
|
+
await gate.recordMember(rootName, group.rootAbsPath, checkpoint.id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return decision;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Group a file action's resolved absolute targets by the root that contains each
|
|
71
|
+
* (JC-G). write/edit carry an already-absolute `path`; patch preview paths are
|
|
72
|
+
* relative to the PRIMARY cwd (`path.relative(ctx.cwd, abs)` in apply_patch), so
|
|
73
|
+
* we reconstruct the absolute path from the primary root rather than trusting
|
|
74
|
+
* classify's `targets` — which also correctly handles a patch spanning roots.
|
|
75
|
+
*/
|
|
76
|
+
export function attributeFileTargets(action, ws) {
|
|
77
|
+
const abs = [];
|
|
78
|
+
if (action.kind === "write" || action.kind === "edit") {
|
|
79
|
+
if (action.path)
|
|
80
|
+
abs.push(action.path);
|
|
81
|
+
}
|
|
82
|
+
else if (action.kind === "patch" && action.preview?.type === "patch") {
|
|
83
|
+
for (const file of action.preview.files) {
|
|
84
|
+
abs.push(path.resolve(ws.primary().absPath, file.path));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const byRoot = new Map();
|
|
88
|
+
for (const target of abs) {
|
|
89
|
+
const root = ws.rootContaining(target);
|
|
90
|
+
const group = byRoot.get(root.name) ?? {
|
|
91
|
+
rootAbsPath: root.absPath,
|
|
92
|
+
paths: [],
|
|
93
|
+
};
|
|
94
|
+
group.paths.push(target);
|
|
95
|
+
byRoot.set(root.name, group);
|
|
96
|
+
}
|
|
97
|
+
return byRoot;
|
|
98
|
+
}
|
|
@@ -44,8 +44,14 @@ export declare class CheckpointGate {
|
|
|
44
44
|
* mutation begins a fresh run. The set is materialized lazily on the first member
|
|
45
45
|
* (so its `createdAt` marks the run's first mutation, and a no-mutation run
|
|
46
46
|
* writes no manifest).
|
|
47
|
+
*
|
|
48
|
+
* `runId` may be supplied to key the set manifest by a caller-chosen id (C.28: a
|
|
49
|
+
* background job passes its OWN id, so `cruxy rollback <jobId>` maps straight to
|
|
50
|
+
* that job's set and reverts exactly the job — pre- AND post-pause mutations,
|
|
51
|
+
* because a job calls this ONCE at start and never again on resume). Omitted → a
|
|
52
|
+
* fresh generated `run-…` id, unchanged from C.26.
|
|
47
53
|
*/
|
|
48
|
-
beginRun(summary: string): void;
|
|
54
|
+
beginRun(summary: string, runId?: string): void;
|
|
49
55
|
/**
|
|
50
56
|
* Get-or-create the per-root service. The first time a root is touched this
|
|
51
57
|
* process, its service is constructed and joined to the current run; an untouched
|
package/dist/checkpoint/gate.js
CHANGED
|
@@ -28,10 +28,16 @@ export class CheckpointGate {
|
|
|
28
28
|
* mutation begins a fresh run. The set is materialized lazily on the first member
|
|
29
29
|
* (so its `createdAt` marks the run's first mutation, and a no-mutation run
|
|
30
30
|
* writes no manifest).
|
|
31
|
+
*
|
|
32
|
+
* `runId` may be supplied to key the set manifest by a caller-chosen id (C.28: a
|
|
33
|
+
* background job passes its OWN id, so `cruxy rollback <jobId>` maps straight to
|
|
34
|
+
* that job's set and reverts exactly the job — pre- AND post-pause mutations,
|
|
35
|
+
* because a job calls this ONCE at start and never again on resume). Omitted → a
|
|
36
|
+
* fresh generated `run-…` id, unchanged from C.26.
|
|
31
37
|
*/
|
|
32
|
-
beginRun(summary) {
|
|
38
|
+
beginRun(summary, runId) {
|
|
33
39
|
this.summary = summary;
|
|
34
|
-
this.runId = newRunId();
|
|
40
|
+
this.runId = runId ?? newRunId();
|
|
35
41
|
this.set = null;
|
|
36
42
|
for (const svc of this.services.values())
|
|
37
43
|
svc.beginRun(summary);
|
package/dist/checkpoint/index.js
CHANGED
|
@@ -8,7 +8,10 @@ import { Command } from "commander";
|
|
|
8
8
|
* PRs made during the run are not undone.
|
|
9
9
|
*
|
|
10
10
|
* Routing:
|
|
11
|
-
* • an explicit `<id>`
|
|
11
|
+
* • an explicit `<id>` that names a run/JOB set (C.28: a background job's id) →
|
|
12
|
+
* set-based rollback of exactly that run, restoring every root it touched;
|
|
13
|
+
* • an explicit `<id>` that is a checkpoint id → single-root rollback (escape
|
|
14
|
+
* hatch; also the recovery route if the primary root's set index was removed);
|
|
12
15
|
* • no id, a set manifest exists → set-based rollback of the latest run;
|
|
13
16
|
* • no id, no set manifest → JC-F fallback to legacy single-root, logged.
|
|
14
17
|
*/
|
|
@@ -81,9 +81,7 @@ async function legacyRollback(root, config, approval, interactive, id, t) {
|
|
|
81
81
|
* one U.3 approval, then a sequential apply that stops on first failure
|
|
82
82
|
* (`CHECKPOINT_SET_PARTIAL`, R3). Both coded errors propagate to the boundary.
|
|
83
83
|
*/
|
|
84
|
-
async function setRollback(
|
|
85
|
-
const sets = await listSets(primaryRoot); // newest first
|
|
86
|
-
const set = sets[0];
|
|
84
|
+
async function setRollback(config, approval, t, set) {
|
|
87
85
|
// Validate-all BEFORE any apply — a missing/corrupt member throws here.
|
|
88
86
|
const members = await validateSet(set, config);
|
|
89
87
|
if (setIsNoop(members)) {
|
|
@@ -116,14 +114,17 @@ async function setRollback(primaryRoot, config, approval, t) {
|
|
|
116
114
|
* PRs made during the run are not undone.
|
|
117
115
|
*
|
|
118
116
|
* Routing:
|
|
119
|
-
* • an explicit `<id>`
|
|
117
|
+
* • an explicit `<id>` that names a run/JOB set (C.28: a background job's id) →
|
|
118
|
+
* set-based rollback of exactly that run, restoring every root it touched;
|
|
119
|
+
* • an explicit `<id>` that is a checkpoint id → single-root rollback (escape
|
|
120
|
+
* hatch; also the recovery route if the primary root's set index was removed);
|
|
120
121
|
* • no id, a set manifest exists → set-based rollback of the latest run;
|
|
121
122
|
* • no id, no set manifest → JC-F fallback to legacy single-root, logged.
|
|
122
123
|
*/
|
|
123
124
|
export function rollbackCommand() {
|
|
124
125
|
return new Command("rollback")
|
|
125
126
|
.description("restore the working tree to a checkpoint, undoing a run's file changes")
|
|
126
|
-
.argument("[id]", "checkpoint id (defaults to the most recent run)")
|
|
127
|
+
.argument("[id]", "run/job id or checkpoint id (defaults to the most recent run)")
|
|
127
128
|
.action(async (id) => {
|
|
128
129
|
const interactive = Boolean(process.stdin.isTTY);
|
|
129
130
|
// Refuse before touching anything: rollback is a deliberate, interactive
|
|
@@ -138,20 +139,26 @@ export function rollbackCommand() {
|
|
|
138
139
|
interactive,
|
|
139
140
|
io: defaultPromptIO(shouldUseColor()),
|
|
140
141
|
});
|
|
141
|
-
|
|
142
|
-
// recovery route if the primary root — and its set index — was removed).
|
|
142
|
+
const sets = await listSets(primaryRoot); // newest first
|
|
143
143
|
if (id !== undefined) {
|
|
144
|
+
// A job/run id first: if `<id>` names a set manifest (a background job's
|
|
145
|
+
// id IS its set's runId, C.28), roll back that whole run. Otherwise treat
|
|
146
|
+
// it as a single checkpoint id (the per-member escape hatch).
|
|
147
|
+
const jobSet = sets.find((s) => s.runId === id);
|
|
148
|
+
if (jobSet) {
|
|
149
|
+
await setRollback(config, approval, t, jobSet);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
144
152
|
await legacyRollback(primaryRoot, config, approval, interactive, id, t);
|
|
145
153
|
return;
|
|
146
154
|
}
|
|
147
155
|
// No set manifest → JC-F: fall back to legacy single-root, logged (never
|
|
148
156
|
// silent). The primary root name matches single-root workspace naming.
|
|
149
|
-
const sets = await listSets(primaryRoot);
|
|
150
157
|
if (sets.length === 0) {
|
|
151
158
|
logger.info(`no set manifest — single-root rollback against ${path.basename(primaryRoot)}`);
|
|
152
159
|
await legacyRollback(primaryRoot, config, approval, interactive, undefined, t);
|
|
153
160
|
return;
|
|
154
161
|
}
|
|
155
|
-
await setRollback(
|
|
162
|
+
await setRollback(config, approval, t, sets[0]);
|
|
156
163
|
});
|
|
157
164
|
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -193,6 +193,14 @@ export function runCommand() {
|
|
|
193
193
|
await runInteractive(session, undefined, renderer, checkpoints, hookCommands);
|
|
194
194
|
}
|
|
195
195
|
finally {
|
|
196
|
+
// Background jobs (C.28): cancel every live job on session exit —
|
|
197
|
+
// kill-tree'ing each job's process group (no orphan) and reporting an
|
|
198
|
+
// honest "N job(s) cancelled". A paused job is cancelled too, but any
|
|
199
|
+
// checkpoint it took survives for review/rollback.
|
|
200
|
+
const cancelled = (await session.jobs?.cancelAll("session exit")) ?? 0;
|
|
201
|
+
if (cancelled > 0) {
|
|
202
|
+
logger.print(t.muted(`${cancelled} background job${cancelled === 1 ? "" : "s"} cancelled on exit`));
|
|
203
|
+
}
|
|
196
204
|
// LSP (C.12) + MCP (C.27): gracefully shut down any external server
|
|
197
205
|
// processes spawned during the session (the shared process-exit
|
|
198
206
|
// kill-tree is the fail-safe for a hard kill).
|
|
@@ -216,6 +224,10 @@ export function runCommand() {
|
|
|
216
224
|
}
|
|
217
225
|
finally {
|
|
218
226
|
renderer.close();
|
|
227
|
+
// Background jobs (C.28): a one-shot run has no between-turns servicing
|
|
228
|
+
// loop, so cancel every live job on exit (kill-tree, no orphan) rather
|
|
229
|
+
// than leave one paused forever. Checkpoints taken survive for rollback.
|
|
230
|
+
await session.jobs?.cancelAll("session exit");
|
|
219
231
|
// LSP (C.12) + MCP (C.27): gracefully shut down any external server
|
|
220
232
|
// processes spawned during the run (the shared process-exit kill-tree
|
|
221
233
|
// is the fail-safe for a hard kill).
|
package/dist/cli/repl.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { type StreamRenderer } from "../render/index.js";
|
|
|
7
7
|
* The REPL's slash commands — the autocomplete candidate set (U.7). Keep in
|
|
8
8
|
* sync with the dispatch below and the HELP text.
|
|
9
9
|
*/
|
|
10
|
-
export declare const REPL_COMMANDS: readonly ["/help", "/clear", "/compact", "/reload", "/plan", "/exit", "/quit"];
|
|
10
|
+
export declare const REPL_COMMANDS: readonly ["/help", "/clear", "/compact", "/reload", "/plan", "/jobs", "/logs", "/cancel", "/exit", "/quit"];
|
|
11
11
|
/** The stdin/stdout pair the REPL reads from and prompts on. Injectable for tests. */
|
|
12
12
|
export interface ReplIO {
|
|
13
13
|
input: Readable;
|
package/dist/cli/repl.js
CHANGED
|
@@ -20,6 +20,9 @@ export const REPL_COMMANDS = [
|
|
|
20
20
|
"/compact",
|
|
21
21
|
"/reload",
|
|
22
22
|
"/plan",
|
|
23
|
+
"/jobs",
|
|
24
|
+
"/logs",
|
|
25
|
+
"/cancel",
|
|
23
26
|
"/exit",
|
|
24
27
|
"/quit",
|
|
25
28
|
];
|
|
@@ -29,6 +32,9 @@ const HELP = `Commands:
|
|
|
29
32
|
/compact summarize older history to free up context now
|
|
30
33
|
/reload re-read project instructions (CRUXY.md)
|
|
31
34
|
/plan toggle plan mode (propose a plan before executing)
|
|
35
|
+
/jobs list background jobs and their status
|
|
36
|
+
/logs <id> show a background job's log
|
|
37
|
+
/cancel <id> cancel a background job
|
|
32
38
|
/exit, /quit leave cruxy
|
|
33
39
|
Ctrl+D leave cruxy`;
|
|
34
40
|
const defaultIO = () => ({
|
|
@@ -148,6 +154,89 @@ async function handleAddRoot(input, session) {
|
|
|
148
154
|
printReplError(err);
|
|
149
155
|
}
|
|
150
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Service any background-job approvals that piled up (C.28) — the auto-surface
|
|
159
|
+
* point. Called when the foreground is idle (between turns), so a paused job's
|
|
160
|
+
* gated action is decided through the SAME U.3 prompt as a foreground one, one at
|
|
161
|
+
* a time. No pending → a no-op (nothing printed). The job resumes on approval.
|
|
162
|
+
*/
|
|
163
|
+
async function drainJobApprovals(session) {
|
|
164
|
+
const jobs = session.jobs;
|
|
165
|
+
if (!jobs || !jobs.hasPendingApprovals())
|
|
166
|
+
return;
|
|
167
|
+
logger.print(theme.muted(`\n${theme.glyph.bullet} a background job needs your approval:`));
|
|
168
|
+
await jobs.serviceApprovals();
|
|
169
|
+
}
|
|
170
|
+
/** Render the background-job list (`/jobs`). */
|
|
171
|
+
function handleJobsList(session) {
|
|
172
|
+
const jobs = session.jobs;
|
|
173
|
+
if (!jobs) {
|
|
174
|
+
logger.print(theme.muted("background jobs are disabled — enable with `cruxy config set jobs.enabled true`"));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const list = jobs.list();
|
|
178
|
+
if (list.length === 0) {
|
|
179
|
+
logger.print(theme.muted("no background jobs this session"));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
for (const j of list) {
|
|
183
|
+
const status = j.status === "failed" ? theme.danger(j.status) : theme.accent(j.status);
|
|
184
|
+
const pending = j.pendingApproval
|
|
185
|
+
? theme.muted(` — needs approval: ${j.pendingApproval}`)
|
|
186
|
+
: "";
|
|
187
|
+
const err = j.error ? theme.muted(` (${j.error})`) : "";
|
|
188
|
+
logger.print(`${theme.strong(j.id)} ${status} ${j.label}${pending}${err}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** Print one job's log (`/logs <id>`). */
|
|
192
|
+
function handleJobLogs(input, session) {
|
|
193
|
+
const jobs = session.jobs;
|
|
194
|
+
if (!jobs) {
|
|
195
|
+
logger.print(theme.muted("background jobs are disabled"));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const id = input.slice("/logs".length).trim();
|
|
199
|
+
if (!id) {
|
|
200
|
+
logger.print(theme.muted("usage: /logs <id>"));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const log = jobs.logs(id);
|
|
205
|
+
if (log.dropped > 0) {
|
|
206
|
+
logger.print(theme.muted(`… ${log.dropped} earlier line(s) rolled off`));
|
|
207
|
+
}
|
|
208
|
+
for (const line of log.lines) {
|
|
209
|
+
const text = line.stream === "err" ? theme.danger(line.text) : line.text;
|
|
210
|
+
logger.print(text);
|
|
211
|
+
}
|
|
212
|
+
logger.print(theme.muted(`(${log.status})`));
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
printReplError(err);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Cancel a job (`/cancel <id>`). */
|
|
219
|
+
async function handleJobCancel(input, session) {
|
|
220
|
+
const jobs = session.jobs;
|
|
221
|
+
if (!jobs) {
|
|
222
|
+
logger.print(theme.muted("background jobs are disabled"));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const id = input.slice("/cancel".length).trim();
|
|
226
|
+
if (!id) {
|
|
227
|
+
logger.print(theme.muted("usage: /cancel <id>"));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const cancelled = jobs.cancel(id);
|
|
232
|
+
logger.print(theme.muted(cancelled
|
|
233
|
+
? `cancelling ${id} (its process tree is killed; any checkpoint survives for rollback)`
|
|
234
|
+
: `${id} is already finished`));
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
printReplError(err);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
151
240
|
/**
|
|
152
241
|
* Drive an interactive multi-turn session: prompt, read a line, dispatch slash
|
|
153
242
|
* commands or run a turn, repeat. Assistant text and tool-call progress stream
|
|
@@ -170,6 +259,11 @@ export async function runInteractive(session, io = defaultIO(), renderer = creat
|
|
|
170
259
|
}
|
|
171
260
|
async function replLoop(session, io, renderer, checkpoints, slashCommands = []) {
|
|
172
261
|
for (;;) {
|
|
262
|
+
// Auto-surface (C.28): before prompting, service any background-job approvals
|
|
263
|
+
// that piled up while the last turn ran — one at a time, through the same U.3
|
|
264
|
+
// prompt. Done here (foreground idle, no readline interface live) so a job's
|
|
265
|
+
// prompt never contends with the line reader.
|
|
266
|
+
await drainJobApprovals(session);
|
|
173
267
|
const line = await readLine(io, PROMPT);
|
|
174
268
|
// EOF / Ctrl+D.
|
|
175
269
|
if (line === null) {
|
|
@@ -217,6 +311,18 @@ async function replLoop(session, io, renderer, checkpoints, slashCommands = [])
|
|
|
217
311
|
logger.print(HELP);
|
|
218
312
|
continue;
|
|
219
313
|
}
|
|
314
|
+
if (trimmed === "/jobs") {
|
|
315
|
+
handleJobsList(session);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (trimmed === "/logs" || trimmed.startsWith("/logs ")) {
|
|
319
|
+
handleJobLogs(trimmed, session);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (trimmed === "/cancel" || trimmed.startsWith("/cancel ")) {
|
|
323
|
+
await handleJobCancel(trimmed, session);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
220
326
|
if (trimmed === "/add-root" || trimmed.startsWith("/add-root ")) {
|
|
221
327
|
await handleAddRoot(trimmed, session);
|
|
222
328
|
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
|