ai-remote 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +59 -20
  2. package/dist/cli.mjs +311 -130
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,53 +7,92 @@
7
7
 
8
8
  ## 🚀 Quick Start (CLI)
9
9
 
10
- Run immediately with `npx` (zero install):
10
+ Run immediately with `npx` (zero install). `open` names the machine once and
11
+ leaves a session running; every command after it drives that session, so none of
12
+ them repeats the host, the account or the password:
11
13
 
12
14
  ```bash
13
15
  # Connect and open a live supervision window:
14
16
  AI_REMOTE_PASSWORD=secret npx ai-remote open 192.168.1.50 -u Administrator
15
17
 
16
18
  # Take a screenshot:
17
- AI_REMOTE_PASSWORD=secret npx ai-remote shot 192.168.1.50 -u Administrator -o screen.png
19
+ npx ai-remote shot -o screen.png
18
20
 
19
21
  # Click or type on the remote desktop:
20
- npx ai-remote click 192.168.1.50 500,300
21
- npx ai-remote type 192.168.1.50 "notepad"
22
- npx ai-remote key 192.168.1.50 Enter
22
+ npx ai-remote click 500,300
23
+ npx ai-remote type "notepad"
24
+ npx ai-remote key Enter
23
25
 
24
26
  # Run commands in the remote terminal over SSH:
25
- npx ai-remote exec 192.168.1.50 "dir"
27
+ npx ai-remote exec "dir"
28
+
29
+ # Let it go when you are done -- nothing closes on its own:
30
+ npx ai-remote close
26
31
  ```
27
32
 
28
33
  ### CLI Commands
29
34
 
30
35
  ```
31
- ai-remote open <host[:port]> connect, show a window, and stay open
32
- ai-remote shot <host[:port]> screenshot the desktop
33
- ai-remote click <host[:port]> X,Y click, --button right|middle, --double
34
- ai-remote type <host[:port]> "text" type into whatever has focus
35
- ai-remote key <host[:port]> <Code> press keys, e.g. MetaLeft, ControlLeft+KeyA
36
- ai-remote exec <host[:port]> "cmd" run a command in the terminal (SSH)
37
- ai-remote shell <host[:port]> an interactive terminal
38
- ai-remote do <host[:port]> "steps" several actions in one go
39
- ai-remote view <host[:port]> open the window on a running session
40
- ai-remote view <host[:port]> --close close the window, session keeps running
41
- ai-remote status | list | close [<host>]
42
- ai-remote probe <host[:port]> is anything listening?
36
+ ai-remote open <host[:port]> connect, show a window, and stay open
37
+
38
+ ai-remote shot screenshot the desktop
39
+ ai-remote click X,Y click, --button right|middle, --double
40
+ ai-remote type "text" type into whatever has focus
41
+ ai-remote key <Code> press keys, e.g. MetaLeft, ControlLeft+KeyA
42
+ ai-remote exec "cmd" run a command in the terminal (SSH)
43
+ ai-remote shell an interactive terminal
44
+ ai-remote do "steps" several actions in one go
45
+ ai-remote view open the window on the session
46
+ ai-remote view --close close the window, session keeps running
47
+ ai-remote status what the session is doing
48
+ ai-remote list every session that is open
49
+ ai-remote close close it; --all closes every session
50
+ ai-remote probe [<host[:port]>] is anything listening?
51
+ ```
52
+
53
+ ### Sessions
54
+
55
+ The session opened last is the one the commands drive, and it lives until it is
56
+ closed -- including across terminals, so an agent and the person watching it
57
+ reach the same desktop. With more than one open, `--session NAME` picks between
58
+ them; a session answers to its name, its host, or `host:port`.
59
+
60
+ ```bash
61
+ npx ai-remote open 192.168.1.50 -u Administrator --session desk
62
+ npx ai-remote open 192.168.1.60 -u Administrator --session build
63
+
64
+ npx ai-remote list # * marks the one commands will use
65
+ npx ai-remote shot --session desk
66
+ npx ai-remote close --all
43
67
  ```
44
68
 
45
- ### Common Options
69
+ `probe` is the one command besides `open` that still takes an address, because
70
+ it answers whether a session could be opened at all; with no address it probes
71
+ the current session's host.
72
+
73
+ ### Options for `open`
46
74
 
47
75
  - `-u, --user NAME`: Account name on the host.
48
76
  - `-d, --domain NAME`: Windows domain or computer name.
77
+ - `--session NAME`: Name this session (default: `host_port`).
49
78
  - `--ssh-user NAME`: Account for the SSH terminal (defaults to `--user`).
50
79
  - `--ssh-port N`: SSH port (default: 22).
51
80
  - `-s, --security MODE`: `auto` | `nla` | `tls` | `rdp` (default: `auto`).
52
81
  - `-W, --width N` / `-H, --height N`: Desktop resolution (default: 1280x800).
53
- - `-o, --out FILE`: Screenshot output file path (default: `screen.png`).
82
+ - `--idle MINUTES`: Close an unused session (default: 0, never).
54
83
  - `--headless` / `--no-view`: Run headless without opening the viewer window.
84
+ - `--no-reuse`: A second session beside one that is already open.
85
+
86
+ ### Options for the rest
87
+
88
+ - `--session NAME`: Which session to drive (default: the one opened last).
89
+ - `-o, --out FILE`: Screenshot output file path (default: `screen.png`).
90
+ - `--max-edge N`: Shrink a screenshot to fit N.
55
91
  - `--json`: Output machine-readable JSON results on stdout.
56
92
 
93
+ The password comes from `AI_REMOTE_PASSWORD` (or `REMOTECTL_PASSWORD`). There is
94
+ no `--password` flag: a command line is visible to every process on the machine.
95
+
57
96
  ---
58
97
 
59
98
  ## 📦 Library Installation
package/dist/cli.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/cli/cli.ts
4
4
  import { basename, resolve as resolvePath } from "node:path";
5
5
  import { spawn as spawn2 } from "node:child_process";
6
- import { readFileSync, readdirSync, existsSync as existsSync2, openSync, unlinkSync as unlinkSync2 } from "node:fs";
6
+ import { readFileSync as readFileSync2, openSync } from "node:fs";
7
7
 
8
8
  // src/protocols/ssh/messages.ts
9
9
  var MSG = {
@@ -2827,8 +2827,8 @@ var Tls12Client = class {
2827
2827
  case HANDSHAKE.FINISHED: {
2828
2828
  const expected = await this.#verifyData("server finished");
2829
2829
  const actual = body.subarray(0, 12);
2830
- const matches = expected.length === actual.length && expected.every((byte, i) => byte === actual[i]);
2831
- if (!matches) throw new Error("TLS: the host Finished message did not verify");
2830
+ const matches2 = expected.length === actual.length && expected.every((byte, i) => byte === actual[i]);
2831
+ if (!matches2) throw new Error("TLS: the host Finished message did not verify");
2832
2832
  this.transcript.push(message);
2833
2833
  this.established = true;
2834
2834
  this.resolveHandshake({
@@ -7569,6 +7569,7 @@ function sessionName(host, port, explicit) {
7569
7569
  var socketPath = (name) => join(RUN_DIR, `${name}.sock`);
7570
7570
  var metaPath = (name) => join(RUN_DIR, `${name}.json`);
7571
7571
  var logPath = (name) => join(RUN_DIR, `${name}.log`);
7572
+ var currentPath = () => join(ROOT, "current");
7572
7573
 
7573
7574
  // src/cli/daemon.ts
7574
7575
  async function runDaemon(options) {
@@ -7606,7 +7607,11 @@ async function runDaemon(options) {
7606
7607
  viewer: viewer?.url ?? null,
7607
7608
  width: session.framebuffer.width || size.width,
7608
7609
  height: session.framebuffer.height || size.height,
7609
- startedAt
7610
+ startedAt,
7611
+ // So `shell` and `exec` need no flags either: the account the terminal
7612
+ // signs in as was settled when the session was opened.
7613
+ sshUser: options.sshUsername || bareUsername(options.username),
7614
+ sshPort: options.sshPort
7610
7615
  }, null, 2));
7611
7616
  }
7612
7617
  let viewer = null;
@@ -7674,6 +7679,8 @@ async function runDaemon(options) {
7674
7679
  window: viewer?.window?.kind ?? null,
7675
7680
  viewers: viewer?.viewers ?? 0,
7676
7681
  shell: shell ? "open" : shellStarting ? "opening" : "closed",
7682
+ sshUser: options.sshUsername || bareUsername(options.username),
7683
+ sshPort: options.sshPort,
7677
7684
  pid: process.pid
7678
7685
  };
7679
7686
  case "shot": {
@@ -7797,10 +7804,89 @@ async function runScript(session, script) {
7797
7804
  return done;
7798
7805
  }
7799
7806
 
7807
+ // src/cli/sessions.ts
7808
+ import { existsSync as existsSync2, readFileSync, readdirSync, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
7809
+ function liveSessions() {
7810
+ ensureRunDir();
7811
+ return readdirSync(RUN_DIR).filter((file) => file.endsWith(".json")).flatMap((file) => {
7812
+ try {
7813
+ const meta = JSON.parse(readFileSync(`${RUN_DIR}/${file}`, "utf8"));
7814
+ try {
7815
+ process.kill(meta.pid, 0);
7816
+ } catch {
7817
+ cleanup(meta.name);
7818
+ return [];
7819
+ }
7820
+ return [meta];
7821
+ } catch {
7822
+ return [];
7823
+ }
7824
+ }).toSorted((a, b) => a.startedAt.localeCompare(b.startedAt));
7825
+ }
7826
+ function cleanup(name) {
7827
+ for (const file of [socketPath(name), metaPath(name), logPath(name)]) {
7828
+ try {
7829
+ if (existsSync2(file)) unlinkSync2(file);
7830
+ } catch {
7831
+ }
7832
+ }
7833
+ forgetCurrent(name);
7834
+ }
7835
+ function setCurrent(name) {
7836
+ ensureRunDir();
7837
+ writeFileSync2(currentPath(), `${name}
7838
+ `, { mode: 384 });
7839
+ }
7840
+ function readCurrent() {
7841
+ try {
7842
+ return readFileSync(currentPath(), "utf8").trim() || null;
7843
+ } catch {
7844
+ return null;
7845
+ }
7846
+ }
7847
+ function forgetCurrent(name) {
7848
+ if (name && readCurrent() !== name) return;
7849
+ try {
7850
+ if (existsSync2(currentPath())) unlinkSync2(currentPath());
7851
+ } catch {
7852
+ }
7853
+ }
7854
+ var matches = (meta, asked) => meta.name === asked || meta.host === asked || `${meta.host}:${meta.port}` === asked;
7855
+ function pickSession(open, asked, current, program = "ai-remote") {
7856
+ if (asked) {
7857
+ const named = open.find((meta) => matches(meta, asked));
7858
+ return named ? { session: named } : {
7859
+ problem: `No open session matches "${asked}".${listing(open, program)}`
7860
+ };
7861
+ }
7862
+ if (!open.length) {
7863
+ return { problem: `No session is open. Start one with \`${program} open <host[:port]>\`.` };
7864
+ }
7865
+ const marked = current ? open.find((meta) => meta.name === current) : void 0;
7866
+ if (marked) return { session: marked };
7867
+ if (open.length === 1) return { session: open[0] };
7868
+ return {
7869
+ problem: `${open.length} sessions are open and none of them is the current one. Pass \`--session NAME\`.${listing(open, program)}`
7870
+ };
7871
+ }
7872
+ function listing(open, program) {
7873
+ if (!open.length) return ` Nothing is open -- \`${program} open <host[:port]>\` starts a session.`;
7874
+ return `
7875
+ Open: ${open.map((meta) => `${meta.name} (${meta.host}:${meta.port})`).join(", ")}`;
7876
+ }
7877
+ function freeName(base, taken) {
7878
+ const used = new Set(taken);
7879
+ if (!used.has(base)) return base;
7880
+ for (let suffix = 2; ; suffix++) {
7881
+ const candidate = `${base}_${suffix}`;
7882
+ if (!used.has(candidate)) return candidate;
7883
+ }
7884
+ }
7885
+
7800
7886
  // src/cli/cli.ts
7801
7887
  function readLog(name) {
7802
7888
  try {
7803
- return readFileSync(logPath(name), "utf8").split("\n").filter((line) => line.trim() && !/^\s/.test(line) && !line.startsWith("[RDP] ")).slice(-4).join("\n");
7889
+ return readFileSync2(logPath(name), "utf8").split("\n").filter((line) => line.trim() && !/^\s/.test(line) && !line.startsWith("[RDP] ")).slice(-4).join("\n");
7804
7890
  } catch {
7805
7891
  return "";
7806
7892
  }
@@ -7811,40 +7897,56 @@ var NAME = (() => {
7811
7897
  })();
7812
7898
  var USAGE = `${NAME} -- drive a remote desktop, with a window you can watch
7813
7899
 
7814
- ${NAME} open <host[:port]> connect, show a window, and stay open
7815
- ${NAME} shot <host[:port]> screenshot the desktop
7816
- ${NAME} click <host[:port]> X,Y click, --button right|middle, --double
7817
- ${NAME} type <host[:port]> "text" type into whatever has focus
7818
- ${NAME} key <host[:port]> <Code> press keys, e.g. MetaLeft, ControlLeft+KeyA
7819
- ${NAME} exec <host[:port]> "cmd" run a command in the terminal (SSH)
7820
- ${NAME} shell <host[:port]> an interactive terminal
7821
- ${NAME} do <host[:port]> "steps" several actions in one go
7822
- ${NAME} view <host[:port]> open the window on a running session
7823
- ${NAME} view <host[:port]> --close close the window, session keeps running
7824
- ${NAME} status | list | close [<host>]
7825
- ${NAME} probe <host[:port]> is anything listening?
7900
+ One command names the machine:
7901
+
7902
+ ${NAME} open <host[:port]> connect, show a window, and stay open
7903
+
7904
+ The rest drive that session, and take no host:
7905
+
7906
+ ${NAME} shot screenshot the desktop
7907
+ ${NAME} click X,Y click, --button right|middle, --double
7908
+ ${NAME} type "text" type into whatever has focus
7909
+ ${NAME} key <Code> press keys, e.g. MetaLeft, ControlLeft+KeyA
7910
+ ${NAME} exec "cmd" run a command in the terminal (SSH)
7911
+ ${NAME} shell an interactive terminal
7912
+ ${NAME} do "steps" several actions in one go
7913
+ ${NAME} view open the window on the session
7914
+ ${NAME} view --close close the window, session keeps running
7915
+ ${NAME} status what the session is doing
7916
+ ${NAME} list every session that is open
7917
+ ${NAME} close close it; --all closes every session
7918
+ ${NAME} probe [<host[:port]>] is anything listening?
7826
7919
 
7827
7920
  Steps for "do", separated by semicolons:
7828
7921
  move X,Y \xB7 click X,Y[,right|middle] \xB7 dblclick X,Y \xB7 scroll X,Y,DX,DY
7829
7922
  type TEXT \xB7 key Code[+Code...] \xB7 cad \xB7 wait MS \xB7 shot FILE
7830
7923
 
7831
- Options
7924
+ Sessions
7925
+ The session opened last is the one commands use. --session NAME picks
7926
+ another, and a session answers to its name, its host, or host:port.
7927
+ A session stays open until it is closed; \`${NAME} list\` shows them.
7928
+
7929
+ Options for open
7832
7930
  -u, --user NAME account on the host
7833
7931
  -d, --domain NAME Windows domain or computer name
7932
+ --session NAME name this session (default: host_port)
7834
7933
  --ssh-user NAME only if the terminal signs in as someone else;
7835
7934
  by default it reuses --user and the same password
7836
7935
  --ssh-port N SSH port (default: 22)
7837
7936
  -s, --security MODE auto | nla | tls | rdp (default: auto)
7838
7937
  -W, --width N desktop width (default: 1280)
7839
7938
  -H, --height N desktop height (default: 800)
7840
- -o, --out FILE where a screenshot goes (default: screen.png)
7841
- --max-edge N shrink a screenshot to fit N
7842
7939
  --view-port N port for the window (default: 7373)
7843
7940
  --idle MINUTES close an unused session (default: 0, never)
7844
7941
  --headless no window (same as --no-view)
7845
7942
  --no-view no window
7846
7943
  --no-open window, but do not launch a browser
7847
- --no-reuse a fresh session, ignoring any that is already open
7944
+ --no-reuse a second session beside one that is already open
7945
+
7946
+ Options for the rest
7947
+ --session NAME which session to drive (default: the last opened)
7948
+ -o, --out FILE where a screenshot goes (default: screen.png)
7949
+ --max-edge N shrink a screenshot to fit N
7848
7950
  --json machine-readable result on stdout
7849
7951
 
7850
7952
  The password comes from AI_REMOTE_PASSWORD (or REMOTECTL_PASSWORD). There is no
@@ -7871,6 +7973,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
7871
7973
  "view-port",
7872
7974
  "idle",
7873
7975
  "name",
7976
+ "session",
7874
7977
  "ssh-user",
7875
7978
  "ssh-port",
7876
7979
  "settle"
@@ -7888,7 +7991,7 @@ function parse(argv) {
7888
7991
  if (VALUE_FLAGS.has(name)) flags[name] = argv[++i] ?? "";
7889
7992
  else flags[name] = true;
7890
7993
  }
7891
- return { command: positional[0] ?? "", target: positional[1] ?? "", rest: positional.slice(2), flags };
7994
+ return { command: positional[0] ?? "", rest: positional.slice(1), flags };
7892
7995
  }
7893
7996
  var flag = (args, ...names) => {
7894
7997
  for (const name of names) if (typeof args.flags[name] === "string") return args.flags[name];
@@ -7908,29 +8011,38 @@ var CliError = class extends Error {
7908
8011
  code;
7909
8012
  };
7910
8013
  var password = () => process.env.AI_REMOTE_PASSWORD ?? process.env.REMOTECTL_PASSWORD ?? "";
7911
- function liveSessions() {
7912
- ensureRunDir();
7913
- return readdirSync(RUN_DIR).filter((file) => file.endsWith(".json")).flatMap((file) => {
7914
- try {
7915
- const meta = JSON.parse(readFileSync(`${RUN_DIR}/${file}`, "utf8"));
7916
- try {
7917
- process.kill(meta.pid, 0);
7918
- } catch {
7919
- cleanup(meta.name);
7920
- return [];
7921
- }
7922
- return [meta];
7923
- } catch {
7924
- return [];
7925
- }
7926
- });
8014
+ function currentSession(args) {
8015
+ const chosen = pickSession(liveSessions(), flag(args, "session", "name"), readCurrent(), NAME);
8016
+ if ("problem" in chosen) throw new CliError(chosen.problem, 12);
8017
+ return chosen.session;
7927
8018
  }
7928
- function cleanup(name) {
7929
- for (const file of [socketPath(name), metaPath(name), logPath(name)]) {
7930
- try {
7931
- if (existsSync2(file)) unlinkSync2(file);
7932
- } catch {
7933
- }
8019
+ function refuseTarget(command, token) {
8020
+ const address = /^(\d{1,3}\.){3}\d{1,3}(:\d+)?$/.test(token) || /^[\w-]+(\.[\w-]+)+(:\d+)?$/.test(token) || /^localhost(:\d+)?$/i.test(token);
8021
+ throw new CliError(
8022
+ address ? `\`${command}\` does not take a host: it drives the session \`${NAME} open\` left running.
8023
+ ${NAME} open ${token} then ${NAME} ${command} ...
8024
+ --session NAME picks between several; \`${NAME} list\` shows what is open.` : `\`${command}\` takes no argument: it acts on the session \`${NAME} open\` left running.
8025
+ ${NAME} ${command} --session ${token} acts on another one
8026
+ \`${NAME} list\` shows what is open.`,
8027
+ 12
8028
+ );
8029
+ }
8030
+ var TAKES_NOTHING = /* @__PURE__ */ new Set(["shot", "view", "status", "shell", "close"]);
8031
+ var COORDINATE = /^-?\d+\s*,\s*-?\d+$/;
8032
+ function refuseLegacyTarget(args) {
8033
+ const { command, rest } = args;
8034
+ if (TAKES_NOTHING.has(command) && rest.length) refuseTarget(command, rest[0]);
8035
+ const spec = flag(args, "at") ?? rest[0];
8036
+ if (command === "click" && spec && !COORDINATE.test(spec) && rest[1] && COORDINATE.test(rest[1])) {
8037
+ refuseTarget("click", spec);
8038
+ }
8039
+ if (command === "key" && rest[0] && /[.:]/.test(rest[0])) refuseTarget("key", rest[0]);
8040
+ }
8041
+ function refuseSessionHost(args, command, session) {
8042
+ if (args.rest.length < 2) return;
8043
+ const token = args.rest[0].replace(/^\w+:\/\//, "");
8044
+ if (token === session.host || token === `${session.host}:${session.port}` || token === session.name) {
8045
+ refuseTarget(command, args.rest[0]);
7934
8046
  }
7935
8047
  }
7936
8048
  async function ask(name, op, args = {}) {
@@ -7942,6 +8054,14 @@ async function ask(name, op, args = {}) {
7942
8054
  if (!response.ok) throw new CliError(response.error ?? `The session refused "${op}".`, response.code ?? 1);
7943
8055
  return response.result;
7944
8056
  }
8057
+ async function tell(session, op, args = {}) {
8058
+ const result = await ask(session.name, op, args);
8059
+ if (result === null) {
8060
+ cleanup(session.name);
8061
+ throw new CliError(`The session ${session.name} is no longer answering. It has been cleaned up.`, 12);
8062
+ }
8063
+ return result;
8064
+ }
7945
8065
  async function startSession(args, name, host, port) {
7946
8066
  const daemonArgs = [
7947
8067
  process.argv[1],
@@ -7965,8 +8085,10 @@ async function startSession(args, name, host, port) {
7965
8085
  flag(args, "ssh-port") ?? "22",
7966
8086
  "--view-port",
7967
8087
  flag(args, "view-port") ?? "7373",
8088
+ // Zero unless asked for: a session stays open until it is closed. An agent
8089
+ // that pauses to think is not an agent that has finished.
7968
8090
  "--idle",
7969
- flag(args, "idle") ?? "30"
8091
+ flag(args, "idle") ?? "0"
7970
8092
  ];
7971
8093
  if (has(args, "no-view", "headless")) daemonArgs.push("--no-view");
7972
8094
  if (has(args, "no-open")) daemonArgs.push("--no-open");
@@ -8000,38 +8122,84 @@ async function main() {
8000
8122
  return;
8001
8123
  }
8002
8124
  const json = has(args, "json");
8003
- if (args.command === "list" || args.command === "status" && !args.target) {
8125
+ if (args.command === "__session") {
8126
+ const { host, port } = splitTarget(args.rest[0] ?? "", 3389);
8127
+ await runDaemon({
8128
+ name: sessionName(host, port, flag(args, "name", "session")),
8129
+ host,
8130
+ port,
8131
+ username: flag(args, "u", "user") ?? "",
8132
+ password: password(),
8133
+ domain: flag(args, "d", "domain") ?? "",
8134
+ width: Number(flag(args, "W", "width") ?? 1280),
8135
+ height: Number(flag(args, "H", "height") ?? 800),
8136
+ security: flag(args, "s", "security") ?? "auto",
8137
+ clientName: "ai-remote",
8138
+ view: !has(args, "no-view", "headless"),
8139
+ launchBrowser: !has(args, "no-open"),
8140
+ viewPort: Number(flag(args, "view-port") ?? 7373),
8141
+ sshUsername: flag(args, "ssh-user") ?? flag(args, "u", "user") ?? "",
8142
+ sshPort: Number(flag(args, "ssh-port") ?? 22),
8143
+ tab: has(args, "tab"),
8144
+ fullscreen: has(args, "fullscreen"),
8145
+ idleMs: Math.max(0, Number(flag(args, "idle") ?? 0)) * 6e4
8146
+ });
8147
+ return;
8148
+ }
8149
+ if (args.command === "__window") {
8150
+ await runWindow(process.argv.slice(3));
8151
+ return;
8152
+ }
8153
+ if (args.command === "list") {
8004
8154
  const sessions = liveSessions();
8155
+ const current = readCurrent();
8005
8156
  if (json) {
8006
- process.stdout.write(`${JSON.stringify({ sessions })}
8157
+ process.stdout.write(`${JSON.stringify({ current, sessions })}
8007
8158
  `);
8008
8159
  return;
8009
8160
  }
8010
8161
  if (!sessions.length) {
8011
- process.stdout.write("No sessions are open.\n");
8162
+ process.stdout.write(`No sessions are open. \`${NAME} open <host>\` starts one.
8163
+ `);
8012
8164
  return;
8013
8165
  }
8014
8166
  for (const meta of sessions) {
8015
- process.stdout.write(`${meta.name} ${meta.host}:${meta.port} ${meta.width}x${meta.height} pid ${meta.pid}${meta.viewer ? ` ${meta.viewer}` : ""}
8167
+ process.stdout.write(`${meta.name === current ? "*" : " "} ${meta.name} ${meta.host}:${meta.port} ${meta.width}x${meta.height} pid ${meta.pid}${meta.viewer ? ` ${meta.viewer}` : ""}
8016
8168
  `);
8017
8169
  }
8018
8170
  return;
8019
8171
  }
8020
- if (!args.target && args.command !== "close") throw new CliError("Name the machine to connect to.", 12);
8021
- const { host, port } = splitTarget(args.target || "", 3389);
8022
- const name = sessionName(host, port, flag(args, "name"));
8023
- if (args.command === "close") {
8024
- const targets = args.target ? [name] : liveSessions().map((meta) => meta.name);
8025
- for (const target of targets) {
8026
- await ask(target, "close").catch(() => null);
8027
- cleanup(target);
8028
- process.stdout.write(`Closed ${target}.
8029
- `);
8030
- }
8031
- if (!targets.length) process.stdout.write("No sessions are open.\n");
8172
+ if (args.command === "open") {
8173
+ const target = args.rest[0];
8174
+ if (!target) throw new CliError(`Name the machine to connect to: \`${NAME} open <host[:port]>\``, 12);
8175
+ const { host, port } = splitTarget(target, 3389);
8176
+ const asked = flag(args, "session", "name");
8177
+ const base = sessionName(host, port, asked);
8178
+ const name2 = has(args, "no-reuse") && !asked ? freeName(base, liveSessions().map((meta) => meta.name)) : base;
8179
+ let status = has(args, "no-reuse") ? null : await ask(name2, "status");
8180
+ const reused = Boolean(status);
8181
+ if (!status) status = await startSession(args, name2, host, port);
8182
+ if (!has(args, "no-view", "headless") && !status.viewer) {
8183
+ Object.assign(status, await ask(name2, "view-open", { launch: !has(args, "no-open") }));
8184
+ }
8185
+ setCurrent(name2);
8186
+ report(json, "open", {
8187
+ host,
8188
+ port,
8189
+ name: name2,
8190
+ reused,
8191
+ width: status.width,
8192
+ height: status.height,
8193
+ viewer: status.viewer,
8194
+ window: status.window,
8195
+ viewers: status.viewers
8196
+ });
8032
8197
  return;
8033
8198
  }
8034
8199
  if (args.command === "probe") {
8200
+ const target = args.rest[0];
8201
+ const where = target ? splitTarget(target, 3389) : currentSession(args);
8202
+ const { host, port } = where;
8035
8203
  const { reachable, ms, detail } = await probe(host, port);
8036
8204
  if (json) process.stdout.write(`${JSON.stringify({ host, port, reachable, ms, detail })}
8037
8205
  `);
@@ -8040,106 +8208,103 @@ async function main() {
8040
8208
  if (!reachable) throw new CliError(detail, 10);
8041
8209
  return;
8042
8210
  }
8043
- if (args.command === "__session") {
8044
- await runDaemon({
8045
- name,
8046
- host,
8047
- port,
8048
- username: flag(args, "u", "user") ?? "",
8049
- password: password(),
8050
- domain: flag(args, "d", "domain") ?? "",
8051
- width: Number(flag(args, "W", "width") ?? 1280),
8052
- height: Number(flag(args, "H", "height") ?? 800),
8053
- security: flag(args, "s", "security") ?? "auto",
8054
- clientName: "ai-remote",
8055
- view: !has(args, "no-view", "headless"),
8056
- launchBrowser: !has(args, "no-open"),
8057
- viewPort: Number(flag(args, "view-port") ?? 7373),
8058
- sshUsername: flag(args, "ssh-user") ?? flag(args, "u", "user") ?? "",
8059
- sshPort: Number(flag(args, "ssh-port") ?? 22),
8060
- tab: has(args, "tab"),
8061
- fullscreen: has(args, "fullscreen"),
8062
- // Zero by default: a session stays open until it is closed. An agent
8063
- // that pauses to think is not an agent that has finished.
8064
- idleMs: Math.max(0, Number(flag(args, "idle") ?? 0)) * 6e4
8065
- });
8211
+ refuseLegacyTarget(args);
8212
+ if (args.command === "close") {
8213
+ const targets = has(args, "all") ? liveSessions().map((meta) => meta.name) : [currentSession(args).name];
8214
+ for (const target of targets) {
8215
+ await ask(target, "close").catch(() => null);
8216
+ cleanup(target);
8217
+ process.stdout.write(`Closed ${target}.
8218
+ `);
8219
+ }
8220
+ if (!targets.length) process.stdout.write("No sessions are open.\n");
8221
+ const left = liveSessions();
8222
+ if (!readCurrent() && left.length) setCurrent(left[left.length - 1].name);
8066
8223
  return;
8067
8224
  }
8068
- if (args.command === "__window") {
8069
- await runWindow(process.argv.slice(3));
8225
+ if (args.command === "status" && !liveSessions().length) {
8226
+ if (json) process.stdout.write(`${JSON.stringify({ ok: true, sessions: [] })}
8227
+ `);
8228
+ else process.stdout.write("No sessions are open.\n");
8070
8229
  return;
8071
8230
  }
8231
+ const session = currentSession(args);
8232
+ const { name } = session;
8072
8233
  if (args.command === "shell") {
8073
- await interactiveShell(args, host);
8234
+ await interactiveShell(args, session);
8074
8235
  return;
8075
8236
  }
8076
- let status = has(args, "no-reuse") ? null : await ask(name, "status");
8077
- const reused = Boolean(status);
8078
- if (!status) status = await startSession(args, name, host, port);
8079
- const report = { host, port, name, reused, width: status.width, height: status.height };
8237
+ const summary = {
8238
+ host: session.host,
8239
+ port: session.port,
8240
+ name,
8241
+ width: session.width,
8242
+ height: session.height
8243
+ };
8080
8244
  switch (args.command) {
8081
- case "open":
8082
- if (!has(args, "no-view", "headless") && !status.viewer) {
8083
- Object.assign(status, await ask(name, "view-open", { launch: !has(args, "no-open") }));
8084
- }
8085
- Object.assign(report, { viewer: status.viewer, window: status.window, viewers: status.viewers });
8245
+ case "status": {
8246
+ Object.assign(summary, await tell(session, "status"));
8086
8247
  break;
8248
+ }
8087
8249
  case "view": {
8088
8250
  if (has(args, "close")) {
8089
- const { closed } = await ask(name, "view-close");
8090
- Object.assign(report, { viewer: null, closed });
8251
+ const { closed } = await tell(session, "view-close");
8252
+ Object.assign(summary, { viewer: null, closed });
8091
8253
  break;
8092
8254
  }
8093
- Object.assign(report, await ask(name, "view-open", { launch: !has(args, "no-open") }));
8255
+ Object.assign(summary, await tell(session, "view-open", { launch: !has(args, "no-open") }));
8094
8256
  break;
8095
8257
  }
8096
8258
  case "shot": {
8097
8259
  const file = resolvePath(flag(args, "o", "out") ?? "screen.png");
8098
8260
  const maxEdge = Number(flag(args, "max-edge") ?? 0);
8099
- Object.assign(report, await ask(name, "shot", { file, maxEdge }));
8261
+ Object.assign(summary, await tell(session, "shot", { file, maxEdge }));
8100
8262
  break;
8101
8263
  }
8102
8264
  case "click": {
8103
8265
  const spec = flag(args, "at") ?? args.rest[0];
8104
- if (!spec) throw new CliError("click needs a position, e.g. `click 192.168.1.5 640,400`", 12);
8266
+ if (!spec) throw new CliError(`click needs a position, e.g. \`${NAME} click 640,400\``, 12);
8105
8267
  const [x, y] = spec.split(",").map(Number);
8106
8268
  const button2 = flag(args, "button") ?? "left";
8107
8269
  const step = `click ${x},${y}${button2 === "left" ? "" : `,${button2}`}`;
8108
- await ask(name, "script", { script: has(args, "double") ? `${step}; ${step}` : step });
8109
- Object.assign(report, { clicked: { x, y, button: button2 } });
8270
+ await tell(session, "script", { script: has(args, "double") ? `${step}; ${step}` : step });
8271
+ Object.assign(summary, { clicked: { x, y, button: button2 } });
8110
8272
  break;
8111
8273
  }
8112
8274
  case "type": {
8275
+ refuseSessionHost(args, "type", session);
8113
8276
  const text = args.rest.join(" ");
8114
8277
  if (!text) throw new CliError("type needs some text", 12);
8115
- await ask(name, "script", { script: `type ${text}` });
8116
- Object.assign(report, { typed: text.length });
8278
+ await tell(session, "script", { script: `type ${text}` });
8279
+ Object.assign(summary, { typed: text.length });
8117
8280
  break;
8118
8281
  }
8119
8282
  case "key": {
8120
8283
  if (!args.rest.length) throw new CliError("key needs at least one code, e.g. MetaLeft", 12);
8121
- await ask(name, "script", { script: args.rest.map((code) => `key ${code}`).join("; ") });
8122
- Object.assign(report, { keys: args.rest });
8284
+ await tell(session, "script", { script: args.rest.map((code) => `key ${code}`).join("; ") });
8285
+ Object.assign(summary, { keys: args.rest });
8123
8286
  break;
8124
8287
  }
8125
8288
  case "do": {
8289
+ refuseSessionHost(args, "do", session);
8126
8290
  const script = args.rest.join(" ");
8127
8291
  if (!script) throw new CliError('do needs a script, e.g. "key MetaLeft; wait 800; shot s.png"', 12);
8128
- Object.assign(report, await ask(name, "script", { script }));
8292
+ Object.assign(summary, await tell(session, "script", { script }));
8129
8293
  break;
8130
8294
  }
8131
8295
  case "exec": {
8296
+ refuseSessionHost(args, "exec", session);
8132
8297
  const command = args.rest.join(" ");
8133
8298
  if (!command) throw new CliError("exec needs a command to run", 12);
8134
- const result = await ask(name, "exec", { command });
8135
- Object.assign(report, result);
8299
+ const result = await tell(session, "exec", { command });
8300
+ Object.assign(summary, result);
8136
8301
  if (!json) {
8137
8302
  process.stdout.write(result.output.endsWith("\n") ? result.output : `${result.output}
8138
8303
  `);
8139
8304
  if (result.exitStatus) process.stderr.write(`exit ${result.exitStatus}
8140
8305
  `);
8141
8306
  }
8142
- if (json) process.stdout.write(`${JSON.stringify({ ok: result.exitStatus === 0, ...report })}
8307
+ if (json) process.stdout.write(`${JSON.stringify({ ok: result.exitStatus === 0, ...summary })}
8143
8308
  `);
8144
8309
  process.exit(result.exitStatus === 0 ? 0 : 1);
8145
8310
  return;
@@ -8147,16 +8312,29 @@ async function main() {
8147
8312
  default:
8148
8313
  throw new CliError(`Unknown command "${args.command}".`, 12);
8149
8314
  }
8150
- if (json) process.stdout.write(`${JSON.stringify({ ok: true, ...report })}
8315
+ report(json, args.command, summary);
8316
+ }
8317
+ function report(json, command, summary) {
8318
+ if (json) process.stdout.write(`${JSON.stringify({ ok: true, ...summary })}
8151
8319
  `);
8152
- else process.stdout.write(`${describe(args.command, report)}
8320
+ else process.stdout.write(`${describe(command, summary)}
8153
8321
  `);
8154
8322
  }
8155
- async function interactiveShell(args, host) {
8323
+ async function interactiveShell(args, session) {
8324
+ const username = bareUsername(flag(args, "ssh-user") ?? session.sshUser ?? "");
8325
+ if (!username) {
8326
+ throw new CliError(
8327
+ `The session ${session.name} has no account for a terminal. Open it with -u, or pass --ssh-user here.`,
8328
+ 12
8329
+ );
8330
+ }
8331
+ if (!password()) {
8332
+ throw new CliError("The terminal signs in for itself, so it needs AI_REMOTE_PASSWORD in this shell.", 11);
8333
+ }
8156
8334
  const shell = new Shell({
8157
- host,
8158
- port: Number(flag(args, "ssh-port") ?? 22),
8159
- username: flag(args, "ssh-user") ?? bareUsername(flag(args, "u", "user") ?? ""),
8335
+ host: session.host,
8336
+ port: Number(flag(args, "ssh-port") ?? session.sshPort ?? 22),
8337
+ username,
8160
8338
  password: password(),
8161
8339
  columns: process.stdout.columns ?? 120,
8162
8340
  rows: process.stdout.rows ?? 30
@@ -8194,26 +8372,29 @@ async function probe(host, port) {
8194
8372
  socket.on("error", (error) => done(false, error.message));
8195
8373
  });
8196
8374
  }
8197
- function describe(command, report) {
8198
- const size = `${report.width}x${report.height}`;
8199
- const where = report.reused ? "the open session" : "a new session";
8375
+ function describe(command, summary) {
8376
+ const size = `${summary.width}x${summary.height}`;
8200
8377
  if (command === "open") {
8201
- const window = report.window === "window" ? "A window is open on it." : report.window === "tab" ? "It opened in your browser." : report.viewer ? `Open it at ${report.viewer}` : "No window (headless).";
8202
- return `${report.reused ? "Already connected" : "Connected"} to ${report.host}:${report.port} at ${size}. ${window}
8203
- Run more commands against it, then \`${NAME} close ${report.host}\` when you are done.`;
8378
+ const window = summary.window === "window" ? "A window is open on it." : summary.window === "tab" ? "It opened in your browser." : summary.viewer ? `Open it at ${summary.viewer}` : "No window (headless).";
8379
+ return `${summary.reused ? "Already connected" : "Connected"} to ${summary.host}:${summary.port} at ${size}. ${window}
8380
+ Session ${summary.name}. Run more commands with no host, then \`${NAME} close\` when you are done.`;
8381
+ }
8382
+ if (command === "status") {
8383
+ return `${summary.name} ${summary.host}:${summary.port} ${size} ${summary.connected ? "connected" : "disconnected"} terminal ${summary.shell}
8384
+ ${summary.viewer ? `Window: ${summary.viewer} (${summary.viewers} watching)` : "No window."}`;
8204
8385
  }
8205
8386
  if (command === "view") {
8206
- if (report.viewer) return `Window: ${report.viewer}`;
8207
- return report.closed ? "Closed the window. The session is still open." : "There was no window open.";
8387
+ if (summary.viewer) return `Window: ${summary.viewer}`;
8388
+ return summary.closed ? "Closed the window. The session is still open." : "There was no window open.";
8208
8389
  }
8209
- if (command === "shot") return `Wrote ${report.file} (${report.width}x${report.height}) from ${where}.`;
8390
+ if (command === "shot") return `Wrote ${summary.file} (${summary.width}x${summary.height}) from ${summary.name}.`;
8210
8391
  if (command === "click") {
8211
- const { x, y } = report.clicked;
8392
+ const { x, y } = summary.clicked;
8212
8393
  return `Clicked (${x}, ${y}) on a ${size} desktop.`;
8213
8394
  }
8214
- if (command === "type") return `Typed ${report.typed} characters.`;
8215
- if (command === "key") return `Pressed ${report.keys.join(", ")}.`;
8216
- if (command === "do") return `Ran ${report.steps.length} steps: ${report.steps.join(" \u2192 ")}`;
8395
+ if (command === "type") return `Typed ${summary.typed} characters.`;
8396
+ if (command === "key") return `Pressed ${summary.keys.join(", ")}.`;
8397
+ if (command === "do") return `Ran ${summary.steps.length} steps: ${summary.steps.join(" \u2192 ")}`;
8217
8398
  return "Done.";
8218
8399
  }
8219
8400
  main().catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-remote",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
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": {