@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.
- package/CHANGELOG.md +15 -0
- package/dist/commands/commands.js +3 -3
- package/dist/commands/computer-actions.js +1 -0
- package/dist/commands/cost.js +2 -2
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/exec.js +56 -1
- package/dist/commands/hooks.js +3 -3
- package/dist/commands/inspect.js +13 -17
- package/dist/commands/mcp.js +3 -3
- package/dist/commands/permissions.js +3 -3
- package/dist/commands/rules.js +2 -2
- package/dist/commands/sessions.js +18 -1
- package/dist/commands/skills.js +3 -3
- package/dist/commands/ssh.js +23 -0
- package/dist/commands/sync.js +2 -2
- package/dist/commands/teams.js +7 -12
- package/dist/commands/usage.js +2 -2
- package/dist/commands/utils.d.ts +8 -0
- package/dist/commands/utils.js +20 -0
- package/dist/commands/versions.js +2 -2
- package/dist/commands/view.js +33 -9
- package/dist/commands/workflows.js +3 -3
- package/dist/index.js +12 -0
- package/dist/lib/agent-spec/index.d.ts +18 -0
- package/dist/lib/agent-spec/index.js +35 -0
- package/dist/lib/agent-spec/primitives.d.ts +28 -0
- package/dist/lib/agent-spec/primitives.js +57 -0
- package/dist/lib/agent-spec/provider.d.ts +2 -0
- package/dist/lib/agent-spec/provider.js +9 -0
- package/dist/lib/agent-spec/resolve.d.ts +33 -0
- package/dist/lib/agent-spec/resolve.js +174 -0
- package/dist/lib/agent-spec/types.d.ts +57 -0
- package/dist/lib/agent-spec/types.js +18 -0
- package/dist/lib/crabbox/cli.d.ts +98 -0
- package/dist/lib/crabbox/cli.js +218 -0
- package/dist/lib/crabbox/lease.d.ts +41 -0
- package/dist/lib/crabbox/lease.js +73 -0
- package/dist/lib/crabbox/runtimes.d.ts +57 -0
- package/dist/lib/crabbox/runtimes.js +109 -0
- package/dist/lib/daemon.js +32 -0
- package/dist/lib/devices/pending.d.ts +18 -0
- package/dist/lib/devices/pending.js +103 -0
- package/dist/lib/devices/sync.d.ts +21 -2
- package/dist/lib/devices/sync.js +26 -10
- package/dist/lib/hosts/dispatch.d.ts +27 -10
- package/dist/lib/hosts/dispatch.js +55 -19
- package/dist/lib/hosts/option.d.ts +14 -0
- package/dist/lib/hosts/option.js +19 -0
- package/dist/lib/hosts/passthrough.d.ts +30 -0
- package/dist/lib/hosts/passthrough.js +141 -0
- package/dist/lib/hosts/remote-cmd.d.ts +36 -0
- package/dist/lib/hosts/remote-cmd.js +56 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/secrets/bundles.js +29 -20
- package/dist/lib/secrets/index.d.ts +11 -0
- package/dist/lib/secrets/index.js +18 -1
- package/dist/lib/secrets/linux.d.ts +14 -0
- package/dist/lib/secrets/linux.js +21 -0
- package/dist/lib/session/active.d.ts +8 -0
- package/dist/lib/session/active.js +18 -1
- package/dist/lib/session/provenance.d.ts +56 -0
- package/dist/lib/session/provenance.js +157 -0
- package/dist/lib/ssh-exec.d.ts +22 -0
- package/dist/lib/ssh-exec.js +59 -2
- package/dist/lib/ssh-tunnel.d.ts +0 -5
- package/dist/lib/ssh-tunnel.js +65 -8
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/sync-umbrella.js +10 -6
- package/dist/lib/versions.d.ts +13 -4
- package/dist/lib/versions.js +27 -20
- package/package.json +2 -1
- package/dist/lib/agent-spec.d.ts +0 -36
- 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
|
-
/**
|
|
8
|
-
pending:
|
|
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.
|
package/dist/lib/devices/sync.js
CHANGED
|
@@ -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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
2
|
+
* Dispatch a headless `agents …` command onto a host over SSH.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
* log and its exit code to a sibling `.exit` file, so progress survives a
|
|
6
|
-
* connection (followed via offset-tail in progress.ts). This is the
|
|
7
|
-
* the
|
|
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
|
-
|
|
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
|
|
2
|
+
* Dispatch a headless `agents …` command onto a host over SSH.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
* log and its exit code to a sibling `.exit` file, so progress survives a
|
|
6
|
-
* connection (followed via offset-tail in progress.ts). This is the
|
|
7
|
-
* the
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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}${
|
|
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.
|
|
47
|
-
prompt: opts.
|
|
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;
|