@aragon-agent/cli 0.6.34 → 0.6.35

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 (64) hide show
  1. package/README.md +3071 -3007
  2. package/dist/agent/controller.d.ts +29 -0
  3. package/dist/agent/controller.js +42 -1
  4. package/dist/agent/kit-apply.d.ts +56 -0
  5. package/dist/agent/kit-apply.js +209 -0
  6. package/dist/agent/model-profile-settings.d.ts +28 -0
  7. package/dist/agent/model-profile-settings.js +142 -29
  8. package/dist/agent/reducer.d.ts +1 -1
  9. package/dist/commands/builtins.js +4 -0
  10. package/dist/commands/kit-command.d.ts +20 -0
  11. package/dist/commands/kit-command.js +131 -0
  12. package/dist/config/kit-resolution.d.ts +93 -0
  13. package/dist/config/kit-resolution.js +226 -0
  14. package/dist/config/model-kits.d.ts +224 -0
  15. package/dist/config/model-kits.js +302 -0
  16. package/dist/config/model-profile-store.d.ts +25 -0
  17. package/dist/config/model-profiles.d.ts +41 -3
  18. package/dist/config/model-profiles.js +46 -5
  19. package/dist/config/schema.d.ts +16 -0
  20. package/dist/input/keymap.js +18 -4
  21. package/dist/ui/App.js +108 -12
  22. package/dist/ui/Header.d.ts +9 -1
  23. package/dist/ui/Header.js +8 -2
  24. package/dist/ui/PromptInput.js +1 -1
  25. package/dist/ui/ScrollbackShell.d.ts +18 -3
  26. package/dist/ui/ScrollbackShell.js +1 -1
  27. package/dist/ui/StatusBar.d.ts +13 -0
  28. package/dist/ui/StatusBar.js +10 -3
  29. package/dist/ui/TeamPanel.d.ts +12 -0
  30. package/dist/ui/TeamPanel.js +10 -31
  31. package/dist/ui/Transcript.js +22 -2
  32. package/dist/ui/entries/TeamCard.d.ts +18 -2
  33. package/dist/ui/entries/TeamCard.js +47 -13
  34. package/dist/ui/layout/header-hints.d.ts +11 -1
  35. package/dist/ui/layout/header-hints.js +35 -3
  36. package/dist/ui/layout/kit-chip.d.ts +50 -0
  37. package/dist/ui/layout/kit-chip.js +81 -0
  38. package/dist/ui/layout/virtual-window.js +9 -0
  39. package/dist/ui/overlays/HelpOverlay.js +7 -0
  40. package/dist/ui/overlays/KitEditor.d.ts +23 -0
  41. package/dist/ui/overlays/KitEditor.js +101 -0
  42. package/dist/ui/overlays/KitManager.d.ts +23 -0
  43. package/dist/ui/overlays/KitManager.js +104 -0
  44. package/dist/ui/overlays/KitPicker.d.ts +49 -0
  45. package/dist/ui/overlays/KitPicker.js +58 -0
  46. package/dist/ui/overlays/ModelProfilePicker.js +16 -6
  47. package/dist/ui/overlays/SettingsScreen.js +139 -16
  48. package/dist/ui/overlays/kit-picker-state.d.ts +87 -0
  49. package/dist/ui/overlays/kit-picker-state.js +136 -0
  50. package/dist/ui/overlays/kit-settings-state.d.ts +129 -0
  51. package/dist/ui/overlays/kit-settings-state.js +267 -0
  52. package/dist/ui/overlays/model-profile-state.d.ts +26 -3
  53. package/dist/ui/overlays/model-profile-state.js +59 -6
  54. package/dist/ui/scrollback/LiveTail.d.ts +2 -2
  55. package/dist/ui/scrollback/LiveTail.js +7 -2
  56. package/dist/ui/scrollback/live-preview.js +10 -3
  57. package/dist/ui/scrollback/tail-window.d.ts +12 -0
  58. package/dist/ui/scrollback/tail-window.js +17 -2
  59. package/dist/ui/scrollback/use-scrollback-view.js +7 -0
  60. package/dist/ui/team-status.d.ts +141 -0
  61. package/dist/ui/team-status.js +224 -0
  62. package/dist/ui/transcript-text.js +13 -4
  63. package/dist/ui/use-wheel-routing.js +2 -2
  64. package/package.json +2 -2
@@ -1,4 +1,6 @@
1
1
  import { type ModelSettingsSnapshot } from './model-profile-settings.js';
2
+ import { type ActiveKit } from '../config/kit-resolution.js';
3
+ import type { KitApplyResult } from '../config/model-kits.js';
2
4
  import type { ModelSettingsDraft, ModelSettingsPatch, ModelSettingsSaveResult } from '../config/model-profile-store.js';
3
5
  import { type CompactionIdentity } from '../compaction/memory-identity.js';
4
6
  /**
@@ -629,6 +631,33 @@ export declare class AgentController {
629
631
  saveModelSettingsPatch(patch: ModelSettingsPatch): ModelSettingsSaveResult;
630
632
  /** Refresh both role connections and credentials while retaining conversation state. */
631
633
  reloadModelSettings(): ModelSettingsSaveResult;
634
+ /**
635
+ * The narrow port `agent/kit-apply.ts` runs against (agent-model-kits §6.3).
636
+ *
637
+ * A LITERAL ADAPTER RATHER THAN `this`: the port's member names (`isBusy`,
638
+ * `hasKey`, `preview`) are deliberately NOT the controller's names, because a
639
+ * port that shadows a public method is one signature change away from silently
640
+ * calling itself. Three of the five members are one-liners onto existing
641
+ * methods; nothing here is new state.
642
+ */
643
+ private kitApplyPort;
644
+ /**
645
+ * Apply a kit for THIS SESSION ONLY (D-7 / I-5): no commit, ever.
646
+ *
647
+ * Failed applies leave the live config and the file untouched (I-10), because
648
+ * the whole operation is one prepare-and-apply with no commit in between.
649
+ */
650
+ applySessionKit(kitId: string): KitApplyResult;
651
+ /** Release the session kit and return to the config file's bindings (RV-P1-6). */
652
+ clearSessionKit(): KitApplyResult;
653
+ /**
654
+ * Which kit is in effect right now (session > default > disk bindings).
655
+ *
656
+ * DERIVED, never stored (D-5): the header chip, the settings root row and
657
+ * `/kit` all read this, so there is exactly one answer to "which preset am I
658
+ * running" and no copy of it that can drift.
659
+ */
660
+ getActiveKit(): ActiveKit;
632
661
  /** Prebuild all potentially failing model/prompt work before committing the file. */
633
662
  prepareModelSettingsSnapshot(config: CliConfig): ModelSettingsSnapshot;
634
663
  /** Apply prepared state synchronously, without rebuilding the controller or its history. */
@@ -1,4 +1,6 @@
1
- import { createModelSettingsDraft, saveModelSettings, reloadModelSettings, } from './model-profile-settings.js';
1
+ import { createModelSettingsDraft, previewModelSettings, saveModelSettings, reloadModelSettings, } from './model-profile-settings.js';
2
+ import { applySessionKit as applySessionKitPort, clearSessionKit as clearSessionKitPort } from './kit-apply.js';
3
+ import { resolveActiveKit } from '../config/kit-resolution.js';
2
4
  import { randomUUID } from 'node:crypto';
3
5
  import { join } from 'node:path';
4
6
  import { verifyCompactionIdentity } from '../compaction/memory-identity.js';
@@ -1680,6 +1682,45 @@ export class AgentController {
1680
1682
  }
1681
1683
  /** Refresh both role connections and credentials while retaining conversation state. */
1682
1684
  reloadModelSettings() { return reloadModelSettings(this); }
1685
+ /**
1686
+ * The narrow port `agent/kit-apply.ts` runs against (agent-model-kits §6.3).
1687
+ *
1688
+ * A LITERAL ADAPTER RATHER THAN `this`: the port's member names (`isBusy`,
1689
+ * `hasKey`, `preview`) are deliberately NOT the controller's names, because a
1690
+ * port that shadows a public method is one signature change away from silently
1691
+ * calling itself. Three of the five members are one-liners onto existing
1692
+ * methods; nothing here is new state.
1693
+ */
1694
+ kitApplyPort() {
1695
+ return {
1696
+ getConfig: () => this.config,
1697
+ isBusy: () => this.isModelSettingsBusy(),
1698
+ hasKey: (providerId, role) => this.hasApiKey(providerId, role),
1699
+ createDraft: () => this.createModelSettingsDraft(),
1700
+ preview: (draft) => previewModelSettings(draft, this),
1701
+ };
1702
+ }
1703
+ /**
1704
+ * Apply a kit for THIS SESSION ONLY (D-7 / I-5): no commit, ever.
1705
+ *
1706
+ * Failed applies leave the live config and the file untouched (I-10), because
1707
+ * the whole operation is one prepare-and-apply with no commit in between.
1708
+ */
1709
+ applySessionKit(kitId) {
1710
+ return applySessionKitPort({ kitId, port: this.kitApplyPort() });
1711
+ }
1712
+ /** Release the session kit and return to the config file's bindings (RV-P1-6). */
1713
+ clearSessionKit() {
1714
+ return clearSessionKitPort(this.kitApplyPort());
1715
+ }
1716
+ /**
1717
+ * Which kit is in effect right now (session > default > disk bindings).
1718
+ *
1719
+ * DERIVED, never stored (D-5): the header chip, the settings root row and
1720
+ * `/kit` all read this, so there is exactly one answer to "which preset am I
1721
+ * running" and no copy of it that can drift.
1722
+ */
1723
+ getActiveKit() { return resolveActiveKit(this.config); }
1683
1724
  /** Prebuild all potentially failing model/prompt work before committing the file. */
1684
1725
  prepareModelSettingsSnapshot(config) {
1685
1726
  return { config, model: { providerId: config.provider, modelId: config.model,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The session exit: apply a kit WITHOUT writing the config file (§4.2 exit B).
3
+ *
4
+ * ASCII ONLY - `src/agent/**` is inside `glyphs.test.ts::inScope`.
5
+ *
6
+ * WHY A NARROW PORT AND NOT THE CONTROLLER: the same reason
7
+ * `ModelSettingsController` exists next door - a five-method port can be stubbed
8
+ * by one object literal in a test, so every refusal code is reachable without a
9
+ * controller, a provider, or a disk. The controller supplies the real one.
10
+ *
11
+ * EVERY PATH EITHER APPLIES OR CHANGES NOTHING (I-10). There is no partial state
12
+ * to clean up, because the whole thing is one `preview` call: the reused prepare
13
+ * chain builds the snapshot before it touches the live config, exactly as a save
14
+ * does - only the commit is missing.
15
+ */
16
+ import type { CliConfig } from '../config/schema.js';
17
+ import type { ModelSettingsDraft, ModelSettingsSaveResult } from '../config/model-profile-store.js';
18
+ import type { HasApiKey } from '../team/model.js';
19
+ import { type KitApplyResult, type KitNote } from '../config/model-kits.js';
20
+ export interface KitApplyPort {
21
+ getConfig(): CliConfig;
22
+ /** `controller.isModelSettingsBusy` - a run, a team, a review, a compaction. */
23
+ isBusy(): boolean;
24
+ /** `controller.hasApiKey`: two-parameter form, so a role-scoped key is seen. */
25
+ hasKey: HasApiKey;
26
+ /** `controller.createModelSettingsDraft`: already seeded with `sessionKit`. */
27
+ createDraft(): ModelSettingsDraft;
28
+ /** `previewModelSettings`: validate + prepare + apply, never commit. */
29
+ preview(draft: ModelSettingsDraft): ModelSettingsSaveResult;
30
+ }
31
+ /** One note, as the sentence the toast shows. Exhaustive by construction. */
32
+ export declare function kitNoteMessage(note: KitNote): string;
33
+ /** Every note for a result, in the order the estimator emitted it. */
34
+ export declare function kitNoteMessages(notes: readonly KitNote[]): string[];
35
+ /** A non-empty sentence for EVERY code, including the two kit-only ones. */
36
+ export declare function kitApplyMessage(result: KitApplyResult): string;
37
+ /**
38
+ * Apply a kit for THIS SESSION ONLY.
39
+ *
40
+ * `kitId` is a resolved id: the `/kit use` command and the picker may accept a
41
+ * name or an `#id` prefix, but resolving those is the command layer's job, so
42
+ * an ambiguous name is reported once, at the surface that accepted it.
43
+ */
44
+ export declare function applySessionKit(input: {
45
+ kitId: string;
46
+ port: KitApplyPort;
47
+ }): KitApplyResult;
48
+ /**
49
+ * Release the session kit: back to the bindings in the config file (RV-P1-6).
50
+ *
51
+ * WITHOUT THIS the feature is one-way: `ctrl+K` could enter a session kit and
52
+ * nothing could leave it except restarting the process. It shares the session
53
+ * exit's prepare chain, so it inherits every refusal code rather than defining
54
+ * a second set of failure semantics.
55
+ */
56
+ export declare function clearSessionKit(port: KitApplyPort): KitApplyResult;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The session exit: apply a kit WITHOUT writing the config file (§4.2 exit B).
3
+ *
4
+ * ASCII ONLY - `src/agent/**` is inside `glyphs.test.ts::inScope`.
5
+ *
6
+ * WHY A NARROW PORT AND NOT THE CONTROLLER: the same reason
7
+ * `ModelSettingsController` exists next door - a five-method port can be stubbed
8
+ * by one object literal in a test, so every refusal code is reachable without a
9
+ * controller, a provider, or a disk. The controller supplies the real one.
10
+ *
11
+ * EVERY PATH EITHER APPLIES OR CHANGES NOTHING (I-10). There is no partial state
12
+ * to clean up, because the whole thing is one `preview` call: the reused prepare
13
+ * chain builds the snapshot before it touches the live config, exactly as a save
14
+ * does - only the commit is missing.
15
+ */
16
+ import { emptyModelProfiles } from '../config/model-profiles.js';
17
+ import { findKit, kitCandidate, kitSlotConnections } from '../config/model-kits.js';
18
+ import { ALL_SLOTS } from '../config/kit-resolution.js';
19
+ import { modelSettingsResultMessage } from './model-profile-settings.js';
20
+ /**
21
+ * Kit-specific wording, layered over `modelSettingsResultMessage` (RV-P1-3).
22
+ *
23
+ * ONLY the codes whose save-screen text would send the user to the wrong screen
24
+ * are overridden. Everything else keeps the wording the settings screen already
25
+ * uses, so a `read_failed` reads the same wherever it appears.
26
+ */
27
+ const KIT_CODE_MESSAGES = {
28
+ busy: 'Wait for the current run to finish before switching kit.',
29
+ conflict: 'Settings changed elsewhere. Reload before switching kit.',
30
+ write_failed: 'Could not write config. Your settings are unchanged.',
31
+ apply_failed: 'Could not apply the kit to this session. Nothing changed.',
32
+ kit_missing: 'That kit is not in your library. Create it in /settings (Settings > Kit pack).',
33
+ kit_references_missing_profile: 'That kit points at a profile that no longer exists.'
34
+ + ' Fix it in /settings (Settings > Kit pack).',
35
+ };
36
+ /**
37
+ * The `KitNote` sentences, verbatim (agent-model-kits §4.2).
38
+ *
39
+ * ONE TABLE, THREE CALLERS: the picker, `/kit use`, and any future surface must
40
+ * say the same thing about the same consequence. `fast_review_enabled` is the
41
+ * one that matters most - it is a recurring charge the user did not ask for by
42
+ * name, only by applying a kit that switches the tier on.
43
+ */
44
+ const KIT_NOTE_MESSAGES = {
45
+ fast_tier_enabled: 'Fast tier enabled by this kit.',
46
+ fast_review_enabled: 'Periodic review is on for this tier (/fast review off to stop it).',
47
+ fast_tier_off: 'Fast tier turned off by this kit.',
48
+ restart_required: 'Fast tier needs a restart to take effect.',
49
+ main_key_missing: 'Main profile has no API key; set one in /settings.',
50
+ subagent_key_missing: 'Subagent profile has no API key; children fall back to main.',
51
+ subagent_fast_unavailable: 'Subagent uses the fast tier, which this kit leaves off; children fall back to main.',
52
+ };
53
+ /** One note, as the sentence the toast shows. Exhaustive by construction. */
54
+ export function kitNoteMessage(note) { return KIT_NOTE_MESSAGES[note]; }
55
+ /** Every note for a result, in the order the estimator emitted it. */
56
+ export function kitNoteMessages(notes) {
57
+ return notes.map(kitNoteMessage);
58
+ }
59
+ /** A non-empty sentence for EVERY code, including the two kit-only ones. */
60
+ export function kitApplyMessage(result) {
61
+ if (result.ok) {
62
+ return result.released
63
+ ? 'Kit released; using the config file\'s settings.'
64
+ : 'Kit applied for this session.';
65
+ }
66
+ const code = result.code ?? 'invalid';
67
+ const specific = KIT_CODE_MESSAGES[code];
68
+ if (specific)
69
+ return specific;
70
+ // NO `apply_failed` BRANCH ABOVE: it is in the table, because a stopped apply
71
+ // must say "nothing changed" rather than repeat the save screen's wording.
72
+ // Every remaining code falls through to the shared table, which is what makes
73
+ // "there is a code with no sentence" unrepresentable instead of merely unlikely.
74
+ return modelSettingsResultMessage({ ok: false, persisted: false, status: 'rejected',
75
+ code: code });
76
+ }
77
+ /** Slots whose referenced profile is gone (I-8 / AC-39). */
78
+ function missingSlots(kit, draft) {
79
+ const connections = kitSlotConnections(draft.profiles, kit);
80
+ const missing = [];
81
+ if (!connections.main)
82
+ missing.push('main');
83
+ if (kit.fastProfileId !== null && !connections.fast)
84
+ missing.push('fast');
85
+ if (kit.subagent.model === 'profile' && !connections.subagent.name)
86
+ missing.push('subagent');
87
+ return missing;
88
+ }
89
+ function missingIssue(slot, kit) {
90
+ const field = slot === 'subagent' ? 'subagent.modelProfileId' : `${slot}ProfileId`;
91
+ return { path: `modelProfiles.kits[${kit.id}].${field}`,
92
+ code: 'missing_reference',
93
+ message: `Kit "${kit.name}" references a profile that no longer exists (${slot}).` };
94
+ }
95
+ function keyMissing(port, draft, id, role) {
96
+ const entry = id ? draft.profiles?.entries.find((candidate) => candidate.id === id) : undefined;
97
+ if (!entry)
98
+ return false;
99
+ return !entry.apiKey && !port.hasKey(entry.provider, role);
100
+ }
101
+ /**
102
+ * Everything the user is entitled to know about the apply (§4.2 `KitNote`).
103
+ *
104
+ * Computed from the config AS IT WAS BEFORE, because half of these are about
105
+ * transitions ("the tier just came ON") and the other half are still true after.
106
+ */
107
+ function kitNotes(input) {
108
+ const { before, draft, kit, port } = input;
109
+ const notes = [];
110
+ const wasEnabled = before.fast.enabled;
111
+ if (kit.fastProfileId !== null && !wasEnabled)
112
+ notes.push('fast_tier_enabled');
113
+ // The honest disclosure of a real cost (F-23): a fast tier with review on
114
+ // spends money on its own, every N turns, without being asked.
115
+ if (kit.fastProfileId !== null && !wasEnabled && before.fast.review) {
116
+ notes.push('fast_review_enabled');
117
+ }
118
+ if (kit.fastProfileId === null && wasEnabled)
119
+ notes.push('fast_tier_off');
120
+ if (input.restartRequired)
121
+ notes.push('restart_required');
122
+ if (keyMissing(port, draft, kit.mainProfileId, 'main'))
123
+ notes.push('main_key_missing');
124
+ if (kit.subagent.model === 'profile'
125
+ && keyMissing(port, draft, kit.subagent.modelProfileId, 'main')) {
126
+ notes.push('subagent_key_missing');
127
+ }
128
+ if (kit.subagent.model === 'fast' && kit.fastProfileId === null) {
129
+ notes.push('subagent_fast_unavailable');
130
+ }
131
+ return notes;
132
+ }
133
+ /**
134
+ * Apply a kit for THIS SESSION ONLY.
135
+ *
136
+ * `kitId` is a resolved id: the `/kit use` command and the picker may accept a
137
+ * name or an `#id` prefix, but resolving those is the command layer's job, so
138
+ * an ambiguous name is reported once, at the surface that accepted it.
139
+ */
140
+ export function applySessionKit(input) {
141
+ const { kitId, port } = input;
142
+ if (port.isBusy())
143
+ return { ok: false, code: 'busy', notes: [] };
144
+ const before = port.getConfig();
145
+ const draft = port.createDraft();
146
+ const library = draft.profiles ?? emptyModelProfiles();
147
+ const kit = findKit(library, kitId);
148
+ if (!kit)
149
+ return { ok: false, code: 'kit_missing', notes: [] };
150
+ const missing = missingSlots(kit, draft);
151
+ if (missing.length) {
152
+ return { ok: false, code: 'kit_references_missing_profile', notes: [],
153
+ issues: missing.map((slot) => missingIssue(slot, kit)) };
154
+ }
155
+ const patch = kitCandidate({ library, kit });
156
+ draft.profiles = patch.modelProfiles;
157
+ draft.patch = { ...draft.patch, team: patch.team, fast: patch.fast };
158
+ // I-13: a kit that turns the tier OFF must NOT project the fast role, or
159
+ // `projectModelProfiles`'s `continue` would copy the DISK fast endpoint into
160
+ // the live config and the kit would read as "restore, then switch off".
161
+ draft.activateRoles = kit.fastProfileId === null ? ['main'] : ['main', 'fast'];
162
+ draft.sessionKit = { id: kit.id, held: [...ALL_SLOTS] };
163
+ // THE MARKER, not a heuristic: this draft IS the kit, so `releasedSlots` must
164
+ // not read the apply's own writes as user decisions (§4.2 exit B).
165
+ draft.sessionKitFresh = true;
166
+ const result = port.preview(draft);
167
+ if (!result.ok) {
168
+ return { ok: false, code: result.code ?? 'invalid', notes: [],
169
+ ...(result.issues ? { issues: result.issues } : {}) };
170
+ }
171
+ const notes = kitNotes({ before, draft, kit, port,
172
+ ...(result.restartRequired ? { restartRequired: true } : {}) });
173
+ return { ok: true, notes };
174
+ }
175
+ /**
176
+ * Release the session kit: back to the bindings in the config file (RV-P1-6).
177
+ *
178
+ * WITHOUT THIS the feature is one-way: `ctrl+K` could enter a session kit and
179
+ * nothing could leave it except restarting the process. It shares the session
180
+ * exit's prepare chain, so it inherits every refusal code rather than defining
181
+ * a second set of failure semantics.
182
+ */
183
+ export function clearSessionKit(port) {
184
+ if (port.isBusy())
185
+ return { ok: false, code: 'busy', notes: [] };
186
+ const before = port.getConfig();
187
+ const draft = port.createDraft();
188
+ const file = draft.baseline;
189
+ draft.sessionKit = null;
190
+ draft.patch = {
191
+ ...draft.patch,
192
+ team: { model: file.team.model, modelProfileId: file.team.modelProfileId },
193
+ // The disk fast section, KEY BY KEY: `enabled` restores the tier state and
194
+ // the other three restore the endpoints the file names, so no trace of the
195
+ // kit's fast choice survives in the live config.
196
+ fast: { enabled: file.fast.enabled, provider: file.fast.provider,
197
+ model: file.fast.model, baseUrl: file.fast.baseUrl },
198
+ };
199
+ draft.activateRoles = ['main', 'fast'];
200
+ const result = port.preview(draft);
201
+ if (!result.ok)
202
+ return { ok: false, code: result.code ?? 'invalid', notes: [] };
203
+ const notes = [];
204
+ if (result.restartRequired)
205
+ notes.push('restart_required');
206
+ if (before.fast.enabled && !file.fast.enabled)
207
+ notes.push('fast_tier_off');
208
+ return { ok: true, released: true, notes };
209
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ModelRef } from '@aragon-agent/core';
2
2
  import { type CliConfig } from '../config/schema.js';
3
3
  import { type ModelSettingsDraft, type ModelSettingsSaveResult } from '../config/model-profile-store.js';
4
+ import { type KitSlot } from '../config/model-kits.js';
4
5
  /** Fully prepared live state; applying it performs no input parsing or disk access. */
5
6
  export interface ModelSettingsSnapshot {
6
7
  config: CliConfig;
@@ -22,10 +23,37 @@ export interface ModelSettingsController {
22
23
  export declare function createModelSettingsDraft(live: CliConfig, liveRevision: number): ModelSettingsDraft;
23
24
  /** Map stable failure codes to safe UI text; never include parser or SDK error messages. */
24
25
  export declare function modelSettingsResultMessage(result: ModelSettingsSaveResult): string;
26
+ /**
27
+ * The slots THIS transaction explicitly decided, and that therefore leave the
28
+ * session kit (§4.3 / D-6).
29
+ *
30
+ * Released BY SLOT, never wholesale: a user who only retargeted the subagent
31
+ * must not lose the main and fast choices the kit made for this session, and a
32
+ * user who retargeted main must not have it silently ignored. Per-slot is the
33
+ * only rule shaped like the data (three independent slots).
34
+ */
35
+ export declare function releasedSlots(draft: ModelSettingsDraft): KitSlot[];
25
36
  /** Validate and prepare everything before the one synchronous, atomic disk commit. */
26
37
  export declare function saveModelSettings(input: {
27
38
  draft: ModelSettingsDraft;
28
39
  controller: ModelSettingsController;
29
40
  }): ModelSettingsSaveResult;
41
+ /**
42
+ * The SAME prepare chain, without the commit: a session kit (§4.2 exit B).
43
+ *
44
+ * NO `diskRevision` GATE: nothing is written, so a save that landed elsewhere is
45
+ * not this operation's business. It still refuses while work is in flight
46
+ * (`busy`) and still refuses a draft whose disk could not be read, so "nothing
47
+ * changed" is guaranteed for every other failure too (I-10).
48
+ */
49
+ export declare function previewModelSettings(draft: ModelSettingsDraft, controller: ModelSettingsController): ModelSettingsSaveResult;
50
+ /**
51
+ * Apply a prepared snapshot.
52
+ *
53
+ * `persisted` HAS NO DEFAULT (RV-P1-4): the one attribute that distinguishes
54
+ * "the file now says this" from "only this process says this" is not something
55
+ * a call site should be able to forget.
56
+ */
57
+ export declare function applyPreparedSnapshot(controller: ModelSettingsController, snapshot: ModelSettingsSnapshot, persisted: boolean): ModelSettingsSaveResult;
30
58
  /** Reload the complete model snapshot; invalid disk content never replaces a live session. */
31
59
  export declare function reloadModelSettings(controller: ModelSettingsController): ModelSettingsSaveResult;
@@ -4,7 +4,10 @@ import { mergePersistedConfig } from '../config/store.js';
4
4
  import { adaptLegacyConnectionPatch, assertLegacyFastTarget, commitModelSettings, readModelSettingsDisk, } from '../config/model-profile-store.js';
5
5
  import { projectModelProfiles, resolveModelProfileState } from '../config/model-profile-resolution.js';
6
6
  import { validateModelProfiles } from '../config/model-profiles.js';
7
+ import { applyHeldToLive, carrySessionKit } from '../config/kit-resolution.js';
8
+ import { findKit } from '../config/model-kits.js';
7
9
  import { registerProfileSecrets } from '../logging/secret-registry.js';
10
+ import { getLogger } from '../logging/logger.js';
8
11
  /** Capture both baselines when the editor opens, without migrating or writing anything. */
9
12
  export function createModelSettingsDraft(live, liveRevision) {
10
13
  const disk = readModelSettingsDisk();
@@ -15,6 +18,11 @@ export function createModelSettingsDraft(live, liveRevision) {
15
18
  profiles: disk.config?.modelProfiles
16
19
  ? structuredClone(disk.config.modelProfiles) : undefined,
17
20
  activateRoles: [], patch: {},
21
+ // SEEDED FROM THE LIVE CONFIG (RV-P0-2). Without this line `draft.sessionKit`
22
+ // is always `undefined`, the `undefined` case below means "release", and any
23
+ // save made from the settings screen would silently drop a session kit the
24
+ // user just applied - the exact opposite of "this session only".
25
+ sessionKit: live.sessionKit ? structuredClone(live.sessionKit) : undefined,
18
26
  ...(!disk.ok ? { readError: 'Cannot read config. Repair it with aragon config edit.' } : {}),
19
27
  };
20
28
  }
@@ -61,6 +69,47 @@ function hasConnectionPatch(patch, role) {
61
69
  const section = role === 'main' ? patch : patch.fast;
62
70
  return !!section && ['provider', 'model', 'baseUrl'].some((key) => key in section);
63
71
  }
72
+ /**
73
+ * The slots THIS transaction explicitly decided, and that therefore leave the
74
+ * session kit (§4.3 / D-6).
75
+ *
76
+ * Released BY SLOT, never wholesale: a user who only retargeted the subagent
77
+ * must not lose the main and fast choices the kit made for this session, and a
78
+ * user who retargeted main must not have it silently ignored. Per-slot is the
79
+ * only rule shaped like the data (three independent slots).
80
+ */
81
+ export function releasedSlots(draft) {
82
+ const held = draft.sessionKit?.held ?? [];
83
+ if (!held.length)
84
+ return [];
85
+ // THE APPLY ITSELF RELEASES NOTHING (`kit-apply.ts` sets this marker). Without
86
+ // it the apply's own writes - `activateRoles`, `patch.team`, the re-bound
87
+ // library - read as "the user decided every slot", and the save would drop the
88
+ // three slots it was just handed.
89
+ if (draft.sessionKitFresh)
90
+ return [];
91
+ const kit = findKit(draft.profiles ?? draft.baseline.modelProfiles, draft.sessionKit?.id);
92
+ if (!kit)
93
+ return [...held];
94
+ const released = new Set();
95
+ for (const role of ['main', 'fast']) {
96
+ if (draft.activateRoles.includes(role) || connectionChanged(draft, role)
97
+ || hasConnectionPatch(draft.patch, role))
98
+ released.add(role);
99
+ }
100
+ if (draft.patch.team !== undefined)
101
+ released.add('subagent');
102
+ return [...released].filter((slot) => held.includes(slot));
103
+ }
104
+ /** The session kit as it stands AFTER this transaction's explicit decisions. */
105
+ function narrowedSessionKit(draft) {
106
+ const current = draft.sessionKit;
107
+ if (!current)
108
+ return null;
109
+ const released = releasedSlots(draft);
110
+ const held = current.held.filter((slot) => !released.includes(slot));
111
+ return held.length ? { id: current.id, held } : null;
112
+ }
64
113
  function liveCandidate(live, candidate, draft) {
65
114
  const patch = draft.patch;
66
115
  const normalizedPatch = {};
@@ -128,8 +177,16 @@ function liveCandidate(live, candidate, draft) {
128
177
  next.apiKeyOverrideTarget = undefined;
129
178
  }
130
179
  else {
131
- next.fast = { ...next.fast, provider: projected.fast.provider,
132
- model: projected.fast.model, baseUrl: projected.fast.baseUrl };
180
+ // I-13 (RV-P1-7): `projectModelProfiles` projects fast ONLY when the
181
+ // library binds one. With `fastId: null` the projection's `fast` is just the
182
+ // file's untouched section (`projected` starts as `{...file}`), so copying it
183
+ // here would resurrect the disk's fast endpoint underneath a kit that
184
+ // deliberately turned the tier off - the live config would then say "off"
185
+ // while naming a model nobody chose.
186
+ if (projected.modelProfiles?.fastId) {
187
+ next.fast = { ...next.fast, provider: projected.fast.provider,
188
+ model: projected.fast.model, baseUrl: projected.fast.baseUrl };
189
+ }
133
190
  }
134
191
  }
135
192
  if (next.apiKeyOverride && !next.apiKeyOverrideTarget
@@ -139,49 +196,100 @@ function liveCandidate(live, candidate, draft) {
139
196
  next.modelProfileState = resolveModelProfileState(next);
140
197
  return next;
141
198
  }
142
- /** Validate and prepare everything before the one synchronous, atomic disk commit. */
143
- export function saveModelSettings(input) {
144
- const { draft, controller } = input;
145
- if (controller.isModelSettingsBusy())
146
- return rejected('busy');
147
- if (draft.liveRevision !== controller.getSettingsRevision())
148
- return rejected('conflict');
149
- const disk = readModelSettingsDisk();
150
- if (!disk.ok || draft.readError)
151
- return rejected('read_failed');
152
- if (disk.revision !== draft.diskRevision)
153
- return rejected('conflict');
154
- const issues = validateModelProfiles(draft.profiles ?? disk.config?.modelProfiles);
199
+ /**
200
+ * Build both candidates and the snapshot.
201
+ *
202
+ * TWO CANDIDATES, NAMED DIFFERENTLY ON PURPOSE (RV-P0-1 / I-14). `diskCandidate`
203
+ * is the commit side and never learns about a held slot; `liveCandidateInput`
204
+ * is what the session sees and is never committed. Collapsing them into one
205
+ * variable is the defect this split exists to prevent: one unrelated save would
206
+ * then write the session's kit into the user's permanent defaults, and nothing
207
+ * visible would change.
208
+ */
209
+ function prepareModelSettings(input) {
210
+ const { draft, controller, current } = input;
211
+ const issues = validateModelProfiles(draft.profiles ?? current.modelProfiles);
155
212
  if (issues.length)
156
- return { ...rejected('invalid'), issues };
157
- let snapshot;
158
- let candidate;
213
+ return { failure: { ...rejected('invalid'), issues } };
159
214
  try {
160
- const current = disk.config ?? structuredClone(DEFAULT_CONFIG);
161
215
  const profilesChanged = draft.activateRoles.length > 0
162
216
  || JSON.stringify(draft.profiles) !== JSON.stringify(draft.baseline.modelProfiles);
163
217
  const implicitLegacy = draft.profiles === undefined || !profilesChanged;
164
218
  const patch = adaptLegacyConnectionPatch({ current, patch: implicitLegacy
165
219
  ? draft.patch : { ...draft.patch, modelProfiles: draft.profiles } });
166
- candidate = mergePersistedConfig(current, patch);
167
- registerProfileSecrets(candidate);
168
- const next = liveCandidate(controller.getConfig(), candidate, draft);
220
+ const diskCandidate = mergePersistedConfig(current, patch);
221
+ registerProfileSecrets(diskCandidate);
222
+ const sessionKit = narrowedSessionKit(draft);
223
+ const applied = applyHeldToLive(diskCandidate, sessionKit);
224
+ if (applied.dropped.length) {
225
+ getLogger().warn('agent', 'session_kit_slots_dropped', { slots: applied.dropped });
226
+ }
227
+ const next = liveCandidate(controller.getConfig(), applied.candidate, draft);
228
+ next.sessionKit = sessionKit ?? undefined;
169
229
  // Disk preferences cannot reveal startup/session gateways retained by the live main role.
170
230
  if (implicitLegacy && current.modelProfiles?.fastId && hasConnectionPatch(draft.patch, 'fast')) {
171
231
  assertLegacyFastTarget(next.fast, next);
172
232
  }
173
- snapshot = controller.prepareModelSettingsSnapshot(next);
233
+ const snapshot = controller.prepareModelSettingsSnapshot(next);
234
+ return { prepared: { diskCandidate, liveCandidateInput: applied.candidate, snapshot } };
174
235
  }
175
236
  catch (error) {
176
237
  const code = error.code ?? 'invalid';
177
- return rejected(code);
238
+ return { failure: rejected(code) };
178
239
  }
179
- const commit = commitModelSettings({ candidate, diskRevision: draft.diskRevision });
240
+ }
241
+ /** Validate and prepare everything before the one synchronous, atomic disk commit. */
242
+ export function saveModelSettings(input) {
243
+ const { draft, controller } = input;
244
+ if (controller.isModelSettingsBusy())
245
+ return rejected('busy');
246
+ if (draft.liveRevision !== controller.getSettingsRevision())
247
+ return rejected('conflict');
248
+ const disk = readModelSettingsDisk();
249
+ if (!disk.ok || draft.readError)
250
+ return rejected('read_failed');
251
+ if (disk.revision !== draft.diskRevision)
252
+ return rejected('conflict');
253
+ const prepared = prepareModelSettings({ draft, controller,
254
+ current: disk.config ?? structuredClone(DEFAULT_CONFIG) });
255
+ if (!prepared.prepared)
256
+ return prepared.failure;
257
+ const commit = commitModelSettings({ candidate: prepared.prepared.diskCandidate,
258
+ diskRevision: draft.diskRevision });
180
259
  if (!commit.ok)
181
260
  return rejected(commit.code ?? 'write_failed');
182
- return applyPreparedSnapshot(controller, snapshot, true);
261
+ return applyPreparedSnapshot(controller, prepared.prepared.snapshot, true);
262
+ }
263
+ /**
264
+ * The SAME prepare chain, without the commit: a session kit (§4.2 exit B).
265
+ *
266
+ * NO `diskRevision` GATE: nothing is written, so a save that landed elsewhere is
267
+ * not this operation's business. It still refuses while work is in flight
268
+ * (`busy`) and still refuses a draft whose disk could not be read, so "nothing
269
+ * changed" is guaranteed for every other failure too (I-10).
270
+ */
271
+ export function previewModelSettings(draft, controller) {
272
+ if (controller.isModelSettingsBusy())
273
+ return rejected('busy');
274
+ if (draft.readError)
275
+ return rejected('read_failed');
276
+ const disk = readModelSettingsDisk();
277
+ if (!disk.ok)
278
+ return rejected('read_failed');
279
+ const prepared = prepareModelSettings({ draft, controller,
280
+ current: disk.config ?? structuredClone(DEFAULT_CONFIG) });
281
+ if (!prepared.prepared)
282
+ return prepared.failure;
283
+ return applyPreparedSnapshot(controller, prepared.prepared.snapshot, false);
183
284
  }
184
- function applyPreparedSnapshot(controller, snapshot, persisted) {
285
+ /**
286
+ * Apply a prepared snapshot.
287
+ *
288
+ * `persisted` HAS NO DEFAULT (RV-P1-4): the one attribute that distinguishes
289
+ * "the file now says this" from "only this process says this" is not something
290
+ * a call site should be able to forget.
291
+ */
292
+ export function applyPreparedSnapshot(controller, snapshot, persisted) {
185
293
  try {
186
294
  controller.applyModelSettingsSnapshot(snapshot);
187
295
  }
@@ -220,14 +328,19 @@ export function reloadModelSettings(controller) {
220
328
  if (live.unicode !== undefined)
221
329
  resolved.unicode = live.unicode;
222
330
  resolved.reducedMotion ||= !live.color;
331
+ // THE SESSION KIT SURVIVES A RELOAD (D-6): the three connections it holds
332
+ // must come from the kit, not from the file we just re-read, and the
333
+ // projection has to be recomputed so the live config stays equivalent to
334
+ // what its own bindings say (I-3).
335
+ const carried = carrySessionKit({ resolved, previous: live });
223
336
  const after = readModelSettingsDisk();
224
337
  if (!after.ok)
225
338
  return rejected('read_failed');
226
339
  if (after.revision !== disk.revision)
227
340
  return rejected('conflict');
228
- if (resolved.modelProfileState?.main.invalid)
341
+ if (carried.modelProfileState?.main.invalid)
229
342
  return rejected('invalid');
230
- return applyPreparedSnapshot(controller, controller.prepareModelSettingsSnapshot(resolved), false);
343
+ return applyPreparedSnapshot(controller, controller.prepareModelSettingsSnapshot(carried), false);
231
344
  }
232
345
  catch {
233
346
  return rejected('invalid');
@@ -22,7 +22,7 @@ import type { RetryPhase } from './retry-view.js';
22
22
  import type { FilePatch } from '../tools/patch.js';
23
23
  export type NoticeLevel = 'info' | 'warn' | 'error';
24
24
  export type ToastLevel = NoticeLevel | 'success';
25
- export type Overlay = null | 'settings' | 'model' | 'help' | 'queue' | 'confirm' | 'question' | 'plan';
25
+ export type Overlay = null | 'settings' | 'model' | 'help' | 'queue' | 'confirm' | 'question' | 'plan' | 'kit';
26
26
  export type ToolStatus = 'pending' | 'running' | 'done' | 'error';
27
27
  /** Ephemeral, auto-dismissed acknowledgement (spec §3.9). */
28
28
  export interface Toast {