@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.
- package/CHANGELOG.md +3 -0
- package/dist/commands/doctor.js +57 -4
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +198 -15
- package/dist/commands/hosts.d.ts +11 -0
- package/dist/commands/hosts.js +229 -0
- package/dist/commands/repo.d.ts +29 -0
- package/dist/commands/repo.js +174 -38
- package/dist/commands/secrets.d.ts +2 -7
- package/dist/commands/secrets.js +108 -29
- package/dist/commands/sessions.d.ts +2 -0
- package/dist/commands/sessions.js +8 -24
- package/dist/commands/ssh.d.ts +14 -0
- package/dist/commands/ssh.js +263 -0
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +22 -5
- package/dist/commands/view.js +27 -11
- package/dist/index.js +3 -1
- package/dist/lib/agents.d.ts +1 -0
- package/dist/lib/agents.js +44 -4
- package/dist/lib/browser/drivers/ssh.d.ts +47 -2
- package/dist/lib/browser/drivers/ssh.js +113 -24
- package/dist/lib/browser/profiles.js +28 -1
- package/dist/lib/browser/runtime-state.js +28 -8
- package/dist/lib/browser/types.d.ts +10 -1
- package/dist/lib/cli-resources.js +10 -1
- package/dist/lib/devices/connect.d.ts +34 -0
- package/dist/lib/devices/connect.js +101 -0
- package/dist/lib/devices/registry.d.ts +78 -0
- package/dist/lib/devices/registry.js +168 -0
- package/dist/lib/devices/ssh-config.d.ts +21 -0
- package/dist/lib/devices/ssh-config.js +33 -0
- package/dist/lib/devices/tailscale.d.ts +31 -0
- package/dist/lib/devices/tailscale.js +126 -0
- package/dist/lib/doctor-diff.d.ts +12 -0
- package/dist/lib/doctor-diff.js +89 -2
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +62 -19
- package/dist/lib/hooks.d.ts +17 -0
- package/dist/lib/hooks.js +127 -3
- package/dist/lib/hosts/dispatch.d.ts +26 -0
- package/dist/lib/hosts/dispatch.js +71 -0
- package/dist/lib/hosts/progress.d.ts +21 -0
- package/dist/lib/hosts/progress.js +49 -0
- package/dist/lib/hosts/providers/local.d.ts +17 -0
- package/dist/lib/hosts/providers/local.js +81 -0
- package/dist/lib/hosts/ready.d.ts +37 -0
- package/dist/lib/hosts/ready.js +88 -0
- package/dist/lib/hosts/registry.d.ts +22 -0
- package/dist/lib/hosts/registry.js +65 -0
- package/dist/lib/hosts/ssh-config.d.ts +37 -0
- package/dist/lib/hosts/ssh-config.js +157 -0
- package/dist/lib/hosts/tasks.d.ts +32 -0
- package/dist/lib/hosts/tasks.js +58 -0
- package/dist/lib/hosts/types.d.ts +51 -0
- package/dist/lib/hosts/types.js +21 -0
- package/dist/lib/loop.d.ts +9 -0
- package/dist/lib/loop.js +13 -1
- package/dist/lib/mcp.js +12 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.js +9 -5
- package/dist/lib/platform/exec.d.ts +10 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/platform/index.d.ts +1 -0
- package/dist/lib/platform/index.js +1 -0
- package/dist/lib/platform/links.d.ts +15 -0
- package/dist/lib/platform/links.js +42 -0
- package/dist/lib/platform/paths.d.ts +18 -0
- package/dist/lib/platform/paths.js +22 -0
- package/dist/lib/platform/posixpath.d.ts +28 -0
- package/dist/lib/platform/posixpath.js +153 -0
- package/dist/lib/plugins.d.ts +10 -0
- package/dist/lib/plugins.js +1 -1
- package/dist/lib/project-launch.js +6 -3
- package/dist/lib/sandbox.js +5 -2
- package/dist/lib/secrets/remote.d.ts +67 -0
- package/dist/lib/secrets/remote.js +133 -0
- package/dist/lib/self-update.js +7 -2
- package/dist/lib/session/db.d.ts +24 -0
- package/dist/lib/session/db.js +80 -5
- package/dist/lib/session/discover.d.ts +28 -0
- package/dist/lib/session/discover.js +303 -4
- package/dist/lib/session/parse.d.ts +7 -0
- package/dist/lib/session/parse.js +110 -0
- package/dist/lib/session/relative-time.d.ts +7 -0
- package/dist/lib/session/relative-time.js +28 -0
- package/dist/lib/session/remote.d.ts +31 -3
- package/dist/lib/session/remote.js +121 -14
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/ssh-exec.d.ts +45 -0
- package/dist/lib/ssh-exec.js +61 -0
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/types.d.ts +21 -0
- package/dist/lib/versions.d.ts +6 -2
- package/dist/lib/versions.js +8 -4
- package/package.json +1 -1
- 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
|
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/commands/exec.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/commands/exec.js
CHANGED
|
@@ -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('--
|
|
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: --
|
|
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
|
|
780
|
+
for (const bundleRef of options.secrets) {
|
|
610
781
|
try {
|
|
611
|
-
const { bundle,
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
+
}
|