yaver-feedback-react-native 0.7.0 → 0.7.1

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.
@@ -5,55 +5,68 @@ export interface DiscoveryResult {
5
5
  latency: number;
6
6
  }
7
7
  /**
8
- * Device discovery for finding Yaver agents on the local network or via Convex.
8
+ * Device discovery for finding Yaver agents.
9
9
  *
10
- * Three discovery strategies (tried in order):
11
- * 1. **Convex cloud** fetch agent IP from Convex `/devices/list` (for cloud machines)
12
- * 2. **Stored connection** try cached URL from last successful connection
13
- * 3. **LAN scan** probe common LAN IPs via `/health` endpoint
10
+ * **Convex is the primary source of truth.** The user's Convex account
11
+ * has the freshest IP / port for each registered agent, updated every
12
+ * 2 minutes via heartbeat. The SDK should therefore:
13
+ * 1. On every `discover()` call, re-query Convex for the latest IP
14
+ * (no local cache shortcut when `convexUrl` + `authToken` are
15
+ * available).
16
+ * 2. Dedup the returned list (Convex can carry stale rows after
17
+ * re-pair) and pick the freshest online machine.
18
+ * 3. Probe the machine's `quicHost:httpPort` directly.
19
+ * 4. If the direct probe fails (different LAN / roaming), route
20
+ * through the configured relay.
21
+ * 5. Store the successful URL only AFTER the probe confirms it's
22
+ * reachable. Stored cache is used only as a last-chance shortcut
23
+ * when Convex itself is unreachable.
24
+ *
25
+ * Compared to the previous implementation this removes the "trust
26
+ * stored URL first" shortcut that caused the SDK to keep trying a dead
27
+ * cached IP long after the Mac's IP rotated.
14
28
  */
15
29
  export declare class YaverDiscovery {
16
- /**
17
- * Discover an agent. Tries Convex cloud first (if configured),
18
- * then stored connection, then LAN scan.
19
- */
20
30
  static discover(options?: {
21
31
  convexUrl?: string;
22
32
  authToken?: string;
23
33
  preferredDeviceId?: string;
24
34
  }): Promise<DiscoveryResult | null>;
25
35
  /**
26
- * Fetch the agent URL from Convex device list or cloud machines.
27
- * No hardcoded IPs needed Convex knows where the agent is.
36
+ * Re-query Convex ignoring any cached URL. Intended for the call
37
+ * site right after a probe/network failure it's the "the IP
38
+ * probably changed, ask the source of truth again" path.
28
39
  */
29
- static discoverFromConvex(convexUrl: string, authToken: string, preferredDeviceId?: string): Promise<DiscoveryResult | null>;
40
+ static refreshFromConvex(options: {
41
+ convexUrl: string;
42
+ authToken: string;
43
+ preferredDeviceId?: string;
44
+ }): Promise<DiscoveryResult | null>;
30
45
  /**
31
- * Discover agent via relay HTTP proxy.
32
- * Fetches relay server list from Convex platformConfig, then probes
33
- * `{relayHttpUrl}/d/{deviceId}/health` to reach the agent over the internet.
46
+ * Fetch the agent URL from Convex. Dedups rows, prefers fresh ones,
47
+ * falls back to relay if the direct LAN IP isn't reachable.
34
48
  */
35
- static discoverViaRelay(convexUrl: string, authToken: string, deviceId: string): Promise<DiscoveryResult | null>;
49
+ static discoverFromConvex(convexUrl: string, authToken: string, preferredDeviceId?: string): Promise<DiscoveryResult | null>;
36
50
  /**
37
- * Probe with extra headers (e.g. relay password).
51
+ * Race `/health` probes across N URLs in parallel. First 200 wins;
52
+ * everything else is abandoned. Mirrors the mobile app's
53
+ * `raceDirectCandidates` pattern — the single most reliable thing it
54
+ * does on same-LAN.
38
55
  */
39
- static probeWithHeaders(url: string, headers: Record<string, string>): Promise<DiscoveryResult | null>;
56
+ static raceProbe(urls: string[]): Promise<DiscoveryResult | null>;
40
57
  /**
41
- * Probe a specific URL for a running Yaver agent.
42
- * Hits the `/health` endpoint with a 2s timeout.
58
+ * Discover agent via relay HTTP proxy. Uses the user's configured
59
+ * relay first (from `/auth/validate`), then the platform relay list.
43
60
  */
61
+ static discoverViaRelay(convexUrl: string, authToken: string, deviceId: string): Promise<DiscoveryResult | null>;
62
+ static probeWithHeaders(url: string, headers: Record<string, string>): Promise<DiscoveryResult | null>;
63
+ /** Probe a specific URL for a running Yaver agent (2.5 s timeout). */
44
64
  static probe(url: string): Promise<DiscoveryResult | null>;
45
- /**
46
- * Manually connect to a specific agent URL.
47
- * Probes the URL and stores the connection if successful.
48
- */
49
65
  static connect(url: string): Promise<DiscoveryResult | null>;
50
- /** Get the cached agent connection from storage. */
51
66
  static getStored(): Promise<{
52
67
  url: string;
53
68
  hostname: string;
54
69
  } | null>;
55
- /** Store a successful discovery result. */
56
70
  static store(result: DiscoveryResult): Promise<void>;
57
- /** Clear the stored agent connection. */
58
71
  static clear(): Promise<void>;
59
72
  }
package/dist/Discovery.js CHANGED
@@ -14,27 +14,43 @@ function getAsyncStorage() {
14
14
  return null;
15
15
  }
16
16
  }
17
+ const deviceDedup_1 = require("./deviceDedup");
17
18
  const STORAGE_KEY = 'yaver_feedback_agent';
18
19
  const DEFAULT_PORT = 18080;
19
- const TIMEOUT_MS = 2000;
20
- // Common LAN subnets and host suffixes to scan
21
- const SUBNETS = ['192.168.1', '192.168.0', '10.0.0', '10.0.1'];
22
- const HOST_SUFFIXES = [1, 2, 50, 100, 101, 200];
20
+ const PROBE_TIMEOUT_MS = 2500;
21
+ const RELAY_PROBE_TIMEOUT_MS = 6000;
22
+ // LAN fallback sweep used ONLY when Convex lookup fails AND the stored
23
+ // cache probe fails. Keep tight covers 192.168.1/0.x and 10.0.0/1.x
24
+ // with a handful of common host suffixes. The primary path is always
25
+ // Convex.
26
+ const LAN_SUBNETS = ['192.168.1', '192.168.0', '10.0.0', '10.0.1'];
27
+ const LAN_HOST_SUFFIXES = [1, 2, 50, 100, 101, 200];
23
28
  /**
24
- * Device discovery for finding Yaver agents on the local network or via Convex.
29
+ * Device discovery for finding Yaver agents.
25
30
  *
26
- * Three discovery strategies (tried in order):
27
- * 1. **Convex cloud** fetch agent IP from Convex `/devices/list` (for cloud machines)
28
- * 2. **Stored connection** try cached URL from last successful connection
29
- * 3. **LAN scan** probe common LAN IPs via `/health` endpoint
31
+ * **Convex is the primary source of truth.** The user's Convex account
32
+ * has the freshest IP / port for each registered agent, updated every
33
+ * 2 minutes via heartbeat. The SDK should therefore:
34
+ * 1. On every `discover()` call, re-query Convex for the latest IP
35
+ * (no local cache shortcut when `convexUrl` + `authToken` are
36
+ * available).
37
+ * 2. Dedup the returned list (Convex can carry stale rows after
38
+ * re-pair) and pick the freshest online machine.
39
+ * 3. Probe the machine's `quicHost:httpPort` directly.
40
+ * 4. If the direct probe fails (different LAN / roaming), route
41
+ * through the configured relay.
42
+ * 5. Store the successful URL only AFTER the probe confirms it's
43
+ * reachable. Stored cache is used only as a last-chance shortcut
44
+ * when Convex itself is unreachable.
45
+ *
46
+ * Compared to the previous implementation this removes the "trust
47
+ * stored URL first" shortcut that caused the SDK to keep trying a dead
48
+ * cached IP long after the Mac's IP rotated.
30
49
  */
31
50
  class YaverDiscovery {
32
- /**
33
- * Discover an agent. Tries Convex cloud first (if configured),
34
- * then stored connection, then LAN scan.
35
- */
36
51
  static async discover(options) {
37
- // Strategy 1: Convex cloud discovery (for cloud machines)
52
+ // Strategy 1: Convex always tried first when credentials are
53
+ // available. No cache shortcut.
38
54
  if (options?.convexUrl && options?.authToken) {
39
55
  const result = await YaverDiscovery.discoverFromConvex(options.convexUrl, options.authToken, options.preferredDeviceId);
40
56
  if (result) {
@@ -42,19 +58,22 @@ class YaverDiscovery {
42
58
  return result;
43
59
  }
44
60
  }
45
- // Strategy 2: Try stored connection
61
+ // Strategy 2: Stored URL. Only used as a fallback when Convex was
62
+ // unreachable. A successful probe here means either the mobile is
63
+ // offline or Convex is — we'll trust the stored IP.
46
64
  const stored = await YaverDiscovery.getStored();
47
65
  if (stored) {
48
66
  const result = await YaverDiscovery.probe(stored.url);
49
- if (result) {
67
+ if (result)
50
68
  return result;
51
- }
52
69
  await YaverDiscovery.clear();
53
70
  }
54
- // Strategy 3: Scan common LAN IPs in parallel
71
+ // Strategy 3: LAN fallback. Small sweep of common subnets. This is
72
+ // only hit when the user has no Convex session (device-local mode)
73
+ // or both Convex + cache lookups failed.
55
74
  const candidates = [];
56
- for (const subnet of SUBNETS) {
57
- for (const suffix of HOST_SUFFIXES) {
75
+ for (const subnet of LAN_SUBNETS) {
76
+ for (const suffix of LAN_HOST_SUFFIXES) {
58
77
  candidates.push(`http://${subnet}.${suffix}:${DEFAULT_PORT}`);
59
78
  }
60
79
  }
@@ -68,13 +87,26 @@ class YaverDiscovery {
68
87
  return null;
69
88
  }
70
89
  /**
71
- * Fetch the agent URL from Convex device list or cloud machines.
72
- * No hardcoded IPs needed Convex knows where the agent is.
90
+ * Re-query Convex ignoring any cached URL. Intended for the call
91
+ * site right after a probe/network failure it's the "the IP
92
+ * probably changed, ask the source of truth again" path.
93
+ */
94
+ static async refreshFromConvex(options) {
95
+ await YaverDiscovery.clear();
96
+ const result = await YaverDiscovery.discoverFromConvex(options.convexUrl, options.authToken, options.preferredDeviceId);
97
+ if (result)
98
+ await YaverDiscovery.store(result);
99
+ return result;
100
+ }
101
+ /**
102
+ * Fetch the agent URL from Convex. Dedups rows, prefers fresh ones,
103
+ * falls back to relay if the direct LAN IP isn't reachable.
73
104
  */
74
105
  static async discoverFromConvex(convexUrl, authToken, preferredDeviceId) {
75
106
  const base = convexUrl.replace(/\/$/, '');
76
107
  try {
77
- // Try cloud machines first (CPU/GPU managed machines)
108
+ // Try cloud machines first (CPU/GPU managed machines). These are
109
+ // long-lived with stable IPs so the direct probe is cheap.
78
110
  const machinesRes = await fetch(`${base}/machines`, {
79
111
  headers: { Authorization: `Bearer ${authToken}` },
80
112
  });
@@ -88,27 +120,71 @@ class YaverDiscovery {
88
120
  return probed;
89
121
  }
90
122
  }
91
- // Fall back to device list (personal machines registered with Convex)
123
+ // Fall back to personal devices registered in Convex.
92
124
  const devicesRes = await fetch(`${base}/devices/list`, {
93
125
  headers: { Authorization: `Bearer ${authToken}` },
94
126
  });
95
127
  if (!devicesRes.ok)
96
128
  return null;
97
129
  const data = await devicesRes.json();
98
- const devices = data.devices ?? data ?? [];
99
- // Find preferred device or first online one
100
- const target = preferredDeviceId
101
- ? devices.find((d) => d.deviceId === preferredDeviceId)
102
- : devices.find((d) => d.isOnline);
103
- if (!target?.quicHost)
130
+ const rawList = Array.isArray(data?.devices) ? data.devices : data;
131
+ if (!Array.isArray(rawList) || rawList.length === 0)
132
+ return null;
133
+ // Normalise Convex fields RemoteDevice shape so dedup works the
134
+ // same way `listReachableDevices` does it.
135
+ const normalised = rawList.map((d) => ({
136
+ deviceId: d.deviceId ?? d.id,
137
+ name: d.name ?? '',
138
+ platform: d.platform ?? d.os ?? '',
139
+ isOnline: !!d.isOnline,
140
+ needsAuth: !!d.needsAuth,
141
+ runnerDown: !!d.runnerDown,
142
+ lastHeartbeat: d.lastHeartbeat ?? 0,
143
+ isGuest: !!d.isGuest,
144
+ hostName: d.hostName,
145
+ hostEmail: d.hostEmail,
146
+ accessScope: d.accessScope ?? 'owner',
147
+ quicHost: d.quicHost ?? d.host ?? '',
148
+ quicPort: d.quicPort ?? 0,
149
+ httpPort: d.httpPort ?? d.quicPort,
150
+ publicKey: d.publicKey,
151
+ hwid: d.hardwareId ?? d.hwid,
152
+ localIps: Array.isArray(d.localIps)
153
+ ? d.localIps
154
+ : Array.isArray(d.lanIps)
155
+ ? d.lanIps
156
+ : undefined,
157
+ }));
158
+ const deduped = (0, deviceDedup_1.collapseRemoteDevices)(normalised);
159
+ const target = (0, deviceDedup_1.pickTargetDevice)(deduped, preferredDeviceId);
160
+ if (!target)
104
161
  return null;
105
- // Try direct connection first (same LAN)
106
- const port = target.httpPort ?? DEFAULT_PORT;
107
- const directUrl = `http://${target.quicHost}:${port}`;
108
- const directResult = await YaverDiscovery.probe(directUrl);
109
- if (directResult)
110
- return directResult;
111
- // Direct connection failed try via HTTP relay (off-LAN)
162
+ // Build the same candidate set the Yaver mobile app races on: the
163
+ // primary `quicHost` plus every LAN IP reported in the latest
164
+ // heartbeat (`localIps`). Multi-homed hosts commonly advertise
165
+ // en0 + utun (tailscale) + docker0 etc.; probing all of them in
166
+ // parallel makes the SDK "just work" on the same Wi-Fi without
167
+ // depending on which NIC the user's router DHCP'd them from.
168
+ const port = target.httpPort ?? target.quicPort ?? DEFAULT_PORT;
169
+ const ipSet = new Set();
170
+ if (target.quicHost)
171
+ ipSet.add(target.quicHost);
172
+ for (const ip of target.localIps ?? []) {
173
+ if (ip)
174
+ ipSet.add(ip);
175
+ }
176
+ const candidates = Array.from(ipSet).map((ip) => `http://${ip}:${port}`);
177
+ if (candidates.length > 0) {
178
+ const direct = await YaverDiscovery.raceProbe(candidates);
179
+ if (direct)
180
+ return direct;
181
+ }
182
+ // Warn if the chosen target looks stale — informative only; we
183
+ // still fell through to the relay path below.
184
+ const stale = target.lastHeartbeat &&
185
+ Date.now() - target.lastHeartbeat > deviceDedup_1.HEARTBEAT_STALE_MS;
186
+ void stale;
187
+ // Direct probes all failed — route through relay.
112
188
  const relayResult = await YaverDiscovery.discoverViaRelay(base, authToken, target.deviceId);
113
189
  if (relayResult)
114
190
  return relayResult;
@@ -119,13 +195,51 @@ class YaverDiscovery {
119
195
  }
120
196
  }
121
197
  /**
122
- * Discover agent via relay HTTP proxy.
123
- * Fetches relay server list from Convex platformConfig, then probes
124
- * `{relayHttpUrl}/d/{deviceId}/health` to reach the agent over the internet.
198
+ * Race `/health` probes across N URLs in parallel. First 200 wins;
199
+ * everything else is abandoned. Mirrors the mobile app's
200
+ * `raceDirectCandidates` pattern the single most reliable thing it
201
+ * does on same-LAN.
202
+ */
203
+ static async raceProbe(urls) {
204
+ if (!urls || urls.length === 0)
205
+ return null;
206
+ const attempts = urls.map((url) => YaverDiscovery.probe(url).then((r) => {
207
+ if (!r)
208
+ throw new Error('no-200');
209
+ return r;
210
+ }));
211
+ try {
212
+ // `Promise.any` isn't on every RN runtime yet (older Hermes).
213
+ // Hand-roll the same behaviour so we don't need a polyfill.
214
+ return await new Promise((resolve) => {
215
+ let remaining = attempts.length;
216
+ let settled = false;
217
+ attempts.forEach((p) => {
218
+ p.then((r) => {
219
+ if (settled)
220
+ return;
221
+ settled = true;
222
+ resolve(r);
223
+ }).catch(() => {
224
+ remaining -= 1;
225
+ if (remaining <= 0 && !settled) {
226
+ settled = true;
227
+ resolve(null);
228
+ }
229
+ });
230
+ });
231
+ });
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ }
237
+ /**
238
+ * Discover agent via relay HTTP proxy. Uses the user's configured
239
+ * relay first (from `/auth/validate`), then the platform relay list.
125
240
  */
126
241
  static async discoverViaRelay(convexUrl, authToken, deviceId) {
127
242
  try {
128
- // Fetch relay server list from user settings first, then platform config
129
243
  const settingsRes = await fetch(`${convexUrl}/auth/validate`, {
130
244
  headers: { Authorization: `Bearer ${authToken}` },
131
245
  });
@@ -136,7 +250,6 @@ class YaverDiscovery {
136
250
  relayUrl = settingsData.relayUrl;
137
251
  relayPassword = settingsData.relayPassword;
138
252
  }
139
- // If no user-level relay, fetch platform relay servers
140
253
  if (!relayUrl) {
141
254
  const configRes = await fetch(`${convexUrl}/platform-config?key=relay_servers`);
142
255
  if (configRes.ok) {
@@ -145,36 +258,29 @@ class YaverDiscovery {
145
258
  ? JSON.parse(configData.value)
146
259
  : configData.value;
147
260
  if (Array.isArray(servers) && servers.length > 0) {
148
- // Pick the first (highest priority) relay with an httpUrl
149
261
  const relay = servers.find((s) => s.httpUrl);
150
- if (relay) {
262
+ if (relay)
151
263
  relayUrl = relay.httpUrl;
152
- }
153
264
  }
154
265
  }
155
266
  }
156
267
  if (!relayUrl)
157
268
  return null;
158
- // Probe agent through relay: {relayHttpUrl}/d/{deviceId}/health
159
269
  const relayBase = `${relayUrl.replace(/\/$/, '')}/d/${deviceId}`;
160
- const result = await YaverDiscovery.probeWithHeaders(relayBase, {
270
+ return YaverDiscovery.probeWithHeaders(relayBase, {
161
271
  'X-Relay-Password': relayPassword || '',
162
272
  });
163
- return result;
164
273
  }
165
274
  catch {
166
275
  return null;
167
276
  }
168
277
  }
169
- /**
170
- * Probe with extra headers (e.g. relay password).
171
- */
172
278
  static async probeWithHeaders(url, headers) {
173
279
  const base = url.replace(/\/$/, '');
174
280
  const start = Date.now();
175
281
  try {
176
282
  const controller = new AbortController();
177
- const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS + 3000); // relay adds latency
283
+ const timeoutId = setTimeout(() => controller.abort(), RELAY_PROBE_TIMEOUT_MS);
178
284
  const response = await fetch(`${base}/health`, {
179
285
  method: 'GET',
180
286
  headers,
@@ -192,7 +298,7 @@ class YaverDiscovery {
192
298
  version = data.version ?? 'unknown';
193
299
  }
194
300
  catch {
195
- // Health endpoint might return plain text
301
+ // /health may return plain text
196
302
  }
197
303
  return { url: base, hostname, version, latency };
198
304
  }
@@ -200,24 +306,20 @@ class YaverDiscovery {
200
306
  return null;
201
307
  }
202
308
  }
203
- /**
204
- * Probe a specific URL for a running Yaver agent.
205
- * Hits the `/health` endpoint with a 2s timeout.
206
- */
309
+ /** Probe a specific URL for a running Yaver agent (2.5 s timeout). */
207
310
  static async probe(url) {
208
311
  const base = url.replace(/\/$/, '');
209
312
  const start = Date.now();
210
313
  try {
211
314
  const controller = new AbortController();
212
- const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
315
+ const timeoutId = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
213
316
  const response = await fetch(`${base}/health`, {
214
317
  method: 'GET',
215
318
  signal: controller.signal,
216
319
  });
217
320
  clearTimeout(timeoutId);
218
- if (!response.ok) {
321
+ if (!response.ok)
219
322
  return null;
220
- }
221
323
  const latency = Date.now() - start;
222
324
  let hostname = 'Unknown';
223
325
  let version = 'unknown';
@@ -227,7 +329,7 @@ class YaverDiscovery {
227
329
  version = data.version ?? 'unknown';
228
330
  }
229
331
  catch {
230
- // Health endpoint might return plain text — that's fine
332
+ // /health may return plain text
231
333
  }
232
334
  return { url: base, hostname, version, latency };
233
335
  }
@@ -235,18 +337,12 @@ class YaverDiscovery {
235
337
  return null;
236
338
  }
237
339
  }
238
- /**
239
- * Manually connect to a specific agent URL.
240
- * Probes the URL and stores the connection if successful.
241
- */
242
340
  static async connect(url) {
243
341
  const result = await YaverDiscovery.probe(url);
244
- if (result) {
342
+ if (result)
245
343
  await YaverDiscovery.store(result);
246
- }
247
344
  return result;
248
345
  }
249
- /** Get the cached agent connection from storage. */
250
346
  static async getStored() {
251
347
  const storage = getAsyncStorage();
252
348
  if (!storage)
@@ -265,7 +361,6 @@ class YaverDiscovery {
265
361
  return null;
266
362
  }
267
363
  }
268
- /** Store a successful discovery result. */
269
364
  static async store(result) {
270
365
  const storage = getAsyncStorage();
271
366
  if (!storage)
@@ -277,7 +372,6 @@ class YaverDiscovery {
277
372
  // Storage failure is non-fatal
278
373
  }
279
374
  }
280
- /** Clear the stored agent connection. */
281
375
  static async clear() {
282
376
  const storage = getAsyncStorage();
283
377
  if (!storage)
@@ -75,17 +75,44 @@ const FeedbackModal = () => {
75
75
  setToast(null);
76
76
  setAction('idle');
77
77
  }, []);
78
- // ─── 1. Hot reload ─────────────────────────────────────────────────
79
- const handleHotReload = (0, react_1.useCallback)(async () => {
80
- const client = YaverFeedback_1.YaverFeedback.getP2PClient();
78
+ // Helper: run a P2P call; on network failure, ask YaverFeedback to
79
+ // re-query Convex for the fresh IP and retry once. Solves the common
80
+ // case where the Mac's LAN IP rotated while the SDK held a stale URL.
81
+ const runWithReconnect = (0, react_1.useCallback)(async (fn) => {
82
+ let client = YaverFeedback_1.YaverFeedback.getP2PClient();
81
83
  if (!client) {
82
- setError('Not connected to the agent yet.');
83
- return;
84
+ const ok = await YaverFeedback_1.YaverFeedback.reconnect();
85
+ if (ok)
86
+ client = YaverFeedback_1.YaverFeedback.getP2PClient();
87
+ }
88
+ if (!client) {
89
+ throw new Error('Not connected to the agent yet.');
84
90
  }
91
+ try {
92
+ await fn(client);
93
+ }
94
+ catch (err) {
95
+ const msg = err instanceof Error ? err.message : String(err);
96
+ const transient = /Network request failed|timeout|ECONNREFUSED|Failed to fetch|fetch failed|aborted/i.test(msg);
97
+ if (!transient)
98
+ throw err;
99
+ const ok = await YaverFeedback_1.YaverFeedback.reconnect();
100
+ if (!ok)
101
+ throw err;
102
+ const fresh = YaverFeedback_1.YaverFeedback.getP2PClient();
103
+ if (!fresh)
104
+ throw err;
105
+ await fn(fresh);
106
+ }
107
+ }, []);
108
+ // ─── 1. Hot reload ─────────────────────────────────────────────────
109
+ const handleHotReload = (0, react_1.useCallback)(async () => {
85
110
  setAction('hot-reloading');
86
111
  setError(null);
87
112
  try {
88
- await client.reloadApp('dev');
113
+ await runWithReconnect(async (client) => {
114
+ await client.reloadApp('dev');
115
+ });
89
116
  setToast('Reload sent');
90
117
  closeSoon(800);
91
118
  }
@@ -96,7 +123,7 @@ const FeedbackModal = () => {
96
123
  if (mountedRef.current)
97
124
  setAction('idle');
98
125
  }
99
- }, [closeSoon]);
126
+ }, [closeSoon, runWithReconnect]);
100
127
  // ─── 2. Screenshot + Fix ───────────────────────────────────────────
101
128
  //
102
129
  // Hide the modal first so the screenshot captures the actual screen
@@ -79,7 +79,11 @@ const YaverMachinePickerScreen = ({ token, currentDeviceId, onPick, onCancel, })
79
79
  };
80
80
  const renderDevice = (device) => {
81
81
  const selected = device.deviceId === currentDeviceId;
82
- const stale = Date.now() - device.lastHeartbeat > 60000;
82
+ // Match the Yaver mobile app: HEARTBEAT_STALE_MS is 90 s. Using
83
+ // 60 s here flashed yellow on a single missed agent beat even
84
+ // though the Mac was up.
85
+ const stale = device.lastHeartbeat > 0 &&
86
+ Date.now() - device.lastHeartbeat > 90000;
83
87
  const healthColor = !device.isOnline
84
88
  ? '#ef4444'
85
89
  : device.needsAuth || device.runnerDown || stale
@@ -19,6 +19,16 @@ export declare class YaverFeedback {
19
19
  * Sets config.agentUrl and creates P2PClient on success.
20
20
  */
21
21
  static discoverAgent(): Promise<void>;
22
+ /**
23
+ * Force a fresh Convex lookup for the agent URL — ignoring any
24
+ * cached URL. Callers use this after a P2P request fails
25
+ * (connection refused / timeout) because the most common cause is
26
+ * the Mac's LAN IP rotating. Convex has the fresh one, so we
27
+ * re-query and probe `[quicHost, ...localIps]` in parallel.
28
+ *
29
+ * Returns true when a new URL was adopted.
30
+ */
31
+ static reconnect(): Promise<boolean>;
22
32
  /**
23
33
  * Pull a cached session token + selected device from AsyncStorage (populated
24
34
  * by the in-SDK login + machine-picker screens). When present the SDK can
@@ -188,6 +188,36 @@ class YaverFeedback {
188
188
  // Discovery failed — FloatingButton will show disconnected, user can retry
189
189
  }
190
190
  }
191
+ /**
192
+ * Force a fresh Convex lookup for the agent URL — ignoring any
193
+ * cached URL. Callers use this after a P2P request fails
194
+ * (connection refused / timeout) because the most common cause is
195
+ * the Mac's LAN IP rotating. Convex has the fresh one, so we
196
+ * re-query and probe `[quicHost, ...localIps]` in parallel.
197
+ *
198
+ * Returns true when a new URL was adopted.
199
+ */
200
+ static async reconnect() {
201
+ if (!config || !enabled)
202
+ return false;
203
+ if (!config.authToken || !config.convexUrl)
204
+ return false;
205
+ try {
206
+ const result = await Discovery_1.YaverDiscovery.refreshFromConvex({
207
+ convexUrl: config.convexUrl,
208
+ authToken: config.authToken,
209
+ preferredDeviceId: config.preferredDeviceId,
210
+ });
211
+ if (!result)
212
+ return false;
213
+ config.agentUrl = result.url;
214
+ p2pClient = new P2PClient_1.P2PClient(result.url, config.authToken ?? '');
215
+ return true;
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ }
191
221
  /**
192
222
  * Pull a cached session token + selected device from AsyncStorage (populated
193
223
  * by the in-SDK login + machine-picker screens). When present the SDK can
package/dist/auth.d.ts CHANGED
@@ -105,7 +105,17 @@ export interface RemoteDevice {
105
105
  accessScope: 'owner' | 'shared-scoped' | 'shared-legacy';
106
106
  quicHost: string;
107
107
  quicPort: number;
108
+ /** Agent HTTP port — preferred over quicPort when present. */
109
+ httpPort?: number;
108
110
  publicKey?: string;
111
+ /** Hardware identifier — used for dedup across re-pair events. */
112
+ hwid?: string;
113
+ /**
114
+ * Every LAN IP the agent reported in its last heartbeat. Useful on
115
+ * multi-homed hosts — probing all of them in parallel is the same
116
+ * trick the Yaver mobile app uses to "just work" on the same Wi-Fi.
117
+ */
118
+ localIps?: string[];
109
119
  }
110
120
  export interface DeviceList {
111
121
  owned: RemoteDevice[];
@@ -114,5 +124,9 @@ export interface DeviceList {
114
124
  /**
115
125
  * Fetch the set of remote dev machines this user can reach. Splits into
116
126
  * owned (user is the host) vs shared (host invited them as a guest).
127
+ *
128
+ * Collapses duplicate rows before splitting — Convex can return multiple
129
+ * rows per physical machine after a re-pair or hostname change, and the
130
+ * raw list used to render as "Kvancs-MacBook-Air.local ×3" in the picker.
117
131
  */
118
132
  export declare function listReachableDevices(token: string): Promise<DeviceList>;