@aragon-agent/cli 0.6.12 → 0.6.13

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 (101) hide show
  1. package/README.md +81 -15
  2. package/dist/agent/controller.d.ts +29 -1
  3. package/dist/agent/controller.js +123 -22
  4. package/dist/agent/model-profile-settings.d.ts +31 -0
  5. package/dist/agent/model-profile-settings.js +199 -0
  6. package/dist/agent/queued-messages.d.ts +12 -0
  7. package/dist/agent/queued-messages.js +18 -0
  8. package/dist/agent/reducer.d.ts +13 -1
  9. package/dist/agent/reducer.js +51 -12
  10. package/dist/cli.js +15 -1
  11. package/dist/commands/builtins.js +66 -39
  12. package/dist/commands/queue-command.d.ts +3 -0
  13. package/dist/commands/queue-command.js +5 -0
  14. package/dist/commands/registry.d.ts +3 -0
  15. package/dist/compaction/child.d.ts +4 -2
  16. package/dist/compaction/child.js +4 -1
  17. package/dist/compaction/compactor.d.ts +7 -2
  18. package/dist/compaction/compactor.js +13 -11
  19. package/dist/compaction/context-command.js +5 -0
  20. package/dist/compaction/wiring.d.ts +4 -3
  21. package/dist/compaction/wiring.js +6 -1
  22. package/dist/config/cli-commands.d.ts +2 -0
  23. package/dist/config/cli-commands.js +16 -4
  24. package/dist/config/load.d.ts +2 -1
  25. package/dist/config/load.js +15 -8
  26. package/dist/config/model-profile-resolution.d.ts +20 -0
  27. package/dist/config/model-profile-resolution.js +81 -0
  28. package/dist/config/model-profile-store.d.ts +67 -0
  29. package/dist/config/model-profile-store.js +153 -0
  30. package/dist/config/model-profiles.d.ts +44 -0
  31. package/dist/config/model-profiles.js +121 -0
  32. package/dist/config/schema.d.ts +9 -0
  33. package/dist/config/store.d.ts +11 -1
  34. package/dist/config/store.js +43 -27
  35. package/dist/fast/resolve.d.ts +2 -1
  36. package/dist/fast/resolve.js +6 -4
  37. package/dist/fast/wiring.d.ts +5 -3
  38. package/dist/fast/wiring.js +8 -4
  39. package/dist/input/stdin-filter.js +3 -2
  40. package/dist/logging/install.js +4 -2
  41. package/dist/logging/secret-registry.d.ts +2 -0
  42. package/dist/logging/secret-registry.js +18 -0
  43. package/dist/team/runtime.d.ts +2 -1
  44. package/dist/team/subagent.d.ts +3 -1
  45. package/dist/team/subagent.js +3 -1
  46. package/dist/ui/ActivityLine.d.ts +6 -70
  47. package/dist/ui/ActivityLine.js +12 -34
  48. package/dist/ui/App.d.ts +1 -0
  49. package/dist/ui/App.js +113 -205
  50. package/dist/ui/BottomStatusRow.d.ts +6 -86
  51. package/dist/ui/BottomStatusRow.js +11 -26
  52. package/dist/ui/Composer.d.ts +16 -83
  53. package/dist/ui/Composer.js +9 -100
  54. package/dist/ui/PromptInput.d.ts +4 -1
  55. package/dist/ui/PromptInput.js +41 -34
  56. package/dist/ui/QueuePanel.d.ts +8 -0
  57. package/dist/ui/QueuePanel.js +9 -0
  58. package/dist/ui/QueueStatusRow.d.ts +2 -0
  59. package/dist/ui/QueueStatusRow.js +1 -1
  60. package/dist/ui/StatusBar.d.ts +13 -149
  61. package/dist/ui/StatusBar.js +27 -142
  62. package/dist/ui/clipboard-task.d.ts +29 -0
  63. package/dist/ui/clipboard-task.js +66 -0
  64. package/dist/ui/clipboard.d.ts +29 -50
  65. package/dist/ui/clipboard.js +141 -75
  66. package/dist/ui/composer-limits.d.ts +3 -4
  67. package/dist/ui/composer-limits.js +4 -9
  68. package/dist/ui/composer-rows.d.ts +1 -1
  69. package/dist/ui/composer-rows.js +24 -137
  70. package/dist/ui/editor-navigation.d.ts +45 -0
  71. package/dist/ui/editor-navigation.js +85 -0
  72. package/dist/ui/editor-reducer.d.ts +4 -0
  73. package/dist/ui/editor-reducer.js +29 -15
  74. package/dist/ui/interaction-copy.d.ts +77 -0
  75. package/dist/ui/interaction-copy.js +40 -0
  76. package/dist/ui/interaction-hints.d.ts +18 -0
  77. package/dist/ui/interaction-hints.js +92 -0
  78. package/dist/ui/layout/budget.js +1 -1
  79. package/dist/ui/layout/queue-layout.d.ts +19 -0
  80. package/dist/ui/layout/queue-layout.js +26 -0
  81. package/dist/ui/layout/status-layout.d.ts +55 -0
  82. package/dist/ui/layout/status-layout.js +109 -0
  83. package/dist/ui/layout/todo-layout.d.ts +1 -0
  84. package/dist/ui/layout/todo-layout.js +1 -1
  85. package/dist/ui/overlays/HelpOverlay.js +8 -8
  86. package/dist/ui/overlays/ModelProfileEditor.d.ts +12 -0
  87. package/dist/ui/overlays/ModelProfileEditor.js +103 -0
  88. package/dist/ui/overlays/ModelProfilePicker.d.ts +35 -0
  89. package/dist/ui/overlays/ModelProfilePicker.js +167 -0
  90. package/dist/ui/overlays/QueueOverlay.d.ts +22 -0
  91. package/dist/ui/overlays/QueueOverlay.js +79 -0
  92. package/dist/ui/overlays/SettingsScreen.d.ts +9 -2
  93. package/dist/ui/overlays/SettingsScreen.js +347 -3
  94. package/dist/ui/overlays/model-profile-state.d.ts +74 -0
  95. package/dist/ui/overlays/model-profile-state.js +119 -0
  96. package/dist/ui/run-status-row.d.ts +7 -19
  97. package/dist/ui/run-status-row.js +2 -18
  98. package/dist/ui/selection/selection-controller.d.ts +2 -2
  99. package/dist/ui/selection/selection-controller.js +1 -0
  100. package/dist/ui/use-wheel-routing.js +2 -1
  101. package/package.json +2 -2
@@ -0,0 +1,81 @@
1
+ import { DEFAULT_CONFIG } from './schema.js';
2
+ import { emptyModelProfileState, normalizeModelProfiles, validateModelProfiles } from './model-profiles.js';
3
+ /** Compare configured endpoint spelling strictly, with null and undefined equivalent. */
4
+ export function normalizeProfileBaseUrl(value) {
5
+ return value?.trim() || null;
6
+ }
7
+ /** Project a valid library onto a copy without rewriting legacy preferences. */
8
+ export function projectModelProfiles(file) {
9
+ const issues = validateModelProfiles(file.modelProfiles);
10
+ const state = emptyModelProfileState(issues.length > 0);
11
+ const projected = { ...file };
12
+ if (issues.length || !file.modelProfiles)
13
+ return { file: projected, state, issues };
14
+ const library = normalizeModelProfiles(file.modelProfiles);
15
+ projected.modelProfiles = library;
16
+ for (const role of ['main', 'fast']) {
17
+ const entry = library.entries.find((profile) => profile.id === library[`${role}Id`]);
18
+ if (!entry)
19
+ continue;
20
+ state[role] = { selectedId: entry.id, appliedId: entry.id,
21
+ overriddenFields: [], invalid: false };
22
+ const connection = { provider: entry.provider, model: entry.model, baseUrl: entry.baseUrl };
23
+ if (role === 'main')
24
+ Object.assign(projected, connection);
25
+ else
26
+ projected.fast = { ...DEFAULT_CONFIG.fast, ...file.fast, ...connection,
27
+ baseUrl: entry.baseUrl ?? '' };
28
+ }
29
+ return { file: projected, state, issues };
30
+ }
31
+ /** Derive binding status from current connections, including session/model overrides. */
32
+ export function resolveModelProfileState(config, profiles = config.modelProfiles, invalid = false) {
33
+ const issues = validateModelProfiles(profiles);
34
+ const state = emptyModelProfileState(invalid || issues.length > 0);
35
+ if (issues.length || !profiles)
36
+ return state;
37
+ for (const role of ['main', 'fast']) {
38
+ const entry = profiles.entries.find((profile) => profile.id === profiles[`${role}Id`]);
39
+ if (!entry)
40
+ continue;
41
+ const connection = role === 'main' ? config : config.fast;
42
+ const changed = ['provider', 'model', 'baseUrl'].filter((field) => field === 'baseUrl'
43
+ ? normalizeProfileBaseUrl(connection[field]) !== normalizeProfileBaseUrl(entry[field])
44
+ : connection[field] !== entry[field]);
45
+ state[role] = { selectedId: entry.id,
46
+ appliedId: changed.some((field) => field !== 'model') ? null : entry.id,
47
+ overriddenFields: changed, invalid: false };
48
+ }
49
+ return state;
50
+ }
51
+ /** Resolve only credentials scoped to the requested role and actual connection target. */
52
+ export function resolveModelRoleKey(input) {
53
+ const { config, role, providerId } = input;
54
+ const shared = config.apiKeys[providerId] || undefined;
55
+ const profiles = validateModelProfiles(config.modelProfiles).length
56
+ ? undefined : config.modelProfiles;
57
+ const profileMode = profiles && (profiles.mainId !== null || profiles.fastId !== null);
58
+ if (!profileMode) {
59
+ const target = config.apiKeyOverrideTarget;
60
+ if (target) {
61
+ return role === 'main' && providerId === config.provider
62
+ && target.provider === config.provider
63
+ && normalizeProfileBaseUrl(target.baseUrl) === normalizeProfileBaseUrl(config.baseUrl)
64
+ ? config.apiKeyOverride || shared : shared;
65
+ }
66
+ return config.apiKeyOverride && providerId === config.provider
67
+ ? config.apiKeyOverride : shared;
68
+ }
69
+ const actualProvider = role === 'main'
70
+ ? config.provider : config.fast.provider || config.provider;
71
+ if (providerId !== actualProvider)
72
+ return shared;
73
+ const target = config.apiKeyOverrideTarget;
74
+ if (role === 'main' && config.apiKeyOverride && target?.provider === config.provider
75
+ && normalizeProfileBaseUrl(target.baseUrl) === normalizeProfileBaseUrl(config.baseUrl)) {
76
+ return config.apiKeyOverride;
77
+ }
78
+ const state = resolveModelProfileState(config)[role];
79
+ const entry = profiles.entries.find((profile) => profile.id === state.appliedId);
80
+ return entry?.apiKey || shared;
81
+ }
@@ -0,0 +1,67 @@
1
+ import { type ModelProfilesConfig, type ModelRole, type ProfileValidationIssue } from './model-profiles.js';
2
+ import type { CliConfig, CompactionConfig, FastConfig, LogConfig, PersistedConfig } from './schema.js';
3
+ export interface ModelSettingsPatch extends Partial<Omit<PersistedConfig, 'modelProfiles' | 'fast' | 'compaction' | 'log'>> {
4
+ fast?: Partial<FastConfig>;
5
+ compaction?: Partial<CompactionConfig>;
6
+ log?: Partial<LogConfig>;
7
+ }
8
+ export interface ModelSettingsDraft {
9
+ profiles?: ModelProfilesConfig;
10
+ patch: ModelSettingsPatch;
11
+ activateRoles: ModelRole[];
12
+ diskRevision: string;
13
+ liveRevision: number;
14
+ baseline: PersistedConfig;
15
+ liveBaseline: CliConfig;
16
+ readError?: string;
17
+ }
18
+ export interface ModelSettingsSaveResult {
19
+ ok: boolean;
20
+ persisted: boolean;
21
+ status: 'applied' | 'restart_required' | 'rejected' | 'saved_apply_failed';
22
+ code?: 'invalid' | 'busy' | 'conflict' | 'read_failed' | 'write_failed' | 'profile_key_required' | 'legacy_conversion_unsupported' | 'apply_failed';
23
+ issues?: ProfileValidationIssue[];
24
+ restartRequired?: boolean;
25
+ }
26
+ export type ConfigReadError = 'invalid_json' | 'invalid_root' | 'read_failed';
27
+ export interface ModelSettingsDisk {
28
+ ok: boolean;
29
+ config: PersistedConfig;
30
+ parsed: Partial<PersistedConfig> | null;
31
+ raw: Buffer | null;
32
+ revision: string;
33
+ error?: ConfigReadError;
34
+ }
35
+ /** A stable, safe error for adapters; never includes caught exception messages. */
36
+ export declare class ModelProfileConfigError extends Error {
37
+ readonly code: NonNullable<ModelSettingsSaveResult['code']>;
38
+ constructor(code: NonNullable<ModelSettingsSaveResult['code']>);
39
+ }
40
+ /** Read and hash exactly the same bytes; a missing file has its own revision sentinel. */
41
+ export declare function readModelSettingsDisk(): ModelSettingsDisk;
42
+ export interface ModelSettingsCommitInput {
43
+ candidate: PersistedConfig;
44
+ diskRevision: string;
45
+ }
46
+ export interface ModelSettingsStoreDependencies {
47
+ read: () => ModelSettingsDisk;
48
+ write: (candidate: PersistedConfig) => void;
49
+ }
50
+ /** Optimistically commit a prepared candidate using one atomic write; never applies live state. */
51
+ export declare function commitModelSettings(input: ModelSettingsCommitInput, dependencies?: ModelSettingsStoreDependencies): ModelSettingsSaveResult;
52
+ export interface LegacyPatchInput {
53
+ current: PersistedConfig;
54
+ patch: ModelSettingsPatch & {
55
+ modelProfiles?: ModelProfilesConfig;
56
+ };
57
+ }
58
+ /** Adapt legacy edits without mutating a shared profile or moving its dedicated key. */
59
+ export declare function adaptLegacyConnectionPatch(input: LegacyPatchInput): LegacyPatchInput['patch'];
60
+ /** A detached default endpoint must not acquire a gateway through legacy inheritance. */
61
+ export declare function assertLegacyFastTarget(fast: {
62
+ provider: string;
63
+ baseUrl?: string | null;
64
+ }, main: {
65
+ provider: string;
66
+ baseUrl?: string | null;
67
+ }): void;
@@ -0,0 +1,153 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { getConfigPath } from './app-paths.js';
4
+ import { normalizePersistedConfig, writeConfigFile } from './store.js';
5
+ import { registerProfileSecrets } from '../logging/secret-registry.js';
6
+ import { normalizeModelProfiles, validateModelProfiles } from './model-profiles.js';
7
+ import { normalizeProfileBaseUrl, projectModelProfiles } from './model-profile-resolution.js';
8
+ /** A stable, safe error for adapters; never includes caught exception messages. */
9
+ export class ModelProfileConfigError extends Error {
10
+ code;
11
+ constructor(code) {
12
+ super(code === 'profile_key_required' || code === 'legacy_conversion_unsupported'
13
+ ? `${code}: Use /settings to edit or copy this profile.`
14
+ : `${code}: Check the configuration with aragon config edit.`);
15
+ this.code = code;
16
+ this.name = 'ModelProfileConfigError';
17
+ }
18
+ }
19
+ /** Read and hash exactly the same bytes; a missing file has its own revision sentinel. */
20
+ export function readModelSettingsDisk() {
21
+ let raw;
22
+ try {
23
+ raw = readFileSync(getConfigPath());
24
+ }
25
+ catch (error) {
26
+ const missing = error.code === 'ENOENT';
27
+ return { ok: missing, config: normalizePersistedConfig({}), parsed: null, raw: null,
28
+ revision: missing ? 'missing' : 'unreadable',
29
+ ...(missing ? {} : { error: 'read_failed' }) };
30
+ }
31
+ const revision = createHash('sha256').update(raw).digest('hex');
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(raw.toString('utf8'));
35
+ }
36
+ catch {
37
+ return failedRead(raw, revision, 'invalid_json');
38
+ }
39
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
40
+ return failedRead(raw, revision, 'invalid_root');
41
+ }
42
+ registerProfileSecrets(parsed);
43
+ return { ok: true, raw, revision, parsed: parsed,
44
+ config: normalizePersistedConfig(parsed) };
45
+ }
46
+ function failedRead(raw, revision, error) {
47
+ return { ok: false, raw, revision, error, config: normalizePersistedConfig({}), parsed: null };
48
+ }
49
+ /** Optimistically commit a prepared candidate using one atomic write; never applies live state. */
50
+ export function commitModelSettings(input, dependencies = {
51
+ read: readModelSettingsDisk, write: writeConfigFile,
52
+ }) {
53
+ const current = dependencies.read();
54
+ if (!current.ok)
55
+ return rejected('read_failed');
56
+ if (current.revision !== input.diskRevision)
57
+ return rejected('conflict');
58
+ if (validateModelProfiles(current.config.modelProfiles).length)
59
+ return rejected('invalid');
60
+ registerProfileSecrets(input.candidate);
61
+ const issues = validateModelProfiles(input.candidate.modelProfiles);
62
+ if (issues.length)
63
+ return { ...rejected('invalid'), issues };
64
+ try {
65
+ dependencies.write(input.candidate);
66
+ }
67
+ catch {
68
+ return rejected('write_failed');
69
+ }
70
+ return { ok: true, persisted: true, status: 'applied' };
71
+ }
72
+ function rejected(code) {
73
+ return { ok: false, persisted: false, status: 'rejected', code };
74
+ }
75
+ function touchesConnection(patch) {
76
+ return Boolean(patch && ['provider', 'model', 'baseUrl'].some((key) => Object.prototype.hasOwnProperty.call(patch, key)));
77
+ }
78
+ /** Adapt legacy edits without mutating a shared profile or moving its dedicated key. */
79
+ export function adaptLegacyConnectionPatch(input) {
80
+ const { current, patch } = input;
81
+ if (Object.prototype.hasOwnProperty.call(patch, 'modelProfiles')) {
82
+ if (validateModelProfiles(current.modelProfiles).length
83
+ || validateModelProfiles(patch.modelProfiles).length) {
84
+ throw new ModelProfileConfigError('invalid');
85
+ }
86
+ let result = patch;
87
+ for (const role of ['main', 'fast']) {
88
+ if (!patch.modelProfiles?.[`${role}Id`]) {
89
+ result = adaptUnboundProviders(current, result, role);
90
+ }
91
+ }
92
+ return result;
93
+ }
94
+ const roles = ['main', 'fast'].filter((role) => touchesConnection(role === 'main' ? patch : patch.fast));
95
+ if (!roles.length)
96
+ return patch;
97
+ if (!current.modelProfiles)
98
+ return adaptUnboundProviders(current, patch);
99
+ if (validateModelProfiles(current.modelProfiles).length) {
100
+ throw new ModelProfileConfigError('invalid');
101
+ }
102
+ const profiles = normalizeModelProfiles(current.modelProfiles);
103
+ const result = { ...patch, modelProfiles: profiles };
104
+ for (const role of roles)
105
+ adaptRole(current, result, role);
106
+ return result;
107
+ }
108
+ function adaptUnboundProviders(current, patch, role) {
109
+ const result = { ...patch };
110
+ if (role !== 'fast' && patch.provider !== undefined && patch.provider !== current.provider
111
+ && !Object.prototype.hasOwnProperty.call(patch, 'baseUrl'))
112
+ result.baseUrl = null;
113
+ if (role !== 'main' && patch.fast?.provider !== undefined
114
+ && patch.fast.provider !== current.fast.provider
115
+ && !Object.prototype.hasOwnProperty.call(patch.fast, 'baseUrl')) {
116
+ result.fast = { ...patch.fast, baseUrl: '' };
117
+ }
118
+ return result;
119
+ }
120
+ function adaptRole(current, patch, role) {
121
+ const profiles = patch.modelProfiles;
122
+ const entry = profiles.entries.find((profile) => profile.id === profiles[`${role}Id`]);
123
+ if (!entry) {
124
+ Object.assign(patch, adaptUnboundProviders(current, patch, role));
125
+ return;
126
+ }
127
+ if (entry.apiKey)
128
+ throw new ModelProfileConfigError('profile_key_required');
129
+ const explicit = role === 'main' ? patch : patch.fast;
130
+ const provider = explicit.provider ?? entry.provider;
131
+ const changedProvider = provider !== entry.provider;
132
+ const baseUrl = Object.prototype.hasOwnProperty.call(explicit, 'baseUrl')
133
+ ? explicit.baseUrl ?? null : changedProvider ? null : entry.baseUrl;
134
+ const connection = { provider, model: explicit.model ?? entry.model, baseUrl };
135
+ if (role === 'fast') {
136
+ const main = projectModelProfiles(current).file;
137
+ const mainProvider = patch.provider ?? main.provider ?? current.provider;
138
+ const mainUrl = Object.prototype.hasOwnProperty.call(patch, 'baseUrl')
139
+ ? patch.baseUrl : main.baseUrl;
140
+ assertLegacyFastTarget(connection, { provider: mainProvider, baseUrl: mainUrl });
141
+ patch.fast = { ...connection, ...patch.fast, baseUrl: baseUrl ?? '' };
142
+ }
143
+ else
144
+ Object.assign(patch, connection);
145
+ profiles[`${role}Id`] = null;
146
+ }
147
+ /** A detached default endpoint must not acquire a gateway through legacy inheritance. */
148
+ export function assertLegacyFastTarget(fast, main) {
149
+ if (!normalizeProfileBaseUrl(fast.baseUrl) && (fast.provider || main.provider) === main.provider
150
+ && normalizeProfileBaseUrl(main.baseUrl)) {
151
+ throw new ModelProfileConfigError('legacy_conversion_unsupported');
152
+ }
153
+ }
@@ -0,0 +1,44 @@
1
+ export type ModelRole = 'main' | 'fast';
2
+ export interface ModelProfile {
3
+ id: string;
4
+ name: string;
5
+ provider: string;
6
+ model: string;
7
+ baseUrl: string | null;
8
+ apiKey: string | null;
9
+ }
10
+ export interface ModelProfilesConfig {
11
+ version: 1;
12
+ entries: ModelProfile[];
13
+ mainId: string | null;
14
+ fastId: string | null;
15
+ }
16
+ export interface ProfileBindingState {
17
+ selectedId: string | null;
18
+ appliedId: string | null;
19
+ overriddenFields: Array<'provider' | 'model' | 'baseUrl'>;
20
+ invalid: boolean;
21
+ }
22
+ export interface ModelProfilesRuntime {
23
+ main: ProfileBindingState;
24
+ fast: ProfileBindingState;
25
+ }
26
+ export interface ProfileValidationIssue {
27
+ path: string;
28
+ code: 'invalid' | 'duplicate_id' | 'missing_reference' | 'unsupported_version';
29
+ message: string;
30
+ }
31
+ /** Return a fresh empty library without initiating a disk migration. */
32
+ export declare function emptyModelProfiles(): ModelProfilesConfig;
33
+ /** Return independently mutable role state. */
34
+ export declare function emptyModelProfileState(invalid?: boolean): ModelProfilesRuntime;
35
+ /** Validate untrusted input; diagnostics never include supplied values. */
36
+ export declare function validateModelProfiles(value: unknown): ProfileValidationIssue[];
37
+ /** Normalize a valid library into a safe field whitelist; throws a fixed error if invalid. */
38
+ export declare function normalizeModelProfiles(value: unknown): ModelProfilesConfig;
39
+ /** Create an entry with a stable UUID and a frozen default name. */
40
+ export declare function createModelProfile(input: Omit<ModelProfile, 'id'>): ModelProfile;
41
+ /** Duplicate the current draft entry, including its explicitly edited credential. */
42
+ export declare function duplicateModelProfile(entry: ModelProfile): ModelProfile;
43
+ /** Remove an unused entry; binding references must be changed explicitly first. */
44
+ export declare function removeModelProfile(library: ModelProfilesConfig, id: string): ModelProfilesConfig;
@@ -0,0 +1,121 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { ADAPTER_PROVIDERS } from './schema.js';
3
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
4
+ /** Return a fresh empty library without initiating a disk migration. */
5
+ export function emptyModelProfiles() {
6
+ return { version: 1, entries: [], mainId: null, fastId: null };
7
+ }
8
+ /** Return independently mutable role state. */
9
+ export function emptyModelProfileState(invalid = false) {
10
+ const binding = () => ({ selectedId: null, appliedId: null,
11
+ overriddenFields: [], invalid });
12
+ return { main: binding(), fast: binding() };
13
+ }
14
+ function isRecord(value) {
15
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
16
+ }
17
+ function validText(value, max, allowEmpty = false) {
18
+ return typeof value === 'string' && !CONTROL.test(value)
19
+ && (allowEmpty || value.trim().length > 0) && [...value.trim()].length <= max;
20
+ }
21
+ function validUrl(value) {
22
+ if (value === null || (typeof value === 'string' && !value.trim()))
23
+ return true;
24
+ if (typeof value !== 'string' || CONTROL.test(value))
25
+ return false;
26
+ try {
27
+ const url = new URL(value.trim());
28
+ return ['http:', 'https:'].includes(url.protocol) && Boolean(url.hostname)
29
+ && !url.username && !url.password && !value.includes('?') && !value.includes('#');
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ function validateEntry(value, index) {
36
+ const path = `modelProfiles.entries[${index}]`;
37
+ const invalid = (field, message) => ({
38
+ path: field ? `${path}.${field}` : path, code: 'invalid', message,
39
+ });
40
+ if (!isRecord(value))
41
+ return [invalid('', 'Profile must be an object.')];
42
+ const issues = [];
43
+ if (!validText(value.id, 128))
44
+ issues.push(invalid('id', 'ID must have 1-128 safe characters.'));
45
+ if (!validText(value.name, 80, true)) {
46
+ issues.push(invalid('name', 'Name must have at most 80 safe characters.'));
47
+ }
48
+ if (!ADAPTER_PROVIDERS.includes(value.provider)) {
49
+ issues.push(invalid('provider', 'Select a supported provider.'));
50
+ }
51
+ if (!validText(value.model, 256)) {
52
+ issues.push(invalid('model', 'Model must have 1-256 safe characters.'));
53
+ }
54
+ if (!validUrl(value.baseUrl)) {
55
+ issues.push(invalid('baseUrl', 'Use an HTTP(S) URL without credentials, query or fragment.'));
56
+ }
57
+ if (value.apiKey !== null && !validText(value.apiKey, 8192)) {
58
+ issues.push(invalid('apiKey', 'Enter a nonempty key up to 8192 safe characters.'));
59
+ }
60
+ return issues;
61
+ }
62
+ /** Validate untrusted input; diagnostics never include supplied values. */
63
+ export function validateModelProfiles(value) {
64
+ if (value === undefined)
65
+ return [];
66
+ if (!isRecord(value))
67
+ return [{ path: 'modelProfiles', code: 'invalid',
68
+ message: 'Model profiles must be an object. Repair with aragon config edit.' }];
69
+ if (value.version !== 1)
70
+ return [{ path: 'modelProfiles.version',
71
+ code: 'unsupported_version', message: 'Unsupported model profiles version.' }];
72
+ if (!Array.isArray(value.entries) || value.entries.length > 200) {
73
+ return [{ path: 'modelProfiles.entries', code: 'invalid',
74
+ message: 'Use an array with at most 200 profiles; remove unused profiles to add more.' }];
75
+ }
76
+ const issues = value.entries.flatMap(validateEntry);
77
+ const ids = new Set();
78
+ value.entries.forEach((entry, index) => {
79
+ if (!isRecord(entry) || typeof entry.id !== 'string')
80
+ return;
81
+ if (ids.has(entry.id))
82
+ issues.push({ path: `modelProfiles.entries[${index}].id`,
83
+ code: 'duplicate_id', message: 'Profile IDs must be unique.' });
84
+ ids.add(entry.id);
85
+ });
86
+ for (const field of ['mainId', 'fastId']) {
87
+ if (value[field] !== null && (typeof value[field] !== 'string' || !ids.has(value[field]))) {
88
+ issues.push({ path: `modelProfiles.${field}`, code: 'missing_reference',
89
+ message: 'Select an existing profile or Current custom.' });
90
+ }
91
+ }
92
+ return issues;
93
+ }
94
+ /** Normalize a valid library into a safe field whitelist; throws a fixed error if invalid. */
95
+ export function normalizeModelProfiles(value) {
96
+ if (validateModelProfiles(value).length)
97
+ throw new Error('Invalid model profiles.');
98
+ if (value === undefined)
99
+ return emptyModelProfiles();
100
+ const library = value;
101
+ return { version: 1, mainId: library.mainId, fastId: library.fastId,
102
+ entries: library.entries.map((entry) => ({ id: entry.id, name: entry.name.trim()
103
+ || [...entry.model.trim()].slice(0, 80).join(''), provider: entry.provider,
104
+ model: entry.model.trim(), baseUrl: entry.baseUrl?.trim() || null,
105
+ apiKey: entry.apiKey?.trim() || null })) };
106
+ }
107
+ /** Create an entry with a stable UUID and a frozen default name. */
108
+ export function createModelProfile(input) {
109
+ const entry = { ...input, id: randomUUID() };
110
+ return normalizeModelProfiles({ ...emptyModelProfiles(), entries: [entry] }).entries[0];
111
+ }
112
+ /** Duplicate the current draft entry, including its explicitly edited credential. */
113
+ export function duplicateModelProfile(entry) {
114
+ return { ...entry, id: randomUUID(), name: `${[...entry.name].slice(0, 73).join('')} (copy)` };
115
+ }
116
+ /** Remove an unused entry; binding references must be changed explicitly first. */
117
+ export function removeModelProfile(library, id) {
118
+ if (library.mainId === id || library.fastId === id)
119
+ throw new Error('Profile is still in use.');
120
+ return { ...library, entries: library.entries.filter((entry) => entry.id !== id) };
121
+ }
@@ -671,6 +671,7 @@ export declare function clampBashConfig(raw: unknown): BashConfig;
671
671
  */
672
672
  export declare function parseThresholdInput(raw: string): number | null;
673
673
  export interface PersistedConfig {
674
+ modelProfiles?: import('./model-profiles.js').ModelProfilesConfig;
674
675
  version: number;
675
676
  provider: string;
676
677
  model: string;
@@ -838,6 +839,14 @@ export declare const LEGACY_STATE_KEYS: readonly ["promptHistory", "submitCount"
838
839
  */
839
840
  export declare function stripLegacyStateKeys<T extends object>(raw: T): T;
840
841
  export interface CliConfig {
842
+ /** A committed model snapshot failed to apply; no further requests may start. */
843
+ modelSettingsRestartRequired?: boolean;
844
+ modelProfiles?: import('./model-profiles.js').ModelProfilesConfig;
845
+ modelProfileState?: import('./model-profiles.js').ModelProfilesRuntime;
846
+ apiKeyOverrideTarget?: {
847
+ provider: string;
848
+ baseUrl: string | null;
849
+ };
841
850
  provider: string;
842
851
  model: string;
843
852
  baseUrl?: string;
@@ -10,6 +10,8 @@
10
10
  * the drift the header of that module warns about.
11
11
  */
12
12
  import { getConfigPath, getSessionsDir } from './app-paths.js';
13
+ import { type ModelSettingsPatch } from './model-profile-store.js';
14
+ import { type ModelProfilesConfig } from './model-profiles.js';
13
15
  import { type PersistedConfig } from './schema.js';
14
16
  export { getConfigPath, getSessionsDir };
15
17
  export interface ConfigFileRead {
@@ -40,6 +42,8 @@ export declare function writeConfigFile(config: PersistedConfig): void;
40
42
  * Unlike `readConfigFile`, this always returns a complete `PersistedConfig`.
41
43
  */
42
44
  export declare function loadPersistedConfig(): PersistedConfig;
45
+ /** Fill legacy defaults without projecting bindings or discarding unknown profile data. */
46
+ export declare function normalizePersistedConfig(input: Partial<PersistedConfig>): PersistedConfig;
43
47
  /**
44
48
  * Read the persisted config, apply a patch, write it back atomically, and
45
49
  * return the merged result. This is the single write path used by the settings
@@ -59,4 +63,10 @@ export declare function loadPersistedConfig(): PersistedConfig;
59
63
  * a third SECTION, but it is still one level deep (scalars only), so the same
60
64
  * hand-written merge covers it — and it must stay that way for that reason.
61
65
  */
62
- export declare function updatePersistedConfig(patch: Partial<PersistedConfig>): PersistedConfig;
66
+ export declare function updatePersistedConfig(patch: ModelSettingsPatch & {
67
+ modelProfiles?: ModelProfilesConfig;
68
+ }): PersistedConfig;
69
+ /** Merge an already adapted patch, replacing profile arrays as one complete section. */
70
+ export declare function mergePersistedConfig(current: PersistedConfig, patch: ModelSettingsPatch & {
71
+ modelProfiles?: ModelProfilesConfig;
72
+ }): PersistedConfig;
@@ -9,11 +9,14 @@
9
9
  * a second name for `skills/paths.ts::getUserSkillsDir()`, which is precisely
10
10
  * the drift the header of that module warns about.
11
11
  */
12
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, renameSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
13
+ import { randomUUID } from 'node:crypto';
13
14
  import { dirname } from 'node:path';
14
15
  import process from 'node:process';
15
16
  import { getConfigPath, getSessionsDir } from './app-paths.js';
16
- import { registerSecretsFrom } from '../logging/secret-registry.js';
17
+ import { registerProfileSecrets, registerSecretsFrom } from '../logging/secret-registry.js';
18
+ import { adaptLegacyConnectionPatch, ModelProfileConfigError, readModelSettingsDisk } from './model-profile-store.js';
19
+ import { normalizeModelProfiles } from './model-profiles.js';
17
20
  import { clampBashConfig, CONFIG_VERSION, DEFAULT_CONFIG, clampCompactionConfig, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampContextWindow, clampUpdateConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
18
21
  /**
19
22
  * Normalize `maxTokens` for both directions of the config file.
@@ -62,25 +65,12 @@ export { getConfigPath, getSessionsDir };
62
65
  * workflow, so the failure mode had to become visible.
63
66
  */
64
67
  export function readConfigFile() {
65
- const file = getConfigPath();
66
- if (!existsSync(file))
67
- return { config: null };
68
- try {
69
- const raw = readFileSync(file, 'utf-8');
70
- const parsed = JSON.parse(raw);
71
- if (parsed && typeof parsed === 'object') {
72
- // Retired layout preference: tolerate old files without restoring a UI mode.
73
- delete parsed.fullscreen;
74
- return { config: parsed };
75
- }
76
- return { config: null, parseError: `${file} does not contain a JSON object` };
77
- }
78
- catch (err) {
79
- return {
80
- config: null,
81
- parseError: `${file}: ${err instanceof Error ? err.message : String(err)}`,
82
- };
83
- }
68
+ const read = readModelSettingsDisk();
69
+ if (!read.ok)
70
+ return { config: null, parseError: `${getConfigPath()}: ${read.error}` };
71
+ if (read.parsed)
72
+ delete read.parsed.fullscreen;
73
+ return { config: read.parsed };
84
74
  }
85
75
  // ---------------------------------------------------------------------------
86
76
  // Write (atomic + 0600)
@@ -97,10 +87,19 @@ function ensureDir(dir) {
97
87
  export function writeConfigFile(config) {
98
88
  const file = getConfigPath();
99
89
  ensureDir(dirname(file));
100
- const tmp = `${file}.${process.pid}.tmp`;
90
+ const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
101
91
  const json = `${JSON.stringify(config, null, 2)}\n`;
102
- writeFileSync(tmp, json, { encoding: 'utf-8', mode: 0o600 });
103
- renameSync(tmp, file);
92
+ try {
93
+ writeFileSync(tmp, json, { encoding: 'utf-8', mode: 0o600, flag: 'wx' });
94
+ renameSync(tmp, file);
95
+ }
96
+ catch {
97
+ try {
98
+ rmSync(tmp, { force: true });
99
+ }
100
+ catch { /* Only our temporary path is eligible. */ }
101
+ throw new ModelProfileConfigError('write_failed');
102
+ }
104
103
  // rename may not preserve mode on every platform; re-chmod on POSIX.
105
104
  if (process.platform !== 'win32') {
106
105
  try {
@@ -116,12 +115,17 @@ export function writeConfigFile(config) {
116
115
  * Unlike `readConfigFile`, this always returns a complete `PersistedConfig`.
117
116
  */
118
117
  export function loadPersistedConfig() {
118
+ return normalizePersistedConfig(readConfigFile().config ?? {});
119
+ }
120
+ /** Fill legacy defaults without projecting bindings or discarding unknown profile data. */
121
+ export function normalizePersistedConfig(input) {
119
122
  // STRIP BEFORE THE SPREAD, not after. `partial` is the raw file, so any
120
123
  // `promptHistory` / `submitCount` / `mouseNoticeSeen` / `recentModels` left
121
124
  // on disk would otherwise ride the spread into `merged` and be written back
122
125
  // out by `updatePersistedConfig` — the "I deleted it and it grew back" bug
123
126
  // that `stripLegacyStateKeys` exists to make impossible (§4.5).
124
- const partial = stripLegacyStateKeys(readConfigFile().config ?? {});
127
+ const partial = stripLegacyStateKeys(input);
128
+ delete partial.fullscreen;
125
129
  return {
126
130
  ...DEFAULT_CONFIG,
127
131
  ...partial,
@@ -207,14 +211,27 @@ export function loadPersistedConfig() {
207
211
  * hand-written merge covers it — and it must stay that way for that reason.
208
212
  */
209
213
  export function updatePersistedConfig(patch) {
214
+ const read = readModelSettingsDisk();
215
+ if (!read.ok)
216
+ throw new ModelProfileConfigError('read_failed');
217
+ const adapted = adaptLegacyConnectionPatch({ current: read.config, patch });
218
+ const merged = mergePersistedConfig(read.config, adapted);
219
+ writeConfigFile(merged);
220
+ return merged;
221
+ }
222
+ /** Merge an already adapted patch, replacing profile arrays as one complete section. */
223
+ export function mergePersistedConfig(current, patch) {
210
224
  // Registration site 5. Whatever route a key took to get here, this is the one
211
225
  // function that writes it to disk, so this is the last place it can be added
212
226
  // to the redactor's backstop before it also starts appearing in log records.
213
227
  registerSecretsFrom(patch.apiKeys);
214
- const current = loadPersistedConfig();
228
+ registerProfileSecrets(patch);
229
+ const submittedProfiles = patch.modelProfiles === undefined
230
+ ? {} : { modelProfiles: normalizeModelProfiles(patch.modelProfiles) };
215
231
  const merged = {
216
232
  ...current,
217
233
  ...patch,
234
+ ...submittedProfiles,
218
235
  version: CONFIG_VERSION,
219
236
  // The write half of the gate: without it `config set maxTokens 900000` puts
220
237
  // a number on disk that the read path silently rewrites on every launch.
@@ -273,6 +290,5 @@ export function updatePersistedConfig(patch) {
273
290
  // `bash_output` / `bash_kill` from the next launch onward.
274
291
  bash: clampBashConfig({ ...current.bash, ...(patch.bash ?? {}) }),
275
292
  };
276
- writeConfigFile(merged);
277
293
  return merged;
278
294
  }
@@ -18,6 +18,7 @@
18
18
  * at construction is a tier that can be describing a provider the session no
19
19
  * longer uses.
20
20
  */
21
+ import type { ModelRole } from '../config/model-profiles.js';
21
22
  import { type CliConfig } from '../config/schema.js';
22
23
  import type { FastTier } from './types.js';
23
24
  /**
@@ -29,7 +30,7 @@ import type { FastTier } from './types.js';
29
30
  * `--api-key` override. Injected rather than reimplemented, because a second
30
31
  * key resolver is a second answer to "why does it say no key?".
31
32
  */
32
- export declare function resolveFastTier(config: CliConfig, hasKey: (providerId: string) => boolean): FastTier;
33
+ export declare function resolveFastTier(config: CliConfig, hasKey: (providerId: string, role?: ModelRole) => boolean): FastTier;
33
34
  /**
34
35
  * The one-line notice for a tier the user switched on that cannot resolve, or
35
36
  * `null` when there is nothing worth saying.