ai-remote 0.4.16 → 0.4.18

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/README.md CHANGED
@@ -91,10 +91,10 @@ Silencing them is a choice made where the command is run:
91
91
  npx --silent ai-remote list # this invocation only
92
92
  NPM_CONFIG_LOGLEVEL=error npx ... # this shell only
93
93
  npm config set loglevel error # this machine, all npm commands
94
- npm install -g ai-remote # no npm in the path at all; use `remotectl`
94
+ npm install -g ai-remote # no npm in the path at all; use `ai-remote`
95
95
  ```
96
96
 
97
- The last one also skips the 300 ms: `remotectl list` answers in about 27 ms. It
97
+ The last one also skips the 300 ms: `ai-remote list` answers in about 27 ms. It
98
98
  is a per-machine decision rather than something the package can carry, which is
99
99
  why `npx` is still what the examples here use.
100
100
 
@@ -216,21 +216,89 @@ npx ai-remote exec "uname -a"
216
216
  npx ai-remote shell # Ctrl-] to leave
217
217
  ```
218
218
 
219
+ ### Modifier keys between a Mac and a PC
220
+
221
+ A Mac's shortcut modifier is Command; Windows and Linux use Control. Send the
222
+ physical key through unchanged and Command-C on a Windows desktop opens the
223
+ Start menu and types a C, because Command is physically the same key as the
224
+ Windows logo key.
225
+
226
+ Microsoft's own Mac client sends it through unchanged and documents the
227
+ consequence: *"The Command key on the Mac keyboard equals the Windows key. To
228
+ perform actions that use the Command button on the Mac, you will need to use the
229
+ control button in Windows (for example Copy = Ctrl+C)."* That is consistent, and
230
+ it is also the thing people complain about most, because it asks the fingers to
231
+ learn a second shortcut modifier for one application.
232
+
233
+ So by default, when one end is a Mac and the other is not, **Control and Command
234
+ swap**:
235
+
236
+ | pressed on the Mac | arrives on the Windows/Linux desktop |
237
+ | --- | --- |
238
+ | Command | Control — `Command-C` copies |
239
+ | Control | the Windows key — `Control-D` shows the desktop |
240
+ | Option | Alt, unchanged |
241
+
242
+ and the mirror when a PC keyboard drives a Mac desktop (the VNC case):
243
+
244
+ | pressed on the PC | arrives on the Mac |
245
+ | --- | --- |
246
+ | Control | Command — `Control-C` copies |
247
+ | Windows key | Control |
248
+ | Alt | Option, unchanged |
249
+
250
+ A swap rather than a redirect, deliberately: it is a bijection, so the Windows
251
+ key is still reachable and nothing becomes impossible to press. Redirecting
252
+ Command to Control and leaving Control alone would give a Mac two Controls and
253
+ no Windows key at all.
254
+
255
+ ```bash
256
+ npx ai-remote open 192.168.1.50 -u owner # adapt, the default
257
+ npx ai-remote open 192.168.1.50 -u owner --keys literal # Microsoft's behaviour
258
+ ```
259
+
260
+ The far end is worked out rather than assumed: RDP is Windows, and for VNC only
261
+ macOS Screen Sharing offers Apple's Diffie-Hellman security type, which is
262
+ already what decides how far a scroll notch goes. Ends that agree -- Mac to Mac,
263
+ PC to PC, Linux to Windows -- are left alone entirely, and `status` reports what
264
+ the mapping is doing under `keys`.
265
+
266
+ **Only the window's live keyboard goes through this.** `key` and `do` name the
267
+ key they want *on the host*, so they are never remapped -- `key MetaLeft` opens
268
+ the Start menu on Windows whatever keyboard is in front of you, and
269
+ `key ControlLeft+KeyC` means Control-C there. Ctrl+Alt+Del is likewise a
270
+ sequence the session synthesizes from real Windows codes, not three keys somebody
271
+ pressed, so the swap does not turn it into Windows+Alt+Del.
272
+
219
273
  ### The clipboard
220
274
 
221
- Off by default, and turned on either from the **clipboard button in the window's
222
- toolbar** or from a command line:
275
+ **On by default.** Text copied on this machine reaches the remote clipboard and
276
+ text copied there reaches this one. The **clipboard button in the window's
277
+ toolbar** is the switch, and so is the command line:
223
278
 
224
279
  ```bash
225
- npx ai-remote clipboard on # both directions, until turned off
226
280
  npx ai-remote clipboard # what it is doing now
227
- npx ai-remote open 192.168.1.50 -u owner --clipboard
281
+ npx ai-remote clipboard off # stop it for this session
282
+ npx ai-remote open 192.168.1.50 -u owner --no-clipboard
228
283
  ```
229
284
 
230
- While it is on, text copied on this machine reaches the remote clipboard and text
231
- copied there reaches this one. Only a desktop carries a clipboard -- RDP
232
- negotiates a `cliprdr` channel and VNC has cut-text in the protocol -- so a
233
- terminal-only session has none and the toolbar button does not appear.
285
+ Only a desktop carries a clipboard -- RDP negotiates a `cliprdr` channel and VNC
286
+ has cut-text in the protocol -- so a terminal-only session has none, the toolbar
287
+ button does not appear, and nothing is started.
288
+
289
+ Two things follow from it being a default rather than a choice, and both are
290
+ worth knowing:
291
+
292
+ - **Everything you copy locally is sent** while a session is open. That is the
293
+ point, and it is also the reason `open` prints one line saying so. If you are
294
+ about to copy something the remote machine should not have, `clipboard off`
295
+ first.
296
+ - **It polls, so it costs something.** No platform gives Node a
297
+ clipboard-change event, so the local side is read twice a second for as long
298
+ as the session is open. Measured on an M4 that is 5.7 ms per read, about 1% of
299
+ one core per session. The rate stays at twice a second rather than being
300
+ slowed to save it, because the failure it prevents -- pasting what was on the
301
+ clipboard *before* the thing you just copied -- is worse than the cost.
234
302
 
235
303
  **The bridge is the session's, not the page's.** The obvious place to put it
236
304
  would be the viewer, using the browser's `navigator.clipboard` -- but a browser
@@ -355,7 +423,9 @@ where a decrypted key belongs, and it is offered from there.
355
423
  - `-s, --security MODE`: `auto` | `nla` | `tls` | `rdp` (default: `auto`).
356
424
  - `-W, --width N` / `-H, --height N`: Desktop resolution (default: 1280x800).
357
425
  - `--idle MINUTES`: Close an unused session (default: 0, never).
358
- - `--clipboard`: Share this machine's clipboard with the desktop, both ways.
426
+ - `--no-clipboard`: Do not share this machine's clipboard. Shared by default.
427
+ - `--keys MODE`: `adapt` (default) swaps Control and Command when one end is a
428
+ Mac and the other is not; `literal` sends every key through as itself.
359
429
  - `--headless` / `--no-view`: Run headless without opening the viewer window.
360
430
  - `--no-reuse`: A second session beside one that is already open.
361
431
  - `--save`: Keep the password for next time, once it has worked.
@@ -369,8 +439,8 @@ where a decrypted key belongs, and it is offered from there.
369
439
  - `--max-edge N`: Shrink a screenshot to fit N.
370
440
  - `--json`: Output machine-readable JSON results on stdout.
371
441
 
372
- The password, where one is wanted, comes from `AI_REMOTE_PASSWORD` (or
373
- `REMOTECTL_PASSWORD`). There is no `--password` flag: a command line is visible
442
+ The password, where one is wanted, comes from `AI_REMOTE_PASSWORD`. There is no
443
+ `--password` flag: a command line is visible
374
444
  to every process on the machine.
375
445
 
376
446
  ---
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ai-remote
3
- description: Drive a remote Windows, Linux or macOS desktop from the command line with `npx ai-remote` (also installed as `remotectl`) — open one session, then screenshot it, click, type, press keys, run shell commands over SSH, and watch it in a window. Use this skill whenever the user wants to control, inspect, automate or screenshot a machine over RDP/VNC/SSH — including when they never name the tool and just say "click the button on that Windows box", "what's on the screen of 192.168.x.x", "log into the VM and run this", or "take a screenshot of the remote desktop".
3
+ description: Drive a remote Windows, Linux or macOS desktop from the command line with `npx ai-remote` — open one session, then screenshot it, click, type, press keys, run shell commands over SSH, and watch it in a window. Use this skill whenever the user wants to control, inspect, automate or screenshot a machine over RDP/VNC/SSH — including when they never name the tool and just say "click the button on that Windows box", "what's on the screen of 192.168.x.x", "log into the VM and run this", or "take a screenshot of the remote desktop".
4
4
  ---
5
5
 
6
6
  # Driving a remote desktop with ai-remote
@@ -114,6 +114,29 @@ screenshot full size and click the coordinates you read off it.
114
114
  image tokens and a 800px one is a fraction of that, usually with no loss of
115
115
  anything you needed to see.
116
116
 
117
+ ## Modifier keys between a Mac and a PC
118
+
119
+ When one end is a Mac and the other is not, **Control and Command swap** so the
120
+ shortcut key the fingers know keeps working:
121
+
122
+ | pressed on a Mac | arrives on Windows/Linux |
123
+ | --- | --- |
124
+ | Command | Control — so Command-C copies |
125
+ | Control | the Windows key |
126
+ | Option | Alt, unchanged |
127
+
128
+ and the mirror when a PC keyboard drives a Mac desktop: Control arrives as
129
+ Command, the Windows key as Control, Alt as Option.
130
+
131
+ `open --keys literal` turns it off and sends every key through as itself, which
132
+ is what Microsoft's own Mac client does — there, Command *is* the Windows key and
133
+ Windows shortcuts want Control.
134
+
135
+ **This applies only to the window's live keyboard.** `key` and `do` name the key
136
+ they want *on the host*, so they are never remapped: `key MetaLeft` opens the
137
+ Start menu on Windows whatever the local keyboard is, and `key ControlLeft+KeyC`
138
+ means Control-C there.
139
+
117
140
  ## Keys
118
141
 
119
142
  Key codes are DOM-style physical codes: `MetaLeft`, `Enter`, `Escape`, `Tab`,
@@ -175,23 +198,24 @@ when the second command should only run if the first worked.
175
198
 
176
199
  ## The clipboard
177
200
 
178
- Off by default. The **clipboard button in the window's toolbar** turns it on, or
179
- from a command line:
201
+ **On by default**, both ways: text copied on this machine reaches the remote
202
+ clipboard and text copied there reaches this one, so a password, a path or a
203
+ stack trace moves without being typed.
180
204
 
181
205
  ```bash
182
- npx ai-remote clipboard on # both directions, until turned off
183
206
  npx ai-remote clipboard # what it is doing now
184
- npx ai-remote open HOST -u USER --clipboard # on from the start
207
+ npx ai-remote clipboard off # stop it for this session
208
+ npx ai-remote open HOST -u USER --no-clipboard # never start it
185
209
  ```
186
210
 
187
- While it is on, text copied on this machine reaches the remote clipboard and
188
- text copied there reaches this one so a password, a path or a stack trace
189
- moves without being typed. Only a desktop has a clipboard: RDP negotiates one
190
- and VNC has it in the protocol, while an `--ssh` session has none and the button
191
- does not appear.
211
+ The toolbar's clipboard button is the same switch. Only a desktop has a
212
+ clipboard RDP negotiates one and VNC has it in the protocol so an `--ssh`
213
+ session has none and the button does not appear.
192
214
 
193
- Turn it off before copying anything on this machine that the remote end should
194
- not see. Off is a real off nothing is read and nothing is written.
215
+ **Tell the user it is on** if they are about to copy something on their own
216
+ machine that the remote end should not see: while sharing is on, everything
217
+ copied locally is sent. `clipboard off` is a real off — nothing is read and
218
+ nothing is written.
195
219
 
196
220
  ## Copying files
197
221
 
@@ -262,8 +286,7 @@ agent.
262
286
  A desktop always wants one; a terminal usually does not, because of the keys
263
287
  above. Three places are tried, in this order:
264
288
 
265
- 1. `AI_REMOTE_PASSWORD` in this shell, or `REMOTECTL_PASSWORD` under the other
266
- name.
289
+ 1. `AI_REMOTE_PASSWORD` in this shell.
267
290
  2. Whatever `--save` kept for that machine.
268
291
  3. You, at the terminal — only when one is attached, and only after the first
269
292
  two have come up empty.
@@ -417,6 +440,7 @@ npx ai-remote do "key MetaLeft; wait 800; type notepad; shot s.png"
417
440
  npx ai-remote exec "command" [--reconnect]
418
441
  npx ai-remote reconnect # a new login: fresh PATH
419
442
  npx ai-remote clipboard [on|off] # share text both ways
443
+ # open --keys adapt|literal modifier mapping
420
444
  npx ai-remote cp [-r] SRC DST # ':path' is on the session
421
445
  npx ai-remote view [--watch-only] [--close]
422
446
  npx ai-remote status | list | close [--all]
@@ -428,19 +452,18 @@ Every command takes `--session NAME` to pick between open sessions, and `--json`
428
452
  for machine-readable output. `ai-remote --help` is the authority; this file is
429
453
  the map.
430
454
 
431
- ## Working inside this repository
455
+ ## Working with the installed package
432
456
 
433
- This repo *is* ai-remote. When testing a change, drive the local build rather
434
- than the published package, or you will be exercising the last release instead
435
- of your edit:
457
+ The published package does not include its TypeScript source tree. The CLI and
458
+ its internal modules are bundled into `dist`, so paths such as
459
+ `packages/core/src/cli/cli.ts` will not exist in an installed copy. Use the
460
+ public command when driving a machine:
436
461
 
437
462
  ```bash
438
- node packages/core/scripts/build.mjs # after editing packages/core/src/cli/*
439
- node packages/core/dist/cli.mjs open HOST -u USER --headless
463
+ npx ai-remote open HOST -u USER --headless
440
464
  ```
441
465
 
442
- The CLI's own source is [`packages/core/src/cli/`](src/cli/):
443
- `cli.ts` is the grammar, `sessions.ts` decides which session a command drives,
444
- `daemon.ts` is the process that holds the connection, `copy.ts` is `cp` over the
445
- SFTP client in [`protocols/ssh/sftp.ts`](src/protocols/ssh/sftp.ts), and
446
- `viewer.ts` plus `window.ts` are the window. `pnpm test` covers them.
466
+ Do not edit or import files under `dist`; they are generated implementation
467
+ artifacts, not public entry points. To change ai-remote itself, work from a
468
+ source checkout, rebuild the package there, and test the generated CLI before
469
+ publishing a new version.
@@ -1,3 +1,6 @@
1
+ // src/cli/generated/version.ts
2
+ var VERSION = "0.4.18";
3
+
1
4
  // src/cli/viewer-layout.ts
2
5
  var TERMINAL_COLUMNS = 80;
3
6
  var TERMINAL_CELL_WIDTH = 7.25;
@@ -48,6 +51,7 @@ var logPath = (name) => join(RUN_DIR, `${name}.log`);
48
51
  var currentPath = () => join(ROOT, "current");
49
52
 
50
53
  export {
54
+ VERSION,
51
55
  SIDE_PANEL_WIDTH,
52
56
  isTitlebarDragPoint,
53
57
  logicalDisplaySize,
@@ -1,8 +1,9 @@
1
1
  import {
2
2
  ROOT,
3
+ VERSION,
3
4
  isTitlebarDragPoint,
4
5
  logicalDisplaySize
5
- } from "./cli-chunk-K2DYJXC5.mjs";
6
+ } from "./cli-chunk-R2ZEHRBG.mjs";
6
7
 
7
8
  // src/cli/window.ts
8
9
  import { spawn } from "node:child_process";
@@ -208,7 +209,8 @@ var ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><def
208
209
  // src/cli/bundle.ts
209
210
  var APP_NAME = "ai-remote";
210
211
  var windowsDir = () => join(ROOT, "window");
211
- var bundleDir = () => join(windowsDir(), `v${ICON_VERSION}`, `${APP_NAME}.app`);
212
+ var bundleName = () => `v${VERSION}-${ICON_VERSION}`;
213
+ var bundleDir = () => join(windowsDir(), bundleName(), `${APP_NAME}.app`);
212
214
  var PLIST = `<?xml version="1.0" encoding="UTF-8"?>
213
215
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
214
216
  <plist version="1.0">
@@ -220,8 +222,8 @@ var PLIST = `<?xml version="1.0" encoding="UTF-8"?>
220
222
  <key>CFBundleIconFile</key><string>${APP_NAME}</string>
221
223
  <key>CFBundlePackageType</key><string>APPL</string>
222
224
  <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
223
- <key>CFBundleShortVersionString</key><string>1.0</string>
224
- <key>CFBundleVersion</key><string>${ICON_VERSION}</string>
225
+ <key>CFBundleShortVersionString</key><string>${VERSION}</string>
226
+ <key>CFBundleVersion</key><string>${VERSION}</string>
225
227
  <key>NSHighResolutionCapable</key><true/>
226
228
  <key>LSApplicationCategoryType</key><string>public.app-category.utilities</string>
227
229
  </dict>
@@ -248,7 +250,7 @@ function build(app, executable) {
248
250
  writeFileSync(join(contents, "Resources", `${APP_NAME}.icns`), iconIcns());
249
251
  symlinkSync(process.execPath, executable);
250
252
  for (const old of readdirSync(windowsDir())) {
251
- if (old !== `v${ICON_VERSION}`) rmSync(join(windowsDir(), old), { recursive: true, force: true });
253
+ if (old !== bundleName()) rmSync(join(windowsDir(), old), { recursive: true, force: true });
252
254
  }
253
255
  }
254
256
 
@@ -7,13 +7,13 @@ import {
7
7
  downscale,
8
8
  encodePng,
9
9
  openWindow
10
- } from "./cli-chunk-7UHHVT7Q.mjs";
10
+ } from "./cli-chunk-SGEG6DMQ.mjs";
11
11
  import {
12
12
  SIDE_PANEL_WIDTH,
13
13
  ensureRunDir,
14
14
  metaPath,
15
15
  socketPath
16
- } from "./cli-chunk-K2DYJXC5.mjs";
16
+ } from "./cli-chunk-R2ZEHRBG.mjs";
17
17
  import {
18
18
  Shell,
19
19
  bareUsername
@@ -7555,7 +7555,7 @@ var WebSocketPeer = class extends EventTarget {
7555
7555
  this.dispatchEvent(new Event("close"));
7556
7556
  }
7557
7557
  #onData(chunk) {
7558
- if (process.env.REMOTECTL_DEBUG) console.log("[ws] data", chunk.length, "bytes");
7558
+ if (process.env.AI_REMOTE_DEBUG) console.log("[ws] data", chunk.length, "bytes");
7559
7559
  this.#buffer = Buffer.concat([this.#buffer, chunk]);
7560
7560
  for (; ; ) {
7561
7561
  const parsed = readFrame(this.#buffer);
@@ -7861,7 +7861,8 @@ async function startViewer(session, port, {
7861
7861
  watchOnly = false,
7862
7862
  title,
7863
7863
  host,
7864
- clipboard
7864
+ clipboard,
7865
+ mapKey = (code) => code
7865
7866
  } = {}) {
7866
7867
  const token = randomBytes2(16).toString("hex");
7867
7868
  const peers = /* @__PURE__ */ new Set();
@@ -7974,7 +7975,7 @@ async function startViewer(session, port, {
7974
7975
  if (clipboard) void clipboard.state().then((state) => sendClipboard(state, peer));
7975
7976
  peer.addEventListener("message", (event) => {
7976
7977
  const raw = event.data;
7977
- if (process.env.REMOTECTL_DEBUG) console.log("[viewer] <-", raw);
7978
+ if (process.env.AI_REMOTE_DEBUG) console.log("[viewer] <-", raw);
7978
7979
  try {
7979
7980
  const message = JSON.parse(raw);
7980
7981
  if (message.type === "control") {
@@ -8006,7 +8007,7 @@ async function startViewer(session, port, {
8006
8007
  shellFor.get(peer)?.resize(size.columns, size.rows);
8007
8008
  return;
8008
8009
  }
8009
- if (session) applyInput(session, message);
8010
+ if (session) applyInput(session, message, mapKey);
8010
8011
  } catch (error) {
8011
8012
  console.warn("[viewer] dropped an unreadable message", error);
8012
8013
  }
@@ -8121,7 +8122,7 @@ async function startViewer(session, port, {
8121
8122
  }
8122
8123
  };
8123
8124
  }
8124
- function applyInput(session, message) {
8125
+ function applyInput(session, message, mapKey) {
8125
8126
  switch (message.type) {
8126
8127
  case "move":
8127
8128
  session.movePointer(message.x, message.y);
@@ -8133,7 +8134,7 @@ function applyInput(session, message) {
8133
8134
  session.scroll(message.x, message.y, message.dx ?? 0, message.dy ?? 0);
8134
8135
  break;
8135
8136
  case "key":
8136
- session.sendKeyCode(message.code, Boolean(message.pressed));
8137
+ session.sendKeyCode(mapKey(String(message.code)), Boolean(message.pressed));
8137
8138
  break;
8138
8139
  case "text":
8139
8140
  session.typeText(String(message.text ?? ""));
@@ -8325,6 +8326,43 @@ var ClipboardBridge = class {
8325
8326
  }
8326
8327
  };
8327
8328
 
8329
+ // src/cli/keys.ts
8330
+ function platformOf(nodePlatform) {
8331
+ if (nodePlatform === "darwin") return "macos";
8332
+ if (nodePlatform === "win32") return "windows";
8333
+ return "other";
8334
+ }
8335
+ var SWAP = {
8336
+ MetaLeft: "ControlLeft",
8337
+ MetaRight: "ControlRight",
8338
+ ControlLeft: "MetaLeft",
8339
+ ControlRight: "MetaRight"
8340
+ };
8341
+ function needsSwap(local, remote) {
8342
+ if (remote === "none" || local === "none") return false;
8343
+ return local === "macos" !== (remote === "macos");
8344
+ }
8345
+ function keyMapper(options) {
8346
+ if ((options.style ?? "adapt") === "literal" || !needsSwap(options.local, options.remote)) {
8347
+ return (code) => code;
8348
+ }
8349
+ return (code) => SWAP[code] ?? code;
8350
+ }
8351
+ function describeKeys(options) {
8352
+ const style = options.style ?? "adapt";
8353
+ const { local, remote } = options;
8354
+ if (remote === "none") {
8355
+ return "No desktop, so no keyboard to map: a terminal session types into a shell.";
8356
+ }
8357
+ if (!needsSwap(local, remote)) {
8358
+ return "Keyboard sent through as it is: both ends use the same shortcut modifier.";
8359
+ }
8360
+ if (style === "literal") {
8361
+ return local === "macos" ? "Keyboard sent through unchanged: Command is the Windows key, so shortcuts want Control (Copy is Control-C). `--keys adapt` swaps them." : "Keyboard sent through unchanged: Control is Control, and the Windows key is Command. `--keys adapt` swaps them.";
8362
+ }
8363
+ return local === "macos" ? "Mac keyboard on a non-Mac desktop: Command acts as Control (so Command-C copies) and Control acts as the Windows key. `--keys literal` sends them through as they are." : "Non-Mac keyboard on a Mac desktop: Control acts as Command (so Control-C copies) and the Windows key acts as Control. `--keys literal` sends them through as they are.";
8364
+ }
8365
+
8328
8366
  // src/cli/daemon.ts
8329
8367
  async function runDaemon(options) {
8330
8368
  ensureRunDir();
@@ -8337,6 +8375,17 @@ async function runDaemon(options) {
8337
8375
  log: (message) => console.log(message)
8338
8376
  });
8339
8377
  let unwatchClipboard = null;
8378
+ function remotePlatform() {
8379
+ if (protocol === "rdp") return "windows";
8380
+ if (protocol === "vnc") return session?.client?.appleServer ? "macos" : "other";
8381
+ return "none";
8382
+ }
8383
+ const keyStyle = options.keys === "literal" ? "literal" : "adapt";
8384
+ const mapKey = (code) => keyMapper({
8385
+ local: platformOf(process.platform),
8386
+ remote: remotePlatform(),
8387
+ style: keyStyle
8388
+ })(code);
8340
8389
  function openDesktop(kind, password = options.password, port = desktopPort) {
8341
8390
  if (kind === "rdp") return new RdpSession(options);
8342
8391
  if (kind === "vnc") {
@@ -8445,6 +8494,7 @@ async function runDaemon(options) {
8445
8494
  sidePanel,
8446
8495
  watchOnly,
8447
8496
  host: options.host,
8497
+ mapKey,
8448
8498
  clipboard: {
8449
8499
  state: () => clipboard.state(),
8450
8500
  // The switch belongs to the session, so a viewer that moves it tells
@@ -8556,6 +8606,18 @@ async function runDaemon(options) {
8556
8606
  viewers: viewer?.viewers ?? 0,
8557
8607
  shell: shell ? "open" : shellStarting ? "opening" : "closed",
8558
8608
  clipboard: await clipboard.state(),
8609
+ keys: {
8610
+ style: keyStyle,
8611
+ local: platformOf(process.platform),
8612
+ remote: remotePlatform(),
8613
+ /** True only when a key is actually being rewritten. */
8614
+ swapped: keyStyle === "adapt" && needsSwap(platformOf(process.platform), remotePlatform()),
8615
+ describe: describeKeys({
8616
+ local: platformOf(process.platform),
8617
+ remote: remotePlatform(),
8618
+ style: keyStyle
8619
+ })
8620
+ },
8559
8621
  watchOnly: options.watchOnly,
8560
8622
  sshUser: options.sshUsername || bareUsername(options.username),
8561
8623
  sshPort: options.sshPort,
@@ -8635,6 +8697,16 @@ async function runDaemon(options) {
8635
8697
  await new Promise((resolve) => server.listen(path, resolve));
8636
8698
  writeMeta();
8637
8699
  if (session) watchDesktop(session);
8700
+ if (session) console.log(`[keys] ${describeKeys({
8701
+ local: platformOf(process.platform),
8702
+ remote: remotePlatform(),
8703
+ style: keyStyle
8704
+ })}`);
8705
+ if (session && options.clipboard !== false) {
8706
+ await clipboard.set(true).catch((error) => {
8707
+ console.log(`[clipboard] not shared (${error instanceof Error ? error.message : String(error)})`);
8708
+ });
8709
+ }
8638
8710
  const idleTimer = options.idleMs > 0 ? setInterval(() => {
8639
8711
  if (Date.now() - lastUsed > options.idleMs) shutdown("idle");
8640
8712
  }, 3e4) : null;
@@ -4,8 +4,8 @@ import {
4
4
  openWindow,
5
5
  readCurrentDisplaySize,
6
6
  runWindow
7
- } from "./cli-chunk-7UHHVT7Q.mjs";
8
- import "./cli-chunk-K2DYJXC5.mjs";
7
+ } from "./cli-chunk-SGEG6DMQ.mjs";
8
+ import "./cli-chunk-R2ZEHRBG.mjs";
9
9
  export {
10
10
  detectCurrentDisplaySize,
11
11
  nativeWindowAvailable,
package/dist/cli.mjs CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  import {
6
6
  RUN_DIR,
7
7
  SIDE_PANEL_WIDTH,
8
+ VERSION,
8
9
  currentPath,
9
10
  ensureRunDir,
10
11
  fullscreenRdpSize,
@@ -12,10 +13,10 @@ import {
12
13
  metaPath,
13
14
  sessionName,
14
15
  socketPath
15
- } from "./cli-chunk-K2DYJXC5.mjs";
16
+ } from "./cli-chunk-R2ZEHRBG.mjs";
16
17
 
17
18
  // src/cli/cli.ts
18
- import { basename, resolve as resolvePath } from "node:path";
19
+ import { resolve as resolvePath } from "node:path";
19
20
  import { readFileSync as readFileSync2, openSync } from "node:fs";
20
21
 
21
22
  // src/cli/prompt.ts
@@ -149,10 +150,7 @@ function readLog(name) {
149
150
  return "";
150
151
  }
151
152
  }
152
- var NAME = (() => {
153
- const invoked = basename(process.argv[1] ?? "").replace(/\.mjs$/, "");
154
- return invoked === "ai-remote" || invoked === "remotectl" ? invoked : "ai-remote";
155
- })();
153
+ var NAME = "ai-remote";
156
154
  var USAGE = `${NAME} -- drive a remote desktop, with a window you can watch
157
155
 
158
156
  One command names the machine:
@@ -184,6 +182,7 @@ The rest drive that session, and take no host:
184
182
  ${NAME} forget [<host[:port]>] throw one away; --all throws all
185
183
  ${NAME} close close it; --all closes every session
186
184
  ${NAME} probe [<host[:port]>] is anything listening?
185
+ ${NAME} --version which version this is
187
186
 
188
187
  Copying files, like scp but with the session standing in for the host:
189
188
  ${NAME} cp ./setup.exe :C:/Users/owner/Desktop/ to the session in hand
@@ -219,7 +218,11 @@ Options for open
219
218
  --fullscreen fill the display; RDP uses its current resolution
220
219
  --side-panel open the SSH terminal docked on the right
221
220
  --audio play the remote computer's sound in the window (RDP)
222
- --clipboard share this machine's clipboard with the desktop
221
+ --no-clipboard do not share this machine's clipboard (shared by default)
222
+ --keys MODE adapt | literal (default: adapt)
223
+ adapt swaps Control and Command when one end is a Mac,
224
+ so Command-C copies on Windows and Control-C copies on
225
+ a Mac; literal sends every key through as itself
223
226
  --view-port N port for the window (default: 7373)
224
227
  --idle MINUTES close an unused session (default: 0, never)
225
228
  --headless no window (same as --no-view)
@@ -244,7 +247,7 @@ Signing in
244
247
  read if they have no passphrase. A desktop -- RDP or VNC -- has no such thing
245
248
  and always wants a password.
246
249
 
247
- The password comes from AI_REMOTE_PASSWORD (or REMOTECTL_PASSWORD), then from
250
+ The password comes from AI_REMOTE_PASSWORD, then from
248
251
  whatever --save kept for that machine, and last from asking you -- once the
249
252
  ways that need no asking have been tried, and only when a terminal is there to
250
253
  be asked. There is no --password flag: a command line is visible to every
@@ -281,7 +284,8 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
281
284
  "ssh-port",
282
285
  "settle",
283
286
  "vnc-port",
284
- "port"
287
+ "port",
288
+ "keys"
285
289
  ]);
286
290
  function parse(argv) {
287
291
  const flags = {};
@@ -331,7 +335,7 @@ var CliError = class extends Error {
331
335
  }
332
336
  code;
333
337
  };
334
- var password = () => process.env.AI_REMOTE_PASSWORD ?? process.env.REMOTECTL_PASSWORD ?? "";
338
+ var password = () => process.env.AI_REMOTE_PASSWORD ?? "";
335
339
  function splitKey(key) {
336
340
  const at = key.lastIndexOf("@");
337
341
  return at === -1 ? ["", key] : [key.slice(0, at), key.slice(at + 1)];
@@ -432,7 +436,7 @@ async function startSession(args, name, host, port, secret) {
432
436
  const explicitHeight = flag(args, "H", "height");
433
437
  let fullscreenSize = null;
434
438
  if (mode === "rdp" && has(args, "fullscreen") && (!explicitWidth || !explicitHeight)) {
435
- const { detectCurrentDisplaySize } = await import("./cli-window-LN32ONC4.mjs");
439
+ const { detectCurrentDisplaySize } = await import("./cli-window-B6ICRJVI.mjs");
436
440
  const display = await detectCurrentDisplaySize();
437
441
  if (display) fullscreenSize = fullscreenRdpSize(display, has(args, "side-panel"));
438
442
  }
@@ -468,12 +472,15 @@ async function startSession(args, name, host, port, secret) {
468
472
  // Zero unless asked for: a session stays open until it is closed. An agent
469
473
  // that pauses to think is not an agent that has finished.
470
474
  "--idle",
471
- flag(args, "idle") ?? "0"
475
+ flag(args, "idle") ?? "0",
476
+ "--keys",
477
+ flag(args, "keys") ?? "adapt"
472
478
  ];
473
479
  if (mode !== "rdp") daemonArgs.push(`--${mode}`);
474
480
  for (const identity of args.identities) daemonArgs.push("-i", identity);
475
481
  if (has(args, "no-view", "headless")) daemonArgs.push("--no-view");
476
482
  if (has(args, "no-open")) daemonArgs.push("--no-open");
483
+ if (has(args, "no-clipboard")) daemonArgs.push("--no-clipboard");
477
484
  if (has(args, "watch-only", "observe")) daemonArgs.push("--watch-only");
478
485
  if (has(args, "tab")) daemonArgs.push("--tab");
479
486
  if (has(args, "fullscreen")) daemonArgs.push("--fullscreen");
@@ -541,13 +548,18 @@ function sessionFailure(name, died, secret, mode) {
541
548
  }
542
549
  async function main() {
543
550
  const args = parse(process.argv.slice(2));
551
+ if (has(args, "v", "version") || args.command === "version") {
552
+ process.stdout.write(`${NAME} ${VERSION}
553
+ `);
554
+ return;
555
+ }
544
556
  if (!args.command || has(args, "h", "help")) {
545
557
  process.stdout.write(USAGE);
546
558
  return;
547
559
  }
548
560
  const json = has(args, "json");
549
561
  if (args.command === "__display-size") {
550
- const { readCurrentDisplaySize } = await import("./cli-window-LN32ONC4.mjs");
562
+ const { readCurrentDisplaySize } = await import("./cli-window-B6ICRJVI.mjs");
551
563
  const size = await readCurrentDisplaySize();
552
564
  if (size) process.stdout.write(`${JSON.stringify(size)}
553
565
  `);
@@ -556,7 +568,7 @@ async function main() {
556
568
  if (args.command === "__session") {
557
569
  const mode = modeFor(args, args.rest[0]);
558
570
  const { host, port } = splitTarget(args.rest[0] ?? "", defaultPort(mode));
559
- const { runDaemon } = await import("./cli-daemon-4VU67MLE.mjs");
571
+ const { runDaemon } = await import("./cli-daemon-6XQDFK3P.mjs");
560
572
  await runDaemon({
561
573
  name: sessionName(host, port, flag(args, "name", "session")),
562
574
  host,
@@ -582,12 +594,14 @@ async function main() {
582
594
  // RDP is the only protocol here with an audio channel, and a session with
583
595
  // no window has nowhere to play what the channel would carry.
584
596
  audio: has(args, "audio", "sound") && mode === "rdp" && !has(args, "no-view", "headless"),
585
- idleMs: Math.max(0, Number(flag(args, "idle") ?? 0)) * 6e4
597
+ idleMs: Math.max(0, Number(flag(args, "idle") ?? 0)) * 6e4,
598
+ keys: flag(args, "keys") === "literal" ? "literal" : "adapt",
599
+ clipboard: !has(args, "no-clipboard")
586
600
  });
587
601
  return;
588
602
  }
589
603
  if (args.command === "__window") {
590
- const { runWindow } = await import("./cli-window-LN32ONC4.mjs");
604
+ const { runWindow } = await import("./cli-window-B6ICRJVI.mjs");
591
605
  await runWindow(process.argv.slice(3));
592
606
  return;
593
607
  }
@@ -634,6 +648,10 @@ Saved, nothing open (\`${NAME} open <host>\` needs no password):
634
648
  if (args.command === "open") {
635
649
  const target = args.rest[0];
636
650
  if (!target) throw new CliError(`Name the machine to connect to: \`${NAME} open <host[:port]>\``, 12);
651
+ const keys = flag(args, "keys");
652
+ if (keys && keys !== "adapt" && keys !== "literal") {
653
+ throw new CliError(`--keys takes "adapt" or "literal", not "${keys}".`, 12);
654
+ }
637
655
  const mode = modeFor(args, target);
638
656
  const { host, port } = splitTarget(target, defaultPort(mode));
639
657
  const asked = flag(args, "session", "name");
@@ -679,12 +697,10 @@ Saved, nothing open (\`${NAME} open <host>\` needs no password):
679
697
  sidePanel: has(args, "side-panel")
680
698
  }));
681
699
  }
682
- if (has(args, "clipboard")) {
683
- const shared = await ask(name2, "clipboard", { on: true });
684
- if (!shared?.available) {
685
- process.stderr.write("The clipboard cannot be shared with this session (it needs a desktop at the far end and a clipboard tool here).\n");
686
- }
687
- Object.assign(status, { clipboard: shared });
700
+ const shared = await ask(name2, "clipboard", {});
701
+ Object.assign(status, { clipboard: shared });
702
+ if (has(args, "clipboard") && !shared?.available) {
703
+ process.stderr.write("The clipboard cannot be shared with this session (it needs a desktop at the far end and a clipboard tool here).\n");
688
704
  }
689
705
  setCurrent(name2);
690
706
  report(json, "open", {
@@ -703,6 +719,14 @@ Saved, nothing open (\`${NAME} open <host>\` needs no password):
703
719
  if (prompted && !saved) {
704
720
  process.stderr.write("Add --save next time to keep that password.\n");
705
721
  }
722
+ if (status.keys?.swapped && status.keys?.describe) {
723
+ process.stderr.write(`${status.keys.describe}
724
+ `);
725
+ }
726
+ if (status.clipboard?.on) {
727
+ process.stderr.write(`Clipboard shared both ways (${status.clipboard.tool}). \`${NAME} clipboard off\` stops it; --no-clipboard opens without it.
728
+ `);
729
+ }
706
730
  return;
707
731
  }
708
732
  if (args.command === "probe") {
package/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "ai-remote",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
4
4
  "description": "RDP, VNC and SSH protocol engines that run in Node, a Worker or a browser -- plus `npx ai-remote`, a CLI that drives a machine and shows you what it is doing",
5
5
  "type": "module",
6
6
  "bin": {
7
- "ai-remote": "dist/cli.mjs",
8
- "remotectl": "dist/cli.mjs"
7
+ "ai-remote": "dist/cli.mjs"
9
8
  },
10
9
  "license": "MIT",
11
10
  "author": {