@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.
- package/CHANGELOG.md +260 -0
- package/README.md +29 -0
- package/dist/bootstrap.js +32 -1
- package/dist/commands/monitors.js +187 -23
- package/dist/commands/perf.js +10 -0
- package/dist/commands/routines.test-fixture.js +5 -0
- package/dist/commands/send.d.ts +2 -1
- package/dist/commands/send.js +7 -5
- package/dist/commands/sessions-picker.d.ts +13 -0
- package/dist/commands/sessions-picker.js +17 -8
- package/dist/commands/sessions-stats.js +37 -5
- package/dist/commands/sessions.js +52 -16
- package/dist/commands/ssh.js +12 -1
- package/dist/commands/teams-picker.js +20 -6
- package/dist/commands/teams.d.ts +2 -2
- package/dist/commands/teams.js +77 -21
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.js +7 -2
- package/dist/lib/accounting/rotate.js +12 -4
- package/dist/lib/accounting/usage-sync.d.ts +12 -2
- package/dist/lib/accounting/usage-sync.js +34 -6
- package/dist/lib/auto-pull-worker.js +7 -2
- package/dist/lib/cloud/rush.d.ts +7 -0
- package/dist/lib/cloud/rush.js +29 -1
- package/dist/lib/daemon/daemon.d.ts +22 -0
- package/dist/lib/daemon/daemon.js +39 -0
- package/dist/lib/daemon/session-index-service.js +9 -1
- package/dist/lib/daemon-ticks.d.ts +15 -0
- package/dist/lib/daemon-ticks.js +26 -0
- package/dist/lib/device-config.d.ts +5 -1
- package/dist/lib/device-config.js +2 -2
- package/dist/lib/devices/health.js +5 -1
- package/dist/lib/devices/pool.d.ts +25 -2
- package/dist/lib/devices/pool.js +32 -2
- package/dist/lib/devices/stats-cache.d.ts +0 -6
- package/dist/lib/devices/stats-cache.js +2 -9
- package/dist/lib/doctor-diff.d.ts +14 -0
- package/dist/lib/doctor-diff.js +43 -2
- package/dist/lib/feed/events.js +4 -0
- package/dist/lib/git.d.ts +38 -0
- package/dist/lib/git.js +58 -0
- package/dist/lib/hosts/ready.d.ts +8 -0
- package/dist/lib/hosts/ready.js +13 -2
- package/dist/lib/installations/versions.d.ts +17 -0
- package/dist/lib/installations/versions.js +53 -2
- package/dist/lib/monitors/config.d.ts +71 -3
- package/dist/lib/monitors/config.js +100 -12
- package/dist/lib/monitors/pid-watch.d.ts +35 -0
- package/dist/lib/monitors/pid-watch.js +45 -0
- package/dist/lib/monitors/remote.d.ts +18 -0
- package/dist/lib/monitors/remote.js +11 -0
- package/dist/lib/perf/db.d.ts +1 -1
- package/dist/lib/perf/db.js +53 -2
- package/dist/lib/perf/types.d.ts +14 -0
- package/dist/lib/permissions.js +7 -2
- package/dist/lib/plugins/plugins.d.ts +17 -3
- package/dist/lib/plugins/plugins.js +84 -9
- package/dist/lib/pty-server.d.ts +14 -0
- package/dist/lib/pty-server.js +49 -5
- package/dist/lib/secrets/drivers/rush.js +5 -0
- package/dist/lib/self-update.d.ts +42 -0
- package/dist/lib/self-update.js +88 -0
- package/dist/lib/session/cloud.js +5 -0
- package/dist/lib/session/db.d.ts +32 -6
- package/dist/lib/session/db.js +128 -12
- package/dist/lib/session/live-metadata.js +3 -3
- package/dist/lib/smart-launch.d.ts +6 -0
- package/dist/lib/smart-launch.js +5 -2
- package/dist/lib/staleness/writers/plugins.js +5 -2
- package/dist/lib/staleness/writers/subagents.js +13 -3
- package/dist/lib/state.d.ts +7 -4
- package/dist/lib/state.js +7 -4
- package/dist/lib/subagents.js +8 -2
- package/dist/lib/teams/api.d.ts +8 -0
- package/dist/lib/teams/api.js +50 -6
- package/dist/lib/teams/delivery.d.ts +14 -4
- package/dist/lib/teams/delivery.js +15 -5
- package/dist/lib/teams/scheduler.d.ts +10 -0
- package/dist/lib/teams/scheduler.js +8 -0
- package/dist/lib/traces/sync.d.ts +113 -6
- package/dist/lib/traces/sync.js +193 -19
- package/dist/lib/view-types.d.ts +12 -0
- 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;
|
package/dist/lib/perf/db.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/perf/db.js
CHANGED
|
@@ -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);
|
package/dist/lib/perf/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/permissions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
258
|
-
*
|
|
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,
|
|
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
|
-
|
|
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.
|
|
1436
|
-
*
|
|
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,
|
|
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 (
|
|
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 (
|
|
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
|
|
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 (
|
|
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 (!
|
|
1607
|
+
if (!active.names.has(pluginName)) {
|
|
1533
1608
|
orphans.push(entry.name);
|
|
1534
1609
|
}
|
|
1535
1610
|
}
|
package/dist/lib/pty-server.d.ts
CHANGED
|
@@ -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.
|
package/dist/lib/pty-server.js
CHANGED
|
@@ -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
|
|
185
|
-
//
|
|
186
|
-
//
|
|
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
|
-
|
|
209
|
-
|
|
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;
|
package/dist/lib/self-update.js
CHANGED
|
@@ -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) {
|