@phnx-labs/agents-cli 1.22.57 → 1.22.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +294 -0
  2. package/README.md +29 -0
  3. package/dist/bootstrap.js +39 -1
  4. package/dist/commands/accounts.js +7 -3
  5. package/dist/commands/apply.js +10 -2
  6. package/dist/commands/fork.d.ts +23 -10
  7. package/dist/commands/fork.js +115 -58
  8. package/dist/commands/monitors.js +198 -23
  9. package/dist/commands/prune.js +5 -3
  10. package/dist/commands/routines.d.ts +8 -0
  11. package/dist/commands/routines.js +57 -3
  12. package/dist/commands/routines.test-fixture.js +5 -0
  13. package/dist/commands/send.d.ts +2 -1
  14. package/dist/commands/send.js +7 -5
  15. package/dist/commands/sessions-picker.d.ts +11 -0
  16. package/dist/commands/sessions-picker.js +16 -0
  17. package/dist/commands/sessions-stats.js +37 -5
  18. package/dist/commands/sessions.js +40 -5
  19. package/dist/commands/share.d.ts +14 -0
  20. package/dist/commands/share.js +43 -2
  21. package/dist/commands/ssh.js +12 -1
  22. package/dist/commands/status.js +1 -1
  23. package/dist/commands/sync.js +83 -7
  24. package/dist/commands/traces.js +7 -0
  25. package/dist/commands/versions.js +12 -4
  26. package/dist/commands/view.js +7 -2
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +6 -1
  29. package/dist/lib/account-registry.d.ts +5 -1
  30. package/dist/lib/account-registry.js +47 -14
  31. package/dist/lib/accounting/capacity.d.ts +18 -7
  32. package/dist/lib/accounting/capacity.js +19 -8
  33. package/dist/lib/accounting/usage-sync.d.ts +29 -1
  34. package/dist/lib/accounting/usage-sync.js +76 -2
  35. package/dist/lib/accounting/usage.js +7 -1
  36. package/dist/lib/auth-mint.d.ts +11 -1
  37. package/dist/lib/auth-mint.js +21 -6
  38. package/dist/lib/auto-pull-worker.js +7 -2
  39. package/dist/lib/browser/ipc.d.ts +8 -0
  40. package/dist/lib/browser/ipc.js +87 -0
  41. package/dist/lib/browser/service.d.ts +19 -0
  42. package/dist/lib/browser/service.js +96 -11
  43. package/dist/lib/browser/sessions-list.js +10 -1
  44. package/dist/lib/cloud/rush.d.ts +7 -0
  45. package/dist/lib/cloud/rush.js +29 -1
  46. package/dist/lib/daemon/daemon.d.ts +22 -0
  47. package/dist/lib/daemon/daemon.js +39 -0
  48. package/dist/lib/daemon/runner.d.ts +3 -0
  49. package/dist/lib/daemon/runner.js +86 -45
  50. package/dist/lib/daemon/session-index-service.js +9 -1
  51. package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +14 -8
  53. package/dist/lib/daemon-services.js +1 -1
  54. package/dist/lib/daemon-ticks.d.ts +15 -0
  55. package/dist/lib/daemon-ticks.js +26 -0
  56. package/dist/lib/device-config.d.ts +5 -1
  57. package/dist/lib/device-config.js +2 -2
  58. package/dist/lib/devices/connect.d.ts +17 -8
  59. package/dist/lib/devices/connect.js +31 -14
  60. package/dist/lib/devices/health.js +5 -1
  61. package/dist/lib/devices/pool.d.ts +25 -2
  62. package/dist/lib/devices/pool.js +32 -2
  63. package/dist/lib/devices/stats-cache.d.ts +0 -6
  64. package/dist/lib/devices/stats-cache.js +2 -9
  65. package/dist/lib/doctor-diff.d.ts +14 -0
  66. package/dist/lib/doctor-diff.js +120 -9
  67. package/dist/lib/fleet/manifest.d.ts +17 -0
  68. package/dist/lib/fleet/manifest.js +26 -0
  69. package/dist/lib/git.d.ts +38 -0
  70. package/dist/lib/git.js +58 -0
  71. package/dist/lib/hooks/install.d.ts +27 -11
  72. package/dist/lib/hooks/install.js +42 -17
  73. package/dist/lib/hosts/ready.d.ts +8 -0
  74. package/dist/lib/hosts/ready.js +13 -2
  75. package/dist/lib/hosts/reconnect.d.ts +52 -203
  76. package/dist/lib/hosts/reconnect.js +64 -284
  77. package/dist/lib/installations/migrate.d.ts +6 -120
  78. package/dist/lib/installations/migrate.js +27 -259
  79. package/dist/lib/installations/shims.d.ts +13 -95
  80. package/dist/lib/installations/shims.js +22 -139
  81. package/dist/lib/installations/store.js +1 -1
  82. package/dist/lib/installations/versions.d.ts +43 -133
  83. package/dist/lib/installations/versions.js +94 -206
  84. package/dist/lib/monitors/config.d.ts +71 -3
  85. package/dist/lib/monitors/config.js +100 -12
  86. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  87. package/dist/lib/monitors/pid-watch.js +45 -0
  88. package/dist/lib/monitors/remote.d.ts +18 -0
  89. package/dist/lib/monitors/remote.js +11 -0
  90. package/dist/lib/permissions.js +7 -2
  91. package/dist/lib/plugins/plugins.d.ts +17 -3
  92. package/dist/lib/plugins/plugins.js +84 -9
  93. package/dist/lib/plugins/skills.d.ts +8 -1
  94. package/dist/lib/plugins/skills.js +18 -2
  95. package/dist/lib/pty-server.d.ts +14 -0
  96. package/dist/lib/pty-server.js +49 -5
  97. package/dist/lib/refresh.d.ts +9 -0
  98. package/dist/lib/refresh.js +3 -1
  99. package/dist/lib/routine-readiness.d.ts +15 -1
  100. package/dist/lib/routine-readiness.js +41 -0
  101. package/dist/lib/sandbox.d.ts +4 -1
  102. package/dist/lib/sandbox.js +30 -1
  103. package/dist/lib/secrets/agent.d.ts +80 -225
  104. package/dist/lib/secrets/agent.js +139 -401
  105. package/dist/lib/secrets/bundles.d.ts +73 -222
  106. package/dist/lib/secrets/bundles.js +168 -467
  107. package/dist/lib/secrets/drivers/rush.js +5 -0
  108. package/dist/lib/secrets/reaper.d.ts +28 -70
  109. package/dist/lib/secrets/reaper.js +30 -85
  110. package/dist/lib/secrets/remote.d.ts +42 -129
  111. package/dist/lib/secrets/remote.js +55 -173
  112. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  113. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  114. package/dist/lib/self-heal/registry.js +2 -0
  115. package/dist/lib/self-heal/types.d.ts +1 -1
  116. package/dist/lib/self-update.d.ts +65 -0
  117. package/dist/lib/self-update.js +138 -0
  118. package/dist/lib/session/active.d.ts +13 -1
  119. package/dist/lib/session/active.js +2 -0
  120. package/dist/lib/session/cloud.js +5 -0
  121. package/dist/lib/session/db.d.ts +51 -6
  122. package/dist/lib/session/db.js +266 -20
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/tool-calls.d.ts +43 -1
  126. package/dist/lib/session/tool-calls.js +74 -44
  127. package/dist/lib/session/tool-store.d.ts +33 -2
  128. package/dist/lib/session/tool-store.js +56 -3
  129. package/dist/lib/smart-launch.d.ts +6 -0
  130. package/dist/lib/smart-launch.js +5 -2
  131. package/dist/lib/staleness/writers/plugins.js +5 -2
  132. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  133. package/dist/lib/staleness/writers/sources.js +2 -1
  134. package/dist/lib/staleness/writers/subagents.js +13 -3
  135. package/dist/lib/state.d.ts +7 -4
  136. package/dist/lib/state.js +7 -4
  137. package/dist/lib/subagents.js +8 -2
  138. package/dist/lib/sync-status.d.ts +22 -0
  139. package/dist/lib/sync-status.js +27 -0
  140. package/dist/lib/sync-umbrella.d.ts +9 -0
  141. package/dist/lib/sync-umbrella.js +21 -2
  142. package/dist/lib/teams/scheduler.d.ts +10 -0
  143. package/dist/lib/teams/scheduler.js +8 -0
  144. package/dist/lib/traces/insights.d.ts +47 -14
  145. package/dist/lib/traces/insights.js +92 -21
  146. package/dist/lib/traces/phenotype.d.ts +23 -3
  147. package/dist/lib/traces/phenotype.js +72 -24
  148. package/dist/lib/traces/sync.d.ts +128 -6
  149. package/dist/lib/traces/sync.js +294 -35
  150. package/dist/lib/traces/worker-template.js +154 -1
  151. package/dist/lib/view-types.d.ts +12 -0
  152. package/package.json +2 -2
@@ -1,79 +1,132 @@
1
+ /**
2
+ * `agents sessions fork <session>` — branch an existing conversation into a new,
3
+ * independent same-harness sibling, seeded with a recap so it picks up where the
4
+ * original left off. The original is untouched. Also exposed as the hidden
5
+ * top-level alias `agents fork` (back-compat).
6
+ *
7
+ * The source is resolved CROSS-FLEET (the same path `agents sessions preview`
8
+ * uses), so a session that lives on another device forks fine — the sibling is
9
+ * handed a plain-text recap as its opening input and never has to reach the
10
+ * source transcript. Because the seed is text, any REPL harness can be forked,
11
+ * not just Claude.
12
+ *
13
+ * Thin command layer; the pure recap text lives in `lib/session/fork.ts`.
14
+ */
15
+ import { spawnSync } from 'child_process';
1
16
  import chalk from 'chalk';
2
17
  import { setHelpSections } from '../lib/help.js';
3
- import { findSessionsById } from '../lib/session/db.js';
4
- import { discoverSessions } from '../lib/session/discover.js';
5
- import { forkSession, isForkableAgent, FORKABLE_AGENTS } from '../lib/session/fork.js';
18
+ import { getCliLaunch } from '../lib/cli-entry.js';
19
+ import { buildForkRecap, forkLabelFor } from '../lib/session/fork.js';
20
+ function defaultDeps() {
21
+ return {
22
+ runPreview: (sub) => {
23
+ const p = getCliLaunch(['sessions', 'preview', ...sub]);
24
+ // stderr inherited so preview's own resolution errors reach the user verbatim.
25
+ const r = spawnSync(p.command, p.args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'inherit'] });
26
+ return { status: r.status, stdout: r.stdout ?? '' };
27
+ },
28
+ launch: (sub) => {
29
+ const l = getCliLaunch(sub);
30
+ // In-place stdio so the sibling takes over this terminal.
31
+ const r = spawnSync(l.command, l.args, { stdio: 'inherit' });
32
+ return { status: r.status };
33
+ },
34
+ };
35
+ }
6
36
  const FORK_HELP = {
7
37
  examples: `
8
- # Fork a session by (partial) id, then continue the fork
38
+ # Fork a session by (partial) id — launches a same-harness sibling seeded with a recap
9
39
  agents sessions fork 4f3a9c21
10
- agents sessions resume <new-id>
11
40
 
12
- # Give the fork a name
41
+ # Name the fork's session label
13
42
  agents sessions fork 4f3a9c21 --name "try redis instead"
43
+
44
+ # Place the sibling on a fleet worker instead of here
45
+ agents sessions fork 4f3a9c21 --device auto
46
+
47
+ # Open the sibling in a fresh terminal tab where you work
48
+ agents sessions fork 4f3a9c21 --terminal
14
49
  `,
15
50
  notes: `
16
- - 'resume' continues the SAME conversation; 'fork' copies it under a new id so the two diverge.
17
- - The fork is a full copy of the conversation so far; continuing it never touches the original.
18
- - Resolve the session the same way as resume: an exact or prefix id fragment.
19
- - Native copy currently supports: ${FORKABLE_AGENTS.join(', ')}. For other harnesses, branch by
20
- starting a fresh agent and seeding it with '/continue <id>' — the source stays put.
51
+ - 'resume' continues the SAME conversation; 'fork' launches a NEW same-harness
52
+ session seeded with a recap of the source, so the two diverge.
53
+ - Works cross-device and cross-harness: the source is resolved across the fleet
54
+ and the sibling gets a plain-text recap, so it never reaches the source transcript.
55
+ - The recap carries the source id — the sibling can run '/continue <id>' for the
56
+ full history if it needs more than the recap.
57
+ - Resolve the source the same way as resume: an exact or prefix id fragment.
21
58
  `,
22
59
  };
23
60
  /**
24
- * Resolve the source session, copy it under a fresh id, and print how to
25
- * continue the fork. Shared by `agents sessions fork` and the `agents fork` alias.
61
+ * Resolve the source cross-fleet, build a recap from its preview digest, and
62
+ * launch a same-harness sibling seeded with that recap. Shared by
63
+ * `agents sessions fork` and the `agents fork` alias.
26
64
  */
27
- export async function runFork(sessionArg, options) {
28
- // Resolve the source. Try the index first; only pay for a rescan if the id
29
- // isn't found yet (mirrors the resume path's freshen-then-lookup).
30
- let matches = findSessionsById(sessionArg, {});
31
- if (matches.length === 0) {
32
- await discoverSessions({});
33
- matches = findSessionsById(sessionArg, {});
34
- }
35
- if (matches.length === 0) {
36
- // Errors go to stderr and set a non-zero exit code so a script chaining on
37
- // `agents sessions fork <id> && agents sessions resume <new>` doesn't proceed
38
- // on a failed fork.
39
- console.error(chalk.red(`No session matching "${sessionArg}".`));
40
- console.error(chalk.gray('List candidates with: agents sessions'));
65
+ export async function runFork(sessionArg, options, deps = defaultDeps()) {
66
+ // Resolve + digest in one cross-fleet hop by shelling the existing preview
67
+ // verb: it resolves the id across the fleet (SSH fan-out + peer hop), computes
68
+ // the digest on the OWNING device, and prints it as JSON — so a remote source
69
+ // resolves fine and we never re-implement resolution or digesting here.
70
+ // --terminal opens a tab on THIS machine; --device dispatches over SSH. `agents
71
+ // run` refuses the combination, so reject it here with a fork-specific message
72
+ // before resolving anything, rather than after an overpromising progress line.
73
+ if (options.device && options.terminal !== undefined) {
74
+ console.error(chalk.red('Pick one placement: --terminal opens a tab here; --device places the sibling on another box. They cannot combine.'));
41
75
  process.exitCode = 1;
42
76
  return;
43
77
  }
44
- if (matches.length > 1) {
45
- console.error(chalk.yellow(`"${sessionArg}" is ambiguous — ${matches.length} sessions match. Use a longer id:`));
46
- for (const m of matches.slice(0, 8)) {
47
- console.error(chalk.gray(` ${m.shortId} ${m.agent} ${m.label || m.topic || ''}`));
48
- }
49
- process.exitCode = 1;
78
+ const res = deps.runPreview([sessionArg, '--json']);
79
+ if (res.status !== 0) {
80
+ // preview already explained why on stderr; propagate its exit code.
81
+ process.exitCode = res.status ?? 1;
50
82
  return;
51
83
  }
52
- const source = matches[0];
53
- if (!isForkableAgent(source.agent)) {
54
- // A native copy needs the agent's transcript to be resumable by id; harnesses
55
- // without that can still be branched by hand. Fail loud with the manual path
56
- // rather than a silent no-op or a fake copy.
57
- console.error(chalk.yellow(`A native fork copy isn't supported for ${source.agent} sessions yet (supported: ${FORKABLE_AGENTS.join(', ')}).`));
58
- console.error(chalk.gray(` Branch it by hand — start a fresh ${source.agent} and seed it with the source's context:`));
59
- console.error(chalk.gray(` agents run ${source.agent} --terminal # then, in the new session:`));
60
- console.error(chalk.gray(` /continue ${source.shortId}`));
84
+ let data;
85
+ try {
86
+ data = JSON.parse(res.stdout);
87
+ }
88
+ catch {
89
+ console.error(chalk.red(`Could not read the source session for "${sessionArg}".`));
61
90
  process.exitCode = 1;
62
91
  return;
63
92
  }
64
- let result;
65
- try {
66
- result = forkSession(source, { name: options.name });
67
- }
68
- catch (err) {
69
- console.error(chalk.red(`Could not fork ${source.shortId}: ${err.message}`));
93
+ const source = data?.session;
94
+ if (!source?.id || !source?.agent) {
95
+ console.error(chalk.red(`Could not resolve a forkable source for "${sessionArg}".`));
70
96
  process.exitCode = 1;
71
97
  return;
72
98
  }
73
- console.log(chalk.green(`Forked ${source.shortId} -> ${result.shortId}`));
74
- console.log(chalk.gray(` Label: ${result.label}`));
75
- console.log(chalk.gray(` Continue: agents sessions resume ${result.shortId}`));
76
- console.log(chalk.gray(` Original ${source.shortId} is untouched.`));
99
+ const digest = data?.preview ?? undefined;
100
+ // Most sessions have no explicit --name label; fall back to the auto-derived
101
+ // topic the rest of the CLI shows, not the raw short id (forkLabelFor is the
102
+ // shared 3-tier resolver, and preview's --json now carries `topic`).
103
+ const label = forkLabelFor({ label: source.label, topic: source.topic, shortId: source.shortId });
104
+ const recap = buildForkRecap({
105
+ agent: source.agent,
106
+ label,
107
+ cwd: source.cwd,
108
+ ticketId: source.ticketId,
109
+ machine: source.machine,
110
+ shortId: source.shortId,
111
+ id: source.id,
112
+ lastAssistant: digest?.lastAssistant,
113
+ changes: digest?.changes,
114
+ });
115
+ // Launch a NEW same-harness session, load-balanced across accounts (balanced),
116
+ // seeded with the recap as its opening input. Runs here by default; --device
117
+ // places it on the fleet; --terminal opens it in a fresh tab where the user works.
118
+ const runArgs = ['run', source.agent, recap, '-i', '--strategy', 'balanced', '--name', options.name || `fork of ${label}`];
119
+ if (options.device)
120
+ runArgs.push('--device', options.device);
121
+ if (options.terminal !== undefined) {
122
+ runArgs.push('--terminal');
123
+ if (typeof options.terminal === 'string')
124
+ runArgs.push(options.terminal);
125
+ }
126
+ const where = options.device ? ` on ${options.device}` : options.terminal !== undefined ? ' in a new terminal' : '';
127
+ console.error(chalk.gray(`Forking ${source.shortId} → new ${source.agent} session${where}, seeded with a recap…`));
128
+ const child = deps.launch(runArgs);
129
+ process.exitCode = child.status ?? 0;
77
130
  }
78
131
  /**
79
132
  * Register `agents sessions fork <session>` — the canonical surface (fork is a
@@ -82,10 +135,12 @@ export async function runFork(sessionArg, options) {
82
135
  export function registerSessionsForkCommand(sessionsCmd) {
83
136
  const cmd = sessionsCmd
84
137
  .command('fork <session>')
85
- .description('Branch a session into a new, independent copy you can continue separately. The original is untouched.')
86
- .option('--name <label>', 'Label for the fork (default: "fork of <original>")');
138
+ .description('Branch a session into a new same-harness sibling, seeded with a recap so it continues the work. The original is untouched.')
139
+ .option('--name <label>', 'Session label for the fork (default: "fork of <original>")')
140
+ .option('--device <host>', 'Place the sibling on a fleet device (name or "auto"); defaults to here')
141
+ .option('--terminal [backend]', 'Open the sibling in a real terminal tab (iterm | ghostty | terminal | tmux | vscodium-agent) instead of in-place');
87
142
  setHelpSections(cmd, FORK_HELP);
88
- cmd.action(runFork);
143
+ cmd.action((session, options) => runFork(session, options));
89
144
  }
90
145
  /**
91
146
  * Register the hidden top-level `agents fork` alias. Kept working for back-compat
@@ -94,7 +149,9 @@ export function registerSessionsForkCommand(sessionsCmd) {
94
149
  export function registerForkCommand(program) {
95
150
  const cmd = program
96
151
  .command('fork <session>', { hidden: true })
97
- .description('Alias for `agents sessions fork` — branch a session into a new, independent copy.')
98
- .option('--name <label>', 'Label for the fork (default: "fork of <original>")');
99
- cmd.action(runFork);
152
+ .description('Alias for `agents sessions fork` — branch a session into a new same-harness sibling.')
153
+ .option('--name <label>', 'Session label for the fork (default: "fork of <original>")')
154
+ .option('--device <host>', 'Place the sibling on a fleet device (name or "auto"); defaults to here')
155
+ .option('--terminal [backend]', 'Open the sibling in a real terminal tab (iterm | ghostty | terminal | tmux | vscodium-agent) instead of in-place');
156
+ cmd.action((session, options) => runFork(session, options));
100
157
  }
@@ -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)
@@ -139,6 +212,13 @@ function livenessLabel(monitor, state, liveness) {
139
212
  if (liveness.lastError) {
140
213
  return chalk.red(`checked ${liveness.checkCount}x · error: ${liveness.lastError.replace(/\s+/g, ' ').slice(0, 60)}`);
141
214
  }
215
+ const latestFire = listFires(monitor.name).at(-1);
216
+ if (latestFire) {
217
+ const outcome = resolveFireOutcome(monitor.name, latestFire);
218
+ if (!outcome.ok) {
219
+ return chalk.red(`ACTION FAILED ${formatRelativeTime(latestFire.firedAt)}`) + chalk.gray(` · checked ${liveness.checkCount}x`);
220
+ }
221
+ }
142
222
  if (state?.lastFiredAt) {
143
223
  return chalk.green(`fired ${formatRelativeTime(state.lastFiredAt)}`) + chalk.gray(` · checked ${liveness.checkCount}x`);
144
224
  }
@@ -223,11 +303,28 @@ async function validateDevice(name) {
223
303
  }
224
304
  return normalized;
225
305
  }
226
- /** Parse the source flags into a MonitorSource, exiting on missing/ambiguous input. */
227
- 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) {
228
308
  const chosen = [];
229
309
  if (options.watch)
230
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
+ }
231
328
  if (options.poll) {
232
329
  chosen.push({ type: 'poll', source: { type: 'poll', command: options.poll[0], interval: options.poll[1] } });
233
330
  }
@@ -267,7 +364,7 @@ function buildSource(options) {
267
364
  chosen.push({ type: 'webhook', source: { type: 'webhook', webhook } });
268
365
  }
269
366
  if (chosen.length === 0) {
270
- 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'));
271
368
  process.exit(1);
272
369
  }
273
370
  if (chosen.length > 1) {
@@ -276,7 +373,12 @@ function buildSource(options) {
276
373
  }
277
374
  return chosen[0].source;
278
375
  }
279
- /** 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
+ */
280
382
  function buildCondition(options) {
281
383
  const modes = [];
282
384
  if (options.onChange)
@@ -289,6 +391,9 @@ function buildCondition(options) {
289
391
  stderrLine(chalk.red('--on-change, --match, and --every are mutually exclusive'));
290
392
  process.exit(1);
291
393
  }
394
+ if (options.watchPid && modes.length === 0) {
395
+ return { mode: 'match', match: PID_WATCH_EXITED_TOKEN };
396
+ }
292
397
  const mode = modes[0] ?? (options.match ? 'match' : 'on-change');
293
398
  const condition = { mode };
294
399
  if (options.match)
@@ -451,11 +556,17 @@ export function registerMonitorsCommands(program) {
451
556
  # A fleet box going loaded → spin up an agent
452
557
  agents monitors add box-loaded --watch-device yosemite-s0 --match loaded \\
453
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.'
454
565
  `,
455
566
  notes: `
456
567
  A monitor is a routine whose trigger is a watched SOURCE instead of a clock.
457
568
  It has three parts:
458
- - 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)
459
570
  - CONDITION (--on-change [default], --match <re>, --every; --dedupe-key)
460
571
  - ACTION (--run <agent> --prompt, --routine, --notify, --webhook-out)
461
572
  --postcondition <cmd> on --run/--routine asserts the effect
@@ -470,6 +581,11 @@ export function registerMonitorsCommands(program) {
470
581
  v1 evaluates poll sources (command, poll, poll-http, file, device). Push
471
582
  sources (ws, webhook) are accepted but delivered through a receiver wired in
472
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.
473
589
  `,
474
590
  });
475
591
  // ─── add ────────────────────────────────────────────────────────────────────
@@ -478,6 +594,7 @@ export function registerMonitorsCommands(program) {
478
594
  .description('Create a monitor from inline flags or a YAML file. Auto-starts the daemon unless daemon.enabled is false.')
479
595
  // SOURCE
480
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')
481
598
  .option('--poll <cmd...>', 'Re-run a command every interval: --poll "<cmd>" <interval> (e.g. 30s)')
482
599
  .option('--poll-http <url...>', 'GET a URL every interval: --poll-http <url> <interval> (e.g. 15m)')
483
600
  .option('--on <source:event>', 'Webhook trigger source: github:pull_request or linear:Issue')
@@ -515,7 +632,7 @@ export function registerMonitorsCommands(program) {
515
632
  .option('--force', 'Overwrite a same-named monitor, or add one that duplicates an existing watcher')
516
633
  .action(async (nameOrPath, options) => {
517
634
  // File mode: a single arg pointing at an existing .yml with no source flags.
518
- 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);
519
636
  if (!hasSourceFlag && nameOrPath && /\.ya?ml$/.test(nameOrPath) && fs.existsSync(path.resolve(nameOrPath))) {
520
637
  const resolved = path.resolve(nameOrPath);
521
638
  let parsed;
@@ -548,7 +665,7 @@ export function registerMonitorsCommands(program) {
548
665
  stderrLine(chalk.gray('Usage: agents monitors add <name> --poll "<cmd>" 30s --match fail --run claude --prompt "..."'));
549
666
  process.exit(1);
550
667
  }
551
- const source = buildSource(options);
668
+ const source = buildSource(options, nameOrPath);
552
669
  // A --watch-device source name must resolve to a registered fleet member —
553
670
  // validate it (fail fast with the registered list) so a typo/removed device
554
671
  // can't silently watch the local machine (same gate as --device/--devices).
@@ -621,15 +738,37 @@ export function registerMonitorsCommands(program) {
621
738
  // ─── list ────────────────────────────────────────────────────────────────────
622
739
  monitorsCmd
623
740
  .command('list')
624
- .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.')
625
742
  .option('--json', 'Emit machine-readable JSON')
626
- .action((options) => {
743
+ .option('--local', 'This device only — skip the fleet fan-out')
744
+ .action(async (options) => {
745
+ const self = machineId();
627
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);
628
764
  if (options.json) {
629
- const payload = monitors.map((m) => {
765
+ const localRows = monitors.map((m) => {
630
766
  const state = readState(m.name);
631
767
  const liveness = readLiveness(m.name);
768
+ const latestFire = listFires(m.name).at(-1);
769
+ const latestOutcome = latestFire ? resolveFireOutcome(m.name, latestFire) : null;
632
770
  return {
771
+ machine: self,
633
772
  name: m.name,
634
773
  enabled: m.enabled,
635
774
  source: m.source,
@@ -640,6 +779,11 @@ export function registerMonitorsCommands(program) {
640
779
  // exactly the case it exists for. `source` already ships whole.
641
780
  action: m.action,
642
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',
643
787
  runsHere: monitorRunsOnThisDevice(m),
644
788
  lastSeenAt: state?.lastSeenAt ?? null,
645
789
  lastFiredAt: state?.lastFiredAt ?? null,
@@ -650,26 +794,52 @@ export function registerMonitorsCommands(program) {
650
794
  lastError: liveness?.lastError ?? null,
651
795
  consecutiveErrors: liveness?.consecutiveErrors ?? 0,
652
796
  stalled: isStalled(m, liveness),
797
+ lastActionStatus: latestOutcome?.runStatus ?? null,
798
+ lastActionFailed: latestOutcome ? !latestOutcome.ok : false,
653
799
  };
654
800
  });
655
- stdoutJson(payload);
801
+ stdoutJson([...localRows, ...remote.map(remoteMonitorJsonRow)]);
656
802
  return;
657
803
  }
658
- if (monitors.length === 0) {
804
+ if (monitors.length === 0 && remote.length === 0) {
659
805
  console.log(chalk.gray('No monitors configured'));
660
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);
661
809
  return;
662
810
  }
663
811
  console.log(chalk.bold('Monitors\n'));
664
- for (const m of monitors) {
665
- const state = readState(m.name);
666
- const liveness = readLiveness(m.name);
667
- const enabled = m.enabled ? chalk.green('on') : chalk.gray('off');
668
- const here = monitorRunsOnThisDevice(m);
669
- const owner = here ? ownerLabel(m) : chalk.gray(ownerLabel(m));
670
- console.log(` ${chalk.cyan(m.name.padEnd(22))} ${enabled.padEnd(3)} ${sourceLabel(m.source)}`);
671
- 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
+ }
672
840
  }
841
+ if (fleet && (fleet.discoveryFailed || fleet.skipped.length > 0))
842
+ fleetReachNote(fleet);
673
843
  console.log();
674
844
  });
675
845
  // ─── view ──────────────────────────────────────────────────────────────────
@@ -809,7 +979,12 @@ export function registerMonitorsCommands(program) {
809
979
  monitorPath = safeJoin(dir, `${name}.yml`);
810
980
  const builtIn = readMonitor(name);
811
981
  if (builtIn) {
812
- 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);
813
988
  console.log(chalk.gray(`Editing a copy of built-in monitor '${name}' in your user dir: ${monitorPath}`));
814
989
  }
815
990
  else {
@@ -66,9 +66,11 @@ function collectOrphans(types, all) {
66
66
  }
67
67
  if (types.includes('hooks')) {
68
68
  for (const { agent, version } of scopePairs(iterHooksCapableVersions(), all)) {
69
- // Orphan hooks = scripts present in the version home that no
70
- // agents.yaml/hooks.yaml entry registers, so they never fire. Same
71
- // definition the doctor overview reports.
69
+ // Orphan hooks = scripts present in the version home but absent from
70
+ // every configured source (user + system + extras) — genuinely dead
71
+ // files, not merely unregistered ones (sync copies helper/test scripts
72
+ // that no manifest entry declares). Same definition the doctor overview
73
+ // reports (PHNX-2693).
72
74
  const orphans = listUnmanagedHooksInVersionHome(agent, version);
73
75
  if (orphans.length > 0) {
74
76
  groups.push({ type: 'hooks', agent, version, orphans });
@@ -46,5 +46,13 @@ export declare function groupRoutineJobsByDevice(jobs: JobConfig[], registry: De
46
46
  */
47
47
  export declare function groupRoutineJobsByProject(jobs: JobConfig[], knownProjectNames: Set<string>): RoutineListGroup[];
48
48
  export declare function buildRunsJson(runs: RunMeta[]): Record<string, unknown>[];
49
+ /**
50
+ * The short, human reason a run did not simply complete — for inline display in
51
+ * the list/detail so "why did it fail" needs no dig into the run dir. Prefers the
52
+ * concrete `errorMessage` (which carries `auth_failed: …`, OAuth-revoked, timeouts),
53
+ * then the readiness block for a `blocked` run, then the mapped skip reason. Returns
54
+ * null for a healthy (`completed`/`running`) run, which needs no annotation.
55
+ */
56
+ export declare function runFailureReason(run: RunMeta): string | null;
49
57
  /** Register the `agents routines` command tree. */
50
58
  export declare function registerRoutinesCommands(program: Command): void;