@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/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;
|
|
@@ -29,6 +29,12 @@ export interface PromptIO {
|
|
|
29
29
|
readLine(): Promise<string>;
|
|
30
30
|
/** Whether to emit ANSI color. */
|
|
31
31
|
color: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Terminal columns (U.12). Optional so every existing PromptIO literal is
|
|
34
|
+
* unchanged; when absent the render resolves the width itself. Threaded so a
|
|
35
|
+
* narrow prompt reflows the command and never truncates the risk marker.
|
|
36
|
+
*/
|
|
37
|
+
columns?: number;
|
|
32
38
|
}
|
|
33
39
|
/**
|
|
34
40
|
* Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
|
|
@@ -37,6 +43,6 @@ export interface PromptIO {
|
|
|
37
43
|
*/
|
|
38
44
|
export declare function promptForApproval(request: ApprovalRequest, io: PromptIO): Promise<PromptChoice>;
|
|
39
45
|
/** Render the full prompt block: header, detail (diff or command+cwd), choices. */
|
|
40
|
-
export declare function render(request: ApprovalRequest, color: boolean): string;
|
|
46
|
+
export declare function render(request: ApprovalRequest, color: boolean, columns?: number): string;
|
|
41
47
|
/** Build the real PromptIO: prompt to stderr, read keys/lines from stdin. */
|
|
42
48
|
export declare function defaultPromptIO(color: boolean): PromptIO;
|
package/dist/approval/prompt.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { readSingleKey } from "../components/input.js";
|
|
3
3
|
import { renderActionPreview } from "../render/diff.js";
|
|
4
|
+
import { resolveColumns } from "../render/capabilities.js";
|
|
5
|
+
import { fitMiddle, reflow, visibleWidth } from "../render/layout.js";
|
|
4
6
|
import { themeForColor } from "../theme/index.js";
|
|
5
7
|
/**
|
|
6
8
|
* Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
|
|
@@ -8,7 +10,7 @@ import { themeForColor } from "../theme/index.js";
|
|
|
8
10
|
* is a reject.
|
|
9
11
|
*/
|
|
10
12
|
export async function promptForApproval(request, io) {
|
|
11
|
-
io.write(render(request, io.color));
|
|
13
|
+
io.write(render(request, io.color, io.columns ?? resolveColumns()));
|
|
12
14
|
const key = (await io.readKey()).toLowerCase();
|
|
13
15
|
io.write("\n");
|
|
14
16
|
switch (key) {
|
|
@@ -34,21 +36,41 @@ export async function promptForApproval(request, io) {
|
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
/** Render the full prompt block: header, detail (diff or command+cwd), choices. */
|
|
37
|
-
export function render(request, color) {
|
|
39
|
+
export function render(request, color, columns = resolveColumns()) {
|
|
38
40
|
const t = themeForColor(color);
|
|
39
41
|
const destructive = request.tier === "destructive";
|
|
40
|
-
// Risk survives all
|
|
41
|
-
// (`!` vs `?`) for NO_COLOR,
|
|
42
|
-
// (`(destructive)` / `(mutate)` / `(read)`) — never by hue alone
|
|
43
|
-
//
|
|
42
|
+
// Risk survives all FOUR degradations now (U.11 color/unicode/reader + U.12
|
|
43
|
+
// width): the mark carries it by *shape* (`!` vs `?`) for NO_COLOR, the label
|
|
44
|
+
// by *word* (`(destructive)` / `(mutate)` / `(read)`) — never by hue alone —
|
|
45
|
+
// and under narrow width the header line that holds both is emitted WHOLE,
|
|
46
|
+
// never passed through a right-truncating fit that could drop the label.
|
|
44
47
|
const mark = destructive ? t.danger(t.strong("!")) : t.warning("?");
|
|
45
48
|
const label = tierLabel(request.tier, t);
|
|
46
49
|
const lines = [];
|
|
47
|
-
lines.push(
|
|
48
|
-
lines.push(detail(request, t));
|
|
50
|
+
lines.push(...header(request.summary, mark, label, t, columns));
|
|
51
|
+
lines.push(detail(request, t, columns));
|
|
49
52
|
lines.push(choices(request.scope, t));
|
|
50
53
|
return lines.filter((l) => l !== "").join("\n") + " ";
|
|
51
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The header, width-aware (U.12). Wide: the one inline line `! cruxy wants to
|
|
57
|
+
* <summary> (destructive)`. Narrow: the risk (mark + tier label) stands on its
|
|
58
|
+
* own line — emitted whole, never truncated — and the summary reflows beneath
|
|
59
|
+
* it, so the security-relevant part is always visible while the description
|
|
60
|
+
* wraps rather than soft-wrapping into a torn line.
|
|
61
|
+
*/
|
|
62
|
+
function header(summary, mark, label, t, width) {
|
|
63
|
+
const inline = `${mark} cruxy wants to ${t.strong(summary)}${label}`;
|
|
64
|
+
if (visibleWidth(inline) <= width)
|
|
65
|
+
return [inline];
|
|
66
|
+
// The risk line (mark + tier word, e.g. `! (destructive)`) is short and is
|
|
67
|
+
// NEVER passed through fit(): if it overflows an absurdly narrow terminal it
|
|
68
|
+
// wraps (nothing dropped) rather than losing its tier. The action prose +
|
|
69
|
+
// summary reflow beneath it.
|
|
70
|
+
const riskLine = `${mark}${label}`;
|
|
71
|
+
const body = reflow(`cruxy wants to ${summary}`, Math.max(1, width - 2)).map((l) => ` ${t.strong(l)}`);
|
|
72
|
+
return [riskLine, ...body];
|
|
73
|
+
}
|
|
52
74
|
/** The worded risk tag, colored by tier — always present, so meaning never
|
|
53
75
|
* rides on the `!`/`?` shape or its color alone. */
|
|
54
76
|
function tierLabel(tier, t) {
|
|
@@ -62,17 +84,28 @@ function tierLabel(tier, t) {
|
|
|
62
84
|
}
|
|
63
85
|
}
|
|
64
86
|
/** The action detail: a diff for file actions, the command + cwd for shell/test. */
|
|
65
|
-
function detail(request, t) {
|
|
87
|
+
function detail(request, t, width) {
|
|
66
88
|
if (request.action.kind === "shell" || request.action.kind === "test") {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
// The command is REFLOWED, never truncated (U.12): you always see the whole
|
|
90
|
+
// thing you are authorizing — it wraps across as many lines as it needs. The
|
|
91
|
+
// cwd (a path) middle-truncates so its leaf survives.
|
|
92
|
+
const command = request.action.command ?? "";
|
|
93
|
+
const wrapped = reflow(command, Math.max(1, width - 4));
|
|
94
|
+
const cmdLines = [
|
|
95
|
+
` ${t.muted("$")} ${wrapped[0] ?? ""}`,
|
|
96
|
+
...wrapped.slice(1).map((l) => ` ${l}`),
|
|
97
|
+
];
|
|
98
|
+
const cwd = fitMiddle(request.cwd, Math.max(1, width - 5), t.glyph.ellipsis);
|
|
99
|
+
return [...cmdLines, ` ${t.muted(`in ${cwd}`)}`].join("\n");
|
|
71
100
|
}
|
|
72
101
|
if (request.action.kind === "mcp") {
|
|
73
102
|
// The server runs UNSANDBOXED with the user's privileges — say so at the
|
|
74
|
-
// point of the call, not just at trust time.
|
|
75
|
-
|
|
103
|
+
// point of the call, not just at trust time. Reflowed so the whole warning
|
|
104
|
+
// survives narrow width (it must not be the part that gets clipped).
|
|
105
|
+
const note = `external MCP server "${request.action.server ?? ""}" — runs unsandboxed with your privileges`;
|
|
106
|
+
return reflow(note, Math.max(1, width - 2))
|
|
107
|
+
.map((l) => ` ${t.muted(l)}`)
|
|
108
|
+
.join("\n");
|
|
76
109
|
}
|
|
77
110
|
if (request.action.kind === "vcs" && request.action.root) {
|
|
78
111
|
// C.26 Step 4 (⚖︎JC-4): name the acting root alongside the resolved owner/repo
|
|
@@ -80,12 +113,12 @@ function detail(request, t) {
|
|
|
80
113
|
// PR acts in and the real API destination before approving.
|
|
81
114
|
return [
|
|
82
115
|
` ${t.muted(`root ${request.action.root}`)}`,
|
|
83
|
-
renderActionPreview(request.action.preview, t),
|
|
116
|
+
renderActionPreview(request.action.preview, t, width),
|
|
84
117
|
]
|
|
85
118
|
.filter((l) => l !== "")
|
|
86
119
|
.join("\n");
|
|
87
120
|
}
|
|
88
|
-
return renderActionPreview(request.action.preview, t);
|
|
121
|
+
return renderActionPreview(request.action.preview, t, width);
|
|
89
122
|
}
|
|
90
123
|
/** The choices line, including a short label of what an `a` grant would cover. */
|
|
91
124
|
function choices(scope, t) {
|
|
@@ -117,6 +150,8 @@ export function defaultPromptIO(color) {
|
|
|
117
150
|
readKey: () => readSingleKey(),
|
|
118
151
|
readLine: readLineFromStdin,
|
|
119
152
|
color,
|
|
153
|
+
// The prompt writes to stderr — width from stderr's own columns (U.12).
|
|
154
|
+
columns: resolveColumns(process.stderr),
|
|
120
155
|
};
|
|
121
156
|
}
|
|
122
157
|
/** Read one line in cooked mode; "" on EOF. */
|
|
@@ -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).
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { getSkillService, resetSkillServices } from "../../skills/index.js";
|
|
3
3
|
import { shouldUseColor } from "../../errors/index.js";
|
|
4
|
+
import { kvStack, resolveColumns } from "../../render/index.js";
|
|
4
5
|
import { themeForColor } from "../../theme/index.js";
|
|
5
6
|
import { logger } from "../../utils/logger.js";
|
|
6
7
|
/**
|
|
@@ -34,8 +35,15 @@ export function skillsCommand() {
|
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
36
37
|
logger.print(`\n${t.heading("sources")} ${t.muted("(precedence, high to low)")}`);
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// Aligned `source dir` columns when there's room; at narrow width the
|
|
39
|
+
// dir paths would collide with the key column, so kvStack stacks each
|
|
40
|
+
// pair instead (U.12) — the paths stay readable rather than truncating.
|
|
41
|
+
const rows = status.sources.map((s) => ({
|
|
42
|
+
key: s.source,
|
|
43
|
+
value: t.muted(s.dir),
|
|
44
|
+
}));
|
|
45
|
+
for (const line of kvStack(rows, resolveColumns(process.stdout) - 2, t)) {
|
|
46
|
+
logger.print(` ${line}`);
|
|
39
47
|
}
|
|
40
48
|
logger.print("");
|
|
41
49
|
if (status.errors.length === 0) {
|
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;
|