airloom 0.1.3 → 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 +254 -48
  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>
@@ -1239,10 +1433,10 @@ function getLanIP() {
1239
1433
  return void 0;
1240
1434
  }
1241
1435
  function resolveViewerDir() {
1242
- const prod = resolve(__dirname, "viewer");
1243
- if (existsSync2(prod)) return prod;
1244
- const dev = resolve(__dirname, "../../viewer/dist");
1245
- 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;
1246
1440
  return void 0;
1247
1441
  }
1248
1442
  async function main() {
@@ -1308,17 +1502,22 @@ async function main() {
1308
1502
  };
1309
1503
  if (cliArgs.cli || cliArgs.preset) {
1310
1504
  let command = cliArgs.cli;
1311
- if (!command && cliArgs.preset) {
1312
- const preset = CLI_PRESETS.find((p) => p.id === cliArgs.preset);
1313
- if (!preset) {
1505
+ let presetInfo;
1506
+ if (cliArgs.preset) {
1507
+ presetInfo = CLI_PRESETS.find((p) => p.id === cliArgs.preset);
1508
+ if (!presetInfo) {
1314
1509
  console.error(`[host] Unknown preset "${cliArgs.preset}". Available: ${CLI_PRESETS.map((p) => p.id).join(", ")}`);
1315
1510
  process.exit(1);
1316
1511
  }
1317
- command = preset.command;
1512
+ if (!command) command = presetInfo.command;
1318
1513
  }
1319
1514
  if (command) {
1320
- state.adapter = new CLIAdapter({ command });
1321
- 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"})`);
1322
1521
  }
1323
1522
  } else {
1324
1523
  const saved = loadConfig();
@@ -1341,8 +1540,14 @@ async function main() {
1341
1540
  }
1342
1541
  case "cli": {
1343
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;
1344
1544
  if (cmd) {
1345
- 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
+ });
1346
1551
  }
1347
1552
  break;
1348
1553
  }
@@ -1418,6 +1623,7 @@ async function main() {
1418
1623
  if (shuttingDown) return;
1419
1624
  shuttingDown = true;
1420
1625
  console.log("\n[host] Shutting down...");
1626
+ state.adapter?.destroy?.();
1421
1627
  try {
1422
1628
  channel.close();
1423
1629
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airloom",
3
- "version": "0.1.3",
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"