@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
@@ -204,6 +204,46 @@ export const SubagentConfigSchema = z
204
204
  .default({}),
205
205
  })
206
206
  .strict();
207
+ /**
208
+ * Session-scoped background jobs (C.28): non-interactive orchestration the main
209
+ * agent dispatches with `run_in_background`, running CONCURRENTLY with the
210
+ * foreground session but bound to it — nothing survives session exit (NOT a
211
+ * daemon). A job hitting a gated action enqueues an approval request into the one
212
+ * foreground queue and pauses until a human services it; a paused job releases
213
+ * its execution slot.
214
+ *
215
+ * Two distinct ceilings, stated explicitly because they bound different things:
216
+ * • {@link maxJobs} — how many background JOBS may exist at once (queued +
217
+ * running + paused). A dispatch past it is refused (`CRUXY_E_JOB_LIMIT`).
218
+ * • the shared execution cap is `subagent.maxConcurrency` (default 3) — how many
219
+ * runs (subagents AND jobs, combined) may EXECUTE at once. It is NOT
220
+ * duplicated here: jobs and subagents draw from the one semaphore. So up to
221
+ * `maxJobs` jobs can be alive while only `maxConcurrency` execute; the rest
222
+ * wait for a slot (or are paused on a human).
223
+ */
224
+ export const JobsConfigSchema = z
225
+ .object({
226
+ /**
227
+ * Master switch. When true, the `run_in_background` tool is registered so the
228
+ * agent can dispatch background jobs. OFF by default — background work is an
229
+ * opt-in capability, and a session that never enables it behaves exactly as
230
+ * before (no tool, no manager, no queue).
231
+ */
232
+ enabled: z.boolean().default(false),
233
+ /**
234
+ * Ceiling on live background jobs (queued + running + paused). Distinct from
235
+ * the shared execution cap (`subagent.maxConcurrency`): this bounds how many
236
+ * jobs can be OUTSTANDING, not how many run at once. Default 5.
237
+ */
238
+ maxJobs: z.number().int().positive().default(5),
239
+ /**
240
+ * How many of a job's most-recent log lines are retained in its ring buffer
241
+ * for `cruxy logs <id>`. Bounded so a chatty job can't grow memory without
242
+ * limit; older lines roll off oldest-first. Default 1000.
243
+ */
244
+ logBufferLines: z.number().int().positive().default(1000),
245
+ })
246
+ .strict();
207
247
  /**
208
248
  * Sandbox / container execution (C.16): defense-in-depth beneath the U.3 gate.
209
249
  * When enabled, `run_command` and `run_tests` execute inside an isolated,
@@ -490,6 +530,7 @@ export const CruxyConfigSchema = z
490
530
  lsp: LspConfigSchema.default({}),
491
531
  checkpoint: CheckpointConfigSchema.default({}),
492
532
  subagent: SubagentConfigSchema.default({}),
533
+ jobs: JobsConfigSchema.default({}),
493
534
  test: TestConfigSchema.default({}),
494
535
  sandbox: SandboxConfigSchema.default({}),
495
536
  hooks: HooksConfigSchema.default({}),
@@ -219,6 +219,24 @@ export declare function subagentScopeOverlap(conflicts: readonly ScopeConflict[]
219
219
  * reasons over; thrown only when the orchestrator itself cannot proceed.
220
220
  */
221
221
  export declare function subagentFailed(underlying?: unknown): CruxyError;
222
+ /**
223
+ * A `run_in_background` dispatch was refused because the session already holds
224
+ * `jobs.maxJobs` live jobs (queued + running + paused). The MODEL corrects it, so
225
+ * this is a coded tool error, not a silent drop: wait for a job to finish (or
226
+ * cancel one) and retry, or run the work in the foreground.
227
+ */
228
+ export declare function jobLimitExceeded(maxJobs: number, live: number): CruxyError;
229
+ /**
230
+ * `cruxy cancel/logs/rollback <id>` named a job that does not exist in this
231
+ * session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
232
+ * is legitimately gone — fail loud with the id rather than a silent no-op.
233
+ */
234
+ export declare function jobNotFound(id: string): CruxyError;
235
+ /**
236
+ * A background-jobs command was used while the feature is disabled. Surfaced with
237
+ * how to enable it rather than pretending there are simply no jobs.
238
+ */
239
+ export declare function jobsDisabled(): CruxyError;
222
240
  /**
223
241
  * No test command could be detected and none is configured (C.13). cruxy never
224
242
  * invents a test command — the fix is always to declare one.
@@ -792,6 +792,55 @@ export function subagentFailed(underlying) {
792
792
  underlying,
793
793
  });
794
794
  }
795
+ // ── background jobs (exit 2 / 19) — C.28 ──────────────────────────────────────
796
+ /**
797
+ * A `run_in_background` dispatch was refused because the session already holds
798
+ * `jobs.maxJobs` live jobs (queued + running + paused). The MODEL corrects it, so
799
+ * this is a coded tool error, not a silent drop: wait for a job to finish (or
800
+ * cancel one) and retry, or run the work in the foreground.
801
+ */
802
+ export function jobLimitExceeded(maxJobs, live) {
803
+ return new CruxyError({
804
+ code: ErrorCode.JobLimit,
805
+ title: `too many background jobs: ${live} live, limit ${maxJobs}`,
806
+ cause: "the count of queued + running + paused jobs is at `jobs.maxJobs`; a new " +
807
+ "dispatch would exceed the ceiling on OUTSTANDING jobs (distinct from the " +
808
+ "shared execution cap `subagent.maxConcurrency`)",
809
+ nextSteps: [
810
+ "wait for a running job to finish, or `cruxy cancel <id>` one you no longer need",
811
+ "raise `jobs.maxJobs` in config if more concurrent jobs are intended",
812
+ "or run this task in the foreground instead of the background",
813
+ ],
814
+ meta: { maxJobs, live },
815
+ });
816
+ }
817
+ /**
818
+ * `cruxy cancel/logs/rollback <id>` named a job that does not exist in this
819
+ * session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
820
+ * is legitimately gone — fail loud with the id rather than a silent no-op.
821
+ */
822
+ export function jobNotFound(id) {
823
+ return new CruxyError({
824
+ code: ErrorCode.JobNotFound,
825
+ title: `no background job with id "${id}" in this session`,
826
+ cause: "background jobs live only for the session that dispatched them; an id from " +
827
+ "a previous session, or a mistyped one, has no live job",
828
+ nextSteps: ["run `cruxy jobs` to list the live jobs and their ids"],
829
+ meta: { id },
830
+ });
831
+ }
832
+ /**
833
+ * A background-jobs command was used while the feature is disabled. Surfaced with
834
+ * how to enable it rather than pretending there are simply no jobs.
835
+ */
836
+ export function jobsDisabled() {
837
+ return new CruxyError({
838
+ code: ErrorCode.JobsDisabled,
839
+ title: "background jobs are disabled",
840
+ cause: "`jobs.enabled` is false, so no jobs can be dispatched or listed",
841
+ nextSteps: ["enable it: `cruxy config set jobs.enabled true`"],
842
+ });
843
+ }
795
844
  // ── testing (exit 2) ──────────────────────────────────────────────────────────
796
845
  /**
797
846
  * No test command could be detected and none is configured (C.13). cruxy never
@@ -173,6 +173,19 @@ export declare const ErrorCode: {
173
173
  * is a single-repo artifact, so it is refused (naming both) rather than silently
174
174
  * PR one half. */
175
175
  readonly VcsCrossRoot: "CRUXY_E_VCS_CROSS_ROOT";
176
+ /** A `run_in_background` dispatch was refused because the live job count
177
+ * (queued + running + paused) already sits at `jobs.maxJobs`. The MODEL corrects
178
+ * it — wait for a job to finish (or cancel one) and retry — so it is a usage-tier
179
+ * coded error, never a silently-dropped dispatch. */
180
+ readonly JobLimit: "CRUXY_E_JOB_LIMIT";
181
+ /** `cruxy cancel/logs/rollback <id>` named a job id that does not exist in this
182
+ * session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
183
+ * is legitimately unknown — fail loud with the id rather than a silent no-op. */
184
+ readonly JobNotFound: "CRUXY_E_JOB_NOT_FOUND";
185
+ /** A background-jobs command (`cruxy jobs/logs/cancel`) was used while
186
+ * `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
187
+ * rather than pretending there are simply no jobs. */
188
+ readonly JobsDisabled: "CRUXY_E_JOBS_DISABLED";
176
189
  };
177
190
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
178
191
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -193,6 +193,20 @@ export const ErrorCode = {
193
193
  * is a single-repo artifact, so it is refused (naming both) rather than silently
194
194
  * PR one half. */
195
195
  VcsCrossRoot: "CRUXY_E_VCS_CROSS_ROOT",
196
+ // session-scoped background jobs (exit 2 / 19) — C.28
197
+ /** A `run_in_background` dispatch was refused because the live job count
198
+ * (queued + running + paused) already sits at `jobs.maxJobs`. The MODEL corrects
199
+ * it — wait for a job to finish (or cancel one) and retry — so it is a usage-tier
200
+ * coded error, never a silently-dropped dispatch. */
201
+ JobLimit: "CRUXY_E_JOB_LIMIT",
202
+ /** `cruxy cancel/logs/rollback <id>` named a job id that does not exist in this
203
+ * session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
204
+ * is legitimately unknown — fail loud with the id rather than a silent no-op. */
205
+ JobNotFound: "CRUXY_E_JOB_NOT_FOUND",
206
+ /** A background-jobs command (`cruxy jobs/logs/cancel`) was used while
207
+ * `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
208
+ * rather than pretending there are simply no jobs. */
209
+ JobsDisabled: "CRUXY_E_JOBS_DISABLED",
196
210
  };
197
211
  /**
198
212
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -304,6 +318,13 @@ const EXIT_CODES = {
304
318
  // refusals kin to the other cross-root guards — they share the greppable code.
305
319
  [ErrorCode.VcsRemoteChanged]: 18,
306
320
  [ErrorCode.VcsCrossRoot]: 18,
321
+ // Background jobs (C.28). The dispatch-limit refusal is model-correctable, so
322
+ // it shares the usage exit code (2) with the other model-facing coded errors
323
+ // (subagent depth/scope). The CLI-facing ones — an unknown job id, the feature
324
+ // disabled — get a distinct greppable category (19).
325
+ [ErrorCode.JobLimit]: 2,
326
+ [ErrorCode.JobNotFound]: 19,
327
+ [ErrorCode.JobsDisabled]: 19,
307
328
  };
308
329
  /** The process exit code for an error code (defaults to 1 for safety). */
309
330
  export function exitCodeFor(code) {
@@ -0,0 +1,85 @@
1
+ import type { ApprovalDecision, RiskTier } from "../approval/index.js";
2
+ /**
3
+ * The single foreground pending-approval queue (C.28) — the seam that lets a
4
+ * NON-interactive background job get a gated action approved by the ONE
5
+ * interactive foreground human, without becoming a second interactive context.
6
+ *
7
+ * The §0 model: a background job is not a competing consumer of the terminal —
8
+ * it is a PRODUCER on this queue. When a job hits a gated action it `submit`s the
9
+ * request here and blocks on the returned promise; it holds no terminal, no
10
+ * mutex, and (having released its execution slot) no compute while it waits. The
11
+ * foreground drains the queue when it is idle (between turns): {@link serviceAll}
12
+ * runs each request's `finalize` — the REAL U.3 prompt + checkpoint, serialized
13
+ * through the shared approval mutex INSIDE `finalize` — and settles the producer
14
+ * with the human's decision. Because the prompt happens on the foreground under
15
+ * the shared mutex, a background job can never paint a second prompt over a
16
+ * foreground one, and the action never executes until a human has decided.
17
+ *
18
+ * This queue is pure transport: it holds pending entries and settles them. It
19
+ * knows nothing about checkpoints, the mutex, or how a decision is reached — the
20
+ * manager composes that into each entry's `finalize`.
21
+ */
22
+ /** A read-only view of one pending request (for `/jobs` display + tests). */
23
+ export interface PendingApproval {
24
+ /** Queue-unique id. */
25
+ readonly id: string;
26
+ /** The job that is blocked on this decision. */
27
+ readonly jobId: string;
28
+ /** One plain line describing the action (e.g. "run: rm -rf build"). */
29
+ readonly summary: string;
30
+ /** Risk tier (mutate | destructive — reads never reach the queue). */
31
+ readonly tier: RiskTier;
32
+ }
33
+ /** What a producing job submits: how to describe it, and how to actually decide. */
34
+ export interface ApprovalSubmission {
35
+ jobId: string;
36
+ summary: string;
37
+ tier: RiskTier;
38
+ /**
39
+ * Run the REAL decision: the interactive U.3 prompt + the job's checkpoint
40
+ * snapshot, serialized on the shared approval mutex. Composed by the manager so
41
+ * this queue stays decoupled. Called by {@link serviceAll} on the foreground;
42
+ * its resolved decision settles the producer's `submit` promise. A throw
43
+ * (e.g. a checkpoint failure) rejects the producer's promise so the job fails
44
+ * loud rather than hanging.
45
+ */
46
+ finalize: () => Promise<ApprovalDecision>;
47
+ }
48
+ export declare class ApprovalQueue {
49
+ private readonly queue;
50
+ private seq;
51
+ /**
52
+ * A job submits a gated action and awaits the decision. Resolves when the
53
+ * foreground services it ({@link serviceAll}) or a caller {@link withdraw}s it;
54
+ * rejects if the finalize throws. The action does NOT execute here — the caller
55
+ * only proceeds after this resolves `{allow:true}`.
56
+ */
57
+ submit(sub: ApprovalSubmission): Promise<ApprovalDecision>;
58
+ /** Whether any request is waiting to be serviced. */
59
+ hasPending(): boolean;
60
+ /** Count of requests waiting. */
61
+ get size(): number;
62
+ /** Read-only snapshot of every pending request, FIFO order. */
63
+ pending(): PendingApproval[];
64
+ /** The pending request for one job (a job has at most one at a time), if any. */
65
+ pendingFor(jobId: string): PendingApproval | undefined;
66
+ /**
67
+ * Foreground drain: service every currently-pending request, FIFO, ONE AT A
68
+ * TIME — each `finalize` fully settles (including the human's keypress) before
69
+ * the next begins, so two background prompts never overlap and the shared mutex
70
+ * inside `finalize` also serializes them against any foreground action. Returns
71
+ * the number serviced. Requests that arrive AFTER draining starts wait for the
72
+ * next drain (bounded work per idle window). A finalize throw rejects that one
73
+ * producer and drains on — one job's checkpoint failure never wedges the queue.
74
+ */
75
+ serviceAll(): Promise<number>;
76
+ /**
77
+ * Withdraw a job's pending request WITHOUT prompting — used when the job is
78
+ * cancelled (or the session exits) while it is paused: settle the producer with
79
+ * `decision` (a deny) so its `submit` promise resolves and the job can finish
80
+ * tearing down instead of blocking forever. Returns true if one was withdrawn.
81
+ */
82
+ withdraw(jobId: string, decision: ApprovalDecision): boolean;
83
+ /** Settle an entry exactly once (double-settle is a defensive no-op). */
84
+ private settle;
85
+ }
@@ -0,0 +1,96 @@
1
+ export class ApprovalQueue {
2
+ queue = [];
3
+ seq = 0;
4
+ /**
5
+ * A job submits a gated action and awaits the decision. Resolves when the
6
+ * foreground services it ({@link serviceAll}) or a caller {@link withdraw}s it;
7
+ * rejects if the finalize throws. The action does NOT execute here — the caller
8
+ * only proceeds after this resolves `{allow:true}`.
9
+ */
10
+ submit(sub) {
11
+ return new Promise((resolve, reject) => {
12
+ this.queue.push({
13
+ id: `pa-${++this.seq}`,
14
+ jobId: sub.jobId,
15
+ summary: sub.summary,
16
+ tier: sub.tier,
17
+ finalize: sub.finalize,
18
+ resolve,
19
+ reject,
20
+ settled: false,
21
+ });
22
+ });
23
+ }
24
+ /** Whether any request is waiting to be serviced. */
25
+ hasPending() {
26
+ return this.queue.length > 0;
27
+ }
28
+ /** Count of requests waiting. */
29
+ get size() {
30
+ return this.queue.length;
31
+ }
32
+ /** Read-only snapshot of every pending request, FIFO order. */
33
+ pending() {
34
+ return this.queue.map((e) => ({
35
+ id: e.id,
36
+ jobId: e.jobId,
37
+ summary: e.summary,
38
+ tier: e.tier,
39
+ }));
40
+ }
41
+ /** The pending request for one job (a job has at most one at a time), if any. */
42
+ pendingFor(jobId) {
43
+ return this.pending().find((p) => p.jobId === jobId);
44
+ }
45
+ /**
46
+ * Foreground drain: service every currently-pending request, FIFO, ONE AT A
47
+ * TIME — each `finalize` fully settles (including the human's keypress) before
48
+ * the next begins, so two background prompts never overlap and the shared mutex
49
+ * inside `finalize` also serializes them against any foreground action. Returns
50
+ * the number serviced. Requests that arrive AFTER draining starts wait for the
51
+ * next drain (bounded work per idle window). A finalize throw rejects that one
52
+ * producer and drains on — one job's checkpoint failure never wedges the queue.
53
+ */
54
+ async serviceAll() {
55
+ let serviced = 0;
56
+ for (;;) {
57
+ const entry = this.queue.shift();
58
+ if (!entry)
59
+ break;
60
+ if (entry.settled)
61
+ continue; // withdrawn between snapshot and here
62
+ try {
63
+ const decision = await entry.finalize();
64
+ this.settle(entry, () => entry.resolve(decision));
65
+ }
66
+ catch (err) {
67
+ this.settle(entry, () => entry.reject(err));
68
+ }
69
+ serviced++;
70
+ }
71
+ return serviced;
72
+ }
73
+ /**
74
+ * Withdraw a job's pending request WITHOUT prompting — used when the job is
75
+ * cancelled (or the session exits) while it is paused: settle the producer with
76
+ * `decision` (a deny) so its `submit` promise resolves and the job can finish
77
+ * tearing down instead of blocking forever. Returns true if one was withdrawn.
78
+ */
79
+ withdraw(jobId, decision) {
80
+ const entry = this.queue.find((e) => e.jobId === jobId && !e.settled);
81
+ if (!entry)
82
+ return false;
83
+ const i = this.queue.indexOf(entry);
84
+ if (i !== -1)
85
+ this.queue.splice(i, 1);
86
+ this.settle(entry, () => entry.resolve(decision));
87
+ return true;
88
+ }
89
+ /** Settle an entry exactly once (double-settle is a defensive no-op). */
90
+ settle(entry, run) {
91
+ if (entry.settled)
92
+ return;
93
+ entry.settled = true;
94
+ run();
95
+ }
96
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from "zod";
2
+ import type { Tool } from "../tools/index.js";
3
+ import type { JobManager } from "./manager.js";
4
+ /**
5
+ * The `run_in_background` tool (C.28): the agent-facing seam for dispatching a
6
+ * background job. Unlike `spawn_subagent`, it does NOT block — it returns a job
7
+ * id immediately and the job runs CONCURRENTLY with the rest of the turn and the
8
+ * session. The agent uses it for long-running, self-contained work it does not
9
+ * want to wait on inline; the human watches it with `/jobs` and `/logs`, and any
10
+ * side effect the job takes still requires a foreground approval.
11
+ */
12
+ export declare const RUN_IN_BACKGROUND_TOOL_NAME = "run_in_background";
13
+ declare const parameters: z.ZodObject<{
14
+ task: z.ZodString;
15
+ tools: z.ZodOptional<z.ZodArray<z.ZodString, "atleastone">>;
16
+ root: z.ZodOptional<z.ZodString>;
17
+ maxIterations: z.ZodOptional<z.ZodNumber>;
18
+ maxTokens: z.ZodOptional<z.ZodNumber>;
19
+ }, "strip", z.ZodTypeAny, {
20
+ task: string;
21
+ root?: string | undefined;
22
+ maxTokens?: number | undefined;
23
+ maxIterations?: number | undefined;
24
+ tools?: [string, ...string[]] | undefined;
25
+ }, {
26
+ task: string;
27
+ root?: string | undefined;
28
+ maxTokens?: number | undefined;
29
+ maxIterations?: number | undefined;
30
+ tools?: [string, ...string[]] | undefined;
31
+ }>;
32
+ /** Build the `run_in_background` tool bound to the session's job manager. */
33
+ export declare function makeRunInBackgroundTool(manager: JobManager): Tool<typeof parameters>;
34
+ export {};
@@ -0,0 +1,96 @@
1
+ import { z } from "zod";
2
+ import { CruxyError } from "../errors/index.js";
3
+ /**
4
+ * The `run_in_background` tool (C.28): the agent-facing seam for dispatching a
5
+ * background job. Unlike `spawn_subagent`, it does NOT block — it returns a job
6
+ * id immediately and the job runs CONCURRENTLY with the rest of the turn and the
7
+ * session. The agent uses it for long-running, self-contained work it does not
8
+ * want to wait on inline; the human watches it with `/jobs` and `/logs`, and any
9
+ * side effect the job takes still requires a foreground approval.
10
+ */
11
+ export const RUN_IN_BACKGROUND_TOOL_NAME = "run_in_background";
12
+ const parameters = z.object({
13
+ task: z
14
+ .string()
15
+ .min(1)
16
+ .describe("The complete, self-contained task for the background job. It starts with " +
17
+ "NO context beyond this text — include every path, constraint, and expected " +
18
+ "output. The job runs concurrently; you get a job id back immediately."),
19
+ tools: z
20
+ .array(z.string().min(1))
21
+ .nonempty()
22
+ .optional()
23
+ .describe("Tool names to grant the job — a subset of your own. Omit for the default " +
24
+ "read-only set. Grant write/shell tools only when the job needs them; every " +
25
+ "side effect still pauses for a foreground human approval."),
26
+ root: z
27
+ .string()
28
+ .min(1)
29
+ .optional()
30
+ .describe("Workspace root name (from your Environment) to confine the job's writes to. " +
31
+ "Omit for a read-only job or a single-root workspace."),
32
+ maxIterations: z
33
+ .number()
34
+ .int()
35
+ .positive()
36
+ .optional()
37
+ .describe("Cap on the job's model turns (clamped to the configured ceiling)."),
38
+ maxTokens: z
39
+ .number()
40
+ .int()
41
+ .positive()
42
+ .optional()
43
+ .describe("Cap on the job's total tokens (clamped to the configured ceiling)."),
44
+ });
45
+ /** Build the `run_in_background` tool bound to the session's job manager. */
46
+ export function makeRunInBackgroundTool(manager) {
47
+ return {
48
+ name: RUN_IN_BACKGROUND_TOOL_NAME,
49
+ description: "Dispatch a self-contained task to run as a BACKGROUND JOB, concurrently with " +
50
+ "the rest of this session. Returns a job id immediately (non-blocking) — the " +
51
+ "job runs on its own while you continue. Use it for long or independent work " +
52
+ "you don't need to wait on inline (e.g. \"run the full test suite and fix any " +
53
+ "failures\"). The job's output goes to a log (the user reads it with /logs); any " +
54
+ "file/shell action it takes pauses for the user's approval. For a subtask whose " +
55
+ "RESULT you need before continuing, use spawn_subagent (which blocks) instead.",
56
+ parameters,
57
+ async execute(input) {
58
+ const spec = {
59
+ task: input.task,
60
+ ...(input.tools ? { tools: input.tools } : {}),
61
+ ...(input.root !== undefined ? { root: input.root } : {}),
62
+ budget: {
63
+ ...(input.maxIterations !== undefined
64
+ ? { maxIterations: input.maxIterations }
65
+ : {}),
66
+ ...(input.maxTokens !== undefined
67
+ ? { maxTokens: input.maxTokens }
68
+ : {}),
69
+ },
70
+ };
71
+ let view;
72
+ try {
73
+ view = manager.dispatch(spec);
74
+ }
75
+ catch (err) {
76
+ // Disabled / job-limit / unknown-root are the model's to correct — feed
77
+ // the coded, actionable message back as a tool error, never a stack.
78
+ if (CruxyError.is(err)) {
79
+ return {
80
+ ok: false,
81
+ error: `${err.code}: ${err.title}${err.cause ? ` — ${err.cause}` : ""}`,
82
+ };
83
+ }
84
+ throw err;
85
+ }
86
+ return {
87
+ ok: true,
88
+ output: JSON.stringify({
89
+ jobId: view.id,
90
+ status: view.status,
91
+ note: "dispatched — runs in the background; check with /jobs and /logs",
92
+ }),
93
+ };
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./types.js";
2
+ export * from "./log-buffer.js";
3
+ export * from "./log-renderer.js";
4
+ export * from "./approval-queue.js";
5
+ export * from "./manager.js";
6
+ export * from "./dispatch-tool.js";
@@ -0,0 +1,6 @@
1
+ export * from "./types.js";
2
+ export * from "./log-buffer.js";
3
+ export * from "./log-renderer.js";
4
+ export * from "./approval-queue.js";
5
+ export * from "./manager.js";
6
+ export * from "./dispatch-tool.js";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A bounded ring buffer of a background job's log lines (C.28). A job runs
3
+ * non-interactively, so its progress isn't on screen — `cruxy logs <id>` reads
4
+ * it back here. Bounded so a chatty job cannot grow memory without limit: once
5
+ * `capacity` lines are held, the oldest roll off. When anything is dropped the
6
+ * buffer records how many, so a reader is never misled into thinking a truncated
7
+ * tail is the whole story (`cruxy logs` prints an honest "… N earlier lines").
8
+ */
9
+ export interface LogLine {
10
+ /** Milliseconds since the job started (monotonic, injected — never wall clock
11
+ * inside the buffer, so it stays deterministic and testable). */
12
+ atMs: number;
13
+ /** `out` for normal progress, `err` for diagnostics/failures. */
14
+ stream: "out" | "err";
15
+ /** The line text (no trailing newline). */
16
+ text: string;
17
+ }
18
+ export declare class LogBuffer {
19
+ private readonly capacity;
20
+ private readonly lines;
21
+ private droppedCount;
22
+ constructor(capacity: number);
23
+ /** Append a line, rolling the oldest off (and counting it) when at capacity. */
24
+ append(line: LogLine): void;
25
+ /** The retained lines, oldest-first (a copy — callers never mutate the buffer). */
26
+ snapshot(): LogLine[];
27
+ /** How many lines rolled off the front (0 when nothing was dropped). */
28
+ get dropped(): number;
29
+ /** Lines currently retained. */
30
+ get size(): number;
31
+ }
@@ -0,0 +1,30 @@
1
+ export class LogBuffer {
2
+ capacity;
3
+ lines = [];
4
+ droppedCount = 0;
5
+ constructor(capacity) {
6
+ // A non-positive capacity would drop everything; clamp to at least 1 so a
7
+ // misconfigured buffer still shows the most recent line.
8
+ this.capacity = Math.max(1, Math.floor(capacity));
9
+ }
10
+ /** Append a line, rolling the oldest off (and counting it) when at capacity. */
11
+ append(line) {
12
+ this.lines.push(line);
13
+ while (this.lines.length > this.capacity) {
14
+ this.lines.shift();
15
+ this.droppedCount++;
16
+ }
17
+ }
18
+ /** The retained lines, oldest-first (a copy — callers never mutate the buffer). */
19
+ snapshot() {
20
+ return [...this.lines];
21
+ }
22
+ /** How many lines rolled off the front (0 when nothing was dropped). */
23
+ get dropped() {
24
+ return this.droppedCount;
25
+ }
26
+ /** Lines currently retained. */
27
+ get size() {
28
+ return this.lines.length;
29
+ }
30
+ }
@@ -0,0 +1,32 @@
1
+ import { type Theme } from "../theme/index.js";
2
+ import type { RenderCapabilities, StreamRenderer, ToolLifecycleEvent } from "../render/index.js";
3
+ /**
4
+ * A {@link StreamRenderer} for a background job (C.28) that captures activity into
5
+ * the job's log buffer and writes NOTHING to any terminal — a job runs
6
+ * non-interactively, off screen, and its foreground session owns the terminal.
7
+ * `cruxy logs <id>` (a REPL `/logs`) reads back what was captured here.
8
+ *
9
+ * Assistant text is accumulated and flushed a line at a time on `endSegment`;
10
+ * committed chrome notes and tool-call completions are captured verbatim. The
11
+ * live-region methods (status, phase, progress, prompt-resolved) are dropped —
12
+ * transient decor has no meaning in an append-only log.
13
+ */
14
+ export declare class JobLogRenderer implements StreamRenderer {
15
+ private readonly sink;
16
+ readonly caps: RenderCapabilities;
17
+ readonly theme: Theme;
18
+ private pending;
19
+ constructor(sink: (stream: "out" | "err", text: string) => void);
20
+ beginTurn(): void;
21
+ write(delta: string): void;
22
+ endSegment(): void;
23
+ note(text: string): void;
24
+ toolLifecycle(event: ToolLifecycleEvent): void;
25
+ endTurn(): void;
26
+ preview(): void;
27
+ status(): void;
28
+ setPhase(): void;
29
+ progress(): void;
30
+ promptResolved(): void;
31
+ close(): void;
32
+ }