@phnx-labs/agents-cli 1.20.87 → 1.20.89

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 (94) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +12 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/doctor.d.ts +0 -19
  6. package/dist/commands/doctor.js +219 -305
  7. package/dist/commands/exec.js +7 -19
  8. package/dist/commands/factory.js +26 -2
  9. package/dist/commands/funnel.js +16 -1
  10. package/dist/commands/inspect.js +3 -5
  11. package/dist/commands/menubar.js +117 -34
  12. package/dist/commands/routines.js +25 -3
  13. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  14. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  15. package/dist/commands/secrets.js +2 -0
  16. package/dist/commands/sessions.d.ts +7 -1
  17. package/dist/commands/sessions.js +40 -12
  18. package/dist/commands/ssh.js +3 -3
  19. package/dist/commands/usage.d.ts +3 -2
  20. package/dist/commands/usage.js +2 -9
  21. package/dist/commands/webhook.js +7 -2
  22. package/dist/lib/agents.d.ts +31 -1
  23. package/dist/lib/agents.js +55 -0
  24. package/dist/lib/command-skills.d.ts +10 -0
  25. package/dist/lib/command-skills.js +14 -0
  26. package/dist/lib/commands.js +28 -2
  27. package/dist/lib/daemon.d.ts +29 -0
  28. package/dist/lib/daemon.js +75 -6
  29. package/dist/lib/devices/doctor-findings.d.ts +167 -0
  30. package/dist/lib/devices/doctor-findings.js +893 -0
  31. package/dist/lib/devices/fleet-divergence.d.ts +22 -0
  32. package/dist/lib/devices/fleet-divergence.js +34 -10
  33. package/dist/lib/devices/fleet-inventory.d.ts +17 -6
  34. package/dist/lib/devices/fleet-inventory.js +56 -8
  35. package/dist/lib/events.d.ts +1 -1
  36. package/dist/lib/exec.d.ts +14 -3
  37. package/dist/lib/exec.js +41 -8
  38. package/dist/lib/factory/snapshot.d.ts +78 -0
  39. package/dist/lib/factory/snapshot.js +209 -0
  40. package/dist/lib/fs-atomic.d.ts +14 -1
  41. package/dist/lib/fs-atomic.js +35 -3
  42. package/dist/lib/funnel.d.ts +1 -0
  43. package/dist/lib/funnel.js +8 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  47. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  48. package/dist/lib/menubar/install-menubar.js +183 -28
  49. package/dist/lib/platform/process.d.ts +2 -0
  50. package/dist/lib/platform/process.js +5 -3
  51. package/dist/lib/project-resources.js +34 -20
  52. package/dist/lib/resources.d.ts +8 -0
  53. package/dist/lib/resources.js +34 -1
  54. package/dist/lib/routines-placement.d.ts +2 -1
  55. package/dist/lib/routines-placement.js +8 -4
  56. package/dist/lib/routines.d.ts +57 -1
  57. package/dist/lib/routines.js +74 -1
  58. package/dist/lib/runner.d.ts +16 -1
  59. package/dist/lib/runner.js +58 -16
  60. package/dist/lib/sandbox.d.ts +2 -0
  61. package/dist/lib/sandbox.js +38 -0
  62. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  63. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  64. package/dist/lib/secrets/bundles.js +9 -34
  65. package/dist/lib/secrets/filestore.d.ts +152 -34
  66. package/dist/lib/secrets/filestore.js +676 -123
  67. package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
  68. package/dist/lib/secrets/rc-hygiene.js +0 -24
  69. package/dist/lib/session/active.d.ts +6 -6
  70. package/dist/lib/session/active.js +6 -6
  71. package/dist/lib/session/discover.d.ts +5 -0
  72. package/dist/lib/session/discover.js +137 -1
  73. package/dist/lib/session/parse.d.ts +2 -0
  74. package/dist/lib/session/parse.js +76 -37
  75. package/dist/lib/session/remote-active.d.ts +4 -1
  76. package/dist/lib/session/remote-active.js +8 -2
  77. package/dist/lib/session/sync/agents.js +0 -0
  78. package/dist/lib/session/types.d.ts +1 -1
  79. package/dist/lib/session/types.js +1 -1
  80. package/dist/lib/session/viewing-in.d.ts +31 -0
  81. package/dist/lib/session/viewing-in.js +47 -0
  82. package/dist/lib/staleness/detectors/commands.js +14 -5
  83. package/dist/lib/staleness/types.d.ts +2 -0
  84. package/dist/lib/staleness/writers/commands.js +13 -7
  85. package/dist/lib/state.d.ts +17 -0
  86. package/dist/lib/state.js +30 -2
  87. package/dist/lib/triggers/handlers.d.ts +95 -0
  88. package/dist/lib/triggers/handlers.js +384 -0
  89. package/dist/lib/triggers/webhook.d.ts +10 -2
  90. package/dist/lib/triggers/webhook.js +65 -11
  91. package/dist/lib/usage.d.ts +72 -1
  92. package/dist/lib/usage.js +21 -27
  93. package/dist/lib/versions.js +30 -13
  94. package/package.json +1 -1
@@ -7,6 +7,8 @@ import { fanOutDevices, planFleetTargets, remoteFleetTargets } from '../lib/devi
7
7
  import { fleetDialTarget } from '../lib/devices/connect.js';
8
8
  import { compareFleetInventories } from '../lib/devices/fleet-divergence.js';
9
9
  import { collectLocalFleetInventory } from '../lib/devices/fleet-inventory.js';
10
+ import { buildLocalFindings, fleetDivergenceToFindings, signInToFindings, renderFindings, } from '../lib/devices/doctor-findings.js';
11
+ import { getCliVersion } from '../lib/version.js';
10
12
  import { resolveHost } from '../lib/hosts/registry.js';
11
13
  import { sshExecAsync } from '../lib/ssh-exec.js';
12
14
  import { sshTargetFor } from '../lib/hosts/types.js';
@@ -14,7 +16,6 @@ import { machineId } from '../lib/session/sync/config.js';
14
16
  import chalk from 'chalk';
15
17
  import { checkAllClis, collectTeamsDoctorData } from '../lib/teams/agents.js';
16
18
  import { AGENTS, ALL_AGENT_IDS, resolveAgentName, formatAgentError, getAccountInfo } from '../lib/agents.js';
17
- import { formatSignInBadge } from '../lib/signin-badge.js';
18
19
  import { getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalledVersions, parseAgentSpec, } from '../lib/versions.js';
19
20
  import { loadManifest, isStale } from '../lib/staleness/index.js';
20
21
  import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
@@ -26,8 +27,8 @@ import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
26
27
  import { listCliStatus } from '../lib/cli-resources.js';
27
28
  import { setHelpSections } from '../lib/help.js';
28
29
  import { heal, healChangedAnything } from '../lib/heal.js';
29
- import { blocksLocalScripts, getEffectiveExecutionPolicy } from '../lib/platform/winpath.js';
30
- import { scanUserRcFiles, rcSecretWarningLines } from '../lib/secrets/rc-hygiene.js';
30
+ import { getEffectiveExecutionPolicy } from '../lib/platform/winpath.js';
31
+ import { scanUserRcFiles } from '../lib/secrets/rc-hygiene.js';
31
32
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
32
33
  import { readRepoBehindMarkers } from '../lib/auto-pull.js';
33
34
  import * as fs from 'fs';
@@ -63,239 +64,19 @@ export function wrapLine(prefix, text, width = terminalWidth()) {
63
64
  lines.push(line);
64
65
  return lines;
65
66
  }
67
+ /** Reshape into the pure finding-builder's input: install state
68
+ * per declared CLI, plus the manifests the loader rejected (a bad manifest
69
+ * declares a CLI that can never install, so it is a finding, not silence). */
70
+ function toHostCliInput(status) {
71
+ return {
72
+ statuses: status.statuses.map((c) => ({ name: c.manifest.name, installed: c.installed })),
73
+ errors: status.errors.map((e) => ({ file: e.file, reason: e.reason })),
74
+ };
75
+ }
66
76
  function printWrappedLine(prefix, text) {
67
77
  for (const line of wrapLine(prefix, text))
68
78
  console.log(chalk.gray(line));
69
79
  }
70
- function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers, duplicateHooks) {
71
- // Triaged health banner FIRST, so a user running bare `agents doctor` sees what
72
- // is unhealthy, why it matters, and the exact fix before scrolling the detail
73
- // sections below. Same triage model as target mode, aggregated across versions.
74
- const overviewHealth = computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, duplicateHooks);
75
- console.log(chalk.bold('Health'));
76
- renderHealthBlock(overviewHealth, {
77
- healthySummary: syncRows.length
78
- ? `${syncRows.length} version${syncRows.length === 1 ? '' : 's'} reconciled · hooks wired · sources current`
79
- : 'no installed versions to check',
80
- healFix: verdictIsAutoFixable(overviewHealth) ? 'agents doctor --fix' : undefined,
81
- });
82
- console.log();
83
- console.log(chalk.bold('Agent CLIs'));
84
- // Show the fleet you actually run — agents that are ready in PATH, plus any
85
- // you MANAGE (have installed versions) whose binary isn't resolving (a real
86
- // problem). The other supported-but-unadopted agents collapse to one hint line
87
- // instead of a column of red "not installed" nags for tools you never wanted.
88
- const managed = new Set(ALL_AGENT_IDS.filter((a) => listInstalledVersions(a).length > 0));
89
- const entries = Object.entries(clis);
90
- const shown = entries.filter(([name, e]) => e.installed || managed.has(name));
91
- const hidden = entries.filter(([name, e]) => !e.installed && !managed.has(name)).map(([name]) => name);
92
- if (shown.length === 0) {
93
- console.log(chalk.gray(' (none installed — `agents add <name>` to start)'));
94
- }
95
- else {
96
- for (const [name, entry] of shown) {
97
- const pretty = (AGENT_NAMES[name] || name).padEnd(11);
98
- if (entry.installed) {
99
- const badge = formatSignInBadge(signIn[name]);
100
- const pathHint = entry.path ? chalk.gray(` ${entry.path}`) : '';
101
- console.log(` ${chalk.green('ready')} ${pretty} ${badge}${pathHint}`);
102
- }
103
- else {
104
- console.log(` ${chalk.red('no ')} ${pretty} ${chalk.gray(entry.error || 'not installed')}`);
105
- }
106
- }
107
- }
108
- if (hidden.length > 0) {
109
- printWrappedLine(' ', `+${hidden.length} more supported (${hidden.join(', ')}) — \`agents add <name>\` to manage`);
110
- }
111
- console.log();
112
- console.log(chalk.bold('Sync status (installed versions)'));
113
- if (syncRows.length === 0) {
114
- console.log(chalk.gray(' (no versions installed; add one with `agents add <agent>@<version>`)'));
115
- }
116
- else {
117
- let anyOutOfSync = false;
118
- for (const row of syncRows) {
119
- const tag = row.isDefault ? chalk.gray(' (default)') : '';
120
- const label = `${AGENT_NAMES[row.agent] || row.agent}@${row.version}${tag}`;
121
- const unwired = (row.unwiredHooks ?? 0) > 0;
122
- if (row.status === 'fresh' && !unwired) {
123
- console.log(` ${chalk.green('fresh')} ${label}`);
124
- continue;
125
- }
126
- anyOutOfSync = true;
127
- if (row.status === 'stale') {
128
- console.log(` ${chalk.yellow('stale')} ${label} ${chalk.gray('— sources changed since last sync')}`);
129
- }
130
- else if (row.status === 'never-synced') {
131
- console.log(` ${chalk.gray('cold ')} ${label} ${chalk.gray('— never synced')}`);
132
- }
133
- else {
134
- // Manifest-fresh but a declared hook is present-on-disk yet not wired into
135
- // settings.json — it never fires (the yosemite-s1 blind spot).
136
- console.log(` ${chalk.red('unwired')} ${label} ${chalk.gray('— hooks present but not wired into settings.json')}`);
137
- }
138
- for (const line of row.divergence ?? []) {
139
- console.log(chalk.gray(` ${line}`));
140
- }
141
- }
142
- // Launching does NOT reconcile a version home — the shim hot path only
143
- // resolves a version and compiles project-scoped resources (shims.ts v15/v16).
144
- // Version homes are reconciled only by management commands, so point at one
145
- // rather than promising an auto-sync that never happens.
146
- if (anyOutOfSync) {
147
- printWrappedLine(' ', 'Reconcile with `agents doctor <agent>@<version> --fix` or `agents sync <agent>@<version>` (not applied on launch).');
148
- }
149
- }
150
- console.log();
151
- console.log(chalk.bold('Orphans (installed versions)'));
152
- if (orphanRows.length === 0) {
153
- console.log(chalk.gray(' (none — version homes match central sources)'));
154
- }
155
- else {
156
- for (const row of orphanRows) {
157
- const parts = [];
158
- if (row.commands > 0)
159
- parts.push(`${row.commands} command${row.commands === 1 ? '' : 's'}`);
160
- if (row.skills > 0)
161
- parts.push(`${row.skills} skill${row.skills === 1 ? '' : 's'}`);
162
- if (row.hooks > 0)
163
- parts.push(`${row.hooks} hook${row.hooks === 1 ? '' : 's'}`);
164
- const label = `${AGENT_NAMES[row.agent] || row.agent}@${row.version}`;
165
- console.log(` ${chalk.yellow('warn ')} ${label} ${chalk.gray(parts.join(', '))}`);
166
- }
167
- console.log(chalk.gray(' Run `agents prune cleanup` to remove.'));
168
- }
169
- console.log();
170
- // Host CLIs are host-global (declared in any DotAgents repo's cli/, installed
171
- // to PATH — not synced into version homes), so they live in the overview, not
172
- // the per-version resource diff. Source tag shows which repo layer declared
173
- // each, including user-level and extra repos.
174
- console.log(chalk.bold('Host CLIs'));
175
- if (hostClis.statuses.length === 0) {
176
- console.log(chalk.gray(' (none declared — add one with `agents cli add <name>`)'));
177
- }
178
- else {
179
- const nameWidth = Math.max(...hostClis.statuses.map((s) => s.manifest.name.length));
180
- for (const { manifest, installed } of hostClis.statuses) {
181
- const label = manifest.name.padEnd(nameWidth);
182
- const src = chalk.gray(`[${manifest.source}]`);
183
- if (installed) {
184
- const prefix = ` ${chalk.green('ready')} ${label} ${src}`;
185
- const desc = manifest.description
186
- ? ` ${truncateToWidth(collapseWhitespace(manifest.description), Math.max(1, terminalWidth() - stringWidth(prefix) - 2))}`
187
- : '';
188
- console.log(prefix + chalk.gray(desc));
189
- }
190
- else {
191
- const prefix = ` ${chalk.red('miss ')} ${label} ${src}`;
192
- const msg = `not installed — run \`agents cli install ${manifest.name}\``;
193
- const budget = Math.max(1, terminalWidth() - stringWidth(prefix) - 2);
194
- console.log(prefix + chalk.gray(` ${truncateToWidth(msg, budget)}`));
195
- }
196
- }
197
- }
198
- for (const err of hostClis.errors) {
199
- console.log(` ${chalk.red('err ')} ${chalk.gray(err.file)}: ${chalk.gray(err.reason)}`);
200
- }
201
- // On Windows a Restricted/AllSigned execution policy silently breaks the
202
- // generated `agents.ps1` launcher — postinstall diagnoses it interactively,
203
- // but a non-interactive install never sees that. Surface it here too.
204
- renderExecPolicyAdvisory();
205
- // Credentials exported from a shell rc file leak into every process's
206
- // environment (readable via /proc/<pid>/environ) — the RUSH-1968 class. Flag
207
- // them here so the master passphrase never silently lives in `~/.zshenv` again.
208
- renderRcHygieneAdvisory();
209
- // Repos that are behind upstream — surfaced here instead of on stderr during
210
- // every command. Markers are written by the background fetch worker and persist
211
- // until the user runs `agents repo pull <alias>`.
212
- renderRepoBehindAdvisory(repoBehindMarkers);
213
- }
214
- // ─── windows execution-policy advisory ─────────────────────────────────────────
215
- /**
216
- * Windows-only advisory lines. When the effective PowerShell execution policy
217
- * blocks unsigned local `.ps1` scripts (`Restricted`/`AllSigned`), the generated
218
- * `agents.ps1` launcher fails in PowerShell even when it is on PATH. Surface the
219
- * remediation; the `.cmd` companion still works, so this is a warning, not an
220
- * error, and doctor never auto-changes the policy. Pure — returns `[]` on
221
- * non-Windows or a permissive policy, so it is testable without invoking
222
- * PowerShell.
223
- */
224
- export function execPolicyWarningLines(platform, policy) {
225
- if (platform !== 'win32')
226
- return [];
227
- if (!blocksLocalScripts(policy))
228
- return [];
229
- return [
230
- `PowerShell execution policy is ${policy} — it blocks the generated agents.ps1 launcher.`,
231
- 'Fix: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned',
232
- 'The agents.cmd shim still works regardless of the policy.',
233
- ];
234
- }
235
- function renderRcHygieneAdvisory() {
236
- const findings = scanUserRcFiles();
237
- const lines = rcSecretWarningLines(findings);
238
- if (lines.length === 0)
239
- return;
240
- console.log();
241
- console.log(chalk.bold('Secrets in shell config'));
242
- const [headline, ...rest] = lines;
243
- console.log(` ${chalk.yellow('warn ')} ${headline}`);
244
- for (const line of rest) {
245
- console.log(chalk.gray(` ${line}`));
246
- }
247
- }
248
- // ─── repo-behind advisory ─────────────────────────────────────────────────────
249
- /**
250
- * Render repo-behind notices from background fetch markers as a "Repo updates"
251
- * section in `agents doctor`. Reads without consuming the markers so repeated
252
- * invocations keep showing the notice until the user runs `agents repo pull`.
253
- */
254
- function renderRepoBehindAdvisory(markers) {
255
- const behind = markers.filter((m) => m.behind > 0);
256
- if (behind.length === 0)
257
- return;
258
- console.log();
259
- console.log(chalk.bold('Repo updates'));
260
- for (const m of behind) {
261
- const label = m.alias === 'user' ? '~/.agents/' : m.alias;
262
- const commits = `${m.behind} commit${m.behind === 1 ? '' : 's'}`;
263
- console.log(` ${chalk.yellow('warn ')} ${label} is ${commits} behind ${m.branch}`);
264
- console.log(chalk.gray(` run \`agents repo pull ${m.alias}\` to update`));
265
- }
266
- }
267
- function renderExecPolicyAdvisory() {
268
- // Only probe the policy on Windows — getEffectiveExecutionPolicy() spawns
269
- // powershell, which is a wasted (doomed) process on POSIX where the advisory
270
- // never applies.
271
- if (process.platform !== 'win32')
272
- return;
273
- const lines = execPolicyWarningLines(process.platform, getEffectiveExecutionPolicy());
274
- if (lines.length === 0)
275
- return;
276
- console.log();
277
- console.log(chalk.bold('Execution policy (Windows)'));
278
- const [headline, ...rest] = lines;
279
- console.log(` ${chalk.yellow('warn ')} ${headline}`);
280
- for (const line of rest) {
281
- console.log(chalk.gray(` ${line}`));
282
- }
283
- }
284
- const AGENT_ORDER = ['claude', 'codex', 'kimi', 'grok', 'antigravity', 'opencode', 'cursor', 'droid'];
285
- function shortAgentHeader(name) {
286
- return name.slice(0, 4).padEnd(4);
287
- }
288
- function agentCell(entry) {
289
- if (!entry)
290
- return chalk.gray('- ');
291
- if (entry.installed) {
292
- const signedInHint = entry.signedIn ? '*' : ' ';
293
- return chalk.green(`rdy${signedInHint}`.padEnd(4));
294
- }
295
- if (entry.error)
296
- return chalk.red('err '.padEnd(4));
297
- return chalk.gray('no '.padEnd(4));
298
- }
299
80
  async function resolveFleetTargets(opts) {
300
81
  const singleName = opts.host || opts.device;
301
82
  if (singleName) {
@@ -377,12 +158,73 @@ async function probeFleetInventory(target) {
377
158
  return null;
378
159
  try {
379
160
  const parsed = JSON.parse(res.stdout);
380
- return parsed.fleet ?? null;
161
+ return asFleetInventory(parsed.fleet);
381
162
  }
382
163
  catch {
383
164
  return null;
384
165
  }
385
166
  }
167
+ /**
168
+ * Narrow a remote `doctor --json` `.fleet` payload to a usable inventory, or null.
169
+ *
170
+ * The remote runs ITS OWN agents-cli, whose version we do not control, so the
171
+ * payload is untrusted input rather than a typed value — a cast alone let a
172
+ * partial object (`{"fleet":{}}` from a skewed or truncated remote) reach
173
+ * `compareFleetInventories`, which indexes `inv.resources[kind]` and
174
+ * `Object.keys(inv.agentVersions)` unconditionally and throws. Returning null
175
+ * routes that device into the existing "older agents-cli / no inventory" path,
176
+ * which is honest and already rendered, instead of aborting the whole fan-out.
177
+ *
178
+ * This validates EVERY field of {@link FleetInventory} that anything downstream
179
+ * reads — `resources`, `agentVersions`, `repos` and each optional `signIn` row.
180
+ * It was tightened four times during review, each round finding the next
181
+ * unvalidated layer, so treat partial validation here as a bug: a field added to
182
+ * the inventory must be checked here in the same change.
183
+ */
184
+ function asFleetInventory(value) {
185
+ // `typeof [] === 'object'`, so a shallow object check is not enough: an array
186
+ // passes it, every `inv.resources[kind] ?? []` then yields empty, and the
187
+ // comparison reports EVERY baseline resource as missing on that device — a
188
+ // screenful of false findings, worse than the crash this guard replaced.
189
+ const isMap = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
190
+ const isStringArray = (x) => Array.isArray(x) && x.every((e) => typeof e === 'string');
191
+ if (!isMap(value))
192
+ return null;
193
+ const v = value;
194
+ if (!isMap(v.resources) || !Object.values(v.resources).every(isStringArray))
195
+ return null;
196
+ if (!isMap(v.agentVersions) || !Object.values(v.agentVersions).every(isStringArray))
197
+ return null;
198
+ // `repos.agents` / `repos.system` must each be a RepoState or null. Checking
199
+ // only that `repos` is a record let `{agents: [], system: []}` through: `[]` is
200
+ // truthy, so `describeRepoDrift` read `.head`/`.branch`/`.dirty` off an array,
201
+ // got undefined for each, and could emit a bogus repo-drift row.
202
+ const isRepoState = (x) => x === null
203
+ || (isMap(x)
204
+ && (x.branch === null || typeof x.branch === 'string')
205
+ && (x.head === null || typeof x.head === 'string')
206
+ && typeof x.dirty === 'boolean');
207
+ if (!isMap(v.repos) || !Object.values(v.repos).every(isRepoState))
208
+ return null;
209
+ // `signIn` is optional (older remotes omit it) but every FIELD must be
210
+ // well-formed if sent — not just `version`. `provable` and `signedIn` are read
211
+ // as booleans to decide a CRITICAL vs a hedged warning, so a remote sending
212
+ // `provable: "yes"` would print a logged-out critical for a signed-in version,
213
+ // and a non-string `account` would render straight into the accounts line.
214
+ if (v.signIn !== undefined) {
215
+ if (!isMap(v.signIn))
216
+ return null;
217
+ const isSignInRow = (r) => isMap(r)
218
+ && typeof r.version === 'string'
219
+ && typeof r.signedIn === 'boolean'
220
+ && typeof r.provable === 'boolean'
221
+ && (r.account === null || typeof r.account === 'string');
222
+ const rowsOk = Object.values(v.signIn).every((rows) => Array.isArray(rows) && rows.every(isSignInRow));
223
+ if (!rowsOk)
224
+ return null;
225
+ }
226
+ return v;
227
+ }
386
228
  async function runDevicesDoctor(opts) {
387
229
  const singleName = opts.host || opts.device;
388
230
  const targets = await resolveFleetTargets(opts);
@@ -395,7 +237,7 @@ async function runDevicesDoctor(opts) {
395
237
  name: localName,
396
238
  online: true,
397
239
  agents: await collectTeamsDoctorData(),
398
- inventory: collectLocalFleetInventory(opts.cwd ?? process.cwd()),
240
+ inventory: await collectLocalFleetInventory(opts.cwd ?? process.cwd()),
399
241
  });
400
242
  }
401
243
  // Remote targets in parallel: agent readiness (teams doctor) and the harness
@@ -427,74 +269,99 @@ async function runDevicesDoctor(opts) {
427
269
  const divergence = singleName
428
270
  ? null
429
271
  : compareFleetInventories(results.map((r) => ({ name: r.name, inventory: r.inventory ?? null })), localName);
430
- if (opts.json) {
431
- console.log(JSON.stringify({ devices: results, fleet: divergence }, null, 2));
272
+ if (opts.json && results.length === 0) {
273
+ console.log(JSON.stringify({ devices: results, fleet: divergence, findings: [] }, null, 2));
432
274
  return;
433
275
  }
434
276
  if (results.length === 0) {
435
277
  console.log(chalk.gray('No registered devices. Run `agents devices` to register some.'));
436
278
  return;
437
279
  }
438
- const agentsToShow = AGENT_ORDER.filter((a) => results.some((r) => r.agents[a] !== undefined));
439
- console.log(chalk.bold('Agent readiness by device'));
440
- if (agentsToShow.length === 0) {
441
- console.log(chalk.gray(' (no agent data collected)'));
442
- return;
443
- }
444
- const nameWidth = Math.max(...results.map((r) => r.name.length));
445
- const header = ` ${'Device'.padEnd(nameWidth)} ${agentsToShow.map(shortAgentHeader).join(' ')}`;
446
- console.log(chalk.gray(header));
447
- for (const row of results) {
448
- const status = row.online ? chalk.green('online ') : chalk.red('offline');
449
- const errorSuffix = row.error ? ` ${chalk.gray(row.error)}` : '';
450
- const cells = agentsToShow.map((a) => agentCell(row.agents[a])).join(' ');
451
- console.log(` ${row.name.padEnd(nameWidth)} ${status} ${cells}${errorSuffix}`);
280
+ // ── Hybrid fleet view (RUSH-2069): CRITICAL section across all boxes, then a
281
+ // per-computer block for each. Findings come from:
282
+ // - LOCAL: per-version resource reports + sync/orphan/repo-behind + sign-in.
283
+ // - REMOTE: the box's self-reported inventory (per-version sign-in) folded to
284
+ // logged-out findings, plus cross-device divergence (version-skew, repo
285
+ // drift, missing resources). A remote on an older CLI with no `signIn` in
286
+ // its inventory emits the "older agents-cli — can't report per-version
287
+ // sign-in upgrade" warning so the readout stays honest.
288
+ const cwd = opts.cwd ?? process.cwd();
289
+ const findings = [];
290
+ const accounts = {};
291
+ for (const r of results) {
292
+ if (r.name === localName) {
293
+ // Local findings from the real reports (not just the inventory summary).
294
+ const localReports = [];
295
+ for (const agent of ALL_AGENT_IDS) {
296
+ for (const version of listInstalledVersions(agent)) {
297
+ localReports.push(diffVersionResources(agent, version, { cwd, excludeProject: true }));
298
+ }
299
+ }
300
+ const localCliMissing = ALL_AGENT_IDS.filter((a) => listInstalledVersions(a).length > 0 && !checkAllClis()[a]?.installed);
301
+ findings.push(...buildLocalFindings({
302
+ device: localName,
303
+ syncRows: checkSyncStatus(cwd),
304
+ orphanRows: countOrphans(),
305
+ repoBehind: readRepoBehindMarkers(),
306
+ reports: localReports,
307
+ signIn: r.inventory?.signIn ?? {},
308
+ cliMissing: localCliMissing,
309
+ duplicateHooks: inspectDuplicateVersionHooks(cwd),
310
+ hostClis: toHostCliInput(listCliStatus(cwd)),
311
+ rcSecrets: scanUserRcFiles(),
312
+ execPolicy: process.platform === 'win32'
313
+ ? { platform: process.platform, policy: getEffectiveExecutionPolicy() }
314
+ : undefined,
315
+ isolatedVersions: localReports
316
+ .filter((rep) => isVersionIsolated(rep.agent, rep.version))
317
+ .map((rep) => `${rep.agent}@${rep.version}`),
318
+ }));
319
+ accounts[localName] = r.inventory?.signIn ?? {};
320
+ continue;
321
+ }
322
+ // Remote device.
323
+ if (!r.online) {
324
+ // An offline / unreachable box: surface it as a warning so it isn't
325
+ // silently dropped from the per-computer section.
326
+ findings.push({
327
+ severity: 'warning', kind: 'stale-cli', device: r.name,
328
+ message: r.error ? `unreachable — ${r.error}` : 'unreachable',
329
+ remediation: 'check the device',
330
+ });
331
+ continue;
332
+ }
333
+ if (r.inventory?.signIn) {
334
+ findings.push(...signInToFindings(r.name, r.inventory.signIn));
335
+ accounts[r.name] = r.inventory.signIn;
336
+ }
337
+ else {
338
+ // Reachable, but an older CLI that can't report per-version sign-in.
339
+ findings.push({
340
+ severity: 'warning', kind: 'stale-cli', device: r.name,
341
+ message: "older agents-cli — can't report per-version sign-in",
342
+ remediation: 'upgrade',
343
+ });
344
+ accounts[r.name] = {};
345
+ }
452
346
  }
453
- console.log();
454
- console.log(chalk.gray(' rdy* = installed and signed in · rdy = installed · no = not installed · err = probe failed · - = offline'));
347
+ // Cross-device divergence → version-skew / repo-drift / missing-resource
348
+ // warnings, attributed to the lagging box.
455
349
  if (divergence) {
456
- console.log();
457
- for (const line of renderFleetDivergence(divergence))
458
- console.log(line);
350
+ findings.push(...fleetDivergenceToFindings(divergence.divergences, divergence.baseline));
459
351
  }
460
- }
461
- /**
462
- * Render the cross-device divergence section for `agents doctor --devices`
463
- * (RUSH-2027): a clean all-clear when the fleet agrees, otherwise the specific
464
- * gaps a resource/version present here but missing on a box, or a diverged
465
- * config repo — grouped by device, each a plain-language line. Devices that
466
- * couldn't be compared (offline / older CLI) are named so the readout is honest.
467
- */
468
- export function renderFleetDivergence(report) {
469
- const lines = [chalk.bold('Cross-device divergence')];
470
- const skippedNote = report.skippedDevices.length > 0
471
- ? chalk.gray(` (not compared: ${report.skippedDevices.join(', ')} — offline or no inventory)`)
472
- : null;
473
- if (!report.hasDivergence) {
474
- lines.push(chalk.green(` Fleet is consistent — every compared device matches ${report.baseline}.`));
475
- if (skippedNote)
476
- lines.push(skippedNote);
477
- return lines;
478
- }
479
- // Group findings by the LAGGING device so a box with several gaps reads as one
480
- // block AND the remediation points at the right box. For a `*-missing-local`
481
- // finding the resource is absent on the local baseline (present on `d.device`),
482
- // so the box that's behind is the baseline — not the remote that has it.
483
- const byDevice = new Map();
484
- for (const d of report.divergences) {
485
- const lagging = d.kind.endsWith('-missing-local') ? report.baseline : d.device;
486
- (byDevice.get(lagging) ?? byDevice.set(lagging, []).get(lagging)).push(d);
487
- }
488
- for (const device of Array.from(byDevice.keys()).sort()) {
489
- lines.push(` ${chalk.yellow('⚠')} ${chalk.bold(device)}`);
490
- for (const d of byDevice.get(device)) {
491
- lines.push(chalk.gray(` ${d.message}`));
492
- }
352
+ // `--json` emits HERE, not before the findings are built: a consumer that reads
353
+ // the JSON must see the same finding set the text renders — same membership,
354
+ // same severities, so the CRITICAL `(N)` and each device's `✗ N critical`
355
+ // reconcile. Emitting early left `--devices --json` with no `findings` at all
356
+ // while the bare `--json` had one.
357
+ if (opts.json) {
358
+ console.log(JSON.stringify({ devices: results, fleet: divergence, findings }, null, 2));
359
+ return;
360
+ }
361
+ const header = `${chalk.gray('agents doctor ·')} ${chalk.hex('#a3e635')(String(results.length))} ${chalk.gray('devices · baseline')} ${chalk.hex('#a3e635')(localName)}`;
362
+ for (const line of renderFindings(findings, accounts, { fleet: true, baseline: localName, header })) {
363
+ console.log(line);
493
364
  }
494
- if (skippedNote)
495
- lines.push(skippedNote);
496
- lines.push(chalk.gray(' Read-only — run `agents apply` or `agents repo pull` on a lagging box to reconcile.'));
497
- return lines;
498
365
  }
499
366
  function parseTargetArg(arg) {
500
367
  const at = arg.indexOf('@');
@@ -1423,10 +1290,14 @@ export function registerDoctorCommand(program) {
1423
1290
  const orphanRows = countOrphans();
1424
1291
  const hostClis = listCliStatus(cwd);
1425
1292
  const repoBehindMarkers = readRepoBehindMarkers();
1293
+ // The local inventory now carries per-version sign-in (RUSH-2069), so it
1294
+ // is the single source for both the accounts line and the logged-out
1295
+ // criticals. Collected once (async: it parses each version's account).
1296
+ const inventory = await collectLocalFleetInventory(cwd);
1297
+ const localName = machineId();
1426
1298
  const duplicateHooks = inspectDuplicateVersionHooks(cwd);
1427
- // Advisory login state per installed agent (file-based getAccountInfo,
1428
- // no home the account-global/active credential). Best-effort: a probe
1429
- // failure just leaves that agent's badge as "logged out".
1299
+ // Legacy account-global sign-in map, kept for `--json` back-compat
1300
+ // (ssh.ts RemoteDoctorJson / menubar read `signIn`). File-based, no home.
1430
1301
  const signIn = {};
1431
1302
  await Promise.all(Object.entries(clis)
1432
1303
  .filter(([, e]) => e.installed)
@@ -1438,6 +1309,41 @@ export function registerDoctorCommand(program) {
1438
1309
  /* advisory only */
1439
1310
  }
1440
1311
  }));
1312
+ // Per-version resource reports drive the missing-hook / missing-plugin /
1313
+ // unwired / content-drift findings. Non-project layers only (the global
1314
+ // home is never reconciled against per-cwd project resources).
1315
+ const reports = [];
1316
+ for (const agent of ALL_AGENT_IDS) {
1317
+ for (const version of listInstalledVersions(agent)) {
1318
+ reports.push(diffVersionResources(agent, version, { cwd, excludeProject: true }));
1319
+ }
1320
+ }
1321
+ // A MANAGED agent (installed versions) whose binary won't resolve is a
1322
+ // real critical — an unmanaged-and-absent agent is not.
1323
+ const cliMissing = ALL_AGENT_IDS.filter((a) => listInstalledVersions(a).length > 0 && clis[a] && !clis[a].installed);
1324
+ // An isolated copy is skipped by the agent-wide `--fix` sweep, so its
1325
+ // findings must never fold into a collapsed cross-version row.
1326
+ const isolatedVersions = reports
1327
+ .filter((r) => isVersionIsolated(r.agent, r.version))
1328
+ .map((r) => `${r.agent}@${r.version}`);
1329
+ const findings = buildLocalFindings({
1330
+ device: localName,
1331
+ syncRows,
1332
+ orphanRows,
1333
+ repoBehind: repoBehindMarkers,
1334
+ reports,
1335
+ signIn: inventory.signIn ?? {},
1336
+ cliMissing,
1337
+ duplicateHooks,
1338
+ hostClis: toHostCliInput(hostClis),
1339
+ rcSecrets: scanUserRcFiles(),
1340
+ // getEffectiveExecutionPolicy spawns powershell — a doomed process on
1341
+ // POSIX, where the advisory never applies. Probe only on Windows.
1342
+ execPolicy: process.platform === 'win32'
1343
+ ? { platform: process.platform, policy: getEffectiveExecutionPolicy() }
1344
+ : undefined,
1345
+ isolatedVersions,
1346
+ });
1441
1347
  if (opts.json) {
1442
1348
  console.log(JSON.stringify({
1443
1349
  clis,
@@ -1453,12 +1359,14 @@ export function registerDoctorCommand(program) {
1453
1359
  // reading `sync`/`orphans`/`repos` are unaffected.
1454
1360
  health: computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, duplicateHooks),
1455
1361
  duplicateHooks,
1362
+ // Prioritized RUSH-2069 findings (critical/warning, per-version, with
1363
+ // remediation). Additive alongside the legacy fields above.
1364
+ findings,
1456
1365
  // This host's harness inventory — installed resources per kind,
1457
- // installed version ids per agent, and `.agents`/`.system` repo
1458
- // state — so `agents doctor --devices` can compare presence across
1459
- // the fleet and flag a resource/version present here but missing
1460
- // elsewhere (RUSH-2027). Read-only; no network.
1461
- fleet: collectLocalFleetInventory(cwd),
1366
+ // installed version ids per agent, `.agents`/`.system` repo state, and
1367
+ // per-version sign-in — so `agents doctor --devices` can compare
1368
+ // presence and sign-in across the fleet (RUSH-2027/2069). Read-only.
1369
+ fleet: inventory,
1462
1370
  hostClis: {
1463
1371
  statuses: hostClis.statuses.map((s) => ({
1464
1372
  name: s.manifest.name,
@@ -1480,11 +1388,17 @@ export function registerDoctorCommand(program) {
1480
1388
  }, null, 2));
1481
1389
  return;
1482
1390
  }
1483
- renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers, duplicateHooks);
1484
- // Point at the interactive reconcile when anything is out of sync — the
1485
- // report shouldn't be a dead end. `agents status` runs the unified
1486
- // home-reading engine and offers to sync (opt-in, never auto-fires here).
1487
- // Unwired hooks and a behind-origin repo count as out-of-sync too.
1391
+ // Single-machine hybrid: CRITICAL section + one `▸ <machine>` block.
1392
+ const accounts = {
1393
+ [localName]: inventory.signIn ?? {},
1394
+ };
1395
+ const header = `${chalk.gray('agents doctor ·')} ${chalk.hex('#a3e635')(localName)}${chalk.gray(` ${getCliVersion()}`)}`;
1396
+ for (const line of renderFindings(findings, accounts, { fleet: false, baseline: localName, header })) {
1397
+ console.log(line);
1398
+ }
1399
+ // Point at the interactive reconcile when anything is out of sync — each
1400
+ // finding carries its own fix, but `agents status` is the one place that
1401
+ // reviews and applies them together (opt-in, never auto-fires here).
1488
1402
  if (syncRows.some((r) => r.status !== 'fresh' || (r.unwiredHooks ?? 0) > 0) || repoBehindMarkers.some((m) => m.behind > 0)) {
1489
1403
  console.log(chalk.gray('\nRun `agents status` to review and sync what has drifted.'));
1490
1404
  }