@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.
- package/CHANGELOG.md +260 -0
- package/README.md +29 -0
- package/dist/bootstrap.js +32 -1
- package/dist/commands/monitors.js +187 -23
- package/dist/commands/perf.js +10 -0
- package/dist/commands/routines.test-fixture.js +5 -0
- package/dist/commands/send.d.ts +2 -1
- package/dist/commands/send.js +7 -5
- package/dist/commands/sessions-picker.d.ts +13 -0
- package/dist/commands/sessions-picker.js +17 -8
- package/dist/commands/sessions-stats.js +37 -5
- package/dist/commands/sessions.js +52 -16
- package/dist/commands/ssh.js +12 -1
- package/dist/commands/teams-picker.js +20 -6
- package/dist/commands/teams.d.ts +2 -2
- package/dist/commands/teams.js +77 -21
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.js +7 -2
- package/dist/lib/accounting/rotate.js +12 -4
- package/dist/lib/accounting/usage-sync.d.ts +12 -2
- package/dist/lib/accounting/usage-sync.js +34 -6
- package/dist/lib/auto-pull-worker.js +7 -2
- package/dist/lib/cloud/rush.d.ts +7 -0
- package/dist/lib/cloud/rush.js +29 -1
- package/dist/lib/daemon/daemon.d.ts +22 -0
- package/dist/lib/daemon/daemon.js +39 -0
- package/dist/lib/daemon/session-index-service.js +9 -1
- package/dist/lib/daemon-ticks.d.ts +15 -0
- package/dist/lib/daemon-ticks.js +26 -0
- package/dist/lib/device-config.d.ts +5 -1
- package/dist/lib/device-config.js +2 -2
- package/dist/lib/devices/health.js +5 -1
- package/dist/lib/devices/pool.d.ts +25 -2
- package/dist/lib/devices/pool.js +32 -2
- package/dist/lib/devices/stats-cache.d.ts +0 -6
- package/dist/lib/devices/stats-cache.js +2 -9
- package/dist/lib/doctor-diff.d.ts +14 -0
- package/dist/lib/doctor-diff.js +43 -2
- package/dist/lib/feed/events.js +4 -0
- package/dist/lib/git.d.ts +38 -0
- package/dist/lib/git.js +58 -0
- package/dist/lib/hosts/ready.d.ts +8 -0
- package/dist/lib/hosts/ready.js +13 -2
- package/dist/lib/installations/versions.d.ts +17 -0
- package/dist/lib/installations/versions.js +53 -2
- package/dist/lib/monitors/config.d.ts +71 -3
- package/dist/lib/monitors/config.js +100 -12
- package/dist/lib/monitors/pid-watch.d.ts +35 -0
- package/dist/lib/monitors/pid-watch.js +45 -0
- package/dist/lib/monitors/remote.d.ts +18 -0
- package/dist/lib/monitors/remote.js +11 -0
- package/dist/lib/perf/db.d.ts +1 -1
- package/dist/lib/perf/db.js +53 -2
- package/dist/lib/perf/types.d.ts +14 -0
- package/dist/lib/permissions.js +7 -2
- package/dist/lib/plugins/plugins.d.ts +17 -3
- package/dist/lib/plugins/plugins.js +84 -9
- package/dist/lib/pty-server.d.ts +14 -0
- package/dist/lib/pty-server.js +49 -5
- package/dist/lib/secrets/drivers/rush.js +5 -0
- package/dist/lib/self-update.d.ts +42 -0
- package/dist/lib/self-update.js +88 -0
- package/dist/lib/session/cloud.js +5 -0
- package/dist/lib/session/db.d.ts +32 -6
- package/dist/lib/session/db.js +128 -12
- package/dist/lib/session/live-metadata.js +3 -3
- package/dist/lib/smart-launch.d.ts +6 -0
- package/dist/lib/smart-launch.js +5 -2
- package/dist/lib/staleness/writers/plugins.js +5 -2
- package/dist/lib/staleness/writers/subagents.js +13 -3
- package/dist/lib/state.d.ts +7 -4
- package/dist/lib/state.js +7 -4
- package/dist/lib/subagents.js +8 -2
- package/dist/lib/teams/api.d.ts +8 -0
- package/dist/lib/teams/api.js +50 -6
- package/dist/lib/teams/delivery.d.ts +14 -4
- package/dist/lib/teams/delivery.js +15 -5
- package/dist/lib/teams/scheduler.d.ts +10 -0
- package/dist/lib/teams/scheduler.js +8 -0
- package/dist/lib/traces/sync.d.ts +113 -6
- package/dist/lib/traces/sync.js +193 -19
- package/dist/lib/view-types.d.ts +12 -0
- 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
|
-
/**
|
|
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
|
|
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
|
-
.
|
|
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
|
|
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(
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/commands/perf.js
CHANGED
|
@@ -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',
|
package/dist/commands/send.d.ts
CHANGED
|
@@ -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`)
|
|
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
|
package/dist/commands/send.js
CHANGED
|
@@ -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
|
|
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).
|
|
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
|
-
#
|
|
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
|
|
124
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
* preview can't parse it locally — it fetches the
|
|
31
|
-
* shows metadata + a "resume there" note until that
|
|
32
|
-
*
|
|
33
|
-
* file
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
}
|