airloom 0.1.13 → 0.1.17

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.js CHANGED
@@ -600,16 +600,15 @@ var AblyAdapter = class {
600
600
 
601
601
  // src/index.ts
602
602
  import { sha256 as sha2562 } from "@noble/hashes/sha256";
603
- import { networkInterfaces } from "os";
604
- import { fileURLToPath } from "url";
605
- import { dirname, resolve as resolve3 } from "path";
606
- import { existsSync as existsSync4 } from "fs";
607
- import QRCode from "qrcode";
603
+ import { networkInterfaces } from "node:os";
604
+ import { fileURLToPath } from "node:url";
605
+ import { dirname, resolve as resolve3 } from "node:path";
606
+ import { existsSync as existsSync4 } from "node:fs";
608
607
 
609
608
  // src/server.ts
610
609
  import express from "express";
611
- import { createServer } from "http";
612
- import { existsSync as existsSync3 } from "fs";
610
+ import { createServer } from "node:http";
611
+ import { existsSync as existsSync3 } from "node:fs";
613
612
  import { WebSocketServer, WebSocket } from "ws";
614
613
 
615
614
  // src/adapters/anthropic.ts
@@ -742,9 +741,9 @@ var OpenAIAdapter = class {
742
741
  };
743
742
 
744
743
  // src/adapters/cli.ts
745
- import { spawn } from "child_process";
746
- import { existsSync } from "fs";
747
- import { delimiter, isAbsolute, join, resolve } from "path";
744
+ import { spawn } from "node:child_process";
745
+ import { existsSync } from "node:fs";
746
+ import { delimiter, isAbsolute, join, resolve } from "node:path";
748
747
  var CLI_PRESETS = [
749
748
  {
750
749
  id: "devin",
@@ -998,9 +997,9 @@ var CLIAdapter = class {
998
997
  };
999
998
 
1000
999
  // src/config.ts
1001
- import { readFileSync, writeFileSync, mkdirSync } from "fs";
1002
- import { homedir } from "os";
1003
- import { join as join2 } from "path";
1000
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
1001
+ import { homedir } from "node:os";
1002
+ import { join as join2 } from "node:path";
1004
1003
  var CONFIG_DIR = join2(homedir(), ".config", "airloom");
1005
1004
  var CONFIG_PATH = join2(CONFIG_DIR, "config.json");
1006
1005
  function loadConfig() {
@@ -1026,8 +1025,8 @@ function getConfigPath() {
1026
1025
  }
1027
1026
 
1028
1027
  // src/terminal.ts
1029
- import { basename, delimiter as delimiter2, isAbsolute as isAbsolute2, join as join3, resolve as resolve2 } from "path";
1030
- import { existsSync as existsSync2 } from "fs";
1028
+ import { basename, delimiter as delimiter2, isAbsolute as isAbsolute2, join as join3, resolve as resolve2 } from "node:path";
1029
+ import { existsSync as existsSync2 } from "node:fs";
1031
1030
  import { spawn as spawn2 } from "node-pty";
1032
1031
  function resolveExecutable2(command, envPath = process.env.PATH ?? "") {
1033
1032
  if (!command) return null;
@@ -1112,20 +1111,57 @@ function getTerminalLaunchDisplay(explicitCommand) {
1112
1111
  const command = getDefaultTerminalCommand(explicitCommand);
1113
1112
  return [command.file, ...command.args].join(" ");
1114
1113
  }
1114
+ var MAX_BUFFER_BYTES = 512 * 1024;
1115
1115
  var TerminalSession = class {
1116
- constructor(channel, getLaunchCommand) {
1116
+ constructor(channel, getLaunchCommand, broadcastFn) {
1117
1117
  this.channel = channel;
1118
1118
  this.getLaunchCommand = getLaunchCommand;
1119
+ this.broadcastFn = broadcastFn;
1120
+ this.start();
1119
1121
  }
1120
1122
  pty = null;
1121
1123
  stream = null;
1122
1124
  batcher = null;
1123
1125
  cols = 120;
1124
1126
  rows = 36;
1127
+ outputBuffer = "";
1128
+ start() {
1129
+ const command = getDefaultTerminalCommand(this.getLaunchCommand?.());
1130
+ const file = resolveExecutable2(command.file) ?? command.file;
1131
+ console.log(`[host] PTY spawn: ${file} ${command.args.join(" ")} (${this.cols}x${this.rows})`);
1132
+ const env = { ...process.env, TERM: "xterm-256color" };
1133
+ try {
1134
+ this.pty = spawn2(file, command.args, {
1135
+ name: "xterm-256color",
1136
+ cols: this.cols,
1137
+ rows: this.rows,
1138
+ cwd: process.cwd(),
1139
+ env
1140
+ });
1141
+ } catch (err) {
1142
+ console.error("[host] PTY spawn failed:", err.message);
1143
+ return;
1144
+ }
1145
+ this.pty.onData((data) => {
1146
+ process.stdout.write(data);
1147
+ this.outputBuffer += data;
1148
+ if (this.outputBuffer.length > MAX_BUFFER_BYTES) {
1149
+ this.outputBuffer = this.outputBuffer.slice(this.outputBuffer.length - MAX_BUFFER_BYTES);
1150
+ }
1151
+ this.batcher?.write(data);
1152
+ this.broadcastFn?.({ type: "terminal_output", data });
1153
+ });
1154
+ this.pty.onExit(({ exitCode, signal }) => {
1155
+ this.batcher?.flush();
1156
+ this.detachStream();
1157
+ this.channel.send({ type: "terminal_exit", exitCode, signal });
1158
+ this.pty = null;
1159
+ });
1160
+ }
1125
1161
  handleMessage(message) {
1126
1162
  switch (message.type) {
1127
1163
  case "terminal_open":
1128
- this.open(message);
1164
+ this.attach(message);
1129
1165
  break;
1130
1166
  case "terminal_input":
1131
1167
  this.writeInput(message);
@@ -1134,58 +1170,56 @@ var TerminalSession = class {
1134
1170
  this.resize(message);
1135
1171
  break;
1136
1172
  case "terminal_close":
1137
- this.close();
1173
+ this.detachStream();
1138
1174
  break;
1139
1175
  case "terminal_exit":
1140
1176
  break;
1141
1177
  }
1142
1178
  }
1143
- close() {
1179
+ attach(message) {
1180
+ this.cols = Math.max(20, Math.floor(message.cols || this.cols));
1181
+ this.rows = Math.max(5, Math.floor(message.rows || this.rows));
1182
+ this.pty?.resize(this.cols, this.rows);
1183
+ this.detachStream();
1184
+ const meta = { kind: "terminal", cols: this.cols, rows: this.rows };
1185
+ this.stream = this.channel.createStream(meta);
1186
+ this.batcher = new AdaptiveOutputBatcher((data) => {
1187
+ this.stream?.write(data);
1188
+ });
1189
+ if (this.outputBuffer) {
1190
+ this.stream.write(this.outputBuffer);
1191
+ }
1192
+ if (!this.pty) {
1193
+ this.start();
1194
+ }
1195
+ }
1196
+ /** End the current stream without killing the PTY (called on peer disconnect). */
1197
+ detachStream() {
1144
1198
  this.batcher?.destroy();
1145
1199
  this.batcher = null;
1146
1200
  if (this.stream && !this.stream.ended) this.stream.end();
1147
1201
  this.stream = null;
1202
+ }
1203
+ /** Kill the PTY — called only on host shutdown. */
1204
+ destroy() {
1205
+ this.detachStream();
1148
1206
  try {
1149
1207
  this.pty?.kill();
1150
1208
  } catch {
1151
1209
  }
1152
1210
  this.pty = null;
1153
- }
1154
- open(message) {
1155
- this.cols = Math.max(20, Math.floor(message.cols || this.cols));
1156
- this.rows = Math.max(5, Math.floor(message.rows || this.rows));
1157
- if (this.pty) {
1158
- this.pty.resize(this.cols, this.rows);
1159
- return;
1160
- }
1161
- const command = getDefaultTerminalCommand(this.getLaunchCommand?.());
1162
- const file = resolveExecutable2(command.file) ?? command.file;
1163
- const meta = { kind: "terminal", cols: this.cols, rows: this.rows };
1164
- this.stream = this.channel.createStream(meta);
1165
- this.batcher = new AdaptiveOutputBatcher((data) => this.stream?.write(data));
1166
- const env = { ...process.env, TERM: "xterm-256color" };
1167
- this.pty = spawn2(file, command.args, {
1168
- name: "xterm-256color",
1169
- cols: this.cols,
1170
- rows: this.rows,
1171
- cwd: process.cwd(),
1172
- env
1173
- });
1174
- this.pty.onData((data) => this.batcher?.write(data));
1175
- this.pty.onExit(({ exitCode, signal }) => {
1176
- this.batcher?.flush();
1177
- this.stream?.end();
1178
- this.channel.send({ type: "terminal_exit", exitCode, signal });
1179
- this.stream = null;
1180
- this.batcher = null;
1181
- this.pty = null;
1182
- });
1211
+ this.outputBuffer = "";
1183
1212
  }
1184
1213
  writeInput(message) {
1185
1214
  if (!this.pty) return;
1186
1215
  this.batcher?.noteInput();
1187
1216
  this.pty.write(message.data);
1188
1217
  }
1218
+ writeRawInput(data) {
1219
+ if (!this.pty) return;
1220
+ this.batcher?.noteInput();
1221
+ this.pty.write(data);
1222
+ }
1189
1223
  resize(message) {
1190
1224
  this.cols = Math.max(20, Math.floor(message.cols || this.cols));
1191
1225
  this.rows = Math.max(5, Math.floor(message.rows || this.rows));
@@ -1310,16 +1344,30 @@ function createHostServer(opts) {
1310
1344
  }
1311
1345
  res.json({ ok: true });
1312
1346
  });
1347
+ app.get("/api/pair", (req, res) => {
1348
+ const { session } = req.query;
1349
+ if (!session || session !== opts.state.sessionToken) {
1350
+ res.status(401).json({ error: "Invalid session" });
1351
+ return;
1352
+ }
1353
+ res.json({
1354
+ token: opts.state.ablyToken,
1355
+ transport: opts.state.transport ?? "ws",
1356
+ relay: opts.state.relayUrl
1357
+ });
1358
+ });
1313
1359
  wss.on("connection", (ws) => {
1314
1360
  uiClients.add(ws);
1315
1361
  ws.on("close", () => uiClients.delete(ws));
1316
1362
  ws.on("message", (data) => {
1317
1363
  try {
1318
1364
  const message = JSON.parse(data.toString());
1319
- if (message.type && message.type.startsWith("terminal_")) {
1320
- if (opts.state.channel) {
1321
- opts.state.channel.send(message);
1322
- }
1365
+ if (message.type === "terminal_input" && typeof message.data === "string") {
1366
+ const filtered = message.data.replace(/\x1b\]\d+;[^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
1367
+ if (!filtered) return;
1368
+ opts.state.terminal?.writeRawInput(filtered);
1369
+ } else if (message.type === "terminal_resize") {
1370
+ opts.state.terminal?.handleMessage(message);
1323
1371
  }
1324
1372
  } catch (err) {
1325
1373
  console.error("[host] Invalid WebSocket message:", err);
@@ -1389,6 +1437,7 @@ var HOST_HTML = `<!DOCTYPE html>
1389
1437
  <meta charset="UTF-8">
1390
1438
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1391
1439
  <title>Airloom - Host</title>
1440
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css">
1392
1441
  <style>
1393
1442
  *{margin:0;padding:0;box-sizing:border-box}
1394
1443
  body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0a0a0a;color:#e0e0e0;min-height:100vh}
@@ -1411,39 +1460,14 @@ var HOST_HTML = `<!DOCTYPE html>
1411
1460
  button{background:#7c8aff;color:#fff;border:none;cursor:pointer;font-weight:600}
1412
1461
  button:hover{background:#6b79ee}
1413
1462
  .messages{max-height:400px;overflow-y:auto;display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
1414
- .msg{padding:10px 14px;border-radius:10px;max-width:85%;word-break:break-word;font-size:.9rem;line-height:1.5}
1415
- .msg.user{background:#2a3a6a;align-self:flex-end;white-space:pre-wrap}
1463
+ .msg{padding:10px 14px;border-radius:10px;max-width:85%;word-break:break-word;font-size:.9rem;line-height:1.5;white-space:pre-wrap}
1464
+ .msg.user{background:#2a3a6a;align-self:flex-end}
1416
1465
  .msg.assistant{background:#1e1e1e;border:1px solid #2a2a2a;align-self:flex-start}
1417
- .msg.assistant p{margin:0 0 .5em}.msg.assistant p:last-child{margin-bottom:0}
1418
- .msg.assistant h1,.msg.assistant h2,.msg.assistant h3,.msg.assistant h4,.msg.assistant h5,.msg.assistant h6{font-size:.95rem;font-weight:600;margin:.6em 0 .3em;color:#e0e0e0}
1419
- .msg.assistant h1{font-size:1.05rem}.msg.assistant h2{font-size:1rem}
1420
- .msg.assistant ul,.msg.assistant ol{margin:.3em 0;padding-left:1.4em}.msg.assistant li{margin:.15em 0}
1421
- .msg.assistant a{color:#7c8aff;text-decoration:underline}
1422
- .msg.assistant code{font-family:'SF Mono',Menlo,Consolas,monospace;font-size:.82rem;background:rgba(255,255,255,.07);padding:1px 5px;border-radius:4px}
1423
- .msg.assistant pre{margin:.5em 0;padding:10px 12px;border-radius:8px;background:#111;overflow-x:auto}
1424
- .msg.assistant pre code{background:none;padding:0;font-size:.8rem;line-height:1.5;white-space:pre;display:block}
1425
- .msg.assistant blockquote{margin:.4em 0;padding:4px 12px;border-left:3px solid #2a2a2a;color:#888}
1426
- .msg.assistant table{border-collapse:collapse;margin:.4em 0;font-size:.82rem}
1427
- .msg.assistant th,.msg.assistant td{border:1px solid #2a2a2a;padding:4px 8px}
1428
- .msg.assistant th{background:rgba(255,255,255,.05)}
1429
- .msg.assistant hr{border:none;border-top:1px solid #2a2a2a;margin:.5em 0}
1430
- .msg.typing{display:flex;align-items:center;gap:5px;padding:14px 18px}
1431
- .msg.typing .dot{width:7px;height:7px;border-radius:50%;background:#888;animation:dot-pulse 1.4s ease-in-out infinite}
1432
- .msg.typing .dot:nth-child(2){animation-delay:.2s}
1433
- .msg.typing .dot:nth-child(3){animation-delay:.4s}
1434
- @keyframes dot-pulse{0%,80%,100%{opacity:.25;transform:scale(.8)}40%{opacity:1;transform:scale(1)}}
1435
1466
  .input-area{display:flex;gap:8px}
1436
1467
  .input-area textarea{flex:1;resize:none;min-height:44px;max-height:120px;padding:10px 14px;border-radius:8px;border:1px solid #333;background:#111;color:#e0e0e0;font-family:inherit;font-size:.9rem}
1437
-
1438
- /* Terminal mode styles */
1439
- .terminal-mode .chat-only { display: none !important; }
1440
- .terminal-mode .terminal-only { display: block; }
1441
- .chat-mode .terminal-only { display: none !important; }
1442
- .chat-mode .chat-only { display: block; }
1443
-
1444
- .terminal-container{background:#05070c;border:1px solid #2a2a2a;border-radius:8px;height:400px;overflow:hidden;margin-bottom:12px}
1468
+ .terminal-container{background:#05070c;border:1px solid #2a2a2a;border-radius:8px;height:420px;overflow:hidden;margin-bottom:12px}
1445
1469
  #terminal{width:100%;height:100%;padding:8px}
1446
- .terminal-toolbar{display:flex;gap:8px;margin-bottom:12px}
1470
+ .toolbar{display:flex;gap:8px;margin-bottom:12px}
1447
1471
  .tool-btn{padding:6px 12px;font-size:.85rem;background:#333;border:none;border-radius:6px;color:#e0e0e0;cursor:pointer}
1448
1472
  .tool-btn:hover{background:#444}
1449
1473
  </style>
@@ -1458,360 +1482,251 @@ var HOST_HTML = `<!DOCTYPE html>
1458
1482
  <div class="status"><div class="dot wait" id="dot"></div><span id="statusText">Initializing...</span></div>
1459
1483
  <p style="color:#888;font-size:.9rem;margin-top:8px" id="launchText">Launch: current shell</p>
1460
1484
  </div>
1461
-
1462
- <!-- Chat mode UI -->
1463
- <div id="chatMode" class="chat-mode">
1464
- <div class="card" id="configCard">
1465
- <h2>AI Configuration</h2>
1466
- <div class="config-form">
1467
- <select id="adapterType">
1468
- <option value="anthropic">Anthropic Claude</option>
1469
- <option value="openai">OpenAI GPT</option>
1470
- <option value="cli">CLI Adapter</option>
1471
- </select>
1472
- <input type="text" id="model" placeholder="Model (optional)" />
1473
- <input type="text" id="apiKey" placeholder="API Key (if not set in environment)" />
1474
- <button onclick="configureAdapter()">Configure Adapter</button>
1475
- </div>
1476
- </div>
1477
-
1478
- <div class="card chat-only" id="chatCard" style="display:none">
1479
- <h2>Chat Messages</h2>
1480
- <div class="messages" id="messages"></div>
1481
- <div class="input-area chat-only">
1482
- <textarea id="messageInput" placeholder="Type your message..." rows="1"></textarea>
1483
- <button onclick="sendMessage()">Send</button>
1484
- </div>
1485
- </div>
1485
+
1486
+ <div class="card pairing" id="pairingCard" style="display:none">
1487
+ <h2>Connect Your Phone</h2>
1488
+ <img id="qrCode" alt="QR Code"/>
1489
+ <div class="pairing-code" id="pairingCode"></div>
1490
+ <p style="color:#888;font-size:.85rem">Scan QR or enter code in viewer</p>
1486
1491
  </div>
1487
-
1488
- <!-- Terminal mode UI -->
1489
- <div id="terminalMode" class="terminal-mode" style="display:none">
1492
+
1493
+ <!-- Terminal mode: shell, Devin, Codex etc. (default when no AI adapter) -->
1494
+ <div id="terminalSection" style="display:none">
1490
1495
  <div class="card">
1491
- <h2>Terminal Configuration</h2>
1496
+ <h2>Launch Configuration</h2>
1492
1497
  <div class="config-form">
1493
1498
  <select id="cliPreset"></select>
1494
1499
  <input type="text" id="command" placeholder="Custom launch command" style="display:none"/>
1495
1500
  <p style="color:#666;font-size:.8rem;margin-top:4px" id="presetDesc"></p>
1496
- <button onclick="configureTerminal()">Apply Launch Target</button>
1501
+ <button id="applyLaunchBtn">Apply Launch Target</button>
1497
1502
  </div>
1498
1503
  </div>
1499
-
1500
- <div class="card terminal-only">
1501
- <h2>Terminal Session</h2>
1502
- <div class="terminal-toolbar terminal-only">
1503
- <button class="tool-btn" onclick="focusTerminal()">Focus</button>
1504
- <button class="tool-btn" onclick="sendCtrlC()">Ctrl+C</button>
1505
- <button class="tool-btn" onclick="sendEsc()">Esc</button>
1506
- <button class="tool-btn" onclick="sendTab()">Tab</button>
1504
+ <div class="card">
1505
+ <h2>Terminal</h2>
1506
+ <div class="toolbar">
1507
+ <button class="tool-btn" id="focusTermBtn">Focus</button>
1508
+ <button class="tool-btn" id="ctrlCBtn">Ctrl+C</button>
1509
+ <button class="tool-btn" id="escBtn">Esc</button>
1510
+ <button class="tool-btn" id="tabBtn">Tab</button>
1507
1511
  </div>
1508
- <div class="terminal-container terminal-only" id="terminalContainer">
1512
+ <div class="terminal-container" id="terminalContainer">
1509
1513
  <div id="terminal"></div>
1510
1514
  </div>
1511
1515
  </div>
1512
1516
  </div>
1513
-
1514
- <div class="card pairing" id="pairingCard" style="display:none">
1515
- <h2>Connect Your Phone</h2>
1516
- <img id="qrCode" alt="QR Code"/>
1517
- <div class="pairing-code" id="pairingCode"></div>
1518
- <p style="color:#888;font-size:.85rem">Scan QR or enter code in viewer</p>
1517
+
1518
+ <!-- Chat mode: direct LLM connections (Anthropic, OpenAI) -->
1519
+ <div id="chatSection" style="display:none">
1520
+ <div class="card">
1521
+ <h2>Chat</h2>
1522
+ <div class="messages" id="messages"></div>
1523
+ <div class="input-area">
1524
+ <textarea id="messageInput" placeholder="Type your message..." rows="1"></textarea>
1525
+ <button id="sendBtn">Send</button>
1526
+ </div>
1527
+ </div>
1519
1528
  </div>
1529
+
1520
1530
  </div>
1521
- <script>
1522
- // Import xterm dynamically to avoid bundling issues
1523
- const loadTerminal = async () => {
1524
- if (window.Terminal) return;
1525
- const [{ Terminal }, { FitAddon }] = await Promise.all([
1526
- import('@xterm/xterm'),
1527
- import('@xterm/addon-fit')
1528
- ]);
1529
- window.Terminal = Terminal;
1530
- window.FitAddon = FitAddon;
1531
- };
1531
+ <script type="module">
1532
+ import { Terminal } from 'https://esm.sh/@xterm/xterm@6';
1533
+ import { FitAddon } from 'https://esm.sh/@xterm/addon-fit@0.11';
1532
1534
 
1533
- const ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
1534
- let cliPresets=[];
1535
- let currentMode = null; // 'chat' or 'terminal'
1535
+ const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
1536
1536
  let term = null;
1537
1537
  let fitAddon = null;
1538
+ let termInitialized = false;
1539
+ let cliPresets = [];
1538
1540
 
1539
- // Mode detection and UI switching
1540
- function switchToMode(mode) {
1541
- if (currentMode === mode) return;
1542
- currentMode = mode;
1543
-
1544
- const chatMode = document.getElementById('chatMode');
1545
- const terminalMode = document.getElementById('terminalMode');
1546
-
1547
- if (mode === 'terminal') {
1548
- chatMode.style.display = 'none';
1549
- terminalMode.style.display = 'block';
1550
- document.body.classList.add('terminal-mode');
1551
- document.body.classList.remove('chat-mode');
1552
- initTerminal();
1553
- } else {
1554
- chatMode.style.display = 'block';
1555
- terminalMode.style.display = 'none';
1556
- document.body.classList.add('chat-mode');
1557
- document.body.classList.remove('terminal-mode');
1558
- }
1559
- }
1560
-
1561
- // Terminal functionality
1562
- async function initTerminal() {
1563
- if (term) return;
1564
- await loadTerminal();
1565
-
1566
- term = new window.Terminal({
1541
+ function initTerminal() {
1542
+ if (termInitialized) return;
1543
+ termInitialized = true;
1544
+ term = new Terminal({
1567
1545
  cursorBlink: true,
1568
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
1546
+ fontFamily: 'ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace',
1569
1547
  fontSize: 14,
1570
1548
  lineHeight: 1.25,
1571
- allowTransparency: true,
1572
1549
  scrollback: 5000,
1573
1550
  theme: {
1574
- background: '#05070c',
1575
- foreground: '#e6edf3',
1576
- cursor: '#7c8aff',
1577
- cursorAccent: '#05070c',
1551
+ background: '#05070c', foreground: '#e6edf3', cursor: '#7c8aff', cursorAccent: '#05070c',
1578
1552
  selectionBackground: 'rgba(124,138,255,0.28)',
1579
- black: '#0a0d14',
1580
- red: '#ff7b72',
1581
- green: '#3fb950',
1582
- yellow: '#d29922',
1583
- blue: '#7c8aff',
1584
- magenta: '#bc8cff',
1585
- cyan: '#39c5cf',
1586
- white: '#c9d1d9',
1587
- brightBlack: '#6e7681',
1588
- brightRed: '#ffa198',
1589
- brightGreen: '#56d364',
1590
- brightYellow: '#e3b341',
1591
- brightBlue: '#a5b4ff',
1592
- brightMagenta: '#d2a8ff',
1593
- brightCyan: '#56d4dd',
1594
- brightWhite: '#f0f6fc',
1553
+ black: '#0a0d14', red: '#ff7b72', green: '#3fb950', yellow: '#d29922',
1554
+ blue: '#7c8aff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#c9d1d9',
1555
+ brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364', brightYellow: '#e3b341',
1556
+ brightBlue: '#a5b4ff', brightMagenta: '#d2a8ff', brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
1595
1557
  },
1596
1558
  });
1597
-
1598
- fitAddon = new window.FitAddon();
1559
+ fitAddon = new FitAddon();
1599
1560
  term.loadAddon(fitAddon);
1600
1561
  term.open(document.getElementById('terminal'));
1601
-
1602
1562
  term.onData((data) => {
1603
- if (ws.readyState === WebSocket.OPEN) {
1604
- ws.send(JSON.stringify({ type: 'terminal_input', data }));
1605
- }
1563
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'terminal_input', data }));
1606
1564
  });
1607
-
1608
- // Handle terminal resize
1609
- const resizeObserver = new ResizeObserver(() => {
1610
- if (fitAddon && term) {
1611
- fitAddon.fit();
1612
- if (ws.readyState === WebSocket.OPEN) {
1613
- ws.send(JSON.stringify({
1614
- type: 'terminal_resize',
1615
- cols: term.cols,
1616
- rows: term.rows
1617
- }));
1618
- }
1565
+ new ResizeObserver(() => {
1566
+ fitAddon.fit();
1567
+ if (ws.readyState === WebSocket.OPEN && term) {
1568
+ ws.send(JSON.stringify({ type: 'terminal_resize', cols: term.cols, rows: term.rows }));
1619
1569
  }
1620
- });
1621
- resizeObserver.observe(document.getElementById('terminalContainer'));
1622
-
1623
- // Auto-focus terminal
1624
- term.focus();
1570
+ }).observe(document.getElementById('terminalContainer'));
1571
+ if (ws.readyState === WebSocket.OPEN) sendTerminalOpen();
1625
1572
  }
1626
1573
 
1627
- function focusTerminal() {
1628
- if (term) term.focus();
1629
- }
1630
-
1631
- function sendCtrlC() {
1632
- if (ws.readyState === WebSocket.OPEN) {
1633
- ws.send(JSON.stringify({ type: 'terminal_input', data: '' }));
1634
- }
1635
- if (term) term.focus();
1636
- }
1637
-
1638
- function sendEsc() {
1639
- if (ws.readyState === WebSocket.OPEN) {
1640
- ws.send(JSON.stringify({ type: 'terminal_input', data: '\x1B' }));
1641
- }
1642
- if (term) term.focus();
1574
+ function sendTerminalOpen() {
1575
+ if (!term || !fitAddon) return;
1576
+ fitAddon.fit();
1577
+ ws.send(JSON.stringify({ type: 'terminal_open', cols: term.cols, rows: term.rows }));
1578
+ term.focus();
1643
1579
  }
1644
1580
 
1645
- function sendTab() {
1646
- if (ws.readyState === WebSocket.OPEN) {
1647
- ws.send(JSON.stringify({ type: 'terminal_input', data: ' ' }));
1648
- }
1649
- if (term) term.focus();
1650
- }
1581
+ ws.onopen = () => { if (termInitialized) sendTerminalOpen(); };
1651
1582
 
1652
- // Load CLI presets for terminal mode
1653
- fetch('/api/cli-presets').then(r=>r.json()).then(presets=>{
1654
- cliPresets=presets;
1655
- const sel=document.getElementById('cliPreset');
1656
- const shellOpt=document.createElement('option');
1657
- shellOpt.value='shell';
1658
- shellOpt.textContent='Shell (default)';
1659
- sel.appendChild(shellOpt);
1660
- presets.forEach(p=>{const o=document.createElement('option');o.value=p.id;o.textContent=p.name;sel.appendChild(o)});
1661
- sel.addEventListener('change',()=>{
1662
- const id=sel.value;
1663
- const p=cliPresets.find(x=>x.id===id);
1664
- const cmd=document.getElementById('command');
1665
- if(id==='shell'){cmd.style.display='none';cmd.value='';document.getElementById('presetDesc').textContent='Start an interactive login shell. Run commands directly in the phone terminal.';return;}
1666
- if(id==='custom'){cmd.style.display='';document.getElementById('presetDesc').textContent='Enter the exact launch command.';return;}
1667
- cmd.style.display='none';
1668
- document.getElementById('presetDesc').textContent=p?p.description:'';
1669
- });
1670
- sel.value='shell';
1671
- sel.dispatchEvent(new Event('change'));
1672
- return fetch('/api/config').then(r=>r.json());
1673
- }).then(cfg=>{
1674
- if(!cfg) return;
1675
- const {saved}=cfg;
1676
- if(saved && (saved.type==='terminal' || saved.type==='cli')){
1677
- if(saved.preset) document.getElementById('cliPreset').value=saved.preset;
1678
- if(saved.command) document.getElementById('command').value=saved.command;
1679
- document.getElementById('cliPreset').dispatchEvent(new Event('change'));
1680
- }
1681
- }).catch(()=>{});
1682
-
1683
- // WebSocket message handling
1684
- ws.onmessage=e=>{
1685
- const d=JSON.parse(e.data);
1686
- if(d.type==='peer_connected'){
1687
- document.getElementById('dot').className='dot on';
1688
- document.getElementById('statusText').textContent='Phone connected';
1689
- }
1690
- else if(d.type==='peer_disconnected'){
1691
- document.getElementById('dot').className='dot wait';
1692
- document.getElementById('statusText').textContent='Phone disconnected';
1693
- }
1694
- else if(d.type==='terminal_configured'){
1695
- document.getElementById('launchText').textContent='Launch: '+d.terminalLaunch;
1696
- document.getElementById('statusText').textContent='Launch target updated';
1697
- }
1698
- else if(d.type==='configured'){
1699
- document.getElementById('statusText').textContent='AI adapter configured';
1700
- }
1701
- else if(d.type==='stream_chunk' && term){
1583
+ ws.onmessage = (e) => {
1584
+ const d = JSON.parse(e.data);
1585
+ if (d.type === 'peer_connected') {
1586
+ document.getElementById('dot').className = 'dot on';
1587
+ document.getElementById('statusText').textContent = 'Phone connected';
1588
+ } else if (d.type === 'peer_disconnected') {
1589
+ document.getElementById('dot').className = 'dot wait';
1590
+ document.getElementById('statusText').textContent = 'Phone disconnected';
1591
+ } else if (d.type === 'terminal_configured') {
1592
+ document.getElementById('launchText').textContent = 'Launch: ' + d.terminalLaunch;
1593
+ } else if (d.type === 'terminal_output' && term) {
1702
1594
  term.write(d.data);
1703
- }
1704
- else if(d.type==='stream_end' && term){
1705
- // Terminal stream ended
1706
- }
1707
- else if(d.type==='terminal_exit' && term){
1708
- const detail = typeof d.exitCode === 'number' ? \`exit \${d.exitCode}\` : 'terminated';
1595
+ } else if (d.type === 'terminal_exit' && term) {
1596
+ const code = typeof d.exitCode === 'number' ? \`exit \${d.exitCode}\` : 'terminated';
1709
1597
  term.writeln(\`\r
1710
- [terminal \${detail}]\`);
1711
- }
1712
- else if(d.type==='message'){
1713
- // Chat message - add to chat UI
1714
- const messagesDiv = document.getElementById('messages');
1715
- if (messagesDiv) {
1716
- const msgDiv = document.createElement('div');
1717
- msgDiv.className = \`msg \${d.role}\`;
1718
- msgDiv.textContent = d.content;
1719
- messagesDiv.appendChild(msgDiv);
1720
- messagesDiv.scrollTop = messagesDiv.scrollHeight;
1598
+ [terminal \${code}]\`);
1599
+ } else if (d.type === 'stream_chunk') {
1600
+ const msgs = document.getElementById('messages');
1601
+ if (msgs) {
1602
+ let last = msgs.lastElementChild;
1603
+ if (!last || last.dataset.streaming !== 'true') {
1604
+ last = document.createElement('div');
1605
+ last.className = 'msg assistant';
1606
+ last.dataset.streaming = 'true';
1607
+ msgs.appendChild(last);
1608
+ }
1609
+ last.textContent += d.data;
1610
+ msgs.scrollTop = msgs.scrollHeight;
1611
+ }
1612
+ } else if (d.type === 'stream_end') {
1613
+ const msgs = document.getElementById('messages');
1614
+ if (msgs?.lastElementChild) delete msgs.lastElementChild.dataset.streaming;
1615
+ } else if (d.type === 'message') {
1616
+ const msgs = document.getElementById('messages');
1617
+ if (msgs) {
1618
+ const el = document.createElement('div');
1619
+ el.className = \`msg \${d.role}\`;
1620
+ el.textContent = d.content;
1621
+ msgs.appendChild(el);
1622
+ msgs.scrollTop = msgs.scrollHeight;
1721
1623
  }
1722
1624
  }
1723
1625
  };
1724
1626
 
1725
- // Load initial status and determine mode
1726
- fetch('/api/status').then(r=>r.json()).then(d=>{
1727
- if(d.terminalLaunch) document.getElementById('launchText').textContent='Launch: '+d.terminalLaunch;
1728
- if(d.pairingCode){
1729
- document.getElementById('pairingCard').style.display='';
1730
- document.getElementById('pairingCode').textContent=d.pairingCode;
1731
- if(d.pairingQR) document.getElementById('qrCode').src=d.pairingQR;
1732
- }
1733
- if(d.connected){
1734
- document.getElementById('dot').className='dot on';
1735
- document.getElementById('statusText').textContent='Phone connected';
1736
- } else{
1737
- document.getElementById('dot').className='dot wait';
1738
- document.getElementById('statusText').textContent='Waiting for phone...';
1739
- }
1740
-
1741
- // Determine mode: terminal mode if no adapter but has terminal launch
1742
- if (!d.adapter && d.terminalLaunch) {
1743
- switchToMode('terminal');
1627
+ fetch('/api/status').then(r => r.json()).then(d => {
1628
+ if (d.terminalLaunch) document.getElementById('launchText').textContent = 'Launch: ' + d.terminalLaunch;
1629
+ if (d.pairingCode) {
1630
+ document.getElementById('pairingCard').style.display = '';
1631
+ document.getElementById('pairingCode').textContent = d.pairingCode;
1632
+ if (d.pairingQR) document.getElementById('qrCode').src = d.pairingQR;
1633
+ }
1634
+ document.getElementById('dot').className = d.connected ? 'dot on' : 'dot wait';
1635
+ document.getElementById('statusText').textContent = d.connected ? 'Phone connected' : 'Waiting for phone...';
1636
+ if (d.adapter) {
1637
+ document.getElementById('chatSection').style.display = '';
1638
+ (d.messages || []).forEach(m => {
1639
+ const msgs = document.getElementById('messages');
1640
+ const el = document.createElement('div');
1641
+ el.className = \`msg \${m.role}\`;
1642
+ el.textContent = m.content;
1643
+ msgs.appendChild(el);
1644
+ });
1744
1645
  } else {
1745
- switchToMode('chat');
1746
- if (d.adapter) {
1747
- document.getElementById('chatCard').style.display = 'block';
1748
- }
1646
+ document.getElementById('terminalSection').style.display = '';
1647
+ initTerminal();
1749
1648
  }
1750
1649
  });
1751
1650
 
1752
- // Configuration functions
1753
- async function configureAdapter() {
1754
- const type = document.getElementById('adapterType').value;
1755
- const model = document.getElementById('model').value;
1756
- const apiKey = document.getElementById('apiKey').value;
1757
-
1758
- const response = await fetch('/api/configure', {
1759
- method: 'POST',
1760
- headers: { 'Content-Type': 'application/json' },
1761
- body: JSON.stringify({ type, model, apiKey })
1651
+ fetch('/api/cli-presets').then(r => r.json()).then(presets => {
1652
+ cliPresets = presets;
1653
+ const sel = document.getElementById('cliPreset');
1654
+ const shellOpt = document.createElement('option');
1655
+ shellOpt.value = 'shell'; shellOpt.textContent = 'Shell (default)';
1656
+ sel.appendChild(shellOpt);
1657
+ presets.forEach(p => {
1658
+ const o = document.createElement('option');
1659
+ o.value = p.id; o.textContent = p.name;
1660
+ sel.appendChild(o);
1762
1661
  });
1763
- const result = await response.json();
1764
- if (result.error) {
1765
- alert(result.error);
1766
- } else {
1767
- document.getElementById('chatCard').style.display = 'block';
1662
+ sel.addEventListener('change', () => {
1663
+ const id = sel.value;
1664
+ const p = cliPresets.find(x => x.id === id);
1665
+ const cmd = document.getElementById('command');
1666
+ const desc = document.getElementById('presetDesc');
1667
+ if (id === 'shell') { cmd.style.display = 'none'; cmd.value = ''; desc.textContent = 'Interactive login shell.'; return; }
1668
+ if (id === 'custom') { cmd.style.display = ''; desc.textContent = 'Enter the exact launch command.'; return; }
1669
+ cmd.style.display = 'none';
1670
+ desc.textContent = p ? p.description : '';
1671
+ });
1672
+ sel.value = 'shell'; sel.dispatchEvent(new Event('change'));
1673
+ return fetch('/api/config').then(r => r.json());
1674
+ }).then(cfg => {
1675
+ if (!cfg?.saved) return;
1676
+ const { saved } = cfg;
1677
+ if (saved.type === 'terminal' || saved.type === 'cli') {
1678
+ if (saved.preset) document.getElementById('cliPreset').value = saved.preset;
1679
+ if (saved.command) document.getElementById('command').value = saved.command;
1680
+ document.getElementById('cliPreset').dispatchEvent(new Event('change'));
1768
1681
  }
1769
- }
1682
+ }).catch(() => {});
1770
1683
 
1771
- async function configureTerminal() {
1684
+ document.getElementById('applyLaunchBtn').addEventListener('click', async () => {
1772
1685
  const preset = document.getElementById('cliPreset').value;
1773
1686
  const command = document.getElementById('command').value;
1774
-
1775
- const response = await fetch('/api/configure', {
1776
- method: 'POST',
1777
- headers: { 'Content-Type': 'application/json' },
1778
- body: JSON.stringify({ type: 'cli', preset, command })
1779
- });
1780
- const result = await response.json();
1781
- if (result.error) {
1782
- alert(result.error);
1783
- }
1784
- }
1687
+ const r = await fetch('/api/configure', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'cli', preset, command }) });
1688
+ const d = await r.json();
1689
+ if (d.error) alert(d.error);
1690
+ });
1691
+
1692
+ document.getElementById('focusTermBtn').addEventListener('click', () => term?.focus());
1693
+ document.getElementById('ctrlCBtn').addEventListener('click', () => {
1694
+ term?.focus();
1695
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'terminal_input', data: '' }));
1696
+ });
1697
+ document.getElementById('escBtn').addEventListener('click', () => {
1698
+ term?.focus();
1699
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'terminal_input', data: '\x1B' }));
1700
+ });
1701
+ document.getElementById('tabBtn').addEventListener('click', () => {
1702
+ term?.focus();
1703
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'terminal_input', data: ' ' }));
1704
+ });
1705
+
1706
+ document.getElementById('sendBtn').addEventListener('click', sendMessage);
1707
+ document.getElementById('messageInput').addEventListener('keydown', (e) => {
1708
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
1709
+ });
1785
1710
 
1786
- // Chat functionality
1787
1711
  function sendMessage() {
1788
1712
  const input = document.getElementById('messageInput');
1789
1713
  const content = input.value.trim();
1790
1714
  if (!content) return;
1791
-
1792
- fetch('/api/send', {
1793
- method: 'POST',
1794
- headers: { 'Content-Type': 'application/json' },
1795
- body: JSON.stringify({ content })
1796
- }).then(() => {
1797
- input.value = '';
1798
- }).catch(err => {
1799
- console.error('Failed to send message:', err);
1800
- });
1715
+ fetch('/api/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) })
1716
+ .then(() => { input.value = ''; })
1717
+ .catch(err => console.error('Failed to send:', err));
1801
1718
  }
1802
-
1803
- // Handle Enter key in chat input
1804
- document.getElementById('messageInput')?.addEventListener('keydown', (e) => {
1805
- if (e.key === 'Enter' && !e.shiftKey) {
1806
- e.preventDefault();
1807
- sendMessage();
1808
- }
1809
- });
1810
1719
  </script>
1811
1720
  </body>
1812
1721
  </html>`;
1813
1722
 
1814
1723
  // src/index.ts
1724
+ var QRCode = null;
1725
+ async function getQRCode() {
1726
+ if (!QRCode) QRCode = await import("qrcode");
1727
+ return QRCode;
1728
+ }
1729
+ console.log("[host] Module loaded");
1815
1730
  function parseArgs(argv) {
1816
1731
  const args = {};
1817
1732
  const rest = argv.slice(2);
@@ -1873,6 +1788,7 @@ if (cliArgs.help) {
1873
1788
  var DEFAULT_ABLY_KEY = "SfHSAQ.IRTOQQ:FBbi9a7ZV6jIu0Gdo_UeYhIN4rzpMrud5-LldURNh9s";
1874
1789
  var DEFAULT_VIEWER_URL = "https://bobstrogg.github.io/Airloom/";
1875
1790
  var VIEWER_URL = process.env.VIEWER_URL ?? DEFAULT_VIEWER_URL;
1791
+ var IS_DEV = !process.env.VIEWER_URL && !new URL(import.meta.url).pathname.includes("node_modules");
1876
1792
  var RELAY_URL = process.env.RELAY_URL;
1877
1793
  var ABLY_API_KEY = process.env.ABLY_API_KEY ?? (RELAY_URL ? void 0 : DEFAULT_ABLY_KEY);
1878
1794
  var ABLY_TOKEN_TTL = parseInt(process.env.ABLY_TOKEN_TTL ?? String(24 * 60 * 60 * 1e3), 10);
@@ -1968,7 +1884,10 @@ async function main() {
1968
1884
  connected: false,
1969
1885
  terminalLaunch,
1970
1886
  terminalLaunchCommand: launchCommand,
1971
- messages: []
1887
+ messages: [],
1888
+ sessionToken: session.sessionToken,
1889
+ ablyToken: useAbly ? pairingData.token : void 0,
1890
+ transport: useAbly ? "ably" : "ws"
1972
1891
  };
1973
1892
  console.log(`[host] Terminal launch: ${terminalLaunch}`);
1974
1893
  if (cliArgs.cli || cliArgs.preset) {
@@ -2034,31 +1953,37 @@ async function main() {
2034
1953
  const { server, broadcast, port } = await createHostServer({ port: HOST_PORT, state, viewerDir });
2035
1954
  const pairingBase64 = Buffer.from(pairingJSON).toString("base64url");
2036
1955
  const viewerBase = VIEWER_URL.replace(/\/+$/, "");
2037
- const qrContent = `${viewerBase}/#${pairingBase64}`;
1956
+ const pagesUrl = `${viewerBase}/#${pairingBase64}`;
2038
1957
  const lanIP = getLanIP();
2039
1958
  const lanHost = lanIP ?? "localhost";
2040
1959
  const lanBaseUrl = `http://${lanHost}:${port}`;
2041
1960
  const lanViewerUrl = viewerDir ? `${lanBaseUrl}/viewer/#${pairingBase64}` : null;
2042
- const qrDataUrl = await QRCode.toDataURL(qrContent, { width: 300, margin: 2 });
2043
- const qrTerminal = await QRCode.toString(qrContent, { type: "terminal", small: true });
1961
+ const qrTarget = IS_DEV && lanViewerUrl ? lanViewerUrl : pagesUrl;
1962
+ const qrcode = await getQRCode();
1963
+ const qrDataUrl = await qrcode.toDataURL(qrTarget, { width: 300, margin: 2 });
1964
+ const qrTerminal = await qrcode.toString(qrTarget, { type: "terminal", small: true });
2044
1965
  state.pairingQR = qrDataUrl;
2045
1966
  console.log("\nPairing QR Code:");
2046
1967
  console.log(qrTerminal);
2047
1968
  console.log(`Pairing Code: ${displayCode}`);
2048
- console.log(`Viewer URL: ${qrContent}`);
2049
- if (lanViewerUrl) {
2050
- console.log(`LAN Viewer: ${lanViewerUrl}`);
1969
+ if (IS_DEV && lanViewerUrl) {
1970
+ console.log(`Viewer URL (LAN/dev): ${lanViewerUrl}`);
1971
+ console.log(`Pages URL: ${pagesUrl}`);
1972
+ } else {
1973
+ console.log(`Viewer URL: ${pagesUrl}`);
1974
+ if (lanViewerUrl) console.log(`LAN Viewer: ${lanViewerUrl}`);
2051
1975
  }
2052
1976
  if (!useAbly) console.log(`Relay: ${RELAY_URL}`);
2053
1977
  const localUrl = `http://localhost:${port}`;
2054
1978
  console.log(`[host] Web UI at ${localUrl}
2055
1979
  `);
2056
- import("child_process").then(({ exec }) => {
1980
+ import("node:child_process").then(({ exec }) => {
2057
1981
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2058
1982
  exec(`${cmd} ${localUrl}`);
2059
1983
  }).catch(() => {
2060
1984
  });
2061
- const terminal = new TerminalSession(channel, () => state.terminalLaunchCommand);
1985
+ const terminal = new TerminalSession(channel, () => state.terminalLaunchCommand, broadcast);
1986
+ state.terminal = terminal;
2062
1987
  channel.on("ready", () => {
2063
1988
  console.log("[host] Phone connected! Channel ready.");
2064
1989
  state.connected = true;
@@ -2067,11 +1992,12 @@ async function main() {
2067
1992
  channel.on("peer_left", () => {
2068
1993
  console.log("[host] Phone disconnected.");
2069
1994
  state.connected = false;
2070
- terminal.close();
1995
+ terminal.detachStream();
2071
1996
  broadcast({ type: "peer_disconnected" });
2072
1997
  });
2073
1998
  channel.on("message", (data) => {
2074
1999
  if (isTerminalMessage(data)) {
2000
+ console.log("[host] Terminal message from phone:", data.type);
2075
2001
  terminal.handleMessage(data);
2076
2002
  return;
2077
2003
  }
@@ -2093,7 +2019,7 @@ async function main() {
2093
2019
  if (shuttingDown) return;
2094
2020
  shuttingDown = true;
2095
2021
  console.log("\n[host] Shutting down...");
2096
- terminal.close();
2022
+ terminal.destroy();
2097
2023
  state.adapter?.destroy?.();
2098
2024
  try {
2099
2025
  channel.close();