@phnx-labs/agents-cli 1.22.58 → 1.22.59

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 (65) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/README.md +29 -0
  3. package/dist/bootstrap.js +32 -1
  4. package/dist/commands/monitors.js +187 -23
  5. package/dist/commands/routines.test-fixture.js +5 -0
  6. package/dist/commands/send.d.ts +2 -1
  7. package/dist/commands/send.js +7 -5
  8. package/dist/commands/sessions-stats.js +37 -5
  9. package/dist/commands/sessions.js +39 -5
  10. package/dist/commands/ssh.js +12 -1
  11. package/dist/commands/versions.js +12 -4
  12. package/dist/commands/view.js +7 -2
  13. package/dist/lib/auto-pull-worker.js +7 -2
  14. package/dist/lib/cloud/rush.d.ts +7 -0
  15. package/dist/lib/cloud/rush.js +29 -1
  16. package/dist/lib/daemon/daemon.d.ts +22 -0
  17. package/dist/lib/daemon/daemon.js +39 -0
  18. package/dist/lib/daemon/session-index-service.js +9 -1
  19. package/dist/lib/daemon-ticks.d.ts +15 -0
  20. package/dist/lib/daemon-ticks.js +26 -0
  21. package/dist/lib/device-config.d.ts +5 -1
  22. package/dist/lib/device-config.js +2 -2
  23. package/dist/lib/devices/health.js +5 -1
  24. package/dist/lib/devices/pool.d.ts +25 -2
  25. package/dist/lib/devices/pool.js +32 -2
  26. package/dist/lib/devices/stats-cache.d.ts +0 -6
  27. package/dist/lib/devices/stats-cache.js +2 -9
  28. package/dist/lib/doctor-diff.d.ts +14 -0
  29. package/dist/lib/doctor-diff.js +43 -2
  30. package/dist/lib/git.d.ts +38 -0
  31. package/dist/lib/git.js +58 -0
  32. package/dist/lib/hosts/ready.d.ts +8 -0
  33. package/dist/lib/hosts/ready.js +13 -2
  34. package/dist/lib/installations/versions.d.ts +17 -0
  35. package/dist/lib/installations/versions.js +53 -2
  36. package/dist/lib/monitors/config.d.ts +71 -3
  37. package/dist/lib/monitors/config.js +100 -12
  38. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  39. package/dist/lib/monitors/pid-watch.js +45 -0
  40. package/dist/lib/monitors/remote.d.ts +18 -0
  41. package/dist/lib/monitors/remote.js +11 -0
  42. package/dist/lib/permissions.js +7 -2
  43. package/dist/lib/plugins/plugins.d.ts +17 -3
  44. package/dist/lib/plugins/plugins.js +84 -9
  45. package/dist/lib/pty-server.d.ts +14 -0
  46. package/dist/lib/pty-server.js +49 -5
  47. package/dist/lib/secrets/drivers/rush.js +5 -0
  48. package/dist/lib/self-update.d.ts +42 -0
  49. package/dist/lib/self-update.js +88 -0
  50. package/dist/lib/session/cloud.js +5 -0
  51. package/dist/lib/session/db.d.ts +32 -6
  52. package/dist/lib/session/db.js +128 -12
  53. package/dist/lib/smart-launch.d.ts +6 -0
  54. package/dist/lib/smart-launch.js +5 -2
  55. package/dist/lib/staleness/writers/plugins.js +5 -2
  56. package/dist/lib/staleness/writers/subagents.js +13 -3
  57. package/dist/lib/state.d.ts +7 -4
  58. package/dist/lib/state.js +7 -4
  59. package/dist/lib/subagents.js +8 -2
  60. package/dist/lib/teams/scheduler.d.ts +10 -0
  61. package/dist/lib/teams/scheduler.js +8 -0
  62. package/dist/lib/traces/sync.d.ts +113 -6
  63. package/dist/lib/traces/sync.js +193 -19
  64. package/dist/lib/view-types.d.ts +12 -0
  65. package/package.json +2 -2
@@ -254,12 +254,26 @@ export declare function removePluginFromVersion(pluginName: string, pluginRoot:
254
254
  mcp: number;
255
255
  };
256
256
  /**
257
- * Remove orphaned plugin entries from a version home. An entry is "orphan" if
258
- * its plugin name is not in the active plugin set. Soft-deletes the affected
257
+ * The active plugin set, either as bare names (legacy callers / the dual-dash
258
+ * sweep, which has no marketplace to key on) or as the discovered plugins
259
+ * themselves (which carry marketplace provenance, enabling per-marketplace
260
+ * orphan detection — the PHNX-2618 shadow case below).
261
+ */
262
+ export type ActivePluginsInput = Set<string> | Array<{
263
+ name: string;
264
+ marketplace?: string;
265
+ }>;
266
+ /**
267
+ * Remove orphaned plugin entries from a version home. A marketplace-plugin
268
+ * install is "orphan" when no active source plugin matches its (marketplace,
269
+ * name) pair (see isOrphanMarketplacePlugin). Soft-deletes the affected
259
270
  * marketplace plugin dir to ~/.agents/.trash/plugins/. Also cleans up any
260
271
  * legacy dual-dash skills/ directories from older agents-cli versions.
272
+ *
273
+ * Pass the discovered plugins (`discoverPlugins()`) for marketplace-aware
274
+ * detection; a bare `Set<string>` of names keeps the original name-only behavior.
261
275
  */
262
- export declare function cleanOrphanedPluginSkills(agent: AgentId, versionHome: string, activePluginNames: Set<string>, version?: string): string[];
276
+ export declare function cleanOrphanedPluginSkills(agent: AgentId, versionHome: string, activePlugins: ActivePluginsInput, version?: string): string[];
263
277
  export interface VersionPluginDiff {
264
278
  agent: AgentId;
265
279
  version: string;
@@ -1430,14 +1430,88 @@ function cleanLegacyFlatLayout(pluginName, pluginRoot, agent, versionHome, resul
1430
1430
  catch { /* ignore */ }
1431
1431
  }
1432
1432
  }
1433
- // ─── Orphan cleanup ───────────────────────────────────────────────────────────
1433
+ function pairKey(marketplace, name) {
1434
+ return `${marketplace}${name}`;
1435
+ }
1436
+ function indexActivePlugins(input) {
1437
+ if (input instanceof Set)
1438
+ return { names: input, pairs: null };
1439
+ const names = new Set();
1440
+ const pairs = new Set();
1441
+ for (const p of input) {
1442
+ names.add(p.name);
1443
+ // A discovered plugin always carries provenance; the type allows undefined,
1444
+ // so mirror discovery's own default (a marketplace-less plugin is the user
1445
+ // "agents-cli" marketplace — see discoverPlugins / buildDiscoveredPlugin).
1446
+ pairs.add(pairKey(p.marketplace ?? MARKETPLACE_NAME, p.name));
1447
+ }
1448
+ return { names, pairs };
1449
+ }
1450
+ /**
1451
+ * Does the SOURCE repo backing a synthesized marketplace exist on disk? A
1452
+ * marketplace's version-home install is only authoritative-cleanable when its
1453
+ * source repo is present: a present repo missing a plugin means that plugin was
1454
+ * genuinely removed, while an absent repo (a project we're not in, a removed
1455
+ * extra repo) is merely unreachable and must not be mistaken for deletion.
1456
+ *
1457
+ * We check the REPO root, not the plugins/ subdir: the user repo (~/.agents/)
1458
+ * always exists but its plugins/ dir may not, and "user repo present, no `code`
1459
+ * plugin in it" is exactly what makes an `agents-cli` `code` shadow a real
1460
+ * orphan (PHNX-2618).
1461
+ */
1462
+ function marketplaceSourceRepoExists(marketplaceName, cwd) {
1463
+ const spec = marketplaceSpecForName(marketplaceName, cwd);
1464
+ switch (spec.kind) {
1465
+ case 'user': return fs.existsSync(path.dirname(getPluginsDir()));
1466
+ case 'system': return fs.existsSync(path.dirname(getSystemPluginsDir()));
1467
+ case 'extra': return fs.existsSync(path.dirname(getExtraPluginsDir(spec.alias)));
1468
+ case 'project': {
1469
+ const root = getProjectPluginsDir(cwd);
1470
+ return root != null && fs.existsSync(path.dirname(root));
1471
+ }
1472
+ }
1473
+ }
1474
+ /**
1475
+ * Is a version-home marketplace-plugin install an orphan (safe to trash)?
1476
+ *
1477
+ * A version-home plugin is keyed by (marketplace, name), not name alone — the
1478
+ * bug PHNX-2618 exposed. When the same plugin name lives in two marketplaces
1479
+ * (e.g. a legacy `code` under `agents-cli` and the current `code` under
1480
+ * `agents-system`), a name-only test keeps BOTH alive because the name is active
1481
+ * somewhere, so the stale copy never gets cleaned and serves deleted skills.
1482
+ *
1483
+ * - Pair still active → keep.
1484
+ * - Pair gone, but the marketplace's source repo is present (authoritative) →
1485
+ * orphan. Trash it even though another marketplace still ships that name.
1486
+ * - Pair gone AND the source repo is absent (unreachable) → fall back to the
1487
+ * original name-only test so an unrelated sync can't trash a plugin whose
1488
+ * source simply isn't on this box / in this cwd right now.
1489
+ *
1490
+ * When `pairs` is null (a legacy bare-name caller), this reduces to the original
1491
+ * name-only behavior unchanged.
1492
+ */
1493
+ function isOrphanMarketplacePlugin(marketplaceName, pluginName, active, cwd) {
1494
+ if (active.pairs === null)
1495
+ return !active.names.has(pluginName);
1496
+ if (active.pairs.has(pairKey(marketplaceName, pluginName)))
1497
+ return false;
1498
+ if (marketplaceSourceRepoExists(marketplaceName, cwd))
1499
+ return true;
1500
+ return !active.names.has(pluginName);
1501
+ }
1434
1502
  /**
1435
- * Remove orphaned plugin entries from a version home. An entry is "orphan" if
1436
- * its plugin name is not in the active plugin set. Soft-deletes the affected
1503
+ * Remove orphaned plugin entries from a version home. A marketplace-plugin
1504
+ * install is "orphan" when no active source plugin matches its (marketplace,
1505
+ * name) pair (see isOrphanMarketplacePlugin). Soft-deletes the affected
1437
1506
  * marketplace plugin dir to ~/.agents/.trash/plugins/. Also cleans up any
1438
1507
  * legacy dual-dash skills/ directories from older agents-cli versions.
1508
+ *
1509
+ * Pass the discovered plugins (`discoverPlugins()`) for marketplace-aware
1510
+ * detection; a bare `Set<string>` of names keeps the original name-only behavior.
1439
1511
  */
1440
- export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames, version) {
1512
+ export function cleanOrphanedPluginSkills(agent, versionHome, activePlugins, version) {
1513
+ const active = indexActivePlugins(activePlugins);
1514
+ const cwd = process.cwd();
1441
1515
  const removed = [];
1442
1516
  // 1. Walk every marketplace's install dir and trash entries no longer active.
1443
1517
  for (const name of listVersionMarketplaceNames(agent, versionHome)) {
@@ -1449,7 +1523,7 @@ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames,
1449
1523
  for (const entry of fs.readdirSync(mktPluginsDir, { withFileTypes: true })) {
1450
1524
  if (!entry.isDirectory() || entry.name.startsWith('.'))
1451
1525
  continue;
1452
- if (activePluginNames.has(entry.name))
1526
+ if (!isOrphanMarketplacePlugin(name, entry.name, active, cwd))
1453
1527
  continue;
1454
1528
  try {
1455
1529
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -1488,7 +1562,7 @@ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames,
1488
1562
  if (dashIdx === -1)
1489
1563
  continue;
1490
1564
  const pluginName = entry.name.slice(0, dashIdx);
1491
- if (activePluginNames.has(pluginName))
1565
+ if (active.names.has(pluginName))
1492
1566
  continue;
1493
1567
  try {
1494
1568
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -1505,7 +1579,8 @@ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames,
1505
1579
  }
1506
1580
  export function diffVersionPlugins(agent, version) {
1507
1581
  const versionHome = getVersionHomePath(agent, version);
1508
- const activePlugins = new Set(discoverPlugins().map(p => p.name));
1582
+ const active = indexActivePlugins(discoverPlugins());
1583
+ const cwd = process.cwd();
1509
1584
  const orphans = [];
1510
1585
  for (const name of listVersionMarketplaceNames(agent, versionHome)) {
1511
1586
  const mktPluginsDir = path.join(marketplaceRoot(name, agent, versionHome), 'plugins');
@@ -1514,7 +1589,7 @@ export function diffVersionPlugins(agent, version) {
1514
1589
  for (const entry of fs.readdirSync(mktPluginsDir, { withFileTypes: true })) {
1515
1590
  if (!entry.isDirectory() || entry.name.startsWith('.'))
1516
1591
  continue;
1517
- if (!activePlugins.has(entry.name)) {
1592
+ if (isOrphanMarketplacePlugin(name, entry.name, active, cwd)) {
1518
1593
  orphans.push(entry.name);
1519
1594
  }
1520
1595
  }
@@ -1529,7 +1604,7 @@ export function diffVersionPlugins(agent, version) {
1529
1604
  if (dashIdx === -1)
1530
1605
  continue;
1531
1606
  const pluginName = entry.name.slice(0, dashIdx);
1532
- if (!activePlugins.has(pluginName)) {
1607
+ if (!active.names.has(pluginName)) {
1533
1608
  orphans.push(entry.name);
1534
1609
  }
1535
1610
  }
@@ -21,6 +21,20 @@ export { captureProcessStartTime };
21
21
  * (the authoritative code comes from node-pty's onExit).
22
22
  */
23
23
  export declare function buildSentinelCommand(shell: string, command: string): string;
24
+ /**
25
+ * Turn a native-binding load failure into an actionable, platform-aware message.
26
+ *
27
+ * The binding is `@homebridge/node-pty-prebuilt-multiarch`'s `pty.node`. On Linux
28
+ * it is baked into the npm tarball and always present; on macOS/Windows it is
29
+ * fetched per host + Node ABI at install time by the package's `prebuild-install`
30
+ * postinstall. The common failure is a Node runtime whose ABI has no published
31
+ * prebuild (the PHNX-2740 darwin-arm64 case): `prebuild-install` finds nothing,
32
+ * the build-from-source fallback also fails, and the loader throws a bare
33
+ * `Cannot find module '.../pty.node'` / MODULE_NOT_FOUND with no clue what to do.
34
+ *
35
+ * Pure (returns the lines) so it can be unit-tested without spawning the server.
36
+ */
37
+ export declare function describeNativePtyLoadFailure(err: unknown): string[];
24
38
  /**
25
39
  * Resolve the IPC endpoint for a given platform + PTY scratch dir. Pure so both
26
40
  * branches are testable without stubbing process.platform.
@@ -88,6 +88,43 @@ export function buildSentinelCommand(shell, command) {
88
88
  }
89
89
  return `${command}; echo "${SENTINEL}:$?"`;
90
90
  }
91
+ /**
92
+ * Turn a native-binding load failure into an actionable, platform-aware message.
93
+ *
94
+ * The binding is `@homebridge/node-pty-prebuilt-multiarch`'s `pty.node`. On Linux
95
+ * it is baked into the npm tarball and always present; on macOS/Windows it is
96
+ * fetched per host + Node ABI at install time by the package's `prebuild-install`
97
+ * postinstall. The common failure is a Node runtime whose ABI has no published
98
+ * prebuild (the PHNX-2740 darwin-arm64 case): `prebuild-install` finds nothing,
99
+ * the build-from-source fallback also fails, and the loader throws a bare
100
+ * `Cannot find module '.../pty.node'` / MODULE_NOT_FOUND with no clue what to do.
101
+ *
102
+ * Pure (returns the lines) so it can be unit-tested without spawning the server.
103
+ */
104
+ export function describeNativePtyLoadFailure(err) {
105
+ const detail = err instanceof Error ? (err.stack || err.message) : String(err);
106
+ const abi = process.versions.modules;
107
+ const nodeVersion = process.version;
108
+ const platform = `${process.platform}-${process.arch}`;
109
+ const lines = [
110
+ `agents pty could not load its native terminal binding (@homebridge/node-pty-prebuilt-multiarch).`,
111
+ ` platform: ${platform} node: ${nodeVersion} (ABI ${abi})`,
112
+ ``,
113
+ `The prebuilt binary for this platform + Node ABI is missing. On macOS/Windows`,
114
+ `it is downloaded at install time; a Node version newer than any published`,
115
+ `prebuild, or an install that skipped the postinstall, leaves it absent.`,
116
+ ``,
117
+ `Fix it by reinstalling so the native binding is fetched or built:`,
118
+ ` npm rebuild @homebridge/node-pty-prebuilt-multiarch # rebuild for this Node`,
119
+ ` # or reinstall the CLI: npm i -g @phnx-labs/agents-cli`,
120
+ `If your Node (ABI ${abi}) is newer than the shipped prebuilds, install an LTS`,
121
+ `Node and retry, or ensure a C++ toolchain is present for the source build.`,
122
+ ``,
123
+ `Underlying error:`,
124
+ ...detail.split('\n').map(l => ` ${l}`),
125
+ ];
126
+ return lines;
127
+ }
91
128
  /** Get the PTY helper directory, creating it if needed. */
92
129
  function getPtyDir() {
93
130
  const dir = getPtyDirRoot();
@@ -181,9 +218,16 @@ export async function runPtyServer() {
181
218
  let nodePty;
182
219
  let XtermTerminal;
183
220
  try {
184
- // The Homebridge multiarch fork of node-pty: API-identical (same 1.x N-API
185
- // codebase) but ships prebuilt binaries for Linux glibc + musl, x64 + arm64
186
- // (plus macOS/Windows), so no compiler is needed on Linux/Alpine/arm64.
221
+ // The Homebridge multiarch fork of node-pty (API-identical to the 1.x N-API
222
+ // upstream). Its npm tarball BAKES IN the Linux prebuilds (glibc + musl, every
223
+ // arch + Node ABI), so Linux never needs a compiler or a network fetch. The
224
+ // darwin-arm64 / win32 binaries are NOT in the tarball — they are downloaded
225
+ // per host+Node-ABI at install time by the package's own `prebuild-install`
226
+ // postinstall (gated by bun's `trustedDependencies`). That fetch fails when the
227
+ // running Node ABI is newer than any published prebuild (e.g. 0.13.1 shipped no
228
+ // darwin-arm64 above Node 24 / ABI 137, so Node 25/26 fell through to a
229
+ // build-from-source that also failed — PHNX-2740), leaving a raw MODULE_NOT_FOUND.
230
+ // Fail loud with the platform/ABI and a concrete remediation instead.
187
231
  nodePty = await import('@homebridge/node-pty-prebuilt-multiarch');
188
232
  // Handle ESM default export
189
233
  if (nodePty.default?.spawn)
@@ -205,8 +249,8 @@ export async function runPtyServer() {
205
249
  catch { }
206
250
  }
207
251
  catch (err) {
208
- console.error('node-pty (@homebridge/node-pty-prebuilt-multiarch) is required for PTY support.');
209
- console.error('Install: bun add @homebridge/node-pty-prebuilt-multiarch');
252
+ for (const line of describeNativePtyLoadFailure(err))
253
+ console.error(line);
210
254
  process.exit(1);
211
255
  }
212
256
  try {
@@ -26,6 +26,11 @@ function readRushToken() {
26
26
  if (!token) {
27
27
  throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
28
28
  }
29
+ const expiresAt = data.session?.expires_at;
30
+ if (typeof expiresAt === 'number' && expiresAt <= Date.now() / 1000) {
31
+ const expiredAt = new Date(expiresAt * 1000).toISOString();
32
+ throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
33
+ }
29
34
  return token;
30
35
  }
31
36
  async function api(method, endpoint, body) {
@@ -204,6 +204,48 @@ export declare function verifyInstalledVersion(packageRoot: string, expectedVers
204
204
  * upgraded) package root.
205
205
  */
206
206
  export declare function refreshAliasShims(packageRoot: string): void;
207
+ /** One global bin link the upgrade reconciled: what it is and what happened. */
208
+ export interface BinLinkRepair {
209
+ /** The `package.json#bin` key (`agents`, `ag`, `browser`, `computer`). */
210
+ name: string;
211
+ /** `<prefix>/bin/<name>` — the PATH entry the box's shell resolves. */
212
+ linkPath: string;
213
+ /** Absolute path the link must resolve to (`<packageRoot>/<bin target>`). */
214
+ target: string;
215
+ /**
216
+ * `ok` — already resolved to the freshly-installed target.
217
+ * `repaired` — was missing / dangling / pointing elsewhere, now relinked.
218
+ * `failed` — could not be made to resolve (see `error`).
219
+ */
220
+ action: 'ok' | 'repaired' | 'failed';
221
+ /** Set only for `failed`: why the relink did not take. */
222
+ error?: string;
223
+ }
224
+ /**
225
+ * The global bin links the upgrade OWNS: `<prefix>/bin/<name>` for every
226
+ * `package.json#bin` entry (`agents`, `ag`, `browser`, `computer`).
227
+ *
228
+ * npm creates these on a normal `install -g`, but a box with several installs
229
+ * (or a reify interrupted after the old links were retired) can end an upgrade
230
+ * with the package at the new version and these links **missing** — the state
231
+ * that stranded zion (PHNX-2768): `/opt/homebrew/lib/node_modules/...` at
232
+ * 1.22.40 but `/opt/homebrew/bin/{agents,ag,browser,computer}` gone, so every
233
+ * `agents` invocation was "command not found" until the links were relinked by
234
+ * hand. The rollout probe reported it `unverified`; the box was left broken.
235
+ *
236
+ * So the upgrade verifies these links right after installing and **restores any
237
+ * that are wrong** — covering the sibling entrypoints, not just `agents`, since
238
+ * `ag`/`browser`/`computer` share the same failure. Each link is reconciled
239
+ * independently; a link that cannot be made to resolve is reported `failed` so
240
+ * the caller can fail loud (the rollout then marks the box failed, not merely
241
+ * unverified) instead of returning a box the package upgraded but cannot run.
242
+ *
243
+ * POSIX only — Windows npm bins are `.cmd`/`.ps1` shims, not symlinks, so this
244
+ * relink shape does not apply and the caller skips it there. `prefix` is the
245
+ * npm global prefix from {@link deriveGlobalPrefix}; the bun path uses its own
246
+ * bin layout and is out of scope.
247
+ */
248
+ export declare function ensureGlobalBinLinks(packageRoot: string, prefix: string): BinLinkRepair[];
207
249
  export interface AgentsCliInstall {
208
250
  /** The PATH entry (`<dir>/agents`) that resolves to this install, when found through PATH. */
209
251
  binPath?: string;
@@ -454,6 +454,94 @@ export function refreshAliasShims(packageRoot) {
454
454
  stdio: 'ignore',
455
455
  });
456
456
  }
457
+ /** Resolve `p` through symlinks, or null when it does not resolve (missing/dangling). */
458
+ function realpathOrNull(p) {
459
+ try {
460
+ return fs.realpathSync(p);
461
+ }
462
+ catch {
463
+ return null;
464
+ }
465
+ }
466
+ /**
467
+ * Reconcile one `<binDir>/<name>` link to `target`. A link that already resolves
468
+ * to `target` is left untouched (`ok`); anything else — absent, dangling, or
469
+ * pointing at a stale/foreign path — is replaced with a fresh **relative**
470
+ * symlink (`../lib/node_modules/@phnx-labs/agents-cli/dist/index.js`), the exact
471
+ * shape npm and the by-hand zion repair both produced, then re-verified. A
472
+ * repair that still does not resolve (target missing, unwritable bin dir) is
473
+ * reported `failed` with the reason rather than silently swallowed.
474
+ */
475
+ function reconcileBinLink(name, linkPath, target) {
476
+ const wanted = realpathOrNull(target);
477
+ if (wanted !== null && realpathOrNull(linkPath) === wanted) {
478
+ return { name, linkPath, target, action: 'ok' };
479
+ }
480
+ try {
481
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
482
+ // Replace whatever is there (a dangling link, a stale link, or nothing).
483
+ fs.rmSync(linkPath, { force: true });
484
+ fs.symlinkSync(path.relative(path.dirname(linkPath), target), linkPath);
485
+ const resolved = realpathOrNull(linkPath);
486
+ if (resolved !== null && resolved === realpathOrNull(target)) {
487
+ return { name, linkPath, target, action: 'repaired' };
488
+ }
489
+ return {
490
+ name,
491
+ linkPath,
492
+ target,
493
+ action: 'failed',
494
+ error: resolved === null
495
+ ? `link created but still does not resolve (is ${target} present?)`
496
+ : `link resolves to ${resolved}, not ${target}`,
497
+ };
498
+ }
499
+ catch (err) {
500
+ return { name, linkPath, target, action: 'failed', error: err instanceof Error ? err.message : String(err) };
501
+ }
502
+ }
503
+ /**
504
+ * The global bin links the upgrade OWNS: `<prefix>/bin/<name>` for every
505
+ * `package.json#bin` entry (`agents`, `ag`, `browser`, `computer`).
506
+ *
507
+ * npm creates these on a normal `install -g`, but a box with several installs
508
+ * (or a reify interrupted after the old links were retired) can end an upgrade
509
+ * with the package at the new version and these links **missing** — the state
510
+ * that stranded zion (PHNX-2768): `/opt/homebrew/lib/node_modules/...` at
511
+ * 1.22.40 but `/opt/homebrew/bin/{agents,ag,browser,computer}` gone, so every
512
+ * `agents` invocation was "command not found" until the links were relinked by
513
+ * hand. The rollout probe reported it `unverified`; the box was left broken.
514
+ *
515
+ * So the upgrade verifies these links right after installing and **restores any
516
+ * that are wrong** — covering the sibling entrypoints, not just `agents`, since
517
+ * `ag`/`browser`/`computer` share the same failure. Each link is reconciled
518
+ * independently; a link that cannot be made to resolve is reported `failed` so
519
+ * the caller can fail loud (the rollout then marks the box failed, not merely
520
+ * unverified) instead of returning a box the package upgraded but cannot run.
521
+ *
522
+ * POSIX only — Windows npm bins are `.cmd`/`.ps1` shims, not symlinks, so this
523
+ * relink shape does not apply and the caller skips it there. `prefix` is the
524
+ * npm global prefix from {@link deriveGlobalPrefix}; the bun path uses its own
525
+ * bin layout and is out of scope.
526
+ */
527
+ export function ensureGlobalBinLinks(packageRoot, prefix) {
528
+ let bin;
529
+ try {
530
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
531
+ bin = pkg && typeof pkg.bin === 'object' && pkg.bin !== null ? pkg.bin : {};
532
+ }
533
+ catch (err) {
534
+ throw new Error(`could not read bin entries from ${path.join(packageRoot, 'package.json')}: ${err instanceof Error ? err.message : String(err)}`);
535
+ }
536
+ const binDir = path.join(prefix, 'bin');
537
+ const repairs = [];
538
+ for (const [name, rel] of Object.entries(bin)) {
539
+ if (typeof rel !== 'string' || !rel)
540
+ continue;
541
+ repairs.push(reconcileBinLink(name, path.join(binDir, name), path.resolve(packageRoot, rel)));
542
+ }
543
+ return repairs;
544
+ }
457
545
  /**
458
546
  * The package root a resolved `agents` entrypoint belongs to, or null when the
459
547
  * path is not an agents-cli entry at all. Two shipped shapes:
@@ -29,6 +29,11 @@ function readToken() {
29
29
  if (!token) {
30
30
  throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
31
31
  }
32
+ const expiresAt = data.session?.expires_at;
33
+ if (typeof expiresAt === 'number' && expiresAt <= Date.now() / 1000) {
34
+ const expiredAt = new Date(expiresAt * 1000).toISOString();
35
+ throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
36
+ }
32
37
  return token;
33
38
  }
34
39
  async function api(method, endpoint, token) {
@@ -13,7 +13,7 @@ import { type ToolScanResumePoint } from './tool-store.js';
13
13
  /** Current schema version; bumped when migrations are added. Exported so tests
14
14
  * assert against the constant instead of hardcoding a number that every bump
15
15
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
16
- export declare const SCHEMA_VERSION = 43;
16
+ export declare const SCHEMA_VERSION = 44;
17
17
  /**
18
18
  * Bump to force the content extractor (assistant-answer text, alongside the
19
19
  * user-prompt text every harness already accumulates) to re-derive on every
@@ -317,6 +317,16 @@ export declare function getSessionExistenceCacheStats(): {
317
317
  };
318
318
  /** Query sessions from the database, applying filters and ordering by last-activity descending (default). */
319
319
  export declare function querySessions(options?: QueryOptions): SessionMeta[];
320
+ /**
321
+ * Cheap query for the daemon's deferred tool-index pass (PHNX-3411).
322
+ *
323
+ * Returns the most-recently-active sessions whose parseSession reads a large
324
+ * flat transcript (kimi: wire.jsonl, grok: chat_history.jsonl). Their scanners
325
+ * produce no events, so upsertSessionsBatch skips them in the warm tick to
326
+ * avoid wedging the event loop. ensureToolIndex uses tool_scan_ledger stamps
327
+ * to skip already-current rows and applies byte/file budget caps.
328
+ */
329
+ export declare function querySessionsForDeferredToolIndex(limit: number): SessionMeta[];
320
330
  /** Count sessions matching the given filter options. */
321
331
  export declare function countSessions(options?: QueryOptions): number;
322
332
  /** One grouped row in a cost/duration rollup. */
@@ -519,14 +529,30 @@ export declare function queryResourceUsageStats(options: QueryOptions & {
519
529
  limit?: number;
520
530
  }): ResourceStatRow[];
521
531
  /**
522
- * Coverage of the resource-usage signal: how many distinct sessions carry any
523
- * row in session_resource_usage vs. the total indexed. A low ratio means the
524
- * historical backfill (`agents sessions backfill resources`) hasn't run — the
525
- * stats surface uses this to tell the user their zero-counts may just be
526
- * un-scanned history, not genuine non-use.
532
+ * Coverage of the resource-usage signal, as three honest facts:
533
+ *
534
+ * - `scanned` — sessions the resource extractor has actually processed, i.e.
535
+ * those carrying a `resource_scan_ledger` row at the current
536
+ * `RESOURCE_INDEX_VERSION`. This is the true "has the historical backfill run"
537
+ * signal: the ledger is stamped for EVERY scanned session, including ones that
538
+ * invoked nothing (`resource_count = 0`), so `scanned/total` rises to ~1 after
539
+ * `agents sessions backfill resources` regardless of how sparse explicit
540
+ * invocations are.
541
+ * - `covered` — distinct sessions that carry AT LEAST ONE row in
542
+ * `session_resource_usage`, i.e. that actually recorded an explicit invocation.
543
+ * This is an ABSOLUTE signal count, not a coverage ratio: it stays small even
544
+ * at full scan coverage because most sessions invoke no skill/command, and a
545
+ * non-recording harness contributes none by construction.
546
+ * - `total` — sessions indexed.
547
+ *
548
+ * The two were previously conflated: `covered/total` was framed as coverage and
549
+ * read ~1.2% even after a full backfill (most sessions genuinely invoke nothing),
550
+ * so the "run the backfill" hint never cleared. Keying the hint on `scanned/total`
551
+ * fixes that — see `commands/sessions-stats.ts` (PHNX-2301).
527
552
  */
528
553
  export declare function resourceUsageCoverage(): {
529
554
  covered: number;
555
+ scanned: number;
530
556
  total: number;
531
557
  };
532
558
  /** Outcome of a resource-usage backfill run. */