@aragon-agent/cli 0.6.3 → 0.6.4

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 (94) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +95 -2
  3. package/dist/agent/controller.d.ts +119 -0
  4. package/dist/agent/controller.js +275 -1
  5. package/dist/agent/reducer.d.ts +69 -0
  6. package/dist/agent/reducer.js +100 -0
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/cli.js +84 -22
  10. package/dist/commands/builtins.js +92 -1
  11. package/dist/compaction/child.js +5 -0
  12. package/dist/compaction/command.js +14 -1
  13. package/dist/compaction/compactor.d.ts +34 -0
  14. package/dist/compaction/compactor.js +158 -37
  15. package/dist/compaction/types.d.ts +36 -0
  16. package/dist/compaction/wiring.d.ts +9 -0
  17. package/dist/compaction/wiring.js +22 -1
  18. package/dist/config/env.d.ts +1 -1
  19. package/dist/config/env.js +48 -1
  20. package/dist/config/load.d.ts +11 -0
  21. package/dist/config/load.js +39 -1
  22. package/dist/config/schema.d.ts +85 -0
  23. package/dist/config/schema.js +33 -0
  24. package/dist/config/store.js +21 -1
  25. package/dist/input/limits.d.ts +72 -0
  26. package/dist/input/limits.js +63 -0
  27. package/dist/input/paste-parse.d.ts +71 -0
  28. package/dist/input/paste-parse.js +95 -0
  29. package/dist/input/stdin-filter.d.ts +81 -0
  30. package/dist/input/stdin-filter.js +379 -0
  31. package/dist/logging/install.d.ts +2 -0
  32. package/dist/logging/install.js +53 -1
  33. package/dist/proc/classify.d.ts +52 -0
  34. package/dist/proc/classify.js +143 -0
  35. package/dist/proc/kill-tree.d.ts +42 -0
  36. package/dist/proc/kill-tree.js +119 -0
  37. package/dist/proc/limits.d.ts +84 -0
  38. package/dist/proc/limits.js +84 -0
  39. package/dist/proc/log-ring.d.ts +68 -0
  40. package/dist/proc/log-ring.js +102 -0
  41. package/dist/proc/prompt.d.ts +24 -0
  42. package/dist/proc/prompt.js +49 -0
  43. package/dist/proc/readiness.d.ts +102 -0
  44. package/dist/proc/readiness.js +219 -0
  45. package/dist/proc/supervisor.d.ts +103 -0
  46. package/dist/proc/supervisor.js +465 -0
  47. package/dist/proc/types.d.ts +175 -0
  48. package/dist/proc/types.js +15 -0
  49. package/dist/session/persist.js +36 -1
  50. package/dist/tools/bash-tool.d.ts +51 -2
  51. package/dist/tools/bash-tool.js +321 -51
  52. package/dist/tools/fs-tools.d.ts +17 -0
  53. package/dist/tools/index.d.ts +55 -2
  54. package/dist/tools/index.js +39 -1
  55. package/dist/tools/proc-tools.d.ts +25 -0
  56. package/dist/tools/proc-tools.js +102 -0
  57. package/dist/ui/App.d.ts +15 -2
  58. package/dist/ui/App.js +256 -7
  59. package/dist/ui/Composer.d.ts +54 -1
  60. package/dist/ui/Composer.js +50 -6
  61. package/dist/ui/PromptInput.d.ts +49 -3
  62. package/dist/ui/PromptInput.js +184 -52
  63. package/dist/ui/StatusBar.d.ts +19 -0
  64. package/dist/ui/StatusBar.js +5 -2
  65. package/dist/ui/Transcript.js +29 -1
  66. package/dist/ui/composer-limits.d.ts +31 -0
  67. package/dist/ui/composer-limits.js +50 -0
  68. package/dist/ui/composer-rows.d.ts +73 -0
  69. package/dist/ui/composer-rows.js +201 -0
  70. package/dist/ui/editor-reducer.d.ts +37 -0
  71. package/dist/ui/editor-reducer.js +88 -12
  72. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  73. package/dist/ui/entries/ServiceCard.js +116 -0
  74. package/dist/ui/entries/UserEntry.d.ts +17 -3
  75. package/dist/ui/entries/UserEntry.js +34 -5
  76. package/dist/ui/layout/budget.d.ts +34 -6
  77. package/dist/ui/layout/budget.js +32 -7
  78. package/dist/ui/layout/virtual-window.js +51 -1
  79. package/dist/ui/overlays/HelpOverlay.js +3 -0
  80. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  81. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  82. package/dist/ui/overlays/SettingsScreen.js +14 -1
  83. package/dist/ui/paste-frames.d.ts +87 -0
  84. package/dist/ui/paste-frames.js +112 -0
  85. package/dist/ui/paste-tokens.d.ts +90 -0
  86. package/dist/ui/paste-tokens.js +147 -0
  87. package/dist/ui/screen.d.ts +16 -0
  88. package/dist/ui/screen.js +45 -2
  89. package/dist/ui/selection/selection-controller.d.ts +1 -1
  90. package/dist/ui/transcript-text.js +49 -0
  91. package/dist/ui/use-wheel-routing.d.ts +1 -1
  92. package/package.json +2 -2
  93. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  94. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -26,6 +26,15 @@ export class Compactor {
26
26
  lastCompactionTurn = -Infinity;
27
27
  // --- Per-session state ---
28
28
  compactions = 0;
29
+ /**
30
+ * How many `compact()` calls returned WITHOUT announcing (quiet-noop D-9).
31
+ *
32
+ * A SESSION TOTAL, LIKE `compactions`, and reset by neither `onRunStart()` nor
33
+ * `clearSelfDisable()`: `/compact on` overrides a guard, it does not rewrite
34
+ * history. `/compact status` is its only reader, and it is the whole of what
35
+ * keeps a decline reportable once the transcript stops carrying one.
36
+ */
37
+ declined = 0;
29
38
  tokensReclaimed = 0;
30
39
  sessionUsage = { inputTokens: 0, outputTokens: 0 };
31
40
  pricingUnknown = false;
@@ -218,22 +227,19 @@ export class Compactor {
218
227
  const uiTrigger = manual ? 'manual' : ctx.trigger;
219
228
  const config = this.deps.getConfig().compaction;
220
229
  const summarizer = this.resolveSummarizer();
230
+ // GUARDS 1 AND 2 COUNT *ATTEMPTS*, NOT ANNOUNCEMENTS, and they stay here
231
+ // (quiet-noop §3.3). A decline still consumed a checkpoint: it still has to
232
+ // serve the cooldown and it still has to be charged to the per-run cap, or a
233
+ // history that can never be compacted would re-attempt on every turn for the
234
+ // whole run. `inFlight` stays for the same reason - the chip reports what the
235
+ // compactor is DOING, not what it has decided to say.
221
236
  this.compactionsThisRun += 1;
222
237
  this.lastCompactionTurn = ctx.turnIndex;
223
- this.compactions += 1;
224
238
  this.inFlight = true;
225
- const index = this.compactions;
226
- this.deps.emit({
227
- type: 'compaction_start',
228
- index,
229
- trigger: uiTrigger,
230
- model: summarizer?.ref.modelId ?? '',
231
- });
232
239
  const started = Date.now();
233
240
  try {
234
241
  return await this.runCompaction({
235
242
  ctx,
236
- index,
237
243
  uiTrigger,
238
244
  config,
239
245
  summarizer,
@@ -250,8 +256,56 @@ export class Compactor {
250
256
  // =========================================================================
251
257
  // Internals
252
258
  // =========================================================================
259
+ /**
260
+ * Open the card (quiet-noop §3.2).
261
+ *
262
+ * THE ONLY EMITTER OF `compaction_start`, AND THE ONLY PLACE `this.compactions`
263
+ * ADVANCES. A compaction that never reaches an announce point is a DECLINE: it
264
+ * changed nothing, called nothing and spent nothing, and it is deliberately
265
+ * indistinguishable - on every human surface except `/compact status`'s
266
+ * `declined` count (D-9) - from a turn at which `shouldCompact` returned false.
267
+ * `finish` is the other half of that rule.
268
+ *
269
+ * IT RETURNS THE ANNOUNCEMENT RATHER THAN STORING IT (P1-1), and the caller
270
+ * keeps it in a LOCAL for the lifetime of one `runCompaction` call - the same
271
+ * slot `index` occupied before this change. An instance field would have needed
272
+ * a no-re-entrancy argument, and the obvious one is WRONG: guard 1 lives in
273
+ * `shouldCompact`, while the idle `/compact` path calls `compact()` straight
274
+ * through `wiring.compactNow()` without consulting it. What actually keeps two
275
+ * compactions apart is that `command.ts` QUEUES instead of calling `compactNow`
276
+ * while the loop runs - a property of a different file, which is exactly why
277
+ * this one must not depend on it. A clobbered field would leave `finish` unable
278
+ * to settle the card it opened, and a card that never settles pins
279
+ * `Transcript`'s monotonic boundary for the rest of the session.
280
+ */
281
+ announce(trigger, summarizer) {
282
+ this.compactions += 1;
283
+ this.deps.emit({
284
+ type: 'compaction_start',
285
+ index: this.compactions,
286
+ trigger,
287
+ model: summarizer?.ref.modelId ?? '',
288
+ });
289
+ return { index: this.compactions };
290
+ }
253
291
  async runCompaction(args) {
254
292
  const { ctx, config, summarizer } = args;
293
+ // THE ANNOUNCEMENT IS A LOCAL, NEVER A FIELD (quiet-noop §3.2 / P1-1), and
294
+ // `??=` is what makes the eager path and the two commit points idempotent
295
+ // with respect to each other.
296
+ let announced = null;
297
+ const announce = (model) => {
298
+ announced ??= this.announce(args.uiTrigger, model);
299
+ };
300
+ // A REQUESTED compaction and a REACTIVE one announce BEFORE they know the
301
+ // answer, for two different reasons. `manual` is a user instruction, and its
302
+ // QUEUED form (`/compact` typed mid-run) has NO other reporting surface - a
303
+ // silent decline there would be a command that did nothing and said nothing.
304
+ // `overflow` runs after the provider has ALREADY refused the request; if it
305
+ // cannot recover, the card is the only place that says why the run is about
306
+ // to die. Both paths stay byte-identical to the round before this one.
307
+ if (args.uiTrigger !== 'pressure')
308
+ announce(summarizer);
255
309
  const before = ctx.messages;
256
310
  const protectedPrefix = countProtectedPrefix(before);
257
311
  // THE ONE LEVER THE GUARDS DO NOT HAVE (D-24). A tail that alone exceeds the
@@ -271,6 +325,12 @@ export class Compactor {
271
325
  // `plan === null` branch tries it before conceding.
272
326
  const reliefOnly = this.relieveInPlace(before, protectedPrefix, ctx.systemPrompt);
273
327
  if (reliefOnly) {
328
+ // COMMIT POINT A (quiet-noop §3.4). Relief changes the history the run
329
+ // continues from - a bounded, announced data loss - so it is announced
330
+ // even though no model was called. `null` summarizer for the same reason
331
+ // `finish` writes `model: ''` on a relief-only record: naming a model here
332
+ // would claim a call that never ran.
333
+ announce(null);
274
334
  this.checkProgress(ctx, args.uiTrigger, reliefOnly.messages);
275
335
  return this.finish({ ...args, tailRelief: reliefSummary(reliefOnly) }, {
276
336
  action: 'replace',
@@ -282,17 +342,51 @@ export class Compactor {
282
342
  mode: 'truncated',
283
343
  messages: reliefOnly.messages,
284
344
  reason: 'tail_relief_only',
285
- }, { droppedMessages: 0 }, keepRecentTurns);
345
+ }, { droppedMessages: 0 }, keepRecentTurns, announced);
286
346
  }
287
347
  this.noteNoProgress(args.uiTrigger, 'nothing_to_drop');
288
- return this.finish(args, { action: 'keep', reason: 'nothing_to_drop' }, null, keepRecentTurns);
348
+ return this.finish(args, { action: 'keep', reason: 'nothing_to_drop' }, null, keepRecentTurns, announced);
289
349
  }
290
350
  const anchor = buildAnchor(before);
291
351
  if (!anchor) {
292
352
  // A history with no `user` message at all cannot have a goal anchor, and
293
353
  // D-6 makes the anchor non-negotiable. Refusing is the honest outcome.
294
- return this.finish(args, { action: 'keep', reason: 'no_anchor' }, null, keepRecentTurns);
354
+ //
355
+ // STRUCTURALLY UNREACHABLE, AND KEPT ANYWAY (quiet-noop E-23 / §3.4). This
356
+ // is reached only after `plan !== null`, and `planCompaction` returns a plan
357
+ // only when some cut leaves `countTurns >= keep` behind it - `countTurns`
358
+ // counts `user`-role messages and `keep` is at least 1, so a plan PROVES the
359
+ // one thing `buildAnchor` looks for. A provably dead guard is worth strictly
360
+ // more than the non-null assertion that would replace it. It stays ABOVE the
361
+ // commit point on purpose: if the plan/anchor relationship ever changes, the
362
+ // reason that appears is one decided before any work began, and silence is
363
+ // what it should get - but any new PRE-COMMIT decline must then either
364
+ // charge guard 4 (as `nothing_to_drop` does) or be proven unreachable in
365
+ // turn, because guard 4 is what bounds how long a silent decline can repeat.
366
+ return this.finish(args, { action: 'keep', reason: 'no_anchor' }, null, keepRecentTurns, announced);
295
367
  }
368
+ // COMMIT POINT B (quiet-noop §3.4). Everything below this line either spends
369
+ // money (`runLadder`), rewrites the history, or faults - and all three are the
370
+ // user's business.
371
+ //
372
+ // BEFORE `buildDigest`, NOT AFTER. A throw inside the digest renderer reaches
373
+ // the engine as `manager_threw`, and the engine settles the card from the
374
+ // verdict alone; with no card open, that fault would be invisible on every
375
+ // human surface. Announcing first costs one event on a path that is about to
376
+ // spend seconds and dollars.
377
+ //
378
+ // WHAT IT STILL DOES NOT COVER (recorded, not closed - quiet-noop §12 / IF-2).
379
+ // A throw in the SYNCHRONOUS region ABOVE this line - `countProtectedPrefix`,
380
+ // `planCompaction`, `relieveInPlace`, `buildAnchor` - also reaches the engine
381
+ // as `manager_threw`, and on `pressure` that fault now has no card, charges no
382
+ // guard (`noteNoProgress` is never reached) and does not advance `declined`
383
+ // (`finish` is never called) - so a host bug there would repeat at every
384
+ // checkpoint for a whole run in silence. `manager_timeout` cannot land there:
385
+ // that region holds no `await`, which is the property AC-Q12 pins. Closing it
386
+ // means hoisting the announcement out of this function, which is precisely
387
+ // what P1-1 decided against - so it is written down rather than fixed under
388
+ // another change's name.
389
+ announce(summarizer);
296
390
  const head = before.slice(protectedPrefix, plan.cutIndex);
297
391
  const priorBlock = before.slice(0, protectedPrefix).find((m) => isPriorSummaryBlock(m));
298
392
  // The prior block is carried into the DIGEST rather than the tail, so the
@@ -312,14 +406,14 @@ export class Compactor {
312
406
  ...(args.instructions ? { instructions: args.instructions } : {}),
313
407
  });
314
408
  if (ladder.kind === 'aborted') {
315
- return this.finish(args, { action: 'keep', reason: 'aborted' }, null, keepRecentTurns);
409
+ return this.finish(args, { action: 'keep', reason: 'aborted' }, null, keepRecentTurns, announced);
316
410
  }
317
411
  if (ladder.kind === 'failed' && config.onFailure === 'stop') {
318
412
  // Rung 4. The run proceeds and will probably hit `context_overflow`, which
319
413
  // is what the user asked for by setting this.
320
414
  this.deps.notify('error', `Context compaction failed (${ladder.reason}) and compaction.onFailure is "stop". ` +
321
415
  'The next request may exceed the context window. Try /compact again, or /clear, or /reset.');
322
- return this.finish(args, { action: 'keep', reason: `summarize_failed: ${ladder.reason}` }, null, keepRecentTurns);
416
+ return this.finish(args, { action: 'keep', reason: `summarize_failed: ${ladder.reason}` }, null, keepRecentTurns, announced);
323
417
  }
324
418
  const truncated = ladder.kind === 'failed';
325
419
  const mode = truncated ? 'truncated' : 'summarized';
@@ -365,7 +459,7 @@ export class Compactor {
365
459
  messages,
366
460
  ...(truncated ? {} : { summary: ladder.text }),
367
461
  ...(truncated ? { reason: `truncated: ${ladder.reason}` } : {}),
368
- }, { droppedMessages: plan.droppedMessages, summary: truncated ? undefined : ladder.text }, keepRecentTurns);
462
+ }, { droppedMessages: plan.droppedMessages, summary: truncated ? undefined : ladder.text }, keepRecentTurns, announced);
369
463
  }
370
464
  /** Tail relief against this session's window (hardening §3.3.3 / W2). */
371
465
  relieveInPlace(messages, from, systemPrompt) {
@@ -522,19 +616,57 @@ export class Compactor {
522
616
  `${this.deps.getConfig().compaction.keepRecentTurns} turns alone exceed the threshold. ` +
523
617
  'Lower compaction.keepRecentTurns, or use /clear or /reset.');
524
618
  }
525
- finish(args, outcome, applied, keepRecentTurnsUsed) {
619
+ finish(args, outcome, applied, keepRecentTurnsUsed, announced) {
526
620
  const before = args.ctx.messages.length;
527
621
  const after = outcome.action === 'replace' ? outcome.messages.length : before;
528
622
  const tokensBefore = this.lastPressure?.occupied ?? 0;
623
+ // `'relieved'` IS A CLI-ONLY WORD (RV-5 / DH-20). The port reports
624
+ // `'truncated'` for a relief-only compaction, which is accurate for the
625
+ // engine's narrower question; the card's `summarized N messages with
626
+ // <model>` line would otherwise render "summarized 0 messages with
627
+ // claude-haiku-4-5" for a compaction in which no model was called.
628
+ const mode = reliefOnly(outcome)
629
+ ? 'relieved'
630
+ : outcome.action === 'replace'
631
+ ? outcome.mode
632
+ : 'none';
633
+ // NO MODEL ON A RELIEF-ONLY RECORD, because no model was called. The card
634
+ // names the summarizer from this field, and naming one here would be the
635
+ // same claim `mode: 'relieved'` exists to stop making.
636
+ const model = reliefOnly(outcome) ? '' : args.summarizer?.ref.modelId ?? '';
637
+ const durationMs = Date.now() - args.started;
638
+ // THE ON-DISK RECORD OF EVERY ATTEMPT IS UNCHANGED (quiet-noop §9). This
639
+ // feature makes the TRANSCRIPT quiet, not the logs: a declined compaction is
640
+ // still one `compaction` line here, still one `compaction_start` /
641
+ // `compaction_end` pair from CORE in the JSONL sink, and still reflected in
642
+ // `/compact status`.
643
+ this.log.info('compaction', {
644
+ trigger: args.uiTrigger,
645
+ announced: announced !== null,
646
+ before,
647
+ after,
648
+ droppedMessages: applied?.droppedMessages ?? 0,
649
+ mode,
650
+ model,
651
+ durationMs,
652
+ });
653
+ // A SILENT DECLINE (quiet-noop §3.1). No card was opened, so there is nothing
654
+ // to settle and nothing to say IN THE TRANSCRIPT. Returning here is what makes
655
+ // a declined checkpoint indistinguishable from one at which `shouldCompact`
656
+ // said no.
657
+ //
658
+ // `declined` IS THE ONE THING THAT STILL SPEAKS (D-9). Quiet is the goal;
659
+ // invisible is not, and `/compact status` is a published guaranteed surface
660
+ // (`context-auto-compaction` §6.5). One integer here is what keeps that
661
+ // promise true while the card goes away.
662
+ if (!announced) {
663
+ this.declined += 1;
664
+ return outcome;
665
+ }
529
666
  const record = {
530
- index: args.index,
667
+ index: announced.index,
531
668
  trigger: args.uiTrigger,
532
- // `'relieved'` IS A CLI-ONLY WORD (RV-5 / DH-20). The port reports
533
- // `'truncated'` for a relief-only compaction, which is accurate for the
534
- // engine's narrower question; the card's `summarized N messages with
535
- // <model>` line would otherwise render "summarized 0 messages with
536
- // claude-haiku-4-5" for a compaction in which no model was called.
537
- mode: reliefOnly(outcome) ? 'relieved' : outcome.action === 'replace' ? outcome.mode : 'none',
669
+ mode,
538
670
  // OPTIMISTIC AND CORRECTED BY THE WIRING (§3.2.1). Only the ENGINE knows
539
671
  // whether the splice passed the structural gate, and it says so on
540
672
  // `compaction_end`; the wiring rewrites this before the card settles.
@@ -546,23 +678,11 @@ export class Compactor {
546
678
  tokensBefore,
547
679
  tokensAfter: 0, // filled by the wiring from the engine's own estimator
548
680
  ...(applied?.summary ? { summary: applied.summary } : {}),
549
- // NO MODEL ON A RELIEF-ONLY RECORD, because no model was called. The card
550
- // names the summarizer from this field, and naming one here would be the
551
- // same claim `mode: 'relieved'` exists to stop making.
552
- model: reliefOnly(outcome) ? '' : args.summarizer?.ref.modelId ?? '',
553
- durationMs: Date.now() - args.started,
681
+ model,
682
+ durationMs,
554
683
  ...(args.ctx.trigger === 'overflow' ? { keepRecentTurnsUsed } : {}),
555
684
  ...(args.tailRelief ? { tailRelief: args.tailRelief } : {}),
556
685
  };
557
- this.log.info('compaction', {
558
- trigger: args.uiTrigger,
559
- before,
560
- after,
561
- droppedMessages: applied?.droppedMessages ?? 0,
562
- mode: record.mode,
563
- model: record.model,
564
- durationMs: record.durationMs,
565
- });
566
686
  this.deps.emit({ type: 'compaction_end', record });
567
687
  return outcome;
568
688
  }
@@ -605,6 +725,7 @@ export class Compactor {
605
725
  sessionTotals() {
606
726
  return {
607
727
  compactions: this.compactions,
728
+ declined: this.declined,
608
729
  tokensReclaimed: this.tokensReclaimed,
609
730
  usage: this.sessionUsage,
610
731
  pricingUnknown: this.pricingUnknown,
@@ -143,7 +143,26 @@ export interface CompactionSnapshot {
143
143
  live: boolean;
144
144
  /** The summarizer's model id, or `''`. */
145
145
  model: string;
146
+ /**
147
+ * How many compactions ANNOUNCED themselves (quiet-noop §6.2 / D-5).
148
+ *
149
+ * COMPACTIONS THAT RAN, NOT CHECKPOINTS THAT FIRED. A `pressure` compaction
150
+ * that declines before calling a model or changing the history never advances
151
+ * this, which is what keeps the card's `#N` contiguous. `declined` below is
152
+ * the other half of the same total.
153
+ */
146
154
  compactions: number;
155
+ /**
156
+ * How many `pressure` checkpoints declined BEFORE committing to any work
157
+ * (quiet-noop D-9 / P1-2).
158
+ *
159
+ * OPTIONAL SO EVERY EXISTING CONSTRUCTION SITE STAYS UNTOUCHED, and rendered
160
+ * by `/compact status` only when non-zero — a healthy session must not read
161
+ * `(0 checkpoints declined)`. This is the one human surface that still speaks
162
+ * about a decline after the transcript went quiet, which is what keeps
163
+ * `context-auto-compaction` §6.5's "guaranteed reporting surface" promise true.
164
+ */
165
+ declined?: number;
147
166
  /** Sum of `(tokensBefore - tokensAfter)` over applied compactions. */
148
167
  tokensReclaimed: number;
149
168
  /** What compaction itself has spent this session. */
@@ -161,6 +180,23 @@ export interface CompactionSnapshot {
161
180
  generation: number;
162
181
  pressure: Pressure;
163
182
  }
183
+ /**
184
+ * THE COMPACTION EVENT STREAM.
185
+ *
186
+ * `compaction_start` and `compaction_end` are emitted ONLY for compactions that
187
+ * COMMITTED TO WORK (quiet-noop §3.1): a `pressure` compaction that declines
188
+ * before calling a model or changing the history emits NEITHER, and is
189
+ * deliberately indistinguishable from a turn at which `shouldCompact` returned
190
+ * false. `manual` and `overflow` always emit both.
191
+ *
192
+ * The pair is still BALANCED: every `compaction_start` is followed by exactly
193
+ * one `compaction_end` (a `finally` in the engine guarantees the verdict, and
194
+ * `settlePending` synthesizes a skeleton record when the compactor's own has not
195
+ * arrived). Consumers that count PAIRS are unaffected; consumers that counted
196
+ * ATTEMPTS must read `/compact status` or the JSONL diagnostic log instead -
197
+ * CORE's own `compaction_start` / `compaction_end` pair still fires for every
198
+ * attempt, declined or not, and that is the stream the log subscribes to.
199
+ */
164
200
  export type CompactionEvent = {
165
201
  type: 'compaction_start';
166
202
  index: number;
@@ -25,6 +25,15 @@
25
25
  * methods delegate to a compactor that reads config LIVE - which is what lets
26
26
  * `/compact off`, `/compact threshold 0.8` and a settings-screen save take effect
27
27
  * mid-session without touching the `Agent`.
28
+ *
29
+ * DECLINED COMPACTIONS PRODUCE NO CLI EVENTS (quiet-noop §3.6). A `pressure`
30
+ * compaction that changed nothing, called nothing and spent nothing never emits
31
+ * `compaction_start`, so `onCompactorEvent` never opens a card and `settlePending`
32
+ * finds neither a held record nor a skeleton to synthesize. CORE still emits its
33
+ * own pair for every attempt, which is why `settlePending` still runs - it just
34
+ * refreshes the snapshot and returns. `onCompactorEvent`'s `if (!this.open)`
35
+ * guard is now unreachable for declines and stays for the abort race it was
36
+ * written for.
28
37
  */
29
38
  import { type AgentEvent, type ContextManager, type Message, type ModelInfo, type ModelRef, type ProviderRegistry, type ProviderRegistryOptions, type TokenUsage } from '@aragon-agent/core';
30
39
  import type { CliConfig } from '../config/schema.js';
@@ -25,6 +25,15 @@
25
25
  * methods delegate to a compactor that reads config LIVE - which is what lets
26
26
  * `/compact off`, `/compact threshold 0.8` and a settings-screen save take effect
27
27
  * mid-session without touching the `Agent`.
28
+ *
29
+ * DECLINED COMPACTIONS PRODUCE NO CLI EVENTS (quiet-noop §3.6). A `pressure`
30
+ * compaction that changed nothing, called nothing and spent nothing never emits
31
+ * `compaction_start`, so `onCompactorEvent` never opens a card and `settlePending`
32
+ * finds neither a held record nor a skeleton to synthesize. CORE still emits its
33
+ * own pair for every attempt, which is why `settlePending` still runs - it just
34
+ * refreshes the snapshot and returns. `onCompactorEvent`'s `if (!this.open)`
35
+ * guard is now unreachable for declines and stays for the abort race it was
36
+ * written for.
28
37
  */
29
38
  import { DEFAULT_RETRY_POLICY, estimatePromptTokens, initProviders, validateHistory, } from '@aragon-agent/core';
30
39
  import { getLogger } from '../logging/logger.js';
@@ -328,6 +337,10 @@ export class CompactionWiring {
328
337
  live: this.enabled && summarizer !== null,
329
338
  model: summarizer?.ref.modelId ?? '',
330
339
  compactions: totals.compactions,
340
+ // OMITTED WHEN ZERO (quiet-noop D-9), so a healthy session's snapshot is
341
+ // shaped exactly as it was before this feature and `/compact status` has
342
+ // nothing extra to render.
343
+ ...(totals.declined > 0 ? { declined: totals.declined } : {}),
331
344
  tokensReclaimed: totals.tokensReclaimed,
332
345
  usage: totals.usage,
333
346
  pricingUnknown: totals.pricingUnknown,
@@ -445,8 +458,16 @@ export class CompactionWiring {
445
458
  // compaction no matter which branch the verdict took - including the early
446
459
  // `return` below.
447
460
  const dropped = this.compactor.takeLastDropped();
448
- if (!record)
461
+ if (!record) {
462
+ // A SILENT DECLINE (quiet-noop §3.6). No card - but the gauge and the chip
463
+ // are still refreshed: `inFlight` has just gone false and `/compact status`
464
+ // reads this same snapshot, which is where the `declined` count surfaces.
465
+ // `snapshot()` uses the CACHED pressure (`compactor.lastMeasured()`), so
466
+ // this costs no re-measurement, and guard 4 caps the number of declines per
467
+ // session at `stuckLimit`.
468
+ this.emit({ type: 'snapshot', snapshot: this.snapshot() });
449
469
  return;
470
+ }
450
471
  const settled = {
451
472
  ...record,
452
473
  applied: verdict.applied,
@@ -7,7 +7,7 @@
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
8
  * ARAGON_SHOW_THINKING,
9
9
  * ARAGON_MAX_TOKENS (a number, or `auto` / `0` for the per-model ceiling),
10
- * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
10
+ * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PASTE, ARAGON_PLAN, ARAGON_TEAM,
11
11
  * ARAGON_TEAM_MAX, ARAGON_RETRY, ARAGON_RETRY_MAX, ARAGON_FAST,
12
12
  * ARAGON_FAST_PROVIDER, ARAGON_FAST_MODEL, ARAGON_FAST_BASE_URL,
13
13
  * ARAGON_LOG_LEVEL,
@@ -7,7 +7,7 @@
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
8
  * ARAGON_SHOW_THINKING,
9
9
  * ARAGON_MAX_TOKENS (a number, or `auto` / `0` for the per-model ceiling),
10
- * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
10
+ * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PASTE, ARAGON_PLAN, ARAGON_TEAM,
11
11
  * ARAGON_TEAM_MAX, ARAGON_RETRY, ARAGON_RETRY_MAX, ARAGON_FAST,
12
12
  * ARAGON_FAST_PROVIDER, ARAGON_FAST_MODEL, ARAGON_FAST_BASE_URL,
13
13
  * ARAGON_LOG_LEVEL,
@@ -141,6 +141,13 @@ export function readEnvConfig() {
141
141
  mouseSelect === 'on' ||
142
142
  mouseSelect === 'yes';
143
143
  }
144
+ // ARAGON_PASTE=0 turns paste recognition off, which is the documented one-env
145
+ // revert to v0.6.3 input handling (tui-paste-handling section 7.2). Parsed by
146
+ // the POSITIVE list, exactly as its two neighbours above are.
147
+ const paste = process.env.ARAGON_PASTE?.trim().toLowerCase();
148
+ if (paste !== undefined && paste.length > 0) {
149
+ partial.paste = paste === '1' || paste === 'true' || paste === 'on' || paste === 'yes';
150
+ }
144
151
  // ARAGON_SCROLL_RESUME_MS=<n> — idle delay before a paused viewport returns to
145
152
  // the newest line; `0` disables auto-resume. THE ONLY CHANNEL BESIDES THE
146
153
  // CONFIG FILE, because §6.1 gives this key no CLI flag on purpose. `load.ts`
@@ -239,6 +246,46 @@ export function readEnvConfig() {
239
246
  if (Object.keys(section).length > 0)
240
247
  partial.todo = section;
241
248
  }
249
+ // ARAGON_BASH_BACKGROUND=0 turns background services off entirely (no
250
+ // `background` param, no `bash_output`/`bash_kill`, no prompt block, no chip);
251
+ // ARAGON_BASH_AUTO_BACKGROUND=0 keeps them but stops the classifier firing on
252
+ // an unset `background`. Parsed by the POSITIVE list, exactly as
253
+ // ARAGON_FULLSCREEN / ARAGON_MOUSE / ARAGON_PLAN / ARAGON_TEAM / ARAGON_TODO
254
+ // are - NOT by `envBool` below, which uses a negative list.
255
+ //
256
+ // Both pass the test every env var in this file has to pass: an unattended run
257
+ // in a container with a fixed entrypoint can reach neither a flag nor a config
258
+ // file, and "do not leave a dev server running in my CI job" is exactly the
259
+ // kind of decision that channel needs to make.
260
+ //
261
+ // ONE ACCUMULATED SECTION, ASSIGNED ONCE - the shape the `team` and `todo`
262
+ // branches above already use, and NOT two `if` blocks each assigning
263
+ // `partial.bash` (the `todo` P1-4 trap). THE CAST IS WHAT WOULD MAKE THAT
264
+ // MISTAKE SILENT: a second assignment compiles clean and drops the first key,
265
+ // so `ARAGON_BASH_BACKGROUND=0 ARAGON_BASH_AUTO_BACKGROUND=0` would quietly
266
+ // re-register the tools the user turned off.
267
+ const bashBackground = process.env.ARAGON_BASH_BACKGROUND?.trim().toLowerCase();
268
+ const bashAuto = process.env.ARAGON_BASH_AUTO_BACKGROUND?.trim().toLowerCase();
269
+ if ((bashBackground !== undefined && bashBackground.length > 0) ||
270
+ (bashAuto !== undefined && bashAuto.length > 0)) {
271
+ const section = {};
272
+ if (bashBackground !== undefined && bashBackground.length > 0) {
273
+ section.background =
274
+ bashBackground === '1' ||
275
+ bashBackground === 'true' ||
276
+ bashBackground === 'on' ||
277
+ bashBackground === 'yes';
278
+ }
279
+ if (bashAuto !== undefined && bashAuto.length > 0) {
280
+ section.autoBackground =
281
+ bashAuto === '1' || bashAuto === 'true' || bashAuto === 'on' || bashAuto === 'yes';
282
+ }
283
+ // A PARTIAL section, safe only because every consumer deep-merges it - the
284
+ // same contract `partial.skills`, `partial.log`, `partial.team` and
285
+ // `partial.todo` rely on.
286
+ if (Object.keys(section).length > 0)
287
+ partial.bash = section;
288
+ }
242
289
  // ARAGON_RETRY=0 is the kill switch for API retry; ARAGON_RETRY_MAX=<n>
243
290
  // overrides the count. Parsed by the POSITIVE list, exactly as
244
291
  // ARAGON_FULLSCREEN / ARAGON_MOUSE / ARAGON_PLAN / ARAGON_TEAM / ARAGON_TODO
@@ -49,6 +49,17 @@ export interface CliFlags {
49
49
  * `false` on every run (the `--todo-panel` trap).
50
50
  */
51
51
  mouseSelect?: boolean;
52
+ /**
53
+ * `--paste` ⇒ true, `--no-paste` ⇒ false, absent ⇒ undefined
54
+ * (tui-paste-handling section 7.1).
55
+ *
56
+ * DECLARED AS A PAIR, and carried through `toFlags`, for the reason the two
57
+ * notes above record. `paste` is additionally PERSISTED and defaults to
58
+ * `true`, so a lone `--no-paste` would make commander default `opts.paste` to
59
+ * `true` -- indistinguishable from silence, and silently overriding a stored
60
+ * `false` on every run that passed no flag at all.
61
+ */
62
+ paste?: boolean;
52
63
  /** `--no-skills` ⇒ false. Turns the whole skill subsystem off (§7.5). */
53
64
  skills?: boolean;
54
65
  /** `--skill <name>` ×N — force Level 2 injection for this run. */
@@ -6,7 +6,7 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import process from 'node:process';
9
- import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampMaxTokens, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, parseThresholdInput, } from './schema.js';
9
+ import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampMaxTokens, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, parseThresholdInput, } from './schema.js';
10
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
11
11
  import { readConfigFile } from './store.js';
12
12
  import { getConfigPath } from './app-paths.js';
@@ -80,6 +80,24 @@ function resolveMouseSelect(flags, env, file) {
80
80
  return file.mouseSelect;
81
81
  return DEFAULT_CONFIG.mouseSelect;
82
82
  }
83
+ /**
84
+ * Paste handling: flag > env > file > default `true` (tui-paste-handling
85
+ * section 7.4).
86
+ *
87
+ * SHAPED EXACTLY LIKE `resolveMouse` above, for the reason its own note gives:
88
+ * reaching for the `hints` shape -- `flags.x !== undefined ? ... : file.x ??
89
+ * DEFAULT`, which reads NO ENV AT ALL -- would ship `ARAGON_PASTE` as
90
+ * documented-but-dead.
91
+ */
92
+ function resolvePaste(flags, env, file) {
93
+ if (flags.paste !== undefined)
94
+ return flags.paste;
95
+ if (env.paste !== undefined)
96
+ return env.paste;
97
+ if (file.paste !== undefined)
98
+ return file.paste;
99
+ return DEFAULT_CONFIG.paste;
100
+ }
83
101
  /**
84
102
  * Idle scroll-resume delay: env › file › default 5000, clamped to [0, 120000].
85
103
  *
@@ -153,6 +171,24 @@ function resolveTodoConfig(flags, env, file) {
153
171
  ...(flags.todoFollow !== undefined ? { followThrough: flags.todoFollow } : {}),
154
172
  });
155
173
  }
174
+ /**
175
+ * Merge the `bash` section across the three layers that supply it
176
+ * (defaults > file > env), background-service-supervision §5.4.
177
+ *
178
+ * THREE LAYERS, NOT FOUR, AND THAT IS NOT AN OVERSIGHT. There is deliberately no
179
+ * `--no-background` flag: the two switches this section carries are already
180
+ * reachable from `config.json` (a per-machine preference) and from the
181
+ * environment (the channel a container entrypoint has), which is the two-part
182
+ * test every knob in this file is held to. A third spelling would be a third
183
+ * place for the answer to differ.
184
+ */
185
+ function resolveBashConfig(env, file) {
186
+ return clampBashConfig({
187
+ ...DEFAULT_CONFIG.bash,
188
+ ...(file.bash ?? {}),
189
+ ...(env.bash ?? {}),
190
+ });
191
+ }
156
192
  /**
157
193
  * Merge the `retry` section across all four layers (defaults › file › env ›
158
194
  * flags), llm-api-retry-backoff §6.2.
@@ -520,6 +556,7 @@ export function loadConfig(flags = {}) {
520
556
  hints,
521
557
  mouse: resolveMouse(flags, env.partial, file),
522
558
  mouseSelect: resolveMouseSelect(flags, env.partial, file),
559
+ paste: resolvePaste(flags, env.partial, file),
523
560
  scrollResumeMs: resolveScrollResumeMs(env.partial, file),
524
561
  // From `<home>/state.json`, not the config file — see `ui-state.ts`.
525
562
  submitCount: readSubmitCount(),
@@ -545,6 +582,7 @@ export function loadConfig(flags = {}) {
545
582
  fast: resolveFastConfig(flags, env.partial, file),
546
583
  update: resolveUpdateConfig(flags, env.partial, file),
547
584
  compaction: resolveCompactionConfig(flags, env.partial, file),
585
+ bash: resolveBashConfig(env.partial, file),
548
586
  cwd,
549
587
  color,
550
588
  colorLevel,