@phnx-labs/agents-cli 1.22.74 → 1.22.76

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 (148) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +21 -9
  3. package/dist/bootstrap.js +7 -7
  4. package/dist/cli/command-registry.js +5 -0
  5. package/dist/commands/artifacts-setup.js +1 -1
  6. package/dist/commands/artifacts.js +1 -1
  7. package/dist/commands/auth.js +7 -1
  8. package/dist/commands/browser.js +104 -10
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/computer.d.ts +1 -0
  11. package/dist/commands/computer.js +26 -7
  12. package/dist/commands/config.js +27 -4
  13. package/dist/commands/cost.js +6 -4
  14. package/dist/commands/doctor.d.ts +6 -5
  15. package/dist/commands/doctor.js +32 -274
  16. package/dist/commands/exec.d.ts +2 -0
  17. package/dist/commands/exec.js +9 -2
  18. package/dist/commands/harness.d.ts +1 -0
  19. package/dist/commands/harness.js +11 -3
  20. package/dist/commands/hooks.js +7 -6
  21. package/dist/commands/mcp.js +7 -6
  22. package/dist/commands/memory.js +7 -7
  23. package/dist/commands/monitors.js +3 -2
  24. package/dist/commands/open.d.ts +25 -12
  25. package/dist/commands/open.js +24 -10
  26. package/dist/commands/permissions.js +7 -6
  27. package/dist/commands/plugins.js +21 -17
  28. package/dist/commands/route.js +33 -16
  29. package/dist/commands/rules.js +7 -12
  30. package/dist/commands/sessions-share.js +1 -1
  31. package/dist/commands/setup-watchdog.js +2 -2
  32. package/dist/commands/setup.js +22 -1
  33. package/dist/commands/share.js +26 -10
  34. package/dist/commands/skills.js +7 -6
  35. package/dist/commands/subagents.js +7 -6
  36. package/dist/commands/sync.js +81 -10
  37. package/dist/commands/view.js +4 -1
  38. package/dist/commands/watchdog.d.ts +1 -1
  39. package/dist/commands/watchdog.js +10 -10
  40. package/dist/commands/webhook.d.ts +4 -0
  41. package/dist/commands/webhook.js +22 -4
  42. package/dist/commands/workflows.js +7 -6
  43. package/dist/lib/account-registry.js +27 -6
  44. package/dist/lib/accounting/rotate.d.ts +3 -1
  45. package/dist/lib/accounting/rotate.js +8 -4
  46. package/dist/lib/auth-health.d.ts +2 -0
  47. package/dist/lib/auth-health.js +2 -0
  48. package/dist/lib/browser/chrome.d.ts +21 -0
  49. package/dist/lib/browser/chrome.js +60 -3
  50. package/dist/lib/browser/drivers/local.d.ts +21 -0
  51. package/dist/lib/browser/drivers/local.js +102 -9
  52. package/dist/lib/browser/profiles.d.ts +29 -1
  53. package/dist/lib/browser/profiles.js +50 -1
  54. package/dist/lib/browser/types.d.ts +18 -0
  55. package/dist/lib/computer/computer-rpc.d.ts +6 -1
  56. package/dist/lib/computer/computer-rpc.js +23 -3
  57. package/dist/lib/computer/des.d.ts +1 -0
  58. package/dist/lib/computer/des.js +114 -0
  59. package/dist/lib/computer/rfb-client.d.ts +53 -0
  60. package/dist/lib/computer/rfb-client.js +562 -0
  61. package/dist/lib/config-keys.d.ts +7 -2
  62. package/dist/lib/config-keys.js +17 -2
  63. package/dist/lib/daemon/auth-sync-service.js +3 -0
  64. package/dist/lib/daemon/daemon.js +17 -10
  65. package/dist/lib/daemon/session-summarizer-service.d.ts +24 -0
  66. package/dist/lib/daemon/session-summarizer-service.js +39 -0
  67. package/dist/lib/daemon/usage-sync-service.js +3 -0
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/daemon-ticks.d.ts +2 -2
  71. package/dist/lib/daemon-ticks.js +2 -1
  72. package/dist/lib/daemon-webhooks.js +15 -2
  73. package/dist/lib/deeplink/register.js +10 -9
  74. package/dist/lib/deeplink/url.d.ts +4 -4
  75. package/dist/lib/deeplink/url.js +4 -4
  76. package/dist/lib/device-config.js +25 -0
  77. package/dist/lib/devices/doctor-findings.d.ts +4 -4
  78. package/dist/lib/devices/doctor-findings.js +14 -8
  79. package/dist/lib/devices/registry.js +2 -0
  80. package/dist/lib/devices/stats-cache.d.ts +4 -0
  81. package/dist/lib/devices/stats-cache.js +19 -0
  82. package/dist/lib/drift-sync.d.ts +3 -1
  83. package/dist/lib/drift-sync.js +16 -5
  84. package/dist/lib/exec.d.ts +2 -0
  85. package/dist/lib/exec.js +16 -1
  86. package/dist/lib/fleet-shared-repo-sync.d.ts +12 -0
  87. package/dist/lib/fleet-shared-repo-sync.js +101 -4
  88. package/dist/lib/fleet-shared-state.d.ts +8 -0
  89. package/dist/lib/heal.d.ts +4 -3
  90. package/dist/lib/heal.js +5 -4
  91. package/dist/lib/hosts/ready.d.ts +1 -1
  92. package/dist/lib/hosts/ready.js +16 -4
  93. package/dist/lib/hosts/reconnect.js +4 -2
  94. package/dist/lib/identity/client.d.ts +6 -0
  95. package/dist/lib/identity/index.d.ts +16 -0
  96. package/dist/lib/identity/index.js +25 -1
  97. package/dist/lib/profiles.d.ts +2 -0
  98. package/dist/lib/profiles.js +28 -9
  99. package/dist/lib/reconcile-and-repair.d.ts +109 -0
  100. package/dist/lib/reconcile-and-repair.js +267 -0
  101. package/dist/lib/routers.d.ts +12 -1
  102. package/dist/lib/routers.js +30 -1
  103. package/dist/lib/scheduling/routines.js +8 -2
  104. package/dist/lib/session/active.d.ts +13 -0
  105. package/dist/lib/session/db.d.ts +47 -7
  106. package/dist/lib/session/db.js +114 -12
  107. package/dist/lib/session/mirror.js +58 -0
  108. package/dist/lib/session/remote/remote-list.d.ts +2 -0
  109. package/dist/lib/session/remote/remote-list.js +4 -0
  110. package/dist/lib/session/remote/watch.js +22 -2
  111. package/dist/lib/session/session-cache.d.ts +19 -0
  112. package/dist/lib/session/session-cache.js +46 -0
  113. package/dist/lib/session/types.d.ts +34 -0
  114. package/dist/lib/share/backend.d.ts +6 -4
  115. package/dist/lib/share/backend.js +10 -8
  116. package/dist/lib/share/config.d.ts +4 -3
  117. package/dist/lib/share/config.js +10 -1
  118. package/dist/lib/share/delete.d.ts +1 -1
  119. package/dist/lib/share/delete.js +1 -1
  120. package/dist/lib/share/html.d.ts +1 -1
  121. package/dist/lib/share/html.js +1 -1
  122. package/dist/lib/share/provision.d.ts +1 -1
  123. package/dist/lib/share/provision.js +2 -2
  124. package/dist/lib/share/publish.d.ts +23 -7
  125. package/dist/lib/share/publish.js +58 -12
  126. package/dist/lib/share/worker-template.js +221 -60
  127. package/dist/lib/startup/command-registry.js +2 -2
  128. package/dist/lib/state.d.ts +15 -0
  129. package/dist/lib/state.js +29 -7
  130. package/dist/lib/summarizer/config.d.ts +46 -0
  131. package/dist/lib/summarizer/config.js +83 -0
  132. package/dist/lib/summarizer/pass.d.ts +45 -0
  133. package/dist/lib/summarizer/pass.js +112 -0
  134. package/dist/lib/summarizer/summarize.d.ts +68 -0
  135. package/dist/lib/summarizer/summarize.js +120 -0
  136. package/dist/lib/teams/agents.d.ts +4 -3
  137. package/dist/lib/teams/agents.js +12 -4
  138. package/dist/lib/teams/scheduler.d.ts +4 -2
  139. package/dist/lib/teams/scheduler.js +6 -6
  140. package/dist/lib/tmux/session.d.ts +2 -0
  141. package/dist/lib/tmux/session.js +7 -1
  142. package/dist/lib/types.d.ts +20 -0
  143. package/dist/lib/verbs.d.ts +23 -0
  144. package/dist/lib/verbs.js +24 -0
  145. package/dist/lib/view-types.d.ts +4 -0
  146. package/dist/lib/watchdog/rotate.d.ts +1 -1
  147. package/dist/lib/watchdog/rotate.js +1 -1
  148. package/package.json +1 -1
@@ -3,7 +3,7 @@ import * as fs from 'fs';
3
3
  import * as os from 'os';
4
4
  import * as path from 'path';
5
5
  import { registerCommandGroups } from '../lib/help.js';
6
- import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer/computer-rpc.js';
6
+ import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, resolveVncEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer/computer-rpc.js';
7
7
  import { setupRemoteHelper, startRemoteTunnel, stopRemoteHelper, hydrateRemoteEnvFromState, readRemoteState, resolveRemoteDevice, REMOTE_TASK_NAME, WIN_HELPER_EXE, } from '../lib/computer/ssh-tunnel.js';
8
8
  import { sshExec } from '../lib/ssh-exec.js';
9
9
  import { encodePowershell } from '../lib/hosts/remote-cmd.js';
@@ -41,6 +41,8 @@ export function shouldBlockOffPlatform(opts) {
41
41
  return false;
42
42
  if (opts.tcpConfigured)
43
43
  return false; // remote (Windows) daemon over a tunnel
44
+ if (opts.vncConfigured)
45
+ return false; // RFB/VNC desktop (Linux GUI over the wire)
44
46
  if (opts.device)
45
47
  return false; // remote path resolves its own endpoint
46
48
  return true;
@@ -84,20 +86,37 @@ export function reconcileScreenshotExt(outPath, buf) {
84
86
  export function registerComputerCommand(program) {
85
87
  const computer = program
86
88
  .command('computer')
87
- .description('Drive macOS apps via Accessibility, or a remote Windows device with --device — list, screenshot, click, type')
89
+ .description('Drive macOS apps via Accessibility, a Linux GUI desktop with --vnc, or a remote Windows device with --device — screenshot, click, type')
90
+ // A VNC/RFB desktop is driven over the wire (--vnc host:port, e.g. an x11vnc
91
+ // server on a headless Linux box or an LXD container). Set it before the gate.
92
+ .option('--vnc <host:port>', 'Drive a GUI desktop over VNC/RFB (x11vnc/Xvnc; port defaults to 5901) instead of a native helper')
93
+ .option('--vnc-password <password>', 'VNC password for --vnc (or set COMPUTER_HELPER_VNC_PASSWORD)')
88
94
  // The whole subsystem is macOS Accessibility / TCC for LOCAL driving. Off
89
- // macOS it still works against a remote daemon (COMPUTER_HELPER_TCP set, or
90
- // a `--device <name>` invocation). Fail fast with a clear message only when
91
- // neither remote path is available, instead of a downstream launchctl error.
95
+ // macOS it still works against a remote daemon (COMPUTER_HELPER_TCP set, a
96
+ // --vnc desktop, or a `--device <name>` invocation). Fail fast with a clear
97
+ // message only when no remote path is available, instead of a downstream error.
92
98
  .hook('preAction', async (_thisCommand, actionCommand) => {
93
- const device = actionCommand.opts().device;
99
+ const globals = actionCommand.optsWithGlobals();
100
+ // --vnc selects the RFB transport for every verb under this command.
101
+ if (globals.vnc) {
102
+ process.env.COMPUTER_HELPER_VNC = globals.vnc;
103
+ if (globals.vncPassword)
104
+ process.env.COMPUTER_HELPER_VNC_PASSWORD = globals.vncPassword;
105
+ }
106
+ const device = globals.device;
94
107
  // Verbs with --device reconnect to the tunnel `start --device` recorded;
95
108
  // this sets COMPUTER_HELPER_TCP so the shared client picks the TCP transport.
96
109
  if (device && !REMOTE_LIFECYCLE.has(actionCommand.name())) {
97
110
  hydrateRemoteEnvFromState(device);
98
111
  }
99
- if (shouldBlockOffPlatform({ platform: process.platform, tcpConfigured: resolveTcpEndpoint() != null, device })) {
112
+ if (shouldBlockOffPlatform({
113
+ platform: process.platform,
114
+ tcpConfigured: resolveTcpEndpoint() != null,
115
+ vncConfigured: resolveVncEndpoint() != null,
116
+ device,
117
+ })) {
100
118
  console.error('agents computer: macOS only for local driving — it uses the macOS Accessibility API.');
119
+ console.error('For a Linux GUI desktop over VNC: `agents computer --vnc <host:port> screenshot`.');
101
120
  console.error('For a remote Windows device: register it with `agents devices`, then use --device (or set COMPUTER_HELPER_TCP).');
102
121
  process.exit(1);
103
122
  }
@@ -45,6 +45,8 @@ function parseValue(key, parsed, raw) {
45
45
  return raw.trim();
46
46
  case 'project':
47
47
  return raw.trim();
48
+ case 'summarizer':
49
+ return parsed.property === 'enabled' ? parseBool(raw, key) : raw.trim();
48
50
  case 'device': {
49
51
  const property = parsed.property;
50
52
  switch (property) {
@@ -126,6 +128,10 @@ function setConfig(parsed, value) {
126
128
  case 'project':
127
129
  setProjectRoot(value);
128
130
  return;
131
+ case 'summarizer': {
132
+ setConfigValue(`summarizer.${parsed.property}`, value);
133
+ return;
134
+ }
129
135
  case 'device': {
130
136
  const configName = devicePropertyToConfigName(parsed.property);
131
137
  if (parsed.property === 'notes') {
@@ -176,10 +182,18 @@ function unsetConfig(parsed) {
176
182
  }
177
183
  case 'project': {
178
184
  const had = getProjectRoot() !== undefined;
179
- updateMeta((meta) => {
180
- const { projectRoot: _projectRoot, ...rest } = meta;
181
- return rest;
182
- });
185
+ // `projectRoot` is a machine-local key that `writeMeta` persists to the
186
+ // device doc; it only clears that doc when the key is PRESENT-but-falsy
187
+ // (`writesProjectRoot` needs the own-property, state.ts). Dropping the key
188
+ // from the returned object left the device-doc value in place, so
189
+ // `config get` still returned it after unset. Set it undefined instead.
190
+ updateMeta((meta) => ({ ...meta, projectRoot: undefined }));
191
+ return had;
192
+ }
193
+ case 'summarizer': {
194
+ const name = `summarizer.${parsed.property}`;
195
+ const had = getConfigValue(name).value !== undefined;
196
+ unsetConfigValue(name);
183
197
  return had;
184
198
  }
185
199
  case 'device': {
@@ -212,6 +226,8 @@ function getConfig(parsed) {
212
226
  }
213
227
  case 'project':
214
228
  return getProjectRoot();
229
+ case 'summarizer':
230
+ return getConfigValue(`summarizer.${parsed.property}`).value;
215
231
  case 'device': {
216
232
  const configName = devicePropertyToConfigName(parsed.property);
217
233
  return getConfigValue(configName, { device: parsed.device }).value;
@@ -287,6 +303,13 @@ function* listCentralConfigEntries() {
287
303
  const key = 'browser.device';
288
304
  yield { key, value: browserDevice, hint: configKeyStorageHint(parseConfigKey(key)) };
289
305
  }
306
+ // Session-summarizer keys (PHNX-3939) — user scope, syncs fleet-wide.
307
+ for (const key of ['summarizer.enabled', 'summarizer.baseUrl', 'summarizer.model']) {
308
+ const value = getConfigValue(key).value;
309
+ if (value !== undefined) {
310
+ yield { key, value, hint: configKeyStorageHint(parseConfigKey(key)) };
311
+ }
312
+ }
290
313
  }
291
314
  /** Collect device-scope config entries. */
292
315
  function* listDeviceConfigEntries(device) {
@@ -11,12 +11,13 @@ export function registerCostCommand(insightsCmd) {
11
11
  .description('Roll up $ cost and duration across local agent sessions')
12
12
  .option('--json', 'Output the rollup as JSON')
13
13
  .option('--since <time>', 'Only sessions newer than this (e.g., 7d, 4w, or ISO date)')
14
- .option('--by <dimension>', 'Group the breakdown by: agent (default), project, day, or account (the Claude org that produced each session)')
14
+ .option('--by <dimension>', 'Group the breakdown by: agent (default), project, day, model, or account (the Claude org that produced each session)')
15
15
  .addHelpText('after', `
16
16
  Examples:
17
17
  agents insights cost Daily histogram + top sessions + per-agent breakdown
18
18
  agents insights cost --since 30d Last 30 days only
19
19
  agents insights cost --by project Break down by project instead of agent
20
+ agents insights cost --by model --json Per-model output tokens and recorded cost
20
21
  agents insights cost --by day --json Machine-readable daily rollup
21
22
 
22
23
  Cost is computed offline from a versioned per-model price table (${PRICING_VERSION}).
@@ -35,9 +36,9 @@ Cost is computed offline from a versioned per-model price table (${PRICING_VERSI
35
36
  function resolveGroup(by) {
36
37
  if (by === undefined)
37
38
  return 'agent';
38
- if (by === 'agent' || by === 'project' || by === 'day' || by === 'account')
39
+ if (by === 'agent' || by === 'project' || by === 'day' || by === 'model' || by === 'account')
39
40
  return by;
40
- console.error(chalk.red('error: --by must be one of: agent, project, day, account'));
41
+ console.error(chalk.red('error: --by must be one of: agent, project, day, model, account'));
41
42
  process.exit(1);
42
43
  }
43
44
  async function costAction(options) {
@@ -111,7 +112,8 @@ async function costAction(options) {
111
112
  const groupLabel = groupBy === 'agent' ? 'agent'
112
113
  : groupBy === 'project' ? 'project'
113
114
  : groupBy === 'account' ? 'account'
114
- : 'day';
115
+ : groupBy === 'model' ? 'model'
116
+ : 'day';
115
117
  out.push(chalk.bold(`By ${groupLabel}`));
116
118
  const cols = terminalWidth();
117
119
  const costW2 = Math.max(...breakdown.map(r => formatUsd(r.costUsd).length), 4);
@@ -15,11 +15,12 @@
15
15
  * unified diff body for each divergent file. Mirrors the resolution that
16
16
  * the shim drives at runtime: project > user > system > extras.
17
17
  *
18
- * Read-only by default: doctor diagnoses, it doesn't mutate. Pass `--fix` to
19
- * heal the gaps it finds (install missing resources, repair Claude-invalid
20
- * plugin manifests, refresh stale plugins, reconcile drift). Run
21
- * `agents prune cleanup` to act on orphan readouts, or just launch the agent to
22
- * apply pending sync.
18
+ * Read-only always: doctor diagnoses, it never mutates. To fix the gaps it
19
+ * finds — install missing resources, repair Claude-invalid plugin manifests,
20
+ * refresh stale plugins, reconcile drift, repair hook runtime shims — run
21
+ * `agents sync <agent>@all` (the one fixer, a superset of the old `doctor
22
+ * --fix`). Run `agents prune cleanup` to act on orphan readouts. `--fix` is a
23
+ * deprecated no-op that now errors with a pointer to `agents sync`.
23
24
  */
24
25
  import type { Command } from 'commander';
25
26
  import { type FleetInventory } from '../lib/devices/fleet-divergence.js';
@@ -4,7 +4,7 @@ import { addHostOption } from '../lib/hosts/option.js';
4
4
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
5
5
  import { loadDevices } from '../lib/devices/registry.js';
6
6
  import { fanOutDevices, planFleetTargets, remoteFleetTargets } from '../lib/devices/fleet.js';
7
- import { enterDoctorOverviewGate, invalidateDoctorOverviewCache, writeDoctorOverviewCache } from '../lib/devices/doctor-overview-cache.js';
7
+ import { enterDoctorOverviewGate, writeDoctorOverviewCache } from '../lib/devices/doctor-overview-cache.js';
8
8
  import { fleetDialTarget } from '../lib/devices/connect.js';
9
9
  import { compareFleetInventories, FLEET_HOOK_RUNTIME_STATES } from '../lib/devices/fleet-divergence.js';
10
10
  import { collectLocalFleetInventory } from '../lib/devices/fleet-inventory.js';
@@ -23,7 +23,7 @@ import { getVersionHomePath, listInstalledVersions, } from '../lib/installations
23
23
  import { resolveAgentTargets, AgentSpecError } from '../lib/agent-spec/index.js';
24
24
  import { loadManifest, isStale } from '../lib/staleness/index.js';
25
25
  import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
26
- import { checkVersionHookWiring, inspectDuplicateVersionHooks, registerHooksToSettings, repairManagedHookRuntimeArtifacts } from '../lib/hooks/install.js';
26
+ import { inspectDuplicateVersionHooks } from '../lib/hooks/install.js';
27
27
  import { inspectReservedAuthBundle } from '../lib/secrets/bundles.js';
28
28
  import { isVersionIsolated } from '../lib/installations/versions.js';
29
29
  import { computeDrift, checkSyncStatus, countOrphans, computeSourceBehind } from '../lib/drift.js';
@@ -33,18 +33,13 @@ import { probeOwnerSink } from '../lib/channels/owner-sink.js';
33
33
  import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
34
34
  import { listCliStatus, listCliStatusAsync } from '../lib/cli-resources.js';
35
35
  import { setHelpSections } from '../lib/help.js';
36
- import { heal, healChangedAnything } from '../lib/heal.js';
37
36
  import { getEffectiveExecutionPolicy } from '../lib/platform/winpath.js';
38
37
  import { auditWindowsSshEnrollment, diagnoseWindowsSshFailure } from '../lib/devices/windows-ssh-enrollment.js';
39
38
  import { scanUserRcFiles, masterPassphraseInEnv } from '../lib/secrets/rc-hygiene.js';
40
39
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
41
40
  import { readRepoBehindMarkers } from '../lib/auto-pull.js';
42
41
  import { detectAgentsBinaryShadows } from '../lib/binary-shadow.js';
43
- import { remediateStaleAgentsCliInstalls, resolveRunningPackageRoot, } from '../lib/self-update.js';
44
42
  import * as fs from 'fs';
45
- import * as path from 'path';
46
- import { fileURLToPath } from 'url';
47
- const __doctorDirname = path.dirname(fileURLToPath(import.meta.url));
48
43
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
49
44
  // ─── overview mode (no target) ────────────────────────────────────────────────
50
45
  function collapseWhitespace(s) {
@@ -521,7 +516,7 @@ function parseTargetArg(arg) {
521
516
  const agent = resolveAgentName(agentPart);
522
517
  if (!agent)
523
518
  return { error: formatAgentError(agentPart) };
524
- // No qualifier → non-isolated sweep of every installed version; --fix uses the non-isolated path
519
+ // No qualifier → diagnose every installed version of the agent.
525
520
  if (!qualifier) {
526
521
  const versions = listInstalledVersions(agent);
527
522
  if (versions.length === 0)
@@ -659,11 +654,8 @@ function readExpectedForDiff(kind, row) {
659
654
  return null;
660
655
  return safeRead(row.sourcePath);
661
656
  }
662
- // Family of agents whose hooks re-wire through registerHooksToSettings into a
663
- // Claude-style settings.json (matches checkVersionHookWiring's supported set).
664
- const HOOK_WIRING_FIX_AGENTS = ['claude', 'droid'];
665
- /** Categories `--fix` reconciles (vs. `agents repo pull` for a behind source, or
666
- * `agents prune cleanup` for an orphan). Drives the heal footer. */
657
+ /** Categories `agents sync` reconciles (vs. `agents repo pull` for a behind
658
+ * source, or `agents prune cleanup` for an orphan). Drives the heal footer. */
667
659
  const AUTO_FIXABLE_CATEGORIES = new Set([
668
660
  'hook-runtime-broken', 'unwired-hook', 'settings-missing', 'settings-unparseable', 'missing', 'divergent', 'stale', 'never-synced',
669
661
  ]);
@@ -693,8 +685,10 @@ function missingResourceSeverity(kind) {
693
685
  export function computeVerdict(report) {
694
686
  const issues = [];
695
687
  const idLabel = `${report.agent}@${report.version}`;
696
- const fixCmd = `agents doctor ${idLabel} --fix`;
697
- const syncCmd = `agents sync ${idLabel} --yes`;
688
+ // doctor diagnoses; `agents sync` fixes. Every auto-fixable finding points at
689
+ // the one fixer — a command sync can actually deliver (asserted in the tests).
690
+ const fixCmd = `agents sync ${idLabel} --yes`;
691
+ const syncCmd = fixCmd;
698
692
  // ── critical: settings.json / unwired hooks (silent breakage) ──
699
693
  const w = report.hookWiring;
700
694
  for (const issue of w?.runtimeBroken ?? []) {
@@ -897,7 +891,7 @@ export function computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, d
897
891
  issues.push({
898
892
  severity: 'critical', category: 'hook-runtime-broken', subject: label,
899
893
  impact: `${brokenRuntime} generated hook wrapper${brokenRuntime === 1 ? '' : 's'} unusable; affected hooks cannot run`,
900
- fix: `agents doctor ${row.agent}@${row.version} --fix`,
894
+ fix: `agents sync ${row.agent}@${row.version} --yes`,
901
895
  text: `${label} ${brokenRuntime} hook runtime broken`, color: 'red',
902
896
  });
903
897
  }
@@ -931,7 +925,7 @@ export function computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, d
931
925
  issues.push({
932
926
  severity: 'warning', category: 'stale', subject: label,
933
927
  impact: 'sources changed since last sync',
934
- fix: `agents doctor ${row.agent}@${row.version} --fix`,
928
+ fix: `agents sync ${row.agent}@${row.version} --yes`,
935
929
  text: `${label} stale`, color: 'yellow',
936
930
  });
937
931
  }
@@ -1040,247 +1034,16 @@ function renderTargetText(report, options) {
1040
1034
  }
1041
1035
  console.log();
1042
1036
  const verdict = computeVerdict(report);
1043
- // A source layer behind origin is healed by `agents repo pull`, not `--fix` —
1037
+ // A source layer behind origin is healed by `agents repo pull`, not by sync —
1044
1038
  // the per-issue fix already names the right command, so the heal footer only
1045
- // shows when something is genuinely `--fix`-able (never in the source-behind or
1039
+ // shows when something is genuinely sync-fixable (never in the source-behind or
1046
1040
  // orphan-only case, where it would mislead).
1047
1041
  const hooksWired = report.hookWiring?.supported ? ' · hooks wired' : '';
1048
1042
  renderHealthBlock(verdict, {
1049
1043
  healthySummary: `${verdict.reconciled} resource${verdict.reconciled === 1 ? '' : 's'} reconciled${hooksWired} · sources current`,
1050
- healFix: verdictIsAutoFixable(verdict) ? `agents doctor ${report.agent}@${report.version} --fix` : undefined,
1044
+ healFix: verdictIsAutoFixable(verdict) ? `agents sync ${report.agent}@${report.version} --yes` : undefined,
1051
1045
  });
1052
1046
  }
1053
- // ─── fix / heal mode ───────────────────────────────────────────────────────────
1054
- function renderHealText(result) {
1055
- for (const r of result.repairedManifests) {
1056
- console.log(` ${chalk.green('repair')} plugin ${chalk.bold(r.plugin)} ${chalk.gray(`— dropped invalid ${r.droppedFields.join(', ')} field`)}`);
1057
- }
1058
- for (const r of result.refreshedPlugins) {
1059
- console.log(` ${chalk.green('refresh')} plugin ${chalk.bold(r.plugin)} ${chalk.gray(`${r.from} → ${r.to}`)}`);
1060
- }
1061
- for (const s of result.skippedPlugins) {
1062
- const why = s.reason === 'modified'
1063
- ? `locally modified — left as-is (run \`agents plugins update ${s.plugin}\` to force)`
1064
- : `no baseline recorded — left as-is (run \`agents plugins update ${s.plugin}\` to adopt)`;
1065
- console.log(` ${chalk.yellow('hold ')} plugin ${chalk.bold(s.plugin)} ${chalk.gray(`${s.from} → ${s.upstream} available; ${why}`)}`);
1066
- }
1067
- for (const v of result.versions) {
1068
- const label = `${AGENT_NAMES[v.agent] || v.agent}@${v.version}`;
1069
- if (v.healed.length === 0 && v.skipped.length === 0)
1070
- continue;
1071
- const byKind = new Map();
1072
- for (const h of v.healed)
1073
- byKind.set(h.kind, (byKind.get(h.kind) ?? 0) + 1);
1074
- const parts = Array.from(byKind, ([k, n]) => `${n} ${k}`);
1075
- if (v.healed.length > 0) {
1076
- console.log(` ${chalk.green('fixed ')} ${label} ${chalk.gray(parts.join(', '))}`);
1077
- }
1078
- const drift = v.skipped.filter((s) => s.reason === 'drift');
1079
- const unres = v.skipped.filter((s) => s.reason === 'unreconcilable');
1080
- if (drift.length > 0) {
1081
- console.log(` ${chalk.yellow('drift ')} ${label} ${chalk.gray(`${drift.length} hand-edited — left as-is (use \`--diff\` to inspect)`)}`);
1082
- }
1083
- if (unres.length > 0) {
1084
- const names = unres.map((s) => `${s.kind}/${s.name}`).join(', ');
1085
- console.log(` ${chalk.yellow('hold ')} ${label} ${chalk.gray(`${unres.length} couldn't reconcile (${names}) — source/home mismatch the writer can't satisfy`)}`);
1086
- }
1087
- }
1088
- console.log();
1089
- const healed = result.versions.reduce((n, v) => n + v.healed.length, 0);
1090
- const touchedVersions = result.versions.filter((v) => v.healed.length > 0).length;
1091
- if (!healChangedAnything(result)) {
1092
- console.log(chalk.green('✓ Everything in sync — nothing to heal.'));
1093
- }
1094
- else {
1095
- const bits = [];
1096
- if (healed > 0)
1097
- bits.push(`${healed} resource${healed === 1 ? '' : 's'} across ${touchedVersions} version${touchedVersions === 1 ? '' : 's'}`);
1098
- if (result.repairedManifests.length > 0)
1099
- bits.push(`${result.repairedManifests.length} manifest${result.repairedManifests.length === 1 ? '' : 's'} repaired`);
1100
- if (result.refreshedPlugins.length > 0)
1101
- bits.push(`${result.refreshedPlugins.length} plugin${result.refreshedPlugins.length === 1 ? '' : 's'} refreshed`);
1102
- console.log(chalk.green(`✓ Healed ${bits.join(', ')}.`));
1103
- }
1104
- }
1105
- /**
1106
- * Re-wire hooks that reconcile as files but are absent from settings.json.
1107
- *
1108
- * heal() only re-syncs resources the diff flags missing/diff; a hook whose file
1109
- * is byte-identical to source but never referenced in settings.json is neither,
1110
- * so heal walks past it. registerHooksToSettings (the same call `agents sync`
1111
- * makes at versions.ts) regenerates the wiring, so run it for any Claude-family
1112
- * version this fix targets that has unwired hooks. Only claude/droid — the set
1113
- * checkVersionHookWiring can verify.
1114
- */
1115
- function rewireUnwiredHooks(parsed) {
1116
- const out = [];
1117
- const agents = parsed?.agent
1118
- ? (HOOK_WIRING_FIX_AGENTS.includes(parsed.agent) ? [parsed.agent] : [])
1119
- : HOOK_WIRING_FIX_AGENTS;
1120
- for (const agent of agents) {
1121
- // A named version is explicit consent; a sweep excludes isolated copies,
1122
- // mirroring heal().
1123
- const versions = parsed?.agent && parsed.versionExplicit
1124
- ? parsed.versions
1125
- : listInstalledVersions(agent).filter((v) => !isVersionIsolated(agent, v));
1126
- for (const version of versions) {
1127
- const before = checkVersionHookWiring(agent, version);
1128
- if (!before.supported)
1129
- continue;
1130
- const need = before.unwired.length + (before.settingsMissing ? (before.expected ?? 0) : 0);
1131
- if (need === 0)
1132
- continue;
1133
- try {
1134
- const registration = registerHooksToSettings(agent, getVersionHomePath(agent, version));
1135
- if (registration.errors.length > 0) {
1136
- out.push({ agent, version, rewired: 0, remaining: need, failure: 'register-failed' });
1137
- continue;
1138
- }
1139
- const after = checkVersionHookWiring(agent, version);
1140
- const remaining = after.unwired.length + (after.settingsMissing ? (after.expected ?? 0) : 0);
1141
- out.push({ agent, version, rewired: Math.max(0, need - remaining), remaining });
1142
- }
1143
- catch {
1144
- // A shim write can fail before the native config writer gets to return
1145
- // its own errors. Record the same stable class and keep doctor --fix
1146
- // moving to the one bounded runtime repair pass below.
1147
- out.push({ agent, version, rewired: 0, remaining: need, failure: 'register-failed' });
1148
- }
1149
- }
1150
- }
1151
- return out;
1152
- }
1153
- function renderHookRewireText(rewired) {
1154
- for (const r of rewired) {
1155
- const label = `${AGENT_NAMES[r.agent] || r.agent}@${r.version}`;
1156
- if (r.failure) {
1157
- console.log(` ${chalk.red('hold ')} ${label} ${chalk.gray('native hook wiring could not be updated')}`);
1158
- }
1159
- else if (r.remaining === 0) {
1160
- console.log(` ${chalk.green('rewired')} ${label} ${chalk.gray(`${r.rewired} hook${r.rewired === 1 ? '' : 's'} wired into settings.json`)}`);
1161
- }
1162
- else {
1163
- console.log(` ${chalk.yellow('hold ')} ${label} ${chalk.gray(`${r.remaining} hook${r.remaining === 1 ? '' : 's'} still unwired — run \`agents sync ${r.agent}@${r.version} --yes\``)}`);
1164
- }
1165
- }
1166
- }
1167
- function runtimeRepairFilter(parsed) {
1168
- if (!parsed)
1169
- return undefined;
1170
- // A concrete target can be narrowed to that version. Broad selectors (@all,
1171
- // @latest, agent-only) still make one bounded repair pass for that harness.
1172
- return {
1173
- agent: parsed.agent,
1174
- ...(parsed.versionExplicit && parsed.versions.length === 1 ? { version: parsed.versions[0] } : {}),
1175
- };
1176
- }
1177
- function renderHookRuntimeRepairText(repair) {
1178
- for (const fixed of repair.fixed) {
1179
- console.log(` ${chalk.green('fixed ')} ${chalk.gray(fixed)}`);
1180
- }
1181
- for (const unresolved of repair.needsAttention) {
1182
- console.log(` ${chalk.red('hold ')} ${chalk.gray(unresolved)}`);
1183
- }
1184
- }
1185
- /**
1186
- * RUSH-2415: delete npx-cache / unsafe-legacy / pre-1.22.30 agents-cli copies
1187
- * when a fixed peer already exists. Bare `doctor --fix` is the remediation
1188
- * surface the multi-install warning points at; a targeted
1189
- * `doctor <agent>@<version> --fix` only heals that version home and must not
1190
- * touch other CLI installs on the box.
1191
- */
1192
- function purgeStaleAgentsCliCopies(_opts) {
1193
- let runningRoot;
1194
- try {
1195
- // dist/commands/doctor.js (or src/commands/doctor.ts under vitest) —
1196
- // resolveRunningPackageRoot walks up until package.json names this package.
1197
- runningRoot = resolveRunningPackageRoot(__doctorDirname);
1198
- }
1199
- catch {
1200
- return null;
1201
- }
1202
- return remediateStaleAgentsCliInstalls({
1203
- runningRoot,
1204
- runningVersion: getCliVersion(),
1205
- });
1206
- }
1207
- function renderStaleInstallPurgeText(purge) {
1208
- if (purge.removed.length === 0 && purge.failed.length === 0 && purge.unresolved.length === 0)
1209
- return;
1210
- console.log(chalk.bold('\nStale agents-cli installs'));
1211
- for (const r of purge.removed) {
1212
- const why = r.reasons.join(', ');
1213
- console.log(` ${chalk.green('purged')} ${chalk.gray(`${r.packageRoot} ${r.version} (${why})`)}`);
1214
- }
1215
- for (const f of purge.failed) {
1216
- console.log(` ${chalk.red('hold ')} ${chalk.gray(`${f.packageRoot} ${f.version} — ${f.error}`)}`);
1217
- }
1218
- // RUSH-2705/2713: a duplicate --fix cannot auto-purge — either a healthy
1219
- // >=1.22.30 peer, OR a pre-1.22.30 copy left alone only because no fixed peer
1220
- // exists to fall back to (that one is NOT healthy). Either way, hand back the
1221
- // command that removes it instead of ending on a bare "everything in sync".
1222
- // Don't call it "healthy" — that would understate a genuinely vulnerable copy.
1223
- for (const u of purge.unresolved) {
1224
- console.log(` ${chalk.yellow('manual')} ${chalk.gray(`${u.packageRoot} ${u.version} — --fix will not delete this copy; remove it with:`)}`);
1225
- console.log(` ${chalk.bold(u.manualRemoveCommand)}`);
1226
- }
1227
- }
1228
- async function runFix(parsed, opts) {
1229
- // Heal targets the global install — project layer is irrelevant, so cwd is
1230
- // left to heal's neutral default rather than process.cwd().
1231
- if (!opts.json)
1232
- console.log(chalk.bold('Healing…'));
1233
- // `agents doctor <agent> --fix` is a SWEEP, same as the bare form — leave the
1234
- // version list to heal() so it applies its isolated-copy filter. Passing the
1235
- // enumerated list here would smuggle isolated versions past that filter.
1236
- // A named version (`<agent>@<version>`) is explicit consent and is passed through.
1237
- const result = await heal({
1238
- mode: 'full',
1239
- agent: parsed?.agent,
1240
- versions: parsed?.versionExplicit ? parsed.versions : undefined,
1241
- });
1242
- // Re-wire hooks the diff-driven heal leaves behind (present file, not wired).
1243
- const rewired = rewireUnwiredHooks(parsed);
1244
- // One inspect→generate→verify pass, after normal resource and native-wiring
1245
- // repair have settled. This routine never calls sync/register and never
1246
- // retries; unresolved wrappers remain an explicit non-zero doctor outcome.
1247
- const hookRuntimeRepair = repairManagedHookRuntimeArtifacts({ filter: runtimeRepairFilter(parsed) });
1248
- // Bare doctor --fix also purges latent pre-fix / legacy agents-cli copies
1249
- // that only warn today (RUSH-2415). A scoped agent@version fix leaves them
1250
- // alone — the multi-install surface is machine-wide, not per-agent.
1251
- const staleInstallPurge = parsed === null ? purgeStaleAgentsCliCopies(opts) : null;
1252
- const rewireFailed = rewired.some((entry) => entry.failure !== undefined);
1253
- if (healChangedAnything(result) ||
1254
- rewired.some((entry) => entry.rewired > 0 || entry.remaining > 0 || entry.failure !== undefined) ||
1255
- hookRuntimeRepair.attempts.length > 0 ||
1256
- (staleInstallPurge !== null && (staleInstallPurge.removed.length > 0 || staleInstallPurge.failed.length > 0))) {
1257
- invalidateDoctorOverviewCache();
1258
- }
1259
- if (opts.json) {
1260
- console.log(JSON.stringify({
1261
- ...result,
1262
- hookRewire: rewired,
1263
- hookRuntimeRepair,
1264
- ...(staleInstallPurge ? { staleInstallPurge } : {}),
1265
- }, null, 2));
1266
- if (rewireFailed
1267
- || hookRuntimeRepair.needsAttention.length > 0
1268
- || (staleInstallPurge !== null && staleInstallPurge.failed.length > 0)) {
1269
- process.exitCode = 1;
1270
- }
1271
- return;
1272
- }
1273
- renderHealText(result);
1274
- renderHookRewireText(rewired);
1275
- renderHookRuntimeRepairText(hookRuntimeRepair);
1276
- if (staleInstallPurge)
1277
- renderStaleInstallPurgeText(staleInstallPurge);
1278
- if (rewireFailed
1279
- || hookRuntimeRepair.needsAttention.length > 0
1280
- || (staleInstallPurge !== null && staleInstallPurge.failed.length > 0)) {
1281
- process.exitCode = 1;
1282
- }
1283
- }
1284
1047
  function checkLabel(row) {
1285
1048
  return `${AGENT_NAMES[row.agent] || row.agent}@${row.version}`;
1286
1049
  }
@@ -1360,7 +1123,7 @@ function runCheckGate(opts, cwd) {
1360
1123
  }
1361
1124
  const hints = [];
1362
1125
  if (drift.staleCount > 0 || drift.neverSyncedCount > 0 || drift.unwiredHookVersions > 0 || drift.brokenHookRuntimeVersions > 0) {
1363
- hints.push('`agents doctor --fix` (or `agents doctor <agent>@<version> --fix`)');
1126
+ hints.push('`agents sync <agent>@all` (or `agents sync <agent>@<version>`)');
1364
1127
  }
1365
1128
  if (drift.sourceBehind.length > 0)
1366
1129
  hints.push('`agents repo pull <alias>` for a source layer behind origin');
@@ -1447,7 +1210,7 @@ async function runDevicesCheck(opts, cwd) {
1447
1210
  : [`${d.stale} stale`, `${d.neverSynced} never-synced`].filter((p) => !p.startsWith('0 ')).join(', ');
1448
1211
  console.error(` ${chalk.yellow(d.device.padEnd(18))} ${detail || 'drift'}`);
1449
1212
  }
1450
- console.error(chalk.gray('\nReconcile each device with `agents doctor --fix` or `agents repo pull user`.'));
1213
+ console.error(chalk.gray('\nReconcile each device with `agents sync <agent>@all` or `agents repo pull user`.'));
1451
1214
  }
1452
1215
  process.exit(1);
1453
1216
  }
@@ -1457,7 +1220,7 @@ export function registerDoctorCommand(program) {
1457
1220
  .description('Diagnose CLI availability, sync status, and resource divergence (optionally for a specific agent[@version]).')
1458
1221
  .option('--json', 'Output machine-readable JSON')
1459
1222
  .option('--diff', 'In target mode, include unified diffs for divergent files')
1460
- .option('--fix', 'Heal gaps: install missing resources, repair invalid plugin manifests, refresh stale plugins, reconcile drift, and purge stale/legacy agents-cli installs (npx-cache, pre-1.22.30, unsafe helper installer) when a fixed peer exists')
1223
+ .option('--fix', '(deprecated) doctor is diagnose-only — this errors with a pointer to `agents sync`, the one fixer')
1461
1224
  .option('--kind <kinds>', 'Restrict to comma-separated resource kinds (commands,skills,hooks,rules,mcp,permissions,subagents,plugins,workflows,memory)')
1462
1225
  .option('--cwd <path>', 'Resolution cwd for project layer detection (default: process.cwd())')
1463
1226
  .option('--adopt <agent>', "Take over the agent's native launcher that shadows the shim (symlink it to the version-managed shim; reversible with --release)")
@@ -1488,11 +1251,11 @@ export function registerDoctorCommand(program) {
1488
1251
  # Inspect only rules and hooks, with full diffs
1489
1252
  agents doctor claude@default --kind rules,hooks --diff
1490
1253
 
1491
- # Heal every gap across all installed versions
1492
- agents doctor --fix
1254
+ # Fix every gap across all installed versions (the one fixer)
1255
+ agents sync claude@all
1493
1256
 
1494
- # Heal just one agent (all its installed versions)
1495
- agents doctor claude --fix
1257
+ # Fix just one version
1258
+ agents sync claude@2.1.207
1496
1259
 
1497
1260
  # Fleet: agent readiness + cross-device divergence (missing plugins/skills,
1498
1261
  # agent-version gaps, .agents/.system repo drift) vs this machine
@@ -1503,7 +1266,7 @@ export function registerDoctorCommand(program) {
1503
1266
  agents doctor --check --quiet # just the verdict line
1504
1267
  agents doctor --check --json # machine-readable, for scripting
1505
1268
  agents doctor --check --devices # gate every registered device
1506
- agents doctor --check || { echo "resources drifted — run 'agents doctor --fix'"; exit 1; }
1269
+ agents doctor --check || { echo "resources drifted — run 'agents sync <agent>@all'"; exit 1; }
1507
1270
  `,
1508
1271
  });
1509
1272
  doctorCmd.action(async (target, opts) => {
@@ -1587,20 +1350,15 @@ export function registerDoctorCommand(program) {
1587
1350
  }
1588
1351
  return;
1589
1352
  }
1590
- // --fix turns the read-only diagnosis into a heal. With no target it heals
1591
- // every installed version; with a target it scopes to that agent.
1353
+ // `doctor --fix` moved to `agents sync` — doctor is diagnose-only now.
1354
+ // Keep the flag for one release as a signpost: it never heals, it points at
1355
+ // the one fixer and exits non-zero so scripts and muscle memory get a clear
1356
+ // redirect instead of a silent no-op.
1592
1357
  if (opts.fix) {
1593
- let scope = null;
1594
- if (target) {
1595
- const parsed = parseTargetArg(target);
1596
- if ('error' in parsed) {
1597
- console.error(chalk.red(parsed.error));
1598
- process.exit(1);
1599
- }
1600
- scope = parsed;
1601
- }
1602
- await runFix(scope, opts);
1603
- return;
1358
+ const where = target ? `agents sync ${target}` : 'agents sync <agent>@all';
1359
+ console.error(chalk.yellow('`agents doctor --fix` has moved to `agents sync`.'));
1360
+ console.error(chalk.gray(` doctor now only diagnoses. To fix, run: ${where}`));
1361
+ process.exit(1);
1604
1362
  }
1605
1363
  if (!target) {
1606
1364
  // Singleflight + short-TTL cache for the bare `doctor --json` overview.
@@ -1666,8 +1424,8 @@ export function registerDoctorCommand(program) {
1666
1424
  // A MANAGED agent (installed versions) whose binary won't resolve is a
1667
1425
  // real critical — an unmanaged-and-absent agent is not.
1668
1426
  const cliMissing = ALL_AGENT_IDS.filter((a) => listInstalledVersions(a).length > 0 && clis[a] && !clis[a].installed);
1669
- // An isolated copy is skipped by the agent-wide `--fix` sweep, so its
1670
- // findings must never fold into a collapsed cross-version row.
1427
+ // An isolated copy is skipped by the agent-wide `agents sync` sweep, so
1428
+ // its findings must never fold into a collapsed cross-version row.
1671
1429
  const isolatedVersions = reports
1672
1430
  .filter((r) => isVersionIsolated(r.agent, r.version))
1673
1431
  .map((r) => `${r.agent}@${r.version}`);
@@ -8,6 +8,8 @@
8
8
  import { type Command } from 'commander';
9
9
  import type { ExecEffort } from '../lib/exec.js';
10
10
  import { RUN_AUTO_KEYWORD } from '../lib/types.js';
11
+ /** Validate a caller-supplied session id before it reaches tmux, paths, indexes, or remote dispatch. */
12
+ export declare function parseExplicitSessionId(value: string): string;
11
13
  export interface RunAccountPickerRequest {
12
14
  requested: boolean;
13
15
  normalizedAgentSpec: string;