@cruxy/cli 1.9.0 → 1.11.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 (51) hide show
  1. package/README.md +2 -2
  2. package/dist/agent/loop.js +16 -1
  3. package/dist/agent/session.js +73 -9
  4. package/dist/approval/classify.js +170 -40
  5. package/dist/approval/prompt.js +52 -6
  6. package/dist/approval/service.js +1 -11
  7. package/dist/budget/session-budget.js +10 -1
  8. package/dist/checkpoint/coverage.js +147 -4
  9. package/dist/cli/command-catalog.js +5 -1
  10. package/dist/cli/commands/config.js +18 -3
  11. package/dist/cli/commands/limits.js +76 -0
  12. package/dist/cli/commands/logs.js +149 -0
  13. package/dist/cli/commands/pr.js +11 -11
  14. package/dist/cli/commands/rollback.js +10 -2
  15. package/dist/cli/commands/run.js +25 -6
  16. package/dist/cli/commands/sessions.js +181 -0
  17. package/dist/cli/onboard.js +0 -9
  18. package/dist/cli/program.js +19 -1
  19. package/dist/cli/repl.js +25 -0
  20. package/dist/cli/session-commands.js +45 -2
  21. package/dist/cli/session-factory.js +31 -10
  22. package/dist/config/manager.js +91 -11
  23. package/dist/config/schema.js +194 -20
  24. package/dist/constants.js +12 -2
  25. package/dist/errors/constructors.js +84 -46
  26. package/dist/errors/types.js +6 -0
  27. package/dist/jobs/index.js +1 -0
  28. package/dist/jobs/log-renderer.js +10 -5
  29. package/dist/jobs/log-store.js +505 -0
  30. package/dist/jobs/manager.js +338 -18
  31. package/dist/mcp/client.js +16 -0
  32. package/dist/render/limits-report.js +213 -0
  33. package/dist/render/limits-view.js +125 -0
  34. package/dist/routing/index.js +1 -1
  35. package/dist/routing/router.js +34 -14
  36. package/dist/routing/types.js +0 -2
  37. package/dist/sandbox/service.js +9 -0
  38. package/dist/sandbox/types.js +15 -0
  39. package/dist/session/index.js +3 -1
  40. package/dist/session/list.js +20 -6
  41. package/dist/session/log.js +120 -21
  42. package/dist/session/prune.js +166 -0
  43. package/dist/session/resume.js +5 -0
  44. package/dist/subagent/orchestrator.js +87 -34
  45. package/dist/subagent/spawn-tool.js +11 -4
  46. package/dist/tools/schema-depth.js +18 -0
  47. package/dist/tui/limits-panel.js +53 -30
  48. package/dist/usage/collect.js +20 -1
  49. package/dist/usage/summary.js +48 -1
  50. package/dist/usage/types.js +52 -0
  51. package/package.json +2 -2
@@ -0,0 +1,505 @@
1
+ import { appendFileSync, closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, } from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { APP_VERSION } from "../constants.js";
5
+ import { SESSION_FILE_EXT, reservedDir } from "../session/paths.js";
6
+ /**
7
+ * Persisted background-job logs (#172 item 1), under the `subagents/` subtree
8
+ * `paths.ts` reserved for exactly this:
9
+ *
10
+ * ```
11
+ * ~/.cruxy/projects/<project>/
12
+ * <session-id>.jsonl ← the conversation (P2)
13
+ * subagents/
14
+ * <session-id>.<job-id>.jsonl ← one background job's output (here)
15
+ * ```
16
+ *
17
+ * ## What this is, and what it is emphatically not
18
+ *
19
+ * It is a POST-MORTEM. It is not "your job survives a restart", and the
20
+ * difference is not pedantry — it is the first bug someone would otherwise
21
+ * file. A job is an in-process {@link ../agent/loop.js} call, `run.ts` calls
22
+ * `cancelAll("session exit")` on the way out, and `orphan.test.ts` pins that
23
+ * this kill-tree's the whole process group. So a job never outlives the session
24
+ * that dispatched it. What this store lets you read afterwards is what the job
25
+ * SAID before it stopped, which is the one thing that used to die with the
26
+ * process — its file mutations already survived as a checkpoint set
27
+ * (`rollback.ts` finds it by `runId === job.id`) and its spend already survived
28
+ * as a usage record keyed by the same id.
29
+ *
30
+ * ## Streamed, never flushed at exit
31
+ *
32
+ * Every line is written when it happens, through the single sink
33
+ * `JobManager.log` already funnels all of them through. The alternative —
34
+ * serialising the ring buffer once on the way out — was rejected twice over:
35
+ *
36
+ * - it would live in the same `finally` that `process.exit` skips. The
37
+ * SIGINT/SIGTERM/SIGHUP handlers in `tui/restore.ts` and
38
+ * `utils/child-tree.ts` both exit the process directly, so a closed terminal
39
+ * window, a dropped ssh session or a plain `kill` runs no `finally` at all.
40
+ * A log that only persists on the orderly path persists exactly the runs
41
+ * that did not need persisting and loses every one that did.
42
+ * - the whole point of the issue is to remove the ring buffer's
43
+ * drop-on-overflow. Writing the BUFFER at exit would persist the truncated
44
+ * tail, i.e. ship the data loss to disk verbatim.
45
+ *
46
+ * ## Durability: the same rules as `session/log.ts`, unchanged
47
+ *
48
+ * One `appendFileSync` of one newline-terminated JSON line, `O_APPEND` so
49
+ * interleaving writers cannot corrupt each other, `0600` from the first line,
50
+ * and a `broken` latch so an unwritable disk goes inert after ONE warning
51
+ * rather than warning per line. A torn final line is skipped by the reader
52
+ * instead of prevented by the writer. Nothing here is temp-then-rename: that
53
+ * makes a whole-file REWRITE atomic, which is the wrong tool for a file that is
54
+ * only ever appended to.
55
+ */
56
+ /** Extension of a persisted job log. Matches the session log's, deliberately. */
57
+ export const JOB_LOG_EXT = ".jsonl";
58
+ /** Schema version of the `job` header record. */
59
+ export const JOB_LOG_VERSION = 1;
60
+ /**
61
+ * How much of a file's tail is read to find its terminal record.
62
+ *
63
+ * The sweep needs one bit per file — did this job finish, and how — and reading
64
+ * a capped-out 20k-line log in full to learn it would put megabytes of read on
65
+ * the startup path that #255 spent its time removing. The terminal record is
66
+ * the last line by construction (nothing logs into a job after its `finally`,
67
+ * and both `cancelAll` and `abortSiblings` skip jobs that are already
68
+ * terminal), so a few KB always contains it. The tail is SCANNED for the last
69
+ * `end` record rather than assuming the final line is one, so a future writer
70
+ * that appends something after it does not silently turn every finished job
71
+ * into an interrupted one.
72
+ */
73
+ const TAIL_BYTES = 8192;
74
+ // ── the record shapes ───────────────────────────────────────────────────────
75
+ //
76
+ // `.passthrough()` throughout, matching `session/types.ts`: a newer cruxy's
77
+ // extra fields must never make an older one discard a log it can otherwise
78
+ // read.
79
+ const JobHeaderSchema = z
80
+ .object({
81
+ kind: z.literal("job"),
82
+ version: z.number().int().positive(),
83
+ jobId: z.string(),
84
+ sessionId: z.string(),
85
+ label: z.string(),
86
+ cwd: z.string(),
87
+ startedAt: z.string(),
88
+ cliVersion: z.string().optional(),
89
+ })
90
+ .passthrough();
91
+ const JobLineSchema = z
92
+ .object({
93
+ kind: z.literal("line"),
94
+ atMs: z.number(),
95
+ stream: z.enum(["out", "err"]),
96
+ text: z.string(),
97
+ })
98
+ .passthrough();
99
+ const JobTruncatedSchema = z
100
+ .object({
101
+ kind: z.literal("truncated"),
102
+ atMs: z.number(),
103
+ cap: z.number().int().nonnegative(),
104
+ })
105
+ .passthrough();
106
+ const JobEndSchema = z
107
+ .object({
108
+ kind: z.literal("end"),
109
+ at: z.string(),
110
+ status: z.string(),
111
+ iterations: z.number().int().nonnegative().optional(),
112
+ error: z.string().optional(),
113
+ })
114
+ .passthrough();
115
+ /**
116
+ * A reader's verdict on a job that has no terminal record.
117
+ *
118
+ * NOT a {@link JobStatus}: no job is ever IN this state, and adding it to the
119
+ * lifecycle enum would put a case into every `switch` over a job's real states
120
+ * for a value the manager can never produce. It is what a FILE looks like, not
121
+ * what a job was.
122
+ */
123
+ export const INTERRUPTED = "interrupted";
124
+ // ── paths ───────────────────────────────────────────────────────────────────
125
+ /** `~/.cruxy/projects/<project>/subagents` */
126
+ export function jobLogsDir(cwd) {
127
+ return reservedDir(cwd, "subagents");
128
+ }
129
+ /**
130
+ * Filename-safe form of an id. Everything outside `[A-Za-z0-9_-]` collapses to
131
+ * `-`, so a name can never contain a path separator or the `.` that separates
132
+ * the two ids.
133
+ *
134
+ * Like {@link ../session/paths.js projectKey}, this is an INDEX and is not
135
+ * reversible. The true ids are recorded in the header record, which is what a
136
+ * reader reports. Real ids (a UUID session, `job-<n>-<rand>`) are already safe;
137
+ * this exists so a test seam's `idFactory` cannot write outside the subtree.
138
+ */
139
+ export function idKey(id) {
140
+ const key = id.replace(/[^A-Za-z0-9_-]+/g, "-");
141
+ return key === "" ? "unknown" : key;
142
+ }
143
+ /** `~/.cruxy/projects/<project>/subagents/<session-id>.<job-id>.jsonl` */
144
+ export function jobLogPath(cwd, sessionId, jobId) {
145
+ return path.join(jobLogsDir(cwd), `${idKey(sessionId)}.${idKey(jobId)}${JOB_LOG_EXT}`);
146
+ }
147
+ /**
148
+ * One background job's on-disk log.
149
+ *
150
+ * ## The per-job cap, and why one is needed at all
151
+ *
152
+ * Retention (#257) bounds how many session-shaped things the tree KEEPS. It
153
+ * cannot bound how big ONE of them gets, and a background job is the first
154
+ * writer here that can produce unbounded output on its own: it runs a full
155
+ * agent loop off screen, and a job stuck in a tool-call loop emits lines for as
156
+ * long as its budget lasts with nobody watching. Streaming it straight to disk
157
+ * with no ceiling is precisely the second unbounded writer under `~/.cruxy`
158
+ * that #257 was filed to prevent — the ring buffer's bound was doing this job
159
+ * implicitly, and taking the buffer off the critical path takes that bound away
160
+ * with it.
161
+ *
162
+ * So the file gets its own: `jobs.logFileLines`, default 20000, floored at
163
+ * `jobs.logBufferLines` because a file that kept LESS than memory would make
164
+ * persisting strictly worse than not persisting.
165
+ *
166
+ * ## The truncation marker is written at the cap, not at the end
167
+ *
168
+ * When the cap is reached a `truncated` record goes down immediately and
169
+ * further lines are dropped. It is not deferred to the terminal record and it
170
+ * does not carry a running count of what came after, for the same reason the
171
+ * rest of this file streams: a marker that only lands on the orderly path is
172
+ * absent from every log that was killed, which is where the truncation is most
173
+ * likely to matter. An exact count of unrecorded lines is not knowable after a
174
+ * SIGKILL either, so promising one would be the lie the marker exists to
175
+ * prevent.
176
+ *
177
+ * The terminal record is written EVEN PAST THE CAP. Suppressing it there would
178
+ * make every capped-out job read as interrupted, which is the one thing the
179
+ * reader's rule must never get wrong.
180
+ */
181
+ export class JobLogWriter {
182
+ file;
183
+ cap;
184
+ logger;
185
+ /** Set once a write fails: the writer goes inert rather than warning per line. */
186
+ broken = false;
187
+ written = 0;
188
+ truncated = false;
189
+ closed = false;
190
+ /**
191
+ * The header, held until the job records something — the same lazy-meta rule
192
+ * `session/log.ts` adopted in #269, for the same reason: a writer that is
193
+ * constructed and never used should leave nothing on disk. In practice
194
+ * `dispatch` logs immediately, so this flushes on the job's first breath.
195
+ */
196
+ pendingHeader;
197
+ constructor(opts) {
198
+ this.file = opts.file ?? jobLogPath(opts.cwd, opts.sessionId, opts.jobId);
199
+ this.cap = Math.max(1, Math.floor(opts.cap));
200
+ this.logger = opts.logger;
201
+ this.pendingHeader = {
202
+ kind: "job",
203
+ version: JOB_LOG_VERSION,
204
+ jobId: opts.jobId,
205
+ sessionId: opts.sessionId,
206
+ label: opts.label,
207
+ cwd: opts.cwd,
208
+ startedAt: new Date().toISOString(),
209
+ cliVersion: APP_VERSION,
210
+ };
211
+ }
212
+ /** Append one log line, honouring the cap. */
213
+ line(line) {
214
+ if (this.broken || this.closed)
215
+ return;
216
+ if (this.written >= this.cap) {
217
+ if (this.truncated)
218
+ return;
219
+ this.truncated = true;
220
+ this.writeRecord({
221
+ kind: "truncated",
222
+ atMs: line.atMs,
223
+ cap: this.cap,
224
+ });
225
+ return;
226
+ }
227
+ if (this.writeRecord({ kind: "line", ...line }))
228
+ this.written++;
229
+ }
230
+ /**
231
+ * Write the terminal record. Called from the one `finally` that runs on every
232
+ * path a job can actually terminate on — so the presence of this record is
233
+ * exactly the fact {@link readJobLog} reads back. Idempotent.
234
+ */
235
+ end(status, iterations, error) {
236
+ if (this.broken || this.closed)
237
+ return;
238
+ this.closed = true;
239
+ this.writeRecord({
240
+ kind: "end",
241
+ at: new Date().toISOString(),
242
+ status,
243
+ iterations,
244
+ ...(error !== undefined ? { error } : {}),
245
+ });
246
+ }
247
+ writeRecord(record) {
248
+ if (this.broken)
249
+ return false;
250
+ const header = this.pendingHeader;
251
+ if (header !== null) {
252
+ this.pendingHeader = null;
253
+ if (!this.writeLine(header))
254
+ return false;
255
+ }
256
+ return this.writeLine(record);
257
+ }
258
+ writeLine(record) {
259
+ try {
260
+ mkdirSync(path.dirname(this.file), { recursive: true });
261
+ // `mode` applies only at creation — 0600 from the first line, matching
262
+ // the session log, credentials, usage and memory.
263
+ appendFileSync(this.file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
264
+ return true;
265
+ }
266
+ catch (err) {
267
+ this.broken = true;
268
+ this.logger?.warn(`background job log not being saved: ${err.message} ` +
269
+ `(the job continues, and \`/logs\` still reads it from memory)`);
270
+ return false;
271
+ }
272
+ }
273
+ }
274
+ /**
275
+ * Persisted job logs for `cwd`'s project, most-recently-modified first.
276
+ *
277
+ * `readdir` plus one `stat` each — no file is opened, exactly as
278
+ * {@link ../session/list.js sessionFilesByRecency} does for sessions, and for
279
+ * the same reason: recency is knowable from the directory alone, so the
280
+ * per-file read only happens for the rows a caller actually shows.
281
+ *
282
+ * The `.jsonl` filter and the `isFile` test are load-bearing, not tidiness:
283
+ * everything returned here is something the sweep will consider deleting, and
284
+ * `statSync` succeeds on a directory. A name that does not split into exactly
285
+ * two ids is skipped rather than guessed at — it was not written by this
286
+ * writer, and the sweep must not delete files it does not own.
287
+ */
288
+ export function jobLogFilesByRecency(cwd) {
289
+ const dir = jobLogsDir(cwd);
290
+ let names;
291
+ try {
292
+ names = readdirSync(dir);
293
+ }
294
+ catch {
295
+ return []; // no jobs have ever run here — normal, not an error
296
+ }
297
+ const refs = [];
298
+ for (const name of names) {
299
+ if (!name.endsWith(JOB_LOG_EXT))
300
+ continue;
301
+ const stem = name.slice(0, -JOB_LOG_EXT.length);
302
+ const dot = stem.indexOf(".");
303
+ if (dot <= 0 || dot === stem.length - 1)
304
+ continue;
305
+ const sessionKey = stem.slice(0, dot);
306
+ const jobKey = stem.slice(dot + 1);
307
+ if (jobKey.includes("."))
308
+ continue;
309
+ const file = path.join(dir, name);
310
+ try {
311
+ const stat = statSync(file);
312
+ if (!stat.isFile())
313
+ continue;
314
+ refs.push({
315
+ file,
316
+ sessionKey,
317
+ jobKey,
318
+ mtimeMs: stat.mtimeMs,
319
+ size: stat.size,
320
+ });
321
+ }
322
+ catch {
323
+ continue; // deleted mid-listing
324
+ }
325
+ }
326
+ refs.sort((a, b) => b.mtimeMs - a.mtimeMs || a.file.localeCompare(b.file));
327
+ return refs;
328
+ }
329
+ /** Refs whose job id equals `id` or begins with it (the `--resume` rule). */
330
+ export function matchJobLogs(refs, id) {
331
+ const exact = refs.filter((r) => r.jobKey === id);
332
+ if (exact.length > 0)
333
+ return exact;
334
+ return refs.filter((r) => r.jobKey.startsWith(id));
335
+ }
336
+ /**
337
+ * Read one persisted log back.
338
+ *
339
+ * ## RECONCILIATION BY ABSENCE — the whole rule, and there is no other
340
+ *
341
+ * A job is {@link INTERRUPTED} if and only if its file carries no `end`
342
+ * record. `JobManager.execute`'s `finally` writes one on every path that runs
343
+ * at all; the paths that do not run are precisely the ones that killed the
344
+ * process without warning. So "no terminal record" means "the process died
345
+ * mid-job", every time, with nothing to maintain.
346
+ *
347
+ * WHAT THIS REPLACES, and why neither alternative works here:
348
+ *
349
+ * - A PID + start-time liveness marker is category-wrong. A job is not a
350
+ * process — it is an in-process `runAgent` call — so the pid on disk would
351
+ * be the CLI's, and "that pid is alive" only ever tells you the SESSION is
352
+ * alive, which every caller already knows more cheaply. It also adds
353
+ * writable state that a crash can leave stale, which is the disease and not
354
+ * the cure.
355
+ * - A STARTUP SWEEP that rewrites non-terminal logs cannot be the load-bearing
356
+ * rule, because a reader can open a log before any restart happens — `cruxy
357
+ * logs` from a second terminal while the writing session is still running.
358
+ * It would see a `running` job and have nothing to distinguish that from a
359
+ * stale one. Deriving from absence answers the live case and the dead case
360
+ * with the same sentence.
361
+ *
362
+ * Tolerant throughout, matching `replay.ts`: a torn or unknown line is skipped
363
+ * rather than failing the read, because a log that was killed mid-write is the
364
+ * exact case this function exists to serve. Returns null only when the file has
365
+ * no readable header — that is not a log this writer produced.
366
+ */
367
+ export function readJobLog(file) {
368
+ let raw;
369
+ try {
370
+ raw = readFileSync(file, "utf8");
371
+ }
372
+ catch {
373
+ return null;
374
+ }
375
+ let header = null;
376
+ const lines = [];
377
+ let end = null;
378
+ let truncatedAt;
379
+ for (const text of raw.split("\n")) {
380
+ if (text.trim() === "")
381
+ continue;
382
+ let json;
383
+ try {
384
+ json = JSON.parse(text);
385
+ }
386
+ catch {
387
+ continue; // torn line — skip, same tolerance as replay
388
+ }
389
+ if (header === null) {
390
+ const h = JobHeaderSchema.safeParse(json);
391
+ if (h.success)
392
+ header = h.data;
393
+ continue; // nothing before the header is ours
394
+ }
395
+ const line = JobLineSchema.safeParse(json);
396
+ if (line.success) {
397
+ lines.push({
398
+ atMs: line.data.atMs,
399
+ stream: line.data.stream,
400
+ text: line.data.text,
401
+ });
402
+ continue;
403
+ }
404
+ const trunc = JobTruncatedSchema.safeParse(json);
405
+ if (trunc.success) {
406
+ truncatedAt = trunc.data.cap;
407
+ continue;
408
+ }
409
+ const e = JobEndSchema.safeParse(json);
410
+ if (e.success)
411
+ end = e.data;
412
+ }
413
+ if (header === null)
414
+ return null;
415
+ return {
416
+ jobId: header.jobId,
417
+ sessionId: header.sessionId,
418
+ label: header.label,
419
+ cwd: header.cwd,
420
+ startedAt: header.startedAt,
421
+ status: terminalStatus(end),
422
+ ...(end?.at !== undefined ? { endedAt: end.at } : {}),
423
+ ...(end?.iterations !== undefined ? { iterations: end.iterations } : {}),
424
+ ...(end?.error !== undefined ? { error: end.error } : {}),
425
+ lines,
426
+ ...(truncatedAt !== undefined ? { truncatedAt } : {}),
427
+ };
428
+ }
429
+ /**
430
+ * The terminal status of a log WITHOUT reading it in full — the tail only.
431
+ *
432
+ * This is what the sweep asks of every file it considers, so it must not cost a
433
+ * full read: see {@link TAIL_BYTES}. Returns {@link INTERRUPTED} when no `end`
434
+ * record is in the tail, which for a file this writer produced means there is
435
+ * none at all.
436
+ */
437
+ export function readJobLogTerminal(file) {
438
+ let tail;
439
+ try {
440
+ tail = readTail(file, TAIL_BYTES);
441
+ }
442
+ catch {
443
+ return INTERRUPTED;
444
+ }
445
+ let end = null;
446
+ for (const text of tail.split("\n")) {
447
+ if (text.trim() === "")
448
+ continue;
449
+ let json;
450
+ try {
451
+ json = JSON.parse(text);
452
+ }
453
+ catch {
454
+ continue; // the first line of a tail read is torn by definition
455
+ }
456
+ const e = JobEndSchema.safeParse(json);
457
+ if (e.success)
458
+ end = e.data;
459
+ }
460
+ return terminalStatus(end);
461
+ }
462
+ /** The `end` record's status, narrowed — or {@link INTERRUPTED} when absent. */
463
+ function terminalStatus(end) {
464
+ if (end === null)
465
+ return INTERRUPTED;
466
+ // The status is read back as a plain string and narrowed here rather than
467
+ // parsed as an enum, so a log written by a build that knows a status this one
468
+ // does not is reported as written instead of being discarded.
469
+ return end.status;
470
+ }
471
+ /** The last `bytes` of a file, decoded as UTF-8. Short files read whole. */
472
+ function readTail(file, bytes) {
473
+ const fd = openSync(file, "r");
474
+ try {
475
+ const size = fstatSync(fd).size;
476
+ const length = Math.min(size, bytes);
477
+ const buf = Buffer.alloc(length);
478
+ readSync(fd, buf, 0, length, size - length);
479
+ return buf.toString("utf8");
480
+ }
481
+ finally {
482
+ closeSync(fd);
483
+ }
484
+ }
485
+ /**
486
+ * The {@link idKey}s of every session file currently in `cwd`'s project.
487
+ *
488
+ * KEYS AND NOT IDS, because that is the only comparison that is safe in both
489
+ * directions. A log's filename carries a sanitized session id, so comparing it
490
+ * against a raw session id would mismatch for any id `idKey` altered — and a
491
+ * mismatch here does not spare a file, it DELETES one, by concluding the owning
492
+ * session is gone. Sanitizing both sides makes the two agree by construction.
493
+ * (For a real session id — a UUID — `idKey` is the identity function; this
494
+ * matters only for a test seam or a future id format.)
495
+ *
496
+ * Derived from the same scan `prune.ts` already ran, so the sweep adds no
497
+ * second directory walk over the session files.
498
+ */
499
+ export function sessionKeysPresent(files) {
500
+ const keys = new Set();
501
+ for (const { file } of files) {
502
+ keys.add(idKey(path.basename(file, SESSION_FILE_EXT)));
503
+ }
504
+ return keys;
505
+ }