agentbox-app 0.1.7 → 0.1.12

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 (255) hide show
  1. package/README.md +220 -58
  2. package/bin/agentbox.mjs +18 -3
  3. package/briefs/message-rules.md +24 -3
  4. package/briefs/worker.md +63 -49
  5. package/briefs/writing-rules.md +90 -131
  6. package/main/account-discovery.mjs +21 -20
  7. package/main/account-tooling.mjs +11 -11
  8. package/main/agent-activity.mjs +45 -5
  9. package/main/agent-schedule.mjs +14 -14
  10. package/main/agent-sessions.mjs +94 -10
  11. package/main/agent-updates.mjs +116 -0
  12. package/main/agents.mjs +52 -46
  13. package/main/analytics.mjs +27 -0
  14. package/main/approval-prompt-server.mjs +2 -2
  15. package/main/approvals.mjs +14 -5
  16. package/main/artifact-path.mjs +11 -0
  17. package/main/claude-bin.mjs +32 -5
  18. package/main/claude-input.mjs +45 -3
  19. package/main/claude-plan.mjs +25 -13
  20. package/main/code-change.mjs +3 -3
  21. package/main/codex-account.mjs +1 -1
  22. package/main/codex-bin.mjs +18 -0
  23. package/main/codex-launch-env.mjs +31 -0
  24. package/main/codex-memory-gate.mjs +260 -0
  25. package/main/codex-models.mjs +12 -7
  26. package/main/codex-threads.mjs +46 -23
  27. package/main/codex-usage-file.mjs +7 -7
  28. package/main/codex-usage.mjs +4 -4
  29. package/main/codex-watch.mjs +12 -12
  30. package/main/config.mjs +82 -17
  31. package/main/demo.mjs +17 -14
  32. package/main/dev-window.mjs +9 -9
  33. package/main/doc-scheme.mjs +2 -0
  34. package/main/engine-setup.mjs +204 -0
  35. package/main/feedback.mjs +55 -0
  36. package/main/folder-pictures.mjs +48 -0
  37. package/main/frame-scrollbars.mjs +90 -0
  38. package/main/fresh-user.mjs +7 -4
  39. package/main/git-change-offthread.mjs +70 -0
  40. package/main/git-change-thread.mjs +11 -0
  41. package/main/git-change.mjs +9 -3
  42. package/main/img-scheme.mjs +70 -1
  43. package/main/instruction-settings.mjs +134 -60
  44. package/main/ipc.mjs +335 -42
  45. package/main/leftovers.mjs +518 -0
  46. package/main/live-replies.mjs +43 -5
  47. package/main/machine.mjs +48 -1
  48. package/main/main.mjs +138 -15
  49. package/main/memory-gate-history.mjs +254 -0
  50. package/main/memory-gate-server.mjs +461 -0
  51. package/main/memory-gate.mjs +206 -0
  52. package/main/message-priority.mjs +87 -0
  53. package/main/notify.mjs +1 -1
  54. package/main/rail-note.mjs +2 -2
  55. package/main/refresh-agent-models.mjs +21 -0
  56. package/main/repeats.mjs +4 -4
  57. package/main/row-label.mjs +115 -51
  58. package/main/runs-on.mjs +93 -0
  59. package/main/serve.mjs +77 -5
  60. package/main/settings.mjs +245 -26
  61. package/main/ship-queue.mjs +279 -0
  62. package/main/sign-in-files.mjs +39 -0
  63. package/main/source-updater.mjs +298 -0
  64. package/main/store/home.mjs +84 -7
  65. package/main/store/project.mjs +15 -19
  66. package/main/store/work-items.mjs +150 -6
  67. package/main/store.mjs +245 -13
  68. package/main/supervisor.mjs +1634 -417
  69. package/main/task-commands.mjs +8 -10
  70. package/main/task-folders.mjs +94 -16
  71. package/main/task-remote-control.mjs +3 -3
  72. package/main/task-terminals.mjs +14 -2
  73. package/main/team/index.mjs +440 -0
  74. package/main/team/memory-cloud.mjs +247 -0
  75. package/main/team/projects.mjs +162 -0
  76. package/main/team/session.mjs +225 -0
  77. package/main/team/sign-in.mjs +106 -0
  78. package/main/team/supabase-backend.mjs +231 -0
  79. package/main/team/sync.mjs +163 -0
  80. package/main/updater.mjs +15 -5
  81. package/main/write-before-reload.mjs +50 -0
  82. package/mcp/core/page.mjs +10 -12
  83. package/mcp/core/work.mjs +123 -26
  84. package/mcp/tools.mjs +13 -6
  85. package/package.json +21 -9
  86. package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
  87. package/renderer/dist/assets/index-ChKsQkRw.js +1169 -0
  88. package/renderer/dist/assets/index-ClBXnq3B.css +32 -0
  89. package/renderer/dist/index.html +2 -2
  90. package/scripts/after-install.mjs +14 -0
  91. package/scripts/approval-server.sh +7 -2
  92. package/scripts/memory-gate-hook.sh +59 -0
  93. package/shared/agent-import.mjs +39 -16
  94. package/shared/agents.d.mts +26 -8
  95. package/shared/agents.mjs +100 -108
  96. package/shared/analytics-events.mjs +19 -2
  97. package/shared/answers.mjs +12 -0
  98. package/shared/bridge-map.d.mts +12 -0
  99. package/shared/bridge-map.mjs +38 -1
  100. package/shared/claude-commands.generated.mjs +2 -2
  101. package/shared/claude-models.generated.mjs +6 -6
  102. package/shared/claude-usage.mjs +6 -6
  103. package/shared/codex-import.mjs +7 -7
  104. package/shared/codex-modes.d.mts +50 -0
  105. package/shared/codex-modes.mjs +1 -1
  106. package/shared/codex-usage.mjs +7 -8
  107. package/shared/demo-world.mjs +6 -6
  108. package/shared/engines.mjs +27 -10
  109. package/shared/feedback.d.mts +14 -0
  110. package/shared/feedback.mjs +44 -0
  111. package/shared/first-run-examples.mjs +4 -4
  112. package/shared/first-run-practice.d.mts +16 -0
  113. package/shared/first-run-practice.mjs +176 -70
  114. package/shared/fresh-user-home.mjs +34 -4
  115. package/shared/instruction-defaults.json +2 -2
  116. package/shared/product-name.mjs +4 -2
  117. package/shared/project-folder-check.mjs +9 -3
  118. package/shared/rank.d.mts +1 -0
  119. package/shared/rank.mjs +22 -0
  120. package/shared/referenced-files.mjs +1 -1
  121. package/shared/runs-on.d.mts +20 -0
  122. package/shared/runs-on.mjs +79 -0
  123. package/shared/schemes.d.mts +11 -0
  124. package/shared/schemes.mjs +1 -1
  125. package/shared/shortcuts.d.mts +41 -0
  126. package/shared/shortcuts.mjs +236 -0
  127. package/shared/spawn-trouble.d.mts +2 -0
  128. package/shared/spawn-trouble.mjs +40 -19
  129. package/shared/team-rules.d.mts +17 -0
  130. package/shared/team-rules.mjs +230 -0
  131. package/shared/team-status.d.mts +11 -0
  132. package/shared/team-status.mjs +90 -0
  133. package/shared/terminal-links.d.mts +2 -0
  134. package/shared/terminal-links.mjs +55 -0
  135. package/shared/terminal-state.d.mts +1 -0
  136. package/shared/terminal-state.mjs +16 -0
  137. package/shared/thread-cards.d.mts +19 -0
  138. package/shared/thread-cards.mjs +254 -0
  139. package/shared/usage.d.mts +1 -1
  140. package/shared/usage.mjs +6 -7
  141. package/shared/work-items.mjs +258 -28
  142. package/shared/work-lines.mjs +393 -16
  143. package/worker-permissions.json +1 -1
  144. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  145. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  146. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  147. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  148. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  149. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  150. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  151. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  152. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  153. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  154. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  155. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  156. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  157. package/renderer/dist/assets/index-B7hhfHrT.js +0 -684
  158. package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
  159. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  160. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  161. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  162. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  163. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  164. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  165. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  166. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  167. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  168. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  169. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  170. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  171. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  172. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  173. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  174. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  175. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  176. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  177. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  178. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  179. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  180. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  181. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  182. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  183. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  184. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  185. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  186. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  187. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  188. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  189. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  190. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  191. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  192. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  193. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  194. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  195. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  196. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  197. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  198. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  199. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  200. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  201. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  202. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  203. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  204. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  205. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  206. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  207. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  208. package/renderer/dist/emulator/audio-rules.mjs +0 -74
  209. package/renderer/dist/emulator/data/compression/README.md +0 -11
  210. package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
  211. package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
  212. package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
  213. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  214. package/renderer/dist/emulator/data/cores/README.md +0 -22
  215. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  216. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  217. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  218. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  219. package/renderer/dist/emulator/data/cores/package.json +0 -65
  220. package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
  221. package/renderer/dist/emulator/data/emulator.css +0 -1614
  222. package/renderer/dist/emulator/data/loader.js +0 -170
  223. package/renderer/dist/emulator/data/localization/README.md +0 -63
  224. package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
  225. package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
  226. package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
  227. package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
  228. package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
  229. package/renderer/dist/emulator/data/localization/en-US.json +0 -339
  230. package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
  231. package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
  232. package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
  233. package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
  234. package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
  235. package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
  236. package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
  237. package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
  238. package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
  239. package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
  240. package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
  241. package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
  242. package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
  243. package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
  244. package/renderer/dist/emulator/data/src/GameManager.js +0 -464
  245. package/renderer/dist/emulator/data/src/compression.js +0 -142
  246. package/renderer/dist/emulator/data/src/emulator.js +0 -6158
  247. package/renderer/dist/emulator/data/src/gamepad.js +0 -167
  248. package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
  249. package/renderer/dist/emulator/data/src/shaders.js +0 -236
  250. package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
  251. package/renderer/dist/emulator/data/src/storage.js +0 -125
  252. package/renderer/dist/emulator/data/version.json +0 -5
  253. package/renderer/dist/emulator/host.js +0 -299
  254. package/renderer/dist/emulator/index.html +0 -28
  255. package/renderer/dist/emulator/provenance.json +0 -6
@@ -0,0 +1,106 @@
1
+ // SIGNING IN WITH GOOGLE FROM A DESKTOP APP.
2
+ //
3
+ // Supabase does the Google part. The app opens the sign-in page in the
4
+ // person's own browser and listens on 127.0.0.1 for the browser to come back
5
+ // with a one-time code, which it trades for a session (PKCE, so the code is
6
+ // useless to anyone who did not start this sign-in). The listener exists only
7
+ // while a sign-in is in flight and answers nothing but that one path.
8
+ //
9
+ // The return address must be in the project's allowed list
10
+ // (cloud/supabase/config.toml, additional_redirect_urls).
11
+ import http from 'node:http';
12
+ import { Name } from '../../shared/product-name.mjs';
13
+
14
+ // Whatever came back in the address is the browser's to show as text, never
15
+ // as markup: error_description is anybody's to write (review, 2026-10-01).
16
+ const escape = (text) => String(text).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
17
+ export const PAGE = (title, line) => `<!doctype html><meta charset="utf-8"><title>${title}</title>
18
+ <body style="margin:0;height:100vh;display:grid;place-items:center;background:#1a1817;color:#efebe7;font:16px -apple-system,system-ui,sans-serif">
19
+ <div style="text-align:center"><div style="font-size:22px;margin-bottom:8px">${escape(title)}</div><div style="color:#a09994">${escape(line)}</div></div>`;
20
+
21
+ // ONE ATTEMPT AT A TIME, AND ANY ATTEMPT CAN BE LET GO (2026-10-04). The first
22
+ // teammate to install this clicked Continue with Google and saw nothing: the
23
+ // page had opened somewhere she could not see, the button then sat disabled
24
+ // for the five minutes the listener waits, and a reload and a second click
25
+ // failed on "address already in use", because the first attempt still held
26
+ // the return address. A new attempt now replaces the one before it, and a
27
+ // cancel frees the address at once.
28
+ let inFlight = null;
29
+
30
+ const cancelled = () => Object.assign(new Error('Sign-in was cancelled.'), { cancelled: true });
31
+
32
+ /** Stop the Google sign-in that is waiting, if there is one. True if one was. */
33
+ export function cancelGoogleSignIn() {
34
+ if (!inFlight) return false;
35
+ inFlight.cancel();
36
+ return true;
37
+ }
38
+
39
+ export async function signInWithGoogle({ client, openExternal, returnUrl, timeoutMs = 5 * 60 * 1000, onUrl = () => {}, log = () => {} }) {
40
+ const url = new URL(returnUrl);
41
+ const port = Number(url.port);
42
+ if (url.hostname !== '127.0.0.1' || !port) throw new Error(`the sign-in return address must be on 127.0.0.1 with a port, not ${returnUrl}`);
43
+
44
+ if (inFlight) { log('team: Google sign-in: a new attempt replaces the one still waiting'); await inFlight.cancel(); }
45
+
46
+ let finish;
47
+ const done = new Promise((resolve, reject) => { finish = { resolve, reject }; });
48
+ done.catch(() => {}); // a cancel can land before anything awaits it
49
+ const server = http.createServer(async (req, res) => {
50
+ const here = new URL(req.url, returnUrl);
51
+ if (here.pathname !== url.pathname) { res.writeHead(404); res.end(); return; }
52
+ const code = here.searchParams.get('code');
53
+ const problem = here.searchParams.get('error_description') || here.searchParams.get('error');
54
+ log(`team: Google sign-in: the browser came back ${code ? 'with a code' : `without one (${problem || 'no reason given'})`}`);
55
+ if (!code) {
56
+ res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' });
57
+ res.end(PAGE('Sign-in did not finish', problem || `Go back to ${Name} and try again.`));
58
+ finish.reject(new Error(problem || 'sign-in came back without a code'));
59
+ return;
60
+ }
61
+ const { error: refused } = await client.auth.exchangeCodeForSession(code);
62
+ // "invalid flow state" is what an old tab, or one from a cancelled try, gets.
63
+ const error = refused && /flow state/i.test(refused.message ?? '')
64
+ ? new Error(`That sign-in page was from an earlier try. Go back to ${Name} and click Continue with Google again.`)
65
+ : refused;
66
+ res.writeHead(error ? 400 : 200, { 'content-type': 'text/html; charset=utf-8' });
67
+ res.end(error ? PAGE('Sign-in did not finish', error.message) : PAGE('You are signed in', `You can close this tab and go back to ${Name}.`));
68
+ if (error) finish.reject(error); else finish.resolve();
69
+ });
70
+
71
+ const closed = () => new Promise((resolve) => { server.close(() => resolve()); server.closeAllConnections?.(); });
72
+ const mine = { cancel: () => { finish.reject(cancelled()); return closed(); } };
73
+ inFlight = mine;
74
+ try {
75
+ await new Promise((resolve, reject) => {
76
+ server.once('error', reject);
77
+ server.listen(port, '127.0.0.1', resolve);
78
+ });
79
+ } catch (err) {
80
+ if (inFlight === mine) inFlight = null;
81
+ if (err?.code === 'EADDRINUSE') {
82
+ throw new Error(`Another copy of ${Name} is already waiting for a Google sign-in. Finish it there, or quit that copy and try again.`);
83
+ }
84
+ throw err;
85
+ }
86
+ const timer = setTimeout(() => finish.reject(new Error('Google sign-in timed out. Try again.')), timeoutMs);
87
+ try {
88
+ const { data, error } = await client.auth.signInWithOAuth({
89
+ provider: 'google',
90
+ options: { redirectTo: returnUrl, skipBrowserRedirect: true, queryParams: { prompt: 'select_account' } },
91
+ });
92
+ if (error) throw error;
93
+ log(`team: Google sign-in: opening the browser, waiting on ${returnUrl}`);
94
+ onUrl(data.url);
95
+ await Promise.race([openExternal(data.url), done]);
96
+ await done;
97
+ log('team: Google sign-in: done');
98
+ } catch (err) {
99
+ log(`team: Google sign-in: ${err?.cancelled ? 'cancelled' : `did not finish: ${err?.message ?? err}`}`);
100
+ throw err;
101
+ } finally {
102
+ clearTimeout(timer);
103
+ if (inFlight === mine) inFlight = null;
104
+ await closed();
105
+ }
106
+ }
@@ -0,0 +1,231 @@
1
+ // THE SHARED CLOUD, ON SUPABASE. The same calls as memory-cloud.mjs, made
2
+ // against the tables in cloud/supabase/migrations, through a supabase-js
3
+ // client that is already signed in. Row level security on the server is what
4
+ // actually keeps people apart; nothing here is trusted to.
5
+ import { asPulled } from '../../shared/team-rules.mjs';
6
+ import { cleanStatus } from '../../shared/team-status.mjs';
7
+
8
+ const PAGE = 500;
9
+ const PUSH_BATCH = 200;
10
+
11
+ const PERSON_COLS = 'id,email,name,avatar_url,status_text,status_until';
12
+ const personOut = (p) => p && ({
13
+ id: p.id, email: p.email, name: p.name, avatarUrl: p.avatar_url ?? null,
14
+ status: p.status_text ? { text: p.status_text, until: p.status_until ? Date.parse(p.status_until) : null } : null,
15
+ });
16
+
17
+ function must({ data, error }, what) {
18
+ if (error) throw new Error(`${what}: ${error.message}`);
19
+ return data;
20
+ }
21
+
22
+ export function supabaseBackend(client) {
23
+ let personId = null;
24
+ async function myId() {
25
+ if (personId) return personId;
26
+ const { data, error } = await client.auth.getUser();
27
+ if (error || !data?.user) throw new Error('not signed in');
28
+ personId = data.user.id;
29
+ return personId;
30
+ }
31
+
32
+ return {
33
+ kind: 'supabase',
34
+ get personId() { return personId; },
35
+
36
+ async me() {
37
+ const id = await myId();
38
+ const rows = must(await client.from('people').select(PERSON_COLS).eq('id', id).limit(1), 'reading you');
39
+ return personOut(rows[0]) ?? null;
40
+ },
41
+
42
+ // A line you wrote about yourself. The row filter is your own id and
43
+ // people_update_self says the same, so this can only ever be yours.
44
+ async setStatus({ text, until = null, personId: who = null } = {}) {
45
+ const me = await myId();
46
+ if (who && who !== me) throw new Error('refusing to write somebody else\'s status');
47
+ const clean = cleanStatus(text);
48
+ must(await client.from('people').update({
49
+ status_text: clean,
50
+ status_until: clean && until ? new Date(until).toISOString() : null,
51
+ }).eq('id', me), 'saving your status');
52
+ return clean ? { text: clean, until: clean && until ? until : null } : null;
53
+ },
54
+
55
+ // THE INVITES WAITING FOR YOU, to the email Google confirmed. Nothing is
56
+ // joined by reading them: joining is acceptInvite, one team, on a yes.
57
+ async pendingInvites() {
58
+ await myId();
59
+ const rows = must(await client.rpc('pending_invites'), 'reading your invites') ?? [];
60
+ return rows.map((r) => ({ teamId: r.team_id, teamName: r.team_name, invitedBy: r.invited_by ?? null, invitedByName: r.invited_by_name ?? null }));
61
+ },
62
+
63
+ async acceptInvite(teamId) {
64
+ await myId();
65
+ must(await client.rpc('accept_invite', { p_team: teamId }), 'joining the team');
66
+ return teamId;
67
+ },
68
+
69
+ // Every team you are on, oldest first. Which one this Mac is in is the
70
+ // Mac's own record (main/team/index.mjs), never this order alone.
71
+ async myTeams() {
72
+ const id = await myId();
73
+ const members = must(await client.from('team_members').select('team_id').eq('person_id', id), 'reading your team');
74
+ const ids = members.map((m) => m.team_id);
75
+ if (!ids.length) return [];
76
+ const rows = must(await client.from('teams').select('id,name').in('id', ids).order('created_at'), 'reading your team');
77
+ return rows.map((r) => ({ id: r.id, name: r.name }));
78
+ },
79
+
80
+ async myTeam() {
81
+ return (await this.myTeams())[0] ?? null;
82
+ },
83
+
84
+ async createTeam(name) {
85
+ const id = await myId();
86
+ const [team] = must(await client.from('teams').insert({ name, created_by: id }).select('id,name'), 'starting a team');
87
+ must(await client.from('team_members').insert({ team_id: team.id, person_id: id, role: 'owner' }), 'joining your new team');
88
+ return team;
89
+ },
90
+
91
+ async teamPeople(teamId) {
92
+ await myId();
93
+ const members = must(await client.from('team_members').select('person_id,role').eq('team_id', teamId), 'reading your team');
94
+ const ids = members.map((m) => m.person_id);
95
+ if (!ids.length) return [];
96
+ const roles = new Map(members.map((m) => [m.person_id, m.role]));
97
+ const people = must(await client.from('people').select(PERSON_COLS).in('id', ids), 'reading your teammates');
98
+ return people.map((p) => ({ ...personOut(p), role: roles.get(p.id) ?? 'member' }));
99
+ },
100
+
101
+ // TEAM SETTINGS (2026-10-01). The rules are the database's
102
+ // (20261001000600_team_settings.sql): the owner renames, removes and
103
+ // cancels; anyone leaves.
104
+ async renameTeam(teamId, name) {
105
+ await myId();
106
+ const rows = must(await client.from('teams').update({ name: String(name).trim() }).eq('id', teamId).select('id,name'), 'renaming the team');
107
+ if (!rows.length) throw new Error('only the team’s owner can rename it');
108
+ return rows[0];
109
+ },
110
+
111
+ async removeMember(teamId, who) {
112
+ await myId();
113
+ const rows = must(await client.from('team_members').delete().eq('team_id', teamId).eq('person_id', who).select('person_id'), 'removing them');
114
+ if (!rows.length) throw new Error('only the team’s owner can remove somebody');
115
+ },
116
+
117
+ async listInvites(teamId) {
118
+ await myId();
119
+ const rows = must(await client.from('team_invites').select('email,invited_by').eq('team_id', teamId), 'reading the invites');
120
+ return rows.map((r) => ({ email: r.email, invitedBy: r.invited_by ?? null }));
121
+ },
122
+
123
+ async cancelInvite(teamId, email) {
124
+ await myId();
125
+ must(await client.from('team_invites').delete().eq('team_id', teamId).eq('email', String(email).trim()), 'cancelling the invite');
126
+ },
127
+
128
+ async invite(teamId, email) {
129
+ const id = await myId();
130
+ const { error } = await client.from('team_invites').upsert(
131
+ { team_id: teamId, email: String(email).trim(), invited_by: id },
132
+ { onConflict: 'team_id,email', ignoreDuplicates: true },
133
+ );
134
+ if (error) throw new Error(`inviting ${email}: ${error.message}`);
135
+ },
136
+
137
+ async listProjects() {
138
+ await myId();
139
+ const projects = must(await client.from('projects').select('id,team_id,name,visibility,created_by,direct'), 'reading shared projects');
140
+ const ids = projects.map((p) => p.id);
141
+ const people = ids.length ? must(await client.from('project_people').select('project_id,person_id').in('project_id', ids), 'reading who is on them') : [];
142
+ return projects.map((p) => ({
143
+ id: p.id, teamId: p.team_id, name: p.name, visibility: p.visibility, createdBy: p.created_by, direct: !!p.direct,
144
+ people: people.filter((pp) => pp.project_id === p.id).map((pp) => pp.person_id),
145
+ }));
146
+ },
147
+
148
+ async shareProject({ id, teamId, name, visibility = 'team', people = [], direct = false }) {
149
+ const me = await myId();
150
+ must(await client.from('projects').upsert({ id, team_id: teamId, name, visibility, created_by: me, direct: !!direct }, { onConflict: 'id' }), 'sharing the project');
151
+ const current = must(await client.from('project_people').select('person_id').eq('project_id', id), 'reading who is on it');
152
+ const want = new Set(people);
153
+ const have = new Set(current.map((r) => r.person_id));
154
+ const gone = [...have].filter((p) => !want.has(p));
155
+ const added = [...want].filter((p) => !have.has(p));
156
+ if (gone.length) must(await client.from('project_people').delete().eq('project_id', id).in('person_id', gone), 'taking people off it');
157
+ if (added.length) must(await client.from('project_people').insert(added.map((p) => ({ project_id: id, person_id: p }))), 'adding people to it');
158
+ return { id, teamId, name, visibility, createdBy: me, people: [...want] };
159
+ },
160
+
161
+ async pushLines(projectId, lines) {
162
+ const me = await myId();
163
+ let added = 0;
164
+ for (let i = 0; i < lines.length; i += PUSH_BATCH) {
165
+ const batch = lines.slice(i, i + PUSH_BATCH).map((line) => {
166
+ if (!line?.uid || line.by !== me) throw new Error('refusing to push a line somebody else wrote');
167
+ return { project_id: projectId, uid: line.uid, by_person: me, body: line };
168
+ });
169
+ const rows = must(await client.from('lines').upsert(batch, { onConflict: 'uid', ignoreDuplicates: true }).select('uid'), 'sending your changes');
170
+ added += rows?.length ?? 0;
171
+ }
172
+ return added;
173
+ },
174
+
175
+ async pullLines(projectId, afterSeq = 0, limit = PAGE) {
176
+ const me = await myId();
177
+ const rows = must(await client.from('lines').select('seq,body,by_person,created_at').eq('project_id', projectId).gt('seq', afterSeq).order('seq').limit(limit), 'reading your team\'s changes');
178
+ // WHO WROTE A LINE IS THE COLUMN THE DATABASE CHECKED, never the copy
179
+ // inside the line: row level security makes by_person the signed-in
180
+ // writer, while the body is whatever their Mac sent. A teammate who
181
+ // wrote somebody else's id into a body would otherwise be believed. WHEN
182
+ // is the database's too, and their Mac's lease fields are dropped
183
+ // (shared/team-rules.mjs asPulled).
184
+ return rows.map((r) => ({ seq: Number(r.seq), line: asPulled(r.body, { by: r.by_person, at: Date.parse(r.created_at), me }) }));
185
+ },
186
+
187
+ // YOUR CARDS: one per thread worth showing the team (shared/thread-cards.mjs).
188
+ // Upserted whole, and any card of yours the Mac no longer lists is removed,
189
+ // so a thread made private or finished yesterday leaves the board. A card
190
+ // not marked visible never goes up at all, whatever the Mac handed over.
191
+ async putCards(teamId, all) {
192
+ const me = await myId();
193
+ const cards = all.filter((c) => c.visible === true);
194
+ if (cards.length) {
195
+ must(await client.from('thread_cards').upsert(cards.map((c) => ({
196
+ person_id: me, team_id: teamId, thread_id: c.threadId, visible: !!c.visible,
197
+ // WHO IT REACHES: the chosen people, or no list at all for the whole
198
+ // team (w-41ff964775). The read rule on the table does the enforcing.
199
+ people: c.people == null ? null : [...new Set(c.people.filter((p) => typeof p === 'string' && p))],
200
+ title: c.visible ? c.title : null, project: c.visible ? c.project : null, state: c.state,
201
+ priority: Number.isFinite(c.priority) ? c.priority : null,
202
+ problem: c.visible ? c.problem : null, progress: c.visible ? c.progress : null, solution: c.visible ? c.solution : null,
203
+ blocked_by: c.visible ? c.blockedBy ?? [] : [], blocks: c.visible ? c.blocks ?? [] : [],
204
+ updated_at: new Date(c.updatedAt).toISOString(),
205
+ })), { onConflict: 'person_id,thread_id' }), 'sharing your threads with the team');
206
+ }
207
+ let stale = client.from('thread_cards').delete().eq('person_id', me);
208
+ if (cards.length) stale = stale.not('thread_id', 'in', `(${cards.map((c) => `"${c.threadId}"`).join(',')})`);
209
+ must(await stale, 'clearing threads the team no longer needs to see');
210
+ },
211
+
212
+ async listCards() {
213
+ await myId();
214
+ const rows = must(await client.from('thread_cards').select('*'), 'reading your team\'s threads');
215
+ return rows.map((r) => ({
216
+ personId: r.person_id, threadId: r.thread_id, visible: r.visible, title: r.title, project: r.project, people: r.people ?? null,
217
+ state: r.state, priority: r.priority, problem: r.problem, progress: r.progress, solution: r.solution,
218
+ blockedBy: r.blocked_by ?? [], blocks: r.blocks ?? [], updatedAt: Date.parse(r.updated_at),
219
+ }));
220
+ },
221
+
222
+ subscribe(onChange) {
223
+ const channel = client.channel(`team-${Math.random().toString(36).slice(2)}`)
224
+ .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'lines' }, () => onChange())
225
+ .on('postgres_changes', { event: '*', schema: 'public', table: 'thread_cards' }, () => onChange())
226
+ .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'people' }, () => onChange())
227
+ .subscribe();
228
+ return () => { client.removeChannel(channel); };
229
+ },
230
+ };
231
+ }
@@ -0,0 +1,163 @@
1
+ // KEEPING A SHARED PROJECT THE SAME ON EVERY TEAMMATE'S MAC.
2
+ //
3
+ // One pass (`syncOnce`) does four things, in this order:
4
+ //
5
+ // 1. JOIN. A project somebody shared that this Mac does not have yet becomes
6
+ // a project of its own here, in a folder of its own.
7
+ // 2. PUSH. Every ledger line this person wrote in a shared project since the
8
+ // last push goes to the cloud. A byte offset per project is the cursor,
9
+ // since the ledger only grows. A line written before anyone signed in
10
+ // (the project's history from when it was private) goes too, as this
11
+ // person's, with an id made from its own bytes so it can never go twice.
12
+ // 3. PULL. Every line the cloud has that this Mac has not seen comes in
13
+ // through the store's one write path (`appendForeignLines`), at most
14
+ // once, holding only what a teammate may set here (shared/team-rules.mjs
15
+ // whatATeammateMaySet). A line this person wrote comes back too, in a
16
+ // conversation and nowhere else (whatYourOtherCopyMaySet), because a
17
+ // second copy of the app signed in as them is a Mac that has not seen it
18
+ // either; the uid is what makes pulling one back onto the copy that wrote
19
+ // it cost nothing.
20
+ // 4. PRIVATE WORK. One title-free line per open task in a private project
21
+ // (who, what state, when it moved), and only when that set changed.
22
+ //
23
+ // Everything outside this file is passed in, so two "Macs" can run in one test
24
+ // over an in-memory cloud. The app wires the real ones in main/team/index.mjs.
25
+ import crypto from 'node:crypto';
26
+ import { whatATeammateMaySet, whatYourOtherCopyMaySet } from '../../shared/team-rules.mjs';
27
+
28
+ const PAGE = 500;
29
+ // HOW MANY SHARED PROJECTS A MAC TAKES ON BY ITSELF. Anyone on the team can
30
+ // share a project, and each one becomes a folder here; past this many, a pass
31
+ // joins no more, so a teammate cannot fill this disk with folders.
32
+ export const MAX_SHARED_PROJECTS = 50;
33
+
34
+ // Where each shared project's cursors stand, kept between runs.
35
+ export function memorySyncState() {
36
+ const map = new Map();
37
+ return {
38
+ get: (projectId) => ({ pushedTo: 0, pulledSeq: 0, ...(map.get(projectId) || {}) }),
39
+ set: (projectId, value) => { map.set(projectId, { ...value }); },
40
+ getCardsHash: () => map.get('__cards__')?.hash ?? null,
41
+ setCardsHash: (hash) => { map.set('__cards__', { hash }); },
42
+ getTeam: (personId) => map.get('__teams__')?.[personId] ?? null,
43
+ setTeam: (personId, teamId) => { map.set('__teams__', { ...(map.get('__teams__') || {}), [personId]: teamId }); },
44
+ getSince: (personId) => map.get('__since__')?.[personId] ?? null,
45
+ setSince: (personId, at) => { map.set('__since__', { ...(map.get('__since__') || {}), [personId]: at }); },
46
+ };
47
+ }
48
+
49
+ export function fileSyncState(file, fs) {
50
+ let data = {};
51
+ try { data = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { data = {}; }
52
+ const save = () => {
53
+ const tmp = `${file}.${process.pid}.tmp`;
54
+ fs.writeFileSync(tmp, JSON.stringify(data));
55
+ fs.renameSync(tmp, file);
56
+ };
57
+ return {
58
+ get: (projectId) => ({ pushedTo: 0, pulledSeq: 0, ...(data.projects?.[projectId] || {}) }),
59
+ set: (projectId, value) => { data.projects = { ...(data.projects || {}), [projectId]: { ...value } }; save(); },
60
+ getCardsHash: () => data.cardsHash ?? null,
61
+ setCardsHash: (hash) => { data.cardsHash = hash; save(); },
62
+ // WHICH TEAM THIS MAC IS IN, per person: once in one, it stays there
63
+ // until that person says otherwise (main/team/index.mjs).
64
+ getTeam: (personId) => data.teams?.[personId] ?? null,
65
+ setTeam: (personId, teamId) => { data.teams = { ...(data.teams || {}), [personId]: teamId }; save(); },
66
+ // WHEN THIS PERSON STARTED SHARING ON THIS MAC. Threads from before it stay
67
+ // theirs unless they share one (shared/thread-cards.mjs, `shownToTeam`).
68
+ getSince: (personId) => data.since?.[personId] ?? null,
69
+ setSince: (personId, at) => { data.since = { ...(data.since || {}), [personId]: at }; save(); },
70
+ };
71
+ }
72
+
73
+ // A line from before anyone signed in has no writer and no id. It is pushed as
74
+ // the sharer's, with an id made from its bytes, so a second push is the same id.
75
+ function adopt(line, me) {
76
+ if (line.by && line.uid) return line;
77
+ const uid = line.uid || `h-${crypto.createHash('sha256').update(JSON.stringify(line)).digest('hex').slice(0, 24)}`;
78
+ return { ...line, by: line.by || me, uid };
79
+ }
80
+
81
+ export function createTeamSync({ backend, disk, state, listShared, joinProject, listCards = () => [], teamIdOf = () => null }) {
82
+ let running = null;
83
+
84
+ async function pushProject(project, me) {
85
+ const cursor = state.get(project.projectId);
86
+ const { lines, end } = disk.readLinesFrom(project.dir, cursor.pushedTo);
87
+ const mine = lines.filter((l) => l && (!l.by || l.by === me)).map((l) => adopt(l, me));
88
+ let pushed = 0;
89
+ if (mine.length) pushed = await backend.pushLines(project.projectId, mine);
90
+ state.set(project.projectId, { ...state.get(project.projectId), pushedTo: end });
91
+ return pushed;
92
+ }
93
+
94
+ async function pullProject(project, me) {
95
+ let pulled = 0;
96
+ for (;;) {
97
+ const cursor = state.get(project.projectId);
98
+ const rows = await backend.pullLines(project.projectId, cursor.pulledSeq, PAGE);
99
+ if (!rows.length) break;
100
+ const direct = project.direct === true;
101
+ const bring = rows.map((r) => r.line).filter(Boolean)
102
+ // A line of your own comes back only in a conversation, and a line of a
103
+ // teammate's keeps only what they may set here (shared/team-rules.mjs).
104
+ .map((l) => (l.by === me ? whatYourOtherCopyMaySet(l, { direct }) : whatATeammateMaySet(l, { direct })))
105
+ .filter(Boolean);
106
+ pulled += disk.appendForeignLines(project.dir, bring);
107
+ // Lines pulled in move the file's end, but they were never ours to push,
108
+ // and a push cursor left behind them would only re-read and skip them.
109
+ const after = state.get(project.projectId);
110
+ state.set(project.projectId, { ...after, pulledSeq: rows[rows.length - 1].seq });
111
+ if (rows.length < PAGE) break;
112
+ }
113
+ return pulled;
114
+ }
115
+
116
+ // YOUR THREADS AS THE TEAM SEES THEM. Published only when they changed, so a
117
+ // quiet Mac costs the cloud nothing on each pass.
118
+ async function publishCards() {
119
+ const teamId = teamIdOf?.();
120
+ if (!teamId) return false;
121
+ const cards = (listCards() || []).slice().sort((a, b) => a.threadId.localeCompare(b.threadId));
122
+ const hash = crypto.createHash('sha256').update(teamId + JSON.stringify(cards)).digest('hex');
123
+ if (hash === state.getCardsHash()) return false;
124
+ await backend.putCards(teamId, cards);
125
+ state.setCardsHash(hash);
126
+ return true;
127
+ }
128
+
129
+ async function run() {
130
+ const me = backend.personId ?? (await backend.me())?.id;
131
+ if (!me) throw new Error('nobody is signed in');
132
+ const report = { joined: [], notJoined: 0, pushed: 0, pulled: 0, cards: false };
133
+
134
+ const cloudProjects = await backend.listProjects();
135
+ const local = new Map(listShared().map((p) => [p.projectId, p]));
136
+ for (const project of cloudProjects) {
137
+ if (local.has(project.id)) continue;
138
+ if (local.size >= MAX_SHARED_PROJECTS) { report.notJoined += 1; continue; }
139
+ const joined = await joinProject(project);
140
+ if (joined) { local.set(project.id, joined); report.joined.push(project.name); }
141
+ }
142
+
143
+ for (const project of cloudProjects) {
144
+ const here = local.get(project.id);
145
+ if (!here) continue;
146
+ report.pushed += await pushProject(here, me);
147
+ report.pulled += await pullProject(here, me);
148
+ }
149
+
150
+ report.cards = await publishCards();
151
+ return report;
152
+ }
153
+
154
+ return {
155
+ // One pass at a time: a second call while one runs waits for it and then
156
+ // runs again, so a change that lands mid-pass is never left behind.
157
+ async syncOnce() {
158
+ while (running) await running.catch(() => {});
159
+ running = run();
160
+ try { return await running; } finally { running = null; }
161
+ },
162
+ };
163
+ }
package/main/updater.mjs CHANGED
@@ -28,9 +28,10 @@
28
28
  // the downloaded update is applied then, so the next launch is current. The
29
29
  // button is the fast path, not the only path.
30
30
  //
31
- // WHERE THE UPDATE COMES FROM. `Astral-Agent/astral-releases`, the same public
32
- // repo astral.ac/download already redirects to, configured in package.json's
33
- // `build.publish`. electron-updater reads `releases/latest`, then
31
+ // WHERE THE UPDATE COMES FROM. `agentboxhq/agentbox-releases`, the same public
32
+ // repo agentbox.ac/download redirects to, configured in package.json's
33
+ // `build.publish` (until 2026-10-05 it was Astral-Agent/astral-releases, which
34
+ // is private, so no installed copy could ever read it). electron-updater reads `releases/latest`, then
34
35
  // `latest-mac.yml` on that release, and compares its `version` against this
35
36
  // build's. It downloads the `.zip`, NOT the `.dmg`: Squirrel. Mac swaps an .app
36
37
  // bundle and cannot read a disk image, which is why `build.mac.target` gained a
@@ -64,7 +65,8 @@ const EVERY_MS = 6 * 60 * 60 * 1000;
64
65
  * Electron app, a network, or a packaged build.
65
66
  */
66
67
  export function describe(state) {
67
- const { phase, currentVersion, newVersion, percent, error, checkedAt, readyAt } = state;
68
+ const { phase, currentVersion, newVersion, percent, error, checkedAt, readyAt, source, changes, behind } = state;
69
+ const installing = phase === 'installing';
68
70
  return {
69
71
  phase,
70
72
  currentVersion,
@@ -79,10 +81,18 @@ export function describe(state) {
79
81
  // every other row's right end answers "how old is this". Built off the
80
82
  // clock instead it would read "now" forever, which is the app looking busy
81
83
  // about something that has been sitting on the disk since lunchtime.
82
- readyAt: phase === 'ready' ? (readyAt ?? null) : null,
84
+ readyAt: phase === 'ready' || installing ? (readyAt ?? null) : null,
83
85
  // The one question every screen actually asks. A screen should not have to
84
86
  // learn which phase strings mean "there is a button to press".
85
87
  ready: phase === 'ready',
88
+ // A COPY RUN FROM SOURCE (main/source-updater.mjs) says three more things:
89
+ // that it is one, what changed (newest first, at most five titles, and how
90
+ // many there are in all), and that the button was pressed and it is
91
+ // rebuilding, which takes long enough that the row has to say so.
92
+ source: !!source,
93
+ changes: Array.isArray(changes) ? changes : [],
94
+ behind: typeof behind === 'number' ? behind : null,
95
+ installing,
86
96
  };
87
97
  }
88
98
 
@@ -0,0 +1,50 @@
1
+ // ⌘R WRITES WHAT THE PAGE IS HOLDING BEFORE IT RELOADS.
2
+ //
3
+ // A close, an approval, a picked option and a typed reply are all held in the
4
+ // page for three seconds so Z can take them back (`deferCommit` in App.tsx),
5
+ // and the only thing that ever writes one is a timer in that page. Reloading
6
+ // straight away threw the timer and the action out together: she closed a
7
+ // thread, pressed ⌘R, and it came back to her inbox as if she had never
8
+ // touched it (w-47218417a5).
9
+ //
10
+ // So the reload asks first. The page writes what it holds (or answers at once
11
+ // when it holds nothing) and says so, and only then does the window reload.
12
+ // The wait is bounded: a page that never answers, because it crashed or is a
13
+ // build from before this existed, still reloads, since ⌘R doing nothing is a
14
+ // complaint this chord has had twice already.
15
+
16
+ import { randomUUID } from 'node:crypto';
17
+
18
+ export const WRITE_HELD = 'zero:write-held';
19
+ export const WROTE_HELD = 'zero:wrote-held';
20
+
21
+ // A store write is a local file append, so this is far more than one needs; it
22
+ // only has to be short enough that a page that never answers is not mistaken
23
+ // for ⌘R not arriving.
24
+ const WAIT_MS = 2000;
25
+
26
+ export async function writeHeldThenReload({ webContents, ipcMain, reload, timeoutMs = WAIT_MS }) {
27
+ if (!webContents || webContents.isDestroyed()) return;
28
+ // Each ask carries its own id, so an answer meant for another one (a page
29
+ // from before the last reload, a second press) is not taken for this one.
30
+ const nonce = randomUUID();
31
+ await new Promise((resolve) => {
32
+ const finish = () => {
33
+ clearTimeout(timer);
34
+ ipcMain.removeListener(WROTE_HELD, onWrote);
35
+ resolve();
36
+ };
37
+ const onWrote = (event, answered) => {
38
+ if (answered === nonce && event?.sender === webContents) finish();
39
+ };
40
+ const timer = setTimeout(finish, timeoutMs);
41
+ ipcMain.on(WROTE_HELD, onWrote);
42
+ try {
43
+ webContents.send(WRITE_HELD, nonce);
44
+ } catch {
45
+ finish();
46
+ }
47
+ });
48
+ if (webContents.isDestroyed()) return;
49
+ reload();
50
+ }
package/mcp/core/page.mjs CHANGED
@@ -1,24 +1,22 @@
1
1
  // LOOKING AT A PAGE A WORKER JUST BUILT, AND ONLY AT A LOCAL ONE.
2
2
  //
3
3
  // Where this comes from (w-5ebf7bf7bb, MP-09). The terminal clients this app is
4
- // keeping parity with mostly ship a browser now. Hers, 2026-09-23: a worker
5
- // driving a browser of OURS is good and should happen; a worker driving the
6
- // browser she is signed into is not, and she never wants that ability to exist,
7
- // because that browser holds her logins, her cookies and everything else. That
8
- // half is a rule in CLAUDE.md with a test behind it.
4
+ // keeping parity with mostly ship a browser now. A worker driving a browser of
5
+ // OURS is good and should happen; a worker driving the browser the user is
6
+ // signed into is not, and that ability must never exist, because that browser
7
+ // holds the user's logins, cookies and everything else. That half is a rule in
8
+ // CLAUDE.md with a test behind it.
9
9
  //
10
- // The browser she meant is gone. She had the whole rest screen and the browser
11
- // inside the app deleted the day before (w-030026f226, for the open source cut),
12
- // so there is no pane left to drive. This is the smallest honest thing that
10
+ // The app has no browser pane of its own to drive. This is the smallest honest thing that
13
11
  // still delivers what a worker actually needs: start your app, look at it, show
14
- // her the picture. No window, no logins, nothing of hers.
12
+ // the user the picture. No window, no logins, nothing of the user's.
15
13
  //
16
14
  // LOCAL ONLY, AND THE RULE IS THE POINT RATHER THAN A SETTING. A worker that can
17
15
  // fetch any address is a worker that can post anything it has read to any
18
16
  // address. Loopback cannot leave the machine, so the capability is bounded by
19
17
  // what it can reach rather than by whoever remembers to review a prompt. If
20
- // this is ever widened it is a permission design and hers to approve, which is
21
- // what she said in the same breath as approving the rest of it.
18
+ // this is ever widened it is a permission design that needs the owner's
19
+ // explicit approval.
22
20
  import fs from 'node:fs';
23
21
  import path from 'node:path';
24
22
  import { spawn } from 'node:child_process';
@@ -89,7 +87,7 @@ export async function lookAtPage({ product, url, width = 1280, height = 900, cap
89
87
  * stored in the browser, and it is thrown away with the profile directory.
90
88
  */
91
89
  export async function captureWithChrome({ url, width, height, chrome = findChrome(), timeoutMs = 20_000 }) {
92
- if (!chrome) return { error: 'No Chrome, Chromium or Edge on this Mac, so there is nothing to open the page with.' };
90
+ if (!chrome) return { error: 'No Chrome, Chromium or Edge on this computer, so there is nothing to open the page with.' };
93
91
  const profile = fs.mkdtempSync(path.join(process.env.TMPDIR ?? '/tmp', 'look-at-page-'));
94
92
  const child = spawn(chrome, [
95
93
  '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`,