@phnx-labs/agents-cli 1.22.58 → 1.22.60

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 (83) hide show
  1. package/CHANGELOG.md +260 -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/perf.js +10 -0
  6. package/dist/commands/routines.test-fixture.js +5 -0
  7. package/dist/commands/send.d.ts +2 -1
  8. package/dist/commands/send.js +7 -5
  9. package/dist/commands/sessions-picker.d.ts +13 -0
  10. package/dist/commands/sessions-picker.js +17 -8
  11. package/dist/commands/sessions-stats.js +37 -5
  12. package/dist/commands/sessions.js +52 -16
  13. package/dist/commands/ssh.js +12 -1
  14. package/dist/commands/teams-picker.js +20 -6
  15. package/dist/commands/teams.d.ts +2 -2
  16. package/dist/commands/teams.js +77 -21
  17. package/dist/commands/versions.js +12 -4
  18. package/dist/commands/view.js +7 -2
  19. package/dist/lib/accounting/rotate.js +12 -4
  20. package/dist/lib/accounting/usage-sync.d.ts +12 -2
  21. package/dist/lib/accounting/usage-sync.js +34 -6
  22. package/dist/lib/auto-pull-worker.js +7 -2
  23. package/dist/lib/cloud/rush.d.ts +7 -0
  24. package/dist/lib/cloud/rush.js +29 -1
  25. package/dist/lib/daemon/daemon.d.ts +22 -0
  26. package/dist/lib/daemon/daemon.js +39 -0
  27. package/dist/lib/daemon/session-index-service.js +9 -1
  28. package/dist/lib/daemon-ticks.d.ts +15 -0
  29. package/dist/lib/daemon-ticks.js +26 -0
  30. package/dist/lib/device-config.d.ts +5 -1
  31. package/dist/lib/device-config.js +2 -2
  32. package/dist/lib/devices/health.js +5 -1
  33. package/dist/lib/devices/pool.d.ts +25 -2
  34. package/dist/lib/devices/pool.js +32 -2
  35. package/dist/lib/devices/stats-cache.d.ts +0 -6
  36. package/dist/lib/devices/stats-cache.js +2 -9
  37. package/dist/lib/doctor-diff.d.ts +14 -0
  38. package/dist/lib/doctor-diff.js +43 -2
  39. package/dist/lib/feed/events.js +4 -0
  40. package/dist/lib/git.d.ts +38 -0
  41. package/dist/lib/git.js +58 -0
  42. package/dist/lib/hosts/ready.d.ts +8 -0
  43. package/dist/lib/hosts/ready.js +13 -2
  44. package/dist/lib/installations/versions.d.ts +17 -0
  45. package/dist/lib/installations/versions.js +53 -2
  46. package/dist/lib/monitors/config.d.ts +71 -3
  47. package/dist/lib/monitors/config.js +100 -12
  48. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  49. package/dist/lib/monitors/pid-watch.js +45 -0
  50. package/dist/lib/monitors/remote.d.ts +18 -0
  51. package/dist/lib/monitors/remote.js +11 -0
  52. package/dist/lib/perf/db.d.ts +1 -1
  53. package/dist/lib/perf/db.js +53 -2
  54. package/dist/lib/perf/types.d.ts +14 -0
  55. package/dist/lib/permissions.js +7 -2
  56. package/dist/lib/plugins/plugins.d.ts +17 -3
  57. package/dist/lib/plugins/plugins.js +84 -9
  58. package/dist/lib/pty-server.d.ts +14 -0
  59. package/dist/lib/pty-server.js +49 -5
  60. package/dist/lib/secrets/drivers/rush.js +5 -0
  61. package/dist/lib/self-update.d.ts +42 -0
  62. package/dist/lib/self-update.js +88 -0
  63. package/dist/lib/session/cloud.js +5 -0
  64. package/dist/lib/session/db.d.ts +32 -6
  65. package/dist/lib/session/db.js +128 -12
  66. package/dist/lib/session/live-metadata.js +3 -3
  67. package/dist/lib/smart-launch.d.ts +6 -0
  68. package/dist/lib/smart-launch.js +5 -2
  69. package/dist/lib/staleness/writers/plugins.js +5 -2
  70. package/dist/lib/staleness/writers/subagents.js +13 -3
  71. package/dist/lib/state.d.ts +7 -4
  72. package/dist/lib/state.js +7 -4
  73. package/dist/lib/subagents.js +8 -2
  74. package/dist/lib/teams/api.d.ts +8 -0
  75. package/dist/lib/teams/api.js +50 -6
  76. package/dist/lib/teams/delivery.d.ts +14 -4
  77. package/dist/lib/teams/delivery.js +15 -5
  78. package/dist/lib/teams/scheduler.d.ts +10 -0
  79. package/dist/lib/teams/scheduler.js +8 -0
  80. package/dist/lib/traces/sync.d.ts +113 -6
  81. package/dist/lib/traces/sync.js +193 -19
  82. package/dist/lib/view-types.d.ts +12 -0
  83. package/package.json +2 -2
@@ -20,10 +20,28 @@ import { type GatherRemoteAgentsJsonDeps } from '../remote-agents-json.js';
20
20
  import type { MonitorConfig } from './config.js';
21
21
  /** Recursion guard: a peer answering the fan-out must not fan out again. */
22
22
  export declare const NO_MONITOR_FANOUT_ENV = "AGENTS_MONITORS_LOCAL";
23
+ /**
24
+ * The owning box's view of a remote monitor, beyond its behavioral identity —
25
+ * enough for `monitors list` to render it (enabled/placement/scope) and show a
26
+ * one-line liveness note without a second round-trip. All optional: a peer on an
27
+ * older CLI may omit them, and the duplicate guard never reads them.
28
+ */
29
+ export interface RemoteMonitorDisplay {
30
+ enabled?: boolean;
31
+ owner?: string;
32
+ scope?: 'user' | 'system';
33
+ stalled?: boolean;
34
+ checkCount?: number;
35
+ lastCheckedAt?: string | null;
36
+ lastFiredAt?: string | null;
37
+ lastActionFailed?: boolean;
38
+ }
23
39
  /** One monitor as seen on a peer, tagged with the box it lives on. */
24
40
  export interface RemoteMonitor {
25
41
  machine: string;
26
42
  monitor: Pick<MonitorConfig, 'name' | 'source' | 'condition' | 'action'>;
43
+ /** The owning box's enabled/placement/scope/liveness view, for `list` display. */
44
+ display?: RemoteMonitorDisplay;
27
45
  }
28
46
  /**
29
47
  * Parse a peer's `monitors list --json`. Defensive against version skew: a peer
@@ -48,9 +48,20 @@ export function parseRemoteMonitors(stdout, machine) {
48
48
  // row cannot participate in the duplicate check either way.
49
49
  if (!m.name || !m.source || !m.condition || !m.action)
50
50
  continue;
51
+ const display = {
52
+ enabled: typeof m.enabled === 'boolean' ? m.enabled : undefined,
53
+ owner: typeof m.owner === 'string' ? m.owner : undefined,
54
+ scope: m.scope === 'system' || m.scope === 'user' ? m.scope : undefined,
55
+ stalled: typeof m.stalled === 'boolean' ? m.stalled : undefined,
56
+ checkCount: typeof m.checkCount === 'number' ? m.checkCount : undefined,
57
+ lastCheckedAt: typeof m.lastCheckedAt === 'string' ? m.lastCheckedAt : undefined,
58
+ lastFiredAt: typeof m.lastFiredAt === 'string' ? m.lastFiredAt : undefined,
59
+ lastActionFailed: typeof m.lastActionFailed === 'boolean' ? m.lastActionFailed : undefined,
60
+ };
51
61
  out.push({
52
62
  machine,
53
63
  monitor: { name: m.name, source: m.source, condition: m.condition, action: m.action },
64
+ display,
54
65
  });
55
66
  }
56
67
  return out;
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import Database from '../sqlite.js';
8
8
  import type { AggregateOptions, PerfAggregateRow } from './types.js';
9
- export type { AggregateOptions, PerfAggregateRow, PerfSample } from './types.js';
9
+ export type { AggregateOptions, PerfAggregateRow, PerfPhaseStat, PerfSample } from './types.js';
10
10
  export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
11
11
  export { percentile } from '../percentile.js';
12
12
  export declare const PERF_SCHEMA_VERSION = 1;
@@ -12,6 +12,31 @@ import { localMachineId } from '../origin-machine.js';
12
12
  import { resolveProjectKey } from '../project-key.js';
13
13
  import { percentile } from '../percentile.js';
14
14
  import { resolveSpoolPath, shortSessionId, _resetPerfSpoolForTest } from './spool.js';
15
+ /**
16
+ * Parse the `phases` map from a sample's meta_json. Fail-soft: a row with no
17
+ * meta_json, malformed JSON, or a non-numeric phase value contributes nothing
18
+ * rather than throwing (the warehouse must survive any writer's shape).
19
+ */
20
+ function parsePhases(metaJson) {
21
+ if (!metaJson)
22
+ return undefined;
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(metaJson);
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ const phases = parsed?.phases;
31
+ if (!phases || typeof phases !== 'object')
32
+ return undefined;
33
+ const out = {};
34
+ for (const [name, val] of Object.entries(phases)) {
35
+ if (typeof val === 'number' && Number.isFinite(val))
36
+ out[name] = val;
37
+ }
38
+ return Object.keys(out).length > 0 ? out : undefined;
39
+ }
15
40
  export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
16
41
  export { percentile } from '../percentile.js';
17
42
  export const PERF_SCHEMA_VERSION = 1;
@@ -222,7 +247,7 @@ export function aggregateSamples(opts = {}) {
222
247
  clauses.push('agent = ?');
223
248
  params.push(opts.agent);
224
249
  }
225
- const rows = db.prepare(`SELECT kind, label, duration_ms, cache, exit_code, status, cwd
250
+ const rows = db.prepare(`SELECT kind, label, duration_ms, cache, exit_code, status, cwd, meta_json
226
251
  FROM samples WHERE ${clauses.join(' AND ')}`).all(...params);
227
252
  // Memoize cwd -> project key: resolveProjectKey walks the filesystem for
228
253
  // a repo root, and many rows in one warehouse query share the same cwd.
@@ -244,10 +269,21 @@ export function aggregateSamples(opts = {}) {
244
269
  const key = `${r.kind}\0${r.label}`;
245
270
  let b = map.get(key);
246
271
  if (!b) {
247
- b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0, blocks: 0, timeouts: 0 };
272
+ b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0, blocks: 0, timeouts: 0, phases: new Map() };
248
273
  map.set(key, b);
249
274
  }
250
275
  b.durations.push(Number(r.duration_ms));
276
+ const phases = parsePhases(r.meta_json);
277
+ if (phases) {
278
+ for (const [name, ms] of Object.entries(phases)) {
279
+ let arr = b.phases.get(name);
280
+ if (!arr) {
281
+ arr = [];
282
+ b.phases.set(name, arr);
283
+ }
284
+ arr.push(ms);
285
+ }
286
+ }
251
287
  if (r.cache === 'hit')
252
288
  b.hits++;
253
289
  else if (r.cache === 'stale-prefetch')
@@ -301,6 +337,21 @@ export function aggregateSamples(opts = {}) {
301
337
  }
302
338
  if (b.timeouts > 0)
303
339
  row.timeoutRate = Math.round((b.timeouts / n) * 1000) / 1000;
340
+ if (b.phases.size > 0) {
341
+ const phases = {};
342
+ for (const [name, durs] of b.phases) {
343
+ if (durs.length === 0)
344
+ continue;
345
+ const ps = durs.slice().sort((a, c) => a - c);
346
+ phases[name] = {
347
+ n: ps.length,
348
+ p50Ms: Math.round(percentile(ps, 50)),
349
+ p90Ms: Math.round(percentile(ps, 90)),
350
+ };
351
+ }
352
+ if (Object.keys(phases).length > 0)
353
+ row.phases = phases;
354
+ }
304
355
  if (opts.project)
305
356
  row.project = opts.project;
306
357
  out.push(row);
@@ -21,6 +21,13 @@ export interface PerfSample {
21
21
  status?: string;
22
22
  metaJson?: string;
23
23
  }
24
+ /** Percentiles for one sub-phase (e.g. agent.run's `startup`) across a bucket. */
25
+ export interface PerfPhaseStat {
26
+ /** Samples that carried this phase (may be < the row's `n`). */
27
+ n: number;
28
+ p50Ms: number;
29
+ p90Ms: number;
30
+ }
24
31
  export interface PerfAggregateRow {
25
32
  kind: string;
26
33
  label: string;
@@ -31,6 +38,13 @@ export interface PerfAggregateRow {
31
38
  meanMs: number;
32
39
  maxMs: number;
33
40
  minMs: number;
41
+ /**
42
+ * Per-sub-phase percentiles parsed from each sample's meta_json `phases`.
43
+ * Present only when at least one sample in the bucket recorded a phase
44
+ * (agent.run records `startup` — time from spawnAgent entry to child spawn —
45
+ * so boot cost is trackable independently of the total run, PHNX-3468).
46
+ */
47
+ phases?: Record<string, PerfPhaseStat>;
34
48
  cacheHitPct?: number;
35
49
  cacheStalePct?: number;
36
50
  cacheMissPct?: number;
@@ -323,8 +323,13 @@ export function buildPermissionsFromGroups(groupNames) {
323
323
  const content = fs.readFileSync(filePath, 'utf-8');
324
324
  // Extract rules using line-by-line regex (more robust than YAML parsing)
325
325
  // Matches lines like: - "Bash(git *)" or - "WebFetch(domain:example.com)"
326
- // Handles nested quotes that break YAML parsers
327
- const lines = content.split('\n');
326
+ // Handles nested quotes that break YAML parsers.
327
+ // Split on CRLF or LF: git checks group yaml out with CRLF on Windows
328
+ // (core.autocrlf), and a plain split('\n') leaves a trailing '\r' so the
329
+ // closing-quote anchor `"$` never matches — extracting ZERO rules, which
330
+ // wrote an empty permission set and left `agents doctor --fix` unable to
331
+ // reconcile permissions on Windows forever (PHNX-3187).
332
+ const lines = content.split(/\r?\n/);
328
333
  let section = null;
329
334
  for (const line of lines) {
330
335
  const sectionMatch = line.match(/^\s*(allow|deny)\s*:\s*(?:#.*)?$/);
@@ -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) {