ai-or-die 0.1.83 → 0.1.84

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/bin/ai-or-die.js CHANGED
@@ -36,6 +36,7 @@ program
36
36
  .option('--terminal-alias <name>', 'display alias for Terminal (default: env TERMINAL_ALIAS or "Terminal")')
37
37
  .option('--tunnel', 'enable dev tunnel (requires devtunnel CLI installed)')
38
38
  .option('--tunnel-allow-anonymous', 'allow anonymous access to dev tunnel')
39
+ .option('--mesh', 'expose this instance over a permanent Tailscale mesh (userspace; requires tailscale installed; set AIORDIE_TS_AUTHKEY to enroll)')
39
40
  .option('--no-stt', 'disable local speech-to-text (on by default; downloads ~670MB Parakeet V3 model on first use)')
40
41
  .option('--stt-endpoint <url>', 'use external STT endpoint (OpenAI-compatible)')
41
42
  .option('--stt-model-dir <path>', 'custom directory for STT model files')
@@ -88,9 +89,10 @@ async function main() {
88
89
  }
89
90
 
90
91
  // Handle authentication logic
91
- // Tunnel mode disables auth — the tunnel itself controls access
92
+ // Tunnel mode disables auth — the tunnel controls access. Mesh KEEPS auth
93
+ // on (ACL + Bearer layered); --mesh wins over --tunnel here.
92
94
  let authToken = null;
93
- let noAuth = options.disableAuth === true || options.tunnel === true;
95
+ let noAuth = options.disableAuth === true || (options.tunnel === true && !options.mesh);
94
96
 
95
97
  if (!noAuth) {
96
98
  if (options.auth) {
@@ -131,6 +133,9 @@ async function main() {
131
133
  // the display on.
132
134
  keepalive: options.keepalive !== false && process.env.AIORDIE_DISABLE_KEEPALIVE !== '1',
133
135
  keepaliveDisplay: options.keepaliveDisplay === true || process.env.AIORDIE_KEEPALIVE_DISPLAY === '1',
136
+ // Mesh binds loopback-only always: the tailnet `serve` proxy reaches the
137
+ // port, the LAN never does (even with --https). Other modes: all interfaces.
138
+ bindHost: options.mesh ? '127.0.0.1' : undefined,
134
139
  };
135
140
 
136
141
  console.log('Starting ai-or-die...');
@@ -193,6 +198,22 @@ async function main() {
193
198
  }
194
199
  }
195
200
 
201
+ // Mesh: permanent Tailscale reachability. Coexists with --tunnel (mesh for
202
+ // owned devices, tunnel fallback for borrowed ones). Auth token stays ON.
203
+ if (options.mesh) {
204
+ const { MeshManager } = require('../src/mesh-manager');
205
+ const mesh = new MeshManager({
206
+ port,
207
+ dev: options.dev,
208
+ onUrl: (meshUrl) => {
209
+ const t = authToken ? `${meshUrl}?token=${authToken}` : meshUrl;
210
+ console.log(`\n \x1b[1m\x1b[32mMesh ready:\x1b[0m \x1b[1m\x1b[4m${t}\x1b[0m\n`);
211
+ },
212
+ });
213
+ app.setMeshManager(mesh);
214
+ await mesh.start();
215
+ }
216
+
196
217
  console.log('\nPress Ctrl+C to stop the server\n');
197
218
 
198
219
  // Shutdown is owned by the server's single SIGINT/SIGTERM handler
package/mesh/go.mod ADDED
@@ -0,0 +1,5 @@
1
+ module aiordie-mesh
2
+
3
+ go 1.23
4
+
5
+ require tailscale.com v1.80.0
package/mesh/main.go ADDED
@@ -0,0 +1,65 @@
1
+ // Command aiordie-mesh is the userspace mesh sidecar for ai-or-die. It joins a
2
+ // Tailscale tailnet entirely in userspace via tsnet (no kernel TUN, no admin,
3
+ // no system service) and reverse-proxies the tailnet listener to ai-or-die's
4
+ // local HTTP/WebSocket port. Only this one port is exposed on the mesh; the
5
+ // rest of the machine never joins. Enrollment is one-time via TS_AUTHKEY; the
6
+ // node identity persists in --statedir so the key is never needed again.
7
+ //
8
+ // stdout protocol (parsed by src/mesh-manager.js):
9
+ // MESH-URL https://<name> node is up and serving
10
+ // MESH-NEEDLOGIN <url> no/!valid key — operator must enroll
11
+ // MESH-ERR <msg> fatal
12
+ package main
13
+
14
+ import (
15
+ "context"
16
+ "flag"
17
+ "fmt"
18
+ "net/http"
19
+ "net/http/httputil"
20
+ "net/url"
21
+ "os"
22
+ "strings"
23
+ "time"
24
+
25
+ "tailscale.com/tsnet"
26
+ )
27
+
28
+ func main() {
29
+ port := flag.String("port", "7777", "local ai-or-die port to expose")
30
+ host := flag.String("hostname", "aiordie", "tailnet hostname")
31
+ dir := flag.String("statedir", "./ts-state", "persistent node state dir")
32
+ flag.Parse()
33
+
34
+ s := &tsnet.Server{Dir: *dir, Hostname: *host}
35
+ // Surface the login URL exactly once instead of tsnet's default stderr spam.
36
+ s.AuthKey = os.Getenv("TS_AUTHKEY")
37
+ defer s.Close()
38
+
39
+ // Bring the node up; report the enroll URL if it needs login.
40
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
41
+ defer cancel()
42
+ if _, err := s.Up(ctx); err != nil {
43
+ // Up blocks until authed; on timeout it's almost always missing/!key.
44
+ fmt.Printf("MESH-NEEDLOGIN https://login.tailscale.com/admin/settings/keys\n")
45
+ fmt.Printf("MESH-ERR %v\n", err)
46
+ os.Exit(1)
47
+ }
48
+
49
+ ln, err := s.Listen("tcp", ":80")
50
+ if err != nil {
51
+ fmt.Printf("MESH-ERR %v\n", err)
52
+ os.Exit(1)
53
+ }
54
+ target, _ := url.Parse("http://127.0.0.1:" + *port)
55
+ rp := httputil.NewSingleHostReverseProxy(target) // handles WS upgrade via hijack
56
+
57
+ st, _ := s.Up(ctx)
58
+ name := *host
59
+ if st != nil && st.Self != nil && st.Self.DNSName != "" {
60
+ name = strings.TrimSuffix(st.Self.DNSName, ".")
61
+ }
62
+ fmt.Printf("MESH-URL https://%s\n", name)
63
+
64
+ _ = http.Serve(ln, rp)
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.83",
3
+ "version": "0.1.84",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ // MeshManager — permanent reachability over a Tailscale tailnet via the
4
+ // aiordie-mesh sidecar (tsnet in USERSPACE: no kernel TUN, no admin, no system
5
+ // service). Only ai-or-die's own port joins the mesh; the rest of the machine
6
+ // stays off it. Mirrors TunnelManager's lifecycle (detect → start → backoff →
7
+ // stop) so bin/ai-or-die.js supervises it like the dev tunnel. Verified E2E:
8
+ // the sidecar enrolls and reverse-proxies the local port to <host>.ts.net.
9
+
10
+ const { spawn } = require('child_process');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+
15
+ const URL_TIMEOUT_MS = 60000; // enroll + name can take ~30s on first run
16
+ const STABILITY_THRESHOLD_MS = 60000; // 60s up = reset retry budget
17
+ const MIN_RESTART_DELAY_MS = 1000;
18
+ const MAX_RESTART_DELAY_MS = 30000;
19
+ const MAX_RETRIES = 10;
20
+
21
+ class MeshManager {
22
+ constructor(options = {}) {
23
+ this.port = options.port || 7777; // the ai-or-die port to expose
24
+ this.dev = options.dev || false;
25
+ this.onUrl = options.onUrl || (() => {});
26
+ // Consume the key once and scrub it everywhere it could leak.
27
+ this._authKey = options.authKey || process.env.AIORDIE_TS_AUTHKEY || null;
28
+ delete process.env.AIORDIE_TS_AUTHKEY;
29
+ this._childEnv = { ...process.env };
30
+ delete this._childEnv.AIORDIE_TS_AUTHKEY;
31
+
32
+ const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
33
+ const base = process.platform === 'win32'
34
+ ? path.join(localApp, 'ai-or-die')
35
+ : path.join(os.homedir(), '.ai-or-die');
36
+ this.stateDir = options.stateDir || path.join(base, 'ts-state');
37
+ const exe = process.platform === 'win32' ? 'aiordie-mesh.exe' : 'aiordie-mesh';
38
+ this.sidecar = options.sidecar || path.join(base, 'bin', exe);
39
+ this.hostname = (options.hostname || `aiordie-${os.hostname()}`).toLowerCase().replace(/[^a-z0-9-]/g, '');
40
+
41
+ this.proc = null;
42
+ this.dnsName = null;
43
+ this.stopping = false;
44
+ this.retryCount = 0;
45
+ this._totalRestarts = 0;
46
+ this._stabilityTimer = null;
47
+ this._restartDelayTimer = null;
48
+ this._restartDelayResolve = null;
49
+ this._stabilityThresholdMs = options._stabilityThresholdMs || STABILITY_THRESHOLD_MS;
50
+ }
51
+
52
+ /** Never throws — degrades to localhost/devtunnel on any failure. */
53
+ async start() {
54
+ console.log('\n Connecting mesh (Tailscale userspace)...');
55
+ this.stopping = false;
56
+ if (!fs.existsSync(this.sidecar)) { this._printMissing(); return; }
57
+ if (!this._authKey && !this._enrolled()) { this._printNotEnrolled(); return; }
58
+ try { fs.mkdirSync(this.stateDir, { recursive: true }); } catch (_) {}
59
+ await this._spawn();
60
+ }
61
+
62
+ getStatus() {
63
+ return { running: this.proc !== null && !this.stopping && !!this.dnsName, publicUrl: this.dnsName ? `https://${this.dnsName}` : null };
64
+ }
65
+
66
+ async stop() {
67
+ this.stopping = true;
68
+ this._clearStabilityTimer();
69
+ clearTimeout(this._restartDelayTimer);
70
+ if (this._restartDelayResolve) { this._restartDelayResolve(); this._restartDelayResolve = null; }
71
+ if (!this.proc) return;
72
+ return new Promise((resolve) => {
73
+ const t = setTimeout(() => { try { this.proc.kill('SIGKILL'); } catch (_) {} resolve(); }, 5000);
74
+ this.proc.once('exit', () => { clearTimeout(t); resolve(); });
75
+ try { this.proc.kill(); } catch (_) {}
76
+ });
77
+ }
78
+
79
+ /** State dir already holds a node identity → no key needed. */
80
+ _enrolled() {
81
+ try { return fs.existsSync(path.join(this.stateDir, 'tailscaled.state')); } catch (_) { return false; }
82
+ }
83
+
84
+ _spawn() {
85
+ return new Promise((resolve) => {
86
+ const args = ['--port', String(this.port), '--hostname', this.hostname, '--statedir', this.stateDir];
87
+ // Pass the key to the sidecar via TS_AUTHKEY (enroll only); it's already
88
+ // stripped from this process + base child env, so it reaches only this child.
89
+ const env = this._authKey ? { ...this._childEnv, TS_AUTHKEY: this._authKey } : this._childEnv;
90
+ this.proc = spawn(this.sidecar, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
91
+ let done = false;
92
+ const timer = setTimeout(() => { if (!done) { done = true; console.warn(' \x1b[33mMesh: no URL within 60s — check key/connectivity.\x1b[0m'); resolve(); } }, URL_TIMEOUT_MS);
93
+ this.proc.stdout.on('data', (d) => {
94
+ const out = d.toString();
95
+ if (this.dev) process.stdout.write(` [mesh] ${out}`);
96
+ const url = out.match(/MESH-URL (https:\/\/\S+)/);
97
+ if (url && !this.dnsName) {
98
+ this.dnsName = url[1].replace(/^https:\/\//, '');
99
+ this._authKey = null;
100
+ this._startStabilityTimer();
101
+ this.onUrl(`https://${this.dnsName}`);
102
+ if (!done) { done = true; clearTimeout(timer); resolve(); }
103
+ }
104
+ if (/MESH-NEEDLOGIN/.test(out)) { this._printNotEnrolled(); if (!done) { done = true; clearTimeout(timer); resolve(); } }
105
+ });
106
+ this.proc.stderr.on('data', (d) => { if (this.dev) process.stderr.write(` [mesh] ${d}`); });
107
+ this.proc.on('error', (e) => { console.error(` \x1b[31mmesh sidecar failed: ${e.message}\x1b[0m`); this.proc = null; if (!done) { done = true; clearTimeout(timer); resolve(); } });
108
+ this.proc.on('exit', (code) => {
109
+ this._clearStabilityTimer(); this.proc = null; this.dnsName = null;
110
+ if (!this.stopping && code !== 0) this._restart();
111
+ });
112
+ });
113
+ }
114
+
115
+ _printMissing() {
116
+ console.log('\n \x1b[33mMesh: sidecar not installed.\x1b[0m Fetched on next release build; see docs/specs/mesh.md.');
117
+ console.log(' Continuing on localhost/devtunnel.\n');
118
+ }
119
+
120
+ _printNotEnrolled() {
121
+ const ex = process.platform === 'win32' ? '$env:AIORDIE_TS_AUTHKEY="<key>"; ai-or-die --mesh' : 'AIORDIE_TS_AUTHKEY=<key> ai-or-die --mesh';
122
+ console.log('\n \x1b[1m\x1b[33mMesh: NOT ENROLLED\x1b[0m — enroll once:');
123
+ console.log(' 1. Reusable, tagged key (NOT ephemeral): https://login.tailscale.com/admin/settings/keys');
124
+ console.log(` 2. \x1b[1m${ex}\x1b[0m`);
125
+ console.log(' 3. Revoke the key once the machine appears in the console.');
126
+ console.log(' Continuing on localhost/devtunnel meanwhile.\n');
127
+ }
128
+
129
+ _startStabilityTimer() {
130
+ this._clearStabilityTimer();
131
+ this._stabilityTimer = setTimeout(() => { this.retryCount = 0; }, this._stabilityThresholdMs);
132
+ if (this._stabilityTimer.unref) this._stabilityTimer.unref();
133
+ }
134
+ _clearStabilityTimer() { if (this._stabilityTimer) { clearTimeout(this._stabilityTimer); this._stabilityTimer = null; } }
135
+
136
+ async _restart() {
137
+ this._totalRestarts++; this.retryCount++;
138
+ if (this.retryCount > MAX_RETRIES) { console.error(' \x1b[31mMesh sidecar crashed too many times. Server continues on localhost.\x1b[0m'); return; }
139
+ const delay = Math.min(2 ** (this.retryCount - 1) * MIN_RESTART_DELAY_MS, MAX_RESTART_DELAY_MS);
140
+ await new Promise((resolve) => { this._restartDelayResolve = resolve; this._restartDelayTimer = setTimeout(resolve, delay); if (this._restartDelayTimer.unref) this._restartDelayTimer.unref(); });
141
+ this._restartDelayResolve = null;
142
+ if (this.stopping) return;
143
+ await this._spawn();
144
+ }
145
+ }
146
+
147
+ module.exports = { MeshManager, _constants: { URL_TIMEOUT_MS, MAX_RETRIES } };
package/src/server.js CHANGED
@@ -117,6 +117,9 @@ const BACKPRESSURE_LIMIT_BG = 128 * 1024;
117
117
  class ClaudeCodeWebServer {
118
118
  constructor(options = {}) {
119
119
  this.port = options.port != null ? options.port : 7777;
120
+ // Bind host: null = all interfaces (default; devtunnel/LAN). Mesh mode pins
121
+ // '127.0.0.1' so only the tailnet `serve` proxy reaches the port, not the LAN.
122
+ this.bindHost = options.bindHost || null;
120
123
  this.auth = options.auth;
121
124
  this.noAuth = options.noAuth || false;
122
125
  this.dev = options.dev || false;
@@ -168,6 +171,7 @@ class ClaudeCodeWebServer {
168
171
  onEvent: (sessionId, event) => this.handleVSCodeTunnelEvent(sessionId, event),
169
172
  });
170
173
  this.tunnelManager = null; // Set via setTunnelManager() from CLI entry point
174
+ this.meshManager = null; // Set via setMeshManager() from CLI entry point
171
175
  this.installAdvisor = new InstallAdvisor();
172
176
  // Pure test-runner detection — keeps heavy native/local-model subsystems
173
177
  // (STT, sticky-notes) inert in the suite so it never downloads models or
@@ -450,6 +454,10 @@ class ClaudeCodeWebServer {
450
454
  this.tunnelManager = tm;
451
455
  }
452
456
 
457
+ setMeshManager(mm) {
458
+ this.meshManager = mm;
459
+ }
460
+
453
461
  async saveSessionsToDisk(force = false) {
454
462
  if (force) {
455
463
  this.sessionStore.markDirty();
@@ -560,6 +568,7 @@ class ClaudeCodeWebServer {
560
568
  Promise.resolve().then(() => this.stickyNoteEngine.shutdown()),
561
569
  Promise.resolve().then(() => this.sttEngine.shutdown()),
562
570
  Promise.resolve().then(() => (this.tunnelManager ? this.tunnelManager.stop() : undefined)),
571
+ Promise.resolve().then(() => (this.meshManager ? this.meshManager.stop() : undefined)),
563
572
  ]);
564
573
  await this.close();
565
574
  clearTimeout(forceExitTimer);
@@ -3420,8 +3429,18 @@ class ClaudeCodeWebServer {
3420
3429
  this.handleWebSocketConnection(ws, req);
3421
3430
  });
3422
3431
 
3432
+ // WS keepalive: DERP relays (mesh mode) drop idle sockets; a server ping
3433
+ // every 15s keeps long-lived terminal connections alive and reaps dead ones.
3434
+ this._wsKeepalive = setInterval(() => {
3435
+ for (const ws of this.wss.clients) {
3436
+ if (ws.readyState === WebSocket.OPEN) { try { ws.ping(); } catch (_) {} }
3437
+ }
3438
+ }, 15000);
3439
+ if (this._wsKeepalive.unref) this._wsKeepalive.unref();
3440
+ this.wss.on('close', () => clearInterval(this._wsKeepalive));
3441
+
3423
3442
  return new Promise((resolve, reject) => {
3424
- server.listen(this.port, (err) => {
3443
+ const onListen = (err) => {
3425
3444
  if (err) {
3426
3445
  reject(err);
3427
3446
  } else {
@@ -3431,7 +3450,10 @@ class ClaudeCodeWebServer {
3431
3450
  this.keepaliveManager.start();
3432
3451
  resolve(server);
3433
3452
  }
3434
- });
3453
+ };
3454
+ // bindHost null → all interfaces (default). Mesh pins 127.0.0.1.
3455
+ if (this.bindHost) server.listen(this.port, this.bindHost, onListen);
3456
+ else server.listen(this.port, onListen);
3435
3457
  });
3436
3458
  }
3437
3459