@yeaft/webchat-agent 1.0.300 → 1.0.301

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.
@@ -0,0 +1,618 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
3
+ import {
4
+ accessSync,
5
+ chmodSync,
6
+ constants,
7
+ existsSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ renameSync,
13
+ rmSync,
14
+ statSync,
15
+ unlinkSync,
16
+ writeFileSync,
17
+ } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { basename, delimiter, dirname, join, resolve } from 'node:path';
20
+ import { gunzipSync, inflateRawSync } from 'node:zlib';
21
+
22
+ const DEFAULT_ROOT = join(homedir(), '.yeaft');
23
+ const DOWNLOAD_TIMEOUT_MS = 30_000;
24
+ const MAX_ARCHIVE_BYTES = 25 * 1024 * 1024;
25
+ const MAX_BINARY_BYTES = 20 * 1024 * 1024;
26
+ const FAILURE_COOLDOWN_MS = 60 * 60 * 1000;
27
+ const LOCK_STALE_MS = 2 * 60 * 1000;
28
+ const LOCK_WAIT_MS = 15_000;
29
+ const STATE_FILE = 'managed-cli.json';
30
+ const installFlights = new Map();
31
+
32
+ const TOOL_SPECS = Object.freeze({
33
+ rg: {
34
+ version: '15.2.0',
35
+ repository: 'BurntSushi/ripgrep',
36
+ tag: '15.2.0',
37
+ assets: {
38
+ 'linux-x64': ['ripgrep-15.2.0-x86_64-unknown-linux-musl.tar.gz', '33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c'],
39
+ 'linux-arm64': ['ripgrep-15.2.0-aarch64-unknown-linux-musl.tar.gz', '800b1e7206afe799dfb5a6901f23147cfaabe0e52210538100f61e86e1740915'],
40
+ 'darwin-x64': ['ripgrep-15.2.0-x86_64-apple-darwin.tar.gz', 'af7825fcc69a2afc7a7aea55fc9af90e26421d8f20fe59df32e233c0b8a231c1'],
41
+ 'darwin-arm64': ['ripgrep-15.2.0-aarch64-apple-darwin.tar.gz', '3750b2e93f37e0c692657da574d7019a101c0084da05a790c83fd335bad973e4'],
42
+ 'win32-x64': ['ripgrep-15.2.0-x86_64-pc-windows-msvc.zip', '71b2fef860abe467217a538ff31de02f5258807c0129f771846f87bd029aafc5'],
43
+ 'win32-arm64': ['ripgrep-15.2.0-aarch64-pc-windows-msvc.zip', 'e4abca10c3a64ebea742667dd7009449d49403db5460dd6873e389fa2945360f'],
44
+ },
45
+ },
46
+ fd: {
47
+ version: '10.3.0',
48
+ repository: 'sharkdp/fd',
49
+ tag: 'v10.3.0',
50
+ aliases: ['fdfind'],
51
+ assets: {
52
+ 'linux-x64': ['fd-v10.3.0-x86_64-unknown-linux-musl.tar.gz', '2b6bfaae8c48f12050813c2ffe1884c61ea26e750d803df9c9114550a314cd14'],
53
+ 'linux-arm64': ['fd-v10.3.0-aarch64-unknown-linux-musl.tar.gz', '996b9b1366433b211cb3bbedba91c9dbce2431842144d925428ead0adf32020b'],
54
+ 'darwin-x64': ['fd-v10.3.0-x86_64-apple-darwin.tar.gz', '50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3'],
55
+ 'darwin-arm64': ['fd-v10.3.0-aarch64-apple-darwin.tar.gz', '0570263812089120bc2a5d84f9e65cd0c25e4a4d724c80075c357239c74ae904'],
56
+ 'win32-x64': ['fd-v10.3.0-x86_64-pc-windows-msvc.zip', '318aa2a6fa664325933e81fda60d523fff29444129e91ebf0726b5b3bcd8b059'],
57
+ 'win32-arm64': ['fd-v10.3.0-aarch64-pc-windows-msvc.zip', 'bf9b1e31bcac71c1e95d49c56f0d872f525b95d03854e94b1d4dd6786f825cc5'],
58
+ },
59
+ },
60
+ dust: {
61
+ version: '1.2.4',
62
+ repository: 'bootandy/dust',
63
+ tag: 'v1.2.4',
64
+ assets: {
65
+ 'linux-x64': ['dust-v1.2.4-x86_64-unknown-linux-musl.tar.gz', '4e313f9f854017e58a2ada4c0d1774677b8cf53d63ab55a991d5871d5f504452'],
66
+ 'linux-arm64': ['dust-v1.2.4-aarch64-unknown-linux-musl.tar.gz', 'e09b0d24b5da0fa06aecf1561849c13ae41ef055c1ce7077e35e9a46744b16af'],
67
+ 'darwin-x64': ['dust-v1.2.4-x86_64-apple-darwin.tar.gz', 'bf84d3ff7f58e325d3eb5bb7696df6b22ef1e01fec80c2d8f7c9d3e611be66f4'],
68
+ 'win32-x64': ['dust-v1.2.4-x86_64-pc-windows-msvc.zip', 'eb08d642f016787bb9fc918a4dc5f34665463657fddf83a40f2441cbf020fb4c'],
69
+ },
70
+ },
71
+ });
72
+
73
+ function executableName(name, platform = process.platform) {
74
+ return platform === 'win32' ? `${name}.exe` : name;
75
+ }
76
+
77
+ export function managedCliBinDir(yeaftDir = DEFAULT_ROOT) {
78
+ return join(resolve(yeaftDir), 'bin');
79
+ }
80
+
81
+ export function prependManagedCliBinToPath(yeaftDir = DEFAULT_ROOT, env = process.env, platform = process.platform) {
82
+ const binDir = managedCliBinDir(yeaftDir);
83
+ const current = typeof env.PATH === 'string'
84
+ ? env.PATH
85
+ : (typeof env.Path === 'string' ? env.Path : '');
86
+ const parts = current.split(delimiter).filter(Boolean);
87
+ const normalized = platform === 'win32' ? binDir.toLowerCase() : binDir;
88
+ if (!parts.some(part => (platform === 'win32' ? part.toLowerCase() : part) === normalized)) {
89
+ const nextPath = [binDir, ...parts].join(delimiter);
90
+ env.PATH = nextPath;
91
+ if (platform === 'win32' && Object.hasOwn(env, 'Path')) env.Path = nextPath;
92
+ }
93
+ return binDir;
94
+ }
95
+
96
+ function canExecute(path, platform) {
97
+ try {
98
+ accessSync(path, platform === 'win32' ? constants.F_OK : constants.X_OK);
99
+ return statSync(path).isFile();
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function pathCandidates(name, { env, platform }) {
106
+ const names = platform === 'win32'
107
+ ? [name.endsWith('.exe') ? name : `${name}.exe`, name]
108
+ : [name];
109
+ const pathEntries = String(env.PATH || env.Path || '').split(delimiter).filter(Boolean);
110
+ const candidates = [];
111
+ for (const directory of pathEntries) {
112
+ for (const candidate of names) candidates.push(join(directory, candidate));
113
+ }
114
+ return candidates;
115
+ }
116
+
117
+ function hashFile(path) {
118
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
119
+ }
120
+
121
+ function samePath(left, right, platform) {
122
+ let normalizedLeft;
123
+ let normalizedRight;
124
+ try { normalizedLeft = realpathSync(left); } catch { normalizedLeft = resolve(left); }
125
+ try { normalizedRight = realpathSync(right); } catch { normalizedRight = resolve(right); }
126
+ return platform === 'win32'
127
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
128
+ : normalizedLeft === normalizedRight;
129
+ }
130
+
131
+ function sameFileIdentity(left, right) {
132
+ try {
133
+ const leftStat = statSync(left, { bigint: true });
134
+ const rightStat = statSync(right, { bigint: true });
135
+ return leftStat.isFile()
136
+ && rightStat.isFile()
137
+ && leftStat.dev === rightStat.dev
138
+ && leftStat.ino === rightStat.ino;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ function inspectManagedBinary(name, { yeaftDir, platform, arch }) {
145
+ const asset = selectAsset(name, platform, arch);
146
+ const path = join(managedCliBinDir(yeaftDir), executableName(name, platform));
147
+ if (!asset || !canExecute(path, platform)) return { path, exists: false, valid: false };
148
+ const installation = readState(yeaftDir).installations?.[name];
149
+ if (installation?.version !== asset.version
150
+ || installation?.platform !== platform
151
+ || installation?.arch !== arch
152
+ || installation?.assetFileName !== asset.fileName
153
+ || installation?.archiveSha256 !== asset.sha256
154
+ || typeof installation?.binarySha256 !== 'string') {
155
+ return { path, exists: true, valid: false };
156
+ }
157
+ try {
158
+ return {
159
+ path,
160
+ exists: true,
161
+ valid: hashFile(path) === installation.binarySha256,
162
+ };
163
+ } catch {
164
+ return { path, exists: true, valid: false };
165
+ }
166
+ }
167
+
168
+ function resolveExternalCommand(name, { yeaftDir, env, platform }) {
169
+ const spec = TOOL_SPECS[name];
170
+ const managedBinDir = managedCliBinDir(yeaftDir);
171
+ const managedPath = join(managedBinDir, executableName(name, platform));
172
+ for (const commandName of [name, ...(spec.aliases || [])]) {
173
+ for (const candidate of pathCandidates(commandName, { env, platform })) {
174
+ if (samePath(dirname(candidate), managedBinDir, platform)
175
+ || samePath(candidate, managedPath, platform)
176
+ || sameFileIdentity(candidate, managedPath)) continue;
177
+ if (canExecute(candidate, platform)) return candidate;
178
+ }
179
+ }
180
+ return null;
181
+ }
182
+
183
+ export function resolveManagedCliCommand(name, options = {}) {
184
+ const spec = TOOL_SPECS[name];
185
+ if (!spec) return null;
186
+ const platform = options.platform || process.platform;
187
+ const env = options.env || process.env;
188
+ const arch = options.arch || process.arch;
189
+ const yeaftDir = resolve(options.yeaftDir || DEFAULT_ROOT);
190
+ const managed = inspectManagedBinary(name, { yeaftDir, platform, arch });
191
+ if (managed.valid) return managed.path;
192
+ return resolveExternalCommand(name, { yeaftDir, env, platform });
193
+ }
194
+
195
+ function selectAsset(name, platform, arch) {
196
+ const spec = TOOL_SPECS[name];
197
+ if (!spec) return null;
198
+ const asset = spec.assets[`${platform}-${arch}`];
199
+ if (!asset) return null;
200
+ const [fileName, sha256] = asset;
201
+ return {
202
+ ...spec,
203
+ fileName,
204
+ sha256,
205
+ url: `https://github.com/${spec.repository}/releases/download/${spec.tag}/${fileName}`,
206
+ };
207
+ }
208
+
209
+ function readState(yeaftDir) {
210
+ try {
211
+ const parsed = JSON.parse(readFileSync(join(yeaftDir, STATE_FILE), 'utf8'));
212
+ return parsed && typeof parsed === 'object' ? parsed : {};
213
+ } catch {
214
+ return {};
215
+ }
216
+ }
217
+
218
+ function writeState(yeaftDir, state) {
219
+ const target = join(yeaftDir, STATE_FILE);
220
+ const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
221
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
222
+ renameSync(temporary, target);
223
+ }
224
+
225
+ function sleep(ms) {
226
+ return new Promise(resolveSleep => setTimeout(resolveSleep, ms));
227
+ }
228
+
229
+ async function acquireLock(lockDir, waitMs = LOCK_WAIT_MS, ready = null) {
230
+ const startedAt = Date.now();
231
+ for (;;) {
232
+ try {
233
+ mkdirSync(lockDir);
234
+ return true;
235
+ } catch (error) {
236
+ if (error?.code !== 'EEXIST') throw error;
237
+ if (ready?.()) return false;
238
+
239
+ let invalidLock = false;
240
+ let staleLock = false;
241
+ try {
242
+ const lockStat = lstatSync(lockDir);
243
+ invalidLock = !lockStat.isDirectory();
244
+ staleLock = !invalidLock && Date.now() - lockStat.mtimeMs > LOCK_STALE_MS;
245
+ } catch {
246
+ // Inspection failures are treated as a busy lock until the deadline.
247
+ }
248
+
249
+ if (invalidLock) {
250
+ try { unlinkSync(lockDir); } catch {}
251
+ return false;
252
+ }
253
+ if (staleLock) {
254
+ try { rmSync(lockDir, { recursive: true, force: true }); } catch {}
255
+ }
256
+
257
+ const remainingMs = waitMs - (Date.now() - startedAt);
258
+ if (remainingMs <= 0) return false;
259
+ await sleep(Math.min(250, remainingMs));
260
+ }
261
+ }
262
+ }
263
+
264
+ async function updateState(yeaftDir, update) {
265
+ const lockDir = join(yeaftDir, '.managed-cli-state.lock');
266
+ const acquired = await acquireLock(lockDir, LOCK_WAIT_MS);
267
+ if (!acquired) throw new Error('managed CLI state is busy');
268
+ try {
269
+ const current = readState(yeaftDir);
270
+ const next = update(current && typeof current === 'object' ? current : {});
271
+ writeState(yeaftDir, next);
272
+ return next;
273
+ } finally {
274
+ rmSync(lockDir, { recursive: true, force: true });
275
+ }
276
+ }
277
+
278
+ async function downloadArchive(asset, fetchFn, timeoutMs) {
279
+ const controller = new AbortController();
280
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
281
+ try {
282
+ const response = await fetchFn(asset.url, {
283
+ redirect: 'follow',
284
+ signal: controller.signal,
285
+ headers: { 'User-Agent': 'yeaft-agent-managed-cli' },
286
+ });
287
+ if (!response.ok) throw new Error(`download returned HTTP ${response.status}`);
288
+ const declaredSize = Number(response.headers?.get?.('content-length'));
289
+ if (Number.isFinite(declaredSize) && declaredSize > MAX_ARCHIVE_BYTES) {
290
+ throw new Error(`archive is too large (${declaredSize} bytes)`);
291
+ }
292
+
293
+ const chunks = [];
294
+ let total = 0;
295
+ for await (const chunk of response.body || []) {
296
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
297
+ total += buffer.length;
298
+ if (total > MAX_ARCHIVE_BYTES) throw new Error('archive exceeds download limit');
299
+ chunks.push(buffer);
300
+ }
301
+ const archive = Buffer.concat(chunks);
302
+ if (archive.length === 0) throw new Error('download returned an empty archive');
303
+ const actual = createHash('sha256').update(archive).digest('hex');
304
+ if (actual !== asset.sha256) throw new Error(`checksum mismatch for ${asset.fileName}`);
305
+ return archive;
306
+ } finally {
307
+ clearTimeout(timer);
308
+ }
309
+ }
310
+
311
+ function tarString(buffer, start, length) {
312
+ return buffer.subarray(start, start + length).toString('utf8').replace(/\0.*$/, '');
313
+ }
314
+
315
+ function extractTarBinary(archive, binaryFileName) {
316
+ const tar = gunzipSync(archive, { maxOutputLength: 64 * 1024 * 1024 });
317
+ for (let offset = 0; offset + 512 <= tar.length;) {
318
+ if (tar.subarray(offset, offset + 512).every(byte => byte === 0)) break;
319
+ const name = tarString(tar, offset, 100);
320
+ const prefix = tarString(tar, offset + 345, 155);
321
+ const entryName = prefix ? `${prefix}/${name}` : name;
322
+ const sizeText = tarString(tar, offset + 124, 12).trim();
323
+ const size = Number.parseInt(sizeText || '0', 8);
324
+ if (!Number.isSafeInteger(size) || size < 0 || size > MAX_BINARY_BYTES) {
325
+ throw new Error('invalid tar entry size');
326
+ }
327
+ const dataStart = offset + 512;
328
+ const dataEnd = dataStart + size;
329
+ if (dataEnd > tar.length) throw new Error('truncated tar archive');
330
+ const type = tar[offset + 156];
331
+ if ((type === 0 || type === 48) && basename(entryName) === binaryFileName) {
332
+ return Buffer.from(tar.subarray(dataStart, dataEnd));
333
+ }
334
+ offset = dataStart + Math.ceil(size / 512) * 512;
335
+ }
336
+ throw new Error(`binary ${binaryFileName} was not found in tar archive`);
337
+ }
338
+
339
+ function findZipEnd(buffer) {
340
+ const minimum = Math.max(0, buffer.length - 65_557);
341
+ for (let offset = buffer.length - 22; offset >= minimum; offset -= 1) {
342
+ if (buffer.readUInt32LE(offset) === 0x06054b50) return offset;
343
+ }
344
+ return -1;
345
+ }
346
+
347
+ function extractZipBinary(archive, binaryFileName) {
348
+ const endOffset = findZipEnd(archive);
349
+ if (endOffset < 0) throw new Error('zip central directory was not found');
350
+ const entryCount = archive.readUInt16LE(endOffset + 10);
351
+ let offset = archive.readUInt32LE(endOffset + 16);
352
+
353
+ for (let index = 0; index < entryCount; index += 1) {
354
+ if (offset + 46 > archive.length || archive.readUInt32LE(offset) !== 0x02014b50) {
355
+ throw new Error('invalid zip central directory');
356
+ }
357
+ const compression = archive.readUInt16LE(offset + 10);
358
+ const compressedSize = archive.readUInt32LE(offset + 20);
359
+ const uncompressedSize = archive.readUInt32LE(offset + 24);
360
+ const nameLength = archive.readUInt16LE(offset + 28);
361
+ const extraLength = archive.readUInt16LE(offset + 30);
362
+ const commentLength = archive.readUInt16LE(offset + 32);
363
+ const localOffset = archive.readUInt32LE(offset + 42);
364
+ const entryName = archive.subarray(offset + 46, offset + 46 + nameLength).toString('utf8');
365
+
366
+ if (basename(entryName) === binaryFileName) {
367
+ if (uncompressedSize > MAX_BINARY_BYTES) throw new Error('binary exceeds extraction limit');
368
+ if (localOffset + 30 > archive.length || archive.readUInt32LE(localOffset) !== 0x04034b50) {
369
+ throw new Error('invalid zip local header');
370
+ }
371
+ const localNameLength = archive.readUInt16LE(localOffset + 26);
372
+ const localExtraLength = archive.readUInt16LE(localOffset + 28);
373
+ const dataStart = localOffset + 30 + localNameLength + localExtraLength;
374
+ const dataEnd = dataStart + compressedSize;
375
+ if (dataEnd > archive.length) throw new Error('truncated zip archive');
376
+ const compressed = archive.subarray(dataStart, dataEnd);
377
+ const binary = compression === 0
378
+ ? Buffer.from(compressed)
379
+ : (compression === 8
380
+ ? inflateRawSync(compressed, { maxOutputLength: MAX_BINARY_BYTES })
381
+ : null);
382
+ if (!binary) throw new Error(`unsupported zip compression method ${compression}`);
383
+ if (binary.length !== uncompressedSize) throw new Error('zip binary size mismatch');
384
+ return binary;
385
+ }
386
+ offset += 46 + nameLength + extraLength + commentLength;
387
+ }
388
+ throw new Error(`binary ${binaryFileName} was not found in zip archive`);
389
+ }
390
+
391
+ export function extractManagedCliBinary(archive, archiveName, commandName, platform = process.platform) {
392
+ const binaryFileName = executableName(commandName, platform);
393
+ if (archiveName.endsWith('.tar.gz')) return extractTarBinary(archive, binaryFileName);
394
+ if (archiveName.endsWith('.zip')) return extractZipBinary(archive, binaryFileName);
395
+ throw new Error(`unsupported archive format: ${archiveName}`);
396
+ }
397
+
398
+ function versionOutputMatches(name, version, output) {
399
+ const labels = name === 'rg' ? ['ripgrep'] : [name];
400
+ const firstLine = String(output || '').trim().split(/\r?\n/, 1)[0]?.toLowerCase();
401
+ return labels.some(label => firstLine === `${label} ${version}`.toLowerCase()
402
+ || firstLine.startsWith(`${label} ${version} `));
403
+ }
404
+
405
+ function replaceManagedBinary(temporary, installedPath, platform) {
406
+ if (platform !== 'win32' || !existsSync(installedPath)) {
407
+ renameSync(temporary, installedPath);
408
+ return;
409
+ }
410
+ const backup = `${installedPath}.${process.pid}.${Date.now()}.backup`;
411
+ renameSync(installedPath, backup);
412
+ try {
413
+ renameSync(temporary, installedPath);
414
+ rmSync(backup, { force: true });
415
+ } catch (error) {
416
+ try { renameSync(backup, installedPath); } catch {}
417
+ throw error;
418
+ }
419
+ }
420
+
421
+ async function installOne(name, options) {
422
+ const { yeaftDir, platform, arch, env, fetchFn, timeoutMs, lockWaitMs } = options;
423
+ let managed = inspectManagedBinary(name, { yeaftDir, platform, arch });
424
+ if (managed.valid) return { name, status: 'available', path: managed.path };
425
+ if (!managed.exists) {
426
+ const external = resolveExternalCommand(name, { yeaftDir, platform, env });
427
+ if (external) return { name, status: 'available', path: external, source: 'system' };
428
+ }
429
+
430
+ const asset = selectAsset(name, platform, arch);
431
+ if (!asset) return { name, status: 'unsupported', platform, arch };
432
+
433
+ const binDir = managedCliBinDir(yeaftDir);
434
+ mkdirSync(binDir, { recursive: true, mode: 0o755 });
435
+ const installedPath = managed.path;
436
+ const lockDir = join(binDir, `.install-${name}.lock`);
437
+ const acquired = await acquireLock(lockDir, lockWaitMs, () => (
438
+ inspectManagedBinary(name, { yeaftDir, platform, arch }).valid
439
+ ));
440
+ if (!acquired) {
441
+ managed = inspectManagedBinary(name, { yeaftDir, platform, arch });
442
+ return managed.valid
443
+ ? { name, status: 'available', path: managed.path }
444
+ : { name, status: 'busy' };
445
+ }
446
+
447
+ try {
448
+ managed = inspectManagedBinary(name, { yeaftDir, platform, arch });
449
+ if (managed.valid) return { name, status: 'available', path: managed.path };
450
+ if (!managed.exists) {
451
+ const external = resolveExternalCommand(name, { yeaftDir, platform, env });
452
+ if (external) return { name, status: 'available', path: external, source: 'system' };
453
+ }
454
+ const archive = await downloadArchive(asset, fetchFn, timeoutMs);
455
+ const binary = extractManagedCliBinary(archive, asset.fileName, name, platform);
456
+ if (binary.length === 0) throw new Error('archive contained an empty binary');
457
+ const temporary = `${installedPath}.${process.pid}.${Date.now()}.tmp`;
458
+ try {
459
+ writeFileSync(temporary, binary, { mode: 0o755, flag: 'wx' });
460
+ if (platform !== 'win32') chmodSync(temporary, 0o755);
461
+ const verification = spawnSync(temporary, ['--version'], {
462
+ encoding: 'utf8',
463
+ timeout: 5000,
464
+ windowsHide: true,
465
+ });
466
+ if (verification.error || verification.status !== 0
467
+ || !versionOutputMatches(name, asset.version, verification.stdout)) {
468
+ throw new Error(`installed ${name} binary failed its version check`);
469
+ }
470
+ replaceManagedBinary(temporary, installedPath, platform);
471
+ } finally {
472
+ rmSync(temporary, { force: true });
473
+ }
474
+ const binarySha256 = hashFile(installedPath);
475
+ await updateState(yeaftDir, state => ({
476
+ ...state,
477
+ version: 2,
478
+ updatedAt: Date.now(),
479
+ installations: {
480
+ ...(state.installations && typeof state.installations === 'object'
481
+ ? state.installations
482
+ : {}),
483
+ [name]: {
484
+ version: asset.version,
485
+ platform,
486
+ arch,
487
+ assetFileName: asset.fileName,
488
+ archiveSha256: asset.sha256,
489
+ binarySha256,
490
+ },
491
+ },
492
+ }));
493
+ return {
494
+ name,
495
+ status: 'installed',
496
+ path: installedPath,
497
+ version: asset.version,
498
+ binarySha256,
499
+ };
500
+ } finally {
501
+ rmSync(lockDir, { recursive: true, force: true });
502
+ }
503
+ }
504
+
505
+ export function ensureManagedCliTools(options = {}) {
506
+ const yeaftDir = resolve(options.yeaftDir || DEFAULT_ROOT);
507
+ const platform = options.platform || process.platform;
508
+ const arch = options.arch || process.arch;
509
+ const env = options.env || process.env;
510
+
511
+ const flightKey = `${yeaftDir}:${platform}:${arch}`;
512
+ if (installFlights.has(flightKey)) return installFlights.get(flightKey);
513
+
514
+ const toolReady = Object.fromEntries(Object.keys(TOOL_SPECS).map(name => {
515
+ let resolveReady;
516
+ const promise = new Promise(resolveTool => { resolveReady = resolveTool; });
517
+ return [name, { promise, resolve: resolveReady }];
518
+ }));
519
+ const skipInstall = options.skipInstall || env.YEAFT_SKIP_MANAGED_CLI_INSTALLS === 'true';
520
+
521
+ const flight = (async () => {
522
+ mkdirSync(yeaftDir, { recursive: true, mode: 0o755 });
523
+ const now = typeof options.now === 'function' ? options.now() : Date.now();
524
+ const state = readState(yeaftDir);
525
+ const failures = state.failures && typeof state.failures === 'object' ? { ...state.failures } : {};
526
+ const fetchFn = options.fetchFn || globalThis.fetch;
527
+ const timeoutMs = Number.isFinite(options.timeoutMs)
528
+ ? Math.max(1000, options.timeoutMs)
529
+ : DOWNLOAD_TIMEOUT_MS;
530
+ const lockWaitMs = Number.isFinite(options.lockWaitMs)
531
+ ? Math.max(0, options.lockWaitMs)
532
+ : LOCK_WAIT_MS;
533
+
534
+ const results = await Promise.all(Object.keys(TOOL_SPECS).map(async name => {
535
+ let result;
536
+ try {
537
+ if (skipInstall) {
538
+ result = { name, status: 'skipped' };
539
+ } else {
540
+ const managed = inspectManagedBinary(name, { yeaftDir, platform, arch });
541
+ const external = managed.exists
542
+ ? null
543
+ : resolveExternalCommand(name, { yeaftDir, platform, env });
544
+ if (managed.valid) {
545
+ result = { name, status: 'available', path: managed.path };
546
+ } else if (external) {
547
+ result = { name, status: 'available', path: external, source: 'system' };
548
+ } else {
549
+ const failure = failures[name];
550
+ if (!options.force && Number.isFinite(failure?.at) && now - failure.at < FAILURE_COOLDOWN_MS) {
551
+ result = { name, status: 'cooldown', reason: failure.reason };
552
+ } else if (typeof fetchFn !== 'function') {
553
+ result = { name, status: 'failed', reason: 'fetch is unavailable' };
554
+ } else {
555
+ try {
556
+ result = await installOne(name, {
557
+ yeaftDir, platform, arch, env, fetchFn, timeoutMs, lockWaitMs,
558
+ });
559
+ } catch (error) {
560
+ result = { name, status: 'failed', reason: error?.message || String(error) };
561
+ }
562
+ }
563
+ }
564
+ }
565
+ return result;
566
+ } finally {
567
+ toolReady[name].resolve(result || { name, status: 'failed', reason: 'setup did not complete' });
568
+ }
569
+ }));
570
+
571
+ for (const result of results) {
572
+ if (result.status === 'failed') {
573
+ failures[result.name] = { at: now, reason: result.reason || result.status };
574
+ } else if (result.status !== 'busy') {
575
+ delete failures[result.name];
576
+ }
577
+ }
578
+ try {
579
+ await updateState(yeaftDir, state => ({
580
+ ...state,
581
+ version: 2,
582
+ updatedAt: now,
583
+ failures,
584
+ installations: state.installations && typeof state.installations === 'object'
585
+ ? state.installations
586
+ : {},
587
+ }));
588
+ } catch {
589
+ // Tool installation remains usable when the diagnostic state is unwritable.
590
+ }
591
+ return results;
592
+ })();
593
+
594
+ flight.toolReady = Object.fromEntries(
595
+ Object.entries(toolReady).map(([name, entry]) => [name, entry.promise]),
596
+ );
597
+ installFlights.set(flightKey, flight);
598
+ flight.finally(() => {
599
+ for (const [name, entry] of Object.entries(toolReady)) {
600
+ entry.resolve({ name, status: 'failed', reason: 'setup did not complete' });
601
+ }
602
+ installFlights.delete(flightKey);
603
+ }).catch(() => {});
604
+ return flight;
605
+ }
606
+
607
+ export function managedCliToolReady(ready, name) {
608
+ return ready?.toolReady?.[name] || ready || Promise.resolve([]);
609
+ }
610
+
611
+ export function summarizeManagedCliResults(results) {
612
+ return (results || []).map(result => {
613
+ const detail = result.path || result.reason || `${result.platform || ''}-${result.arch || ''}`;
614
+ return `${result.name}:${result.status}${detail ? `(${detail})` : ''}`;
615
+ }).join(', ');
616
+ }
617
+
618
+ export const managedCliToolSpecs = TOOL_SPECS;
package/yeaft/session.js CHANGED
@@ -81,6 +81,7 @@ const DEFAULT_COMPACT_TRIGGER_RATIO = 0.7;
81
81
  * @property {boolean} [skipSkills] — Skip skill loading
82
82
  * @property {object[]} [extraTools] — Additional ToolDef objects to register
83
83
  * @property {object} [configOverrides] — Additional config overrides
84
+ * @property {Promise<Array>} [managedCliReady] — optional entrypoint-owned CLI setup
84
85
  */
85
86
 
86
87
  /**
@@ -95,6 +96,7 @@ const DEFAULT_COMPACT_TRIGGER_RATIO = 0.7;
95
96
  * @property {import('./tools/registry.js').ToolRegistry} toolRegistry — Tool registry
96
97
  * @property {import('./debug-trace.js').DebugTrace|import('./debug-trace.js').NullTrace} trace
97
98
  * @property {string} yeaftDir — Resolved data directory path
99
+ * @property {Promise<Array>} managedCliReady — optional CLI setup completion
98
100
  * @property {{ skills: number, mcpServers: string[], mcpFailed: object[], tools: number }} status
99
101
  * @property {() => Promise<void>} shutdown — Graceful shutdown
100
102
  */
@@ -148,6 +150,7 @@ export async function loadSession(options = {}) {
148
150
  extraTools = [],
149
151
  configOverrides = {},
150
152
  serverMode = false,
153
+ managedCliReady = null,
151
154
  } = options;
152
155
 
153
156
  // ─── 1. Determine config + store directories ─────────────
@@ -169,6 +172,8 @@ export async function loadSession(options = {}) {
169
172
  const storeInitResult = configInitResult;
170
173
  overrides.dir = configDir;
171
174
 
175
+ const managedCliInstall = managedCliReady || Promise.resolve([]);
176
+
172
177
  // Log any warnings from directory initialization.
173
178
  const initWarnings = configInitResult.warnings;
174
179
  for (const w of initWarnings) {
@@ -479,6 +484,7 @@ export async function loadSession(options = {}) {
479
484
  yeaftDir,
480
485
  toolStats,
481
486
  taskManager,
487
+ managedCliReady: managedCliInstall,
482
488
  });
483
489
 
484
490
  // ─── 9a-pre. Create per-group history Compactor ────────
@@ -633,6 +639,7 @@ export async function loadSession(options = {}) {
633
639
  amsRegistry,
634
640
  toolStats,
635
641
  taskManager,
642
+ managedCliReady: managedCliInstall,
636
643
  shutdown,
637
644
  // task-325c: user-initiated abort API. Delegates to web-bridge which
638
645
  // owns the single AbortController. Lazy-imported to avoid a hard cycle
@@ -158,6 +158,7 @@ export function startSubAgent(agent, deps = {}) {
158
158
  skillManager: deps.skillManager || null,
159
159
  mcpManager: deps.mcpManager || null,
160
160
  yeaftDir: deps.yeaftDir || null,
161
+ managedCliReady: deps.managedCliReady || null,
161
162
  toolStats: deps.toolStats || null,
162
163
  taskManager: deps.taskManager || null,
163
164
  });