@phnx-labs/agents-cli 1.20.31 → 1.20.33

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 (74) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/commands/commands.js +3 -3
  3. package/dist/commands/computer-actions.js +1 -0
  4. package/dist/commands/cost.js +2 -2
  5. package/dist/commands/doctor.js +2 -2
  6. package/dist/commands/exec.js +56 -1
  7. package/dist/commands/hooks.js +3 -3
  8. package/dist/commands/inspect.js +13 -17
  9. package/dist/commands/mcp.js +3 -3
  10. package/dist/commands/permissions.js +3 -3
  11. package/dist/commands/rules.js +2 -2
  12. package/dist/commands/sessions.js +18 -1
  13. package/dist/commands/skills.js +3 -3
  14. package/dist/commands/ssh.js +23 -0
  15. package/dist/commands/sync.js +2 -2
  16. package/dist/commands/teams.js +7 -12
  17. package/dist/commands/usage.js +2 -2
  18. package/dist/commands/utils.d.ts +8 -0
  19. package/dist/commands/utils.js +20 -0
  20. package/dist/commands/versions.js +2 -2
  21. package/dist/commands/view.js +33 -9
  22. package/dist/commands/workflows.js +3 -3
  23. package/dist/index.js +12 -0
  24. package/dist/lib/agent-spec/index.d.ts +18 -0
  25. package/dist/lib/agent-spec/index.js +35 -0
  26. package/dist/lib/agent-spec/primitives.d.ts +28 -0
  27. package/dist/lib/agent-spec/primitives.js +57 -0
  28. package/dist/lib/agent-spec/provider.d.ts +2 -0
  29. package/dist/lib/agent-spec/provider.js +9 -0
  30. package/dist/lib/agent-spec/resolve.d.ts +33 -0
  31. package/dist/lib/agent-spec/resolve.js +174 -0
  32. package/dist/lib/agent-spec/types.d.ts +57 -0
  33. package/dist/lib/agent-spec/types.js +18 -0
  34. package/dist/lib/crabbox/cli.d.ts +98 -0
  35. package/dist/lib/crabbox/cli.js +218 -0
  36. package/dist/lib/crabbox/lease.d.ts +41 -0
  37. package/dist/lib/crabbox/lease.js +73 -0
  38. package/dist/lib/crabbox/runtimes.d.ts +57 -0
  39. package/dist/lib/crabbox/runtimes.js +109 -0
  40. package/dist/lib/daemon.js +32 -0
  41. package/dist/lib/devices/pending.d.ts +18 -0
  42. package/dist/lib/devices/pending.js +103 -0
  43. package/dist/lib/devices/sync.d.ts +21 -2
  44. package/dist/lib/devices/sync.js +26 -10
  45. package/dist/lib/hosts/dispatch.d.ts +27 -10
  46. package/dist/lib/hosts/dispatch.js +55 -19
  47. package/dist/lib/hosts/option.d.ts +14 -0
  48. package/dist/lib/hosts/option.js +19 -0
  49. package/dist/lib/hosts/passthrough.d.ts +30 -0
  50. package/dist/lib/hosts/passthrough.js +141 -0
  51. package/dist/lib/hosts/remote-cmd.d.ts +36 -0
  52. package/dist/lib/hosts/remote-cmd.js +56 -0
  53. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  54. package/dist/lib/secrets/bundles.js +29 -20
  55. package/dist/lib/secrets/index.d.ts +11 -0
  56. package/dist/lib/secrets/index.js +18 -1
  57. package/dist/lib/secrets/linux.d.ts +14 -0
  58. package/dist/lib/secrets/linux.js +21 -0
  59. package/dist/lib/session/active.d.ts +8 -0
  60. package/dist/lib/session/active.js +18 -1
  61. package/dist/lib/session/provenance.d.ts +56 -0
  62. package/dist/lib/session/provenance.js +157 -0
  63. package/dist/lib/ssh-exec.d.ts +22 -0
  64. package/dist/lib/ssh-exec.js +59 -2
  65. package/dist/lib/ssh-tunnel.d.ts +0 -5
  66. package/dist/lib/ssh-tunnel.js +65 -8
  67. package/dist/lib/state.d.ts +2 -0
  68. package/dist/lib/state.js +2 -0
  69. package/dist/lib/sync-umbrella.js +10 -6
  70. package/dist/lib/versions.d.ts +13 -4
  71. package/dist/lib/versions.js +27 -20
  72. package/package.json +2 -1
  73. package/dist/lib/agent-spec.d.ts +0 -36
  74. package/dist/lib/agent-spec.js +0 -157
@@ -0,0 +1,103 @@
1
+ /**
2
+ * "Pending device" sentinels.
3
+ *
4
+ * When the daemon's tailscale probe finds a node that is neither registered nor
5
+ * ignored, it drops a sentinel file under ~/.agents/.cache/state/devices-pending/
6
+ * — the same filesystem-signal pattern the attention hook uses for the menu bar.
7
+ * The Swift helper polls that dir every 10s and renders a "NEW DEVICES" section
8
+ * with Register / Ignore. The file NAME is the device name; the file CONTENT is
9
+ * the platform (one line), so the tray can show "zion (macos)" without opening
10
+ * the registry.
11
+ *
12
+ * The daemon owns writes (reconcile to match the current pending set); the CLI
13
+ * `agents devices register|ignore` clears a single sentinel the moment the user
14
+ * acts, so the badge updates immediately instead of waiting for the next probe.
15
+ */
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import { getDevicesPendingDir } from '../state.js';
19
+ /** Device-name sentinels must be safe filenames (no path traversal). The device
20
+ * name charset is already the ssh-alias set, but guard defensively. */
21
+ function isSafeName(name) {
22
+ return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
23
+ }
24
+ /**
25
+ * Make the sentinel dir exactly match `pending`: create a file per pending
26
+ * device (content = platform), and delete any leftover sentinel whose device is
27
+ * no longer pending (it got registered, ignored, or left the tailnet). Best-
28
+ * effort — a filesystem error here must never crash the daemon, so callers pass
29
+ * this through their existing try/catch.
30
+ */
31
+ export function reconcilePendingSentinels(pending) {
32
+ const dir = getDevicesPendingDir();
33
+ const want = new Map(pending.filter((p) => isSafeName(p.name)).map((p) => [p.name, p.platform]));
34
+ // Whole body is best-effort: a filesystem error here must never propagate into
35
+ // the daemon loop or `agents sync`. The top-level mkdir/readdir are guarded
36
+ // too, so no caller needs its own try/catch.
37
+ let existing;
38
+ try {
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ existing = fs.readdirSync(dir).filter((n) => !n.startsWith('.'));
41
+ }
42
+ catch {
43
+ return;
44
+ }
45
+ // Remove sentinels that are no longer pending.
46
+ for (const name of existing) {
47
+ if (!want.has(name)) {
48
+ try {
49
+ fs.unlinkSync(path.join(dir, name));
50
+ }
51
+ catch { /* already gone */ }
52
+ }
53
+ }
54
+ // Write/refresh the sentinels that should exist.
55
+ for (const [name, platform] of want) {
56
+ const p = path.join(dir, name);
57
+ const body = `${platform}\n`;
58
+ // Only write when missing or changed, to avoid needless mtime churn.
59
+ let current = null;
60
+ try {
61
+ current = fs.readFileSync(p, 'utf-8');
62
+ }
63
+ catch {
64
+ current = null;
65
+ }
66
+ if (current !== body) {
67
+ try {
68
+ fs.writeFileSync(p, body);
69
+ }
70
+ catch { /* best-effort */ }
71
+ }
72
+ }
73
+ }
74
+ /** Remove one device's pending sentinel (after the user registers or ignores it).
75
+ * No-op if it doesn't exist. */
76
+ export function clearPendingSentinel(name) {
77
+ if (!isSafeName(name))
78
+ return;
79
+ try {
80
+ fs.unlinkSync(path.join(getDevicesPendingDir(), name));
81
+ }
82
+ catch { /* already gone */ }
83
+ }
84
+ /** Read the current pending sentinels (name + platform). Used by tests and any
85
+ * TS-side consumer; the menu-bar helper reads the dir directly in Swift. */
86
+ export function readPendingSentinels() {
87
+ const dir = getDevicesPendingDir();
88
+ let names;
89
+ try {
90
+ names = fs.readdirSync(dir).filter((n) => !n.startsWith('.'));
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ return names.map((name) => {
96
+ let platform = 'unknown';
97
+ try {
98
+ platform = fs.readFileSync(path.join(dir, name), 'utf-8').trim() || 'unknown';
99
+ }
100
+ catch { /* keep default */ }
101
+ return { name, platform };
102
+ });
103
+ }
@@ -1,11 +1,22 @@
1
1
  import { type TailscaleNode } from './tailscale.js';
2
+ import type { PendingDevice } from './pending.js';
3
+ /**
4
+ * bootstrap — register every non-ignored node (opt-out). First-run `agents
5
+ * setup` and manual `agents devices sync`, so the fleet is usable out of box.
6
+ * refresh — only refresh reachability of already-registered nodes; a brand-new
7
+ * node is NOT auto-added, it is surfaced as `pending` for the user to approve
8
+ * (opt-in). Ongoing autosync and the daemon probe use this, so newcomers flow
9
+ * through the menu-bar "NEW DEVICES → Register / Ignore" gate instead of
10
+ * silently landing in the registry.
11
+ */
12
+ export type DeviceSyncMode = 'bootstrap' | 'refresh';
2
13
  export interface DeviceSyncResult {
3
14
  /** False when discovery could not run (e.g. tailscale absent) in soft mode. */
4
15
  ok: boolean;
5
16
  /** Number of tailscale nodes upserted into the registry. */
6
17
  synced: number;
7
- /** Node names discovered but neither registered-before nor ignored. */
8
- pending: string[];
18
+ /** Nodes discovered but neither registered-before nor ignored (name+platform). */
19
+ pending: PendingDevice[];
9
20
  /** Populated when ok is false: why discovery was skipped. */
10
21
  reason?: string;
11
22
  }
@@ -15,6 +26,13 @@ export interface DeviceSyncResult {
15
26
  * flag matrix is unit-testable without a live tailnet.
16
27
  */
17
28
  export declare function computePendingDevices(nodes: TailscaleNode[], registered: Iterable<string>, ignored: Iterable<string>): string[];
29
+ /**
30
+ * Which discovered nodes to upsert this run — the mode-defining decision, pure
31
+ * so it is unit-testable without a tailnet. Ignored nodes are always skipped.
32
+ * In `refresh` mode a node that isn't already registered is skipped too (it
33
+ * stays pending for approval); `bootstrap` includes every non-ignored node.
34
+ */
35
+ export declare function selectNodesToUpsert(nodes: TailscaleNode[], registered: Set<string>, ignored: Set<string>, mode: DeviceSyncMode): TailscaleNode[];
18
36
  /**
19
37
  * Ingest `tailscale status --json` into the registry. In soft mode a missing
20
38
  * tailscale binary / unreachable daemon resolves to `{ ok: false }` instead of
@@ -24,6 +42,7 @@ export declare function computePendingDevices(nodes: TailscaleNode[], registered
24
42
  */
25
43
  export declare function runDeviceSync(opts?: {
26
44
  soft?: boolean;
45
+ mode?: DeviceSyncMode;
27
46
  }): Promise<DeviceSyncResult>;
28
47
  /**
29
48
  * The register/remove/ignore decision for the interactive curation picker.
@@ -28,6 +28,21 @@ export function computePendingDevices(nodes, registered, ignored) {
28
28
  .map((n) => n.name)
29
29
  .filter((name) => !known.has(name) && !skip.has(name));
30
30
  }
31
+ /**
32
+ * Which discovered nodes to upsert this run — the mode-defining decision, pure
33
+ * so it is unit-testable without a tailnet. Ignored nodes are always skipped.
34
+ * In `refresh` mode a node that isn't already registered is skipped too (it
35
+ * stays pending for approval); `bootstrap` includes every non-ignored node.
36
+ */
37
+ export function selectNodesToUpsert(nodes, registered, ignored, mode) {
38
+ return nodes.filter((n) => {
39
+ if (ignored.has(n.name))
40
+ return false;
41
+ if (mode === 'refresh' && !registered.has(n.name))
42
+ return false;
43
+ return true;
44
+ });
45
+ }
31
46
  /**
32
47
  * Ingest `tailscale status --json` into the registry. In soft mode a missing
33
48
  * tailscale binary / unreachable daemon resolves to `{ ok: false }` instead of
@@ -36,6 +51,7 @@ export function computePendingDevices(nodes, registered, ignored) {
36
51
  * "new" means "not previously registered and not ignored".
37
52
  */
38
53
  export async function runDeviceSync(opts = {}) {
54
+ const mode = opts.mode ?? 'bootstrap';
39
55
  // Soft mode must be non-fatal for ANY failure, not just a missing tailscale:
40
56
  // a corrupted registry/ignore file (both throw by design), a disk error, or
41
57
  // registry lock contention (plausible when many agents SessionStart-autosync
@@ -44,18 +60,18 @@ export async function runDeviceSync(opts = {}) {
44
60
  try {
45
61
  const nodes = parseTailscaleStatus(tailscaleStatusJson());
46
62
  const [registeredBefore, ignored] = await Promise.all([loadDevices(), loadIgnored()]);
47
- const pending = computePendingDevices(nodes, Object.keys(registeredBefore), ignored);
48
- // Register/refresh every node the user has NOT dismissed. Skipping ignored
49
- // nodes is what makes the "register all" default safe: a phone or someone
50
- // else's laptop the user once dismissed never silently comes back.
51
- let synced = 0;
52
- for (const node of nodes) {
53
- if (ignored.has(node.name))
54
- continue;
63
+ const registered = new Set(Object.keys(registeredBefore));
64
+ const pendingNames = computePendingDevices(nodes, registered, ignored);
65
+ const byName = new Map(nodes.map((n) => [n.name, n]));
66
+ const pending = pendingNames.map((name) => ({
67
+ name,
68
+ platform: byName.get(name)?.platform ?? 'unknown',
69
+ }));
70
+ const toUpsert = selectNodesToUpsert(nodes, registered, ignored, mode);
71
+ for (const node of toUpsert) {
55
72
  await upsertDevice(node.name, nodeToDeviceInput(node));
56
- synced++;
57
73
  }
58
- return { ok: true, synced, pending };
74
+ return { ok: true, synced: toUpsert.length, pending };
59
75
  }
60
76
  catch (err) {
61
77
  if (opts.soft) {
@@ -1,13 +1,20 @@
1
1
  /**
2
- * Dispatch a headless agent run onto a host over SSH.
2
+ * Dispatch a headless `agents …` command onto a host over SSH.
3
3
  *
4
- * The run is launched detached (`nohup … &`) writing combined output to a remote
5
- * log and its exit code to a sibling `.exit` file, so progress survives a dropped
6
- * connection (followed via offset-tail in progress.ts). This is the offload win:
7
- * the agent's process/thread/file fan-out happens on the host, not the laptop.
4
+ * The command is launched detached (`nohup … &`) writing combined output to a
5
+ * remote log and its exit code to a sibling `.exit` file, so progress survives a
6
+ * dropped connection (followed via offset-tail in progress.ts). This is the
7
+ * offload win: the process/thread/file fan-out happens on the host, not the
8
+ * laptop. `agents run` uses it; `agents teams start --watch --host` reuses the
9
+ * same core so a remote team supervisor keeps running after you disconnect.
8
10
  */
9
11
  import type { Host } from './types.js';
10
12
  import { type HostTask } from './tasks.js';
13
+ export interface DispatchResult {
14
+ task: HostTask;
15
+ /** Exit code when followed; undefined when detached (--no-follow). */
16
+ exitCode?: number;
17
+ }
11
18
  export interface DispatchOptions {
12
19
  agent: string;
13
20
  prompt: string;
@@ -18,9 +25,19 @@ export interface DispatchOptions {
18
25
  follow?: boolean;
19
26
  timeoutMs?: number;
20
27
  }
21
- export interface DispatchResult {
22
- task: HostTask;
23
- /** Exit code when followed; undefined when detached (--no-follow). */
24
- exitCode?: number;
25
- }
28
+ /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
26
29
  export declare function dispatchToHost(host: Host, opts: DispatchOptions): Promise<DispatchResult>;
30
+ export interface CommandDispatchOptions {
31
+ /** `agents …` args (command name first), already stripped of routing flags. */
32
+ forwardedArgs: string[];
33
+ remoteCwd?: string;
34
+ follow?: boolean;
35
+ timeoutMs?: number;
36
+ }
37
+ /**
38
+ * Dispatch an arbitrary long-running `agents <command>` onto a host detached —
39
+ * used for `teams start --watch --host`, whose supervisor must outlive the SSH
40
+ * connection. Reachability is assumed (the caller has already resolved the host);
41
+ * a launch failure surfaces the remote stderr.
42
+ */
43
+ export declare function dispatchAgentsCommand(host: Host, opts: CommandDispatchOptions): Promise<DispatchResult>;
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Dispatch a headless agent run onto a host over SSH.
2
+ * Dispatch a headless `agents …` command onto a host over SSH.
3
3
  *
4
- * The run is launched detached (`nohup … &`) writing combined output to a remote
5
- * log and its exit code to a sibling `.exit` file, so progress survives a dropped
6
- * connection (followed via offset-tail in progress.ts). This is the offload win:
7
- * the agent's process/thread/file fan-out happens on the host, not the laptop.
4
+ * The command is launched detached (`nohup … &`) writing combined output to a
5
+ * remote log and its exit code to a sibling `.exit` file, so progress survives a
6
+ * dropped connection (followed via offset-tail in progress.ts). This is the
7
+ * offload win: the process/thread/file fan-out happens on the host, not the
8
+ * laptop. `agents run` uses it; `agents teams start --watch --host` reuses the
9
+ * same core so a remote team supervisor keeps running after you disconnect.
8
10
  */
9
11
  import { randomUUID } from 'crypto';
10
12
  import { sshExec, shellQuote } from '../ssh-exec.js';
@@ -16,25 +18,22 @@ import { followHostTask } from './progress.js';
16
18
  // regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
17
19
  // injection-safe to interpolate unquoted into remote commands.
18
20
  const REMOTE_DIR = '$HOME/.agents/.cache/hosts';
19
- export async function dispatchToHost(host, opts) {
20
- const target = sshTargetFor(host);
21
- const { warnings } = ensureHostReady(host, { agent: opts.agent });
22
- for (const w of warnings)
23
- process.stderr.write(`[hosts] warning: ${w}\n`);
21
+ /**
22
+ * The launch + task-record + optional follow core. Both `dispatchToHost` (run)
23
+ * and `dispatchAgentsCommand` (teams) build their `forwardedArgs` and call here,
24
+ * so the nohup/exit-file/offset-tail machinery lives in exactly one place.
25
+ */
26
+ async function launchDetached(host, target, opts) {
24
27
  const id = randomUUID().slice(0, 8);
25
28
  const remoteLog = `${REMOTE_DIR}/${id}.log`;
26
29
  const remoteExit = `${REMOTE_DIR}/${id}.exit`;
27
30
  // Inner command run under a login shell so PATH resolves `agents`.
28
- const runParts = ['agents', 'run', shellQuote(opts.agent), shellQuote(opts.prompt), '--quiet'];
29
- if (opts.mode)
30
- runParts.push('--mode', shellQuote(opts.mode));
31
- if (opts.model)
32
- runParts.push('--model', shellQuote(opts.model));
31
+ const invocation = ['agents', ...opts.forwardedArgs].map(shellQuote).join(' ');
33
32
  const cwd = opts.remoteCwd ? `cd ${shellQuote(opts.remoteCwd)} && ` : '';
34
- const inner = `${cwd}${runParts.join(' ')} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
33
+ const inner = `${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
35
34
  // Outer: ensure dir, launch detached under bash -lc, print the PID.
36
35
  const launch = `mkdir -p ${REMOTE_DIR}; nohup bash -lc ${shellQuote(inner)} >/dev/null 2>&1 & echo $!`;
37
- const res = sshExec(target, launch, { timeoutMs: 30000 });
36
+ const res = sshExec(target, launch, { timeoutMs: 30000, multiplex: true });
38
37
  if (res.code !== 0) {
39
38
  throw new Error(`Failed to launch on "${host.name}": ${(res.stderr || res.stdout).trim() || 'ssh error'}`);
40
39
  }
@@ -43,8 +42,8 @@ export async function dispatchToHost(host, opts) {
43
42
  id,
44
43
  host: host.name,
45
44
  target,
46
- agent: opts.agent,
47
- prompt: opts.prompt,
45
+ agent: opts.agentLabel,
46
+ prompt: opts.promptLabel,
48
47
  pid: Number.isFinite(pid) ? pid : undefined,
49
48
  remoteLog,
50
49
  remoteExit,
@@ -69,3 +68,40 @@ export async function dispatchToHost(host, opts) {
69
68
  });
70
69
  return { task: finished ?? task, exitCode };
71
70
  }
71
+ /** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
72
+ export async function dispatchToHost(host, opts) {
73
+ const target = sshTargetFor(host);
74
+ const { warnings } = ensureHostReady(host, { agent: opts.agent });
75
+ for (const w of warnings)
76
+ process.stderr.write(`[hosts] warning: ${w}\n`);
77
+ const forwardedArgs = ['run', opts.agent, opts.prompt, '--quiet'];
78
+ if (opts.mode)
79
+ forwardedArgs.push('--mode', opts.mode);
80
+ if (opts.model)
81
+ forwardedArgs.push('--model', opts.model);
82
+ return launchDetached(host, target, {
83
+ forwardedArgs,
84
+ remoteCwd: opts.remoteCwd,
85
+ follow: opts.follow,
86
+ timeoutMs: opts.timeoutMs,
87
+ agentLabel: opts.agent,
88
+ promptLabel: opts.prompt,
89
+ });
90
+ }
91
+ /**
92
+ * Dispatch an arbitrary long-running `agents <command>` onto a host detached —
93
+ * used for `teams start --watch --host`, whose supervisor must outlive the SSH
94
+ * connection. Reachability is assumed (the caller has already resolved the host);
95
+ * a launch failure surfaces the remote stderr.
96
+ */
97
+ export async function dispatchAgentsCommand(host, opts) {
98
+ const target = sshTargetFor(host);
99
+ return launchDetached(host, target, {
100
+ forwardedArgs: opts.forwardedArgs,
101
+ remoteCwd: opts.remoteCwd,
102
+ follow: opts.follow,
103
+ timeoutMs: opts.timeoutMs,
104
+ agentLabel: opts.forwardedArgs[0] ?? 'agents',
105
+ promptLabel: opts.forwardedArgs.join(' '),
106
+ });
107
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared `--host` option registrar. Every command that can run on a remote host
3
+ * declares the flag through here, so its spelling, help text, and companions
4
+ * (`--remote-cwd`, `--no-tty`, `--any`) stay identical everywhere and show up in
5
+ * each command's `--help`.
6
+ *
7
+ * The flags are consumed centrally by `maybeRunOnHost` (passthrough.ts) *before*
8
+ * commander parses, so for a real remote run the local action never sees them.
9
+ * Registering them here still matters: it documents the flag and keeps the local
10
+ * fall-through (e.g. `--host <this-machine>`) from erroring on an unknown option.
11
+ */
12
+ import type { Command } from 'commander';
13
+ /** Attach the standard `--host` flag family to a command and return it (chainable). */
14
+ export declare function addHostOption(cmd: Command): Command;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared `--host` option registrar. Every command that can run on a remote host
3
+ * declares the flag through here, so its spelling, help text, and companions
4
+ * (`--remote-cwd`, `--no-tty`, `--any`) stay identical everywhere and show up in
5
+ * each command's `--help`.
6
+ *
7
+ * The flags are consumed centrally by `maybeRunOnHost` (passthrough.ts) *before*
8
+ * commander parses, so for a real remote run the local action never sees them.
9
+ * Registering them here still matters: it documents the flag and keeps the local
10
+ * fall-through (e.g. `--host <this-machine>`) from erroring on an unknown option.
11
+ */
12
+ /** Attach the standard `--host` flag family to a command and return it (chainable). */
13
+ export function addHostOption(cmd) {
14
+ return cmd
15
+ .option('-H, --host <name>', 'Run this command on a registered host (or user@host) over SSH instead of locally. See `agents hosts`.')
16
+ .option('--remote-cwd <dir>', 'Working directory on the host for --host runs.')
17
+ .option('--no-tty', 'Force non-interactive output for --host runs even from a terminal.')
18
+ .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.');
19
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Generic `--host` passthrough — the single choke point that runs an allowlisted
3
+ * `agents <command>` on a remote host instead of locally, so read-only and
4
+ * config commands (`view`, `usage`, `cost`, `doctor`, `inspect`, `list`, `sync`)
5
+ * and the team lifecycle (`teams …`) all gain remote support with no per-command
6
+ * code. Called once from `index.ts` before commander parses; returns `true` when
7
+ * it handled the invocation (the local command must then NOT run).
8
+ *
9
+ * Transport is SSH (via `ssh-exec.ts`), never a daemon: SSH is the one hardened
10
+ * choke point already used everywhere, and it gives auth + encryption + host-key
11
+ * trust for free. Read-only commands stream synchronously (`sshStream`); the one
12
+ * long-running case — `teams start --watch` — dispatches detached so the remote
13
+ * supervisor outlives a dropped connection.
14
+ *
15
+ * `run` and `sessions` are deliberately absent from the table below: they own
16
+ * richer `--host` handling in their own command actions (detached run dispatch;
17
+ * multi-host session fan-out) and must fall through to it.
18
+ */
19
+ /** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
20
+ export declare function flagValue(args: string[], long: string, short?: string): string | undefined;
21
+ /**
22
+ * Route `agents <command> … --host <name>` to a remote if the command is
23
+ * host-routable and a `--host` was given. Returns `false` (run locally) when
24
+ * there is no `--host`, the command isn't in the table, or the target is this
25
+ * very machine.
26
+ *
27
+ * @param command the resolved subcommand name (`process.argv`'s first non-flag).
28
+ * @param allArgs `process.argv.slice(2)` — the command name followed by its args.
29
+ */
30
+ export declare function maybeRunOnHost(command: string, allArgs: string[]): Promise<boolean>;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Generic `--host` passthrough — the single choke point that runs an allowlisted
3
+ * `agents <command>` on a remote host instead of locally, so read-only and
4
+ * config commands (`view`, `usage`, `cost`, `doctor`, `inspect`, `list`, `sync`)
5
+ * and the team lifecycle (`teams …`) all gain remote support with no per-command
6
+ * code. Called once from `index.ts` before commander parses; returns `true` when
7
+ * it handled the invocation (the local command must then NOT run).
8
+ *
9
+ * Transport is SSH (via `ssh-exec.ts`), never a daemon: SSH is the one hardened
10
+ * choke point already used everywhere, and it gives auth + encryption + host-key
11
+ * trust for free. Read-only commands stream synchronously (`sshStream`); the one
12
+ * long-running case — `teams start --watch` — dispatches detached so the remote
13
+ * supervisor outlives a dropped connection.
14
+ *
15
+ * `run` and `sessions` are deliberately absent from the table below: they own
16
+ * richer `--host` handling in their own command actions (detached run dispatch;
17
+ * multi-host session fan-out) and must fall through to it.
18
+ */
19
+ import chalk from 'chalk';
20
+ import { assertValidSshTarget, sshStream } from '../ssh-exec.js';
21
+ import { resolveHost, resolveHostByCap } from './registry.js';
22
+ import { sshTargetFor } from './types.js';
23
+ import { dispatchAgentsCommand } from './dispatch.js';
24
+ import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
25
+ import { machineId } from '../session/sync/config.js';
26
+ const REMOTE_PASSTHROUGH = {
27
+ view: {},
28
+ usage: {},
29
+ cost: {},
30
+ doctor: {},
31
+ inspect: {},
32
+ list: {},
33
+ sync: { nonInteractive: ['--yes'] },
34
+ teams: {},
35
+ };
36
+ /** `--no-tty` is stripped like the routing flags but carries no value. */
37
+ const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
38
+ /** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
39
+ export function flagValue(args, long, short) {
40
+ for (let i = 0; i < args.length; i++) {
41
+ const a = args[i];
42
+ if (a === `--${long}` || (short && a === `-${short}`))
43
+ return args[i + 1];
44
+ if (a.startsWith(`--${long}=`))
45
+ return a.slice(long.length + 3);
46
+ if (short && a.startsWith(`-${short}=`))
47
+ return a.slice(short.length + 2);
48
+ if (short && new RegExp(`^-${short}(.+)`).test(a))
49
+ return a.slice(2);
50
+ }
51
+ return undefined;
52
+ }
53
+ /** Synthesize a `Host` for a raw `user@host` / bare-alias target (not enrolled). */
54
+ function syntheticHost(target) {
55
+ const at = target.indexOf('@');
56
+ if (at !== -1) {
57
+ return { name: target, provider: 'local', source: 'inline', user: target.slice(0, at), address: target.slice(at + 1) };
58
+ }
59
+ // Bare name: ssh resolves it from ~/.ssh/config, or connects to it as a hostname.
60
+ return { name: target, provider: 'local', source: 'ssh-config' };
61
+ }
62
+ /** Resolve a `--host` value to a Host: enrolled name → capability tag → raw target. */
63
+ async function resolveTargetHost(name, any) {
64
+ const enrolled = await resolveHost(name);
65
+ if (enrolled)
66
+ return enrolled;
67
+ try {
68
+ return await resolveHostByCap(name, any);
69
+ }
70
+ catch (e) {
71
+ // "Multiple hosts tagged …" is actionable — surface it. "No host tagged" falls
72
+ // through to treating the value as a literal ssh target.
73
+ if (e instanceof Error && e.message.startsWith('Multiple hosts'))
74
+ throw e;
75
+ }
76
+ assertValidSshTarget(name); // rejects injection / flag-smuggling before it reaches ssh
77
+ return syntheticHost(name);
78
+ }
79
+ /**
80
+ * Route `agents <command> … --host <name>` to a remote if the command is
81
+ * host-routable and a `--host` was given. Returns `false` (run locally) when
82
+ * there is no `--host`, the command isn't in the table, or the target is this
83
+ * very machine.
84
+ *
85
+ * @param command the resolved subcommand name (`process.argv`'s first non-flag).
86
+ * @param allArgs `process.argv.slice(2)` — the command name followed by its args.
87
+ */
88
+ export async function maybeRunOnHost(command, allArgs) {
89
+ const spec = REMOTE_PASSTHROUGH[command];
90
+ if (!spec)
91
+ return false;
92
+ const hostName = flagValue(allArgs, 'host', 'H');
93
+ if (!hostName)
94
+ return false;
95
+ // Running against your own machine is just a local run — skip the SSH round-trip.
96
+ // `machineId()` is the same self-identifier the device registry and session
97
+ // sync use (lowercased short hostname); compare case-insensitively.
98
+ if (hostName.toLowerCase() === machineId())
99
+ return false;
100
+ const remoteCwd = flagValue(allArgs, 'remote-cwd');
101
+ const any = allArgs.includes('--any');
102
+ let host;
103
+ try {
104
+ host = await resolveTargetHost(hostName, any);
105
+ }
106
+ catch (e) {
107
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
108
+ process.exitCode = 1;
109
+ return true;
110
+ }
111
+ const target = sshTargetFor(host);
112
+ // Interactive only when our own stdout is a terminal and the caller didn't opt
113
+ // out — otherwise force the command's non-interactive path so no half-drawn
114
+ // picker is piped into a file or another program.
115
+ const interactive = !!process.stdout.isTTY && !allArgs.includes('--no-tty');
116
+ let forwarded = stripRoutingFlags(allArgs, STRIP_SPECS);
117
+ if (!interactive && spec.nonInteractive)
118
+ forwarded = [...forwarded, ...spec.nonInteractive];
119
+ // The one long-running case: keep the remote team supervisor alive past a
120
+ // disconnect by dispatching it detached (nohup), still streaming live.
121
+ const isWatchedTeamStart = command === 'teams' && forwarded[1] === 'start' && forwarded.includes('--watch');
122
+ if (isWatchedTeamStart) {
123
+ try {
124
+ const { exitCode } = await dispatchAgentsCommand(host, { forwardedArgs: forwarded, remoteCwd });
125
+ process.exitCode = exitCode && exitCode > 0 ? exitCode : 0;
126
+ }
127
+ catch (e) {
128
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
129
+ process.exitCode = 1;
130
+ }
131
+ return true;
132
+ }
133
+ const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd);
134
+ const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
135
+ if (code === 255) {
136
+ console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
137
+ chalk.gray(' Check: agents hosts check ' + host.name));
138
+ }
139
+ process.exitCode = code;
140
+ return true;
141
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Pure argv helpers for `--host` passthrough — build the remote `agents …`
3
+ * invocation and strip the local-only routing flags before forwarding.
4
+ *
5
+ * Kept free of any SSH/process side effects so the two-layer quoting and the
6
+ * flag-stripping edge cases (glued short forms, `=value`, variadic) are unit
7
+ * testable without a live host. The transport itself lives in `ssh-exec.ts`
8
+ * (`sshExec`/`sshStream`); orchestration lives in `passthrough.ts`.
9
+ */
10
+ /** A flag to strip from a forwarded argv, with whether it consumes a value. */
11
+ export interface StripSpec {
12
+ /** Long form without leading dashes, e.g. `host`, `remote-cwd`. */
13
+ long: string;
14
+ /** Optional single-letter short form without the dash, e.g. `H`. */
15
+ short?: string;
16
+ /** True when the flag takes a following value token (`--host <name>`). */
17
+ takesValue: boolean;
18
+ }
19
+ /**
20
+ * Remove routing flags (and their values) from a command's args, leaving the
21
+ * rest untouched and in order so they forward verbatim to the remote binary.
22
+ * Handles every form commander accepts: `--host h`, `--host=h`, `-H h`, `-H=h`,
23
+ * and the glued short form `-Hh`.
24
+ *
25
+ * @param args the command's args (already past the command name).
26
+ */
27
+ export declare function stripRoutingFlags(args: string[], specs: StripSpec[]): string[];
28
+ /** The routing flags every `--host`-capable command shares. */
29
+ export declare const HOST_ROUTING_SPECS: StripSpec[];
30
+ /**
31
+ * Build the single command string for `ssh <target> <cmd>`. The forwarded args
32
+ * are quoted for the inner login shell, then the whole `agents …` invocation is
33
+ * quoted again so it survives `bash -lc <...>` — `bash -lc` so the remote login
34
+ * PATH resolves `agents`. An optional `cd` runs first for `--remote-cwd`.
35
+ */
36
+ export declare function buildRemoteAgentsInvocation(forwardedArgs: string[], remoteCwd?: string): string;