airloom 0.1.2 → 0.1.4

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.js +266 -54
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -267,7 +267,7 @@ var Channel = class extends EventEmitter2 {
267
267
  }
268
268
  waitForReady(timeoutMs = 3e4) {
269
269
  if (this._ready) return Promise.resolve();
270
- return new Promise((resolve2, reject) => {
270
+ return new Promise((resolve3, reject) => {
271
271
  const cleanup = () => {
272
272
  clearTimeout(timer);
273
273
  this.removeListener("ready", onReady);
@@ -276,7 +276,7 @@ var Channel = class extends EventEmitter2 {
276
276
  };
277
277
  const onReady = () => {
278
278
  cleanup();
279
- resolve2();
279
+ resolve3();
280
280
  };
281
281
  const onError = (err) => {
282
282
  cleanup();
@@ -373,14 +373,14 @@ var WebSocketAdapter = class {
373
373
  }
374
374
  async doConnect() {
375
375
  const ws = await this.createWebSocket(this.url);
376
- return new Promise((resolve2, reject) => {
376
+ return new Promise((resolve3, reject) => {
377
377
  ws.onopen = () => {
378
378
  this._connected = true;
379
379
  this.shouldReconnect = true;
380
380
  this.reconnectAttempts = 0;
381
381
  const msg = this.role === "host" ? { type: "create", sessionToken: this.sessionToken } : { type: "join", sessionToken: this.sessionToken };
382
382
  ws.send(JSON.stringify(msg));
383
- resolve2();
383
+ resolve3();
384
384
  };
385
385
  ws.onmessage = (event) => {
386
386
  const raw = event.data;
@@ -510,8 +510,8 @@ var AblyAdapter = class {
510
510
  clientOpts.token = this.opts.token;
511
511
  }
512
512
  this.ably = new Realtime(clientOpts);
513
- await new Promise((resolve2, reject) => {
514
- this.ably.connection.once("connected", () => resolve2());
513
+ await new Promise((resolve3, reject) => {
514
+ this.ably.connection.once("connected", () => resolve3());
515
515
  this.ably.connection.once("failed", (stateChange) => {
516
516
  reject(new Error(stateChange?.reason?.message ?? "Ably connection failed"));
517
517
  });
@@ -598,14 +598,14 @@ var AblyAdapter = class {
598
598
  import { sha256 as sha2562 } from "@noble/hashes/sha256";
599
599
  import { networkInterfaces } from "os";
600
600
  import { fileURLToPath } from "url";
601
- import { dirname, resolve } from "path";
602
- import { existsSync as existsSync2 } from "fs";
601
+ import { dirname, resolve as resolve2 } from "path";
602
+ import { existsSync as existsSync3 } from "fs";
603
603
  import QRCode from "qrcode";
604
604
 
605
605
  // src/server.ts
606
606
  import express from "express";
607
607
  import { createServer } from "http";
608
- import { existsSync } from "fs";
608
+ import { existsSync as existsSync2 } from "fs";
609
609
  import { WebSocketServer, WebSocket } from "ws";
610
610
 
611
611
  // src/adapters/anthropic.ts
@@ -739,85 +739,266 @@ var OpenAIAdapter = class {
739
739
 
740
740
  // src/adapters/cli.ts
741
741
  import { spawn } from "child_process";
742
+ import { existsSync } from "fs";
743
+ import { delimiter, isAbsolute, join, resolve } from "path";
742
744
  var CLI_PRESETS = [
743
745
  {
744
746
  id: "devin",
745
747
  name: "Devin",
746
- command: "devin --permission-mode dangerous -p --",
747
- description: "Devin CLI in non-interactive print mode"
748
+ command: "devin",
749
+ description: "Devin CLI (persistent REPL session)",
750
+ mode: "repl",
751
+ silenceTimeout: 8e3
748
752
  },
749
753
  {
750
754
  id: "claude-code",
751
755
  name: "Claude Code",
756
+ command: "claude",
757
+ description: "Claude Code (persistent REPL session)",
758
+ mode: "repl",
759
+ silenceTimeout: 8e3
760
+ },
761
+ {
762
+ id: "claude-code-oneshot",
763
+ name: "Claude Code (one-shot)",
752
764
  command: "claude -p --output-format text",
753
- description: "Claude Code in print mode"
765
+ description: "Claude Code in print mode (new process per prompt)",
766
+ mode: "oneshot"
754
767
  },
755
768
  {
756
769
  id: "codex",
757
770
  name: "Codex",
758
771
  command: "codex exec --full-auto",
759
- description: "OpenAI Codex CLI in non-interactive exec mode"
772
+ description: "OpenAI Codex CLI in non-interactive exec mode",
773
+ mode: "oneshot"
760
774
  },
761
775
  {
762
776
  id: "aider",
763
777
  name: "Aider",
764
778
  command: "aider --yes --no-auto-commits --message",
765
- description: "Aider in scripting mode (prompt via --message)"
779
+ description: "Aider in scripting mode (prompt via --message)",
780
+ mode: "oneshot"
766
781
  },
767
782
  {
768
783
  id: "custom",
769
784
  name: "Custom",
770
785
  command: "",
771
- description: "Custom command \u2014 prompt appended as last argument"
786
+ description: "Custom command (default: one-shot, prompt appended as last arg)",
787
+ mode: "oneshot"
772
788
  }
773
789
  ];
790
+ var ANSI_RE = /[\x1b\x9b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[^[\]()#;?][^\x1b]?|\r/g;
791
+ var stripAnsi = (s) => s.replace(ANSI_RE, "");
792
+ var isDecorativeLine = (line) => {
793
+ const t = line.trim();
794
+ if (!t) return false;
795
+ return /^[╭╰╮╯│├┤┬┴┼─━═┄┈┌┐└┘║╔╗╚╝╠╣╦╩╬▔▁░▒▓█\-+|=*~_\s]+$/.test(t);
796
+ };
797
+ var isNoiseLine = (line) => {
798
+ const t = line.trim();
799
+ if (!t) return false;
800
+ return t === "#" || t.includes("Devin for Terminal") || /^v\d{4}\./.test(t) || t.startsWith("Mode: ") || t.includes("Tip: Use shift+tab") || t.startsWith("Update v") || t.includes("\u2219 Pro");
801
+ };
802
+ function resolveExecutable(command, envPath = process.env.PATH ?? "") {
803
+ if (!command) return null;
804
+ if (isAbsolute(command) && existsSync(command)) return command;
805
+ if (command.includes("/")) {
806
+ const candidate = resolve(process.cwd(), command);
807
+ return existsSync(candidate) ? candidate : null;
808
+ }
809
+ for (const dir of envPath.split(delimiter)) {
810
+ if (!dir) continue;
811
+ const candidate = join(dir.replace(/^~(?=$|\/)/, process.env.HOME ?? "~"), command);
812
+ if (existsSync(candidate)) return candidate;
813
+ }
814
+ return null;
815
+ }
774
816
  var CLIAdapter = class {
775
817
  name = "cli";
776
818
  model;
777
819
  command;
778
820
  args;
821
+ mode;
822
+ silenceTimeout;
823
+ pty = null;
824
+ ptyState = "starting";
825
+ startupResolve = null;
826
+ startupTimer = null;
827
+ activeStream = null;
828
+ activeResolve = null;
829
+ silenceTimer = null;
830
+ lastInput = "";
779
831
  constructor(config) {
780
832
  if (!config.command) throw new Error("Command is required for CLI adapter");
781
- const parts = config.command.split(" ");
833
+ const parts = config.command.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [config.command];
782
834
  this.command = parts[0];
783
- this.args = parts.slice(1);
784
- this.model = config.model || config.command;
835
+ this.args = parts.slice(1).map((a) => a.replace(/^"|"$/g, ""));
836
+ this.mode = config.mode ?? "oneshot";
837
+ this.silenceTimeout = config.silenceTimeout ?? 5e3;
838
+ this.model = config.model || `${config.command} (${this.mode})`;
785
839
  }
786
840
  async streamResponse(messages, stream) {
841
+ if (this.mode === "repl") return this.replResponse(messages, stream);
842
+ return this.oneshotResponse(messages, stream);
843
+ }
844
+ destroy() {
845
+ if (this.silenceTimer) clearTimeout(this.silenceTimer);
846
+ if (this.startupTimer) clearTimeout(this.startupTimer);
847
+ try {
848
+ this.pty?.kill();
849
+ } catch {
850
+ }
851
+ this.pty = null;
852
+ this.finishResponse();
853
+ }
854
+ // --- oneshot ----------------------------------------------------------------
855
+ async oneshotResponse(messages, stream) {
787
856
  const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
788
857
  if (!lastUserMsg) {
789
858
  stream.write("[No user message provided]");
790
859
  stream.end();
791
860
  return;
792
861
  }
793
- return new Promise((resolve2) => {
862
+ return new Promise((resolvePromise) => {
794
863
  const args = [...this.args, lastUserMsg.content];
795
- const proc = spawn(this.command, args, {
796
- stdio: ["pipe", "pipe", "pipe"]
797
- });
798
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
864
+ const proc = spawn(this.command, args, { stdio: ["pipe", "pipe", "pipe"] });
799
865
  proc.stdin.end();
800
866
  proc.stdout.on("data", (data) => stream.write(stripAnsi(data.toString())));
801
867
  proc.stderr.on("data", (data) => stream.write(stripAnsi(data.toString())));
802
868
  proc.on("close", () => {
803
869
  stream.end();
804
- resolve2();
870
+ resolvePromise();
805
871
  });
806
872
  proc.on("error", (err) => {
807
873
  stream.write(`[Error: ${err.message}]`);
808
874
  stream.end();
809
- resolve2();
875
+ resolvePromise();
810
876
  });
811
877
  });
812
878
  }
879
+ // --- repl -------------------------------------------------------------------
880
+ async ensurePty() {
881
+ if (this.pty) return this.pty;
882
+ const nodePty = await import("node-pty");
883
+ const executable = resolveExecutable(this.command) ?? this.command;
884
+ console.log(`[cli-repl] Spawning PTY: ${executable} ${this.args.join(" ")}`);
885
+ const pty = nodePty.spawn(executable, this.args, {
886
+ name: "xterm-256color",
887
+ cols: 120,
888
+ rows: 40,
889
+ cwd: process.cwd(),
890
+ env: { ...process.env, NO_COLOR: "1" }
891
+ });
892
+ pty.onData((data) => this.onData(data));
893
+ pty.onExit(({ exitCode }) => {
894
+ console.log(`[cli-repl] PTY exited (code ${exitCode})`);
895
+ this.pty = null;
896
+ this.ptyState = "idle";
897
+ this.finishResponse();
898
+ });
899
+ this.pty = pty;
900
+ this.ptyState = "starting";
901
+ await new Promise((resolvePromise) => {
902
+ this.startupResolve = resolvePromise;
903
+ const maxTimer = setTimeout(() => {
904
+ if (this.ptyState === "starting") {
905
+ this.ptyState = "idle";
906
+ this.startupResolve?.();
907
+ this.startupResolve = null;
908
+ }
909
+ if (this.startupTimer) {
910
+ clearTimeout(this.startupTimer);
911
+ this.startupTimer = null;
912
+ }
913
+ }, 5e3);
914
+ pty.onExit(() => {
915
+ clearTimeout(maxTimer);
916
+ resolvePromise();
917
+ });
918
+ });
919
+ return pty;
920
+ }
921
+ onData(raw) {
922
+ const clean = stripAnsi(raw);
923
+ switch (this.ptyState) {
924
+ case "starting":
925
+ if (this.startupTimer) clearTimeout(this.startupTimer);
926
+ this.startupTimer = setTimeout(() => {
927
+ this.ptyState = "idle";
928
+ this.startupResolve?.();
929
+ this.startupResolve = null;
930
+ }, 2e3);
931
+ break;
932
+ case "idle":
933
+ break;
934
+ case "responding": {
935
+ if (!this.activeStream || !clean) break;
936
+ const filtered = this.filterOutput(clean);
937
+ if (filtered) this.activeStream.write(filtered);
938
+ this.resetSilenceTimer();
939
+ break;
940
+ }
941
+ }
942
+ }
943
+ filterOutput(text) {
944
+ const lines = text.split("\n");
945
+ const kept = [];
946
+ for (const line of lines) {
947
+ const trimmed = line.trim();
948
+ if (!trimmed) {
949
+ kept.push(line);
950
+ continue;
951
+ }
952
+ if (this.lastInput && (trimmed === this.lastInput.trim() || trimmed === `> ${this.lastInput.trim()}`)) continue;
953
+ if (isDecorativeLine(line) || isNoiseLine(line)) continue;
954
+ kept.push(line);
955
+ }
956
+ return kept.join("\n").replace(/^\n+/, "");
957
+ }
958
+ resetSilenceTimer() {
959
+ if (this.silenceTimer) clearTimeout(this.silenceTimer);
960
+ this.silenceTimer = setTimeout(() => {
961
+ this.ptyState = "idle";
962
+ this.finishResponse();
963
+ }, this.silenceTimeout);
964
+ }
965
+ finishResponse() {
966
+ if (this.silenceTimer) {
967
+ clearTimeout(this.silenceTimer);
968
+ this.silenceTimer = null;
969
+ }
970
+ const stream = this.activeStream;
971
+ const resolvePromise = this.activeResolve;
972
+ this.activeStream = null;
973
+ this.activeResolve = null;
974
+ if (stream && !stream.ended) stream.end();
975
+ resolvePromise?.();
976
+ }
977
+ async replResponse(messages, stream) {
978
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
979
+ if (!lastUserMsg) {
980
+ stream.write("[No user message provided]");
981
+ stream.end();
982
+ return;
983
+ }
984
+ const pty = await this.ensurePty();
985
+ return new Promise((resolvePromise) => {
986
+ this.activeStream = stream;
987
+ this.activeResolve = resolvePromise;
988
+ this.ptyState = "responding";
989
+ this.lastInput = lastUserMsg.content;
990
+ pty.write(lastUserMsg.content + "\r");
991
+ this.resetSilenceTimer();
992
+ });
993
+ }
813
994
  };
814
995
 
815
996
  // src/config.ts
816
997
  import { readFileSync, writeFileSync, mkdirSync } from "fs";
817
998
  import { homedir } from "os";
818
- import { join } from "path";
819
- var CONFIG_DIR = join(homedir(), ".config", "airloom");
820
- var CONFIG_PATH = join(CONFIG_DIR, "config.json");
999
+ import { join as join2 } from "path";
1000
+ var CONFIG_DIR = join2(homedir(), ".config", "airloom");
1001
+ var CONFIG_PATH = join2(CONFIG_DIR, "config.json");
821
1002
  function loadConfig() {
822
1003
  try {
823
1004
  const raw = readFileSync(CONFIG_PATH, "utf-8");
@@ -858,7 +1039,7 @@ function createHostServer(opts) {
858
1039
  app.get("/", (_req, res) => {
859
1040
  res.type("html").send(HOST_HTML);
860
1041
  });
861
- if (opts.viewerDir && existsSync(opts.viewerDir)) {
1042
+ if (opts.viewerDir && existsSync2(opts.viewerDir)) {
862
1043
  app.use("/viewer", express.static(opts.viewerDir));
863
1044
  }
864
1045
  app.get("/api/status", (_req, res) => {
@@ -912,7 +1093,13 @@ function createHostServer(opts) {
912
1093
  res.status(400).json({ error: "CLI adapter requires a command (or set AIRLOOM_CLI_COMMAND env var)" });
913
1094
  return;
914
1095
  }
915
- opts.state.adapter = new CLIAdapter({ command: cmd, model });
1096
+ const presetInfo = preset ? CLI_PRESETS.find((p) => p.id === preset) : void 0;
1097
+ opts.state.adapter = new CLIAdapter({
1098
+ command: cmd,
1099
+ model,
1100
+ mode: presetInfo?.mode,
1101
+ silenceTimeout: presetInfo?.silenceTimeout
1102
+ });
916
1103
  break;
917
1104
  }
918
1105
  default:
@@ -957,11 +1144,11 @@ function createHostServer(opts) {
957
1144
  if (ws.readyState === WebSocket.OPEN) ws.send(msg);
958
1145
  }
959
1146
  }
960
- return new Promise((resolve2) => {
1147
+ return new Promise((resolve3) => {
961
1148
  server.listen(opts.port, "0.0.0.0", () => {
962
1149
  const addr = server.address();
963
1150
  const actualPort = typeof addr === "object" && addr ? addr.port : opts.port;
964
- resolve2({ server, broadcast, port: actualPort });
1151
+ resolve3({ server, broadcast, port: actualPort });
965
1152
  });
966
1153
  server.on("error", (err) => {
967
1154
  console.error(`[host] Server error: ${err.message}`);
@@ -972,19 +1159,25 @@ function createHostServer(opts) {
972
1159
  async function handleAIResponse(channel, adapter, state, broadcast) {
973
1160
  const stream = channel.createStream({ model: adapter.model });
974
1161
  let fullResponse = "";
975
- const batcher = new Batcher({
1162
+ const uiBatcher = new Batcher({
976
1163
  interval: 100,
977
1164
  onFlush: (data) => broadcast({ type: "stream_chunk", data })
978
1165
  });
979
1166
  const origWrite = stream.write.bind(stream);
1167
+ const relayBatcher = new Batcher({
1168
+ interval: 500,
1169
+ maxBytes: 4096,
1170
+ onFlush: (data) => origWrite(data)
1171
+ });
980
1172
  stream.write = (data) => {
981
1173
  fullResponse += data;
982
- batcher.write(data);
983
- origWrite(data);
1174
+ uiBatcher.write(data);
1175
+ relayBatcher.write(data);
984
1176
  };
985
1177
  const origEnd = stream.end.bind(stream);
986
1178
  stream.end = () => {
987
- batcher.flush();
1179
+ uiBatcher.flush();
1180
+ relayBatcher.flush();
988
1181
  broadcast({ type: "stream_end" });
989
1182
  state.messages.push({ role: "assistant", content: fullResponse, timestamp: Date.now() });
990
1183
  trimMessages(state.messages);
@@ -998,7 +1191,8 @@ async function handleAIResponse(channel, adapter, state, broadcast) {
998
1191
  stream.write(`[Error: ${message}]`);
999
1192
  stream.end();
1000
1193
  }
1001
- batcher.destroy();
1194
+ uiBatcher.destroy();
1195
+ relayBatcher.destroy();
1002
1196
  }
1003
1197
  }
1004
1198
  var HOST_HTML = `<!DOCTYPE html>
@@ -1211,6 +1405,7 @@ Environment variables:
1211
1405
  OPENAI_API_KEY API key for the OpenAI adapter.
1212
1406
  ABLY_API_KEY Your own Ably key (overrides default community relay).
1213
1407
  RELAY_URL Self-hosted WebSocket relay URL (disables Ably).
1408
+ VIEWER_URL Public viewer URL (default: GitHub Pages).
1214
1409
  HOST_PORT Same as --port (CLI flag takes precedence).
1215
1410
  `.trimStart());
1216
1411
  }
@@ -1220,6 +1415,8 @@ if (cliArgs.help) {
1220
1415
  process.exit(0);
1221
1416
  }
1222
1417
  var DEFAULT_ABLY_KEY = "SfHSAQ.IRTOQQ:FBbi9a7ZV6jIu0Gdo_UeYhIN4rzpMrud5-LldURNh9s";
1418
+ var DEFAULT_VIEWER_URL = "https://bobstrogg.github.io/Airloom/";
1419
+ var VIEWER_URL = process.env.VIEWER_URL ?? DEFAULT_VIEWER_URL;
1223
1420
  var RELAY_URL = process.env.RELAY_URL;
1224
1421
  var ABLY_API_KEY = process.env.ABLY_API_KEY ?? (RELAY_URL ? void 0 : DEFAULT_ABLY_KEY);
1225
1422
  var ABLY_TOKEN_TTL = parseInt(process.env.ABLY_TOKEN_TTL ?? String(24 * 60 * 60 * 1e3), 10);
@@ -1236,10 +1433,10 @@ function getLanIP() {
1236
1433
  return void 0;
1237
1434
  }
1238
1435
  function resolveViewerDir() {
1239
- const prod = resolve(__dirname, "viewer");
1240
- if (existsSync2(prod)) return prod;
1241
- const dev = resolve(__dirname, "../../viewer/dist");
1242
- if (existsSync2(dev)) return dev;
1436
+ const prod = resolve2(__dirname, "viewer");
1437
+ if (existsSync3(prod)) return prod;
1438
+ const dev = resolve2(__dirname, "../../viewer/dist");
1439
+ if (existsSync3(dev)) return dev;
1243
1440
  return void 0;
1244
1441
  }
1245
1442
  async function main() {
@@ -1305,17 +1502,22 @@ async function main() {
1305
1502
  };
1306
1503
  if (cliArgs.cli || cliArgs.preset) {
1307
1504
  let command = cliArgs.cli;
1308
- if (!command && cliArgs.preset) {
1309
- const preset = CLI_PRESETS.find((p) => p.id === cliArgs.preset);
1310
- if (!preset) {
1505
+ let presetInfo;
1506
+ if (cliArgs.preset) {
1507
+ presetInfo = CLI_PRESETS.find((p) => p.id === cliArgs.preset);
1508
+ if (!presetInfo) {
1311
1509
  console.error(`[host] Unknown preset "${cliArgs.preset}". Available: ${CLI_PRESETS.map((p) => p.id).join(", ")}`);
1312
1510
  process.exit(1);
1313
1511
  }
1314
- command = preset.command;
1512
+ if (!command) command = presetInfo.command;
1315
1513
  }
1316
1514
  if (command) {
1317
- state.adapter = new CLIAdapter({ command });
1318
- console.log(`[host] CLI adapter: ${command}`);
1515
+ state.adapter = new CLIAdapter({
1516
+ command,
1517
+ mode: presetInfo?.mode,
1518
+ silenceTimeout: presetInfo?.silenceTimeout
1519
+ });
1520
+ console.log(`[host] CLI adapter: ${command} (${presetInfo?.mode ?? "oneshot"})`);
1319
1521
  }
1320
1522
  } else {
1321
1523
  const saved = loadConfig();
@@ -1338,8 +1540,14 @@ async function main() {
1338
1540
  }
1339
1541
  case "cli": {
1340
1542
  const cmd = saved.command || process.env.AIRLOOM_CLI_COMMAND;
1543
+ const savedPreset = saved.preset ? CLI_PRESETS.find((p) => p.id === saved.preset) : void 0;
1341
1544
  if (cmd) {
1342
- state.adapter = new CLIAdapter({ command: cmd, model: saved.model });
1545
+ state.adapter = new CLIAdapter({
1546
+ command: cmd,
1547
+ model: saved.model,
1548
+ mode: savedPreset?.mode,
1549
+ silenceTimeout: savedPreset?.silenceTimeout
1550
+ });
1343
1551
  }
1344
1552
  break;
1345
1553
  }
@@ -1360,19 +1568,22 @@ async function main() {
1360
1568
  console.log("[host] Viewer dist not found \u2014 QR will open raw JSON fallback");
1361
1569
  }
1362
1570
  const { server, broadcast, port } = await createHostServer({ port: HOST_PORT, state, viewerDir });
1363
- const lanIP = getLanIP();
1364
- const host = lanIP ?? "localhost";
1365
- const baseUrl = `http://${host}:${port}`;
1366
1571
  const pairingBase64 = Buffer.from(pairingJSON).toString("base64url");
1367
- const qrContent = viewerDir ? `${baseUrl}/viewer/#${pairingBase64}` : `${baseUrl}/#${pairingBase64}`;
1572
+ const viewerBase = VIEWER_URL.replace(/\/+$/, "");
1573
+ const qrContent = `${viewerBase}/#${pairingBase64}`;
1574
+ const lanIP = getLanIP();
1575
+ const lanHost = lanIP ?? "localhost";
1576
+ const lanBaseUrl = `http://${lanHost}:${port}`;
1577
+ const lanViewerUrl = viewerDir ? `${lanBaseUrl}/viewer/#${pairingBase64}` : null;
1368
1578
  const qrDataUrl = await QRCode.toDataURL(qrContent, { width: 300, margin: 2 });
1369
1579
  const qrTerminal = await QRCode.toString(qrContent, { type: "terminal", small: true });
1370
1580
  state.pairingQR = qrDataUrl;
1371
1581
  console.log("\nPairing QR Code:");
1372
1582
  console.log(qrTerminal);
1373
1583
  console.log(`Pairing Code: ${displayCode}`);
1374
- if (lanIP) {
1375
- console.log(`Viewer URL: ${qrContent}`);
1584
+ console.log(`Viewer URL: ${qrContent}`);
1585
+ if (lanViewerUrl) {
1586
+ console.log(`LAN Viewer: ${lanViewerUrl}`);
1376
1587
  }
1377
1588
  if (!useAbly) console.log(`Relay: ${RELAY_URL}`);
1378
1589
  const localUrl = `http://localhost:${port}`;
@@ -1412,6 +1623,7 @@ async function main() {
1412
1623
  if (shuttingDown) return;
1413
1624
  shuttingDown = true;
1414
1625
  console.log("\n[host] Shutting down...");
1626
+ state.adapter?.destroy?.();
1415
1627
  try {
1416
1628
  channel.close();
1417
1629
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airloom",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Run AI on your computer, control it from your phone. E2E encrypted.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "ably": "^2.20.0",
16
16
  "events": "^3.3.0",
17
17
  "express": "^5.1.0",
18
+ "node-pty": "^1.1.0",
18
19
  "qrcode": "^1.5.0",
19
20
  "tweetnacl": "^1.0.3",
20
21
  "ws": "^8.18.0"