@aragon-agent/cli 0.6.23 → 0.6.25

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 (66) hide show
  1. package/CHANGELOG.md +1835 -1786
  2. package/README.md +2578 -2544
  3. package/dist/agent/controller.d.ts +60 -3
  4. package/dist/agent/controller.js +111 -6
  5. package/dist/agent/conversation-reset.d.ts +91 -0
  6. package/dist/agent/conversation-reset.js +66 -0
  7. package/dist/agent/model-probe-limits.d.ts +39 -0
  8. package/dist/agent/model-probe-limits.js +25 -0
  9. package/dist/agent/model-probe.d.ts +104 -0
  10. package/dist/agent/model-probe.js +391 -0
  11. package/dist/agent/model-profile-settings.js +29 -2
  12. package/dist/agent/reducer.js +45 -13
  13. package/dist/commands/builtins.js +101 -37
  14. package/dist/commands/clear-command.d.ts +27 -0
  15. package/dist/commands/clear-command.js +107 -0
  16. package/dist/commands/registry.d.ts +20 -1
  17. package/dist/compaction/child.d.ts +15 -0
  18. package/dist/compaction/child.js +14 -3
  19. package/dist/compaction/compactor.js +1 -0
  20. package/dist/compaction/context-command.js +10 -1
  21. package/dist/compaction/meter.d.ts +9 -0
  22. package/dist/compaction/meter.js +16 -1
  23. package/dist/compaction/pressure.d.ts +7 -0
  24. package/dist/compaction/pressure.js +5 -0
  25. package/dist/compaction/types.d.ts +11 -0
  26. package/dist/compaction/wiring.js +1 -0
  27. package/dist/config/cli-commands.d.ts +1 -1
  28. package/dist/config/cli-commands.js +12 -0
  29. package/dist/config/load.js +24 -6
  30. package/dist/config/model-profile-resolution.d.ts +8 -0
  31. package/dist/config/model-profile-resolution.js +13 -6
  32. package/dist/config/model-profile-store.d.ts +4 -2
  33. package/dist/config/model-profiles.d.ts +21 -0
  34. package/dist/config/model-profiles.js +28 -2
  35. package/dist/config/schema.d.ts +61 -0
  36. package/dist/config/schema.js +36 -0
  37. package/dist/session/clear-archive.d.ts +51 -0
  38. package/dist/session/clear-archive.js +160 -0
  39. package/dist/team/model.d.ts +88 -0
  40. package/dist/team/model.js +136 -0
  41. package/dist/team/report.d.ts +11 -0
  42. package/dist/team/report.js +24 -1
  43. package/dist/team/subagent.js +37 -21
  44. package/dist/team/task-tool.d.ts +25 -6
  45. package/dist/team/task-tool.js +15 -9
  46. package/dist/todo/types.d.ts +5 -3
  47. package/dist/ui/App.js +44 -7
  48. package/dist/ui/entries/CompactionCard.d.ts +5 -4
  49. package/dist/ui/entries/CompactionCard.js +5 -4
  50. package/dist/ui/overlays/HelpOverlay.js +8 -2
  51. package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
  52. package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
  53. package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
  54. package/dist/ui/overlays/ModelProfileEditor.js +117 -54
  55. package/dist/ui/overlays/ModelProfilePicker.d.ts +14 -2
  56. package/dist/ui/overlays/ModelProfilePicker.js +51 -10
  57. package/dist/ui/overlays/QuestionOverlay.js +47 -6
  58. package/dist/ui/overlays/SettingsScreen.d.ts +17 -0
  59. package/dist/ui/overlays/SettingsScreen.js +152 -18
  60. package/dist/ui/overlays/model-probe-view.d.ts +128 -0
  61. package/dist/ui/overlays/model-probe-view.js +225 -0
  62. package/dist/ui/overlays/model-profile-state.d.ts +62 -5
  63. package/dist/ui/overlays/model-profile-state.js +121 -14
  64. package/dist/ui/overlays/use-model-probe.d.ts +30 -0
  65. package/dist/ui/overlays/use-model-probe.js +109 -0
  66. package/package.json +2 -2
@@ -16,9 +16,12 @@ import { buildContinuationMessage } from '../todo/follow-through.js';
16
16
  import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
17
17
  import { loadSession, resolveSessionPath, saveSession } from '../session/persist.js';
18
18
  import { getLogger } from '../logging/logger.js';
19
+ import { resolveFastTier } from '../fast/resolve.js';
20
+ import { subagentModelSummary } from '../ui/overlays/model-profile-state.js';
19
21
  import { registerQueueCommand } from './queue-command.js';
20
22
  import { perfCommand } from './perf.js';
21
23
  import { runCompactCommand } from '../compaction/command.js';
24
+ import { runClearCommand } from './clear-command.js';
22
25
  import { runContextCommand } from '../compaction/context-command.js';
23
26
  /**
24
27
  * `/fast status` — THE GUARANTEED REPORTING SURFACE for the tier (§4.4 / R-8).
@@ -90,6 +93,66 @@ function formatUpdateStatus(port, override) {
90
93
  lines.push(` next ${next ? new Date(next).toLocaleTimeString() : 'not scheduled'}`);
91
94
  return lines.join('\n');
92
95
  }
96
+ /**
97
+ * `/team model [main|fast|<profile-name>]` (subagent-model §3.6 / 5.2).
98
+ *
99
+ * The user-owned switch's command-line surface. `main` and `fast` are verbs
100
+ * compared case-insensitively; a NAME is matched EXACTLY, case and all,
101
+ * against the library - the same identity the picker shows - and a miss lists
102
+ * the names that would work rather than failing silently. The summary reuses
103
+ * the exact wording the settings root row renders, so the two surfaces can
104
+ * never describe the same setting differently.
105
+ */
106
+ function runTeamModelCommand(ctx, subject) {
107
+ const controller = ctx.controller;
108
+ const config = controller.getConfig();
109
+ const tier = resolveFastTier(config, (id, role) => controller.hasApiKey(id, role));
110
+ const summary = subagentModelSummary({
111
+ mode: config.team.model,
112
+ modelProfileId: config.team.modelProfileId,
113
+ mainConnection: `${config.provider}:${config.model}`,
114
+ fastConnection: tier.ok ? `${tier.ref.providerId}:${tier.ref.modelId}` : null,
115
+ profiles: config.modelProfiles,
116
+ hasKey: (id) => controller.hasApiKey(id),
117
+ });
118
+ if (!subject) {
119
+ // The status readout: mode + the connection actually in effect + the
120
+ // disclosed reason when the request had to fall back (D-4).
121
+ const resolution = controller.subagentModelResolution();
122
+ const note = resolution.fallbackReason
123
+ ? ` Unavailable (${resolution.fallbackReason}); children run on the main model.`
124
+ : resolution.effective === 'main'
125
+ ? ''
126
+ : ` Children run on ${resolution.ref.providerId}:${resolution.ref.modelId}.`;
127
+ ctx.notify('info', `Subagent model: ${summary}.${note}`);
128
+ return;
129
+ }
130
+ if (subject.toLowerCase() === 'main' || subject.toLowerCase() === 'fast') {
131
+ const mode = subject.toLowerCase();
132
+ const applied = controller.setTeamConfig({ model: mode });
133
+ ctx.persistConfig({ team: { model: applied.model } });
134
+ if (mode === 'fast' && !tier.ok) {
135
+ ctx.notify('warn', 'The fast tier is not available now; subagents will fall back to the main model.');
136
+ }
137
+ ctx.toast('success', `Subagent model: ${mode === 'main' ? 'same as main' : 'fast tier'}.`);
138
+ return;
139
+ }
140
+ const entries = config.modelProfiles?.entries ?? [];
141
+ const matches = entries.filter((entry) => entry.name === subject);
142
+ if (matches.length === 0) {
143
+ const names = entries.slice(0, 8).map((entry) => entry.name);
144
+ ctx.notify('warn', `No profile named "${subject}".`
145
+ + (names.length ? ` Available: ${names.join(', ')}` : ' Create one in /settings first.'));
146
+ return;
147
+ }
148
+ const entry = matches[0];
149
+ const disambiguation = matches.length > 1 ? ` (#${entry.id.slice(0, 8)})` : '';
150
+ const applied = controller.setTeamConfig({ model: 'profile', modelProfileId: entry.id });
151
+ const teamPatch = { model: applied.model,
152
+ modelProfileId: applied.modelProfileId };
153
+ ctx.persistConfig({ team: teamPatch });
154
+ ctx.toast('success', `Subagent model: ${entry.name}${disambiguation} (${entry.provider}:${entry.model}).`);
155
+ }
93
156
  function formatFastStatus(controller) {
94
157
  const status = controller.getFastStatus();
95
158
  const cfg = controller.getFastConfig();
@@ -254,7 +317,7 @@ const COMMANDS = [
254
317
  },
255
318
  {
256
319
  name: 'team',
257
- description: 'Team subagents: status | on | off | max <n> | overseer on|off',
320
+ description: 'Team subagents: status | on | off | max <n> | overseer on|off | model [main|fast|<profile-name>]',
258
321
  /**
259
322
  * `/team on` CANNOT turn team mode on in a session that started without it,
260
323
  * and this command must not pretend otherwise (§4.5 / D-17 / P0-2).
@@ -275,6 +338,12 @@ const COMMANDS = [
275
338
  const arg = ctx.args.trim().toLowerCase();
276
339
  const [verb, value] = arg.split(/\s+/, 2);
277
340
  const controller = ctx.controller;
341
+ // RAW TOKENS, KEPT BESIDE THE LOWERCASED PAIR (V-2): `model` matches a
342
+ // profile NAME case-sensitively - like the picker and the library -
343
+ // and the whole-args toLowerCase() above would have made
344
+ // `/team model MyProfile` silently unmatchable. Only the VERB token is
345
+ // compared through the lowercased pair.
346
+ const rawTokens = ctx.args.trim().split(/\s+/).filter((token) => token.length > 0);
278
347
  // Refused mid-dispatch for the reason `/reload` already records: half a
279
348
  // dispatch under one ceiling and half under another produces a result
280
349
  // nothing can afterwards explain.
@@ -284,6 +353,7 @@ const COMMANDS = [
284
353
  }
285
354
  if (!verb || verb === 'status') {
286
355
  const cfg = controller.getTeamConfig();
356
+ const config = controller.getConfig();
287
357
  const state = !controller.isTeamRegistered()
288
358
  ? 'off for this session (started with --no-team)'
289
359
  : controller.isTeamEnabled()
@@ -293,9 +363,20 @@ const COMMANDS = [
293
363
  const roster = snapshot
294
364
  ? `\nRunning: ${snapshot.runs.map((r) => `${r.label} (${r.phase})`).join(', ')}`
295
365
  : '';
366
+ const tier = resolveFastTier(controller.getConfig(), (id, role) => controller.hasApiKey(id, role));
367
+ const subagent = subagentModelSummary({
368
+ mode: cfg.model,
369
+ modelProfileId: cfg.modelProfileId,
370
+ mainConnection: `${config.provider}:${config.model}`,
371
+ fastConnection: tier.ok ? `${tier.ref.providerId}:${tier.ref.modelId}` : null,
372
+ profiles: config.modelProfiles,
373
+ hasKey: (id) => controller.hasApiKey(id),
374
+ });
296
375
  ctx.notify('info', `Team mode: ${state}. Max subagents: ${cfg.maxSubagents}, ` +
297
376
  `concurrent: ${cfg.maxConcurrent}, supervisor: ${cfg.overseer ? 'on' : 'off'}` +
298
- `${cfg.overseer ? ' (never kills; checks the fast tier each tick)' : ' (legacy hard timeouts)'}.${roster}`);
377
+ `${cfg.overseer ? ' (never kills; checks the fast tier each tick)' : ' (legacy hard timeouts)'}.` +
378
+ `\nSubagent model: ${subagent}` +
379
+ `${roster}`);
299
380
  return;
300
381
  }
301
382
  if (verb === 'on' || verb === 'off') {
@@ -332,6 +413,10 @@ const COMMANDS = [
332
413
  ctx.toast('success', `Max subagents set to ${applied.maxSubagents}.`);
333
414
  return;
334
415
  }
416
+ if (verb === 'model') {
417
+ runTeamModelCommand(ctx, rawTokens[1]);
418
+ return;
419
+ }
335
420
  if (verb === 'overseer') {
336
421
  if (value !== 'on' && value !== 'off') {
337
422
  ctx.notify('warn', 'Usage: /team overseer on|off');
@@ -347,7 +432,7 @@ const COMMANDS = [
347
432
  ctx.toast('success', `Dispatch supervisor ${applied.overseer ? 'on' : 'off'}.`);
348
433
  return;
349
434
  }
350
- ctx.notify('warn', `Unknown argument "${verb}" - use /team [status|on|off|max <n>|overseer on|off].`);
435
+ ctx.notify('warn', `Unknown argument "${verb}" - use /team [status|on|off|max <n>|overseer on|off|model main|fast|<profile-name>].`);
351
436
  },
352
437
  },
353
438
  {
@@ -873,44 +958,23 @@ const COMMANDS = [
873
958
  },
874
959
  },
875
960
  {
961
+ // REGISTRATION ONLY. The body lives in `commands/clear-command.ts`: this file
962
+ // is already past the 1000-line guideline, and `/clear` is no longer a
963
+ // two-line projection reset - it interrupts a run, waits for it, archives the
964
+ // conversation and then clears (clear-command-context-reset D-8).
876
965
  name: 'clear',
877
- description: 'Clear the visible transcript and the todo panel',
878
- run: (ctx) => {
879
- // A USER OVERRIDE OF A PROJECTION, the same class as `/todo clear`, so it
880
- // reuses reason `'user'`; I-2 and its standing exception are stated on
881
- // `TodoStore`. Clearing the STORE and not merely the view mirror is what
882
- // keeps `/save`, `/todo status|continue` and follow-through agreeing with
883
- // the screen — they read the store directly. `messages` is untouched:
884
- // clearing the conversation is still `/reset` alone.
885
- //
886
- // DELIBERATELY WITHOUT `/todo clear`'s two guards: an `isRunning()` refusal
887
- // would leave `/clear` unable to clear even the transcript mid-run, and an
888
- // early return on an empty list would skip the dispatch below entirely.
889
- // Clearing an empty store is already silent — it emits into a reducer
890
- // branch that writes two fields that are both already at their defaults.
891
- //
892
- // Bounded side effect: the emptied store re-arms `todo_write`'s "a fresh
893
- // plan has at least two items" gate, so a model that shrinks a plan to one
894
- // item right after a mid-run `/clear` gets the refusal text and retries.
895
- ctx.controller.clearTodos();
896
- ctx.dispatch({ type: 'clearTranscript' });
897
- },
966
+ description: 'Start a new conversation; "/clear screen" only clears the screen',
967
+ run: runClearCommand,
898
968
  },
899
969
  {
970
+ // ALIAS BY DESIGN (D-2), AND THIS IS THE WHOLE FIX. Until this change `/clear`
971
+ // cleared the screen while `/reset` cleared the session, so the command a
972
+ // user reaches for in a long conversation was the one that kept the history.
973
+ // One implementation makes that drift structurally impossible rather than
974
+ // merely fixed: there is no second body left to diverge.
900
975
  name: 'reset',
901
- description: 'Start a new conversation',
902
- run: (ctx) => {
903
- if (ctx.controller.isRunning()) {
904
- ctx.notify('warn', 'Cannot reset while the agent is running. Wait for it to stop.');
905
- return;
906
- }
907
- ctx.controller.clearMessages();
908
- ctx.controller.clearAllQueues();
909
- ctx.dispatch({ type: 'resetConversation' });
910
- ctx.toast('info', 'Started a new conversation.');
911
- if (ctx.state.pendingSteering.length)
912
- ctx.notify('warn', `\u5df2\u53d6\u6d88 ${ctx.state.pendingSteering.length} \u6761\u672a\u63a5\u6536\u6d88\u606f`);
913
- },
976
+ description: 'Alias of /clear',
977
+ run: runClearCommand,
914
978
  },
915
979
  {
916
980
  name: 'cwd',
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `/clear` - start a new conversation - plus its `/reset` alias and the
3
+ * `/clear screen` form that keeps the old "just wipe the screen" meaning
4
+ * (clear-command-context-reset §3.5 / D-1 / D-2 / D-3).
5
+ *
6
+ * ASCII ONLY: `src/commands/**` is inside `glyphs.test.ts::inScope`. The one
7
+ * localized string below is written with `\u` escapes for that reason, exactly
8
+ * as the `/reset` body it replaces did.
9
+ *
10
+ * WHY THE BODY LIVES HERE AND THE REGISTRATION STAYS IN `builtins.ts`: the same
11
+ * reason `/compact` and `/context` do (D-8). `builtins.ts` is 1511 lines against
12
+ * `CLAUDE.md`'s 1000-line cap, and this command is no longer a two-line
13
+ * projection reset - it interrupts a run, waits for it, archives a conversation
14
+ * and then clears. A two-line edit belongs inline; this does not.
15
+ *
16
+ * THE COMMAND'S ONLY JOB IS SEQUENCING AND SPEAKING. Every state change happens
17
+ * on the controller port (`AgentController.resetConversation`, which delegates
18
+ * the ORDER to `agent/conversation-reset.ts`), and every word the user reads is
19
+ * decided here, in one place, in the exact order they will read it.
20
+ */
21
+ import type { CommandContext } from './registry.js';
22
+ /**
23
+ * Run `/clear` or `/reset`. Both registry entries point HERE (D-2), which is
24
+ * what stops the two from drifting back into "one clears the screen, one clears
25
+ * the session" - the drift that produced the defect this feature fixes.
26
+ */
27
+ export declare function runClearCommand(ctx: CommandContext): Promise<void>;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `/clear` - start a new conversation - plus its `/reset` alias and the
3
+ * `/clear screen` form that keeps the old "just wipe the screen" meaning
4
+ * (clear-command-context-reset §3.5 / D-1 / D-2 / D-3).
5
+ *
6
+ * ASCII ONLY: `src/commands/**` is inside `glyphs.test.ts::inScope`. The one
7
+ * localized string below is written with `\u` escapes for that reason, exactly
8
+ * as the `/reset` body it replaces did.
9
+ *
10
+ * WHY THE BODY LIVES HERE AND THE REGISTRATION STAYS IN `builtins.ts`: the same
11
+ * reason `/compact` and `/context` do (D-8). `builtins.ts` is 1511 lines against
12
+ * `CLAUDE.md`'s 1000-line cap, and this command is no longer a two-line
13
+ * projection reset - it interrupts a run, waits for it, archives a conversation
14
+ * and then clears. A two-line edit belongs inline; this does not.
15
+ *
16
+ * THE COMMAND'S ONLY JOB IS SEQUENCING AND SPEAKING. Every state change happens
17
+ * on the controller port (`AgentController.resetConversation`, which delegates
18
+ * the ORDER to `agent/conversation-reset.ts`), and every word the user reads is
19
+ * decided here, in one place, in the exact order they will read it.
20
+ */
21
+ import { getLogger } from '../logging/logger.js';
22
+ /** The toast a successful clear shows. Pinned verbatim by an existing test. */
23
+ const CLEARED_TOAST = 'Started a new conversation.';
24
+ /**
25
+ * Run `/clear` or `/reset`. Both registry entries point HERE (D-2), which is
26
+ * what stops the two from drifting back into "one clears the screen, one clears
27
+ * the session" - the drift that produced the defect this feature fixes.
28
+ */
29
+ export async function runClearCommand(ctx) {
30
+ const verb = ctx.args.trim().toLowerCase();
31
+ if (verb === 'screen') {
32
+ // The old behaviour, byte for byte (I-3): silent, transcript plus the todo
33
+ // panel, history untouched.
34
+ clearScreenOnly(ctx);
35
+ return;
36
+ }
37
+ if (verb !== '') {
38
+ // Rejected rather than ignored. A command that silently accepts and
39
+ // discards an argument is a surface that lies about what it understood.
40
+ ctx.notify('warn', `Unknown argument "${ctx.args.trim()}" - use /clear [screen].`);
41
+ return;
42
+ }
43
+ // READ BEFORE CLEARING (P1-2 / I-9). `clearAllQueues()` empties
44
+ // `pendingUserSteering`, so a count taken afterwards is always zero - and a
45
+ // notice that says "0 messages cancelled" is worse than no notice at all.
46
+ const cancelled = ctx.state.pendingSteering.length;
47
+ const result = await ctx.controller.resetConversation({
48
+ interrupt: true,
49
+ // A THUNK, NOT A VALUE (P0-1 / §3.3). `ctx.state` is the view as of command
50
+ // entry, and the archive happens AFTER the interrupted engine unwinds - so
51
+ // the last tool cards of the abandoned run are written to the transcript in
52
+ // between. A value captured here would archive everything except the part
53
+ // the user was looking at when they typed the command.
54
+ readTranscript: () => ctx.transcriptEntries?.() ?? ctx.state.entries,
55
+ });
56
+ if (!result.ok) {
57
+ // ZERO SIDE EFFECTS (I-4), and a trace (P1-5). The user gets one sentence
58
+ // naming the next action; the log gets the reason, because "my /clear did
59
+ // nothing" is otherwise unanswerable after the fact.
60
+ getLogger().warn('agent', 'clear_still_running', { reason: result.reason });
61
+ ctx.notify('warn', result.message);
62
+ return;
63
+ }
64
+ // THE VIEW GOES LAST (B-5 / §3.3). `App`'s dispatch wrapper zeroes the run
65
+ // metrics, the interaction phase and the scroll anchor on this action, so
66
+ // dispatching it after the engine has settled is what makes "cleared" mean
67
+ // "nothing is still writing into it".
68
+ ctx.dispatch({ type: 'resetConversation' });
69
+ // THE ONE CONSUMER OF `messagesCleared` (P2-1). It cannot join the toast:
70
+ // `clear-command-todo.test.ts` pins that string verbatim, and a count nobody
71
+ // asked for is noise in the one place the user reads confirmations.
72
+ getLogger().info('agent', 'conversation_cleared', {
73
+ messagesCleared: result.messagesCleared,
74
+ interrupted: result.interrupted,
75
+ });
76
+ ctx.toast('info', CLEARED_TOAST);
77
+ // REINSTATED FROM THE OLD `/reset` (P1-1 / C-3). Dropping it would silently
78
+ // delete a user-visible acknowledgement that already existed and was already
79
+ // pinned by test.
80
+ if (cancelled) {
81
+ ctx.notify('warn', `\u5df2\u53d6\u6d88 ${cancelled} \u6761\u672a\u63a5\u6536\u6d88\u606f`);
82
+ }
83
+ if (result.archivedPath) {
84
+ // A PATH RATHER THAN A PROMISE: the user can copy it, and `/resume` accepts
85
+ // an absolute path. "Archived" with no way to find the file is a reassurance,
86
+ // not a recovery.
87
+ ctx.notify('info', `Previous conversation archived: ${result.archivedPath} (/resume <path> to restore)`);
88
+ }
89
+ }
90
+ /**
91
+ * Today's `/clear`, unchanged and silent (D-3 / I-3).
92
+ *
93
+ * Kept as a real form rather than as a separate command because "clear the
94
+ * screen" and "clear the conversation" are two different intentions, and a user
95
+ * who wants the first must not have to risk the second. It also gives
96
+ * `clearTranscript` a live dispatcher: `CLAUDE.md` forbids orphaned actions, and
97
+ * this was the last one that would have had none.
98
+ */
99
+ function clearScreenOnly(ctx) {
100
+ // A USER OVERRIDE OF A PROJECTION, the same class as `/todo clear`, so it
101
+ // reuses reason `'user'`; the distinction against `clearMessages()`'s `'reset'`
102
+ // is recorded on `TodoStore`. Clearing the STORE and not merely the view
103
+ // mirror is what keeps `/save`, `/todo status|continue` and follow-through
104
+ // agreeing with the screen - they read the store directly.
105
+ ctx.controller.clearTodos();
106
+ ctx.dispatch({ type: 'clearTranscript' });
107
+ }
@@ -6,7 +6,7 @@ import type { SubmitMessageOptions } from '../agent/prompt-options.js';
6
6
  import type { CopyRequest, CopyRequestResult } from '../ui/clipboard-task.js';
7
7
  import type { AgentController } from '../agent/controller.js';
8
8
  import type { AgentMode } from '../agent/agent-mode.js';
9
- import type { NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } from '../agent/reducer.js';
9
+ import type { Entry, NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } from '../agent/reducer.js';
10
10
  import type { PersistedConfig } from '../config/schema.js';
11
11
  import type { FollowThroughBudget } from '../todo/follow-through.js';
12
12
  import type { UpdateCommandPort } from '../update/types.js';
@@ -108,6 +108,25 @@ export interface CommandContext {
108
108
  * stream is handed over directly.
109
109
  */
110
110
  writeForeign?: (text: string) => void;
111
+ /**
112
+ * The LIVE transcript rows, for `/clear`'s archive (clear-command-context-reset
113
+ * P0-1 / §3.5).
114
+ *
115
+ * A THUNK, AND THAT IS THE WHOLE POINT. The `state` field above is a snapshot
116
+ * of `ViewState` taken when the command started; `/clear` interrupts a run and
117
+ * then waits for the engine to unwind, so the last tool cards of that run are
118
+ * written to the transcript AFTER `state` was captured. Reading `state.entries`
119
+ * would archive everything except the part the user was just looking at.
120
+ *
121
+ * OPTIONAL, like `followBudget` / `update` / `mouse`: every existing
122
+ * `CommandContext` fixture in this package builds the object by literal, and a
123
+ * required field would break all of them at compile time for a feature none of
124
+ * them are about. Absent means the archive degrades to history-only.
125
+ *
126
+ * `Entry` arrives as a TYPE-ONLY import so this module keeps its runtime shape
127
+ * (the same rule the `update/types.js` import above follows).
128
+ */
129
+ transcriptEntries?: () => Entry[];
111
130
  }
112
131
  /** What `/mouse` can see and do. Read at command time, never captured. */
113
132
  export interface MouseCommandPort {
@@ -56,6 +56,21 @@ export interface ChildCompactionDeps {
56
56
  export interface ChildContextManagerRequest {
57
57
  role?: ModelRole;
58
58
  label: string;
59
+ /**
60
+ * REQ-LEVEL key resolvers, preferred over the lead's (V-4).
61
+ *
62
+ * `wiring.ts::childFactory` injects the LEAD layer's `getApiKey`/`hasKey`,
63
+ * which cannot see a fast-only key (`role='fast'` only) or a profile's
64
+ * dedicated key (it exists only inside the entry). `Compactor.resolveSummarizer`
65
+ * gates its main choice on `hasKey(provider, role)` and reads the call key
66
+ * through `getApiKey`, so without these a `team.model: fast/profile` child
67
+ * whose only key lives in one of those places would have its compaction
68
+ * SILENTLY DISABLED - the summarizer resolves to null and nobody says why.
69
+ * `subagent.ts` passes the same profile-aware closures the child's own
70
+ * `Agent` gets; ABSENT keeps today's behavior byte for byte (matrix 24).
71
+ */
72
+ getApiKey?: (providerId: string, role?: ModelRole) => string | undefined;
73
+ hasKey?: (providerId: string, role?: ModelRole) => boolean;
59
74
  /**
60
75
  * The model the child actually runs on.
61
76
  *
@@ -65,6 +65,10 @@ export function createChildContextManager(req, deps) {
65
65
  baseUrl: req.model.baseUrl,
66
66
  // The lead's manual limit describes its connection, not every worker.
67
67
  contextWindow: sameConnection ? base.contextWindow : null,
68
+ // THE ORIGIN FOLLOWS THE VALUE through the same gate, honoring the iff
69
+ // contract (value null => origin undefined): a child on a different
70
+ // connection inherits neither the number nor its provenance.
71
+ contextWindowOrigin: sameConnection ? base.contextWindowOrigin : undefined,
68
72
  // NO POLICY OVERLAY (main-agent parity): `keepRecentTurns`, `onFailure`,
69
73
  // `useFastTier` and `archive` are read LIVE from the base config, so the
70
74
  // child compacts under exactly the policy the lead compacts under. A
@@ -81,15 +85,22 @@ export function createChildContextManager(req, deps) {
81
85
  return source === undefined ? deps.isPricedModel(req.model) : source !== 'fallback';
82
86
  },
83
87
  getWindowOverride: () => childConfig().contextWindow,
88
+ getWindowOverrideOrigin: () => childConfig().contextWindowOrigin,
84
89
  });
85
90
  let overflowNotified = false;
91
+ // REQ-LEVEL RESOLVERS WIN (V-4), and the fallback is the lead layer - so a
92
+ // request built without them behaves exactly as before, while one built by
93
+ // `subagent.ts` under `team.model: fast/profile` lets the summarizer see
94
+ // the same keys the child itself authenticates with.
95
+ const hasKey = req.hasKey ?? deps.hasKey;
96
+ const getApiKey = req.getApiKey ?? deps.getApiKey;
86
97
  const compactor = new Compactor({
87
98
  getConfig: childConfig,
88
99
  meter,
89
100
  mainRole: req.role ?? 'main',
90
- resolveFastCandidate: () => resolveFastTier(deps.getConfig(), deps.hasKey),
91
- hasKey: deps.hasKey,
92
- getApiKey: deps.getApiKey,
101
+ resolveFastCandidate: () => resolveFastTier(deps.getConfig(), hasKey),
102
+ hasKey,
103
+ getApiKey,
93
104
  getModelInfoFor: deps.getModelInfoFor,
94
105
  isPricedModel: deps.isPricedModel,
95
106
  getMessages: req.getMessages,
@@ -403,6 +403,7 @@ export class Compactor {
403
403
  return source === undefined ? this.deps.isPricedModel(this.mainRef()) : source !== 'fallback';
404
404
  },
405
405
  getWindowOverride: () => this.deps.getConfig().contextWindow,
406
+ getWindowOverrideOrigin: () => this.deps.getConfig().contextWindowOrigin,
406
407
  });
407
408
  return this.ownMeter;
408
409
  }
@@ -87,7 +87,16 @@ function occupancyLine(usage) {
87
87
  */
88
88
  function windowLine(usage) {
89
89
  if (usage.windowOverridden) {
90
- return `${usage.window} from contextWindow configuration (overrides API, model table, and model-windows.json)`;
90
+ // THREE NAMED SURFACES (profile-context-window §6.2). `undefined` falls
91
+ // into the config branch ON PURPOSE: that string is what every pre-feature
92
+ // test asserts, and a meter built without the origin dep must report
93
+ // byte-identically (invariant 6).
94
+ const named = usage.windowOverrideOrigin === 'profile'
95
+ ? 'from your model profile'
96
+ : usage.windowOverrideOrigin === 'env'
97
+ ? 'from ARAGON_CONTEXT_WINDOW'
98
+ : 'from contextWindow configuration';
99
+ return `${usage.window} ${named} (overrides API, model table, and model-windows.json)`;
91
100
  }
92
101
  if (!usage.windowKnown) {
93
102
  return (`UNKNOWN - no verified model limit; internal fallback budget ${usage.window}; ` +
@@ -28,6 +28,7 @@
28
28
  * `current()` carries a structural belt for a missed invalidation site.
29
29
  */
30
30
  import type { AgentEvent, Message, ModelInfo, TokenUsage } from '@aragon-agent/core';
31
+ import type { ContextWindowOrigin } from '../config/schema.js';
31
32
  import type { ContextUsageSnapshot, Pressure } from './types.js';
32
33
  /**
33
34
  * How long a scheduled publish waits.
@@ -62,6 +63,14 @@ export interface ContextMeterDeps {
62
63
  * measurement, so a live settings edit moves the denominator without a relaunch.
63
64
  */
64
65
  getWindowOverride(): number | null;
66
+ /**
67
+ * WHERE the override above came from, read live beside it
68
+ * (profile-context-window §6.1). Optional so every existing construction
69
+ * site stays untouched: a meter built without it reports an overridden
70
+ * window with origin `undefined`, and `/context` falls back to its
71
+ * pre-feature "from contextWindow configuration" wording byte-for-byte.
72
+ */
73
+ getWindowOverrideOrigin?(): ContextWindowOrigin | undefined;
65
74
  getRequestVersion?(): number;
66
75
  }
67
76
  export type ContextUsageListener = (usage: ContextUsageSnapshot) => void;
@@ -63,6 +63,9 @@ export function toContextUsage(p) {
63
63
  deltaTokens: p.deltaTokens,
64
64
  windowKnown: p.windowKnown,
65
65
  windowOverridden: p.windowOverridden,
66
+ ...(p.windowOverrideOrigin !== undefined
67
+ ? { windowOverrideOrigin: p.windowOverrideOrigin }
68
+ : {}),
66
69
  };
67
70
  }
68
71
  /** The reading a meter with nothing measured yet reports. */
@@ -426,6 +429,10 @@ export class ContextMeter {
426
429
  window.contextWindow !== last?.contextWindow ||
427
430
  window.windowKnown !== last?.windowKnown ||
428
431
  window.windowOverridden !== last?.windowOverridden ||
432
+ // ORIGIN IS PART OF THE DIRTY CHECK: a save that keeps the same number
433
+ // but moves it from the legacy key to the bound profile would otherwise
434
+ // leave `/context` naming the old source until the next measurement.
435
+ window.windowOverrideOrigin !== last?.windowOverrideOrigin ||
429
436
  this.deps.getModelInfo().contextWindowSource !== last?.windowSource)
430
437
  this.dirty = true;
431
438
  }
@@ -451,6 +458,9 @@ export class ContextMeter {
451
458
  windowSource: this.deps.getModelInfo().contextWindowSource,
452
459
  windowKnown: window.windowKnown,
453
460
  windowOverridden: window.windowOverridden,
461
+ ...(window.windowOverrideOrigin !== undefined
462
+ ? { windowOverrideOrigin: window.windowOverrideOrigin }
463
+ : {}),
454
464
  ...(this.estimateOffset !== undefined ? { estimateOffset: this.estimateOffset } : {}),
455
465
  ...(this.measuredPrefixLength !== undefined
456
466
  ? { measuredPrefixLength: this.measuredPrefixLength }
@@ -469,7 +479,12 @@ export class ContextMeter {
469
479
  resolveWindow() {
470
480
  const override = this.deps.getWindowOverride();
471
481
  if (override !== null && Number.isFinite(override) && override > 0) {
472
- return { contextWindow: override, windowKnown: true, windowOverridden: true };
482
+ // ORIGIN ONLY RIDES THE OVERRIDE BRANCH: the AUTO branch names its own
483
+ // source through `getModelInfo().contextWindowSource`, and the two must
484
+ // never both claim the denominator (profile-context-window §6.1).
485
+ const origin = this.deps.getWindowOverrideOrigin?.();
486
+ return { contextWindow: override, windowKnown: true, windowOverridden: true,
487
+ ...(origin !== undefined ? { windowOverrideOrigin: origin } : {}) };
473
488
  }
474
489
  return {
475
490
  contextWindow: this.deps.getModelInfo().contextWindow,
@@ -93,6 +93,13 @@ export interface PressureInput {
93
93
  * construction site is unchanged and the arithmetic below is untouched.
94
94
  */
95
95
  windowOverridden?: boolean;
96
+ /**
97
+ * Which override surface supplied `contextWindow` (profile-context-window).
98
+ * PURE PASS-THROUGH like `windowOverridden` - the arithmetic below never
99
+ * reads it; it only travels with the number so `/context` can name the
100
+ * source honestly.
101
+ */
102
+ windowOverrideOrigin?: Pressure['windowOverrideOrigin'];
96
103
  /** Carried from the last `turn_end`; applied to every ESTIMATED figure. */
97
104
  estimateOffset?: number;
98
105
  /**
@@ -174,6 +174,11 @@ export function computePressure(input) {
174
174
  // context-usage-gauge-accuracy; the flag only travels with the number so
175
175
  // `/context` can name the denominator's source (I-6).
176
176
  windowOverridden: input.windowOverridden ?? false,
177
+ // CONDITIONAL SPREAD: with no origin there is no KEY, keeping a
178
+ // feature-off Pressure byte-identical to the pre-feature one (I-6).
179
+ ...(input.windowOverrideOrigin !== undefined
180
+ ? { windowOverrideOrigin: input.windowOverrideOrigin }
181
+ : {}),
177
182
  ...(input.estimateOffset !== undefined ? { estimateOffset: input.estimateOffset } : {}),
178
183
  deltaTokens,
179
184
  };
@@ -20,6 +20,7 @@
20
20
  * possible change.
21
21
  */
22
22
  import type { ModelRef, TokenUsage } from '@aragon-agent/core';
23
+ import type { ContextWindowOrigin } from '../config/schema.js';
23
24
  /** Billing belongs to the actual call, including fallback and late responses. */
24
25
  export interface CompactionBilling {
25
26
  modelRef?: ModelRef;
@@ -92,6 +93,14 @@ export interface Pressure {
92
93
  * one being wrong is exactly the case that most needs to be visible.
93
94
  */
94
95
  windowOverridden: boolean;
96
+ /**
97
+ * WHICH override surface supplied the denominator (profile-context-window
98
+ * §5.2): env / legacy top-level key / the profile bound as Main. Optional
99
+ * and PURE PASS-THROUGH - present only when `windowOverridden` is true, and
100
+ * `undefined` there means "config", the pre-feature answer. The arithmetic
101
+ * never reads it; only `/context`'s window line does.
102
+ */
103
+ windowOverrideOrigin?: ContextWindowOrigin;
95
104
  /**
96
105
  * `occupiedTokens(lastUsage) - estimateOf(the history that produced it)`,
97
106
  * clamped to `>= 0`, or `undefined` before the first `turn_end`.
@@ -150,6 +159,8 @@ export interface ContextUsageSnapshot {
150
159
  windowKnown: boolean;
151
160
  /** Whether the denominator came from the user's `contextWindow` (I-6). */
152
161
  windowOverridden: boolean;
162
+ /** Which override surface named the denominator; see `Pressure` (pass-through). */
163
+ windowOverrideOrigin?: ContextWindowOrigin;
153
164
  }
154
165
  /** One compaction, in whatever state it reached. Rendered by `CompactionCard`. */
155
166
  export interface CompactionRecord {
@@ -105,6 +105,7 @@ export class CompactionWiring {
105
105
  return source === undefined ? deps.isPricedModel(this.mainRef()) : source !== 'fallback';
106
106
  },
107
107
  getWindowOverride: () => deps.getConfig().contextWindow,
108
+ getWindowOverrideOrigin: () => deps.getConfig().contextWindowOrigin,
108
109
  });
109
110
  this.compactor = new Compactor({
110
111
  getConfig: deps.getConfig,
@@ -20,7 +20,7 @@ export declare const LOG_CONFIG_SET_KEYS: readonly ["log.level", "log.toFile", "
20
20
  * switch falls through every case, writes nothing, and still prints
21
21
  * `Set team.enabled = false`.
22
22
  */
23
- export declare const TEAM_CONFIG_SET_KEYS: readonly ["team.enabled", "team.maxSubagents", "team.maxConcurrent", "team.subagentTimeoutMs", "team.dispatchTimeoutMs", "team.maxTurnsPerSubagent", "team.overseer", "team.overseerIntervalMs"];
23
+ export declare const TEAM_CONFIG_SET_KEYS: readonly ["team.enabled", "team.maxSubagents", "team.maxConcurrent", "team.subagentTimeoutMs", "team.dispatchTimeoutMs", "team.maxTurnsPerSubagent", "team.overseer", "team.overseerIntervalMs", "team.model", "team.modelProfileId"];
24
24
  /**
25
25
  * Config keys under `retry.` that `aragon config set` accepts
26
26
  * (llm-api-retry-backoff §8.2).
@@ -45,6 +45,8 @@ export const TEAM_CONFIG_SET_KEYS = [
45
45
  'team.maxTurnsPerSubagent',
46
46
  'team.overseer',
47
47
  'team.overseerIntervalMs',
48
+ 'team.model',
49
+ 'team.modelProfileId',
48
50
  ];
49
51
  /**
50
52
  * Config keys under `retry.` that `aragon config set` accepts
@@ -205,6 +207,16 @@ export function applyTeamConfigSet(key, value) {
205
207
  return patch({ overseer: isTrue(value) });
206
208
  case 'team.overseerIntervalMs':
207
209
  return patch(clampOne('overseerIntervalMs'));
210
+ // The subagent-model keys (subagent-model-and-question-controls V-3):
211
+ // without these two cases `aragon config set team.model fast` is refused
212
+ // with "Unknown config key" while `config list` shows the key one command
213
+ // over - the exact gap COMPACTION_CONFIG_SET_KEYS records at length.
214
+ // Both route through `clampTeamConfig` like every other key here: a typo'd
215
+ // mode resolves to 'main' and a bad id is nulled, never rejected.
216
+ case 'team.model':
217
+ return patch(clampOne('model'));
218
+ case 'team.modelProfileId':
219
+ return patch(clampOne('modelProfileId'));
208
220
  default:
209
221
  return null;
210
222
  }