@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
@@ -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
package/dist/lib/loop.js CHANGED
@@ -49,7 +49,19 @@ export function loopSignalPath(runDir) {
49
49
  * agent both recalls the prior turn AND knows what to do this iteration.
50
50
  */
51
51
  export function buildLoopContinuePrompt(prevSessionId, entrypoint) {
52
- return `/continue ${prevSessionId}\n\n${entrypoint}`;
52
+ return buildContinuePrompt(prevSessionId, entrypoint);
53
+ }
54
+ /**
55
+ * The universal (Tier-2) resume directive: a `/continue <id>` first message that
56
+ * tells the agent to load the prior transcript via `agents sessions <id>` and
57
+ * pick up. Works for ANY agent that ships the `/continue` command — the resume
58
+ * path for agents without a native `--resume` (gemini, grok, opencode, …). An
59
+ * optional follow-on prompt is appended after a blank line; omitted when empty so
60
+ * a bare resume sends just the directive.
61
+ */
62
+ export function buildContinuePrompt(sessionId, prompt) {
63
+ const directive = `/continue ${sessionId}`;
64
+ return prompt && prompt.trim() ? `${directive}\n\n${prompt}` : directive;
53
65
  }
54
66
  /**
55
67
  * Resolve a loop interval string to milliseconds. `"0"` is an explicit
package/dist/lib/mcp.js CHANGED
@@ -13,6 +13,7 @@ import * as yaml from 'yaml';
13
13
  import { execFileSync } from 'child_process';
14
14
  import { getMcpDir, getUserMcpDir, getProjectAgentsDir, getVersionsDir } from './state.js';
15
15
  import { getBinaryPath, getVersionHomePath } from './versions.js';
16
+ import { IS_WINDOWS, needsWindowsShell } from './platform/index.js';
16
17
  import { AGENTS } from './agents.js';
17
18
  import { isCapable } from './capabilities.js';
18
19
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
@@ -217,6 +218,7 @@ function installMcpViaClaude(binaryPath, server, versionHome) {
217
218
  stdio: 'pipe',
218
219
  timeout: 30000,
219
220
  env: execEnv,
221
+ shell: needsWindowsShell(binaryPath),
220
222
  });
221
223
  }
222
224
  else {
@@ -225,6 +227,7 @@ function installMcpViaClaude(binaryPath, server, versionHome) {
225
227
  stdio: 'pipe',
226
228
  timeout: 30000,
227
229
  env: execEnv,
230
+ shell: needsWindowsShell(binaryPath),
228
231
  });
229
232
  }
230
233
  }
@@ -245,6 +248,7 @@ function installMcpViaCodex(binaryPath, server, versionHome) {
245
248
  stdio: 'pipe',
246
249
  timeout: 30000,
247
250
  env: { ...process.env, HOME: versionHome },
251
+ shell: needsWindowsShell(binaryPath),
248
252
  });
249
253
  }
250
254
  // Note: Codex may not support HTTP MCPs
@@ -274,7 +278,7 @@ function registerMcpCommand(agentId, name, commandSpec, scope, transport, option
274
278
  ? ['mcp', 'add', '--transport', transport, '--scope', scope, name, '--', ...commandArgs]
275
279
  : ['mcp', 'add', name, '--', ...commandArgs];
276
280
  const env = options.home ? { ...process.env, HOME: options.home } : process.env;
277
- execFileSync(bin, args, { stdio: 'pipe', timeout: 30000, env });
281
+ execFileSync(bin, args, { stdio: 'pipe', timeout: 30000, env, shell: needsWindowsShell(bin) });
278
282
  return { success: true };
279
283
  }
280
284
  catch (err) {
@@ -442,9 +446,14 @@ export function installMcpServers(agentId, version, versionHome, mcpNames, optio
442
446
  }
443
447
  const applied = [];
444
448
  const errors = [];
445
- // Get binary path for CLI-based agents
449
+ // Get binary path for CLI-based agents. On Windows npm drops a `.cmd` launcher
450
+ // next to the extensionless POSIX wrapper in node_modules/.bin; prefer it so
451
+ // the CLI is actually executable (the bare wrapper is a shell script).
446
452
  const cliCommand = AGENTS[agentId].cliCommand;
447
- const binaryPath = path.join(getVersionsDir(), agentId, version, 'node_modules', '.bin', cliCommand);
453
+ let binaryPath = path.join(getVersionsDir(), agentId, version, 'node_modules', '.bin', cliCommand);
454
+ if (IS_WINDOWS && fs.existsSync(binaryPath + '.cmd')) {
455
+ binaryPath += '.cmd';
456
+ }
448
457
  for (const server of servers) {
449
458
  try {
450
459
  if (agentId === 'claude') {