@phnx-labs/agents-cli 1.20.26 → 1.20.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/commands/doctor.d.ts +5 -2
  3. package/dist/commands/doctor.js +182 -30
  4. package/dist/commands/exec.d.ts +1 -1
  5. package/dist/commands/exec.js +177 -6
  6. package/dist/commands/hosts.d.ts +11 -0
  7. package/dist/commands/hosts.js +229 -0
  8. package/dist/commands/repo.d.ts +29 -0
  9. package/dist/commands/repo.js +174 -38
  10. package/dist/commands/secrets.d.ts +2 -7
  11. package/dist/commands/secrets.js +15 -23
  12. package/dist/commands/sessions.d.ts +2 -0
  13. package/dist/commands/sessions.js +27 -25
  14. package/dist/commands/sync.d.ts +2 -0
  15. package/dist/commands/sync.js +22 -5
  16. package/dist/commands/view.js +27 -11
  17. package/dist/index.js +4 -13
  18. package/dist/lib/agent-spec.d.ts +36 -0
  19. package/dist/lib/agent-spec.js +157 -0
  20. package/dist/lib/agents.d.ts +1 -0
  21. package/dist/lib/agents.js +45 -4
  22. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  23. package/dist/lib/browser/drivers/ssh.js +113 -24
  24. package/dist/lib/browser/profiles.js +28 -1
  25. package/dist/lib/browser/runtime-state.js +28 -8
  26. package/dist/lib/browser/types.d.ts +10 -1
  27. package/dist/lib/cli-resources.js +10 -1
  28. package/dist/lib/daemon.js +32 -0
  29. package/dist/lib/doctor-diff.d.ts +19 -0
  30. package/dist/lib/doctor-diff.js +107 -15
  31. package/dist/lib/exec.d.ts +27 -0
  32. package/dist/lib/exec.js +62 -19
  33. package/dist/lib/heal.d.ts +107 -0
  34. package/dist/lib/heal.js +279 -0
  35. package/dist/lib/hooks.d.ts +17 -0
  36. package/dist/lib/hooks.js +127 -3
  37. package/dist/lib/hosts/dispatch.d.ts +26 -0
  38. package/dist/lib/hosts/dispatch.js +71 -0
  39. package/dist/lib/hosts/progress.d.ts +21 -0
  40. package/dist/lib/hosts/progress.js +49 -0
  41. package/dist/lib/hosts/providers/local.d.ts +17 -0
  42. package/dist/lib/hosts/providers/local.js +81 -0
  43. package/dist/lib/hosts/ready.d.ts +37 -0
  44. package/dist/lib/hosts/ready.js +88 -0
  45. package/dist/lib/hosts/registry.d.ts +22 -0
  46. package/dist/lib/hosts/registry.js +65 -0
  47. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  48. package/dist/lib/hosts/ssh-config.js +157 -0
  49. package/dist/lib/hosts/tasks.d.ts +32 -0
  50. package/dist/lib/hosts/tasks.js +58 -0
  51. package/dist/lib/hosts/types.d.ts +51 -0
  52. package/dist/lib/hosts/types.js +21 -0
  53. package/dist/lib/loop.d.ts +9 -0
  54. package/dist/lib/loop.js +13 -1
  55. package/dist/lib/mcp.js +12 -3
  56. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  57. package/dist/lib/migrate.js +9 -5
  58. package/dist/lib/platform/exec.d.ts +10 -0
  59. package/dist/lib/platform/exec.js +17 -0
  60. package/dist/lib/platform/index.d.ts +1 -0
  61. package/dist/lib/platform/index.js +1 -0
  62. package/dist/lib/platform/links.d.ts +15 -0
  63. package/dist/lib/platform/links.js +42 -0
  64. package/dist/lib/platform/paths.d.ts +18 -0
  65. package/dist/lib/platform/paths.js +22 -0
  66. package/dist/lib/platform/posixpath.d.ts +28 -0
  67. package/dist/lib/platform/posixpath.js +153 -0
  68. package/dist/lib/plugin-marketplace.d.ts +18 -0
  69. package/dist/lib/plugin-marketplace.js +67 -1
  70. package/dist/lib/plugins.d.ts +33 -1
  71. package/dist/lib/plugins.js +56 -11
  72. package/dist/lib/project-launch.js +6 -3
  73. package/dist/lib/sandbox.js +5 -2
  74. package/dist/lib/self-update.js +7 -2
  75. package/dist/lib/session/db.d.ts +23 -0
  76. package/dist/lib/session/db.js +76 -1
  77. package/dist/lib/session/discover.d.ts +26 -0
  78. package/dist/lib/session/discover.js +75 -4
  79. package/dist/lib/session/relative-time.d.ts +7 -0
  80. package/dist/lib/session/relative-time.js +28 -0
  81. package/dist/lib/session/remote.d.ts +61 -0
  82. package/dist/lib/session/remote.js +221 -0
  83. package/dist/lib/ssh-exec.d.ts +45 -0
  84. package/dist/lib/ssh-exec.js +61 -0
  85. package/dist/lib/staleness/detectors/commands.js +7 -6
  86. package/dist/lib/staleness/writers/commands.js +7 -12
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/startup/dev-build.d.ts +22 -0
  90. package/dist/lib/startup/dev-build.js +41 -0
  91. package/dist/lib/types.d.ts +28 -0
  92. package/dist/lib/versions.d.ts +9 -3
  93. package/dist/lib/versions.js +43 -7
  94. package/package.json +3 -3
  95. package/scripts/postinstall.js +62 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Local host provider: the v1 directory.
3
+ *
4
+ * `list()` is the union of ssh-config `Host` stanzas (read-only, connection
5
+ * details owned by ssh) and inline entries the user registered in agents.yaml.
6
+ * The `Meta.hosts` overlay (caps/os, keyed by name) is merged onto both. We
7
+ * never copy or rewrite ssh config.
8
+ */
9
+ import { readMeta, updateMeta } from '../../state.js';
10
+ import { listSshConfigHosts, isSshConfigHost } from '../ssh-config.js';
11
+ function entries() {
12
+ return readMeta().hosts ?? {};
13
+ }
14
+ function toHost(name, entry, enrolled) {
15
+ return {
16
+ name,
17
+ provider: 'local',
18
+ enrolled,
19
+ source: entry.source,
20
+ address: entry.address,
21
+ user: entry.user,
22
+ os: entry.os,
23
+ caps: entry.caps,
24
+ addedAt: entry.addedAt,
25
+ };
26
+ }
27
+ export class LocalHostProvider {
28
+ id = 'local';
29
+ capabilities() {
30
+ return { directory: true, mutate: true, presence: false, relay: false, lease: false };
31
+ }
32
+ async list() {
33
+ const overlay = entries();
34
+ const out = [];
35
+ const seen = new Set();
36
+ // Inline + overlaid hosts from the registry.
37
+ for (const [name, entry] of Object.entries(overlay)) {
38
+ out.push(toHost(name, entry, true));
39
+ seen.add(name);
40
+ }
41
+ // ssh-config hosts not already carrying an overlay → available, not enrolled.
42
+ for (const name of listSshConfigHosts()) {
43
+ if (seen.has(name))
44
+ continue;
45
+ out.push(toHost(name, { source: 'ssh-config' }, false));
46
+ seen.add(name);
47
+ }
48
+ return out.sort((a, b) => a.name.localeCompare(b.name));
49
+ }
50
+ async resolve(name) {
51
+ const entry = entries()[name];
52
+ if (entry)
53
+ return toHost(name, entry, true);
54
+ if (isSshConfigHost(name))
55
+ return toHost(name, { source: 'ssh-config' }, false);
56
+ return null;
57
+ }
58
+ async register(spec) {
59
+ const entry = {
60
+ source: spec.source,
61
+ ...(spec.source === 'inline' ? { address: spec.address, user: spec.user } : {}),
62
+ ...(spec.os ? { os: spec.os } : {}),
63
+ ...(spec.caps && spec.caps.length ? { caps: spec.caps } : {}),
64
+ addedAt: spec.addedAt ?? new Date().toISOString(),
65
+ };
66
+ updateMeta((meta) => ({ ...meta, hosts: { ...(meta.hosts ?? {}), [spec.name]: entry } }));
67
+ return toHost(spec.name, entry, true);
68
+ }
69
+ async remove(name) {
70
+ updateMeta((meta) => {
71
+ const hosts = { ...(meta.hosts ?? {}) };
72
+ delete hosts[name];
73
+ // Drop the key entirely when empty so we don't leave `hosts: {}` behind.
74
+ if (Object.keys(hosts).length === 0) {
75
+ const { hosts: _omit, ...rest } = meta;
76
+ return rest;
77
+ }
78
+ return { ...meta, hosts };
79
+ });
80
+ }
81
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Host readiness + bootstrap.
3
+ *
4
+ * Before a dispatch we ensure the box is reachable and has agents-cli. Enrollment
5
+ * can additionally install/upgrade agents-cli to match the local version (version
6
+ * parity) using the same shape as scripts/sandbox.sh. We never copy `.history`.
7
+ */
8
+ import type { Host } from './types.js';
9
+ /** Resolve this CLI's own version by walking up to the nearest package.json. */
10
+ export declare function localCliVersion(): string | null;
11
+ /** uname over ssh → reachable + OS string. */
12
+ export declare function probeHost(target: string): {
13
+ reachable: boolean;
14
+ os?: string;
15
+ };
16
+ /** Remote agents-cli version (login shell for PATH), or null if not installed. */
17
+ export declare function remoteAgentsVersion(target: string): string | null;
18
+ /** True if the named agent CLI is installed on the remote (best-effort). */
19
+ export declare function remoteAgentInstalled(target: string, agent: string): boolean;
20
+ /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
21
+ export declare function bootstrapAgentsCli(target: string, version: string | null): {
22
+ ok: boolean;
23
+ output: string;
24
+ };
25
+ export interface EnsureReadyOptions {
26
+ agent: string;
27
+ /** Throw instead of warn when the agent isn't installed remotely. */
28
+ requireAgent?: boolean;
29
+ }
30
+ /**
31
+ * Verify a host can run the agent: reachable + agents-cli present. Throws with an
32
+ * actionable message otherwise. Agent-not-installed is a warning by default (the
33
+ * remote `agents run` will surface it); pass requireAgent to make it fatal.
34
+ */
35
+ export declare function ensureHostReady(host: Host, opts: EnsureReadyOptions): {
36
+ warnings: string[];
37
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Host readiness + bootstrap.
3
+ *
4
+ * Before a dispatch we ensure the box is reachable and has agents-cli. Enrollment
5
+ * can additionally install/upgrade agents-cli to match the local version (version
6
+ * parity) using the same shape as scripts/sandbox.sh. We never copy `.history`.
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import { sshExec, sshReachable, shellQuote } from '../ssh-exec.js';
12
+ import { sshTargetFor } from './types.js';
13
+ /** Resolve this CLI's own version by walking up to the nearest package.json. */
14
+ export function localCliVersion() {
15
+ let dir = path.dirname(fileURLToPath(import.meta.url));
16
+ for (let i = 0; i < 8; i++) {
17
+ const pkg = path.join(dir, 'package.json');
18
+ try {
19
+ const data = JSON.parse(fs.readFileSync(pkg, 'utf-8'));
20
+ if (data.name && data.version)
21
+ return data.version;
22
+ }
23
+ catch {
24
+ /* keep walking */
25
+ }
26
+ const parent = path.dirname(dir);
27
+ if (parent === dir)
28
+ break;
29
+ dir = parent;
30
+ }
31
+ return null;
32
+ }
33
+ /** uname over ssh → reachable + OS string. */
34
+ export function probeHost(target) {
35
+ const r = sshExec(target, 'uname -s 2>/dev/null || echo unknown', { timeoutMs: 12000 });
36
+ if (r.code !== 0)
37
+ return { reachable: false };
38
+ const os = r.stdout.trim();
39
+ return { reachable: true, os: os && os !== 'unknown' ? os : undefined };
40
+ }
41
+ /** Remote agents-cli version (login shell for PATH), or null if not installed. */
42
+ export function remoteAgentsVersion(target) {
43
+ const r = sshExec(target, 'bash -lc "agents --version 2>/dev/null"', { timeoutMs: 20000 });
44
+ if (r.code !== 0)
45
+ return null;
46
+ const v = r.stdout.trim();
47
+ return v || null;
48
+ }
49
+ /** True if the named agent CLI is installed on the remote (best-effort). */
50
+ export function remoteAgentInstalled(target, agent) {
51
+ // `agents view` (formerly `agents list`) prints capitalized display names
52
+ // (e.g. "Claude"), so match case-insensitively.
53
+ const r = sshExec(target, `bash -lc "agents view 2>/dev/null || agents list 2>/dev/null"`, { timeoutMs: 20000 });
54
+ if (r.code !== 0)
55
+ return false;
56
+ return new RegExp(`\\b${agent}\\b`, 'i').test(r.stdout);
57
+ }
58
+ /** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
59
+ export function bootstrapAgentsCli(target, version) {
60
+ const spec = version ? `@phnx-labs/agents-cli@${version}` : '@phnx-labs/agents-cli';
61
+ const script = `npm install -g ${shellQuote(spec)} 2>&1 | tail -3; ` +
62
+ `if [ ! -d ~/.agents/.system ]; then agents setup 2>&1 | tail -3 || true; fi; ` +
63
+ `agents --version`;
64
+ const r = sshExec(target, `bash -lc ${shellQuote(script)}`, { timeoutMs: 300000 });
65
+ return { ok: r.code === 0, output: (r.stdout + r.stderr).trim() };
66
+ }
67
+ /**
68
+ * Verify a host can run the agent: reachable + agents-cli present. Throws with an
69
+ * actionable message otherwise. Agent-not-installed is a warning by default (the
70
+ * remote `agents run` will surface it); pass requireAgent to make it fatal.
71
+ */
72
+ export function ensureHostReady(host, opts) {
73
+ const target = sshTargetFor(host);
74
+ if (!sshReachable(target)) {
75
+ throw new Error(`Host "${host.name}" (${target}) is not reachable over SSH. Check it's online and key auth works.`);
76
+ }
77
+ if (!remoteAgentsVersion(target)) {
78
+ throw new Error(`agents-cli is not installed on "${host.name}". Enroll it first: agents hosts add ${host.name} (bootstraps agents-cli).`);
79
+ }
80
+ const warnings = [];
81
+ if (!remoteAgentInstalled(target, opts.agent)) {
82
+ const msg = `Agent "${opts.agent}" may not be installed on "${host.name}" (remote \`agents add ${opts.agent}\` to install).`;
83
+ if (opts.requireAgent)
84
+ throw new Error(msg);
85
+ warnings.push(msg);
86
+ }
87
+ return { warnings };
88
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Host provider registry.
3
+ *
4
+ * Mirrors the cloud provider registry: a Map of provider id → implementation,
5
+ * instantiated once. v1 registers only `local`; adding `rush`/`tailscale`/
6
+ * `crabbox` later is a one-line `providers.set(...)` with no caller changes.
7
+ */
8
+ import type { Host, HostProvider, HostProviderId } from './types.js';
9
+ export declare function getProvider(id: HostProviderId): HostProvider;
10
+ export declare function getAllProviders(): HostProvider[];
11
+ /** Every host across all registered providers, deduped by name (first wins). */
12
+ export declare function listAllHosts(): Promise<Host[]>;
13
+ /**
14
+ * Resolve a host name to a single host across providers, or null if unknown.
15
+ * First provider that owns the name wins (only `local` in v1).
16
+ */
17
+ export declare function resolveHost(name: string): Promise<Host | null>;
18
+ /**
19
+ * Resolve a host by capability tag (e.g. `--host gpu`). Returns the single
20
+ * matching host, or throws on 0 or >1 matches unless `any` is set (then first).
21
+ */
22
+ export declare function resolveHostByCap(cap: string, any?: boolean): Promise<Host>;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Host provider registry.
3
+ *
4
+ * Mirrors the cloud provider registry: a Map of provider id → implementation,
5
+ * instantiated once. v1 registers only `local`; adding `rush`/`tailscale`/
6
+ * `crabbox` later is a one-line `providers.set(...)` with no caller changes.
7
+ */
8
+ import { LocalHostProvider } from './providers/local.js';
9
+ const providers = new Map();
10
+ function initProviders() {
11
+ if (providers.size > 0)
12
+ return;
13
+ providers.set('local', new LocalHostProvider());
14
+ }
15
+ export function getProvider(id) {
16
+ initProviders();
17
+ const provider = providers.get(id);
18
+ if (!provider) {
19
+ throw new Error(`Unknown host provider: ${id}. Available: ${[...providers.keys()].join(', ')}`);
20
+ }
21
+ return provider;
22
+ }
23
+ export function getAllProviders() {
24
+ initProviders();
25
+ return [...providers.values()];
26
+ }
27
+ /** Every host across all registered providers, deduped by name (first wins). */
28
+ export async function listAllHosts() {
29
+ const seen = new Set();
30
+ const out = [];
31
+ for (const provider of getAllProviders()) {
32
+ for (const host of await provider.list()) {
33
+ if (seen.has(host.name))
34
+ continue;
35
+ seen.add(host.name);
36
+ out.push(host);
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+ /**
42
+ * Resolve a host name to a single host across providers, or null if unknown.
43
+ * First provider that owns the name wins (only `local` in v1).
44
+ */
45
+ export async function resolveHost(name) {
46
+ for (const provider of getAllProviders()) {
47
+ const host = await provider.resolve(name);
48
+ if (host)
49
+ return host;
50
+ }
51
+ return null;
52
+ }
53
+ /**
54
+ * Resolve a host by capability tag (e.g. `--host gpu`). Returns the single
55
+ * matching host, or throws on 0 or >1 matches unless `any` is set (then first).
56
+ */
57
+ export async function resolveHostByCap(cap, any = false) {
58
+ const matches = (await listAllHosts()).filter((h) => h.caps?.includes(cap));
59
+ if (matches.length === 0)
60
+ throw new Error(`No host tagged "${cap}". Tag one with: agents hosts add <name> --cap ${cap}`);
61
+ if (matches.length > 1 && !any) {
62
+ throw new Error(`Multiple hosts tagged "${cap}": ${matches.map((h) => h.name).join(', ')}. Name one, or pass --any.`);
63
+ }
64
+ return matches[0];
65
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Read the user's SSH config as a host directory — we never copy or rewrite it.
3
+ *
4
+ * `~/.ssh/config` is the source of truth for connection details; we only parse
5
+ * `Host` stanzas to list candidate names and `ssh -G <name>` to resolve them for
6
+ * display. `known_hosts` is a secondary candidate source for enrollment.
7
+ */
8
+ /**
9
+ * Parse `Host` stanza names from ssh config text. Wildcard/negated patterns
10
+ * (`*`, `?`, `!`) are skipped — they're match rules, not concrete hosts.
11
+ * Pure (text in, names out) so it's unit-testable.
12
+ */
13
+ export declare function parseSshConfigHosts(content: string): string[];
14
+ /**
15
+ * Parse hostnames from known_hosts text. Hashed entries (`|1|…`) carry no
16
+ * recoverable hostname and are skipped; `[host]:port` and comma lists are split.
17
+ * Pure for testability.
18
+ */
19
+ export declare function parseKnownHosts(content: string): string[];
20
+ /** `Host` names from ~/.ssh/config, following `Include` globs (best-effort). */
21
+ export declare function listSshConfigHosts(): string[];
22
+ /** Hostnames recorded in ~/.ssh/known_hosts (hashed entries skipped). */
23
+ export declare function listKnownHosts(): string[];
24
+ /** True if `name` is a concrete `Host` stanza in ssh config. */
25
+ export declare function isSshConfigHost(name: string): boolean;
26
+ export interface SshGResult {
27
+ hostname?: string;
28
+ user?: string;
29
+ port?: string;
30
+ }
31
+ /**
32
+ * Authoritative resolution of a host's effective ssh config via `ssh -G <name>`
33
+ * (honors Match/Include). Returns undefined if `ssh` is unavailable. Note:
34
+ * `ssh -G` returns defaults even for unknown names — pair with `isSshConfigHost`
35
+ * to decide whether a name is actually configured.
36
+ */
37
+ export declare function sshResolve(name: string): SshGResult | undefined;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Read the user's SSH config as a host directory — we never copy or rewrite it.
3
+ *
4
+ * `~/.ssh/config` is the source of truth for connection details; we only parse
5
+ * `Host` stanzas to list candidate names and `ssh -G <name>` to resolve them for
6
+ * display. `known_hosts` is a secondary candidate source for enrollment.
7
+ */
8
+ import * as fs from 'fs';
9
+ import * as os from 'os';
10
+ import * as path from 'path';
11
+ import { spawnSync } from 'child_process';
12
+ import { assertValidSshTarget } from '../ssh-exec.js';
13
+ const SSH_DIR = path.join(os.homedir(), '.ssh');
14
+ const SSH_CONFIG = path.join(SSH_DIR, 'config');
15
+ const KNOWN_HOSTS = path.join(SSH_DIR, 'known_hosts');
16
+ /**
17
+ * Parse `Host` stanza names from ssh config text. Wildcard/negated patterns
18
+ * (`*`, `?`, `!`) are skipped — they're match rules, not concrete hosts.
19
+ * Pure (text in, names out) so it's unit-testable.
20
+ */
21
+ export function parseSshConfigHosts(content) {
22
+ const names = [];
23
+ const seen = new Set();
24
+ for (const rawLine of content.split('\n')) {
25
+ const line = rawLine.trim();
26
+ if (!line || line.startsWith('#'))
27
+ continue;
28
+ const m = /^Host\s+(.+)$/i.exec(line);
29
+ if (!m)
30
+ continue;
31
+ for (const tok of m[1].split(/\s+/)) {
32
+ if (!tok || /[*?!]/.test(tok))
33
+ continue;
34
+ if (seen.has(tok))
35
+ continue;
36
+ seen.add(tok);
37
+ names.push(tok);
38
+ }
39
+ }
40
+ return names;
41
+ }
42
+ /**
43
+ * Parse hostnames from known_hosts text. Hashed entries (`|1|…`) carry no
44
+ * recoverable hostname and are skipped; `[host]:port` and comma lists are split.
45
+ * Pure for testability.
46
+ */
47
+ export function parseKnownHosts(content) {
48
+ const names = [];
49
+ const seen = new Set();
50
+ for (const rawLine of content.split('\n')) {
51
+ const line = rawLine.trim();
52
+ if (!line || line.startsWith('#'))
53
+ continue;
54
+ const first = line.split(/\s+/)[0];
55
+ if (!first || first.startsWith('|'))
56
+ continue; // hashed
57
+ for (const entry of first.split(',')) {
58
+ // strip [host]:port → host
59
+ const host = entry.replace(/^\[/, '').replace(/\](:\d+)?$/, '');
60
+ if (!host || /[*?]/.test(host) || seen.has(host))
61
+ continue;
62
+ seen.add(host);
63
+ names.push(host);
64
+ }
65
+ }
66
+ return names;
67
+ }
68
+ /** `Host` names from ~/.ssh/config, following `Include` globs (best-effort). */
69
+ export function listSshConfigHosts() {
70
+ const names = new Set();
71
+ const visit = (file, depth) => {
72
+ if (depth > 8)
73
+ return;
74
+ let content;
75
+ try {
76
+ content = fs.readFileSync(file, 'utf-8');
77
+ }
78
+ catch {
79
+ return;
80
+ }
81
+ for (const name of parseSshConfigHosts(content))
82
+ names.add(name);
83
+ // Follow Include directives (best-effort, relative to ~/.ssh).
84
+ for (const rawLine of content.split('\n')) {
85
+ const m = /^\s*Include\s+(.+)$/i.exec(rawLine);
86
+ if (!m)
87
+ continue;
88
+ for (const pat of m[1].trim().split(/\s+/)) {
89
+ const abs = path.isAbsolute(pat) ? pat : path.join(SSH_DIR, pat.replace(/^~\//, ''));
90
+ for (const f of globMaybe(abs))
91
+ visit(f, depth + 1);
92
+ }
93
+ }
94
+ };
95
+ visit(SSH_CONFIG, 0);
96
+ return [...names];
97
+ }
98
+ /** Minimal glob: expand a single trailing `*` in the basename, else literal. */
99
+ function globMaybe(pattern) {
100
+ if (!pattern.includes('*')) {
101
+ return fs.existsSync(pattern) ? [pattern] : [];
102
+ }
103
+ const dir = path.dirname(pattern);
104
+ const base = path.basename(pattern);
105
+ const re = new RegExp('^' + base.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
106
+ try {
107
+ return fs.readdirSync(dir).filter((f) => re.test(f)).map((f) => path.join(dir, f));
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ }
113
+ /** Hostnames recorded in ~/.ssh/known_hosts (hashed entries skipped). */
114
+ export function listKnownHosts() {
115
+ try {
116
+ return parseKnownHosts(fs.readFileSync(KNOWN_HOSTS, 'utf-8'));
117
+ }
118
+ catch {
119
+ return [];
120
+ }
121
+ }
122
+ /** True if `name` is a concrete `Host` stanza in ssh config. */
123
+ export function isSshConfigHost(name) {
124
+ return listSshConfigHosts().includes(name);
125
+ }
126
+ /**
127
+ * Authoritative resolution of a host's effective ssh config via `ssh -G <name>`
128
+ * (honors Match/Include). Returns undefined if `ssh` is unavailable. Note:
129
+ * `ssh -G` returns defaults even for unknown names — pair with `isSshConfigHost`
130
+ * to decide whether a name is actually configured.
131
+ */
132
+ export function sshResolve(name) {
133
+ // Same target-injection guard as sshExec: a name starting with `-` (or
134
+ // carrying shell metacharacters) must never reach `ssh` as a bare argv where
135
+ // it could be parsed as a flag (e.g. `-oProxyCommand=…`).
136
+ try {
137
+ assertValidSshTarget(name);
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ const res = spawnSync('ssh', ['-G', name], { encoding: 'utf-8', timeout: 5000 });
143
+ if (res.status !== 0 || !res.stdout)
144
+ return undefined;
145
+ const out = {};
146
+ for (const line of res.stdout.split('\n')) {
147
+ const [key, ...rest] = line.trim().split(/\s+/);
148
+ const val = rest.join(' ');
149
+ if (key === 'hostname')
150
+ out.hostname = val;
151
+ else if (key === 'user')
152
+ out.user = val;
153
+ else if (key === 'port')
154
+ out.port = val;
155
+ }
156
+ return out;
157
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Local record of dispatched host tasks.
3
+ *
4
+ * Each dispatch writes a `<id>.json` sidecar next to its `<id>.log` (and the
5
+ * remote's `<id>.exit`) under ~/.agents/.cache/hosts/, so `agents hosts ps/logs`
6
+ * can list runs and follow output across CLI invocations. (Folding these into
7
+ * the cloud SQLite store so `agents cloud ps` sees them is a fast-follow — it
8
+ * needs care around the cloud status-refresh path.)
9
+ */
10
+ export type HostTaskStatus = 'running' | 'completed' | 'failed' | 'unknown';
11
+ export interface HostTask {
12
+ id: string;
13
+ host: string;
14
+ target: string;
15
+ agent: string;
16
+ prompt: string;
17
+ pid?: number;
18
+ /** Remote paths (under the host's ~/.agents/.cache/hosts/). */
19
+ remoteLog: string;
20
+ remoteExit: string;
21
+ status: HostTaskStatus;
22
+ exitCode?: number;
23
+ createdAt: string;
24
+ finishedAt?: string;
25
+ }
26
+ export declare function hostsCacheDir(): string;
27
+ /** Local path we mirror a task's remote log into while following. */
28
+ export declare function localLogPath(id: string): string;
29
+ export declare function saveTask(task: HostTask): void;
30
+ export declare function loadTask(id: string): HostTask | null;
31
+ export declare function updateTask(id: string, patch: Partial<HostTask>): HostTask | null;
32
+ export declare function listTasks(): HostTask[];
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Local record of dispatched host tasks.
3
+ *
4
+ * Each dispatch writes a `<id>.json` sidecar next to its `<id>.log` (and the
5
+ * remote's `<id>.exit`) under ~/.agents/.cache/hosts/, so `agents hosts ps/logs`
6
+ * can list runs and follow output across CLI invocations. (Folding these into
7
+ * the cloud SQLite store so `agents cloud ps` sees them is a fast-follow — it
8
+ * needs care around the cloud status-refresh path.)
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { getCacheDir } from '../state.js';
13
+ export function hostsCacheDir() {
14
+ return path.join(getCacheDir(), 'hosts');
15
+ }
16
+ function taskFile(id) {
17
+ return path.join(hostsCacheDir(), `${id}.json`);
18
+ }
19
+ /** Local path we mirror a task's remote log into while following. */
20
+ export function localLogPath(id) {
21
+ return path.join(hostsCacheDir(), `${id}.log`);
22
+ }
23
+ export function saveTask(task) {
24
+ fs.mkdirSync(hostsCacheDir(), { recursive: true });
25
+ fs.writeFileSync(taskFile(task.id), JSON.stringify(task, null, 2));
26
+ }
27
+ export function loadTask(id) {
28
+ try {
29
+ return JSON.parse(fs.readFileSync(taskFile(id), 'utf-8'));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function updateTask(id, patch) {
36
+ const task = loadTask(id);
37
+ if (!task)
38
+ return null;
39
+ const next = { ...task, ...patch };
40
+ saveTask(next);
41
+ return next;
42
+ }
43
+ export function listTasks() {
44
+ let files;
45
+ try {
46
+ files = fs.readdirSync(hostsCacheDir()).filter((f) => f.endsWith('.json'));
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ const tasks = [];
52
+ for (const f of files) {
53
+ const task = loadTask(f.replace(/\.json$/, ''));
54
+ if (task)
55
+ tasks.push(task);
56
+ }
57
+ return tasks.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
58
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Agent-host provider contract.
3
+ *
4
+ * A `HostProvider` answers "what are my hosts, and how do I reach them?" — the
5
+ * pluggable directory/metadata/reachability layer. v1 ships only the `local`
6
+ * provider (ssh-config ∪ inline registry); `rush`/`tailscale`/`crabbox` are
7
+ * additive fast-follows behind this same contract. Capability-gated so partial
8
+ * providers are first-class (mirrors the cloud provider registry).
9
+ */
10
+ import type { HostEntry } from '../types.js';
11
+ export type HostProviderId = 'local';
12
+ export type HostStatus = 'online' | 'offline' | 'unknown';
13
+ /** A host as seen at runtime: its persisted entry plus name/provider/status. */
14
+ export interface Host extends HostEntry {
15
+ name: string;
16
+ provider: HostProviderId;
17
+ /** True when the host has an explicit overlay/inline entry in the registry. */
18
+ enrolled?: boolean;
19
+ status?: HostStatus;
20
+ }
21
+ export interface HostProviderCapabilities {
22
+ /** Can list/track hosts. */
23
+ directory: boolean;
24
+ /** Can add/remove hosts. */
25
+ mutate: boolean;
26
+ /** Reports online/offline without an explicit probe. */
27
+ presence: boolean;
28
+ /** Can dispatch a command without an SSH address (its own relay). */
29
+ relay: boolean;
30
+ /** Can provision new hosts. */
31
+ lease: boolean;
32
+ }
33
+ export interface HostProvider {
34
+ id: HostProviderId;
35
+ capabilities(): HostProviderCapabilities;
36
+ /** Every host this provider knows about. */
37
+ list(): Promise<Host[]>;
38
+ /** Resolve one host by name, or null if unknown to this provider. */
39
+ resolve(name: string): Promise<Host | null>;
40
+ /** Persist a host (mutate-capable providers only). */
41
+ register?(spec: Host): Promise<Host>;
42
+ /** Remove a host (mutate-capable providers only). */
43
+ remove?(name: string): Promise<void>;
44
+ /** Presence without an explicit probe (presence-capable providers only). */
45
+ presence?(name: string): Promise<HostStatus>;
46
+ }
47
+ /**
48
+ * The ssh target string for a host: the bare name for ssh-config hosts (ssh
49
+ * resolves HostName/User/Port/Identity), else `user@address` (or `address`).
50
+ */
51
+ export declare function sshTargetFor(host: Host): string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Agent-host provider contract.
3
+ *
4
+ * A `HostProvider` answers "what are my hosts, and how do I reach them?" — the
5
+ * pluggable directory/metadata/reachability layer. v1 ships only the `local`
6
+ * provider (ssh-config ∪ inline registry); `rush`/`tailscale`/`crabbox` are
7
+ * additive fast-follows behind this same contract. Capability-gated so partial
8
+ * providers are first-class (mirrors the cloud provider registry).
9
+ */
10
+ /**
11
+ * The ssh target string for a host: the bare name for ssh-config hosts (ssh
12
+ * resolves HostName/User/Port/Identity), else `user@address` (or `address`).
13
+ */
14
+ export function sshTargetFor(host) {
15
+ if (host.source === 'ssh-config')
16
+ return host.name;
17
+ if (!host.address) {
18
+ throw new Error(`Host "${host.name}" is inline but has no address.`);
19
+ }
20
+ return host.user ? `${host.user}@${host.address}` : host.address;
21
+ }
@@ -104,6 +104,15 @@ export declare function loopSignalPath(runDir: string): string;
104
104
  * agent both recalls the prior turn AND knows what to do this iteration.
105
105
  */
106
106
  export declare function buildLoopContinuePrompt(prevSessionId: string, entrypoint: string): string;
107
+ /**
108
+ * The universal (Tier-2) resume directive: a `/continue <id>` first message that
109
+ * tells the agent to load the prior transcript via `agents sessions <id>` and
110
+ * pick up. Works for ANY agent that ships the `/continue` command — the resume
111
+ * path for agents without a native `--resume` (gemini, grok, opencode, …). An
112
+ * optional follow-on prompt is appended after a blank line; omitted when empty so
113
+ * a bare resume sends just the directive.
114
+ */
115
+ export declare function buildContinuePrompt(sessionId: string, prompt?: string): string;
107
116
  /**
108
117
  * Resolve a loop interval string to milliseconds. `"0"` is an explicit
109
118
  * back-to-back run (0ms). Any other string must parse via parseTimeout