lunel-cli 0.1.13 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +442 -276
  2. package/package.json +2 -3
package/dist/index.js CHANGED
@@ -1,216 +1,80 @@
1
1
  #!/usr/bin/env node
2
2
  import { WebSocket } from "ws";
3
3
  import qrcode from "qrcode-terminal";
4
+ import { createOpencode } from "@opencode-ai/sdk";
4
5
  import Ignore from "ignore";
5
6
  const ignore = Ignore.default;
6
7
  import * as fs from "fs/promises";
7
- import * as fsSync from "fs";
8
8
  import * as path from "path";
9
9
  import * as os from "os";
10
10
  import { spawn, execSync } from "child_process";
11
- import * as pty from "node-pty";
12
- import { createServer } from "net";
13
- import { fileURLToPath } from "url";
14
- const __filename = fileURLToPath(import.meta.url);
15
- const __dirname = path.dirname(__filename);
11
+ import { createServer, createConnection } from "net";
16
12
  const PROXY_URL = process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev";
17
- const VERSION = JSON.parse(fsSync.readFileSync(path.join(__dirname, "../package.json"), "utf-8")).version;
13
+ import { createRequire } from "module";
14
+ const __require = createRequire(import.meta.url);
15
+ const VERSION = __require("../package.json").version;
18
16
  // Root directory - sandbox all file operations to this
19
17
  const ROOT_DIR = process.cwd();
20
- // Simple ANSI parser for terminal state
21
- class AnsiParser {
22
- buffer;
23
- cursorX = 0;
24
- cursorY = 0;
25
- cols;
26
- rows;
27
- currentFg = '#ffffff';
28
- currentBg = '#000000';
29
- scrollbackBuffer = [];
30
- // ANSI color codes to hex
31
- colors = {
32
- 0: '#000000', 1: '#cc0000', 2: '#4e9a06', 3: '#c4a000',
33
- 4: '#3465a4', 5: '#75507b', 6: '#06989a', 7: '#d3d7cf',
34
- 8: '#555753', 9: '#ef2929', 10: '#8ae234', 11: '#fce94f',
35
- 12: '#729fcf', 13: '#ad7fa8', 14: '#34e2e2', 15: '#eeeeec',
36
- };
37
- constructor(cols, rows) {
38
- this.cols = cols;
39
- this.rows = rows;
40
- this.buffer = this.createEmptyBuffer();
41
- }
42
- createEmptyBuffer() {
43
- return Array.from({ length: this.rows }, () => Array.from({ length: this.cols }, () => ({
44
- char: ' ',
45
- fg: '#ffffff',
46
- bg: '#000000',
47
- })));
48
- }
49
- scrollUp() {
50
- // Move first line to scrollback
51
- this.scrollbackBuffer.push(this.buffer.shift());
52
- // Keep scrollback limited
53
- if (this.scrollbackBuffer.length > 1000) {
54
- this.scrollbackBuffer.shift();
55
- }
56
- // Add new empty line at bottom
57
- this.buffer.push(Array.from({ length: this.cols }, () => ({
58
- char: ' ',
59
- fg: '#ffffff',
60
- bg: '#000000',
61
- })));
62
- }
63
- write(data) {
64
- let i = 0;
65
- while (i < data.length) {
66
- const char = data[i];
67
- // Check for escape sequence
68
- if (char === '\x1b' && data[i + 1] === '[') {
69
- // Parse CSI sequence
70
- let j = i + 2;
71
- let params = '';
72
- while (j < data.length && /[0-9;]/.test(data[j])) {
73
- params += data[j];
74
- j++;
75
- }
76
- const cmd = data[j];
77
- i = j + 1;
78
- this.handleCSI(params, cmd);
79
- continue;
80
- }
81
- // Handle special characters
82
- if (char === '\n') {
83
- this.cursorY++;
84
- if (this.cursorY >= this.rows) {
85
- this.scrollUp();
86
- this.cursorY = this.rows - 1;
87
- }
88
- i++;
89
- continue;
90
- }
91
- if (char === '\r') {
92
- this.cursorX = 0;
93
- i++;
94
- continue;
95
- }
96
- if (char === '\b') {
97
- if (this.cursorX > 0)
98
- this.cursorX--;
99
- i++;
100
- continue;
101
- }
102
- if (char === '\t') {
103
- this.cursorX = Math.min(this.cols - 1, (Math.floor(this.cursorX / 8) + 1) * 8);
104
- i++;
105
- continue;
106
- }
107
- if (char === '\x07') { // Bell
108
- i++;
109
- continue;
110
- }
111
- // Skip other control characters
112
- if (char.charCodeAt(0) < 32) {
113
- i++;
114
- continue;
115
- }
116
- // Regular character
117
- if (this.cursorX >= this.cols) {
118
- this.cursorX = 0;
119
- this.cursorY++;
120
- if (this.cursorY >= this.rows) {
121
- this.scrollUp();
122
- this.cursorY = this.rows - 1;
123
- }
124
- }
125
- if (this.cursorY >= 0 && this.cursorY < this.rows &&
126
- this.cursorX >= 0 && this.cursorX < this.cols) {
127
- this.buffer[this.cursorY][this.cursorX] = {
128
- char,
129
- fg: this.currentFg,
130
- bg: this.currentBg,
131
- };
132
- }
133
- this.cursorX++;
134
- i++;
135
- }
136
- }
137
- handleCSI(params, cmd) {
138
- const args = params.split(';').map(p => parseInt(p) || 0);
139
- switch (cmd) {
140
- case 'A': // Cursor up
141
- this.cursorY = Math.max(0, this.cursorY - (args[0] || 1));
142
- break;
143
- case 'B': // Cursor down
144
- this.cursorY = Math.min(this.rows - 1, this.cursorY + (args[0] || 1));
145
- break;
146
- case 'C': // Cursor forward
147
- this.cursorX = Math.min(this.cols - 1, this.cursorX + (args[0] || 1));
148
- break;
149
- case 'D': // Cursor back
150
- this.cursorX = Math.max(0, this.cursorX - (args[0] || 1));
151
- break;
152
- case 'H': // Cursor position
153
- case 'f':
154
- this.cursorY = Math.min(this.rows - 1, Math.max(0, (args[0] || 1) - 1));
155
- this.cursorX = Math.min(this.cols - 1, Math.max(0, (args[1] || 1) - 1));
156
- break;
157
- case 'J': // Erase display
158
- if (args[0] === 2) {
159
- this.buffer = this.createEmptyBuffer();
160
- this.cursorX = 0;
161
- this.cursorY = 0;
162
- }
163
- break;
164
- case 'K': // Erase line
165
- for (let x = this.cursorX; x < this.cols; x++) {
166
- this.buffer[this.cursorY][x] = { char: ' ', fg: this.currentFg, bg: this.currentBg };
167
- }
168
- break;
169
- case 'm': // SGR - Select Graphic Rendition
170
- for (const arg of args) {
171
- if (arg === 0) {
172
- this.currentFg = '#ffffff';
173
- this.currentBg = '#000000';
174
- }
175
- else if (arg >= 30 && arg <= 37) {
176
- this.currentFg = this.colors[arg - 30] || '#ffffff';
177
- }
178
- else if (arg >= 40 && arg <= 47) {
179
- this.currentBg = this.colors[arg - 40] || '#000000';
180
- }
181
- else if (arg >= 90 && arg <= 97) {
182
- this.currentFg = this.colors[arg - 90 + 8] || '#ffffff';
183
- }
184
- else if (arg >= 100 && arg <= 107) {
185
- this.currentBg = this.colors[arg - 100 + 8] || '#000000';
186
- }
187
- }
188
- break;
189
- }
190
- }
191
- resize(cols, rows) {
192
- const newBuffer = Array.from({ length: rows }, (_, y) => Array.from({ length: cols }, (_, x) => (y < this.rows && x < this.cols) ? this.buffer[y][x] : { char: ' ', fg: '#ffffff', bg: '#000000' }));
193
- this.cols = cols;
194
- this.rows = rows;
195
- this.buffer = newBuffer;
196
- this.cursorX = Math.min(this.cursorX, cols - 1);
197
- this.cursorY = Math.min(this.cursorY, rows - 1);
198
- }
199
- getState() {
200
- return {
201
- buffer: this.buffer,
202
- cursorX: this.cursorX,
203
- cursorY: this.cursorY,
204
- cols: this.cols,
205
- rows: this.rows,
206
- };
207
- }
208
- }
18
+ // Terminal sessions
209
19
  const terminals = new Map();
210
20
  const processes = new Map();
211
21
  const processOutputBuffers = new Map();
212
22
  // CPU usage tracking
213
23
  let lastCpuInfo = null;
24
+ // OpenCode client
25
+ let opencodeClient = null;
26
+ // Proxy tunnel management
27
+ let currentSessionCode = null;
28
+ const activeTunnels = new Map();
29
+ // Popular development server ports to scan on connect
30
+ const DEV_PORTS = [
31
+ 1234, // Parcel
32
+ 1313, // Hugo
33
+ 2000, // Deno (alt)
34
+ 3000, // Next.js / CRA / Remix / Express
35
+ 3001, // Next.js (alt)
36
+ 3002, // Dev server (alt)
37
+ 3003, // Dev server (alt)
38
+ 3333, // AdonisJS / Nitro
39
+ 4000, // Gatsby / Redwood
40
+ 4200, // Angular CLI
41
+ 4321, // Astro
42
+ 4173, // Vite preview
43
+ 4444, // Selenium
44
+ 4567, // Sinatra
45
+ 5000, // Flask / Sails.js
46
+ 5001, // Flask (alt)
47
+ 5173, // Vite
48
+ 5174, // Vite (alt)
49
+ 5175, // Vite (alt)
50
+ 5500, // Live Server (VS Code)
51
+ 5555, // Prisma Studio
52
+ 6006, // Storybook
53
+ 7000, // Hapi
54
+ 7070, // Dev server
55
+ 7777, // Dev server
56
+ 8000, // Django / Laravel / Gatsby
57
+ 8001, // Django (alt)
58
+ 8008, // Dev server
59
+ 8010, // Dev server
60
+ 8080, // Vue CLI / Webpack Dev Server / Spring Boot
61
+ 8081, // Metro Bundler
62
+ 8082, // Dev server (alt)
63
+ 8100, // Ionic
64
+ 8200, // Vault
65
+ 8443, // HTTPS dev server
66
+ 8787, // Wrangler (Cloudflare Workers)
67
+ 8888, // Jupyter Notebook
68
+ 8899, // Dev server
69
+ 9000, // PHP / SonarQube
70
+ 9090, // Prometheus / Cockpit
71
+ 9200, // Elasticsearch
72
+ 9229, // Node.js debugger
73
+ 9292, // Rack (Ruby)
74
+ 10000, // Webmin
75
+ 19006, // Expo web
76
+ 24678, // Vite HMR WebSocket
77
+ ];
214
78
  // ============================================================================
215
79
  // Path Safety
216
80
  // ============================================================================
@@ -674,78 +538,37 @@ async function handleGitDiscard(payload) {
674
538
  // ============================================================================
675
539
  let dataChannel = null;
676
540
  function handleTerminalSpawn(payload) {
541
+ const shell = payload.shell || process.env.SHELL || "/bin/sh";
677
542
  const cols = payload.cols || 80;
678
543
  const rows = payload.rows || 24;
679
- // Find a working shell
680
- let shell = payload.shell || process.env.SHELL || '';
681
- const possibleShells = os.platform() === 'win32'
682
- ? ['powershell.exe', 'cmd.exe']
683
- : [shell, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
684
- shell = '';
685
- for (const candidate of possibleShells) {
686
- if (!candidate)
687
- continue;
688
- try {
689
- if (os.platform() !== 'win32') {
690
- execSync(`test -x "${candidate}"`, { stdio: 'ignore' });
691
- }
692
- shell = candidate;
693
- break;
694
- }
695
- catch {
696
- // Try next
697
- }
698
- }
699
- if (!shell) {
700
- throw Object.assign(new Error('No valid shell found'), { code: 'ENOSHELL' });
701
- }
702
544
  const terminalId = `term-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
703
- console.log(`[Terminal] Spawning PTY shell: ${shell} (${cols}x${rows}) in ${ROOT_DIR}`);
704
- // Create ANSI parser for terminal state
705
- const parser = new AnsiParser(cols, rows);
706
- // Spawn PTY with node-pty
707
- const ptyProcess = pty.spawn(shell, [], {
708
- name: 'xterm-256color',
709
- cols,
710
- rows,
545
+ const proc = spawn(shell, [], {
711
546
  cwd: ROOT_DIR,
712
- env: process.env,
713
- });
714
- console.log(`[Terminal] PTY spawned with PID: ${ptyProcess.pid}`);
715
- terminals.set(terminalId, {
716
- ptyProcess,
717
- shell,
718
- cols,
719
- rows,
720
- buffer: parser.getState().buffer,
721
- cursorX: 0,
722
- cursorY: 0,
723
- parser,
547
+ env: {
548
+ ...process.env,
549
+ TERM: "xterm-256color",
550
+ COLUMNS: cols.toString(),
551
+ LINES: rows.toString(),
552
+ },
553
+ stdio: ["pipe", "pipe", "pipe"],
724
554
  });
725
- // Process output and send grid state
726
- ptyProcess.onData((data) => {
727
- parser.write(data);
728
- const state = parser.getState();
555
+ terminals.set(terminalId, proc);
556
+ // Stream output to app via data channel
557
+ const sendOutput = (data) => {
729
558
  if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
730
559
  const msg = {
731
560
  v: 1,
732
561
  id: `evt-${Date.now()}`,
733
562
  ns: "terminal",
734
- action: "state",
735
- payload: {
736
- terminalId,
737
- buffer: state.buffer,
738
- cursorX: state.cursorX,
739
- cursorY: state.cursorY,
740
- cols: state.cols,
741
- rows: state.rows,
742
- },
563
+ action: "output",
564
+ payload: { terminalId, data: data.toString() },
743
565
  };
744
566
  dataChannel.send(JSON.stringify(msg));
745
567
  }
746
- });
747
- ptyProcess.onExit(({ exitCode }) => {
748
- console.log(`[Terminal] PTY exited with code: ${exitCode}`);
568
+ };
569
+ proc.stdout?.on("data", sendOutput);
570
+ proc.stderr?.on("data", sendOutput);
571
+ proc.on("close", (code) => {
749
572
  terminals.delete(terminalId);
750
573
  if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
751
574
  const msg = {
@@ -753,12 +576,12 @@ function handleTerminalSpawn(payload) {
753
576
  id: `evt-${Date.now()}`,
754
577
  ns: "terminal",
755
578
  action: "exit",
756
- payload: { terminalId, code: exitCode },
579
+ payload: { terminalId, code },
757
580
  };
758
581
  dataChannel.send(JSON.stringify(msg));
759
582
  }
760
583
  });
761
- return { terminalId, shell, cols, rows };
584
+ return { terminalId };
762
585
  }
763
586
  function handleTerminalWrite(payload) {
764
587
  const terminalId = payload.terminalId;
@@ -767,10 +590,10 @@ function handleTerminalWrite(payload) {
767
590
  throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
768
591
  if (typeof data !== "string")
769
592
  throw Object.assign(new Error("data is required"), { code: "EINVAL" });
770
- const session = terminals.get(terminalId);
771
- if (!session)
593
+ const proc = terminals.get(terminalId);
594
+ if (!proc)
772
595
  throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
773
- session.ptyProcess.write(data);
596
+ proc.stdin?.write(data);
774
597
  return {};
775
598
  }
776
599
  function handleTerminalResize(payload) {
@@ -779,27 +602,21 @@ function handleTerminalResize(payload) {
779
602
  const rows = payload.rows;
780
603
  if (!terminalId)
781
604
  throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
782
- if (!cols || !rows)
783
- throw Object.assign(new Error("cols and rows are required"), { code: "EINVAL" });
784
- const session = terminals.get(terminalId);
785
- if (!session)
605
+ const proc = terminals.get(terminalId);
606
+ if (!proc)
786
607
  throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
787
- // Resize PTY (sends SIGWINCH)
788
- session.ptyProcess.resize(cols, rows);
789
- // Resize parser buffer
790
- session.parser.resize(cols, rows);
791
- session.cols = cols;
792
- session.rows = rows;
608
+ // Note: For proper PTY resize, you'd need node-pty
609
+ // This is a simplified version
793
610
  return {};
794
611
  }
795
612
  function handleTerminalKill(payload) {
796
613
  const terminalId = payload.terminalId;
797
614
  if (!terminalId)
798
615
  throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
799
- const session = terminals.get(terminalId);
800
- if (!session)
616
+ const proc = terminals.get(terminalId);
617
+ if (!proc)
801
618
  throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
802
- session.ptyProcess.kill();
619
+ proc.kill();
803
620
  terminals.delete(terminalId);
804
621
  return {};
805
622
  }
@@ -809,7 +626,7 @@ function handleTerminalKill(payload) {
809
626
  function handleSystemCapabilities() {
810
627
  return {
811
628
  version: VERSION,
812
- namespaces: ["fs", "git", "terminal", "processes", "ports", "monitor", "http"],
629
+ namespaces: ["fs", "git", "terminal", "processes", "ports", "monitor", "http", "ai", "proxy"],
813
630
  platform: os.platform(),
814
631
  rootDir: ROOT_DIR,
815
632
  hostname: os.hostname(),
@@ -1273,6 +1090,267 @@ async function handleHttpRequest(payload) {
1273
1090
  }
1274
1091
  }
1275
1092
  // ============================================================================
1093
+ // AI Handlers (OpenCode SDK)
1094
+ // ============================================================================
1095
+ async function handleAiCreateSession(payload) {
1096
+ const title = payload.title || undefined;
1097
+ const response = await opencodeClient.session.create({ body: { title } });
1098
+ return { session: response.data };
1099
+ }
1100
+ async function handleAiListSessions() {
1101
+ const response = await opencodeClient.session.list();
1102
+ return { sessions: response.data };
1103
+ }
1104
+ async function handleAiGetSession(payload) {
1105
+ const id = payload.id;
1106
+ const response = await opencodeClient.session.get({ path: { id } });
1107
+ return { session: response.data };
1108
+ }
1109
+ async function handleAiDeleteSession(payload) {
1110
+ const id = payload.id;
1111
+ await opencodeClient.session.delete({ path: { id } });
1112
+ return {};
1113
+ }
1114
+ async function handleAiGetMessages(payload) {
1115
+ const id = payload.id;
1116
+ const response = await opencodeClient.session.messages({ path: { id } });
1117
+ return { messages: response.data };
1118
+ }
1119
+ async function handleAiPrompt(payload) {
1120
+ const sessionId = payload.sessionId;
1121
+ const text = payload.text;
1122
+ const model = payload.model;
1123
+ // Fire and forget — results stream via SSE events forwarded on data channel
1124
+ opencodeClient.session.prompt({
1125
+ path: { id: sessionId },
1126
+ body: {
1127
+ parts: [{ type: "text", text }],
1128
+ ...(model ? { model } : {}),
1129
+ },
1130
+ }).catch((err) => {
1131
+ if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
1132
+ dataChannel.send(JSON.stringify({
1133
+ v: 1,
1134
+ id: `evt-${Date.now()}`,
1135
+ ns: "ai",
1136
+ action: "event",
1137
+ payload: {
1138
+ type: "prompt_error",
1139
+ properties: { sessionId, error: err.message },
1140
+ },
1141
+ }));
1142
+ }
1143
+ });
1144
+ return { ack: true };
1145
+ }
1146
+ async function handleAiAbort(payload) {
1147
+ const id = payload.sessionId;
1148
+ await opencodeClient.session.abort({ path: { id } });
1149
+ return {};
1150
+ }
1151
+ async function handleAiAgents() {
1152
+ const response = await opencodeClient.app.agents();
1153
+ return { agents: response.data };
1154
+ }
1155
+ async function handleAiProviders() {
1156
+ const response = await opencodeClient.config.providers();
1157
+ return { providers: response.data };
1158
+ }
1159
+ async function handleAiSetAuth(payload) {
1160
+ const providerId = payload.providerId;
1161
+ const key = payload.key;
1162
+ await opencodeClient.auth.set({
1163
+ path: { id: providerId },
1164
+ body: { type: "api", key },
1165
+ });
1166
+ return {};
1167
+ }
1168
+ async function handleAiCommand(payload) {
1169
+ const sessionId = payload.sessionId;
1170
+ const command = payload.command;
1171
+ const args = payload.arguments || "";
1172
+ const response = await opencodeClient.session.command({
1173
+ path: { id: sessionId },
1174
+ body: { command, arguments: args },
1175
+ });
1176
+ return { result: response.data };
1177
+ }
1178
+ async function handleAiRevert(payload) {
1179
+ const sessionId = payload.sessionId;
1180
+ const messageId = payload.messageId;
1181
+ await opencodeClient.session.revert({
1182
+ path: { id: sessionId },
1183
+ body: { messageID: messageId },
1184
+ });
1185
+ return {};
1186
+ }
1187
+ async function handleAiUnrevert(payload) {
1188
+ const sessionId = payload.sessionId;
1189
+ await opencodeClient.session.unrevert({ path: { id: sessionId } });
1190
+ return {};
1191
+ }
1192
+ async function handleAiShare(payload) {
1193
+ const sessionId = payload.sessionId;
1194
+ const response = await opencodeClient.session.share({ path: { id: sessionId } });
1195
+ return { share: response.data };
1196
+ }
1197
+ async function handleAiPermissionReply(payload) {
1198
+ const permissionId = payload.permissionId;
1199
+ const sessionId = payload.sessionId;
1200
+ const approved = payload.approved;
1201
+ await opencodeClient.postSessionIdPermissionsPermissionId({
1202
+ path: { id: sessionId, permissionID: permissionId },
1203
+ body: { response: approved ? "once" : "reject" },
1204
+ });
1205
+ return {};
1206
+ }
1207
+ // SSE event forwarding from OpenCode to mobile app
1208
+ async function subscribeToOpenCodeEvents(client) {
1209
+ try {
1210
+ const events = await client.event.subscribe();
1211
+ for await (const event of events.stream) {
1212
+ if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
1213
+ const msg = {
1214
+ v: 1,
1215
+ id: `evt-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
1216
+ ns: "ai",
1217
+ action: "event",
1218
+ payload: {
1219
+ type: event.type,
1220
+ properties: event.properties,
1221
+ },
1222
+ };
1223
+ dataChannel.send(JSON.stringify(msg));
1224
+ }
1225
+ }
1226
+ }
1227
+ catch (err) {
1228
+ console.error("OpenCode event stream error:", err);
1229
+ setTimeout(() => subscribeToOpenCodeEvents(client), 3000);
1230
+ }
1231
+ }
1232
+ // Proxy Handlers
1233
+ // ============================================================================
1234
+ async function scanDevPorts() {
1235
+ const openPorts = [];
1236
+ const checks = DEV_PORTS.map((port) => {
1237
+ return new Promise((resolve) => {
1238
+ const socket = createConnection({ port, host: "127.0.0.1" });
1239
+ socket.setTimeout(200);
1240
+ socket.on("connect", () => {
1241
+ openPorts.push(port);
1242
+ socket.destroy();
1243
+ resolve();
1244
+ });
1245
+ socket.on("timeout", () => {
1246
+ socket.destroy();
1247
+ resolve();
1248
+ });
1249
+ socket.on("error", () => {
1250
+ resolve();
1251
+ });
1252
+ });
1253
+ });
1254
+ await Promise.all(checks);
1255
+ return openPorts.sort((a, b) => a - b);
1256
+ }
1257
+ async function handleProxyConnect(payload) {
1258
+ const tunnelId = payload.tunnelId;
1259
+ const port = payload.port;
1260
+ if (!tunnelId)
1261
+ throw Object.assign(new Error("tunnelId is required"), { code: "EINVAL" });
1262
+ if (!port)
1263
+ throw Object.assign(new Error("port is required"), { code: "EINVAL" });
1264
+ if (!currentSessionCode)
1265
+ throw Object.assign(new Error("no active session"), { code: "ENOENT" });
1266
+ // 1. Open TCP connection to the local service
1267
+ const tcpSocket = createConnection({ port, host: "127.0.0.1" });
1268
+ await new Promise((resolve, reject) => {
1269
+ const timeout = setTimeout(() => {
1270
+ tcpSocket.destroy();
1271
+ reject(Object.assign(new Error(`TCP connect timeout to localhost:${port}`), { code: "ETIMEOUT" }));
1272
+ }, 5000);
1273
+ tcpSocket.on("connect", () => {
1274
+ clearTimeout(timeout);
1275
+ resolve();
1276
+ });
1277
+ tcpSocket.on("error", (err) => {
1278
+ clearTimeout(timeout);
1279
+ reject(Object.assign(new Error(`TCP connect failed: ${err.message}`), { code: "ECONNREFUSED" }));
1280
+ });
1281
+ });
1282
+ // 2. Open proxy WebSocket to gateway
1283
+ const wsBase = PROXY_URL.replace(/^http/, "ws");
1284
+ const proxyWsUrl = `${wsBase}/v1/ws/proxy?code=${currentSessionCode}&tunnelId=${tunnelId}&role=cli`;
1285
+ const proxyWs = new WebSocket(proxyWsUrl);
1286
+ await new Promise((resolve, reject) => {
1287
+ const timeout = setTimeout(() => {
1288
+ proxyWs.close();
1289
+ tcpSocket.destroy();
1290
+ reject(Object.assign(new Error("Proxy WS connect timeout"), { code: "ETIMEOUT" }));
1291
+ }, 5000);
1292
+ proxyWs.on("open", () => {
1293
+ clearTimeout(timeout);
1294
+ resolve();
1295
+ });
1296
+ proxyWs.on("error", (err) => {
1297
+ clearTimeout(timeout);
1298
+ tcpSocket.destroy();
1299
+ reject(Object.assign(new Error(`Proxy WS failed: ${err.message}`), { code: "ECONNREFUSED" }));
1300
+ });
1301
+ });
1302
+ // 3. Store the tunnel
1303
+ activeTunnels.set(tunnelId, { tunnelId, port, tcpSocket, proxyWs });
1304
+ // 4. Pipe: TCP data -> proxy WS (as binary)
1305
+ tcpSocket.on("data", (chunk) => {
1306
+ if (proxyWs.readyState === WebSocket.OPEN) {
1307
+ proxyWs.send(chunk);
1308
+ }
1309
+ });
1310
+ // 5. Pipe: proxy WS -> TCP socket (as binary)
1311
+ proxyWs.on("message", (data) => {
1312
+ if (!tcpSocket.destroyed) {
1313
+ tcpSocket.write(data);
1314
+ }
1315
+ });
1316
+ // 6. Close cascade: TCP closes -> close WS
1317
+ tcpSocket.on("close", () => {
1318
+ activeTunnels.delete(tunnelId);
1319
+ if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
1320
+ proxyWs.close();
1321
+ }
1322
+ });
1323
+ tcpSocket.on("error", () => {
1324
+ activeTunnels.delete(tunnelId);
1325
+ if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
1326
+ proxyWs.close();
1327
+ }
1328
+ });
1329
+ // 7. Close cascade: WS closes -> close TCP
1330
+ proxyWs.on("close", () => {
1331
+ activeTunnels.delete(tunnelId);
1332
+ if (!tcpSocket.destroyed) {
1333
+ tcpSocket.destroy();
1334
+ }
1335
+ });
1336
+ proxyWs.on("error", () => {
1337
+ activeTunnels.delete(tunnelId);
1338
+ if (!tcpSocket.destroyed) {
1339
+ tcpSocket.destroy();
1340
+ }
1341
+ });
1342
+ return { tunnelId, port };
1343
+ }
1344
+ function cleanupAllTunnels() {
1345
+ for (const [, tunnel] of activeTunnels) {
1346
+ tunnel.tcpSocket.destroy();
1347
+ if (tunnel.proxyWs.readyState === WebSocket.OPEN) {
1348
+ tunnel.proxyWs.close();
1349
+ }
1350
+ }
1351
+ activeTunnels.clear();
1352
+ }
1353
+ // ============================================================================
1276
1354
  // Message Router
1277
1355
  // ============================================================================
1278
1356
  async function processMessage(message) {
@@ -1460,6 +1538,66 @@ async function processMessage(message) {
1460
1538
  throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
1461
1539
  }
1462
1540
  break;
1541
+ case "ai":
1542
+ switch (action) {
1543
+ case "prompt":
1544
+ result = await handleAiPrompt(payload);
1545
+ break;
1546
+ case "createSession":
1547
+ result = await handleAiCreateSession(payload);
1548
+ break;
1549
+ case "listSessions":
1550
+ result = await handleAiListSessions();
1551
+ break;
1552
+ case "getSession":
1553
+ result = await handleAiGetSession(payload);
1554
+ break;
1555
+ case "deleteSession":
1556
+ result = await handleAiDeleteSession(payload);
1557
+ break;
1558
+ case "getMessages":
1559
+ result = await handleAiGetMessages(payload);
1560
+ break;
1561
+ case "abort":
1562
+ result = await handleAiAbort(payload);
1563
+ break;
1564
+ case "agents":
1565
+ result = await handleAiAgents();
1566
+ break;
1567
+ case "providers":
1568
+ result = await handleAiProviders();
1569
+ break;
1570
+ case "setAuth":
1571
+ result = await handleAiSetAuth(payload);
1572
+ break;
1573
+ case "command":
1574
+ result = await handleAiCommand(payload);
1575
+ break;
1576
+ case "revert":
1577
+ result = await handleAiRevert(payload);
1578
+ break;
1579
+ case "unrevert":
1580
+ result = await handleAiUnrevert(payload);
1581
+ break;
1582
+ case "share":
1583
+ result = await handleAiShare(payload);
1584
+ break;
1585
+ case "permission":
1586
+ result = await handleAiPermissionReply(payload);
1587
+ break;
1588
+ default:
1589
+ throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
1590
+ }
1591
+ break;
1592
+ case "proxy":
1593
+ switch (action) {
1594
+ case "connect":
1595
+ result = await handleProxyConnect(payload);
1596
+ break;
1597
+ default:
1598
+ throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
1599
+ }
1600
+ break;
1463
1601
  default:
1464
1602
  throw Object.assign(new Error(`Unknown namespace: ${ns}`), { code: "EINVAL" });
1465
1603
  }
@@ -1506,6 +1644,7 @@ function displayQR(code) {
1506
1644
  });
1507
1645
  }
1508
1646
  function connectWebSocket(code) {
1647
+ currentSessionCode = code;
1509
1648
  const wsBase = PROXY_URL.replace(/^http/, "ws");
1510
1649
  const controlUrl = `${wsBase}/v1/ws/cli/control?code=${code}`;
1511
1650
  const dataUrl = `${wsBase}/v1/ws/cli/data?code=${code}`;
@@ -1537,6 +1676,23 @@ function connectWebSocket(code) {
1537
1676
  }
1538
1677
  if (message.type === "peer_connected") {
1539
1678
  console.log("App connected!\n");
1679
+ // Scan dev ports and notify app
1680
+ scanDevPorts().then((openPorts) => {
1681
+ if (openPorts.length > 0) {
1682
+ console.log(`Found ${openPorts.length} open dev port(s): ${openPorts.join(", ")}`);
1683
+ }
1684
+ if (controlWs.readyState === WebSocket.OPEN) {
1685
+ controlWs.send(JSON.stringify({
1686
+ v: 1,
1687
+ id: `evt-${Date.now()}`,
1688
+ ns: "proxy",
1689
+ action: "ports_discovered",
1690
+ payload: { ports: openPorts },
1691
+ }));
1692
+ }
1693
+ }).catch((err) => {
1694
+ console.error("Port scan failed:", err);
1695
+ });
1540
1696
  return;
1541
1697
  }
1542
1698
  if (message.type === "peer_disconnected") {
@@ -1555,6 +1711,7 @@ function connectWebSocket(code) {
1555
1711
  });
1556
1712
  controlWs.on("close", (code, reason) => {
1557
1713
  console.log(`\nControl channel disconnected (${code}: ${reason.toString()})`);
1714
+ cleanupAllTunnels();
1558
1715
  dataWs.close();
1559
1716
  process.exit(0);
1560
1717
  });
@@ -1585,6 +1742,7 @@ function connectWebSocket(code) {
1585
1742
  });
1586
1743
  dataWs.on("close", (code, reason) => {
1587
1744
  console.log(`\nData channel disconnected (${code}: ${reason.toString()})`);
1745
+ cleanupAllTunnels();
1588
1746
  controlWs.close();
1589
1747
  process.exit(0);
1590
1748
  });
@@ -1595,8 +1753,8 @@ function connectWebSocket(code) {
1595
1753
  process.on("SIGINT", () => {
1596
1754
  console.log("\nShutting down...");
1597
1755
  // Kill all terminals
1598
- for (const [id, session] of terminals) {
1599
- session.ptyProcess.kill();
1756
+ for (const [id, proc] of terminals) {
1757
+ proc.kill();
1600
1758
  }
1601
1759
  terminals.clear();
1602
1760
  // Kill all managed processes
@@ -1605,6 +1763,7 @@ function connectWebSocket(code) {
1605
1763
  }
1606
1764
  processes.clear();
1607
1765
  processOutputBuffers.clear();
1766
+ cleanupAllTunnels();
1608
1767
  controlWs.close();
1609
1768
  dataWs.close();
1610
1769
  process.exit(0);
@@ -1614,6 +1773,13 @@ async function main() {
1614
1773
  console.log("Lunel CLI v" + VERSION);
1615
1774
  console.log("=".repeat(20) + "\n");
1616
1775
  try {
1776
+ // Start OpenCode server + client
1777
+ console.log("Starting OpenCode...");
1778
+ const { client } = await createOpencode();
1779
+ opencodeClient = client;
1780
+ console.log("OpenCode ready.\n");
1781
+ // Subscribe to OpenCode events
1782
+ subscribeToOpenCodeEvents(client);
1617
1783
  const code = await createSession();
1618
1784
  displayQR(code);
1619
1785
  connectWebSocket(code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lunel-cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",
@@ -25,9 +25,8 @@
25
25
  "prepublishOnly": "npm run build"
26
26
  },
27
27
  "dependencies": {
28
- "@xterm/headless": "^5.5.0",
28
+ "@opencode-ai/sdk": "^1.1.56",
29
29
  "ignore": "^6.0.2",
30
- "node-pty": "^1.1.0",
31
30
  "qrcode-terminal": "^0.12.0",
32
31
  "ws": "^8.18.0"
33
32
  },