@aragon-agent/cli 0.6.22 → 0.6.24

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 (48) hide show
  1. package/README.md +20 -2
  2. package/dist/agent/controller.d.ts +11 -0
  3. package/dist/agent/controller.js +42 -4
  4. package/dist/agent/model-profile-settings.js +29 -2
  5. package/dist/agent/reducer.js +8 -6
  6. package/dist/commands/builtins.js +87 -3
  7. package/dist/compaction/child.d.ts +15 -0
  8. package/dist/compaction/child.js +14 -3
  9. package/dist/compaction/compactor.js +1 -0
  10. package/dist/compaction/context-command.js +10 -1
  11. package/dist/compaction/meter.d.ts +9 -0
  12. package/dist/compaction/meter.js +16 -1
  13. package/dist/compaction/pressure.d.ts +7 -0
  14. package/dist/compaction/pressure.js +5 -0
  15. package/dist/compaction/types.d.ts +11 -0
  16. package/dist/compaction/wiring.js +1 -0
  17. package/dist/config/cli-commands.d.ts +1 -1
  18. package/dist/config/cli-commands.js +12 -0
  19. package/dist/config/load.js +66 -9
  20. package/dist/config/model-profile-resolution.d.ts +8 -0
  21. package/dist/config/model-profile-resolution.js +13 -6
  22. package/dist/config/model-profile-store.d.ts +4 -2
  23. package/dist/config/model-profiles.d.ts +13 -0
  24. package/dist/config/model-profiles.js +18 -2
  25. package/dist/config/schema.d.ts +86 -0
  26. package/dist/config/schema.js +74 -0
  27. package/dist/team/model.d.ts +88 -0
  28. package/dist/team/model.js +136 -0
  29. package/dist/team/overseer.d.ts +5 -3
  30. package/dist/team/overseer.js +15 -4
  31. package/dist/team/report.d.ts +11 -0
  32. package/dist/team/report.js +24 -1
  33. package/dist/team/runtime.js +20 -3
  34. package/dist/team/subagent.d.ts +10 -0
  35. package/dist/team/subagent.js +57 -27
  36. package/dist/team/task-tool.d.ts +25 -6
  37. package/dist/team/task-tool.js +15 -9
  38. package/dist/team/types.d.ts +4 -3
  39. package/dist/ui/App.js +5 -0
  40. package/dist/ui/overlays/ModelProfileEditor.js +18 -4
  41. package/dist/ui/overlays/ModelProfilePicker.d.ts +14 -2
  42. package/dist/ui/overlays/ModelProfilePicker.js +51 -10
  43. package/dist/ui/overlays/QuestionOverlay.js +47 -6
  44. package/dist/ui/overlays/SettingsScreen.d.ts +9 -0
  45. package/dist/ui/overlays/SettingsScreen.js +119 -17
  46. package/dist/ui/overlays/model-profile-state.d.ts +53 -4
  47. package/dist/ui/overlays/model-profile-state.js +111 -14
  48. package/package.json +2 -2
package/README.md CHANGED
@@ -406,7 +406,7 @@ signal ends the run (`130` SIGINT, `143` SIGTERM, `129` SIGHUP).
406
406
  | `/save [file]` | Save the session to JSON |
407
407
  | `/resume [file]` | Load a saved session |
408
408
  | `/plan [on\|off\|status]` | Toggle plan mode - the keyboard-free equivalent of `Shift+Tab` |
409
- | `/team [on\|off\|max <n>\|overseer on\|off]` | Team subagents: status and the live roster, the on/off switch, the fan-out width, the dispatch supervisor |
409
+ | `/team [on\|off\|max <n>\|overseer on\|off\|model [main\|fast\|<profile-name>]]` | Team subagents: status and the live roster, the on/off switch, the fan-out width, the dispatch supervisor |
410
410
  | `/todo [on\|off\|panel on\|off\|follow <mode>\|clear\|continue]` | Todo planning: status, the on/off switch, the rail, follow-through, and picking an unfinished plan back up |
411
411
  | `/retry [show\|on\|off\|max <n>]` | API retry: the effective ladder, the on/off switch, the retry count. Takes effect **in the running session**, children included |
412
412
  | `/fast [on\|off\|model <id>\|provider <id>\|same\|review <n\|off>]` | Fast model tier: status and this session's totals, the on/off switch, which model, the review cadence |
@@ -792,6 +792,11 @@ questions.
792
792
  - **`ask_user`** renders a keyboard-driven wizard: 1–5 questions, 2–4 options
793
793
  each, exactly one marked `RECOMMENDED`, plus an `Other…` free-text option.
794
794
  `↑`/`↓` choose, `Enter` confirms and advances, `←` goes back, `Esc` cancels.
795
+ **`Space` selects**: on a multi-choice question it toggles the highlighted
796
+ option; on a single-choice question it marks the highlighted option without
797
+ advancing, and `Enter` then submits the marked item — while a plain `Enter`
798
+ without `Space` still takes the highlighted option, recommended preselect
799
+ and all. `Space` does nothing on the `Other…` row (`Enter` opens its editor).
795
800
  Pressing `Enter` through the whole wizard accepts every recommendation, which
796
801
  is a deliberate "use your judgement" path rather than an accident. A last
797
802
  screen lets you review before submitting.
@@ -1118,6 +1123,17 @@ parallel, then read one combined report of what they each found. It decides for
1118
1123
  itself whether to delegate; a session that never calls `task` is the session
1119
1124
  that existed before this feature.
1120
1125
 
1126
+ **Which model a subagent runs on is your setting, not the model's.** The
1127
+ default (`team.model: 'main'`) keeps main-agent parity: every child runs the
1128
+ lead's own connection. Switch it to the **fast tier** or a **saved profile**
1129
+ from the settings screen's *Subagent model* row or with
1130
+ `/team model main|fast|<profile-name>` — useful when a fan-out of read-only
1131
+ scouts does not need the main model. The choice is re-resolved on every
1132
+ dispatch, and an unusable one (tier off, profile deleted, key missing) falls
1133
+ back to the main model with a disclosed line in the dispatch report instead of
1134
+ failing the dispatch. The model itself still cannot downgrade its own
1135
+ delegation: a stale `model` key in a `task` call is ignored, exactly as before.
1136
+
1121
1137
  ```
1122
1138
  > read the auth middleware, map the route table, and check the migration
1123
1139
 
@@ -2025,6 +2041,8 @@ env / `.env` → CLI flags**.
2025
2041
  | `team.maxTurnsPerSubagent` | `0` | Runaway-loop cap, clamped to `[0, 100]`. `0` (the default) = no cap, matching the lead. A subagent stopped at a positive cap is reported as `stopped: turn cap reached`, with whatever it had produced. |
2026
2042
  | `team.overseer` | `true` | The dispatch supervisor: one per dispatch on the fast model, ticking on a geometric cadence over every live subagent and deciding wait / nudge / replace / abandon. With the fast tier missing a tick degrades to an announced unassisted wait — never a kill; with this `false`, children run under the legacy hard ceilings exactly. `/team overseer on\|off` flips it live, and it is read per dispatch. |
2027
2043
  | `team.overseerIntervalMs` | `0` | Supervisor cadence base, clamped to `[0, 3600000]`. `0` (the default) = the structural 300 s; a positive `team.subagentTimeoutMs` is honoured first when this is `0` (compat). Later checks grow ×1.6, clamped to `[60 s, 900 s]`; any nudge/replace/abandon resets that child's cadence to the base. Only meaningful while `team.overseer` is true. |
2044
+ | `team.model` | `"main"` | Which model every subagent runs on: `'main'` (the default - the lead's own connection), `'fast'` (the fast tier, re-resolved per dispatch), or `'profile'` (the entry named by `team.modelProfileId`). A **user-owned setting** — the model itself still cannot downgrade its own delegation, and an unusable choice falls back to the main model with a disclosed line in the dispatch report. Also settable via `/team model main\|fast\|<profile-name>` and the settings screen's **Subagent model** row. |
2045
+ | `team.modelProfileId` | `null` | The profile entry `team.model: 'profile'` binds to. Reference existence is not validated here — a dangling id degrades to the main model at resolution time, so deleting a profile never breaks a dispatch. |
2028
2046
  | `todo.enabled` | `true` | Master switch for todo planning. `false` means the `todo_write` tool is never registered — `/todo on` in such a session saves the setting for next launch and says so, rather than advertising a tool that is not there. |
2029
2047
  | `todo.panel` | `true` | Render the right-hand rail. Independent of `todo.enabled` on purpose: a screen-reader user wants the planning discipline without the column, and the system prompt varies one sentence accordingly. |
2030
2048
  | `todo.followThrough` | `"notify"` | What happens when a run ends with steps left. `notify` says so and stops; `auto` continues the plan after a 3-second grace window, bounded by two structural limits (one fruitless attempt, 25 continuations per plan) that are deliberately **not** settings; `off` is silent. An unrecognized value falls back to `notify`. |
@@ -2521,7 +2539,7 @@ VS Code 用户设置添加 `"terminal.integrated.copyOnSelection": false`,快
2521
2539
 
2522
2540
  在 `/settings` 中使用 **Main profile** 和 **Fast profile**,可以分别为主任务与快速模型选择已保存的方案。两种角色共享配置库,但各自选择互不影响,也可以选择同一个方案。**Manage profiles** 提供新建、编辑、复制和删除;名称留空时采用模型名。同名方案通过地址主机和唯一 ID 区分。超过八条时按 `/` 搜索名称、模型或服务商。
2523
2541
 
2524
- 上下方向键或 Tab 移动,Enter 选择或编辑,根设置页 Ctrl+S 保存。普通文本字段的 Enter 仍保存设置。选择和子表单的修改先进入草稿;Esc 返回或确认丢弃。被主模型或快速模型引用的方案必须先解除绑定才能删除。复制包含本轮已经编辑的凭据,并产生新的 ID。
2542
+ 上下方向键或 Tab 移动,Enter 选择或编辑,根设置页 Ctrl+S 保存。普通文本字段的 Enter 仍保存设置。选择和子表单的修改先进入草稿;Esc 返回或确认丢弃。列表页(角色选择页与管理页)聚焦在方案条目上时按 `e` 直接编辑、按 `d` 直接进入删除确认。删除被引用的方案(主模型 / 快速模型 / 子代理)走“解除绑定并删除”单次流程:确认页会列出引用方,确认后绑定同步清空;未被引用的方案仍显示普通的 Delete。复制包含本轮已经编辑的凭据,并产生新的 ID。
2525
2543
 
2526
2544
  方案保存服务商、模型、地址和凭据,不改变思考级别、token 上限、快速评审频率或预算。选择快速模型方案不会自动启用快速层。若本次启动时快速层未注册,首次启用会提示“已保存,待重启”;缺少模型或凭据时也需要先补齐。
2527
2545
 
@@ -20,6 +20,7 @@ import { type ConfirmRequest } from '../tools/index.js';
20
20
  import type { ToolPermission } from '../exec/permission.js';
21
21
  import { type ToolOutputListener } from '../tools/tool-output-store.js';
22
22
  import type { FilePatch } from '../tools/patch.js';
23
+ import type { SubagentModelResolution } from '../team/model.js';
23
24
  import type { TeamEvent, TeamSnapshot } from '../team/types.js';
24
25
  import { ProcSupervisor } from '../proc/supervisor.js';
25
26
  import type { ProcEventListener, ServiceSnapshot } from '../proc/types.js';
@@ -478,6 +479,16 @@ export declare class AgentController {
478
479
  * value can never reach the runtime.
479
480
  */
480
481
  setTeamConfig(patch: Partial<TeamConfig>): TeamConfig;
482
+ /**
483
+ * The dispatch-time subagent model resolution (subagent-model §3.4).
484
+ *
485
+ * One authority for the `task` report's disclosure line and its price
486
+ * table: both read through here, so the header can never name one model
487
+ * while pricing another. Re-resolved on every call for the reason
488
+ * `resolveFastTier` records - `/fast off`, a settings save and `/team model`
489
+ * all rewrite the inputs mid-session.
490
+ */
491
+ subagentModelResolution(): SubagentModelResolution;
481
492
  /** Refused mid-dispatch by `/team`; half a dispatch under two ceilings is not
482
493
  * a result anything can afterwards explain (the reasoning `/reload` uses). */
483
494
  isTeamBusy(): boolean;
@@ -16,6 +16,7 @@ import { createChildReviewer } from '../team/child-reviewer.js';
16
16
  import { TeamHumanQueue } from '../team/human-queue.js';
17
17
  import { createFastOverseerProvider } from '../team/overseer.js';
18
18
  import { createTaskTool } from '../team/task-tool.js';
19
+ import { resolveSubagentModel } from '../team/model.js';
19
20
  import { buildTeamBlock } from '../team/prompt.js';
20
21
  import { buildFastBlock } from '../fast/prompt.js';
21
22
  import { ProcSupervisor } from '../proc/supervisor.js';
@@ -516,9 +517,22 @@ export class AgentController {
516
517
  maxSubagents: () => this.config.team.maxSubagents,
517
518
  hasApiKey: () => this.hasApiKey(),
518
519
  activeProvider: () => this.config.provider,
519
- // Every child runs the lead's own model, so the lead's cost table
520
- // is the one honest price for the dispatch total.
521
- modelCost: () => this.getModelInfo().cost,
520
+ // Priced at the model children ACTUALLY run (subagent-model §3.4):
521
+ // under the default `main` mode this is exactly the lead's own
522
+ // table (I-2 byte parity); a fast or profile model the static
523
+ // tables do not price yields undefined, so the report says
524
+ // `pricing unknown` rather than fabricating $0.00 (C-11 / RV-4).
525
+ modelCost: () => {
526
+ const resolution = this.subagentModelResolution();
527
+ if (resolution.effective === 'main')
528
+ return this.getModelInfo().cost;
529
+ return this.isPricedModel(resolution.ref)
530
+ ? this.getModelInfoFor(resolution.ref).cost
531
+ : undefined;
532
+ },
533
+ // The same resolution, re-read per dispatch, feeds the report's
534
+ // disclosure line - one read per consumer, one authority both.
535
+ subagentModel: () => this.subagentModelResolution(),
522
536
  // The FAST table for the supervisor's own spend line
523
537
  // (subagent-overseer-v2 D-7 / R-P1-2). Reuses the tier
524
538
  // resolution and the unknown-pricing predicate the fast
@@ -575,6 +589,10 @@ export class AgentController {
575
589
  isWindowKnown: () => this.getModelInfo().contextWindowSource !== 'fallback',
576
590
  // LIVE, so a settings-screen edit moves the denominator without a relaunch.
577
591
  getWindowOverride: () => this.config.contextWindow,
592
+ // Beside the value, LIVE too: `/context` must name the surface that
593
+ // supplied the denominator (profile-context-window AC-9, one of four
594
+ // meter sites that must stay in lockstep).
595
+ getWindowOverrideOrigin: () => this.config.contextWindowOrigin,
578
596
  });
579
597
  // --- Context compaction, part 1: BEFORE the Agent ----------------------
580
598
  //
@@ -634,7 +652,15 @@ export class AgentController {
634
652
  // `dispatchTimeoutMs: 0` (the default) reaches the executor as an
635
653
  // override of `0` = NO TIMER AT ALL: the dispatch lasts as long as its
636
654
  // slowest child, and only an Esc ends it early (main-agent parity).
637
- ...(this.teamRegistered ? { task: config.team.dispatchTimeoutMs } : {}),
655
+ // D-4, THE SECOND ARMING SITE: the runtime arms its own dispatch
656
+ // timer under the same gate, because the executor timeout being
657
+ // cooperative means this override must never be the ONLY ceiling.
658
+ // With the supervisor gate open the ceiling disarms HERE too, so
659
+ // team.overseer can never leave two kill paths alive behind one
660
+ // config. Read once at construction, exactly as before.
661
+ ...(this.teamRegistered
662
+ ? { task: config.team.overseer ? 0 : config.team.dispatchTimeoutMs }
663
+ : {}),
638
664
  },
639
665
  },
640
666
  });
@@ -1299,6 +1325,18 @@ export class AgentController {
1299
1325
  this.rebuildSystemPrompt();
1300
1326
  return team;
1301
1327
  }
1328
+ /**
1329
+ * The dispatch-time subagent model resolution (subagent-model §3.4).
1330
+ *
1331
+ * One authority for the `task` report's disclosure line and its price
1332
+ * table: both read through here, so the header can never name one model
1333
+ * while pricing another. Re-resolved on every call for the reason
1334
+ * `resolveFastTier` records - `/fast off`, a settings save and `/team model`
1335
+ * all rewrite the inputs mid-session.
1336
+ */
1337
+ subagentModelResolution() {
1338
+ return resolveSubagentModel(this.config, (id, role) => this.hasApiKey(id, role));
1339
+ }
1302
1340
  /** Refused mid-dispatch by `/team`; half a dispatch under two ceilings is not
1303
1341
  * a result anything can afterwards explain (the reasoning `/reload` uses). */
1304
1342
  isTeamBusy() {
@@ -68,7 +68,11 @@ function liveCandidate(live, candidate, draft) {
68
68
  normalizedPatch[key] = candidate[key];
69
69
  }
70
70
  const next = { ...live, ...normalizedPatch };
71
- for (const section of ['apiKeys', 'fast', 'compaction', 'log']) {
71
+ // 'team' joined the loop with the subagent-model picker (D-5): its rows
72
+ // write `draft.patch.team`, and `mergePersistedConfig` clamps the candidate
73
+ // through `clampTeamConfig` on the same per-key merge `/team` uses, so the
74
+ // live view and the file can never disagree about which model children run.
75
+ for (const section of ['apiKeys', 'fast', 'compaction', 'log', 'team']) {
72
76
  const normalized = { ...live[section] };
73
77
  for (const key of Object.keys(patch[section] ?? {})) {
74
78
  normalized[key] = candidate[section][key];
@@ -84,10 +88,33 @@ function liveCandidate(live, candidate, draft) {
84
88
  next.maxTokens = candidate.maxTokens ?? undefined;
85
89
  if ('baseUrl' in patch)
86
90
  next.baseUrl = candidate.baseUrl ?? undefined;
87
- const projected = projectModelProfiles(candidate).file;
91
+ const projection = projectModelProfiles(candidate);
92
+ // `projected` keeps its name and every existing use (`projected.provider`
93
+ // etc.); the FULL projection is retained because `mainOwnsContextWindow`
94
+ // lives on it, not on `.file` (profile-context-window §6.4).
95
+ const projected = projection.file;
88
96
  if (draft.profiles !== undefined || candidate.modelProfiles !== undefined) {
89
97
  next.modelProfiles = structuredClone(projected.modelProfiles);
90
98
  }
99
+ // THE WINDOW VALUE AND ITS ORIGIN RECOMPUTE IN THE SAME BATCH (P0-1): the
100
+ // meter's `getWindowOverride()` reads `this.config.contextWindow`, so a save
101
+ // that only recomputed the origin would leave a freshly edited profile
102
+ // window invisible until the next launch. env is a STARTUP-layer fact and no
103
+ // save may displace it (AC-4 / AC-14c); an explicit root-row edit takes the
104
+ // candidate value; a bound Main profile owns the denominator (value or null,
105
+ // tradeoff B); unbinding falls back to the legacy top-level key.
106
+ next.contextWindow = live.contextWindowOrigin === 'env'
107
+ ? live.contextWindow
108
+ : 'contextWindow' in patch ? candidate.contextWindow
109
+ : projection.mainOwnsContextWindow ? projected.contextWindow ?? null
110
+ : candidate.contextWindow;
111
+ // ORIGIN OBEYS THE IFF CONTRACT (§6.1): value null means AUTO, so origin
112
+ // MUST reset to undefined - this unified formula is what removes the stale
113
+ // 'profile' a per-branch expression would leave behind on unbind-to-null.
114
+ next.contextWindowOrigin = next.contextWindow === null ? undefined
115
+ : live.contextWindowOrigin === 'env' ? 'env'
116
+ : 'contextWindow' in patch ? 'config'
117
+ : projection.mainOwnsContextWindow ? 'profile' : 'config';
91
118
  next.liveToolOutput = live.liveToolOutput;
92
119
  for (const role of ['main', 'fast']) {
93
120
  if (!draft.activateRoles.includes(role) && !connectionChanged(draft, role)
@@ -183,18 +183,20 @@ export function reduceEvent(event, cost, patches) {
183
183
  }
184
184
  /**
185
185
  * Tools that render their own transcript entry, so the generic tool card would
186
- * be a duplicate. EXACTLY ONE MEMBER; read todo-plan-execution §3.8 before
187
- * adding a second.
186
+ * be a duplicate. `task` is the second member (F-8 A'3): every dispatch opens
187
+ * a TeamCard immediately after, so the pair read as TWO dispatches - the first
188
+ * failed, the second succeeded - when there was only ever one. `todo_write`'s
189
+ * reasons (todo-plan-execution §3.8 / D-10) carry over unchanged.
188
190
  *
189
191
  * SUPPRESSING ONLY `tool_call_start` IS SUFFICIENT, AND THAT IS THE WHOLE REASON
190
192
  * THIS IS SAFE (D-10). Every later handler — `toolCallDelta` / `toolCallEnd` /
191
193
  * `toolExecStart` / `toolExecEnd` — resolves its target through
192
194
  * `findToolEntryId`, which returns `undefined` when no entry was created, and
193
- * every one of them already early-returns on that. There is no fifth handler. A
194
- * second suppression rule would be four more places to get the id matching
195
- * wrong, for nothing.
195
+ * every one of them already early-returns on that. There is no fifth handler.
196
+ * Blast radius verified: headless and `exec` writers key on EVENTS, not tool
197
+ * names, and `transcript-text` renders `kind:'team'` on its own.
196
198
  */
197
- const SELF_RENDERING_TOOLS = new Set(['todo_write']);
199
+ const SELF_RENDERING_TOOLS = new Set(['todo_write', 'task']);
198
200
  function reduceStreamEvent(se) {
199
201
  switch (se.type) {
200
202
  case 'thinking_start':
@@ -16,6 +16,8 @@ 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';
@@ -90,6 +92,66 @@ function formatUpdateStatus(port, override) {
90
92
  lines.push(` next ${next ? new Date(next).toLocaleTimeString() : 'not scheduled'}`);
91
93
  return lines.join('\n');
92
94
  }
95
+ /**
96
+ * `/team model [main|fast|<profile-name>]` (subagent-model §3.6 / 5.2).
97
+ *
98
+ * The user-owned switch's command-line surface. `main` and `fast` are verbs
99
+ * compared case-insensitively; a NAME is matched EXACTLY, case and all,
100
+ * against the library - the same identity the picker shows - and a miss lists
101
+ * the names that would work rather than failing silently. The summary reuses
102
+ * the exact wording the settings root row renders, so the two surfaces can
103
+ * never describe the same setting differently.
104
+ */
105
+ function runTeamModelCommand(ctx, subject) {
106
+ const controller = ctx.controller;
107
+ const config = controller.getConfig();
108
+ const tier = resolveFastTier(config, (id, role) => controller.hasApiKey(id, role));
109
+ const summary = subagentModelSummary({
110
+ mode: config.team.model,
111
+ modelProfileId: config.team.modelProfileId,
112
+ mainConnection: `${config.provider}:${config.model}`,
113
+ fastConnection: tier.ok ? `${tier.ref.providerId}:${tier.ref.modelId}` : null,
114
+ profiles: config.modelProfiles,
115
+ hasKey: (id) => controller.hasApiKey(id),
116
+ });
117
+ if (!subject) {
118
+ // The status readout: mode + the connection actually in effect + the
119
+ // disclosed reason when the request had to fall back (D-4).
120
+ const resolution = controller.subagentModelResolution();
121
+ const note = resolution.fallbackReason
122
+ ? ` Unavailable (${resolution.fallbackReason}); children run on the main model.`
123
+ : resolution.effective === 'main'
124
+ ? ''
125
+ : ` Children run on ${resolution.ref.providerId}:${resolution.ref.modelId}.`;
126
+ ctx.notify('info', `Subagent model: ${summary}.${note}`);
127
+ return;
128
+ }
129
+ if (subject.toLowerCase() === 'main' || subject.toLowerCase() === 'fast') {
130
+ const mode = subject.toLowerCase();
131
+ const applied = controller.setTeamConfig({ model: mode });
132
+ ctx.persistConfig({ team: { model: applied.model } });
133
+ if (mode === 'fast' && !tier.ok) {
134
+ ctx.notify('warn', 'The fast tier is not available now; subagents will fall back to the main model.');
135
+ }
136
+ ctx.toast('success', `Subagent model: ${mode === 'main' ? 'same as main' : 'fast tier'}.`);
137
+ return;
138
+ }
139
+ const entries = config.modelProfiles?.entries ?? [];
140
+ const matches = entries.filter((entry) => entry.name === subject);
141
+ if (matches.length === 0) {
142
+ const names = entries.slice(0, 8).map((entry) => entry.name);
143
+ ctx.notify('warn', `No profile named "${subject}".`
144
+ + (names.length ? ` Available: ${names.join(', ')}` : ' Create one in /settings first.'));
145
+ return;
146
+ }
147
+ const entry = matches[0];
148
+ const disambiguation = matches.length > 1 ? ` (#${entry.id.slice(0, 8)})` : '';
149
+ const applied = controller.setTeamConfig({ model: 'profile', modelProfileId: entry.id });
150
+ const teamPatch = { model: applied.model,
151
+ modelProfileId: applied.modelProfileId };
152
+ ctx.persistConfig({ team: teamPatch });
153
+ ctx.toast('success', `Subagent model: ${entry.name}${disambiguation} (${entry.provider}:${entry.model}).`);
154
+ }
93
155
  function formatFastStatus(controller) {
94
156
  const status = controller.getFastStatus();
95
157
  const cfg = controller.getFastConfig();
@@ -254,7 +316,7 @@ const COMMANDS = [
254
316
  },
255
317
  {
256
318
  name: 'team',
257
- description: 'Team subagents: status | on | off | max <n> | overseer on|off',
319
+ description: 'Team subagents: status | on | off | max <n> | overseer on|off | model [main|fast|<profile-name>]',
258
320
  /**
259
321
  * `/team on` CANNOT turn team mode on in a session that started without it,
260
322
  * and this command must not pretend otherwise (§4.5 / D-17 / P0-2).
@@ -275,6 +337,12 @@ const COMMANDS = [
275
337
  const arg = ctx.args.trim().toLowerCase();
276
338
  const [verb, value] = arg.split(/\s+/, 2);
277
339
  const controller = ctx.controller;
340
+ // RAW TOKENS, KEPT BESIDE THE LOWERCASED PAIR (V-2): `model` matches a
341
+ // profile NAME case-sensitively - like the picker and the library -
342
+ // and the whole-args toLowerCase() above would have made
343
+ // `/team model MyProfile` silently unmatchable. Only the VERB token is
344
+ // compared through the lowercased pair.
345
+ const rawTokens = ctx.args.trim().split(/\s+/).filter((token) => token.length > 0);
278
346
  // Refused mid-dispatch for the reason `/reload` already records: half a
279
347
  // dispatch under one ceiling and half under another produces a result
280
348
  // nothing can afterwards explain.
@@ -284,6 +352,7 @@ const COMMANDS = [
284
352
  }
285
353
  if (!verb || verb === 'status') {
286
354
  const cfg = controller.getTeamConfig();
355
+ const config = controller.getConfig();
287
356
  const state = !controller.isTeamRegistered()
288
357
  ? 'off for this session (started with --no-team)'
289
358
  : controller.isTeamEnabled()
@@ -293,9 +362,20 @@ const COMMANDS = [
293
362
  const roster = snapshot
294
363
  ? `\nRunning: ${snapshot.runs.map((r) => `${r.label} (${r.phase})`).join(', ')}`
295
364
  : '';
365
+ const tier = resolveFastTier(controller.getConfig(), (id, role) => controller.hasApiKey(id, role));
366
+ const subagent = subagentModelSummary({
367
+ mode: cfg.model,
368
+ modelProfileId: cfg.modelProfileId,
369
+ mainConnection: `${config.provider}:${config.model}`,
370
+ fastConnection: tier.ok ? `${tier.ref.providerId}:${tier.ref.modelId}` : null,
371
+ profiles: config.modelProfiles,
372
+ hasKey: (id) => controller.hasApiKey(id),
373
+ });
296
374
  ctx.notify('info', `Team mode: ${state}. Max subagents: ${cfg.maxSubagents}, ` +
297
375
  `concurrent: ${cfg.maxConcurrent}, supervisor: ${cfg.overseer ? 'on' : 'off'}` +
298
- `${cfg.overseer ? ' (never kills; checks the fast tier each tick)' : ' (legacy hard timeouts)'}.${roster}`);
376
+ `${cfg.overseer ? ' (never kills; checks the fast tier each tick)' : ' (legacy hard timeouts)'}.` +
377
+ `\nSubagent model: ${subagent}` +
378
+ `${roster}`);
299
379
  return;
300
380
  }
301
381
  if (verb === 'on' || verb === 'off') {
@@ -332,6 +412,10 @@ const COMMANDS = [
332
412
  ctx.toast('success', `Max subagents set to ${applied.maxSubagents}.`);
333
413
  return;
334
414
  }
415
+ if (verb === 'model') {
416
+ runTeamModelCommand(ctx, rawTokens[1]);
417
+ return;
418
+ }
335
419
  if (verb === 'overseer') {
336
420
  if (value !== 'on' && value !== 'off') {
337
421
  ctx.notify('warn', 'Usage: /team overseer on|off');
@@ -347,7 +431,7 @@ const COMMANDS = [
347
431
  ctx.toast('success', `Dispatch supervisor ${applied.overseer ? 'on' : 'off'}.`);
348
432
  return;
349
433
  }
350
- ctx.notify('warn', `Unknown argument "${verb}" - use /team [status|on|off|max <n>|overseer on|off].`);
434
+ ctx.notify('warn', `Unknown argument "${verb}" - use /team [status|on|off|max <n>|overseer on|off|model main|fast|<profile-name>].`);
351
435
  },
352
436
  },
353
437
  {
@@ -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).