@aragon-agent/cli 0.5.6 → 0.5.9

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 (156) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/README.md +1429 -1105
  3. package/dist/agent/coalesce.d.ts +13 -0
  4. package/dist/agent/coalesce.js +50 -11
  5. package/dist/agent/controller.d.ts +141 -2
  6. package/dist/agent/controller.js +396 -4
  7. package/dist/agent/entry-limits.d.ts +65 -0
  8. package/dist/agent/entry-limits.js +133 -0
  9. package/dist/agent/headless.d.ts +8 -0
  10. package/dist/agent/headless.js +40 -0
  11. package/dist/agent/reducer.d.ts +253 -3
  12. package/dist/agent/reducer.js +553 -28
  13. package/dist/agent/retry-view.d.ts +68 -0
  14. package/dist/agent/retry-view.js +95 -0
  15. package/dist/agent/system-prompt.d.ts +11 -0
  16. package/dist/agent/system-prompt.js +2 -0
  17. package/dist/cli.js +122 -4
  18. package/dist/commands/builtins.d.ts +1 -1
  19. package/dist/commands/builtins.js +313 -2
  20. package/dist/commands/perf.d.ts +45 -0
  21. package/dist/commands/perf.js +78 -0
  22. package/dist/config/cli-commands.d.ts +53 -2
  23. package/dist/config/cli-commands.js +136 -3
  24. package/dist/config/env.d.ts +6 -1
  25. package/dist/config/env.js +131 -1
  26. package/dist/config/load.d.ts +63 -0
  27. package/dist/config/load.js +125 -2
  28. package/dist/config/schema.d.ts +273 -10
  29. package/dist/config/schema.js +250 -7
  30. package/dist/config/store.js +20 -1
  31. package/dist/fast/critique.d.ts +43 -0
  32. package/dist/fast/critique.js +62 -0
  33. package/dist/fast/digest.d.ts +42 -0
  34. package/dist/fast/digest.js +85 -0
  35. package/dist/fast/frames.d.ts +99 -0
  36. package/dist/fast/frames.js +180 -0
  37. package/dist/fast/limits.d.ts +98 -0
  38. package/dist/fast/limits.js +98 -0
  39. package/dist/fast/prompt.d.ts +72 -0
  40. package/dist/fast/prompt.js +94 -0
  41. package/dist/fast/resolve.d.ts +43 -0
  42. package/dist/fast/resolve.js +105 -0
  43. package/dist/fast/review-call.d.ts +99 -0
  44. package/dist/fast/review-call.js +130 -0
  45. package/dist/fast/reviewer.d.ts +248 -0
  46. package/dist/fast/reviewer.js +664 -0
  47. package/dist/fast/types.d.ts +128 -0
  48. package/dist/fast/types.js +14 -0
  49. package/dist/fast/wiring.d.ts +170 -0
  50. package/dist/fast/wiring.js +316 -0
  51. package/dist/logging/install.d.ts +15 -1
  52. package/dist/logging/install.js +37 -4
  53. package/dist/logging/logger.d.ts +1 -1
  54. package/dist/session/persist.js +57 -1
  55. package/dist/team/normalize.d.ts +26 -1
  56. package/dist/team/normalize.js +23 -3
  57. package/dist/team/prompt.d.ts +11 -0
  58. package/dist/team/prompt.js +10 -0
  59. package/dist/team/report.d.ts +18 -0
  60. package/dist/team/report.js +27 -1
  61. package/dist/team/runtime.d.ts +13 -2
  62. package/dist/team/runtime.js +33 -6
  63. package/dist/team/subagent.d.ts +19 -1
  64. package/dist/team/subagent.js +72 -10
  65. package/dist/team/task-tool.d.ts +26 -0
  66. package/dist/team/task-tool.js +48 -4
  67. package/dist/team/types.d.ts +80 -1
  68. package/dist/tools/bash-tool.js +19 -3
  69. package/dist/tools/diff.d.ts +25 -5
  70. package/dist/tools/diff.js +72 -41
  71. package/dist/tools/file-change-store.d.ts +34 -0
  72. package/dist/tools/file-change-store.js +57 -0
  73. package/dist/tools/fs-tools.d.ts +26 -0
  74. package/dist/tools/fs-tools.js +62 -5
  75. package/dist/tools/index.d.ts +31 -0
  76. package/dist/tools/index.js +7 -1
  77. package/dist/tools/patch.d.ts +115 -0
  78. package/dist/tools/patch.js +453 -0
  79. package/dist/tools/terminal-output.d.ts +107 -0
  80. package/dist/tools/terminal-output.js +198 -0
  81. package/dist/tools/tool-output-store.d.ts +68 -0
  82. package/dist/tools/tool-output-store.js +102 -0
  83. package/dist/ui/ActivityLine.d.ts +48 -0
  84. package/dist/ui/ActivityLine.js +22 -0
  85. package/dist/ui/App.d.ts +1 -4
  86. package/dist/ui/App.js +357 -44
  87. package/dist/ui/BottomStatusRow.d.ts +43 -0
  88. package/dist/ui/BottomStatusRow.js +14 -0
  89. package/dist/ui/Composer.d.ts +1 -6
  90. package/dist/ui/Composer.js +2 -2
  91. package/dist/ui/Markdown.d.ts +7 -1
  92. package/dist/ui/Markdown.js +15 -48
  93. package/dist/ui/PromptInput.d.ts +1 -15
  94. package/dist/ui/PromptInput.js +1 -38
  95. package/dist/ui/StatusBar.d.ts +50 -0
  96. package/dist/ui/StatusBar.js +7 -3
  97. package/dist/ui/TeamPanel.d.ts +5 -4
  98. package/dist/ui/TeamPanel.js +1 -1
  99. package/dist/ui/TodoPanel.d.ts +5 -4
  100. package/dist/ui/Transcript.d.ts +80 -5
  101. package/dist/ui/Transcript.js +179 -11
  102. package/dist/ui/activity-phrases.d.ts +33 -0
  103. package/dist/ui/activity-phrases.js +50 -0
  104. package/dist/ui/entries/AssistantEntry.d.ts +38 -1
  105. package/dist/ui/entries/AssistantEntry.js +34 -3
  106. package/dist/ui/entries/DiffView.d.ts +67 -0
  107. package/dist/ui/entries/DiffView.js +103 -0
  108. package/dist/ui/entries/FastCard.d.ts +53 -0
  109. package/dist/ui/entries/FastCard.js +87 -0
  110. package/dist/ui/entries/RetryCard.d.ts +56 -0
  111. package/dist/ui/entries/RetryCard.js +60 -0
  112. package/dist/ui/entries/TeamCard.d.ts +9 -1
  113. package/dist/ui/entries/TeamCard.js +28 -2
  114. package/dist/ui/entries/TodoCard.d.ts +9 -1
  115. package/dist/ui/entries/TodoCard.js +23 -1
  116. package/dist/ui/entries/ToolCard.d.ts +50 -1
  117. package/dist/ui/entries/ToolCard.js +104 -7
  118. package/dist/ui/entries/ToolPreview.d.ts +2 -2
  119. package/dist/ui/entries/UserEntry.d.ts +9 -1
  120. package/dist/ui/entries/UserEntry.js +17 -1
  121. package/dist/ui/exit-snapshot.d.ts +7 -0
  122. package/dist/ui/glyphs.d.ts +9 -0
  123. package/dist/ui/glyphs.js +2 -0
  124. package/dist/ui/layout/AppShell.d.ts +13 -22
  125. package/dist/ui/layout/AppShell.js +3 -38
  126. package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
  127. package/dist/ui/layout/MeasuredEntry.js +30 -0
  128. package/dist/ui/layout/ScrollViewport.js +8 -2
  129. package/dist/ui/layout/viewport-geometry.d.ts +28 -0
  130. package/dist/ui/layout/viewport-geometry.js +27 -0
  131. package/dist/ui/layout/virtual-window.d.ts +100 -0
  132. package/dist/ui/layout/virtual-window.js +308 -0
  133. package/dist/ui/live-clamp.d.ts +42 -0
  134. package/dist/ui/live-clamp.js +74 -0
  135. package/dist/ui/markdown-blocks.d.ts +25 -1
  136. package/dist/ui/markdown-blocks.js +43 -0
  137. package/dist/ui/overlays/HelpOverlay.js +16 -2
  138. package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
  139. package/dist/ui/overlays/SettingsScreen.js +147 -1
  140. package/dist/ui/render-cache.d.ts +81 -0
  141. package/dist/ui/render-cache.js +195 -0
  142. package/dist/ui/render-governor.d.ts +54 -0
  143. package/dist/ui/render-governor.js +78 -0
  144. package/dist/ui/transcript-text.d.ts +11 -0
  145. package/dist/ui/transcript-text.js +32 -0
  146. package/dist/ui/use-height-store.d.ts +31 -0
  147. package/dist/ui/use-height-store.js +76 -0
  148. package/dist/ui/use-render-governor.d.ts +39 -0
  149. package/dist/ui/use-render-governor.js +57 -0
  150. package/dist/ui/use-startup-notices.d.ts +9 -1
  151. package/dist/ui/use-startup-notices.js +35 -1
  152. package/dist/ui/use-wheel-routing.d.ts +16 -32
  153. package/dist/ui/use-wheel-routing.js +21 -38
  154. package/package.json +2 -2
  155. package/dist/ui/layout/regions.d.ts +0 -55
  156. package/dist/ui/layout/regions.js +0 -52
@@ -1,20 +1,75 @@
1
1
  /**
2
2
  * Built-in slash commands (spec §5.2):
3
3
  * /help /model /settings /thinking /max-tokens /tools /clear /reset /cwd /save
4
- * /resume /copy /exit (/quit).
4
+ * /resume /copy /exit (/quit), plus the /team, /fast and /todo subsystems.
5
5
  */
6
6
  import { spawn } from 'node:child_process';
7
7
  import { existsSync, statSync } from 'node:fs';
8
8
  import { isAbsolute, resolve } from 'node:path';
9
9
  import process from 'node:process';
10
10
  import { resolveOutputTokens } from '@aragon-agent/core';
11
- import { clampThinkingLevel, clampTheme, isThemeName, parseMaxTokensInput, DEFAULT_MAX_TOKENS, LEGACY_DARK_THEME, THEME_NAMES, THINKING_LEVELS, } from '../config/schema.js';
11
+ import { clampThinkingLevel, clampTheme, isAdapterProvider, isThemeName, parseMaxTokensInput, ADAPTER_PROVIDERS, DEFAULT_MAX_TOKENS, HARD_MAX_RETRIES, LEGACY_DARK_THEME, THEME_NAMES, THINKING_LEVELS, } from '../config/schema.js';
12
+ import { computeCost, formatCost, formatTokens } from '../agent/usage.js';
12
13
  import { TODO_FOLLOW_LIMITS } from '../todo/limits.js';
13
14
  import { buildContinuationMessage } from '../todo/follow-through.js';
14
15
  import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
15
16
  import { loadConfig } from '../config/load.js';
16
17
  import { loadSession, resolveSessionPath, saveSession } from '../session/persist.js';
17
18
  import { getLogger } from '../logging/logger.js';
19
+ import { perfCommand } from './perf.js';
20
+ /**
21
+ * `/fast status` — THE GUARANTEED REPORTING SURFACE for the tier (§4.4 / R-8).
22
+ *
23
+ * The chip degrades on a narrow terminal and the card only appears when a review
24
+ * happens; this is the one place that always answers "is it on, what is it, and
25
+ * has it done anything". The session totals are here rather than on the status
26
+ * bar because the aggregate there cannot answer "is the cheap tier actually
27
+ * saving me anything?".
28
+ *
29
+ * `unknown (no price table for <model>)` RATHER THAN A CURRENCY AMOUNT when the
30
+ * static table has never seen the model (C-11 / RV-4 / AC-34): `$0.00` on a
31
+ * feature that is spending money is the same class of lie as pricing a Haiku
32
+ * child at Sonnet rates.
33
+ */
34
+ function formatFastStatus(controller) {
35
+ const status = controller.getFastStatus();
36
+ const cfg = controller.getFastConfig();
37
+ const state = !status.registered
38
+ ? 'off for this session (not configured at launch)'
39
+ : status.enabled
40
+ ? 'on'
41
+ : 'off';
42
+ const lines = [];
43
+ if (status.tier.ok) {
44
+ const same = status.tier.sameAsMain ? ' (same as main)' : '';
45
+ lines.push(`Fast tier: ${state}. Model: ${status.tier.ref.providerId}:${status.tier.ref.modelId}${same}.`);
46
+ }
47
+ else {
48
+ lines.push(`Fast tier: ${state}. Model: ${cfg.model || '(not set)'}.`);
49
+ }
50
+ lines.push(`Review: ${cfg.review ? `every ${cfg.reviewEveryTurns} turns` : 'off'}. ` +
51
+ `Delegation: ${cfg.delegate ? 'on' : 'off'}.`);
52
+ const snapshot = status.snapshot;
53
+ const cost = snapshot.pricingUnknown
54
+ ? `unknown (no price table for ${snapshot.model})`
55
+ : formatCost(computeCost(snapshot.usage, status.tier.ok ? controller.getModelInfoFor(status.tier.ref).cost : undefined));
56
+ // `n/N`, NEVER A BARE `n` (fast-model-tier-hardening §4.3). A bare count
57
+ // answers "what have I spent" only for a reader who already knows the
58
+ // ceiling, which is precisely what the user does not know; `12/40` answers
59
+ // that and "how much is left" in the same three characters, and makes the
60
+ // budget discoverable to someone who never read the config schema. The
61
+ // exhausted form names the remedy inline, because a status line that reports
62
+ // a stop without its escape hatch just relocates the question.
63
+ //
64
+ // `budgetReached` is READ, not re-derived (RV-H10): with the tier off both
65
+ // numbers are `0` placeholders and `reviews >= reviewBudget` would be true.
66
+ const budget = snapshot.budgetReached ? ' (budget reached - raise with /fast budget <n>)' : '';
67
+ lines.push(`This session: ${snapshot.reviews}/${snapshot.reviewBudget} reviews${budget}, ` +
68
+ `${snapshot.delegated} delegated subagents, ` +
69
+ `in ${formatTokens(snapshot.usage.inputTokens)} / out ` +
70
+ `${formatTokens(snapshot.usage.outputTokens)}, cost ${cost}.`);
71
+ return lines.join('\n');
72
+ }
18
73
  const COMMANDS = [
19
74
  {
20
75
  name: 'help',
@@ -222,6 +277,187 @@ const COMMANDS = [
222
277
  ctx.notify('warn', `Unknown argument "${verb}" - use /team [status|on|off|max <n>].`);
223
278
  },
224
279
  },
280
+ {
281
+ name: 'fast',
282
+ description: 'Fast model tier: status | on | off | model <id> | provider <id> | same | ' +
283
+ 'review <n|off> | budget <n> | delegate on|off',
284
+ /**
285
+ * `/fast on` CANNOT turn the tier on in a session that started without it,
286
+ * for the reason `/team on` above records at length: the tool array is built
287
+ * ONCE and rebuilding it is forbidden (C-2), so a session that grew a schema
288
+ * field mid-flight would be a session whose request payloads change shape
289
+ * between turns.
290
+ *
291
+ * EVERY MUTATING BRANCH DOES THE TWO CALLS PLUS PERSIST that `/todo`
292
+ * documents: `controller.setFastConfig(patch)` updates the object `App`
293
+ * reads at render time AND re-resolves the tier AND rebuilds the prompt;
294
+ * `ctx.persistConfig({ fast: patch })` survives the session. Persist alone
295
+ * reports success and changes nothing until relaunch; the setter alone
296
+ * forgets by morning.
297
+ */
298
+ run: (ctx) => {
299
+ const arg = ctx.args.trim();
300
+ const [rawVerb, value] = arg.split(/\s+/, 2);
301
+ const verb = (rawVerb ?? '').toLowerCase();
302
+ const controller = ctx.controller;
303
+ // Refused mid-dispatch for the reason `/team` records: half a dispatch
304
+ // under one tier and half under another produces a report nothing can
305
+ // afterwards explain. THAT ARGUMENT IS ABOUT WRITES, so the two READS are
306
+ // grouped and answerable (RV-H3): `/fast status` always was, and a bare
307
+ // `/fast budget` reports the same numbers by another name. `/fast budget
308
+ // 80` is a write and is still refused.
309
+ const isRead = verb === 'status' || (verb === 'budget' && !value);
310
+ if (verb && !isRead && controller.isTeamBusy()) {
311
+ ctx.notify('warn', 'Cannot change fast-tier settings mid-dispatch.');
312
+ return;
313
+ }
314
+ const persist = (patch) => {
315
+ controller.setFastConfig(patch);
316
+ ctx.persistConfig({ fast: patch });
317
+ };
318
+ if (!verb || verb === 'status') {
319
+ ctx.notify('info', formatFastStatus(controller));
320
+ return;
321
+ }
322
+ if (verb === 'on' || verb === 'off') {
323
+ const enabled = verb === 'on';
324
+ persist({ enabled });
325
+ if (!controller.isFastRegistered()) {
326
+ // The honest branch, and the whole point of splitting by what was
327
+ // decided at construction (§3.3). It is one sentence, and it keeps
328
+ // AC-2 / AC-3 intact: a session launched without the tier never grows
329
+ // a schema field, whatever the user types afterwards.
330
+ ctx.notify('info', enabled
331
+ ? 'The fast tier is off for this session (not configured at launch). ' +
332
+ 'Saved for next launch.'
333
+ : 'The fast tier is already off for this session. Saved for next launch.');
334
+ return;
335
+ }
336
+ controller.setFastEnabled(enabled);
337
+ ctx.toast('success', `Fast tier ${enabled ? 'on' : 'off'}.`);
338
+ return;
339
+ }
340
+ if (verb === 'model') {
341
+ const raw = (value ?? '').trim();
342
+ if (!raw) {
343
+ ctx.notify('warn', 'Usage: /fast model <id> (or provider:model)');
344
+ return;
345
+ }
346
+ // `provider:model` is accepted because that is how a user thinks about a
347
+ // tier on another vendor, and an unknown provider is rejected with THE
348
+ // MESSAGE `cli.tsx` ALREADY PRINTS rather than a second wording for the
349
+ // same fact (RV-16).
350
+ const colon = raw.indexOf(':');
351
+ if (colon > 0) {
352
+ const provider = raw.slice(0, colon);
353
+ const model = raw.slice(colon + 1);
354
+ if (!isAdapterProvider(provider)) {
355
+ ctx.notify('warn', `Unknown provider "${provider}". Choose: ${ADAPTER_PROVIDERS.join(', ')}.`);
356
+ return;
357
+ }
358
+ persist({ provider, model });
359
+ ctx.toast('success', `Fast model set to ${provider}:${model}.`);
360
+ return;
361
+ }
362
+ persist({ model: raw });
363
+ ctx.toast('success', `Fast model set to ${raw}.`);
364
+ return;
365
+ }
366
+ if (verb === 'provider') {
367
+ const raw = (value ?? '').trim();
368
+ if (!raw) {
369
+ ctx.notify('warn', 'Usage: /fast provider <id>');
370
+ return;
371
+ }
372
+ if (!isAdapterProvider(raw)) {
373
+ ctx.notify('warn', `Unknown provider "${raw}". Choose: ${ADAPTER_PROVIDERS.join(', ')}.`);
374
+ return;
375
+ }
376
+ persist({ provider: raw });
377
+ ctx.toast('success', `Fast provider set to ${raw}.`);
378
+ return;
379
+ }
380
+ if (verb === 'same') {
381
+ // R-e IN ONE WORD. `sameAsMain` is display-only and no branch reads it
382
+ // (R-13): the user is allowed to run both tiers on one model, and the
383
+ // review still costs what it costs.
384
+ const cfg = controller.getConfig();
385
+ persist({
386
+ provider: cfg.provider,
387
+ model: cfg.model,
388
+ baseUrl: cfg.baseUrl ?? '',
389
+ });
390
+ ctx.toast('success', `Fast tier set to ${cfg.provider}:${cfg.model} (same as main).`);
391
+ return;
392
+ }
393
+ if (verb === 'review') {
394
+ const raw = (value ?? '').trim().toLowerCase();
395
+ if (raw === 'off') {
396
+ persist({ review: false });
397
+ ctx.toast('success', 'Fast review off.');
398
+ return;
399
+ }
400
+ const n = Number.parseInt(raw, 10);
401
+ if (!Number.isFinite(n) || n <= 0) {
402
+ ctx.notify('warn', 'Usage: /fast review <n> | off');
403
+ return;
404
+ }
405
+ // Clamped by `setFastConfig` and again on the persist path, so a value
406
+ // outside the range can reach neither the runtime nor the file.
407
+ const applied = controller.setFastConfig({ review: true, reviewEveryTurns: n });
408
+ ctx.persistConfig({
409
+ fast: { review: true, reviewEveryTurns: applied.reviewEveryTurns },
410
+ });
411
+ ctx.toast('success', `Fast review every ${applied.reviewEveryTurns} turns.`);
412
+ return;
413
+ }
414
+ if (verb === 'budget') {
415
+ const snapshot = controller.getFastStatus().snapshot;
416
+ const raw = (value ?? '').trim();
417
+ if (!raw) {
418
+ // The READ. Answerable mid-dispatch (see the guard above), and it
419
+ // reports the same pair `/fast status` renders as `n/N`.
420
+ ctx.notify('info', `Fast review budget: ${snapshot.reviews} of ${snapshot.reviewBudget} used ` +
421
+ 'this session. Use /fast budget <1-500> to raise it.');
422
+ return;
423
+ }
424
+ const n = Number.parseInt(raw, 10);
425
+ if (!Number.isFinite(n) || n <= 0) {
426
+ // Change NOTHING on a typo, the discipline `/max-tokens` sets: a
427
+ // silent resolve to the default discards the number the user meant.
428
+ ctx.notify('warn', 'Usage: /fast budget <1-500>');
429
+ return;
430
+ }
431
+ // BOTH CALLS, IN THIS ORDER (RV-H4 / AC-H17). `setFastConfig` is what
432
+ // makes a raised budget take effect LIVE - the reviewer's `budgetLimit()`
433
+ // reads the controller's in-memory config, never disk - and it also
434
+ // re-emits `tier_changed`, which is how the new denominator reaches the
435
+ // status chip without a restart. See the comment on this command: a
436
+ // persist alone reports success and changes nothing until relaunch.
437
+ const applied = controller.setFastConfig({ reviewMaxPerSession: n });
438
+ ctx.persistConfig({
439
+ fast: { reviewMaxPerSession: applied.reviewMaxPerSession },
440
+ });
441
+ // The APPLIED value, not the requested one, so a clamp is visible.
442
+ ctx.toast('success', `Fast review budget: ${applied.reviewMaxPerSession} per session. ` +
443
+ `${snapshot.reviews} used.`);
444
+ return;
445
+ }
446
+ if (verb === 'delegate') {
447
+ const raw = (value ?? '').trim().toLowerCase();
448
+ if (raw !== 'on' && raw !== 'off') {
449
+ ctx.notify('warn', 'Usage: /fast delegate <on|off>');
450
+ return;
451
+ }
452
+ persist({ delegate: raw === 'on' });
453
+ ctx.toast('success', `Fast delegation ${raw}.`);
454
+ return;
455
+ }
456
+ ctx.notify('warn', `Unknown argument "${verb}" - use /fast ` +
457
+ '[status|on|off|model <id>|provider <id>|same|review <n|off>|budget <n>|' +
458
+ 'delegate on|off].');
459
+ },
460
+ },
225
461
  {
226
462
  name: 'todo',
227
463
  description: 'Todo planning: status | on | off | panel on|off | follow <mode> | clear | continue',
@@ -376,6 +612,77 @@ const COMMANDS = [
376
612
  '/todo [status|on|off|panel on|off|follow <mode>|clear|continue].');
377
613
  },
378
614
  },
615
+ {
616
+ name: 'retry',
617
+ description: 'API retry: show | on | off | max <n>',
618
+ /**
619
+ * TWO CALLS FOR EVERY MUTATION, IN THIS ORDER, and neither is optional
620
+ * (llm-api-retry-backoff §6.9 / §6.3):
621
+ *
622
+ * `setRetryConfig` replaces the policy on the live `ProviderRegistry`, so
623
+ * the change takes effect in THIS session — and, because
624
+ * `TeamRuntime` holds the same registry instance, for every
625
+ * child too. Without it `/retry off` reports success and
626
+ * changes nothing until the next launch.
627
+ * `persistConfig` is what survives the session.
628
+ *
629
+ * UNLIKE `/team on` AND `/todo on` there is no "off for this session" branch:
630
+ * retry is not a tool, nothing is registered at construction, and there is
631
+ * therefore no state a flag could have made unreachable. `--no-retry` is a
632
+ * starting value, not a permanent verdict.
633
+ */
634
+ run: (ctx) => {
635
+ const arg = ctx.args.trim().toLowerCase();
636
+ const [verb, value] = arg.split(/\s+/, 2);
637
+ const controller = ctx.controller;
638
+ if (!verb || verb === 'show' || verb === 'status') {
639
+ const cfg = controller.getRetryConfig();
640
+ if (!cfg.enabled || cfg.maxRetries <= 0) {
641
+ ctx.notify('info', `API retry: off (maxRetries ${cfg.maxRetries}). Use /retry on or /retry max <n>.`);
642
+ return;
643
+ }
644
+ ctx.notify('info', `API retry: on. Up to ${cfg.maxRetries} retries, ${cfg.initialDelayMs}ms doubling to ` +
645
+ `${cfg.maxDelayMs}ms${cfg.jitter ? ' with jitter' : ''}, ` +
646
+ `giving up after ${Math.round(cfg.maxElapsedMs / 1000)}s. ` +
647
+ `Retry-After ${cfg.respectRetryAfter ? 'honoured' : 'ignored'}.`);
648
+ return;
649
+ }
650
+ if (verb === 'on' || verb === 'off') {
651
+ const enabled = verb === 'on';
652
+ const applied = controller.setRetryConfig({ enabled });
653
+ // A PARTIAL `retry` section, safe only because `updatePersistedConfig`
654
+ // deep-merges it — the same contract `/team on` relies on.
655
+ ctx.persistConfig({ retry: { enabled } });
656
+ if (enabled && applied.maxRetries <= 0) {
657
+ // Honest rather than cheerful: `enabled` is on but the count is the
658
+ // kill switch, so nothing will actually retry.
659
+ ctx.notify('warn', 'API retry enabled, but maxRetries is 0 - use /retry max <n>.');
660
+ return;
661
+ }
662
+ ctx.toast('success', `API retry ${enabled ? 'on' : 'off'}.`);
663
+ return;
664
+ }
665
+ if (verb === 'max') {
666
+ // `Number.isFinite` and `>= 0`, NOT `> 0`: `0` is this key's documented
667
+ // floor and its kill switch, and rejecting it here would make `/retry max 0`
668
+ // the one documented control that does not work (R-16).
669
+ const n = Number.parseInt(value ?? '', 10);
670
+ if (!Number.isFinite(n) || n < 0) {
671
+ ctx.notify('warn', `Usage: /retry max <0-${HARD_MAX_RETRIES}> (0 turns retry off)`);
672
+ return;
673
+ }
674
+ const applied = controller.setRetryConfig({ maxRetries: n });
675
+ ctx.persistConfig({
676
+ retry: { maxRetries: applied.maxRetries },
677
+ });
678
+ ctx.toast('success', applied.maxRetries === 0
679
+ ? 'API retry off (maxRetries 0).'
680
+ : `API retry: up to ${applied.maxRetries} retries.`);
681
+ return;
682
+ }
683
+ ctx.notify('warn', `Unknown argument "${verb}" - use /retry [show|on|off|max <n>].`);
684
+ },
685
+ },
379
686
  {
380
687
  name: 'expand',
381
688
  description: 'Expand / collapse a recent tool card (default: last)',
@@ -535,6 +842,10 @@ const COMMANDS = [
535
842
  ctx.toast('success', `Reloaded config: ${config.provider}:${config.model}.`);
536
843
  },
537
844
  },
845
+ // Lives in its own module because it is the ONLY built-in that reads the
846
+ // render layer, and threading `ui/render-cache.js` into this file would make
847
+ // every other command's import graph pull the renderer in with it.
848
+ perfCommand,
538
849
  {
539
850
  name: 'exit',
540
851
  aliases: ['quit'],
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `/perf` — what the renderer is actually doing (tui-render-performance §5.4).
3
+ *
4
+ * SILENT DEGRADATION IS THE ONE THING A "robust" CLAIM CANNOT AFFORD. The
5
+ * governor makes the stream chunkier under load, the retain ring drops old
6
+ * entries, and virtualisation mounts a fraction of the transcript. Every one of
7
+ * those is invisible by design, so there has to be one place that names them.
8
+ *
9
+ * The snapshot crosses the React boundary through a module-level single slot,
10
+ * the shape `ui/exit-snapshot.ts` already uses and for the same reason: a
11
+ * command runs outside the render pass and has no other way to reach `App`'s
12
+ * refs.
13
+ *
14
+ * READ-ONLY AND ALLOCATION-LIGHT. It renders as a NOTICE ENTRY rather than a
15
+ * live panel, because a performance readout that costs a repaint per second to
16
+ * display would be self-defeating.
17
+ */
18
+ import type { SlashCommand } from './registry.js';
19
+ export interface PerfSnapshot {
20
+ /** Governor rung, its resolved interval, and the last measured commit cost. */
21
+ rung: number;
22
+ intervalMs: number;
23
+ lastCommitMs: number;
24
+ governorEnabled: boolean;
25
+ /** Transcript accounting. */
26
+ totalEntries: number;
27
+ retain: number;
28
+ droppedEntries: number;
29
+ mountedEntries: number;
30
+ /** Height cache. */
31
+ heightsMeasured: number;
32
+ heightsEstimated: number;
33
+ cols: number;
34
+ /** Geometry. */
35
+ mode: 'fullscreen' | 'inline';
36
+ viewportRows: number;
37
+ offset: number;
38
+ }
39
+ /** Called by `App`. Passing `null` detaches. */
40
+ export declare function setPerfSnapshotProvider(fn: (() => PerfSnapshot | null) | null): void;
41
+ export declare function readPerfSnapshot(): PerfSnapshot | null;
42
+ export declare function setPerfResetHook(fn: (() => void) | null): void;
43
+ /** Format a snapshot as the five-line block documented in §5.4. */
44
+ export declare function formatPerfReport(snap: PerfSnapshot): string;
45
+ export declare const perfCommand: SlashCommand;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `/perf` — what the renderer is actually doing (tui-render-performance §5.4).
3
+ *
4
+ * SILENT DEGRADATION IS THE ONE THING A "robust" CLAIM CANNOT AFFORD. The
5
+ * governor makes the stream chunkier under load, the retain ring drops old
6
+ * entries, and virtualisation mounts a fraction of the transcript. Every one of
7
+ * those is invisible by design, so there has to be one place that names them.
8
+ *
9
+ * The snapshot crosses the React boundary through a module-level single slot,
10
+ * the shape `ui/exit-snapshot.ts` already uses and for the same reason: a
11
+ * command runs outside the render pass and has no other way to reach `App`'s
12
+ * refs.
13
+ *
14
+ * READ-ONLY AND ALLOCATION-LIGHT. It renders as a NOTICE ENTRY rather than a
15
+ * live panel, because a performance readout that costs a repaint per second to
16
+ * display would be self-defeating.
17
+ */
18
+ import { clearRenderCaches, renderCacheStats } from '../ui/render-cache.js';
19
+ /** Cleared on unmount, so `/perf` in a torn-down tree says so instead of lying. */
20
+ let provider = null;
21
+ /** Called by `App`. Passing `null` detaches. */
22
+ export function setPerfSnapshotProvider(fn) {
23
+ provider = fn;
24
+ }
25
+ export function readPerfSnapshot() {
26
+ return provider ? provider() : null;
27
+ }
28
+ /** Reset hooks the App registers alongside the snapshot provider. */
29
+ let resetGovernor = null;
30
+ export function setPerfResetHook(fn) {
31
+ resetGovernor = fn;
32
+ }
33
+ function mb(bytes) {
34
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
35
+ }
36
+ /** Format a snapshot as the five-line block documented in §5.4. */
37
+ export function formatPerfReport(snap) {
38
+ const caches = renderCacheStats();
39
+ const eco = snap.rung > 0 ? ' - eco' : '';
40
+ const governor = snap.governorEnabled ? '' : ' - governor off';
41
+ return [
42
+ `render rung ${snap.rung} - interval ${snap.intervalMs}ms - last commit ` +
43
+ `${Math.round(snap.lastCommitMs)}ms${eco}${governor}`,
44
+ `transcript ${snap.totalEntries} entries - ${snap.retain} retained - ` +
45
+ `${snap.droppedEntries} dropped - ${snap.mountedEntries} mounted`,
46
+ `heights ${snap.heightsMeasured} cached - ${snap.heightsEstimated} estimated - ` +
47
+ `cols ${snap.cols}`,
48
+ `caches md ${caches.markdownEntries} (${mb(caches.markdownBytes)}) - ` +
49
+ `hl ${caches.highlightEntries} (${mb(caches.highlightBytes)}) - ` +
50
+ `lines ${caches.lineEntries}`,
51
+ `mode ${snap.mode} - viewport ${snap.viewportRows} rows - offset ${snap.offset}`,
52
+ ].join('\n');
53
+ }
54
+ export const perfCommand = {
55
+ name: 'perf',
56
+ description: 'Render statistics (/perf reset clears the caches and the governor)',
57
+ run: (ctx) => {
58
+ const arg = ctx.args.trim().toLowerCase();
59
+ if (arg === 'reset') {
60
+ clearRenderCaches();
61
+ resetGovernor?.();
62
+ ctx.toast('success', 'Render caches cleared, governor reset.');
63
+ return;
64
+ }
65
+ if (arg.length > 0) {
66
+ ctx.notify('warn', `Unknown argument "${arg}" - use /perf [reset].`);
67
+ return;
68
+ }
69
+ const snap = readPerfSnapshot();
70
+ if (!snap) {
71
+ // Reachable from a headless / detached tree. Saying so beats printing a
72
+ // block of zeroes that reads like a renderer doing nothing.
73
+ ctx.notify('info', 'No render statistics: the TUI is not mounted.');
74
+ return;
75
+ }
76
+ ctx.notify('info', formatPerfReport(snap));
77
+ },
78
+ };
@@ -1,6 +1,6 @@
1
1
  /**
2
- * `aragon config get | list | edit | home`, plus the `log.*` and `team.*`
3
- * branches of `config set`.
2
+ * `aragon config get | list | edit | home`, plus the `log.*`, `team.*`,
3
+ * `retry.*` and `fast.*` branches of `config set`.
4
4
  *
5
5
  * These live here rather than in `cli.tsx` because `runConfigSet` is already a
6
6
  * 21-case switch — well past the complexity ceiling this package writes down —
@@ -21,6 +21,28 @@ export declare const LOG_CONFIG_SET_KEYS: readonly ["log.level", "log.toFile", "
21
21
  * `Set team.enabled = false`.
22
22
  */
23
23
  export declare const TEAM_CONFIG_SET_KEYS: readonly ["team.enabled", "team.maxSubagents", "team.maxConcurrent", "team.subagentTimeoutMs", "team.dispatchTimeoutMs", "team.maxTurnsPerSubagent"];
24
+ /**
25
+ * Config keys under `retry.` that `aragon config set` accepts
26
+ * (llm-api-retry-backoff §8.2).
27
+ *
28
+ * Handled here rather than in `cli.tsx`'s switch for the reason this module's
29
+ * header gives about `log.*` and `team.*`: that switch is past this package's
30
+ * complexity ceiling, and a key added to the membership set but forgotten in the
31
+ * switch falls through every case, writes nothing, and still prints
32
+ * `Set retry.maxRetries = 0`.
33
+ */
34
+ export declare const RETRY_CONFIG_SET_KEYS: readonly ["retry.enabled", "retry.maxRetries", "retry.initialDelayMs", "retry.maxDelayMs", "retry.multiplier", "retry.jitter", "retry.respectRetryAfter", "retry.maxElapsedMs", "retry.onPartialStream"];
35
+ /**
36
+ * Config keys under `fast.` that `aragon config set` accepts (fast-model-tier
37
+ * §4.2).
38
+ *
39
+ * A DEDICATED SETTER RATHER THAN A GENERIC DOTTED WRITER, for the reason the
40
+ * three sets above each record: a generic setter would write `fast` as a FLAT
41
+ * string key (`"fast.model": "haiku"` at the top level of the file), the section
42
+ * would silently never take effect, and `config set` would still print
43
+ * `Set fast.model = haiku`.
44
+ */
45
+ export declare const FAST_CONFIG_SET_KEYS: readonly ["fast.enabled", "fast.provider", "fast.model", "fast.baseUrl", "fast.thinkingLevel", "fast.delegate", "fast.review", "fast.reviewEveryTurns", "fast.reviewContextTurns", "fast.reviewMaxChars", "fast.reviewMaxPerSession"];
24
46
  /**
25
47
  * Translate a `log.*` dotted key into a patch, or `null` for anything else.
26
48
  *
@@ -43,6 +65,35 @@ export declare function applyLogConfigSet(key: string, value: string): Partial<P
43
65
  * won't stick". `clampTeamConfig` is the one gate both directions pass through.
44
66
  */
45
67
  export declare function applyTeamConfigSet(key: string, value: string): Partial<PersistedConfig> | null;
68
+ /**
69
+ * Translate a `retry.*` dotted key into a patch, or `null` for anything else.
70
+ *
71
+ * The patch is a PARTIAL `retry` section, safe only because
72
+ * `updatePersistedConfig` deep-merges that section — with a shallow merge,
73
+ * setting the retry count would silently reset `respectRetryAfter` and `jitter`.
74
+ *
75
+ * `maxRetries` GOES THROUGH `clampRetryConfig`, NOT `coercePositiveInt`, and that
76
+ * is not a style choice (R-16): `coercePositiveInt` returns its fallback for
77
+ * `n <= 0`, so `config set retry.maxRetries 0` would write **10** — the maximum
78
+ * instead of the kill switch, silently. The whole section is routed through the
79
+ * one gate so no key here can pick up the wrong coercion later; the caller echoes
80
+ * the STORED value, which is what makes an out-of-range input visible.
81
+ */
82
+ export declare function applyRetryConfigSet(key: string, value: string): Partial<PersistedConfig> | null;
83
+ /**
84
+ * Translate a `fast.*` dotted key into a patch, or `null` for anything else.
85
+ *
86
+ * The patch is a PARTIAL `fast` section, safe only because
87
+ * `updatePersistedConfig` deep-merges that section — with a shallow merge,
88
+ * setting the cadence would silently switch the whole tier off (R-10).
89
+ *
90
+ * EVERY NUMERIC AND ENUM KEY ROUTES THROUGH `clampFastConfig`, not through
91
+ * `coercePositiveInt` or a local parse, for the reason `applyRetryConfigSet`
92
+ * records: one gate for both directions means no key here can pick up a
93
+ * different coercion later, and `config set fast.reviewEveryTurns 400` writes
94
+ * the clamped 50 while the caller echoes the STORED value (AC-13).
95
+ */
96
+ export declare function applyFastConfigSet(key: string, value: string): Partial<PersistedConfig> | null;
46
97
  /**
47
98
  * `log.redactSecrets false` is the one setting that lets credentials reach the
48
99
  * disk. It is command-line-only (there is no settings-screen entry) and it says