@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.
Files changed (62) hide show
  1. package/dist/agent/session.d.ts +13 -0
  2. package/dist/agent/session.js +6 -0
  3. package/dist/approval/prompt.d.ts +7 -1
  4. package/dist/approval/prompt.js +52 -17
  5. package/dist/checkpoint/gate-hook.d.ts +28 -0
  6. package/dist/checkpoint/gate-hook.js +98 -0
  7. package/dist/checkpoint/gate.d.ts +7 -1
  8. package/dist/checkpoint/gate.js +8 -2
  9. package/dist/checkpoint/index.d.ts +1 -0
  10. package/dist/checkpoint/index.js +1 -0
  11. package/dist/cli/commands/rollback.d.ts +4 -1
  12. package/dist/cli/commands/rollback.js +16 -9
  13. package/dist/cli/commands/run.js +12 -0
  14. package/dist/cli/commands/skills.js +10 -2
  15. package/dist/cli/repl.d.ts +1 -1
  16. package/dist/cli/repl.js +113 -1
  17. package/dist/cli/session-factory.d.ts +4 -12
  18. package/dist/cli/session-factory.js +50 -96
  19. package/dist/components/frame.d.ts +6 -3
  20. package/dist/components/frame.js +21 -23
  21. package/dist/components/fuzzy.js +5 -1
  22. package/dist/components/select.js +4 -1
  23. package/dist/config/schema.d.ts +86 -0
  24. package/dist/config/schema.js +41 -0
  25. package/dist/errors/constructors.d.ts +18 -0
  26. package/dist/errors/constructors.js +49 -0
  27. package/dist/errors/types.d.ts +13 -0
  28. package/dist/errors/types.js +21 -0
  29. package/dist/jobs/approval-queue.d.ts +85 -0
  30. package/dist/jobs/approval-queue.js +96 -0
  31. package/dist/jobs/dispatch-tool.d.ts +34 -0
  32. package/dist/jobs/dispatch-tool.js +96 -0
  33. package/dist/jobs/index.d.ts +6 -0
  34. package/dist/jobs/index.js +6 -0
  35. package/dist/jobs/log-buffer.d.ts +31 -0
  36. package/dist/jobs/log-buffer.js +30 -0
  37. package/dist/jobs/log-renderer.d.ts +32 -0
  38. package/dist/jobs/log-renderer.js +70 -0
  39. package/dist/jobs/manager.d.ts +139 -0
  40. package/dist/jobs/manager.js +397 -0
  41. package/dist/jobs/types.d.ts +81 -0
  42. package/dist/jobs/types.js +10 -0
  43. package/dist/render/capabilities.d.ts +11 -0
  44. package/dist/render/capabilities.js +19 -3
  45. package/dist/render/diff.d.ts +1 -1
  46. package/dist/render/diff.js +23 -7
  47. package/dist/render/index.d.ts +4 -2
  48. package/dist/render/index.js +8 -2
  49. package/dist/render/layout.d.ts +59 -0
  50. package/dist/render/layout.js +158 -0
  51. package/dist/render/resize.d.ts +36 -0
  52. package/dist/render/resize.js +45 -0
  53. package/dist/render/state.d.ts +13 -0
  54. package/dist/render/state.js +38 -0
  55. package/dist/render/tty-renderer.d.ts +8 -0
  56. package/dist/render/tty-renderer.js +36 -11
  57. package/dist/render/types.d.ts +15 -1
  58. package/dist/subagent/orchestrator.d.ts +9 -0
  59. package/dist/subagent/orchestrator.js +6 -1
  60. package/dist/subagent/semaphore.d.ts +40 -11
  61. package/dist/subagent/semaphore.js +23 -26
  62. package/package.json +1 -1
@@ -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
  /**
@@ -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;
@@ -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 three degradations (U.11): the mark carries it by *shape*
41
- // (`!` vs `?`) for NO_COLOR, and the label carries it by *word*
42
- // (`(destructive)` / `(mutate)` / `(read)`) — never by hue alone, and legible
43
- // to a screen reader that would otherwise read `!` as "exclamation mark".
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(`${mark} cruxy wants to ${t.strong(request.summary)}${label}`);
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
- return [
68
- ` ${t.muted("$")} ${request.action.command ?? ""}`,
69
- ` ${t.muted(`in ${request.cwd}`)}`,
70
- ].join("\n");
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
- return ` ${t.muted(`external MCP server "${request.action.server ?? ""}" runs unsandboxed with your privileges`)}`;
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
@@ -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);
@@ -7,3 +7,4 @@ export * from "./service.js";
7
7
  export * from "./set.js";
8
8
  export * from "./set-rollback.js";
9
9
  export * from "./gate.js";
10
+ export * from "./gate-hook.js";
@@ -7,3 +7,4 @@ export * from "./service.js";
7
7
  export * from "./set.js";
8
8
  export * from "./set-rollback.js";
9
9
  export * from "./gate.js";
10
+ export * from "./gate-hook.js";
@@ -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>` single-root rollback of that checkpoint (escape hatch);
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(primaryRoot, config, approval, t) {
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>` single-root rollback of that checkpoint (escape hatch);
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
- // Explicit id single-root path (the per-member escape hatch; also the
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(primaryRoot, config, approval, t);
162
+ await setRollback(config, approval, t, sets[0]);
156
163
  });
157
164
  }
@@ -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
- for (const { source, dir } of status.sources) {
38
- logger.print(` ${t.kv(source, t.muted(dir), 8)}`);
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) {
@@ -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;