@phnx-labs/agents-cli 1.20.46 → 1.20.48

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.
@@ -22,6 +22,8 @@
22
22
  * through the explicit export/import flow in src/lib/secrets/sync.ts
23
23
  * rather than the system's cloud-keychain path.
24
24
  */
25
+ import type { NativeImportReport } from './fallback.js';
26
+ export type { NativeImportReport, NativeImportResult, NativeImportStatus } from './fallback.js';
25
27
  /** Supported secret resolution backends. */
26
28
  export type SecretProvider = 'keychain' | 'env' | 'file' | 'exec';
27
29
  /** A typed reference to a secret, consisting of a provider and a provider-specific value. */
@@ -181,6 +183,15 @@ export declare function parseOrphanMigrationOutput(stdout: string): OrphanMigrat
181
183
  * aborted" from "nothing to do" (empty array).
182
184
  */
183
185
  export declare function migrateOrphanedKeychainItems(prefix: string): OrphanMigrationResult[];
186
+ /**
187
+ * Import agents-cli secrets from the native store (GNOME Keyring / Windows
188
+ * Credential Manager) into the encrypted file store — the Linux/Windows
189
+ * analogue of the macOS orphan/legacy migration, exposed as
190
+ * `agents secrets import-keyring`. Requires the native store to be
191
+ * reachable/unlocked; `commit=false` is a dry-run. macOS returns an empty
192
+ * report (it has no file fallback and uses `migrate-acl` instead).
193
+ */
194
+ export declare function importNativeItems(prefix: string, commit: boolean): NativeImportReport;
184
195
  /** Options controlling how secret refs are resolved. */
185
196
  export interface ResolveOptions {
186
197
  /** Translate a short keychain ID to a fully namespaced item name. */
@@ -26,8 +26,8 @@ import { execFileSync, spawnSync } from 'child_process';
26
26
  import * as fs from 'fs';
27
27
  import * as os from 'os';
28
28
  import * as path from 'path';
29
- import { linuxBackend, usesFileFallback as linuxUsesFileFallback } from './linux.js';
30
- import { windowsBackend, usesFileFallback as windowsUsesFileFallback } from './windows.js';
29
+ import { linuxBackend, usesFileFallback as linuxUsesFileFallback, importNativeSecretToolItems } from './linux.js';
30
+ import { windowsBackend, usesFileFallback as windowsUsesFileFallback, importNativeCredManItems } from './windows.js';
31
31
  import { getKeychainHelperPath } from './install-helper.js';
32
32
  const SERVICE_PREFIX = 'agents-cli';
33
33
  const SECRETS_ITEM_PREFIX = `${SERVICE_PREFIX}.secrets.`;
@@ -518,6 +518,24 @@ export function migrateOrphanedKeychainItems(prefix) {
518
518
  }
519
519
  return parseOrphanMigrationOutput(result.stdout?.toString() || '');
520
520
  }
521
+ /**
522
+ * Import agents-cli secrets from the native store (GNOME Keyring / Windows
523
+ * Credential Manager) into the encrypted file store — the Linux/Windows
524
+ * analogue of the macOS orphan/legacy migration, exposed as
525
+ * `agents secrets import-keyring`. Requires the native store to be
526
+ * reachable/unlocked; `commit=false` is a dry-run. macOS returns an empty
527
+ * report (it has no file fallback and uses `migrate-acl` instead).
528
+ */
529
+ export function importNativeItems(prefix, commit) {
530
+ if (backend)
531
+ return { available: false, locked: false, results: [] };
532
+ assertSupportedPlatform();
533
+ if (isLinux())
534
+ return importNativeSecretToolItems(prefix, commit);
535
+ if (isWindows())
536
+ return importNativeCredManItems(prefix, commit);
537
+ return { available: false, locked: false, results: [] };
538
+ }
521
539
  function expandHome(p) {
522
540
  if (p.startsWith('~/') || p === '~') {
523
541
  return path.join(os.homedir(), p.slice(1));
@@ -17,6 +17,7 @@
17
17
  * item = the secret identifier
18
18
  */
19
19
  import type { KeychainBackend } from './index.js';
20
+ import { type NativeImportReport } from './fallback.js';
20
21
  export { encryptForFallback, decryptForFallback, fileBackend, type EncFile, } from './filestore.js';
21
22
  /**
22
23
  * True when secret operations currently route to the encrypted-file store
@@ -55,6 +56,12 @@ export declare function parseSecretToolItems(output: string, prefix: string): st
55
56
  * so we use secret-tool search which outputs in a specific format.
56
57
  */
57
58
  export declare function listSecretToolItems(prefix: string): string[];
59
+ /**
60
+ * Copy agents-cli items from the keyring into the file store (the `import-keyring`
61
+ * backend for Linux). Requires an unlocked keyring; items already in the file
62
+ * store are left untouched. With `commit=false` it reports what it *would* do.
63
+ */
64
+ export declare function importNativeSecretToolItems(prefix: string, commit: boolean): NativeImportReport;
58
65
  /** KeychainBackend implementation for Linux. Routes through secret-tool
59
66
  * with a transparent encrypted-file fallback when the default Secret
60
67
  * Service collection is locked (or libsecret-tools is not installed but
@@ -19,6 +19,7 @@
19
19
  import { spawnSync } from 'child_process';
20
20
  import * as os from 'os';
21
21
  import { fileStore, fileDir, fileStoreHasItems, machinePassphraseExists, _resetFileStoreForTest, } from './filestore.js';
22
+ import { noteNativeShadow, _resetFallbackNoticeForTest, } from './fallback.js';
22
23
  // Re-exported so existing importers (and tests) can keep reaching these via
23
24
  // './linux.js'. The implementations live in ./filestore.ts.
24
25
  export { encryptForFallback, decryptForFallback, fileBackend, } from './filestore.js';
@@ -35,13 +36,16 @@ let isAvailable = false;
35
36
  // ---------- file fallback state ----------
36
37
  let useFileFallback = false;
37
38
  let warnedFallback = false;
39
+ // Set once the keyring is observed locked/unreachable in this process, so the
40
+ // read-through in get/has stops re-probing it (and stops re-emitting notices).
41
+ let nativeUnreachable = false;
38
42
  function activateFileFallback() {
39
43
  if (useFileFallback)
40
44
  return;
41
45
  useFileFallback = true;
42
46
  if (!warnedFallback) {
43
47
  warnedFallback = true;
44
- process.stderr.write(`[agents] secret-service collection locked, using file-based store at ${fileDir()}\n`);
48
+ process.stderr.write(`[agents] using the encrypted file store at ${fileDir()}\n`);
45
49
  }
46
50
  }
47
51
  function isLockedCollectionError(stderr) {
@@ -111,8 +115,21 @@ export function usesFileFallback() {
111
115
  /** secret-tool lookup attributes:
112
116
  * service=agents-cli account=<user> item=<itemName> */
113
117
  export function hasSecretToolToken(item) {
114
- if (preflight() === 'file')
115
- return fileStore.has(item);
118
+ if (preflight() === 'file') {
119
+ if (fileStore.has(item))
120
+ return true;
121
+ // The file store is primary under the fallback, but an item can still live
122
+ // only in an (unlocked) keyring that predates it — read through so it isn't
123
+ // silently shadowed.
124
+ const probe = readNativeItemRaw(item);
125
+ if (probe.value !== undefined) {
126
+ noteNativeShadow('shadowed', fileDir());
127
+ return true;
128
+ }
129
+ if (probe.locked)
130
+ noteNativeShadow('locked', fileDir());
131
+ return false;
132
+ }
116
133
  const user = os.userInfo().username;
117
134
  const result = spawnSync('secret-tool', [
118
135
  'lookup',
@@ -125,14 +142,25 @@ export function hasSecretToolToken(item) {
125
142
  }
126
143
  const stderr = result.stderr?.toString() ?? '';
127
144
  if (isLockedCollectionError(stderr)) {
145
+ nativeUnreachable = true;
128
146
  activateFileFallback();
129
147
  return fileStore.has(item);
130
148
  }
131
149
  return false;
132
150
  }
133
151
  export function getSecretToolToken(item) {
134
- if (preflight() === 'file')
135
- return fileStore.get(item);
152
+ if (preflight() === 'file') {
153
+ if (fileStore.has(item))
154
+ return fileStore.get(item);
155
+ const probe = readNativeItemRaw(item);
156
+ if (probe.value !== undefined) {
157
+ noteNativeShadow('shadowed', fileDir());
158
+ return probe.value;
159
+ }
160
+ if (probe.locked)
161
+ noteNativeShadow('locked', fileDir());
162
+ throw new Error(`Secret '${item}' not found in the file store or keyring.`);
163
+ }
136
164
  const user = os.userInfo().username;
137
165
  const result = spawnSync('secret-tool', [
138
166
  'lookup',
@@ -148,6 +176,7 @@ export function getSecretToolToken(item) {
148
176
  }
149
177
  const stderr = result.stderr?.toString() ?? '';
150
178
  if (isLockedCollectionError(stderr)) {
179
+ nativeUnreachable = true;
151
180
  activateFileFallback();
152
181
  return fileStore.get(item);
153
182
  }
@@ -171,6 +200,7 @@ export function setSecretToolToken(item, value) {
171
200
  return;
172
201
  const stderr = result.stderr?.toString().trim() ?? '';
173
202
  if (isLockedCollectionError(stderr)) {
203
+ nativeUnreachable = true;
174
204
  activateFileFallback();
175
205
  fileStore.set(item, value);
176
206
  return;
@@ -193,6 +223,7 @@ export function deleteSecretToolToken(item) {
193
223
  return true;
194
224
  const stderr = result.stderr?.toString() ?? '';
195
225
  if (isLockedCollectionError(stderr)) {
226
+ nativeUnreachable = true;
196
227
  activateFileFallback();
197
228
  return fileStore.delete(item);
198
229
  }
@@ -252,6 +283,81 @@ export function listSecretToolItems(prefix) {
252
283
  const output = `${result.stdout?.toString() || ''}\n${result.stderr?.toString() || ''}`;
253
284
  return parseSecretToolItems(output, prefix);
254
285
  }
286
+ // ---------- native-direct helpers (bypass preflight routing) ----------
287
+ //
288
+ // These always talk to secret-tool regardless of whether the process has fallen
289
+ // back to the file store. They power (a) the read-through that keeps the file
290
+ // store from silently shadowing keyring items, and (b) `import-keyring`.
291
+ /**
292
+ * Read one item straight from the keyring. Returns `{value}` on a hit,
293
+ * `{locked:true}` when the collection is locked/unreachable, and `{}` on a plain
294
+ * miss. Never throws and never emits — the caller decides whether to notice.
295
+ */
296
+ function readNativeItemRaw(item) {
297
+ if (nativeUnreachable)
298
+ return { locked: true };
299
+ if (!secretToolAvailable())
300
+ return {};
301
+ const user = os.userInfo().username;
302
+ const r = spawnSync('secret-tool', [
303
+ 'lookup', 'service', SERVICE, 'account', user, 'item', item,
304
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
305
+ if (r.status === 0) {
306
+ const v = r.stdout?.toString().trim();
307
+ return v && v.length ? { value: v } : {};
308
+ }
309
+ if (isLockedCollectionError(r.stderr?.toString() ?? '')) {
310
+ nativeUnreachable = true;
311
+ return { locked: true };
312
+ }
313
+ return {};
314
+ }
315
+ /**
316
+ * Enumerate agents-cli items in the keyring whose name starts with `prefix`.
317
+ * `available` is false when secret-tool isn't installed; `locked` is true when
318
+ * the collection is locked.
319
+ */
320
+ function listNativeItemsRaw(prefix) {
321
+ if (!secretToolAvailable())
322
+ return { items: [], locked: false, available: false };
323
+ const r = spawnSync('secret-tool', [
324
+ 'search', '--all', 'service', SERVICE,
325
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
326
+ if (r.status !== 0) {
327
+ const locked = isLockedCollectionError(r.stderr?.toString() ?? '');
328
+ if (locked)
329
+ nativeUnreachable = true;
330
+ return { items: [], locked, available: true };
331
+ }
332
+ const output = `${r.stdout?.toString() || ''}\n${r.stderr?.toString() || ''}`;
333
+ return { items: parseSecretToolItems(output, prefix), locked: false, available: true };
334
+ }
335
+ /**
336
+ * Copy agents-cli items from the keyring into the file store (the `import-keyring`
337
+ * backend for Linux). Requires an unlocked keyring; items already in the file
338
+ * store are left untouched. With `commit=false` it reports what it *would* do.
339
+ */
340
+ export function importNativeSecretToolItems(prefix, commit) {
341
+ const { items, locked, available } = listNativeItemsRaw(prefix);
342
+ if (!available || locked)
343
+ return { available, locked, results: [] };
344
+ const results = [];
345
+ for (const item of items) {
346
+ if (fileStore.has(item)) {
347
+ results.push({ item, status: 'exists' });
348
+ continue;
349
+ }
350
+ const probe = readNativeItemRaw(item);
351
+ if (probe.value === undefined) {
352
+ results.push({ item, status: 'failed', detail: probe.locked ? 'keyring locked' : 'unreadable' });
353
+ continue;
354
+ }
355
+ if (commit)
356
+ fileStore.set(item, probe.value);
357
+ results.push({ item, status: commit ? 'imported' : 'would-import' });
358
+ }
359
+ return { available, locked, results };
360
+ }
255
361
  /** KeychainBackend implementation for Linux. Routes through secret-tool
256
362
  * with a transparent encrypted-file fallback when the default Secret
257
363
  * Service collection is locked (or libsecret-tools is not installed but
@@ -280,6 +386,8 @@ export function _resetForTest(opts = {}) {
280
386
  _resetFileStoreForTest({ fileDir: opts.fileDir ?? null, passphrase: opts.passphrase ?? null });
281
387
  useFileFallback = opts.forceFileFallback ?? false;
282
388
  warnedFallback = false;
389
+ nativeUnreachable = false;
283
390
  checkedAvailability = false;
284
391
  isAvailable = false;
392
+ _resetFallbackNoticeForTest();
285
393
  }
@@ -27,6 +27,7 @@
27
27
  * names directly.
28
28
  */
29
29
  import type { KeychainBackend } from './index.js';
30
+ import { type NativeImportReport } from './fallback.js';
30
31
  export { encryptForFallback, decryptForFallback, fileBackend, type EncFile, } from './filestore.js';
31
32
  /**
32
33
  * CRED_MAX_CREDENTIAL_BLOB_SIZE — Credential Manager rejects a generic
@@ -53,6 +54,12 @@ export declare function listCredManItems(prefix: string): string[];
53
54
  * parseSecretToolItems (linux.ts). Exported for tests.
54
55
  */
55
56
  export declare function parseWindowsCredList(output: string, prefix: string): string[];
57
+ /**
58
+ * Copy agents-cli credentials from Credential Manager into the file store (the
59
+ * `import-keyring` backend for Windows). Requires a reachable store; items
60
+ * already in the file store are left untouched.
61
+ */
62
+ export declare function importNativeCredManItems(prefix: string, commit: boolean): NativeImportReport;
56
63
  /**
57
64
  * KeychainBackend implementation for Windows. Routes through Windows Credential
58
65
  * Manager (via PowerShell P/Invoke) with a transparent encrypted-file fallback
@@ -29,6 +29,7 @@
29
29
  import { spawnSync } from 'child_process';
30
30
  import { encodePwshBase64 } from '../pwsh.js';
31
31
  import { fileStore, fileDir, fileStoreHasItems, machinePassphraseExists, _resetFileStoreForTest, } from './filestore.js';
32
+ import { noteNativeShadow, _resetFallbackNoticeForTest, } from './fallback.js';
32
33
  // Re-exported so importers (and tests) can keep reaching these via './windows.js'.
33
34
  export { encryptForFallback, decryptForFallback, fileBackend, } from './filestore.js';
34
35
  const POWERSHELL = 'powershell.exe';
@@ -250,13 +251,16 @@ let isAvailable = false;
250
251
  // ---------- file fallback state ----------
251
252
  let useFileFallback = false;
252
253
  let warnedFallback = false;
254
+ // Set once Credential Manager is observed unreachable in this process, so the
255
+ // read-through in get/has stops re-probing it (and re-emitting notices).
256
+ let nativeUnreachable = false;
253
257
  function activateFileFallback() {
254
258
  if (useFileFallback)
255
259
  return;
256
260
  useFileFallback = true;
257
261
  if (!warnedFallback) {
258
262
  warnedFallback = true;
259
- process.stderr.write(`[agents] Windows Credential Manager unavailable, using file-based store at ${fileDir()}\n`);
263
+ process.stderr.write(`[agents] using the encrypted file store at ${fileDir()}\n`);
260
264
  }
261
265
  }
262
266
  /**
@@ -316,22 +320,45 @@ export function usesFileFallback() {
316
320
  }
317
321
  // ---------- Credential Manager ops with fallback ----------
318
322
  export function hasCredManToken(item) {
319
- if (preflight() === 'file')
320
- return fileStore.has(item);
323
+ if (preflight() === 'file') {
324
+ if (fileStore.has(item))
325
+ return true;
326
+ // Read through to Credential Manager so an item that predates the fallback
327
+ // isn't silently shadowed by the file store.
328
+ const probe = readNativeCredItemRaw(item);
329
+ if (probe.value !== undefined) {
330
+ noteNativeShadow('shadowed', fileDir());
331
+ return true;
332
+ }
333
+ if (probe.unavailable)
334
+ noteNativeShadow('locked', fileDir());
335
+ return false;
336
+ }
321
337
  const r = runCred('has', { target: item });
322
338
  if (r.status === 0)
323
339
  return true;
324
340
  if (r.status === 3)
325
341
  return false;
326
342
  if (isCredManUnavailableError(r)) {
343
+ nativeUnreachable = true;
327
344
  activateFileFallback();
328
345
  return fileStore.has(item);
329
346
  }
330
347
  return false;
331
348
  }
332
349
  export function getCredManToken(item) {
333
- if (preflight() === 'file')
334
- return fileStore.get(item);
350
+ if (preflight() === 'file') {
351
+ if (fileStore.has(item))
352
+ return fileStore.get(item);
353
+ const probe = readNativeCredItemRaw(item);
354
+ if (probe.value !== undefined) {
355
+ noteNativeShadow('shadowed', fileDir());
356
+ return probe.value;
357
+ }
358
+ if (probe.unavailable)
359
+ noteNativeShadow('locked', fileDir());
360
+ throw new Error(`Secret '${item}' not found in the file store or Credential Manager.`);
361
+ }
335
362
  const r = runCred('get', { target: item });
336
363
  if (r.status === 0) {
337
364
  // stdout is base64 of the raw UTF-8 blob (dodges PowerShell encoding corruption).
@@ -340,6 +367,7 @@ export function getCredManToken(item) {
340
367
  if (r.status === 3)
341
368
  throw new Error(`Secret '${item}' not found in Credential Manager.`);
342
369
  if (isCredManUnavailableError(r)) {
370
+ nativeUnreachable = true;
343
371
  activateFileFallback();
344
372
  return fileStore.get(item);
345
373
  }
@@ -362,6 +390,7 @@ export function setCredManToken(item, value) {
362
390
  if (r.status === 0)
363
391
  return;
364
392
  if (isCredManUnavailableError(r)) {
393
+ nativeUnreachable = true;
365
394
  activateFileFallback();
366
395
  fileStore.set(item, value);
367
396
  return;
@@ -377,6 +406,7 @@ export function deleteCredManToken(item) {
377
406
  if (r.status === 3)
378
407
  return false;
379
408
  if (isCredManUnavailableError(r)) {
409
+ nativeUnreachable = true;
380
410
  activateFileFallback();
381
411
  return fileStore.delete(item);
382
412
  }
@@ -389,6 +419,7 @@ export function listCredManItems(prefix) {
389
419
  if (r.status === 0)
390
420
  return parseWindowsCredList(r.stdout, prefix);
391
421
  if (isCredManUnavailableError(r)) {
422
+ nativeUnreachable = true;
392
423
  activateFileFallback();
393
424
  return fileStore.list(prefix);
394
425
  }
@@ -407,6 +438,78 @@ export function parseWindowsCredList(output, prefix) {
407
438
  .filter((s) => s.startsWith(prefix));
408
439
  return [...new Set(items)]; // dedupe
409
440
  }
441
+ // ---------- native-direct helpers (bypass preflight routing) ----------
442
+ //
443
+ // Always talk to Credential Manager regardless of the file fallback. They power
444
+ // (a) the read-through that keeps the file store from shadowing credman items,
445
+ // and (b) `import-keyring`.
446
+ /**
447
+ * Read one item straight from Credential Manager. `{value}` on hit,
448
+ * `{unavailable:true}` when the store is unreachable, `{}` on a plain miss.
449
+ * Never throws, never emits.
450
+ */
451
+ function readNativeCredItemRaw(item) {
452
+ if (nativeUnreachable)
453
+ return { unavailable: true };
454
+ if (!powershellAvailable())
455
+ return {};
456
+ const r = runCred('get', { target: item });
457
+ if (r.status === 0)
458
+ return { value: Buffer.from(r.stdout.trim(), 'base64').toString('utf8') };
459
+ if (r.status === 3)
460
+ return {};
461
+ if (isCredManUnavailableError(r)) {
462
+ nativeUnreachable = true;
463
+ return { unavailable: true };
464
+ }
465
+ return {};
466
+ }
467
+ /**
468
+ * Enumerate agents-cli credentials under `prefix`. Windows credentials have no
469
+ * service scoping — the target IS the identifier — so we NEVER enumerate with an
470
+ * empty filter (that returns unrelated machine credentials). The filter is
471
+ * floored to the `agents-cli.` namespace; bare items (unprefixed targets) are
472
+ * therefore out of scope for auto-discovery on Windows.
473
+ */
474
+ function listNativeCredItemsRaw(prefix) {
475
+ if (!powershellAvailable())
476
+ return { items: [], locked: false, available: false };
477
+ const floor = prefix && prefix.startsWith('agents-cli.') ? prefix : 'agents-cli.';
478
+ const r = runCred('list', { prefix: floor });
479
+ if (r.status === 0)
480
+ return { items: parseWindowsCredList(r.stdout, floor), locked: false, available: true };
481
+ if (isCredManUnavailableError(r)) {
482
+ nativeUnreachable = true;
483
+ return { items: [], locked: true, available: true };
484
+ }
485
+ return { items: [], locked: false, available: true };
486
+ }
487
+ /**
488
+ * Copy agents-cli credentials from Credential Manager into the file store (the
489
+ * `import-keyring` backend for Windows). Requires a reachable store; items
490
+ * already in the file store are left untouched.
491
+ */
492
+ export function importNativeCredManItems(prefix, commit) {
493
+ const { items, locked, available } = listNativeCredItemsRaw(prefix);
494
+ if (!available || locked)
495
+ return { available, locked, results: [] };
496
+ const results = [];
497
+ for (const item of items) {
498
+ if (fileStore.has(item)) {
499
+ results.push({ item, status: 'exists' });
500
+ continue;
501
+ }
502
+ const probe = readNativeCredItemRaw(item);
503
+ if (probe.value === undefined) {
504
+ results.push({ item, status: 'failed', detail: probe.unavailable ? 'credential manager unavailable' : 'unreadable' });
505
+ continue;
506
+ }
507
+ if (commit)
508
+ fileStore.set(item, probe.value);
509
+ results.push({ item, status: commit ? 'imported' : 'would-import' });
510
+ }
511
+ return { available, locked, results };
512
+ }
410
513
  /**
411
514
  * KeychainBackend implementation for Windows. Routes through Windows Credential
412
515
  * Manager (via PowerShell P/Invoke) with a transparent encrypted-file fallback
@@ -440,6 +543,8 @@ export function _resetForTest(opts = {}) {
440
543
  _resetFileStoreForTest({ fileDir: opts.fileDir ?? null, passphrase: opts.passphrase ?? null });
441
544
  useFileFallback = opts.forceFileFallback ?? false;
442
545
  warnedFallback = false;
546
+ nativeUnreachable = false;
547
+ _resetFallbackNoticeForTest();
443
548
  if (opts.forceAvailable === undefined || opts.forceAvailable === null) {
444
549
  checkedAvailability = false;
445
550
  isAvailable = false;
@@ -4,7 +4,7 @@
4
4
  // background so the shim schema settles without user-facing churn.
5
5
  import { resultOf } from '../types.js';
6
6
  import { AGENTS } from '../../agents.js';
7
- import { ensureShimCurrent, ensureVersionedAliasCurrent, isShimCurrent, isVersionedAliasCurrent, removeLegacyUserShim, listAgentsWithInstalledVersions, } from '../../shims.js';
7
+ import { createShim, ensureShimCurrent, ensureVersionedAliasCurrent, isShimCurrent, isVersionedAliasCurrent, shimPointsAtLiveInstall, removeLegacyUserShim, listAgentsWithInstalledVersions, listShimFileNames, pruneOrphanedCommandShim, } from '../../shims.js';
8
8
  import { listInstalledVersions } from '../../versions.js';
9
9
  export const shimsCheck = {
10
10
  id: 'shims',
@@ -19,6 +19,14 @@ export const shimsCheck = {
19
19
  ensureShimCurrent(agent);
20
20
  fixed.push(`${cmd} shim`);
21
21
  }
22
+ else if (!shimPointsAtLiveInstall(agent)) {
23
+ // Schema is current but the baked AGENTS_BIN points at a different, removed
24
+ // install (dev build, old npm-global, rotated version dir). ensureShimCurrent
25
+ // would no-op on a schema-current shim, so force a rewrite to the current install.
26
+ if (!ctx.dryRun)
27
+ createShim(agent);
28
+ fixed.push(`${cmd} shim (repointed to current install)`);
29
+ }
22
30
  for (const version of listInstalledVersions(agent)) {
23
31
  if (!isVersionedAliasCurrent(agent, version)) {
24
32
  if (!ctx.dryRun)
@@ -30,6 +38,16 @@ export const shimsCheck = {
30
38
  if (!ctx.dryRun && removeLegacyUserShim(agent))
31
39
  fixed.push(`removed legacy ${cmd} shim`);
32
40
  }
41
+ // Prune orphaned legacy command shims (browser/secrets/sessions/… left by a
42
+ // removed install) whose baked AGENTS_BIN is dead — the current source never
43
+ // regenerates them, and they either die with exit 127 or shadow the real
44
+ // package bin on PATH. Only removes shims whose target install is gone.
45
+ if (!ctx.dryRun) {
46
+ for (const name of listShimFileNames()) {
47
+ if (pruneOrphanedCommandShim(name))
48
+ fixed.push(`pruned orphaned ${name} shim`);
49
+ }
50
+ }
33
51
  return resultOf(fixed, []);
34
52
  },
35
53
  };
@@ -2456,6 +2456,8 @@ export function readKimiMeta(filePath) {
2456
2456
  project = parts.slice(0, -1).join('/');
2457
2457
  }
2458
2458
  }
2459
+ // Parse wire.jsonl to extract message count and token usage
2460
+ const { messageCount, tokenCount } = parseKimiWireMetrics(sessionDir);
2459
2461
  const meta = {
2460
2462
  id: sessionId,
2461
2463
  shortId,
@@ -2464,9 +2466,48 @@ export function readKimiMeta(filePath) {
2464
2466
  project,
2465
2467
  filePath,
2466
2468
  topic,
2469
+ messageCount,
2470
+ tokenCount: tokenCount > 0 ? tokenCount : undefined,
2467
2471
  };
2468
2472
  return { meta, content: lastPrompt || '' };
2469
2473
  }
2474
+ /** Parse Kimi's wire.jsonl to extract message count and token usage.
2475
+ * TODO: optimize to stream (like scanClaudeSession) to avoid loading large files into memory.
2476
+ * For now, synchronous readFileSync matches the pattern of reading state.json and is acceptable
2477
+ * since session dirs are usually fresh in FS cache during incremental scans. */
2478
+ function parseKimiWireMetrics(sessionDir) {
2479
+ const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
2480
+ let messageCount = 0;
2481
+ let tokenCount = 0;
2482
+ if (!fs.existsSync(wirePath)) {
2483
+ return { messageCount: 0, tokenCount: 0 };
2484
+ }
2485
+ try {
2486
+ const lines = fs.readFileSync(wirePath, 'utf-8').split('\n');
2487
+ for (const line of lines) {
2488
+ if (!line.trim())
2489
+ continue;
2490
+ try {
2491
+ const event = JSON.parse(line);
2492
+ if (event.type === 'context.append_message') {
2493
+ messageCount++;
2494
+ }
2495
+ else if (event.type === 'usage.record' && event.usage) {
2496
+ // Kimi usage structure: inputOther + output + inputCacheRead + inputCacheCreation
2497
+ const u = event.usage;
2498
+ tokenCount += (u.inputOther || 0) + (u.output || 0) + (u.inputCacheRead || 0) + (u.inputCacheCreation || 0);
2499
+ }
2500
+ }
2501
+ catch {
2502
+ // Malformed line, skip
2503
+ }
2504
+ }
2505
+ }
2506
+ catch {
2507
+ // If wire.jsonl can't be read, return 0s (graceful degradation)
2508
+ }
2509
+ return { messageCount, tokenCount };
2510
+ }
2470
2511
  /** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
2471
2512
  export function parseTimeFilter(input) {
2472
2513
  const relativeMatch = input.match(/^(\d+)([mhdw])$/i);
@@ -284,6 +284,28 @@ export declare function shimExists(agent: AgentId): boolean;
284
284
  * older version that needs regeneration.
285
285
  */
286
286
  export declare function isShimCurrent(agent: AgentId): boolean;
287
+ /**
288
+ * True when the agent shim's baked `AGENTS_BIN` is fine to keep: it either already
289
+ * points at the install we'd generate now, OR points at some OTHER install that
290
+ * still exists on disk (leave it — regenerating could ping-pong two live installs
291
+ * sharing the shims dir). Returns FALSE only when the shim points at a DIFFERENT,
292
+ * now-removed install — the exact drift a deleted dev build (`~/.local/agents-cli-dev`),
293
+ * an old npm-global (`/opt/homebrew`), or a rotated version dir leaves behind. A shim
294
+ * can pass the schema check (`isShimCurrent`) yet still carry that stale path.
295
+ */
296
+ export declare function shimPointsAtLiveInstall(agent: AgentId): boolean;
297
+ /** Shim files in the shims dir, excluding the hooks/ subdir and @-versioned aliases. */
298
+ export declare function listShimFileNames(): string[];
299
+ /**
300
+ * Prune a stale, orphaned shim: one that is NOT a managed agent shim and NOT a user
301
+ * alias, whose baked `AGENTS_BIN` points at an install that no longer exists. These
302
+ * are legacy `exec "$AGENTS_BIN" <cmd>` command shims (browser/secrets/sessions/…)
303
+ * left behind by a removed install — the current source never generates them, and
304
+ * they either die with `exit 127` or shadow the real package bin on PATH. Only
305
+ * removed when the baked target is gone, so a working shim is never touched.
306
+ * Returns true if removed.
307
+ */
308
+ export declare function pruneOrphanedCommandShim(fileName: string): boolean;
287
309
  /**
288
310
  * Regenerate the shim if it's missing or outdated. Returns a status describing
289
311
  * what happened — callers can surface a one-line notice to the user ("Updated