@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.
Files changed (152) hide show
  1. package/CHANGELOG.md +294 -0
  2. package/README.md +29 -0
  3. package/dist/bootstrap.js +39 -1
  4. package/dist/commands/accounts.js +7 -3
  5. package/dist/commands/apply.js +10 -2
  6. package/dist/commands/fork.d.ts +23 -10
  7. package/dist/commands/fork.js +115 -58
  8. package/dist/commands/monitors.js +198 -23
  9. package/dist/commands/prune.js +5 -3
  10. package/dist/commands/routines.d.ts +8 -0
  11. package/dist/commands/routines.js +57 -3
  12. package/dist/commands/routines.test-fixture.js +5 -0
  13. package/dist/commands/send.d.ts +2 -1
  14. package/dist/commands/send.js +7 -5
  15. package/dist/commands/sessions-picker.d.ts +11 -0
  16. package/dist/commands/sessions-picker.js +16 -0
  17. package/dist/commands/sessions-stats.js +37 -5
  18. package/dist/commands/sessions.js +40 -5
  19. package/dist/commands/share.d.ts +14 -0
  20. package/dist/commands/share.js +43 -2
  21. package/dist/commands/ssh.js +12 -1
  22. package/dist/commands/status.js +1 -1
  23. package/dist/commands/sync.js +83 -7
  24. package/dist/commands/traces.js +7 -0
  25. package/dist/commands/versions.js +12 -4
  26. package/dist/commands/view.js +7 -2
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +6 -1
  29. package/dist/lib/account-registry.d.ts +5 -1
  30. package/dist/lib/account-registry.js +47 -14
  31. package/dist/lib/accounting/capacity.d.ts +18 -7
  32. package/dist/lib/accounting/capacity.js +19 -8
  33. package/dist/lib/accounting/usage-sync.d.ts +29 -1
  34. package/dist/lib/accounting/usage-sync.js +76 -2
  35. package/dist/lib/accounting/usage.js +7 -1
  36. package/dist/lib/auth-mint.d.ts +11 -1
  37. package/dist/lib/auth-mint.js +21 -6
  38. package/dist/lib/auto-pull-worker.js +7 -2
  39. package/dist/lib/browser/ipc.d.ts +8 -0
  40. package/dist/lib/browser/ipc.js +87 -0
  41. package/dist/lib/browser/service.d.ts +19 -0
  42. package/dist/lib/browser/service.js +96 -11
  43. package/dist/lib/browser/sessions-list.js +10 -1
  44. package/dist/lib/cloud/rush.d.ts +7 -0
  45. package/dist/lib/cloud/rush.js +29 -1
  46. package/dist/lib/daemon/daemon.d.ts +22 -0
  47. package/dist/lib/daemon/daemon.js +39 -0
  48. package/dist/lib/daemon/runner.d.ts +3 -0
  49. package/dist/lib/daemon/runner.js +86 -45
  50. package/dist/lib/daemon/session-index-service.js +9 -1
  51. package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +14 -8
  53. package/dist/lib/daemon-services.js +1 -1
  54. package/dist/lib/daemon-ticks.d.ts +15 -0
  55. package/dist/lib/daemon-ticks.js +26 -0
  56. package/dist/lib/device-config.d.ts +5 -1
  57. package/dist/lib/device-config.js +2 -2
  58. package/dist/lib/devices/connect.d.ts +17 -8
  59. package/dist/lib/devices/connect.js +31 -14
  60. package/dist/lib/devices/health.js +5 -1
  61. package/dist/lib/devices/pool.d.ts +25 -2
  62. package/dist/lib/devices/pool.js +32 -2
  63. package/dist/lib/devices/stats-cache.d.ts +0 -6
  64. package/dist/lib/devices/stats-cache.js +2 -9
  65. package/dist/lib/doctor-diff.d.ts +14 -0
  66. package/dist/lib/doctor-diff.js +120 -9
  67. package/dist/lib/fleet/manifest.d.ts +17 -0
  68. package/dist/lib/fleet/manifest.js +26 -0
  69. package/dist/lib/git.d.ts +38 -0
  70. package/dist/lib/git.js +58 -0
  71. package/dist/lib/hooks/install.d.ts +27 -11
  72. package/dist/lib/hooks/install.js +42 -17
  73. package/dist/lib/hosts/ready.d.ts +8 -0
  74. package/dist/lib/hosts/ready.js +13 -2
  75. package/dist/lib/hosts/reconnect.d.ts +52 -203
  76. package/dist/lib/hosts/reconnect.js +64 -284
  77. package/dist/lib/installations/migrate.d.ts +6 -120
  78. package/dist/lib/installations/migrate.js +27 -259
  79. package/dist/lib/installations/shims.d.ts +13 -95
  80. package/dist/lib/installations/shims.js +22 -139
  81. package/dist/lib/installations/store.js +1 -1
  82. package/dist/lib/installations/versions.d.ts +43 -133
  83. package/dist/lib/installations/versions.js +94 -206
  84. package/dist/lib/monitors/config.d.ts +71 -3
  85. package/dist/lib/monitors/config.js +100 -12
  86. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  87. package/dist/lib/monitors/pid-watch.js +45 -0
  88. package/dist/lib/monitors/remote.d.ts +18 -0
  89. package/dist/lib/monitors/remote.js +11 -0
  90. package/dist/lib/permissions.js +7 -2
  91. package/dist/lib/plugins/plugins.d.ts +17 -3
  92. package/dist/lib/plugins/plugins.js +84 -9
  93. package/dist/lib/plugins/skills.d.ts +8 -1
  94. package/dist/lib/plugins/skills.js +18 -2
  95. package/dist/lib/pty-server.d.ts +14 -0
  96. package/dist/lib/pty-server.js +49 -5
  97. package/dist/lib/refresh.d.ts +9 -0
  98. package/dist/lib/refresh.js +3 -1
  99. package/dist/lib/routine-readiness.d.ts +15 -1
  100. package/dist/lib/routine-readiness.js +41 -0
  101. package/dist/lib/sandbox.d.ts +4 -1
  102. package/dist/lib/sandbox.js +30 -1
  103. package/dist/lib/secrets/agent.d.ts +80 -225
  104. package/dist/lib/secrets/agent.js +139 -401
  105. package/dist/lib/secrets/bundles.d.ts +73 -222
  106. package/dist/lib/secrets/bundles.js +168 -467
  107. package/dist/lib/secrets/drivers/rush.js +5 -0
  108. package/dist/lib/secrets/reaper.d.ts +28 -70
  109. package/dist/lib/secrets/reaper.js +30 -85
  110. package/dist/lib/secrets/remote.d.ts +42 -129
  111. package/dist/lib/secrets/remote.js +55 -173
  112. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  113. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  114. package/dist/lib/self-heal/registry.js +2 -0
  115. package/dist/lib/self-heal/types.d.ts +1 -1
  116. package/dist/lib/self-update.d.ts +65 -0
  117. package/dist/lib/self-update.js +138 -0
  118. package/dist/lib/session/active.d.ts +13 -1
  119. package/dist/lib/session/active.js +2 -0
  120. package/dist/lib/session/cloud.js +5 -0
  121. package/dist/lib/session/db.d.ts +51 -6
  122. package/dist/lib/session/db.js +266 -20
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/tool-calls.d.ts +43 -1
  126. package/dist/lib/session/tool-calls.js +74 -44
  127. package/dist/lib/session/tool-store.d.ts +33 -2
  128. package/dist/lib/session/tool-store.js +56 -3
  129. package/dist/lib/smart-launch.d.ts +6 -0
  130. package/dist/lib/smart-launch.js +5 -2
  131. package/dist/lib/staleness/writers/plugins.js +5 -2
  132. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  133. package/dist/lib/staleness/writers/sources.js +2 -1
  134. package/dist/lib/staleness/writers/subagents.js +13 -3
  135. package/dist/lib/state.d.ts +7 -4
  136. package/dist/lib/state.js +7 -4
  137. package/dist/lib/subagents.js +8 -2
  138. package/dist/lib/sync-status.d.ts +22 -0
  139. package/dist/lib/sync-status.js +27 -0
  140. package/dist/lib/sync-umbrella.d.ts +9 -0
  141. package/dist/lib/sync-umbrella.js +21 -2
  142. package/dist/lib/teams/scheduler.d.ts +10 -0
  143. package/dist/lib/teams/scheduler.js +8 -0
  144. package/dist/lib/traces/insights.d.ts +47 -14
  145. package/dist/lib/traces/insights.js +92 -21
  146. package/dist/lib/traces/phenotype.d.ts +23 -3
  147. package/dist/lib/traces/phenotype.js +72 -24
  148. package/dist/lib/traces/sync.d.ts +128 -6
  149. package/dist/lib/traces/sync.js +294 -35
  150. package/dist/lib/traces/worker-template.js +154 -1
  151. package/dist/lib/view-types.d.ts +12 -0
  152. 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 unrestricted. Both sides normalize so
161
- * `Yosemite-S0` and `yosemite-s0.tailnet.ts.net` agree with `yosemite-s0`.
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'>): boolean;
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 unrestricted. Both sides normalize so
57
- * `Yosemite-S0` and `yosemite-s0.tailnet.ts.net` agree with `yosemite-s0`.
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. `scope` decides the enabled default when
290
- * the YAML has no explicit `enabled:` field: a `user` monitor defaults to
291
- * enabled (MONITOR_DEFAULTS), while a `system` built-in stays opt-in (disabled)
292
- * until the user enables it — mirroring how routines treat a fresh built-in
293
- * (lib/routines.ts readJobFile).
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
- // A system built-in with no explicit `enabled:` is opt-in until enabled;
307
- // a user monitor keeps the enabled-by-default behavior.
308
- enabled: hasEnabled ? parsed.enabled !== false : scope === 'system' ? false : (MONITOR_DEFAULTS.enabled ?? true),
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;
@@ -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
  }
@@ -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. Returns null if no source has a SKILL.md.
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
  /**