@phnx-labs/agents-cli 1.20.53 → 1.20.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.54
6
+
7
+ - **Unified fleet target resolution for `agents ssh` + `sessions --host`.** `agents ssh` now accepts the full target grammar the fan-out already used — a registered `name`, a `user@device` (same device, login user overridden, still dialed via its Tailscale route rather than raw LAN DNS), and an ad-hoc `user@host`/`host` literal — instead of only an exact device name (`agents ssh muqsit@mac-mini` no longer errors "Unknown device"). A bare unregistered alias still reports "Unknown device". `sessions --host user@device` now resolves the host part through the registry too, so it stops silently diverging onto the non-Tailscale route. New `resolveDeviceTarget`; `resolveSshTarget` shares one host-part matcher. Source: `apps/cli/src/lib/devices/resolve-target.ts`, `apps/cli/src/commands/ssh.ts`.
8
+ - **`agents sessions --host` searches the peer's whole index, not its login cwd.** A remote listing runs in the peer's SSH-login home dir and was silently cwd-scoped, so `sessions --host <box>` read as empty (`No sessions found for /home/<user>`) even when the box's index was full. `--host` now defaults to whole-index (`--all`) scope; an explicit path query / `--project` / `--since` / `--agent` filter still narrows on top. It also runs the peer once, for itself (`AGENTS_SESSIONS_LOCAL=1`), so it no longer re-sweeps the fleet and prints a spurious `<this-machine>: unreachable`. Source: `apps/cli/src/lib/session/remote.ts`, `apps/cli/src/commands/sessions.ts`.
9
+ - **`agents devices sync` pins each device's login user.** Tailscale status carries a node's OS + address but not the account you ssh in as, so sync now materializes the local operator's username onto newly-synced devices (never clobbering a user you pinned). This makes `--host <device>` dial the same account no matter which machine launches the fan-out, instead of leaning on ssh's implicit local-username default. Source: `apps/cli/src/lib/devices/sync.ts`.
10
+
5
11
  ## 1.20.53
6
12
 
7
13
  - **`agents add <agent>@latest` resolves to a concrete version before installing (no install race).** `latest` (like `oldest`) is now resolved via `npm view` up front and installed as a pinned spec directly into `versions/<agent>/<version>/`. Previously `latest` installed into a shared, well-known `versions/<agent>/latest/` scratch dir and was renamed to the real version only after npm finished — so a concurrent `agents view` reconcile (`reconcileStaleLatestForAgent`) or a second `latest` install could rename that dir out from under npm mid-extraction, corrupting the install with `ENOENT` on the seeded `package.json`. A concrete dir per version has no shared name to race on. Source: `apps/cli/src/lib/versions.ts`.
package/dist/bin/agents CHANGED
Binary file
@@ -28,7 +28,7 @@ import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/
28
28
  import { discoverSessions, countSessionsInScope, resolveSessionById, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
29
29
  import { filterTeamSessions } from '../lib/session/team-filter.js';
30
30
  import { parseSession } from '../lib/session/parse.js';
31
- import { runRemoteSessions, buildForwardedArgs } from '../lib/session/remote.js';
31
+ import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
32
32
  import { formatRelativeTime } from '../lib/session/relative-time.js';
33
33
  import { renderConversationMarkdown, renderSummary, renderSummaryHeader, computeSummaryStats, renderJson, filterEvents, parseRoleList } from '../lib/session/render.js';
34
34
  import { renderMarkdown } from '../lib/markdown.js';
@@ -559,8 +559,10 @@ export function serializeSessionsJson(sessions) {
559
559
  */
560
560
  async function runRemoteSessionsJson(hosts) {
561
561
  // Forward the caller's own filters (query, --limit, --since, …) minus --host,
562
- // and guarantee --json so each peer answers with a parseable array.
563
- const forwarded = buildForwardedArgs(process.argv, new Set(hosts));
562
+ // and guarantee --json so each peer answers with a parseable array. Force
563
+ // whole-index scope: an explicit --host means "that box's index", not the
564
+ // slice that happens to sit under the peer's SSH-login home dir.
565
+ const forwarded = ensureWholeIndex(buildForwardedArgs(process.argv, new Set(hosts)));
564
566
  if (!forwarded.includes('--json'))
565
567
  forwarded.push('--json');
566
568
  const { sessions } = await gatherRemoteList(forwarded, hosts);
@@ -19,7 +19,8 @@ import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
19
19
  import { machineId } from '../lib/session/sync/config.js';
20
20
  import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
21
21
  import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from '../lib/devices/tailscale.js';
22
- import { planDeviceReconciliation, runDeviceSync } from '../lib/devices/sync.js';
22
+ import { localLoginUser, planDeviceReconciliation, runDeviceSync, withDefaultUser } from '../lib/devices/sync.js';
23
+ import { resolveDeviceTarget } from '../lib/devices/resolve-target.js';
23
24
  import { clearPendingSentinel } from '../lib/devices/pending.js';
24
25
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
25
26
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
@@ -106,8 +107,11 @@ async function runInteractiveDeviceSync() {
106
107
  }
107
108
  const byName = new Map(nodes.map((n) => [n.name, n]));
108
109
  const plan = planDeviceReconciliation(byName.keys(), selected, registered, ignored);
109
- for (const name of plan.toRegister)
110
- await upsertDevice(name, nodeToDeviceInput(byName.get(name)));
110
+ const localUser = localLoginUser();
111
+ for (const name of plan.toRegister) {
112
+ const input = withDefaultUser(nodeToDeviceInput(byName.get(name)), reg[name]?.user, localUser);
113
+ await upsertDevice(name, input);
114
+ }
111
115
  for (const name of plan.toUnignore)
112
116
  await removeIgnored(name);
113
117
  for (const name of plan.toRemove)
@@ -332,16 +336,24 @@ secrets bundle via an askpass shim — the password never touches argv.
332
336
  await runAskpass();
333
337
  return;
334
338
  }
335
- const device = await mustGetDevice(name);
339
+ // Accept the full fleet target grammar: a registered `name`, a
340
+ // `user@device` (same device, login user overridden — dialed via its
341
+ // Tailscale route, not LAN DNS), or an ad-hoc `user@host`/`host` literal.
342
+ // A bare unregistered alias still errors as "Unknown device".
343
+ const device = resolveDeviceTarget(name, await loadDevices());
344
+ if (!device) {
345
+ console.error(chalk.red(`Unknown device '${name}'. See 'agents devices list'.`));
346
+ process.exit(1);
347
+ }
336
348
  // Preflight: a device Tailscale last saw offline would otherwise hang
337
349
  // for the full ConnectTimeout. Fail fast with a clear message instead.
338
350
  if (device.tailscale && !device.tailscale.online) {
339
- console.error(chalk.red(`Device '${name}' is offline (Tailscale last saw it ${device.tailscale.lastSeen ?? 'a while ago'}).`));
351
+ console.error(chalk.red(`Device '${device.name}' is offline (Tailscale last saw it ${device.tailscale.lastSeen ?? 'a while ago'}).`));
340
352
  console.error(chalk.gray("Run 'agents devices sync' to refresh reachability."));
341
353
  process.exit(1);
342
354
  }
343
355
  if (device.tailscale?.online && !device.tailscale.direct) {
344
- console.error(chalk.yellow(`Note: connection to '${name}' is relayed (DERP ${device.tailscale.relay ?? '?'}) — expect higher latency.`));
356
+ console.error(chalk.yellow(`Note: connection to '${device.name}' is relayed (DERP ${device.tailscale.relay ?? '?'}) — expect higher latency.`));
345
357
  }
346
358
  try {
347
359
  const shim = writeAskpassShim();
@@ -1,4 +1,4 @@
1
- import { type DeviceRegistry } from './registry.js';
1
+ import { type DeviceProfile, type DeviceRegistry } from './registry.js';
2
2
  /** A dialable peer: the ssh target, the machine id used to tag its rows, a
3
3
  * display name, and the OS family that picks the remote shell dialect. */
4
4
  export interface ResolvedSshTarget {
@@ -7,14 +7,31 @@ export interface ResolvedSshTarget {
7
7
  name: string;
8
8
  os?: string;
9
9
  }
10
+ /** Split a `user@host` / `host` token into its login user (if any) and host part. */
11
+ export declare function splitUserHost(token: string): {
12
+ user?: string;
13
+ host: string;
14
+ };
10
15
  /**
11
16
  * Resolve one `--host`/`--device` token to a concrete ssh target through the
12
17
  * registry. Registry hit → the device's real address + platform (so the machine
13
- * id, route, and OS all match the auto-discovery sweep). Miss a literal
14
- * `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
15
- * undefined only when the token fails the shared ssh-target injection guard.
18
+ * id, route, and OS all match the auto-discovery sweep), with any `user@`
19
+ * overriding the login account. Miss → a literal `user@host` fallback, its OS
20
+ * taken from the host overlay if enrolled. Returns undefined only when the token
21
+ * fails the shared ssh-target injection guard.
16
22
  */
17
23
  export declare function resolveSshTarget(token: string, reg: DeviceRegistry): ResolvedSshTarget | undefined;
24
+ /**
25
+ * Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
26
+ * grammar as {@link resolveSshTarget}, but returns the whole profile (auth,
27
+ * shell, tailscale metadata) `buildSshInvocation` needs — not just a target
28
+ * string. A registered `name` or `user@device` yields that profile (with the
29
+ * login user overridden by any `user@`); an ad-hoc `user@host`/`host` literal
30
+ * yields a synthesized key-auth profile. A bare unregistered alias (no `@`/dot)
31
+ * returns undefined so the caller reports "Unknown device" rather than dialing a
32
+ * literal — the strict behaviour the interactive wrapper has always had.
33
+ */
34
+ export declare function resolveDeviceTarget(token: string, reg: DeviceRegistry): DeviceProfile | undefined;
18
35
  /**
19
36
  * Resolve an explicit `--host`/`--device` list to dialable targets, reading the
20
37
  * registry once. A token that fails the injection guard is skipped with a
@@ -13,8 +13,12 @@
13
13
  * `%C` hash → a cold dial every time) and could read a perfectly reachable box
14
14
  * as "unreachable" when only the non-Tailscale route was down.
15
15
  *
16
- * A raw `user@host` that matches no registered device falls back to a literal
17
- * target so ad-hoc boxes still work.
16
+ * The grammar is uniform across the fleet: `mac-mini` (device name), and
17
+ * `muqsit@mac-mini` (same device, login user overridden) both resolve through
18
+ * the registry to the device's Tailscale route — the `user@` form no longer
19
+ * short-circuits to a bare `ssh muqsit@mac-mini` (LAN DNS). A `user@host` that
20
+ * matches no registered device falls back to a literal target so ad-hoc boxes
21
+ * still work.
18
22
  */
19
23
  import chalk from 'chalk';
20
24
  import { assertValidSshTarget } from '../ssh-exec.js';
@@ -22,12 +26,28 @@ import { normalizeHost } from '../machine-id.js';
22
26
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
23
27
  import { sshTargetFor } from './connect.js';
24
28
  import { loadDevices } from './registry.js';
29
+ /** Split a `user@host` / `host` token into its login user (if any) and host part. */
30
+ export function splitUserHost(token) {
31
+ const at = token.indexOf('@');
32
+ return at === -1 ? { host: token } : { user: token.slice(0, at), host: token.slice(at + 1) };
33
+ }
34
+ /**
35
+ * Match a host part (the piece after any `user@`) to a registered device: exact
36
+ * registry key first, then a normalized-host match so `yosemite-s0` and
37
+ * `yosemite-s0.<tailnet>.ts.net` land on the same profile. The single source of
38
+ * truth both `resolveSshTarget` (fan-out) and `resolveDeviceTarget` (`agents
39
+ * ssh`) share, so a `user@device` can never resolve two different routes.
40
+ */
41
+ function matchDevice(host, reg) {
42
+ return reg[host] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(host));
43
+ }
25
44
  /**
26
45
  * Resolve one `--host`/`--device` token to a concrete ssh target through the
27
46
  * registry. Registry hit → the device's real address + platform (so the machine
28
- * id, route, and OS all match the auto-discovery sweep). Miss a literal
29
- * `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
30
- * undefined only when the token fails the shared ssh-target injection guard.
47
+ * id, route, and OS all match the auto-discovery sweep), with any `user@`
48
+ * overriding the login account. Miss → a literal `user@host` fallback, its OS
49
+ * taken from the host overlay if enrolled. Returns undefined only when the token
50
+ * fails the shared ssh-target injection guard.
31
51
  */
32
52
  export function resolveSshTarget(token, reg) {
33
53
  try {
@@ -36,22 +56,70 @@ export function resolveSshTarget(token, reg) {
36
56
  catch {
37
57
  return undefined;
38
58
  }
39
- const bare = token.split('@').pop() || token;
40
- // An explicit `user@host` names an exact account/target honour it literally.
41
- // A bare alias (`yosemite-s0`) resolves through the registry to the device's
42
- // real address, so it never diverges from the auto-discovery sweep.
43
- const device = token.includes('@')
44
- ? undefined
45
- : reg[token] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(bare));
59
+ const { user, host } = splitUserHost(token);
60
+ // A device and a `user@device` are the same box; resolve the host part through
61
+ // the registry so both dial the Tailscale route, and let an explicit `user@`
62
+ // override only the login account.
63
+ const device = matchDevice(host, reg);
46
64
  if (device) {
47
65
  try {
48
- return { target: sshTargetFor(device), machine: normalizeHost(device.name), name: device.name, os: device.platform };
66
+ const effective = user ? { ...device, user } : device;
67
+ return { target: sshTargetFor(effective), machine: normalizeHost(device.name), name: device.name, os: device.platform };
49
68
  }
50
69
  catch {
51
70
  // Registered but has no address to dial — fall through to the literal token.
52
71
  }
53
72
  }
54
- return { target: token, machine: normalizeHost(bare), name: token, os: resolveRemoteOsSync(token) };
73
+ return { target: token, machine: normalizeHost(host), name: token, os: resolveRemoteOsSync(token) };
74
+ }
75
+ /** Timestamps for a synthesized ad-hoc profile — never persisted, so a constant
76
+ * keeps the value deterministic (and side-effect free) without reading the clock. */
77
+ const SYNTH_TS = '1970-01-01T00:00:00.000Z';
78
+ /** True when a token is clearly a network target (a `user@`, or a dotted/IPv6
79
+ * host / IP) rather than a bare alias. A bare unknown word is a typo, so `agents
80
+ * ssh foo` still says "Unknown device" instead of dialing a literal `foo`. */
81
+ function looksLikeHostLiteral(token) {
82
+ return token.includes('@') || token.includes('.') || token.includes(':');
83
+ }
84
+ /** Synthesize a throwaway device profile for an ad-hoc `user@host` / `host`
85
+ * literal so `agents ssh` can dial a box that was never registered. */
86
+ function adHocDevice(token, host, user) {
87
+ const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
88
+ return {
89
+ name: token,
90
+ platform: 'unknown',
91
+ shell: 'posix',
92
+ user,
93
+ address: { via: 'manual', dnsName: isIp ? undefined : host, ip: isIp ? host : undefined },
94
+ auth: { method: 'key' },
95
+ createdAt: SYNTH_TS,
96
+ updatedAt: SYNTH_TS,
97
+ };
98
+ }
99
+ /**
100
+ * Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
101
+ * grammar as {@link resolveSshTarget}, but returns the whole profile (auth,
102
+ * shell, tailscale metadata) `buildSshInvocation` needs — not just a target
103
+ * string. A registered `name` or `user@device` yields that profile (with the
104
+ * login user overridden by any `user@`); an ad-hoc `user@host`/`host` literal
105
+ * yields a synthesized key-auth profile. A bare unregistered alias (no `@`/dot)
106
+ * returns undefined so the caller reports "Unknown device" rather than dialing a
107
+ * literal — the strict behaviour the interactive wrapper has always had.
108
+ */
109
+ export function resolveDeviceTarget(token, reg) {
110
+ try {
111
+ assertValidSshTarget(token);
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ const { user, host } = splitUserHost(token);
117
+ const device = matchDevice(host, reg);
118
+ if (device)
119
+ return user ? { ...device, user } : device;
120
+ if (looksLikeHostLiteral(token))
121
+ return adHocDevice(token, host, user);
122
+ return undefined;
55
123
  }
56
124
  /**
57
125
  * Resolve an explicit `--host`/`--device` list to dialable targets, reading the
@@ -1,5 +1,23 @@
1
+ import { type DeviceInput } from './registry.js';
1
2
  import { type TailscaleNode } from './tailscale.js';
2
3
  import type { PendingDevice } from './pending.js';
4
+ /**
5
+ * The login user to stamp onto newly-synced devices. Tailscale status carries a
6
+ * node's OS and address but NOT the account you ssh in as, so we materialize the
7
+ * local operator's username — tailnet devices are overwhelmingly one person's
8
+ * boxes, and this is exactly the account ssh would already fall back to. Pinning
9
+ * it in the registry makes `--host <device>` dial that account no matter which
10
+ * machine launches the fan-out (a peer whose local user differs otherwise dials
11
+ * the wrong account). Returns undefined when the username isn't a safe ssh
12
+ * identifier, so a weird value never lands in the registry. */
13
+ export declare function localLoginUser(): string | undefined;
14
+ /**
15
+ * Fill in a device's login user during sync WITHOUT ever clobbering an account
16
+ * the user pinned. Precedence: an existing registered user wins; else the local
17
+ * operator's username; else leave it unset (ssh's implicit local default still
18
+ * applies). Pure so the "never overwrite an explicit user" guard is unit-tested
19
+ * without a tailnet. */
20
+ export declare function withDefaultUser(input: DeviceInput, prevUser: string | undefined, localUser: string | undefined): DeviceInput;
3
21
  /**
4
22
  * bootstrap — register every non-ignored node (opt-out). First-run `agents
5
23
  * setup` and manual `agents devices sync`, so the fleet is usable out of box.
@@ -14,8 +14,39 @@
14
14
  * - soft (`soft: true`): auto-callers must never abort setup/sync because a
15
15
  * machine has no tailscale — they get a result with `ok: false` instead.
16
16
  */
17
+ import * as os from 'os';
17
18
  import { loadDevices, loadIgnored, upsertDevice, } from './registry.js';
18
19
  import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from './tailscale.js';
20
+ /**
21
+ * The login user to stamp onto newly-synced devices. Tailscale status carries a
22
+ * node's OS and address but NOT the account you ssh in as, so we materialize the
23
+ * local operator's username — tailnet devices are overwhelmingly one person's
24
+ * boxes, and this is exactly the account ssh would already fall back to. Pinning
25
+ * it in the registry makes `--host <device>` dial that account no matter which
26
+ * machine launches the fan-out (a peer whose local user differs otherwise dials
27
+ * the wrong account). Returns undefined when the username isn't a safe ssh
28
+ * identifier, so a weird value never lands in the registry. */
29
+ export function localLoginUser() {
30
+ let u;
31
+ try {
32
+ u = os.userInfo().username;
33
+ }
34
+ catch {
35
+ u = process.env.USER || process.env.USERNAME || undefined;
36
+ }
37
+ return u && /^[a-zA-Z0-9._-]+$/.test(u) ? u : undefined;
38
+ }
39
+ /**
40
+ * Fill in a device's login user during sync WITHOUT ever clobbering an account
41
+ * the user pinned. Precedence: an existing registered user wins; else the local
42
+ * operator's username; else leave it unset (ssh's implicit local default still
43
+ * applies). Pure so the "never overwrite an explicit user" guard is unit-tested
44
+ * without a tailnet. */
45
+ export function withDefaultUser(input, prevUser, localUser) {
46
+ if (input.user || prevUser || !localUser)
47
+ return input;
48
+ return { ...input, user: localUser };
49
+ }
19
50
  /**
20
51
  * Node names present on the tailnet but neither already in the registry nor on
21
52
  * the ignore-list — i.e. genuinely new devices worth surfacing. Pure so the
@@ -68,8 +99,10 @@ export async function runDeviceSync(opts = {}) {
68
99
  platform: byName.get(name)?.platform ?? 'unknown',
69
100
  }));
70
101
  const toUpsert = selectNodesToUpsert(nodes, registered, ignored, mode);
102
+ const localUser = localLoginUser();
71
103
  for (const node of toUpsert) {
72
- await upsertDevice(node.name, nodeToDeviceInput(node));
104
+ const input = withDefaultUser(nodeToDeviceInput(node), registeredBefore[node.name]?.user, localUser);
105
+ await upsertDevice(node.name, input);
73
106
  }
74
107
  return { ok: true, synced: toUpsert.length, pending };
75
108
  }
@@ -16,6 +16,19 @@ export declare function shellQuote(s: string): string;
16
16
  * (`[runtime, script, 'sessions', ...]`).
17
17
  */
18
18
  export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>): string[];
19
+ /**
20
+ * Force a forwarded `agents sessions` listing to span the peer's WHOLE index.
21
+ *
22
+ * A remote listing runs in the peer's SSH-login cwd — its home dir — and the
23
+ * default listing is silently cwd-scoped, so `sessions --host box` reads as
24
+ * empty even when the box's index is full (`No sessions found for /home/<user>`).
25
+ * Across SSH a peer's cwd is meaningless, so `--host` defaults to `--all`
26
+ * (whole-index) scope. This only drops the *cwd* narrowing — an explicit path
27
+ * query, `--project`, `--since`, or `--agent` filter still narrows on top, and
28
+ * a query that looks like a path takes precedence over `--all` on the remote.
29
+ * Idempotent: never adds a second `--all`.
30
+ */
31
+ export declare function ensureWholeIndex(forwardedArgs: string[]): string[];
19
32
  /**
20
33
  * Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
21
34
  * are quoted for the inner login shell, then the whole `agents …` invocation is
@@ -30,6 +30,7 @@ import { getCacheDir } from '../state.js';
30
30
  import { SSH_OPTS, controlOpts, assertValidSshTarget } from '../ssh-exec.js';
31
31
  import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
32
32
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
33
+ import { NO_FANOUT_ENV } from './remote-active.js';
33
34
  import { formatRelativeTime } from './relative-time.js';
34
35
  import { terminalWidth } from './width.js';
35
36
  /**
@@ -81,6 +82,21 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
81
82
  }
82
83
  return out;
83
84
  }
85
+ /**
86
+ * Force a forwarded `agents sessions` listing to span the peer's WHOLE index.
87
+ *
88
+ * A remote listing runs in the peer's SSH-login cwd — its home dir — and the
89
+ * default listing is silently cwd-scoped, so `sessions --host box` reads as
90
+ * empty even when the box's index is full (`No sessions found for /home/<user>`).
91
+ * Across SSH a peer's cwd is meaningless, so `--host` defaults to `--all`
92
+ * (whole-index) scope. This only drops the *cwd* narrowing — an explicit path
93
+ * query, `--project`, `--since`, or `--agent` filter still narrows on top, and
94
+ * a query that looks like a path takes precedence over `--all` on the remote.
95
+ * Idempotent: never adds a second `--all`.
96
+ */
97
+ export function ensureWholeIndex(forwardedArgs) {
98
+ return forwardedArgs.includes('--all') ? forwardedArgs : [...forwardedArgs, '--all'];
99
+ }
84
100
  /**
85
101
  * Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
86
102
  * are quoted for the inner login shell, then the whole `agents …` invocation is
@@ -93,16 +109,23 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
93
109
  * remote renders its table to the local screen.
94
110
  */
95
111
  export function buildRemoteCommand(forwardedArgs, columns, os) {
112
+ // `--host <box>` means "that box's own sessions" — so the peer must answer for
113
+ // ITSELF and not re-sweep its fleet. Without this the remote `agents sessions`
114
+ // fans back out to every device IT knows (including us), printing a spurious
115
+ // `<this-machine>: unreachable`. AGENTS_SESSIONS_LOCAL=1 pins the peer local,
116
+ // matching the JSON fan-out path (`remote-list.ts`).
96
117
  if (remoteShellFor(os) === 'powershell') {
97
- const env = columns && columns > 0 ? { COLUMNS: String(columns) } : undefined;
118
+ const env = { [NO_FANOUT_ENV]: '1' };
119
+ if (columns && columns > 0)
120
+ env.COLUMNS = String(columns);
98
121
  return buildWindowsAgentsCommand({ args: forwardedArgs, env });
99
122
  }
100
123
  const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
101
124
  // Forward the caller's terminal width so the remote renders the table to the
102
125
  // local screen (over SSH the remote's own COLUMNS is unset/wrong). `VAR=val
103
126
  // cmd` scopes the env to that process — the remote's terminalWidth() reads it.
104
- const withCols = columns && columns > 0 ? `COLUMNS=${columns} ${inner}` : inner;
105
- return `bash -lc ${shellQuote(withCols)}`;
127
+ const envPrefix = `${NO_FANOUT_ENV}=1` + (columns && columns > 0 ? ` COLUMNS=${columns}` : '');
128
+ return `bash -lc ${shellQuote(`${envPrefix} ${inner}`)}`;
106
129
  }
107
130
  /**
108
131
  * Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
@@ -182,7 +205,7 @@ function replayRemoteCache(host, forwardedArgs) {
182
205
  export function runRemoteSessions(hosts, argv = process.argv) {
183
206
  for (const host of hosts)
184
207
  assertValidSshTarget(host); // fail fast on any bad target
185
- const forwarded = buildForwardedArgs(argv, new Set(hosts));
208
+ const forwarded = ensureWholeIndex(buildForwardedArgs(argv, new Set(hosts)));
186
209
  const cols = terminalWidth();
187
210
  const multi = hosts.length > 1;
188
211
  let failures = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.53",
3
+ "version": "1.20.54",
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",