@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
@@ -14,10 +14,10 @@ import * as yaml from 'yaml';
14
14
  import { isDaemonRunning, signalDaemonReload, startDaemon, } from '../lib/daemon/daemon.js';
15
15
  import { findDuplicateMonitor, monitorFingerprint } from '../lib/monitors/fingerprint.js';
16
16
  import { gatherFleetMonitors, NO_MONITOR_FANOUT_ENV } from '../lib/monitors/remote.js';
17
- import { listMonitors, readMonitor, writeMonitor, deleteMonitor, setMonitorEnabled, getMonitorPath, validateMonitor, monitorRunsOnThisDevice, parseInterval, } from '../lib/monitors/config.js';
17
+ import { listMonitors, readMonitor, writeMonitor, deleteMonitor, setMonitorEnabled, getMonitorPath, validateMonitor, monitorRunsOnThisDevice, requiresSingleOwner, monitorSharedInputOwner, parseInterval, } from '../lib/monitors/config.js';
18
18
  import { formatRelativeTime } from '../lib/session/relative-time.js';
19
19
  import { evaluateMonitorOnce, POLL_SOURCE_TYPES } from '../lib/monitors/engine.js';
20
- import { listFires, readState, readLiveness, resolveFireOutcome } from '../lib/monitors/state.js';
20
+ import { listFires, readState, readLiveness, resolveFireOutcome, getMonitorHistoryDir } from '../lib/monitors/state.js';
21
21
  import { listRuns, getLatestRun, getRunDir } from '../lib/scheduling/routines.js';
22
22
  import { getMonitorsDir } from '../lib/state.js';
23
23
  import { IS_WINDOWS } from '../lib/platform/index.js';
@@ -26,6 +26,8 @@ import { machineId, normalizeHost } from '../lib/machine-id.js';
26
26
  import { loadDevices } from '../lib/devices/registry.js';
27
27
  import { assertDaemonEnabled } from '../lib/device-config.js';
28
28
  import { setHelpSections } from '../lib/help.js';
29
+ import { isPidAlive } from '../lib/session/active.js';
30
+ import { PID_WATCH_EXITED_TOKEN, pidLivenessCommand } from '../lib/monitors/pid-watch.js';
29
31
  import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
30
32
  function stdoutJson(payload) {
31
33
  process.stdout.write(JSON.stringify(payload) + '\n');
@@ -94,8 +96,79 @@ function ownerLabel(monitor) {
94
96
  return monitor.device;
95
97
  if (monitor.devices && monitor.devices.length > 0)
96
98
  return monitor.devices.join(',');
99
+ // An unpinned shared-input monitor (a system built-in by default) is NOT
100
+ // fleet-wide — it fires only on the single resolved owner (SING-9). Show that
101
+ // owner, not a misleading "all"; surface the unresolved case loudly.
102
+ if (requiresSingleOwner(monitor)) {
103
+ const owner = monitorSharedInputOwner();
104
+ return owner ? `${owner} (owner)` : 'unowned — set interactive.host';
105
+ }
97
106
  return 'all';
98
107
  }
108
+ /** The `(built-in)` tag for a system-layer monitor, mirroring routines' list. */
109
+ function builtinTag(monitor) {
110
+ return monitor.scope === 'system' ? chalk.gray(' (built-in)') : '';
111
+ }
112
+ /**
113
+ * A one-line liveness note for a monitor living on a PEER, reconstructed from the
114
+ * `--json` fields that box reported (it computes the colorized local label, which
115
+ * doesn't cross the wire). Deliberately terse — the owning box's `monitors view`
116
+ * has the full detail.
117
+ */
118
+ function remoteLivenessNote(d) {
119
+ if (!d)
120
+ return chalk.gray('—');
121
+ if (d.enabled === false)
122
+ return chalk.gray('paused');
123
+ if (d.stalled)
124
+ return chalk.red('STALLED');
125
+ if (d.lastActionFailed)
126
+ return chalk.red('ACTION FAILED');
127
+ if (d.lastFiredAt)
128
+ return chalk.green(`fired ${formatRelativeTime(d.lastFiredAt)}`);
129
+ if (typeof d.checkCount === 'number' && d.checkCount > 0)
130
+ return chalk.gray(`checked ${d.checkCount}x`);
131
+ if (d.lastCheckedAt)
132
+ return chalk.gray(`checked ${formatRelativeTime(d.lastCheckedAt)}`);
133
+ return chalk.yellow('never polled');
134
+ }
135
+ /**
136
+ * Print a stderr note when the fleet fan-out couldn't consult every box, so a
137
+ * partial listing never silently reads as "these are all the monitors there are".
138
+ */
139
+ function fleetReachNote(fleet) {
140
+ if (fleet.discoveryFailed) {
141
+ stderrLine(chalk.yellow(' Note: could not reach the device registry — the fleet was not checked, only this device is shown.'));
142
+ }
143
+ else if (fleet.skipped.length > 0) {
144
+ stderrLine(chalk.yellow(` Note: could not reach ${fleet.skipped.length} device(s): ${fleet.skipped.join(', ')} — their monitors are not shown.`));
145
+ }
146
+ }
147
+ /** A remote monitor rendered into the same `--json` row shape as a local one. */
148
+ function remoteMonitorJsonRow(r) {
149
+ const d = r.display;
150
+ return {
151
+ machine: r.machine,
152
+ name: r.monitor.name,
153
+ enabled: d?.enabled ?? null,
154
+ source: r.monitor.source,
155
+ condition: r.monitor.condition,
156
+ action: r.monitor.action,
157
+ owner: d?.owner ?? null,
158
+ scope: d?.scope ?? null,
159
+ builtin: d?.scope === 'system',
160
+ runsHere: false,
161
+ lastSeenAt: null,
162
+ lastFiredAt: d?.lastFiredAt ?? null,
163
+ lastCheckedAt: d?.lastCheckedAt ?? null,
164
+ checkCount: d?.checkCount ?? 0,
165
+ lastError: null,
166
+ consecutiveErrors: 0,
167
+ stalled: d?.stalled ?? false,
168
+ lastActionStatus: null,
169
+ lastActionFailed: d?.lastActionFailed ?? false,
170
+ };
171
+ }
99
172
  /** A monitor's evaluation cadence in ms (falls back to the engine default). */
100
173
  function monitorIntervalMs(monitor) {
101
174
  if (monitor.source.interval)
@@ -230,11 +303,28 @@ async function validateDevice(name) {
230
303
  }
231
304
  return normalized;
232
305
  }
233
- /** Parse the source flags into a MonitorSource, exiting on missing/ambiguous input. */
234
- function buildSource(options) {
306
+ /** Parse the source flags into a MonitorSource, exiting on missing/ambiguous input. `name` seeds the --watch-pid running-seen marker. */
307
+ function buildSource(options, name) {
235
308
  const chosen = [];
236
309
  if (options.watch)
237
310
  chosen.push({ type: 'command', source: { type: 'command', command: options.watch } });
311
+ if (options.watchPid) {
312
+ const pid = Number(options.watchPid);
313
+ if (!Number.isInteger(pid) || pid < 1) {
314
+ stderrLine(chalk.red(`--watch-pid must be a positive integer pid, got '${options.watchPid}'`));
315
+ process.exit(1);
316
+ }
317
+ // Fail loud rather than silently arming a watcher on a corpse (PHNX-3023):
318
+ // a pid that is already gone at arm time can never transition to "exited",
319
+ // so the monitor would sit enabled and never fire.
320
+ if (!isPidAlive(pid) && !options.force) {
321
+ stderrLine(chalk.red(`Process ${pid} is not running — there is nothing to watch.`));
322
+ stderrLine(chalk.gray('Pass --force to arm it anyway (e.g. the pid is about to be spawned by a concurrent step).'));
323
+ process.exit(1);
324
+ }
325
+ const seenRunningMarkerPath = path.join(getMonitorHistoryDir(name), 'pid-watch-seen-running');
326
+ chosen.push({ type: 'command', source: { type: 'command', command: pidLivenessCommand(pid, seenRunningMarkerPath) } });
327
+ }
238
328
  if (options.poll) {
239
329
  chosen.push({ type: 'poll', source: { type: 'poll', command: options.poll[0], interval: options.poll[1] } });
240
330
  }
@@ -274,7 +364,7 @@ function buildSource(options) {
274
364
  chosen.push({ type: 'webhook', source: { type: 'webhook', webhook } });
275
365
  }
276
366
  if (chosen.length === 0) {
277
- stderrLine(chalk.red('A source is required: --watch, --poll, --poll-http, --ws, --watch-file, --watch-device, or --on'));
367
+ stderrLine(chalk.red('A source is required: --watch, --watch-pid, --poll, --poll-http, --ws, --watch-file, --watch-device, or --on'));
278
368
  process.exit(1);
279
369
  }
280
370
  if (chosen.length > 1) {
@@ -283,7 +373,12 @@ function buildSource(options) {
283
373
  }
284
374
  return chosen[0].source;
285
375
  }
286
- /** Parse the condition flags into a MonitorCondition (default on-change). */
376
+ /**
377
+ * Parse the condition flags into a MonitorCondition (default on-change).
378
+ * `--watch-pid` with no explicit mode defaults to firing on exit rather than
379
+ * on-change, since "running"/"exited" is a two-value observation where
380
+ * on-change would fire the instant the daemon takes its first poll.
381
+ */
287
382
  function buildCondition(options) {
288
383
  const modes = [];
289
384
  if (options.onChange)
@@ -296,6 +391,9 @@ function buildCondition(options) {
296
391
  stderrLine(chalk.red('--on-change, --match, and --every are mutually exclusive'));
297
392
  process.exit(1);
298
393
  }
394
+ if (options.watchPid && modes.length === 0) {
395
+ return { mode: 'match', match: PID_WATCH_EXITED_TOKEN };
396
+ }
299
397
  const mode = modes[0] ?? (options.match ? 'match' : 'on-change');
300
398
  const condition = { mode };
301
399
  if (options.match)
@@ -458,11 +556,17 @@ export function registerMonitorsCommands(program) {
458
556
  # A fleet box going loaded → spin up an agent
459
557
  agents monitors add box-loaded --watch-device yosemite-s0 --match loaded \\
460
558
  --run claude --prompt 'yosemite-s0 is loaded: {event}. Investigate.'
559
+
560
+ # A backgrounded shell that will never exit on its own (a watch loop, gh pr
561
+ # checks --watch, a long sleep) — arm a REAL watcher instead of trusting the
562
+ # harness's own exit hook (which only fires when the process dies):
563
+ agents monitors add pr-checks-1234 --watch-pid 48213 \\
564
+ --run claude --prompt 'PID 48213 exited: {event}. Resume and check the result.'
461
565
  `,
462
566
  notes: `
463
567
  A monitor is a routine whose trigger is a watched SOURCE instead of a clock.
464
568
  It has three parts:
465
- - SOURCE (--watch, --poll, --poll-http, --ws, --watch-file, --watch-device, --on)
569
+ - SOURCE (--watch, --watch-pid, --poll, --poll-http, --ws, --watch-file, --watch-device, --on)
466
570
  - CONDITION (--on-change [default], --match <re>, --every; --dedupe-key)
467
571
  - ACTION (--run <agent> --prompt, --routine, --notify, --webhook-out)
468
572
  --postcondition <cmd> on --run/--routine asserts the effect
@@ -477,6 +581,11 @@ export function registerMonitorsCommands(program) {
477
581
  v1 evaluates poll sources (command, poll, poll-http, file, device). Push
478
582
  sources (ws, webhook) are accepted but delivered through a receiver wired in
479
583
  a follow-up.
584
+
585
+ --watch-pid <pid> refuses to arm (fails loud) when the pid is already dead —
586
+ a "will re-invoke me" watcher pointed at a corpse never fires. It defaults
587
+ the condition to fire on exit, unlike a raw --watch which defaults to
588
+ on-change.
480
589
  `,
481
590
  });
482
591
  // ─── add ────────────────────────────────────────────────────────────────────
@@ -485,6 +594,7 @@ export function registerMonitorsCommands(program) {
485
594
  .description('Create a monitor from inline flags or a YAML file. Auto-starts the daemon unless daemon.enabled is false.')
486
595
  // SOURCE
487
596
  .option('--watch <cmd>', 'Run a shell command; its stdout is the observation')
597
+ .option('--watch-pid <pid>', 'Watch a backgrounded process for exit — a reliable, daemon-polled alternative to a harness exit hook. Fails loud if the pid is already gone. Defaults to firing on exit')
488
598
  .option('--poll <cmd...>', 'Re-run a command every interval: --poll "<cmd>" <interval> (e.g. 30s)')
489
599
  .option('--poll-http <url...>', 'GET a URL every interval: --poll-http <url> <interval> (e.g. 15m)')
490
600
  .option('--on <source:event>', 'Webhook trigger source: github:pull_request or linear:Issue')
@@ -522,7 +632,7 @@ export function registerMonitorsCommands(program) {
522
632
  .option('--force', 'Overwrite a same-named monitor, or add one that duplicates an existing watcher')
523
633
  .action(async (nameOrPath, options) => {
524
634
  // File mode: a single arg pointing at an existing .yml with no source flags.
525
- const hasSourceFlag = Boolean(options.watch || options.poll || options.pollHttp || options.on || options.ws || options.watchFile || options.watchDevice);
635
+ const hasSourceFlag = Boolean(options.watch || options.watchPid || options.poll || options.pollHttp || options.on || options.ws || options.watchFile || options.watchDevice);
526
636
  if (!hasSourceFlag && nameOrPath && /\.ya?ml$/.test(nameOrPath) && fs.existsSync(path.resolve(nameOrPath))) {
527
637
  const resolved = path.resolve(nameOrPath);
528
638
  let parsed;
@@ -555,7 +665,7 @@ export function registerMonitorsCommands(program) {
555
665
  stderrLine(chalk.gray('Usage: agents monitors add <name> --poll "<cmd>" 30s --match fail --run claude --prompt "..."'));
556
666
  process.exit(1);
557
667
  }
558
- const source = buildSource(options);
668
+ const source = buildSource(options, nameOrPath);
559
669
  // A --watch-device source name must resolve to a registered fleet member —
560
670
  // validate it (fail fast with the registered list) so a typo/removed device
561
671
  // can't silently watch the local machine (same gate as --device/--devices).
@@ -628,17 +738,37 @@ export function registerMonitorsCommands(program) {
628
738
  // ─── list ────────────────────────────────────────────────────────────────────
629
739
  monitorsCmd
630
740
  .command('list')
631
- .description('See all monitors: source, condition, action, owner, and last fire.')
741
+ .description('See all monitors across the fleet: source, condition, action, owner, last fire, and the box each lives on.')
632
742
  .option('--json', 'Emit machine-readable JSON')
633
- .action((options) => {
743
+ .option('--local', 'This device only — skip the fleet fan-out')
744
+ .action(async (options) => {
745
+ const self = machineId();
634
746
  const monitors = listMonitors();
747
+ // A peer answering the fan-out (NO_MONITOR_FANOUT_ENV set) reports its own
748
+ // box only, so the parent's gather is a flat union and never recurses. The
749
+ // duplicate guard reads exactly this bare local array, so its shape must not
750
+ // change when the env is set. `--local` is the same opt-out for a human.
751
+ const peerMode = !!process.env[NO_MONITOR_FANOUT_ENV];
752
+ const localOnly = peerMode || options.local === true;
753
+ let fleet = null;
754
+ if (!localOnly) {
755
+ // Same cross-machine fan-out `sessions --active` and the add-time guard
756
+ // use — visibility, not git-sync (PHNX-2506 item 3). Never throws; an
757
+ // unreachable fleet degrades to an empty remote set plus skipped names.
758
+ fleet = await gatherFleetMonitors();
759
+ }
760
+ // Peers may echo this box's own monitors back (a synced mirror, or the box
761
+ // resolving its own name); drop anything on `self` so local rows aren't
762
+ // double-listed.
763
+ const remote = (fleet?.monitors ?? []).filter((r) => normalizeHost(r.machine) !== self);
635
764
  if (options.json) {
636
- const payload = monitors.map((m) => {
765
+ const localRows = monitors.map((m) => {
637
766
  const state = readState(m.name);
638
767
  const liveness = readLiveness(m.name);
639
768
  const latestFire = listFires(m.name).at(-1);
640
769
  const latestOutcome = latestFire ? resolveFireOutcome(m.name, latestFire) : null;
641
770
  return {
771
+ machine: self,
642
772
  name: m.name,
643
773
  enabled: m.enabled,
644
774
  source: m.source,
@@ -649,6 +779,11 @@ export function registerMonitorsCommands(program) {
649
779
  // exactly the case it exists for. `source` already ships whole.
650
780
  action: m.action,
651
781
  owner: ownerLabel(m),
782
+ // `scope`/`builtin` (PHNX-2506 item 2): where the monitor came from,
783
+ // so "is this a shipped built-in?" is answerable without knowing the
784
+ // system mirror exists. Peers read `scope` off this field.
785
+ scope: m.scope ?? 'user',
786
+ builtin: m.scope === 'system',
652
787
  runsHere: monitorRunsOnThisDevice(m),
653
788
  lastSeenAt: state?.lastSeenAt ?? null,
654
789
  lastFiredAt: state?.lastFiredAt ?? null,
@@ -663,24 +798,48 @@ export function registerMonitorsCommands(program) {
663
798
  lastActionFailed: latestOutcome ? !latestOutcome.ok : false,
664
799
  };
665
800
  });
666
- stdoutJson(payload);
801
+ stdoutJson([...localRows, ...remote.map(remoteMonitorJsonRow)]);
667
802
  return;
668
803
  }
669
- if (monitors.length === 0) {
804
+ if (monitors.length === 0 && remote.length === 0) {
670
805
  console.log(chalk.gray('No monitors configured'));
671
806
  console.log(chalk.gray(' Add one: agents monitors add <name> --poll "<cmd>" 30s --match fail --run claude --prompt "..."'));
807
+ if (fleet && (fleet.discoveryFailed || fleet.skipped.length > 0))
808
+ fleetReachNote(fleet);
672
809
  return;
673
810
  }
674
811
  console.log(chalk.bold('Monitors\n'));
675
- for (const m of monitors) {
676
- const state = readState(m.name);
677
- const liveness = readLiveness(m.name);
678
- const enabled = m.enabled ? chalk.green('on') : chalk.gray('off');
679
- const here = monitorRunsOnThisDevice(m);
680
- const owner = here ? ownerLabel(m) : chalk.gray(ownerLabel(m));
681
- console.log(` ${chalk.cyan(m.name.padEnd(22))} ${enabled.padEnd(3)} ${sourceLabel(m.source)}`);
682
- console.log(` ${' '.repeat(22)} ${chalk.gray(`[${m.condition.mode}]`)} → ${actionLabel(m.action)} ${chalk.gray(`owner: ${owner}`)} ${livenessLabel(m, state, liveness)}`);
812
+ // This box first — it has full liveness detail.
813
+ if (monitors.length > 0) {
814
+ if (remote.length > 0)
815
+ console.log(chalk.gray(` ${self} (this device)`));
816
+ for (const m of monitors) {
817
+ const state = readState(m.name);
818
+ const liveness = readLiveness(m.name);
819
+ const enabled = m.enabled ? chalk.green('on') : chalk.gray('off');
820
+ const here = monitorRunsOnThisDevice(m);
821
+ const owner = here ? ownerLabel(m) : chalk.gray(ownerLabel(m));
822
+ console.log(` ${chalk.cyan(m.name.padEnd(22))} ${enabled.padEnd(3)} ${sourceLabel(m.source)}${builtinTag(m)}`);
823
+ console.log(` ${' '.repeat(22)} ${chalk.gray(`[${m.condition.mode}]`)} → ${actionLabel(m.action)} ${chalk.gray(`owner: ${owner}`)} ${livenessLabel(m, state, liveness)}`);
824
+ }
825
+ }
826
+ // Then each peer's monitors, grouped by the box they live on.
827
+ const byMachine = new Map();
828
+ for (const r of remote) {
829
+ const key = r.machine;
830
+ (byMachine.get(key) ?? byMachine.set(key, []).get(key)).push(r);
831
+ }
832
+ for (const machine of [...byMachine.keys()].sort()) {
833
+ console.log(chalk.gray(`\n ${machine}`));
834
+ for (const r of byMachine.get(machine)) {
835
+ const m = r.monitor;
836
+ const enabled = r.display?.enabled === false ? chalk.gray('off') : chalk.green('on');
837
+ console.log(` ${chalk.cyan(m.name.padEnd(22))} ${enabled.padEnd(3)} ${sourceLabel(m.source)}${builtinTag({ scope: r.display?.scope })}`);
838
+ console.log(` ${' '.repeat(22)} ${chalk.gray(`[${m.condition.mode}]`)} → ${actionLabel(m.action)} ${chalk.gray(`owner: ${r.display?.owner ?? machine}`)} ${remoteLivenessNote(r.display)}`);
839
+ }
683
840
  }
841
+ if (fleet && (fleet.discoveryFailed || fleet.skipped.length > 0))
842
+ fleetReachNote(fleet);
684
843
  console.log();
685
844
  });
686
845
  // ─── view ──────────────────────────────────────────────────────────────────
@@ -820,7 +979,12 @@ export function registerMonitorsCommands(program) {
820
979
  monitorPath = safeJoin(dir, `${name}.yml`);
821
980
  const builtIn = readMonitor(name);
822
981
  if (builtIn) {
823
- fs.writeFileSync(monitorPath, yaml.stringify(builtIn), 'utf-8');
982
+ // Route through writeMonitor, NOT a raw yaml.stringify: readMonitor
983
+ // stamps the derived `scope` (and a built-in's `enabled: true`), and
984
+ // only writeMonitor strips those from the on-disk schema. A raw dump
985
+ // would persist `scope: system` into the user copy — dead, contradictory
986
+ // state that violates the "scope never persists" contract.
987
+ writeMonitor(builtIn);
824
988
  console.log(chalk.gray(`Editing a copy of built-in monitor '${name}' in your user dir: ${monitorPath}`));
825
989
  }
826
990
  else {
@@ -149,6 +149,11 @@ export function createDaemonHarness(fileSlug) {
149
149
  // and kills real tmux-wrapped processes every five-minute tick (RUSH-2545).
150
150
  AGENTS_HISTORY_DIR: path.join(home, '.agents', '.history'),
151
151
  AGENTS_SKIP_MIGRATION: '1',
152
+ // PHNX-2545 test-home tripwire: name the isolated home this daemon must
153
+ // resolve its state dir under. If the HOME override above ever failed to
154
+ // reach the child, runDaemon()'s assertTestDaemonHome() refuses to boot
155
+ // instead of ticking its scheduler against the operator's real host.
156
+ AGENTS_DAEMON_TEST_HOME: home,
152
157
  },
153
158
  detached: true,
154
159
  stdio: 'ignore',
@@ -11,7 +11,8 @@
11
11
  * Compat: positional text still works (`agents send "hi" --channel … --to …`).
12
12
  *
13
13
  * `agents notify` is the same delivery path with owner defaults
14
- * (`send --to owner`). Not a second stack. Not agent control — use
14
+ * (`send --to owner`) and is deprecated — new callers should use
15
+ * `agents feed post`. Not a second stack. Not agent control — use
15
16
  * `agents message` / `agents sessions inject` for running agents.
16
17
  *
17
18
  * Feed / activity are a different plane (record + read); feed.broadcast may
@@ -47,7 +47,7 @@ async function runSend(positionalText, opts, ownerMode) {
47
47
  }
48
48
  const SHARED_NOTES = `
49
49
  Planes (do not mix them up):
50
- send / notify - DELIVER a message to a recipient (this command)
50
+ send - DELIVER a message to a recipient (this command)
51
51
  feed post - RECORD progress / milestones (optional broadcast may call send)
52
52
  activity - READ the activity stream (not a send path)
53
53
  message / inject - CONTROL a running agent (mailbox answer or terminal keystroke)
@@ -97,7 +97,7 @@ export function registerSendCommand(program) {
97
97
  });
98
98
  const notifyCmd = program
99
99
  .command('notify [text]')
100
- .description('Deliver to the owner (alias of send --to owner). Channel + target default from notify.owner in agents.yaml.')
100
+ .description('[DEPRECATED] Deliver to the owner (alias of send --to owner). Use "agents feed post" for new code.')
101
101
  .option('--text <text>', 'message body (preferred over positional text)')
102
102
  .option('--channel <name>', 'override owner channel')
103
103
  .option('--to <target>', 'override owner target (or pass a non-owner dest with --channel)')
@@ -110,7 +110,7 @@ export function registerSendCommand(program) {
110
110
  .option('--dry-run', 'resolve + build but do not send');
111
111
  setHelpSections(notifyCmd, {
112
112
  examples: `
113
- # Same as: agents send --to owner --text "…"
113
+ # Deprecated: prefer "agents feed post --title \"…\" \"…\" --level important"
114
114
  agents notify --text "Build finished — PR #1346 is green"
115
115
  agents notify "legacy positional still works"
116
116
 
@@ -120,13 +120,15 @@ export function registerSendCommand(program) {
120
120
  agents notify --text "probe" --dry-run --json
121
121
  `,
122
122
  notes: `
123
- notify ≡ send --to owner. One delivery stack (lib/channels). Set
124
- notify.owner.{channel,to} in agents.yaml once per machine/fleet.
123
+ DEPRECATED. notify ≡ send --to owner and still works, but new callers
124
+ should use "agents feed post" (record + optional broadcast) instead.
125
+ Set notify.owner.{channel,to} in agents.yaml once per machine/fleet.
125
126
 
126
127
  ${SHARED_NOTES}
127
128
  `,
128
129
  });
129
130
  notifyCmd.action(async (text, opts) => {
131
+ console.error(chalk.yellow('Warning: "agents notify" is deprecated. Use "agents feed post" for progress posts that can also reach the owner.'));
130
132
  await runSend(text, opts, true);
131
133
  });
132
134
  }
@@ -6,6 +6,19 @@ import { discoverPlugins } from '../lib/plugins/plugins.js';
6
6
  import { setHelpSections } from '../lib/help.js';
7
7
  import { terminalWidth, truncateToWidth, padToWidth, stringWidth } from '../lib/session/width.js';
8
8
  const DEFAULT_TOP = 20;
9
+ /**
10
+ * The recorded-set the zero-counts must be read against (PHNX-2301). A resource
11
+ * only records an EXPLICIT invocation from a harness that emits the event:
12
+ * `Skill` tool calls come from Claude + Kimi, slash-commands from Claude only
13
+ * (`SKILL_TOOL_NAME_BY_AGENT` in `lib/session/highlights.ts`, slash-command
14
+ * parsing in `lib/session/parse.ts`). A session under any other harness — or an
15
+ * auto-triggered skill, which emits no event on any harness — contributes
16
+ * nothing, so its resources read as 0 whether or not they were used. Keep these
17
+ * in lockstep with the writer: widening them here without a verified transcript
18
+ * tool-name would make the caveat lie about coverage it does not have.
19
+ */
20
+ const RECORDING_SKILL_HARNESSES = ['claude', 'kimi'];
21
+ const RECORDING_COMMAND_HARNESSES = ['claude'];
9
22
  /**
10
23
  * The both-ends "dead weight" set: installed resources whose identity
11
24
  * (kind + name — name already embeds `plugin:short`, so it matches the stored
@@ -128,7 +141,7 @@ async function statsAction(cmd) {
128
141
  const totalInvocations = allInvoked.reduce((s, r) => s + r.invocations, 0);
129
142
  if (opts.json) {
130
143
  process.stdout.write(JSON.stringify({
131
- schemaVersion: 1,
144
+ schemaVersion: 2,
132
145
  kind: 'sessions-stats',
133
146
  generatedAt: new Date().toISOString(),
134
147
  filters: {
@@ -142,9 +155,21 @@ async function statsAction(cmd) {
142
155
  signal: {
143
156
  explicitOnly: true,
144
157
  note: 'Explicit invocations only (slash commands + Skill tool calls). Auto-triggered skills emit no event and read as 0. Skill invocations are recorded for Claude and Kimi; slash-commands for Claude only.',
158
+ // Structured recorded-set so a machine consumer can tell a zero from a
159
+ // non-recording harness apart from a genuine non-use (PHNX-2301): a
160
+ // zeroInvoked resource may simply have been auto-triggered, or only
161
+ // used under a harness not in these sets.
162
+ recording: {
163
+ skills: RECORDING_SKILL_HARNESSES,
164
+ commands: RECORDING_COMMAND_HARNESSES,
165
+ },
145
166
  },
146
167
  coverage: {
168
+ // sessionsWithUsage/sessionsIndexed kept for the SES-IF-4b envelope
169
+ // contract; sessionsScanned is the honest backfill-coverage numerator
170
+ // (ledger rows), distinct from the absolute sessionsWithUsage count.
147
171
  sessionsWithUsage: coverage.covered,
172
+ sessionsScanned: coverage.scanned,
148
173
  sessionsIndexed: coverage.total,
149
174
  },
150
175
  totals: {
@@ -210,8 +235,12 @@ function renderHuman(args) {
210
235
  out.push(chalk.bold('Resource usage') +
211
236
  chalk.gray(` · ${allInvokedCount} invoked · ${totalInvocations} invocation${totalInvocations !== 1 ? 's' : ''}` +
212
237
  (scopeBits.length ? ` · ${scopeBits.join(' · ')}` : '')));
213
- out.push(chalk.gray(` coverage: ${coverage.covered}/${coverage.total} sessions carry the signal`) +
214
- (coverage.total > 0 && coverage.covered / coverage.total < 0.5
238
+ // Scan coverage (ledger) is the honest "has the backfill folded in history?"
239
+ // signal and drives the hint; sessionsWithUsage is an ABSOLUTE count of sessions
240
+ // carrying ≥1 explicit invocation, which stays small at full scan coverage
241
+ // because most sessions invoke nothing (PHNX-2301).
242
+ out.push(chalk.gray(` coverage: ${coverage.scanned}/${coverage.total} sessions scanned · ${coverage.covered} carry an explicit invocation`) +
243
+ (coverage.total > 0 && coverage.scanned / coverage.total < 0.5
215
244
  ? chalk.yellow(' — run `agents sessions backfill resources` to fold in history')
216
245
  : ''));
217
246
  out.push(chalk.gray(' signal: explicit invocations only (slash commands + Skill tool); auto-triggered skills read as 0; skills from Claude+Kimi, slash-commands Claude-only'));
@@ -228,8 +257,11 @@ function renderHuman(args) {
228
257
  }
229
258
  out.push('');
230
259
  }
231
- // Zero-invoked section (the dead weight).
232
- out.push(chalk.bold('Installed but never invoked') + chalk.gray(` (${zeroInvoked.length})`));
260
+ // Zero-invoked section (the dead weight). "Never invoked" is scoped to the
261
+ // recorded set: never EXPLICITLY invoked under a recording harness (skills
262
+ // Claude+Kimi, commands Claude-only) — it may still have been auto-triggered or
263
+ // used under another harness, which is why this is dead-weight EVIDENCE, not proof.
264
+ out.push(chalk.bold('Installed but never explicitly invoked') + chalk.gray(` (${zeroInvoked.length})`));
233
265
  if (zeroInvoked.length === 0) {
234
266
  out.push(chalk.gray(' (every installed resource has at least one explicit invocation in this window)'));
235
267
  }
@@ -2316,9 +2316,16 @@ limitSource) {
2316
2316
  return;
2317
2317
  }
2318
2318
  // --device / --devices values are merged into options.host for internal routing. A bare `all` / `fleet` sentinel means
2319
- // "search every peer" — which is already the default — so it resolves to no
2320
- // explicit host set rather than erroring on a device literally named "all".
2321
- const deviceTargets = [...(options.device ?? []), ...(options.devices ?? [])]
2319
+ // "search every peer" — which is already the default for `--active` and the
2320
+ // interactive listing — so it resolves to no explicit host set rather than
2321
+ // erroring on a device literally named "all".
2322
+ const rawDeviceTargets = [...(options.device ?? []), ...(options.devices ?? [])];
2323
+ // But the HISTORICAL `--json` listing does NOT fan out by default (it stays a
2324
+ // deterministic local slice for scripts), so the sentinel would otherwise be
2325
+ // silently dropped and the fleet never reached (PHNX-2673). Remember it so that
2326
+ // path can sweep every peer, exactly as the interactive listing already does.
2327
+ const fleetWide = rawDeviceTargets.some(t => t.toLowerCase() === 'all' || t.toLowerCase() === 'fleet');
2328
+ const deviceTargets = rawDeviceTargets
2322
2329
  .filter(t => t.toLowerCase() !== 'all' && t.toLowerCase() !== 'fleet');
2323
2330
  if (deviceTargets.length > 0) {
2324
2331
  options.host = [...(options.host ?? []), ...deviceTargets];
@@ -2775,16 +2782,43 @@ limitSource) {
2775
2782
  // each peer) would fall to FTS content search and return every transcript
2776
2783
  // that merely MENTIONS the id, defeating exact remote resolution. A genuine
2777
2784
  // search phrase keeps the ranked metadata+content path.
2778
- const filtered = searchQuery
2785
+ let filtered = searchQuery
2779
2786
  ? resolveSessionQuery(sessions, searchQuery, {
2780
2787
  scope: { agent: options.agent, project: options.project, routine: options.routine },
2781
2788
  }).matches
2782
2789
  : sessions;
2790
+ // `--device all`/`--fleet` on a historical `--json` query fans out to every
2791
+ // peer and merges their rows in — the SAME SSH sweep the interactive listing
2792
+ // below uses, and the whole-fleet twin of the explicit `--device <host>
2793
+ // --json` path (`runRemoteSessionsJson`). Without this the documented
2794
+ // "search the whole fleet" flag returned local rows only (PHNX-2673). Guarded
2795
+ // to the sentinel so a bare `--json` stays a deterministic local slice for
2796
+ // scripts, and skipped under `--local`/the peer recursion guard. Dead peers
2797
+ // are skipped by the fan-out, never fatal — enrichment, not a dependency.
2798
+ const forceLocalJson = options.local === true || process.env[NO_FANOUT_ENV] === '1';
2799
+ if (fleetWide && !forceLocalJson) {
2800
+ const forwarded = ensureWholeIndex(buildForwardedArgs(process.argv, new Set(options.host ?? [])));
2801
+ if (!forwarded.includes('--json'))
2802
+ forwarded.push('--json');
2803
+ const fanSpinner = isInteractiveTerminal() ? ora('Reaching other machines...').start() : null;
2804
+ try {
2805
+ const { sessions: remoteSessions } = await gatherRemoteList(forwarded, undefined);
2806
+ if (remoteSessions.length > 0) {
2807
+ filtered = mergeLocalFirst([...filtered, ...remoteSessions], machineId());
2808
+ }
2809
+ }
2810
+ catch {
2811
+ // fan-out is an enrichment, never a hard dependency
2812
+ }
2813
+ finally {
2814
+ fanSpinner?.stop();
2815
+ }
2816
+ }
2783
2817
  // JSON is the canonical picker contract: enrich the durable rows once
2784
2818
  // from the shared live cache so every consumer gets lifecycle/recovery
2785
2819
  // metadata without performing its own join or transcript scan.
2786
2820
  const live = await gatherActiveSessions({
2787
- local: options.local === true || process.env[NO_FANOUT_ENV] === '1',
2821
+ local: forceLocalJson,
2788
2822
  hosts: options.host,
2789
2823
  });
2790
2824
  process.stdout.write(serializeSessionsJson(serializeSessionPickerRows(filtered, live.sessions)));
@@ -130,7 +130,11 @@ function pctCell(v, width) {
130
130
  * header — which defeats the scannability this column exists for. */
131
131
  const SPEC_WIDTH_MIN = 12;
132
132
  /** The static hardware as one compact cell — `12c 64G 1T`: cores, total RAM,
133
- * total root disk via fmtBytes. `—` only when no probe has ever seen the box.
133
+ * total root disk via fmtBytes. `—` covers two cases: no probe has ever seen
134
+ * the box, or the probe answered but yielded no usable core count (`parseNcpu`
135
+ * finding none, or the Windows `ncpu` group failing the finite-and-positive
136
+ * check) — the cell is keyed on `ncpu` because a spec string without it would
137
+ * read as a machine with zero cores.
134
138
  *
135
139
  * Deliberately NOT gated on `reachable`: hardware does not change while a
136
140
  * machine is down, so an offline device keeps rendering the spec from its last
@@ -2298,6 +2302,13 @@ box; \`agents doctor\` names it.
2298
2302
 
2299
2303
  A box whose probe cannot answer (no POSIX shell, e.g. Windows) is reported
2300
2304
  \`unverified\` rather than counted as a success.
2305
+
2306
+ The upgrade OWNS its global bin links: after installing, it verifies that
2307
+ \`<prefix>/bin/{agents,ag,browser,computer}\` resolve to the freshly-installed
2308
+ copy and RESTORES any the package manager dropped — the state that once left a
2309
+ box upgraded in place but with every \`agents\` invocation "command not found"
2310
+ (PHNX-2768). A link it cannot make resolve fails the upgrade loud, so that box
2311
+ is reported \`failed\` (exit non-zero), never a stranded \`ok\`.
2301
2312
  `)
2302
2313
  .action(async (version) => {
2303
2314
  let cmd;
@@ -11,7 +11,7 @@ import { installVersion, removeVersion, listInstalledVersions, isVersionInstalle
11
11
  import { carryForwardSettings } from '../lib/settings-manifest.js';
12
12
  import { createShim, createVersionedAlias, supportsIsolatedInstall, isIsolationProtected, CONFIG_ENV_ISOLATED_AGENTS, removeShim, shimExists, getShimsDir, getShimPath, getPathShadowingExecutable, isShimsInPath, getPathSetupInstructions, addShimsToPath, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/installations/shims.js';
13
13
  import { isInteractiveTerminal, isPromptCancelled, requireInteractiveSelection } from './utils.js';
14
- import { tryAutoPull } from '../lib/git.js';
14
+ import { tryAutoPullSystemRepo } from '../lib/git.js';
15
15
  import { getAgentsDir, getTrashVersionsDir } from '../lib/state.js';
16
16
  import { setHelpSections } from '../lib/help.js';
17
17
  import { updateSessionFilePaths } from '../lib/session/db.js';
@@ -630,10 +630,18 @@ export function registerVersionsCommands(program) {
630
630
  useCmd.action(async (agentArg, versionArg, options) => {
631
631
  try {
632
632
  const skipPrompts = options.yes || !isInteractiveTerminal();
633
- // Auto-pull ~/.agents/.system if it's a git repo with remote (silent on success)
633
+ // Auto-pull ~/.agents/.system if it's a git repo tracking the EXPECTED
634
+ // system remote (silent on success). The system repo ships hooks that run
635
+ // as shell on tool events, so pulling from a repointed origin would be
636
+ // remote code execution — tryAutoPullSystemRepo refuses that (PHNX-2957).
634
637
  const agentsDir = getAgentsDir();
635
- const pullResult = await tryAutoPull(agentsDir);
636
- if (pullResult.pulled) {
638
+ const pullResult = await tryAutoPullSystemRepo(agentsDir);
639
+ if (pullResult.refused) {
640
+ console.error(chalk.red(`Refusing to auto-sync ~/.agents/.system: its origin (${pullResult.actualRemote}) is not the expected system repo.`));
641
+ console.error(chalk.gray('The system repo ships hooks that run on tool events; a fast-forward from an unexpected origin is not applied. ' +
642
+ 'Re-point it (git -C ~/.agents/.system remote set-url origin <expected>) or set AGENTS_SYSTEM_REPO, then re-run `agents setup --force`.'));
643
+ }
644
+ else if (pullResult.pulled) {
637
645
  console.log(chalk.gray('Synced ~/.agents/.system from remote'));
638
646
  }
639
647
  // Support both "claude 2.0.65" and "claude@2.0.65" formats