@mindexec/cli 0.2.136 → 0.2.137

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindexec/cli",
3
- "version": "0.2.136",
3
+ "version": "0.2.137",
4
4
  "description": "MindExec local runtime and bridge CLI",
5
5
  "main": "server.js",
6
6
  "type": "module",
@@ -8,6 +8,7 @@ import net from 'node:net';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
+ import { WebSocketServer } from 'ws';
11
12
 
12
13
  const BRIDGE_TOKEN = 'remote-registry-follower-smoke-token';
13
14
  const PAIR_TOKEN = 'remote-registry-follower-pair-token';
@@ -84,6 +85,7 @@ function createRegistryTarget({ endpoint, endpointCandidates, leaseId, active =
84
85
 
85
86
  function startFakeSupabase(getTarget) {
86
87
  const requests = [];
88
+ const realtimeClients = new Set();
87
89
  const server = createServer((req, res) => {
88
90
  const parsed = new URL(req.url || '/', 'http://127.0.0.1');
89
91
  requests.push({
@@ -108,6 +110,77 @@ function startFakeSupabase(getTarget) {
108
110
  res.writeHead(404, { 'Content-Type': 'application/json' });
109
111
  res.end(JSON.stringify({ error: 'not-found' }));
110
112
  });
113
+ const realtime = new WebSocketServer({ noServer: true });
114
+
115
+ realtime.on('connection', (ws, req) => {
116
+ realtimeClients.add(ws);
117
+ ws.on('message', data => {
118
+ const text = data.toString();
119
+ let message = null;
120
+ try {
121
+ message = JSON.parse(text);
122
+ } catch {
123
+ return;
124
+ }
125
+
126
+ if (message.event === 'phx_join') {
127
+ assert.equal(String(req.headers.apikey || ''), SUPABASE_KEY);
128
+ assert.equal(String(req.headers.authorization || ''), `Bearer ${ACCESS_TOKEN}`);
129
+ assert.equal(message.payload?.access_token, ACCESS_TOKEN);
130
+ assert.deepEqual(
131
+ message.payload?.config?.postgres_changes?.[0],
132
+ {
133
+ event: '*',
134
+ schema: 'public',
135
+ table: 'remote_host_targets',
136
+ filter: `user_id=eq.${USER_ID}`
137
+ });
138
+ ws.remoteRegistryTopic = message.topic;
139
+ ws.send(JSON.stringify({
140
+ topic: message.topic,
141
+ event: 'phx_reply',
142
+ payload: {
143
+ status: 'ok',
144
+ response: {
145
+ postgres_changes: [
146
+ {
147
+ id: 1,
148
+ event: '*',
149
+ schema: 'public',
150
+ table: 'remote_host_targets'
151
+ }
152
+ ]
153
+ }
154
+ },
155
+ ref: message.ref,
156
+ join_ref: message.join_ref
157
+ }));
158
+ return;
159
+ }
160
+
161
+ if (message.event === 'heartbeat') {
162
+ ws.send(JSON.stringify({
163
+ topic: message.topic,
164
+ event: 'phx_reply',
165
+ payload: { status: 'ok', response: {} },
166
+ ref: message.ref
167
+ }));
168
+ }
169
+ });
170
+ ws.on('close', () => realtimeClients.delete(ws));
171
+ });
172
+
173
+ server.on('upgrade', (req, socket, head) => {
174
+ const parsed = new URL(req.url || '/', 'http://127.0.0.1');
175
+ if (parsed.pathname !== '/realtime/v1/websocket') {
176
+ socket.destroy();
177
+ return;
178
+ }
179
+
180
+ realtime.handleUpgrade(req, socket, head, ws => {
181
+ realtime.emit('connection', ws, req);
182
+ });
183
+ });
111
184
 
112
185
  return new Promise((resolve, reject) => {
113
186
  server.once('error', reject);
@@ -117,7 +190,41 @@ function startFakeSupabase(getTarget) {
117
190
  resolve({
118
191
  url: `http://127.0.0.1:${port}`,
119
192
  requests,
120
- stop: () => new Promise(done => server.close(done))
193
+ realtimeClients,
194
+ broadcastChange: (type = 'UPDATE') => {
195
+ for (const client of realtimeClients) {
196
+ if (client.readyState !== 1 || !client.remoteRegistryTopic) {
197
+ continue;
198
+ }
199
+
200
+ client.send(JSON.stringify({
201
+ topic: client.remoteRegistryTopic,
202
+ event: 'postgres_changes',
203
+ payload: {
204
+ ids: [1],
205
+ data: {
206
+ schema: 'public',
207
+ table: 'remote_host_targets',
208
+ commit_timestamp: new Date().toISOString(),
209
+ type,
210
+ record: getTarget(),
211
+ old_record: null
212
+ }
213
+ },
214
+ ref: null
215
+ }));
216
+ }
217
+ },
218
+ stop: () => new Promise(done => {
219
+ for (const client of realtimeClients) {
220
+ try {
221
+ client.close();
222
+ } catch {
223
+ // best effort only
224
+ }
225
+ }
226
+ realtime.close(() => server.close(done));
227
+ })
121
228
  });
122
229
  });
123
230
  });
@@ -140,8 +247,10 @@ function spawnBridge({ bridgePort, remoteHubPort, workspacePath, authRoot, label
140
247
  WORKSPACE_PATH: workspacePath,
141
248
  MINDEXEC_AUTH_DATA_ROOT: authRoot,
142
249
  MINDEXEC_REMOTE_REGISTRY_FOLLOWER: follower ? '1' : '0',
143
- MINDEXEC_REMOTE_REGISTRY_POLL_MS: '500',
250
+ MINDEXEC_REMOTE_REGISTRY_POLL_MS: '10000',
144
251
  MINDEXEC_REMOTE_REGISTRY_FAST_RETRY_MS: '250',
252
+ MINDEXEC_REMOTE_REGISTRY_REALTIME_RECONNECT_MS: '250',
253
+ MINDEXEC_REMOTE_REGISTRY_REALTIME_DEBOUNCE_MS: '100',
145
254
  SUPABASE_URL: supabaseUrl,
146
255
  SUPABASE_KEY,
147
256
  NO_COLOR: '1'
@@ -196,7 +305,7 @@ async function writeSession(authRoot) {
196
305
  }), 'utf8');
197
306
  }
198
307
 
199
- async function waitForManagedAgent(bridge, managerEndpoint, label) {
308
+ async function waitForManagedAgent(bridge, managerEndpoint, label, timeoutMs = 20000) {
200
309
  return await waitFor(async () => {
201
310
  const result = await fetchJson(`${bridge.baseUrl}/api/remote/agent/status`);
202
311
  const agent = result.payload;
@@ -205,7 +314,7 @@ async function waitForManagedAgent(bridge, managerEndpoint, label) {
205
314
  && String(agent.manager || '') === managerEndpoint
206
315
  ? agent
207
316
  : null;
208
- }, 20000, `${label} managed agent\n${bridge.details()}`);
317
+ }, timeoutMs, `${label} managed agent\n${bridge.details()}`);
209
318
  }
210
319
 
211
320
  async function waitForConnectedDevice(bridge, label) {
@@ -280,14 +389,18 @@ async function main() {
280
389
  assert.equal(firstAgent.manager, hostAEndpoint);
281
390
  assert.equal(firstAgent.managerCandidates?.[0], staleEndpoint, JSON.stringify(firstAgent.managerCandidates));
282
391
  await waitForConnectedDevice(hostA, 'host-a');
392
+ const firstStatus = await fetchJson(`${client.baseUrl}/api/status`);
393
+ assert.equal(firstStatus.payload?.remoteRegistryRealtime?.subscribed, true, JSON.stringify(firstStatus.payload?.remoteRegistryRealtime));
394
+ assert.ok(fakeSupabase.realtimeClients.size >= 1, 'expected LocalBridge realtime watcher to connect');
283
395
 
284
396
  currentTarget = createRegistryTarget({
285
397
  endpoint: hostBEndpoint,
286
398
  endpointCandidates: [hostBEndpoint],
287
399
  leaseId: 'lease-b'
288
400
  });
401
+ fakeSupabase.broadcastChange('UPDATE');
289
402
 
290
- const secondAgent = await waitForManagedAgent(client, hostBEndpoint, 'host-b');
403
+ const secondAgent = await waitForManagedAgent(client, hostBEndpoint, 'host-b', 9000);
291
404
  assert.equal(secondAgent.usingNpx, false, JSON.stringify(secondAgent));
292
405
  assert.match(String(secondAgent.launcher || ''), /mindexec-remote-fast/i);
293
406
  assert.equal(secondAgent.manager, hostBEndpoint);
@@ -299,6 +412,7 @@ async function main() {
299
412
  active: false,
300
413
  expires_at: new Date(Date.now() - 1000).toISOString()
301
414
  };
415
+ fakeSupabase.broadcastChange('UPDATE');
302
416
 
303
417
  await waitFor(async () => {
304
418
  const result = await fetchJson(`${client.baseUrl}/api/remote/agent/status`);
@@ -306,7 +420,10 @@ async function main() {
306
420
  && String(result.payload?.lastError || '') === 'registry-inactive'
307
421
  ? result.payload
308
422
  : null;
309
- }, 15000, `registry inactive stop\n${client.details()}`);
423
+ }, 9000, `registry inactive stop\n${client.details()}`);
424
+ const finalStatus = await fetchJson(`${client.baseUrl}/api/status`);
425
+ assert.ok(finalStatus.payload?.remoteRegistryRealtime?.changes >= 2, JSON.stringify(finalStatus.payload?.remoteRegistryRealtime));
426
+ assert.ok(finalStatus.payload?.remoteRegistryRealtime?.wakeups >= 2, JSON.stringify(finalStatus.payload?.remoteRegistryRealtime));
310
427
 
311
428
  assert.ok(fakeSupabase.requests.length >= 2, JSON.stringify(fakeSupabase.requests));
312
429
  console.log('Remote registry follower smoke OK');
package/server.js CHANGED
@@ -17,7 +17,7 @@ import multer from 'multer';
17
17
  import crypto from 'crypto';
18
18
  import sharp from 'sharp';
19
19
  import { createServer } from 'http';
20
- import { WebSocketServer } from 'ws';
20
+ import { WebSocket, WebSocketServer } from 'ws';
21
21
  import chokidar from 'chokidar';
22
22
  import Parser from 'web-tree-sitter';
23
23
  import { fileURLToPath } from 'url';
@@ -3141,6 +3141,11 @@ const REMOTE_REGISTRY_FOLLOWER_ENABLED = !/^(0|false|no|off)$/i.test(String(proc
3141
3141
  const REMOTE_REGISTRY_FOLLOWER_POLL_MS = Math.max(1500, Number(process.env.MINDEXEC_REMOTE_REGISTRY_POLL_MS || 5000) || 5000);
3142
3142
  const REMOTE_REGISTRY_FOLLOWER_FAST_RETRY_MS = Math.max(500, Number(process.env.MINDEXEC_REMOTE_REGISTRY_FAST_RETRY_MS || 1200) || 1200);
3143
3143
  const REMOTE_REGISTRY_FOLLOWER_MISSING_SESSION_STOP_COUNT = 3;
3144
+ const REMOTE_REGISTRY_REALTIME_ENABLED = REMOTE_REGISTRY_FOLLOWER_ENABLED
3145
+ && !/^(0|false|no|off)$/i.test(String(process.env.MINDEXEC_REMOTE_REGISTRY_REALTIME || 'true'));
3146
+ const REMOTE_REGISTRY_REALTIME_HEARTBEAT_MS = Math.max(10000, Number(process.env.MINDEXEC_REMOTE_REGISTRY_REALTIME_HEARTBEAT_MS || 25000) || 25000);
3147
+ const REMOTE_REGISTRY_REALTIME_RECONNECT_MS = Math.max(1000, Number(process.env.MINDEXEC_REMOTE_REGISTRY_REALTIME_RECONNECT_MS || 2500) || 2500);
3148
+ const REMOTE_REGISTRY_REALTIME_DEBOUNCE_MS = Math.max(100, Number(process.env.MINDEXEC_REMOTE_REGISTRY_REALTIME_DEBOUNCE_MS || 250) || 250);
3144
3149
  let remoteAgentState = createRemoteAgentIdleState();
3145
3150
  let remoteAgentSyncReportState = null;
3146
3151
  let remoteAgentSyncReportLogKey = '';
@@ -3153,6 +3158,15 @@ let remoteRegistryFollowerTimer = null;
3153
3158
  let remoteRegistryFollowerInFlight = false;
3154
3159
  let remoteRegistryFollowerStarted = false;
3155
3160
  let remoteRegistryFollowerConsecutiveMissingSession = 0;
3161
+ let remoteRegistryRealtimeSocket = null;
3162
+ let remoteRegistryRealtimeSessionKey = '';
3163
+ let remoteRegistryRealtimeTopic = '';
3164
+ let remoteRegistryRealtimeJoinRef = '';
3165
+ let remoteRegistryRealtimeRef = 0;
3166
+ let remoteRegistryRealtimeHeartbeatTimer = null;
3167
+ let remoteRegistryRealtimeReconnectTimer = null;
3168
+ let remoteRegistryRealtimeReconnectContext = null;
3169
+ let remoteRegistryRealtimeLastWakeAt = 0;
3156
3170
  let remoteRegistryFollowerState = {
3157
3171
  enabled: REMOTE_REGISTRY_FOLLOWER_ENABLED,
3158
3172
  status: REMOTE_REGISTRY_FOLLOWER_ENABLED ? 'idle' : 'disabled',
@@ -3166,6 +3180,26 @@ let remoteRegistryFollowerState = {
3166
3180
  targetNodeId: '',
3167
3181
  authenticated: false
3168
3182
  };
3183
+ let remoteRegistryRealtimeState = {
3184
+ enabled: REMOTE_REGISTRY_REALTIME_ENABLED,
3185
+ status: REMOTE_REGISTRY_REALTIME_ENABLED ? 'idle' : 'disabled',
3186
+ reason: '',
3187
+ connected: false,
3188
+ subscribed: false,
3189
+ userId: '',
3190
+ topic: '',
3191
+ lastOpenAt: '',
3192
+ lastJoinAt: '',
3193
+ lastMessageAt: '',
3194
+ lastChangeAt: '',
3195
+ lastCloseAt: '',
3196
+ lastErrorAt: '',
3197
+ lastError: '',
3198
+ reconnects: 0,
3199
+ messages: 0,
3200
+ changes: 0,
3201
+ wakeups: 0
3202
+ };
3169
3203
 
3170
3204
  function createRemoteAgentIdleState(overrides = {}) {
3171
3205
  return {
@@ -3617,6 +3651,11 @@ function isRemoteAgentProcessRunning() {
3617
3651
  && !proc.killed;
3618
3652
  }
3619
3653
 
3654
+ function isRemoteAgentRegistryOwned() {
3655
+ return isRemoteAgentProcessRunning()
3656
+ && /registry/i.test(String(remoteAgentState.source || ''));
3657
+ }
3658
+
3620
3659
  function isLocalRemoteHostTargetActive() {
3621
3660
  const status = remoteHub.getStatus({ includeSecrets: false });
3622
3661
  return status?.hostTargetActive === true
@@ -4417,6 +4456,424 @@ function updateRemoteRegistryFollowerState(patch = {}) {
4417
4456
  emitBridgeEvent('RemoteRegistryFollowerUpdated', serializeRemoteRegistryFollowerState());
4418
4457
  }
4419
4458
 
4459
+ function serializeRemoteRegistryRealtimeState() {
4460
+ return {
4461
+ ...remoteRegistryRealtimeState,
4462
+ heartbeatMs: REMOTE_REGISTRY_REALTIME_HEARTBEAT_MS,
4463
+ reconnectMs: REMOTE_REGISTRY_REALTIME_RECONNECT_MS,
4464
+ debounceMs: REMOTE_REGISTRY_REALTIME_DEBOUNCE_MS
4465
+ };
4466
+ }
4467
+
4468
+ function updateRemoteRegistryRealtimeState(patch = {}) {
4469
+ remoteRegistryRealtimeState = {
4470
+ ...remoteRegistryRealtimeState,
4471
+ ...patch,
4472
+ enabled: REMOTE_REGISTRY_REALTIME_ENABLED
4473
+ };
4474
+ emitBridgeEvent('RemoteRegistryRealtimeUpdated', serializeRemoteRegistryRealtimeState());
4475
+ }
4476
+
4477
+ function clearRemoteRegistryRealtimeTimers() {
4478
+ if (remoteRegistryRealtimeHeartbeatTimer) {
4479
+ clearInterval(remoteRegistryRealtimeHeartbeatTimer);
4480
+ remoteRegistryRealtimeHeartbeatTimer = null;
4481
+ }
4482
+ if (remoteRegistryRealtimeReconnectTimer) {
4483
+ clearTimeout(remoteRegistryRealtimeReconnectTimer);
4484
+ remoteRegistryRealtimeReconnectTimer = null;
4485
+ }
4486
+ }
4487
+
4488
+ function closeRemoteRegistryRealtime(reason = 'stopped') {
4489
+ clearRemoteRegistryRealtimeTimers();
4490
+ remoteRegistryRealtimeReconnectContext = null;
4491
+ const socket = remoteRegistryRealtimeSocket;
4492
+ remoteRegistryRealtimeSocket = null;
4493
+ remoteRegistryRealtimeSessionKey = '';
4494
+ remoteRegistryRealtimeTopic = '';
4495
+ remoteRegistryRealtimeJoinRef = '';
4496
+
4497
+ if (socket && socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) {
4498
+ try {
4499
+ socket.close(1000, String(reason || 'stopped').slice(0, 120));
4500
+ } catch {
4501
+ // Best-effort close only.
4502
+ }
4503
+ }
4504
+
4505
+ updateRemoteRegistryRealtimeState({
4506
+ status: REMOTE_REGISTRY_REALTIME_ENABLED ? 'stopped' : 'disabled',
4507
+ reason,
4508
+ connected: false,
4509
+ subscribed: false,
4510
+ userId: '',
4511
+ topic: ''
4512
+ });
4513
+ }
4514
+
4515
+ function buildRemoteRegistryRealtimeUrl(config) {
4516
+ try {
4517
+ const url = new URL('/realtime/v1/websocket', config.url);
4518
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
4519
+ url.searchParams.set('apikey', config.key);
4520
+ url.searchParams.set('vsn', '1.0.0');
4521
+ return url.toString();
4522
+ } catch {
4523
+ return '';
4524
+ }
4525
+ }
4526
+
4527
+ function getRemoteRegistryRealtimeSessionKey(config, session) {
4528
+ const tokenHash = crypto
4529
+ .createHash('sha256')
4530
+ .update(String(session?.accessToken || ''))
4531
+ .digest('hex')
4532
+ .slice(0, 16);
4533
+ return [
4534
+ String(config?.url || '').replace(/\/+$/, ''),
4535
+ String(session?.userId || ''),
4536
+ tokenHash
4537
+ ].join('|');
4538
+ }
4539
+
4540
+ function sendRemoteRegistryRealtimeMessage(socket, topic, event, payload = {}, options = {}) {
4541
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
4542
+ return '';
4543
+ }
4544
+
4545
+ const ref = String(++remoteRegistryRealtimeRef);
4546
+ const message = {
4547
+ topic,
4548
+ event,
4549
+ payload,
4550
+ ref
4551
+ };
4552
+ if (options.joinRef) {
4553
+ message.join_ref = options.joinRef;
4554
+ }
4555
+ socket.send(JSON.stringify(message));
4556
+ return ref;
4557
+ }
4558
+
4559
+ function sendRemoteRegistryRealtimeHeartbeat(socket) {
4560
+ sendRemoteRegistryRealtimeMessage(socket, 'phoenix', 'heartbeat', {});
4561
+ }
4562
+
4563
+ function parseRemoteRegistryRealtimeMessage(data) {
4564
+ let raw = data;
4565
+ if (Buffer.isBuffer(raw)) {
4566
+ raw = raw.toString('utf8');
4567
+ }
4568
+ if (ArrayBuffer.isView(raw)) {
4569
+ raw = Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8');
4570
+ }
4571
+
4572
+ const parsed = JSON.parse(String(raw || '{}'));
4573
+ if (Array.isArray(parsed)) {
4574
+ return {
4575
+ joinRef: parsed[0] ?? '',
4576
+ ref: parsed[1] ?? '',
4577
+ topic: parsed[2] ?? '',
4578
+ event: parsed[3] ?? '',
4579
+ payload: parsed[4] ?? {}
4580
+ };
4581
+ }
4582
+
4583
+ return {
4584
+ joinRef: parsed.join_ref ?? parsed.joinRef ?? '',
4585
+ ref: parsed.ref ?? '',
4586
+ topic: parsed.topic ?? '',
4587
+ event: parsed.event ?? '',
4588
+ payload: parsed.payload ?? {}
4589
+ };
4590
+ }
4591
+
4592
+ function isRemoteRegistryRealtimeChange(message) {
4593
+ if (!message || message.event !== 'postgres_changes') {
4594
+ return false;
4595
+ }
4596
+
4597
+ const data = message.payload?.data || message.payload;
4598
+ const table = String(data?.table || data?.record?.table || '').trim();
4599
+ const schema = String(data?.schema || data?.record?.schema || 'public').trim();
4600
+ return table === 'remote_host_targets' && (!schema || schema === 'public');
4601
+ }
4602
+
4603
+ function wakeRemoteRegistryFollowerFromRealtime(reason = 'realtime-change') {
4604
+ const now = Date.now();
4605
+ if (now - remoteRegistryRealtimeLastWakeAt < REMOTE_REGISTRY_REALTIME_DEBOUNCE_MS) {
4606
+ return;
4607
+ }
4608
+
4609
+ remoteRegistryRealtimeLastWakeAt = now;
4610
+ updateRemoteRegistryRealtimeState({
4611
+ wakeups: remoteRegistryRealtimeState.wakeups + 1,
4612
+ lastChangeAt: new Date().toISOString(),
4613
+ reason
4614
+ });
4615
+ wakeRemoteRegistryFollower(reason).catch(error => {
4616
+ updateRemoteRegistryRealtimeState({
4617
+ status: 'error',
4618
+ reason: 'wake-failed',
4619
+ lastError: error?.message || String(error || ''),
4620
+ lastErrorAt: new Date().toISOString()
4621
+ });
4622
+ });
4623
+ }
4624
+
4625
+ function scheduleRemoteRegistryRealtimeReconnect(reason = 'reconnect') {
4626
+ if (!REMOTE_REGISTRY_REALTIME_ENABLED || isShuttingDown || !remoteRegistryRealtimeReconnectContext) {
4627
+ return;
4628
+ }
4629
+
4630
+ if (remoteRegistryRealtimeReconnectTimer) {
4631
+ return;
4632
+ }
4633
+
4634
+ updateRemoteRegistryRealtimeState({
4635
+ status: 'reconnecting',
4636
+ reason,
4637
+ connected: false,
4638
+ subscribed: false,
4639
+ reconnects: remoteRegistryRealtimeState.reconnects + 1
4640
+ });
4641
+ remoteRegistryRealtimeReconnectTimer = setTimeout(() => {
4642
+ remoteRegistryRealtimeReconnectTimer = null;
4643
+ const context = remoteRegistryRealtimeReconnectContext;
4644
+ if (!context || isShuttingDown) {
4645
+ return;
4646
+ }
4647
+ ensureRemoteRegistryRealtimeSubscription(context.config, context.session, 'realtime-reconnect')
4648
+ .catch(error => {
4649
+ updateRemoteRegistryRealtimeState({
4650
+ status: 'error',
4651
+ reason: 'reconnect-failed',
4652
+ lastError: error?.message || String(error || ''),
4653
+ lastErrorAt: new Date().toISOString()
4654
+ });
4655
+ scheduleRemoteRegistryRealtimeReconnect('reconnect-failed');
4656
+ });
4657
+ }, REMOTE_REGISTRY_REALTIME_RECONNECT_MS);
4658
+ remoteRegistryRealtimeReconnectTimer?.unref?.();
4659
+ }
4660
+
4661
+ async function ensureRemoteRegistryRealtimeSubscription(config, session, trigger = 'sync') {
4662
+ if (!REMOTE_REGISTRY_REALTIME_ENABLED) {
4663
+ closeRemoteRegistryRealtime('disabled');
4664
+ return serializeRemoteRegistryRealtimeState();
4665
+ }
4666
+
4667
+ if (!config?.url || !config?.key || !session?.accessToken || !session?.userId) {
4668
+ closeRemoteRegistryRealtime('not-authenticated');
4669
+ return serializeRemoteRegistryRealtimeState();
4670
+ }
4671
+
4672
+ const sessionKey = getRemoteRegistryRealtimeSessionKey(config, session);
4673
+ if (remoteRegistryRealtimeSocket
4674
+ && remoteRegistryRealtimeSessionKey === sessionKey
4675
+ && (remoteRegistryRealtimeSocket.readyState === WebSocket.OPEN
4676
+ || remoteRegistryRealtimeSocket.readyState === WebSocket.CONNECTING)) {
4677
+ return serializeRemoteRegistryRealtimeState();
4678
+ }
4679
+
4680
+ closeRemoteRegistryRealtime('replaced');
4681
+ const wsUrl = buildRemoteRegistryRealtimeUrl(config);
4682
+ if (!wsUrl) {
4683
+ updateRemoteRegistryRealtimeState({
4684
+ status: 'error',
4685
+ reason: 'invalid-realtime-url',
4686
+ lastError: 'invalid-realtime-url',
4687
+ lastErrorAt: new Date().toISOString()
4688
+ });
4689
+ return serializeRemoteRegistryRealtimeState();
4690
+ }
4691
+
4692
+ const topic = `realtime:remote-host-targets-${String(session.userId).replace(/[^a-zA-Z0-9]/g, '') || 'user'}`;
4693
+ const socket = new WebSocket(wsUrl, {
4694
+ headers: {
4695
+ apikey: config.key,
4696
+ Authorization: `Bearer ${session.accessToken}`
4697
+ }
4698
+ });
4699
+
4700
+ remoteRegistryRealtimeSocket = socket;
4701
+ remoteRegistryRealtimeSessionKey = sessionKey;
4702
+ remoteRegistryRealtimeTopic = topic;
4703
+ remoteRegistryRealtimeReconnectContext = {
4704
+ config: { url: config.url, key: config.key },
4705
+ session: {
4706
+ accessToken: session.accessToken,
4707
+ userId: session.userId,
4708
+ expiresAtMs: session.expiresAtMs
4709
+ }
4710
+ };
4711
+
4712
+ updateRemoteRegistryRealtimeState({
4713
+ status: 'connecting',
4714
+ reason: trigger,
4715
+ connected: false,
4716
+ subscribed: false,
4717
+ userId: session.userId,
4718
+ topic,
4719
+ lastError: ''
4720
+ });
4721
+
4722
+ socket.on('open', () => {
4723
+ if (remoteRegistryRealtimeSocket !== socket) {
4724
+ return;
4725
+ }
4726
+
4727
+ const now = new Date().toISOString();
4728
+ remoteRegistryRealtimeJoinRef = String(++remoteRegistryRealtimeRef);
4729
+ updateRemoteRegistryRealtimeState({
4730
+ status: 'joining',
4731
+ reason: 'socket-open',
4732
+ connected: true,
4733
+ subscribed: false,
4734
+ lastOpenAt: now,
4735
+ lastJoinAt: now
4736
+ });
4737
+ socket.send(JSON.stringify({
4738
+ topic,
4739
+ event: 'phx_join',
4740
+ payload: {
4741
+ config: {
4742
+ broadcast: { ack: false, self: false },
4743
+ presence: { enabled: false },
4744
+ postgres_changes: [
4745
+ {
4746
+ event: '*',
4747
+ schema: 'public',
4748
+ table: 'remote_host_targets',
4749
+ filter: `user_id=eq.${session.userId}`
4750
+ }
4751
+ ],
4752
+ private: false
4753
+ },
4754
+ access_token: session.accessToken
4755
+ },
4756
+ ref: remoteRegistryRealtimeJoinRef,
4757
+ join_ref: remoteRegistryRealtimeJoinRef
4758
+ }));
4759
+
4760
+ if (remoteRegistryRealtimeHeartbeatTimer) {
4761
+ clearInterval(remoteRegistryRealtimeHeartbeatTimer);
4762
+ }
4763
+ remoteRegistryRealtimeHeartbeatTimer = setInterval(
4764
+ () => sendRemoteRegistryRealtimeHeartbeat(socket),
4765
+ REMOTE_REGISTRY_REALTIME_HEARTBEAT_MS);
4766
+ remoteRegistryRealtimeHeartbeatTimer?.unref?.();
4767
+ });
4768
+
4769
+ socket.on('message', data => {
4770
+ if (remoteRegistryRealtimeSocket !== socket) {
4771
+ return;
4772
+ }
4773
+
4774
+ let message = null;
4775
+ try {
4776
+ message = parseRemoteRegistryRealtimeMessage(data);
4777
+ } catch (error) {
4778
+ updateRemoteRegistryRealtimeState({
4779
+ status: 'error',
4780
+ reason: 'message-parse-failed',
4781
+ lastError: error?.message || String(error || ''),
4782
+ lastErrorAt: new Date().toISOString()
4783
+ });
4784
+ return;
4785
+ }
4786
+
4787
+ updateRemoteRegistryRealtimeState({
4788
+ messages: remoteRegistryRealtimeState.messages + 1,
4789
+ lastMessageAt: new Date().toISOString()
4790
+ });
4791
+
4792
+ if (message.event === 'phx_reply' && message.ref === remoteRegistryRealtimeJoinRef) {
4793
+ const ok = String(message.payload?.status || '').toLowerCase() === 'ok';
4794
+ updateRemoteRegistryRealtimeState({
4795
+ status: ok ? 'subscribed' : 'error',
4796
+ reason: ok ? 'joined' : 'join-failed',
4797
+ subscribed: ok,
4798
+ lastError: ok ? '' : JSON.stringify(message.payload || {}).slice(0, 240),
4799
+ lastErrorAt: ok ? remoteRegistryRealtimeState.lastErrorAt : new Date().toISOString()
4800
+ });
4801
+ return;
4802
+ }
4803
+
4804
+ if (message.event === 'system') {
4805
+ const status = String(message.payload?.status || '').toLowerCase();
4806
+ if (status === 'ok') {
4807
+ updateRemoteRegistryRealtimeState({
4808
+ status: 'subscribed',
4809
+ reason: 'system-ok',
4810
+ subscribed: true
4811
+ });
4812
+ } else if (status === 'error' || status === 'timeout') {
4813
+ updateRemoteRegistryRealtimeState({
4814
+ status: 'error',
4815
+ reason: 'system-error',
4816
+ subscribed: false,
4817
+ lastError: String(message.payload?.message || status).slice(0, 240),
4818
+ lastErrorAt: new Date().toISOString()
4819
+ });
4820
+ }
4821
+ return;
4822
+ }
4823
+
4824
+ if (message.event === 'phx_error' || message.event === 'phx_close') {
4825
+ updateRemoteRegistryRealtimeState({
4826
+ status: 'error',
4827
+ reason: message.event,
4828
+ subscribed: false,
4829
+ lastError: message.event,
4830
+ lastErrorAt: new Date().toISOString()
4831
+ });
4832
+ scheduleRemoteRegistryRealtimeReconnect(message.event);
4833
+ return;
4834
+ }
4835
+
4836
+ if (isRemoteRegistryRealtimeChange(message)) {
4837
+ updateRemoteRegistryRealtimeState({
4838
+ changes: remoteRegistryRealtimeState.changes + 1
4839
+ });
4840
+ wakeRemoteRegistryFollowerFromRealtime('realtime-change');
4841
+ }
4842
+ });
4843
+
4844
+ socket.on('close', (code, reasonBuffer) => {
4845
+ if (remoteRegistryRealtimeSocket !== socket) {
4846
+ return;
4847
+ }
4848
+
4849
+ clearRemoteRegistryRealtimeTimers();
4850
+ remoteRegistryRealtimeSocket = null;
4851
+ updateRemoteRegistryRealtimeState({
4852
+ status: 'closed',
4853
+ reason: reasonBuffer?.toString?.() || `close:${code}`,
4854
+ connected: false,
4855
+ subscribed: false,
4856
+ lastCloseAt: new Date().toISOString()
4857
+ });
4858
+ scheduleRemoteRegistryRealtimeReconnect(`close:${code}`);
4859
+ });
4860
+
4861
+ socket.on('error', error => {
4862
+ if (remoteRegistryRealtimeSocket !== socket) {
4863
+ return;
4864
+ }
4865
+
4866
+ updateRemoteRegistryRealtimeState({
4867
+ status: 'error',
4868
+ reason: 'socket-error',
4869
+ lastError: error?.message || String(error || ''),
4870
+ lastErrorAt: new Date().toISOString()
4871
+ });
4872
+ });
4873
+
4874
+ return serializeRemoteRegistryRealtimeState();
4875
+ }
4876
+
4420
4877
  function readSupabaseRuntimeConfig() {
4421
4878
  const envUrl = String(process.env.SUPABASE_URL || process.env.MINDEXEC_SUPABASE_URL || '').trim();
4422
4879
  const envKey = String(
@@ -4643,6 +5100,7 @@ async function runRemoteRegistryFollowerOnce(trigger = 'timer') {
4643
5100
  try {
4644
5101
  const config = readSupabaseRuntimeConfig();
4645
5102
  if (!config.url || !config.key) {
5103
+ closeRemoteRegistryRealtime('supabase-config-missing');
4646
5104
  updateRemoteRegistryFollowerState({
4647
5105
  status: 'skipped',
4648
5106
  reason: 'supabase-config-missing',
@@ -4664,9 +5122,10 @@ async function runRemoteRegistryFollowerOnce(trigger = 'timer') {
4664
5122
  const sessionPayload = await readStableAuthSessionPayload();
4665
5123
  const session = sessionPayload.found ? parseSupabaseSessionForRegistry(sessionPayload.content) : null;
4666
5124
  if (!session) {
5125
+ closeRemoteRegistryRealtime('registry-not-authenticated');
4667
5126
  remoteRegistryFollowerConsecutiveMissingSession += 1;
4668
5127
  if (remoteRegistryFollowerConsecutiveMissingSession >= REMOTE_REGISTRY_FOLLOWER_MISSING_SESSION_STOP_COUNT
4669
- && isRemoteAgentProcessRunning()) {
5128
+ && isRemoteAgentRegistryOwned()) {
4670
5129
  await stopRemoteAgentConnection('registry-not-authenticated');
4671
5130
  }
4672
5131
 
@@ -4694,6 +5153,7 @@ async function runRemoteRegistryFollowerOnce(trigger = 'timer') {
4694
5153
 
4695
5154
  remoteRegistryFollowerConsecutiveMissingSession = 0;
4696
5155
  if (session.expiresAtMs && session.expiresAtMs <= Date.now()) {
5156
+ closeRemoteRegistryRealtime('session-expired');
4697
5157
  updateRemoteRegistryFollowerState({
4698
5158
  status: 'skipped',
4699
5159
  reason: 'session-expired',
@@ -4713,6 +5173,7 @@ async function runRemoteRegistryFollowerOnce(trigger = 'timer') {
4713
5173
  }
4714
5174
 
4715
5175
  const localHub = remoteHub.getStatus({ includeSecrets: false });
5176
+ await ensureRemoteRegistryRealtimeSubscription(config, session, trigger);
4716
5177
  const target = await fetchRemoteRegistryTarget(config, session);
4717
5178
 
4718
5179
  if (localHub?.hostTargetActive === true) {
@@ -4779,7 +5240,7 @@ async function runRemoteRegistryFollowerOnce(trigger = 'timer') {
4779
5240
  }
4780
5241
 
4781
5242
  if (!target?.active || isRemoteRegistryTargetExpired(target)) {
4782
- if (isRemoteAgentProcessRunning()) {
5243
+ if (isRemoteAgentRegistryOwned()) {
4783
5244
  await stopRemoteAgentConnection('registry-inactive');
4784
5245
  }
4785
5246
  updateRemoteRegistryFollowerState({
@@ -9416,6 +9877,7 @@ app.get('/api/status', async (req, res) => {
9416
9877
  remoteFrameWs: serializeRemoteFrameWsDiagnostics(),
9417
9878
  remoteAgent: serializeRemoteAgentState(),
9418
9879
  remoteRegistryFollower: serializeRemoteRegistryFollowerState(),
9880
+ remoteRegistryRealtime: serializeRemoteRegistryRealtimeState(),
9419
9881
  shellJobsPath: '/api/shell/jobs',
9420
9882
  companyCore: {
9421
9883
  baseUrl: companyCoreBaseUrl,
@@ -11246,6 +11708,12 @@ async function shutdownBridge(signal) {
11246
11708
  // Ignore registry follower shutdown errors
11247
11709
  }
11248
11710
 
11711
+ try {
11712
+ closeRemoteRegistryRealtime('bridge-shutdown');
11713
+ } catch {
11714
+ // Ignore registry realtime shutdown errors
11715
+ }
11716
+
11249
11717
  try {
11250
11718
  await stopRemoteAgentConnection('bridge-shutdown');
11251
11719
  } catch {