ai-remote 0.4.16 → 0.4.17

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
@@ -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.
package/SKILL.md CHANGED
@@ -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
 
@@ -417,6 +441,7 @@ npx ai-remote do "key MetaLeft; wait 800; type notepad; shot s.png"
417
441
  npx ai-remote exec "command" [--reconnect]
418
442
  npx ai-remote reconnect # a new login: fresh PATH
419
443
  npx ai-remote clipboard [on|off] # share text both ways
444
+ # open --keys adapt|literal modifier mapping
420
445
  npx ai-remote cp [-r] SRC DST # ':path' is on the session
421
446
  npx ai-remote view [--watch-only] [--close]
422
447
  npx ai-remote status | list | close [--all]
@@ -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-ZQ32C4LC.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
 
@@ -1,3 +1,6 @@
1
+ // src/cli/generated/version.ts
2
+ var VERSION = "0.4.17";
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,
@@ -7,13 +7,13 @@ import {
7
7
  downscale,
8
8
  encodePng,
9
9
  openWindow
10
- } from "./cli-chunk-7UHHVT7Q.mjs";
10
+ } from "./cli-chunk-DGHEB4P5.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-ZQ32C4LC.mjs";
17
17
  import {
18
18
  Shell,
19
19
  bareUsername
@@ -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();
@@ -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-DGHEB4P5.mjs";
8
+ import "./cli-chunk-ZQ32C4LC.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,7 +13,7 @@ import {
12
13
  metaPath,
13
14
  sessionName,
14
15
  socketPath
15
- } from "./cli-chunk-K2DYJXC5.mjs";
16
+ } from "./cli-chunk-ZQ32C4LC.mjs";
16
17
 
17
18
  // src/cli/cli.ts
18
19
  import { basename, resolve as resolvePath } from "node:path";
@@ -184,6 +185,7 @@ The rest drive that session, and take no host:
184
185
  ${NAME} forget [<host[:port]>] throw one away; --all throws all
185
186
  ${NAME} close close it; --all closes every session
186
187
  ${NAME} probe [<host[:port]>] is anything listening?
188
+ ${NAME} --version which version this is
187
189
 
188
190
  Copying files, like scp but with the session standing in for the host:
189
191
  ${NAME} cp ./setup.exe :C:/Users/owner/Desktop/ to the session in hand
@@ -219,7 +221,11 @@ Options for open
219
221
  --fullscreen fill the display; RDP uses its current resolution
220
222
  --side-panel open the SSH terminal docked on the right
221
223
  --audio play the remote computer's sound in the window (RDP)
222
- --clipboard share this machine's clipboard with the desktop
224
+ --no-clipboard do not share this machine's clipboard (shared by default)
225
+ --keys MODE adapt | literal (default: adapt)
226
+ adapt swaps Control and Command when one end is a Mac,
227
+ so Command-C copies on Windows and Control-C copies on
228
+ a Mac; literal sends every key through as itself
223
229
  --view-port N port for the window (default: 7373)
224
230
  --idle MINUTES close an unused session (default: 0, never)
225
231
  --headless no window (same as --no-view)
@@ -281,7 +287,8 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
281
287
  "ssh-port",
282
288
  "settle",
283
289
  "vnc-port",
284
- "port"
290
+ "port",
291
+ "keys"
285
292
  ]);
286
293
  function parse(argv) {
287
294
  const flags = {};
@@ -432,7 +439,7 @@ async function startSession(args, name, host, port, secret) {
432
439
  const explicitHeight = flag(args, "H", "height");
433
440
  let fullscreenSize = null;
434
441
  if (mode === "rdp" && has(args, "fullscreen") && (!explicitWidth || !explicitHeight)) {
435
- const { detectCurrentDisplaySize } = await import("./cli-window-LN32ONC4.mjs");
442
+ const { detectCurrentDisplaySize } = await import("./cli-window-H5VHVL4I.mjs");
436
443
  const display = await detectCurrentDisplaySize();
437
444
  if (display) fullscreenSize = fullscreenRdpSize(display, has(args, "side-panel"));
438
445
  }
@@ -468,12 +475,15 @@ async function startSession(args, name, host, port, secret) {
468
475
  // Zero unless asked for: a session stays open until it is closed. An agent
469
476
  // that pauses to think is not an agent that has finished.
470
477
  "--idle",
471
- flag(args, "idle") ?? "0"
478
+ flag(args, "idle") ?? "0",
479
+ "--keys",
480
+ flag(args, "keys") ?? "adapt"
472
481
  ];
473
482
  if (mode !== "rdp") daemonArgs.push(`--${mode}`);
474
483
  for (const identity of args.identities) daemonArgs.push("-i", identity);
475
484
  if (has(args, "no-view", "headless")) daemonArgs.push("--no-view");
476
485
  if (has(args, "no-open")) daemonArgs.push("--no-open");
486
+ if (has(args, "no-clipboard")) daemonArgs.push("--no-clipboard");
477
487
  if (has(args, "watch-only", "observe")) daemonArgs.push("--watch-only");
478
488
  if (has(args, "tab")) daemonArgs.push("--tab");
479
489
  if (has(args, "fullscreen")) daemonArgs.push("--fullscreen");
@@ -541,13 +551,18 @@ function sessionFailure(name, died, secret, mode) {
541
551
  }
542
552
  async function main() {
543
553
  const args = parse(process.argv.slice(2));
554
+ if (has(args, "v", "version") || args.command === "version") {
555
+ process.stdout.write(`${NAME} ${VERSION}
556
+ `);
557
+ return;
558
+ }
544
559
  if (!args.command || has(args, "h", "help")) {
545
560
  process.stdout.write(USAGE);
546
561
  return;
547
562
  }
548
563
  const json = has(args, "json");
549
564
  if (args.command === "__display-size") {
550
- const { readCurrentDisplaySize } = await import("./cli-window-LN32ONC4.mjs");
565
+ const { readCurrentDisplaySize } = await import("./cli-window-H5VHVL4I.mjs");
551
566
  const size = await readCurrentDisplaySize();
552
567
  if (size) process.stdout.write(`${JSON.stringify(size)}
553
568
  `);
@@ -556,7 +571,7 @@ async function main() {
556
571
  if (args.command === "__session") {
557
572
  const mode = modeFor(args, args.rest[0]);
558
573
  const { host, port } = splitTarget(args.rest[0] ?? "", defaultPort(mode));
559
- const { runDaemon } = await import("./cli-daemon-4VU67MLE.mjs");
574
+ const { runDaemon } = await import("./cli-daemon-4YPQAC5X.mjs");
560
575
  await runDaemon({
561
576
  name: sessionName(host, port, flag(args, "name", "session")),
562
577
  host,
@@ -582,12 +597,14 @@ async function main() {
582
597
  // RDP is the only protocol here with an audio channel, and a session with
583
598
  // no window has nowhere to play what the channel would carry.
584
599
  audio: has(args, "audio", "sound") && mode === "rdp" && !has(args, "no-view", "headless"),
585
- idleMs: Math.max(0, Number(flag(args, "idle") ?? 0)) * 6e4
600
+ idleMs: Math.max(0, Number(flag(args, "idle") ?? 0)) * 6e4,
601
+ keys: flag(args, "keys") === "literal" ? "literal" : "adapt",
602
+ clipboard: !has(args, "no-clipboard")
586
603
  });
587
604
  return;
588
605
  }
589
606
  if (args.command === "__window") {
590
- const { runWindow } = await import("./cli-window-LN32ONC4.mjs");
607
+ const { runWindow } = await import("./cli-window-H5VHVL4I.mjs");
591
608
  await runWindow(process.argv.slice(3));
592
609
  return;
593
610
  }
@@ -634,6 +651,10 @@ Saved, nothing open (\`${NAME} open <host>\` needs no password):
634
651
  if (args.command === "open") {
635
652
  const target = args.rest[0];
636
653
  if (!target) throw new CliError(`Name the machine to connect to: \`${NAME} open <host[:port]>\``, 12);
654
+ const keys = flag(args, "keys");
655
+ if (keys && keys !== "adapt" && keys !== "literal") {
656
+ throw new CliError(`--keys takes "adapt" or "literal", not "${keys}".`, 12);
657
+ }
637
658
  const mode = modeFor(args, target);
638
659
  const { host, port } = splitTarget(target, defaultPort(mode));
639
660
  const asked = flag(args, "session", "name");
@@ -679,12 +700,10 @@ Saved, nothing open (\`${NAME} open <host>\` needs no password):
679
700
  sidePanel: has(args, "side-panel")
680
701
  }));
681
702
  }
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 });
703
+ const shared = await ask(name2, "clipboard", {});
704
+ Object.assign(status, { clipboard: shared });
705
+ if (has(args, "clipboard") && !shared?.available) {
706
+ 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
707
  }
689
708
  setCurrent(name2);
690
709
  report(json, "open", {
@@ -703,6 +722,14 @@ Saved, nothing open (\`${NAME} open <host>\` needs no password):
703
722
  if (prompted && !saved) {
704
723
  process.stderr.write("Add --save next time to keep that password.\n");
705
724
  }
725
+ if (status.keys?.swapped && status.keys?.describe) {
726
+ process.stderr.write(`${status.keys.describe}
727
+ `);
728
+ }
729
+ if (status.clipboard?.on) {
730
+ process.stderr.write(`Clipboard shared both ways (${status.clipboard.tool}). \`${NAME} clipboard off\` stops it; --no-clipboard opens without it.
731
+ `);
732
+ }
706
733
  return;
707
734
  }
708
735
  if (args.command === "probe") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-remote",
3
- "version": "0.4.16",
3
+ "version": "0.4.17",
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": {