@phnx-labs/agents-cli 1.22.58 → 1.22.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +260 -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/perf.js +10 -0
  6. package/dist/commands/routines.test-fixture.js +5 -0
  7. package/dist/commands/send.d.ts +2 -1
  8. package/dist/commands/send.js +7 -5
  9. package/dist/commands/sessions-picker.d.ts +13 -0
  10. package/dist/commands/sessions-picker.js +17 -8
  11. package/dist/commands/sessions-stats.js +37 -5
  12. package/dist/commands/sessions.js +52 -16
  13. package/dist/commands/ssh.js +12 -1
  14. package/dist/commands/teams-picker.js +20 -6
  15. package/dist/commands/teams.d.ts +2 -2
  16. package/dist/commands/teams.js +77 -21
  17. package/dist/commands/versions.js +12 -4
  18. package/dist/commands/view.js +7 -2
  19. package/dist/lib/accounting/rotate.js +12 -4
  20. package/dist/lib/accounting/usage-sync.d.ts +12 -2
  21. package/dist/lib/accounting/usage-sync.js +34 -6
  22. package/dist/lib/auto-pull-worker.js +7 -2
  23. package/dist/lib/cloud/rush.d.ts +7 -0
  24. package/dist/lib/cloud/rush.js +29 -1
  25. package/dist/lib/daemon/daemon.d.ts +22 -0
  26. package/dist/lib/daemon/daemon.js +39 -0
  27. package/dist/lib/daemon/session-index-service.js +9 -1
  28. package/dist/lib/daemon-ticks.d.ts +15 -0
  29. package/dist/lib/daemon-ticks.js +26 -0
  30. package/dist/lib/device-config.d.ts +5 -1
  31. package/dist/lib/device-config.js +2 -2
  32. package/dist/lib/devices/health.js +5 -1
  33. package/dist/lib/devices/pool.d.ts +25 -2
  34. package/dist/lib/devices/pool.js +32 -2
  35. package/dist/lib/devices/stats-cache.d.ts +0 -6
  36. package/dist/lib/devices/stats-cache.js +2 -9
  37. package/dist/lib/doctor-diff.d.ts +14 -0
  38. package/dist/lib/doctor-diff.js +43 -2
  39. package/dist/lib/feed/events.js +4 -0
  40. package/dist/lib/git.d.ts +38 -0
  41. package/dist/lib/git.js +58 -0
  42. package/dist/lib/hosts/ready.d.ts +8 -0
  43. package/dist/lib/hosts/ready.js +13 -2
  44. package/dist/lib/installations/versions.d.ts +17 -0
  45. package/dist/lib/installations/versions.js +53 -2
  46. package/dist/lib/monitors/config.d.ts +71 -3
  47. package/dist/lib/monitors/config.js +100 -12
  48. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  49. package/dist/lib/monitors/pid-watch.js +45 -0
  50. package/dist/lib/monitors/remote.d.ts +18 -0
  51. package/dist/lib/monitors/remote.js +11 -0
  52. package/dist/lib/perf/db.d.ts +1 -1
  53. package/dist/lib/perf/db.js +53 -2
  54. package/dist/lib/perf/types.d.ts +14 -0
  55. package/dist/lib/permissions.js +7 -2
  56. package/dist/lib/plugins/plugins.d.ts +17 -3
  57. package/dist/lib/plugins/plugins.js +84 -9
  58. package/dist/lib/pty-server.d.ts +14 -0
  59. package/dist/lib/pty-server.js +49 -5
  60. package/dist/lib/secrets/drivers/rush.js +5 -0
  61. package/dist/lib/self-update.d.ts +42 -0
  62. package/dist/lib/self-update.js +88 -0
  63. package/dist/lib/session/cloud.js +5 -0
  64. package/dist/lib/session/db.d.ts +32 -6
  65. package/dist/lib/session/db.js +128 -12
  66. package/dist/lib/session/live-metadata.js +3 -3
  67. package/dist/lib/smart-launch.d.ts +6 -0
  68. package/dist/lib/smart-launch.js +5 -2
  69. package/dist/lib/staleness/writers/plugins.js +5 -2
  70. package/dist/lib/staleness/writers/subagents.js +13 -3
  71. package/dist/lib/state.d.ts +7 -4
  72. package/dist/lib/state.js +7 -4
  73. package/dist/lib/subagents.js +8 -2
  74. package/dist/lib/teams/api.d.ts +8 -0
  75. package/dist/lib/teams/api.js +50 -6
  76. package/dist/lib/teams/delivery.d.ts +14 -4
  77. package/dist/lib/teams/delivery.js +15 -5
  78. package/dist/lib/teams/scheduler.d.ts +10 -0
  79. package/dist/lib/teams/scheduler.js +8 -0
  80. package/dist/lib/traces/sync.d.ts +113 -6
  81. package/dist/lib/traces/sync.js +193 -19
  82. package/dist/lib/view-types.d.ts +12 -0
  83. 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 {
@@ -132,6 +132,16 @@ function renderLabelTable(title, rows, warnMs, limit) {
132
132
  formatMs(r.maxMs),
133
133
  formatRateColumn(r),
134
134
  ]), sliced.map((r) => r.p99Ms > warnMs));
135
+ // Sub-phase break-out (agent.run's `startup` = spawn overhead before the child
136
+ // runs, PHNX-3468). Indented under the row so a slow boot is visible next to
137
+ // the total without adding columns that are empty for phaseless labels.
138
+ for (const r of sliced) {
139
+ if (!r.phases)
140
+ continue;
141
+ for (const [name, ph] of Object.entries(r.phases)) {
142
+ console.log(chalk.gray(` └ ${name}: p50 ${formatMs(ph.p50Ms)} p90 ${formatMs(ph.p90Ms)} (n=${ph.n})`));
143
+ }
144
+ }
135
145
  }
136
146
  /**
137
147
  * Prefer SQLite samples; fall back to the legacy daily JSONL so existing
@@ -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
  }
@@ -3,6 +3,19 @@ import { fetchPeerPreviewDigest } from '../lib/session/remote-list.js';
3
3
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
4
4
  import type { FileChange } from '../lib/session/digest.js';
5
5
  import { extractArtifacts, extractHooks, extractLinks, extractSkills } from '../lib/session/highlights.js';
6
+ /** A session whose transcript FILE is on another machine: its `filePath` is on
7
+ * that peer's disk, so the preview can't parse it locally — it fetches the
8
+ * peer's digest over SSH and shows metadata + a "resume there" note until that
9
+ * lands. `_remote` is an explicit signal; a peer `machine` plus no readable
10
+ * local file covers host-dispatch index rows and live-registry rows that were
11
+ * synthesized outside the fan-out. A locally-readable synced mirror stays
12
+ * local even when its recorded owner is another machine.
13
+ *
14
+ * This is the READ rule and only the read rule. Whether a session may be
15
+ * RESUMED here is a different question with a different answer — a mirror is
16
+ * readable but not resumable — and `sessionOwnerDevice`
17
+ * (lib/session/resume-owner.ts) is the single place that answers it (RUSH-2022). */
18
+ export declare function transcriptOnPeerOf(session: SessionMeta): string | undefined;
6
19
  /**
7
20
  * Compact checklist tally for list rows and previews (RUSH-2045).
8
21
  * Example: `✓6/8 · A5 wiring runner`. Empty string when there is no list.
@@ -25,19 +25,28 @@ import { createMemoryCache } from '../lib/memory-cache.js';
25
25
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
26
26
  import { extractArtifacts, extractHooks, extractLinks, extractRepos, extractSkills, harnessTracksBackgroundShells, isBackgroundShellStart, isSubAgentTool, } from '../lib/session/highlights.js';
27
27
  import { getSessionPlugins, readSessionPreviewCache, writeSessionPreviewCache, readSessionContent, readArchivedSessionPreview } from '../lib/session/db.js';
28
- /** A session whose transcript FILE is on another machine (folded in over the
29
- * live cross-machine fan-out): its `filePath` is on that peer's disk, so the
30
- * preview can't parse it locally — it fetches the peer's digest over SSH and
31
- * shows metadata + a "resume there" note until that lands. Keys off `_remote`,
32
- * not the machine tag, so locally-readable synced mirrors still parse their
33
- * file normally.
28
+ import { machineId } from '../lib/session/sync/config.js';
29
+ /** A session whose transcript FILE is on another machine: its `filePath` is on
30
+ * that peer's disk, so the preview can't parse it locally — it fetches the
31
+ * peer's digest over SSH and shows metadata + a "resume there" note until that
32
+ * lands. `_remote` is an explicit signal; a peer `machine` plus no readable
33
+ * local file covers host-dispatch index rows and live-registry rows that were
34
+ * synthesized outside the fan-out. A locally-readable synced mirror stays
35
+ * local even when its recorded owner is another machine.
34
36
  *
35
37
  * This is the READ rule and only the read rule. Whether a session may be
36
38
  * RESUMED here is a different question with a different answer — a mirror is
37
39
  * readable but not resumable — and `sessionOwnerDevice`
38
40
  * (lib/session/resume-owner.ts) is the single place that answers it (RUSH-2022). */
39
- function transcriptOnPeerOf(session) {
40
- return session._remote ? session.machine : undefined;
41
+ export function transcriptOnPeerOf(session) {
42
+ if (session._remote)
43
+ return session.machine;
44
+ if (session.machine
45
+ && session.machine !== machineId()
46
+ && (!session.filePath || !fs.existsSync(session.filePath))) {
47
+ return session.machine;
48
+ }
49
+ return undefined;
41
50
  }
42
51
  /**
43
52
  * Compact checklist tally for list rows and previews (RUSH-2045).
@@ -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
  }