ai-or-die 0.1.88 → 0.1.89

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
@@ -216,6 +216,7 @@ async function main() {
216
216
  const mesh = new MeshManager({
217
217
  port,
218
218
  dev: options.dev,
219
+ authToken,
219
220
  onUrl: (meshUrl) => {
220
221
  const t = authToken ? `${meshUrl}?token=${authToken}` : meshUrl;
221
222
  console.log(`\n \x1b[1m\x1b[32mMesh ready:\x1b[0m \x1b[1m\x1b[4m${t}\x1b[0m\n`);
package/mesh/main.go CHANGED
@@ -17,6 +17,7 @@
17
17
  // MESH-URL https://<name> node is up, serving real TLS at the edge
18
18
  // MESH-URL http://<name> node is up, but TLS certs unavailable (degraded)
19
19
  // MESH-NOCERT hint: enable HTTPS Certificates in the tailnet
20
+ // MESH-PEERS {"self":...,"peers":[...]} tagged fleet peers snapshot
20
21
  // MESH-NEEDLOGIN <url> no/!valid key — operator must enroll
21
22
  // MESH-ERR <msg> fatal
22
23
  package main
@@ -24,6 +25,7 @@ package main
24
25
  import (
25
26
  "context"
26
27
  "crypto/tls"
28
+ "encoding/json"
27
29
  "flag"
28
30
  "fmt"
29
31
  "net"
@@ -31,16 +33,23 @@ import (
31
33
  "net/http/httputil"
32
34
  "net/url"
33
35
  "os"
36
+ "sort"
34
37
  "strings"
35
38
  "time"
36
39
 
40
+ "tailscale.com/ipn/ipnstate"
37
41
  "tailscale.com/tsnet"
38
42
  )
39
43
 
40
44
  // version is stamped at build time via -ldflags "-X main.version=<contentHash>".
41
45
  var version = "dev"
42
46
 
43
- const certWaitTimeout = 20 * time.Second
47
+ const (
48
+ certWaitTimeout = 20 * time.Second
49
+ meshPeersInterval = 20 * time.Second
50
+ meshPeersMaxPeers = 512
51
+ meshPeerTag = "tag:aiordie"
52
+ )
44
53
 
45
54
  func main() {
46
55
  port := flag.String("port", "7777", "local ai-or-die port to expose (loopback)")
@@ -55,6 +64,8 @@ func main() {
55
64
  return
56
65
  }
57
66
 
67
+ proxyBearer := os.Getenv("AIORDIE_PROXY_BEARER")
68
+
58
69
  // Resolve + validate the backend target. It MUST be loopback: the sidecar is
59
70
  // a tailnet-facing reverse proxy, and pointing it off-host would expose an
60
71
  // arbitrary origin to the whole tailnet.
@@ -80,6 +91,10 @@ func main() {
80
91
  os.Exit(1)
81
92
  }
82
93
 
94
+ if lc, err := s.LocalClient(); err == nil {
95
+ go emitMeshPeersLoop(context.Background(), lc)
96
+ }
97
+
83
98
  name := *host
84
99
  if st != nil && st.Self != nil && st.Self.DNSName != "" {
85
100
  name = strings.TrimSuffix(st.Self.DNSName, ".")
@@ -91,7 +106,7 @@ func main() {
91
106
  // so wss works regardless; this header is hygiene for any absolute-URL or
92
107
  // redirect the app generates (no mixed content).
93
108
  edgeProto := "http"
94
- rp := newEdgeProxy(target, name, &edgeProto)
109
+ rp := newEdgeProxy(target, name, &edgeProto, proxyBearer)
95
110
 
96
111
  // Decide the scheme BEFORE advertising. tsnet's ListenTLS returns a listener
97
112
  // without provisioning the cert; the ACME work (and any failure) happens
@@ -163,6 +178,94 @@ func isLoopbackHost(h string) bool {
163
178
  return ip != nil && ip.IsLoopback()
164
179
  }
165
180
 
181
+ type statusClient interface {
182
+ Status(context.Context) (*ipnstate.Status, error)
183
+ }
184
+
185
+ type meshPeersSnapshot struct {
186
+ Self meshPeerSelf `json:"self"`
187
+ Peers []meshPeer `json:"peers"`
188
+ }
189
+
190
+ type meshPeerSelf struct {
191
+ Hostname string `json:"hostname"`
192
+ DNSName string `json:"dnsName"`
193
+ }
194
+
195
+ type meshPeer struct {
196
+ Hostname string `json:"hostname"`
197
+ DNSName string `json:"dnsName"`
198
+ Online bool `json:"online"`
199
+ }
200
+
201
+ func emitMeshPeersLoop(ctx context.Context, lc statusClient) {
202
+ emitMeshPeers(ctx, lc)
203
+ ticker := time.NewTicker(meshPeersInterval)
204
+ defer ticker.Stop()
205
+ for {
206
+ select {
207
+ case <-ctx.Done():
208
+ return
209
+ case <-ticker.C:
210
+ emitMeshPeers(ctx, lc)
211
+ }
212
+ }
213
+ }
214
+
215
+ func emitMeshPeers(ctx context.Context, lc statusClient) {
216
+ status, err := lc.Status(ctx)
217
+ if err != nil || status == nil {
218
+ return
219
+ }
220
+ line := meshPeersFromStatus(status)
221
+ b, err := json.Marshal(line)
222
+ if err != nil {
223
+ return
224
+ }
225
+ fmt.Printf("MESH-PEERS %s\n", b)
226
+ }
227
+
228
+ func meshPeersFromStatus(status *ipnstate.Status) meshPeersSnapshot {
229
+ var self meshPeerSelf
230
+ if status.Self != nil {
231
+ self = meshPeerSelf{Hostname: status.Self.HostName, DNSName: normalizeDNSName(status.Self.DNSName)}
232
+ }
233
+
234
+ peers := make([]meshPeer, 0)
235
+ for _, ps := range status.Peer {
236
+ if ps == nil || !peerHasTag(ps, meshPeerTag) {
237
+ continue
238
+ }
239
+ peers = append(peers, meshPeer{Hostname: ps.HostName, DNSName: normalizeDNSName(ps.DNSName), Online: ps.Online})
240
+ }
241
+ sort.Slice(peers, func(i, j int) bool {
242
+ if peers[i].DNSName != peers[j].DNSName {
243
+ return peers[i].DNSName < peers[j].DNSName
244
+ }
245
+ return peers[i].Hostname < peers[j].Hostname
246
+ })
247
+ if len(peers) > meshPeersMaxPeers {
248
+ peers = peers[:meshPeersMaxPeers]
249
+ }
250
+ return meshPeersSnapshot{Self: self, Peers: peers}
251
+ }
252
+
253
+ func peerHasTag(ps *ipnstate.PeerStatus, tag string) bool {
254
+ if ps.Tags == nil {
255
+ return false
256
+ }
257
+ for i := 0; i < ps.Tags.Len(); i++ {
258
+ if ps.Tags.At(i) == tag {
259
+ return true
260
+ }
261
+ }
262
+ return false
263
+ }
264
+
265
+ func normalizeDNSName(s string) string {
266
+ return strings.ToLower(strings.TrimSuffix(s, "."))
267
+ }
268
+
166
269
  // tryListenTLS returns a TLS listener on :443 only when a cert for `name` looks
167
270
  // obtainable; otherwise nil. It first checks the tailnet's advertised cert
168
271
  // domains, then proactively warms the cert with a bounded timeout so a failure
@@ -214,13 +317,22 @@ func redirectToHTTPS(name string) http.HandlerFunc {
214
317
  // X-Forwarded-Host so the app never emits mixed-content links. For an https
215
318
  // loopback backend (a future self-signed local listener) it skips verification —
216
319
  // the host is already validated as loopback, so this is not a trust boundary.
217
- func newEdgeProxy(target *url.URL, name string, proto *string) *httputil.ReverseProxy {
320
+ func newEdgeProxy(target *url.URL, name string, proto *string, proxyBearer string) *httputil.ReverseProxy {
218
321
  rp := httputil.NewSingleHostReverseProxy(target)
219
322
  orig := rp.Director
220
323
  rp.Director = func(r *http.Request) {
221
324
  orig(r)
222
325
  r.Header.Set("X-Forwarded-Proto", *proto)
223
326
  r.Header.Set("X-Forwarded-Host", name)
327
+ // Authenticate tailnet->loopback traffic to the app's bearer middleware.
328
+ // Set REPLACES any client-supplied Authorization (no spoofing). When no
329
+ // token is configured, DELETE a client-supplied Authorization so a tailnet
330
+ // caller can never smuggle one through to the loopback app.
331
+ if proxyBearer != "" {
332
+ r.Header.Set("Authorization", "Bearer "+proxyBearer)
333
+ } else {
334
+ r.Header.Del("Authorization")
335
+ }
224
336
  }
225
337
  if target.Scheme == "https" {
226
338
  rp.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
package/mesh/main_test.go CHANGED
@@ -67,20 +67,21 @@ func TestIsLoopbackHost(t *testing.T) {
67
67
  }
68
68
 
69
69
  // End-to-end through the actual reverse proxy (no tailnet): a real loopback
70
- // backend must receive the X-Forwarded-Proto/Host the edge stamps, and the
71
- // pointer-captured proto must reflect the post-TLS decision.
72
- func TestEdgeProxyForwardsProtoAndHost(t *testing.T) {
73
- var gotProto, gotHost, gotBody string
70
+ // backend must receive the X-Forwarded-Proto/Host the edge stamps, the app
71
+ // bearer, and the pointer-captured proto must reflect the post-TLS decision.
72
+ func TestEdgeProxyForwardsProtoHostAndAuth(t *testing.T) {
73
+ var gotProto, gotHost, gotAuth, gotBody string
74
74
  backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75
75
  gotProto = r.Header.Get("X-Forwarded-Proto")
76
76
  gotHost = r.Header.Get("X-Forwarded-Host")
77
+ gotAuth = r.Header.Get("Authorization")
77
78
  io.WriteString(w, "backend-ok")
78
79
  }))
79
80
  defer backend.Close()
80
81
 
81
82
  target, _ := url.Parse(backend.URL) // 127.0.0.1 loopback
82
83
  proto := "http" // default before the TLS decision
83
- rp := newEdgeProxy(target, "node.tailnet.ts.net", &proto)
84
+ rp := newEdgeProxy(target, "node.tailnet.ts.net", &proto, "app-token")
84
85
  proto = "https" // edge decided real TLS AFTER constructing the proxy
85
86
 
86
87
  front := httptest.NewServer(rp)
@@ -100,6 +101,9 @@ func TestEdgeProxyForwardsProtoAndHost(t *testing.T) {
100
101
  if gotHost != "node.tailnet.ts.net" {
101
102
  t.Errorf("backend saw X-Forwarded-Host=%q, want node.tailnet.ts.net", gotHost)
102
103
  }
104
+ if gotAuth != "Bearer app-token" {
105
+ t.Errorf("backend saw Authorization=%q, want Bearer app-token", gotAuth)
106
+ }
103
107
  if gotBody != "backend-ok" {
104
108
  t.Errorf("proxy body=%q, want backend-ok", gotBody)
105
109
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "version": "1.0.0",
3
- "contentHash": "fb010e2cd6a930ef7eeac0de0adaf903cdb7252815650c38eac2d72f15da6e3d",
3
+ "contentHash": "dbf60b5215ebb0b0947edf8fa47906280dd1fea12ef7e1181918fecfe7d9bcc0",
4
4
  "assets": {
5
- "aiordie-mesh-windows-amd64.exe": "7a30a635b726e5cae694e3f48b37b329d470e8cf493ac5f025b99ea4a0fbded0",
6
- "aiordie-mesh-windows-arm64.exe": "6d056c561538a40991a975e410b61b7ba4e076c456ee280744ae9ade65202dda",
7
- "aiordie-mesh-linux-amd64": "2e5fd52842b67ec56647efb59ce8944bb211055b7630a19206a67b2c80b328fd",
8
- "aiordie-mesh-linux-arm64": "7508a98a2762feb3405df54150a34c53028b5588b44eac504835c56a5df82822",
9
- "aiordie-mesh-darwin-amd64": "d095c85d01036f3a9a529179c9ef5ca67231150bd49fc5adf1c4fb4e0d0da11b",
10
- "aiordie-mesh-darwin-arm64": "8d115b8233697995dc0d7bab8f45f881bba2d37896c86cd70ea626965b1e4cb0"
5
+ "aiordie-mesh-windows-amd64.exe": "3be314f27ddf69560cb34b40b70302609f28fe6feba7600580e68ce04a811a71",
6
+ "aiordie-mesh-windows-arm64.exe": "3169f41dc835522c42dc4766dcd9a608a65430fb3797120bcd1ae9e58014942c",
7
+ "aiordie-mesh-linux-amd64": "7fb0d9c624c85759fb20e789886d1f4e0e2b5621f8ee6105634c7e73fd44d049",
8
+ "aiordie-mesh-linux-arm64": "7d23eb2d8c4c9632f37ad8f11f8c8523ce7d6ce58d0607b4c73fb9ccbc261239",
9
+ "aiordie-mesh-darwin-amd64": "69336be9137a98d8a3409ea87702502fa82d573875041111ea46f65b2fd22442",
10
+ "aiordie-mesh-darwin-arm64": "f73bcb5a6107ac3c0da8a39f06af6620e2db6e66b4e3c135740d9081703fd938"
11
11
  }
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.88",
3
+ "version": "0.1.89",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -187,6 +187,10 @@ function createControlRouter(deps) {
187
187
  }
188
188
  });
189
189
 
190
+ router.get('/mesh/peers', (req, res) => {
191
+ res.json({ peers: deps.getMeshPeers ? deps.getMeshPeers() : [] });
192
+ });
193
+
190
194
  // GET /snapshot — F15 atomic batch resync. Returns every session's derived
191
195
  // status PLUS the event cursor, captured atomically (cursor first, then
192
196
  // statuses), so after a gap/overflow the controller resyncs in ONE call and
@@ -17,28 +17,32 @@ const STABILITY_THRESHOLD_MS = 60000; // 60s up = reset retry budget
17
17
  const MIN_RESTART_DELAY_MS = 1000;
18
18
  const MAX_RESTART_DELAY_MS = 30000;
19
19
  const MAX_RETRIES = 10;
20
+ const MESH_PEERS_JSON_MAX = 256 * 1024;
20
21
 
21
22
  class MeshManager {
22
23
  constructor(options = {}) {
23
24
  this.port = options.port || 7777; // the ai-or-die port to expose
24
25
  this.dev = options.dev || false;
25
26
  this.onUrl = options.onUrl || (() => {});
26
- // Consume the key once and scrub it everywhere it could leak.
27
+ this._proxyBearer = options.authToken || null;
28
+ // Consume the key once and scrub secrets everywhere they could leak.
27
29
  this._authKey = options.authKey || process.env.AIORDIE_TS_AUTHKEY || null;
28
30
  delete process.env.AIORDIE_TS_AUTHKEY;
31
+ delete process.env.AIORDIE_PROXY_BEARER;
29
32
  this._childEnv = { ...process.env };
30
33
  delete this._childEnv.AIORDIE_TS_AUTHKEY;
34
+ delete this._childEnv.AIORDIE_PROXY_BEARER;
31
35
 
32
36
  const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
33
- const base = process.platform === 'win32'
37
+ this._appBase = process.platform === 'win32'
34
38
  ? path.join(localApp, 'ai-or-die')
35
39
  : path.join(os.homedir(), '.ai-or-die');
36
- this.stateDir = options.stateDir || path.join(base, 'ts-state');
40
+ this.stateDir = options.stateDir || path.join(this._appBase, 'ts-state');
37
41
  // The sidecar binary is content-addressed: its path embeds the lock's
38
42
  // contentHash so a new build installs alongside the old one (no overwrite of
39
43
  // a running .exe). Derived from the installer's lock + path helpers.
40
44
  this._installer = options._installer || require('./utils/sidecar-installer');
41
- this.sidecar = options.sidecar || this._sidecarPathFromLock(base);
45
+ this.sidecar = options.sidecar || this._sidecarPathFromLock(this._appBase);
42
46
  this.hostname = (options.hostname || `aiordie-${os.hostname()}`).toLowerCase().replace(/[^a-z0-9-]/g, '');
43
47
 
44
48
  this.proc = null;
@@ -53,6 +57,8 @@ class MeshManager {
53
57
  this._restartDelayTimer = null;
54
58
  this._restartDelayResolve = null;
55
59
  this._stabilityThresholdMs = options._stabilityThresholdMs || STABILITY_THRESHOLD_MS;
60
+ this._stdoutBuffer = '';
61
+ this.peers = null;
56
62
  }
57
63
 
58
64
  /** Never throws — degrades to localhost/devtunnel on any failure. */
@@ -93,11 +99,20 @@ class MeshManager {
93
99
  }
94
100
 
95
101
  getStatus() {
96
- return { running: this.proc !== null && !this.stopping && !!this.dnsName, publicUrl: this.dnsName ? `${this.scheme || 'https'}://${this.dnsName}` : null };
102
+ return {
103
+ running: this.proc !== null && !this.stopping && !!this.dnsName,
104
+ publicUrl: this.dnsName ? `${this.scheme || 'https'}://${this.dnsName}` : null,
105
+ peers: this.peers ? this.peers.peers : [],
106
+ };
107
+ }
108
+
109
+ peersFilePath() {
110
+ return path.join(this._appBase, 'mesh', 'peers.json');
97
111
  }
98
112
 
99
113
  async stop() {
100
114
  this.stopping = true;
115
+ this._deletePeersFile();
101
116
  this._clearStabilityTimer();
102
117
  clearTimeout(this._restartDelayTimer);
103
118
  if (this._restartDelayResolve) { this._restartDelayResolve(); this._restartDelayResolve = null; }
@@ -117,36 +132,123 @@ class MeshManager {
117
132
  _spawn() {
118
133
  return new Promise((resolve) => {
119
134
  const args = ['--port', String(this.port), '--backend', this.backend, '--hostname', this.hostname, '--statedir', this.stateDir];
120
- // Pass the key to the sidecar via TS_AUTHKEY (enroll only); it's already
121
- // stripped from this process + base child env, so it reaches only this child.
122
- const env = this._authKey ? { ...this._childEnv, TS_AUTHKEY: this._authKey } : this._childEnv;
135
+ // Pass secrets only to the sidecar child; both are stripped from this
136
+ // process + base child env before spawning.
137
+ const env = { ...this._childEnv };
138
+ if (this._authKey) env.TS_AUTHKEY = this._authKey;
139
+ if (this._proxyBearer) env.AIORDIE_PROXY_BEARER = this._proxyBearer;
140
+ this._stdoutBuffer = '';
123
141
  this.proc = spawn(this.sidecar, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
124
142
  let done = false;
125
143
  const timer = setTimeout(() => { if (!done) { done = true; console.warn(' \x1b[33mMesh: no URL within 60s — check key/connectivity.\x1b[0m'); resolve(); } }, URL_TIMEOUT_MS);
126
144
  this.proc.stdout.on('data', (d) => {
127
- const out = d.toString();
128
- if (this.dev) process.stdout.write(` [mesh] ${out}`);
129
- if (/MESH-NOCERT/.test(out)) this._printNoCert();
130
- const url = out.match(/MESH-URL (https?):\/\/(\S+)/);
131
- if (url && !this.dnsName) {
132
- this.scheme = url[1];
133
- this.dnsName = url[2];
134
- this._authKey = null;
135
- this._startStabilityTimer();
136
- this.onUrl(`${this.scheme}://${this.dnsName}`);
137
- if (!done) { done = true; clearTimeout(timer); resolve(); }
138
- }
139
- if (/MESH-NEEDLOGIN/.test(out)) { this._printNotEnrolled(); if (!done) { done = true; clearTimeout(timer); resolve(); } }
145
+ if (this.dev) process.stdout.write(` [mesh] ${d}`);
146
+ this._handleStdoutData(d, (line) => {
147
+ if (/^MESH-NOCERT\b/.test(line)) this._printNoCert();
148
+ const url = line.match(/^MESH-URL (https?):\/\/(\S+)/);
149
+ if (url && !this.dnsName) {
150
+ this.scheme = url[1];
151
+ this.dnsName = url[2];
152
+ this._authKey = null;
153
+ this._startStabilityTimer();
154
+ this.onUrl(`${this.scheme}://${this.dnsName}`);
155
+ if (!done) { done = true; clearTimeout(timer); resolve(); }
156
+ }
157
+ if (/^MESH-NEEDLOGIN\b/.test(line)) {
158
+ this._deletePeersFile();
159
+ this._printNotEnrolled();
160
+ if (!done) { done = true; clearTimeout(timer); resolve(); }
161
+ }
162
+ });
140
163
  });
141
164
  this.proc.stderr.on('data', (d) => { if (this.dev) process.stderr.write(` [mesh] ${d}`); });
142
165
  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(); } });
143
166
  this.proc.on('exit', (code) => {
144
- this._clearStabilityTimer(); this.proc = null; this.dnsName = null;
167
+ this._clearStabilityTimer(); this.proc = null; this.dnsName = null; this._deletePeersFile();
145
168
  if (!this.stopping && code !== 0) this._restart();
146
169
  });
147
170
  });
148
171
  }
149
172
 
173
+ _handleStdoutData(chunk, onLine) {
174
+ try {
175
+ this._stdoutBuffer += chunk.toString();
176
+ const lines = this._stdoutBuffer.split('\n');
177
+ this._stdoutBuffer = lines.pop() || '';
178
+ // Bound the carry-over: a newline-less run longer than any legitimate line
179
+ // (MESH-PEERS is capped well under this) is junk/log-spam — drop it so a
180
+ // misbehaving child can't grow the buffer without limit.
181
+ if (this._stdoutBuffer.length > MESH_PEERS_JSON_MAX) this._stdoutBuffer = '';
182
+ for (const rawLine of lines) {
183
+ const line = rawLine.replace(/\r$/, '');
184
+ this._handleStdoutLine(line);
185
+ if (onLine) onLine(line);
186
+ }
187
+ } catch (_) {}
188
+ }
189
+
190
+ _handleStdoutLine(line) {
191
+ try {
192
+ if (/^MESH-NEEDLOGIN\b/.test(line)) this._deletePeersFile();
193
+ const match = /^MESH-PEERS\s+(.+)$/.exec(line);
194
+ if (!match) return;
195
+ const raw = match[1];
196
+ if (Buffer.byteLength(raw, 'utf8') > MESH_PEERS_JSON_MAX) {
197
+ if (this.dev) console.debug(' [mesh] ignoring oversized MESH-PEERS payload');
198
+ return;
199
+ }
200
+ let parsed;
201
+ try {
202
+ parsed = JSON.parse(raw);
203
+ } catch (_) {
204
+ return;
205
+ }
206
+ const normalized = this._validatePeersPayload(parsed);
207
+ if (!normalized) return;
208
+ this.peers = normalized;
209
+ this._writePeersFile(normalized);
210
+ } catch (_) {}
211
+ }
212
+
213
+ _validatePeersPayload(payload) {
214
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
215
+ const self = payload.self;
216
+ if (!self || typeof self !== 'object' || Array.isArray(self)) return null;
217
+ if (typeof self.hostname !== 'string' || typeof self.dnsName !== 'string') return null;
218
+ if (!Array.isArray(payload.peers)) return null;
219
+ const peers = [];
220
+ for (const p of payload.peers) {
221
+ if (!p || typeof p !== 'object' || Array.isArray(p)) continue;
222
+ if (typeof p.hostname !== 'string' || typeof p.dnsName !== 'string' || typeof p.online !== 'boolean') continue;
223
+ peers.push({ hostname: p.hostname, dnsName: p.dnsName, online: p.online });
224
+ }
225
+ return { self: { hostname: self.hostname, dnsName: self.dnsName }, peers };
226
+ }
227
+
228
+ _writePeersFile(snapshot) {
229
+ const file = this.peersFilePath();
230
+ const dir = path.dirname(file);
231
+ const tmp = path.join(dir, `peers.json.${process.pid}.tmp`);
232
+ try {
233
+ fs.mkdirSync(dir, { recursive: true });
234
+ fs.writeFileSync(tmp, JSON.stringify({
235
+ version: 1,
236
+ updatedAt: Date.now(),
237
+ self: snapshot.self,
238
+ peers: snapshot.peers,
239
+ }), { mode: 0o600 });
240
+ fs.chmodSync(tmp, 0o600);
241
+ fs.renameSync(tmp, file);
242
+ } catch (_) {
243
+ try { fs.rmSync(tmp, { force: true }); } catch (_) {}
244
+ }
245
+ }
246
+
247
+ _deletePeersFile() {
248
+ this.peers = null;
249
+ try { fs.rmSync(this.peersFilePath(), { force: true }); } catch (_) {}
250
+ }
251
+
150
252
  _printMissing(err) {
151
253
  const code = err && err.code;
152
254
  const map = {
package/src/server.js CHANGED
@@ -4105,6 +4105,7 @@ class ClaudeCodeWebServer {
4105
4105
  return {
4106
4106
  sessions: this.claudeSessions,
4107
4107
  eventBus: this.controlEventBus,
4108
+ getMeshPeers: () => this.meshManager ? this.meshManager.getStatus().peers : [],
4108
4109
  getStatusSignal: (id) => this._controlStatusSignal(id),
4109
4110
  readTail: async (id, lines) => this._controlReadTail(id, lines),
4110
4111
  createSession: async (opts) => this._controlCreateSession(opts),