@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.
- package/dist/index.cjs +549 -23
- 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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
10623
|
-
var execFileAsync = (0,
|
|
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,
|
|
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)
|
|
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,
|
|
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,
|
|
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:
|
|
11180
|
-
|
|
11181
|
-
|
|
11182
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
+
"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": {
|