@phnx-labs/agents-cli 1.22.58 → 1.22.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/README.md +29 -0
  3. package/dist/bootstrap.js +32 -1
  4. package/dist/commands/monitors.js +187 -23
  5. package/dist/commands/routines.test-fixture.js +5 -0
  6. package/dist/commands/send.d.ts +2 -1
  7. package/dist/commands/send.js +7 -5
  8. package/dist/commands/sessions-stats.js +37 -5
  9. package/dist/commands/sessions.js +39 -5
  10. package/dist/commands/ssh.js +12 -1
  11. package/dist/commands/versions.js +12 -4
  12. package/dist/commands/view.js +7 -2
  13. package/dist/lib/auto-pull-worker.js +7 -2
  14. package/dist/lib/cloud/rush.d.ts +7 -0
  15. package/dist/lib/cloud/rush.js +29 -1
  16. package/dist/lib/daemon/daemon.d.ts +22 -0
  17. package/dist/lib/daemon/daemon.js +39 -0
  18. package/dist/lib/daemon/session-index-service.js +9 -1
  19. package/dist/lib/daemon-ticks.d.ts +15 -0
  20. package/dist/lib/daemon-ticks.js +26 -0
  21. package/dist/lib/device-config.d.ts +5 -1
  22. package/dist/lib/device-config.js +2 -2
  23. package/dist/lib/devices/health.js +5 -1
  24. package/dist/lib/devices/pool.d.ts +25 -2
  25. package/dist/lib/devices/pool.js +32 -2
  26. package/dist/lib/devices/stats-cache.d.ts +0 -6
  27. package/dist/lib/devices/stats-cache.js +2 -9
  28. package/dist/lib/doctor-diff.d.ts +14 -0
  29. package/dist/lib/doctor-diff.js +43 -2
  30. package/dist/lib/git.d.ts +38 -0
  31. package/dist/lib/git.js +58 -0
  32. package/dist/lib/hosts/ready.d.ts +8 -0
  33. package/dist/lib/hosts/ready.js +13 -2
  34. package/dist/lib/installations/versions.d.ts +17 -0
  35. package/dist/lib/installations/versions.js +53 -2
  36. package/dist/lib/monitors/config.d.ts +71 -3
  37. package/dist/lib/monitors/config.js +100 -12
  38. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  39. package/dist/lib/monitors/pid-watch.js +45 -0
  40. package/dist/lib/monitors/remote.d.ts +18 -0
  41. package/dist/lib/monitors/remote.js +11 -0
  42. package/dist/lib/permissions.js +7 -2
  43. package/dist/lib/plugins/plugins.d.ts +17 -3
  44. package/dist/lib/plugins/plugins.js +84 -9
  45. package/dist/lib/pty-server.d.ts +14 -0
  46. package/dist/lib/pty-server.js +49 -5
  47. package/dist/lib/secrets/drivers/rush.js +5 -0
  48. package/dist/lib/self-update.d.ts +42 -0
  49. package/dist/lib/self-update.js +88 -0
  50. package/dist/lib/session/cloud.js +5 -0
  51. package/dist/lib/session/db.d.ts +32 -6
  52. package/dist/lib/session/db.js +128 -12
  53. package/dist/lib/smart-launch.d.ts +6 -0
  54. package/dist/lib/smart-launch.js +5 -2
  55. package/dist/lib/staleness/writers/plugins.js +5 -2
  56. package/dist/lib/staleness/writers/subagents.js +13 -3
  57. package/dist/lib/state.d.ts +7 -4
  58. package/dist/lib/state.js +7 -4
  59. package/dist/lib/subagents.js +8 -2
  60. package/dist/lib/teams/scheduler.d.ts +10 -0
  61. package/dist/lib/teams/scheduler.js +8 -0
  62. package/dist/lib/traces/sync.d.ts +113 -6
  63. package/dist/lib/traces/sync.js +193 -19
  64. package/dist/lib/view-types.d.ts +12 -0
  65. package/package.json +2 -2
package/dist/lib/git.js CHANGED
@@ -537,6 +537,33 @@ export function isSystemRepoRemote(remote) {
537
537
  const c = canonicalGitRemote(remote);
538
538
  return c === canonicalGitRemote(`https://github.com/${systemRepoSlug(DEFAULT_SYSTEM_REPO)}`);
539
539
  }
540
+ /**
541
+ * True when `remote` is the origin the system repo is EXPECTED to track on this
542
+ * machine, honouring an operator's `AGENTS_SYSTEM_REPO` override.
543
+ *
544
+ * The system repo ships hooks that register as shell `command` strings run on
545
+ * every tool event, and its checkout auto-fast-forwards from origin — so a
546
+ * fast-forward from an origin the operator never chose is remote code execution
547
+ * on the next command that loads a system resource (PHNX-2957). This is the
548
+ * pinning predicate every auto-pull of the system repo gates on: pull only when
549
+ * origin is the canonical {@link isSystemRepoRemote} repo, or the exact
550
+ * `AGENTS_SYSTEM_REPO` the operator pointed at instead. Anything else — a
551
+ * repointed origin, a fork, an unset-then-swapped remote — is refused, not
552
+ * pulled. Pure string check; no git spawn.
553
+ */
554
+ export function isExpectedSystemRepoRemote(remote) {
555
+ if (!remote)
556
+ return false;
557
+ const override = process.env.AGENTS_SYSTEM_REPO?.trim();
558
+ if (override) {
559
+ // The override is a source spec (`gh:owner/repo`) or a full clone URL. Match
560
+ // the GitHub-slug form the setup path clones, and the raw spec itself, so a
561
+ // non-GitHub override URL still verifies.
562
+ return (sameGitRemote(remote, `https://github.com/${systemRepoSlug(override)}`) ||
563
+ sameGitRemote(remote, override.replace(/^gh:/, '')));
564
+ }
565
+ return isSystemRepoRemote(remote);
566
+ }
540
567
  /** True when two git remote URLs point at the same repo across transport forms. */
541
568
  export function sameGitRemote(a, b) {
542
569
  if (!a || !b)
@@ -1665,6 +1692,37 @@ export async function tryAutoPull(dir) {
1665
1692
  return { pulled: false, error: err.message };
1666
1693
  }
1667
1694
  }
1695
+ /**
1696
+ * Auto-pull the system repo ONLY after verifying its origin is the expected
1697
+ * system remote (PHNX-2957). The system repo ships hooks that run as shell on
1698
+ * tool events, so fast-forwarding it from an unexpected/repointed origin is
1699
+ * remote code execution. An origin that fails {@link isExpectedSystemRepoRemote}
1700
+ * is REFUSED loud (`refused: true`), never pulled — the canonical system repo
1701
+ * (or an operator's `AGENTS_SYSTEM_REPO`) still fast-forwards exactly as before.
1702
+ *
1703
+ * A system dir with no origin at all is a plain no-op (`pulled: false`), not a
1704
+ * refusal — there is nothing to pull from and nothing to distrust.
1705
+ */
1706
+ export async function tryAutoPullSystemRepo(dir) {
1707
+ if (!isGitRepo(dir))
1708
+ return { pulled: false };
1709
+ let remote;
1710
+ try {
1711
+ const git = simpleGit(dir);
1712
+ const remotes = await git.getRemotes(true);
1713
+ remote = remotes.find(r => r.name === 'origin')?.refs?.fetch;
1714
+ }
1715
+ catch {
1716
+ return { pulled: false };
1717
+ }
1718
+ if (!remote)
1719
+ return { pulled: false };
1720
+ if (!isExpectedSystemRepoRemote(remote)) {
1721
+ return { pulled: false, refused: true, actualRemote: remote };
1722
+ }
1723
+ const res = await tryAutoPull(dir);
1724
+ return { ...res, actualRemote: remote };
1725
+ }
1668
1726
  /**
1669
1727
  * How many commits `dir`'s checked-out branch is behind its upstream, read from
1670
1728
  * the LAST-FETCHED remote-tracking ref — no network call. Returns null when the
@@ -100,6 +100,14 @@ export interface ViewAgentAccountEligibility {
100
100
  * --json`. A picker may route to a signed-out version because launching it is
101
101
  * the login flow, but it must not route to a device whose every signed-in
102
102
  * account is throttled.
103
+ *
104
+ * The sign-in gate reads the per-version `launchable` field — the strict
105
+ * per-version launch truth (`isLaunchableSignedIn`) — so a remote box is judged
106
+ * by the SAME launchability the local candidate uses (`collectRunCandidates` →
107
+ * `isLaunchableSignedIn`), not the display `signedIn` that inherits the
108
+ * active/global HOME login and passes a box that dies at spawn (PHNX-3466). An
109
+ * older remote CLI omits `launchable`, so it falls back to `signedIn` — the
110
+ * pre-fix behavior, so a rolling fleet does not regress.
103
111
  */
104
112
  export declare function viewAgentAccountEligibility(view: string, agent: string): ViewAgentAccountEligibility;
105
113
  export declare function viewAgentSignedIn(view: string, agent: string): boolean | undefined;
@@ -213,6 +213,14 @@ export function missingPinnedVersionMessage(hostName, agent, version, installed)
213
213
  * --json`. A picker may route to a signed-out version because launching it is
214
214
  * the login flow, but it must not route to a device whose every signed-in
215
215
  * account is throttled.
216
+ *
217
+ * The sign-in gate reads the per-version `launchable` field — the strict
218
+ * per-version launch truth (`isLaunchableSignedIn`) — so a remote box is judged
219
+ * by the SAME launchability the local candidate uses (`collectRunCandidates` →
220
+ * `isLaunchableSignedIn`), not the display `signedIn` that inherits the
221
+ * active/global HOME login and passes a box that dies at spawn (PHNX-3466). An
222
+ * older remote CLI omits `launchable`, so it falls back to `signedIn` — the
223
+ * pre-fix behavior, so a rolling fleet does not regress.
216
224
  */
217
225
  export function viewAgentAccountEligibility(view, agent) {
218
226
  try {
@@ -223,12 +231,15 @@ export function viewAgentAccountEligibility(view, agent) {
223
231
  const verdicts = (row.versions ?? []).flatMap((version) => {
224
232
  if (typeof version.signedIn !== 'boolean')
225
233
  return [];
234
+ // Prefer the strict per-version launch signal; fall back to the display
235
+ // `signedIn` for an older remote CLI that does not emit `launchable`.
236
+ const launchable = typeof version.launchable === 'boolean' ? version.launchable : version.signedIn;
226
237
  const throttled = version.usageStatus === 'rate_limited' || version.usageStatus === 'out_of_credits';
227
238
  const authBlocked = version.authVerdict !== null
228
239
  && version.authVerdict !== undefined
229
240
  && isDeadVerdict(version.authVerdict);
230
- const ready = version.signedIn && !authBlocked && !throttled;
231
- return [{ ready, pickerEligible: ready || !version.signedIn || authBlocked }];
241
+ const ready = launchable && !authBlocked && !throttled;
242
+ return [{ ready, pickerEligible: ready || !launchable || authBlocked }];
232
243
  });
233
244
  if (verdicts.length === 0)
234
245
  return { signedIn: undefined, pickerEligible: undefined };
@@ -427,6 +427,23 @@ export declare function mergeRepoScopedSelections(repos: string[], cwd?: string)
427
427
  *
428
428
  * For Gemini: commands are converted from markdown to TOML.
429
429
  */
430
+ /**
431
+ * Resolve a caller's hook selection against the available hook set, matching a
432
+ * selection entry by exact name OR by its extensionless basename, and returning
433
+ * the AVAILABLE (extensioned) name in every case.
434
+ *
435
+ * `available.hooks` carries the source filename WITH its extension
436
+ * (`git-guard.sh`) — the shape the hooks writer's source resolver requires — but
437
+ * the doctor/heal resource diff identifies a hook by its extensionless basename
438
+ * (`git-guard`). A heal pass feeds those diff names straight back as the
439
+ * selection, so a plain exact-set filter matched NOTHING and `agents doctor
440
+ * --fix` could never reconcile a flagged hook (PHNX-3187). Basename tolerance
441
+ * closes that gap without changing the extensioned names the writer needs.
442
+ *
443
+ * Order-stable and de-duplicated; a selection entry that matches nothing is
444
+ * dropped (mirrors resolveSelection).
445
+ */
446
+ export declare function resolveHookSelection(sel: string[] | 'all' | undefined, available: string[]): string[];
430
447
  export declare function syncResourcesToVersion(agent: AgentId, version: string, selection?: ResourceSelection, options?: {
431
448
  projectDir?: string;
432
449
  cwd?: string;
@@ -2198,6 +2198,46 @@ export function mergeRepoScopedSelections(repos, cwd = process.cwd()) {
2198
2198
  *
2199
2199
  * For Gemini: commands are converted from markdown to TOML.
2200
2200
  */
2201
+ /**
2202
+ * Resolve a caller's hook selection against the available hook set, matching a
2203
+ * selection entry by exact name OR by its extensionless basename, and returning
2204
+ * the AVAILABLE (extensioned) name in every case.
2205
+ *
2206
+ * `available.hooks` carries the source filename WITH its extension
2207
+ * (`git-guard.sh`) — the shape the hooks writer's source resolver requires — but
2208
+ * the doctor/heal resource diff identifies a hook by its extensionless basename
2209
+ * (`git-guard`). A heal pass feeds those diff names straight back as the
2210
+ * selection, so a plain exact-set filter matched NOTHING and `agents doctor
2211
+ * --fix` could never reconcile a flagged hook (PHNX-3187). Basename tolerance
2212
+ * closes that gap without changing the extensioned names the writer needs.
2213
+ *
2214
+ * Order-stable and de-duplicated; a selection entry that matches nothing is
2215
+ * dropped (mirrors resolveSelection).
2216
+ */
2217
+ export function resolveHookSelection(sel, available) {
2218
+ if (sel === 'all')
2219
+ return available;
2220
+ if (!Array.isArray(sel))
2221
+ return [];
2222
+ const stripExt = (n) => n.replace(/\.[^./\\]+$/, '');
2223
+ const exact = new Set(available);
2224
+ const byBase = new Map();
2225
+ for (const a of available) {
2226
+ const base = stripExt(a);
2227
+ if (!byBase.has(base))
2228
+ byBase.set(base, a); // first available wins the basename
2229
+ }
2230
+ const out = [];
2231
+ const seen = new Set();
2232
+ for (const name of sel) {
2233
+ const resolved = exact.has(name) ? name : byBase.get(stripExt(name));
2234
+ if (resolved && !seen.has(resolved)) {
2235
+ seen.add(resolved);
2236
+ out.push(resolved);
2237
+ }
2238
+ }
2239
+ return out;
2240
+ }
2201
2241
  export function syncResourcesToVersion(agent, version, selection, options = {}) {
2202
2242
  if (isAgentHardDeprecated(agent)) {
2203
2243
  return { commands: false, skills: false, hooks: false, memory: [], permissions: false, mcp: [], subagents: [], plugins: [], workflows: [], projectSkipped: [], pruned: { commands: [], skills: [] }, declined: [] };
@@ -2494,8 +2534,14 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2494
2534
  console.warn(explainSkip(agent, 'hooks', hooksGate, version) + ' -- skipped');
2495
2535
  }
2496
2536
  else {
2537
+ // Resolve requested hooks against the available set BY BASENAME as well as
2538
+ // exact name. `available.hooks` carries the source filename WITH its
2539
+ // extension (`git-guard.sh`), but the doctor/heal diff identifies a hook
2540
+ // by its extensionless basename (`git-guard`) — so a heal pass that feeds
2541
+ // the diff's names straight back through the plain resolveSelection
2542
+ // matched NOTHING and could never reconcile a flagged hook (PHNX-3187).
2497
2543
  const hooksToSync = selection
2498
- ? resolveSelection(selection.hooks, available.hooks)
2544
+ ? resolveHookSelection(selection.hooks, available.hooks)
2499
2545
  : available.hooks;
2500
2546
  let hookManifest = {};
2501
2547
  if (hooksToSync.length > 0) {
@@ -2647,6 +2693,8 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2647
2693
  if (r.paths)
2648
2694
  writtenTargets.push(...r.paths);
2649
2695
  result.subagents.push(...r.synced);
2696
+ if (r.errors?.length)
2697
+ result.declined.push(...r.errors.map((e) => `subagents: ${e}`));
2650
2698
  // Orphan-sweep for Claude only — see comment on commands/skills sweep
2651
2699
  // for the no-selection guard. OpenClaw stores subagents as siblings of
2652
2700
  // other resources so a readdir sweep would over-reach.
@@ -2673,7 +2721,10 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2673
2721
  if (pluginsToSync.length > 0 && pluginsWriter) {
2674
2722
  if (options.allowExecSurfaces) {
2675
2723
  const allPlugins = discoverPlugins();
2676
- cleanOrphanedPluginSkills(agent, versionHome, new Set(allPlugins.map(p => p.name)));
2724
+ // Pass the discovered plugins (with marketplace provenance) so a stale
2725
+ // install under one marketplace is trashed even when another marketplace
2726
+ // still ships that name — the PHNX-2618 shadow `code` plugin.
2727
+ cleanOrphanedPluginSkills(agent, versionHome, allPlugins);
2677
2728
  const pluginMap = new Map(allPlugins.map(p => [p.name, p]));
2678
2729
  for (const name of pluginsToSync) {
2679
2730
  const plugin = pluginMap.get(name);
@@ -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*(?:#.*)?$/);