agentbox-app 0.1.12 → 0.1.13

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.
Files changed (214) hide show
  1. package/main/acp.mjs +296 -0
  2. package/main/agent-homes.mjs +158 -0
  3. package/main/agent-sessions.mjs +57 -20
  4. package/main/agent-updates.mjs +9 -22
  5. package/main/analytics.mjs +30 -0
  6. package/main/claude-bin.mjs +45 -311
  7. package/main/claude-input.mjs +9 -3
  8. package/main/claude-launch-env.mjs +54 -0
  9. package/main/claude-models.mjs +39 -7
  10. package/main/claude-plan.mjs +16 -16
  11. package/main/claude-usage.mjs +82 -25
  12. package/main/codex-bin.mjs +51 -249
  13. package/main/codex-session.mjs +38 -3
  14. package/main/codex-threads.mjs +41 -8
  15. package/main/codex-watch.mjs +67 -3
  16. package/main/config.mjs +26 -4
  17. package/main/corner-tag.mjs +489 -0
  18. package/main/crash-report.mjs +26 -3
  19. package/main/engine-bin.mjs +595 -0
  20. package/main/engine-setup.mjs +4 -15
  21. package/main/feedback.mjs +11 -6
  22. package/main/harnesses/claude-stream.mjs +208 -0
  23. package/main/harnesses/claude.mjs +84 -0
  24. package/main/harnesses/codex.mjs +65 -0
  25. package/main/harnesses/common.mjs +78 -0
  26. package/main/harnesses/instructions-file.mjs +116 -0
  27. package/main/harnesses/opencode.mjs +234 -0
  28. package/main/harnesses.mjs +65 -0
  29. package/main/importable-threads.mjs +38 -0
  30. package/main/ipc.mjs +165 -31
  31. package/main/local-app-frames.mjs +82 -0
  32. package/main/main.mjs +100 -4
  33. package/main/memory-gate-history.mjs +51 -12
  34. package/main/memory-gate-server.mjs +67 -11
  35. package/main/memory-gate.mjs +49 -8
  36. package/main/memory-log.mjs +124 -0
  37. package/main/notify.mjs +47 -4
  38. package/main/opencode-server.mjs +271 -0
  39. package/main/opencode.mjs +287 -0
  40. package/main/phone-link.mjs +551 -0
  41. package/main/relay-address.mjs +28 -0
  42. package/main/relay-client.mjs +246 -0
  43. package/main/row-label.mjs +6 -6
  44. package/main/runs-on.mjs +32 -35
  45. package/main/serve.mjs +113 -9
  46. package/main/settings.mjs +148 -10
  47. package/main/shell-path.mjs +86 -0
  48. package/main/ship-queue.mjs +24 -6
  49. package/main/sign-in-files.mjs +3 -7
  50. package/main/source-updater.mjs +27 -1
  51. package/main/start-hold.mjs +73 -0
  52. package/main/store.mjs +1 -1
  53. package/main/supervisor.mjs +832 -428
  54. package/main/task-commands.mjs +13 -17
  55. package/main/task-folders-offthread.mjs +6 -1
  56. package/main/task-folders.mjs +490 -98
  57. package/main/task-terminals.mjs +37 -7
  58. package/main/updater.mjs +71 -9
  59. package/main/worktree-include.mjs +494 -0
  60. package/package.json +10 -1
  61. package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
  62. package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
  63. package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
  64. package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
  65. package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
  66. package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
  67. package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
  68. package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
  69. package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
  70. package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
  71. package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
  72. package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
  73. package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
  74. package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
  75. package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
  76. package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
  77. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
  78. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
  79. package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
  80. package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
  81. package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
  82. package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  83. package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
  84. package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  85. package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
  86. package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
  87. package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
  88. package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
  89. package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
  90. package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
  91. package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
  92. package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
  93. package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
  94. package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
  95. package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
  96. package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
  97. package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
  98. package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
  99. package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
  100. package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
  101. package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
  102. package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
  103. package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
  104. package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
  105. package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
  106. package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
  107. package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
  108. package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
  109. package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  110. package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
  111. package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
  112. package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
  113. package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
  114. package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
  115. package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
  116. package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
  117. package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
  118. package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
  119. package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
  120. package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
  121. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
  122. package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
  123. package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
  124. package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
  125. package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
  126. package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
  127. package/renderer/dist/corner-tag.html +13 -0
  128. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  129. package/renderer/dist/emulator/data/compression/README.md +11 -0
  130. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  131. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  132. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  133. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  134. package/renderer/dist/emulator/data/cores/README.md +22 -0
  135. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  136. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  137. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  138. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  139. package/renderer/dist/emulator/data/cores/package.json +65 -0
  140. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  141. package/renderer/dist/emulator/data/emulator.css +1614 -0
  142. package/renderer/dist/emulator/data/loader.js +170 -0
  143. package/renderer/dist/emulator/data/localization/README.md +63 -0
  144. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  145. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  146. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  147. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  148. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  149. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  150. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  151. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  152. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  153. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  154. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  155. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  156. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  157. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  158. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  159. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  160. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  161. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  162. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  163. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  164. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  165. package/renderer/dist/emulator/data/src/compression.js +142 -0
  166. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  167. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  168. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  169. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  170. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  171. package/renderer/dist/emulator/data/src/storage.js +125 -0
  172. package/renderer/dist/emulator/data/version.json +5 -0
  173. package/renderer/dist/emulator/host.js +297 -0
  174. package/renderer/dist/emulator/index.html +28 -0
  175. package/renderer/dist/emulator/provenance.json +6 -0
  176. package/renderer/dist/index.html +16 -3
  177. package/scripts/approval-server.sh +24 -5
  178. package/scripts/before-publish.mjs +12 -10
  179. package/shared/agent-import.mjs +2 -0
  180. package/shared/agents.d.mts +1 -0
  181. package/shared/bridge-map.mjs +18 -0
  182. package/shared/claude-commands.generated.mjs +6 -4
  183. package/shared/claude-models.generated.mjs +6 -4
  184. package/shared/corner-tag.d.mts +14 -0
  185. package/shared/corner-tag.mjs +141 -0
  186. package/shared/engines.mjs +20 -5
  187. package/shared/harness-definitions.mjs +103 -0
  188. package/shared/notify-rules.d.mts +25 -0
  189. package/shared/notify-rules.mjs +19 -0
  190. package/shared/provider-commands.mjs +10 -2
  191. package/shared/rank.d.mts +2 -0
  192. package/shared/rank.mjs +50 -0
  193. package/shared/relay-address.mjs +6 -0
  194. package/shared/relay-crypto.mjs +165 -0
  195. package/shared/runs-on.d.mts +0 -10
  196. package/shared/runs-on.mjs +25 -49
  197. package/shared/shortcuts.mjs +10 -4
  198. package/shared/spawn-trouble.mjs +57 -6
  199. package/shared/terminal-input.d.mts +1 -0
  200. package/shared/terminal-input.mjs +30 -0
  201. package/shared/terminal-keys.d.mts +1 -0
  202. package/shared/terminal-keys.mjs +16 -0
  203. package/shared/terminal-size.d.mts +4 -0
  204. package/shared/terminal-size.mjs +29 -0
  205. package/shared/usage.d.mts +3 -0
  206. package/shared/usage.mjs +7 -0
  207. package/shared/where-it-runs-from.d.mts +43 -0
  208. package/shared/where-it-runs-from.mjs +155 -0
  209. package/shared/work-items.mjs +25 -1
  210. package/shared/work-lines.mjs +1 -1
  211. package/shared/xterm-invalid-range.d.mts +2 -0
  212. package/shared/xterm-invalid-range.mjs +58 -0
  213. package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
  214. package/renderer/dist/assets/index-ClBXnq3B.css +0 -32
@@ -0,0 +1,551 @@
1
+ // THE PHONE DOOR: the desktop app's own inbox, answering a phone.
2
+ //
3
+ // main/serve.mjs is a door for a browser tab on this computer: it boots a
4
+ // second copy of the doing half and listens on the loopback address only. A
5
+ // phone needs the opposite on both counts. It must reach the inbox the desktop
6
+ // window is showing, not a second copy of it, and it must reach it from off
7
+ // this computer.
8
+ //
9
+ // So this takes the handlers the desktop app already registered (main.mjs
10
+ // collects them as `registerIpc` files them) and the pushes the desktop window
11
+ // already receives, and puts serve.mjs's request handler behind a server that
12
+ // listens on every address, once it is turned on in Settings.
13
+ //
14
+ // THE KEY. Whoever holds it can run agents on this computer, so it is long,
15
+ // random, kept in a file only this user can read, and shown only as the QR
16
+ // code. It survives a restart, because a phone's home-screen icon carries it
17
+ // and a key that changed on every launch would break that icon every launch.
18
+ // Reset makes a new one and closes every open phone at once.
19
+ //
20
+ // THE DEVICES. Each phone's page makes up an id for itself and sends it along
21
+ // with the key. The id is the phone's own word and proves nothing; the list is
22
+ // there so you can see who is connected, and Reset is what actually locks
23
+ // somebody out.
24
+
25
+ import http from 'node:http';
26
+ import https from 'node:https';
27
+ import fs from 'node:fs';
28
+ import os from 'node:os';
29
+ import path from 'node:path';
30
+ import crypto from 'node:crypto';
31
+ import { execFile } from 'node:child_process';
32
+ import { fileURLToPath } from 'node:url';
33
+ import QRCode from 'qrcode';
34
+ import webpush from 'web-push';
35
+ import { banner, shouldSpeak } from '../shared/notify-rules.mjs';
36
+ import { createHandler, sameKey } from './serve.mjs';
37
+ import { createRelayClient } from './relay-client.mjs';
38
+ import { Name } from '../shared/product-name.mjs';
39
+
40
+ export const PHONE_PORT = 41745;
41
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
42
+ const ICON = path.join(HERE, 'phone-icon.png');
43
+ const WORKER = path.join(HERE, 'phone-sw.js');
44
+
45
+ // ALERTS ON THE PHONE. A phone app on the home screen can get
46
+ // Web Push on iOS 16.4 and later, over https only, so over the Tailscale
47
+ // address. The computer signs each alert with its own key pair (VAPID) and
48
+ // hands it, encrypted for that one phone, to the phone's push service (Apple's,
49
+ // for an iPhone). The subject names who is sending; Apple wants a URL or a
50
+ // mailto, and the project's page is the honest one.
51
+ const PUSH_SUBJECT = 'https://github.com/savannahfeder/agentbox';
52
+ const COALESCE_MS = 1500;
53
+ // THE CONNECTION TO APPLE. Node tries each of a host's addresses for 250ms
54
+ // before the next, and web.push.apple.com has eight, so over a slow link
55
+ // (measured on this Mac, 2026-10-09: three connects in four failed with
56
+ // ETIMEDOUT after 2s) every alert was dropped. Two and a half seconds an
57
+ // address connected four in four, in about half a second each.
58
+ const PUSH_AGENT = new https.Agent({ keepAlive: true, autoSelectFamilyAttemptTimeout: 2500 });
59
+ const DEVICE_HEADER = 'x-agentbox-device';
60
+ // A phone that has asked nothing for this long, and has no live event stream,
61
+ // is listed as last seen rather than connected.
62
+ const QUIET_MS = 30_000;
63
+
64
+ const newKey = () => crypto.randomBytes(24).toString('base64url');
65
+
66
+ // THE SECURE ADDRESS. Over plain http a phone's browser says "Not Secure" in
67
+ // the address bar (measured on an iPhone, 2026-10-09). Tailscale can put a real
68
+ // certificate in front of this server: `tailscale serve` answers
69
+ // https://<this computer>.<tailnet>.ts.net:8443 inside the tailnet only, and
70
+ // passes each request on to the loopback port. So picking Tailscale picks that.
71
+ // Wi-Fi has no such certificate and stays plain http.
72
+ export const SECURE_PORT = 8443;
73
+ const TAILSCALE_BINS = ['/Applications/Tailscale.app/Contents/MacOS/tailscale', '/usr/local/bin/tailscale', '/opt/homebrew/bin/tailscale', '/usr/bin/tailscale'];
74
+
75
+ /** The `tailscale` command, as three questions. Null when it is not installed. */
76
+ export function tailscaleCli(bins = TAILSCALE_BINS) {
77
+ const bin = bins.find((b) => { try { fs.accessSync(b, fs.constants.X_OK); return true; } catch { return false; } });
78
+ if (!bin) return null;
79
+ const run = (args) => new Promise((resolve, reject) => {
80
+ execFile(bin, args, { timeout: 20_000 }, (err, stdout, stderr) => (err ? reject(new Error(String(stderr || err.message).trim())) : resolve(String(stdout))));
81
+ });
82
+ return {
83
+ /** This computer's name in the tailnet, if it can have a certificate. */
84
+ async name() {
85
+ const d = JSON.parse(await run(['status', '--json']));
86
+ const host = String(d.Self?.DNSName ?? '').replace(/\.$/, '');
87
+ return host && (d.CertDomains ?? []).includes(host) ? host : null;
88
+ },
89
+ serve: (port) => run(['serve', '--bg', '--yes', `--https=${SECURE_PORT}`, `http://127.0.0.1:${port}`]),
90
+ unserve: () => run(['serve', `--https=${SECURE_PORT}`, 'off']),
91
+ };
92
+ }
93
+
94
+ /** Wi-Fi and Tailscale addresses on this computer, in that order. */
95
+ export function phoneAddresses(interfaces = os.networkInterfaces()) {
96
+ const out = [];
97
+ for (const [name, list] of Object.entries(interfaces)) {
98
+ for (const a of list ?? []) {
99
+ if (a.family !== 'IPv4' || a.internal) continue;
100
+ const [x, y] = a.address.split('.').map(Number);
101
+ // Tailscale hands out addresses from 100.64.0.0/10.
102
+ if (x === 100 && y >= 64 && y <= 127) out.push({ via: 'tailscale', host: a.address, iface: name });
103
+ else if (x === 10 || (x === 172 && y >= 16 && y <= 31) || (x === 192 && y === 168)) {
104
+ out.push({ via: 'wifi', host: a.address, iface: name });
105
+ }
106
+ }
107
+ }
108
+ // en0 is the built-in Wi-Fi or Ethernet; prefer it over a VM bridge.
109
+ const rank = (a) => (a.via === 'wifi' ? (a.iface === 'en0' ? 0 : 1) : 2);
110
+ return out.sort((a, b) => rank(a) - rank(b));
111
+ }
112
+
113
+ /**
114
+ * WHO MAY KNOCK. This computer, a private network (a home or office Wi-Fi,
115
+ * an iPhone's hotspot at 172.20.10.x) and Tailscale's own range. Never a
116
+ * public address: the key is the whole computer, and a Mac on a network that
117
+ * hands it a public address must not answer the world with it.
118
+ */
119
+ export function fromThisNetwork(address) {
120
+ const a = String(address ?? '').replace(/^::ffff:/, '');
121
+ if (a === '::1') return true;
122
+ const m = a.match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/);
123
+ if (!m) return false;
124
+ const [x, y] = [Number(m[1]), Number(m[2])];
125
+ return x === 127 || x === 10 || (x === 172 && y >= 16 && y <= 31) || (x === 192 && y === 168) || (x === 100 && y >= 64 && y <= 127);
126
+ }
127
+
128
+ /** "iPhone · Safari" from a user agent string. */
129
+ export function deviceName(ua = '') {
130
+ const what = /iPhone/.test(ua) ? 'iPhone'
131
+ : /iPad/.test(ua) ? 'iPad'
132
+ : /Android/.test(ua) ? 'Android phone'
133
+ : /Macintosh/.test(ua) ? 'Mac'
134
+ : /Windows/.test(ua) ? 'Windows PC'
135
+ : /Linux/.test(ua) ? 'Linux computer'
136
+ : 'Device';
137
+ const browser = /EdgA?\//.test(ua) ? 'Edge'
138
+ : /CriOS|Chrome\//.test(ua) ? 'Chrome'
139
+ : /FxiOS|Firefox\//.test(ua) ? 'Firefox'
140
+ : /Safari\//.test(ua) ? 'Safari'
141
+ : /AppleWebKit/.test(ua) ? 'home-screen app'
142
+ : null;
143
+ return browser ? `${what} · ${browser}` : what;
144
+ }
145
+
146
+ /**
147
+ * @param {{ file: string, channels: Map<string, Function>, listeners: Set<Function>,
148
+ * dist?: string, port?: number, onChange?: () => void,
149
+ * interfaces?: () => object, log?: { warn?: Function } }} options
150
+ */
151
+ export function createPhoneLink({ file, channels, listeners, dist, files = null, port = PHONE_PORT, onChange = () => {}, interfaces = () => os.networkInterfaces(), log = console, tailscale = tailscaleCli(), warm = (url) => fetch(url).catch(() => {}), send = (sub, body, opts) => webpush.sendNotification(sub, body, opts), atDesk = () => false, coalesceMs = COALESCE_MS, relayUrl = '', createRelay = (opts) => createRelayClient(opts) }) {
152
+ let saved = read(file);
153
+ // ANYWHERE, THROUGH THE RELAY (main/relay-client.mjs). With a relay to use it
154
+ // is where a person starts, because it needs nothing on the phone; a route
155
+ // someone picked stays picked. Before the relay every file said "wifi"
156
+ // whether or not anybody picked it, so only a pick marked as one counts.
157
+ const relay = relayUrl ? createRelay({ relayUrl, key: () => saved.key, port: () => port, log, onChange: () => onChange() }) : null;
158
+ const via = () => {
159
+ const picked = saved.viaChosen || saved.via === 'tailscale' ? saved.via : null;
160
+ if (picked === 'anywhere' && !relay) return 'wifi';
161
+ return picked ?? (relay ? 'anywhere' : 'wifi');
162
+ };
163
+ function settleRelay() {
164
+ if (!relay) return;
165
+ if (saved.on && via() === 'anywhere') relay.start();
166
+ else relay.stop();
167
+ }
168
+ let server = null;
169
+ let error = null;
170
+ /** The https name `tailscale serve` is answering on, or why it is not. */
171
+ let secure = { host: null, error: null, busy: null };
172
+ /** id -> { id, name, ip, firstSeen, lastSeen, streams: Set<ServerResponse> } */
173
+ const devices = new Map();
174
+
175
+ const key = () => saved.key;
176
+ // What the alerts know: the inbox count the desktop last drew, the arrivals
177
+ // not yet told, and whether a burst has been told already (the desktop's
178
+ // own rule, shared/notify-rules.mjs: once, then again only for a card).
179
+ let count = 0;
180
+ const unseen = new Map();
181
+ let timer = null;
182
+ let spoke = false;
183
+ let spokeAsk = false;
184
+ const inner = createHandler({ channels, token: key, listeners, dist, files });
185
+
186
+ function save() {
187
+ fs.mkdirSync(path.dirname(file), { recursive: true });
188
+ fs.writeFileSync(file, JSON.stringify(saved, null, 2), { mode: 0o600 });
189
+ try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
190
+ }
191
+
192
+ function seen(req, url) {
193
+ const id = String(req.headers[DEVICE_HEADER] || url.searchParams.get('device') || '').slice(0, 64);
194
+ if (!id) return null;
195
+ const now = Date.now();
196
+ let d = devices.get(id);
197
+ if (!d) {
198
+ d = { id, name: deviceName(req.headers['user-agent']), ip: null, firstSeen: now, lastSeen: now, streams: new Set() };
199
+ devices.set(id, d);
200
+ onChange();
201
+ }
202
+ d.lastSeen = now;
203
+ // Behind `tailscale serve` every request arrives from loopback; the phone's
204
+ // own address is in the header it adds.
205
+ const from = String(req.socket.remoteAddress ?? '').replace(/^::ffff:/, '');
206
+ const forwarded = String(req.headers['x-forwarded-for'] ?? '').split(',')[0].trim();
207
+ d.ip = (from === '127.0.0.1' || from === '::1') && forwarded ? forwarded : from;
208
+ return d;
209
+ }
210
+
211
+ async function handle(req, res) {
212
+ if (!fromThisNetwork(req.socket?.remoteAddress)) { res.writeHead(403).end(); return; }
213
+ const url = new URL(req.url, 'http://phone');
214
+ const guarded = url.pathname === '/events' || url.pathname.startsWith('/api/') || url.pathname === '/manifest.webmanifest';
215
+ const given = req.headers['x-agentbox-token'] || url.searchParams.get('token');
216
+ const ok = sameKey(given, saved.key);
217
+
218
+ // THE HOME-SCREEN ICON. Its start address carries the key, so the icon
219
+ // opens straight into the inbox. Asked for with the key, like the api.
220
+ if (url.pathname === '/manifest.webmanifest') {
221
+ if (!ok) { res.writeHead(403).end(); return; }
222
+ res.writeHead(200, { 'content-type': 'application/manifest+json', 'cache-control': 'no-store' });
223
+ res.end(JSON.stringify({
224
+ name: Name,
225
+ short_name: Name,
226
+ // Saved through the relay, the icon opens with the key after the #, so
227
+ // a cold start (before the phone's worker is back) never hands the key
228
+ // to the relay in an address it could log.
229
+ start_url: req.headers['x-agentbox-relay'] ? `/#k=${encodeURIComponent(given)}` : `/?token=${encodeURIComponent(given)}`,
230
+ scope: '/',
231
+ display: 'standalone',
232
+ // The app's own warm dark. A cool grey here was what Safari painted
233
+ // above and below a warm page (2026-10-11); the page sets the exact
234
+ // colour of its look once it has drawn (renderer/src/phone-screen.ts).
235
+ background_color: '#1e1b1a',
236
+ theme_color: '#1e1b1a',
237
+ icons: [{ src: '/phone-icon.png', sizes: '512x512', type: 'image/png', purpose: 'any' }],
238
+ }));
239
+ return;
240
+ }
241
+
242
+ // The alert worker is the page's own script, so it is as public as the page.
243
+ if (url.pathname === '/phone-sw.js') {
244
+ fs.readFile(WORKER, (err, buf) => {
245
+ if (err) { res.writeHead(404).end(); return; }
246
+ res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache', 'service-worker-allowed': '/' });
247
+ res.end(String(buf).replaceAll('__APP_NAME__', Name));
248
+ });
249
+ return;
250
+ }
251
+
252
+ // A phone turning its alerts on or off, asking for the key to sign up
253
+ // with, or asking for a test. With the key, like the api.
254
+ if (url.pathname === '/phone/alerts') {
255
+ if (!ok || req.method !== 'POST') { res.writeHead(403).end(); return; }
256
+ const d = seen(req, url);
257
+ let body = {};
258
+ try { body = JSON.parse((await readJson(req)) || '{}'); } catch { res.writeHead(400).end(); return; }
259
+ try {
260
+ const answer = await alertsFor(d, body);
261
+ res.writeHead(200, { 'content-type': 'application/json' });
262
+ res.end(JSON.stringify(answer));
263
+ } catch (err) {
264
+ res.writeHead(400, { 'content-type': 'application/json' });
265
+ res.end(JSON.stringify({ error: String(err.message ?? err) }));
266
+ }
267
+ return;
268
+ }
269
+
270
+ // The icon lives beside this file, not in the built screen.
271
+ if (url.pathname === '/phone-icon.png') {
272
+ fs.readFile(ICON, (err, buf) => {
273
+ if (err) { res.writeHead(404).end(); return; }
274
+ res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'max-age=86400' });
275
+ res.end(buf);
276
+ });
277
+ return;
278
+ }
279
+
280
+ if (guarded && ok) {
281
+ const d = seen(req, url);
282
+ if (d && url.pathname === '/events') {
283
+ // A phone that opens the inbox has seen what was waiting.
284
+ seenAll();
285
+ d.streams.add(res);
286
+ onChange();
287
+ req.on('close', () => { d.streams.delete(res); d.lastSeen = Date.now(); onChange(); });
288
+ }
289
+ }
290
+ return inner(req, res);
291
+ }
292
+
293
+ // WHERE IT LISTENS. Over Tailscale the phone arrives through `tailscale
294
+ // serve`, which hands each request on to 127.0.0.1, so the door listens on
295
+ // this computer only and no network the Mac joins can reach it in plain
296
+ // http. Over Wi-Fi the phone is on the network, so it listens on every
297
+ // address and `fromThisNetwork` turns away anyone who is not.
298
+ const wantedHost = () => (secureWanted() || via() === 'anywhere' ? '127.0.0.1' : '0.0.0.0');
299
+ let host = null;
300
+
301
+ function start() {
302
+ if (server) return;
303
+ error = null;
304
+ host = wantedHost();
305
+ server = http.createServer(handle);
306
+ server.on('error', (err) => {
307
+ error = err.code === 'EADDRINUSE'
308
+ ? `Port ${port} is taken by another program, so phones cannot reach this app.`
309
+ : String(err.message ?? err);
310
+ log.warn?.('[phone-link]', error);
311
+ server = null;
312
+ onChange();
313
+ });
314
+ server.listen(port, host, () => onChange());
315
+ }
316
+
317
+ /** Listening on the wrong addresses for the route picked: listen again. */
318
+ function rebind() {
319
+ if (!server || host === wantedHost()) return;
320
+ stop();
321
+ start();
322
+ }
323
+
324
+ // ON WHEN PHONES ARE LET IN OVER TAILSCALE, OFF OTHERWISE. Only the one
325
+ // https port is touched, so anything else this computer serves is left alone.
326
+ function secureWanted() { return saved.on && saved.via === 'tailscale'; }
327
+ function settleSecure() {
328
+ const run = (secure.busy ?? Promise.resolve()).then(async () => {
329
+ if (secureWanted() && !secure.host) {
330
+ if (!tailscale) { secure = { ...secure, error: 'Tailscale is not installed here, so there is no secure address.' }; return; }
331
+ try {
332
+ const host = await tailscale.name();
333
+ if (!host) throw new Error('This tailnet has HTTPS certificates turned off. Turn them on in the Tailscale admin console, under DNS.');
334
+ await tailscale.serve(port);
335
+ secure = { ...secure, host, error: null };
336
+ // The first request makes Tailscale fetch the certificate, which took
337
+ // 40 seconds when measured. Make it now, not on the phone.
338
+ warm(`https://${host}:${SECURE_PORT}/`);
339
+ } catch (err) {
340
+ secure = { ...secure, host: null, error: `No secure address: ${err.message}` };
341
+ log.warn?.('[phone-link]', secure.error);
342
+ }
343
+ } else if (!secureWanted() && secure.host) {
344
+ try { await tailscale?.unserve(); } catch (err) { log.warn?.('[phone-link] tailscale serve off:', err.message); }
345
+ secure = { ...secure, host: null, error: null };
346
+ } else if (!secureWanted()) {
347
+ secure = { ...secure, error: null };
348
+ }
349
+ }).finally(() => { if (secure.busy === run) secure.busy = null; onChange(); });
350
+ secure.busy = run;
351
+ return run;
352
+ }
353
+
354
+ function stop() {
355
+ if (!server) return;
356
+ for (const d of devices.values()) for (const r of d.streams) r.destroy();
357
+ server.close();
358
+ // A phone's kept-alive connection would otherwise go on being answered
359
+ // by a door that is shut.
360
+ server.closeAllConnections?.();
361
+ server = null;
362
+ host = null;
363
+ devices.clear();
364
+ }
365
+
366
+ function dropEveryPhone() {
367
+ for (const d of devices.values()) for (const r of d.streams) r.destroy();
368
+ devices.clear();
369
+ // A phone locked out by a new key must not keep getting alerts either.
370
+ saved.alerts = {};
371
+ }
372
+
373
+ /* ------------------------------- alerts -------------------------------- */
374
+
375
+ function vapid() {
376
+ if (!saved.vapid) { saved.vapid = webpush.generateVAPIDKeys(); save(); }
377
+ return saved.vapid;
378
+ }
379
+
380
+ async function alertsFor(d, { action, subscription } = {}) {
381
+ if (!d) throw new Error('This phone did not say which phone it is.');
382
+ if (action === 'key') return { publicKey: vapid().publicKey, on: !!saved.alerts[d.id] };
383
+ if (action === 'state') return { on: !!saved.alerts[d.id] };
384
+ if (action === 'subscribe') {
385
+ const endpoint = String(subscription?.endpoint ?? '');
386
+ if (!/^https:\/\//.test(endpoint) || !subscription?.keys?.p256dh || !subscription?.keys?.auth) throw new Error('That is not a push subscription.');
387
+ saved.alerts[d.id] = { sub: { endpoint, keys: { p256dh: String(subscription.keys.p256dh), auth: String(subscription.keys.auth) } }, name: d.name, since: Date.now() };
388
+ save();
389
+ onChange();
390
+ return { on: true };
391
+ }
392
+ if (action === 'unsubscribe') { delete saved.alerts[d.id]; save(); onChange(); return { on: false }; }
393
+ if (action === 'test') {
394
+ if (!saved.alerts[d.id]) throw new Error('Alerts are off on this phone.');
395
+ const sent = await push(d.id, { title: 'Alerts are on', body: `This is how ${Name} tells you an agent needs you.`, count, open: null });
396
+ if (!sent && saved.alerts[d.id]) throw new Error(`The computer could not send it (${saved.alerts[d.id].lastError}).`);
397
+ return { on: true, sent };
398
+ }
399
+ throw new Error(`Unknown alerts action ${action}`);
400
+ }
401
+
402
+ /** One alert to one phone. A phone whose subscription is gone is forgotten. */
403
+ async function push(id, payload) {
404
+ const entry = saved.alerts[id];
405
+ if (!entry) return false;
406
+ try {
407
+ await send(entry.sub, JSON.stringify({ ...payload, tag: 'agentbox' }), {
408
+ vapidDetails: { subject: PUSH_SUBJECT, publicKey: vapid().publicKey, privateKey: vapid().privateKey },
409
+ TTL: 6 * 3600,
410
+ urgency: 'high',
411
+ agent: PUSH_AGENT,
412
+ });
413
+ entry.lastSent = Date.now();
414
+ entry.lastError = null;
415
+ return true;
416
+ } catch (err) {
417
+ if (err?.statusCode === 404 || err?.statusCode === 410) {
418
+ delete saved.alerts[id];
419
+ save();
420
+ onChange();
421
+ } else {
422
+ // Said on the Phone page too, not only in a log nobody reads.
423
+ entry.lastError = `${err?.statusCode ?? err?.code ?? 'error'} ${String(err?.body || err?.message || err).trim()}`.trim();
424
+ entry.lastErrorAt = Date.now();
425
+ log.warn?.('[phone-link] alert not sent:', entry.lastError);
426
+ onChange();
427
+ }
428
+ return false;
429
+ }
430
+ }
431
+
432
+ function seenAll() {
433
+ unseen.clear();
434
+ if (timer) { clearTimeout(timer); timer = null; }
435
+ spoke = false;
436
+ spokeAsk = false;
437
+ }
438
+
439
+ async function speak() {
440
+ timer = null;
441
+ if (atDesk()) return seenAll();
442
+ const items = [...unseen.values()];
443
+ if (!shouldSpeak({ spoke, spokeAsk, waiting: items })) return;
444
+ const say = banner(items);
445
+ if (!say) return;
446
+ spoke = true;
447
+ if (items.some((i) => i.kind === 'approval')) spokeAsk = true;
448
+ // A phone with the inbox open on its screen is looking already.
449
+ const away = Object.keys(saved.alerts).filter((id) => !(devices.get(id)?.streams.size > 0));
450
+ await Promise.all(away.map((id) => push(id, { title: say.title, body: say.body, open: say.open, count })));
451
+ }
452
+
453
+ /** What the desktop's notifier was told arrived (main/notify.mjs). */
454
+ function arrivals(list) {
455
+ if (!saved.on || !Object.keys(saved.alerts).length) return;
456
+ if (!Array.isArray(list) || !list.length) return;
457
+ if (atDesk()) return seenAll();
458
+ for (const a of list) if (a?.id && !unseen.has(a.id)) unseen.set(a.id, a);
459
+ if (!unseen.size || timer) return;
460
+ timer = setTimeout(() => { void speak(); }, coalesceMs);
461
+ }
462
+
463
+ async function status({ via: asked } = {}) {
464
+ const addrs = phoneAddresses(interfaces());
465
+ const want = asked ?? via();
466
+ // THROUGH THE RELAY the code is its address with the key after the #,
467
+ // which a browser never sends: the relay sees a room, never the key.
468
+ const anywhere = want === 'anywhere' && relay;
469
+ const pick = anywhere ? { via: 'anywhere', host: new URL(relayUrl).host } : addrs.find((a) => a.via === want) ?? addrs[0] ?? null;
470
+ const safe = anywhere || (pick?.via === 'tailscale' && secure.host);
471
+ const url = !pick ? null
472
+ : anywhere ? `${relayUrl.replace(/\/+$/, '')}/#k=${encodeURIComponent(saved.key)}`
473
+ : safe ? `https://${secure.host}:${SECURE_PORT}/?token=${encodeURIComponent(saved.key)}`
474
+ : `http://${pick.host}:${port}/?token=${encodeURIComponent(saved.key)}`;
475
+ const relayState = anywhere ? relay.state() : null;
476
+ const now = Date.now();
477
+ return {
478
+ on: !!saved.on,
479
+ listening: !!server?.listening,
480
+ /** 127.0.0.1 over Tailscale and the relay, 0.0.0.0 over Wi-Fi. */
481
+ host: server?.listening ? host : null,
482
+ error: error ?? (pick?.via === 'tailscale' ? secure.error : null) ?? relayState?.error ?? null,
483
+ port,
484
+ secure: !!safe,
485
+ settingUp: !!secure.busy,
486
+ /** Through the relay: whether this Mac is connected to it now. */
487
+ relay: relayState ? { connected: !!relayState.connected, host: pick.host } : null,
488
+ via: pick?.via ?? null,
489
+ routes: [
490
+ ...(relay ? [{ via: 'anywhere', host: new URL(relayUrl).host }] : []),
491
+ ...[...new Set(addrs.map((a) => a.via))].map((v) => ({ via: v, host: addrs.find((a) => a.via === v).host })),
492
+ ],
493
+ url: saved.on ? url : null,
494
+ qr: saved.on && url ? await QRCode.toString(url, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' }) : null,
495
+ // The phones seen since the app started, and the phones with alerts on,
496
+ // which may not have opened the inbox since.
497
+ devices: [
498
+ ...[...devices.values()].map((d) => ({ id: d.id, name: d.name, ip: d.ip, firstSeen: d.firstSeen, lastSeen: d.lastSeen, live: d.streams.size > 0 || now - d.lastSeen < QUIET_MS, alerts: !!saved.alerts[d.id], alertError: saved.alerts[d.id]?.lastError ?? null })),
499
+ ...Object.entries(saved.alerts).filter(([id]) => !devices.has(id)).map(([id, a]) => ({ id, name: a.name, ip: null, firstSeen: a.since, lastSeen: a.lastSent ?? a.since, live: false, alerts: true, alertError: a.lastError ?? null })),
500
+ ].sort((a, b) => b.lastSeen - a.lastSeen),
501
+ };
502
+ }
503
+
504
+ /** The Settings page's one channel. */
505
+ async function act({ action, via, device } = {}) {
506
+ if (action === 'on') { saved.on = true; save(); start(); await settleSecure(); settleRelay(); }
507
+ else if (action === 'off') { saved.on = false; save(); stop(); await settleSecure(); settleRelay(); }
508
+ else if (action === 'reset') { saved.key = newKey(); save(); dropEveryPhone(); relay?.restart(); }
509
+ else if (action === 'via' && (via === 'wifi' || via === 'tailscale' || (via === 'anywhere' && relay))) {
510
+ saved.via = via; saved.viaChosen = true; save(); await settleSecure(); settleRelay(); rebind();
511
+ } else if (action === 'via') { /* a route this Mac does not have: nothing to change */ }
512
+ else if (action === 'stop-alerts' && typeof device === 'string') { delete saved.alerts[device]; save(); }
513
+ else if (action !== 'status') throw new Error(`Unknown phone action ${action}`);
514
+ onChange();
515
+ return status();
516
+ }
517
+
518
+ if (saved.on) { start(); void settleSecure(); settleRelay(); }
519
+ return {
520
+ act, status, start, stop, handle, arrivals, seen: seenAll,
521
+ /** The inbox count the desktop last drew, which each alert carries for the icon. */
522
+ count(n) { if (Number.isFinite(n)) count = Math.max(0, Math.floor(n)); },
523
+ settled: () => secure.busy ?? Promise.resolve(),
524
+ get key() { return saved.key; },
525
+ close() { if (timer) clearTimeout(timer); stop(); relay?.stop(); },
526
+ };
527
+ }
528
+
529
+ function read(file) {
530
+ let saved = {};
531
+ try { saved = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* first run */ }
532
+ if (typeof saved.key !== 'string' || saved.key.length < 24) saved.key = newKey();
533
+ return {
534
+ on: !!saved.on,
535
+ key: saved.key,
536
+ via: ['tailscale', 'wifi', 'anywhere'].includes(saved.via) ? saved.via : 'wifi',
537
+ // A route somebody picked, not one written down because it was the only one.
538
+ viaChosen: saved.viaChosen === true,
539
+ vapid: saved.vapid?.publicKey && saved.vapid?.privateKey ? saved.vapid : null,
540
+ alerts: saved.alerts && typeof saved.alerts === 'object' ? saved.alerts : {},
541
+ };
542
+ }
543
+
544
+ function readJson(req) {
545
+ return new Promise((resolve, reject) => {
546
+ let body = '';
547
+ req.on('data', (c) => { body += c; if (body.length > 64 * 1024) reject(new Error('Body too large')); });
548
+ req.on('end', () => resolve(body));
549
+ req.on('error', reject);
550
+ });
551
+ }
@@ -0,0 +1,28 @@
1
+ // WHICH RELAY THIS MAC USES (tests/the-mac-finds-the-relay-the-deploy-wrote-down).
2
+ // AGENTBOX_RELAY_URL first, for trying one by hand; then the address
3
+ // `npm run relay:deploy` wrote to relay/relay.local.json; then the one built
4
+ // into the app, which is empty until a deployed address is committed.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { RELAY_URL } from '../shared/relay-address.mjs';
8
+
9
+ function usable(url) {
10
+ if (typeof url !== 'string' || !url) return null;
11
+ let u;
12
+ try { u = new URL(url); } catch { return null; }
13
+ const local = u.hostname === 'localhost' || u.hostname === '127.0.0.1';
14
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) return null;
15
+ return url.replace(/\/+$/, '');
16
+ }
17
+
18
+ export function relayAddress({ env = process.env, appDir } = {}) {
19
+ const byHand = usable(env.AGENTBOX_RELAY_URL);
20
+ if (byHand) return byHand;
21
+ if (appDir) {
22
+ try {
23
+ const deployed = usable(JSON.parse(fs.readFileSync(path.join(appDir, 'relay', 'relay.local.json'), 'utf8'))?.url);
24
+ if (deployed) return deployed;
25
+ } catch { /* not deployed from this folder */ }
26
+ }
27
+ return usable(RELAY_URL) ?? RELAY_URL;
28
+ }