@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
@@ -9,12 +9,25 @@
9
9
  * upfront copy, always current, but the peer must be reachable. SSH access is the
10
10
  * only auth — if you can `ssh <host>`, you own the box (no identity layer by design).
11
11
  *
12
- * Mirrors the transport already used by `agents secrets export --to-ssh`
12
+ * Offline degradation (no sync, still fetch-first): every *successful* fetch is
13
+ * cached to `~/.agents/.cache/remote-sessions/`, keyed by host + the exact query.
14
+ * When a later run finds the host unreachable, the cache is replayed with a clearly
15
+ * labelled "showing cached results" banner instead of returning nothing. The cache
16
+ * is a byproduct of fetches you already made — never a background job, freely
17
+ * deletable — so the fetch-don't-replicate model holds; this is just graceful
18
+ * degradation when the peer is asleep.
19
+ *
20
+ * Mirrors the transport already used by `agents secrets export --host`
13
21
  * (`src/commands/secrets.ts`): `ssh -o BatchMode=yes <host> bash -lc '<cmd>'`,
14
22
  * with `bash -lc` so the remote login PATH resolves `agents`.
15
23
  */
16
24
  import { spawnSync } from 'child_process';
25
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from 'fs';
26
+ import { join } from 'path';
27
+ import { createHash } from 'crypto';
17
28
  import chalk from 'chalk';
29
+ import { getCacheDir } from '../state.js';
30
+ import { formatRelativeTime } from './relative-time.js';
18
31
  /**
19
32
  * SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
20
33
  * The strict allowlist blocks shell metacharacters and a leading `-`, so a target
@@ -83,30 +96,124 @@ const SSH_OPTS = [
83
96
  '-o', 'StrictHostKeyChecking=accept-new',
84
97
  '-o', 'ConnectTimeout=10',
85
98
  ];
99
+ /**
100
+ * Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
101
+ * connection-layer failures (host down, timeout, refused, auth, changed host
102
+ * key) — distinct from any other non-zero, which is the remote `agents sessions`
103
+ * exit code forwarded back (the query ran but failed). The two must be handled
104
+ * differently: 255 may fall back to cache, a forwarded failure must surface.
105
+ */
106
+ export function classifySshFailure(res) {
107
+ if (res.error)
108
+ return 'spawn-error';
109
+ if (res.status === 0)
110
+ return 'ok';
111
+ if (res.status === 255)
112
+ return 'unreachable';
113
+ return 'query-failed';
114
+ }
115
+ /** Root of the offline-replay cache (`~/.agents/.cache/remote-sessions/`). */
116
+ const REMOTE_CACHE_DIR = join(getCacheDir(), 'remote-sessions');
117
+ /**
118
+ * Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
119
+ * are hashed so distinct queries cache independently; the host stays readable in
120
+ * the filename (sanitised so `user@host` and aliases are filesystem-safe).
121
+ */
122
+ export function remoteCachePath(host, forwardedArgs) {
123
+ const hash = createHash('sha256').update(forwardedArgs.join('\u0000')).digest('hex').slice(0, 16);
124
+ const safeHost = host.replace(/[^a-zA-Z0-9._@-]/g, '_');
125
+ return join(REMOTE_CACHE_DIR, `${safeHost}__${hash}.txt`);
126
+ }
127
+ /** Banner shown above replayed cache rows when the peer is offline. */
128
+ export function formatStaleBanner(host, mtimeMs) {
129
+ const ago = formatRelativeTime(new Date(mtimeMs).toISOString());
130
+ return chalk.yellow(`${host}: offline — showing cached results from ${ago}`);
131
+ }
132
+ /** Message shown when a host is unreachable and there is no cache to fall back to. */
133
+ export function formatUnreachable(host) {
134
+ return chalk.red(`${host}: unreachable over SSH (asleep, offline, or host key changed?) — ConnectTimeout 10s`);
135
+ }
136
+ /** Persist a successful fetch for later offline replay. Best-effort: a cache
137
+ * write must never break the live query. */
138
+ function writeRemoteCache(host, forwardedArgs, output) {
139
+ try {
140
+ mkdirSync(REMOTE_CACHE_DIR, { recursive: true });
141
+ writeFileSync(remoteCachePath(host, forwardedArgs), output);
142
+ }
143
+ catch {
144
+ // ignore — caching is an optimisation, not a guarantee
145
+ }
146
+ }
147
+ /** Replay a cached fetch for an unreachable host. Banner goes to stderr (so a
148
+ * piped stdout stays exactly the cached rows); returns false when nothing is
149
+ * cached for this exact (host, query). */
150
+ function replayRemoteCache(host, forwardedArgs) {
151
+ try {
152
+ const p = remoteCachePath(host, forwardedArgs);
153
+ if (!existsSync(p))
154
+ return false;
155
+ process.stderr.write(formatStaleBanner(host, statSync(p).mtimeMs) + '\n');
156
+ process.stdout.write(readFileSync(p, 'utf8'));
157
+ return true;
158
+ }
159
+ catch {
160
+ return false;
161
+ }
162
+ }
86
163
  /**
87
164
  * Run the current `agents sessions` invocation on one or more remote machines over
88
- * SSH, streaming each remote's output to the terminal. Sets `process.exitCode = 1`
89
- * if any host fails. Reads the invocation from `process.argv` (override via
90
- * `argv` for testing).
165
+ * SSH, writing each remote's output to the terminal. A successful fetch is cached;
166
+ * an unreachable host falls back to that cache (with a stale banner) when present.
167
+ * Sets `process.exitCode = 1` if any host could not be answered (live or cached).
168
+ * Reads the invocation from `process.argv` (override via `argv` for testing).
169
+ *
170
+ * Output is captured rather than `stdio: 'inherit'`-streamed so it can be cached.
171
+ * Session output is small and the remote returns quickly, so buffering is
172
+ * imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
91
173
  */
92
174
  export function runRemoteSessions(hosts, argv = process.argv) {
93
175
  for (const host of hosts)
94
176
  assertValidSshTarget(host); // fail fast on any bad target
95
- const remoteCmd = buildRemoteCommand(buildForwardedArgs(argv, new Set(hosts)));
177
+ const forwarded = buildForwardedArgs(argv, new Set(hosts));
178
+ const remoteCmd = buildRemoteCommand(forwarded);
96
179
  const multi = hosts.length > 1;
97
180
  let failures = 0;
98
181
  for (const host of hosts) {
99
182
  if (multi)
100
183
  process.stdout.write(chalk.cyan(`\n── ${host} ──\n`));
101
- const res = spawnSync('ssh', [...SSH_OPTS, host, remoteCmd], { stdio: 'inherit' });
102
- if (res.error) {
103
- failures++;
104
- console.error(chalk.red(`${host}: ${res.error.message}`));
105
- continue;
106
- }
107
- if (res.status !== 0) {
108
- failures++;
109
- console.error(chalk.red(`${host}: remote query failed (exit ${res.status ?? 'signal'}).`));
184
+ const res = spawnSync('ssh', [...SSH_OPTS, host, remoteCmd], {
185
+ encoding: 'utf8',
186
+ maxBuffer: 64 * 1024 * 1024,
187
+ });
188
+ switch (classifySshFailure(res)) {
189
+ case 'ok':
190
+ process.stdout.write(res.stdout ?? '');
191
+ if (res.stderr)
192
+ process.stderr.write(res.stderr);
193
+ writeRemoteCache(host, forwarded, res.stdout ?? '');
194
+ break;
195
+ case 'unreachable':
196
+ // Served-from-cache counts as answered (degraded, but with data + a clear
197
+ // banner), so it does not increment failures. No cache → a real failure.
198
+ if (!replayRemoteCache(host, forwarded)) {
199
+ failures++;
200
+ console.error(formatUnreachable(host));
201
+ }
202
+ break;
203
+ case 'spawn-error':
204
+ failures++;
205
+ console.error(chalk.red(`${host}: ${res.error?.message ?? 'failed to launch ssh'}`));
206
+ break;
207
+ case 'query-failed':
208
+ // The remote ran but its query exited non-zero — surface its own output
209
+ // and exit code; never mask a genuine error with stale cache.
210
+ failures++;
211
+ if (res.stdout)
212
+ process.stdout.write(res.stdout);
213
+ if (res.stderr)
214
+ process.stderr.write(res.stderr);
215
+ console.error(chalk.red(`${host}: remote query failed (exit ${res.status ?? 'signal'}).`));
216
+ break;
110
217
  }
111
218
  }
112
219
  if (failures > 0)
@@ -7,7 +7,7 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** Agents that store session data on disk and can be discovered by `agents sessions`. */
10
- export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi';
10
+ export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi' | 'droid';
11
11
  /** All agents with session discovery support, in display order. */
12
12
  export declare const SESSION_AGENTS: SessionAgentId[];
13
13
  /** A single normalized event within a session (message, tool call, thinking, etc.). */
@@ -7,4 +7,4 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** All agents with session discovery support, in display order. */
10
- export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi'];
10
+ export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid'];
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared SSH exec primitive — the single hardened choke point for running a
3
+ * command on a remote host over the system `ssh`.
4
+ *
5
+ * `agents hosts` dispatch and the browser driver both go through here so the
6
+ * connection hardening (`BatchMode`, `accept-new`, `ConnectTimeout`) and the
7
+ * target-injection guard live in exactly one place. Target validation is the
8
+ * canonical definition; `commands/secrets.ts` re-exports it.
9
+ */
10
+ /**
11
+ * SSH target: a bare ssh-config host alias (e.g. `yosemite-s0`) or `user@host`.
12
+ * The strict allowlist blocks shell metacharacters so a target can't be
13
+ * smuggled in as part of a remote command, and `sshExec` additionally rejects a
14
+ * leading `-` so it can never be parsed as an ssh argv flag.
15
+ */
16
+ export declare const SSH_TARGET_RE: RegExp;
17
+ export declare function assertValidSshTarget(host: string): void;
18
+ /** POSIX single-quote a string for safe interpolation into a remote shell command. */
19
+ export declare function shellQuote(s: string): string;
20
+ /** Hardened ssh options applied to every connection. */
21
+ export declare const SSH_OPTS: readonly string[];
22
+ export interface SshExecOptions {
23
+ /** Piped to the remote command's stdin (never interpolated into the shell). */
24
+ input?: string;
25
+ /** Kill the ssh process after this many ms. */
26
+ timeoutMs?: number;
27
+ /** Extra ssh flags inserted before the target (e.g. `-tt`). */
28
+ extraSshArgs?: string[];
29
+ }
30
+ export interface SshExecResult {
31
+ /** Remote exit status, or null if ssh itself failed / timed out. */
32
+ code: number | null;
33
+ stdout: string;
34
+ stderr: string;
35
+ timedOut: boolean;
36
+ }
37
+ /**
38
+ * Run `remoteCmd` on `target` over ssh and capture stdout/stderr/exit.
39
+ *
40
+ * `remoteCmd` is passed as a single argv to ssh (the remote login shell parses
41
+ * it); callers that build it from user input must `shellQuote` the pieces.
42
+ */
43
+ export declare function sshExec(target: string, remoteCmd: string, opts?: SshExecOptions): SshExecResult;
44
+ /** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
45
+ export declare function sshReachable(target: string, timeoutMs?: number): boolean;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared SSH exec primitive — the single hardened choke point for running a
3
+ * command on a remote host over the system `ssh`.
4
+ *
5
+ * `agents hosts` dispatch and the browser driver both go through here so the
6
+ * connection hardening (`BatchMode`, `accept-new`, `ConnectTimeout`) and the
7
+ * target-injection guard live in exactly one place. Target validation is the
8
+ * canonical definition; `commands/secrets.ts` re-exports it.
9
+ */
10
+ import { spawnSync } from 'child_process';
11
+ /**
12
+ * SSH target: a bare ssh-config host alias (e.g. `yosemite-s0`) or `user@host`.
13
+ * The strict allowlist blocks shell metacharacters so a target can't be
14
+ * smuggled in as part of a remote command, and `sshExec` additionally rejects a
15
+ * leading `-` so it can never be parsed as an ssh argv flag.
16
+ */
17
+ export const SSH_TARGET_RE = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
18
+ export function assertValidSshTarget(host) {
19
+ if (host.startsWith('-') || !SSH_TARGET_RE.test(host)) {
20
+ throw new Error(`Invalid SSH target ${JSON.stringify(host)}. Expected a host alias or user@host (letters, digits, '.', '_', '-').`);
21
+ }
22
+ }
23
+ /** POSIX single-quote a string for safe interpolation into a remote shell command. */
24
+ export function shellQuote(s) {
25
+ if (/^[A-Za-z0-9_./:=@%+-]+$/.test(s))
26
+ return s;
27
+ return "'" + s.replace(/'/g, "'\\''") + "'";
28
+ }
29
+ /** Hardened ssh options applied to every connection. */
30
+ export const SSH_OPTS = [
31
+ '-o', 'StrictHostKeyChecking=accept-new',
32
+ '-o', 'BatchMode=yes',
33
+ '-o', 'ConnectTimeout=10',
34
+ ];
35
+ /**
36
+ * Run `remoteCmd` on `target` over ssh and capture stdout/stderr/exit.
37
+ *
38
+ * `remoteCmd` is passed as a single argv to ssh (the remote login shell parses
39
+ * it); callers that build it from user input must `shellQuote` the pieces.
40
+ */
41
+ export function sshExec(target, remoteCmd, opts = {}) {
42
+ assertValidSshTarget(target);
43
+ const args = [...SSH_OPTS, ...(opts.extraSshArgs ?? []), target, remoteCmd];
44
+ const res = spawnSync('ssh', args, {
45
+ input: opts.input,
46
+ encoding: 'utf-8',
47
+ timeout: opts.timeoutMs,
48
+ stdio: ['pipe', 'pipe', 'pipe'],
49
+ });
50
+ const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT');
51
+ return {
52
+ code: typeof res.status === 'number' ? res.status : null,
53
+ stdout: res.stdout ?? '',
54
+ stderr: res.stderr ?? '',
55
+ timedOut,
56
+ };
57
+ }
58
+ /** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
59
+ export function sshReachable(target, timeoutMs = 10000) {
60
+ return sshExec(target, 'true', { timeoutMs }).code === 0;
61
+ }
@@ -67,6 +67,8 @@ export declare const loadPty: ModuleLoader;
67
67
  export declare const loadTmux: ModuleLoader;
68
68
  export declare const loadBrowser: ModuleLoader;
69
69
  export declare const loadComputer: ModuleLoader;
70
+ export declare const loadHosts: ModuleLoader;
71
+ export declare const loadSsh: ModuleLoader;
70
72
  export declare const loadPull: ModuleLoader;
71
73
  export declare const loadPush: ModuleLoader;
72
74
  export declare const loadRepo: ModuleLoader;
@@ -45,6 +45,8 @@ export const loadPty = async () => (await import('../../commands/pty.js')).regis
45
45
  export const loadTmux = async () => (await import('../../commands/tmux.js')).registerTmuxCommands;
46
46
  export const loadBrowser = async () => (await import('../../commands/browser.js')).registerBrowserCommand;
47
47
  export const loadComputer = async () => (await import('../../commands/computer.js')).registerComputerCommand;
48
+ export const loadHosts = async () => (await import('../../commands/hosts.js')).registerHostsCommand;
49
+ export const loadSsh = async () => (await import('../../commands/ssh.js')).registerSshCommands;
48
50
  export const loadPull = async () => (await import('../../commands/pull.js')).registerPullCommand;
49
51
  export const loadPush = async () => (await import('../../commands/push.js')).registerPushCommand;
50
52
  export const loadRepo = async () => (await import('../../commands/repo.js')).registerRepoCommands;
@@ -126,6 +128,9 @@ export const COMMAND_LOADERS = {
126
128
  tmux: [loadTmux],
127
129
  browser: [loadBrowser],
128
130
  computer: [loadComputer],
131
+ hosts: [loadHosts],
132
+ ssh: [loadSsh],
133
+ devices: [loadSsh],
129
134
  pull: [loadPull],
130
135
  push: [loadPush],
131
136
  repo: [loadRepo],
@@ -152,6 +152,8 @@ export declare function getTeamsDir(): string;
152
152
  export declare function getTeamsAgentsDir(): string;
153
153
  /** Path to the team registry — list of named teams with timestamps. Durable runtime, per-machine. */
154
154
  export declare function getTeamsRegistryPath(): string;
155
+ /** Path to the device registry — SSH device profiles with platform/auth metadata. Durable runtime, per-machine (host list + addresses are NOT pulled by `agents repo push`). */
156
+ export declare function getDevicesRegistryPath(): string;
155
157
  /** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
156
158
  export declare function getCloudDir(): string;
157
159
  /** Path to terminal session metadata (~/.agents/.cache/terminals/). */
package/dist/lib/state.js CHANGED
@@ -334,6 +334,8 @@ export function getTeamsDir() { return TEAMS_DIR; }
334
334
  export function getTeamsAgentsDir() { return TEAMS_AGENTS_DIR; }
335
335
  /** Path to the team registry — list of named teams with timestamps. Durable runtime, per-machine. */
336
336
  export function getTeamsRegistryPath() { return path.join(HISTORY_DIR, 'teams', 'registry.json'); }
337
+ /** Path to the device registry — SSH device profiles with platform/auth metadata. Durable runtime, per-machine (host list + addresses are NOT pulled by `agents repo push`). */
338
+ export function getDevicesRegistryPath() { return path.join(HISTORY_DIR, 'devices', 'registry.json'); }
337
339
  /** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
338
340
  export function getCloudDir() { return CLOUD_DIR; }
339
341
  /** Path to terminal session metadata (~/.agents/.cache/terminals/). */
@@ -612,6 +612,27 @@ export interface Meta {
612
612
  * lives separately in ~/.agents/.cache/browser/<profile>/.
613
613
  */
614
614
  browser?: Record<string, BrowserProfileConfig>;
615
+ /**
616
+ * Agent-host registry keyed by host name (`agents hosts`). Portable user
617
+ * config synced with `agents repo push/pull`. For `ssh-config` hosts this is
618
+ * just an overlay (caps/os) — the connection details stay in ~/.ssh/config and
619
+ * are never copied. `inline` hosts carry their own address/user.
620
+ */
621
+ hosts?: Record<string, HostEntry>;
622
+ }
623
+ /** Persisted agent-host entry in agents.yaml (overlay or inline). */
624
+ export interface HostEntry {
625
+ /** `ssh-config`: reach via the bare name (ssh resolves). `inline`: use address/user below. */
626
+ source: 'ssh-config' | 'inline';
627
+ /** SSH-reachable target — inline hosts only (ssh-config hosts omit it). */
628
+ address?: string;
629
+ /** SSH user — inline hosts only. */
630
+ user?: string;
631
+ /** Captured at enroll probe. */
632
+ os?: string;
633
+ /** Free-form capability tags for routing (e.g. ['gpu']). */
634
+ caps?: string[];
635
+ addedAt?: string;
615
636
  }
616
637
  /** Browser profile definition stored in agents.yaml. */
617
638
  export interface BrowserProfileConfig {
@@ -241,12 +241,16 @@ export declare function resolveVersion(agent: AgentId, projectPath?: string): st
241
241
  /**
242
242
  * Normalize a user-supplied @version token across CLI subcommands.
243
243
  *
244
- * undefined / "" / "default" -> undefined (caller falls back to project pin or global default)
244
+ * undefined / "" / "default" / "pinned" -> undefined (caller falls back to project pin or global default)
245
+ * "any" -> undefined (caller imposes no version constraint — e.g. resume across any version)
245
246
  * "latest" -> highest installed version (process.exit if none installed)
246
247
  * "oldest" -> lowest installed version (process.exit if none installed)
247
248
  * "x.y.z" (installed) -> "x.y.z"
248
249
  * "x.y.z" (not installed) -> process.exit with installed-list hint
249
250
  *
251
+ * `pinned` is a synonym for `default`: both name the project pin / global
252
+ * default, which the caller resolves.
253
+ *
250
254
  * Use this anywhere the user can type `agents <cmd> claude@<token>` to keep the
251
255
  * vocabulary consistent. Subcommands with different semantics for `latest`
252
256
  * (install/remove/use, where `latest` means npm-latest) keep their existing
@@ -255,7 +259,7 @@ export declare function resolveVersion(agent: AgentId, projectPath?: string): st
255
259
  export declare function resolveVersionAlias(agent: AgentId, raw: string | undefined | null): string | undefined;
256
260
  /**
257
261
  * Loose variant of resolveVersionAlias for record-filter contexts (sessions,
258
- * team history). Same `default`/`latest`/`oldest` semantics, but explicit
262
+ * team history). Same `default`/`pinned`/`latest`/`oldest` semantics, but explicit
259
263
  * versions pass through unchanged so historical records of uninstalled versions
260
264
  * remain queryable.
261
265
  */
@@ -1363,19 +1363,23 @@ export function resolveVersion(agent, projectPath) {
1363
1363
  /**
1364
1364
  * Normalize a user-supplied @version token across CLI subcommands.
1365
1365
  *
1366
- * undefined / "" / "default" -> undefined (caller falls back to project pin or global default)
1366
+ * undefined / "" / "default" / "pinned" -> undefined (caller falls back to project pin or global default)
1367
+ * "any" -> undefined (caller imposes no version constraint — e.g. resume across any version)
1367
1368
  * "latest" -> highest installed version (process.exit if none installed)
1368
1369
  * "oldest" -> lowest installed version (process.exit if none installed)
1369
1370
  * "x.y.z" (installed) -> "x.y.z"
1370
1371
  * "x.y.z" (not installed) -> process.exit with installed-list hint
1371
1372
  *
1373
+ * `pinned` is a synonym for `default`: both name the project pin / global
1374
+ * default, which the caller resolves.
1375
+ *
1372
1376
  * Use this anywhere the user can type `agents <cmd> claude@<token>` to keep the
1373
1377
  * vocabulary consistent. Subcommands with different semantics for `latest`
1374
1378
  * (install/remove/use, where `latest` means npm-latest) keep their existing
1375
1379
  * parsing.
1376
1380
  */
1377
1381
  export function resolveVersionAlias(agent, raw) {
1378
- if (!raw || raw === 'default')
1382
+ if (!raw || raw === 'default' || raw === 'pinned' || raw === 'any')
1379
1383
  return undefined;
1380
1384
  if (raw === 'latest' || raw === 'oldest') {
1381
1385
  const installed = listInstalledVersions(agent);
@@ -1399,12 +1403,12 @@ export function resolveVersionAlias(agent, raw) {
1399
1403
  }
1400
1404
  /**
1401
1405
  * Loose variant of resolveVersionAlias for record-filter contexts (sessions,
1402
- * team history). Same `default`/`latest`/`oldest` semantics, but explicit
1406
+ * team history). Same `default`/`pinned`/`latest`/`oldest` semantics, but explicit
1403
1407
  * versions pass through unchanged so historical records of uninstalled versions
1404
1408
  * remain queryable.
1405
1409
  */
1406
1410
  export function resolveVersionAliasLoose(agent, raw) {
1407
- if (!raw || raw === 'default')
1411
+ if (!raw || raw === 'default' || raw === 'pinned' || raw === 'any')
1408
1412
  return undefined;
1409
1413
  if (raw === 'latest' || raw === 'oldest') {
1410
1414
  const installed = listInstalledVersions(agent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.27",
3
+ "version": "1.20.29",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -239,6 +239,10 @@ To enable version-aware shims, add this to your shell config:
239
239
  console.log(` Installed shorthands: ${written.join(', ')}`);
240
240
  }
241
241
 
242
+ if (process.platform !== 'win32') {
243
+ await ensureAgentsResolvablePosix();
244
+ }
245
+
242
246
  await healLongRunningProcesses();
243
247
 
244
248
  const version = getVersion();
@@ -252,6 +256,64 @@ To enable version-aware shims, add this to your shell config:
252
256
  }
253
257
  }
254
258
 
259
+ /**
260
+ * Make the `agents` command resolvable in a *login* shell on POSIX.
261
+ *
262
+ * `agents`/`ag` reach PATH only through npm's bin symlink in the npm global-bin
263
+ * dir. Under nvm (and other per-user node prefixes) that dir is missing from a
264
+ * non-interactive login shell's PATH, so `bash -lc 'agents …'` fails with
265
+ * command-not-found — which breaks `agents secrets export --host` (it runs
266
+ * `bash -lc 'agents secrets import …'` on the remote) and the routines daemon
267
+ * (src/lib/daemon.ts falls back to bare `agents`). This is the POSIX symmetric
268
+ * counterpart of the Windows branch in main() that registers npm's global-bin
269
+ * dir on the user PATH.
270
+ *
271
+ * Self-heal, not a prompt: it fires ONLY when `agents` is otherwise
272
+ * unresolvable — the genuinely-broken state — so it acts decisively, exactly
273
+ * like the Windows PATH registration. The symlink never clobbers a dev build
274
+ * (scripts/install.sh) or any real file. Skipped in CI (ephemeral homes) and
275
+ * when AGENTS_NO_HEAL=1.
276
+ */
277
+ async function ensureAgentsResolvablePosix() {
278
+ if (process.env.CI || process.env.AGENTS_NO_HEAL === '1') return;
279
+ try {
280
+ const { localBinDir, ensureLocalBinSymlink, loginShellResolves, dirOnLoginPath } =
281
+ await import('../dist/lib/platform/posixpath.js');
282
+
283
+ // macOS/homebrew, system-node Linux, and dev-build users already resolve it.
284
+ if (loginShellResolves('agents')) return;
285
+
286
+ const binDir = localBinDir();
287
+ const results = ['agents', 'ag'].map((name) => ensureLocalBinSymlink(name, AGENTS_BIN, binDir));
288
+ if (!results.some((r) => r.created)) return; // nothing we could safely link
289
+
290
+ if (dirOnLoginPath(binDir)) {
291
+ console.log(`\n Linked 'agents' into ${binDir} (already on the login PATH) so 'bash -lc agents' resolves.`);
292
+ return;
293
+ }
294
+
295
+ // ~/.local/bin isn't on the *bash* login PATH yet. The consumers run
296
+ // `bash -lc`, so add it to the file a bash login shell reads (~/.bash_profile
297
+ // when present, else ~/.profile) — not the interactive $SHELL rc, which for a
298
+ // zsh user (.zshrc) bash would never source.
299
+ const bashRc = fs.existsSync(path.join(HOME, '.bash_profile'))
300
+ ? path.join(HOME, '.bash_profile')
301
+ : path.join(HOME, '.profile');
302
+ const marker = '# agents-cli: ensure ~/.local/bin on PATH (so the agents command resolves)';
303
+ let already = false;
304
+ try {
305
+ already = fs.existsSync(bashRc) && fs.readFileSync(bashRc, 'utf-8').includes(marker);
306
+ } catch { /* unreadable rc — fall through and append */ }
307
+ if (!already) {
308
+ fs.appendFileSync(bashRc, `\n${marker}\nexport PATH="${binDir}:$PATH"\n`);
309
+ }
310
+ console.log(`\n Linked 'agents' into ${binDir} and added it to PATH in ${path.basename(bashRc)}.`);
311
+ console.log(` Restart your shell (or run: source ~/${path.basename(bashRc)}) to pick it up.`);
312
+ } catch {
313
+ /* best-effort: a failure here must never break the install */
314
+ }
315
+ }
316
+
255
317
  /**
256
318
  * Self-heal long-running processes onto the just-installed code (macOS).
257
319
  *