@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
@@ -0,0 +1,70 @@
1
+ import { themeForColor } from "../theme/index.js";
2
+ /** Non-terminal capabilities: a background job renders to NOTHING on screen. */
3
+ const OFFSCREEN_CAPS = {
4
+ tty: false,
5
+ color: false,
6
+ cursor: false,
7
+ spinner: false,
8
+ reducedMotion: true,
9
+ screenReader: false,
10
+ unicode: true,
11
+ width: 80,
12
+ };
13
+ /**
14
+ * A {@link StreamRenderer} for a background job (C.28) that captures activity into
15
+ * the job's log buffer and writes NOTHING to any terminal — a job runs
16
+ * non-interactively, off screen, and its foreground session owns the terminal.
17
+ * `cruxy logs <id>` (a REPL `/logs`) reads back what was captured here.
18
+ *
19
+ * Assistant text is accumulated and flushed a line at a time on `endSegment`;
20
+ * committed chrome notes and tool-call completions are captured verbatim. The
21
+ * live-region methods (status, phase, progress, prompt-resolved) are dropped —
22
+ * transient decor has no meaning in an append-only log.
23
+ */
24
+ export class JobLogRenderer {
25
+ sink;
26
+ caps = OFFSCREEN_CAPS;
27
+ theme = themeForColor(false);
28
+ pending = "";
29
+ constructor(sink) {
30
+ this.sink = sink;
31
+ }
32
+ beginTurn() {
33
+ this.pending = "";
34
+ }
35
+ write(delta) {
36
+ this.pending += delta;
37
+ // Flush any complete lines immediately; keep the trailing partial line.
38
+ const nl = this.pending.lastIndexOf("\n");
39
+ if (nl === -1)
40
+ return;
41
+ const complete = this.pending.slice(0, nl);
42
+ this.pending = this.pending.slice(nl + 1);
43
+ for (const line of complete.split("\n"))
44
+ this.sink("out", line);
45
+ }
46
+ endSegment() {
47
+ if (this.pending.trim())
48
+ this.sink("out", this.pending.trimEnd());
49
+ this.pending = "";
50
+ }
51
+ note(text) {
52
+ this.sink("out", text);
53
+ }
54
+ toolLifecycle(event) {
55
+ if (event.event !== "end")
56
+ return; // only the committed outcome is log-worthy
57
+ const mark = event.ok ? "ok" : "fail";
58
+ this.sink(event.ok ? "out" : "err", `[${mark}] ${event.label}`);
59
+ }
60
+ endTurn() {
61
+ this.endSegment();
62
+ }
63
+ // Transient decor / previews have no place in an append-only job log.
64
+ preview() { }
65
+ status() { }
66
+ setPhase() { }
67
+ progress() { }
68
+ promptResolved() { }
69
+ close() { }
70
+ }
@@ -0,0 +1,139 @@
1
+ import type { Provider } from "@cruxy/sdk";
2
+ import { runAgent } from "../agent/loop.js";
3
+ import { type ApprovalMutex, type PromptIO } from "../approval/index.js";
4
+ import type { CruxyConfig } from "../config/index.js";
5
+ import type { Router } from "../routing/index.js";
6
+ import type { SandboxService } from "../sandbox/index.js";
7
+ import { Semaphore } from "../subagent/index.js";
8
+ import type { ToolRegistry } from "../tools/index.js";
9
+ import type { logger as Logger } from "../utils/logger.js";
10
+ import { Workspace } from "../workspace/index.js";
11
+ import { ApprovalQueue } from "./approval-queue.js";
12
+ import type { JobLog, JobSpec, JobView } from "./types.js";
13
+ /**
14
+ * Everything the manager needs from the surrounding session, injected by the
15
+ * session factory. The SHARED members are the crux of C.28's "one system, not
16
+ * two": `semaphore`, `approvalQueue`, and `approvalMutex` are the SAME instances
17
+ * the foreground loop and subagents use, so a job contends for the one execution
18
+ * cap and serializes its prompts/checkpoints on the one mutex — never a parallel
19
+ * universe with its own limits.
20
+ */
21
+ export interface JobManagerDeps {
22
+ config: CruxyConfig;
23
+ provider: Provider;
24
+ router?: Router;
25
+ /** The parent's registry — the ceiling every job's tool scope derives from. */
26
+ parentRegistry: ToolRegistry;
27
+ cwd: string;
28
+ workspace: Workspace;
29
+ logger: typeof Logger;
30
+ git?: {
31
+ branch: string;
32
+ dirty: boolean;
33
+ } | null;
34
+ projectInstructions?: string | null;
35
+ sandbox?: SandboxService;
36
+ /** The ONE execution semaphore, shared with subagents (JC-D, the shared cap). */
37
+ semaphore: Semaphore;
38
+ /** The ONE foreground pending-approval queue jobs produce onto. */
39
+ approvalQueue: ApprovalQueue;
40
+ /** The ONE approval mutex — job prompts/checkpoints serialize on it. */
41
+ approvalMutex: ApprovalMutex;
42
+ /**
43
+ * Whether an interactive foreground exists to service the queue. FALSE (a
44
+ * one-shot / piped run) means a job's gated action fails loud with
45
+ * `CRUXY_E_APPROVAL_REQUIRED` — it never auto-approves and never hangs on a
46
+ * human who will never come.
47
+ */
48
+ foregroundInteractive: boolean;
49
+ /** The shared prompt I/O used when the foreground services a job's request. */
50
+ promptIO: PromptIO;
51
+ /** Test seam: the loop driver. Defaults to the real {@link runAgent}. */
52
+ runAgentFn?: typeof runAgent;
53
+ /** Test seam: deterministic job ids. Defaults to `job-<seq>-<rand>`. */
54
+ idFactory?: () => string;
55
+ /** Test seam: log-line clock (ms). Defaults to `Date.now`. */
56
+ now?: () => number;
57
+ }
58
+ /**
59
+ * Session-scoped background jobs (C.28). The main agent dispatches a job with
60
+ * `run_in_background`; it runs the SAME agent loop as a subagent but CONCURRENTLY
61
+ * with the foreground session, off screen (its output goes to a log buffer). A
62
+ * gated action pauses the job, releases its execution slot, and enqueues an
63
+ * approval request the foreground human services between turns; on approval the
64
+ * job re-acquires a slot (with priority) and runs on — its pre- AND post-pause
65
+ * mutations coalescing under ONE checkpoint. Cancel and session exit kill-tree a
66
+ * job's process tree (via its cancellation signal), leaving no orphan.
67
+ *
68
+ * The manager is session-scoped and dies with the session (NOT a daemon): nothing
69
+ * here persists a live registry across processes. What survives on disk is a
70
+ * job's checkpoint set, so `cruxy rollback <id>` still works afterwards.
71
+ */
72
+ export declare class JobManager {
73
+ private readonly deps;
74
+ private readonly jobs;
75
+ /** In-flight executions, awaited on {@link cancelAll} so teardown completes. */
76
+ private readonly running;
77
+ private readonly idFactory;
78
+ private readonly now;
79
+ private readonly runAgentFn;
80
+ private seq;
81
+ constructor(deps: JobManagerDeps);
82
+ /**
83
+ * Dispatch a background job (the `run_in_background` seam). Fails loud when the
84
+ * feature is disabled, when the live-job ceiling is reached, or when a named
85
+ * root is unknown — all BEFORE the job is registered, so a refused dispatch
86
+ * leaves no ghost. Returns a view immediately; the job runs asynchronously.
87
+ */
88
+ dispatch(spec: JobSpec): JobView;
89
+ /** Live view of every job this session, dispatch order. */
90
+ list(): JobView[];
91
+ /** One job's view, or undefined if the id is unknown. */
92
+ get(id: string): JobView | undefined;
93
+ /** One job's full log (for `/logs <id>`). Fails loud on an unknown id. */
94
+ logs(id: string): JobLog;
95
+ /**
96
+ * Cancel one job: abort its run (kill-tree'ing any in-flight process via the
97
+ * signal reaching `ctx.signal`) and, if it is paused, withdraw its queued
98
+ * approval so it stops waiting. A checkpoint it already took SURVIVES for
99
+ * review/rollback. Fails loud on an unknown id; a no-op on an already-terminal
100
+ * job (returns false).
101
+ */
102
+ cancel(id: string): boolean;
103
+ /**
104
+ * Cancel EVERY live job (session exit / teardown) and AWAIT their teardown, so
105
+ * nothing is left running or orphaned. Returns how many were cancelled — the
106
+ * caller prints the honest "N job(s) cancelled". Idempotent.
107
+ */
108
+ cancelAll(reason: string): Promise<number>;
109
+ /** Jobs that are not yet terminal (queued + running + paused). */
110
+ liveCount(): number;
111
+ /** Whether any job is paused awaiting a foreground approval (drive the drain). */
112
+ hasPendingApprovals(): boolean;
113
+ /** Service every pending job approval on the foreground (called between turns). */
114
+ serviceApprovals(): Promise<number>;
115
+ /** Abort a job's run and unblock it if paused (shared by cancel/cancelAll). */
116
+ private abort;
117
+ /** Drive one job to completion, mapping every outcome onto its status. */
118
+ private execute;
119
+ /** Build the {@link runAgent} args for a job: scoped registry, budget, ctx. */
120
+ private runArgs;
121
+ /**
122
+ * The job's approval gate — the producer side of the pending-approval queue.
123
+ * A read-tier action passes freely. A mutate/destructive action with NO
124
+ * interactive foreground fails loud (never auto-approve, never hang). Otherwise
125
+ * the job PAUSES: it releases its execution slot (JC-D), enqueues the request
126
+ * for the foreground human, and blocks. On resolution it re-acquires a slot
127
+ * with PRIORITY (so it is not starved by work dispatched while it waited) and
128
+ * returns the decision. The real prompt + this job's checkpoint snapshot happen
129
+ * inside `finalize` on the foreground, serialized on the shared mutex.
130
+ */
131
+ private makeJobApproval;
132
+ /** Resolve a job's scope from an optional root name (fail-loud on unknown). */
133
+ private jobScope;
134
+ private statusFor;
135
+ private errorFor;
136
+ private requireJob;
137
+ private log;
138
+ private view;
139
+ }
@@ -0,0 +1,397 @@
1
+ import { runAgent } from "../agent/loop.js";
2
+ import { ApprovalService, classify, serializeGate, } from "../approval/index.js";
3
+ import { CheckpointGate, withCheckpointGate } from "../checkpoint/index.js";
4
+ import { CruxyError, ErrorCode, approvalRequired, jobLimitExceeded, jobNotFound, jobsDisabled, messageOf, } from "../errors/index.js";
5
+ import { Budget, resolveBudget, scopeRegistry, } from "../subagent/index.js";
6
+ import { Workspace } from "../workspace/index.js";
7
+ import { LogBuffer } from "./log-buffer.js";
8
+ import { JobLogRenderer } from "./log-renderer.js";
9
+ import { isTerminal } from "./types.js";
10
+ /** Longest task excerpt kept as a job label (display, not record). */
11
+ const LABEL_MAX = 60;
12
+ /**
13
+ * Session-scoped background jobs (C.28). The main agent dispatches a job with
14
+ * `run_in_background`; it runs the SAME agent loop as a subagent but CONCURRENTLY
15
+ * with the foreground session, off screen (its output goes to a log buffer). A
16
+ * gated action pauses the job, releases its execution slot, and enqueues an
17
+ * approval request the foreground human services between turns; on approval the
18
+ * job re-acquires a slot (with priority) and runs on — its pre- AND post-pause
19
+ * mutations coalescing under ONE checkpoint. Cancel and session exit kill-tree a
20
+ * job's process tree (via its cancellation signal), leaving no orphan.
21
+ *
22
+ * The manager is session-scoped and dies with the session (NOT a daemon): nothing
23
+ * here persists a live registry across processes. What survives on disk is a
24
+ * job's checkpoint set, so `cruxy rollback <id>` still works afterwards.
25
+ */
26
+ export class JobManager {
27
+ deps;
28
+ jobs = new Map();
29
+ /** In-flight executions, awaited on {@link cancelAll} so teardown completes. */
30
+ running = new Set();
31
+ idFactory;
32
+ now;
33
+ runAgentFn;
34
+ seq = 0;
35
+ constructor(deps) {
36
+ this.deps = deps;
37
+ this.now = deps.now ?? Date.now;
38
+ this.runAgentFn = deps.runAgentFn ?? runAgent;
39
+ this.idFactory =
40
+ deps.idFactory ??
41
+ (() => `job-${++this.seq}-${Math.random().toString(36).slice(2, 6)}`);
42
+ }
43
+ /**
44
+ * Dispatch a background job (the `run_in_background` seam). Fails loud when the
45
+ * feature is disabled, when the live-job ceiling is reached, or when a named
46
+ * root is unknown — all BEFORE the job is registered, so a refused dispatch
47
+ * leaves no ghost. Returns a view immediately; the job runs asynchronously.
48
+ */
49
+ dispatch(spec) {
50
+ const { config } = this.deps;
51
+ if (!config.jobs.enabled)
52
+ throw jobsDisabled();
53
+ const live = this.liveCount();
54
+ if (live >= config.jobs.maxJobs) {
55
+ throw jobLimitExceeded(config.jobs.maxJobs, live);
56
+ }
57
+ // Resolve (and validate) the job's scope up front — an unknown root name
58
+ // throws CRUXY_E_ROOT_UNKNOWN here, which the dispatch tool surfaces to the
59
+ // model, rather than failing silently inside the background run.
60
+ const scope = this.jobScope(spec.root);
61
+ const id = this.idFactory();
62
+ const checkpoints = config.checkpoint.enabled
63
+ ? new CheckpointGate({
64
+ config,
65
+ primaryRoot: scope.workspace.primary().absPath,
66
+ })
67
+ : undefined;
68
+ const job = {
69
+ id,
70
+ spec,
71
+ label: taskLabel(spec.task),
72
+ status: "queued",
73
+ iterations: 0,
74
+ usage: { input_tokens: 0, output_tokens: 0 },
75
+ summary: "",
76
+ logs: new LogBuffer(config.jobs.logBufferLines),
77
+ controller: new AbortController(),
78
+ checkpoints,
79
+ scope,
80
+ holdsSlot: false,
81
+ };
82
+ this.jobs.set(id, job);
83
+ this.log(job, "out", `dispatched: ${job.label}`);
84
+ // Fire-and-forget, but TRACKED: cancelAll awaits these so no execution is
85
+ // left detached at session exit. `execute` never rejects (it maps every
86
+ // failure onto the job's status), so the catch is purely defensive.
87
+ const p = this.execute(job)
88
+ .catch((err) => {
89
+ job.status = "failed";
90
+ job.error = `${ErrorCode.Internal}: ${messageOf(err) ?? "job crashed"}`;
91
+ })
92
+ .finally(() => this.running.delete(p));
93
+ this.running.add(p);
94
+ return this.view(job);
95
+ }
96
+ /** Live view of every job this session, dispatch order. */
97
+ list() {
98
+ return [...this.jobs.values()].map((j) => this.view(j));
99
+ }
100
+ /** One job's view, or undefined if the id is unknown. */
101
+ get(id) {
102
+ const job = this.jobs.get(id);
103
+ return job ? this.view(job) : undefined;
104
+ }
105
+ /** One job's full log (for `/logs <id>`). Fails loud on an unknown id. */
106
+ logs(id) {
107
+ const job = this.requireJob(id);
108
+ return {
109
+ id: job.id,
110
+ status: job.status,
111
+ lines: job.logs.snapshot(),
112
+ dropped: job.logs.dropped,
113
+ };
114
+ }
115
+ /**
116
+ * Cancel one job: abort its run (kill-tree'ing any in-flight process via the
117
+ * signal reaching `ctx.signal`) and, if it is paused, withdraw its queued
118
+ * approval so it stops waiting. A checkpoint it already took SURVIVES for
119
+ * review/rollback. Fails loud on an unknown id; a no-op on an already-terminal
120
+ * job (returns false).
121
+ */
122
+ cancel(id) {
123
+ const job = this.requireJob(id);
124
+ if (isTerminal(job.status))
125
+ return false;
126
+ this.abort(job);
127
+ return true;
128
+ }
129
+ /**
130
+ * Cancel EVERY live job (session exit / teardown) and AWAIT their teardown, so
131
+ * nothing is left running or orphaned. Returns how many were cancelled — the
132
+ * caller prints the honest "N job(s) cancelled". Idempotent.
133
+ */
134
+ async cancelAll(reason) {
135
+ let cancelled = 0;
136
+ for (const job of this.jobs.values()) {
137
+ if (!isTerminal(job.status))
138
+ this.log(job, "out", `cancelling: ${reason}`);
139
+ }
140
+ for (const job of this.jobs.values()) {
141
+ if (!isTerminal(job.status)) {
142
+ this.abort(job);
143
+ cancelled++;
144
+ }
145
+ }
146
+ // Await every in-flight execution so a caller (session exit) does not race
147
+ // ahead of the kill-tree teardown.
148
+ await Promise.allSettled([...this.running]);
149
+ return cancelled;
150
+ }
151
+ /** Jobs that are not yet terminal (queued + running + paused). */
152
+ liveCount() {
153
+ let n = 0;
154
+ for (const job of this.jobs.values())
155
+ if (!isTerminal(job.status))
156
+ n++;
157
+ return n;
158
+ }
159
+ /** Whether any job is paused awaiting a foreground approval (drive the drain). */
160
+ hasPendingApprovals() {
161
+ return this.deps.approvalQueue.hasPending();
162
+ }
163
+ /** Service every pending job approval on the foreground (called between turns). */
164
+ serviceApprovals() {
165
+ return this.deps.approvalQueue.serviceAll();
166
+ }
167
+ // ── internals ────────────────────────────────────────────────────────────
168
+ /** Abort a job's run and unblock it if paused (shared by cancel/cancelAll). */
169
+ abort(job) {
170
+ job.controller.abort();
171
+ // If paused on the queue, withdraw with a deny so its `submit` resolves and
172
+ // the run tears down at its next turn boundary instead of hanging.
173
+ this.deps.approvalQueue.withdraw(job.id, { allow: false });
174
+ this.log(job, "out", "cancel requested");
175
+ }
176
+ /** Drive one job to completion, mapping every outcome onto its status. */
177
+ async execute(job) {
178
+ const signal = job.controller.signal;
179
+ try {
180
+ // Wait for an execution permit — a queued job holds none until a slot frees
181
+ // (the shared cap, contended with subagents). This is the queued→running
182
+ // gate.
183
+ await this.deps.semaphore.acquire();
184
+ job.holdsSlot = true;
185
+ if (signal.aborted) {
186
+ job.status = "cancelled";
187
+ return;
188
+ }
189
+ job.status = "running";
190
+ // ONE undo unit for the WHOLE job, keyed by the job id — begun here and
191
+ // NEVER again (a pause is not a checkpoint boundary), so pre- and post-pause
192
+ // mutations coalesce and `cruxy rollback <id>` reverts the whole job.
193
+ job.checkpoints?.beginRun(job.spec.task, job.id);
194
+ const result = await this.runAgentFn(this.runArgs(job, signal));
195
+ job.iterations = result.iterations;
196
+ job.usage = result.usage;
197
+ job.summary = lastAssistantText(result.messages);
198
+ job.status = this.statusFor(result, job);
199
+ if (job.status === "failed")
200
+ job.error = this.errorFor(result);
201
+ }
202
+ catch (err) {
203
+ if (signal.aborted) {
204
+ job.status = "cancelled";
205
+ }
206
+ else if (CruxyError.is(err) &&
207
+ err.code === ErrorCode.ApprovalRequired) {
208
+ // A gated action with no interactive foreground to service it — the
209
+ // pinned fail-loud path. The job FAILS with the coded reason; it never
210
+ // auto-approves.
211
+ job.status = "failed";
212
+ job.error = `${err.code}: ${err.title}`;
213
+ }
214
+ else {
215
+ job.status = "failed";
216
+ job.error = `${ErrorCode.SubagentFailed}: ${messageOf(err) ?? "unknown error"}`;
217
+ }
218
+ }
219
+ finally {
220
+ if (job.holdsSlot) {
221
+ this.deps.semaphore.release();
222
+ job.holdsSlot = false;
223
+ }
224
+ this.log(job, "out", `job ${job.status}`);
225
+ }
226
+ }
227
+ /** Build the {@link runAgent} args for a job: scoped registry, budget, ctx. */
228
+ runArgs(job, signal) {
229
+ const { deps } = this;
230
+ const registry = scopeRegistry(deps.parentRegistry, job.spec.tools);
231
+ const budget = new Budget(resolveBudget(deps.config.subagent.defaultBudget, {
232
+ ...(job.spec.budget?.maxIterations !== undefined
233
+ ? { maxIterations: job.spec.budget.maxIterations }
234
+ : {}),
235
+ ...(job.spec.budget?.maxTokens !== undefined
236
+ ? { maxTokens: job.spec.budget.maxTokens }
237
+ : {}),
238
+ }));
239
+ const ctx = {
240
+ cwd: job.scope.cwd,
241
+ workspace: job.scope.workspace,
242
+ config: deps.config,
243
+ logger: deps.logger,
244
+ requestApproval: this.makeJobApproval(job),
245
+ checkpointsActive: Boolean(job.checkpoints),
246
+ sandbox: deps.sandbox,
247
+ signal,
248
+ };
249
+ const messages = [{ role: "user", content: job.spec.task }];
250
+ return {
251
+ messages,
252
+ provider: deps.provider,
253
+ registry,
254
+ config: deps.config,
255
+ ctx,
256
+ renderer: new JobLogRenderer((stream, text) => this.log(job, stream, text)),
257
+ git: deps.git,
258
+ projectInstructions: deps.projectInstructions,
259
+ subagent: true,
260
+ budget,
261
+ router: deps.router,
262
+ taskClass: "subagent",
263
+ signal,
264
+ };
265
+ }
266
+ /**
267
+ * The job's approval gate — the producer side of the pending-approval queue.
268
+ * A read-tier action passes freely. A mutate/destructive action with NO
269
+ * interactive foreground fails loud (never auto-approve, never hang). Otherwise
270
+ * the job PAUSES: it releases its execution slot (JC-D), enqueues the request
271
+ * for the foreground human, and blocks. On resolution it re-acquires a slot
272
+ * with PRIORITY (so it is not starved by work dispatched while it waited) and
273
+ * returns the decision. The real prompt + this job's checkpoint snapshot happen
274
+ * inside `finalize` on the foreground, serialized on the shared mutex.
275
+ */
276
+ makeJobApproval(job) {
277
+ const { approvalQueue, semaphore, approvalMutex, foregroundInteractive } = this.deps;
278
+ const cwd = job.scope.cwd;
279
+ const interactive = new ApprovalService({
280
+ cwd,
281
+ interactive: foregroundInteractive,
282
+ io: this.deps.promptIO,
283
+ });
284
+ // The foreground-serviced decision: the interactive U.3 prompt, wrapped in
285
+ // THIS job's checkpoint hook, serialized on the SHARED approval mutex — so a
286
+ // job's prompt/checkpoint serialize against a foreground action's on the same
287
+ // lock, and the snapshot lands under the job's own gate.
288
+ const foregroundGate = serializeGate(withCheckpointGate((a) => interactive.requestApproval(a), job.checkpoints, job.scope.workspace), approvalMutex, cwd);
289
+ return async (action) => {
290
+ const req = classify(action, cwd);
291
+ if (req.tier === "read")
292
+ return { allow: true };
293
+ if (!foregroundInteractive)
294
+ throw approvalRequired(req.summary);
295
+ // Pause: release the slot and enqueue for the foreground human.
296
+ if (job.holdsSlot) {
297
+ semaphore.release();
298
+ job.holdsSlot = false;
299
+ }
300
+ job.status = "paused-needs-approval";
301
+ this.log(job, "out", `paused — needs approval: ${req.summary}`);
302
+ try {
303
+ return await approvalQueue.submit({
304
+ jobId: job.id,
305
+ summary: req.summary,
306
+ tier: req.tier,
307
+ finalize: () => foregroundGate(action),
308
+ });
309
+ }
310
+ finally {
311
+ // Resume: re-take a slot with PRIORITY unless the job was cancelled while
312
+ // paused (in which case the run aborts at its next turn boundary and the
313
+ // outer finally, seeing holdsSlot=false, does not double-release).
314
+ if (!job.controller.signal.aborted) {
315
+ await semaphore.acquire({ priority: true });
316
+ job.holdsSlot = true;
317
+ job.status = "running";
318
+ this.log(job, "out", "resumed after approval");
319
+ }
320
+ }
321
+ };
322
+ }
323
+ /** Resolve a job's scope from an optional root name (fail-loud on unknown). */
324
+ jobScope(rootName) {
325
+ if (rootName === undefined) {
326
+ return { cwd: this.deps.cwd, workspace: this.deps.workspace };
327
+ }
328
+ const root = this.deps.workspace.rootByName(rootName); // throws ROOT_UNKNOWN
329
+ return {
330
+ cwd: root.absPath,
331
+ workspace: new Workspace([
332
+ { name: root.name, absPath: root.absPath, primary: true },
333
+ ]),
334
+ };
335
+ }
336
+ statusFor(result, job) {
337
+ if (result.stop === "completed")
338
+ return "done";
339
+ if (result.stop === "aborted" || job.controller.signal.aborted) {
340
+ return "cancelled";
341
+ }
342
+ return "failed"; // budget / max_iterations — partial, honestly not "done"
343
+ }
344
+ errorFor(result) {
345
+ const reason = result.stop === "budget"
346
+ ? (result.stopReason ?? "budget cap reached")
347
+ : `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
348
+ return `${ErrorCode.SubagentBudget}: ${reason}`;
349
+ }
350
+ requireJob(id) {
351
+ const job = this.jobs.get(id);
352
+ if (!job)
353
+ throw jobNotFound(id);
354
+ return job;
355
+ }
356
+ log(job, stream, text) {
357
+ job.logs.append({ atMs: this.now(), stream, text });
358
+ }
359
+ view(job) {
360
+ const pending = this.deps.approvalQueue.pendingFor(job.id);
361
+ return {
362
+ id: job.id,
363
+ status: job.status,
364
+ label: job.label,
365
+ ...(job.error ? { error: job.error } : {}),
366
+ iterations: job.iterations,
367
+ usage: job.usage,
368
+ ...(pending ? { pendingApproval: pending.summary } : {}),
369
+ };
370
+ }
371
+ }
372
+ /** One-line task excerpt for a job label. */
373
+ function taskLabel(task) {
374
+ const flat = task.replace(/\s+/g, " ").trim();
375
+ return flat.length > LABEL_MAX ? flat.slice(0, LABEL_MAX - 1) + "…" : flat;
376
+ }
377
+ /** The final assistant text of a run — the job's summary. */
378
+ function lastAssistantText(messages) {
379
+ for (let i = messages.length - 1; i >= 0; i--) {
380
+ const msg = messages[i];
381
+ if (msg.role !== "assistant")
382
+ continue;
383
+ if (typeof msg.content === "string") {
384
+ if (msg.content.trim())
385
+ return msg.content.trim();
386
+ continue;
387
+ }
388
+ const text = msg.content
389
+ .filter((block) => block.type === "text")
390
+ .map((block) => block.text)
391
+ .join("\n")
392
+ .trim();
393
+ if (text)
394
+ return text;
395
+ }
396
+ return "";
397
+ }
@@ -0,0 +1,81 @@
1
+ import type { Usage } from "@cruxy/sdk";
2
+ import type { LogLine } from "./log-buffer.js";
3
+ /**
4
+ * Types for session-scoped background jobs (C.28): non-interactive orchestration
5
+ * the main agent dispatches with `run_in_background`. A job runs the SAME agent
6
+ * loop as a subagent, but CONCURRENTLY with (and outliving the turn of) the
7
+ * foreground session — bound to the session's lifetime, nothing survives exit
8
+ * (NOT a daemon). Its gated actions enqueue approval requests into the one
9
+ * foreground queue; a paused job releases its execution slot.
10
+ */
11
+ /**
12
+ * A job's lifecycle state. Every state is HONEST — a job is `done` only when its
13
+ * run actually completed, never a fabricated success. Terminal states are `done`,
14
+ * `failed`, `cancelled`; the rest are live.
15
+ * - `queued` — dispatched, waiting for an execution slot (the shared cap is full).
16
+ * - `running` — holding a slot, executing its agent loop.
17
+ * - `paused-needs-approval` — hit a gated action; its approval is enqueued for a
18
+ * foreground human and it has RELEASED its slot until the human decides (JC-D).
19
+ * - `done` — the run completed.
20
+ * - `failed` — the run errored, exceeded budget, or hit a no-foreground approval
21
+ * wall (`CRUXY_E_APPROVAL_REQUIRED`); `error` carries the coded reason.
22
+ * - `cancelled` — `cruxy cancel <id>` or session exit stopped it; its process tree
23
+ * was kill-tree'd and any checkpoint it took survives for review/rollback.
24
+ */
25
+ export type JobStatus = "queued" | "running" | "paused-needs-approval" | "done" | "failed" | "cancelled";
26
+ /** The terminal states — a job in one of these will never run again. */
27
+ export declare const TERMINAL_JOB_STATUSES: readonly JobStatus[];
28
+ /** Whether a status is terminal (no further execution). */
29
+ export declare function isTerminal(status: JobStatus): boolean;
30
+ /**
31
+ * What a background job should do — mirrors the self-contained subtask shape of a
32
+ * subagent spawn (C.14/C.33). A job starts with NO context beyond `task`.
33
+ */
34
+ export interface JobSpec {
35
+ /** The complete, self-contained task the job should perform. */
36
+ task: string;
37
+ /**
38
+ * Tool names to grant, resolved against the parent registry — a job can only
39
+ * scope DOWN. Omitted → the default read-only set. A job granted write/shell
40
+ * tools takes real, gated actions in the background.
41
+ */
42
+ tools?: readonly string[];
43
+ /**
44
+ * The workspace root (by exact name, C.26) this job is confined to. Omitted →
45
+ * the full session workspace (read-only, or a single-root session).
46
+ */
47
+ root?: string;
48
+ /** Budget overrides, clamped to `subagent.defaultBudget` (never raised). */
49
+ budget?: {
50
+ maxIterations?: number;
51
+ maxTokens?: number;
52
+ };
53
+ }
54
+ /**
55
+ * A read-only view of one job for `cruxy jobs` / the dispatch tool's result /
56
+ * tests. Never exposes the live control handles (abort controller, checkpoint
57
+ * gate) — those stay inside the manager.
58
+ */
59
+ export interface JobView {
60
+ /** Stable, session-unique id (also the checkpoint run id for `cruxy rollback`). */
61
+ id: string;
62
+ status: JobStatus;
63
+ /** One-line task excerpt (for listings). */
64
+ label: string;
65
+ /** Present on `failed`: the coded, actionable reason. */
66
+ error?: string;
67
+ /** Model turns consumed so far. */
68
+ iterations: number;
69
+ /** Token usage accumulated so far. */
70
+ usage: Usage;
71
+ /** A one-line summary of what's pending, when `paused-needs-approval`. */
72
+ pendingApproval?: string;
73
+ }
74
+ /** The full log of one job, for `cruxy logs <id>`. */
75
+ export interface JobLog {
76
+ id: string;
77
+ status: JobStatus;
78
+ lines: LogLine[];
79
+ /** How many earlier lines rolled off the ring buffer (0 when none). */
80
+ dropped: number;
81
+ }