airloom 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +67 -0
  2. package/dist/index.js +1433 -0
  3. package/package.json +50 -0
package/dist/index.js ADDED
@@ -0,0 +1,1433 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../../packages/channel/src/channel.ts
4
+ import { EventEmitter as EventEmitter2 } from "events";
5
+
6
+ // ../../packages/protocol/src/codec.ts
7
+ function toBase64(bytes) {
8
+ if (typeof Buffer !== "undefined") {
9
+ return Buffer.from(bytes).toString("base64");
10
+ }
11
+ let binary = "";
12
+ for (const byte of bytes) {
13
+ binary += String.fromCharCode(byte);
14
+ }
15
+ return btoa(binary);
16
+ }
17
+ function fromBase64(str) {
18
+ if (typeof Buffer !== "undefined") {
19
+ return new Uint8Array(Buffer.from(str, "base64"));
20
+ }
21
+ const binary = atob(str);
22
+ const bytes = new Uint8Array(binary.length);
23
+ for (let i = 0; i < binary.length; i++) {
24
+ bytes[i] = binary.charCodeAt(i);
25
+ }
26
+ return bytes;
27
+ }
28
+ function encodeChannelMessage(msg) {
29
+ return new TextEncoder().encode(JSON.stringify(msg));
30
+ }
31
+ function decodeChannelMessage(data) {
32
+ const msg = JSON.parse(new TextDecoder().decode(data));
33
+ if (!msg || typeof msg !== "object" || typeof msg.type !== "string" || typeof msg.id !== "string") {
34
+ throw new Error("Invalid channel message: missing type or id field");
35
+ }
36
+ return msg;
37
+ }
38
+ function encodePairingData(data) {
39
+ return JSON.stringify(data);
40
+ }
41
+ function randomCode(length) {
42
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
43
+ const limit = 256 - 256 % chars.length;
44
+ const result = [];
45
+ while (result.length < length) {
46
+ const bytes = getRandomBytes(length - result.length + 8);
47
+ for (const b of bytes) {
48
+ if (b >= limit) continue;
49
+ result.push(chars[b % chars.length]);
50
+ if (result.length === length) break;
51
+ }
52
+ }
53
+ return result.join("");
54
+ }
55
+ function getRandomBytes(length) {
56
+ const bytes = new Uint8Array(length);
57
+ if (typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.getRandomValues === "function") {
58
+ globalThis.crypto.getRandomValues(bytes);
59
+ return bytes;
60
+ }
61
+ throw new Error("No crypto implementation available. Requires Node.js >= 18 or a browser with Web Crypto API.");
62
+ }
63
+
64
+ // ../../packages/crypto/src/keypair.ts
65
+ import nacl from "tweetnacl";
66
+ function generateKeyPair() {
67
+ return nacl.box.keyPair();
68
+ }
69
+
70
+ // ../../packages/crypto/src/encrypt.ts
71
+ import nacl2 from "tweetnacl";
72
+ var NONCE_LENGTH = 24;
73
+ var TAG_LENGTH = 16;
74
+ function encrypt(plaintext, key) {
75
+ const nonce = nacl2.randomBytes(NONCE_LENGTH);
76
+ const ciphertext = nacl2.secretbox(plaintext, nonce, key);
77
+ const sealed = new Uint8Array(NONCE_LENGTH + ciphertext.length);
78
+ sealed.set(nonce);
79
+ sealed.set(ciphertext, NONCE_LENGTH);
80
+ return sealed;
81
+ }
82
+ function decrypt(sealed, key) {
83
+ if (sealed.length < NONCE_LENGTH + TAG_LENGTH) {
84
+ throw new Error("Sealed data too short");
85
+ }
86
+ const nonce = sealed.slice(0, NONCE_LENGTH);
87
+ const ciphertext = sealed.slice(NONCE_LENGTH);
88
+ const plaintext = nacl2.secretbox.open(ciphertext, nonce, key);
89
+ if (!plaintext) {
90
+ throw new Error("Decryption failed - data may be tampered with");
91
+ }
92
+ return plaintext;
93
+ }
94
+
95
+ // ../../packages/crypto/src/pairing.ts
96
+ import { sha256 } from "@noble/hashes/sha256";
97
+ import { hkdf } from "@noble/hashes/hkdf";
98
+ function createSession(relayUrl) {
99
+ const keyPair = generateKeyPair();
100
+ const pairingCode = randomCode(8);
101
+ const sessionToken = deriveSessionToken(pairingCode);
102
+ const pairingData = {
103
+ relay: relayUrl,
104
+ session: sessionToken,
105
+ pub: toBase64(keyPair.publicKey),
106
+ v: 1
107
+ };
108
+ return {
109
+ sessionToken,
110
+ pairingCode,
111
+ publicKey: keyPair.publicKey,
112
+ privateKey: keyPair.secretKey,
113
+ pairingData
114
+ };
115
+ }
116
+ function deriveSessionToken(pairingCode) {
117
+ const hash = sha256(new TextEncoder().encode(`airloom-session:${pairingCode}`));
118
+ return Array.from(hash.slice(0, 16), (b) => b.toString(16).padStart(2, "0")).join("");
119
+ }
120
+ function deriveEncryptionKey(sharedSecret, salt) {
121
+ const defaultSalt = new TextEncoder().encode("airloom-v1");
122
+ return hkdf(sha256, sharedSecret, salt ?? defaultSalt, "airloom-encryption", 32);
123
+ }
124
+ function formatPairingCode(code) {
125
+ if (code.length <= 4) return code;
126
+ return `${code.slice(0, 4)}-${code.slice(4)}`;
127
+ }
128
+
129
+ // ../../packages/channel/src/stream.ts
130
+ import { EventEmitter } from "events";
131
+ var WriteStream = class {
132
+ constructor(id, sendFn, meta) {
133
+ this.id = id;
134
+ this.sendFn = sendFn;
135
+ this.meta = meta;
136
+ }
137
+ _ended = false;
138
+ write(data) {
139
+ if (this._ended) throw new Error("Cannot write after stream has ended");
140
+ this.sendFn({ type: "stream_chunk", id: this.id, data });
141
+ }
142
+ end() {
143
+ if (this._ended) return;
144
+ this._ended = true;
145
+ this.sendFn({ type: "stream_end", id: this.id });
146
+ }
147
+ get ended() {
148
+ return this._ended;
149
+ }
150
+ };
151
+ var ReadStream = class extends EventEmitter {
152
+ id;
153
+ meta;
154
+ _ended = false;
155
+ constructor(id, meta) {
156
+ super();
157
+ this.id = id;
158
+ this.meta = meta;
159
+ }
160
+ /** @internal Called when a chunk arrives */
161
+ _pushChunk(data) {
162
+ if (!this._ended) this.emit("data", data);
163
+ }
164
+ /** @internal Called when the stream ends */
165
+ _end() {
166
+ if (this._ended) return;
167
+ this._ended = true;
168
+ this.emit("end");
169
+ }
170
+ get ended() {
171
+ return this._ended;
172
+ }
173
+ };
174
+
175
+ // ../../packages/channel/src/channel.ts
176
+ var Channel = class extends EventEmitter2 {
177
+ adapter;
178
+ encryptionKey;
179
+ role;
180
+ streams = /* @__PURE__ */ new Map();
181
+ _ready = false;
182
+ msgCounter = 0;
183
+ constructor(opts) {
184
+ super();
185
+ this.adapter = opts.adapter;
186
+ this.encryptionKey = opts.encryptionKey ?? null;
187
+ this.role = opts.role;
188
+ this.setupHandlers();
189
+ }
190
+ setupHandlers() {
191
+ this.adapter.onMessage((payload) => {
192
+ this.handlePayload(payload);
193
+ });
194
+ this.adapter.onPeerJoined(() => {
195
+ if (this.encryptionKey) {
196
+ this._ready = true;
197
+ this.emit("ready");
198
+ }
199
+ });
200
+ this.adapter.onPeerLeft(() => {
201
+ this._ready = false;
202
+ this.emit("peer_left");
203
+ });
204
+ this.adapter.onError((err) => this.emit("error", err));
205
+ this.adapter.onDisconnect(() => this.emit("disconnect"));
206
+ }
207
+ handlePayload(base64Payload) {
208
+ try {
209
+ if (!this.encryptionKey) {
210
+ this.emit("error", new Error("Received data but no encryption key"));
211
+ return;
212
+ }
213
+ const sealed = fromBase64(base64Payload);
214
+ const plaintext = decrypt(sealed, this.encryptionKey);
215
+ const msg = decodeChannelMessage(plaintext);
216
+ this.handleMessage(msg);
217
+ } catch (err) {
218
+ this.emit("error", err);
219
+ }
220
+ }
221
+ handleMessage(msg) {
222
+ switch (msg.type) {
223
+ case "message":
224
+ this.emit("message", msg.data);
225
+ break;
226
+ case "stream_start": {
227
+ const stream = new ReadStream(msg.id, msg.meta);
228
+ this.streams.set(msg.id, stream);
229
+ this.emit("stream", stream);
230
+ break;
231
+ }
232
+ case "stream_chunk": {
233
+ const stream = this.streams.get(msg.id);
234
+ if (stream) stream._pushChunk(msg.data);
235
+ break;
236
+ }
237
+ case "stream_end": {
238
+ const stream = this.streams.get(msg.id);
239
+ if (stream) {
240
+ stream._end();
241
+ this.streams.delete(msg.id);
242
+ }
243
+ break;
244
+ }
245
+ }
246
+ }
247
+ sendRaw(msg) {
248
+ if (!this.encryptionKey) throw new Error("No encryption key established");
249
+ const plaintext = encodeChannelMessage(msg);
250
+ const sealed = encrypt(plaintext, this.encryptionKey);
251
+ this.adapter.send(toBase64(sealed));
252
+ }
253
+ send(data) {
254
+ const id = `msg-${++this.msgCounter}`;
255
+ this.sendRaw({ type: "message", id, data });
256
+ }
257
+ createStream(meta) {
258
+ const id = `stream-${++this.msgCounter}`;
259
+ this.sendRaw({ type: "stream_start", id, meta });
260
+ return new WriteStream(id, (msg) => this.sendRaw(msg), meta);
261
+ }
262
+ async connect(sessionToken) {
263
+ await this.adapter.connect(sessionToken, this.role);
264
+ }
265
+ get ready() {
266
+ return this._ready;
267
+ }
268
+ waitForReady(timeoutMs = 3e4) {
269
+ if (this._ready) return Promise.resolve();
270
+ return new Promise((resolve2, reject) => {
271
+ const cleanup = () => {
272
+ clearTimeout(timer);
273
+ this.removeListener("ready", onReady);
274
+ this.removeListener("error", onError);
275
+ this.removeListener("disconnect", onDisconnect);
276
+ };
277
+ const onReady = () => {
278
+ cleanup();
279
+ resolve2();
280
+ };
281
+ const onError = (err) => {
282
+ cleanup();
283
+ reject(err);
284
+ };
285
+ const onDisconnect = () => {
286
+ cleanup();
287
+ reject(new Error("Disconnected before ready"));
288
+ };
289
+ const timer = setTimeout(() => {
290
+ cleanup();
291
+ reject(new Error("Timed out waiting for peer"));
292
+ }, timeoutMs);
293
+ this.once("ready", onReady);
294
+ this.once("error", onError);
295
+ this.once("disconnect", onDisconnect);
296
+ });
297
+ }
298
+ close() {
299
+ for (const stream of this.streams.values()) stream._end();
300
+ this.streams.clear();
301
+ this.adapter.close();
302
+ this.removeAllListeners();
303
+ }
304
+ };
305
+
306
+ // ../../packages/channel/src/batcher.ts
307
+ var Batcher = class {
308
+ buffer = "";
309
+ timer = null;
310
+ flushFn;
311
+ interval;
312
+ maxBytes;
313
+ _destroyed = false;
314
+ constructor(opts) {
315
+ this.interval = opts.interval ?? 500;
316
+ this.maxBytes = opts.maxBytes ?? 4096;
317
+ this.flushFn = opts.onFlush;
318
+ }
319
+ write(data) {
320
+ if (this._destroyed) return;
321
+ this.buffer += data;
322
+ if (this.buffer.length >= this.maxBytes) {
323
+ this.flush();
324
+ return;
325
+ }
326
+ if (!this.timer) {
327
+ this.timer = setTimeout(() => this.flush(), this.interval);
328
+ }
329
+ }
330
+ flush() {
331
+ if (this.timer) {
332
+ clearTimeout(this.timer);
333
+ this.timer = null;
334
+ }
335
+ if (this.buffer) {
336
+ const data = this.buffer;
337
+ this.buffer = "";
338
+ this.flushFn(data);
339
+ }
340
+ }
341
+ destroy() {
342
+ this._destroyed = true;
343
+ this.flush();
344
+ }
345
+ };
346
+
347
+ // ../../packages/channel/src/adapters/websocket.ts
348
+ var WebSocketAdapter = class {
349
+ ws = null;
350
+ url;
351
+ _connected = false;
352
+ messageHandlers = [];
353
+ peerJoinedHandlers = [];
354
+ peerLeftHandlers = [];
355
+ errorHandlers = [];
356
+ disconnectHandlers = [];
357
+ reconnectAttempts = 0;
358
+ maxReconnectAttempts = 5;
359
+ reconnectTimer = null;
360
+ shouldReconnect = false;
361
+ sessionToken = null;
362
+ role = null;
363
+ constructor(url) {
364
+ this.url = url;
365
+ }
366
+ get connected() {
367
+ return this._connected;
368
+ }
369
+ async connect(sessionToken, role) {
370
+ this.sessionToken = sessionToken;
371
+ this.role = role;
372
+ return this.doConnect();
373
+ }
374
+ async doConnect() {
375
+ const ws = await this.createWebSocket(this.url);
376
+ return new Promise((resolve2, reject) => {
377
+ ws.onopen = () => {
378
+ this._connected = true;
379
+ this.shouldReconnect = true;
380
+ this.reconnectAttempts = 0;
381
+ const msg = this.role === "host" ? { type: "create", sessionToken: this.sessionToken } : { type: "join", sessionToken: this.sessionToken };
382
+ ws.send(JSON.stringify(msg));
383
+ resolve2();
384
+ };
385
+ ws.onmessage = (event) => {
386
+ const raw = event.data;
387
+ const data = typeof raw === "string" ? raw : typeof Buffer !== "undefined" && Buffer.isBuffer(raw) ? raw.toString("utf-8") : new TextDecoder().decode(raw);
388
+ try {
389
+ const msg = JSON.parse(data);
390
+ switch (msg.type) {
391
+ case "created":
392
+ case "joined":
393
+ break;
394
+ case "peer_joined":
395
+ this.peerJoinedHandlers.forEach((h) => h());
396
+ break;
397
+ case "peer_left":
398
+ this.peerLeftHandlers.forEach((h) => h());
399
+ break;
400
+ case "forward":
401
+ this.messageHandlers.forEach((h) => h(msg.payload));
402
+ break;
403
+ case "error":
404
+ this.errorHandlers.forEach((h) => h(new Error(msg.message)));
405
+ break;
406
+ }
407
+ } catch (err) {
408
+ this.errorHandlers.forEach((h) => h(err instanceof Error ? err : new Error(String(err))));
409
+ }
410
+ };
411
+ ws.onclose = () => {
412
+ const wasConnected = this._connected;
413
+ this._connected = false;
414
+ if (wasConnected) {
415
+ this.disconnectHandlers.forEach((h) => h());
416
+ }
417
+ if (this.shouldReconnect && !this.reconnectTimer) this.attemptReconnect();
418
+ };
419
+ ws.onerror = (err) => {
420
+ const error = err instanceof Error ? err : new Error("WebSocket error");
421
+ this.errorHandlers.forEach((h) => h(error));
422
+ if (!this._connected) {
423
+ this.shouldReconnect = false;
424
+ reject(error);
425
+ }
426
+ };
427
+ this.ws = ws;
428
+ });
429
+ }
430
+ async createWebSocket(url) {
431
+ if (typeof globalThis.WebSocket !== "undefined") {
432
+ return new globalThis.WebSocket(url);
433
+ }
434
+ try {
435
+ const { default: WS } = await import("ws");
436
+ return new WS(url);
437
+ } catch {
438
+ throw new Error('No WebSocket implementation available. Install the "ws" package for Node.js.');
439
+ }
440
+ }
441
+ attemptReconnect() {
442
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
443
+ if (!this.sessionToken || !this.role) return;
444
+ this.reconnectAttempts++;
445
+ const delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts - 1), 3e4);
446
+ this.reconnectTimer = setTimeout(() => {
447
+ this.reconnectTimer = null;
448
+ this.doConnect().catch(() => {
449
+ });
450
+ }, delay);
451
+ }
452
+ send(payload) {
453
+ if (!this.ws || !this._connected) return;
454
+ this.ws.send(JSON.stringify({ type: "forward", payload }));
455
+ }
456
+ onMessage(handler) {
457
+ this.messageHandlers.push(handler);
458
+ }
459
+ onPeerJoined(handler) {
460
+ this.peerJoinedHandlers.push(handler);
461
+ }
462
+ onPeerLeft(handler) {
463
+ this.peerLeftHandlers.push(handler);
464
+ }
465
+ onError(handler) {
466
+ this.errorHandlers.push(handler);
467
+ }
468
+ onDisconnect(handler) {
469
+ this.disconnectHandlers.push(handler);
470
+ }
471
+ close() {
472
+ this.shouldReconnect = false;
473
+ this.maxReconnectAttempts = 0;
474
+ if (this.reconnectTimer) {
475
+ clearTimeout(this.reconnectTimer);
476
+ this.reconnectTimer = null;
477
+ }
478
+ this.ws?.close();
479
+ this.ws = null;
480
+ this._connected = false;
481
+ }
482
+ };
483
+
484
+ // ../../packages/channel/src/adapters/ably.ts
485
+ import { Realtime } from "ably";
486
+ var AblyAdapter = class {
487
+ ably = null;
488
+ channel = null;
489
+ opts;
490
+ _connected = false;
491
+ clientId = null;
492
+ messageHandlers = [];
493
+ peerJoinedHandlers = [];
494
+ peerLeftHandlers = [];
495
+ errorHandlers = [];
496
+ disconnectHandlers = [];
497
+ constructor(opts) {
498
+ if (!opts.key && !opts.token) throw new Error("Ably API key or token is required");
499
+ this.opts = opts;
500
+ }
501
+ get connected() {
502
+ return this._connected;
503
+ }
504
+ async connect(sessionToken, role) {
505
+ this.clientId = `airloom-${role}-${Date.now()}`;
506
+ const clientOpts = { clientId: this.clientId };
507
+ if (this.opts.key) {
508
+ clientOpts.key = this.opts.key;
509
+ } else {
510
+ clientOpts.token = this.opts.token;
511
+ }
512
+ this.ably = new Realtime(clientOpts);
513
+ await new Promise((resolve2, reject) => {
514
+ this.ably.connection.once("connected", () => resolve2());
515
+ this.ably.connection.once("failed", (stateChange) => {
516
+ reject(new Error(stateChange?.reason?.message ?? "Ably connection failed"));
517
+ });
518
+ });
519
+ this._connected = true;
520
+ this.ably.connection.on("disconnected", () => {
521
+ this._connected = false;
522
+ this.disconnectHandlers.forEach((h) => h());
523
+ });
524
+ this.ably.connection.on("connected", () => {
525
+ this._connected = true;
526
+ if (this.channel) {
527
+ this.channel.presence.get().then((members2) => {
528
+ const hasPeer2 = members2.some((m) => m.clientId !== this.clientId);
529
+ if (hasPeer2) this.peerJoinedHandlers.forEach((h) => h());
530
+ }).catch(() => {
531
+ });
532
+ }
533
+ });
534
+ this.ably.connection.on("failed", (stateChange) => {
535
+ this._connected = false;
536
+ const err = new Error(stateChange?.reason?.message ?? "Ably connection failed");
537
+ this.errorHandlers.forEach((h) => h(err));
538
+ });
539
+ this.channel = this.ably.channels.get(`airloom:${sessionToken}`);
540
+ this.channel.subscribe("forward", (msg) => {
541
+ if (msg.clientId === this.clientId) return;
542
+ if (typeof msg.data === "string") {
543
+ this.messageHandlers.forEach((h) => h(msg.data));
544
+ }
545
+ });
546
+ this.channel.presence.subscribe("enter", (member) => {
547
+ if (member.clientId !== this.clientId) {
548
+ this.peerJoinedHandlers.forEach((h) => h());
549
+ }
550
+ });
551
+ this.channel.presence.subscribe("leave", (member) => {
552
+ if (member.clientId !== this.clientId) {
553
+ this.peerLeftHandlers.forEach((h) => h());
554
+ }
555
+ });
556
+ await this.channel.presence.enter({ role });
557
+ const members = await this.channel.presence.get();
558
+ const hasPeer = members.some((m) => m.clientId !== this.clientId);
559
+ if (hasPeer) {
560
+ this.peerJoinedHandlers.forEach((h) => h());
561
+ }
562
+ }
563
+ send(payload) {
564
+ if (!this.channel || !this._connected) return;
565
+ this.channel.publish("forward", payload);
566
+ }
567
+ onMessage(handler) {
568
+ this.messageHandlers.push(handler);
569
+ }
570
+ onPeerJoined(handler) {
571
+ this.peerJoinedHandlers.push(handler);
572
+ }
573
+ onPeerLeft(handler) {
574
+ this.peerLeftHandlers.push(handler);
575
+ }
576
+ onError(handler) {
577
+ this.errorHandlers.push(handler);
578
+ }
579
+ onDisconnect(handler) {
580
+ this.disconnectHandlers.push(handler);
581
+ }
582
+ close() {
583
+ this.channel?.presence.leave().catch(() => {
584
+ });
585
+ this.channel?.detach().catch(() => {
586
+ });
587
+ try {
588
+ this.ably?.close();
589
+ } catch {
590
+ }
591
+ this.channel = null;
592
+ this.ably = null;
593
+ this._connected = false;
594
+ }
595
+ };
596
+
597
+ // src/index.ts
598
+ import { sha256 as sha2562 } from "@noble/hashes/sha256";
599
+ import { networkInterfaces } from "os";
600
+ import { fileURLToPath } from "url";
601
+ import { dirname, resolve } from "path";
602
+ import { existsSync as existsSync2 } from "fs";
603
+ import QRCode from "qrcode";
604
+
605
+ // src/server.ts
606
+ import express from "express";
607
+ import { createServer } from "http";
608
+ import { existsSync } from "fs";
609
+ import { WebSocketServer, WebSocket } from "ws";
610
+
611
+ // src/adapters/anthropic.ts
612
+ var AnthropicAdapter = class {
613
+ name = "anthropic";
614
+ model;
615
+ apiKey;
616
+ constructor(config) {
617
+ if (!config.apiKey) throw new Error("API key is required for Anthropic adapter");
618
+ this.apiKey = config.apiKey;
619
+ this.model = config.model || "claude-sonnet-4-20250514";
620
+ }
621
+ async streamResponse(messages, stream) {
622
+ const systemMsg = messages.find((m) => m.role === "system")?.content;
623
+ const chatMsgs = messages.filter((m) => m.role !== "system");
624
+ const body = {
625
+ model: this.model,
626
+ max_tokens: 4096,
627
+ stream: true,
628
+ messages: chatMsgs
629
+ };
630
+ if (systemMsg) body.system = systemMsg;
631
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
632
+ method: "POST",
633
+ headers: {
634
+ "Content-Type": "application/json",
635
+ "x-api-key": this.apiKey,
636
+ "anthropic-version": "2023-06-01"
637
+ },
638
+ body: JSON.stringify(body)
639
+ });
640
+ if (!response.ok) {
641
+ const error = await response.text();
642
+ stream.write(`[Error: ${response.status} ${error}]`);
643
+ stream.end();
644
+ return;
645
+ }
646
+ const reader = response.body?.getReader();
647
+ if (!reader) {
648
+ stream.write("[Error: No response body]");
649
+ stream.end();
650
+ return;
651
+ }
652
+ const decoder = new TextDecoder();
653
+ let buffer = "";
654
+ try {
655
+ while (true) {
656
+ const { done, value } = await reader.read();
657
+ if (done) break;
658
+ buffer += decoder.decode(value, { stream: true });
659
+ const lines = buffer.split("\n");
660
+ buffer = lines.pop() ?? "";
661
+ for (const line of lines) {
662
+ if (!line.startsWith("data: ")) continue;
663
+ const data = line.slice(6).trim();
664
+ if (data === "[DONE]") continue;
665
+ try {
666
+ const event = JSON.parse(data);
667
+ if (event.type === "content_block_delta" && event.delta?.text) {
668
+ stream.write(event.delta.text);
669
+ }
670
+ } catch {
671
+ }
672
+ }
673
+ }
674
+ } finally {
675
+ stream.end();
676
+ }
677
+ }
678
+ };
679
+
680
+ // src/adapters/openai.ts
681
+ var OpenAIAdapter = class {
682
+ name = "openai";
683
+ model;
684
+ apiKey;
685
+ baseUrl;
686
+ constructor(config) {
687
+ if (!config.apiKey) throw new Error("API key is required for OpenAI adapter");
688
+ this.apiKey = config.apiKey;
689
+ this.model = config.model || "gpt-4o";
690
+ this.baseUrl = config.baseUrl || "https://api.openai.com/v1";
691
+ }
692
+ async streamResponse(messages, stream) {
693
+ const response = await fetch(`${this.baseUrl}/chat/completions`, {
694
+ method: "POST",
695
+ headers: {
696
+ "Content-Type": "application/json",
697
+ Authorization: `Bearer ${this.apiKey}`
698
+ },
699
+ body: JSON.stringify({ model: this.model, stream: true, messages })
700
+ });
701
+ if (!response.ok) {
702
+ const error = await response.text();
703
+ stream.write(`[Error: ${response.status} ${error}]`);
704
+ stream.end();
705
+ return;
706
+ }
707
+ const reader = response.body?.getReader();
708
+ if (!reader) {
709
+ stream.write("[Error: No response body]");
710
+ stream.end();
711
+ return;
712
+ }
713
+ const decoder = new TextDecoder();
714
+ let buffer = "";
715
+ try {
716
+ while (true) {
717
+ const { done, value } = await reader.read();
718
+ if (done) break;
719
+ buffer += decoder.decode(value, { stream: true });
720
+ const lines = buffer.split("\n");
721
+ buffer = lines.pop() ?? "";
722
+ for (const line of lines) {
723
+ if (!line.startsWith("data: ")) continue;
724
+ const data = line.slice(6).trim();
725
+ if (data === "[DONE]") continue;
726
+ try {
727
+ const event = JSON.parse(data);
728
+ const content = event.choices?.[0]?.delta?.content;
729
+ if (content) stream.write(content);
730
+ } catch {
731
+ }
732
+ }
733
+ }
734
+ } finally {
735
+ stream.end();
736
+ }
737
+ }
738
+ };
739
+
740
+ // src/adapters/cli.ts
741
+ import { spawn } from "child_process";
742
+ var CLI_PRESETS = [
743
+ {
744
+ id: "devin",
745
+ name: "Devin",
746
+ command: "devin --permission-mode dangerous -p --",
747
+ description: "Devin CLI in non-interactive print mode"
748
+ },
749
+ {
750
+ id: "claude-code",
751
+ name: "Claude Code",
752
+ command: "claude -p --output-format text",
753
+ description: "Claude Code in print mode"
754
+ },
755
+ {
756
+ id: "codex",
757
+ name: "Codex",
758
+ command: "codex exec --full-auto",
759
+ description: "OpenAI Codex CLI in non-interactive exec mode"
760
+ },
761
+ {
762
+ id: "aider",
763
+ name: "Aider",
764
+ command: "aider --yes --no-auto-commits --message",
765
+ description: "Aider in scripting mode (prompt via --message)"
766
+ },
767
+ {
768
+ id: "custom",
769
+ name: "Custom",
770
+ command: "",
771
+ description: "Custom command \u2014 prompt appended as last argument"
772
+ }
773
+ ];
774
+ var CLIAdapter = class {
775
+ name = "cli";
776
+ model;
777
+ command;
778
+ args;
779
+ constructor(config) {
780
+ if (!config.command) throw new Error("Command is required for CLI adapter");
781
+ const parts = config.command.split(" ");
782
+ this.command = parts[0];
783
+ this.args = parts.slice(1);
784
+ this.model = config.model || config.command;
785
+ }
786
+ async streamResponse(messages, stream) {
787
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
788
+ if (!lastUserMsg) {
789
+ stream.write("[No user message provided]");
790
+ stream.end();
791
+ return;
792
+ }
793
+ return new Promise((resolve2) => {
794
+ 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, "");
799
+ proc.stdin.end();
800
+ proc.stdout.on("data", (data) => stream.write(stripAnsi(data.toString())));
801
+ proc.stderr.on("data", (data) => stream.write(stripAnsi(data.toString())));
802
+ proc.on("close", () => {
803
+ stream.end();
804
+ resolve2();
805
+ });
806
+ proc.on("error", (err) => {
807
+ stream.write(`[Error: ${err.message}]`);
808
+ stream.end();
809
+ resolve2();
810
+ });
811
+ });
812
+ }
813
+ };
814
+
815
+ // src/config.ts
816
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
817
+ 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");
821
+ function loadConfig() {
822
+ try {
823
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
824
+ const data = JSON.parse(raw);
825
+ if (!data.type) return null;
826
+ return data;
827
+ } catch {
828
+ return null;
829
+ }
830
+ }
831
+ function saveConfig(config) {
832
+ try {
833
+ mkdirSync(CONFIG_DIR, { recursive: true });
834
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
835
+ } catch (err) {
836
+ console.error("[config] Failed to save:", err.message);
837
+ }
838
+ }
839
+ function getConfigPath() {
840
+ return CONFIG_PATH;
841
+ }
842
+
843
+ // src/server.ts
844
+ var MAX_MESSAGES = 200;
845
+ function trimMessages(messages) {
846
+ if (messages.length > MAX_MESSAGES) messages.splice(0, messages.length - MAX_MESSAGES);
847
+ }
848
+ var aiLock = Promise.resolve();
849
+ function enqueueAIResponse(channel, adapter, state, broadcast) {
850
+ aiLock = aiLock.then(() => handleAIResponse(channel, adapter, state, broadcast)).catch((err) => console.error("[host] AI response error:", err));
851
+ }
852
+ function createHostServer(opts) {
853
+ const app = express();
854
+ const server = createServer(app);
855
+ const wss = new WebSocketServer({ server, path: "/ws" });
856
+ const uiClients = /* @__PURE__ */ new Set();
857
+ app.use(express.json());
858
+ app.get("/", (_req, res) => {
859
+ res.type("html").send(HOST_HTML);
860
+ });
861
+ if (opts.viewerDir && existsSync(opts.viewerDir)) {
862
+ app.use("/viewer", express.static(opts.viewerDir));
863
+ }
864
+ app.get("/api/status", (_req, res) => {
865
+ res.json({
866
+ connected: opts.state.connected,
867
+ pairingCode: opts.state.pairingCode,
868
+ pairingQR: opts.state.pairingQR,
869
+ relayUrl: opts.state.relayUrl,
870
+ adapter: opts.state.adapter ? { name: opts.state.adapter.name, model: opts.state.adapter.model } : null,
871
+ messages: opts.state.messages
872
+ });
873
+ });
874
+ app.get("/api/cli-presets", (_req, res) => {
875
+ res.json(CLI_PRESETS);
876
+ });
877
+ app.get("/api/config", (_req, res) => {
878
+ const saved = loadConfig();
879
+ res.json({
880
+ saved,
881
+ envKeys: {
882
+ anthropic: !!process.env.ANTHROPIC_API_KEY,
883
+ openai: !!process.env.OPENAI_API_KEY
884
+ }
885
+ });
886
+ });
887
+ app.post("/api/configure", (req, res) => {
888
+ const { type, apiKey, model, command, preset } = req.body;
889
+ try {
890
+ switch (type) {
891
+ case "anthropic": {
892
+ const key = apiKey || process.env.ANTHROPIC_API_KEY;
893
+ if (!key) {
894
+ res.status(400).json({ error: "API key required (or set ANTHROPIC_API_KEY env var)" });
895
+ return;
896
+ }
897
+ opts.state.adapter = new AnthropicAdapter({ apiKey: key, model });
898
+ break;
899
+ }
900
+ case "openai": {
901
+ const key = apiKey || process.env.OPENAI_API_KEY;
902
+ if (!key) {
903
+ res.status(400).json({ error: "API key required (or set OPENAI_API_KEY env var)" });
904
+ return;
905
+ }
906
+ opts.state.adapter = new OpenAIAdapter({ apiKey: key, model });
907
+ break;
908
+ }
909
+ case "cli": {
910
+ const cmd = command || process.env.AIRLOOM_CLI_COMMAND;
911
+ if (!cmd) {
912
+ res.status(400).json({ error: "CLI adapter requires a command (or set AIRLOOM_CLI_COMMAND env var)" });
913
+ return;
914
+ }
915
+ opts.state.adapter = new CLIAdapter({ command: cmd, model });
916
+ break;
917
+ }
918
+ default:
919
+ res.status(400).json({ error: "Unknown adapter type" });
920
+ return;
921
+ }
922
+ const cfg = { type };
923
+ if (model) cfg.model = model;
924
+ if (type === "cli") {
925
+ cfg.command = command;
926
+ cfg.preset = preset;
927
+ }
928
+ saveConfig(cfg);
929
+ broadcast({ type: "configured", adapter: { name: opts.state.adapter.name, model: opts.state.adapter.model } });
930
+ res.json({ ok: true });
931
+ } catch (err) {
932
+ const message = err instanceof Error ? err.message : "Configuration failed";
933
+ res.status(400).json({ error: message });
934
+ }
935
+ });
936
+ app.post("/api/send", async (req, res) => {
937
+ const { content } = req.body;
938
+ if (!content) {
939
+ res.status(400).json({ error: "No content" });
940
+ return;
941
+ }
942
+ opts.state.messages.push({ role: "user", content, timestamp: Date.now() });
943
+ trimMessages(opts.state.messages);
944
+ broadcast({ type: "message", role: "user", content });
945
+ if (opts.state.adapter && opts.state.channel) {
946
+ enqueueAIResponse(opts.state.channel, opts.state.adapter, opts.state, broadcast);
947
+ }
948
+ res.json({ ok: true });
949
+ });
950
+ wss.on("connection", (ws) => {
951
+ uiClients.add(ws);
952
+ ws.on("close", () => uiClients.delete(ws));
953
+ });
954
+ function broadcast(data) {
955
+ const msg = JSON.stringify(data);
956
+ for (const ws of uiClients) {
957
+ if (ws.readyState === WebSocket.OPEN) ws.send(msg);
958
+ }
959
+ }
960
+ return new Promise((resolve2) => {
961
+ server.listen(opts.port, "0.0.0.0", () => {
962
+ const addr = server.address();
963
+ const actualPort = typeof addr === "object" && addr ? addr.port : opts.port;
964
+ resolve2({ server, broadcast, port: actualPort });
965
+ });
966
+ server.on("error", (err) => {
967
+ console.error(`[host] Server error: ${err.message}`);
968
+ process.exit(1);
969
+ });
970
+ });
971
+ }
972
+ async function handleAIResponse(channel, adapter, state, broadcast) {
973
+ const stream = channel.createStream({ model: adapter.model });
974
+ let fullResponse = "";
975
+ const batcher = new Batcher({
976
+ interval: 100,
977
+ onFlush: (data) => broadcast({ type: "stream_chunk", data })
978
+ });
979
+ const origWrite = stream.write.bind(stream);
980
+ stream.write = (data) => {
981
+ fullResponse += data;
982
+ batcher.write(data);
983
+ origWrite(data);
984
+ };
985
+ const origEnd = stream.end.bind(stream);
986
+ stream.end = () => {
987
+ batcher.flush();
988
+ broadcast({ type: "stream_end" });
989
+ state.messages.push({ role: "assistant", content: fullResponse, timestamp: Date.now() });
990
+ trimMessages(state.messages);
991
+ origEnd();
992
+ };
993
+ try {
994
+ await adapter.streamResponse(state.messages, stream);
995
+ } catch (err) {
996
+ if (!stream.ended) {
997
+ const message = err instanceof Error ? err.message : "Unknown error";
998
+ stream.write(`[Error: ${message}]`);
999
+ stream.end();
1000
+ }
1001
+ batcher.destroy();
1002
+ }
1003
+ }
1004
+ var HOST_HTML = `<!DOCTYPE html>
1005
+ <html lang="en">
1006
+ <head>
1007
+ <meta charset="UTF-8">
1008
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1009
+ <title>Airloom - Host</title>
1010
+ <style>
1011
+ *{margin:0;padding:0;box-sizing:border-box}
1012
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0a0a0a;color:#e0e0e0;min-height:100vh}
1013
+ .container{max-width:800px;margin:0 auto;padding:20px}
1014
+ .page-header{display:flex;align-items:center;gap:12px;margin-bottom:20px}
1015
+ .page-header svg{width:36px;height:36px;flex-shrink:0}
1016
+ .page-header h1{font-size:1.5rem;color:#7c8aff}
1017
+ h2{font-size:1.1rem;margin-bottom:12px;color:#a0a0a0}
1018
+ .card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:20px;margin-bottom:16px}
1019
+ .status{display:flex;align-items:center;gap:8px;margin-bottom:8px}
1020
+ .dot{width:8px;height:8px;border-radius:50%}
1021
+ .dot.on{background:#4caf50} .dot.off{background:#f44336} .dot.wait{background:#ff9800;animation:pulse 1.5s infinite}
1022
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
1023
+ .pairing{text-align:center}
1024
+ .pairing img{max-width:200px;margin:16px auto;display:block;border-radius:8px}
1025
+ .pairing-code{font-family:monospace;font-size:2rem;letter-spacing:4px;color:#7c8aff;margin:12px 0}
1026
+ .config-form{display:flex;flex-direction:column;gap:12px}
1027
+ select,input,button{padding:10px 14px;border-radius:8px;border:1px solid #333;background:#111;color:#e0e0e0;font-size:.95rem}
1028
+ select:focus,input:focus{outline:none;border-color:#7c8aff}
1029
+ button{background:#7c8aff;color:#fff;border:none;cursor:pointer;font-weight:600}
1030
+ button:hover{background:#6b79ee}
1031
+ .messages{max-height:400px;overflow-y:auto;display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
1032
+ .msg{padding:10px 14px;border-radius:10px;max-width:85%;word-break:break-word;font-size:.9rem;line-height:1.5}
1033
+ .msg.user{background:#2a3a6a;align-self:flex-end;white-space:pre-wrap}
1034
+ .msg.assistant{background:#1e1e1e;border:1px solid #2a2a2a;align-self:flex-start}
1035
+ .msg.assistant p{margin:0 0 .5em}.msg.assistant p:last-child{margin-bottom:0}
1036
+ .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}
1037
+ .msg.assistant h1{font-size:1.05rem}.msg.assistant h2{font-size:1rem}
1038
+ .msg.assistant ul,.msg.assistant ol{margin:.3em 0;padding-left:1.4em}.msg.assistant li{margin:.15em 0}
1039
+ .msg.assistant a{color:#7c8aff;text-decoration:underline}
1040
+ .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}
1041
+ .msg.assistant pre{margin:.5em 0;padding:10px 12px;border-radius:8px;background:#111;overflow-x:auto}
1042
+ .msg.assistant pre code{background:none;padding:0;font-size:.8rem;line-height:1.5;white-space:pre;display:block}
1043
+ .msg.assistant blockquote{margin:.4em 0;padding:4px 12px;border-left:3px solid #2a2a2a;color:#888}
1044
+ .msg.assistant table{border-collapse:collapse;margin:.4em 0;font-size:.82rem}
1045
+ .msg.assistant th,.msg.assistant td{border:1px solid #2a2a2a;padding:4px 8px}
1046
+ .msg.assistant th{background:rgba(255,255,255,.05)}
1047
+ .msg.assistant hr{border:none;border-top:1px solid #2a2a2a;margin:.5em 0}
1048
+ .msg.typing{display:flex;align-items:center;gap:5px;padding:14px 18px}
1049
+ .msg.typing .dot{width:7px;height:7px;border-radius:50%;background:#888;animation:dot-pulse 1.4s ease-in-out infinite}
1050
+ .msg.typing .dot:nth-child(2){animation-delay:.2s}
1051
+ .msg.typing .dot:nth-child(3){animation-delay:.4s}
1052
+ @keyframes dot-pulse{0%,80%,100%{opacity:.25;transform:scale(.8)}40%{opacity:1;transform:scale(1)}}
1053
+ .input-area{display:flex;gap:8px}
1054
+ .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}
1055
+ </style>
1056
+ </head>
1057
+ <body>
1058
+ <div class="container">
1059
+ <div class="page-header">
1060
+ <svg viewBox="0 0 100 100" fill="none"><defs><linearGradient id="lg" x1=".3" y1="0" x2=".7" y2="1"><stop stop-color="#a0aaff"/><stop offset="1" stop-color="#6070ef"/></linearGradient></defs><g stroke="url(#lg)" stroke-width="6" stroke-linecap="round"><line x1="22" y1="88" x2="31" y2="64"/><line x1="36" y1="52" x2="50" y2="15"/><line x1="9" y1="58" x2="59.5" y2="58"/><line x1="73.5" y1="58" x2="91" y2="58"/><line x1="50" y1="15" x2="78" y2="88"/></g></svg>
1061
+ <h1>Airloom</h1>
1062
+ </div>
1063
+ <div class="card">
1064
+ <div class="status"><div class="dot wait" id="dot"></div><span id="statusText">Initializing...</span></div>
1065
+ </div>
1066
+ <div class="card" id="configCard">
1067
+ <h2>AI Configuration</h2>
1068
+ <div class="config-form">
1069
+ <select id="adapterType"><option value="anthropic">Anthropic (Claude)</option><option value="openai">OpenAI (GPT)</option><option value="cli">CLI Tool</option></select>
1070
+ <input type="password" id="apiKey" placeholder="API Key"/>
1071
+ <input type="text" id="model" placeholder="Model (optional)"/>
1072
+ <div id="cliConfig" style="display:none">
1073
+ <select id="cliPreset" style="margin-bottom:8px"></select>
1074
+ <input type="text" id="command" placeholder="CLI command (prompt appended as last arg)"/>
1075
+ <p style="color:#666;font-size:.8rem;margin-top:4px" id="presetDesc"></p>
1076
+ </div>
1077
+ <button onclick="configure()">Configure</button>
1078
+ </div>
1079
+ </div>
1080
+ <div class="card pairing" id="pairingCard" style="display:none">
1081
+ <h2>Connect Your Phone</h2>
1082
+ <img id="qrCode" alt="QR Code"/>
1083
+ <div class="pairing-code" id="pairingCode"></div>
1084
+ <p style="color:#888;font-size:.85rem">Scan QR or enter code in viewer</p>
1085
+ </div>
1086
+ <div class="card" id="chatCard" style="display:none">
1087
+ <h2>Conversation</h2>
1088
+ <div class="messages" id="messages"></div>
1089
+ <div class="input-area">
1090
+ <textarea id="msgInput" placeholder="Type a message..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMsg()}"></textarea>
1091
+ <button onclick="sendMsg()">Send</button>
1092
+ </div>
1093
+ </div>
1094
+ </div>
1095
+ <script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
1096
+ <script>
1097
+ if(typeof marked!=='undefined'){marked.setOptions({gfm:true,breaks:true})}
1098
+ function renderMd(t){try{return typeof marked!=='undefined'?marked.parse(t):'<pre>'+t.replace(/</g,'&lt;')+'</pre>'}catch{return '<pre>'+t.replace(/</g,'&lt;')+'</pre>'}}
1099
+ const ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
1100
+ let curResp='',streamEl=null,cliPresets=[];
1101
+ // Load CLI presets
1102
+ fetch('/api/cli-presets').then(r=>r.json()).then(presets=>{
1103
+ cliPresets=presets;
1104
+ const sel=document.getElementById('cliPreset');
1105
+ presets.forEach(p=>{const o=document.createElement('option');o.value=p.id;o.textContent=p.name;sel.appendChild(o)});
1106
+ sel.addEventListener('change',()=>{
1107
+ const p=cliPresets.find(x=>x.id===sel.value);
1108
+ if(p){document.getElementById('command').value=p.command;document.getElementById('presetDesc').textContent=p.description;document.getElementById('command').style.display=p.id==='custom'?'':''}
1109
+ });
1110
+ if(presets.length){sel.dispatchEvent(new Event('change'))}
1111
+ // After presets are loaded, restore saved config
1112
+ return fetch('/api/config').then(r=>r.json());
1113
+ }).then(cfg=>{
1114
+ if(!cfg) return;
1115
+ const {saved,envKeys}=cfg;
1116
+ if(saved){
1117
+ document.getElementById('adapterType').value=saved.type;
1118
+ document.getElementById('adapterType').dispatchEvent(new Event('change'));
1119
+ if(saved.model) document.getElementById('model').value=saved.model;
1120
+ if(saved.type==='cli'){
1121
+ if(saved.preset){document.getElementById('cliPreset').value=saved.preset;document.getElementById('cliPreset').dispatchEvent(new Event('change'))}
1122
+ if(saved.command) document.getElementById('command').value=saved.command;
1123
+ }
1124
+ }
1125
+ // Show hints for env-var API keys
1126
+ if(envKeys.anthropic) document.getElementById('apiKey').placeholder='API Key (ANTHROPIC_API_KEY set)';
1127
+ if(envKeys.openai&&(!saved||saved.type==='openai')) document.getElementById('apiKey').placeholder='API Key (OPENAI_API_KEY set)';
1128
+ }).catch(()=>{});
1129
+ document.getElementById('adapterType').addEventListener('change',e=>{
1130
+ const cli=e.target.value==='cli';
1131
+ document.getElementById('apiKey').style.display=cli?'none':'';
1132
+ document.getElementById('model').style.display=cli?'none':'';
1133
+ document.getElementById('cliConfig').style.display=cli?'':'none';
1134
+ });
1135
+ ws.onmessage=e=>{
1136
+ const d=JSON.parse(e.data);
1137
+ if(d.type==='message'){addMsg(d.role,d.content);if(d.role==='user'){streamEl=addMsg('assistant','');streamEl.classList.add('typing');streamEl.innerHTML='<span class="dot"></span><span class="dot"></span><span class="dot"></span>';curResp=''}}
1138
+ else if(d.type==='stream_chunk'){if(!streamEl){streamEl=addMsg('assistant','');streamEl.classList.add('typing');streamEl.innerHTML='<span class="dot"></span><span class="dot"></span><span class="dot"></span>'}curResp+=d.data;const trimmed=curResp.trimStart();if(trimmed){if(streamEl.classList.contains('typing'))streamEl.classList.remove('typing');streamEl.innerHTML=renderMd(trimmed)}streamEl.scrollIntoView({block:'end'})}
1139
+ else if(d.type==='stream_end'){streamEl=null;curResp=''}
1140
+ else if(d.type==='configured'){document.getElementById('statusText').textContent='Configured: '+d.adapter.name+' ('+d.adapter.model+')'}
1141
+ else if(d.type==='peer_connected'){document.getElementById('dot').className='dot on';document.getElementById('statusText').textContent='Phone connected';document.getElementById('chatCard').style.display=''}
1142
+ else if(d.type==='peer_disconnected'){document.getElementById('dot').className='dot wait';document.getElementById('statusText').textContent='Phone disconnected'}
1143
+ };
1144
+ fetch('/api/status').then(r=>r.json()).then(d=>{
1145
+ if(d.pairingCode){document.getElementById('pairingCard').style.display='';document.getElementById('pairingCode').textContent=d.pairingCode;if(d.pairingQR)document.getElementById('qrCode').src=d.pairingQR}
1146
+ if(d.connected){document.getElementById('dot').className='dot on';document.getElementById('statusText').textContent='Phone connected';document.getElementById('chatCard').style.display=''}
1147
+ else{document.getElementById('dot').className='dot wait';document.getElementById('statusText').textContent='Waiting for phone...'}
1148
+ if(d.adapter)document.getElementById('statusText').textContent+=' | '+d.adapter.name;
1149
+ if(d.messages)d.messages.forEach(m=>addMsg(m.role,m.content));
1150
+ });
1151
+ async function configure(){
1152
+ const r=await fetch('/api/configure',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:document.getElementById('adapterType').value,apiKey:document.getElementById('apiKey').value,model:document.getElementById('model').value,command:document.getElementById('command').value,preset:document.getElementById('cliPreset').value})});
1153
+ const d=await r.json();if(d.error)alert(d.error);
1154
+ }
1155
+ async function sendMsg(){
1156
+ const el=document.getElementById('msgInput'),c=el.value.trim();if(!c)return;el.value='';
1157
+ await fetch('/api/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:c})});
1158
+ }
1159
+ function addMsg(role,content){const el=document.createElement('div');el.className='msg '+role;if(role==='user'||!content){el.textContent=content}else{el.innerHTML=renderMd(content)}document.getElementById('messages').appendChild(el);el.scrollIntoView({block:'end'});return el}
1160
+ </script>
1161
+ </body>
1162
+ </html>`;
1163
+
1164
+ // src/index.ts
1165
+ function parseArgs(argv) {
1166
+ const args = {};
1167
+ const rest = argv.slice(2);
1168
+ for (let i = 0; i < rest.length; i++) {
1169
+ const a = rest[i];
1170
+ if (a === "--help" || a === "-h") {
1171
+ args.help = true;
1172
+ } else if (a === "--cli" && i + 1 < rest.length) {
1173
+ args.cli = rest[++i];
1174
+ } else if (a === "--preset" && i + 1 < rest.length) {
1175
+ args.preset = rest[++i];
1176
+ } else if (a === "--port" && i + 1 < rest.length) {
1177
+ args.port = parseInt(rest[++i], 10);
1178
+ } else if (a.startsWith("--cli=")) {
1179
+ args.cli = a.slice(6);
1180
+ } else if (a.startsWith("--preset=")) {
1181
+ args.preset = a.slice(9);
1182
+ } else if (a.startsWith("--port=")) {
1183
+ args.port = parseInt(a.slice(7), 10);
1184
+ }
1185
+ }
1186
+ return args;
1187
+ }
1188
+ function printHelp() {
1189
+ const presetList = CLI_PRESETS.filter((p) => p.id !== "custom").map((p) => ` ${p.id.padEnd(14)} ${p.command}`).join("\n");
1190
+ console.log(`
1191
+ Airloom \u2014 Run AI on your computer, control it from your phone.
1192
+
1193
+ Usage:
1194
+ airloom [options]
1195
+
1196
+ Options:
1197
+ --cli <command> CLI command to use as the AI adapter.
1198
+ The user's message is appended as the last argument.
1199
+ Example: airloom --cli "devin -p --"
1200
+
1201
+ --preset <name> Use a built-in CLI preset instead of --cli.
1202
+ Available presets:
1203
+ ${presetList}
1204
+
1205
+ --port <number> Port for the host web UI (default: auto-select).
1206
+
1207
+ -h, --help Show this help message.
1208
+
1209
+ Environment variables:
1210
+ ANTHROPIC_API_KEY API key for the Anthropic adapter.
1211
+ OPENAI_API_KEY API key for the OpenAI adapter.
1212
+ ABLY_API_KEY Your own Ably key (overrides default community relay).
1213
+ RELAY_URL Self-hosted WebSocket relay URL (disables Ably).
1214
+ HOST_PORT Same as --port (CLI flag takes precedence).
1215
+ `.trimStart());
1216
+ }
1217
+ var cliArgs = parseArgs(process.argv);
1218
+ if (cliArgs.help) {
1219
+ printHelp();
1220
+ process.exit(0);
1221
+ }
1222
+ var DEFAULT_ABLY_KEY = "SfHSAQ.IRTOQQ:FBbi9a7ZV6jIu0Gdo_UeYhIN4rzpMrud5-LldURNh9s";
1223
+ var RELAY_URL = process.env.RELAY_URL;
1224
+ var ABLY_API_KEY = process.env.ABLY_API_KEY ?? (RELAY_URL ? void 0 : DEFAULT_ABLY_KEY);
1225
+ var ABLY_TOKEN_TTL = parseInt(process.env.ABLY_TOKEN_TTL ?? String(24 * 60 * 60 * 1e3), 10);
1226
+ var HOST_PORT = cliArgs.port ?? parseInt(process.env.HOST_PORT ?? "0", 10);
1227
+ var useAbly = !!ABLY_API_KEY;
1228
+ var isDefaultKey = useAbly && ABLY_API_KEY === DEFAULT_ABLY_KEY;
1229
+ var __dirname = dirname(fileURLToPath(import.meta.url));
1230
+ function getLanIP() {
1231
+ for (const ifaces of Object.values(networkInterfaces())) {
1232
+ for (const iface of ifaces ?? []) {
1233
+ if (iface.family === "IPv4" && !iface.internal) return iface.address;
1234
+ }
1235
+ }
1236
+ return void 0;
1237
+ }
1238
+ 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;
1243
+ return void 0;
1244
+ }
1245
+ async function main() {
1246
+ console.log("Airloom - Host");
1247
+ console.log("==============\n");
1248
+ if (useAbly) {
1249
+ if (isDefaultKey) {
1250
+ console.log("Transport: Ably (community relay \u2014 shared quota)");
1251
+ console.log(" Set ABLY_API_KEY for your own quota, or RELAY_URL for self-hosted.\n");
1252
+ } else {
1253
+ console.log("Transport: Ably (your key)");
1254
+ }
1255
+ } else {
1256
+ console.log(`Transport: WebSocket (self-hosted relay at ${RELAY_URL})`);
1257
+ }
1258
+ const session = createSession(useAbly ? "ably" : RELAY_URL);
1259
+ const displayCode = formatPairingCode(session.pairingCode);
1260
+ let pairingData;
1261
+ if (useAbly) {
1262
+ const { Rest } = await import("ably");
1263
+ const rest = new Rest({ key: ABLY_API_KEY });
1264
+ const channelName = `airloom:${session.sessionToken}`;
1265
+ const tokenDetails = await rest.auth.requestToken({
1266
+ clientId: "*",
1267
+ // viewer picks its own clientId
1268
+ capability: { [channelName]: ["publish", "subscribe", "presence"] },
1269
+ ttl: ABLY_TOKEN_TTL
1270
+ });
1271
+ console.log(`[ably] Scoped token issued (TTL: ${Math.round(ABLY_TOKEN_TTL / 6e4)}min, channel: ${channelName})`);
1272
+ pairingData = {
1273
+ ...session.pairingData,
1274
+ transport: "ably",
1275
+ token: tokenDetails.token
1276
+ };
1277
+ } else {
1278
+ pairingData = { ...session.pairingData };
1279
+ }
1280
+ const pairingJSON = encodePairingData(pairingData);
1281
+ const keyMaterial = sha2562(new TextEncoder().encode("airloom-key:" + session.sessionToken));
1282
+ const encryptionKey = deriveEncryptionKey(keyMaterial);
1283
+ let adapter;
1284
+ if (useAbly) {
1285
+ adapter = new AblyAdapter({ key: ABLY_API_KEY });
1286
+ } else {
1287
+ adapter = new WebSocketAdapter(RELAY_URL);
1288
+ }
1289
+ const channel = new Channel({
1290
+ adapter,
1291
+ role: "host",
1292
+ encryptionKey
1293
+ });
1294
+ await channel.connect(session.sessionToken);
1295
+ console.log("[host] Connected to relay, waiting for phone...");
1296
+ const state = {
1297
+ channel,
1298
+ adapter: null,
1299
+ pairingCode: displayCode,
1300
+ pairingQR: "",
1301
+ // set after server starts
1302
+ relayUrl: useAbly ? "ably" : RELAY_URL,
1303
+ connected: false,
1304
+ messages: []
1305
+ };
1306
+ if (cliArgs.cli || cliArgs.preset) {
1307
+ let command = cliArgs.cli;
1308
+ if (!command && cliArgs.preset) {
1309
+ const preset = CLI_PRESETS.find((p) => p.id === cliArgs.preset);
1310
+ if (!preset) {
1311
+ console.error(`[host] Unknown preset "${cliArgs.preset}". Available: ${CLI_PRESETS.map((p) => p.id).join(", ")}`);
1312
+ process.exit(1);
1313
+ }
1314
+ command = preset.command;
1315
+ }
1316
+ if (command) {
1317
+ state.adapter = new CLIAdapter({ command });
1318
+ console.log(`[host] CLI adapter: ${command}`);
1319
+ }
1320
+ } else {
1321
+ const saved = loadConfig();
1322
+ if (saved) {
1323
+ try {
1324
+ switch (saved.type) {
1325
+ case "anthropic": {
1326
+ const key = process.env.ANTHROPIC_API_KEY;
1327
+ if (key) {
1328
+ state.adapter = new AnthropicAdapter({ apiKey: key, model: saved.model });
1329
+ }
1330
+ break;
1331
+ }
1332
+ case "openai": {
1333
+ const key = process.env.OPENAI_API_KEY;
1334
+ if (key) {
1335
+ state.adapter = new OpenAIAdapter({ apiKey: key, model: saved.model });
1336
+ }
1337
+ break;
1338
+ }
1339
+ case "cli": {
1340
+ const cmd = saved.command || process.env.AIRLOOM_CLI_COMMAND;
1341
+ if (cmd) {
1342
+ state.adapter = new CLIAdapter({ command: cmd, model: saved.model });
1343
+ }
1344
+ break;
1345
+ }
1346
+ }
1347
+ if (state.adapter) {
1348
+ console.log(`[host] Auto-configured: ${state.adapter.name} (${state.adapter.model})`);
1349
+ console.log(` Loaded from ${getConfigPath()}`);
1350
+ }
1351
+ } catch (err) {
1352
+ console.error("[host] Auto-configure failed:", err.message);
1353
+ }
1354
+ }
1355
+ }
1356
+ const viewerDir = resolveViewerDir();
1357
+ if (viewerDir) {
1358
+ console.log(`[host] Viewer files: ${viewerDir}`);
1359
+ } else {
1360
+ console.log("[host] Viewer dist not found \u2014 QR will open raw JSON fallback");
1361
+ }
1362
+ 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
+ let qrContent;
1367
+ if (viewerDir) {
1368
+ const pairingBase64 = Buffer.from(pairingJSON).toString("base64url");
1369
+ qrContent = `${baseUrl}/viewer/#${pairingBase64}`;
1370
+ } else {
1371
+ qrContent = pairingJSON;
1372
+ }
1373
+ const qrDataUrl = await QRCode.toDataURL(qrContent, { width: 300, margin: 2 });
1374
+ const qrTerminal = await QRCode.toString(qrContent, { type: "terminal", small: true });
1375
+ state.pairingQR = qrDataUrl;
1376
+ console.log("\nPairing QR Code:");
1377
+ console.log(qrTerminal);
1378
+ console.log(`Pairing Code: ${displayCode}`);
1379
+ if (viewerDir && lanIP) {
1380
+ console.log(`Viewer URL: ${qrContent}`);
1381
+ }
1382
+ if (!useAbly) console.log(`Relay: ${RELAY_URL}`);
1383
+ const localUrl = `http://localhost:${port}`;
1384
+ console.log(`[host] Web UI at ${localUrl}
1385
+ `);
1386
+ import("child_process").then(({ exec }) => {
1387
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1388
+ exec(`${cmd} ${localUrl}`);
1389
+ }).catch(() => {
1390
+ });
1391
+ channel.on("ready", () => {
1392
+ console.log("[host] Phone connected! Channel ready.");
1393
+ state.connected = true;
1394
+ broadcast({ type: "peer_connected" });
1395
+ });
1396
+ channel.on("peer_left", () => {
1397
+ console.log("[host] Phone disconnected.");
1398
+ state.connected = false;
1399
+ broadcast({ type: "peer_disconnected" });
1400
+ });
1401
+ channel.on("message", (data) => {
1402
+ if (typeof data === "object" && data !== null && "type" in data && "content" in data) {
1403
+ const msg = data;
1404
+ if (msg.type === "chat" && typeof msg.content === "string") {
1405
+ console.log(`[phone] ${msg.content}`);
1406
+ state.messages.push({ role: "user", content: msg.content, timestamp: Date.now() });
1407
+ broadcast({ type: "message", role: "user", content: msg.content });
1408
+ if (state.adapter) {
1409
+ enqueueAIResponse(channel, state.adapter, state, broadcast);
1410
+ }
1411
+ }
1412
+ }
1413
+ });
1414
+ channel.on("error", (err) => console.error("[host] Channel error:", err.message));
1415
+ let shuttingDown = false;
1416
+ const shutdown = () => {
1417
+ if (shuttingDown) return;
1418
+ shuttingDown = true;
1419
+ console.log("\n[host] Shutting down...");
1420
+ try {
1421
+ channel.close();
1422
+ } catch {
1423
+ }
1424
+ server.close(() => process.exit(0));
1425
+ setTimeout(() => process.exit(0), 1e3).unref();
1426
+ };
1427
+ process.on("SIGINT", shutdown);
1428
+ process.on("SIGTERM", shutdown);
1429
+ }
1430
+ main().catch((err) => {
1431
+ console.error("Fatal error:", err);
1432
+ process.exit(1);
1433
+ });