@phnx-labs/agents-cli 1.20.28 → 1.20.30

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 (63) hide show
  1. package/dist/commands/computer-actions.js +6 -2
  2. package/dist/commands/computer.d.ts +12 -0
  3. package/dist/commands/computer.js +88 -13
  4. package/dist/commands/exec.js +22 -10
  5. package/dist/commands/inspect.js +1 -1
  6. package/dist/commands/models.js +8 -2
  7. package/dist/commands/secrets.js +93 -6
  8. package/dist/commands/sessions.js +157 -44
  9. package/dist/commands/ssh.d.ts +14 -0
  10. package/dist/commands/ssh.js +263 -0
  11. package/dist/commands/sync.js +70 -14
  12. package/dist/index.js +2 -1
  13. package/dist/lib/agents.d.ts +0 -4
  14. package/dist/lib/agents.js +54 -5
  15. package/dist/lib/browser/drivers/ssh.js +4 -35
  16. package/dist/lib/computer-rpc.d.ts +6 -1
  17. package/dist/lib/computer-rpc.js +86 -3
  18. package/dist/lib/devices/connect.d.ts +34 -0
  19. package/dist/lib/devices/connect.js +101 -0
  20. package/dist/lib/devices/registry.d.ts +78 -0
  21. package/dist/lib/devices/registry.js +168 -0
  22. package/dist/lib/devices/ssh-config.d.ts +21 -0
  23. package/dist/lib/devices/ssh-config.js +33 -0
  24. package/dist/lib/devices/tailscale.d.ts +31 -0
  25. package/dist/lib/devices/tailscale.js +126 -0
  26. package/dist/lib/exec.js +14 -0
  27. package/dist/lib/models.js +138 -5
  28. package/dist/lib/runner.js +7 -7
  29. package/dist/lib/secrets/remote.d.ts +67 -0
  30. package/dist/lib/secrets/remote.js +133 -0
  31. package/dist/lib/session/active.d.ts +13 -0
  32. package/dist/lib/session/active.js +79 -18
  33. package/dist/lib/session/cloud.js +2 -0
  34. package/dist/lib/session/db.d.ts +12 -0
  35. package/dist/lib/session/db.js +66 -9
  36. package/dist/lib/session/discover.d.ts +7 -0
  37. package/dist/lib/session/discover.js +309 -0
  38. package/dist/lib/session/parse.d.ts +22 -0
  39. package/dist/lib/session/parse.js +132 -2
  40. package/dist/lib/session/remote.d.ts +1 -1
  41. package/dist/lib/session/remote.js +8 -3
  42. package/dist/lib/session/state.d.ts +82 -0
  43. package/dist/lib/session/state.js +221 -0
  44. package/dist/lib/session/tail.d.ts +18 -0
  45. package/dist/lib/session/tail.js +57 -0
  46. package/dist/lib/session/types.d.ts +10 -1
  47. package/dist/lib/session/types.js +1 -1
  48. package/dist/lib/session/width.d.ts +29 -0
  49. package/dist/lib/session/width.js +91 -0
  50. package/dist/lib/shims.d.ts +17 -1
  51. package/dist/lib/shims.js +130 -6
  52. package/dist/lib/ssh-tunnel.d.ts +127 -0
  53. package/dist/lib/ssh-tunnel.js +346 -0
  54. package/dist/lib/startup/command-registry.d.ts +1 -0
  55. package/dist/lib/startup/command-registry.js +3 -0
  56. package/dist/lib/state.d.ts +4 -0
  57. package/dist/lib/state.js +19 -1
  58. package/dist/lib/teams/agents.d.ts +11 -1
  59. package/dist/lib/teams/agents.js +16 -2
  60. package/dist/lib/types.d.ts +1 -0
  61. package/dist/lib/versions.d.ts +19 -0
  62. package/dist/lib/versions.js +84 -24
  63. package/package.json +1 -1
@@ -0,0 +1,78 @@
1
+ /** Operating-system family of a device, used to pick the remote shell. */
2
+ export type DevicePlatform = 'windows' | 'linux' | 'macos' | 'unknown';
3
+ /** Remote shell dialect derived from the platform. */
4
+ export type DeviceShell = 'powershell' | 'posix';
5
+ /** How `agents ssh` authenticates to a device. Both are first-class, fully
6
+ * non-interactive: `key` uses the ssh agent / on-disk keys, `password` pulls
7
+ * the secret from a Keychain-backed secrets bundle via an askpass shim. */
8
+ export type DeviceAuthMethod = 'key' | 'password';
9
+ /** How to reach a device on the network. */
10
+ export interface DeviceAddress {
11
+ /** Where the address came from: a Tailscale node, or a manual entry. */
12
+ via: 'tailscale' | 'manual';
13
+ /** Fully-qualified DNS name (Tailscale MagicDNS), without a trailing dot. */
14
+ dnsName?: string;
15
+ /** Raw IP address (IPv4 preferred). */
16
+ ip?: string;
17
+ }
18
+ /** Authentication settings for a device. */
19
+ export interface DeviceAuth {
20
+ method: DeviceAuthMethod;
21
+ /** Secrets bundle holding the password (when method === 'password'). */
22
+ bundle?: string;
23
+ /** Key within the bundle whose value is the password. Defaults to 'password'. */
24
+ bundleKey?: string;
25
+ }
26
+ /** Last-known Tailscale reachability snapshot for a device. */
27
+ export interface DeviceTailscale {
28
+ online: boolean;
29
+ /** True when the last handshake was a direct (non-relayed) connection. */
30
+ direct: boolean;
31
+ /** DERP relay region code (e.g. 'sfo'); empty when direct. */
32
+ relay?: string;
33
+ lastSeen?: string;
34
+ }
35
+ /** A single registered device. */
36
+ export interface DeviceProfile {
37
+ name: string;
38
+ platform: DevicePlatform;
39
+ shell: DeviceShell;
40
+ user?: string;
41
+ address: DeviceAddress;
42
+ auth: DeviceAuth;
43
+ tailscale?: DeviceTailscale;
44
+ createdAt: string;
45
+ updatedAt: string;
46
+ }
47
+ /** Map of device name to profile. */
48
+ export type DeviceRegistry = Record<string, DeviceProfile>;
49
+ /** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
50
+ export declare function assertValidDeviceName(name: string): void;
51
+ /** Map a Tailscale `OS` field to our platform enum. */
52
+ export declare function platformFromOs(os: string | undefined): DevicePlatform;
53
+ /** The remote shell a platform speaks. */
54
+ export declare function shellForPlatform(platform: DevicePlatform): DeviceShell;
55
+ /**
56
+ * Load all devices from the registry file. Returns an empty object only when
57
+ * the file does not exist. A malformed file is a hard error — silently
58
+ * returning {} would let the next write wipe the user's device list.
59
+ */
60
+ export declare function loadDevices(): Promise<DeviceRegistry>;
61
+ /** Get a single device profile, or null if it is not registered. */
62
+ export declare function getDevice(name: string): Promise<DeviceProfile | null>;
63
+ /** Fields a caller may supply when creating or updating a device. */
64
+ export interface DeviceInput {
65
+ platform?: DevicePlatform;
66
+ user?: string;
67
+ address?: DeviceAddress;
68
+ auth?: DeviceAuth;
69
+ tailscale?: DeviceTailscale;
70
+ }
71
+ /**
72
+ * Create the device if absent, otherwise merge the supplied fields into the
73
+ * existing profile. `shell` is always re-derived from the (possibly new)
74
+ * platform so the two can never drift. Returns the resulting profile.
75
+ */
76
+ export declare function upsertDevice(name: string, input: DeviceInput): Promise<DeviceProfile>;
77
+ /** Remove a device. Returns false if it was not registered. */
78
+ export declare function removeDevice(name: string): Promise<boolean>;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Device registry.
3
+ *
4
+ * Manages the persistent registry of SSH device profiles stored at
5
+ * ~/.agents/.history/devices/registry.json. Each profile records what we had
6
+ * to re-derive by hand the first time we reached a host: its platform (so we
7
+ * know PowerShell vs POSIX), the login user, how to address it (Tailscale
8
+ * DNS name / IP), and how to authenticate (pubkey, or a password pulled from
9
+ * a secrets bundle).
10
+ *
11
+ * Like the team registry this is per-machine runtime state (it embeds a host
12
+ * list + addresses) and lives under .history/ so it is NOT pulled in by
13
+ * `agents repo push`. The load/save/lock plumbing is a deliberate clone of
14
+ * src/lib/teams/registry.ts so the data-loss guarantees match exactly.
15
+ */
16
+ import * as fs from 'fs/promises';
17
+ import * as fsSync from 'fs';
18
+ import * as path from 'path';
19
+ import { randomBytes } from 'crypto';
20
+ import lockfile from 'proper-lockfile';
21
+ import { getDevicesRegistryPath } from '../state.js';
22
+ function registryPath() {
23
+ return getDevicesRegistryPath();
24
+ }
25
+ /** Valid logical device name: the ssh-alias charset, so it renders into an
26
+ * unambiguous `Host` stanza and is safe as an ssh target. */
27
+ const DEVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
28
+ /** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
29
+ export function assertValidDeviceName(name) {
30
+ if (!DEVICE_NAME_RE.test(name)) {
31
+ throw new Error(`Invalid device name ${JSON.stringify(name)}. Use letters, digits, '.', '_', '-' (no spaces) — e.g. 'win-mini'.`);
32
+ }
33
+ }
34
+ /** Map a Tailscale `OS` field to our platform enum. */
35
+ export function platformFromOs(os) {
36
+ switch ((os ?? '').toLowerCase()) {
37
+ case 'windows':
38
+ return 'windows';
39
+ case 'linux':
40
+ return 'linux';
41
+ case 'macos':
42
+ case 'darwin':
43
+ return 'macos';
44
+ default:
45
+ return 'unknown';
46
+ }
47
+ }
48
+ /** The remote shell a platform speaks. */
49
+ export function shellForPlatform(platform) {
50
+ return platform === 'windows' ? 'powershell' : 'posix';
51
+ }
52
+ /**
53
+ * Atomic JSON write: write to a unique sibling tmp file then rename over the
54
+ * target. rename(2) is atomic on POSIX, so a crashed write leaves the old file
55
+ * untouched instead of producing a half-written registry that loadDevices()
56
+ * would reject.
57
+ */
58
+ async function atomicWriteJson(p, data) {
59
+ await fs.mkdir(path.dirname(p), { recursive: true });
60
+ const tmp = `${p}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
61
+ await fs.writeFile(tmp, JSON.stringify(data, null, 2));
62
+ try {
63
+ await fs.rename(tmp, p);
64
+ }
65
+ catch (err) {
66
+ await fs.unlink(tmp).catch(() => { });
67
+ throw err;
68
+ }
69
+ }
70
+ /**
71
+ * Run `fn` while holding an exclusive cross-process lock on the registry file.
72
+ * proper-lockfile requires the target to exist, so we touch it first. Stale
73
+ * locks (from crashed callers) auto-expire after `stale` ms.
74
+ */
75
+ async function withRegistryLock(p, fn) {
76
+ await fs.mkdir(path.dirname(p), { recursive: true });
77
+ if (!fsSync.existsSync(p)) {
78
+ try {
79
+ await fs.writeFile(p, '{}', { flag: 'wx' });
80
+ }
81
+ catch (err) {
82
+ if (err && err.code !== 'EEXIST')
83
+ throw err;
84
+ }
85
+ }
86
+ const release = await lockfile.lock(p, {
87
+ retries: { retries: 60, minTimeout: 25, maxTimeout: 250, factor: 1.5 },
88
+ stale: 10_000,
89
+ });
90
+ try {
91
+ return await fn();
92
+ }
93
+ finally {
94
+ await release();
95
+ }
96
+ }
97
+ /**
98
+ * Load all devices from the registry file. Returns an empty object only when
99
+ * the file does not exist. A malformed file is a hard error — silently
100
+ * returning {} would let the next write wipe the user's device list.
101
+ */
102
+ export async function loadDevices() {
103
+ const p = registryPath();
104
+ let raw;
105
+ try {
106
+ raw = await fs.readFile(p, 'utf-8');
107
+ }
108
+ catch (err) {
109
+ if (err && err.code === 'ENOENT')
110
+ return {};
111
+ throw err;
112
+ }
113
+ try {
114
+ return JSON.parse(raw);
115
+ }
116
+ catch (err) {
117
+ throw new Error(`Device registry corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
118
+ }
119
+ }
120
+ async function saveDevices(reg) {
121
+ await atomicWriteJson(registryPath(), reg);
122
+ }
123
+ /** Get a single device profile, or null if it is not registered. */
124
+ export async function getDevice(name) {
125
+ const reg = await loadDevices();
126
+ return reg[name] ?? null;
127
+ }
128
+ /**
129
+ * Create the device if absent, otherwise merge the supplied fields into the
130
+ * existing profile. `shell` is always re-derived from the (possibly new)
131
+ * platform so the two can never drift. Returns the resulting profile.
132
+ */
133
+ export async function upsertDevice(name, input) {
134
+ assertValidDeviceName(name);
135
+ const p = registryPath();
136
+ return withRegistryLock(p, async () => {
137
+ const reg = await loadDevices();
138
+ const now = new Date().toISOString();
139
+ const prev = reg[name];
140
+ const platform = input.platform ?? prev?.platform ?? 'unknown';
141
+ const merged = {
142
+ name,
143
+ platform,
144
+ shell: shellForPlatform(platform),
145
+ user: input.user ?? prev?.user,
146
+ address: input.address ?? prev?.address ?? { via: 'manual' },
147
+ auth: input.auth ?? prev?.auth ?? { method: 'key' },
148
+ tailscale: input.tailscale ?? prev?.tailscale,
149
+ createdAt: prev?.createdAt ?? now,
150
+ updatedAt: now,
151
+ };
152
+ reg[name] = merged;
153
+ await saveDevices(reg);
154
+ return merged;
155
+ });
156
+ }
157
+ /** Remove a device. Returns false if it was not registered. */
158
+ export async function removeDevice(name) {
159
+ const p = registryPath();
160
+ return withRegistryLock(p, async () => {
161
+ const reg = await loadDevices();
162
+ if (!reg[name])
163
+ return false;
164
+ delete reg[name];
165
+ await saveDevices(reg);
166
+ return true;
167
+ });
168
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Render the device registry into an OpenSSH `ssh_config` include block.
3
+ *
4
+ * Writing a managed include (e.g. `~/.ssh/config.d/agents`) makes every tool
5
+ * that speaks ssh — plain `ssh`/`scp`/`rsync`/`git`, and `agents sessions
6
+ * --host` — resolve the registry's logical device names transparently, without
7
+ * each of them learning about the registry. `agents ssh` stays the value-add
8
+ * layer (preflight, password-from-bundle auth, platform-aware exec) on top.
9
+ *
10
+ * `renderSshConfig` is a pure function (registry in, config text out) so the
11
+ * exact rendering is unit-testable.
12
+ */
13
+ import { type DeviceProfile, type DeviceRegistry } from './registry.js';
14
+ /** The HostName an ssh client should dial for a device: DNS name first, then IP. */
15
+ export declare function hostNameFor(device: DeviceProfile): string | undefined;
16
+ /**
17
+ * Render the whole registry into ssh_config text. Devices are emitted in
18
+ * stable alphabetical order (so the file does not churn between runs) and
19
+ * addressless devices are skipped.
20
+ */
21
+ export declare function renderSshConfig(reg: DeviceRegistry): string;
@@ -0,0 +1,33 @@
1
+ const HEADER = [
2
+ '# Managed by `agents devices` — do not edit by hand.',
3
+ '# Regenerate with: agents devices render',
4
+ '# Include from ~/.ssh/config with: Include config.d/agents',
5
+ ].join('\n');
6
+ /** The HostName an ssh client should dial for a device: DNS name first, then IP. */
7
+ export function hostNameFor(device) {
8
+ return device.address.dnsName ?? device.address.ip;
9
+ }
10
+ /** Render a single device into an ssh_config `Host` stanza, or null if it has no address. */
11
+ function renderHost(device) {
12
+ const hostName = hostNameFor(device);
13
+ if (!hostName)
14
+ return null;
15
+ const lines = [`Host ${device.name}`, ` HostName ${hostName}`];
16
+ if (device.user)
17
+ lines.push(` User ${device.user}`);
18
+ return lines.join('\n');
19
+ }
20
+ /**
21
+ * Render the whole registry into ssh_config text. Devices are emitted in
22
+ * stable alphabetical order (so the file does not churn between runs) and
23
+ * addressless devices are skipped.
24
+ */
25
+ export function renderSshConfig(reg) {
26
+ const stanzas = [];
27
+ for (const name of Object.keys(reg).sort()) {
28
+ const stanza = renderHost(reg[name]);
29
+ if (stanza)
30
+ stanzas.push(stanza);
31
+ }
32
+ return [HEADER, '', ...stanzas, ''].join('\n');
33
+ }
@@ -0,0 +1,31 @@
1
+ import { type DeviceInput, type DevicePlatform } from './registry.js';
2
+ /** A single node distilled from `tailscale status --json`. */
3
+ export interface TailscaleNode {
4
+ name: string;
5
+ platform: DevicePlatform;
6
+ dnsName?: string;
7
+ ip?: string;
8
+ online: boolean;
9
+ direct: boolean;
10
+ relay?: string;
11
+ lastSeen?: string;
12
+ }
13
+ /**
14
+ * Slugify a raw Tailscale HostName into a valid logical device name (ssh alias
15
+ * charset). Used only as a fallback — when a node has a DNSName we prefer its
16
+ * first label, which is the canonical slug Tailscale itself derived.
17
+ */
18
+ export declare function slugifyHostName(hostName: string): string;
19
+ /**
20
+ * Parse `tailscale status --json` output into one node per tailnet device,
21
+ * including Self. Throws on malformed JSON. Nodes without a HostName are
22
+ * skipped (they cannot be addressed by a logical name).
23
+ */
24
+ export declare function parseTailscaleStatus(json: string): TailscaleNode[];
25
+ /** Turn a parsed Tailscale node into the registry fields it can populate. */
26
+ export declare function nodeToDeviceInput(node: TailscaleNode): DeviceInput;
27
+ /**
28
+ * Run `tailscale status --json` and return its raw stdout. Throws a clear
29
+ * error when the binary is missing or the daemon is not reachable.
30
+ */
31
+ export declare function tailscaleStatusJson(): string;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Tailscale ingestion for the device registry.
3
+ *
4
+ * `tailscale status --json` already hands us most of a device registry for
5
+ * free — per node: `OS` (→ platform), `Online`/`LastSeen` (→ reachability),
6
+ * `Relay` vs `CurAddr` (→ direct-vs-relayed latency hint), `DNSName`, and
7
+ * `TailscaleIPs`. `parseTailscaleStatus` turns that JSON into draft device
8
+ * profiles so `agents devices sync` can self-populate instead of you
9
+ * hand-entering hosts. Kept a pure function (JSON in, profiles out) so it is
10
+ * unit-testable without a live tailnet.
11
+ */
12
+ import { spawnSync } from 'child_process';
13
+ import { platformFromOs, } from './registry.js';
14
+ /** Strip MagicDNS's trailing dot so the name is usable as an ssh HostName. */
15
+ function trimDnsDot(dns) {
16
+ if (!dns)
17
+ return undefined;
18
+ return dns.endsWith('.') ? dns.slice(0, -1) : dns;
19
+ }
20
+ /** First IPv4 in the node's address list (preferred over IPv6 for ssh). */
21
+ function firstIpv4(ips) {
22
+ if (!ips)
23
+ return undefined;
24
+ return ips.find((ip) => /^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) ?? ips[0];
25
+ }
26
+ /**
27
+ * Slugify a raw Tailscale HostName into a valid logical device name (ssh alias
28
+ * charset). Used only as a fallback — when a node has a DNSName we prefer its
29
+ * first label, which is the canonical slug Tailscale itself derived.
30
+ */
31
+ export function slugifyHostName(hostName) {
32
+ return hostName
33
+ .toLowerCase()
34
+ .replace(/['’"]/g, '') // drop quotes/apostrophes (so "Bisma's" → "bismas", matching MagicDNS)
35
+ .replace(/[^a-z0-9._-]+/g, '-')
36
+ .replace(/^-+|-+$/g, '');
37
+ }
38
+ /**
39
+ * The logical name for a node. macOS computer names contain spaces and
40
+ * apostrophes ("Bisma's MacBook Pro") and iOS devices all report HostName
41
+ * "localhost" — both break as ssh aliases and the latter collides in the
42
+ * registry. The MagicDNS label (first segment of DNSName) is already a unique,
43
+ * valid slug per device, so prefer it; fall back to a slugified HostName.
44
+ */
45
+ function deviceNameFor(raw, dnsName) {
46
+ const label = dnsName?.split('.')[0];
47
+ if (label && label.length > 0)
48
+ return label;
49
+ const host = raw.HostName?.trim();
50
+ if (!host)
51
+ return null;
52
+ const slug = slugifyHostName(host);
53
+ return slug.length > 0 ? slug : null;
54
+ }
55
+ function toNode(raw) {
56
+ const dnsName = trimDnsDot(raw.DNSName);
57
+ const name = deviceNameFor(raw, dnsName);
58
+ if (!name)
59
+ return null;
60
+ // A non-empty CurAddr means the last handshake was a direct connection;
61
+ // an empty CurAddr with a Relay means traffic is going through DERP.
62
+ const direct = Boolean(raw.CurAddr && raw.CurAddr.length > 0);
63
+ return {
64
+ name,
65
+ platform: platformFromOs(raw.OS),
66
+ dnsName,
67
+ ip: firstIpv4(raw.TailscaleIPs),
68
+ online: Boolean(raw.Online),
69
+ direct,
70
+ relay: raw.Relay || undefined,
71
+ lastSeen: raw.LastSeen,
72
+ };
73
+ }
74
+ /**
75
+ * Parse `tailscale status --json` output into one node per tailnet device,
76
+ * including Self. Throws on malformed JSON. Nodes without a HostName are
77
+ * skipped (they cannot be addressed by a logical name).
78
+ */
79
+ export function parseTailscaleStatus(json) {
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(json);
83
+ }
84
+ catch (err) {
85
+ throw new Error(`Could not parse tailscale status JSON: ${err?.message ?? err}`);
86
+ }
87
+ const out = [];
88
+ if (parsed.Self) {
89
+ const self = toNode(parsed.Self);
90
+ if (self)
91
+ out.push(self);
92
+ }
93
+ for (const raw of Object.values(parsed.Peer ?? {})) {
94
+ const node = toNode(raw);
95
+ if (node)
96
+ out.push(node);
97
+ }
98
+ return out;
99
+ }
100
+ /** Turn a parsed Tailscale node into the registry fields it can populate. */
101
+ export function nodeToDeviceInput(node) {
102
+ return {
103
+ platform: node.platform,
104
+ address: { via: 'tailscale', dnsName: node.dnsName, ip: node.ip },
105
+ tailscale: {
106
+ online: node.online,
107
+ direct: node.direct,
108
+ relay: node.relay,
109
+ lastSeen: node.lastSeen,
110
+ },
111
+ };
112
+ }
113
+ /**
114
+ * Run `tailscale status --json` and return its raw stdout. Throws a clear
115
+ * error when the binary is missing or the daemon is not reachable.
116
+ */
117
+ export function tailscaleStatusJson() {
118
+ const res = spawnSync('tailscale', ['status', '--json'], { encoding: 'utf-8' });
119
+ if (res.error && res.error.code === 'ENOENT') {
120
+ throw new Error('tailscale not found on PATH. Install Tailscale, or add devices manually with `agents devices add`.');
121
+ }
122
+ if (res.status !== 0) {
123
+ throw new Error(`tailscale status failed: ${(res.stderr || res.stdout || '').trim() || `exit ${res.status}`}`);
124
+ }
125
+ return res.stdout ?? '';
126
+ }
package/dist/lib/exec.js CHANGED
@@ -534,6 +534,20 @@ export function buildExecCommand(options) {
534
534
  cmd.push('--dangerously-bypass-approvals-and-sandbox');
535
535
  }
536
536
  }
537
+ else if (options.agent === 'kimi' && !interactive) {
538
+ // kimi's headless prompt mode (`-p`/`--prompt`) is self-contained and REFUSES
539
+ // to be combined with any startup-mode flag: `--plan`, `--auto`, and `--yolo`
540
+ // all abort with "Cannot combine --prompt with --X" (verified against the live
541
+ // kimi CLI). The write-capable modes (edit/auto/skip) all collapse to kimi's
542
+ // default `-p` behavior, which already auto-approves tool calls, so we emit no
543
+ // mode flag. Plan (read-only) has no headless equivalent, so fail closed rather
544
+ // than silently letting a plan-mode run mutate the workspace.
545
+ if (resolvedMode === 'plan') {
546
+ throw new Error('kimi has no headless read-only mode: `--prompt` cannot be combined with `--plan`. ' +
547
+ 'Run kimi in plan mode interactively (omit the prompt), or use --mode edit, auto, or skip.');
548
+ }
549
+ // edit/auto/skip: emit no mode flag — `kimi -p` auto-runs.
550
+ }
537
551
  else {
538
552
  cmd.push(...modeFlags);
539
553
  }
@@ -165,6 +165,26 @@ export function locateModelSource(agent, version) {
165
165
  return { path: pathBin, kind: 'cli' };
166
166
  return null;
167
167
  }
168
+ if (agent === 'antigravity') {
169
+ // The `agy` shim under node_modules/.bin exposes `agy models`. We don't parse
170
+ // any bundle; the CLI produces its own (display-name-only) catalog.
171
+ const cli = path.join(versionDir, 'node_modules', '.bin', 'agy');
172
+ if (fs.existsSync(cli))
173
+ return { path: cli, kind: 'cli' };
174
+ const pathBin = findOnPath('agy');
175
+ if (pathBin)
176
+ return { path: pathBin, kind: 'cli' };
177
+ return null;
178
+ }
179
+ if (agent === 'kimi') {
180
+ const cli = path.join(versionDir, 'node_modules', '.bin', 'kimi');
181
+ if (fs.existsSync(cli))
182
+ return { path: cli, kind: 'cli' };
183
+ const pathBin = findOnPath('kimi');
184
+ if (pathBin)
185
+ return { path: pathBin, kind: 'cli' };
186
+ return null;
187
+ }
168
188
  if (agent === 'cursor') {
169
189
  // cursor-agent is installed via curl script, not agents-cli. Version argument
170
190
  // is accepted for API symmetry but ignored -- cursor lives on PATH.
@@ -606,6 +626,114 @@ function extractOpenClawCatalog(binaryPath) {
606
626
  }));
607
627
  return { models, aliases: {} };
608
628
  }
629
+ /**
630
+ * Extract Antigravity's catalog via `agy models`. Antigravity is unusual: it
631
+ * prints DISPLAY NAMES ONLY, one per line, with no machine ids and no --json:
632
+ * Gemini 3.5 Flash (Medium)
633
+ * Claude Sonnet 4.6 (Thinking)
634
+ * Verified (agy 1.0.11) that those display strings ARE the accepted `--model`
635
+ * values -- `agy --model "Claude Opus 4.6 (Thinking)"` routes to that model,
636
+ * and an unknown value silently falls back to the first row. So we use each
637
+ * display string as both id and displayName, and mark the first row default.
638
+ */
639
+ function extractAntigravityCatalog(binaryPath) {
640
+ let stdout;
641
+ try {
642
+ stdout = execFileSync(binaryPath, ['models'], {
643
+ encoding: 'utf-8',
644
+ stdio: ['ignore', 'pipe', 'ignore'],
645
+ timeout: 15_000,
646
+ maxBuffer: 8 * 1024 * 1024,
647
+ });
648
+ }
649
+ catch {
650
+ return { models: [], aliases: {} };
651
+ }
652
+ // Strip ANSI in case a spinner or color codes slip through.
653
+ // eslint-disable-next-line no-control-regex
654
+ const plain = stdout.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
655
+ const models = [];
656
+ const seen = new Set();
657
+ for (const raw of plain.split('\n')) {
658
+ const name = raw.trim();
659
+ if (!name)
660
+ continue;
661
+ // Guard against any stray banner/usage lines: real rows look like
662
+ // "<Vendor> <Model> (<Level>)". Require an alphanumeric start and a
663
+ // parenthesized suffix, which every observed model row has.
664
+ if (!/^[A-Za-z0-9].*\([^)]+\)\s*$/.test(name))
665
+ continue;
666
+ if (seen.has(name))
667
+ continue;
668
+ seen.add(name);
669
+ models.push({
670
+ id: name,
671
+ displayName: name,
672
+ // Antigravity's first listed model is its default (unknown --model values
673
+ // fall back to it), so flag the first row we accept.
674
+ isDefault: models.length === 0,
675
+ });
676
+ }
677
+ return { models, aliases: {} };
678
+ }
679
+ /**
680
+ * Extract Kimi's catalog via `kimi provider list --json`, which emits the raw
681
+ * providers/models config. Model ids are the `models` object keys (e.g.
682
+ * `kimi-code/kimi-for-coding`). The default is reported on a separate plain
683
+ * `Default model: <id>` line by `kimi provider list` (no flags), so we run that
684
+ * too to flag the default row.
685
+ */
686
+ function extractKimiCatalog(binaryPath) {
687
+ let jsonOut;
688
+ try {
689
+ jsonOut = execFileSync(binaryPath, ['provider', 'list', '--json'], {
690
+ encoding: 'utf-8',
691
+ stdio: ['ignore', 'pipe', 'ignore'],
692
+ timeout: 15_000,
693
+ maxBuffer: 16 * 1024 * 1024,
694
+ });
695
+ }
696
+ catch {
697
+ return { models: [], aliases: {} };
698
+ }
699
+ const firstBrace = jsonOut.indexOf('{');
700
+ if (firstBrace === -1)
701
+ return { models: [], aliases: {} };
702
+ let parsed;
703
+ try {
704
+ parsed = JSON.parse(jsonOut.slice(firstBrace));
705
+ }
706
+ catch {
707
+ return { models: [], aliases: {} };
708
+ }
709
+ // Resolve the default model id from the plain listing's "Default model:" line.
710
+ let defaultId = null;
711
+ try {
712
+ const plain = execFileSync(binaryPath, ['provider', 'list'], {
713
+ encoding: 'utf-8',
714
+ stdio: ['ignore', 'pipe', 'ignore'],
715
+ timeout: 10_000,
716
+ maxBuffer: 4 * 1024 * 1024,
717
+ });
718
+ const m = plain.match(/Default model:\s*(\S+)/);
719
+ if (m)
720
+ defaultId = m[1];
721
+ }
722
+ catch {
723
+ /* default flag is best-effort */
724
+ }
725
+ const modelsObj = parsed?.models && typeof parsed.models === 'object' ? parsed.models : {};
726
+ const models = [];
727
+ for (const id of Object.keys(modelsObj)) {
728
+ const info = modelsObj[id] ?? {};
729
+ models.push({
730
+ id,
731
+ displayName: typeof info.displayName === 'string' ? info.displayName : undefined,
732
+ isDefault: defaultId != null && id === defaultId,
733
+ });
734
+ }
735
+ return { models, aliases: {} };
736
+ }
609
737
  /**
610
738
  * Build (or load from cache) the model catalog for a specific (agent, version).
611
739
  * Cache is keyed on source-file mtime (binary or js module), so re-extracts
@@ -654,6 +782,10 @@ export function getModelCatalog(agent, version) {
654
782
  ({ models, aliases } = extractCursorCatalog(src.path));
655
783
  else if (agent === 'openclaw')
656
784
  ({ models, aliases } = extractOpenClawCatalog(src.path));
785
+ else if (agent === 'antigravity')
786
+ ({ models, aliases } = extractAntigravityCatalog(src.path));
787
+ else if (agent === 'kimi')
788
+ ({ models, aliases } = extractKimiCatalog(src.path));
657
789
  }
658
790
  const catalog = {
659
791
  agent,
@@ -663,11 +795,12 @@ export function getModelCatalog(agent, version) {
663
795
  models,
664
796
  aliases,
665
797
  };
666
- // Don't cache empty CLI extractions: the CLI may have been mid-install,
667
- // network-dependent, or transiently failing. Caching 0 models would mask
668
- // the real catalog forever (mtime won't change). Bundle/binary/js sources
669
- // are deterministic, so cache those even when empty.
670
- if (src.kind !== 'cli' || models.length > 0) {
798
+ // Never cache an empty extraction, regardless of source kind. A 0-model
799
+ // result is always suspect: the CLI may have been mid-install, network-
800
+ // dependent, or transiently failing, and a js/bundle/binary extractor that
801
+ // regex-misses would otherwise pin an empty catalog forever (mtime won't
802
+ // change until the source file does). Only persist a non-empty catalog.
803
+ if (models.length > 0) {
671
804
  cache.entries[key] = { sourcePath: src.path, mtime, catalog };
672
805
  saveCache();
673
806
  }
@@ -95,14 +95,14 @@ export function buildJobCommand(config, resolvedPrompt) {
95
95
  appendModelAndReasoning(cmd, config);
96
96
  }
97
97
  if (config.agent === 'kimi') {
98
+ // kimi daemon jobs always run headless via `--prompt`, which cannot be
99
+ // combined with any startup-mode flag (--plan/--auto/--yolo all abort with
100
+ // "Cannot combine --prompt with --X"). edit/auto/skip reduce to kimi's default
101
+ // headless auto-run, so emit no flag; plan has no headless read-only
102
+ // equivalent, so fail closed rather than silently allowing writes.
98
103
  if (mode === 'plan') {
99
- cmd.push('--plan');
100
- }
101
- else if (mode === 'auto') {
102
- cmd.push('--auto');
103
- }
104
- else if (mode === 'skip') {
105
- cmd.push('--yolo');
104
+ throw new Error('kimi has no headless read-only mode: routine jobs cannot run kimi with --mode plan ' +
105
+ '(kimi rejects --prompt + --plan). Use --mode edit, auto, or skip.');
106
106
  }
107
107
  appendModelAndReasoning(cmd, config);
108
108
  }