@phnx-labs/agents-cli 1.20.26 → 1.20.28

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 (95) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/commands/doctor.d.ts +5 -2
  3. package/dist/commands/doctor.js +182 -30
  4. package/dist/commands/exec.d.ts +1 -1
  5. package/dist/commands/exec.js +177 -6
  6. package/dist/commands/hosts.d.ts +11 -0
  7. package/dist/commands/hosts.js +229 -0
  8. package/dist/commands/repo.d.ts +29 -0
  9. package/dist/commands/repo.js +174 -38
  10. package/dist/commands/secrets.d.ts +2 -7
  11. package/dist/commands/secrets.js +15 -23
  12. package/dist/commands/sessions.d.ts +2 -0
  13. package/dist/commands/sessions.js +27 -25
  14. package/dist/commands/sync.d.ts +2 -0
  15. package/dist/commands/sync.js +22 -5
  16. package/dist/commands/view.js +27 -11
  17. package/dist/index.js +4 -13
  18. package/dist/lib/agent-spec.d.ts +36 -0
  19. package/dist/lib/agent-spec.js +157 -0
  20. package/dist/lib/agents.d.ts +1 -0
  21. package/dist/lib/agents.js +45 -4
  22. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  23. package/dist/lib/browser/drivers/ssh.js +113 -24
  24. package/dist/lib/browser/profiles.js +28 -1
  25. package/dist/lib/browser/runtime-state.js +28 -8
  26. package/dist/lib/browser/types.d.ts +10 -1
  27. package/dist/lib/cli-resources.js +10 -1
  28. package/dist/lib/daemon.js +32 -0
  29. package/dist/lib/doctor-diff.d.ts +19 -0
  30. package/dist/lib/doctor-diff.js +107 -15
  31. package/dist/lib/exec.d.ts +27 -0
  32. package/dist/lib/exec.js +62 -19
  33. package/dist/lib/heal.d.ts +107 -0
  34. package/dist/lib/heal.js +279 -0
  35. package/dist/lib/hooks.d.ts +17 -0
  36. package/dist/lib/hooks.js +127 -3
  37. package/dist/lib/hosts/dispatch.d.ts +26 -0
  38. package/dist/lib/hosts/dispatch.js +71 -0
  39. package/dist/lib/hosts/progress.d.ts +21 -0
  40. package/dist/lib/hosts/progress.js +49 -0
  41. package/dist/lib/hosts/providers/local.d.ts +17 -0
  42. package/dist/lib/hosts/providers/local.js +81 -0
  43. package/dist/lib/hosts/ready.d.ts +37 -0
  44. package/dist/lib/hosts/ready.js +88 -0
  45. package/dist/lib/hosts/registry.d.ts +22 -0
  46. package/dist/lib/hosts/registry.js +65 -0
  47. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  48. package/dist/lib/hosts/ssh-config.js +157 -0
  49. package/dist/lib/hosts/tasks.d.ts +32 -0
  50. package/dist/lib/hosts/tasks.js +58 -0
  51. package/dist/lib/hosts/types.d.ts +51 -0
  52. package/dist/lib/hosts/types.js +21 -0
  53. package/dist/lib/loop.d.ts +9 -0
  54. package/dist/lib/loop.js +13 -1
  55. package/dist/lib/mcp.js +12 -3
  56. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  57. package/dist/lib/migrate.js +9 -5
  58. package/dist/lib/platform/exec.d.ts +10 -0
  59. package/dist/lib/platform/exec.js +17 -0
  60. package/dist/lib/platform/index.d.ts +1 -0
  61. package/dist/lib/platform/index.js +1 -0
  62. package/dist/lib/platform/links.d.ts +15 -0
  63. package/dist/lib/platform/links.js +42 -0
  64. package/dist/lib/platform/paths.d.ts +18 -0
  65. package/dist/lib/platform/paths.js +22 -0
  66. package/dist/lib/platform/posixpath.d.ts +28 -0
  67. package/dist/lib/platform/posixpath.js +153 -0
  68. package/dist/lib/plugin-marketplace.d.ts +18 -0
  69. package/dist/lib/plugin-marketplace.js +67 -1
  70. package/dist/lib/plugins.d.ts +33 -1
  71. package/dist/lib/plugins.js +56 -11
  72. package/dist/lib/project-launch.js +6 -3
  73. package/dist/lib/sandbox.js +5 -2
  74. package/dist/lib/self-update.js +7 -2
  75. package/dist/lib/session/db.d.ts +23 -0
  76. package/dist/lib/session/db.js +76 -1
  77. package/dist/lib/session/discover.d.ts +26 -0
  78. package/dist/lib/session/discover.js +75 -4
  79. package/dist/lib/session/relative-time.d.ts +7 -0
  80. package/dist/lib/session/relative-time.js +28 -0
  81. package/dist/lib/session/remote.d.ts +61 -0
  82. package/dist/lib/session/remote.js +221 -0
  83. package/dist/lib/ssh-exec.d.ts +45 -0
  84. package/dist/lib/ssh-exec.js +61 -0
  85. package/dist/lib/staleness/detectors/commands.js +7 -6
  86. package/dist/lib/staleness/writers/commands.js +7 -12
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/startup/dev-build.d.ts +22 -0
  90. package/dist/lib/startup/dev-build.js +41 -0
  91. package/dist/lib/types.d.ts +28 -0
  92. package/dist/lib/versions.d.ts +9 -3
  93. package/dist/lib/versions.js +43 -7
  94. package/package.json +3 -3
  95. package/scripts/postinstall.js +62 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ **`agents sessions --host <machine>`: query a remote machine's sessions live over SSH**
6
+
7
+ - `agents sessions "<query>" --host <alias|user@host>` runs the same session query on a remote machine's own index over SSH and streams the result back — repeat `--host` (or pass several) to fan out across machines. SSH access is the only auth; there's no daemon or shared store. Targets are validated against a strict allowlist (`SSH_TARGET_RE`) to block flag-smuggling, and the forwarded invocation is double-quoted (`shellQuote`) so a query like `$(whoami)` survives as a literal string on both shell layers. Source: `src/lib/session/remote.ts`, `src/commands/sessions.ts`, `docs/05-sessions.md`.
8
+
9
+ **Fix: migrations + menu-bar self-heal were silently disabled on Homebrew-node installs**
10
+
11
+ - The "is this a dev build?" check walked `dirname(dirname(argv[1]))` looking for a `.git`, without resolving the bin symlink. On a Homebrew-node setup `agents` is `/opt/homebrew/bin/agents`, so it walked up to `/opt/homebrew` — **which is itself a git repo** — and false-positived as a dev build. Dev builds auto-set `AGENTS_SKIP_MIGRATION=1`, which gates **both** one-shot migrations **and** the menu-bar upgrade self-heal. Net effect: every Homebrew-node user ran with migrations and the menu-bar refresh permanently off.
12
+ - Detection now `realpath`s the entrypoint (so a symlinked bin resolves into the real package dir) and requires the `.git`'s repo root to actually be the `@phnx-labs/agents-cli` package — an unrelated ancestor repo no longer counts. Extracted to `src/lib/startup/dev-build.ts` with tests covering the Homebrew symlink layout, a real checkout, and unrelated-ancestor cases.
13
+
5
14
  **Secrets default policy is now `daily` (one Touch ID per ~24h), not `always`**
6
15
 
7
16
  - The default prompt policy for bundles without an explicit one flipped from `always` (Touch ID on *every* read) to **`daily`** (one prompt, then held ~24h until screen-lock / sleep / logout). This is the fix for the prompt storm: a background reader like sessions-sync hammering a bundle now costs one Touch ID per ~24h instead of one per read.
@@ -9,10 +18,30 @@
9
18
  - **Configurable, still flexible.** Set the global default in `agents.yaml` (`secrets.policy: always` to restore prompt-every-time), or override per bundle with `agents secrets policy <bundle> always` for high-value keys (signing, SSH) you want to confirm on every read.
10
19
  - **Explicit `always` now persists** under the legacy `tier: biometry` token (older CLIs read it as their own always default). Bundles with no stored policy inherit the configured default — so an existing always-by-default bundle quietly becomes `daily` on first read by the new CLI, which is the intended migration.
11
20
 
21
+ **Menu bar: a macOS status item for agent activity (`agents menubar`)**
22
+
23
+ - New no-Dock menu bar app showing live agent activity on the machine: a **NEEDS YOU** section (sessions awaiting input + failed/overdue routines), a per-agent **roster** (running / idle counts across installed agents), a **+ New session** launcher, and a one-line routines summary. The icon badges red `!` when something needs you, green with a count when sessions are running.
24
+ - Reads state **directly from disk** — `live-terminals.json`, teams `meta.json`, and the cloud `tasks.db` — so opening the menu never triggers the costly sessions transcript re-index. The CLI is shelled only for actions (start a session, run a routine).
25
+ - **Auto-enabled on macOS** for every user as a launchd login service (`com.phnx-labs.agents-menubar`); a fresh install brings the icon up with no manual step. Manage with `agents menubar enable | disable | status`. Opt out with `agents menubar disable` — sticky across upgrades.
26
+ - **Upgrade self-heal:** the installed bundle is version-stamped, and the startup self-heal now re-installs the helper when a newer release ships a newer build (or the installed copy goes missing), instead of skipping whenever a service already existed. So `npm update` actually moves users onto the new helper binary + plist rather than leaving the old one running (#442). `agents menubar status` shows installed vs current version and staleness.
27
+ - Docs: [Menu bar](docs/menubar.md). macOS only.
28
+
12
29
  **`agents repos view [name]`: inspect one repo's contents without opening it**
13
30
 
14
31
  - New `agents repo view <name>` (also reachable as `agents repos view`, now a first-class alias of the `repo` command) prints a single repo's git state and per-kind resource counts — `system`, `user`, `project`, or an extra-repo alias. Omit the name for an interactive picker over the registered repos. It reuses the `inspect` repo renderer, so output matches `agents inspect <repo>`; supports `--brief` and `--json`. Source: `src/commands/repo.ts`, `src/commands/inspect.ts`.
15
32
 
33
+ **`agents doctor --fix` + a daemon safety check: heal the gap between defined and installed**
34
+
35
+ - Root cause behind "a plugin/command silently vanished": a DotAgents repo can DEFINE a resource that never makes it into an agent home, and nothing closes the gap. Two concrete failure modes — (1) `agents plugins update`/`sync` only reconcile each agent's **default** version, so a non-default installed version keeps serving stale/invalid resources; (2) a plugin.json with a bare-name `skills`/`commands` field makes Claude Code **silently reject the entire plugin**, and the sync path only *warned*. The detection (`agents doctor`'s live-home diff) and the healing (`syncResourcesToVersion`) existed but were never wired together — and the sync fast-guard keyed off the staleness manifest, which is blind to home-side rot.
36
+ - **`agents doctor --fix`** turns the read-only diagnosis into a heal: installs missing resources, repairs Claude-invalid plugin manifests (strips the bare `skills`/`commands` field — Claude auto-discovers from the dirs), fast-forwards stale plugins from their `.source`, and reconciles drift — across **every installed version**, not just defaults. With no target it heals the whole install; `agents doctor <agent> --fix` scopes to one.
37
+ - **Daemon safety check:** the routines daemon now runs the same heal in conservative `safe` mode (~every 6h + ~30s after start) — it fixes only unambiguous gaps (missing resources, invalid manifests, *provably-unmodified* stale plugins) and **notifies rather than clobbers** on hand-edited content or a plugin it can't prove is pristine.
38
+ - Built on the **live-home diff**, not the staleness manifest, so it catches drift the sync fast-guard can't. Heal **fills and fixes, never deletes** (orphans stay `agents prune cleanup`'s job), excludes the project layer (the global home isn't reconciled against per-cwd project resources), and **verifies after writing** — it only claims resources that actually reconciled, so repeated runs converge instead of "fixing" the same item forever.
39
+ - `.source` now records the plugin version at pull time, a baseline that lets the safe path tell an untouched mirror (fast-forward) from a user edit (leave alone).
40
+ - **`agents doctor` overview now covers every installed version, not just defaults.** Sync status and orphans previously reported only each agent's default version — so a stale NON-default version (the exact rot `--fix` heals) was invisible in the readout. Each version is now listed with its default marked. The **Agent CLIs** list also stops nagging: it shows the agents you actually run (ready, or managed-but-broken) and collapses the rest of the supported catalog to a single `+N more supported …` hint instead of a column of red "not installed" lines for tools you never adopted.
41
+ - **Says exactly WHAT is out of sync — plugins first.** A stale version in the overview now lists the specifics under it, prioritizing plugins and their bundled content: `plugin code — 0.6.1→0.7.0, missing skills: ship, learn`. The plugin diff went from presence-only ("installed: yes") to **content-aware** — it compares the version's marketplace mirror against the central source and surfaces a stale mirror version, a Claude-invalid manifest, and the plugin's own skills/commands that never reached the mirror (the system-repo content that matters most). `agents doctor <agent>@<version>` shows the same detail per plugin row.
42
+ - **Fixed a false "drift" that could never be reconciled:** a hook's `.md`/`.rst` doc sibling (e.g. `git-guard.md` next to `git-guard.sh`) was wrongly treated as the hook's runtime *data file*, so the installer's correct omission of docs showed as perpetual drift in `doctor` (and as an un-healable item under `--fix`). Docs are no longer counted as hook data; structured siblings (`.yaml`/`.json`/...) still are.
43
+ - **Corrected a false promise in the sync-status readout.** Stale/cold versions used to say "will sync on next launch" / "first launch will populate" — but version homes are NOT reconciled on launch (the shim hot path only resolves a version and compiles project-scoped resources; v15/v16 moved version-home reconciliation to management commands). The readout now states the fact ("sources changed since last sync" / "never synced") and points at the real fix: `agents doctor <agent>@<version> --fix` or `agents sync <agent>@<version>`.
44
+
16
45
  **Secrets prompt policy: human-readable `always` / `daily`, and `secrets list` now shows it**
17
46
 
18
47
  - Renamed the secrets-agent `tier` to a **prompt policy** with plain-language names: `biometry` → **`always`** (ask every time), `session` → **`daily`** (ask once, then held ~24h until screen-lock / sleep / logout). The old name `session` was misleading — it never meant "once per login session" — and collided with the half-dozen other "session" concepts in the CLI (`agents sessions`, sessions-sync, pty/browser sessions). Set it with `agents secrets policy <bundle> [always|daily]`.
@@ -15,8 +15,11 @@
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: doctor never mutates state. Run `agents prune cleanup` to act on orphan
19
- * readouts, or just launch the agent to apply pending sync.
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.
20
23
  */
21
24
  import type { Command } from 'commander';
22
25
  export declare function registerDoctorCommand(program: Command): void;
@@ -10,22 +10,64 @@ import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
10
10
  import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
11
11
  import { listCliStatus } from '../lib/cli-resources.js';
12
12
  import { setHelpSections } from '../lib/help.js';
13
+ import { heal, healChangedAnything } from '../lib/heal.js';
13
14
  import * as fs from 'fs';
14
15
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
15
16
  // ─── overview mode (no target) ────────────────────────────────────────────────
17
+ // Lines naming exactly what's out of sync for a version, plugins prioritized:
18
+ // each divergent plugin gets its own line with specifics (stale mirror version,
19
+ // invalid manifest, or the bundled skills/commands missing from the mirror —
20
+ // the system-repo plugin content that matters most). Other kinds collapse to
21
+ // compact counts so the readout stays scannable.
22
+ function divergenceLines(report) {
23
+ const lines = [];
24
+ for (const p of report.kinds.plugins) {
25
+ if (p.status === 'missing')
26
+ lines.push(`plugin ${p.name} — not installed`);
27
+ else if (p.status === 'diff')
28
+ lines.push(`plugin ${p.name} — ${p.detail ?? 'mirror drifted'}`);
29
+ }
30
+ const counts = [];
31
+ for (const kind of ['commands', 'skills', 'hooks', 'rules', 'mcp', 'permissions', 'subagents']) {
32
+ const rows = report.kinds[kind];
33
+ const miss = rows.filter((r) => r.status === 'missing').length;
34
+ const dif = rows.filter((r) => r.status === 'diff').length;
35
+ const bits = [];
36
+ if (miss)
37
+ bits.push(`${miss} missing`);
38
+ if (dif)
39
+ bits.push(`${dif} drifted`);
40
+ if (bits.length)
41
+ counts.push(`${kind} ${bits.join('/')}`);
42
+ }
43
+ if (counts.length)
44
+ lines.push(counts.join(' · '));
45
+ return lines;
46
+ }
16
47
  function checkSyncStatus(cwd) {
17
48
  const rows = [];
49
+ // Every installed version, not just the default — a stale NON-default version
50
+ // (e.g. one you launched from yesterday) is exactly the rot that silently
51
+ // serves outdated/invalid resources and that `--fix` now heals. Hiding it here
52
+ // is why that class of bug went unnoticed.
18
53
  for (const agent of ALL_AGENT_IDS) {
19
- const version = getGlobalDefault(agent);
20
- if (!version)
21
- continue;
22
- const manifest = loadManifest(agent, version);
23
- if (!manifest) {
24
- rows.push({ agent, version, status: 'never-synced' });
25
- continue;
54
+ const def = getGlobalDefault(agent);
55
+ for (const version of listInstalledVersions(agent)) {
56
+ const manifest = loadManifest(agent, version);
57
+ const status = !manifest
58
+ ? 'never-synced'
59
+ : isStale(manifest, agent, version, cwd) ? 'stale' : 'fresh';
60
+ const row = { agent, version, status, isDefault: version === def };
61
+ if (status === 'stale') {
62
+ // Resolve the specifics against non-project layers (the global home is
63
+ // never reconciled against per-cwd project resources).
64
+ const report = diffVersionResources(agent, version, { cwd, excludeProject: true });
65
+ const lines = divergenceLines(report);
66
+ if (lines.length)
67
+ row.divergence = lines;
68
+ }
69
+ rows.push(row);
26
70
  }
27
- const stale = isStale(manifest, agent, version, cwd);
28
- rows.push({ agent, version, status: stale ? 'stale' : 'fresh' });
29
71
  }
30
72
  return rows;
31
73
  }
@@ -41,15 +83,11 @@ function countOrphans() {
41
83
  return row;
42
84
  };
43
85
  for (const { agent, version } of iterCommandsCapableVersions()) {
44
- if (version !== getGlobalDefault(agent))
45
- continue;
46
86
  const diff = diffVersionCommands(agent, version);
47
87
  if (diff.orphans.length > 0)
48
88
  ensure(agent, version).commands = diff.orphans.length;
49
89
  }
50
90
  for (const { agent, version } of iterSkillsCapableVersions()) {
51
- if (version !== getGlobalDefault(agent))
52
- continue;
53
91
  const diff = diffVersionSkills(agent, version);
54
92
  if (diff.orphans.length > 0)
55
93
  ensure(agent, version).skills = diff.orphans.length;
@@ -59,8 +97,6 @@ function countOrphans() {
59
97
  // never fire. (Distinct from the source-diff `diffVersionHooks().orphans`,
60
98
  // which false-flags valid system-sourced registered hooks.)
61
99
  for (const { agent, version } of iterHooksCapableVersions()) {
62
- if (version !== getGlobalDefault(agent))
63
- continue;
64
100
  const dead = listUnmanagedHooksInVersionHome(agent, version);
65
101
  if (dead.length > 0)
66
102
  ensure(agent, version).hooks = dead.length;
@@ -69,41 +105,66 @@ function countOrphans() {
69
105
  }
70
106
  function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
71
107
  console.log(chalk.bold('Agent CLIs'));
72
- if (Object.keys(clis).length === 0) {
73
- console.log(chalk.gray(' (no agents reported)'));
108
+ // Show the fleet you actually run — agents that are ready in PATH, plus any
109
+ // you MANAGE (have installed versions) whose binary isn't resolving (a real
110
+ // problem). The other supported-but-unadopted agents collapse to one hint line
111
+ // instead of a column of red "not installed" nags for tools you never wanted.
112
+ const managed = new Set(ALL_AGENT_IDS.filter((a) => listInstalledVersions(a).length > 0));
113
+ const entries = Object.entries(clis);
114
+ const shown = entries.filter(([name, e]) => e.installed || managed.has(name));
115
+ const hidden = entries.filter(([name, e]) => !e.installed && !managed.has(name)).map(([name]) => name);
116
+ if (shown.length === 0) {
117
+ console.log(chalk.gray(' (none installed — `agents add <name>` to start)'));
74
118
  }
75
119
  else {
76
- for (const [name, entry] of Object.entries(clis)) {
77
- const pretty = AGENT_NAMES[name] || name;
120
+ for (const [name, entry] of shown) {
121
+ const pretty = (AGENT_NAMES[name] || name).padEnd(11);
78
122
  if (entry.installed) {
79
- console.log(` ${chalk.green('ready')} ${pretty.padEnd(10)} ${chalk.gray(entry.path || '')}`);
123
+ console.log(` ${chalk.green('ready')} ${pretty} ${chalk.gray(entry.path || '')}`);
80
124
  }
81
125
  else {
82
- console.log(` ${chalk.red('no ')} ${pretty.padEnd(10)} ${chalk.gray(entry.error || 'not installed')}`);
126
+ console.log(` ${chalk.red('no ')} ${pretty} ${chalk.gray(entry.error || 'not installed')}`);
83
127
  }
84
128
  }
85
129
  }
130
+ if (hidden.length > 0) {
131
+ console.log(chalk.gray(` +${hidden.length} more supported (${hidden.join(', ')}) — \`agents add <name>\` to manage`));
132
+ }
86
133
  console.log();
87
- console.log(chalk.bold('Sync status (default versions)'));
134
+ console.log(chalk.bold('Sync status (installed versions)'));
88
135
  if (syncRows.length === 0) {
89
- console.log(chalk.gray(' (no default versions set; pin one with `agents use <agent>@<version>`)'));
136
+ console.log(chalk.gray(' (no versions installed; add one with `agents add <agent>@<version>`)'));
90
137
  }
91
138
  else {
139
+ let anyOutOfSync = false;
92
140
  for (const row of syncRows) {
93
- const label = `${AGENT_NAMES[row.agent] || row.agent}@${row.version}`;
141
+ const tag = row.isDefault ? chalk.gray(' (default)') : '';
142
+ const label = `${AGENT_NAMES[row.agent] || row.agent}@${row.version}${tag}`;
94
143
  if (row.status === 'fresh') {
95
144
  console.log(` ${chalk.green('fresh')} ${label}`);
96
145
  }
97
146
  else if (row.status === 'stale') {
98
- console.log(` ${chalk.yellow('stale')} ${label} ${chalk.gray('— will sync on next launch')}`);
147
+ anyOutOfSync = true;
148
+ console.log(` ${chalk.yellow('stale')} ${label} ${chalk.gray('— sources changed since last sync')}`);
149
+ for (const line of row.divergence ?? []) {
150
+ console.log(chalk.gray(` ${line}`));
151
+ }
99
152
  }
100
153
  else {
101
- console.log(` ${chalk.gray('cold ')} ${label} ${chalk.gray('— never synced; first launch will populate')}`);
154
+ anyOutOfSync = true;
155
+ console.log(` ${chalk.gray('cold ')} ${label} ${chalk.gray('— never synced')}`);
102
156
  }
103
157
  }
158
+ // Launching does NOT reconcile a version home — the shim hot path only
159
+ // resolves a version and compiles project-scoped resources (shims.ts v15/v16).
160
+ // Version homes are reconciled only by management commands, so point at one
161
+ // rather than promising an auto-sync that never happens.
162
+ if (anyOutOfSync) {
163
+ console.log(chalk.gray(' Reconcile with `agents doctor <agent>@<version> --fix` or `agents sync <agent>@<version>` (not applied on launch).'));
164
+ }
104
165
  }
105
166
  console.log();
106
- console.log(chalk.bold('Orphans (default versions)'));
167
+ console.log(chalk.bold('Orphans (installed versions)'));
107
168
  if (orphanRows.length === 0) {
108
169
  console.log(chalk.gray(' (none — version homes match central sources)'));
109
170
  }
@@ -252,7 +313,8 @@ function renderKindSection(kind, rows, layers, options) {
252
313
  }
253
314
  for (const r of visible) {
254
315
  const src = sourceLabel(r, layers);
255
- console.log(` ${statusLabel(r.status)} ${r.name.padEnd(28)} ${src}`);
316
+ const detail = r.detail ? chalk.gray(` ${r.detail}`) : '';
317
+ console.log(` ${statusLabel(r.status)} ${r.name.padEnd(28)} ${src}${detail}`);
256
318
  if (options.showDiff && r.status === 'diff' && r.sourcePath && r.homePath) {
257
319
  const expected = readExpectedForDiff(kind, r);
258
320
  const actual = safeRead(r.homePath);
@@ -340,9 +402,77 @@ function renderTargetText(report, options) {
340
402
  }
341
403
  else {
342
404
  console.log(` Verdict: ${verdictParts.join(', ')}.`);
343
- console.log(chalk.gray(` Run \`agents sync ${report.agent}@${report.version}\` to reconcile, or \`agents prune cleanup\` to drop extras.`));
405
+ console.log(chalk.gray(` Run \`agents doctor ${report.agent}@${report.version} --fix\` to heal, or \`agents prune cleanup\` to drop extras.`));
406
+ }
407
+ }
408
+ // ─── fix / heal mode ───────────────────────────────────────────────────────────
409
+ function renderHealText(result) {
410
+ for (const r of result.repairedManifests) {
411
+ console.log(` ${chalk.green('repair')} plugin ${chalk.bold(r.plugin)} ${chalk.gray(`— dropped invalid ${r.droppedFields.join(', ')} field`)}`);
412
+ }
413
+ for (const r of result.refreshedPlugins) {
414
+ console.log(` ${chalk.green('refresh')} plugin ${chalk.bold(r.plugin)} ${chalk.gray(`${r.from} → ${r.to}`)}`);
415
+ }
416
+ for (const s of result.skippedPlugins) {
417
+ const why = s.reason === 'modified'
418
+ ? `locally modified — left as-is (run \`agents plugins update ${s.plugin}\` to force)`
419
+ : `no baseline recorded — left as-is (run \`agents plugins update ${s.plugin}\` to adopt)`;
420
+ console.log(` ${chalk.yellow('hold ')} plugin ${chalk.bold(s.plugin)} ${chalk.gray(`${s.from} → ${s.upstream} available; ${why}`)}`);
421
+ }
422
+ for (const v of result.versions) {
423
+ const label = `${AGENT_NAMES[v.agent] || v.agent}@${v.version}`;
424
+ if (v.healed.length === 0 && v.skipped.length === 0)
425
+ continue;
426
+ const byKind = new Map();
427
+ for (const h of v.healed)
428
+ byKind.set(h.kind, (byKind.get(h.kind) ?? 0) + 1);
429
+ const parts = Array.from(byKind, ([k, n]) => `${n} ${k}`);
430
+ if (v.healed.length > 0) {
431
+ console.log(` ${chalk.green('fixed ')} ${label} ${chalk.gray(parts.join(', '))}`);
432
+ }
433
+ const drift = v.skipped.filter((s) => s.reason === 'drift');
434
+ const unres = v.skipped.filter((s) => s.reason === 'unreconcilable');
435
+ if (drift.length > 0) {
436
+ console.log(` ${chalk.yellow('drift ')} ${label} ${chalk.gray(`${drift.length} hand-edited — left as-is (use \`--diff\` to inspect)`)}`);
437
+ }
438
+ if (unres.length > 0) {
439
+ const names = unres.map((s) => `${s.kind}/${s.name}`).join(', ');
440
+ console.log(` ${chalk.yellow('hold ')} ${label} ${chalk.gray(`${unres.length} couldn't reconcile (${names}) — source/home mismatch the writer can't satisfy`)}`);
441
+ }
442
+ }
443
+ console.log();
444
+ const healed = result.versions.reduce((n, v) => n + v.healed.length, 0);
445
+ const touchedVersions = result.versions.filter((v) => v.healed.length > 0).length;
446
+ if (!healChangedAnything(result)) {
447
+ console.log(chalk.green('✓ Everything in sync — nothing to heal.'));
448
+ }
449
+ else {
450
+ const bits = [];
451
+ if (healed > 0)
452
+ bits.push(`${healed} resource${healed === 1 ? '' : 's'} across ${touchedVersions} version${touchedVersions === 1 ? '' : 's'}`);
453
+ if (result.repairedManifests.length > 0)
454
+ bits.push(`${result.repairedManifests.length} manifest${result.repairedManifests.length === 1 ? '' : 's'} repaired`);
455
+ if (result.refreshedPlugins.length > 0)
456
+ bits.push(`${result.refreshedPlugins.length} plugin${result.refreshedPlugins.length === 1 ? '' : 's'} refreshed`);
457
+ console.log(chalk.green(`✓ Healed ${bits.join(', ')}.`));
344
458
  }
345
459
  }
460
+ async function runFix(parsed, opts) {
461
+ // Heal targets the global install — project layer is irrelevant, so cwd is
462
+ // left to heal's neutral default rather than process.cwd().
463
+ if (!opts.json)
464
+ console.log(chalk.bold('Healing…'));
465
+ const result = await heal({
466
+ mode: 'full',
467
+ agent: parsed?.agent,
468
+ versions: parsed?.versions,
469
+ });
470
+ if (opts.json) {
471
+ console.log(JSON.stringify(result, null, 2));
472
+ return;
473
+ }
474
+ renderHealText(result);
475
+ }
346
476
  // ─── command registration ────────────────────────────────────────────────────
347
477
  export function registerDoctorCommand(program) {
348
478
  const doctorCmd = program
@@ -350,6 +480,7 @@ export function registerDoctorCommand(program) {
350
480
  .description('Diagnose CLI availability, sync status, and resource divergence (optionally for a specific agent[@version]).')
351
481
  .option('--json', 'Output machine-readable JSON')
352
482
  .option('--diff', 'In target mode, include unified diffs for divergent files')
483
+ .option('--fix', 'Heal gaps: install missing resources, repair invalid plugin manifests, refresh stale plugins, and reconcile drift (all installed versions, or just the target)')
353
484
  .option('--kind <kinds>', 'Restrict to comma-separated resource kinds (commands,skills,hooks,rules,mcp,permissions,subagents,plugins,promptcuts)')
354
485
  .option('--cwd <path>', 'Resolution cwd for project layer detection (default: process.cwd())');
355
486
  setHelpSections(doctorCmd, {
@@ -368,10 +499,31 @@ export function registerDoctorCommand(program) {
368
499
 
369
500
  # Inspect only rules and hooks, with full diffs
370
501
  agents doctor claude@default --kind rules,hooks --diff
502
+
503
+ # Heal every gap across all installed versions
504
+ agents doctor --fix
505
+
506
+ # Heal just one agent (all its installed versions)
507
+ agents doctor claude --fix
371
508
  `,
372
509
  });
373
- doctorCmd.action((target, opts) => {
510
+ doctorCmd.action(async (target, opts) => {
374
511
  const cwd = opts.cwd ? opts.cwd : process.cwd();
512
+ // --fix turns the read-only diagnosis into a heal. With no target it heals
513
+ // every installed version; with a target it scopes to that agent.
514
+ if (opts.fix) {
515
+ let scope = null;
516
+ if (target) {
517
+ const parsed = parseTargetArg(target);
518
+ if ('error' in parsed) {
519
+ console.error(chalk.red(parsed.error));
520
+ process.exit(1);
521
+ }
522
+ scope = parsed;
523
+ }
524
+ await runFix(scope, opts);
525
+ return;
526
+ }
375
527
  if (!target) {
376
528
  const clis = checkAllClis();
377
529
  const syncRows = checkSyncStatus(cwd);
@@ -5,7 +5,7 @@
5
5
  * or headlessly. Supports profile resolution, version rotation, secrets
6
6
  * injection, and multi-agent fallback chains for rate-limit resilience.
7
7
  */
8
- import type { Command } from 'commander';
8
+ import { type Command } from 'commander';
9
9
  /**
10
10
  * Build the LoopConfig the driver consumes from CLI flags and/or a workflow's
11
11
  * `loop:` frontmatter block (issue #332). Returns undefined when neither source
@@ -5,6 +5,7 @@
5
5
  * or headlessly. Supports profile resolution, version rotation, secrets
6
6
  * injection, and multi-agent fallback chains for rate-limit resilience.
7
7
  */
8
+ import { Option } from 'commander';
8
9
  import chalk from 'chalk';
9
10
  import { setHelpSections } from '../lib/help.js';
10
11
  import { parseLoopInterval } from '../lib/loop.js';
@@ -117,7 +118,8 @@ export function registerRunCommand(program) {
117
118
  .option('--quiet', 'Suppress preamble (rotation banner, "Running:" line). Useful when piping JSON events to a parser.', false)
118
119
  .option('--headless', 'Force headless mode. Auto-enabled when a prompt is provided; pass explicitly to stay headless with no prompt (reads the prompt from stdin).', false)
119
120
  .option('-i, --interactive', 'Force interactive mode even when a prompt is provided. Mutually exclusive with --headless.')
120
- .option('--session-id <id>', 'Resume a previous conversation (Claude only)')
121
+ .option('--resume [id]', 'Resume a previous conversation. Accepts a full or partial session id (prefix-matched against the index); omit the id to pick from recent sessions interactively. Resumes under the version that started the session. claude/codex resume natively; other agents replay via a /continue first message. Pair with a prompt to continue headlessly.')
122
+ .option('--session-id <id>', 'Force a NEW conversation to use this exact session UUID (Claude only). This CREATES a session — to resume an existing one, use --resume.')
121
123
  .option('--verbose', 'Show detailed execution logs')
122
124
  .option('--timeout <duration>', 'Kill the agent after this duration (e.g., 30m, 1h, 2h30m)')
123
125
  .option('--fallback <agents>', 'Comma-separated agents to try on rate-limit failure. Each entry accepts an optional @version pin (e.g., codex@0.116.0,gemini). The primary runs first; if it exits with a rate-limit error, the next agent picks up via /continue handoff.')
@@ -130,7 +132,14 @@ export function registerRunCommand(program) {
130
132
  .option('--max-iterations <n>', 'Loop hard cap: stop after N iterations (stoppedBy: max). Loop only.')
131
133
  .option('--budget <tokens>', 'Loop token hard-cap: stop once cumulative tokens reach this (stoppedBy: budget), enforced outside the agent. Loop only.')
132
134
  .option('--until <signal>', 'Loop stop condition. `signal` reads <runDir>/loop-signal.json {continue,reason} each iteration; absent or continue:false stops (fail-closed). Loop only.')
133
- .option('--interval <dur>', 'Loop delay between iterations ("0" back-to-back, "30m" paces). Loop only.');
135
+ .option('--interval <dur>', 'Loop delay between iterations ("0" back-to-back, "30m" paces). Loop only.')
136
+ .option('--host <name>', 'Offload this run onto a registered agent host over SSH instead of running locally. See `agents hosts`.')
137
+ .option('--remote-cwd <dir>', 'Working directory on the host for --host runs.')
138
+ .option('--no-follow', 'With --host, dispatch detached and return immediately (track via `agents hosts ps/logs`).')
139
+ .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.');
140
+ // `--on` and `--computer` are hidden aliases of `--host` — same behavior.
141
+ runCmd.addOption(new Option('--on <name>', 'Alias of --host.').hideHelp());
142
+ runCmd.addOption(new Option('--computer <name>', 'Alias of --host.').hideHelp());
134
143
  setHelpSections(runCmd, {
135
144
  examples: `
136
145
  # Headless, read-only: investigate or summarize without writing files
@@ -171,7 +180,7 @@ export function registerRunCommand(program) {
171
180
 
172
181
  Fallback: --fallback codex,gemini retries on rate-limit failure via /continue handoff. Each entry accepts @version.
173
182
 
174
- Resume: --session-id <id> continues a prior Claude conversation.
183
+ Resume: --resume <id> continues a prior conversation (full or partial id; omit to pick interactively). claude/codex resume natively; others replay via a /continue first message. Add a prompt to continue headlessly.
175
184
 
176
185
  Passthrough: everything after -- is forwarded verbatim to the underlying agent CLI.
177
186
  agents run kimi -- --plan --some-native-flag value
@@ -182,6 +191,61 @@ export function registerRunCommand(program) {
182
191
  // Use command.args (all positional strings) and strip the declared positional args from the front.
183
192
  const declaredArgCount = prompt !== undefined ? 2 : 1;
184
193
  const passthroughArgs = command.args.slice(declaredArgCount);
194
+ // --host/--on/--computer: offload this run onto a registered agent host
195
+ // over SSH instead of running locally. The three flags are aliases.
196
+ const hostGiven = [options.host, options.on, options.computer].filter((v) => !!v);
197
+ if (hostGiven.length > 0) {
198
+ if (new Set(hostGiven).size > 1) {
199
+ console.error(chalk.red('Conflicting --host/--on/--computer values — pass just one.'));
200
+ process.exit(1);
201
+ }
202
+ const hostName = hostGiven[0];
203
+ if (prompt === undefined) {
204
+ console.error(chalk.red('A prompt is required for host runs: agents run <agent> "<task>" --host <name>'));
205
+ process.exit(1);
206
+ }
207
+ const { resolveHost, resolveHostByCap } = await import('../lib/hosts/registry.js');
208
+ const { dispatchToHost } = await import('../lib/hosts/dispatch.js');
209
+ let host = await resolveHost(hostName);
210
+ if (!host) {
211
+ // Not a host name — try capability routing (e.g. --host gpu). A
212
+ // "Multiple hosts tagged…" error is actionable and must surface;
213
+ // only "no host tagged" falls through to the generic unknown-host msg.
214
+ try {
215
+ host = await resolveHostByCap(hostName, options.any);
216
+ }
217
+ catch (e) {
218
+ const msg = e.message ?? '';
219
+ if (msg.startsWith('Multiple hosts')) {
220
+ console.error(chalk.red(msg));
221
+ process.exit(1);
222
+ }
223
+ }
224
+ }
225
+ if (!host) {
226
+ console.error(chalk.red(`Unknown host "${hostName}". List hosts: agents hosts list`));
227
+ process.exit(1);
228
+ }
229
+ try {
230
+ const { exitCode } = await dispatchToHost(host, {
231
+ agent: agentSpec.split('@')[0],
232
+ prompt,
233
+ mode: options.mode,
234
+ model: options.model,
235
+ remoteCwd: options.remoteCwd,
236
+ follow: options.follow !== false,
237
+ });
238
+ if (options.follow === false) {
239
+ console.log(chalk.green(`Dispatched to ${host.name}.`) + chalk.gray(' Track: agents hosts ps · Follow: agents hosts logs <id> -f'));
240
+ process.exit(0);
241
+ }
242
+ process.exit(exitCode === undefined || exitCode === -1 ? 1 : exitCode);
243
+ }
244
+ catch (err) {
245
+ console.error(chalk.red(err.message));
246
+ process.exit(1);
247
+ }
248
+ }
185
249
  // --resume-checkpoint short-circuits normal dispatch entirely: the
186
250
  // checkpoint already carries the agent, version, prompt, session id,
187
251
  // iteration, and loop config of the killed run. Reconstruct ExecOptions
@@ -260,7 +324,7 @@ export function registerRunCommand(program) {
260
324
  process.stderr.write(chalk.gray(`[loop] stopped: ${result.stoppedBy} after ${result.iterations} iteration(s), ${result.tokens} tokens\n`));
261
325
  process.exit(loopExitCode(result.stoppedBy));
262
326
  }
263
- const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, headlessPlanStallCommand }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
327
+ const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, defaultModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive }, { ALL_AGENT_IDS }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, RUN_STRATEGIES }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports },] = await Promise.all([
264
328
  import('../lib/exec.js'),
265
329
  import('../lib/agents.js'),
266
330
  import('../lib/profiles.js'),
@@ -489,6 +553,112 @@ export function registerRunCommand(program) {
489
553
  }
490
554
  }
491
555
  version = resolveVersionAlias(agent, version);
556
+ // --resume: resolve a prior conversation and rewrite the run target to
557
+ // continue it. `version` here is already the alias-resolved candidate-version
558
+ // FILTER (undefined for default/any, concrete for @latest/@oldest/@x.y.z);
559
+ // it is replaced below by the chosen session's OWN version (isolation).
560
+ let resumeNative = false;
561
+ let resumeSessionId;
562
+ let forceInteractive = false;
563
+ if (options.resume !== undefined) {
564
+ if (options.sessionId) {
565
+ console.error(chalk.red('--resume and --session-id are mutually exclusive. --session-id CREATES a session with a fixed id; --resume continues an existing one.'));
566
+ process.exit(1);
567
+ }
568
+ if (options.loop || options.fallback || options.resumeCheckpoint) {
569
+ console.error(chalk.red('--resume cannot be combined with --loop, --fallback, or --resume-checkpoint (those are separate continuation mechanisms).'));
570
+ process.exit(1);
571
+ }
572
+ const { findSessionsById } = await import('../lib/session/db.js');
573
+ const { discoverSessions } = await import('../lib/session/discover.js');
574
+ const { pickSessionInteractive } = await import('./sessions.js');
575
+ const { buildContinuePrompt } = await import('../lib/loop.js');
576
+ // Freshen the index for this agent before any lookup (incremental, cached).
577
+ // AgentId is wider than SessionAgentId (cursor/amp/… keep no transcripts);
578
+ // those simply yield no matches and fall through to the not-found error.
579
+ const sessionAgent = agent;
580
+ await discoverSessions({ agent: sessionAgent, version });
581
+ // Resume is interactive unless a follow-on prompt makes it headless.
582
+ const wantsInteractive = resolveInteractive({ interactive: options.interactive, headless: options.headless, prompt });
583
+ const idArg = typeof options.resume === 'string' ? options.resume.trim() : '';
584
+ let scopeCwd;
585
+ try {
586
+ scopeCwd = fs.realpathSync(cwd);
587
+ }
588
+ catch {
589
+ scopeCwd = cwd;
590
+ }
591
+ let session;
592
+ if (idArg) {
593
+ let matches = findSessionsById(idArg, { agent: sessionAgent, version, cwd: scopeCwd });
594
+ if (matches.length === 0) {
595
+ const wide = findSessionsById(idArg, { agent: sessionAgent, version });
596
+ if (wide.length > 0) {
597
+ if (!options.quiet)
598
+ process.stderr.write(chalk.gray(`No match for "${idArg}" in this project; widened to all projects.\n`));
599
+ matches = wide;
600
+ }
601
+ }
602
+ if (matches.length === 0) {
603
+ console.error(chalk.red(`No ${agent} session matching "${idArg}".`));
604
+ console.error(chalk.gray(`Browse sessions: agents sessions ${idArg}`));
605
+ process.exit(1);
606
+ }
607
+ else if (matches.length === 1) {
608
+ session = matches[0];
609
+ }
610
+ else if (wantsInteractive) {
611
+ const picked = await pickSessionInteractive(matches, `Multiple sessions match "${idArg}":`);
612
+ if (!picked)
613
+ process.exit(0);
614
+ session = picked.session;
615
+ }
616
+ else {
617
+ console.error(chalk.red(`"${idArg}" is ambiguous — ${matches.length} sessions match:`));
618
+ for (const m of matches.slice(0, 10)) {
619
+ console.error(chalk.gray(` ${m.shortId} ${m.timestamp.slice(0, 16).replace('T', ' ')} ${m.topic ?? m.label ?? ''}`));
620
+ }
621
+ console.error(chalk.gray('Pass more of the id, or resume interactively (drop the prompt).'));
622
+ process.exit(1);
623
+ }
624
+ }
625
+ else {
626
+ // Bare --resume: pick from recent sessions in scope. Needs a TTY.
627
+ if (!wantsInteractive) {
628
+ console.error(chalk.red('--resume with no id needs an interactive terminal. Pass a session id (full or prefix), or run without --headless.'));
629
+ process.exit(1);
630
+ }
631
+ const recent = await discoverSessions({ agent: sessionAgent, version, limit: 200 });
632
+ if (recent.length === 0) {
633
+ console.error(chalk.red(`No ${agent} sessions found to resume in this project.`));
634
+ console.error(chalk.gray('Browse all: agents sessions'));
635
+ process.exit(1);
636
+ }
637
+ const picked = await pickSessionInteractive(recent, `Resume which ${agent} session?`);
638
+ if (!picked)
639
+ process.exit(0);
640
+ session = picked.session;
641
+ forceInteractive = true; // bare resume always lands in the agent's TUI
642
+ }
643
+ // Pin to the chosen session's own version (the isolated HOME the transcript
644
+ // lives in) and route by tier.
645
+ version = session.version;
646
+ if (nativeResume(agent)) {
647
+ resumeNative = true;
648
+ resumeSessionId = session.id;
649
+ if (!options.quiet)
650
+ process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''}\n`));
651
+ }
652
+ else {
653
+ // Tier-2: launch fresh with a /continue <id> first message; the agent
654
+ // loads the transcript via `agents sessions <id>` and picks up.
655
+ prompt = buildContinuePrompt(session.id, prompt);
656
+ if (prompt.trim() === `/continue ${session.id}`)
657
+ forceInteractive = true;
658
+ if (!options.quiet)
659
+ process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (/continue replay)${version ? ` @${version}` : ''}\n`));
660
+ }
661
+ }
492
662
  const configuredStrategy = getConfiguredRunStrategy(agent, cwd);
493
663
  const explicitStrategy = options.strategy ? normalizeRunStrategy(options.strategy) : null;
494
664
  if (options.strategy && !explicitStrategy) {
@@ -639,7 +809,7 @@ export function registerRunCommand(program) {
639
809
  agent,
640
810
  version,
641
811
  prompt,
642
- interactive: options.interactive,
812
+ interactive: options.interactive || forceInteractive,
643
813
  mode,
644
814
  effort,
645
815
  cwd: options.cwd,
@@ -647,7 +817,8 @@ export function registerRunCommand(program) {
647
817
  addDirs: options.addDir,
648
818
  json: options.json,
649
819
  headless: options.headless,
650
- sessionId: options.sessionId,
820
+ sessionId: resumeSessionId ?? options.sessionId,
821
+ resume: resumeNative,
651
822
  verbose: options.verbose,
652
823
  timeout: options.timeout,
653
824
  env,