@phnx-labs/agents-cli 1.20.26 → 1.20.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md 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,27 @@
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
+
16
42
  **Secrets prompt policy: human-readable `always` / `daily`, and `secrets list` now shows it**
17
43
 
18
44
  - 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,25 @@ 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) ────────────────────────────────────────────────
16
17
  function checkSyncStatus(cwd) {
17
18
  const rows = [];
19
+ // Every installed version, not just the default — a stale NON-default version
20
+ // (e.g. one you launched from yesterday) is exactly the rot that silently
21
+ // serves outdated/invalid resources and that `--fix` now heals. Hiding it here
22
+ // is why that class of bug went unnoticed.
18
23
  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;
24
+ const def = getGlobalDefault(agent);
25
+ for (const version of listInstalledVersions(agent)) {
26
+ const manifest = loadManifest(agent, version);
27
+ const status = !manifest
28
+ ? 'never-synced'
29
+ : isStale(manifest, agent, version, cwd) ? 'stale' : 'fresh';
30
+ rows.push({ agent, version, status, isDefault: version === def });
26
31
  }
27
- const stale = isStale(manifest, agent, version, cwd);
28
- rows.push({ agent, version, status: stale ? 'stale' : 'fresh' });
29
32
  }
30
33
  return rows;
31
34
  }
@@ -41,15 +44,11 @@ function countOrphans() {
41
44
  return row;
42
45
  };
43
46
  for (const { agent, version } of iterCommandsCapableVersions()) {
44
- if (version !== getGlobalDefault(agent))
45
- continue;
46
47
  const diff = diffVersionCommands(agent, version);
47
48
  if (diff.orphans.length > 0)
48
49
  ensure(agent, version).commands = diff.orphans.length;
49
50
  }
50
51
  for (const { agent, version } of iterSkillsCapableVersions()) {
51
- if (version !== getGlobalDefault(agent))
52
- continue;
53
52
  const diff = diffVersionSkills(agent, version);
54
53
  if (diff.orphans.length > 0)
55
54
  ensure(agent, version).skills = diff.orphans.length;
@@ -59,8 +58,6 @@ function countOrphans() {
59
58
  // never fire. (Distinct from the source-diff `diffVersionHooks().orphans`,
60
59
  // which false-flags valid system-sourced registered hooks.)
61
60
  for (const { agent, version } of iterHooksCapableVersions()) {
62
- if (version !== getGlobalDefault(agent))
63
- continue;
64
61
  const dead = listUnmanagedHooksInVersionHome(agent, version);
65
62
  if (dead.length > 0)
66
63
  ensure(agent, version).hooks = dead.length;
@@ -69,28 +66,40 @@ function countOrphans() {
69
66
  }
70
67
  function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
71
68
  console.log(chalk.bold('Agent CLIs'));
72
- if (Object.keys(clis).length === 0) {
73
- console.log(chalk.gray(' (no agents reported)'));
69
+ // Show the fleet you actually run — agents that are ready in PATH, plus any
70
+ // you MANAGE (have installed versions) whose binary isn't resolving (a real
71
+ // problem). The other supported-but-unadopted agents collapse to one hint line
72
+ // instead of a column of red "not installed" nags for tools you never wanted.
73
+ const managed = new Set(ALL_AGENT_IDS.filter((a) => listInstalledVersions(a).length > 0));
74
+ const entries = Object.entries(clis);
75
+ const shown = entries.filter(([name, e]) => e.installed || managed.has(name));
76
+ const hidden = entries.filter(([name, e]) => !e.installed && !managed.has(name)).map(([name]) => name);
77
+ if (shown.length === 0) {
78
+ console.log(chalk.gray(' (none installed — `agents add <name>` to start)'));
74
79
  }
75
80
  else {
76
- for (const [name, entry] of Object.entries(clis)) {
77
- const pretty = AGENT_NAMES[name] || name;
81
+ for (const [name, entry] of shown) {
82
+ const pretty = (AGENT_NAMES[name] || name).padEnd(11);
78
83
  if (entry.installed) {
79
- console.log(` ${chalk.green('ready')} ${pretty.padEnd(10)} ${chalk.gray(entry.path || '')}`);
84
+ console.log(` ${chalk.green('ready')} ${pretty} ${chalk.gray(entry.path || '')}`);
80
85
  }
81
86
  else {
82
- console.log(` ${chalk.red('no ')} ${pretty.padEnd(10)} ${chalk.gray(entry.error || 'not installed')}`);
87
+ console.log(` ${chalk.red('no ')} ${pretty} ${chalk.gray(entry.error || 'not installed')}`);
83
88
  }
84
89
  }
85
90
  }
91
+ if (hidden.length > 0) {
92
+ console.log(chalk.gray(` +${hidden.length} more supported (${hidden.join(', ')}) — \`agents add <name>\` to manage`));
93
+ }
86
94
  console.log();
87
- console.log(chalk.bold('Sync status (default versions)'));
95
+ console.log(chalk.bold('Sync status (installed versions)'));
88
96
  if (syncRows.length === 0) {
89
- console.log(chalk.gray(' (no default versions set; pin one with `agents use <agent>@<version>`)'));
97
+ console.log(chalk.gray(' (no versions installed; add one with `agents add <agent>@<version>`)'));
90
98
  }
91
99
  else {
92
100
  for (const row of syncRows) {
93
- const label = `${AGENT_NAMES[row.agent] || row.agent}@${row.version}`;
101
+ const tag = row.isDefault ? chalk.gray(' (default)') : '';
102
+ const label = `${AGENT_NAMES[row.agent] || row.agent}@${row.version}${tag}`;
94
103
  if (row.status === 'fresh') {
95
104
  console.log(` ${chalk.green('fresh')} ${label}`);
96
105
  }
@@ -103,7 +112,7 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
103
112
  }
104
113
  }
105
114
  console.log();
106
- console.log(chalk.bold('Orphans (default versions)'));
115
+ console.log(chalk.bold('Orphans (installed versions)'));
107
116
  if (orphanRows.length === 0) {
108
117
  console.log(chalk.gray(' (none — version homes match central sources)'));
109
118
  }
@@ -340,9 +349,77 @@ function renderTargetText(report, options) {
340
349
  }
341
350
  else {
342
351
  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.`));
352
+ console.log(chalk.gray(` Run \`agents doctor ${report.agent}@${report.version} --fix\` to heal, or \`agents prune cleanup\` to drop extras.`));
344
353
  }
345
354
  }
355
+ // ─── fix / heal mode ───────────────────────────────────────────────────────────
356
+ function renderHealText(result) {
357
+ for (const r of result.repairedManifests) {
358
+ console.log(` ${chalk.green('repair')} plugin ${chalk.bold(r.plugin)} ${chalk.gray(`— dropped invalid ${r.droppedFields.join(', ')} field`)}`);
359
+ }
360
+ for (const r of result.refreshedPlugins) {
361
+ console.log(` ${chalk.green('refresh')} plugin ${chalk.bold(r.plugin)} ${chalk.gray(`${r.from} → ${r.to}`)}`);
362
+ }
363
+ for (const s of result.skippedPlugins) {
364
+ const why = s.reason === 'modified'
365
+ ? `locally modified — left as-is (run \`agents plugins update ${s.plugin}\` to force)`
366
+ : `no baseline recorded — left as-is (run \`agents plugins update ${s.plugin}\` to adopt)`;
367
+ console.log(` ${chalk.yellow('hold ')} plugin ${chalk.bold(s.plugin)} ${chalk.gray(`${s.from} → ${s.upstream} available; ${why}`)}`);
368
+ }
369
+ for (const v of result.versions) {
370
+ const label = `${AGENT_NAMES[v.agent] || v.agent}@${v.version}`;
371
+ if (v.healed.length === 0 && v.skipped.length === 0)
372
+ continue;
373
+ const byKind = new Map();
374
+ for (const h of v.healed)
375
+ byKind.set(h.kind, (byKind.get(h.kind) ?? 0) + 1);
376
+ const parts = Array.from(byKind, ([k, n]) => `${n} ${k}`);
377
+ if (v.healed.length > 0) {
378
+ console.log(` ${chalk.green('fixed ')} ${label} ${chalk.gray(parts.join(', '))}`);
379
+ }
380
+ const drift = v.skipped.filter((s) => s.reason === 'drift');
381
+ const unres = v.skipped.filter((s) => s.reason === 'unreconcilable');
382
+ if (drift.length > 0) {
383
+ console.log(` ${chalk.yellow('drift ')} ${label} ${chalk.gray(`${drift.length} hand-edited — left as-is (use \`--diff\` to inspect)`)}`);
384
+ }
385
+ if (unres.length > 0) {
386
+ const names = unres.map((s) => `${s.kind}/${s.name}`).join(', ');
387
+ console.log(` ${chalk.yellow('hold ')} ${label} ${chalk.gray(`${unres.length} couldn't reconcile (${names}) — source/home mismatch the writer can't satisfy`)}`);
388
+ }
389
+ }
390
+ console.log();
391
+ const healed = result.versions.reduce((n, v) => n + v.healed.length, 0);
392
+ const touchedVersions = result.versions.filter((v) => v.healed.length > 0).length;
393
+ if (!healChangedAnything(result)) {
394
+ console.log(chalk.green('✓ Everything in sync — nothing to heal.'));
395
+ }
396
+ else {
397
+ const bits = [];
398
+ if (healed > 0)
399
+ bits.push(`${healed} resource${healed === 1 ? '' : 's'} across ${touchedVersions} version${touchedVersions === 1 ? '' : 's'}`);
400
+ if (result.repairedManifests.length > 0)
401
+ bits.push(`${result.repairedManifests.length} manifest${result.repairedManifests.length === 1 ? '' : 's'} repaired`);
402
+ if (result.refreshedPlugins.length > 0)
403
+ bits.push(`${result.refreshedPlugins.length} plugin${result.refreshedPlugins.length === 1 ? '' : 's'} refreshed`);
404
+ console.log(chalk.green(`✓ Healed ${bits.join(', ')}.`));
405
+ }
406
+ }
407
+ async function runFix(parsed, opts) {
408
+ // Heal targets the global install — project layer is irrelevant, so cwd is
409
+ // left to heal's neutral default rather than process.cwd().
410
+ if (!opts.json)
411
+ console.log(chalk.bold('Healing…'));
412
+ const result = await heal({
413
+ mode: 'full',
414
+ agent: parsed?.agent,
415
+ versions: parsed?.versions,
416
+ });
417
+ if (opts.json) {
418
+ console.log(JSON.stringify(result, null, 2));
419
+ return;
420
+ }
421
+ renderHealText(result);
422
+ }
346
423
  // ─── command registration ────────────────────────────────────────────────────
347
424
  export function registerDoctorCommand(program) {
348
425
  const doctorCmd = program
@@ -350,6 +427,7 @@ export function registerDoctorCommand(program) {
350
427
  .description('Diagnose CLI availability, sync status, and resource divergence (optionally for a specific agent[@version]).')
351
428
  .option('--json', 'Output machine-readable JSON')
352
429
  .option('--diff', 'In target mode, include unified diffs for divergent files')
430
+ .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
431
  .option('--kind <kinds>', 'Restrict to comma-separated resource kinds (commands,skills,hooks,rules,mcp,permissions,subagents,plugins,promptcuts)')
354
432
  .option('--cwd <path>', 'Resolution cwd for project layer detection (default: process.cwd())');
355
433
  setHelpSections(doctorCmd, {
@@ -368,10 +446,31 @@ export function registerDoctorCommand(program) {
368
446
 
369
447
  # Inspect only rules and hooks, with full diffs
370
448
  agents doctor claude@default --kind rules,hooks --diff
449
+
450
+ # Heal every gap across all installed versions
451
+ agents doctor --fix
452
+
453
+ # Heal just one agent (all its installed versions)
454
+ agents doctor claude --fix
371
455
  `,
372
456
  });
373
- doctorCmd.action((target, opts) => {
457
+ doctorCmd.action(async (target, opts) => {
374
458
  const cwd = opts.cwd ? opts.cwd : process.cwd();
459
+ // --fix turns the read-only diagnosis into a heal. With no target it heals
460
+ // every installed version; with a target it scopes to that agent.
461
+ if (opts.fix) {
462
+ let scope = null;
463
+ if (target) {
464
+ const parsed = parseTargetArg(target);
465
+ if ('error' in parsed) {
466
+ console.error(chalk.red(parsed.error));
467
+ process.exit(1);
468
+ }
469
+ scope = parsed;
470
+ }
471
+ await runFix(scope, opts);
472
+ return;
473
+ }
375
474
  if (!target) {
376
475
  const clis = checkAllClis();
377
476
  const syncRows = checkSyncStatus(cwd);
@@ -20,6 +20,7 @@ import { getActiveSessions } from '../lib/session/active.js';
20
20
  import { discoverSessions, countSessionsInScope, resolveSessionById, searchContentIndex } from '../lib/session/discover.js';
21
21
  import { filterTeamSessions } from '../lib/session/team-filter.js';
22
22
  import { parseSession } from '../lib/session/parse.js';
23
+ import { runRemoteSessions } from '../lib/session/remote.js';
23
24
  import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList } from '../lib/session/render.js';
24
25
  import { renderMarkdown } from '../lib/markdown.js';
25
26
  import { colorAgent, resolveAgentName } from '../lib/agents.js';
@@ -313,6 +314,16 @@ async function renderActiveSessions(asJson) {
313
314
  }
314
315
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
315
316
  async function sessionsAction(query, options) {
317
+ if (options.host && options.host.length > 0) {
318
+ try {
319
+ runRemoteSessions(options.host);
320
+ }
321
+ catch (err) {
322
+ console.error(chalk.red(err.message));
323
+ process.exit(1);
324
+ }
325
+ return;
326
+ }
316
327
  if (options.active) {
317
328
  await renderActiveSessions(options.json === true);
318
329
  return;
@@ -1116,7 +1127,8 @@ export function registerSessionsCommands(program) {
1116
1127
  .option('--artifacts', 'List all files written or edited during a session')
1117
1128
  .option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
1118
1129
  .option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
1119
- .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk');
1130
+ .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
1131
+ .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)');
1120
1132
  setHelpSections(sessionsCmd, {
1121
1133
  examples: `
1122
1134
  # Search prior sessions in this project by topic, file path, or command
@@ -1136,8 +1148,15 @@ export function registerSessionsCommands(program) {
1136
1148
 
1137
1149
  # Export for analysis
1138
1150
  agents sessions --since 30d --limit 200 --json > sessions.json
1151
+
1152
+ # Search another machine's sessions live over SSH (no sync needed)
1153
+ agents sessions "auth bug" --last 3 --host yosemite-s1
1154
+
1155
+ # Fan the same query out across several machines
1156
+ agents sessions --all "deploy script" --host box-a --host box-b
1139
1157
  `,
1140
1158
  notes: `
1159
+ - --host runs the query on the remote's own index over SSH (host alias or user@host); repeat or pass several to fan out. SSH access is the only auth.
1141
1160
  - --include and --exclude are mutually exclusive.
1142
1161
  - --first and --last are mutually exclusive.
1143
1162
  - A filter flag (--include/--exclude/--first/--last) without --markdown/--json defaults to --markdown output.
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import * as fs from 'fs';
11
11
  import * as os from 'os';
12
12
  import * as path from 'path';
13
13
  import { fileURLToPath } from 'url';
14
+ import { detectDevBuild } from './lib/startup/dev-build.js';
14
15
  // `ora`, `@inquirer/prompts`, `./commands/utils.js`, and the agents/versions/shims
15
16
  // modules are imported dynamically at their use sites: they are needed only on
16
17
  // interactive / update / shim-repair paths, never for fast commands like
@@ -37,18 +38,7 @@ import { NPM_PACKAGE_NAME, deriveGlobalPrefix, detectPackageManager, installPack
37
38
  // must not scribble on the user's real ~/.agents/), and skip the update prompt
38
39
  // (the "0.0.0-dev -> 1.x.y" message is misleading). Each individual env var
39
40
  // can still be set explicitly to override (set to '0' to re-enable).
40
- const IS_DEV_BUILD = (() => {
41
- if (VERSION.startsWith('0.0.0-dev'))
42
- return true;
43
- try {
44
- const cliPath = process.argv[1] || '';
45
- const repoRoot = path.dirname(path.dirname(cliPath));
46
- return fs.existsSync(path.join(repoRoot, '.git'));
47
- }
48
- catch {
49
- return false;
50
- }
51
- })();
41
+ const IS_DEV_BUILD = detectDevBuild(process.argv[1] || '', VERSION);
52
42
  if (IS_DEV_BUILD) {
53
43
  if (process.env.AGENTS_NO_AUTOPULL === undefined)
54
44
  process.env.AGENTS_NO_AUTOPULL = '1';
@@ -0,0 +1,36 @@
1
+ import type { AgentId } from './types.js';
2
+ export interface AgentTarget {
3
+ agent: AgentId;
4
+ /** Resolved exact version, or null when the agent has no installed versions yet. */
5
+ version: string | null;
6
+ }
7
+ /** Canonical qualifier set, in help/display order. `pinned` ≡ `default`. */
8
+ export declare const AGENT_QUALIFIERS: readonly ["latest", "oldest", "pinned", "default", "all"];
9
+ export type AgentQualifier = (typeof AGENT_QUALIFIERS)[number];
10
+ /** Shared `--help` epilog so every agent-spec command documents the same grammar. */
11
+ export declare const AGENT_SPEC_HELP: string;
12
+ export declare class AgentSpecError extends Error {
13
+ constructor(message: string);
14
+ }
15
+ export interface ResolveAgentTargetsOptions {
16
+ /** Project dir for resolving a bare spec's project pin. Defaults to process.cwd(). */
17
+ cwd?: string;
18
+ /** Restrict the agents a spec may name (e.g. only mcp-capable). Defaults to all. */
19
+ availableAgents?: readonly AgentId[];
20
+ }
21
+ /**
22
+ * Resolve an agent spec (single or comma-list) into concrete installed targets.
23
+ * Domain = installed: `@latest`/`@oldest`/`@all` range over installed versions
24
+ * (`add`/`install` use a separate available-version path). Throws AgentSpecError
25
+ * on bad input — never calls process.exit, so it is safe on the hot path and in
26
+ * library contexts.
27
+ */
28
+ export declare function resolveAgentTargets(spec: string, opts?: ResolveAgentTargetsOptions): AgentTarget[];
29
+ /**
30
+ * Convenience for single-target commands (`use`, `run`): resolve a spec that
31
+ * must name exactly one installed version. Rejects `@all` / multi-target specs.
32
+ */
33
+ export declare function resolveSingleAgentTarget(spec: string, opts?: ResolveAgentTargetsOptions): {
34
+ agent: AgentId;
35
+ version: string;
36
+ };
@@ -0,0 +1,157 @@
1
+ // Centralized agent-spec resolution — one vocabulary, one resolver, reused by
2
+ // every subcommand that accepts `<agent>[@<qualifier>]`.
3
+ //
4
+ // The qualifier vocabulary used to be split across three functions in
5
+ // versions.ts (parseAgentSpec, resolveVersionAlias, resolveInstalledAgentTargets)
6
+ // with diverging support — `@latest`/`@oldest` in one, `@all`/`@default` in
7
+ // another, `@pinned` nowhere. This module is the single source of truth.
8
+ //
9
+ // Built for the hot path (`--launch`, ~100ms budget): the common specs resolve
10
+ // with NO directory enumeration —
11
+ // exact `claude@2.1.181` → one isVersionInstalled() (existsSync)
12
+ // `claude@pinned|@default` → memoized getGlobalDefault() + existsSync
13
+ // bare `claude` → resolveVersion() (memoized meta), no readdir
14
+ // Only the relative qualifiers `@latest`/`@oldest`/`@all` enumerate, and even
15
+ // then via the mtime-cached listInstalledVersions().
16
+ import { AGENTS, ALL_AGENT_IDS, resolveAgentName, formatAgentError } from './agents.js';
17
+ import { listInstalledVersions, getGlobalDefault, isVersionInstalled, resolveVersion, } from './versions.js';
18
+ /** Canonical qualifier set, in help/display order. `pinned` ≡ `default`. */
19
+ export const AGENT_QUALIFIERS = ['latest', 'oldest', 'pinned', 'default', 'all'];
20
+ /** Shared `--help` epilog so every agent-spec command documents the same grammar. */
21
+ export const AGENT_SPEC_HELP = 'Agent spec: <agent>[@<qualifier>]. Qualifiers: ' +
22
+ '@latest (highest installed), @oldest (lowest installed), ' +
23
+ '@pinned / @default (your configured default — synonyms), ' +
24
+ '@all (every installed version), or an exact @x.y.z. ' +
25
+ 'Bare <agent> uses the resolved default (project pin → global default). ' +
26
+ 'Comma-separate to combine: claude@all,codex@latest.';
27
+ export class AgentSpecError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.name = 'AgentSpecError';
31
+ }
32
+ }
33
+ /**
34
+ * Resolve an agent spec (single or comma-list) into concrete installed targets.
35
+ * Domain = installed: `@latest`/`@oldest`/`@all` range over installed versions
36
+ * (`add`/`install` use a separate available-version path). Throws AgentSpecError
37
+ * on bad input — never calls process.exit, so it is safe on the hot path and in
38
+ * library contexts.
39
+ */
40
+ export function resolveAgentTargets(spec, opts = {}) {
41
+ const cwd = opts.cwd ?? process.cwd();
42
+ const available = opts.availableAgents ?? ALL_AGENT_IDS;
43
+ const rawEntries = spec
44
+ .split(',')
45
+ .map((s) => s.trim())
46
+ .filter(Boolean);
47
+ if (rawEntries.length === 0) {
48
+ throw new AgentSpecError('Empty agent spec.');
49
+ }
50
+ // Expand the bare literal `all` (or `all@all`) into every available agent that
51
+ // has at least one installed version. Lenient: agents with nothing installed
52
+ // are skipped rather than erroring.
53
+ const entries = [];
54
+ for (const e of rawEntries) {
55
+ if (e === 'all' || e === 'all@all') {
56
+ for (const a of available) {
57
+ if (listInstalledVersions(a).length > 0)
58
+ entries.push(`${a}@all`);
59
+ }
60
+ }
61
+ else {
62
+ entries.push(e);
63
+ }
64
+ }
65
+ const out = [];
66
+ const seen = new Set();
67
+ const push = (agent, version) => {
68
+ const key = `${agent}@${version ?? ''}`;
69
+ if (!seen.has(key)) {
70
+ seen.add(key);
71
+ out.push({ agent, version });
72
+ }
73
+ };
74
+ for (const entry of entries) {
75
+ const at = entry.indexOf('@');
76
+ const agentToken = (at === -1 ? entry : entry.slice(0, at)).trim();
77
+ const qualifier = at === -1 ? null : entry.slice(at + 1).trim();
78
+ if (!agentToken)
79
+ continue;
80
+ if (at !== -1 && !qualifier) {
81
+ throw new AgentSpecError(`Missing version in '${entry}'. Use ${agentToken}@x.y.z, @latest, @oldest, @pinned, @default, or @all.`);
82
+ }
83
+ const agent = resolveAgentName(agentToken);
84
+ if (!agent || !available.includes(agent)) {
85
+ throw new AgentSpecError(formatAgentError(agentToken, [...available]));
86
+ }
87
+ const name = AGENTS[agent].name;
88
+ // ----- bare: resolved default, NO enumeration in the common case -----
89
+ if (qualifier === null) {
90
+ const resolved = resolveVersion(agent, cwd); // project pin → global default (meta-only)
91
+ if (resolved) {
92
+ push(agent, resolved);
93
+ }
94
+ else {
95
+ const installed = listInstalledVersions(agent);
96
+ if (installed.length === 0)
97
+ push(agent, null);
98
+ else if (installed.length === 1)
99
+ push(agent, installed[0]);
100
+ else
101
+ throw new AgentSpecError(`No default version set for ${name}. Specify one (${agent}@<version>) or set it: agents use ${agent}@<version>.`);
102
+ }
103
+ continue;
104
+ }
105
+ // ----- @pinned / @default: synonyms, meta-only fast path -----
106
+ if (qualifier === 'pinned' || qualifier === 'default') {
107
+ const def = getGlobalDefault(agent);
108
+ if (!def) {
109
+ throw new AgentSpecError(`No default version set for ${name}. Run: agents use ${agent}@<version>`);
110
+ }
111
+ push(agent, def);
112
+ continue;
113
+ }
114
+ // ----- @all: every installed version -----
115
+ if (qualifier === 'all') {
116
+ const installed = listInstalledVersions(agent);
117
+ if (installed.length === 0) {
118
+ throw new AgentSpecError(`No managed versions are installed for ${name}. Run: agents add ${agent}@latest`);
119
+ }
120
+ for (const v of installed)
121
+ push(agent, v);
122
+ continue;
123
+ }
124
+ // ----- @latest / @oldest: enumerate (mtime-cached), pick an end -----
125
+ if (qualifier === 'latest' || qualifier === 'oldest') {
126
+ const installed = listInstalledVersions(agent); // already sorted ascending
127
+ if (installed.length === 0) {
128
+ throw new AgentSpecError(`No managed versions are installed for ${name}. Run: agents add ${agent}@latest`);
129
+ }
130
+ push(agent, qualifier === 'oldest' ? installed[0] : installed[installed.length - 1]);
131
+ continue;
132
+ }
133
+ // ----- exact version: one existsSync, NO enumeration -----
134
+ if (!isVersionInstalled(agent, qualifier)) {
135
+ const installed = listInstalledVersions(agent);
136
+ const hint = installed.length ? ` Installed: ${installed.join(', ')}.` : '';
137
+ throw new AgentSpecError(`${name}@${qualifier} is not installed.${hint} Install it: agents add ${agent}@${qualifier}`);
138
+ }
139
+ push(agent, qualifier);
140
+ }
141
+ return out;
142
+ }
143
+ /**
144
+ * Convenience for single-target commands (`use`, `run`): resolve a spec that
145
+ * must name exactly one installed version. Rejects `@all` / multi-target specs.
146
+ */
147
+ export function resolveSingleAgentTarget(spec, opts = {}) {
148
+ const targets = resolveAgentTargets(spec, opts);
149
+ if (targets.length !== 1) {
150
+ throw new AgentSpecError(`'${spec}' resolves to ${targets.length} targets; this command needs exactly one.`);
151
+ }
152
+ const t = targets[0];
153
+ if (t.version === null) {
154
+ throw new AgentSpecError(`No installed version for ${AGENTS[t.agent].name}. Run: agents add ${t.agent}@latest`);
155
+ }
156
+ return { agent: t.agent, version: t.version };
157
+ }
@@ -296,6 +296,7 @@ export const AGENTS = {
296
296
  commandsDir: '', // OpenClaw uses Gateway-based slash commands, not file-based
297
297
  commandsSubdir: '',
298
298
  skillsDir: path.join(HOME, '.openclaw', 'skills'),
299
+ nativeCommandRuntime: true, // Gateway resolves slash commands — don't convert commands to skills
299
300
  hooksDir: 'hooks',
300
301
  instructionsFile: 'workspace/AGENTS.md', // Primary memory file (also has SOUL.md, IDENTITY.md, etc.)
301
302
  format: 'markdown',
@@ -260,6 +260,36 @@ export async function runDaemon() {
260
260
  };
261
261
  const syncInterval = setInterval(() => { void runSessionSync(); }, 90_000);
262
262
  void runSessionSync(); // kick once at startup
263
+ // Resource safety check: heal gaps between what DotAgents repos define and
264
+ // what's actually installed in each agent home — the slow rot that nothing
265
+ // else catches (a non-default version left stale, a Claude-invalid plugin
266
+ // manifest silently rejecting a whole plugin). Conservative 'safe' mode: it
267
+ // fills missing resources, repairs invalid manifests, and fast-forwards
268
+ // provably-unmodified stale plugins, but never overwrites hand-edited content
269
+ // or a plugin it can't prove is pristine — those it reports for `doctor --fix`.
270
+ // Runs ~every 6h plus once ~30s after startup (staggered so launch isn't busy).
271
+ let healing = false;
272
+ const runHealCheck = async () => {
273
+ if (healing)
274
+ return;
275
+ healing = true;
276
+ try {
277
+ const { heal, summarizeHeal, notifyHeal, healChangedAnything } = await import('./heal.js');
278
+ const result = await heal({ mode: 'safe' });
279
+ if (healChangedAnything(result) || result.skippedPlugins.length > 0) {
280
+ log('INFO', `heal: ${summarizeHeal(result)}`);
281
+ notifyHeal(result);
282
+ }
283
+ }
284
+ catch (err) {
285
+ log('ERROR', `heal check failed: ${err.message}`);
286
+ }
287
+ finally {
288
+ healing = false;
289
+ }
290
+ };
291
+ const healInterval = setInterval(() => { void runHealCheck(); }, 6 * 60 * 60_000);
292
+ const healKickoff = setTimeout(() => { void runHealCheck(); }, 30_000);
263
293
  const handleReload = () => {
264
294
  log('INFO', 'Reloading jobs (SIGHUP)');
265
295
  scheduler.reloadAll();
@@ -275,6 +305,8 @@ export async function runDaemon() {
275
305
  await browserIPC.stop();
276
306
  clearInterval(monitorInterval);
277
307
  clearInterval(syncInterval);
308
+ clearInterval(healInterval);
309
+ clearTimeout(healKickoff);
278
310
  removeDaemonPid();
279
311
  process.exit(0);
280
312
  };
@@ -59,6 +59,13 @@ export interface DiffOptions {
59
59
  cwd?: string;
60
60
  /** Restrict to specific kinds; undefined = all. */
61
61
  kinds?: DoctorKind[];
62
+ /**
63
+ * Drop the project (`<cwd>/.agents/`) layer from resolution. Used by the heal
64
+ * path: the GLOBAL version home is only ever reconciled against user/system/
65
+ * extra sources — project resources are layered at launch, never synced into
66
+ * the global home, so counting them as "missing" there is a false gap.
67
+ */
68
+ excludeProject?: boolean;
62
69
  }
63
70
  export declare function diffVersionResources(agent: AgentId, version: string, options?: DiffOptions): VersionResourceReport;
64
71
  export declare const DOCTOR_ALL_KINDS: DoctorKind[];