@chenacc/agentbridge 0.2.2 → 0.2.4

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": "@chenacc/agentbridge",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Control local coding agents from a phone through a secure WeChat QR web console.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -65,7 +65,7 @@
65
65
  "verify:responsive": "node scripts/verify-responsive.mjs",
66
66
  "verify:guide": "node scripts/verify-product-guide.mjs",
67
67
  "verify:release": "node scripts/verify-plugin-release.mjs",
68
- "check": "node --check src/server.js && node --check src/cli.js && node --check src/config.js && node --check src/cc-switch-models.js && node --check src/workspaces/workspace-catalog.js && node --check src/conversations/conversation-catalog.js && node --check src/adapters/capabilities.js && node --check src/adapters/claude.js && node --check src/adapters/codex.js && node --check src/adapters/deepseek-harness.js && node --check src/adapters/dsh-desktop.js && node --check src/dsh-desktop-channels.js && node --check src/event-store.js && node --check src/notifier.js && node --check src/attachments.js && node --check src/session-manager.js && node --check integrations/dsh-phone/lib/index.js && node --check src/tunnel.js && node --check src/remote.js && node --check src/adapters/zcode-native.js && node --check public/adapter-capabilities.js && node --check public/workspace-navigation.js && node --check public/markdown.js && node --check public/highlight.js && node --check public/transcript.js && node --check public/app.js && node --check scripts/verify-workspace-ui.mjs && node --check scripts/verify-message-blocks.mjs && node --check scripts/verify-background-scrim.mjs && node --check scripts/verify-model-control.mjs && node --check scripts/verify-notifications.mjs && node --check scripts/verify-attachments.mjs && node --check scripts/verify-features.mjs && node --check scripts/verify-quick-nav.mjs && node --check scripts/verify-questions.mjs && node --check scripts/verify-responsive.mjs && node --check scripts/verify-product-guide.mjs && node --check scripts/verify-plugin-release.mjs && node --check scripts/capture-screenshots.mjs",
68
+ "check": "node --check src/server.js && node --check src/cli.js && node --check src/config.js && node --check src/cc-switch-models.js && node --check src/workspaces/workspace-catalog.js && node --check src/conversations/conversation-catalog.js && node --check src/adapters/capabilities.js && node --check src/adapters/claude.js && node --check src/adapters/codex.js && node --check src/adapters/deepseek-harness.js && node --check src/adapters/dsh-desktop.js && node --check src/dsh-desktop-channels.js && node --check src/event-store.js && node --check src/notifier.js && node --check src/attachments.js && node --check src/session-manager.js && node --check integrations/dsh-phone/lib/index.js && node --check src/tunnel.js && node --check src/remote.js && node --check src/origin-announcer.js && node --check src/adapters/zcode-native.js && node --check public/adapter-capabilities.js && node --check public/workspace-navigation.js && node --check public/markdown.js && node --check public/highlight.js && node --check public/transcript.js && node --check public/app.js && node --check scripts/verify-workspace-ui.mjs && node --check scripts/verify-message-blocks.mjs && node --check scripts/verify-background-scrim.mjs && node --check scripts/verify-model-control.mjs && node --check scripts/verify-notifications.mjs && node --check scripts/verify-attachments.mjs && node --check scripts/verify-features.mjs && node --check scripts/verify-quick-nav.mjs && node --check scripts/verify-questions.mjs && node --check scripts/verify-responsive.mjs && node --check scripts/verify-product-guide.mjs && node --check scripts/verify-plugin-release.mjs && node --check scripts/capture-screenshots.mjs",
69
69
  "verify:remote": "node scripts/verify-remote-tunnel.mjs",
70
70
  "capture:shots": "node scripts/capture-screenshots.mjs"
71
71
  },
package/public/app.js CHANGED
@@ -310,22 +310,84 @@ async function resolveRequest(requestId, body) {
310
310
  }
311
311
 
312
312
  async function api(path, options = {}) {
313
- const response = await fetch(path, {
314
- credentials: "same-origin",
315
- ...options,
316
- headers: options.body ? { "content-type": "application/json", ...(options.headers ?? {}) } : options.headers,
317
- });
313
+ let response;
314
+ try {
315
+ response = await fetch(path, {
316
+ credentials: "same-origin",
317
+ ...options,
318
+ headers: options.body ? { "content-type": "application/json", ...(options.headers ?? {}) } : options.headers,
319
+ });
320
+ } catch (error) {
321
+ // No HTTP status at all: the origin itself is unreachable — almost always
322
+ // because the tunnel hostname changed and this page is still on the old one.
323
+ noteConnectionFailure("offline");
324
+ throw error;
325
+ }
318
326
  const body = response.headers.get("content-type")?.includes("application/json") ? await response.json() : null;
319
327
  if (!response.ok) {
328
+ if (response.status === 401) noteConnectionFailure("unauthorized");
320
329
  const error = new Error(body?.error ?? `请求失败 (${response.status})`);
321
330
  error.status = response.status;
322
331
  error.code = body?.code;
323
332
  error.diagnostics = body?.diagnostics;
324
333
  throw error;
325
334
  }
335
+ noteConnectionSuccess();
326
336
  return body;
327
337
  }
328
338
 
339
+ /**
340
+ * A page that cannot reach its bridge must say so.
341
+ *
342
+ * After a tunnel rebuild the phone keeps rendering the last transcript and the
343
+ * connection chip just says 正在重连 — which reads as "still working" while every
344
+ * request is already failing. Two failures in a row is enough evidence to show a
345
+ * banner that explains what to do, because there is nothing else the page can do:
346
+ * the address it was loaded from no longer exists.
347
+ */
348
+ const connectionWatch = { failures: 0, banner: null };
349
+
350
+ function noteConnectionFailure(kind) {
351
+ connectionWatch.failures += 1;
352
+ if (connectionWatch.failures < 2) return;
353
+ showStaleBanner(kind);
354
+ }
355
+
356
+ function noteConnectionSuccess() {
357
+ connectionWatch.failures = 0;
358
+ if (connectionWatch.banner) {
359
+ connectionWatch.banner.remove();
360
+ connectionWatch.banner = null;
361
+ }
362
+ }
363
+
364
+ function showStaleBanner(kind) {
365
+ if (connectionWatch.banner) return;
366
+ const banner = document.createElement("div");
367
+ banner.id = "stale-banner";
368
+ banner.setAttribute("role", "alert");
369
+ banner.style.cssText = [
370
+ "position:fixed", "inset:0 0 auto 0", "z-index:9999",
371
+ "padding:12px 16px", "font:14px/1.5 system-ui,-apple-system,sans-serif",
372
+ "background:#b3261e", "color:#fff", "box-shadow:0 2px 8px rgba(0,0,0,.4)",
373
+ ].join(";");
374
+ banner.textContent = kind === "unauthorized"
375
+ ? "登录状态已失效:这台手机的配对属于旧地址。请在电脑上重新执行 /phone,扫描新的二维码。"
376
+ : "连不上电脑:二维码地址可能已经更换(隧道重建会换域名)。请在电脑上重新执行 /phone 并扫描新二维码。";
377
+ const dismiss = document.createElement("button");
378
+ dismiss.textContent = "知道了";
379
+ dismiss.style.cssText = "margin-left:12px;padding:4px 10px;border:0;border-radius:6px;background:rgba(255,255,255,.2);color:#fff;font:inherit";
380
+ dismiss.addEventListener("click", () => {
381
+ banner.remove();
382
+ connectionWatch.banner = null;
383
+ // Keep counting: a still-broken page will show it again on the next failure.
384
+ connectionWatch.failures = 0;
385
+ });
386
+ banner.appendChild(dismiss);
387
+ document.body.appendChild(banner);
388
+ connectionWatch.banner = banner;
389
+ }
390
+
329
391
  function toast(message) {
330
392
  const element = $("#toast");
331
393
  element.textContent = message;
package/src/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
- import { mkdir, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -52,23 +52,37 @@ Tell the user to scan the QR with WeChat. While the phone controls this session,
52
52
  `;
53
53
  }
54
54
 
55
+ /**
56
+ * The Codex skill is named `phone-codex`, not `phone`.
57
+ *
58
+ * `~/.agents/skills` is shared: Codex reads it, and so does DSH Desktop. A skill
59
+ * called `phone` therefore shadows the DSH plugin's `/phone` command, and typing
60
+ * `/phone` in DSH Desktop runs `phone --agent codex` — pairing a Codex
61
+ * conversation instead of the one on screen. One name per client: the plugin owns
62
+ * `/phone`, this skill answers `/phone-codex`.
63
+ */
64
+ export const CODEX_SKILL_NAME = "phone-codex";
65
+ const CODEX_SKILL_MARKER = "# Send a Codex project to AgentBridge";
66
+
55
67
  export function renderCodexPhoneSkill(entryPath = cliEntry) {
56
68
  const portableEntry = entryPath.replaceAll("\\", "/").replaceAll('"', '\\"');
57
69
  return `---
58
- name: phone
59
- description: Generate a one-time WeChat QR code for a Codex project conversation. Use only when the user explicitly invokes /phone.
70
+ name: ${CODEX_SKILL_NAME}
71
+ description: Generate a one-time WeChat QR code for a Codex project conversation. Use only when the user explicitly invokes /${CODEX_SKILL_NAME}.
60
72
  disable-model-invocation: true
61
73
  ---
62
74
 
63
- # Send a Codex project to AgentBridge
75
+ ${CODEX_SKILL_MARKER}
64
76
 
65
- When the user invokes \`/phone\`, you must immediately execute the following command in the terminal from the current project directory. Do not merely describe the command or ask the user to run it:
77
+ When the user invokes \`/${CODEX_SKILL_NAME}\`, you must immediately execute the following command in the terminal from the current project directory. Do not merely describe the command or ask the user to run it:
66
78
 
67
79
  \`node "${portableEntry}" phone --agent codex --cwd .\`
68
80
 
69
81
  After a successful command, reply with the exact QR image Markdown emitted by the command (for example, \`![微信扫码二维码](<temporary QR image URL>)\`) so Codex Desktop renders a scannable image. The temporary QR image URL and pairing URL must be shown as clickable links; then tell the user to scan the 微信扫码二维码 with WeChat. Do not invent or alter either URL. If the command fails, show its exact safe error and do not claim that a QR code was generated.
70
82
 
71
83
  If the user supplies a known persisted Codex thread ID, append \`--session <thread-id>\` to resume that exact thread. Do not claim that the command attached the current desktop conversation when no thread ID was supplied: Codex does not expose a documented current-thread substitution to this local skill.
84
+
85
+ > Named \`${CODEX_SKILL_NAME}\` on purpose: DSH Desktop reads the same skills folder and owns \`/phone\` for the conversation on screen. Running \`/phone\` here would pair the wrong Agent.
72
86
  `;
73
87
  }
74
88
 
@@ -271,17 +285,51 @@ async function installClaudeSkill(options) {
271
285
  return target;
272
286
  }
273
287
 
288
+ /**
289
+ * Remove a `phone` skill this installer used to create.
290
+ *
291
+ * Earlier releases wrote the Codex skill as `phone`, which shadowed the DSH
292
+ * plugin's `/phone` in DSH Desktop because both clients read `~/.agents/skills`.
293
+ * Re-running the installer has to clean that up, or the wrong pairing keeps
294
+ * happening after an upgrade. Only a file carrying our own marker is touched —
295
+ * a hand-written `phone` skill belongs to whoever wrote it.
296
+ */
297
+ async function removeLegacyCodexSkill(root) {
298
+ const legacy = path.join(root, "phone", "SKILL.md");
299
+ let content;
300
+ try {
301
+ content = await readFile(legacy, "utf8");
302
+ } catch {
303
+ return null;
304
+ }
305
+ if (!content.includes(CODEX_SKILL_MARKER)) return null;
306
+ try {
307
+ await rm(path.join(root, "phone"), { recursive: true, force: true });
308
+ return legacy;
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+
274
314
  async function installCodexSkill(options) {
275
315
  if (options.user && options.project) throw new Error("Choose only one of --user or --project");
276
316
  const projectCwd = path.resolve(options.cwd ?? process.cwd());
277
317
  const root = options.project
278
318
  ? path.join(projectCwd, ".agents", "skills")
279
319
  : path.join(os.homedir(), ".agents", "skills");
280
- const target = path.join(root, "phone", "SKILL.md");
320
+
321
+ const removed = await removeLegacyCodexSkill(root);
322
+ if (removed) {
323
+ console.log(`已移除会抢占 /phone 的旧技能: ${removed}`);
324
+ console.log("(DSH Desktop 也读这个目录,旧的 phone 技能会让 /phone 绑到 Codex 会话。)");
325
+ }
326
+
327
+ const target = path.join(root, CODEX_SKILL_NAME, "SKILL.md");
281
328
  await mkdir(path.dirname(target), { recursive: true });
282
329
  await writeFile(target, renderCodexPhoneSkill(), { encoding: "utf8", flag: "w" });
283
- console.log(`Codex /phone 已安装到: ${target}`);
284
- console.log("重新打开 Codex 桌面项目后,在斜杠命令列表选择 /phone。未提供 thread ID 时,它会创建新的项目会话并清楚标注。");
330
+ console.log(`Codex /${CODEX_SKILL_NAME} 已安装到: ${target}`);
331
+ console.log(`重新打开 Codex 桌面项目后,在斜杠命令列表选择 /${CODEX_SKILL_NAME}。未提供 thread ID 时,它会创建新的项目会话并清楚标注。`);
332
+ console.log("DSH Desktop 里的 /phone 保持归插件所有,绑定的就是屏幕上那条会话。");
285
333
  return target;
286
334
  }
287
335
 
package/src/notifier.js CHANGED
@@ -63,6 +63,19 @@ export function describeNotification(kind, payload = {}, context = {}) {
63
63
  case "notify.test": {
64
64
  return { title: "🔔 AgentBridge 通知测试", text: [`${where}`, "如果你看到这条消息,通知链路是通的。"].filter(Boolean).join("\n") };
65
65
  }
66
+ case "bridge.moved": {
67
+ // The one message a phone cannot get any other way: it is still sitting on
68
+ // the old address, so its own page can no longer be told anything.
69
+ return {
70
+ title: "🔄 AgentBridge 地址已更换",
71
+ text: [
72
+ `${where}`,
73
+ "隧道重建后二维码地址变了,手机上的旧链接已经打不开。",
74
+ `新地址:${clip(payload.origin ?? "", 200)}`,
75
+ "在电脑上重新执行 /phone 并扫描新二维码,或直接打开上面的新地址。",
76
+ ].filter(Boolean).join("\n"),
77
+ };
78
+ }
66
79
  default:
67
80
  return { title: DEFAULT_TITLES[kind] ?? "AgentBridge", text: where };
68
81
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Tell an already-paired phone that the bridge moved.
3
+ *
4
+ * A Cloudflare quick tunnel hands out a new hostname every time the tunnel is
5
+ * rebuilt, and the bridge is rebuilt often (desktop restart, tunnel restart). The
6
+ * phone is sitting on the old origin: its page still renders, every request from
7
+ * it fails, and nothing on the desktop says so. The QR code is not much help
8
+ * either — the person is not looking at the desktop any more.
9
+ *
10
+ * So the address change is announced over the channel that still works when the
11
+ * web page cannot: the push notification the user configured. One message per
12
+ * change, only when a device actually exists to tell, and never on the first run
13
+ * (nothing has "moved" yet).
14
+ *
15
+ * Everything that touches the machine is injectable because this decides whether a
16
+ * third party gets a message, which is worth testing without a network.
17
+ */
18
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
19
+ import path from "node:path";
20
+
21
+ export function announcedOriginPath(dataDir) {
22
+ return path.join(dataDir, "advertised-origin.json");
23
+ }
24
+
25
+ export function readAnnouncedOrigin(dataDir, { readFile = readFileSync } = {}) {
26
+ try {
27
+ const parsed = JSON.parse(String(readFile(announcedOriginPath(dataDir), "utf8")));
28
+ return typeof parsed?.origin === "string" ? parsed.origin : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export function writeAnnouncedOrigin(dataDir, origin, { writeFile = writeFileSync, rename = renameSync, now = () => new Date().toISOString() } = {}) {
35
+ const target = announcedOriginPath(dataDir);
36
+ const temporary = `${target}.tmp`;
37
+ writeFile(temporary, JSON.stringify({ origin, recordedAt: now() }, null, 2), "utf8");
38
+ rename(temporary, target);
39
+ return origin;
40
+ }
41
+
42
+ /** Whether this machine has ever recorded an address (i.e. it is not the first run). */
43
+ export function hasAnnouncedBefore(dataDir, options = {}) {
44
+ return readAnnouncedOrigin(dataDir, options) !== null;
45
+ }
46
+
47
+ /**
48
+ * Compare the address with the last one this bridge announced.
49
+ *
50
+ * Returns `{ announced, reason }`. A failure to notify is not thrown: the bridge is
51
+ * already serving, and a dead webhook must not be able to stop that.
52
+ */
53
+ export async function announceOriginChange({
54
+ dataDir,
55
+ origin,
56
+ notifier = null,
57
+ deviceCount = 0,
58
+ machineName = "",
59
+ log = () => {},
60
+ read = readAnnouncedOrigin,
61
+ write = writeAnnouncedOrigin,
62
+ } = {}) {
63
+ if (!dataDir || !origin) return { announced: false, reason: "missing-argument" };
64
+
65
+ const previous = read(dataDir);
66
+ if (previous === origin) return { announced: false, reason: "unchanged" };
67
+
68
+ // Record first: a crash mid-announcement must not turn one tunnel restart into a
69
+ // notification loop on every subsequent launch.
70
+ write(dataDir, origin);
71
+
72
+ if (!previous) {
73
+ log(`Recorded the first advertised address: ${origin}`);
74
+ return { announced: false, reason: "first-run" };
75
+ }
76
+ if (deviceCount <= 0) return { announced: false, reason: "no-devices" };
77
+ if (!notifier?.enabled) return { announced: false, reason: "notifications-disabled" };
78
+
79
+ try {
80
+ const result = await notifier.send(
81
+ "bridge.moved",
82
+ { previous, origin },
83
+ { sessionTitle: machineName || "AgentBridge", adapterId: "agentbridge" },
84
+ );
85
+ if (result?.sent) log(`Announced the new address to ${deviceCount} paired device(s): ${origin}`);
86
+ else log(`Address change not announced: ${result?.reason ?? "unknown"}`);
87
+ return { announced: result?.sent === true, reason: result?.reason ?? null, previous };
88
+ } catch (error) {
89
+ log(`Address change announcement failed: ${error instanceof Error ? error.message : String(error)}`);
90
+ return { announced: false, reason: "send-failed", previous };
91
+ }
92
+ }
package/src/server.js CHANGED
@@ -5,6 +5,7 @@ import { readFile, stat } from "node:fs/promises";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import QRCode from "qrcode";
7
7
  import { loadConfig, isLoopback } from "./config.js";
8
+ import { announceOriginChange } from "./origin-announcer.js";
8
9
  import { EventStore } from "./event-store.js";
9
10
  import { PairingManager } from "./pairing.js";
10
11
  import { AdapterRegistry } from "./adapter-registry.js";
@@ -33,6 +34,9 @@ function json(res, statusCode, value, headers = {}) {
33
34
  res.writeHead(statusCode, {
34
35
  "content-type": "application/json; charset=utf-8",
35
36
  "cache-control": "no-store",
37
+ // A renewal cookie rides every authenticated JSON response when the session
38
+ // was just extended; explicit per-handler cookies (logout, revoke) still win.
39
+ ...(res.renewCookie ? { "set-cookie": res.renewCookie } : {}),
36
40
  ...headers,
37
41
  });
38
42
  res.end(JSON.stringify(value));
@@ -601,6 +605,16 @@ export async function createBridge(options = {}) {
601
605
  // must not consume a pairing code, mint a device, or take the lease away
602
606
  // from the phone that is already driving this conversation.
603
607
  const reconnecting = body.reconnect === true;
608
+ // An automatic restore (the desktop app just restarted and is opening a
609
+ // conversation the phone already owns) passes `existingOnly`: it must bind
610
+ // the bridge session that already exists and never create a new one, or
611
+ // simply opening a never-paired conversation would fill the phone's
612
+ // workspace drawer with sessions nobody asked for.
613
+ if (reconnecting && body.existingOnly === true
614
+ && !sessions.findByProvider("deepseek-harness-desktop", providerSessionId)) {
615
+ dshDesktopChannels.revoke(registration.id);
616
+ throw Object.assign(new Error("No AgentBridge session is bound to this conversation"), { statusCode: 404 });
617
+ }
604
618
  const device = reconnecting ? null : pairing.createSession("DSH Desktop /phone");
605
619
  let session;
606
620
  try {
@@ -638,6 +652,27 @@ export async function createBridge(options = {}) {
638
652
  });
639
653
  }
640
654
 
655
+ // Which conversations should a freshly started desktop companion re-attach?
656
+ //
657
+ // After DSH Desktop updates or restarts, the bridge still has the sessions
658
+ // and history the phone was using, but the desktop-side channel is gone and
659
+ // nothing re-registers it: the phone reads "桌面端未连接" until someone runs
660
+ // /phone again. The companion asks this endpoint on startup and whenever a
661
+ // conversation is opened, and re-registers only these — a never-paired
662
+ // conversation stays silent instead of appearing as a new session.
663
+ if (req.method === "GET" && url.pathname === "/api/local/dsh-desktop/expected") {
664
+ if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
665
+ const providerSessionIds = [];
666
+ for (const session of sessions.list()) {
667
+ if (session.adapterId !== "deepseek-harness-desktop") continue;
668
+ const providerSessionId = session.providerSessionId;
669
+ if (!providerSessionId) continue;
670
+ if (dshDesktopChannels.isOnline(providerSessionId)) continue;
671
+ providerSessionIds.push(providerSessionId);
672
+ }
673
+ return json(res, 200, { providerSessionIds: [...new Set(providerSessionIds)] });
674
+ }
675
+
641
676
  if (req.method === "POST" && url.pathname === "/api/pairings/exchange") {
642
677
  requireAccessKey(req, url, config);
643
678
  requireSameOrigin(req);
@@ -703,6 +738,19 @@ export async function createBridge(options = {}) {
703
738
  const device = pairing.authenticate(requestToken(req));
704
739
  if (!device) throw Object.assign(new Error("Authentication required"), { statusCode: 401 });
705
740
 
741
+ // Sliding renewal: a phone that keeps using the console is never asked to
742
+ // scan a QR again merely because its original window ran out. The refreshed
743
+ // cookie rides whichever response this request produces (JSON or SSE), so
744
+ // opening the app once is enough for the device to stay paired. Idle
745
+ // sessions still expire on the normal TTL.
746
+ if (pairing.renewIfNeeded(device.id)) {
747
+ res.renewCookie = sessionCookie(
748
+ requestToken(req),
749
+ config.secureCookies && !isLoopback(req.socket.remoteAddress),
750
+ config.sessionTtlMs,
751
+ );
752
+ }
753
+
706
754
  if (req.method === "GET" && url.pathname === "/api/devices") {
707
755
  return json(res, 200, { devices: pairing.listDevices(), currentDeviceId: device.id });
708
756
  }
@@ -820,6 +868,7 @@ export async function createBridge(options = {}) {
820
868
  "cache-control": "no-cache, no-transform",
821
869
  connection: "keep-alive",
822
870
  "x-accel-buffering": "no",
871
+ ...(res.renewCookie ? { "set-cookie": res.renewCookie } : {}),
823
872
  });
824
873
  res.write(": connected\n\n");
825
874
  const writeEvent = (event) => {
@@ -1005,6 +1054,17 @@ export async function startBridge() {
1005
1054
  + "(set it in the desktop console, or point AGENTBRIDGE_NOTIFY_IMPORT at an older .agentbridge folder)",
1006
1055
  );
1007
1056
  }
1057
+ // A quick tunnel gets a new hostname every time it is rebuilt, and the phone that
1058
+ // is already paired is sitting on the old one: its own page can no longer be told
1059
+ // anything, so the change goes out over the push channel instead.
1060
+ void announceOriginChange({
1061
+ dataDir: bridge.config.dataDir,
1062
+ origin: bridge.config.advertisedOrigin,
1063
+ notifier: bridge.notifier,
1064
+ deviceCount: bridge.pairing.listDevices().length,
1065
+ machineName: bridge.config.machineName,
1066
+ log: (message) => console.log(message),
1067
+ }).catch((error) => console.warn(`Address announcement skipped: ${error.message}`));
1008
1068
  // Silence here used to mean a QR encoding an address the phone could never reach.
1009
1069
  if (new URL(bridge.config.advertisedOrigin).hostname === "127.0.0.1") {
1010
1070
  console.warn(