bb-plugin-svaib-workplace 0.5.5 → 0.5.6

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.
@@ -1,5 +1,6 @@
1
1
  // Internal provisioning module for the BB Kimi card. Never put keys in BB settings.
2
- import { access, chmod, lstat, mkdir, readFile, writeFile, rename } from 'node:fs/promises';
2
+ import { access, chmod, lstat, mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { homedir, userInfo } from 'node:os';
4
5
  import { dirname, join, relative, resolve, sep, win32 } from 'node:path';
5
6
  import { spawn } from 'node:child_process';
@@ -11,7 +12,8 @@ export const MODELS = [
11
12
  ['k3', 'Kimi K3'], ['k3-256k', 'Kimi K3 256K'],
12
13
  ['kimi-for-coding', 'Kimi K2.8 Preview'], ['kimi-for-coding-highspeed', 'Kimi K2.7 Code Highspeed (больше квоты)'],
13
14
  ];
14
- function settings(legacy = false) {
15
+ // Keep the previous generated form recognizable for owned-profile migration.
16
+ function catalogSettings(legacy = false) {
15
17
  if (legacy) return { env: legacyEnv() };
16
18
  return { model: MODEL, effortLevel: 'high', availableModels: MODELS.map(([id]) => id),
17
19
  modelPicker: { replaceBuiltInOptions: true, options: MODELS.map(([model, label]) => ({ model, label })) },
@@ -21,6 +23,153 @@ function settings(legacy = false) {
21
23
  CLAUDE_CODE_SUBAGENT_MODEL: MODEL,
22
24
  CLAUDE_CODE_MODEL_CAPABILITIES: 'k3*=effort,max_effort,-xhigh_effort;kimi-for-coding=effort,max_effort,-xhigh_effort;kimi-for-coding-highspeed=-effort,-max_effort,-xhigh_effort' } };
23
25
  }
26
+
27
+ const SHORT_WINDOW = 262144;
28
+ // Claude Code's [1m] switch means 1,000,000, below Kimi's 1,048,576 limit.
29
+ const LONG_WINDOW = 1000000;
30
+ const safePolicy = () => ({ shortWindow: SHORT_WINDOW, longModels: [] });
31
+
32
+ /** No model request, redirects, response/error logging, or unbounded waiting. */
33
+ async function fetchCatalog(apiKey, { fetchImpl = fetch, signal, log = () => {} } = {}) {
34
+ const started = Date.now();
35
+ const timeout = AbortSignal.timeout(8000);
36
+ log('catalog-fetch', 'start');
37
+ try {
38
+ signal?.throwIfAborted();
39
+ const response = await fetchImpl('https://api.kimi.ai/coding/v1/models', {
40
+ headers: { Authorization: `Bearer ${apiKey}`, 'User-Agent': 'svaib-workplace/kimi-setup' },
41
+ redirect: 'error', signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
42
+ });
43
+ if (!response.ok) {
44
+ log('catalog-fetch', 'fallback', { reason: 'http-error', httpStatus: response.status, elapsedMs: Date.now() - started });
45
+ await response.body?.cancel(); return null;
46
+ }
47
+ // The catalog is small. Bound the body as well as the request duration.
48
+ let text = '';
49
+ let bytes = 0;
50
+ const decoder = new TextDecoder();
51
+ for await (const chunk of response.body) {
52
+ bytes += chunk.byteLength;
53
+ if (bytes > 262144) { log('catalog-fetch', 'fallback', { reason: 'oversized', elapsedMs: Date.now() - started }); return null; }
54
+ text += decoder.decode(chunk, { stream: true });
55
+ }
56
+ text += decoder.decode();
57
+ const body = JSON.parse(text);
58
+ log('catalog-fetch', 'success', { byteCount: bytes, elapsedMs: Date.now() - started });
59
+ return body;
60
+ } catch (error) {
61
+ log('catalog-fetch', signal?.aborted ? 'failure' : 'fallback', {
62
+ reason: signal?.aborted ? 'cancelled' : timeout.aborted ? 'timeout' : error instanceof SyntaxError ? 'invalid-json' : 'network-error', elapsedMs: Date.now() - started,
63
+ });
64
+ signal?.throwIfAborted();
65
+ return null;
66
+ }
67
+ }
68
+
69
+ const modelIdPattern = /^[a-z0-9][a-z0-9._/-]{0,127}$/;
70
+ const reservedModels = new Set(['default', 'opus', 'sonnet', 'haiku', 'best', 'opusplan', '__proto__', 'constructor', 'prototype']);
71
+ function validId(id) { return typeof id === 'string' && modelIdPattern.test(id) && !reservedModels.has(id); }
72
+ function seedCatalog() {
73
+ return { defaultModel: MODEL, models: MODELS.map(([id, label]) => ({ id, label, context: SHORT_WINDOW,
74
+ effort: id !== 'kimi-for-coding-highspeed', maxEffort: id !== 'kimi-for-coding-highspeed', retired: false })) };
75
+ }
76
+
77
+ /** API additions need no local model registry; unknown capabilities stay disabled. */
78
+ export function parseCatalog(body) {
79
+ if (!Array.isArray(body?.data) || !body.data.length || body.data.length > 128) return null;
80
+ const models = [];
81
+ const seen = new Set();
82
+ for (const row of body.data) {
83
+ if (!validId(row?.id) || seen.has(row.id)) return null;
84
+ seen.add(row.id);
85
+ const label = typeof row.display_name === 'string' ? row.display_name.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 120) : row.id;
86
+ const fixed256 = row.id === 'k3-256k' || row.id === 'kimi-for-coding-highspeed';
87
+ const limits = [row.context_length, row.limit?.context].filter(value => Number.isSafeInteger(value) && value > 0);
88
+ const context = limits.length ? Math.min(...limits, fixed256 ? SHORT_WINDOW : LONG_WINDOW) : SHORT_WINDOW;
89
+ const levels = row.think_efforts?.support === false ? [] : row.think_efforts?.valid_efforts ?? row.reasoning?.effort?.valid;
90
+ // CLI represents low/medium(high)/high and optionally max, not arbitrary enums.
91
+ const effort = row.supports_reasoning !== false && Array.isArray(levels) && levels.includes('low') && levels.includes('high');
92
+ models.push({ id: row.id, label: label || row.id, context, effort, maxEffort: effort && levels.includes('max'), retired: false });
93
+ }
94
+ return models;
95
+ }
96
+
97
+ function validCatalog(value) {
98
+ return value && Array.isArray(value.models) && value.models.length > 0 && value.models.length <= 129
99
+ && value.models.some(model => model.id === value.defaultModel)
100
+ && new Set(value.models.map(model => model.id)).size === value.models.length
101
+ && value.models.every(model => validId(model.id) && typeof model.label === 'string' && model.label.length <= 120
102
+ && Number.isSafeInteger(model.context) && model.context > 0 && model.context <= LONG_WINDOW
103
+ && typeof model.effort === 'boolean' && typeof model.maxEffort === 'boolean' && typeof model.retired === 'boolean');
104
+ }
105
+
106
+ export async function discoverCatalog(apiKey, previous = seedCatalog(), options = {}) {
107
+ const body = await fetchCatalog(apiKey, options);
108
+ const fresh = parseCatalog(body);
109
+ const fallback = model => ({ ...model, context: Math.min(SHORT_WINDOW, model.context) });
110
+ if (!fresh) {
111
+ options.log?.('catalog-normalize', 'fallback', { reason: body === null ? 'cached' : 'invalid-catalog', modelCount: previous.models.length, longModelCount: 0 });
112
+ return { ...previous, models: previous.models.map(fallback) };
113
+ }
114
+ options.log?.('catalog-normalize', 'success', { modelCount: fresh.length, longModelCount: fresh.filter(model => model.context >= LONG_WINDOW).length });
115
+ if (options.initial) return { defaultModel: fresh.some(model => model.id === previous.defaultModel) ? previous.defaultModel : fresh[0].id, models: fresh };
116
+ const selected = previous.models.find(model => model.id === previous.defaultModel);
117
+ // A removed default stays visible as unavailable; never silently select a new model.
118
+ if (selected && !fresh.some(model => model.id === selected.id)) fresh.push({ ...fallback(selected), retired: true });
119
+ return { defaultModel: previous.defaultModel, models: fresh };
120
+ }
121
+
122
+ function dynamicSettings(catalog) {
123
+ const base = catalogSettings();
124
+ const shortWindow = Math.min(SHORT_WINDOW, ...catalog.models.filter(model => model.context < LONG_WINDOW).map(model => model.context));
125
+ const modelId = id => catalog.models.find(model => model.id === id)?.context >= LONG_WINDOW ? `${id}[1m]` : id;
126
+ const window = model => model.context >= LONG_WINDOW ? LONG_WINDOW : shortWindow;
127
+ const defaultModel = modelId(catalog.defaultModel);
128
+ return { ...base, model: defaultModel, availableModels: catalog.models.map(model => modelId(model.id)), availableModelsMatch: 'exact',
129
+ modelPicker: { replaceBuiltInOptions: true, options: catalog.models.map(model => ({ model: modelId(model.id),
130
+ label: `${model.label} (${window(model) === LONG_WINDOW ? '1M' : Math.floor(window(model) / 1024) + 'K'})`,
131
+ ...(model.retired ? { description: 'Модель больше не доступна в каталоге Kimi. Выбери другую модель для нового разговора.' } : {}),
132
+ })) },
133
+ modelSettings: Object.fromEntries(catalog.models.map(model => [model.id, { autoCompactWindow: Math.max(100000, window(model)) }])),
134
+ env: { ...base.env, ANTHROPIC_MODEL: defaultModel, ANTHROPIC_DEFAULT_FABLE_MODEL: defaultModel,
135
+ ANTHROPIC_DEFAULT_OPUS_MODEL: defaultModel, ANTHROPIC_DEFAULT_SONNET_MODEL: defaultModel,
136
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: defaultModel, CLAUDE_CODE_SUBAGENT_MODEL: defaultModel,
137
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(shortWindow),
138
+ CLAUDE_CODE_MODEL_CAPABILITIES: catalog.models.map(model => `${model.id}=${model.effort ? '' : '-'}effort,${model.maxEffort ? '' : '-'}max_effort,-xhigh_effort`).join(';'),
139
+ },
140
+ };
141
+ }
142
+
143
+ function contextSettingsV2(policy = safePolicy()) {
144
+ const base = catalogSettings();
145
+ const modelId = id => policy.longModels.includes(id) ? `${id}[1m]` : id;
146
+ const window = id => policy.longModels.includes(id) ? LONG_WINDOW : policy.shortWindow;
147
+ return { ...base, model: modelId(MODEL), availableModels: MODELS.map(([id]) => modelId(id)),
148
+ availableModelsMatch: 'exact',
149
+ modelPicker: { replaceBuiltInOptions: true, options: MODELS.map(([id, label]) => ({
150
+ model: modelId(id), label: `${label} (${window(id) === LONG_WINDOW ? '1M' : Math.floor(window(id) / 1024) + 'K'})`,
151
+ })) },
152
+ modelSettings: Object.fromEntries(MODELS.map(([id]) => [id, { autoCompactWindow: Math.max(100000, window(id)) }])),
153
+ env: { ...base.env,
154
+ ANTHROPIC_MODEL: modelId(MODEL), ANTHROPIC_DEFAULT_FABLE_MODEL: modelId(MODEL),
155
+ ANTHROPIC_DEFAULT_OPUS_MODEL: modelId(MODEL), ANTHROPIC_DEFAULT_SONNET_MODEL: modelId(MODEL),
156
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: modelId(MODEL), CLAUDE_CODE_SUBAGENT_MODEL: modelId(MODEL),
157
+ CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(policy.shortWindow),
158
+ },
159
+ };
160
+ }
161
+
162
+ // Reconstruct only a byte-for-byte generated profile. Any user edit is preserved.
163
+ function generatedSettings(text) {
164
+ if ([catalogSettings(), catalogSettings(true)].some(value => text === JSON.stringify(value, null, 2))) return true;
165
+ try {
166
+ const value = JSON.parse(text);
167
+ const shortWindow = Number(value.env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS);
168
+ if (!Number.isSafeInteger(shortWindow) || shortWindow <= 0 || shortWindow > SHORT_WINDOW) return false;
169
+ const longModels = MODELS.filter(([id]) => value.availableModels?.includes(`${id}[1m]`)).map(([id]) => id);
170
+ return text === JSON.stringify(contextSettingsV2({ shortWindow, longModels }), null, 2);
171
+ } catch { return false; }
172
+ }
24
173
  function legacyEnv() {
25
174
  const MODEL = 'k3.1-pro[1m]';
26
175
  return { ANTHROPIC_BASE_URL: 'https://api.kimi.ai/coding/', ANTHROPIC_MODEL: MODEL,
@@ -68,7 +217,7 @@ export async function npmCliPath(nodeExecutable = process.execPath) {
68
217
  throw new Error('npm не найден рядом с Node.js. Установи Node.js 22+ официальным установщиком с npm.');
69
218
  }
70
219
 
71
- export async function provision({ root, apiKey, install = command, npmCli = null, nodeExecutable = process.execPath }) {
220
+ export async function provision({ root, apiKey, install = command, npmCli = null, nodeExecutable = process.execPath, fetchImpl = fetch, signal, log = () => {} }) {
72
221
  if (!/^sk-kimi-[A-Za-z0-9_-]+$/.test(apiKey)) throw new Error('Ожидается ключ Kimi Code вида sk-kimi-…; значение не сохранено.');
73
222
  root = resolve(root);
74
223
  // A separate directory and exclusive writes preserve every existing profile.
@@ -78,14 +227,16 @@ export async function provision({ root, apiKey, install = command, npmCli = null
78
227
  const configRoot = join(root, 'claude');
79
228
  await privateDirectory(configRoot);
80
229
  await writeFile(join(root, 'credentials.json'), JSON.stringify({ apiKey }), { mode: 0o600, flag: 'wx' });
81
- await writeFile(join(configRoot, 'settings.json'), JSON.stringify(settings(), null, 2), { mode: 0o600, flag: 'wx' });
230
+ const initialCatalog = seedCatalog();
231
+ await writeFile(join(configRoot, 'settings.json'), JSON.stringify(dynamicSettings(initialCatalog), null, 2), { mode: 0o600, flag: 'wx' });
82
232
  await writeFile(join(configRoot, '.claude.json'), JSON.stringify({ hasCompletedOnboarding: true, penguinModeOrgEnabled: true }), { mode: 0o600, flag: 'wx' });
83
- await writeFile(join(root, 'setup-owner.json'), JSON.stringify({ schema: 1, adapter: ADAPTER, nodeExecutable }), { mode: 0o600, flag: 'wx' });
84
- return finishProvision({ root, install, npmCli, nodeExecutable });
233
+ await writeFile(join(root, 'setup-owner.json'), JSON.stringify({ schema: 1, adapter: ADAPTER, nodeExecutable, catalogs: [initialCatalog] }), { mode: 0o600, flag: 'wx' });
234
+ await finishProvision({ root, install, npmCli, nodeExecutable, log });
235
+ return upgradeProvision(root, { fetchImpl, signal, initial: true, log });
85
236
  }
86
237
 
87
238
  // Only a profile created by this helper can resume a failed dependency download.
88
- export async function resumeProvision({ root, install = command, npmCli = null, nodeExecutable = null }) {
239
+ export async function resumeProvision({ root, install = command, npmCli = null, nodeExecutable = null, fetchImpl = fetch, signal, log = () => {} }) {
89
240
  for (const name of ['', 'setup-owner.json', 'credentials.json', 'claude', 'claude/settings.json']) {
90
241
  if ((await lstat(join(root, name))).isSymbolicLink()) throw new Error('Профиль настройки изменён. Ничего не перезаписано.');
91
242
  }
@@ -97,14 +248,18 @@ export async function resumeProvision({ root, install = command, npmCli = null,
97
248
  await writeFile(temporary, JSON.stringify(owner), { flag: 'wx', mode: 0o600 });
98
249
  await rename(temporary, join(root, 'setup-owner.json'));
99
250
  }
100
- const result = await finishProvision({ root, install, npmCli, nodeExecutable: owner.nodeExecutable });
101
- return upgradeProvision(root).then(() => result);
251
+ await finishProvision({ root, install, npmCli, nodeExecutable: owner.nodeExecutable, log });
252
+ return upgradeProvision(root, { fetchImpl, signal, initial: true, log });
102
253
  }
103
254
 
104
- async function finishProvision({ root, install, npmCli, nodeExecutable }) {
255
+ async function finishProvision({ root, install, npmCli, nodeExecutable, log }) {
105
256
  const runtime = join(root, 'runtime');
106
- await install(nodeExecutable,
107
- [npmCli ?? await npmCliPath(nodeExecutable), 'install', '--prefix', runtime, '--no-audit', '--no-fund', ADAPTER]);
257
+ const started = Date.now(); log('adapter-install', 'start');
258
+ try {
259
+ await install(nodeExecutable,
260
+ [npmCli ?? await npmCliPath(nodeExecutable), 'install', '--prefix', runtime, '--no-audit', '--no-fund', ADAPTER]);
261
+ log('adapter-install', 'success', { elapsedMs: Date.now() - started });
262
+ } catch (error) { log('adapter-install', 'failure', { reason: 'failed', elapsedMs: Date.now() - started }); throw error; }
108
263
  const launcher = launcherSource(root);
109
264
  await sameOrNew(join(root, 'launch.mjs'), launcher);
110
265
  const agent = registration(root, nodeExecutable);
@@ -119,7 +274,7 @@ async function sameOrNew(path, content) {
119
274
  }
120
275
  }
121
276
 
122
- function launcherSource(root, legacy = false) {
277
+ function launcherSource(root, legacy = false, previous = false) {
123
278
  const adapterDir = join(root, 'runtime', 'node_modules', '@agentclientprotocol', 'claude-agent-acp', 'dist');
124
279
  return `import { readFileSync } from 'node:fs';
125
280
  import { dirname, join } from 'node:path';
@@ -138,13 +293,17 @@ function run(command, args, env) {
138
293
  });
139
294
  }
140
295
  if (process.argv.includes('--list-models')) {
141
- ${legacy ? "console.log('k3.1-pro[1m] - Kimi K3.1 Pro (1M)');" : MODELS.map(([id, label]) => `console.log(${JSON.stringify(id + ' - ' + label)});`).join('\n ')}
296
+ ${legacy ? "console.log('k3.1-pro[1m] - Kimi K3.1 Pro (1M)');" : previous ? MODELS.map(([id, label]) => `console.log(${JSON.stringify(id + ' - ' + label)});`).join('\n ') : "const settings = JSON.parse(readFileSync(join(root, 'claude', 'settings.json'), 'utf8'));\n for (const model of settings.modelPicker.options) console.log(model.model + ' - ' + model.label);"}
142
297
  } else {
143
298
  const apiKey = JSON.parse(readFileSync(join(root, 'credentials.json'), 'utf8')).apiKey;
144
299
  const settings = JSON.parse(readFileSync(join(root, 'claude', 'settings.json'), 'utf8'));
145
300
  const env = { ...process.env, ...settings.env, ANTHROPIC_API_KEY: apiKey, CLAUDE_CONFIG_DIR: join(root, 'claude') };
146
301
  ${legacy ? '' : "for (const name of ['ANTHROPIC_CUSTOM_MODEL_OPTION', 'ANTHROPIC_CUSTOM_MODEL_OPTION_NAME', 'ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION', 'ANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES', 'CLAUDE_CODE_EFFORT_LEVEL', 'CLAUDE_MODEL_CONFIG', 'MAX_THINKING_TOKENS', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS']) delete env[name];\n "}delete env.ANTHROPIC_AUTH_TOKEN;
147
- delete env.CLAUDE_CODE_EXECUTABLE;
302
+ ${legacy || previous === true ? '' : `// The 256K ceiling applies to plain IDs; [1m] is selected per model from the catalog.
303
+ env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = settings.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
304
+ delete env.DISABLE_COMPACT;
305
+ delete env.CLAUDE_CODE_DISABLE_1M_CONTEXT;
306
+ `}delete env.CLAUDE_CODE_EXECUTABLE;
148
307
  delete env.CLAUDE_CODE_OAUTH_TOKEN;
149
308
  delete env.CLAUDE_CODE_USE_BEDROCK;
150
309
  delete env.CLAUDE_CODE_USE_VERTEX;
@@ -164,28 +323,76 @@ if (process.argv.includes('--list-models')) {
164
323
  }
165
324
 
166
325
  /** Upgrade only byte-for-byte generated files. Credentials and user edits are retained. */
167
- export async function upgradeProvision(root) {
168
- for (const name of ['', 'setup-owner.json', 'claude', 'claude/settings.json', 'launch.mjs', 'bb-agent.json']) {
326
+ export async function validateProvision(root) {
327
+ for (const name of ['', 'setup-owner.json', 'credentials.json', 'claude', 'claude/settings.json', 'launch.mjs', 'bb-agent.json']) {
169
328
  if ((await lstat(join(root, name))).isSymbolicLink()) throw new Error('Профиль настройки изменён. Ничего не перезаписано.');
170
329
  }
171
- const owner = JSON.parse(await readFile(join(root, 'setup-owner.json'), 'utf8'));
330
+ const ownerText = await readFile(join(root, 'setup-owner.json'), 'utf8');
331
+ const owner = JSON.parse(ownerText);
172
332
  if (owner.schema !== 1 || owner.adapter !== ADAPTER) throw new Error('Неизвестный профиль настройки.');
333
+ if (owner.catalogs !== undefined && (!Array.isArray(owner.catalogs) || owner.catalogs.length > 2 || !owner.catalogs.every(validCatalog))) throw new Error('Повреждён каталог настройки.');
173
334
  const files = [
174
- ['claude/settings.json', JSON.stringify(settings(true), null, 2), JSON.stringify(settings(), null, 2)],
335
+ ['claude/settings.json', null, null],
175
336
  ['launch.mjs', launcherSource(root, true), launcherSource(root)],
176
337
  ['bb-agent.json', JSON.stringify(registration(root, owner.nodeExecutable, true), null, 2), JSON.stringify(registration(root, owner.nodeExecutable), null, 2)],
177
338
  ];
178
339
  const pending = [];
340
+ const snapshots = [['setup-owner.json', ownerText]];
341
+ let catalog;
179
342
  for (const [name, before, after] of files) {
180
343
  const actual = await readFile(join(root, name), 'utf8');
344
+ snapshots.push([name, actual]);
181
345
  const interim = name === 'bb-agent.json' ? JSON.stringify(registration(root, owner.nodeExecutable, 'catalog'), null, 2) : null;
182
- if (actual !== before && actual !== after && actual !== interim) throw new Error('Профиль настройки изменён. Существующие файлы сохранены.');
346
+ if (name === 'claude/settings.json') catalog = owner.catalogs?.find(value => actual === JSON.stringify(dynamicSettings(value), null, 2));
347
+ const known = name === 'claude/settings.json' ? !!catalog || generatedSettings(actual)
348
+ : actual === before || actual === after || actual === interim || name === 'launch.mjs'
349
+ && [true, 'context'].some(previous => actual === launcherSource(root, false, previous));
350
+ if (!known) throw new Error('Профиль настройки изменён. Существующие файлы сохранены.');
351
+ if (name === 'claude/settings.json') continue;
183
352
  if (actual !== after) pending.push([name, after]);
184
353
  }
185
- for (const [name, after] of pending) {
186
- const temporary = join(root, name + '.upgrade');
187
- await writeFile(temporary, after, { mode: 0o600, flag: 'wx' });
188
- await rename(temporary, join(root, name));
189
- }
354
+ return { pending, owner, snapshots, catalog: catalog ?? seedCatalog() };
355
+ }
356
+
357
+ async function atomicWrite(path, content) {
358
+ const temporary = path + '.upgrade-' + randomUUID();
359
+ try {
360
+ await writeFile(temporary, content, { mode: 0o600, flag: 'wx' });
361
+ await rename(temporary, path);
362
+ } finally { await rm(temporary, { force: true }); }
363
+ }
364
+
365
+ export async function upgradeProvision(root, { fetchImpl = fetch, signal, initial = false, restrictContext = false, log = () => {} } = {}) {
366
+ log('profile-validate', 'start');
367
+ let validated;
368
+ try { validated = await validateProvision(root); }
369
+ catch (error) { log('profile-validate', 'failure', { reason: 'failed' }); throw error; }
370
+ const { pending, owner, snapshots, catalog: previous } = validated;
371
+ log('profile-validate', 'success');
372
+ const credentials = await readFile(join(root, 'credentials.json'), 'utf8');
373
+ const { apiKey } = JSON.parse(credentials);
374
+ const catalog = restrictContext
375
+ ? { ...previous, models: previous.models.map(model => ({ ...model, context: Math.min(SHORT_WINDOW, model.context) })) }
376
+ : await discoverCatalog(apiKey, previous, { fetchImpl, signal, initial, log });
377
+ if (restrictContext) log('catalog-normalize', 'fallback', { reason: 'conservative', modelCount: catalog.models.length, longModelCount: 0 });
378
+ signal?.throwIfAborted();
379
+ const started = Date.now(); log('profile-write', 'start');
380
+ try {
381
+ // Network I/O must not open a window for overwriting edits or a replaced key.
382
+ await validateProvision(root);
383
+ for (const [name, before] of [...snapshots, ['credentials.json', credentials]]) {
384
+ if (await readFile(join(root, name), 'utf8') !== before) throw new Error('Профиль изменился во время проверки контекста. Повтори настройку.');
385
+ }
386
+ // Journal both known generations first. A crash between file renames remains
387
+ // recognizable, while an unrelated edit still fails the byte-for-byte check.
388
+ const ownerPath = join(root, 'setup-owner.json');
389
+ await atomicWrite(ownerPath, JSON.stringify({ ...owner, catalogs: [catalog, previous] }));
390
+ pending.push(['claude/settings.json', JSON.stringify(dynamicSettings(catalog), null, 2)]);
391
+ for (const [name, after] of pending) {
392
+ await atomicWrite(join(root, name), after);
393
+ }
394
+ await atomicWrite(ownerPath, JSON.stringify({ ...owner, catalogs: [catalog] }));
395
+ log('profile-write', 'success', { elapsedMs: Date.now() - started });
396
+ } catch (error) { log('profile-write', 'failure', { reason: 'failed', elapsedMs: Date.now() - started }); throw error; }
190
397
  return registration(root, owner.nodeExecutable);
191
398
  }