@phnx-labs/agents-cli 1.22.57 → 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.
- package/CHANGELOG.md +294 -0
- package/README.md +29 -0
- package/dist/bootstrap.js +39 -1
- package/dist/commands/accounts.js +7 -3
- package/dist/commands/apply.js +10 -2
- package/dist/commands/fork.d.ts +23 -10
- package/dist/commands/fork.js +115 -58
- package/dist/commands/monitors.js +198 -23
- package/dist/commands/prune.js +5 -3
- package/dist/commands/routines.d.ts +8 -0
- package/dist/commands/routines.js +57 -3
- 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 +11 -0
- package/dist/commands/sessions-picker.js +16 -0
- package/dist/commands/sessions-stats.js +37 -5
- package/dist/commands/sessions.js +40 -5
- package/dist/commands/share.d.ts +14 -0
- package/dist/commands/share.js +43 -2
- package/dist/commands/ssh.js +12 -1
- package/dist/commands/status.js +1 -1
- package/dist/commands/sync.js +83 -7
- package/dist/commands/traces.js +7 -0
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.js +7 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -1
- package/dist/lib/account-registry.d.ts +5 -1
- package/dist/lib/account-registry.js +47 -14
- package/dist/lib/accounting/capacity.d.ts +18 -7
- package/dist/lib/accounting/capacity.js +19 -8
- package/dist/lib/accounting/usage-sync.d.ts +29 -1
- package/dist/lib/accounting/usage-sync.js +76 -2
- package/dist/lib/accounting/usage.js +7 -1
- package/dist/lib/auth-mint.d.ts +11 -1
- package/dist/lib/auth-mint.js +21 -6
- package/dist/lib/auto-pull-worker.js +7 -2
- package/dist/lib/browser/ipc.d.ts +8 -0
- package/dist/lib/browser/ipc.js +87 -0
- package/dist/lib/browser/service.d.ts +19 -0
- package/dist/lib/browser/service.js +96 -11
- package/dist/lib/browser/sessions-list.js +10 -1
- 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/runner.d.ts +3 -0
- package/dist/lib/daemon/runner.js +86 -45
- package/dist/lib/daemon/session-index-service.js +9 -1
- package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
- package/dist/lib/daemon/usage-sync-service.js +14 -8
- package/dist/lib/daemon-services.js +1 -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/connect.d.ts +17 -8
- package/dist/lib/devices/connect.js +31 -14
- 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 +120 -9
- package/dist/lib/fleet/manifest.d.ts +17 -0
- package/dist/lib/fleet/manifest.js +26 -0
- package/dist/lib/git.d.ts +38 -0
- package/dist/lib/git.js +58 -0
- package/dist/lib/hooks/install.d.ts +27 -11
- package/dist/lib/hooks/install.js +42 -17
- package/dist/lib/hosts/ready.d.ts +8 -0
- package/dist/lib/hosts/ready.js +13 -2
- package/dist/lib/hosts/reconnect.d.ts +52 -203
- package/dist/lib/hosts/reconnect.js +64 -284
- package/dist/lib/installations/migrate.d.ts +6 -120
- package/dist/lib/installations/migrate.js +27 -259
- package/dist/lib/installations/shims.d.ts +13 -95
- package/dist/lib/installations/shims.js +22 -139
- package/dist/lib/installations/store.js +1 -1
- package/dist/lib/installations/versions.d.ts +43 -133
- package/dist/lib/installations/versions.js +94 -206
- 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/permissions.js +7 -2
- package/dist/lib/plugins/plugins.d.ts +17 -3
- package/dist/lib/plugins/plugins.js +84 -9
- package/dist/lib/plugins/skills.d.ts +8 -1
- package/dist/lib/plugins/skills.js +18 -2
- package/dist/lib/pty-server.d.ts +14 -0
- package/dist/lib/pty-server.js +49 -5
- package/dist/lib/refresh.d.ts +9 -0
- package/dist/lib/refresh.js +3 -1
- package/dist/lib/routine-readiness.d.ts +15 -1
- package/dist/lib/routine-readiness.js +41 -0
- package/dist/lib/sandbox.d.ts +4 -1
- package/dist/lib/sandbox.js +30 -1
- package/dist/lib/secrets/agent.d.ts +80 -225
- package/dist/lib/secrets/agent.js +139 -401
- package/dist/lib/secrets/bundles.d.ts +73 -222
- package/dist/lib/secrets/bundles.js +168 -467
- package/dist/lib/secrets/drivers/rush.js +5 -0
- package/dist/lib/secrets/reaper.d.ts +28 -70
- package/dist/lib/secrets/reaper.js +30 -85
- package/dist/lib/secrets/remote.d.ts +42 -129
- package/dist/lib/secrets/remote.js +55 -173
- package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
- package/dist/lib/self-heal/checks/install-staging.js +96 -0
- package/dist/lib/self-heal/registry.js +2 -0
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/self-update.d.ts +65 -0
- package/dist/lib/self-update.js +138 -0
- package/dist/lib/session/active.d.ts +13 -1
- package/dist/lib/session/active.js +2 -0
- package/dist/lib/session/cloud.js +5 -0
- package/dist/lib/session/db.d.ts +51 -6
- package/dist/lib/session/db.js +266 -20
- package/dist/lib/session/fork.d.ts +45 -26
- package/dist/lib/session/fork.js +32 -95
- package/dist/lib/session/tool-calls.d.ts +43 -1
- package/dist/lib/session/tool-calls.js +74 -44
- package/dist/lib/session/tool-store.d.ts +33 -2
- package/dist/lib/session/tool-store.js +56 -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/sources.d.ts +5 -0
- package/dist/lib/staleness/writers/sources.js +2 -1
- 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/sync-status.d.ts +22 -0
- package/dist/lib/sync-status.js +27 -0
- package/dist/lib/sync-umbrella.d.ts +9 -0
- package/dist/lib/sync-umbrella.js +21 -2
- package/dist/lib/teams/scheduler.d.ts +10 -0
- package/dist/lib/teams/scheduler.js +8 -0
- package/dist/lib/traces/insights.d.ts +47 -14
- package/dist/lib/traces/insights.js +92 -21
- package/dist/lib/traces/phenotype.d.ts +23 -3
- package/dist/lib/traces/phenotype.js +72 -24
- package/dist/lib/traces/sync.d.ts +128 -6
- package/dist/lib/traces/sync.js +294 -35
- package/dist/lib/traces/worker-template.js +154 -1
- package/dist/lib/view-types.d.ts +12 -0
- package/package.json +2 -2
|
@@ -114,6 +114,30 @@ export interface MonitorConfig {
|
|
|
114
114
|
* independently, like routines' `devices`. Mutually exclusive with `device`.
|
|
115
115
|
*/
|
|
116
116
|
devices?: string[];
|
|
117
|
+
/**
|
|
118
|
+
* Does this monitor's SOURCE poll a fleet-shared queue (a PR list, a ticket
|
|
119
|
+
* tracker, the feed, a sync bucket) rather than the firing box's own state
|
|
120
|
+
* (its repos, sessions, caches)?
|
|
121
|
+
*
|
|
122
|
+
* This is the SING-9 placement switch for an UNPINNED monitor (no `device` /
|
|
123
|
+
* `devices`). A shared-input source has no per-box input, so every daemon
|
|
124
|
+
* firing it independently is a multi-executor race on shared state — the exact
|
|
125
|
+
* double-fire bug class. So an unpinned shared-input monitor fires only on the
|
|
126
|
+
* single owner (`interactive.host`, else the sole box on a one-device fleet),
|
|
127
|
+
* never on every daemon.
|
|
128
|
+
*
|
|
129
|
+
* Defaults differ by layer so the SAFE side is the default for each:
|
|
130
|
+
* - a **system built-in** (`scope: 'system'`) is treated as shared-input
|
|
131
|
+
* unless it sets `sharedInput: false`, so a built-in shipped with no pin
|
|
132
|
+
* can never fan out across the fleet — a device-local built-in opts back
|
|
133
|
+
* into fleet-wide firing with `sharedInput: false`;
|
|
134
|
+
* - a **user monitor** keeps its historical fleet-wide default and only
|
|
135
|
+
* becomes owner-restricted when it explicitly sets `sharedInput: true`.
|
|
136
|
+
*
|
|
137
|
+
* An explicit `device` / `devices` pin always wins and makes this moot — the
|
|
138
|
+
* author has already chosen the executor(s).
|
|
139
|
+
*/
|
|
140
|
+
sharedInput?: boolean;
|
|
117
141
|
/** Execute the ACTION on this machine over SSH (placement), distinct from the owner that fires it. */
|
|
118
142
|
runOn?: string;
|
|
119
143
|
/**
|
|
@@ -137,6 +161,15 @@ export interface MonitorConfig {
|
|
|
137
161
|
variables?: Record<string, string>;
|
|
138
162
|
/** Pin the agent version for `run` actions (omit to use the run strategy). */
|
|
139
163
|
version?: string;
|
|
164
|
+
/**
|
|
165
|
+
* Which layer this monitor was read from — `user` (~/.agents/monitors/) or
|
|
166
|
+
* `system` (the npm-shipped built-in mirror ~/.agents/.system/monitors/).
|
|
167
|
+
* A DERIVED, runtime-only annotation stamped by `readMonitorFile`, never a
|
|
168
|
+
* persisted YAML field: it tags a built-in in `list`/`view` (mirroring
|
|
169
|
+
* routines' `(built-in)` label) and `writeMonitor` strips it before writing,
|
|
170
|
+
* so materializing a user copy of a built-in always lands as `user`.
|
|
171
|
+
*/
|
|
172
|
+
scope?: 'user' | 'system';
|
|
140
173
|
}
|
|
141
174
|
/**
|
|
142
175
|
* A fired event. `summary` is injected into the action prompt as `{event}`;
|
|
@@ -154,13 +187,48 @@ export interface MonitorEvent {
|
|
|
154
187
|
* in seconds). Returns null on empty/unparseable/zero input.
|
|
155
188
|
*/
|
|
156
189
|
export declare function parseInterval(interval: string): number | null;
|
|
190
|
+
/**
|
|
191
|
+
* True when an UNPINNED monitor must be placed on a single owner rather than
|
|
192
|
+
* fired by every daemon — the SING-9 guard against a shared-queue double-fire.
|
|
193
|
+
*
|
|
194
|
+
* A `device` / `devices` pin is an explicit executor choice, so it is never
|
|
195
|
+
* owner-overridden (returns false here). For an unpinned monitor the default is
|
|
196
|
+
* layer-specific, SAFE side first: a **system built-in** is treated as
|
|
197
|
+
* shared-input unless it opts out with `sharedInput: false`; a **user monitor**
|
|
198
|
+
* keeps its fleet-wide default and only opts IN with `sharedInput: true`.
|
|
199
|
+
*/
|
|
200
|
+
export declare function requiresSingleOwner(config: Pick<MonitorConfig, 'device' | 'devices' | 'scope' | 'sharedInput'>): boolean;
|
|
201
|
+
/**
|
|
202
|
+
* The single fleet box that owns unpinned shared-input monitors — PURE, so the
|
|
203
|
+
* placement rule is unit-testable without a live tailnet. Priority:
|
|
204
|
+
*
|
|
205
|
+
* 1. the configured `interactive.host` (the box the operator sits at);
|
|
206
|
+
* 2. else, on a fleet with no OTHER registered device, this box — a single-box
|
|
207
|
+
* install has no peer to race, so the built-in still fires here;
|
|
208
|
+
* 3. else `undefined` — a multi-box fleet with no interactive host has no safe
|
|
209
|
+
* single owner, so an unpinned shared-input monitor fires NOWHERE (fail safe:
|
|
210
|
+
* a silent no-op beats a fleet-wide double-fire) until one is pinned.
|
|
211
|
+
*
|
|
212
|
+
* `deviceNames` is the registered fleet (registry keys); `self` is `machineId()`.
|
|
213
|
+
*/
|
|
214
|
+
export declare function resolveSharedInputOwner(interactiveHost: string | undefined, deviceNames: string[], self: string): string | undefined;
|
|
215
|
+
/** Resolve {@link resolveSharedInputOwner} from live config + the device registry. */
|
|
216
|
+
export declare function monitorSharedInputOwner(): string | undefined;
|
|
157
217
|
/**
|
|
158
218
|
* True when the monitor may evaluate + fire on this machine. Owner semantics:
|
|
159
219
|
* `device` (single owner, exactly-once) → only that machine; else `devices`
|
|
160
|
-
* (allowlist) → any listed machine; else
|
|
161
|
-
*
|
|
220
|
+
* (allowlist) → any listed machine; else placement depends on shared-input: an
|
|
221
|
+
* unpinned SHARED-INPUT monitor (a system built-in by default, or a user monitor
|
|
222
|
+
* that set `sharedInput: true`) fires only on the resolved owner
|
|
223
|
+
* ({@link monitorSharedInputOwner}), so a built-in that polls a fleet-shared
|
|
224
|
+
* queue can never fan out across every daemon (SING-9); anything else is
|
|
225
|
+
* unrestricted. Both sides normalize so `Yosemite-S0` and
|
|
226
|
+
* `yosemite-s0.tailnet.ts.net` agree with `yosemite-s0`.
|
|
227
|
+
*
|
|
228
|
+
* `ownerHost` overrides the resolved owner for tests/callers that already know
|
|
229
|
+
* it; omit it in production to resolve from config + the device registry.
|
|
162
230
|
*/
|
|
163
|
-
export declare function monitorRunsOnThisDevice(config: Pick<MonitorConfig, 'device' | 'devices'
|
|
231
|
+
export declare function monitorRunsOnThisDevice(config: Pick<MonitorConfig, 'device' | 'devices' | 'scope' | 'sharedInput'>, ownerHost?: string): boolean;
|
|
164
232
|
/**
|
|
165
233
|
* Validate a partial monitor config, returning a list of human-readable errors.
|
|
166
234
|
* Hand-rolled like validateJob (lib/routines.ts) — no zod. Rejects: no source,
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
import * as fs from 'fs';
|
|
13
13
|
import * as path from 'path';
|
|
14
14
|
import * as yaml from 'yaml';
|
|
15
|
-
import { getMonitorsDir, getSystemMonitorsDir, ensureAgentsDir } from '../state.js';
|
|
15
|
+
import { getMonitorsDir, getSystemMonitorsDir, ensureAgentsDir, readMeta } from '../state.js';
|
|
16
16
|
import { safeJoin, isSafeSegmentName } from '../paths.js';
|
|
17
17
|
import { atomicWriteFileSync } from '../fs-atomic.js';
|
|
18
18
|
import { machineId, normalizeHost } from '../machine-id.js';
|
|
19
|
+
import { loadDevicesSync } from '../devices/registry.js';
|
|
19
20
|
import { ALL_AGENT_IDS } from '../agents.js';
|
|
20
21
|
import { isCustomHarnessName } from '../profiles.js';
|
|
21
22
|
/** Default values applied to every monitor config when fields are omitted. */
|
|
@@ -50,19 +51,84 @@ export function parseInterval(interval) {
|
|
|
50
51
|
const ms = ((((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
|
|
51
52
|
return ms > 0 ? ms : null;
|
|
52
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* True when an UNPINNED monitor must be placed on a single owner rather than
|
|
56
|
+
* fired by every daemon — the SING-9 guard against a shared-queue double-fire.
|
|
57
|
+
*
|
|
58
|
+
* A `device` / `devices` pin is an explicit executor choice, so it is never
|
|
59
|
+
* owner-overridden (returns false here). For an unpinned monitor the default is
|
|
60
|
+
* layer-specific, SAFE side first: a **system built-in** is treated as
|
|
61
|
+
* shared-input unless it opts out with `sharedInput: false`; a **user monitor**
|
|
62
|
+
* keeps its fleet-wide default and only opts IN with `sharedInput: true`.
|
|
63
|
+
*/
|
|
64
|
+
export function requiresSingleOwner(config) {
|
|
65
|
+
if (config.device || (config.devices && config.devices.length > 0))
|
|
66
|
+
return false;
|
|
67
|
+
if (config.scope === 'system')
|
|
68
|
+
return config.sharedInput !== false;
|
|
69
|
+
return config.sharedInput === true;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The single fleet box that owns unpinned shared-input monitors — PURE, so the
|
|
73
|
+
* placement rule is unit-testable without a live tailnet. Priority:
|
|
74
|
+
*
|
|
75
|
+
* 1. the configured `interactive.host` (the box the operator sits at);
|
|
76
|
+
* 2. else, on a fleet with no OTHER registered device, this box — a single-box
|
|
77
|
+
* install has no peer to race, so the built-in still fires here;
|
|
78
|
+
* 3. else `undefined` — a multi-box fleet with no interactive host has no safe
|
|
79
|
+
* single owner, so an unpinned shared-input monitor fires NOWHERE (fail safe:
|
|
80
|
+
* a silent no-op beats a fleet-wide double-fire) until one is pinned.
|
|
81
|
+
*
|
|
82
|
+
* `deviceNames` is the registered fleet (registry keys); `self` is `machineId()`.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveSharedInputOwner(interactiveHost, deviceNames, self) {
|
|
85
|
+
if (typeof interactiveHost === 'string' && interactiveHost.trim()) {
|
|
86
|
+
return normalizeHost(interactiveHost);
|
|
87
|
+
}
|
|
88
|
+
const others = deviceNames.map((d) => normalizeHost(d)).filter((d) => d && d !== self);
|
|
89
|
+
if (others.length === 0)
|
|
90
|
+
return self; // single-box fleet: no peer, no race
|
|
91
|
+
return undefined; // multi-box, no interactive host pinned → no safe owner
|
|
92
|
+
}
|
|
93
|
+
/** Resolve {@link resolveSharedInputOwner} from live config + the device registry. */
|
|
94
|
+
export function monitorSharedInputOwner() {
|
|
95
|
+
const self = machineId();
|
|
96
|
+
const interactiveHost = readMeta().config?.interactiveHost;
|
|
97
|
+
let deviceNames = [];
|
|
98
|
+
try {
|
|
99
|
+
deviceNames = Object.keys(loadDevicesSync());
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
deviceNames = [];
|
|
103
|
+
}
|
|
104
|
+
return resolveSharedInputOwner(typeof interactiveHost === 'string' ? interactiveHost : undefined, deviceNames, self);
|
|
105
|
+
}
|
|
53
106
|
/**
|
|
54
107
|
* True when the monitor may evaluate + fire on this machine. Owner semantics:
|
|
55
108
|
* `device` (single owner, exactly-once) → only that machine; else `devices`
|
|
56
|
-
* (allowlist) → any listed machine; else
|
|
57
|
-
*
|
|
109
|
+
* (allowlist) → any listed machine; else placement depends on shared-input: an
|
|
110
|
+
* unpinned SHARED-INPUT monitor (a system built-in by default, or a user monitor
|
|
111
|
+
* that set `sharedInput: true`) fires only on the resolved owner
|
|
112
|
+
* ({@link monitorSharedInputOwner}), so a built-in that polls a fleet-shared
|
|
113
|
+
* queue can never fan out across every daemon (SING-9); anything else is
|
|
114
|
+
* unrestricted. Both sides normalize so `Yosemite-S0` and
|
|
115
|
+
* `yosemite-s0.tailnet.ts.net` agree with `yosemite-s0`.
|
|
116
|
+
*
|
|
117
|
+
* `ownerHost` overrides the resolved owner for tests/callers that already know
|
|
118
|
+
* it; omit it in production to resolve from config + the device registry.
|
|
58
119
|
*/
|
|
59
|
-
export function monitorRunsOnThisDevice(config) {
|
|
120
|
+
export function monitorRunsOnThisDevice(config, ownerHost) {
|
|
60
121
|
const self = machineId();
|
|
61
122
|
if (config.device)
|
|
62
123
|
return normalizeHost(config.device) === self;
|
|
63
124
|
if (config.devices && config.devices.length > 0) {
|
|
64
125
|
return config.devices.some((d) => normalizeHost(d) === self);
|
|
65
126
|
}
|
|
127
|
+
if (requiresSingleOwner(config)) {
|
|
128
|
+
const resolved = ownerHost !== undefined ? ownerHost : monitorSharedInputOwner();
|
|
129
|
+
const owner = resolved && resolved.trim() ? normalizeHost(resolved) : undefined;
|
|
130
|
+
return owner !== undefined && owner === self;
|
|
131
|
+
}
|
|
66
132
|
return true;
|
|
67
133
|
}
|
|
68
134
|
/** Count the populated source-payload fields to detect "two sources". */
|
|
@@ -267,6 +333,9 @@ export function validateMonitor(config) {
|
|
|
267
333
|
}
|
|
268
334
|
}
|
|
269
335
|
}
|
|
336
|
+
if (config.sharedInput !== undefined && typeof config.sharedInput !== 'boolean') {
|
|
337
|
+
errors.push('sharedInput must be a boolean (true = source polls a fleet-shared queue)');
|
|
338
|
+
}
|
|
270
339
|
if (config.runOn !== undefined && (typeof config.runOn !== 'string' || config.runOn.trim() === '')) {
|
|
271
340
|
errors.push('runOn must be a non-empty machine name (a registered host, device, capability tag, or user@host)');
|
|
272
341
|
}
|
|
@@ -286,11 +355,24 @@ export function validateMonitor(config) {
|
|
|
286
355
|
return errors;
|
|
287
356
|
}
|
|
288
357
|
/**
|
|
289
|
-
* Read and normalize a monitor file.
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
358
|
+
* Read and normalize a monitor file. A built-in defaults to enabled exactly like
|
|
359
|
+
* every other system-layer resource (rules, hooks, commands, skills): a monitor
|
|
360
|
+
* shipped in the system mirror is on for every install unless the user shadows it
|
|
361
|
+
* with an explicit `enabled: false` (via `agents monitors pause`, which writes a
|
|
362
|
+
* user copy — the system mirror is pull-only). There is deliberately no
|
|
363
|
+
* system-scope special-case: monitors used to be the lone outlier that shipped
|
|
364
|
+
* disabled+invisible (PHNX-2506). `scope` no longer changes the enabled default;
|
|
365
|
+
* it is retained on the config so `list`/`view` can tag a built-in.
|
|
366
|
+
*
|
|
367
|
+
* Enabled-by-default is NOT, on its own, permission to fire on every daemon. A
|
|
368
|
+
* shared-input built-in (one whose source polls a fleet-shared queue such as `gh
|
|
369
|
+
* pr list --author @me`) is placed on a single owner by `monitorRunsOnThisDevice`
|
|
370
|
+
* / `requiresSingleOwner` even when the shipped YAML carries no `device:` pin: a
|
|
371
|
+
* system built-in is treated as shared-input unless it sets `sharedInput: false`,
|
|
372
|
+
* so it can never fan out across the fleet and double-fire on a shared queue
|
|
373
|
+
* (SING-9). A device-local built-in opts back into fleet-wide firing with
|
|
374
|
+
* `sharedInput: false`; a genuinely-shared one should still ship a `device:` pin
|
|
375
|
+
* (or `sharedInput: true`) to document the intent.
|
|
294
376
|
*/
|
|
295
377
|
function readMonitorFile(filePath, scope = 'user') {
|
|
296
378
|
try {
|
|
@@ -303,9 +385,12 @@ function readMonitorFile(filePath, scope = 'user') {
|
|
|
303
385
|
...MONITOR_DEFAULTS,
|
|
304
386
|
...parsed,
|
|
305
387
|
name: parsed.name || path.basename(filePath).replace(/\.ya?ml$/, ''),
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
388
|
+
// Enabled unless the user explicitly disables it — same default for user
|
|
389
|
+
// and system layers, so a built-in is visible and on like any other
|
|
390
|
+
// system resource. `scope` is stamped below for the (built-in) tag, not
|
|
391
|
+
// used to gate enablement.
|
|
392
|
+
enabled: hasEnabled ? parsed.enabled !== false : (MONITOR_DEFAULTS.enabled ?? true),
|
|
393
|
+
scope,
|
|
309
394
|
};
|
|
310
395
|
}
|
|
311
396
|
catch {
|
|
@@ -387,6 +472,9 @@ export function writeMonitor(config) {
|
|
|
387
472
|
const output = { ...config };
|
|
388
473
|
if (output.enabled === true)
|
|
389
474
|
delete output.enabled;
|
|
475
|
+
// `scope` is a derived read-time annotation, never part of the on-disk schema —
|
|
476
|
+
// strip it so a materialized user copy of a built-in doesn't persist `scope: system`.
|
|
477
|
+
delete output.scope;
|
|
390
478
|
const devArr = output.devices;
|
|
391
479
|
if (!devArr || devArr.length === 0)
|
|
392
480
|
delete output.devices;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--watch-pid` support — turns a backgrounded OS process into a durable,
|
|
3
|
+
* daemon-polled watcher instead of relying on a harness's own exit hook
|
|
4
|
+
* (PHNX-3023: a "will re-invoke me" background shell never fires when the
|
|
5
|
+
* harness only notifies on process exit and the watch loop itself never
|
|
6
|
+
* exits — `gh pr checks --watch`, a long sleep, a tick poll).
|
|
7
|
+
*
|
|
8
|
+
* The command built here reuses the same existence-check predicate
|
|
9
|
+
* `isPidAlive`'s existence branch uses (`process.kill(pid, 0)`), but it runs
|
|
10
|
+
* inside the monitor engine's own poll loop (`sources/command.ts`) rather than
|
|
11
|
+
* the caller's process — so the check survives past the CLI invocation that
|
|
12
|
+
* armed it.
|
|
13
|
+
*/
|
|
14
|
+
/** The token a --watch-pid source's condition matches on process exit. */
|
|
15
|
+
export declare const PID_WATCH_EXITED_TOKEN = "exited";
|
|
16
|
+
/** Emitted while the pid is alive. */
|
|
17
|
+
export declare const PID_WATCH_RUNNING_TOKEN = "running";
|
|
18
|
+
/**
|
|
19
|
+
* Emitted when the pid is not alive AND has never been observed alive — the
|
|
20
|
+
* `--force` not-yet-spawned case. Deliberately distinct from
|
|
21
|
+
* {@link PID_WATCH_EXITED_TOKEN} so it can never match the exit condition.
|
|
22
|
+
*/
|
|
23
|
+
export declare const PID_WATCH_NOT_YET_SPAWNED_TOKEN = "notyetspawned";
|
|
24
|
+
/**
|
|
25
|
+
* The shell command a --watch-pid source polls. A poll only reports "exited"
|
|
26
|
+
* once it has FIRST observed the pid running — tracked with a marker file the
|
|
27
|
+
* command touches on every "running" poll — otherwise a `--force`-armed watch
|
|
28
|
+
* on a not-yet-spawned pid would report "exited" on its very first poll (the
|
|
29
|
+
* pid doesn't exist *yet*, not *anymore*), which the engine's match-mode
|
|
30
|
+
* fires immediately (no prior state to diff against) and then persists as the
|
|
31
|
+
* baseline — silencing the real exit forever once the process actually spawns
|
|
32
|
+
* and later dies. Portable across the shells `sources/command.ts` invokes
|
|
33
|
+
* (`/bin/sh -c` posix, `cmd /c` Windows).
|
|
34
|
+
*/
|
|
35
|
+
export declare function pidLivenessCommand(pid: number, seenRunningMarkerPath: string): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--watch-pid` support — turns a backgrounded OS process into a durable,
|
|
3
|
+
* daemon-polled watcher instead of relying on a harness's own exit hook
|
|
4
|
+
* (PHNX-3023: a "will re-invoke me" background shell never fires when the
|
|
5
|
+
* harness only notifies on process exit and the watch loop itself never
|
|
6
|
+
* exits — `gh pr checks --watch`, a long sleep, a tick poll).
|
|
7
|
+
*
|
|
8
|
+
* The command built here reuses the same existence-check predicate
|
|
9
|
+
* `isPidAlive`'s existence branch uses (`process.kill(pid, 0)`), but it runs
|
|
10
|
+
* inside the monitor engine's own poll loop (`sources/command.ts`) rather than
|
|
11
|
+
* the caller's process — so the check survives past the CLI invocation that
|
|
12
|
+
* armed it.
|
|
13
|
+
*/
|
|
14
|
+
import { IS_WINDOWS } from '../platform/index.js';
|
|
15
|
+
/** The token a --watch-pid source's condition matches on process exit. */
|
|
16
|
+
export const PID_WATCH_EXITED_TOKEN = 'exited';
|
|
17
|
+
/** Emitted while the pid is alive. */
|
|
18
|
+
export const PID_WATCH_RUNNING_TOKEN = 'running';
|
|
19
|
+
/**
|
|
20
|
+
* Emitted when the pid is not alive AND has never been observed alive — the
|
|
21
|
+
* `--force` not-yet-spawned case. Deliberately distinct from
|
|
22
|
+
* {@link PID_WATCH_EXITED_TOKEN} so it can never match the exit condition.
|
|
23
|
+
*/
|
|
24
|
+
export const PID_WATCH_NOT_YET_SPAWNED_TOKEN = 'notyetspawned';
|
|
25
|
+
/**
|
|
26
|
+
* The shell command a --watch-pid source polls. A poll only reports "exited"
|
|
27
|
+
* once it has FIRST observed the pid running — tracked with a marker file the
|
|
28
|
+
* command touches on every "running" poll — otherwise a `--force`-armed watch
|
|
29
|
+
* on a not-yet-spawned pid would report "exited" on its very first poll (the
|
|
30
|
+
* pid doesn't exist *yet*, not *anymore*), which the engine's match-mode
|
|
31
|
+
* fires immediately (no prior state to diff against) and then persists as the
|
|
32
|
+
* baseline — silencing the real exit forever once the process actually spawns
|
|
33
|
+
* and later dies. Portable across the shells `sources/command.ts` invokes
|
|
34
|
+
* (`/bin/sh -c` posix, `cmd /c` Windows).
|
|
35
|
+
*/
|
|
36
|
+
export function pidLivenessCommand(pid, seenRunningMarkerPath) {
|
|
37
|
+
if (IS_WINDOWS) {
|
|
38
|
+
return (`tasklist /FI "PID eq ${pid}" 2>NUL | findstr /I "${pid}" >NUL ` +
|
|
39
|
+
`&& (type nul > "${seenRunningMarkerPath}" & echo ${PID_WATCH_RUNNING_TOKEN}) ` +
|
|
40
|
+
`|| (if exist "${seenRunningMarkerPath}" (echo ${PID_WATCH_EXITED_TOKEN}) else (echo ${PID_WATCH_NOT_YET_SPAWNED_TOKEN}))`);
|
|
41
|
+
}
|
|
42
|
+
return (`kill -0 ${pid} 2>/dev/null ` +
|
|
43
|
+
`&& { mkdir -p "$(dirname "${seenRunningMarkerPath}")" 2>/dev/null; : > "${seenRunningMarkerPath}"; echo ${PID_WATCH_RUNNING_TOKEN}; } ` +
|
|
44
|
+
`|| { [ -e "${seenRunningMarkerPath}" ] && echo ${PID_WATCH_EXITED_TOKEN} || echo ${PID_WATCH_NOT_YET_SPAWNED_TOKEN}; }`);
|
|
45
|
+
}
|
|
@@ -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/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
|
}
|
|
@@ -79,7 +79,14 @@ export declare function skillContentMatches(agentId: AgentId, skillName: string,
|
|
|
79
79
|
export declare function listCentralSkills(): string[];
|
|
80
80
|
/**
|
|
81
81
|
* Resolve a skill name to its source directory. Searches user dir first,
|
|
82
|
-
* then system dir, then extra repos
|
|
82
|
+
* then system dir, then extra repos, then plugin-bundled skills
|
|
83
|
+
* (`plugins/<plugin>/skills/<name>` under any trusted base). Returns null if no
|
|
84
|
+
* source has a SKILL.md.
|
|
85
|
+
*
|
|
86
|
+
* Crediting plugins here is load-bearing (PHNX-3185): a skill materialized from
|
|
87
|
+
* `plugins/design/skills/design` has a real source, so it must resolve — else
|
|
88
|
+
* `versionSkillMatches` finds no source and the skill reads as drifted, and
|
|
89
|
+
* {@link diffVersionSkills} classes it an orphan `prune cleanup` would delete.
|
|
83
90
|
*/
|
|
84
91
|
export declare function resolveSkillSourcePath(skillName: string): string | null;
|
|
85
92
|
/**
|