@phnx-labs/agents-cli 1.20.27 → 1.20.29

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 (101) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/dist/commands/doctor.js +57 -4
  3. package/dist/commands/exec.d.ts +1 -1
  4. package/dist/commands/exec.js +198 -15
  5. package/dist/commands/hosts.d.ts +11 -0
  6. package/dist/commands/hosts.js +229 -0
  7. package/dist/commands/repo.d.ts +29 -0
  8. package/dist/commands/repo.js +174 -38
  9. package/dist/commands/secrets.d.ts +2 -7
  10. package/dist/commands/secrets.js +108 -29
  11. package/dist/commands/sessions.d.ts +2 -0
  12. package/dist/commands/sessions.js +8 -24
  13. package/dist/commands/ssh.d.ts +14 -0
  14. package/dist/commands/ssh.js +263 -0
  15. package/dist/commands/sync.d.ts +2 -0
  16. package/dist/commands/sync.js +22 -5
  17. package/dist/commands/view.js +27 -11
  18. package/dist/index.js +3 -1
  19. package/dist/lib/agents.d.ts +1 -0
  20. package/dist/lib/agents.js +44 -4
  21. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  22. package/dist/lib/browser/drivers/ssh.js +113 -24
  23. package/dist/lib/browser/profiles.js +28 -1
  24. package/dist/lib/browser/runtime-state.js +28 -8
  25. package/dist/lib/browser/types.d.ts +10 -1
  26. package/dist/lib/cli-resources.js +10 -1
  27. package/dist/lib/devices/connect.d.ts +34 -0
  28. package/dist/lib/devices/connect.js +101 -0
  29. package/dist/lib/devices/registry.d.ts +78 -0
  30. package/dist/lib/devices/registry.js +168 -0
  31. package/dist/lib/devices/ssh-config.d.ts +21 -0
  32. package/dist/lib/devices/ssh-config.js +33 -0
  33. package/dist/lib/devices/tailscale.d.ts +31 -0
  34. package/dist/lib/devices/tailscale.js +126 -0
  35. package/dist/lib/doctor-diff.d.ts +12 -0
  36. package/dist/lib/doctor-diff.js +89 -2
  37. package/dist/lib/exec.d.ts +27 -0
  38. package/dist/lib/exec.js +62 -19
  39. package/dist/lib/hooks.d.ts +17 -0
  40. package/dist/lib/hooks.js +127 -3
  41. package/dist/lib/hosts/dispatch.d.ts +26 -0
  42. package/dist/lib/hosts/dispatch.js +71 -0
  43. package/dist/lib/hosts/progress.d.ts +21 -0
  44. package/dist/lib/hosts/progress.js +49 -0
  45. package/dist/lib/hosts/providers/local.d.ts +17 -0
  46. package/dist/lib/hosts/providers/local.js +81 -0
  47. package/dist/lib/hosts/ready.d.ts +37 -0
  48. package/dist/lib/hosts/ready.js +88 -0
  49. package/dist/lib/hosts/registry.d.ts +22 -0
  50. package/dist/lib/hosts/registry.js +65 -0
  51. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  52. package/dist/lib/hosts/ssh-config.js +157 -0
  53. package/dist/lib/hosts/tasks.d.ts +32 -0
  54. package/dist/lib/hosts/tasks.js +58 -0
  55. package/dist/lib/hosts/types.d.ts +51 -0
  56. package/dist/lib/hosts/types.js +21 -0
  57. package/dist/lib/loop.d.ts +9 -0
  58. package/dist/lib/loop.js +13 -1
  59. package/dist/lib/mcp.js +12 -3
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  61. package/dist/lib/migrate.js +9 -5
  62. package/dist/lib/platform/exec.d.ts +10 -0
  63. package/dist/lib/platform/exec.js +17 -0
  64. package/dist/lib/platform/index.d.ts +1 -0
  65. package/dist/lib/platform/index.js +1 -0
  66. package/dist/lib/platform/links.d.ts +15 -0
  67. package/dist/lib/platform/links.js +42 -0
  68. package/dist/lib/platform/paths.d.ts +18 -0
  69. package/dist/lib/platform/paths.js +22 -0
  70. package/dist/lib/platform/posixpath.d.ts +28 -0
  71. package/dist/lib/platform/posixpath.js +153 -0
  72. package/dist/lib/plugins.d.ts +10 -0
  73. package/dist/lib/plugins.js +1 -1
  74. package/dist/lib/project-launch.js +6 -3
  75. package/dist/lib/sandbox.js +5 -2
  76. package/dist/lib/secrets/remote.d.ts +67 -0
  77. package/dist/lib/secrets/remote.js +133 -0
  78. package/dist/lib/self-update.js +7 -2
  79. package/dist/lib/session/db.d.ts +24 -0
  80. package/dist/lib/session/db.js +80 -5
  81. package/dist/lib/session/discover.d.ts +28 -0
  82. package/dist/lib/session/discover.js +303 -4
  83. package/dist/lib/session/parse.d.ts +7 -0
  84. package/dist/lib/session/parse.js +110 -0
  85. package/dist/lib/session/relative-time.d.ts +7 -0
  86. package/dist/lib/session/relative-time.js +28 -0
  87. package/dist/lib/session/remote.d.ts +31 -3
  88. package/dist/lib/session/remote.js +121 -14
  89. package/dist/lib/session/types.d.ts +1 -1
  90. package/dist/lib/session/types.js +1 -1
  91. package/dist/lib/ssh-exec.d.ts +45 -0
  92. package/dist/lib/ssh-exec.js +61 -0
  93. package/dist/lib/startup/command-registry.d.ts +2 -0
  94. package/dist/lib/startup/command-registry.js +5 -0
  95. package/dist/lib/state.d.ts +2 -0
  96. package/dist/lib/state.js +2 -0
  97. package/dist/lib/types.d.ts +21 -0
  98. package/dist/lib/versions.d.ts +6 -2
  99. package/dist/lib/versions.js +8 -4
  100. package/package.json +1 -1
  101. package/scripts/postinstall.js +62 -0
package/CHANGELOG.md CHANGED
@@ -38,6 +38,9 @@
38
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
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
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>`.
41
44
 
42
45
  **Secrets prompt policy: human-readable `always` / `daily`, and `secrets list` now shows it**
43
46
 
@@ -14,6 +14,36 @@ import { heal, healChangedAnything } from '../lib/heal.js';
14
14
  import * as fs from 'fs';
15
15
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
16
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
+ }
17
47
  function checkSyncStatus(cwd) {
18
48
  const rows = [];
19
49
  // Every installed version, not just the default — a stale NON-default version
@@ -27,7 +57,16 @@ function checkSyncStatus(cwd) {
27
57
  const status = !manifest
28
58
  ? 'never-synced'
29
59
  : isStale(manifest, agent, version, cwd) ? 'stale' : 'fresh';
30
- rows.push({ agent, version, status, isDefault: version === def });
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);
31
70
  }
32
71
  }
33
72
  return rows;
@@ -97,6 +136,7 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
97
136
  console.log(chalk.gray(' (no versions installed; add one with `agents add <agent>@<version>`)'));
98
137
  }
99
138
  else {
139
+ let anyOutOfSync = false;
100
140
  for (const row of syncRows) {
101
141
  const tag = row.isDefault ? chalk.gray(' (default)') : '';
102
142
  const label = `${AGENT_NAMES[row.agent] || row.agent}@${row.version}${tag}`;
@@ -104,12 +144,24 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
104
144
  console.log(` ${chalk.green('fresh')} ${label}`);
105
145
  }
106
146
  else if (row.status === 'stale') {
107
- 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
+ }
108
152
  }
109
153
  else {
110
- 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')}`);
111
156
  }
112
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
+ }
113
165
  }
114
166
  console.log();
115
167
  console.log(chalk.bold('Orphans (installed versions)'));
@@ -261,7 +313,8 @@ function renderKindSection(kind, rows, layers, options) {
261
313
  }
262
314
  for (const r of visible) {
263
315
  const src = sourceLabel(r, layers);
264
- 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}`);
265
318
  if (options.showDiff && r.status === 'diff' && r.sourcePath && r.homePath) {
266
319
  const expected = readExpectedForDiff(kind, r);
267
320
  const actual = safeRead(r.homePath);
@@ -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,11 +324,12 @@ 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 }, { splitBundleRef, resolveSshTarget, remoteResolveEnv }, { 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'),
267
331
  import('../lib/secrets/bundles.js'),
332
+ import('../lib/secrets/remote.js'),
268
333
  import('../lib/rotate.js'),
269
334
  import('../lib/versions.js'),
270
335
  import('../lib/plugins.js'),
@@ -489,6 +554,112 @@ export function registerRunCommand(program) {
489
554
  }
490
555
  }
491
556
  version = resolveVersionAlias(agent, version);
557
+ // --resume: resolve a prior conversation and rewrite the run target to
558
+ // continue it. `version` here is already the alias-resolved candidate-version
559
+ // FILTER (undefined for default/any, concrete for @latest/@oldest/@x.y.z);
560
+ // it is replaced below by the chosen session's OWN version (isolation).
561
+ let resumeNative = false;
562
+ let resumeSessionId;
563
+ let forceInteractive = false;
564
+ if (options.resume !== undefined) {
565
+ if (options.sessionId) {
566
+ 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.'));
567
+ process.exit(1);
568
+ }
569
+ if (options.loop || options.fallback || options.resumeCheckpoint) {
570
+ console.error(chalk.red('--resume cannot be combined with --loop, --fallback, or --resume-checkpoint (those are separate continuation mechanisms).'));
571
+ process.exit(1);
572
+ }
573
+ const { findSessionsById } = await import('../lib/session/db.js');
574
+ const { discoverSessions } = await import('../lib/session/discover.js');
575
+ const { pickSessionInteractive } = await import('./sessions.js');
576
+ const { buildContinuePrompt } = await import('../lib/loop.js');
577
+ // Freshen the index for this agent before any lookup (incremental, cached).
578
+ // AgentId is wider than SessionAgentId (cursor/amp/… keep no transcripts);
579
+ // those simply yield no matches and fall through to the not-found error.
580
+ const sessionAgent = agent;
581
+ await discoverSessions({ agent: sessionAgent, version });
582
+ // Resume is interactive unless a follow-on prompt makes it headless.
583
+ const wantsInteractive = resolveInteractive({ interactive: options.interactive, headless: options.headless, prompt });
584
+ const idArg = typeof options.resume === 'string' ? options.resume.trim() : '';
585
+ let scopeCwd;
586
+ try {
587
+ scopeCwd = fs.realpathSync(cwd);
588
+ }
589
+ catch {
590
+ scopeCwd = cwd;
591
+ }
592
+ let session;
593
+ if (idArg) {
594
+ let matches = findSessionsById(idArg, { agent: sessionAgent, version, cwd: scopeCwd });
595
+ if (matches.length === 0) {
596
+ const wide = findSessionsById(idArg, { agent: sessionAgent, version });
597
+ if (wide.length > 0) {
598
+ if (!options.quiet)
599
+ process.stderr.write(chalk.gray(`No match for "${idArg}" in this project; widened to all projects.\n`));
600
+ matches = wide;
601
+ }
602
+ }
603
+ if (matches.length === 0) {
604
+ console.error(chalk.red(`No ${agent} session matching "${idArg}".`));
605
+ console.error(chalk.gray(`Browse sessions: agents sessions ${idArg}`));
606
+ process.exit(1);
607
+ }
608
+ else if (matches.length === 1) {
609
+ session = matches[0];
610
+ }
611
+ else if (wantsInteractive) {
612
+ const picked = await pickSessionInteractive(matches, `Multiple sessions match "${idArg}":`);
613
+ if (!picked)
614
+ process.exit(0);
615
+ session = picked.session;
616
+ }
617
+ else {
618
+ console.error(chalk.red(`"${idArg}" is ambiguous — ${matches.length} sessions match:`));
619
+ for (const m of matches.slice(0, 10)) {
620
+ console.error(chalk.gray(` ${m.shortId} ${m.timestamp.slice(0, 16).replace('T', ' ')} ${m.topic ?? m.label ?? ''}`));
621
+ }
622
+ console.error(chalk.gray('Pass more of the id, or resume interactively (drop the prompt).'));
623
+ process.exit(1);
624
+ }
625
+ }
626
+ else {
627
+ // Bare --resume: pick from recent sessions in scope. Needs a TTY.
628
+ if (!wantsInteractive) {
629
+ console.error(chalk.red('--resume with no id needs an interactive terminal. Pass a session id (full or prefix), or run without --headless.'));
630
+ process.exit(1);
631
+ }
632
+ const recent = await discoverSessions({ agent: sessionAgent, version, limit: 200 });
633
+ if (recent.length === 0) {
634
+ console.error(chalk.red(`No ${agent} sessions found to resume in this project.`));
635
+ console.error(chalk.gray('Browse all: agents sessions'));
636
+ process.exit(1);
637
+ }
638
+ const picked = await pickSessionInteractive(recent, `Resume which ${agent} session?`);
639
+ if (!picked)
640
+ process.exit(0);
641
+ session = picked.session;
642
+ forceInteractive = true; // bare resume always lands in the agent's TUI
643
+ }
644
+ // Pin to the chosen session's own version (the isolated HOME the transcript
645
+ // lives in) and route by tier.
646
+ version = session.version;
647
+ if (nativeResume(agent)) {
648
+ resumeNative = true;
649
+ resumeSessionId = session.id;
650
+ if (!options.quiet)
651
+ process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''}\n`));
652
+ }
653
+ else {
654
+ // Tier-2: launch fresh with a /continue <id> first message; the agent
655
+ // loads the transcript via `agents sessions <id>` and picks up.
656
+ prompt = buildContinuePrompt(session.id, prompt);
657
+ if (prompt.trim() === `/continue ${session.id}`)
658
+ forceInteractive = true;
659
+ if (!options.quiet)
660
+ process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (/continue replay)${version ? ` @${version}` : ''}\n`));
661
+ }
662
+ }
492
663
  const configuredStrategy = getConfiguredRunStrategy(agent, cwd);
493
664
  const explicitStrategy = options.strategy ? normalizeRunStrategy(options.strategy) : null;
494
665
  if (options.strategy && !explicitStrategy) {
@@ -606,17 +777,28 @@ export function registerRunCommand(program) {
606
777
  // ones. Any resolution failure (missing keychain item, blocked exec ref)
607
778
  // aborts before spawn so the agent never sees a partial env.
608
779
  let secretsEnv = {};
609
- for (const bundleName of options.secrets) {
780
+ for (const bundleRef of options.secrets) {
610
781
  try {
611
- const { bundle, env: bundleEnv } = readAndResolveBundleEnv(bundleName, { caller: `agent ${agent}` });
612
- const entries = describeBundle(bundle);
613
- const counts = {};
614
- for (const e of entries) {
615
- counts[e.kind] = (counts[e.kind] || 0) + 1;
782
+ const { bundle: bundleName, host } = splitBundleRef(bundleRef);
783
+ if (host) {
784
+ // Remote bundle (`bundle@host`): resolve over SSH and inject
785
+ // ephemerally values never touch this machine's keychain or disk.
786
+ const target = await resolveSshTarget(host);
787
+ const bundleEnv = await remoteResolveEnv(target, bundleName);
788
+ console.log(chalk.gray(`[secrets] Resolved ${bundleName}@${host}: ${Object.keys(bundleEnv).length} keys (remote, ephemeral)`));
789
+ secretsEnv = { ...secretsEnv, ...bundleEnv };
790
+ }
791
+ else {
792
+ const { bundle, env: bundleEnv } = readAndResolveBundleEnv(bundleName, { caller: `agent ${agent}` });
793
+ const entries = describeBundle(bundle);
794
+ const counts = {};
795
+ for (const e of entries) {
796
+ counts[e.kind] = (counts[e.kind] || 0) + 1;
797
+ }
798
+ const breakdown = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ');
799
+ console.log(chalk.gray(`[secrets] Resolved ${bundleName}: ${entries.length} keys (${breakdown})`));
800
+ secretsEnv = { ...secretsEnv, ...bundleEnv };
616
801
  }
617
- const breakdown = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ');
618
- console.log(chalk.gray(`[secrets] Resolved ${bundleName}: ${entries.length} keys (${breakdown})`));
619
- secretsEnv = { ...secretsEnv, ...bundleEnv };
620
802
  }
621
803
  catch (err) {
622
804
  console.error(chalk.red(err.message));
@@ -639,7 +821,7 @@ export function registerRunCommand(program) {
639
821
  agent,
640
822
  version,
641
823
  prompt,
642
- interactive: options.interactive,
824
+ interactive: options.interactive || forceInteractive,
643
825
  mode,
644
826
  effort,
645
827
  cwd: options.cwd,
@@ -647,7 +829,8 @@ export function registerRunCommand(program) {
647
829
  addDirs: options.addDir,
648
830
  json: options.json,
649
831
  headless: options.headless,
650
- sessionId: options.sessionId,
832
+ sessionId: resumeSessionId ?? options.sessionId,
833
+ resume: resumeNative,
651
834
  verbose: options.verbose,
652
835
  timeout: options.timeout,
653
836
  env,
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `agents hosts` — register and inspect agent hosts (machines you offload runs to).
3
+ *
4
+ * The registry is a thin overlay: ssh-config hosts are dispatchable with zero
5
+ * registration (connection details stay in ~/.ssh/config); enrollment only adds
6
+ * capability metadata or bootstraps agents-cli, plus inline (non-ssh-config)
7
+ * hosts. Dispatch itself is `agents run --host <name>` (see commands/exec.ts).
8
+ */
9
+ import type { Command } from 'commander';
10
+ /** Register the `agents hosts` command tree. */
11
+ export declare function registerHostsCommand(program: Command): void;
@@ -0,0 +1,229 @@
1
+ /**
2
+ * `agents hosts` — register and inspect agent hosts (machines you offload runs to).
3
+ *
4
+ * The registry is a thin overlay: ssh-config hosts are dispatchable with zero
5
+ * registration (connection details stay in ~/.ssh/config); enrollment only adds
6
+ * capability metadata or bootstraps agents-cli, plus inline (non-ssh-config)
7
+ * hosts. Dispatch itself is `agents run --host <name>` (see commands/exec.ts).
8
+ */
9
+ import chalk from 'chalk';
10
+ import { checkbox, confirm } from '@inquirer/prompts';
11
+ import { assertValidSshTarget } from '../lib/ssh-exec.js';
12
+ import { getProvider, listAllHosts, resolveHost } from '../lib/hosts/registry.js';
13
+ import { sshTargetFor } from '../lib/hosts/types.js';
14
+ import { listSshConfigHosts, listKnownHosts, isSshConfigHost } from '../lib/hosts/ssh-config.js';
15
+ import { probeHost, remoteAgentsVersion, bootstrapAgentsCli, localCliVersion, } from '../lib/hosts/ready.js';
16
+ import { listTasks, loadTask, localLogPath } from '../lib/hosts/tasks.js';
17
+ import { followHostTask } from '../lib/hosts/progress.js';
18
+ import * as fs from 'fs';
19
+ /** Parse `user@host` or `host` into its pieces. */
20
+ function parseTarget(target) {
21
+ const at = target.indexOf('@');
22
+ if (at === -1)
23
+ return { address: target };
24
+ return { user: target.slice(0, at), address: target.slice(at + 1) };
25
+ }
26
+ /** Bootstrap/verify agents-cli on a freshly-enrolled host (best-effort, prompts). */
27
+ async function maybeBootstrap(target, hostName) {
28
+ const probe = probeHost(target);
29
+ if (!probe.reachable) {
30
+ console.log(chalk.yellow(` Not reachable over SSH yet — skipping bootstrap. Fix key auth, then: agents hosts check ${hostName}`));
31
+ return;
32
+ }
33
+ const remoteVer = remoteAgentsVersion(target);
34
+ const localVer = localCliVersion();
35
+ if (!remoteVer) {
36
+ const ok = await confirm({ message: ` agents-cli not found on ${hostName}. Install ${localVer ? `v${localVer}` : 'latest'} now?`, default: true });
37
+ if (ok) {
38
+ console.log(chalk.gray(' Installing agents-cli on the host…'));
39
+ const r = bootstrapAgentsCli(target, localVer);
40
+ console.log(r.ok ? chalk.green(' Installed.') : chalk.red(` Install failed:\n${r.output}`));
41
+ }
42
+ return;
43
+ }
44
+ const remoteClean = remoteVer.replace(/^v/, '');
45
+ if (localVer && remoteClean !== localVer) {
46
+ const ok = await confirm({ message: ` ${hostName} has agents-cli ${remoteClean}, you have ${localVer}. Upgrade to match?`, default: false });
47
+ if (ok) {
48
+ const r = bootstrapAgentsCli(target, localVer);
49
+ console.log(r.ok ? chalk.green(' Upgraded.') : chalk.red(` Upgrade failed:\n${r.output}`));
50
+ }
51
+ }
52
+ }
53
+ async function registerHost(spec) {
54
+ const provider = getProvider('local');
55
+ await provider.register(spec);
56
+ }
57
+ async function doAdd(name, target, opts) {
58
+ // No name + no target → interactive scan of ssh sources.
59
+ if (!name && !target) {
60
+ const existing = new Set((await listAllHosts()).filter((h) => h.enrolled).map((h) => h.name));
61
+ const candidates = [...new Set([...listSshConfigHosts(), ...listKnownHosts()])].filter((c) => !existing.has(c));
62
+ if (candidates.length === 0) {
63
+ console.log(chalk.yellow('No SSH hosts found in ~/.ssh/config or ~/.ssh/known_hosts. Add one explicitly: agents hosts add <name> <user@host>'));
64
+ return;
65
+ }
66
+ const picked = await checkbox({
67
+ message: 'Select hosts to enroll (connection details come from ~/.ssh/config)',
68
+ choices: candidates.map((c) => ({ value: c, name: c })),
69
+ });
70
+ for (const c of picked) {
71
+ // The ssh target is the candidate name itself either way: ssh resolves it
72
+ // for ssh-config hosts, and it's a reachable hostname for known_hosts ones.
73
+ const source = isSshConfigHost(c) ? 'ssh-config' : 'inline';
74
+ const probe = probeHost(c);
75
+ await registerHost({ name: c, provider: 'local', source, ...(source === 'inline' ? { address: c } : {}), os: probe.os, caps: opts.cap });
76
+ console.log(chalk.green(`Enrolled ${c}`) + chalk.gray(` (${source}${probe.os ? `, ${probe.os}` : ''})`));
77
+ if (opts.enroll !== false)
78
+ await maybeBootstrap(c, c);
79
+ }
80
+ return;
81
+ }
82
+ if (!name) {
83
+ console.log(chalk.red('Usage: agents hosts add <name> [user@host]'));
84
+ process.exitCode = 1;
85
+ return;
86
+ }
87
+ let spec;
88
+ let sshTarget;
89
+ if (target) {
90
+ assertValidSshTarget(target);
91
+ const { address, user } = parseTarget(target);
92
+ spec = { name, provider: 'local', source: 'inline', address, user, caps: opts.cap, os: opts.os };
93
+ sshTarget = target;
94
+ }
95
+ else if (isSshConfigHost(name)) {
96
+ spec = { name, provider: 'local', source: 'ssh-config', caps: opts.cap, os: opts.os };
97
+ sshTarget = name;
98
+ }
99
+ else {
100
+ console.log(chalk.red(`"${name}" is not in ~/.ssh/config. Pass a target: agents hosts add ${name} <user@host>`));
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+ const probe = probeHost(sshTarget);
105
+ if (!spec.os && probe.os)
106
+ spec.os = probe.os;
107
+ await registerHost(spec);
108
+ console.log(chalk.green(`Enrolled ${name}`) + chalk.gray(` (${spec.source}${spec.os ? `, ${spec.os}` : ''}${spec.caps?.length ? `, caps: ${spec.caps.join(',')}` : ''})`));
109
+ if (opts.enroll !== false)
110
+ await maybeBootstrap(sshTarget, name);
111
+ }
112
+ async function doList(json) {
113
+ const hosts = await listAllHosts();
114
+ if (json) {
115
+ console.log(JSON.stringify(hosts, null, 2));
116
+ return;
117
+ }
118
+ if (hosts.length === 0) {
119
+ console.log(chalk.gray('No hosts. Enroll one: agents hosts add <name> <user@host> (or just: agents hosts add)'));
120
+ return;
121
+ }
122
+ console.log(chalk.bold('NAME').padEnd(20) + chalk.bold('SOURCE').padEnd(13) + chalk.bold('TARGET').padEnd(28) + chalk.bold('CAPS'));
123
+ for (const h of hosts) {
124
+ const tgt = h.source === 'ssh-config' ? chalk.gray('(ssh-config)') : `${h.user ? h.user + '@' : ''}${h.address ?? ''}`;
125
+ const mark = h.enrolled ? '' : chalk.gray(' ·available');
126
+ console.log(h.name.padEnd(20) + h.source.padEnd(13) + tgt.padEnd(28) + (h.caps?.join(',') ?? '') + mark);
127
+ }
128
+ }
129
+ async function doCheck(name) {
130
+ const host = await resolveHost(name);
131
+ if (!host) {
132
+ console.log(chalk.red(`Unknown host "${name}". Known: ${(await listAllHosts()).map((h) => h.name).join(', ') || '(none)'}`));
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+ const target = sshTargetFor(host);
137
+ process.stdout.write(`Probing ${chalk.cyan(name)} (${target})… `);
138
+ const probe = probeHost(target);
139
+ if (!probe.reachable) {
140
+ console.log(chalk.red('unreachable'));
141
+ process.exitCode = 1;
142
+ return;
143
+ }
144
+ console.log(chalk.green('reachable') + chalk.gray(probe.os ? ` · ${probe.os}` : ''));
145
+ const ver = remoteAgentsVersion(target);
146
+ console.log(` agents-cli: ${ver ? chalk.green(ver) : chalk.yellow('not installed')}`);
147
+ }
148
+ async function doRemove(name) {
149
+ const host = await resolveHost(name);
150
+ if (!host || !host.enrolled) {
151
+ console.log(chalk.yellow(`"${name}" is not enrolled (nothing to remove).`));
152
+ return;
153
+ }
154
+ await getProvider('local').remove(name);
155
+ console.log(chalk.green(`Removed ${name}`));
156
+ }
157
+ async function doPs(json) {
158
+ const tasks = listTasks();
159
+ if (json) {
160
+ console.log(JSON.stringify(tasks, null, 2));
161
+ return;
162
+ }
163
+ if (tasks.length === 0) {
164
+ console.log(chalk.gray('No host tasks yet. Dispatch one: agents run <agent> "<task>" --host <name>'));
165
+ return;
166
+ }
167
+ console.log(chalk.bold('ID').padEnd(11) + chalk.bold('HOST').padEnd(16) + chalk.bold('AGENT').padEnd(10) + chalk.bold('STATUS').padEnd(11) + chalk.bold('PROMPT'));
168
+ for (const t of tasks) {
169
+ const status = t.status === 'completed' ? chalk.green(t.status) : t.status === 'failed' ? chalk.red(t.status) : chalk.yellow(t.status);
170
+ console.log(t.id.padEnd(11) + t.host.padEnd(16) + t.agent.padEnd(10) + status.padEnd(11) + t.prompt.slice(0, 50));
171
+ }
172
+ }
173
+ async function doLogs(id, follow) {
174
+ const task = loadTask(id);
175
+ if (!task) {
176
+ console.log(chalk.red(`Unknown task "${id}".`));
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+ if (follow && task.status === 'running') {
181
+ const code = await followHostTask(task.target, { remoteLog: task.remoteLog, remoteExit: task.remoteExit, taskId: id, echo: true });
182
+ process.exitCode = code === -1 ? 1 : code;
183
+ return;
184
+ }
185
+ try {
186
+ process.stdout.write(fs.readFileSync(localLogPath(id), 'utf-8'));
187
+ }
188
+ catch {
189
+ console.log(chalk.gray('(no local log captured for this task)'));
190
+ }
191
+ }
192
+ /** Register the `agents hosts` command tree. */
193
+ export function registerHostsCommand(program) {
194
+ const hosts = program
195
+ .command('hosts')
196
+ .description('Register and inspect agent hosts (machines you offload runs to with `agents run --host <name>`).');
197
+ hosts
198
+ .command('add [name] [target]')
199
+ .description('Enroll a host. With no args, pick from ~/.ssh/config + known_hosts. `target` is user@host for hosts not in ssh config.')
200
+ .option('--cap <cap...>', 'Capability tag(s) for routing (e.g. --cap gpu)')
201
+ .option('--os <os>', 'Override detected OS label')
202
+ .option('--no-enroll', 'Register only — skip the remote agents-cli bootstrap/version check')
203
+ .action((name, target, opts) => doAdd(name, target, opts));
204
+ hosts
205
+ .command('list')
206
+ .alias('ls')
207
+ .description('List enrolled + ssh-config hosts (metadata only, no probing).')
208
+ .option('--json', 'Output JSON')
209
+ .action((opts) => doList(!!opts.json));
210
+ hosts
211
+ .command('check <name>')
212
+ .description('Probe one host: reachable? agents-cli version?')
213
+ .action((name) => doCheck(name));
214
+ hosts
215
+ .command('remove <name>')
216
+ .alias('rm')
217
+ .description('Remove a host from the registry (does not touch ~/.ssh/config).')
218
+ .action((name) => doRemove(name));
219
+ hosts
220
+ .command('ps')
221
+ .description('List dispatched host tasks.')
222
+ .option('--json', 'Output JSON')
223
+ .action((opts) => doPs(!!opts.json));
224
+ hosts
225
+ .command('logs <id>')
226
+ .description('Show a host task log; -f to follow a running one.')
227
+ .option('-f, --follow', 'Follow live output')
228
+ .action((id, opts) => doLogs(id, !!opts.follow));
229
+ }