@phnx-labs/agents-cli 1.22.74 → 1.22.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.75
4
+
5
+ - **Account labels are per-harness, and survive a daemon publish (PHNX-3887).** One
6
+ identity signed into several harnesses can now carry the SAME label on each —
7
+ `claude#icloud`, `codex#icloud`, and `grok#icloud` all resolve to that harness's own
8
+ login, instead of the first-labelled harness squatting the name and forcing prefixes
9
+ (`cxicloud`, `gkicloud`) on the rest. Uniqueness is scoped to `(agent, label)`;
10
+ provider accounts, selected by bare name via `--account`, stay globally unique, and a
11
+ duplicate label within one harness is still refused. Separately, `agents accounts
12
+ label` wrote version-scoped rows into the central `agents.yaml` as a plain file write
13
+ with no commit, so the daemon's `publish <device> daemon state` tick — which committed
14
+ only the per-device doc and then rebased `--autostash` over the dirty central file —
15
+ silently destroyed them: every box lost its account labels on its next publish. The
16
+ publish now commits `agents.yaml` alongside the device doc, so labels persist and
17
+ actually sync fleet-wide as `accounts label --help` already promised. Source:
18
+ `cli/src/lib/account-registry.ts`, `cli/src/lib/fleet-shared-repo-sync.ts`.
19
+
20
+ - **Keep one daemon alive across concurrent stop/start (PHNX-3941).** Prevent a replacement daemon from exiting while stop holds the lifecycle lock, so concurrent stop/start cannot leave zero instances, and extend real-boot supervisor-wiring coverage to all 20 services. Source: `cli/src/lib/daemon/daemon.ts`.
21
+
22
+ - **`agents computer` can now drive a Linux GUI desktop over VNC/RFB (PHNX-3948).**
23
+ A third computer backend joins the macOS (Accessibility) and Windows (UIA) native
24
+ helpers — this one speaks the RFB/VNC protocol directly to an x11vnc/Xvnc server, so
25
+ a headless Linux desktop (or an LXD container exposing x11vnc on the host's Tailscale
26
+ IP) becomes drivable with no per-box native helper install. Select it with
27
+ `agents computer --vnc <host:port>` (port defaults to 5901; password via
28
+ `--vnc-password` or `COMPUTER_HELPER_VNC_PASSWORD`), then use the same verbs as the
29
+ other backends: `screenshot`, `click`, `right-click`, `type`, `type-text`, `key`,
30
+ `scroll`, `wait`. It is coordinate-based — RFB carries a framebuffer and pointer/key
31
+ events, not an accessibility tree — so the AX-tree verbs (`describe`, `get-text`,
32
+ `ax-action`, element-id targeting) and `launch` fail loud with a clear message rather
33
+ than silently no-op'ing. VNC authentication uses a bundled pure-JS DES (Node 24's
34
+ OpenSSL 3 keeps DES in the off-by-default legacy provider). Source:
35
+ `cli/src/lib/computer/rfb-client.ts`, `cli/src/lib/computer/des.ts`,
36
+ `cli/src/lib/computer/computer-rpc.ts`, `cli/src/commands/computer.ts`.
37
+
3
38
  ## 1.22.74
4
39
 
5
40
  - **Launching an agent no longer dirties your tracked `.gitignore` (PHNX-3718).**
package/README.md CHANGED
@@ -1756,7 +1756,7 @@ Agents are defined in [src/lib/agents.ts](cli/src/lib/agents.ts) -- each is a co
1756
1756
  |---|---|
1757
1757
  | [`cli`](cli) | **The CLI** (this README) — version management, config sync, sessions, teams, cloud, browser, computer, secrets. |
1758
1758
  | [phnx-labs/agi-ext](https://github.com/phnx-labs/agi-ext) | **AGI EXT** — the VS Code extension (agent terminals as tabs + the Fleet dashboard). Moved to its own repo (RUSH-3189); separate product, own publish identity. |
1759
- | [`native/computer-mac`](native/computer-mac) · [`native/computer-win`](native/computer-win) | Native backends behind `agents computer` — Swift (macOS Accessibility + screen capture) and C#/.NET (Windows UI Automation). |
1759
+ | [`native/computer-mac`](native/computer-mac) · [`native/computer-win`](native/computer-win) | Native backends behind `agents computer` — Swift (macOS Accessibility + screen capture) and C#/.NET (Windows UI Automation). A third, install-free backend drives a **Linux GUI desktop over VNC/RFB**: `agents computer --vnc <host:port> screenshot` (coordinate-based — screenshot/click/type/key/scroll against any x11vnc/Xvnc server). |
1760
1760
  | [`packages/session-tracker`](packages/session-tracker) | The `SessionStart` hook that writes live-session state **AGI EXT** reads back (agi-ext `src/core/liveSession.ts`) — not the CLI, which reads transcripts. |
1761
1761
 
1762
1762
  ## Contributing
@@ -10,6 +10,7 @@ import { resolveHelperExec, resolveSocketPath } from '../lib/computer/computer-r
10
10
  export declare function shouldBlockOffPlatform(opts: {
11
11
  platform: NodeJS.Platform;
12
12
  tcpConfigured: boolean;
13
+ vncConfigured?: boolean;
13
14
  device?: string;
14
15
  }): boolean;
15
16
  /**
@@ -3,7 +3,7 @@ import * as fs from 'fs';
3
3
  import * as os from 'os';
4
4
  import * as path from 'path';
5
5
  import { registerCommandGroups } from '../lib/help.js';
6
- import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer/computer-rpc.js';
6
+ import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, resolveVncEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer/computer-rpc.js';
7
7
  import { setupRemoteHelper, startRemoteTunnel, stopRemoteHelper, hydrateRemoteEnvFromState, readRemoteState, resolveRemoteDevice, REMOTE_TASK_NAME, WIN_HELPER_EXE, } from '../lib/computer/ssh-tunnel.js';
8
8
  import { sshExec } from '../lib/ssh-exec.js';
9
9
  import { encodePowershell } from '../lib/hosts/remote-cmd.js';
@@ -41,6 +41,8 @@ export function shouldBlockOffPlatform(opts) {
41
41
  return false;
42
42
  if (opts.tcpConfigured)
43
43
  return false; // remote (Windows) daemon over a tunnel
44
+ if (opts.vncConfigured)
45
+ return false; // RFB/VNC desktop (Linux GUI over the wire)
44
46
  if (opts.device)
45
47
  return false; // remote path resolves its own endpoint
46
48
  return true;
@@ -84,20 +86,37 @@ export function reconcileScreenshotExt(outPath, buf) {
84
86
  export function registerComputerCommand(program) {
85
87
  const computer = program
86
88
  .command('computer')
87
- .description('Drive macOS apps via Accessibility, or a remote Windows device with --device — list, screenshot, click, type')
89
+ .description('Drive macOS apps via Accessibility, a Linux GUI desktop with --vnc, or a remote Windows device with --device — screenshot, click, type')
90
+ // A VNC/RFB desktop is driven over the wire (--vnc host:port, e.g. an x11vnc
91
+ // server on a headless Linux box or an LXD container). Set it before the gate.
92
+ .option('--vnc <host:port>', 'Drive a GUI desktop over VNC/RFB (x11vnc/Xvnc; port defaults to 5901) instead of a native helper')
93
+ .option('--vnc-password <password>', 'VNC password for --vnc (or set COMPUTER_HELPER_VNC_PASSWORD)')
88
94
  // The whole subsystem is macOS Accessibility / TCC for LOCAL driving. Off
89
- // macOS it still works against a remote daemon (COMPUTER_HELPER_TCP set, or
90
- // a `--device <name>` invocation). Fail fast with a clear message only when
91
- // neither remote path is available, instead of a downstream launchctl error.
95
+ // macOS it still works against a remote daemon (COMPUTER_HELPER_TCP set, a
96
+ // --vnc desktop, or a `--device <name>` invocation). Fail fast with a clear
97
+ // message only when no remote path is available, instead of a downstream error.
92
98
  .hook('preAction', async (_thisCommand, actionCommand) => {
93
- const device = actionCommand.opts().device;
99
+ const globals = actionCommand.optsWithGlobals();
100
+ // --vnc selects the RFB transport for every verb under this command.
101
+ if (globals.vnc) {
102
+ process.env.COMPUTER_HELPER_VNC = globals.vnc;
103
+ if (globals.vncPassword)
104
+ process.env.COMPUTER_HELPER_VNC_PASSWORD = globals.vncPassword;
105
+ }
106
+ const device = globals.device;
94
107
  // Verbs with --device reconnect to the tunnel `start --device` recorded;
95
108
  // this sets COMPUTER_HELPER_TCP so the shared client picks the TCP transport.
96
109
  if (device && !REMOTE_LIFECYCLE.has(actionCommand.name())) {
97
110
  hydrateRemoteEnvFromState(device);
98
111
  }
99
- if (shouldBlockOffPlatform({ platform: process.platform, tcpConfigured: resolveTcpEndpoint() != null, device })) {
112
+ if (shouldBlockOffPlatform({
113
+ platform: process.platform,
114
+ tcpConfigured: resolveTcpEndpoint() != null,
115
+ vncConfigured: resolveVncEndpoint() != null,
116
+ device,
117
+ })) {
100
118
  console.error('agents computer: macOS only for local driving — it uses the macOS Accessibility API.');
119
+ console.error('For a Linux GUI desktop over VNC: `agents computer --vnc <host:port> screenshot`.');
101
120
  console.error('For a remote Windows device: register it with `agents devices`, then use --device (or set COMPUTER_HELPER_TCP).');
102
121
  process.exit(1);
103
122
  }
@@ -187,11 +187,32 @@ function nativeRowsForNameOrId(meta, name) {
187
187
  return [];
188
188
  return nativeIdentityRows(meta, found.agent, found.identityKey);
189
189
  }
190
- function assertUniqueUnifiedName(name, meta, doc, exceptIds) {
190
+ /**
191
+ * Native label names are unique per HARNESS, not globally (PHNX-3887).
192
+ *
193
+ * One human identity is commonly signed into several harnesses —
194
+ * `muqsitnawaz@icloud.com` is a claude login AND a codex login AND a grok login.
195
+ * A global namespace let whichever harness was labelled first squat the good
196
+ * name, forcing prefixed junk (`cxicloud`, `gkicloud`) on the rest. Nothing is
197
+ * actually ambiguous at the point of use: the selector is `<harness>#<label>`,
198
+ * and `findUnifiedAccount` already disambiguates via `preferAgent`.
199
+ *
200
+ * Pass `agent` to scope the check to that harness. Omit it for management
201
+ * lookups with no harness in hand (rename/remove), which keep the old
202
+ * fleet-wide check so a rename cannot collide with an unrelated harness's row.
203
+ *
204
+ * Provider (non-native) accounts stay globally unique — they are selected by
205
+ * bare name via `--account`, with no harness to scope them by.
206
+ */
207
+ function assertUniqueUnifiedName(name, meta, doc, exceptIds, agent) {
191
208
  const needle = name.toLowerCase();
192
- const nativeHits = listNativeAccounts(meta).filter(account => account.id === name || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle);
193
- if (nativeHits.some(account => !exceptIds?.has(account.id)))
194
- throw new Error(`Account '${name}' already exists.`);
209
+ const nativeHits = listNativeAccounts(meta).filter(account => (agent === undefined || account.agent === agent)
210
+ && (account.id === name || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle));
211
+ if (nativeHits.some(account => !exceptIds?.has(account.id))) {
212
+ throw new Error(agent === undefined
213
+ ? `Account '${name}' already exists.`
214
+ : `Account '${name}' already exists for the ${agent} harness.`);
215
+ }
195
216
  // Same laziness as findUnifiedAccount: a native row that already owns this
196
217
  // name (even one we are mutating) means we never open the provider store.
197
218
  if (nativeHits.length > 0)
@@ -203,7 +224,7 @@ function assertUniqueUnifiedName(name, meta, doc, exceptIds) {
203
224
  export function addNativeAccount(name, agent, identityKey, identityLabel, scope) {
204
225
  assertNativeLabel(name);
205
226
  const meta = readMeta();
206
- assertUniqueUnifiedName(name, meta);
227
+ assertUniqueUnifiedName(name, meta, undefined, undefined, agent);
207
228
  const duplicate = listNativeAccounts(meta).find(account => account.agent === agent && account.identityKey === identityKey);
208
229
  if (duplicate)
209
230
  throw new Error(`This ${agent} login is already named '${duplicate.name}'.`);
@@ -240,7 +261,7 @@ export function labelNativeAccount(agent, identityKey, identityLabel, label, sco
240
261
  assertNativeLabel(resolvedLabel);
241
262
  const meta = readMeta();
242
263
  const matches = nativeIdentityRows(meta, agent, identityKey);
243
- assertUniqueUnifiedName(resolvedLabel, meta, undefined, new Set(matches.map(account => account.id)));
264
+ assertUniqueUnifiedName(resolvedLabel, meta, undefined, new Set(matches.map(account => account.id)), agent);
244
265
  if (matches.length === 0)
245
266
  return addNativeAccount(resolvedLabel, agent, identityKey, identityLabel, scope);
246
267
  // Sweep every row for this identity (PHNX-3206), routing the whole sweep to the
@@ -25,10 +25,15 @@ export declare function resolveTcpEndpoint(): {
25
25
  port: number;
26
26
  token: string | null;
27
27
  } | null;
28
+ export declare function resolveVncEndpoint(): {
29
+ host: string;
30
+ port: number;
31
+ password: string;
32
+ } | null;
28
33
  export declare function openComputerClient(): ComputerClient;
29
34
  export declare const RPC_TIMEOUT_MS = 30000;
30
35
  export declare function resolveRpcTimeoutMs(env: string | undefined): number;
31
36
  export declare function describeTransport(): {
32
- kind: 'socket' | 'stdio' | 'tcp' | 'none';
37
+ kind: 'socket' | 'stdio' | 'tcp' | 'vnc' | 'none';
33
38
  path: string | null;
34
39
  };
@@ -17,6 +17,7 @@ import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { fileURLToPath } from 'url';
19
19
  import { getHelpersDir, getLogsDir, getUserPermissionsDir, getPermissionsDir } from '../state.js';
20
+ import { RfbClient, parseVncEndpoint } from './rfb-client.js';
20
21
  // Resolve the socket path used by the launchd-managed daemon. Internal
21
22
  // scratch — lives under getHelpersDir() (~/.agents/.cache/helpers/),
22
23
  // matching browser.sock.
@@ -209,11 +210,28 @@ export function resolveTcpEndpoint() {
209
210
  const token = process.env.COMPUTER_HELPER_TOKEN;
210
211
  return { host: hostPart || '127.0.0.1', port, token: token && token.length > 0 ? token : null };
211
212
  }
213
+ // Resolve the VNC/RFB endpoint for driving a remote GUI desktop over the RFB
214
+ // protocol (an x11vnc/Xvnc server — e.g. a headless Linux desktop or an LXD
215
+ // container exposing x11vnc on the host's Tailscale IP). COMPUTER_HELPER_VNC is
216
+ // "host:port" (port defaults to 5901); COMPUTER_HELPER_VNC_PASSWORD is the VNC
217
+ // password. Unlike the native helpers this transport carries a framebuffer, not
218
+ // an accessibility tree, so it is coordinate-based (see rfb-client.ts).
219
+ export function resolveVncEndpoint() {
220
+ const parsed = parseVncEndpoint(process.env.COMPUTER_HELPER_VNC);
221
+ if (!parsed)
222
+ return null;
223
+ return { ...parsed, password: process.env.COMPUTER_HELPER_VNC_PASSWORD ?? '' };
224
+ }
212
225
  // Pick the best transport. Precedence:
213
- // 1. COMPUTER_HELPER_TCP -> the Windows daemon over a (tunneled) TCP port.
214
- // 2. the macOS launchd socket if it exists.
215
- // 3. spawning the helper as a subprocess (legacy/dev fallback).
226
+ // 1. COMPUTER_HELPER_VNC -> an RFB/VNC desktop (Linux GUI over the wire).
227
+ // 2. COMPUTER_HELPER_TCP -> the Windows daemon over a (tunneled) TCP port.
228
+ // 3. the macOS launchd socket if it exists.
229
+ // 4. spawning the helper as a subprocess (legacy/dev fallback).
216
230
  export function openComputerClient() {
231
+ const vnc = resolveVncEndpoint();
232
+ if (vnc) {
233
+ return new RfbClient(vnc.host, vnc.port, vnc.password);
234
+ }
217
235
  const tcp = resolveTcpEndpoint();
218
236
  if (tcp) {
219
237
  return new TcpClient(tcp.host, tcp.port, tcp.token);
@@ -415,6 +433,8 @@ class StdioClient extends BaseClient {
415
433
  // .app) must not be told "no transport". `path` is null: the endpoint is an
416
434
  // env-configured host:port, not an on-disk path.
417
435
  export function describeTransport() {
436
+ if (resolveVncEndpoint())
437
+ return { kind: 'vnc', path: null };
418
438
  if (resolveTcpEndpoint())
419
439
  return { kind: 'tcp', path: null };
420
440
  const sockPath = resolveSocketPath();
@@ -0,0 +1 @@
1
+ export declare function desEncryptBlock(key8: Buffer, block8: Buffer): Buffer;
@@ -0,0 +1,114 @@
1
+ // Minimal, dependency-free single-block DES (ECB) — used only for VNC
2
+ // authentication's challenge/response. Node 24 ships OpenSSL 3, whose DES lives
3
+ // in the legacy provider that is off by default, so `createCipheriv('des-ecb')`
4
+ // throws `digital envelope routines::unsupported`. VNC clients have always
5
+ // bundled their own DES for exactly this reason. This is a textbook FIPS-46 DES
6
+ // implementation over bit arrays — verified against the standard test vector in
7
+ // des.test.ts. It encrypts one 8-byte block; VNC auth encrypts the two halves
8
+ // of the 16-byte challenge independently (see rfb-client.ts).
9
+ const IP = [
10
+ 58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4,
11
+ 62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8,
12
+ 57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3,
13
+ 61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7,
14
+ ];
15
+ const FP = [
16
+ 40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31,
17
+ 38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29,
18
+ 36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27,
19
+ 34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25,
20
+ ];
21
+ const E = [
22
+ 32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13, 12, 13, 14, 15, 16, 17,
23
+ 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25, 24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1,
24
+ ];
25
+ const P = [
26
+ 16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10,
27
+ 2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25,
28
+ ];
29
+ const PC1 = [
30
+ 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36,
31
+ 63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4,
32
+ ];
33
+ const PC2 = [
34
+ 14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10, 23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2,
35
+ 41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48, 44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32,
36
+ ];
37
+ const SHIFTS = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1];
38
+ const SBOX = [
39
+ [14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8, 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13],
40
+ [15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5, 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9],
41
+ [10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1, 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12],
42
+ [7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9, 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14],
43
+ [2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6, 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3],
44
+ [12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8, 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13],
45
+ [4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6, 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12],
46
+ [13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2, 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11],
47
+ ];
48
+ function bytesToBits(buf) {
49
+ const bits = [];
50
+ for (let i = 0; i < buf.length; i++) {
51
+ for (let b = 7; b >= 0; b--)
52
+ bits.push((buf[i] >> b) & 1);
53
+ }
54
+ return bits;
55
+ }
56
+ function bitsToBytes(bits) {
57
+ const out = Buffer.alloc(bits.length / 8);
58
+ for (let i = 0; i < out.length; i++) {
59
+ let byte = 0;
60
+ for (let b = 0; b < 8; b++)
61
+ byte = (byte << 1) | bits[i * 8 + b];
62
+ out[i] = byte;
63
+ }
64
+ return out;
65
+ }
66
+ function permute(bits, table) {
67
+ return table.map((pos) => bits[pos - 1]);
68
+ }
69
+ function keySchedule(key8) {
70
+ const keyBits = bytesToBits(key8);
71
+ const pc1 = permute(keyBits, PC1);
72
+ let c = pc1.slice(0, 28);
73
+ let d = pc1.slice(28, 56);
74
+ const subkeys = [];
75
+ for (let round = 0; round < 16; round++) {
76
+ const s = SHIFTS[round];
77
+ c = c.slice(s).concat(c.slice(0, s));
78
+ d = d.slice(s).concat(d.slice(0, s));
79
+ subkeys.push(permute(c.concat(d), PC2));
80
+ }
81
+ return subkeys;
82
+ }
83
+ function feistel(r, subkey) {
84
+ const expanded = permute(r, E);
85
+ const xored = expanded.map((bit, i) => bit ^ subkey[i]);
86
+ const out = [];
87
+ for (let box = 0; box < 8; box++) {
88
+ const chunk = xored.slice(box * 6, box * 6 + 6);
89
+ const row = (chunk[0] << 1) | chunk[5];
90
+ const col = (chunk[1] << 3) | (chunk[2] << 2) | (chunk[3] << 1) | chunk[4];
91
+ const val = SBOX[box][row * 16 + col];
92
+ for (let b = 3; b >= 0; b--)
93
+ out.push((val >> b) & 1);
94
+ }
95
+ return permute(out, P);
96
+ }
97
+ // Encrypt one 8-byte block with DES-ECB under an 8-byte key.
98
+ export function desEncryptBlock(key8, block8) {
99
+ if (key8.length !== 8)
100
+ throw new Error(`des key must be 8 bytes, got ${key8.length}`);
101
+ if (block8.length !== 8)
102
+ throw new Error(`des block must be 8 bytes, got ${block8.length}`);
103
+ const subkeys = keySchedule(key8);
104
+ const bits = permute(bytesToBits(block8), IP);
105
+ let l = bits.slice(0, 32);
106
+ let r = bits.slice(32, 64);
107
+ for (let round = 0; round < 16; round++) {
108
+ const f = feistel(r, subkeys[round]);
109
+ const next = l.map((bit, i) => bit ^ f[i]);
110
+ l = r;
111
+ r = next;
112
+ }
113
+ return bitsToBytes(permute(r.concat(l), FP));
114
+ }
@@ -0,0 +1,53 @@
1
+ import type { ComputerClient, RPCResponse } from './computer-rpc.js';
2
+ export declare function mirrorByte(b: number): number;
3
+ export declare function vncAuthResponse(challenge: Buffer, password: string): Buffer;
4
+ export declare function buildPixelFormat(): Buffer;
5
+ export declare function encodeSetPixelFormat(pf: Buffer): Buffer;
6
+ export declare function encodeSetEncodings(encodings: number[]): Buffer;
7
+ export declare function encodeFbUpdateRequest(incremental: boolean, x: number, y: number, w: number, h: number): Buffer;
8
+ export declare function encodePointerEvent(buttonMask: number, x: number, y: number): Buffer;
9
+ export declare function encodeKeyEvent(down: boolean, keysym: number): Buffer;
10
+ export declare function keysymForChar(ch: string): number;
11
+ export declare function keysymForKeyName(name: string): number | null;
12
+ export declare function parseKeyCombo(combo: string): {
13
+ modifiers: number[];
14
+ key: number;
15
+ };
16
+ export declare function encodePng(rgb: Buffer, width: number, height: number): Buffer;
17
+ export interface ServerInit {
18
+ width: number;
19
+ height: number;
20
+ name: string;
21
+ }
22
+ export declare function scrollButtonsFor(dx: number, dy: number): {
23
+ vButton: number;
24
+ hButton: number;
25
+ };
26
+ export declare const MAX_RFB_STRING_LEN: number;
27
+ export declare function parseVncEndpoint(raw: string | undefined): {
28
+ host: string;
29
+ port: number;
30
+ } | null;
31
+ export declare class RfbClient implements ComputerClient {
32
+ private host;
33
+ private port;
34
+ private password;
35
+ private sock;
36
+ private reader;
37
+ private ready;
38
+ private info;
39
+ private closed;
40
+ constructor(host: string, port: number, password: string);
41
+ private write;
42
+ private readLenString;
43
+ private handshake;
44
+ private teardown;
45
+ private withTimeout;
46
+ private captureScreen;
47
+ private sendClick;
48
+ private sendScroll;
49
+ private typeText;
50
+ private pressCombo;
51
+ call(method: string, params?: Record<string, unknown>): Promise<RPCResponse>;
52
+ close(): Promise<void>;
53
+ }
@@ -0,0 +1,562 @@
1
+ // RFB (VNC) transport for `agents computer` — drive a remote GUI desktop over
2
+ // the RFB protocol instead of a native Accessibility/UIA daemon.
3
+ //
4
+ // This is the third computer backend, sitting behind the same ComputerClient
5
+ // interface as the macOS Unix-socket helper and the Windows TCP helper. It
6
+ // speaks RFB 3.8 directly to an x11vnc/Xvnc server (e.g. a headless Linux
7
+ // desktop, or an LXD container exposing x11vnc on the host's Tailscale IP), so
8
+ // no per-box native helper install is needed — the display is driven over the
9
+ // same VNC stream a human would watch.
10
+ //
11
+ // It is COORDINATE-BASED: RFB carries a framebuffer and pointer/key events, not
12
+ // an accessibility tree. So it implements the observe verb `screenshot` and the
13
+ // interact verbs `click`/`right-click`/`type`/`type-text`/`key`/`scroll`/`wait`;
14
+ // the AX-tree verbs (`describe`/`get-text`/`ax-action`/element-id targeting) and
15
+ // app lifecycle (`launch`/`apps` beyond the synthetic desktop target) have no RFB
16
+ // equivalent and FAIL LOUD with a clear message rather than a silent no-op.
17
+ //
18
+ // Wire format note: unlike the JSON-RPC transports, there is no line-delimited
19
+ // RPC on the wire here — `call(method, params)` translates a computer RPC verb
20
+ // into RFB protocol messages and shapes the reply to match what the command
21
+ // layer expects from the native helpers (e.g. screenshot -> { image_data, width,
22
+ // height }).
23
+ import { createConnection } from 'net';
24
+ import { deflateSync, crc32 } from 'zlib';
25
+ import { desEncryptBlock } from './des.js';
26
+ // ---------------------------------------------------------------------------
27
+ // Pure protocol helpers (exported for unit tests — no socket needed)
28
+ // ---------------------------------------------------------------------------
29
+ // VNC DES auth mangles the key by reversing the bit order of each byte. This is
30
+ // a historical quirk of the reference implementation, not real DES.
31
+ export function mirrorByte(b) {
32
+ let r = 0;
33
+ for (let i = 0; i < 8; i++)
34
+ r |= ((b >> i) & 1) << (7 - i);
35
+ return r & 0xff;
36
+ }
37
+ // Compute the 16-byte VNC-auth response: DES-ECB encrypt the server's 16-byte
38
+ // challenge with the (bit-mirrored, 8-byte, null-padded) password as the key.
39
+ export function vncAuthResponse(challenge, password) {
40
+ if (challenge.length !== 16)
41
+ throw new Error(`vnc challenge must be 16 bytes, got ${challenge.length}`);
42
+ const key = Buffer.alloc(8, 0);
43
+ const pw = Buffer.from(password, 'latin1');
44
+ for (let i = 0; i < 8 && i < pw.length; i++)
45
+ key[i] = mirrorByte(pw[i]);
46
+ // VNC encrypts the two 8-byte halves of the challenge as independent ECB
47
+ // blocks under the same key.
48
+ return Buffer.concat([
49
+ desEncryptBlock(key, challenge.subarray(0, 8)),
50
+ desEncryptBlock(key, challenge.subarray(8, 16)),
51
+ ]);
52
+ }
53
+ // 32bpp true-color, little-endian, R@16 G@8 B@0 — so a pixel's 4 wire bytes are
54
+ // [B, G, R, x] and RGB extraction is buf[i+2], buf[i+1], buf[i].
55
+ export function buildPixelFormat() {
56
+ const pf = Buffer.alloc(16, 0);
57
+ pf[0] = 32; // bits-per-pixel
58
+ pf[1] = 24; // depth
59
+ pf[2] = 0; // big-endian-flag (little-endian)
60
+ pf[3] = 1; // true-color-flag
61
+ pf.writeUInt16BE(255, 4); // red-max
62
+ pf.writeUInt16BE(255, 6); // green-max
63
+ pf.writeUInt16BE(255, 8); // blue-max
64
+ pf[10] = 16; // red-shift
65
+ pf[11] = 8; // green-shift
66
+ pf[12] = 0; // blue-shift
67
+ return pf;
68
+ }
69
+ export function encodeSetPixelFormat(pf) {
70
+ const msg = Buffer.alloc(4 + 16);
71
+ msg[0] = 0; // SetPixelFormat
72
+ pf.copy(msg, 4);
73
+ return msg;
74
+ }
75
+ export function encodeSetEncodings(encodings) {
76
+ const msg = Buffer.alloc(4 + encodings.length * 4);
77
+ msg[0] = 2; // SetEncodings
78
+ msg.writeUInt16BE(encodings.length, 2);
79
+ encodings.forEach((e, i) => msg.writeInt32BE(e, 4 + i * 4));
80
+ return msg;
81
+ }
82
+ export function encodeFbUpdateRequest(incremental, x, y, w, h) {
83
+ const msg = Buffer.alloc(10);
84
+ msg[0] = 3; // FramebufferUpdateRequest
85
+ msg[1] = incremental ? 1 : 0;
86
+ msg.writeUInt16BE(x, 2);
87
+ msg.writeUInt16BE(y, 4);
88
+ msg.writeUInt16BE(w, 6);
89
+ msg.writeUInt16BE(h, 8);
90
+ return msg;
91
+ }
92
+ export function encodePointerEvent(buttonMask, x, y) {
93
+ const msg = Buffer.alloc(6);
94
+ msg[0] = 5; // PointerEvent
95
+ msg[1] = buttonMask & 0xff;
96
+ msg.writeUInt16BE(Math.max(0, Math.round(x)), 2);
97
+ msg.writeUInt16BE(Math.max(0, Math.round(y)), 4);
98
+ return msg;
99
+ }
100
+ export function encodeKeyEvent(down, keysym) {
101
+ const msg = Buffer.alloc(8);
102
+ msg[0] = 4; // KeyEvent
103
+ msg[1] = down ? 1 : 0;
104
+ msg.writeUInt32BE(keysym >>> 0, 4);
105
+ return msg;
106
+ }
107
+ // X11 keysyms for the named keys an agent actually presses.
108
+ const NAMED_KEYSYMS = {
109
+ return: 0xff0d, enter: 0xff0d, tab: 0xff09, escape: 0xff1b, esc: 0xff1b,
110
+ backspace: 0xff08, delete: 0xffff, del: 0xffff, space: 0x0020,
111
+ home: 0xff50, end: 0xff57, pageup: 0xff55, pagedown: 0xff56,
112
+ left: 0xff51, up: 0xff52, right: 0xff53, down: 0xff54, insert: 0xff63,
113
+ f1: 0xffbe, f2: 0xffbf, f3: 0xffc0, f4: 0xffc1, f5: 0xffc2, f6: 0xffc3,
114
+ f7: 0xffc4, f8: 0xffc5, f9: 0xffc6, f10: 0xffc7, f11: 0xffc8, f12: 0xffc9,
115
+ };
116
+ const MODIFIER_KEYSYMS = {
117
+ shift: 0xffe1, ctrl: 0xffe3, control: 0xffe3,
118
+ alt: 0xffe9, option: 0xffe9, opt: 0xffe9,
119
+ cmd: 0xffeb, command: 0xffeb, super: 0xffeb, meta: 0xffeb, win: 0xffeb,
120
+ };
121
+ // A single printable character -> its keysym. Latin-1 maps 1:1; anything else
122
+ // uses the Unicode-plane keysym (0x01000000 + codepoint).
123
+ export function keysymForChar(ch) {
124
+ const cp = ch.codePointAt(0) ?? 0;
125
+ if (cp >= 0x20 && cp <= 0xff)
126
+ return cp;
127
+ return 0x01000000 + cp;
128
+ }
129
+ export function keysymForKeyName(name) {
130
+ const k = name.trim().toLowerCase();
131
+ if (k in NAMED_KEYSYMS)
132
+ return NAMED_KEYSYMS[k];
133
+ if ([...name].length === 1)
134
+ return keysymForChar(name);
135
+ return null;
136
+ }
137
+ // Parse "ctrl+a" / "cmd+shift+t" / "Return" into modifier keysyms + a key keysym.
138
+ export function parseKeyCombo(combo) {
139
+ const parts = combo.split('+').map((p) => p.trim()).filter(Boolean);
140
+ if (parts.length === 0)
141
+ throw new Error(`empty key combo: "${combo}"`);
142
+ const keyPart = parts[parts.length - 1];
143
+ const modParts = parts.slice(0, -1);
144
+ const modifiers = [];
145
+ for (const m of modParts) {
146
+ const ks = MODIFIER_KEYSYMS[m.toLowerCase()];
147
+ if (ks == null)
148
+ throw new Error(`unknown modifier "${m}" in "${combo}"`);
149
+ modifiers.push(ks);
150
+ }
151
+ const key = keysymForKeyName(keyPart);
152
+ if (key == null)
153
+ throw new Error(`unknown key "${keyPart}" in "${combo}"`);
154
+ return { modifiers, key };
155
+ }
156
+ // Minimal PNG encoder (color-type 2, RGB, 8-bit, no interlace). rgb is
157
+ // width*height*3 bytes, row-major. Uses zlib for IDAT and zlib.crc32 for chunk
158
+ // CRCs — no external dependency, deterministic, unit-testable.
159
+ export function encodePng(rgb, width, height) {
160
+ const stride = width * 3;
161
+ // Prepend a per-scanline filter byte (0 = none).
162
+ const raw = Buffer.alloc(height * (stride + 1));
163
+ for (let y = 0; y < height; y++) {
164
+ raw[y * (stride + 1)] = 0;
165
+ rgb.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
166
+ }
167
+ const idat = deflateSync(raw);
168
+ const chunk = (type, data) => {
169
+ const len = Buffer.alloc(4);
170
+ len.writeUInt32BE(data.length, 0);
171
+ const typeBuf = Buffer.from(type, 'latin1');
172
+ const crc = Buffer.alloc(4);
173
+ crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])) >>> 0, 0);
174
+ return Buffer.concat([len, typeBuf, data, crc]);
175
+ };
176
+ const ihdr = Buffer.alloc(13);
177
+ ihdr.writeUInt32BE(width, 0);
178
+ ihdr.writeUInt32BE(height, 4);
179
+ ihdr[8] = 8; // bit depth
180
+ ihdr[9] = 2; // color type RGB
181
+ ihdr[10] = 0; // compression
182
+ ihdr[11] = 0; // filter
183
+ ihdr[12] = 0; // interlace
184
+ const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
185
+ return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
186
+ }
187
+ // Map scroll deltas to VNC wheel buttons. The `--dy` contract is "negative =
188
+ // down" (honored by the macOS CGEvent and Windows MOUSEEVENTF_WHEEL backends by
189
+ // passing dy straight through); `--dx` negative = left. RFB/X11 wheel buttons:
190
+ // 4 = up, 5 = down, 6 = left, 7 = right. Pure + unit-tested so the direction
191
+ // contract can't silently invert (it did, pre-review).
192
+ export function scrollButtonsFor(dx, dy) {
193
+ return {
194
+ vButton: dy < 0 ? 5 : dy > 0 ? 4 : 0,
195
+ hButton: dx < 0 ? 6 : dx > 0 ? 7 : 0,
196
+ };
197
+ }
198
+ // Guard on any length-prefixed string the server frames (name, failure reason,
199
+ // ServerCutText). A broken/hostile server sending a huge length would otherwise
200
+ // make us wait indefinitely for bytes that never come; fail loud instead.
201
+ export const MAX_RFB_STRING_LEN = 1 << 20; // 1 MiB
202
+ // ---------------------------------------------------------------------------
203
+ // Async socket reader — serve exact-length reads off a streamed connection.
204
+ // ---------------------------------------------------------------------------
205
+ class ByteReader {
206
+ buf = Buffer.alloc(0);
207
+ want = null;
208
+ error = null;
209
+ push(chunk) {
210
+ this.buf = Buffer.concat([this.buf, chunk]);
211
+ this.pump();
212
+ }
213
+ fail(err) {
214
+ this.error = err;
215
+ if (this.want) {
216
+ const w = this.want;
217
+ this.want = null;
218
+ // Reject via a sentinel-length read; the awaiting read() rejects below.
219
+ w.resolve(Buffer.alloc(0));
220
+ }
221
+ }
222
+ pump() {
223
+ if (this.want && this.buf.length >= this.want.n) {
224
+ const { n, resolve } = this.want;
225
+ this.want = null;
226
+ const out = this.buf.subarray(0, n);
227
+ this.buf = this.buf.subarray(n);
228
+ resolve(out);
229
+ }
230
+ }
231
+ read(n) {
232
+ if (this.error)
233
+ return Promise.reject(this.error);
234
+ return new Promise((resolve, reject) => {
235
+ this.want = {
236
+ n,
237
+ resolve: (b) => {
238
+ if (this.error)
239
+ reject(this.error);
240
+ else
241
+ resolve(b);
242
+ },
243
+ };
244
+ this.pump();
245
+ });
246
+ }
247
+ }
248
+ export function parseVncEndpoint(raw) {
249
+ if (!raw || raw.length === 0)
250
+ return null;
251
+ const idx = raw.lastIndexOf(':');
252
+ const host = idx >= 0 ? raw.slice(0, idx) : raw;
253
+ const portStr = idx >= 0 ? raw.slice(idx + 1) : '5901';
254
+ const port = Number(portStr);
255
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
256
+ return null;
257
+ return { host: host || '127.0.0.1', port };
258
+ }
259
+ const RPC_TIMEOUT_MS = 30_000;
260
+ // The one synthetic target the VNC backend exposes: the whole desktop. Lets the
261
+ // screenshot command's list_apps->pickTarget->pid flow resolve without change
262
+ // (`active: true` is what pickTarget's frontmost-app default selects).
263
+ const DESKTOP_APP = { pid: 1, bundle_id: 'desktop', name: 'Desktop', active: true };
264
+ export class RfbClient {
265
+ host;
266
+ port;
267
+ password;
268
+ sock = null;
269
+ reader = new ByteReader();
270
+ ready;
271
+ info = null;
272
+ closed = false;
273
+ constructor(host, port, password) {
274
+ this.host = host;
275
+ this.port = port;
276
+ this.password = password;
277
+ this.ready = this.handshake();
278
+ }
279
+ write(b) {
280
+ if (!this.sock)
281
+ throw new Error('vnc socket not connected');
282
+ this.sock.write(b);
283
+ }
284
+ // Read a U32-length-prefixed latin1 string, capped so a bogus length can't
285
+ // wedge us waiting for bytes that never arrive.
286
+ async readLenString(what) {
287
+ const len = (await this.reader.read(4)).readUInt32BE(0);
288
+ if (len > MAX_RFB_STRING_LEN)
289
+ throw new Error(`vnc ${what} length ${len} exceeds ${MAX_RFB_STRING_LEN}`);
290
+ return (await this.reader.read(len)).toString('latin1');
291
+ }
292
+ async handshake() {
293
+ await new Promise((resolve, reject) => {
294
+ const sock = createConnection({ host: this.host, port: this.port }, () => resolve());
295
+ sock.on('data', (c) => this.reader.push(c));
296
+ sock.on('error', (e) => {
297
+ this.closed = true;
298
+ this.reader.fail(e);
299
+ reject(e);
300
+ });
301
+ sock.on('close', () => {
302
+ this.closed = true;
303
+ this.reader.fail(new Error('vnc connection closed'));
304
+ });
305
+ this.sock = sock;
306
+ });
307
+ // 1. ProtocolVersion: server sends "RFB 003.00x\n", we answer 3.8.
308
+ const serverVersion = (await this.reader.read(12)).toString('latin1');
309
+ if (!serverVersion.startsWith('RFB '))
310
+ throw new Error(`not an RFB server (got "${serverVersion.trim()}")`);
311
+ this.write(Buffer.from('RFB 003.008\n', 'latin1'));
312
+ // 2. Security types (3.7+): count, then that many U8 types.
313
+ const nTypes = (await this.reader.read(1))[0];
314
+ if (nTypes === 0) {
315
+ const reason = await this.readLenString('failure reason');
316
+ throw new Error(`vnc server refused connection: ${reason}`);
317
+ }
318
+ const types = [...(await this.reader.read(nTypes))];
319
+ // Prefer VNC auth (2); accept None (1).
320
+ let chosen;
321
+ if (types.includes(2))
322
+ chosen = 2;
323
+ else if (types.includes(1))
324
+ chosen = 1;
325
+ else
326
+ throw new Error(`no supported vnc security type (server offered ${types.join(',')})`);
327
+ this.write(Buffer.from([chosen]));
328
+ if (chosen === 2) {
329
+ const challenge = await this.reader.read(16);
330
+ if (!this.password)
331
+ throw new Error('vnc server requires a password; set COMPUTER_HELPER_VNC_PASSWORD or --vnc-password');
332
+ const response = vncAuthResponse(challenge, this.password);
333
+ this.write(response);
334
+ }
335
+ // 3. SecurityResult (always present in 3.8).
336
+ const result = (await this.reader.read(4)).readUInt32BE(0);
337
+ if (result !== 0) {
338
+ // 3.8 sends a reason string on failure.
339
+ let reason = 'authentication failed';
340
+ try {
341
+ reason = await this.readLenString('auth failure reason');
342
+ }
343
+ catch { /* some servers just drop the connection instead */ }
344
+ throw new Error(`vnc auth failed: ${reason}`);
345
+ }
346
+ // 4. ClientInit (shared = 1) -> ServerInit.
347
+ this.write(Buffer.from([1]));
348
+ const head = await this.reader.read(4 + 16 + 4);
349
+ const width = head.readUInt16BE(0);
350
+ const height = head.readUInt16BE(2);
351
+ const nameLen = head.readUInt32BE(20);
352
+ if (nameLen > MAX_RFB_STRING_LEN)
353
+ throw new Error(`vnc desktop name length ${nameLen} exceeds ${MAX_RFB_STRING_LEN}`);
354
+ const name = (await this.reader.read(nameLen)).toString('latin1');
355
+ // Pin our pixel format + advertise only Raw so decode is simple + correct.
356
+ this.write(encodeSetPixelFormat(buildPixelFormat()));
357
+ this.write(encodeSetEncodings([0]));
358
+ this.info = { width, height, name };
359
+ return this.info;
360
+ }
361
+ // Tear the connection down for good: reject any pending read, destroy the
362
+ // socket, and mark closed so every later call fails loud. Idempotent.
363
+ teardown(message) {
364
+ if (this.closed)
365
+ return;
366
+ this.closed = true;
367
+ this.reader.fail(new Error(message));
368
+ this.sock?.destroy();
369
+ }
370
+ // A timed-out RFB read cannot be recovered: the awaited bytes may still land
371
+ // later and would be misread as the NEXT request's reply, permanently
372
+ // desyncing the stream. So a timeout tears the whole connection down rather
373
+ // than leaving a pending read for the next call to silently overwrite.
374
+ withTimeout(p, label) {
375
+ return new Promise((resolve, reject) => {
376
+ const timer = setTimeout(() => {
377
+ const msg = `vnc ${label} timed out after ${RPC_TIMEOUT_MS}ms`;
378
+ this.teardown(msg);
379
+ reject(new Error(msg));
380
+ }, RPC_TIMEOUT_MS);
381
+ p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
382
+ });
383
+ }
384
+ // Request the full framebuffer and assemble it into an RGB buffer, then PNG.
385
+ async captureScreen() {
386
+ const info = this.info;
387
+ this.write(encodeFbUpdateRequest(false, 0, 0, info.width, info.height));
388
+ const rgb = Buffer.alloc(info.width * info.height * 3);
389
+ // Read messages until a FramebufferUpdate covering the frame is assembled.
390
+ // We requested a single non-incremental full-frame update, so one
391
+ // FramebufferUpdate message carries all rectangles.
392
+ for (;;) {
393
+ const msgType = (await this.reader.read(1))[0];
394
+ if (msgType === 0) {
395
+ await this.reader.read(1); // padding
396
+ const nRects = (await this.reader.read(2)).readUInt16BE(0);
397
+ for (let r = 0; r < nRects; r++) {
398
+ const rh = await this.reader.read(12);
399
+ const rx = rh.readUInt16BE(0);
400
+ const ry = rh.readUInt16BE(2);
401
+ const rw = rh.readUInt16BE(4);
402
+ const rHt = rh.readUInt16BE(6);
403
+ const enc = rh.readInt32BE(8);
404
+ if (enc !== 0)
405
+ throw new Error(`vnc server used unsupported encoding ${enc} (only Raw was advertised)`);
406
+ const pixels = await this.reader.read(rw * rHt * 4);
407
+ for (let y = 0; y < rHt; y++) {
408
+ for (let x = 0; x < rw; x++) {
409
+ const src = (y * rw + x) * 4;
410
+ const dstX = rx + x;
411
+ const dstY = ry + y;
412
+ if (dstX >= info.width || dstY >= info.height)
413
+ continue;
414
+ const dst = (dstY * info.width + dstX) * 3;
415
+ rgb[dst] = pixels[src + 2]; // R
416
+ rgb[dst + 1] = pixels[src + 1]; // G
417
+ rgb[dst + 2] = pixels[src]; // B
418
+ }
419
+ }
420
+ }
421
+ break;
422
+ }
423
+ else if (msgType === 2) {
424
+ // Bell — no body.
425
+ }
426
+ else if (msgType === 3) {
427
+ // ServerCutText: 3 padding + U32 length + text (discarded).
428
+ await this.reader.read(3);
429
+ await this.readLenString('server cut text');
430
+ }
431
+ else {
432
+ throw new Error(`unexpected vnc server message type ${msgType}`);
433
+ }
434
+ }
435
+ return { image_data: encodePng(rgb, info.width, info.height).toString('base64'), width: info.width, height: info.height };
436
+ }
437
+ sendClick(x, y, button, count) {
438
+ const mask = 1 << (button - 1);
439
+ for (let i = 0; i < Math.max(1, count); i++) {
440
+ this.write(encodePointerEvent(0, x, y)); // move
441
+ this.write(encodePointerEvent(mask, x, y)); // press
442
+ this.write(encodePointerEvent(0, x, y)); // release
443
+ }
444
+ }
445
+ sendScroll(x, y, dx, dy, count) {
446
+ const notches = Math.max(1, count || Math.max(Math.abs(dx), Math.abs(dy)) || 1);
447
+ const { vButton, hButton } = scrollButtonsFor(dx, dy);
448
+ for (const button of [vButton, hButton]) {
449
+ if (!button)
450
+ continue;
451
+ const mask = 1 << (button - 1);
452
+ for (let i = 0; i < notches; i++) {
453
+ this.write(encodePointerEvent(mask, x, y));
454
+ this.write(encodePointerEvent(0, x, y));
455
+ }
456
+ }
457
+ }
458
+ typeText(text) {
459
+ for (const ch of text) {
460
+ const ks = keysymForChar(ch);
461
+ this.write(encodeKeyEvent(true, ks));
462
+ this.write(encodeKeyEvent(false, ks));
463
+ }
464
+ }
465
+ pressCombo(combo) {
466
+ const { modifiers, key } = parseKeyCombo(combo);
467
+ for (const m of modifiers)
468
+ this.write(encodeKeyEvent(true, m));
469
+ this.write(encodeKeyEvent(true, key));
470
+ this.write(encodeKeyEvent(false, key));
471
+ for (const m of [...modifiers].reverse())
472
+ this.write(encodeKeyEvent(false, m));
473
+ }
474
+ async call(method, params = {}) {
475
+ if (this.closed)
476
+ return { id: null, error: { code: 'vnc_closed', message: 'vnc connection closed' } };
477
+ try {
478
+ await this.withTimeout(this.ready, 'handshake');
479
+ const x = Number(params.x ?? 0);
480
+ const y = Number(params.y ?? 0);
481
+ switch (method) {
482
+ case 'list_apps':
483
+ return { id: null, result: { apps: [DESKTOP_APP] } };
484
+ case 'screenshot': {
485
+ if (params.list)
486
+ return { id: null, result: { windows: [] } };
487
+ const shot = await this.withTimeout(this.captureScreen(), 'screenshot');
488
+ return { id: null, result: shot };
489
+ }
490
+ case 'click':
491
+ this.sendClick(x, y, 1, Number(params.count ?? 1));
492
+ return { id: null, result: { ok: true } };
493
+ case 'right_click':
494
+ this.sendClick(x, y, 3, Number(params.count ?? 1));
495
+ return { id: null, result: { ok: true } };
496
+ case 'scroll':
497
+ this.sendScroll(x, y, Number(params.dx ?? 0), Number(params.dy ?? 0), Number(params.count ?? 0));
498
+ return { id: null, result: { ok: true } };
499
+ case 'type': {
500
+ if (params.x != null && params.y != null)
501
+ this.sendClick(x, y, 1, 1);
502
+ if (params.text != null)
503
+ this.typeText(String(params.text));
504
+ if (params.commit)
505
+ this.pressCombo('Return');
506
+ return { id: null, result: { ok: true } };
507
+ }
508
+ case 'type_text':
509
+ if (params.text != null)
510
+ this.typeText(String(params.text));
511
+ if (params.commit)
512
+ this.pressCombo('Return');
513
+ return { id: null, result: { ok: true } };
514
+ case 'key':
515
+ for (const combo of String(params.keys ?? '').split(',').map((s) => s.trim()).filter(Boolean)) {
516
+ this.pressCombo(combo);
517
+ }
518
+ return { id: null, result: { ok: true } };
519
+ case 'focus_window':
520
+ case 'set_focus':
521
+ // No window manager control over RFB; the desktop is the target.
522
+ return { id: null, result: { ok: true } };
523
+ case 'wait': {
524
+ const ms = Number(params.duration_ms ?? 0);
525
+ if (ms > 0)
526
+ await new Promise((r) => setTimeout(r, Math.min(ms, RPC_TIMEOUT_MS)));
527
+ return { id: null, result: { ok: true } };
528
+ }
529
+ case 'describe':
530
+ case 'get_text':
531
+ case 'ax_action':
532
+ return { id: null, error: { code: 'unsupported_over_vnc', message: `'${method}' needs an accessibility tree; the VNC backend is coordinate-based. Use screenshot + click/type by coordinate.` } };
533
+ case 'launch_app':
534
+ return { id: null, error: { code: 'unsupported_over_vnc', message: 'launch is not available over VNC; open the app from the desktop UI with click/type.' } };
535
+ default:
536
+ return { id: null, error: { code: 'unknown_method', message: `vnc backend has no method '${method}'` } };
537
+ }
538
+ }
539
+ catch (e) {
540
+ return { id: null, error: { code: 'vnc_error', message: e.message } };
541
+ }
542
+ }
543
+ async close() {
544
+ if (this.closed)
545
+ return;
546
+ this.closed = true;
547
+ const sock = this.sock;
548
+ if (!sock)
549
+ return;
550
+ sock.end();
551
+ // Await real teardown (like TcpClient.close), but never hang on it.
552
+ await new Promise((resolve) => {
553
+ let done = false;
554
+ const fin = () => { if (!done) {
555
+ done = true;
556
+ resolve();
557
+ } };
558
+ sock.once('close', fin);
559
+ setTimeout(fin, 2000);
560
+ });
561
+ }
562
+ }
@@ -32,6 +32,9 @@ export class AuthSyncService extends BasePeriodicService {
32
32
  ctx.log('WARN', `auth-sync: ${transport.skipped}`);
33
33
  if (transport.error)
34
34
  ctx.log('WARN', `auth-sync: shared-store transport: ${transport.error}`);
35
+ if (transport.untrackedBackedUp?.length) {
36
+ ctx.log('WARN', `auth-sync: backed up ${transport.untrackedBackedUp.length} untracked shared-store collision(s) to ${transport.untrackedBackupDir}: ${transport.untrackedBackedUp.join(', ')}`);
37
+ }
35
38
  if (!transport.success)
36
39
  return;
37
40
  const result = await syncReservedAuthBundle();
@@ -271,7 +271,7 @@ function acquireStartLock() {
271
271
  */
272
272
  const STOP_LOCK_WAIT_MS = 10_000;
273
273
  const STOP_LOCK_POLL_MS = 50;
274
- function acquireStopLock() {
274
+ function acquireLifecycleLock() {
275
275
  const deadline = Date.now() + STOP_LOCK_WAIT_MS;
276
276
  for (;;) {
277
277
  const release = acquireStartLock();
@@ -481,14 +481,13 @@ export function isDaemonRunning() {
481
481
  * liveness check and the write.
482
482
  */
483
483
  export function claimDaemonInstance() {
484
- const release = acquireStartLock();
485
- // acquireStartLock() returns null only when another __daemon-run currently
486
- // holds the O_EXCL lock — a dead holder's lock is reclaimed and retried inside
487
- // acquireStartLock, so null means a *live* claimer is mid-claim. Bail rather
488
- // than run the read-evict-write unlocked: otherwise two first-start processes
489
- // could each see no pid file (before either writes one) and both claim,
490
- // running the concurrent JobScheduler this guard exists to prevent. The live
491
- // claimer we bailed for becomes the singleton, so last-wins still holds.
484
+ // A stop owns this same lock through teardown. Waiting here is load-bearing:
485
+ // returning false while stopDaemon() holds it lets this replacement exit 0,
486
+ // then the stop completes with no singleton left alive. The bounded lifecycle
487
+ // acquisition also preserves concurrent-start serialization: after the first
488
+ // claimer publishes its pid, the waiter takes the lock and performs the normal
489
+ // last-wins takeover rather than ever running the read-evict-write unlocked.
490
+ const release = acquireLifecycleLock();
492
491
  if (!release)
493
492
  return false;
494
493
  try {
@@ -2205,7 +2204,7 @@ export function findSurvivingStateDirDaemons(exclude) {
2205
2204
  * never reports success on an unverified stop.
2206
2205
  */
2207
2206
  export function stopDaemon() {
2208
- const releaseLock = acquireStopLock();
2207
+ const releaseLock = acquireLifecycleLock();
2209
2208
  if (!releaseLock) {
2210
2209
  return {
2211
2210
  ok: false,
@@ -48,6 +48,9 @@ export class UsageSyncService extends BasePeriodicService {
48
48
  ctx.log('WARN', `usage-sync: ${transport.skipped}`);
49
49
  if (transport.error)
50
50
  ctx.log('WARN', `usage-sync: shared-store transport: ${transport.error}`);
51
+ if (transport.untrackedBackedUp?.length) {
52
+ ctx.log('WARN', `usage-sync: backed up ${transport.untrackedBackedUp.length} untracked shared-store collision(s) to ${transport.untrackedBackupDir}: ${transport.untrackedBackedUp.join(', ')}`);
53
+ }
51
54
  if (!transport.success)
52
55
  return;
53
56
  const consumed = consumeUsageSnapshotsFromSharedStore();
@@ -18,6 +18,7 @@ import * as yaml from 'yaml';
18
18
  import { exec } from 'child_process';
19
19
  import { getDaemonConfigDir, getRuntimeStateDir } from './state.js';
20
20
  import { atomicWriteFileSync } from './fs-atomic.js';
21
+ import { closeServerBounded } from './secrets/agent.js';
21
22
  import { readAndResolveBundleEnv } from './secrets/bundles.js';
22
23
  import { startWebhookServer, createFileDeliveryStore, waitForListening } from './triggers/webhook.js';
23
24
  import { buildFunnelUpCommand, FUNNEL_PORTS } from './funnel.js';
@@ -195,8 +196,13 @@ export async function startHostedWebhookReceivers(opts) {
195
196
  log('WARN', `webhook ${webhook.source}:${webhook.event} dispatch failed after ack: ${err.message}`);
196
197
  },
197
198
  });
199
+ const sockets = new Set();
200
+ server.on('connection', (socket) => {
201
+ sockets.add(socket);
202
+ socket.once('close', () => sockets.delete(socket));
203
+ });
198
204
  await waitForListening(server);
199
- servers.push(server);
205
+ servers.push({ server, sockets });
200
206
  log('INFO', `webhook receiver bound on 127.0.0.1:${port} (bundle ${receiver.bundle})`);
201
207
  if (receiver.funnel)
202
208
  reconcileFunnel(receiver.funnel.publicPort, port, log);
@@ -210,6 +216,13 @@ export async function startHostedWebhookReceivers(opts) {
210
216
  }
211
217
  return {
212
218
  count: servers.length,
213
- close: () => Promise.all(servers.map((s) => new Promise((resolve) => s.close(() => resolve())))).then(() => undefined),
219
+ close: () => Promise.all(servers.map(async ({ server, sockets }) => {
220
+ // Stop accepting first, then force every persistent HTTP connection
221
+ // closed so keep-alive cannot hold daemon shutdown open indefinitely.
222
+ const closing = closeServerBounded(server);
223
+ for (const socket of sockets)
224
+ socket.destroy();
225
+ await closing;
226
+ })).then(() => undefined),
214
227
  };
215
228
  }
@@ -21,6 +21,18 @@ export interface FleetSharedRepoSyncResult {
21
21
  timedOut: boolean;
22
22
  skipped: string | null;
23
23
  error: string | null;
24
+ /**
25
+ * Untracked working-tree files that collided with origin and were dropped
26
+ * (byte-identical to origin) before rebasing. See clearCollidingUntracked.
27
+ */
28
+ untrackedCleared?: number;
29
+ /**
30
+ * Untracked working-tree files that collided with origin, differed from it,
31
+ * and were moved to untrackedBackupDir before rebasing.
32
+ */
33
+ untrackedBackedUp?: string[];
34
+ /** Directory the differing untracked collisions were moved to, if any. */
35
+ untrackedBackupDir?: string;
24
36
  }
25
37
  /** Run one real process asynchronously with a hard wall-clock/process-tree bound. */
26
38
  export declare function runBoundedProcess(command: string, args: string[], options: {
@@ -161,6 +161,72 @@ function retainedAutostash(stashList, objectId) {
161
161
  ref: null,
162
162
  };
163
163
  }
164
+ /**
165
+ * `git rebase` checks out its base (origin/<branch>) and aborts when an
166
+ * untracked working-tree file would be overwritten by a file that origin
167
+ * tracks. `--autostash` only sets aside *tracked* changes, so these untracked
168
+ * collisions wedge the rebase on every sync and the device silently falls
169
+ * hundreds of commits behind — the fleet-wide drift root cause (PHNX-3923).
170
+ *
171
+ * In this shared-state repo an untracked file that also exists on origin is a
172
+ * stale local snapshot: the canonical copy is on origin and every device
173
+ * republishes its own state (this device's own owned file is already committed
174
+ * before we get here, so it is tracked and never appears below). Drop the ones
175
+ * byte-identical to origin (lossless) and move any that differ into a backup
176
+ * dir beside the repo root — outside the repo's tracked tree, so the move
177
+ * cannot create a fresh collision — so nothing is silently destroyed. Then let
178
+ * the rebase check out origin's version. A file we cannot clear is left in
179
+ * place; the rebase may still abort, no worse than today and never losing data.
180
+ */
181
+ async function clearCollidingUntracked(git, root, branch) {
182
+ const others = await git(['ls-files', '--others', '--exclude-standard', '-z']);
183
+ if (others.code !== 0)
184
+ return { error: others };
185
+ const relPaths = others.stdout.split('\0').filter(Boolean);
186
+ let cleared = 0;
187
+ const backedUp = [];
188
+ let backupDir = null;
189
+ for (const rel of relPaths) {
190
+ // rev-parse doubles as the "does origin track this path" test (non-zero =
191
+ // absent) — only paths origin tracks are overwritten by the rebase checkout.
192
+ const originHash = await git(['rev-parse', `origin/${branch}:${rel}`]);
193
+ if (originHash.code !== 0 || !originHash.stdout.trim())
194
+ continue;
195
+ const abs = path.join(root, rel);
196
+ // Byte-exact identity via git blob SHAs. Comparing UTF-8-decoded strings
197
+ // would collapse distinct invalid bytes to U+FFFD and could delete
198
+ // non-identical content. `--no-filters` hashes the raw on-disk bytes (no
199
+ // clean/autocrlf normalization), so `identical` is true only when the local
200
+ // bytes exactly equal origin's stored blob — never a filter-normalized
201
+ // near-match. A hash-object failure (unreadable / vanished under a
202
+ // concurrent writer) means "leave it in place".
203
+ const localHash = await git(['hash-object', '--no-filters', '--', abs]);
204
+ if (localHash.code !== 0 || !localHash.stdout.trim())
205
+ continue;
206
+ const identical = originHash.stdout.trim() === localHash.stdout.trim();
207
+ try {
208
+ if (identical) {
209
+ fs.rmSync(abs, { force: true });
210
+ cleared++;
211
+ }
212
+ else {
213
+ if (!backupDir) {
214
+ // A sibling of the repo root: outside the tracked tree (so the move
215
+ // cannot create a fresh collision) yet colocated for easy recovery.
216
+ backupDir = path.join(`${root}-fleet-sync-backups`, String(Date.now()));
217
+ }
218
+ const dest = path.join(backupDir, rel);
219
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
220
+ fs.renameSync(abs, dest);
221
+ backedUp.push(rel);
222
+ }
223
+ }
224
+ catch {
225
+ // Could not clear this collision; leave it in place.
226
+ }
227
+ }
228
+ return { cleared, backedUp, backupDir };
229
+ }
164
230
  async function performFleetSharedRepoSync(root, device, timeoutMs) {
165
231
  if (!fs.existsSync(path.join(root, '.git'))) {
166
232
  return {
@@ -201,29 +267,57 @@ async function performFleetSharedRepoSync(root, device, timeoutMs) {
201
267
  }
202
268
  const ownedFile = fleetSharedStatePath(device, root);
203
269
  const relativeOwnedFile = path.relative(root, ownedFile).split(path.sep).join('/');
270
+ // The central `agents.yaml` is fleet-shared state too — `agents accounts label`
271
+ // writes version-scoped native account rows there (lib/state.ts writeMetaUnlocked),
272
+ // as a plain file write with no commit. Publishing only the per-device file and
273
+ // then `rebase --autostash`-ing over a dirty central file silently destroyed
274
+ // those labels: every box lost its account labels on its next daemon publish
275
+ // (PHNX-3887). Commit it alongside the device doc so the rebase carries the
276
+ // rows instead of stashing them, which is also what `accounts label --help`
277
+ // already promises ("labels live on the central account rows in agents.yaml,
278
+ // which repo push/pull already syncs fleet-wide").
279
+ const centralFile = path.join(root, 'agents.yaml');
280
+ const publishPaths = [relativeOwnedFile];
281
+ if (fs.existsSync(centralFile))
282
+ publishPaths.push('agents.yaml');
283
+ const existingPaths = publishPaths.filter(rel => fs.existsSync(path.join(root, rel)));
204
284
  let committed = false;
205
- if (fs.existsSync(ownedFile)) {
206
- const status = await git(['status', '--porcelain=v1', '--', relativeOwnedFile]);
285
+ if (existingPaths.length > 0) {
286
+ const status = await git(['status', '--porcelain=v1', '--', ...existingPaths]);
207
287
  if (status.code !== 0)
208
288
  return failure('git status', status);
209
289
  if (status.stdout.trim()) {
210
- const add = await git(['add', '--', relativeOwnedFile]);
290
+ const add = await git(['add', '--', ...existingPaths]);
211
291
  if (add.code !== 0)
212
292
  return failure('git add', add);
213
293
  const commit = await git([
214
294
  '-c', 'commit.gpgsign=false',
215
295
  'commit', '--no-verify', '-m', `chore(devices): publish ${device} daemon state`,
216
- '--', relativeOwnedFile,
296
+ '--', ...existingPaths,
217
297
  ]);
218
298
  if (commit.code !== 0)
219
299
  return failure('git commit', commit);
220
300
  committed = true;
221
301
  }
222
302
  }
303
+ let untrackedCleared = 0;
304
+ const untrackedBackedUp = [];
305
+ let untrackedBackupDir;
223
306
  for (let attempt = 1; attempt <= FLEET_SHARED_REPO_PUSH_ATTEMPTS; attempt++) {
224
307
  const fetch = await git(['fetch', 'origin']);
225
308
  if (fetch.code !== 0)
226
309
  return { ...failure('git fetch', fetch), committed };
310
+ // Untracked files that origin tracks would abort the rebase's checkout
311
+ // (autostash only covers tracked changes). Clear them first — this is the
312
+ // fleet-drift root cause (PHNX-3923).
313
+ const reconcile = await clearCollidingUntracked(git, root, branch);
314
+ if ('error' in reconcile) {
315
+ return { ...failure('git ls-files --others', reconcile.error), committed };
316
+ }
317
+ untrackedCleared += reconcile.cleared;
318
+ untrackedBackedUp.push(...reconcile.backedUp);
319
+ if (reconcile.backupDir)
320
+ untrackedBackupDir = reconcile.backupDir;
227
321
  // Keep enough of the same wall-clock bound available to remove a botched
228
322
  // autostash pop from the operator's live checkout before returning.
229
323
  const rebase = await git(['rebase', '--autostash', `origin/${branch}`], FLEET_SHARED_REPO_REBASE_CLEANUP_RESERVE_MS);
@@ -283,6 +377,9 @@ async function performFleetSharedRepoSync(root, device, timeoutMs) {
283
377
  timedOut: false,
284
378
  skipped: null,
285
379
  error: null,
380
+ untrackedCleared,
381
+ untrackedBackedUp,
382
+ untrackedBackupDir,
286
383
  };
287
384
  }
288
385
  if (attempt === FLEET_SHARED_REPO_PUSH_ATTEMPTS || !isPushRace(push)) {
@@ -124,6 +124,8 @@ export declare function resolvePeerTarget(machine: string): Promise<{
124
124
  * error, timeout, version-skewed peer with no `--json` preview envelope.
125
125
  */
126
126
  export declare function fetchPeerPreviewDigest(sessionId: string, machine: string, timeoutMs?: number): Promise<unknown | undefined>;
127
+ /** Parse the JSON envelope returned by `sessions preview --json`. */
128
+ export declare function parsePeerPreviewDigest(parsed: unknown): unknown | undefined;
127
129
  /**
128
130
  * Run `agents <args>` ON a peer over SSH, attached to this terminal (inherited
129
131
  * stdio). `args` is the full arg vector after the binary — callers pass e.g.
@@ -554,6 +554,10 @@ export async function fetchPeerPreviewDigest(sessionId, machine, timeoutMs = PEE
554
554
  catch {
555
555
  return undefined;
556
556
  }
557
+ return parsePeerPreviewDigest(parsed);
558
+ }
559
+ /** Parse the JSON envelope returned by `sessions preview --json`. */
560
+ export function parsePeerPreviewDigest(parsed) {
557
561
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
558
562
  return undefined;
559
563
  const preview = parsed.preview;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.74",
3
+ "version": "1.22.75",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",