aigo 1.0.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.
package/lib/server.js ADDED
@@ -0,0 +1,284 @@
1
+ import express from 'express';
2
+ import { createServer } from 'http';
3
+ import { WebSocketServer } from 'ws';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import pty from 'node-pty';
7
+ import { getTmuxPath, killSession } from './tmux.js';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+
12
+ /**
13
+ * Start the web server with WebSocket support
14
+ */
15
+ export function startServer({ port, token, password, session, lockTimeout, exitTimeout }) {
16
+ return new Promise((resolve, reject) => {
17
+ const app = express();
18
+ const server = createServer(app);
19
+
20
+ // Track last activity for exit timeout
21
+ let lastActivityTime = Date.now();
22
+ let exitTimer = null;
23
+ let hasActiveConnection = false;
24
+
25
+ // Update activity timestamp
26
+ const updateActivity = () => {
27
+ lastActivityTime = Date.now();
28
+ };
29
+
30
+ // Start exit timeout checker
31
+ const startExitTimer = () => {
32
+ if (exitTimeout <= 0 || exitTimer) return;
33
+
34
+ exitTimer = setInterval(() => {
35
+ const inactiveMinutes = (Date.now() - lastActivityTime) / 1000 / 60;
36
+
37
+ if (inactiveMinutes >= exitTimeout) {
38
+ console.log(`\nSession inactive for ${exitTimeout} minutes. Exiting...`);
39
+
40
+ // Kill tmux session
41
+ killSession(session);
42
+ console.log(`Killed tmux session: ${session}`);
43
+
44
+ // Notify all connected clients
45
+ wss.clients.forEach(client => {
46
+ if (client.readyState === client.OPEN) {
47
+ client.send(JSON.stringify({ type: 'session_expired' }));
48
+ client.close(1000, 'Session expired due to inactivity');
49
+ }
50
+ });
51
+
52
+ // Stop timer and exit
53
+ clearInterval(exitTimer);
54
+ server.close();
55
+ process.exit(0);
56
+ }
57
+ }, 60000); // Check every minute
58
+ };
59
+
60
+ // Serve static files from public directory
61
+ const publicPath = path.join(__dirname, '..', 'public');
62
+ app.use('/static', express.static(publicPath));
63
+
64
+ // Token validation middleware
65
+ const validateToken = (req, res, next) => {
66
+ const urlToken = req.params.token;
67
+ if (urlToken !== token) {
68
+ return res.status(403).send('Forbidden: Invalid token');
69
+ }
70
+ next();
71
+ };
72
+
73
+ // Serve terminal page at /t/:token
74
+ app.get('/t/:token', validateToken, (req, res) => {
75
+ res.sendFile(path.join(publicPath, 'index.html'));
76
+ });
77
+
78
+ // Health check endpoint
79
+ app.get('/health', (req, res) => {
80
+ res.json({ status: 'ok' });
81
+ });
82
+
83
+ // WebSocket server
84
+ const wss = new WebSocketServer({ server, path: `/ws/${token}` });
85
+
86
+ wss.on('connection', (ws) => {
87
+ console.log('WebSocket client connected');
88
+ hasActiveConnection = true;
89
+ updateActivity();
90
+
91
+ // Start exit timer on first connection
92
+ startExitTimer();
93
+
94
+ let ptyProcess = null;
95
+ let authenticated = false;
96
+
97
+ // Send auth request
98
+ ws.send(JSON.stringify({ type: 'auth_required' }));
99
+
100
+ // Handle incoming WebSocket messages
101
+ ws.on('message', (data) => {
102
+ const message = data.toString();
103
+
104
+ // Handle authentication
105
+ if (!authenticated) {
106
+ try {
107
+ const parsed = JSON.parse(message);
108
+ if (parsed.type === 'auth' && parsed.password) {
109
+ if (parsed.password === password) {
110
+ authenticated = true;
111
+ updateActivity();
112
+ console.log('Client authenticated successfully');
113
+
114
+ // Send auth success with config
115
+ ws.send(JSON.stringify({
116
+ type: 'auth_success',
117
+ config: {
118
+ lockTimeout: lockTimeout, // minutes
119
+ exitTimeout: exitTimeout // minutes
120
+ }
121
+ }));
122
+
123
+ // Now spawn the PTY
124
+ try {
125
+ const tmuxBin = getTmuxPath() || 'tmux';
126
+ console.log(`Spawning PTY: ${tmuxBin} attach-session -t ${session}`);
127
+
128
+ ptyProcess = pty.spawn(tmuxBin, ['attach-session', '-t', session], {
129
+ name: 'xterm-256color',
130
+ cols: parsed.cols || 80,
131
+ rows: parsed.rows || 24,
132
+ cwd: process.env.HOME,
133
+ env: {
134
+ ...process.env,
135
+ TERM: 'xterm-256color'
136
+ }
137
+ });
138
+
139
+ // Pipe PTY output to WebSocket
140
+ ptyProcess.onData((ptyData) => {
141
+ try {
142
+ if (ws.readyState === ws.OPEN) {
143
+ ws.send(ptyData);
144
+ }
145
+ } catch (err) {
146
+ console.error('Error sending to WebSocket:', err);
147
+ }
148
+ });
149
+
150
+ ptyProcess.onExit(({ exitCode, signal }) => {
151
+ console.log(`PTY exited with code ${exitCode}, signal ${signal}`);
152
+ if (ws.readyState === ws.OPEN) {
153
+ ws.close();
154
+ }
155
+ });
156
+ } catch (err) {
157
+ console.error('Failed to spawn PTY:', err.message);
158
+ ws.send(`\r\n\x1b[31mError: Failed to attach to tmux session '${session}'\x1b[0m\r\n`);
159
+ ws.close(1011, 'PTY spawn failed');
160
+ }
161
+ } else {
162
+ console.log('Client authentication failed');
163
+ ws.send(JSON.stringify({ type: 'auth_failed' }));
164
+ }
165
+ return;
166
+ }
167
+ } catch {
168
+ // Not JSON or not auth message
169
+ }
170
+ return; // Ignore all messages until authenticated
171
+ }
172
+
173
+ // Already authenticated - handle messages
174
+ try {
175
+ const parsed = JSON.parse(message);
176
+
177
+ // Handle activity ping (resets server-side timer)
178
+ if (parsed.type === 'activity') {
179
+ updateActivity();
180
+ return;
181
+ }
182
+
183
+ // Handle resize command
184
+ if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
185
+ updateActivity();
186
+ if (ptyProcess) {
187
+ ptyProcess.resize(parsed.cols, parsed.rows);
188
+ }
189
+ return;
190
+ }
191
+
192
+ // Handle re-auth (after lock screen)
193
+ if (parsed.type === 're-auth' && parsed.password) {
194
+ if (parsed.password === password) {
195
+ updateActivity();
196
+ ws.send(JSON.stringify({ type: 'auth_success' }));
197
+ } else {
198
+ ws.send(JSON.stringify({ type: 'auth_failed' }));
199
+ }
200
+ return;
201
+ }
202
+
203
+ // Handle full cleanup request
204
+ if (parsed.type === 'cleanup') {
205
+ console.log('\nFull cleanup requested from web client...');
206
+
207
+ // Kill PTY first
208
+ if (ptyProcess) {
209
+ ptyProcess.kill();
210
+ }
211
+
212
+ // Kill tmux session
213
+ killSession(session);
214
+ console.log(`Killed tmux session: ${session}`);
215
+
216
+ // Notify client
217
+ ws.send(JSON.stringify({ type: 'cleanup_complete' }));
218
+
219
+ // Close all WebSocket connections
220
+ wss.clients.forEach(client => {
221
+ if (client.readyState === client.OPEN) {
222
+ client.close(1000, 'Cleanup requested');
223
+ }
224
+ });
225
+
226
+ // Stop exit timer
227
+ if (exitTimer) clearInterval(exitTimer);
228
+
229
+ // Give time for cleanup message to send, then exit
230
+ setTimeout(() => {
231
+ console.log('Server shutting down...');
232
+ server.close();
233
+ process.exit(0);
234
+ }, 500);
235
+ return;
236
+ }
237
+ } catch {
238
+ // Not JSON - treat as terminal input
239
+ }
240
+
241
+ // Terminal input
242
+ if (ptyProcess) {
243
+ updateActivity();
244
+ ptyProcess.write(message);
245
+ }
246
+ });
247
+
248
+ ws.on('close', () => {
249
+ console.log('WebSocket client disconnected');
250
+ if (ptyProcess) {
251
+ ptyProcess.kill();
252
+ }
253
+
254
+ // Check if any connections remain
255
+ hasActiveConnection = wss.clients.size > 0;
256
+ });
257
+
258
+ ws.on('error', (err) => {
259
+ console.error('WebSocket error:', err);
260
+ if (ptyProcess) {
261
+ ptyProcess.kill();
262
+ }
263
+ });
264
+ });
265
+
266
+ // Handle upgrade requests - only allow valid token
267
+ server.on('upgrade', (request, socket, head) => {
268
+ const expectedPath = `/ws/${token}`;
269
+ if (request.url !== expectedPath) {
270
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
271
+ socket.destroy();
272
+ return;
273
+ }
274
+ });
275
+
276
+ server.listen(port, () => {
277
+ resolve(server);
278
+ });
279
+
280
+ server.on('error', (err) => {
281
+ reject(err);
282
+ });
283
+ });
284
+ }
package/lib/tmux.js ADDED
@@ -0,0 +1,86 @@
1
+ import { execSync, spawn } from 'child_process';
2
+
3
+ // Cache the tmux path
4
+ let tmuxPath = null;
5
+
6
+ /**
7
+ * Get the full path to tmux binary
8
+ */
9
+ export function getTmuxPath() {
10
+ if (tmuxPath) return tmuxPath;
11
+
12
+ try {
13
+ tmuxPath = execSync('which tmux', { encoding: 'utf-8' }).trim();
14
+ return tmuxPath;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Check if tmux is installed
22
+ */
23
+ export function isTmuxInstalled() {
24
+ return getTmuxPath() !== null;
25
+ }
26
+
27
+ /**
28
+ * Check if a tmux session exists
29
+ */
30
+ export function hasSession(name) {
31
+ const tmuxBin = getTmuxPath() || 'tmux';
32
+ try {
33
+ execSync(`${tmuxBin} has-session -t ${name} 2>/dev/null`);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Create a new tmux session running a command with the given args
42
+ * @param {string} name - tmux session name
43
+ * @param {string} command - command to run (e.g., 'claude', 'agent')
44
+ * @param {string[]} args - arguments to pass to the command
45
+ */
46
+ export function createSession(name, command, args = []) {
47
+ const tmuxBin = getTmuxPath() || 'tmux';
48
+ const cmd = [command, ...args].join(' ');
49
+
50
+ try {
51
+ // Create detached tmux session running the command
52
+ execSync(`${tmuxBin} new-session -d -s ${name} '${cmd}'`, {
53
+ stdio: 'inherit'
54
+ });
55
+ } catch (err) {
56
+ throw new Error(`Failed to create tmux session: ${err.message}`);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Kill a tmux session
62
+ */
63
+ export function killSession(name) {
64
+ const tmuxBin = getTmuxPath() || 'tmux';
65
+ try {
66
+ execSync(`${tmuxBin} kill-session -t ${name} 2>/dev/null`);
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * List all tmux sessions
75
+ */
76
+ export function listSessions() {
77
+ const tmuxBin = getTmuxPath() || 'tmux';
78
+ try {
79
+ const output = execSync(`${tmuxBin} list-sessions -F "#{session_name}"`, {
80
+ encoding: 'utf-8'
81
+ });
82
+ return output.trim().split('\n').filter(Boolean);
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
package/lib/tunnel.js ADDED
@@ -0,0 +1,150 @@
1
+ import { spawn, execSync } from 'child_process';
2
+
3
+ /**
4
+ * Check if ngrok is already running
5
+ * Returns { running: boolean, pids: number[] }
6
+ */
7
+ export function checkNgrokRunning() {
8
+ try {
9
+ let output;
10
+ if (process.platform === 'win32') {
11
+ output = execSync('tasklist /FI "IMAGENAME eq ngrok.exe" /FO CSV /NH 2>nul', { encoding: 'utf8' });
12
+ const lines = output.trim().split('\n').filter(l => l.includes('ngrok.exe'));
13
+ if (lines.length > 0) {
14
+ // Extract PIDs from CSV format: "ngrok.exe","1234",...
15
+ const pids = lines.map(l => {
16
+ const match = l.match(/"ngrok\.exe","(\d+)"/);
17
+ return match ? parseInt(match[1], 10) : null;
18
+ }).filter(Boolean);
19
+ return { running: true, pids };
20
+ }
21
+ } else {
22
+ output = execSync('pgrep -f "ngrok http" 2>/dev/null || true', { encoding: 'utf8' });
23
+ const pids = output.trim().split('\n').filter(Boolean).map(p => parseInt(p, 10));
24
+ if (pids.length > 0) {
25
+ return { running: true, pids };
26
+ }
27
+ }
28
+ } catch (e) {
29
+ // pgrep returns non-zero if no process found, which is fine
30
+ }
31
+ return { running: false, pids: [] };
32
+ }
33
+
34
+ /**
35
+ * Start a tunnel (ngrok or cloudflared) and return the public URL
36
+ */
37
+ export async function startTunnel(type, port) {
38
+ if (type === 'ngrok') {
39
+ return startNgrok(port);
40
+ } else if (type === 'cloudflared') {
41
+ return startCloudflared(port);
42
+ } else {
43
+ throw new Error(`Unknown tunnel type: ${type}. Use 'ngrok' or 'cloudflared'`);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Start ngrok and extract the public URL
49
+ */
50
+ function startNgrok(port) {
51
+ return new Promise((resolve, reject) => {
52
+ const proc = spawn('ngrok', ['http', port.toString(), '--log', 'stdout'], {
53
+ stdio: ['ignore', 'pipe', 'pipe']
54
+ });
55
+
56
+ let output = '';
57
+ let resolved = false;
58
+
59
+ proc.stdout.on('data', (data) => {
60
+ output += data.toString();
61
+
62
+ // Look for the URL in ngrok output
63
+ // Format: url=https://xxxx.ngrok.io or url=https://xxxx.ngrok-free.app
64
+ const urlMatch = output.match(/url=(https:\/\/[^\s]+)/);
65
+ if (urlMatch && !resolved) {
66
+ resolved = true;
67
+ resolve({
68
+ url: urlMatch[1],
69
+ kill: () => proc.kill()
70
+ });
71
+ }
72
+ });
73
+
74
+ proc.stderr.on('data', (data) => {
75
+ output += data.toString();
76
+ });
77
+
78
+ proc.on('error', (err) => {
79
+ if (!resolved) {
80
+ reject(new Error(`Failed to start ngrok: ${err.message}. Is ngrok installed?`));
81
+ }
82
+ });
83
+
84
+ proc.on('exit', (code) => {
85
+ if (!resolved) {
86
+ reject(new Error(`ngrok exited with code ${code}. Output: ${output}`));
87
+ }
88
+ });
89
+
90
+ // Timeout after 30 seconds
91
+ setTimeout(() => {
92
+ if (!resolved) {
93
+ proc.kill();
94
+ reject(new Error('Timeout waiting for ngrok URL'));
95
+ }
96
+ }, 30000);
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Start cloudflared and extract the public URL
102
+ */
103
+ function startCloudflared(port) {
104
+ return new Promise((resolve, reject) => {
105
+ const proc = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
106
+ stdio: ['ignore', 'pipe', 'pipe']
107
+ });
108
+
109
+ let output = '';
110
+ let resolved = false;
111
+
112
+ const handleOutput = (data) => {
113
+ output += data.toString();
114
+
115
+ // Look for the URL in cloudflared output
116
+ // Format: https://xxxx.trycloudflare.com
117
+ const urlMatch = output.match(/(https:\/\/[^\s]+\.trycloudflare\.com)/);
118
+ if (urlMatch && !resolved) {
119
+ resolved = true;
120
+ resolve({
121
+ url: urlMatch[1],
122
+ kill: () => proc.kill()
123
+ });
124
+ }
125
+ };
126
+
127
+ proc.stdout.on('data', handleOutput);
128
+ proc.stderr.on('data', handleOutput); // cloudflared outputs to stderr
129
+
130
+ proc.on('error', (err) => {
131
+ if (!resolved) {
132
+ reject(new Error(`Failed to start cloudflared: ${err.message}. Is cloudflared installed?`));
133
+ }
134
+ });
135
+
136
+ proc.on('exit', (code) => {
137
+ if (!resolved) {
138
+ reject(new Error(`cloudflared exited with code ${code}. Output: ${output}`));
139
+ }
140
+ });
141
+
142
+ // Timeout after 30 seconds
143
+ setTimeout(() => {
144
+ if (!resolved) {
145
+ proc.kill();
146
+ reject(new Error('Timeout waiting for cloudflared URL'));
147
+ }
148
+ }, 30000);
149
+ });
150
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "aigo",
3
+ "version": "1.0.0",
4
+ "description": "Stream Claude Code to the web - run Claude remotely from any device",
5
+ "type": "module",
6
+ "bin": {
7
+ "aigo": "./bin/vigo.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/vigo.js",
11
+ "postinstall": "chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null || true"
12
+ },
13
+ "keywords": [
14
+ "claude",
15
+ "terminal",
16
+ "web",
17
+ "tmux",
18
+ "remote"
19
+ ],
20
+ "author": "wakandan221",
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "express": "^4.21.0",
24
+ "ws": "^8.18.0",
25
+ "node-pty": "^1.0.0"
26
+ }
27
+ }