@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
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `resolveSubagentModel` — THE single authority on which model a team child
3
+ * runs (subagent-model-and-question-controls §3.3).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * EVERY CONSUMER READS THE CHILD'S MODEL THROUGH THIS FUNCTION AND NOWHERE
8
+ * ELSE — `createSubagent`, the `task` report header, the settings root row and
9
+ * `/team model` — following the `resolveFastTier` precedent one directory over:
10
+ * four call sites each recomputing "which model do children run on?" from raw
11
+ * config keys is exactly how the report ends up naming a model the children
12
+ * silently fell back from.
13
+ *
14
+ * IT IS RE-RUN ON EVERY DISPATCH AND EVERY RENDER, NOT ONCE (rule 8 / RV-3 of
15
+ * the fast tier, inherited here): `team.model`, the fast tier and the profile
16
+ * library are all LIVE keys a settings save, `/fast off` or `/team model` can
17
+ * rewrite mid-session. A resolution captured at construction would be a
18
+ * resolution describing a session that no longer exists.
19
+ *
20
+ * A CHILD IS NEVER LEFT WITHOUT A MODEL (D-4). Every failure mode — fast tier
21
+ * off, profile deleted, profile key missing — degrades to the MAIN connection
22
+ * with a `fallbackReason`, so a dispatch cannot fail because its model setting
23
+ * went stale. The reason is disclosed, never swallowed: the report header and
24
+ * the settings summary line both render it.
25
+ */
26
+ import type { ModelRef, ThinkingLevel } from '@aragon-agent/core';
27
+ import type { ModelProfile, ModelRole } from '../config/model-profiles.js';
28
+ import type { CliConfig } from '../config/schema.js';
29
+ /** Which model children run on. `main` is the default and the historical behavior. */
30
+ export type SubagentModelMode = 'main' | 'fast' | 'profile';
31
+ /** Why a non-main request degraded to the main model (D-4 disclosure). */
32
+ export type SubagentFallbackReason =
33
+ /** `team.model='fast'` but `resolveFastTier` is not ok (off, unconfigured, no key). */
34
+ 'fast_unavailable'
35
+ /** `modelProfileId` is absent from the library (deleted, or never existed). */
36
+ | 'profile_missing'
37
+ /** The entry exists but carries no key of its own and the shared layer has none. */
38
+ | 'profile_no_key';
39
+ /** In-memory resolution; recomputed per dispatch/render, never persisted (§6.3). */
40
+ export interface SubagentModelResolution {
41
+ /** The user's requested mode, verbatim from config (UI highlight reads this). */
42
+ mode: SubagentModelMode;
43
+ /** The mode actually in effect; `'main'` whenever a fallback fired. */
44
+ effective: SubagentModelMode;
45
+ /** The connection children will use. On fallback, exactly the main connection. */
46
+ ref: ModelRef;
47
+ /** main/profile take `config.thinkingLevel`; fast takes `config.fast.thinkingLevel`. */
48
+ thinkingLevel: ThinkingLevel;
49
+ /** The role controller key resolution uses: main/profile `'main'`, fast `'fast'`. */
50
+ keyRole: ModelRole;
51
+ /** The profile entry a `profile` request hit, dedicated key included; else null. */
52
+ profile: ModelProfile | null;
53
+ /** Present only when `effective !== mode`. */
54
+ fallbackReason?: SubagentFallbackReason;
55
+ }
56
+ /** The `AgentController.hasApiKey` shape, injected so there is one key resolver. */
57
+ export type HasApiKey = (providerId: string, role?: ModelRole) => boolean;
58
+ /** The lead-layer key resolver `resolveSubagentKey` falls back to. */
59
+ export type SharedKeyResolver = (providerId: string, role: ModelRole) => string | undefined;
60
+ /**
61
+ * Resolve the model every child of the NEXT dispatch will run on.
62
+ *
63
+ * Pure, synchronous, no IO — testable as a matrix over config fixtures.
64
+ *
65
+ * @param config The live effective config. Read fresh; never snapshot it.
66
+ * @param hasKey `AgentController.hasApiKey`, which already resolves the
67
+ * config-file key, the provider environment variable and the one-shot
68
+ * `--api-key` override. Injected rather than reimplemented for the reason
69
+ * `resolveFastTier` records: a second key resolver is a second answer to
70
+ * "why does it say no key?".
71
+ */
72
+ export declare function resolveSubagentModel(config: CliConfig, hasKey: HasApiKey): SubagentModelResolution;
73
+ /**
74
+ * The key a CHILD resolves for a provider, profile-aware (§3.3 / I-4).
75
+ *
76
+ * A profile's dedicated key is visible ONLY here: it never reaches env,
77
+ * `config.apiKeys` or a log line. While the resolution's profile is bound and
78
+ * the provider matches it, the entry key takes priority over the shared layer;
79
+ * every other provider (and every non-profile mode) resolves through the
80
+ * lead's own resolver at `resolution.keyRole` — which is what keeps a `fast`
81
+ * child entitled to the fast-role key and a `main` child to the main one.
82
+ *
83
+ * @param config The live config (carried for parity with the resolver pair).
84
+ * @param resolution A fresh `resolveSubagentModel` output.
85
+ * @param providerId The provider the child needs a key for.
86
+ * @param shared The lead-layer resolver (`controller.resolveKey`).
87
+ */
88
+ export declare function resolveSubagentKey(config: CliConfig, resolution: SubagentModelResolution, providerId: string, shared: SharedKeyResolver): string | undefined;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `resolveSubagentModel` — THE single authority on which model a team child
3
+ * runs (subagent-model-and-question-controls §3.3).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * EVERY CONSUMER READS THE CHILD'S MODEL THROUGH THIS FUNCTION AND NOWHERE
8
+ * ELSE — `createSubagent`, the `task` report header, the settings root row and
9
+ * `/team model` — following the `resolveFastTier` precedent one directory over:
10
+ * four call sites each recomputing "which model do children run on?" from raw
11
+ * config keys is exactly how the report ends up naming a model the children
12
+ * silently fell back from.
13
+ *
14
+ * IT IS RE-RUN ON EVERY DISPATCH AND EVERY RENDER, NOT ONCE (rule 8 / RV-3 of
15
+ * the fast tier, inherited here): `team.model`, the fast tier and the profile
16
+ * library are all LIVE keys a settings save, `/fast off` or `/team model` can
17
+ * rewrite mid-session. A resolution captured at construction would be a
18
+ * resolution describing a session that no longer exists.
19
+ *
20
+ * A CHILD IS NEVER LEFT WITHOUT A MODEL (D-4). Every failure mode — fast tier
21
+ * off, profile deleted, profile key missing — degrades to the MAIN connection
22
+ * with a `fallbackReason`, so a dispatch cannot fail because its model setting
23
+ * went stale. The reason is disclosed, never swallowed: the report header and
24
+ * the settings summary line both render it.
25
+ */
26
+ import { resolveFastTier } from '../fast/resolve.js';
27
+ function mainRef(config) {
28
+ return {
29
+ providerId: config.provider,
30
+ modelId: config.model,
31
+ ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
32
+ };
33
+ }
34
+ function fallbackToMain(config, mode, fallbackReason) {
35
+ // THE ALWAYS-USABLE ANSWER (D-4): whatever the user asked for that could not
36
+ // resolve, the caller still gets a working connection — the same provider,
37
+ // model, base URL and thinking level the lead itself runs on.
38
+ return {
39
+ mode,
40
+ effective: 'main',
41
+ ref: mainRef(config),
42
+ thinkingLevel: config.thinkingLevel,
43
+ keyRole: 'main',
44
+ profile: null,
45
+ fallbackReason,
46
+ };
47
+ }
48
+ /**
49
+ * Resolve the model every child of the NEXT dispatch will run on.
50
+ *
51
+ * Pure, synchronous, no IO — testable as a matrix over config fixtures.
52
+ *
53
+ * @param config The live effective config. Read fresh; never snapshot it.
54
+ * @param hasKey `AgentController.hasApiKey`, which already resolves the
55
+ * config-file key, the provider environment variable and the one-shot
56
+ * `--api-key` override. Injected rather than reimplemented for the reason
57
+ * `resolveFastTier` records: a second key resolver is a second answer to
58
+ * "why does it say no key?".
59
+ */
60
+ export function resolveSubagentModel(config, hasKey) {
61
+ // `clampTeamConfig` guarantees the enum on every read and write path, so the
62
+ // default `'main'` here is belt-and-braces rather than a real branch.
63
+ const mode = config.team.model ?? 'main';
64
+ if (mode === 'fast') {
65
+ const tier = resolveFastTier(config, hasKey);
66
+ if (tier.ok) {
67
+ return {
68
+ mode,
69
+ effective: 'fast',
70
+ ref: tier.ref,
71
+ thinkingLevel: config.fast.thinkingLevel,
72
+ keyRole: 'fast',
73
+ profile: null,
74
+ };
75
+ }
76
+ return fallbackToMain(config, mode, 'fast_unavailable');
77
+ }
78
+ if (mode === 'profile') {
79
+ // A missing library, a missing id and a dangling id are one outcome
80
+ // (D-4): degrade to main and say so. Reference EXISTENCE is deliberately
81
+ // not validated in `clampTeamConfig` — resolution is where it belongs.
82
+ const id = config.team.modelProfileId;
83
+ const entry = id
84
+ ? config.modelProfiles?.entries.find((candidate) => candidate.id === id)
85
+ : undefined;
86
+ if (!entry)
87
+ return fallbackToMain(config, mode, 'profile_missing');
88
+ // The dedicated entry key wins; without one the shared layer must have a
89
+ // key for the entry's provider, or the child could not authenticate at all.
90
+ if (!entry.apiKey && !hasKey(entry.provider, 'main')) {
91
+ return fallbackToMain(config, mode, 'profile_no_key');
92
+ }
93
+ return {
94
+ mode,
95
+ effective: 'profile',
96
+ ref: {
97
+ providerId: entry.provider,
98
+ modelId: entry.model,
99
+ ...(entry.baseUrl ? { baseUrl: entry.baseUrl } : {}),
100
+ },
101
+ thinkingLevel: config.thinkingLevel,
102
+ keyRole: 'main',
103
+ profile: entry,
104
+ };
105
+ }
106
+ return {
107
+ mode: 'main',
108
+ effective: 'main',
109
+ ref: mainRef(config),
110
+ thinkingLevel: config.thinkingLevel,
111
+ keyRole: 'main',
112
+ profile: null,
113
+ };
114
+ }
115
+ /**
116
+ * The key a CHILD resolves for a provider, profile-aware (§3.3 / I-4).
117
+ *
118
+ * A profile's dedicated key is visible ONLY here: it never reaches env,
119
+ * `config.apiKeys` or a log line. While the resolution's profile is bound and
120
+ * the provider matches it, the entry key takes priority over the shared layer;
121
+ * every other provider (and every non-profile mode) resolves through the
122
+ * lead's own resolver at `resolution.keyRole` — which is what keeps a `fast`
123
+ * child entitled to the fast-role key and a `main` child to the main one.
124
+ *
125
+ * @param config The live config (carried for parity with the resolver pair).
126
+ * @param resolution A fresh `resolveSubagentModel` output.
127
+ * @param providerId The provider the child needs a key for.
128
+ * @param shared The lead-layer resolver (`controller.resolveKey`).
129
+ */
130
+ export function resolveSubagentKey(config, resolution, providerId, shared) {
131
+ void config; // parity parameter: the pair reads one config, the key does not need it
132
+ if (resolution.profile && providerId === resolution.ref.providerId) {
133
+ return resolution.profile.apiKey ?? shared(providerId, 'main');
134
+ }
135
+ return shared(providerId, resolution.keyRole);
136
+ }
@@ -21,6 +21,7 @@
21
21
  * because of one HTTP 429.
22
22
  */
23
23
  import type { ModelCost } from '@aragon-agent/core';
24
+ import type { SubagentModelResolution } from './model.js';
24
25
  import type { DispatchOutcome, SubagentRun } from './types.js';
25
26
  export interface ReportOptions {
26
27
  /** The lead's model cost table, so the header can state real spend (R-5).
@@ -37,6 +38,16 @@ export interface ReportOptions {
37
38
  * rule the fast tier already follows everywhere else).
38
39
  */
39
40
  fastCost?: ModelCost;
41
+ /**
42
+ * The dispatch-time subagent model resolution (subagent-model §3.4).
43
+ *
44
+ * ABSENT under the default `team.model: 'main'` mode is what keeps the
45
+ * parity report byte-identical (I-2): the disclosure line exists only when
46
+ * children did NOT run the main model, and a resolution that degraded
47
+ * (fast tier off, profile deleted, profile key missing) says so instead of
48
+ * silently renaming the model the children fell back to (D-4).
49
+ */
50
+ subagentModel?: SubagentModelResolution;
40
51
  }
41
52
  /**
42
53
  * Cut `text` to at most `maxBytes` WITHOUT splitting a UTF-8 sequence.
@@ -106,8 +106,31 @@ function buildHeader(outcome, options) {
106
106
  `(${counts}) in ${formatDuration(durationMs)}.`
107
107
  : `Team dispatch: ${outcome.runs.length} of ${outcome.runs.length} subagents finished ` +
108
108
  `(${counts}) in ${formatDuration(durationMs)}.`);
109
+ // THE PRICE OF THE MODEL CHILDREN ACTUALLY RAN (subagent-model §3.4). The
110
+ // default main flow always supplies a table, so the ternary's last branch
111
+ // is the legacy byte-identical shape; an UNPRICED fast or profile child
112
+ // model says `pricing unknown` - unknown pricing is not zero pricing, and
113
+ // `$0.00` on a dispatch that is spending money is the C-11 / RV-4 lie.
114
+ const spend = options.cost
115
+ ? formatCost(computeCost(outcome.usage, options.cost))
116
+ : options.subagentModel && options.subagentModel.effective !== 'main'
117
+ ? 'pricing unknown'
118
+ : formatCost(computeCost(outcome.usage, options.cost));
109
119
  lines.push(`Tokens: in ${formatTokens(outcome.usage.inputTokens)}, out ` +
110
- `${formatTokens(outcome.usage.outputTokens)}. Cost: ${formatCost(computeCost(outcome.usage, options.cost))}.`);
120
+ `${formatTokens(outcome.usage.outputTokens)}. Cost: ${spend}.`);
121
+ // THE MODEL DISCLOSURE LINE (D-4), immediately after the spend it explains.
122
+ // Rendered only when children did not run the main model, so the default
123
+ // report stays byte-identical (I-2) and a degraded request says WHICH mode
124
+ // was asked for and WHY it fell back rather than quietly renaming it.
125
+ const subagent = options.subagentModel;
126
+ if (subagent && subagent.effective !== 'main') {
127
+ lines.push(`Children ran on ${subagent.ref.providerId}:${subagent.ref.modelId} ` +
128
+ `(subagent model: ${subagent.effective}).`);
129
+ }
130
+ else if (subagent && subagent.fallbackReason) {
131
+ lines.push(`Subagent model ${subagent.mode} unavailable (${subagent.fallbackReason}); ` +
132
+ `children ran on the main model.`);
133
+ }
111
134
  for (const conflict of findFileConflicts(outcome.runs)) {
112
135
  const who = conflict.labels.map((l) => `"${l}"`).join(' and ');
113
136
  lines.push(`WARNING: agents ${who} both wrote ${conflict.path} - review before trusting either.`);
@@ -18,6 +18,7 @@ import { createBuiltinTools } from '../tools/index.js';
18
18
  import { pickActivityArgs, sanitizeActivity } from './activity.js';
19
19
  import { TEAM_LIMITS, TEAM_SUBAGENT_TOOL_NAMES } from './limits.js';
20
20
  import { isRetryableStreamError } from './retry.js';
21
+ import { resolveSubagentKey, resolveSubagentModel } from './model.js';
21
22
  import { makeTeamSend, makeTeamWait, withMailboxTail } from './comm-tools.js';
22
23
  import { buildSubagentBlock } from './prompt.js';
23
24
  /** The default factory: a real `Agent` from `@aragon-agent/core`. */
@@ -162,25 +163,34 @@ export function createSubagent(spec, deps, hooks) {
162
163
  let agentRef = null;
163
164
  const getAgent = () => agentRef;
164
165
  const tools = buildSubagentTools(spec, deps, getAgent);
165
- // THE CHILD'S MODEL IS THE LEAD'S MODEL, READ FROM THE DISPATCH-TIME CONFIG
166
- // (main-agent parity). Same provider, same model id, same base URL, same
167
- // thinking level: a delegation is never a downgrade, and the only model a
168
- // child may surprise its lead with is one a settings-screen edit made live
169
- // between two dispatches.
166
+ // WHICH MODEL A CHILD RUNS IS A USER SETTING, RESOLVED FROM THE
167
+ // DISPATCH-TIME CONFIG through the one authority (subagent-model §3.4).
168
+ // `team.model: 'main'` - the default - is the historical main-agent parity
169
+ // exactly: same provider, model, base URL and thinking level as the lead.
170
+ // `'fast'` / `'profile'` are the user's explicit, visible, reversible cost
171
+ // switches, and EVERY failure mode (tier off, profile deleted, profile key
172
+ // missing) degrades back to the main connection with a disclosed reason
173
+ // rather than failing the dispatch (D-4). The MODEL itself still cannot
174
+ // choose: `normalizeSubagentSpecs` drops a stale `model` key (D-1).
170
175
  //
171
176
  // `maxTokens` inherits `deps.config.maxTokens` below - the same ambition the
172
- // lead sends, clamped by the same adapter, so an inherited large value
173
- // degrades to the right number rather than to an HTTP 400.
174
- const resolved = {
175
- ref: {
176
- providerId: deps.config.provider,
177
- modelId: deps.config.model,
178
- ...(deps.config.baseUrl ? { baseUrl: deps.config.baseUrl } : {}),
179
- },
180
- thinkingLevel: deps.config.thinkingLevel,
181
- role: 'main',
182
- };
183
- const model = resolved.ref;
177
+ // lead sends, clamped by Core's `resolveOutputTokens` against the CHILD
178
+ // model's own ceiling, so an inherited large value degrades to the right
179
+ // number rather than to an HTTP 400.
180
+ const resolution = resolveSubagentModel(deps.config, (id, role) => Boolean(deps.getApiKey(id, role)));
181
+ const model = resolution.ref;
182
+ // THE CHILD'S OWN KEY CLOSURE, PROFILE-AWARE (§3.3 / I-4). A bound profile's
183
+ // dedicated key is visible ONLY inside this closure - never in env, config
184
+ // `apiKeys` or a log line - while every other provider resolves through the
185
+ // lead's resolver at `resolution.keyRole`, which is what keeps a fast child
186
+ // entitled to the fast-role key and a main child to the main one.
187
+ const childGetApiKey = (id) => resolveSubagentKey(deps.config, resolution, id, (providerId, role) => deps.getApiKey(providerId, role));
188
+ // The boolean twin, for the child's compaction summarizer (V-4): the child's
189
+ // own resolution first, the lead layer by role second. Forwarding only
190
+ // `deps.getApiKey` would hide a profile-dedicated key from `resolveSummarizer`
191
+ // and silently disable the child's compaction - the exact gap the req-level
192
+ // resolvers on `ChildContextManagerRequest` exist to close.
193
+ const childHasKey = (id, role) => Boolean(childGetApiKey(id) ?? deps.getApiKey(id, role));
184
194
  // HOISTED TO A LOCAL, AND THAT IS WHAT MAKES W3 POSSIBLE AT ALL. This was built
185
195
  // INLINE inside the `agentFactory({...})` argument, so there was nothing for the
186
196
  // child manager's `getSystemPrompt` accessor to close over - and the accessor
@@ -216,7 +226,12 @@ export function createSubagent(spec, deps, hooks) {
216
226
  const contextManager = deps.contextManagerFor?.({
217
227
  label: spec.label,
218
228
  model,
219
- role: resolved.role ?? 'main',
229
+ // keyRole (V-4): a fast child's summarizer resolves its main choice with
230
+ // role='fast', so a fast-only key is visible to it; profile keys reach it
231
+ // through the req-level resolvers below instead.
232
+ role: resolution.keyRole,
233
+ getApiKey: childGetApiKey,
234
+ hasKey: childHasKey,
220
235
  getMessages: () => agentRef?.state.messages ?? [],
221
236
  getSystemPrompt: () => systemPrompt,
222
237
  onCompacted: () => {
@@ -227,12 +242,13 @@ export function createSubagent(spec, deps, hooks) {
227
242
  systemPrompt,
228
243
  model,
229
244
  tools,
230
- thinkingLevel: resolved.thinkingLevel,
245
+ thinkingLevel: resolution.thinkingLevel,
231
246
  // The LEAD's registry instance, REUSED (P2-2). It holds adapter state, and
232
247
  // constructing five of them per dispatch is pure waste.
233
248
  providerRegistry: deps.providerRegistry,
234
- // The lead's closure, so a settings-screen key edit reaches children too.
235
- getApiKey: (id) => deps.getApiKey(id, resolved.role ?? 'main'),
249
+ // The child's own profile-aware closure, so a settings-screen key edit
250
+ // reaches children too and a bound profile's key reaches ONLY them.
251
+ getApiKey: childGetApiKey,
236
252
  ...(deps.config.maxTokens !== undefined ? { maxTokens: deps.config.maxTokens } : {}),
237
253
  timeouts: {
238
254
  toolTimeout: deps.config.toolTimeoutMs,
@@ -20,14 +20,17 @@
20
20
  * in the schema would behave differently depending on whether `ajv`, an OPTIONAL
21
21
  * dependency of core, happened to install.
22
22
  *
23
- * EVERY CHILD RUNS THE LEAD'S OWN MODEL (main-agent parity): same provider,
24
- * model, thinking level and token ambition, resolved from the LIVE config at
25
- * dispatch. There is no per-child `model` property and no cheap tier to pick -
26
- * a delegation is never a downgrade, and the fast tier reaches children the
27
- * same way it reaches the lead (compaction summaries, per-child reviews).
23
+ * WHICH MODEL A CHILD RUNS IS THE USER'S SETTING, NOT THE MODEL'S (D-1):
24
+ * `team.model` defaults to the lead's own connection (main-agent parity) and
25
+ * can name the fast tier or a profile; there is still no per-child `model`
26
+ * property, so a delegation is never a self-downgrade. Resolution lives in
27
+ * `team/model.ts` and is re-read from the LIVE config at every dispatch; the
28
+ * fast tier otherwise reaches children the way it reaches the lead
29
+ * (compaction summaries, per-child reviews).
28
30
  */
29
31
  import { type AgentTool } from '@aragon-agent/core';
30
32
  import type { ModelCost } from '@aragon-agent/core';
33
+ import type { SubagentModelResolution } from './model.js';
31
34
  import type { TeamRuntime } from './runtime.js';
32
35
  export interface TaskToolDeps {
33
36
  runtime: TeamRuntime;
@@ -37,8 +40,24 @@ export interface TaskToolDeps {
37
40
  /** Whether a usable key exists for the active provider right now (P2-3). */
38
41
  hasApiKey: () => boolean;
39
42
  activeProvider: () => string;
40
- /** The lead's cost table, so the report header states real spend (R-5). */
43
+ /**
44
+ * The cost table of the model children ACTUALLY run (subagent-model §3.4).
45
+ *
46
+ * The controller resolves `team.model` live and prices `modelCost` at the
47
+ * EFFECTIVE model's table: with the default `main` mode this is exactly the
48
+ * lead's own table, so the report's spend line is byte-identical to the
49
+ * parity behavior; with `fast` / `profile` it is that model's table, and an
50
+ * UNPRICED one yields `undefined` so the header says `pricing unknown`
51
+ * rather than fabricating `$0.00` (C-11 / RV-4).
52
+ */
41
53
  modelCost: () => ModelCost | undefined;
54
+ /**
55
+ * The dispatch-time subagent model resolution, for the report's disclosure
56
+ * line (D-4): rendered ONLY when children did not run the main model, and
57
+ * carrying the fallback reason when they could not. OPTIONAL so existing
58
+ * construction sites compile; absent simply omits the line.
59
+ */
60
+ subagentModel?: () => SubagentModelResolution;
42
61
  /**
43
62
  * The FAST tier's cost table, so the report's supervisor spend line states
44
63
  * real spend (subagent-overseer-v2 D-7 / R-P1-2). OPTIONAL and returning
@@ -20,11 +20,13 @@
20
20
  * in the schema would behave differently depending on whether `ajv`, an OPTIONAL
21
21
  * dependency of core, happened to install.
22
22
  *
23
- * EVERY CHILD RUNS THE LEAD'S OWN MODEL (main-agent parity): same provider,
24
- * model, thinking level and token ambition, resolved from the LIVE config at
25
- * dispatch. There is no per-child `model` property and no cheap tier to pick -
26
- * a delegation is never a downgrade, and the fast tier reaches children the
27
- * same way it reaches the lead (compaction summaries, per-child reviews).
23
+ * WHICH MODEL A CHILD RUNS IS THE USER'S SETTING, NOT THE MODEL'S (D-1):
24
+ * `team.model` defaults to the lead's own connection (main-agent parity) and
25
+ * can name the fast tier or a profile; there is still no per-child `model`
26
+ * property, so a delegation is never a self-downgrade. Resolution lives in
27
+ * `team/model.ts` and is re-read from the LIVE config at every dispatch; the
28
+ * fast tier otherwise reaches children the way it reaches the lead
29
+ * (compaction summaries, per-child reviews).
28
30
  */
29
31
  import { errorResult, textResult } from '@aragon-agent/core';
30
32
  import { TEAM_LIMITS } from './limits.js';
@@ -39,8 +41,9 @@ const TEAM_OFF_REFUSAL = 'Team mode is off for this session. Do this work yourse
39
41
  */
40
42
  const TASK_DESCRIPTION = 'Run several independent subagents in parallel and get one combined report. Each ' +
41
43
  'subagent is a fresh agent with the same tools and working directory but NO memory of ' +
42
- 'this conversation - its prompt must be self-contained. Each runs on the same model ' +
43
- 'you are running on. Use this when a task splits ' +
44
+ 'this conversation - its prompt must be self-contained. Each runs on the model the ' +
45
+ 'user configured for subagents (the main model by default). Use this when a task ' +
46
+ 'splits ' +
44
47
  'into 2 or more parts that do not depend on each other\'s output, especially reading or ' +
45
48
  'searching several areas at once. Do NOT use it when one part needs another part\'s ' +
46
49
  'result (do those yourself, in order), when two parts would edit the same file, or for ' +
@@ -120,13 +123,16 @@ export function createTaskTool(deps) {
120
123
  // A cancellation or a partial result rendered as a tool FAILURE invites
121
124
  // the model to retry the whole fan-out, which is the most expensive
122
125
  // possible reaction (the same reasoning as `ask_user`'s cancelled
123
- // shape, and D-14 one level up). Every child ran the lead's own model,
124
- // so the lead's cost table is the one honest price for the total.
126
+ // shape, and D-14 one level up). `modelCost` is priced at the model
127
+ // children ACTUALLY ran - the lead's own table under the default
128
+ // `main` mode - so the total stays one honest price.
125
129
  const cost = deps.modelCost();
126
130
  const fastCost = deps.fastModelCost?.();
131
+ const subagentModel = deps.subagentModel?.();
127
132
  return textResult(buildDispatchReport(outcome, {
128
133
  ...(cost ? { cost } : {}),
129
134
  ...(fastCost ? { fastCost } : {}),
135
+ ...(subagentModel ? { subagentModel } : {}),
130
136
  }));
131
137
  },
132
138
  };
@@ -42,9 +42,11 @@ export interface TodoSnapshot {
42
42
  /**
43
43
  * Why a list went away.
44
44
  *
45
- * `user` - `/todo clear`, or `/clear`. Both are an explicit user instruction,
46
- * which is I-2's standing exception; neither touches `messages`.
47
- * `reset` - `/reset`, or a `/resume` whose file carried no list (§3.13).
45
+ * `user` - `/todo clear`, or `/clear screen` (the screen-only form). Both are
46
+ * an explicit user instruction, which is I-2's standing exception;
47
+ * neither touches `messages`.
48
+ * `reset` - `/clear` (or its alias `/reset`) through `clearMessages()`, or a
49
+ * `/resume` whose file carried no list (§3.13).
48
50
  * `turn` - a new TUI task started, or a legacy caller began a turn after
49
51
  * every item was completed.
50
52
  * `stale` - unfinished, and unwritten for more than `TODO_LIMITS.staleTurns`
package/dist/ui/App.js CHANGED
@@ -55,6 +55,7 @@ import { publishExitSnapshot } from './exit-snapshot.js';
55
55
  import { OverlayFrame } from './layout/OverlayFrame.js';
56
56
  import { helpRows } from './overlays/HelpOverlay.js';
57
57
  import { ModelPicker } from './overlays/ModelPicker.js';
58
+ import { createModelProbe } from '../agent/model-probe.js';
58
59
  import { SettingsScreen, compactionSettingsFrom, fastSettingsFrom, } from './overlays/SettingsScreen.js';
59
60
  import { ConfirmDialog } from './overlays/ConfirmDialog.js';
60
61
  import { QuestionOverlay } from './overlays/QuestionOverlay.js';
@@ -1388,6 +1389,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1388
1389
  requestCopy: copyCoordinator.requestCopy,
1389
1390
  controller,
1390
1391
  state: stateRef.current,
1392
+ // THE ARCHIVE'S ONLY WAY TO SEE THE SCREEN (P0-1 / §3.7), and a thunk for a
1393
+ // reason `state` above illustrates: `state` is a snapshot of this render,
1394
+ // while `/clear` interrupts a run and then waits for it to unwind, so the last
1395
+ // tool cards of the abandoned run are appended AFTER the command starts. A
1396
+ // value here would archive the screen minus the part the user was watching.
1397
+ transcriptEntries: () => stateRef.current.entries,
1391
1398
  dispatch: (action) => {
1392
1399
  if (action.type === 'resetConversation' || action.type === 'restoreEntries') {
1393
1400
  metrics.current = createRunMetrics();
@@ -1535,12 +1542,25 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1535
1542
  };
1536
1543
  submitRef.current = submitMessage;
1537
1544
  const executeSlashInput = async (raw) => {
1538
- const switchWhileBusy = controller.isCompactionBusy?.()
1539
- && /^\s*\/(clear|reset|resume)(?:\s|$)/.test(raw);
1540
- if (switchWhileBusy) {
1545
+ // A CONVERSATION SWITCH OWNS THE QUEUE FOR THE WHOLE COMMAND (B-6 / §3.7).
1546
+ //
1547
+ // The guard used to be raised only when a compaction happened to be busy,
1548
+ // which left the one window this feature is about unwatched: `/clear` on an
1549
+ // IDLE session took the flag nowhere, and `drainCompactionQueue` was still
1550
+ // free to `continue()` a message queued in the previous conversation into
1551
+ // the one the user had just started. Whether there is work to cancel is a
1552
+ // separate question, so the two are now separate values: `switching` decides
1553
+ // the lock, `switchWhileBusy` decides the cancellation.
1554
+ //
1555
+ // `/resume` is still refused mid-run by `handleSubmit` below, so for it the
1556
+ // lock only ever matters while idle - that is the minimum this invariant
1557
+ // asks for, not a contradiction.
1558
+ const switching = /^\s*\/(clear|reset|resume)(?:\s|$)/.test(raw);
1559
+ const switchWhileBusy = switching && Boolean(controller.isCompactionBusy?.());
1560
+ if (switching)
1541
1561
  switchingConversation.current = true;
1562
+ if (switchWhileBusy)
1542
1563
  await controller.cancelCompaction();
1543
- }
1544
1564
  // Retain a recoverable command before any asynchronous handler can fail.
1545
1565
  const safe = redactText(raw, getSecrets());
1546
1566
  recordPrompt(safe);
@@ -1553,7 +1573,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1553
1573
  notify('error', `Command failed: ${redactText(reason, getSecrets())}\nInput: ${safe}`);
1554
1574
  }
1555
1575
  finally {
1556
- if (switchWhileBusy) {
1576
+ if (switching) {
1557
1577
  switchingConversation.current = false;
1558
1578
  drainCompactionQueue.current();
1559
1579
  }
@@ -1563,7 +1583,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1563
1583
  const trimmed = raw.trimStart();
1564
1584
  const slash = trimmed.startsWith('/') && !trimmed.startsWith('//');
1565
1585
  if (slash && (interactionPhase.current === 'running' || controller.isRunning()) &&
1566
- /^\/(reset|resume)(?:\s|$)/.test(trimmed)) {
1586
+ /^\/resume(?:\s|$)/.test(trimmed)) {
1567
1587
  return { accepted: false,
1568
1588
  reason: 'Interrupt the current run before switching conversations.' };
1569
1589
  }
@@ -2050,6 +2070,18 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2050
2070
  const overlay = state.overlay;
2051
2071
  const settingsDraft = useMemo(() => overlay === 'settings'
2052
2072
  ? controller.createModelSettingsDraft() : undefined, [controller, overlay]);
2073
+ // ONE PROBE PER APP (model-profile-connection-test 5 row 9): the port needs the
2074
+ // controller's provider registry, which the overlay has no business owning.
2075
+ // Built only while the settings overlay is open, so no other screen pays for it.
2076
+ const probe = useMemo(() => {
2077
+ if (overlay !== 'settings')
2078
+ return undefined;
2079
+ // A test double or an embedded host may predate this port; absent means the
2080
+ // two probe rows read `(unavailable)` rather than the screen crashing.
2081
+ if (typeof controller.getProviderRegistry !== 'function')
2082
+ return undefined;
2083
+ return createModelProbe({ registry: controller.getProviderRegistry() });
2084
+ }, [controller, overlay]);
2053
2085
  const hasKey = controller.hasApiKey();
2054
2086
  const empty = state.entries.length === 0;
2055
2087
  // RENDER SCOPE, so the chip and the composer hint agree with what the Ctrl+C
@@ -2144,13 +2176,18 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2144
2176
  // `auto`, not an empty string: an empty field reads as "unset", which
2145
2177
  // is indistinguishable from AUTO and is not a state this can be in.
2146
2178
  maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : 'auto',
2179
+ // BOTH HALVES, seeded AND read back (§6.3): the sibling comment above
2180
+ // records what omitting either one costs. `auto` mirrors the row's
2181
+ // empty-field reading; a bound Main profile hides the row entirely
2182
+ // and the profile editor seeds from the entry instead.
2183
+ contextWindow: cfg.contextWindow === null ? 'auto' : String(cfg.contextWindow),
2147
2184
  apiKey: '',
2148
2185
  logLevel: cfg.log.level,
2149
2186
  // The four fast rows, seeded through the pure mapper so the screen and
2150
2187
  // the save handler cannot disagree about what `(inherit)` means.
2151
2188
  ...fastSettingsFrom(cfg.fast),
2152
2189
  ...compactionSettingsFrom(cfg.compaction),
2153
- }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, draft: settingsDraft, onProfileSave: handleProfileSave, onReload: () => controller.createModelSettingsDraft(), onClose: () => dispatch({ type: 'setOverlay', overlay: null }),
2190
+ }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, draft: settingsDraft, probe: probe, onProfileSave: handleProfileSave, onReload: () => controller.createModelSettingsDraft(), onClose: () => dispatch({ type: 'setOverlay', overlay: null }),
2154
2191
  // Read from `cfg` (render scope, re-read every frame), so `/retry off`
2155
2192
  // while the screen is open shows the new value on the next paint.
2156
2193
  retrySummary: cfg.retry.enabled && cfg.retry.maxRetries > 0 ? String(cfg.retry.maxRetries) : 'off' })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { isActive: !tooSmall, state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { isActive: !tooSmall, questions: humanRequest.questions, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { isActive: !tooSmall, plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
@@ -14,10 +14,11 @@
14
14
  * kind — without it the card renders a hint that does nothing and expands an
15
15
  * older tool card instead (C-15 / P1-8 / AC-21).
16
16
  *
17
- * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9). `/clear`
18
- * empties the transcript view but not `messages`, so this card can legitimately
19
- * report "112 -> 9 messages" with nine visible rows above it. Nothing here says
20
- * "above" or "earlier in this transcript".
17
+ * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9).
18
+ * `/clear screen` empties the transcript view but not `messages`, so this card
19
+ * can legitimately report a message count the visible rows do not add up to.
20
+ * Nothing here says "above" or "earlier in this transcript". `/clear` itself is
21
+ * the conversation boundary now: it drops the history and this card with it.
21
22
  */
22
23
  import React from 'react';
23
24
  import type { Theme } from '../theme.js';
@@ -15,10 +15,11 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
15
15
  * kind — without it the card renders a hint that does nothing and expands an
16
16
  * older tool card instead (C-15 / P1-8 / AC-21).
17
17
  *
18
- * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9). `/clear`
19
- * empties the transcript view but not `messages`, so this card can legitimately
20
- * report "112 -> 9 messages" with nine visible rows above it. Nothing here says
21
- * "above" or "earlier in this transcript".
18
+ * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9).
19
+ * `/clear screen` empties the transcript view but not `messages`, so this card
20
+ * can legitimately report a message count the visible rows do not add up to.
21
+ * Nothing here says "above" or "earlier in this transcript". `/clear` itself is
22
+ * the conversation boundary now: it drops the history and this card with it.
22
23
  */
23
24
  import React from 'react';
24
25
  import { Box, Text } from 'ink';