@cli-remote/local 0.3.4 → 0.4.1

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 (2) hide show
  1. package/dist/index.cjs +549 -23
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -2263,7 +2263,7 @@ var require_websocket = __commonJS({
2263
2263
  var EventEmitter = require("events");
2264
2264
  var https = require("https");
2265
2265
  var http = require("http");
2266
- var net = require("net");
2266
+ var net2 = require("net");
2267
2267
  var tls = require("tls");
2268
2268
  var { randomBytes: randomBytes3, createHash: createHash2 } = require("crypto");
2269
2269
  var { Duplex, Readable } = require("stream");
@@ -3007,12 +3007,12 @@ var require_websocket = __commonJS({
3007
3007
  }
3008
3008
  function netConnect(options) {
3009
3009
  options.path = options.socketPath;
3010
- return net.connect(options);
3010
+ return net2.connect(options);
3011
3011
  }
3012
3012
  function tlsConnect(options) {
3013
3013
  options.path = void 0;
3014
3014
  if (!options.servername && options.servername !== "") {
3015
- options.servername = net.isIP(options.host) ? "" : options.host;
3015
+ options.servername = net2.isIP(options.host) ? "" : options.host;
3016
3016
  }
3017
3017
  return tls.connect(options);
3018
3018
  }
@@ -9940,7 +9940,7 @@ var require_lib = __commonJS({
9940
9940
 
9941
9941
  // src/index.ts
9942
9942
  var import_node_fs5 = require("node:fs");
9943
- var import_node_child_process4 = require("node:child_process");
9943
+ var import_node_child_process5 = require("node:child_process");
9944
9944
  var import_node_os8 = require("node:os");
9945
9945
  var import_node_path8 = require("node:path");
9946
9946
  var import_node_url = require("node:url");
@@ -9957,7 +9957,7 @@ var import_websocket_server = __toESM(require_websocket_server(), 1);
9957
9957
  var wrapper_default = import_websocket.default;
9958
9958
 
9959
9959
  // src/client.ts
9960
- var import_node_child_process3 = require("node:child_process");
9960
+ var import_node_child_process4 = require("node:child_process");
9961
9961
  var import_node_readline = require("node:readline");
9962
9962
  var import_node_fs4 = require("node:fs");
9963
9963
  var import_node_os7 = require("node:os");
@@ -10031,6 +10031,8 @@ function resolveCommand(cmd) {
10031
10031
  return { file: "kimi", args: [] };
10032
10032
  case "pi":
10033
10033
  return { file: "pi", args: [] };
10034
+ case "desktop":
10035
+ throw new Error("desktop sessions are not PTY sessions");
10034
10036
  default:
10035
10037
  return { file: cmd, args: [] };
10036
10038
  }
@@ -10214,6 +10216,12 @@ var SessionLogger = class {
10214
10216
  if (this.closed || this.mode === "off") return;
10215
10217
  this.write({ ts: Date.now(), event: "forced", cmd });
10216
10218
  }
10219
+ /** Desktop display/workspace switch metadata (v0.8). Content is never
10220
+ * logged — this records only WHICH screen/desktop was switched to. */
10221
+ logSwitch(kind, value) {
10222
+ if (this.closed || this.mode === "off") return;
10223
+ this.write({ ts: Date.now(), event: "switch", kind, value });
10224
+ }
10217
10225
  logExit(code) {
10218
10226
  if (this.closed || this.mode === "off") return;
10219
10227
  this.write({ ts: Date.now(), event: "exit", code });
@@ -10421,6 +10429,396 @@ async function exists(p) {
10421
10429
  }
10422
10430
  }
10423
10431
 
10432
+ // src/desktop-manager.ts
10433
+ var import_node_child_process = require("node:child_process");
10434
+ var import_node_util = require("node:util");
10435
+ var import_node_net = __toESM(require("node:net"), 1);
10436
+ var execFileP = (0, import_node_util.promisify)(import_node_child_process.execFile);
10437
+ var X11VNC_START_TIMEOUT_MS = 8e3;
10438
+ var PAUSE_POLL_MS = 25;
10439
+ var EXEC_TIMEOUT_MS = 5e3;
10440
+ var DesktopManager = class {
10441
+ constructor(hooks, config, platform2 = process.platform, execImpl, spawnImpl) {
10442
+ this.hooks = hooks;
10443
+ this.config = config;
10444
+ this.platform = platform2;
10445
+ this.exec = execImpl ?? ((file, args, timeoutMs = EXEC_TIMEOUT_MS) => execFileP(file, args, { timeout: timeoutMs }).then((r) => String(r.stdout ?? "")));
10446
+ this.spawnChildFn = spawnImpl ?? ((file, args) => {
10447
+ const child = (0, import_node_child_process.spawn)(file, args, { stdio: "ignore" });
10448
+ child.unref();
10449
+ return child;
10450
+ });
10451
+ }
10452
+ sessions = /* @__PURE__ */ new Map();
10453
+ exec;
10454
+ spawnChildFn;
10455
+ /** Human-readable reason when the machine cannot serve desktop sessions. */
10456
+ unsupportedReason() {
10457
+ if (!this.config.enabled) {
10458
+ return 'desktop disabled on this device (config.json: {"desktop": {"enabled": true}})';
10459
+ }
10460
+ if (this.platform === "win32") {
10461
+ return "desktop sessions are not supported on Windows yet";
10462
+ }
10463
+ return null;
10464
+ }
10465
+ // -------------------------------------------------------------------------
10466
+ // Enumeration (desktop-query)
10467
+ // -------------------------------------------------------------------------
10468
+ /** Probes displays/workspaces. Degrades gracefully — missing tools yield
10469
+ * empty/unknown values, never throws (web always gets an answer). */
10470
+ async queryInfo() {
10471
+ if (this.platform === "darwin") {
10472
+ const displays = await this.listScreensMac().catch(() => []);
10473
+ return {
10474
+ displays,
10475
+ workspaces: null,
10476
+ currentWorkspace: null,
10477
+ canPickDisplay: displays.length > 1,
10478
+ cropMode: "client"
10479
+ };
10480
+ }
10481
+ if (this.platform === "linux") {
10482
+ const [heads, ws] = await Promise.all([
10483
+ this.listHeads().catch(() => []),
10484
+ this.workspaceState().catch(() => null)
10485
+ ]);
10486
+ return {
10487
+ displays: heads.map((h) => ({ name: h.name, width: h.w, height: h.h, x: h.x, y: h.y })),
10488
+ workspaces: ws?.count ?? null,
10489
+ currentWorkspace: ws?.current ?? null,
10490
+ canPickDisplay: heads.length > 0,
10491
+ cropMode: "server"
10492
+ };
10493
+ }
10494
+ return { displays: [], workspaces: null, currentWorkspace: null, canPickDisplay: false, cropMode: "client" };
10495
+ }
10496
+ /** macOS: NSScreen frames via JXA (osascript -l JavaScript + AppKit).
10497
+ * AppKit origins are bottom-left → converted to CG top-left coords to
10498
+ * match the RFB framebuffer. Warm run ~1.5s; any failure → empty list. */
10499
+ async listScreensMac() {
10500
+ const script = [
10501
+ 'ObjC.import("AppKit")',
10502
+ "const screens = $.NSScreen.screens",
10503
+ "let gb = 0",
10504
+ "for (let i = 0; i < screens.count; i++) {",
10505
+ " const f = screens.objectAtIndex(i).frame",
10506
+ " gb = Math.max(gb, f.origin.y + f.size.height)",
10507
+ "}",
10508
+ "const out = []",
10509
+ "for (let i = 0; i < screens.count; i++) {",
10510
+ " const s = screens.objectAtIndex(i)",
10511
+ " const f = s.frame",
10512
+ ' out.push([Math.round(f.origin.x), Math.round(gb - f.origin.y - f.size.height), Math.round(f.size.width), Math.round(f.size.height), s.localizedName.js].join("|"))',
10513
+ "}",
10514
+ 'out.join("\n")'
10515
+ ].join("\n");
10516
+ const out = await this.exec("osascript", ["-l", "JavaScript", "-e", script], 8e3);
10517
+ const displays = [];
10518
+ for (const line of out.split("\n").filter((l) => l.trim())) {
10519
+ const [x, y, w, h, ...rest] = line.split("|");
10520
+ if (x === void 0 || w === void 0 || rest.length === 0) continue;
10521
+ displays.push({
10522
+ name: rest.join("|"),
10523
+ x: Number(x),
10524
+ y: Number(y),
10525
+ width: Number(w),
10526
+ height: Number(h)
10527
+ });
10528
+ }
10529
+ return displays;
10530
+ }
10531
+ /** `xrandr --query` connected heads (name + geometry). */
10532
+ async listHeads() {
10533
+ const out = await this.exec("xrandr", ["--query"]);
10534
+ const heads = [];
10535
+ for (const line of out.split("\n")) {
10536
+ const m = /^(\S+) connected (?:primary )?(\d+)x(\d+)\+(\d+)\+(\d+)\s/.exec(line);
10537
+ if (m) heads.push({ name: m[1], w: Number(m[2]), h: Number(m[3]), x: Number(m[4]), y: Number(m[5]) });
10538
+ }
10539
+ return heads;
10540
+ }
10541
+ /** `wmctrl -d` → { count, current }. The current line carries ` * `. */
10542
+ async workspaceState() {
10543
+ const out = await this.exec("wmctrl", ["-d"]);
10544
+ const lines = out.split("\n").filter((l) => l.trim());
10545
+ if (lines.length === 0) return null;
10546
+ const current = lines.findIndex((l) => /(^|\s)\*(\s|$)/.test(l));
10547
+ return { count: lines.length, current: current >= 0 ? current : 0 };
10548
+ }
10549
+ // -------------------------------------------------------------------------
10550
+ // Session lifecycle
10551
+ // -------------------------------------------------------------------------
10552
+ async spawn(sessionId, opts = {}) {
10553
+ if (this.sessions.has(sessionId)) {
10554
+ throw new Error(`session ${sessionId} already exists`);
10555
+ }
10556
+ const { vncHost: host, vncPort: port } = this.config;
10557
+ if (opts.workspace !== void 0 && this.platform !== "linux") {
10558
+ throw new Error("workspace selection is not supported on this platform (session-internal prev/next still works on macOS)");
10559
+ }
10560
+ if (opts.workspace !== void 0) {
10561
+ await this.switchWorkspaceAbsolute(opts.workspace);
10562
+ this.hooks.log(`desktop ${sessionId}: workspace \u2192 ${opts.workspace}`);
10563
+ }
10564
+ let tcp = await probeConnect(host, port, 750);
10565
+ let ownChild = null;
10566
+ let clip;
10567
+ if (opts.display !== void 0 && this.platform === "linux") {
10568
+ const heads = await this.listHeads().catch(() => []);
10569
+ clip = heads[opts.display];
10570
+ if (!clip) {
10571
+ throw new Error(`display ${opts.display} not found (${heads.length} connected)`);
10572
+ }
10573
+ }
10574
+ if (!tcp && this.platform === "linux") {
10575
+ const x11vnc = await this.startX11vnc(port, clip);
10576
+ ownChild = x11vnc;
10577
+ tcp = await probeConnect(host, port, X11VNC_START_TIMEOUT_MS);
10578
+ if (!tcp) {
10579
+ try {
10580
+ x11vnc?.kill("SIGTERM");
10581
+ } catch {
10582
+ }
10583
+ throw new Error(
10584
+ `no VNC server on ${host}:${port} and x11vnc did not come up (is a desktop/X11 session running?)`
10585
+ );
10586
+ }
10587
+ if (x11vnc) {
10588
+ x11vnc.once("exit", () => {
10589
+ const s = this.sessions.get(sessionId);
10590
+ if (s && s.x11vnc === x11vnc) this.end(sessionId, 1, "x11vnc exited");
10591
+ });
10592
+ }
10593
+ this.hooks.log(
10594
+ `desktop ${sessionId}: started x11vnc on ${host}:${port}` + (clip ? ` clipped to ${clip.w}x${clip.h}+${clip.x}+${clip.y}` : "") + ` (display ${this.config.display}, loopback only)`
10595
+ );
10596
+ }
10597
+ if (!tcp) {
10598
+ throw new Error(
10599
+ this.platform === "darwin" ? `no VNC server on ${host}:${port} \u2014 enable \u300C\u5C4F\u5E55\u5171\u4EAB\u300Din macOS System Settings, or set desktop.vncHost/vncPort` : `no VNC server on ${host}:${port} (auto-start is only supported on Linux X11)`
10600
+ );
10601
+ }
10602
+ if (clip && !ownChild) {
10603
+ throw new Error("external VNC server on the port cannot serve a chosen display (stop it, or spawn with the default display)");
10604
+ }
10605
+ const session = {
10606
+ id: sessionId,
10607
+ tcp,
10608
+ startedAt: Date.now(),
10609
+ paused: false,
10610
+ externalServer: !ownChild,
10611
+ ...ownChild ? { x11vnc: ownChild } : {},
10612
+ ...clip ? { clip: { w: clip.w, h: clip.h, x: clip.x, y: clip.y } } : {}
10613
+ };
10614
+ this.sessions.set(sessionId, session);
10615
+ this.wireSocket(session, tcp);
10616
+ }
10617
+ async startX11vnc(port, clip) {
10618
+ if (this.platform !== "linux") return null;
10619
+ try {
10620
+ const args = ["-display", this.config.display, "-localhost", "-nopw", "-quiet", "-rfbport", String(port)];
10621
+ if (clip) args.push("-clip", `${clip.w}x${clip.h}+${clip.x}+${clip.y}`);
10622
+ return this.spawnChildFn("x11vnc", args);
10623
+ } catch {
10624
+ return null;
10625
+ }
10626
+ }
10627
+ /** Attach data/backpressure/close handlers to a session's TCP socket. */
10628
+ wireSocket(session, tcp) {
10629
+ session.tcp = tcp;
10630
+ tcp.setNoDelay(true);
10631
+ tcp.on("data", (buf) => {
10632
+ this.hooks.send(session.id, new Uint8Array(buf));
10633
+ if (!session.paused && this.hooks.congested()) {
10634
+ session.paused = true;
10635
+ tcp.pause();
10636
+ session.pauseTimer = setInterval(() => {
10637
+ if (!this.hooks.congested()) {
10638
+ session.paused = false;
10639
+ if (session.pauseTimer) clearInterval(session.pauseTimer);
10640
+ session.pauseTimer = void 0;
10641
+ try {
10642
+ tcp.resume();
10643
+ } catch {
10644
+ }
10645
+ }
10646
+ }, PAUSE_POLL_MS);
10647
+ }
10648
+ });
10649
+ tcp.on("error", (err) => {
10650
+ this.hooks.log(`desktop ${session.id}: tcp error ${err.message}`);
10651
+ this.end(session.id, 1, "tcp error");
10652
+ });
10653
+ tcp.on("close", () => {
10654
+ const s = this.sessions.get(session.id);
10655
+ if (s) this.end(session.id, 0, "vnc closed");
10656
+ });
10657
+ }
10658
+ /** VNC client bytes (RFB) from the web side → the local VNC server. */
10659
+ write(sessionId, data) {
10660
+ const s = this.sessions.get(sessionId);
10661
+ if (!s) throw new Error(`desktop session ${sessionId} not found`);
10662
+ s.tcp.write(data);
10663
+ }
10664
+ has(sessionId) {
10665
+ return this.sessions.has(sessionId);
10666
+ }
10667
+ list() {
10668
+ return [...this.sessions.values()].map((s) => ({ sessionId: s.id, startedAt: s.startedAt }));
10669
+ }
10670
+ kill(sessionId) {
10671
+ const s = this.sessions.get(sessionId);
10672
+ if (s) this.end(sessionId, 0, "killed");
10673
+ }
10674
+ /** Called whenever the upstream transport dies or the process exits —
10675
+ * a desktop session must NEVER outlive the tunnel (design §2 #8). */
10676
+ killAll() {
10677
+ for (const id of [...this.sessions.keys()]) {
10678
+ this.kill(id);
10679
+ }
10680
+ }
10681
+ end(sessionId, code, why) {
10682
+ const s = this.sessions.get(sessionId);
10683
+ if (!s) return;
10684
+ this.sessions.delete(sessionId);
10685
+ if (s.pauseTimer) clearInterval(s.pauseTimer);
10686
+ try {
10687
+ s.tcp.destroy();
10688
+ } catch {
10689
+ }
10690
+ if (s.x11vnc) {
10691
+ try {
10692
+ s.x11vnc.kill("SIGTERM");
10693
+ } catch {
10694
+ }
10695
+ }
10696
+ this.hooks.log(`desktop ${sessionId} ended (${why})`);
10697
+ this.hooks.onExit(sessionId, code);
10698
+ }
10699
+ // -------------------------------------------------------------------------
10700
+ // Live switching (desktop-switch)
10701
+ // -------------------------------------------------------------------------
10702
+ /** Returns an error reason on failure, undefined on success. */
10703
+ async switch(sessionId, opts) {
10704
+ const s = this.sessions.get(sessionId);
10705
+ if (!s) return `desktop session ${sessionId} not found`;
10706
+ if (opts.display !== void 0) {
10707
+ const err = await this.switchDisplay(s, opts.display);
10708
+ if (err) return err;
10709
+ }
10710
+ if (opts.workspaceDelta !== void 0) {
10711
+ const err = await this.switchWorkspaceDelta(opts.workspaceDelta);
10712
+ if (err) return err;
10713
+ } else if (opts.workspace !== void 0) {
10714
+ const err = await this.switchWorkspaceAbsolute(opts.workspace);
10715
+ if (err) return err;
10716
+ }
10717
+ return void 0;
10718
+ }
10719
+ /** Linux: respawn OUR x11vnc with a new -clip and swap the TCP socket.
10720
+ * The RFB handshake restarts on the web side (viewer re-attaches). */
10721
+ async switchDisplay(s, display) {
10722
+ if (this.platform === "darwin") {
10723
+ return "display switching is client-side on this platform (web viewport crop)";
10724
+ }
10725
+ if (this.platform !== "linux") return "display switching is not supported on this platform";
10726
+ if (s.externalServer || !s.x11vnc) {
10727
+ return "cannot switch displays on an external VNC server (only locally started x11vnc)";
10728
+ }
10729
+ const heads = await this.listHeads().catch(() => []);
10730
+ const head = heads[display];
10731
+ if (!head) return `display ${display} not found (${heads.length} connected)`;
10732
+ const { vncHost: host, vncPort: port } = this.config;
10733
+ try {
10734
+ s.tcp.removeAllListeners();
10735
+ s.tcp.destroy();
10736
+ } catch {
10737
+ }
10738
+ try {
10739
+ s.x11vnc.kill("SIGTERM");
10740
+ } catch {
10741
+ }
10742
+ const child = await this.startX11vnc(port, head);
10743
+ const tcp = await probeConnect(host, port, X11VNC_START_TIMEOUT_MS);
10744
+ if (!tcp) {
10745
+ this.end(s.id, 1, "x11vnc restart failed during display switch");
10746
+ return "x11vnc did not come back up";
10747
+ }
10748
+ if (child) {
10749
+ child.once("exit", () => {
10750
+ const cur = this.sessions.get(s.id);
10751
+ if (cur && cur.x11vnc === child) this.end(s.id, 1, "x11vnc exited");
10752
+ });
10753
+ }
10754
+ s.x11vnc = child ?? void 0;
10755
+ s.clip = { w: head.w, h: head.h, x: head.x, y: head.y };
10756
+ this.wireSocket(s, tcp);
10757
+ this.hooks.log(`desktop ${s.id}: display \u2192 ${display} (${head.name} ${head.w}x${head.h}+${head.x}+${head.y})`);
10758
+ return void 0;
10759
+ }
10760
+ async switchWorkspaceAbsolute(n) {
10761
+ if (this.platform !== "linux") return "absolute workspace switching is not supported on this platform";
10762
+ try {
10763
+ await this.exec("wmctrl", ["-s", String(n)]);
10764
+ return void 0;
10765
+ } catch {
10766
+ return `wmctrl -s ${n} failed (is wmctrl installed? does workspace ${n} exist?)`;
10767
+ }
10768
+ }
10769
+ async switchWorkspaceDelta(delta) {
10770
+ if (this.platform === "linux") {
10771
+ const ws = await this.workspaceState().catch(() => null);
10772
+ if (!ws) return "cannot read current workspace (wmctrl missing?)";
10773
+ const target = ws.current + delta;
10774
+ if (target < 0 || target >= ws.count) return `no workspace ${target < 0 ? "before" : "after"} the current one`;
10775
+ return this.switchWorkspaceAbsolute(target);
10776
+ }
10777
+ if (this.platform === "darwin") {
10778
+ const keyCode = delta === 1 ? 124 : 123;
10779
+ try {
10780
+ await this.exec(
10781
+ "osascript",
10782
+ ["-e", `tell application "System Events" to key code ${keyCode} using control down`],
10783
+ 8e3
10784
+ );
10785
+ return void 0;
10786
+ } catch {
10787
+ return "System Events keystroke failed (grant Accessibility permission to the app running cli-local, or switch Spaces manually)";
10788
+ }
10789
+ }
10790
+ return "workspace switching is not supported on this platform";
10791
+ }
10792
+ };
10793
+ function probeConnect(host, port, timeoutMs) {
10794
+ return new Promise((resolve2) => {
10795
+ const sock = import_node_net.default.connect({ host, port });
10796
+ let settled = false;
10797
+ const done = (result) => {
10798
+ if (settled) return;
10799
+ settled = true;
10800
+ clearTimeout(timer);
10801
+ if (result === null) {
10802
+ sock.destroy();
10803
+ }
10804
+ resolve2(result);
10805
+ };
10806
+ const timer = setTimeout(() => done(null), timeoutMs);
10807
+ sock.once("connect", () => done(sock));
10808
+ sock.once("error", () => done(null));
10809
+ });
10810
+ }
10811
+
10812
+ // src/config.ts
10813
+ function resolveDesktopConfig(cfg) {
10814
+ return {
10815
+ enabled: cfg?.enabled === true,
10816
+ display: cfg?.display ?? ":0",
10817
+ vncHost: cfg?.vncHost ?? "127.0.0.1",
10818
+ vncPort: cfg?.vncPort ?? 5900
10819
+ };
10820
+ }
10821
+
10424
10822
  // src/gateway-api.ts
10425
10823
  var import_node_http = require("node:http");
10426
10824
  var import_node_fs3 = require("node:fs");
@@ -10606,7 +11004,7 @@ function startSetupCallback(timeoutMs = 3 * 6e4) {
10606
11004
  }
10607
11005
 
10608
11006
  // src/auth.ts
10609
- var import_node_child_process2 = require("node:child_process");
11007
+ var import_node_child_process3 = require("node:child_process");
10610
11008
  var import_node_crypto3 = require("node:crypto");
10611
11009
  var import_promises3 = require("node:fs/promises");
10612
11010
  var import_node_os6 = require("node:os");
@@ -10614,13 +11012,13 @@ var import_node_path6 = require("node:path");
10614
11012
  var import_qrcode = __toESM(require_lib(), 1);
10615
11013
 
10616
11014
  // src/machine-id.ts
10617
- var import_node_child_process = require("node:child_process");
11015
+ var import_node_child_process2 = require("node:child_process");
10618
11016
  var import_node_crypto2 = require("node:crypto");
10619
11017
  var import_promises2 = require("node:fs/promises");
10620
11018
  var import_node_os5 = require("node:os");
10621
11019
  var import_node_path5 = require("node:path");
10622
- var import_node_util = require("node:util");
10623
- var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile);
11020
+ var import_node_util2 = require("node:util");
11021
+ var execFileAsync = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
10624
11022
  var MACHINE_ID_DOMAIN = "cli-remote.machineid.v1";
10625
11023
  function normalizeHardwareId(raw) {
10626
11024
  return raw.trim().toLowerCase().replace(/\s+/g, " ");
@@ -10785,7 +11183,7 @@ function openBrowser(url) {
10785
11183
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
10786
11184
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
10787
11185
  try {
10788
- const child = (0, import_node_child_process2.spawn)(cmd, args, { stdio: "ignore", detached: true });
11186
+ const child = (0, import_node_child_process3.spawn)(cmd, args, { stdio: "ignore", detached: true });
10789
11187
  child.unref();
10790
11188
  } catch {
10791
11189
  }
@@ -10797,7 +11195,14 @@ async function loginAndFetchRegistrationKey(baseUrl, email, password) {
10797
11195
  throw new AuthError(`\u767B\u5F55\u5931\u8D25: ${reason ?? login.status}`);
10798
11196
  }
10799
11197
  const token = login.data.token;
10800
- if (!token) throw new AuthError("\u670D\u52A1\u7AEF\u672A\u8FD4\u56DE\u4F1A\u8BDD token");
11198
+ if (!token) {
11199
+ if (login.data?.totpRequired) {
11200
+ throw new AuthError(
11201
+ "\u8BE5\u8D26\u53F7\u5DF2\u5F00\u542F\u4E24\u6B65\u9A8C\u8BC1\uFF0C\u8D26\u53F7\u5BC6\u7801\u65B9\u5F0F\u4E0D\u53EF\u7528\u3002\u8BF7\u6539\u7528\uFF1A\u2460\u201C\u901A\u8FC7\u7F51\u9875\u767B\u5F55\u83B7\u53D6\u201D\uFF08\u63A8\u8350\uFF09\u6216 \u2461\u624B\u52A8\u7C98\u8D34 registrationKey"
11202
+ );
11203
+ }
11204
+ throw new AuthError("\u670D\u52A1\u7AEF\u672A\u8FD4\u56DE\u4F1A\u8BDD token");
11205
+ }
10801
11206
  const me = await fetch(`${baseUrl}/api/me`, {
10802
11207
  headers: { authorization: `Bearer ${token}` }
10803
11208
  });
@@ -10975,7 +11380,7 @@ function applyConfigDeprecations(cfg) {
10975
11380
  function isTTY() {
10976
11381
  return process.stdin.isTTY === true;
10977
11382
  }
10978
- function detectCommands() {
11383
+ function detectCommands(desktopEnabled) {
10979
11384
  const bins = [
10980
11385
  ["claude", "claude"],
10981
11386
  ["codex", "codex"],
@@ -10986,15 +11391,16 @@ function detectCommands() {
10986
11391
  for (const [cmd, bin] of bins) {
10987
11392
  let ok = false;
10988
11393
  if (process.platform === "win32") {
10989
- ok = (0, import_node_child_process3.spawnSync)("where", [bin], { timeout: 5e3 }).status === 0;
11394
+ ok = (0, import_node_child_process4.spawnSync)("where", [bin], { timeout: 5e3 }).status === 0;
10990
11395
  } else {
10991
11396
  const shell = process.env.SHELL ?? "/bin/bash";
10992
- ok = (0, import_node_child_process3.spawnSync)(shell, ["-l", "-c", `command -v ${bin}`], {
11397
+ ok = (0, import_node_child_process4.spawnSync)(shell, ["-l", "-c", `command -v ${bin}`], {
10993
11398
  timeout: 5e3
10994
11399
  }).status === 0;
10995
11400
  }
10996
11401
  if (ok) found.push(cmd);
10997
11402
  }
11403
+ if (desktopEnabled) found.push("desktop");
10998
11404
  return found;
10999
11405
  }
11000
11406
  async function readInput(prompt) {
@@ -11120,6 +11526,20 @@ async function startLocal(opts = {}) {
11120
11526
  (data) => safeSend(data),
11121
11527
  (msg) => console.log(`[local] ${msg}`)
11122
11528
  );
11529
+ const desktops = new DesktopManager(
11530
+ {
11531
+ send: (sessionId, data) => safeSend(encodeFrame({ t: "desktop-data", sessionId, data })),
11532
+ congested: () => ws ? ws.bufferedAmount > 4 * 1024 * 1024 : false,
11533
+ onExit: (sessionId, code) => {
11534
+ const logger = loggers.get(sessionId);
11535
+ logger?.logExit(code);
11536
+ loggers.delete(sessionId);
11537
+ safeSend(encodeFrame({ t: "exit", sessionId, code }));
11538
+ },
11539
+ log: (msg) => console.log(`[local] ${msg}`)
11540
+ },
11541
+ resolveDesktopConfig(config.desktop)
11542
+ );
11123
11543
  const other = acquireSingleInstanceLock();
11124
11544
  if (other !== null) {
11125
11545
  console.error(`[local] \u53E6\u4E00\u4E2A cli-local \u6B63\u5728\u8FD0\u884C (pid ${other})\uFF0C\u62D2\u7EDD\u53CC\u5F00\u3002`);
@@ -11153,7 +11573,7 @@ async function startLocal(opts = {}) {
11153
11573
  } else {
11154
11574
  credentials = await ensureCredentials(baseUrl, registrationKey);
11155
11575
  }
11156
- const commands = detectCommands();
11576
+ const commands = detectCommands(desktops.unsupportedReason() === null);
11157
11577
  console.log(`[local] connecting to ${url}`);
11158
11578
  console.log(`[local] deviceId=${credentials.deviceId || "(\u672A\u6388\u6743)"}`);
11159
11579
  console.log(`[local] bash: passthrough (audit: ${auditMode}, retention: ${retentionDays === 0 ? "forever" : retentionDays + "d"}${dangerConfirm ? ", dangerConfirm: on" : ""})`);
@@ -11176,11 +11596,18 @@ async function startLocal(opts = {}) {
11176
11596
  uptimeMs: Date.now() - startedAt,
11177
11597
  reconnectAttempts,
11178
11598
  accessTokenExpiresAt: credentials.accessTokenExpiresAt || null,
11179
- sessions: pty.list().map((s) => ({
11180
- sessionId: s.id,
11181
- cmd: s.cmd,
11182
- startedAt: s.startedAt
11183
- }))
11599
+ sessions: [
11600
+ ...pty.list().map((s) => ({
11601
+ sessionId: s.id,
11602
+ cmd: s.cmd,
11603
+ startedAt: s.startedAt
11604
+ })),
11605
+ ...desktops.list().map((d) => ({
11606
+ sessionId: d.sessionId,
11607
+ cmd: "desktop",
11608
+ startedAt: d.startedAt
11609
+ }))
11610
+ ]
11184
11611
  });
11185
11612
  function setPhase(p, reason = "") {
11186
11613
  phase = p;
@@ -11231,6 +11658,10 @@ async function startLocal(opts = {}) {
11231
11658
  exitProcess(0);
11232
11659
  },
11233
11660
  killSession: (id) => {
11661
+ if (desktops.has(id)) {
11662
+ desktops.kill(id);
11663
+ return true;
11664
+ }
11234
11665
  try {
11235
11666
  pty.kill(id);
11236
11667
  return true;
@@ -11283,6 +11714,7 @@ async function startLocal(opts = {}) {
11283
11714
  process.exit(userExitCode);
11284
11715
  }
11285
11716
  console.log("[local] disconnected; PTY sessions kept alive, reconnecting\u2026");
11717
+ desktops.killAll();
11286
11718
  if (phase !== "needs-pairing") setPhase("reconnecting");
11287
11719
  scheduleReconnect();
11288
11720
  });
@@ -11321,6 +11753,7 @@ async function startLocal(opts = {}) {
11321
11753
  controlApi?.close();
11322
11754
  if (opts.daemon) removeGatewayPid();
11323
11755
  transfers.abortAll();
11756
+ desktops.killAll();
11324
11757
  pty.killAll();
11325
11758
  for (const [, logger] of loggers) logger.logExit(-1);
11326
11759
  loggers.clear();
@@ -11359,9 +11792,22 @@ async function startLocal(opts = {}) {
11359
11792
  case "spawn":
11360
11793
  handleSpawn(frame);
11361
11794
  break;
11795
+ case "desktop-query":
11796
+ handleDesktopQuery(frame);
11797
+ break;
11798
+ case "desktop-switch":
11799
+ void handleDesktopSwitch(frame);
11800
+ break;
11362
11801
  case "input":
11363
11802
  handleInput(frame);
11364
11803
  break;
11804
+ case "desktop-data":
11805
+ try {
11806
+ desktops.write(frame.sessionId, frame.data);
11807
+ } catch (e) {
11808
+ console.error("[local] desktop write error:", e.message);
11809
+ }
11810
+ break;
11365
11811
  case "resize":
11366
11812
  pty.resize(frame.sessionId, frame.cols, frame.rows);
11367
11813
  break;
@@ -11414,6 +11860,13 @@ async function startLocal(opts = {}) {
11414
11860
  }
11415
11861
  function handleSpawn(frame) {
11416
11862
  const sessionId = frame.sessionId;
11863
+ if (frame.cmd === "desktop") {
11864
+ void handleDesktopSpawn(sessionId, {
11865
+ ...frame.desktopDisplay !== void 0 ? { display: frame.desktopDisplay } : {},
11866
+ ...frame.desktopWorkspace !== void 0 ? { workspace: frame.desktopWorkspace } : {}
11867
+ });
11868
+ return;
11869
+ }
11417
11870
  try {
11418
11871
  const logger = new SessionLogger(sessionId, frame.cmd, auditMode);
11419
11872
  pty.spawn(
@@ -11461,6 +11914,64 @@ async function startLocal(opts = {}) {
11461
11914
  console.error(`[local] spawn failed for ${sessionId}:`, reason);
11462
11915
  }
11463
11916
  }
11917
+ async function handleDesktopSpawn(sessionId, opts2 = {}) {
11918
+ const unsupported = desktops.unsupportedReason();
11919
+ if (unsupported) {
11920
+ safeSend(encodeFrame({ t: "spawn-err", sessionId, reason: unsupported }));
11921
+ return;
11922
+ }
11923
+ const logger = new SessionLogger(sessionId, "desktop", auditMode);
11924
+ try {
11925
+ await desktops.spawn(sessionId, opts2);
11926
+ loggers.set(sessionId, logger);
11927
+ safeSend(encodeFrame({ t: "spawn-ok", sessionId }));
11928
+ console.log(`[local] desktop session ${sessionId} bridged to VNC`);
11929
+ } catch (e) {
11930
+ logger.logExit(1);
11931
+ const reason = e instanceof Error ? e.message : String(e);
11932
+ safeSend(encodeFrame({ t: "spawn-err", sessionId, reason }));
11933
+ console.error(`[local] desktop spawn failed for ${sessionId}:`, reason);
11934
+ }
11935
+ }
11936
+ function handleDesktopQuery(frame) {
11937
+ desktops.queryInfo().then((info) => {
11938
+ safeSend(encodeFrame({ t: "desktop-info", sessionId: frame.sessionId, ...info }));
11939
+ }).catch(() => {
11940
+ safeSend(
11941
+ encodeFrame({
11942
+ t: "desktop-info",
11943
+ sessionId: frame.sessionId,
11944
+ displays: [],
11945
+ workspaces: null,
11946
+ currentWorkspace: null,
11947
+ canPickDisplay: false,
11948
+ cropMode: "client"
11949
+ })
11950
+ );
11951
+ });
11952
+ }
11953
+ async function handleDesktopSwitch(frame) {
11954
+ const logger = loggers.get(frame.sessionId);
11955
+ const reason = await desktops.switch(frame.sessionId, {
11956
+ ...frame.display !== void 0 ? { display: frame.display } : {},
11957
+ ...frame.workspace !== void 0 ? { workspace: frame.workspace } : {},
11958
+ ...frame.workspaceDelta !== void 0 ? { workspaceDelta: frame.workspaceDelta } : {}
11959
+ }).catch((e) => e instanceof Error ? e.message : String(e));
11960
+ if (reason === void 0) {
11961
+ if (frame.display !== void 0) logger?.logSwitch("display", String(frame.display));
11962
+ if (frame.workspace !== void 0) logger?.logSwitch("workspace", String(frame.workspace));
11963
+ if (frame.workspaceDelta !== void 0)
11964
+ logger?.logSwitch("workspace", frame.workspaceDelta === 1 ? "next" : "prev");
11965
+ }
11966
+ safeSend(
11967
+ encodeFrame({
11968
+ t: "desktop-switch-res",
11969
+ sessionId: frame.sessionId,
11970
+ ok: reason === void 0,
11971
+ ...reason !== void 0 ? { reason } : {}
11972
+ })
11973
+ );
11974
+ }
11464
11975
  function handleInput(frame) {
11465
11976
  const sessionId = frame.sessionId;
11466
11977
  const logger = loggers.get(sessionId);
@@ -11535,10 +12046,25 @@ async function startLocal(opts = {}) {
11535
12046
  }
11536
12047
  }
11537
12048
  function handleKill(sessionId) {
12049
+ if (desktops.has(sessionId)) {
12050
+ desktops.kill(sessionId);
12051
+ return;
12052
+ }
11538
12053
  pty.kill(sessionId);
11539
12054
  lineBuffers.delete(sessionId);
11540
12055
  }
11541
12056
  function handleReplay(frame) {
12057
+ if (desktops.has(frame.sessionId)) {
12058
+ safeSend(
12059
+ encodeFrame({
12060
+ t: "replay-res",
12061
+ sessionId: frame.sessionId,
12062
+ frames: [],
12063
+ exists: true
12064
+ })
12065
+ );
12066
+ return;
12067
+ }
11542
12068
  const frames = pty.replay(frame.sessionId, frame.fromSeq);
11543
12069
  safeSend(
11544
12070
  encodeFrame({
@@ -11698,7 +12224,7 @@ function resolveHere() {
11698
12224
  var argUrl = process.argv[2];
11699
12225
  var argMatch = argUrl && (argUrl.startsWith("ws://") || argUrl.startsWith("wss://"));
11700
12226
  function readVersion() {
11701
- if (true) return "0.3.4";
12227
+ if (true) return "0.4.1";
11702
12228
  try {
11703
12229
  const pkg = JSON.parse(
11704
12230
  (0, import_node_fs5.readFileSync)(new URL("../package.json", import_meta.url), "utf-8")
@@ -11771,7 +12297,7 @@ function entryScript() {
11771
12297
  }
11772
12298
  function run(cmd, args, okToFail = false) {
11773
12299
  try {
11774
- return (0, import_node_child_process4.execFileSync)(cmd, args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
12300
+ return (0, import_node_child_process5.execFileSync)(cmd, args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
11775
12301
  } catch (e) {
11776
12302
  if (okToFail) return null;
11777
12303
  throw e;
@@ -11972,7 +12498,7 @@ if (argUrl === "gateway") {
11972
12498
  for (const [k, v] of Object.entries(process.env)) if (v !== void 0) env[k] = v;
11973
12499
  const args = [entry, "gateway", "run"];
11974
12500
  if (hubArg) args.push("--hub", hubArg);
11975
- const child = (0, import_node_child_process4.spawn)(process.execPath, args, {
12501
+ const child = (0, import_node_child_process5.spawn)(process.execPath, args, {
11976
12502
  detached: true,
11977
12503
  stdio: ["ignore", out, out],
11978
12504
  env
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cli-remote/local",
3
- "version": "0.3.4",
3
+ "version": "0.4.1",
4
4
  "description": "Local PTY agent for cli-remote — exposes your local CLI tools (claude/codex/kimi-cli/shell) to the mobile web client via a self-hosted hub.",
5
5
  "license": "MIT",
6
6
  "repository": {