@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
@@ -1,15 +1,47 @@
1
1
  import { runAgent } from "../agent/loop.js";
2
- import { ApprovalService, classify, serializeGate, } from "../approval/index.js";
2
+ import { ApprovalService, classify, InteractivePolicy, serializeGate, SessionAllowlist, } from "../approval/index.js";
3
3
  import { CheckpointGate, withCheckpointGate } from "../checkpoint/index.js";
4
- import { CruxyError, ErrorCode, approvalRequired, jobLimitExceeded, jobNotFound, jobsDisabled, messageOf, } from "../errors/index.js";
4
+ import { compactTokens } from "../render/units.js";
5
+ import { CruxyError, ErrorCode, approvalRequired, jobLimitExceeded, jobNotFound, jobsDisabled, messageOf, poolDenial, sessionBudgetExhausted, } from "../errors/index.js";
6
+ import { resolveTaskModel } from "../routing/index.js";
7
+ import { UNRESOLVED_TIER } from "../budget/index.js";
8
+ import { UsageCollector } from "../usage/index.js";
5
9
  import { Budget, resolveBudget } from "../agent/budget.js";
6
10
  import { scopeRegistry } from "../subagent/index.js";
7
11
  import { Workspace } from "../workspace/index.js";
8
12
  import { LogBuffer } from "./log-buffer.js";
13
+ import { JobLogWriter } from "./log-store.js";
9
14
  import { JobLogRenderer } from "./log-renderer.js";
10
15
  import { isTerminal } from "./types.js";
11
16
  /** Longest task excerpt kept as a job label (display, not record). */
12
17
  const LABEL_MAX = 60;
18
+ /**
19
+ * THE ADMISSION FLOOR, in whole requests' worth of fixed payload (cli#245).
20
+ *
21
+ * `Budget.exceeded` is checked BEFORE each model turn against the run's
22
+ * cumulative tokens, so a job's first request always goes out whatever its
23
+ * ceiling is: at iteration 0 the usage is zero and no cap can trip. A ceiling
24
+ * the pool narrowed down to a few hundred tokens therefore buys exactly one
25
+ * request — the system prompt and the whole tool catalogue re-sent, real
26
+ * weighted tokens drawn from a window that is already refusing — and then stops
27
+ * the run at the next boundary with `token cap reached`, which `statusFor` maps
28
+ * to `failed`. The job dispatches, the model is told it started, and it dies a
29
+ * second later having produced nothing and spent something.
30
+ *
31
+ * TWO, not one, and `context.reserveTokens` (4500 by default) is the unit
32
+ * because it is the figure this repo already keeps for exactly this quantity:
33
+ * "the built system prompt plus the default tool catalogue". One request's worth
34
+ * buys a job a single model turn it cannot act on — it can call a tool and never
35
+ * see the result. Two is the smallest ceiling under which a job can complete one
36
+ * act-and-report cycle, which is the least a background job can do and still be
37
+ * worth the tokens. It is a FLOOR and not a promise: input is re-sent every
38
+ * turn, so two turns cost more than twice the fixed payload.
39
+ *
40
+ * Derived from config rather than a literal, so it tracks the prompt: a build
41
+ * whose catalogue grows raises the floor with it. A config that sets
42
+ * `reserveTokens` to 0 states that there is no fixed payload, and floors nothing.
43
+ */
44
+ const MIN_JOB_REQUESTS = 2;
13
45
  /**
14
46
  * Session-scoped background jobs (C.28). The main agent dispatches a job with
15
47
  * `run_in_background`; it runs the SAME agent loop as a subagent but CONCURRENTLY
@@ -32,6 +64,8 @@ export class JobManager {
32
64
  idFactory;
33
65
  now;
34
66
  runAgentFn;
67
+ /** The last pool denial a job died on, until the foreground takes it. */
68
+ poolDenied;
35
69
  seq = 0;
36
70
  constructor(deps) {
37
71
  this.deps = deps;
@@ -43,9 +77,21 @@ export class JobManager {
43
77
  }
44
78
  /**
45
79
  * Dispatch a background job (the `run_in_background` seam). Fails loud when the
46
- * feature is disabled, when the live-job ceiling is reached, or when a named
47
- * root is unknown all BEFORE the job is registered, so a refused dispatch
48
- * leaves no ghost. Returns a view immediately; the job runs asynchronously.
80
+ * feature is disabled, when the live-job ceiling is reached, when a named
81
+ * root is unknown, or when the session's weighted budget will not cover the
82
+ * job all BEFORE the job is registered, so a refused dispatch leaves no
83
+ * ghost. Returns a view immediately; the job runs asynchronously.
84
+ *
85
+ * ADMISSION HAPPENS HERE, NOT IN `execute` (cli#245). A queued job can sit
86
+ * behind the shared semaphore for a long time, so "does it fit now" and "does
87
+ * it fit when it runs" are genuinely different questions when the pool is
88
+ * moving — and this is the only one of the two the model can be told the
89
+ * answer to. `dispatch` is synchronous and already throws three coded
90
+ * refusals that `dispatch-tool.ts` converts into a tool error; a budget
91
+ * refusal is that same shape, needing no new mechanism. `execute` has no
92
+ * error channel back to the model at all — only `job.status`, read by a human
93
+ * later — and by then the turn that dispatched has long returned, having
94
+ * already been told the job started.
49
95
  */
50
96
  dispatch(spec) {
51
97
  const { config } = this.deps;
@@ -58,7 +104,13 @@ export class JobManager {
58
104
  // Resolve (and validate) the job's scope up front — an unknown root name
59
105
  // throws CRUXY_E_ROOT_UNKNOWN here, which the dispatch tool surfaces to the
60
106
  // model, rather than failing silently inside the background run.
107
+ //
108
+ // Before the budget check, deliberately, and for the reason the fan-out
109
+ // seam orders its two checks the same way: a job naming a root that does
110
+ // not exist is MALFORMED, and answering it with "your budget is used up"
111
+ // would report a ceiling instead of the typo.
61
112
  const scope = this.jobScope(spec.root);
113
+ const limits = this.admit(spec);
62
114
  const id = this.idFactory();
63
115
  const checkpoints = config.checkpoint.enabled
64
116
  ? new CheckpointGate({
@@ -75,9 +127,11 @@ export class JobManager {
75
127
  usage: { input_tokens: 0, output_tokens: 0 },
76
128
  summary: "",
77
129
  logs: new LogBuffer(config.jobs.logBufferLines),
130
+ writer: this.makeWriter(id, taskLabel(spec.task)),
78
131
  controller: new AbortController(),
79
132
  checkpoints,
80
133
  scope,
134
+ limits,
81
135
  holdsSlot: false,
82
136
  };
83
137
  this.jobs.set(id, job);
@@ -177,6 +231,14 @@ export class JobManager {
177
231
  /** Drive one job to completion, mapping every outcome onto its status. */
178
232
  async execute(job) {
179
233
  const signal = job.controller.signal;
234
+ // Per-request usage for THIS job, collected exactly as the parent's turn and
235
+ // a subagent collect their own. Needed rather than `result.usage` for two
236
+ // reasons: the weighted arithmetic is per-tier, and a run that THREW has no
237
+ // result at all while still having spent whatever it spent before it died.
238
+ // Origin `"job"` on every entry (cli#244): this record is published to the
239
+ // store in its own right, so its entries have to say what they were.
240
+ const usage = new UsageCollector(undefined, "job");
241
+ const startedAt = new Date().toISOString();
180
242
  try {
181
243
  // Wait for an execution permit — a queued job holds none until a slot frees
182
244
  // (the shared cap, contended with subagents). This is the queued→running
@@ -192,7 +254,7 @@ export class JobManager {
192
254
  // NEVER again (a pause is not a checkpoint boundary), so pre- and post-pause
193
255
  // mutations coalesce and `cruxy rollback <id>` reverts the whole job.
194
256
  job.checkpoints?.beginRun(job.spec.task, job.id);
195
- const result = await this.runAgentFn(this.runArgs(job, signal));
257
+ const result = await this.runAgentFn(this.runArgs(job, signal, usage));
196
258
  job.iterations = result.iterations;
197
259
  job.usage = result.usage;
198
260
  job.summary = lastAssistantText(result.messages);
@@ -213,8 +275,31 @@ export class JobManager {
213
275
  job.error = `${err.code}: ${err.title}`;
214
276
  }
215
277
  else {
278
+ // THE WEIGHTED POOL REFUSED THIS JOB (429 `budget_exhausted`), and it is
279
+ // not a per-job outcome (cli#245). Two things used to be lost here, and
280
+ // they are the same two the fan-out seam recovered in cli#243.
281
+ //
282
+ // First the FACTS: `messageOf` flattened the typed error to a string, so
283
+ // `window`, `resetAt` and `miraAvailable` went with it — the last of
284
+ // which is the only one that unblocks someone now rather than telling
285
+ // them when to come back — and a job that died on a 429 read like any
286
+ // other failure in `/jobs`.
287
+ //
288
+ // Second the AUDIENCE. A pool denial is a statement about a denominator
289
+ // this job shares with the foreground turn, so the foreground is
290
+ // entitled to it: without the latch the job dies quietly off-screen and
291
+ // the user walks into their own refusal on the next turn, having been
292
+ // told nothing. It is the same "N denials for one fact" argument #243
293
+ // made across siblings, carried across the job/foreground boundary.
294
+ const denial = poolDenial(err);
216
295
  job.status = "failed";
217
- job.error = `${ErrorCode.SubagentFailed}: ${messageOf(err) ?? "unknown error"}`;
296
+ job.error = denial
297
+ ? denialLine(denial)
298
+ : `${ErrorCode.SubagentFailed}: ${messageOf(err) ?? "unknown error"}`;
299
+ if (denial) {
300
+ this.poolDenied = denial;
301
+ this.abortSiblings(job, denial);
302
+ }
218
303
  }
219
304
  }
220
305
  finally {
@@ -222,21 +307,99 @@ export class JobManager {
222
307
  this.deps.semaphore.release();
223
308
  job.holdsSlot = false;
224
309
  }
310
+ // JOB SPEND IS SESSION SPEND (cli#245), and in the `finally` because the
311
+ // tokens are spent on every path out of here — a job that failed, was
312
+ // cancelled at session exit, or died on a 429 still drew whatever it drew
313
+ // before it stopped. Without this the admission check above reads a
314
+ // numerator its own dispatches never move, and a job running across an
315
+ // idle period stays invisible to `/budget` for the rest of the session.
316
+ //
317
+ // AND NOW ALSO WRITTEN TO THE USAGE STORE (cli#244 answered the question
318
+ // this call site used to defer). One record, handed to both sinks, so
319
+ // `/budget` and `/usage` can never disagree about what this job drew.
320
+ //
321
+ // `job.id` is the run id — stable, already unique, and the thing the user
322
+ // saw in `/jobs`, so a record in the store can be traced back to the job
323
+ // that made it rather than to an anonymous UUID.
324
+ //
325
+ // The session id is read HERE rather than at construction: see the note on
326
+ // `sessionId`. Without it the record persists and then hides from every
327
+ // session-scoped read of the very store it is in.
328
+ const record = usage.toRecord(job.id, this.deps.sessionId?.(), startedAt);
329
+ this.deps.budget?.record(record);
330
+ // Empty records are not published. A job that was cancelled before it
331
+ // reached the model spent nothing, and a run of no requests is not a run —
332
+ // it would only pad `runCount` and shorten retention for nothing.
333
+ if (record.entries.length > 0)
334
+ this.deps.onRunUsage?.(record);
225
335
  this.log(job, "out", `job ${job.status}`);
336
+ // THE TERMINAL RECORD, and the entire reconciliation rule for a persisted
337
+ // log (#172 item 1): a job is `interrupted` if and only if its file has
338
+ // none. This `finally` runs on every path a job can actually terminate
339
+ // on — completed, failed, cancelled, refused by the pool — so its
340
+ // presence means "the job ended" and its absence means "the process died
341
+ // mid-job", with no marker to maintain and nothing to reconcile at
342
+ // startup. See `readJobLog` for why neither a pid liveness marker nor a
343
+ // startup sweep can carry that weight instead.
344
+ job.writer?.end(job.status, job.iterations, job.error);
345
+ }
346
+ }
347
+ /**
348
+ * The pool denial a job died on, handed over ONCE (cli#245).
349
+ *
350
+ * A latch rather than a push: the foreground drains it where it is idle —
351
+ * between turns, next to the pending-approval drain — so a background failure
352
+ * never writes into a live region a turn is painting, and never lands
353
+ * mid-stream in a shell that is reading a line.
354
+ *
355
+ * The newest denial wins. Two jobs refused by one exhausted window are one
356
+ * fact, and printing it twice is the flood #243 aborted a batch to avoid.
357
+ */
358
+ takePoolDenial() {
359
+ const denial = this.poolDenied;
360
+ this.poolDenied = undefined;
361
+ return denial;
362
+ }
363
+ /**
364
+ * ONE POOL DENIAL STOPS EVERY OTHER LIVE JOB (cli#245) — the batch abort #243
365
+ * made across siblings, carried to the job seam, because the argument is
366
+ * identical and the denominator is literally the same one.
367
+ *
368
+ * A 429 is not a fact about the job that received it. It is a fact about a
369
+ * window every live job draws on, so the other jobs are not in a different
370
+ * situation — they are in the same one, a few seconds behind. Left running,
371
+ * each spends its way to its own refusal: three concurrent jobs discovering
372
+ * one exhausted window produce three failed jobs, three wasted partial runs,
373
+ * and three denials for a single fact. Aborting makes the FIRST denial the
374
+ * session's answer, which is what {@link takePoolDenial} then reports once.
375
+ *
376
+ * `abort` and not a status write, so a job stops at its next TURN boundary
377
+ * exactly as `cruxy cancel` stops it: the in-flight request completes,
378
+ * whatever checkpoint it took survives for rollback, and its slot is released
379
+ * through the same `finally`. Aborted jobs land on the `signal.aborted` branch
380
+ * above and end `cancelled` — honest, and distinct from the one job that
381
+ * actually was refused, which ends `failed` carrying the denial. They do not
382
+ * re-latch: the one denial stays the one denial.
383
+ *
384
+ * Queued jobs are aborted too. A job still waiting for a slot has spent
385
+ * nothing yet, and dispatching it into a window that just refused its sibling
386
+ * is the clearest waste of the set.
387
+ */
388
+ abortSiblings(source, denial) {
389
+ for (const job of this.jobs.values()) {
390
+ if (job === source || isTerminal(job.status))
391
+ continue;
392
+ this.log(job, "out", `cancelling: ${denial.code} — job ${source.id} was refused by the weighted pool`);
393
+ this.abort(job);
226
394
  }
227
395
  }
228
396
  /** Build the {@link runAgent} args for a job: scoped registry, budget, ctx. */
229
- runArgs(job, signal) {
397
+ runArgs(job, signal, usage) {
230
398
  const { deps } = this;
231
399
  const registry = scopeRegistry(deps.parentRegistry, job.spec.tools);
232
- const budget = new Budget(resolveBudget(deps.config.subagent.defaultBudget, {
233
- ...(job.spec.budget?.maxIterations !== undefined
234
- ? { maxIterations: job.spec.budget.maxIterations }
235
- : {}),
236
- ...(job.spec.budget?.maxTokens !== undefined
237
- ? { maxTokens: job.spec.budget.maxTokens }
238
- : {}),
239
- }));
400
+ // The caps admission settled at dispatch — resolved there, not here, so a
401
+ // ceiling the budget cut is the ceiling the run actually executes under.
402
+ const budget = new Budget(job.limits);
240
403
  const ctx = {
241
404
  cwd: job.scope.cwd,
242
405
  workspace: job.scope.workspace,
@@ -262,6 +425,7 @@ export class JobManager {
262
425
  router: deps.router,
263
426
  taskClass: "subagent",
264
427
  signal,
428
+ onRequestUsage: (req) => usage.record(req),
265
429
  };
266
430
  }
267
431
  /**
@@ -277,10 +441,18 @@ export class JobManager {
277
441
  makeJobApproval(job) {
278
442
  const { approvalQueue, semaphore, approvalMutex, foregroundInteractive } = this.deps;
279
443
  const cwd = job.scope.cwd;
444
+ // THE session allowlist a job's grants actually accumulate in (cli#251).
445
+ // A job has no factory-built allowlist to share — a child gets the parent's
446
+ // MODE but not its grants (P5 track 3) — so "allow for this session" taken
447
+ // inside a job lands here and is spent by this policy alone, for this job's
448
+ // lifetime. The service used to build this line for us when no policy was
449
+ // passed, which meant the single call site where the allowlist is
450
+ // load-bearing looked identical to the ones where it is inert. Written out
451
+ // so the ownership is readable here rather than inferred from an absence.
280
452
  const interactive = new ApprovalService({
281
453
  cwd,
282
454
  interactive: foregroundInteractive,
283
- io: this.deps.promptIO,
455
+ policy: new InteractivePolicy(new SessionAllowlist(), this.deps.promptIO),
284
456
  });
285
457
  // The foreground-serviced decision: the interactive U.3 prompt, wrapped in
286
458
  // THIS job's checkpoint hook, serialized on the SHARED approval mutex — so a
@@ -321,6 +493,83 @@ export class JobManager {
321
493
  }
322
494
  };
323
495
  }
496
+ /**
497
+ * Ask the session budget whether this job fits, and return the caps it may run
498
+ * under (P10 track 3 / cli#245).
499
+ *
500
+ * THE VERDICT HAS ONLY TWO SHAPES HERE, because a job is dispatched ONE at a
501
+ * time. `spawnMany` can be told "three of your five fit" and hands the parent
502
+ * a positional `not-admitted` result for each of the two that did not; a
503
+ * single dispatch has no such slot and needs none — `admit` with `count: 1`
504
+ * either allows, or narrows to that one run with its token ceiling cut to
505
+ * what the allowance covers, or refuses outright — and a narrowed ceiling
506
+ * below the floor (see {@link MIN_JOB_REQUESTS}) collapses into that last
507
+ * one, because a job cannot use it.
508
+ *
509
+ * The refusal is a THROW, so it lands on `dispatch`'s existing coded-error
510
+ * path and reaches the model through the tool: the same treatment as
511
+ * `jobsDisabled` / `jobLimitExceeded` / an unknown root.
512
+ *
513
+ * Tier: a job routes on the `subagent` task class, exactly as a child does. An
514
+ * unresolved tier still draws on the pool, so it is weighed at the worst case
515
+ * rather than skipped; no router at all means a bring-your-own provider whose
516
+ * tokens never touch the weighted pool, and nothing here bounds them.
517
+ */
518
+ admit(spec) {
519
+ const { budget, config, logger } = this.deps;
520
+ const resolved = resolveBudget(config.subagent.defaultBudget, {
521
+ ...(spec.budget?.maxIterations !== undefined
522
+ ? { maxIterations: spec.budget.maxIterations }
523
+ : {}),
524
+ ...(spec.budget?.maxTokens !== undefined
525
+ ? { maxTokens: spec.budget.maxTokens }
526
+ : {}),
527
+ });
528
+ if (!budget)
529
+ return resolved;
530
+ const verdict = budget.admit({
531
+ count: 1,
532
+ perRunTokens: resolved.maxTokens,
533
+ tier: this.jobTier(),
534
+ });
535
+ if (verdict.kind === "refused")
536
+ throw sessionBudgetExhausted(verdict.reason);
537
+ if (verdict.kind === "allow" || verdict.maxTokens <= 0)
538
+ return resolved;
539
+ // A NARROWED CEILING BELOW THE FLOOR IS A REFUSAL (cli#245). `admit` narrows
540
+ // rather than refuses so that a user with real headroom is never left unable
541
+ // to ask anything at all, and for the turn that is exactly right: the user is
542
+ // present, sees the warning, and can shorten the question or raise
543
+ // `/budget`. A job has none of that. It runs after the turn returned, with
544
+ // nobody watching, on a task the model already wrote — so the same narrowing
545
+ // that keeps a turn useful hands a job a ceiling it cannot start under, and
546
+ // the honest answer is to say so at the seam where the model can still act
547
+ // on it rather than to dispatch a run that fails a second later.
548
+ //
549
+ // Refused HERE and not in the shared rule on purpose: `admit` is one object
550
+ // answering three callers, and only this one is unattended.
551
+ const floor = this.deps.config.context.reserveTokens * MIN_JOB_REQUESTS;
552
+ if (verdict.maxTokens < floor) {
553
+ throw sessionBudgetExhausted(`${verdict.reason}; a background job needs at least ` +
554
+ `${compactTokens(floor)} tokens to complete one act-and-report cycle, ` +
555
+ `so it was refused rather than dispatched to fail on its first turn`);
556
+ }
557
+ // Narrowed: the one run is admitted with a cut ceiling. `Math.min` because
558
+ // `admit` may only ever narrow — a granted figure above the resolved cap
559
+ // would raise a ceiling the config and the spec already set.
560
+ logger.warn(verdict.reason);
561
+ return {
562
+ ...resolved,
563
+ maxTokens: Math.min(resolved.maxTokens, verdict.maxTokens),
564
+ };
565
+ }
566
+ /** The tier a job would route to, or the unresolved/no-pool markers. */
567
+ jobTier() {
568
+ const router = this.deps.router;
569
+ if (!router)
570
+ return undefined; // not a cruxy request — no weighted pool
571
+ return resolveTaskModel(router, "subagent").tier ?? UNRESOLVED_TIER;
572
+ }
324
573
  /** Resolve a job's scope from an optional root name (fail-loud on unknown). */
325
574
  jobScope(rootName) {
326
575
  if (rootName === undefined) {
@@ -354,8 +603,65 @@ export class JobManager {
354
603
  throw jobNotFound(id);
355
604
  return job;
356
605
  }
606
+ /**
607
+ * THE ONE SINK every job log line goes through — the in-memory ring buffer
608
+ * for the live readers (`/logs`, the Tasks view), and the file for afterwards
609
+ * (#172 item 1).
610
+ *
611
+ * Both, on the same call, because the file is written by STREAMING and not by
612
+ * serialising the buffer on the way out. A flush-at-exit design would live in
613
+ * `execute`'s `finally`, and that `finally` is exactly what does not run when
614
+ * the process is felled: `tui/restore.ts` and `utils/child-tree.ts` both
615
+ * install SIGINT/SIGTERM/SIGHUP handlers that call `process.exit` directly,
616
+ * so a closed terminal window, a dropped ssh session or a plain `kill`
617
+ * abandons every pending `finally` in the process. A log that only persists
618
+ * on the orderly path persists the runs nobody needed persisted and loses
619
+ * every one they did.
620
+ *
621
+ * Streaming also happens to be the only version that delivers what the issue
622
+ * asked for: writing the BUFFER at exit would persist its truncated tail, so
623
+ * the drop-on-overflow this was meant to remove would simply move to disk.
624
+ */
357
625
  log(job, stream, text) {
358
- job.logs.append({ atMs: this.now(), stream, text });
626
+ const line = { atMs: this.now(), stream, text };
627
+ job.logs.append(line);
628
+ job.writer?.line(line);
629
+ }
630
+ /**
631
+ * Build a job's on-disk log, or decline to.
632
+ *
633
+ * DECLINES WHEN `sessions.enabled` IS FALSE, and this is load-bearing rather
634
+ * than tidy. The sweep that bounds this subtree
635
+ * ({@link ../session/prune.js sweepOrphanedJobLogs}) is reached only through
636
+ * `pruneSessions`, which returns early on that same flag — so a writer that
637
+ * ignored it would produce files nothing ever collects, which is precisely
638
+ * the second unbounded writer under `~/.cruxy` that #257 was filed to
639
+ * prevent. "Do not record my conversations to disk" also plainly covers what
640
+ * the agent said while working in the background.
641
+ *
642
+ * DECLINES WITH NO SESSION ID for the same ownership reason: a job log is
643
+ * content owned by its session, keyed on the session, and one that cannot
644
+ * name its owner can be neither swept with it nor listed beside it. The id is
645
+ * read lazily (see {@link JobManagerDeps.sessionId}) and is present by the
646
+ * time any job is dispatched — a dispatch happens inside a turn, and the
647
+ * session exists before its first turn. Returning undefined is the honest
648
+ * answer to the case that should not arise, rather than a fabricated key.
649
+ */
650
+ makeWriter(id, label) {
651
+ const { deps } = this;
652
+ if (!deps.config.sessions.enabled)
653
+ return undefined;
654
+ const sessionId = deps.sessionId?.();
655
+ if (sessionId === undefined)
656
+ return undefined;
657
+ return new JobLogWriter({
658
+ cwd: deps.cwd,
659
+ sessionId,
660
+ jobId: id,
661
+ label,
662
+ cap: deps.config.jobs.logFileLines,
663
+ logger: deps.logger,
664
+ });
359
665
  }
360
666
  view(job) {
361
667
  const pending = this.deps.approvalQueue.pendingFor(job.id);
@@ -370,6 +676,20 @@ export class JobManager {
370
676
  };
371
677
  }
372
678
  }
679
+ /**
680
+ * A job's `error` line for a pool denial, with every fact the 429 carried.
681
+ *
682
+ * `job.error` is a string and a `JobView` is what `/jobs` and the Tasks view
683
+ * read, so the typed error cannot be handed over whole here — but its next
684
+ * steps are exactly where `miraAvailable` and `resetAt` were rendered into
685
+ * words, and dropping them is what made a denial indistinguishable from a
686
+ * crash. The typed object itself still survives, on the latch the foreground
687
+ * drains (see {@link JobManager.takePoolDenial}).
688
+ */
689
+ function denialLine(denial) {
690
+ const steps = denial.nextSteps.join("; ");
691
+ return `${denial.code}: ${denial.title}${steps ? ` — ${steps}` : ""}`;
692
+ }
373
693
  /** One-line task excerpt for a job label. */
374
694
  function taskLabel(task) {
375
695
  const flat = task.replace(/\s+/g, " ").trim();
@@ -10,6 +10,22 @@ import { APP_NAME, APP_VERSION } from "../constants.js";
10
10
  */
11
11
  /** The MCP protocol revision cruxy advertises. */
12
12
  const PROTOCOL_VERSION = "2025-06-18";
13
+ /**
14
+ * The three fields cruxy accepts from an advertised tool — and, by omission, the
15
+ * enforcement point for everything it refuses.
16
+ *
17
+ * `annotations` IS DELIBERATELY ABSENT. MCP lets a server describe its own tool
18
+ * with `readOnlyHint` / `destructiveHint` / `idempotentHint`, and zod strips
19
+ * unknown keys, so declaring only these three drops the block here at the wire
20
+ * boundary rather than carrying it inward for someone downstream to be tempted
21
+ * by. The spec's own rule is that clients "MUST consider tool annotations to be
22
+ * untrusted unless they come from trusted servers", and cruxy's MCP trust binds
23
+ * the INVOCATION rather than the code (`mcp/trust.ts`), so it cannot claim that
24
+ * exemption. Adding a field here would not merely widen a parse — it would put a
25
+ * server-controlled boolean within reach of the approval gate. The full argument
26
+ * is at `mcpRequest` in `approval/classify.ts`; `annotations-untrusted.test.ts`
27
+ * pins the property at both layers.
28
+ */
13
29
  const RawToolSchema = z.object({
14
30
  name: z.string().min(1),
15
31
  description: z.string().optional(),