@cli-remote/local 0.3.4 → 0.4.0

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 +499 -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,347 @@ 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 !== "linux") {
10472
+ return { displays: [], workspaces: null, currentWorkspace: null, canPickDisplay: false };
10473
+ }
10474
+ const [heads, ws] = await Promise.all([this.listHeads().catch(() => []), this.workspaceState().catch(() => null)]);
10475
+ return {
10476
+ displays: heads.map((h) => ({ name: h.name, width: h.w, height: h.h })),
10477
+ workspaces: ws?.count ?? null,
10478
+ currentWorkspace: ws?.current ?? null,
10479
+ canPickDisplay: heads.length > 0
10480
+ };
10481
+ }
10482
+ /** `xrandr --query` connected heads (name + geometry). */
10483
+ async listHeads() {
10484
+ const out = await this.exec("xrandr", ["--query"]);
10485
+ const heads = [];
10486
+ for (const line of out.split("\n")) {
10487
+ const m = /^(\S+) connected (?:primary )?(\d+)x(\d+)\+(\d+)\+(\d+)\s/.exec(line);
10488
+ if (m) heads.push({ name: m[1], w: Number(m[2]), h: Number(m[3]), x: Number(m[4]), y: Number(m[5]) });
10489
+ }
10490
+ return heads;
10491
+ }
10492
+ /** `wmctrl -d` → { count, current }. The current line carries ` * `. */
10493
+ async workspaceState() {
10494
+ const out = await this.exec("wmctrl", ["-d"]);
10495
+ const lines = out.split("\n").filter((l) => l.trim());
10496
+ if (lines.length === 0) return null;
10497
+ const current = lines.findIndex((l) => /(^|\s)\*(\s|$)/.test(l));
10498
+ return { count: lines.length, current: current >= 0 ? current : 0 };
10499
+ }
10500
+ // -------------------------------------------------------------------------
10501
+ // Session lifecycle
10502
+ // -------------------------------------------------------------------------
10503
+ async spawn(sessionId, opts = {}) {
10504
+ if (this.sessions.has(sessionId)) {
10505
+ throw new Error(`session ${sessionId} already exists`);
10506
+ }
10507
+ const { vncHost: host, vncPort: port } = this.config;
10508
+ if (opts.workspace !== void 0 && this.platform !== "linux") {
10509
+ throw new Error("workspace selection is not supported on this platform (session-internal prev/next still works on macOS)");
10510
+ }
10511
+ if (opts.display !== void 0 && this.platform !== "linux") {
10512
+ throw new Error("display selection is not supported on this platform");
10513
+ }
10514
+ if (opts.workspace !== void 0) {
10515
+ await this.switchWorkspaceAbsolute(opts.workspace);
10516
+ this.hooks.log(`desktop ${sessionId}: workspace \u2192 ${opts.workspace}`);
10517
+ }
10518
+ let tcp = await probeConnect(host, port, 750);
10519
+ let ownChild = null;
10520
+ let clip;
10521
+ if (opts.display !== void 0) {
10522
+ const heads = await this.listHeads().catch(() => []);
10523
+ clip = heads[opts.display];
10524
+ if (!clip) {
10525
+ throw new Error(`display ${opts.display} not found (${heads.length} connected)`);
10526
+ }
10527
+ }
10528
+ if (!tcp && this.platform === "linux") {
10529
+ const x11vnc = await this.startX11vnc(port, clip);
10530
+ ownChild = x11vnc;
10531
+ tcp = await probeConnect(host, port, X11VNC_START_TIMEOUT_MS);
10532
+ if (!tcp) {
10533
+ try {
10534
+ x11vnc?.kill("SIGTERM");
10535
+ } catch {
10536
+ }
10537
+ throw new Error(
10538
+ `no VNC server on ${host}:${port} and x11vnc did not come up (is a desktop/X11 session running?)`
10539
+ );
10540
+ }
10541
+ if (x11vnc) {
10542
+ x11vnc.once("exit", () => {
10543
+ const s = this.sessions.get(sessionId);
10544
+ if (s && s.x11vnc === x11vnc) this.end(sessionId, 1, "x11vnc exited");
10545
+ });
10546
+ }
10547
+ this.hooks.log(
10548
+ `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)`
10549
+ );
10550
+ }
10551
+ if (!tcp) {
10552
+ throw new Error(
10553
+ 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)`
10554
+ );
10555
+ }
10556
+ if (clip && !ownChild) {
10557
+ throw new Error("external VNC server on the port cannot serve a chosen display (stop it, or spawn with the default display)");
10558
+ }
10559
+ const session = {
10560
+ id: sessionId,
10561
+ tcp,
10562
+ startedAt: Date.now(),
10563
+ paused: false,
10564
+ externalServer: !ownChild,
10565
+ ...ownChild ? { x11vnc: ownChild } : {},
10566
+ ...clip ? { clip: { w: clip.w, h: clip.h, x: clip.x, y: clip.y } } : {}
10567
+ };
10568
+ this.sessions.set(sessionId, session);
10569
+ this.wireSocket(session, tcp);
10570
+ }
10571
+ async startX11vnc(port, clip) {
10572
+ if (this.platform !== "linux") return null;
10573
+ try {
10574
+ const args = ["-display", this.config.display, "-localhost", "-nopw", "-quiet", "-rfbport", String(port)];
10575
+ if (clip) args.push("-clip", `${clip.w}x${clip.h}+${clip.x}+${clip.y}`);
10576
+ return this.spawnChildFn("x11vnc", args);
10577
+ } catch {
10578
+ return null;
10579
+ }
10580
+ }
10581
+ /** Attach data/backpressure/close handlers to a session's TCP socket. */
10582
+ wireSocket(session, tcp) {
10583
+ session.tcp = tcp;
10584
+ tcp.setNoDelay(true);
10585
+ tcp.on("data", (buf) => {
10586
+ this.hooks.send(session.id, new Uint8Array(buf));
10587
+ if (!session.paused && this.hooks.congested()) {
10588
+ session.paused = true;
10589
+ tcp.pause();
10590
+ session.pauseTimer = setInterval(() => {
10591
+ if (!this.hooks.congested()) {
10592
+ session.paused = false;
10593
+ if (session.pauseTimer) clearInterval(session.pauseTimer);
10594
+ session.pauseTimer = void 0;
10595
+ try {
10596
+ tcp.resume();
10597
+ } catch {
10598
+ }
10599
+ }
10600
+ }, PAUSE_POLL_MS);
10601
+ }
10602
+ });
10603
+ tcp.on("error", (err) => {
10604
+ this.hooks.log(`desktop ${session.id}: tcp error ${err.message}`);
10605
+ this.end(session.id, 1, "tcp error");
10606
+ });
10607
+ tcp.on("close", () => {
10608
+ const s = this.sessions.get(session.id);
10609
+ if (s) this.end(session.id, 0, "vnc closed");
10610
+ });
10611
+ }
10612
+ /** VNC client bytes (RFB) from the web side → the local VNC server. */
10613
+ write(sessionId, data) {
10614
+ const s = this.sessions.get(sessionId);
10615
+ if (!s) throw new Error(`desktop session ${sessionId} not found`);
10616
+ s.tcp.write(data);
10617
+ }
10618
+ has(sessionId) {
10619
+ return this.sessions.has(sessionId);
10620
+ }
10621
+ list() {
10622
+ return [...this.sessions.values()].map((s) => ({ sessionId: s.id, startedAt: s.startedAt }));
10623
+ }
10624
+ kill(sessionId) {
10625
+ const s = this.sessions.get(sessionId);
10626
+ if (s) this.end(sessionId, 0, "killed");
10627
+ }
10628
+ /** Called whenever the upstream transport dies or the process exits —
10629
+ * a desktop session must NEVER outlive the tunnel (design §2 #8). */
10630
+ killAll() {
10631
+ for (const id of [...this.sessions.keys()]) {
10632
+ this.kill(id);
10633
+ }
10634
+ }
10635
+ end(sessionId, code, why) {
10636
+ const s = this.sessions.get(sessionId);
10637
+ if (!s) return;
10638
+ this.sessions.delete(sessionId);
10639
+ if (s.pauseTimer) clearInterval(s.pauseTimer);
10640
+ try {
10641
+ s.tcp.destroy();
10642
+ } catch {
10643
+ }
10644
+ if (s.x11vnc) {
10645
+ try {
10646
+ s.x11vnc.kill("SIGTERM");
10647
+ } catch {
10648
+ }
10649
+ }
10650
+ this.hooks.log(`desktop ${sessionId} ended (${why})`);
10651
+ this.hooks.onExit(sessionId, code);
10652
+ }
10653
+ // -------------------------------------------------------------------------
10654
+ // Live switching (desktop-switch)
10655
+ // -------------------------------------------------------------------------
10656
+ /** Returns an error reason on failure, undefined on success. */
10657
+ async switch(sessionId, opts) {
10658
+ const s = this.sessions.get(sessionId);
10659
+ if (!s) return `desktop session ${sessionId} not found`;
10660
+ if (opts.display !== void 0) {
10661
+ const err = await this.switchDisplay(s, opts.display);
10662
+ if (err) return err;
10663
+ }
10664
+ if (opts.workspaceDelta !== void 0) {
10665
+ const err = await this.switchWorkspaceDelta(opts.workspaceDelta);
10666
+ if (err) return err;
10667
+ } else if (opts.workspace !== void 0) {
10668
+ const err = await this.switchWorkspaceAbsolute(opts.workspace);
10669
+ if (err) return err;
10670
+ }
10671
+ return void 0;
10672
+ }
10673
+ /** Linux: respawn OUR x11vnc with a new -clip and swap the TCP socket.
10674
+ * The RFB handshake restarts on the web side (viewer re-attaches). */
10675
+ async switchDisplay(s, display) {
10676
+ if (this.platform !== "linux") return "display switching is not supported on this platform";
10677
+ if (s.externalServer || !s.x11vnc) {
10678
+ return "cannot switch displays on an external VNC server (only locally started x11vnc)";
10679
+ }
10680
+ const heads = await this.listHeads().catch(() => []);
10681
+ const head = heads[display];
10682
+ if (!head) return `display ${display} not found (${heads.length} connected)`;
10683
+ const { vncHost: host, vncPort: port } = this.config;
10684
+ try {
10685
+ s.tcp.removeAllListeners();
10686
+ s.tcp.destroy();
10687
+ } catch {
10688
+ }
10689
+ try {
10690
+ s.x11vnc.kill("SIGTERM");
10691
+ } catch {
10692
+ }
10693
+ const child = await this.startX11vnc(port, head);
10694
+ const tcp = await probeConnect(host, port, X11VNC_START_TIMEOUT_MS);
10695
+ if (!tcp) {
10696
+ this.end(s.id, 1, "x11vnc restart failed during display switch");
10697
+ return "x11vnc did not come back up";
10698
+ }
10699
+ if (child) {
10700
+ child.once("exit", () => {
10701
+ const cur = this.sessions.get(s.id);
10702
+ if (cur && cur.x11vnc === child) this.end(s.id, 1, "x11vnc exited");
10703
+ });
10704
+ }
10705
+ s.x11vnc = child ?? void 0;
10706
+ s.clip = { w: head.w, h: head.h, x: head.x, y: head.y };
10707
+ this.wireSocket(s, tcp);
10708
+ this.hooks.log(`desktop ${s.id}: display \u2192 ${display} (${head.name} ${head.w}x${head.h}+${head.x}+${head.y})`);
10709
+ return void 0;
10710
+ }
10711
+ async switchWorkspaceAbsolute(n) {
10712
+ if (this.platform !== "linux") return "absolute workspace switching is not supported on this platform";
10713
+ try {
10714
+ await this.exec("wmctrl", ["-s", String(n)]);
10715
+ return void 0;
10716
+ } catch {
10717
+ return `wmctrl -s ${n} failed (is wmctrl installed? does workspace ${n} exist?)`;
10718
+ }
10719
+ }
10720
+ async switchWorkspaceDelta(delta) {
10721
+ if (this.platform === "linux") {
10722
+ const ws = await this.workspaceState().catch(() => null);
10723
+ if (!ws) return "cannot read current workspace (wmctrl missing?)";
10724
+ const target = ws.current + delta;
10725
+ if (target < 0 || target >= ws.count) return `no workspace ${target < 0 ? "before" : "after"} the current one`;
10726
+ return this.switchWorkspaceAbsolute(target);
10727
+ }
10728
+ if (this.platform === "darwin") {
10729
+ const keyCode = delta === 1 ? 124 : 123;
10730
+ try {
10731
+ await this.exec(
10732
+ "osascript",
10733
+ ["-e", `tell application "System Events" to key code ${keyCode} using control down`],
10734
+ 8e3
10735
+ );
10736
+ return void 0;
10737
+ } catch {
10738
+ return "System Events keystroke failed (grant Accessibility permission to the app running cli-local, or switch Spaces manually)";
10739
+ }
10740
+ }
10741
+ return "workspace switching is not supported on this platform";
10742
+ }
10743
+ };
10744
+ function probeConnect(host, port, timeoutMs) {
10745
+ return new Promise((resolve2) => {
10746
+ const sock = import_node_net.default.connect({ host, port });
10747
+ let settled = false;
10748
+ const done = (result) => {
10749
+ if (settled) return;
10750
+ settled = true;
10751
+ clearTimeout(timer);
10752
+ if (result === null) {
10753
+ sock.destroy();
10754
+ }
10755
+ resolve2(result);
10756
+ };
10757
+ const timer = setTimeout(() => done(null), timeoutMs);
10758
+ sock.once("connect", () => done(sock));
10759
+ sock.once("error", () => done(null));
10760
+ });
10761
+ }
10762
+
10763
+ // src/config.ts
10764
+ function resolveDesktopConfig(cfg) {
10765
+ return {
10766
+ enabled: cfg?.enabled === true,
10767
+ display: cfg?.display ?? ":0",
10768
+ vncHost: cfg?.vncHost ?? "127.0.0.1",
10769
+ vncPort: cfg?.vncPort ?? 5900
10770
+ };
10771
+ }
10772
+
10424
10773
  // src/gateway-api.ts
10425
10774
  var import_node_http = require("node:http");
10426
10775
  var import_node_fs3 = require("node:fs");
@@ -10606,7 +10955,7 @@ function startSetupCallback(timeoutMs = 3 * 6e4) {
10606
10955
  }
10607
10956
 
10608
10957
  // src/auth.ts
10609
- var import_node_child_process2 = require("node:child_process");
10958
+ var import_node_child_process3 = require("node:child_process");
10610
10959
  var import_node_crypto3 = require("node:crypto");
10611
10960
  var import_promises3 = require("node:fs/promises");
10612
10961
  var import_node_os6 = require("node:os");
@@ -10614,13 +10963,13 @@ var import_node_path6 = require("node:path");
10614
10963
  var import_qrcode = __toESM(require_lib(), 1);
10615
10964
 
10616
10965
  // src/machine-id.ts
10617
- var import_node_child_process = require("node:child_process");
10966
+ var import_node_child_process2 = require("node:child_process");
10618
10967
  var import_node_crypto2 = require("node:crypto");
10619
10968
  var import_promises2 = require("node:fs/promises");
10620
10969
  var import_node_os5 = require("node:os");
10621
10970
  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);
10971
+ var import_node_util2 = require("node:util");
10972
+ var execFileAsync = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
10624
10973
  var MACHINE_ID_DOMAIN = "cli-remote.machineid.v1";
10625
10974
  function normalizeHardwareId(raw) {
10626
10975
  return raw.trim().toLowerCase().replace(/\s+/g, " ");
@@ -10785,7 +11134,7 @@ function openBrowser(url) {
10785
11134
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
10786
11135
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
10787
11136
  try {
10788
- const child = (0, import_node_child_process2.spawn)(cmd, args, { stdio: "ignore", detached: true });
11137
+ const child = (0, import_node_child_process3.spawn)(cmd, args, { stdio: "ignore", detached: true });
10789
11138
  child.unref();
10790
11139
  } catch {
10791
11140
  }
@@ -10797,7 +11146,14 @@ async function loginAndFetchRegistrationKey(baseUrl, email, password) {
10797
11146
  throw new AuthError(`\u767B\u5F55\u5931\u8D25: ${reason ?? login.status}`);
10798
11147
  }
10799
11148
  const token = login.data.token;
10800
- if (!token) throw new AuthError("\u670D\u52A1\u7AEF\u672A\u8FD4\u56DE\u4F1A\u8BDD token");
11149
+ if (!token) {
11150
+ if (login.data?.totpRequired) {
11151
+ throw new AuthError(
11152
+ "\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"
11153
+ );
11154
+ }
11155
+ throw new AuthError("\u670D\u52A1\u7AEF\u672A\u8FD4\u56DE\u4F1A\u8BDD token");
11156
+ }
10801
11157
  const me = await fetch(`${baseUrl}/api/me`, {
10802
11158
  headers: { authorization: `Bearer ${token}` }
10803
11159
  });
@@ -10975,7 +11331,7 @@ function applyConfigDeprecations(cfg) {
10975
11331
  function isTTY() {
10976
11332
  return process.stdin.isTTY === true;
10977
11333
  }
10978
- function detectCommands() {
11334
+ function detectCommands(desktopEnabled) {
10979
11335
  const bins = [
10980
11336
  ["claude", "claude"],
10981
11337
  ["codex", "codex"],
@@ -10986,15 +11342,16 @@ function detectCommands() {
10986
11342
  for (const [cmd, bin] of bins) {
10987
11343
  let ok = false;
10988
11344
  if (process.platform === "win32") {
10989
- ok = (0, import_node_child_process3.spawnSync)("where", [bin], { timeout: 5e3 }).status === 0;
11345
+ ok = (0, import_node_child_process4.spawnSync)("where", [bin], { timeout: 5e3 }).status === 0;
10990
11346
  } else {
10991
11347
  const shell = process.env.SHELL ?? "/bin/bash";
10992
- ok = (0, import_node_child_process3.spawnSync)(shell, ["-l", "-c", `command -v ${bin}`], {
11348
+ ok = (0, import_node_child_process4.spawnSync)(shell, ["-l", "-c", `command -v ${bin}`], {
10993
11349
  timeout: 5e3
10994
11350
  }).status === 0;
10995
11351
  }
10996
11352
  if (ok) found.push(cmd);
10997
11353
  }
11354
+ if (desktopEnabled) found.push("desktop");
10998
11355
  return found;
10999
11356
  }
11000
11357
  async function readInput(prompt) {
@@ -11120,6 +11477,20 @@ async function startLocal(opts = {}) {
11120
11477
  (data) => safeSend(data),
11121
11478
  (msg) => console.log(`[local] ${msg}`)
11122
11479
  );
11480
+ const desktops = new DesktopManager(
11481
+ {
11482
+ send: (sessionId, data) => safeSend(encodeFrame({ t: "desktop-data", sessionId, data })),
11483
+ congested: () => ws ? ws.bufferedAmount > 4 * 1024 * 1024 : false,
11484
+ onExit: (sessionId, code) => {
11485
+ const logger = loggers.get(sessionId);
11486
+ logger?.logExit(code);
11487
+ loggers.delete(sessionId);
11488
+ safeSend(encodeFrame({ t: "exit", sessionId, code }));
11489
+ },
11490
+ log: (msg) => console.log(`[local] ${msg}`)
11491
+ },
11492
+ resolveDesktopConfig(config.desktop)
11493
+ );
11123
11494
  const other = acquireSingleInstanceLock();
11124
11495
  if (other !== null) {
11125
11496
  console.error(`[local] \u53E6\u4E00\u4E2A cli-local \u6B63\u5728\u8FD0\u884C (pid ${other})\uFF0C\u62D2\u7EDD\u53CC\u5F00\u3002`);
@@ -11153,7 +11524,7 @@ async function startLocal(opts = {}) {
11153
11524
  } else {
11154
11525
  credentials = await ensureCredentials(baseUrl, registrationKey);
11155
11526
  }
11156
- const commands = detectCommands();
11527
+ const commands = detectCommands(desktops.unsupportedReason() === null);
11157
11528
  console.log(`[local] connecting to ${url}`);
11158
11529
  console.log(`[local] deviceId=${credentials.deviceId || "(\u672A\u6388\u6743)"}`);
11159
11530
  console.log(`[local] bash: passthrough (audit: ${auditMode}, retention: ${retentionDays === 0 ? "forever" : retentionDays + "d"}${dangerConfirm ? ", dangerConfirm: on" : ""})`);
@@ -11176,11 +11547,18 @@ async function startLocal(opts = {}) {
11176
11547
  uptimeMs: Date.now() - startedAt,
11177
11548
  reconnectAttempts,
11178
11549
  accessTokenExpiresAt: credentials.accessTokenExpiresAt || null,
11179
- sessions: pty.list().map((s) => ({
11180
- sessionId: s.id,
11181
- cmd: s.cmd,
11182
- startedAt: s.startedAt
11183
- }))
11550
+ sessions: [
11551
+ ...pty.list().map((s) => ({
11552
+ sessionId: s.id,
11553
+ cmd: s.cmd,
11554
+ startedAt: s.startedAt
11555
+ })),
11556
+ ...desktops.list().map((d) => ({
11557
+ sessionId: d.sessionId,
11558
+ cmd: "desktop",
11559
+ startedAt: d.startedAt
11560
+ }))
11561
+ ]
11184
11562
  });
11185
11563
  function setPhase(p, reason = "") {
11186
11564
  phase = p;
@@ -11231,6 +11609,10 @@ async function startLocal(opts = {}) {
11231
11609
  exitProcess(0);
11232
11610
  },
11233
11611
  killSession: (id) => {
11612
+ if (desktops.has(id)) {
11613
+ desktops.kill(id);
11614
+ return true;
11615
+ }
11234
11616
  try {
11235
11617
  pty.kill(id);
11236
11618
  return true;
@@ -11283,6 +11665,7 @@ async function startLocal(opts = {}) {
11283
11665
  process.exit(userExitCode);
11284
11666
  }
11285
11667
  console.log("[local] disconnected; PTY sessions kept alive, reconnecting\u2026");
11668
+ desktops.killAll();
11286
11669
  if (phase !== "needs-pairing") setPhase("reconnecting");
11287
11670
  scheduleReconnect();
11288
11671
  });
@@ -11321,6 +11704,7 @@ async function startLocal(opts = {}) {
11321
11704
  controlApi?.close();
11322
11705
  if (opts.daemon) removeGatewayPid();
11323
11706
  transfers.abortAll();
11707
+ desktops.killAll();
11324
11708
  pty.killAll();
11325
11709
  for (const [, logger] of loggers) logger.logExit(-1);
11326
11710
  loggers.clear();
@@ -11359,9 +11743,22 @@ async function startLocal(opts = {}) {
11359
11743
  case "spawn":
11360
11744
  handleSpawn(frame);
11361
11745
  break;
11746
+ case "desktop-query":
11747
+ handleDesktopQuery(frame);
11748
+ break;
11749
+ case "desktop-switch":
11750
+ void handleDesktopSwitch(frame);
11751
+ break;
11362
11752
  case "input":
11363
11753
  handleInput(frame);
11364
11754
  break;
11755
+ case "desktop-data":
11756
+ try {
11757
+ desktops.write(frame.sessionId, frame.data);
11758
+ } catch (e) {
11759
+ console.error("[local] desktop write error:", e.message);
11760
+ }
11761
+ break;
11365
11762
  case "resize":
11366
11763
  pty.resize(frame.sessionId, frame.cols, frame.rows);
11367
11764
  break;
@@ -11414,6 +11811,13 @@ async function startLocal(opts = {}) {
11414
11811
  }
11415
11812
  function handleSpawn(frame) {
11416
11813
  const sessionId = frame.sessionId;
11814
+ if (frame.cmd === "desktop") {
11815
+ void handleDesktopSpawn(sessionId, {
11816
+ ...frame.desktopDisplay !== void 0 ? { display: frame.desktopDisplay } : {},
11817
+ ...frame.desktopWorkspace !== void 0 ? { workspace: frame.desktopWorkspace } : {}
11818
+ });
11819
+ return;
11820
+ }
11417
11821
  try {
11418
11822
  const logger = new SessionLogger(sessionId, frame.cmd, auditMode);
11419
11823
  pty.spawn(
@@ -11461,6 +11865,63 @@ async function startLocal(opts = {}) {
11461
11865
  console.error(`[local] spawn failed for ${sessionId}:`, reason);
11462
11866
  }
11463
11867
  }
11868
+ async function handleDesktopSpawn(sessionId, opts2 = {}) {
11869
+ const unsupported = desktops.unsupportedReason();
11870
+ if (unsupported) {
11871
+ safeSend(encodeFrame({ t: "spawn-err", sessionId, reason: unsupported }));
11872
+ return;
11873
+ }
11874
+ const logger = new SessionLogger(sessionId, "desktop", auditMode);
11875
+ try {
11876
+ await desktops.spawn(sessionId, opts2);
11877
+ loggers.set(sessionId, logger);
11878
+ safeSend(encodeFrame({ t: "spawn-ok", sessionId }));
11879
+ console.log(`[local] desktop session ${sessionId} bridged to VNC`);
11880
+ } catch (e) {
11881
+ logger.logExit(1);
11882
+ const reason = e instanceof Error ? e.message : String(e);
11883
+ safeSend(encodeFrame({ t: "spawn-err", sessionId, reason }));
11884
+ console.error(`[local] desktop spawn failed for ${sessionId}:`, reason);
11885
+ }
11886
+ }
11887
+ function handleDesktopQuery(frame) {
11888
+ desktops.queryInfo().then((info) => {
11889
+ safeSend(encodeFrame({ t: "desktop-info", sessionId: frame.sessionId, ...info }));
11890
+ }).catch(() => {
11891
+ safeSend(
11892
+ encodeFrame({
11893
+ t: "desktop-info",
11894
+ sessionId: frame.sessionId,
11895
+ displays: [],
11896
+ workspaces: null,
11897
+ currentWorkspace: null,
11898
+ canPickDisplay: false
11899
+ })
11900
+ );
11901
+ });
11902
+ }
11903
+ async function handleDesktopSwitch(frame) {
11904
+ const logger = loggers.get(frame.sessionId);
11905
+ const reason = await desktops.switch(frame.sessionId, {
11906
+ ...frame.display !== void 0 ? { display: frame.display } : {},
11907
+ ...frame.workspace !== void 0 ? { workspace: frame.workspace } : {},
11908
+ ...frame.workspaceDelta !== void 0 ? { workspaceDelta: frame.workspaceDelta } : {}
11909
+ }).catch((e) => e instanceof Error ? e.message : String(e));
11910
+ if (reason === void 0) {
11911
+ if (frame.display !== void 0) logger?.logSwitch("display", String(frame.display));
11912
+ if (frame.workspace !== void 0) logger?.logSwitch("workspace", String(frame.workspace));
11913
+ if (frame.workspaceDelta !== void 0)
11914
+ logger?.logSwitch("workspace", frame.workspaceDelta === 1 ? "next" : "prev");
11915
+ }
11916
+ safeSend(
11917
+ encodeFrame({
11918
+ t: "desktop-switch-res",
11919
+ sessionId: frame.sessionId,
11920
+ ok: reason === void 0,
11921
+ ...reason !== void 0 ? { reason } : {}
11922
+ })
11923
+ );
11924
+ }
11464
11925
  function handleInput(frame) {
11465
11926
  const sessionId = frame.sessionId;
11466
11927
  const logger = loggers.get(sessionId);
@@ -11535,10 +11996,25 @@ async function startLocal(opts = {}) {
11535
11996
  }
11536
11997
  }
11537
11998
  function handleKill(sessionId) {
11999
+ if (desktops.has(sessionId)) {
12000
+ desktops.kill(sessionId);
12001
+ return;
12002
+ }
11538
12003
  pty.kill(sessionId);
11539
12004
  lineBuffers.delete(sessionId);
11540
12005
  }
11541
12006
  function handleReplay(frame) {
12007
+ if (desktops.has(frame.sessionId)) {
12008
+ safeSend(
12009
+ encodeFrame({
12010
+ t: "replay-res",
12011
+ sessionId: frame.sessionId,
12012
+ frames: [],
12013
+ exists: true
12014
+ })
12015
+ );
12016
+ return;
12017
+ }
11542
12018
  const frames = pty.replay(frame.sessionId, frame.fromSeq);
11543
12019
  safeSend(
11544
12020
  encodeFrame({
@@ -11698,7 +12174,7 @@ function resolveHere() {
11698
12174
  var argUrl = process.argv[2];
11699
12175
  var argMatch = argUrl && (argUrl.startsWith("ws://") || argUrl.startsWith("wss://"));
11700
12176
  function readVersion() {
11701
- if (true) return "0.3.4";
12177
+ if (true) return "0.4.0";
11702
12178
  try {
11703
12179
  const pkg = JSON.parse(
11704
12180
  (0, import_node_fs5.readFileSync)(new URL("../package.json", import_meta.url), "utf-8")
@@ -11771,7 +12247,7 @@ function entryScript() {
11771
12247
  }
11772
12248
  function run(cmd, args, okToFail = false) {
11773
12249
  try {
11774
- return (0, import_node_child_process4.execFileSync)(cmd, args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
12250
+ return (0, import_node_child_process5.execFileSync)(cmd, args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
11775
12251
  } catch (e) {
11776
12252
  if (okToFail) return null;
11777
12253
  throw e;
@@ -11972,7 +12448,7 @@ if (argUrl === "gateway") {
11972
12448
  for (const [k, v] of Object.entries(process.env)) if (v !== void 0) env[k] = v;
11973
12449
  const args = [entry, "gateway", "run"];
11974
12450
  if (hubArg) args.push("--hub", hubArg);
11975
- const child = (0, import_node_child_process4.spawn)(process.execPath, args, {
12451
+ const child = (0, import_node_child_process5.spawn)(process.execPath, args, {
11976
12452
  detached: true,
11977
12453
  stdio: ["ignore", out, out],
11978
12454
  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.0",
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": {