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,440 @@
1
+ // THE TEAM SERVICE: who is signed in on this Mac, their team, and the sync
2
+ // that keeps shared projects the same on every teammate's Mac.
3
+ //
4
+ // It owns one session (Supabase in the app, memory in tests) and, once someone
5
+ // is signed in:
6
+ // - stamps every ledger line this Mac writes with their person id, here and
7
+ // in the store server the supervisor starts (AGENTBOX_PERSON_ID);
8
+ // - shows them the invites waiting for their email, and joins one only when
9
+ // they say yes (acceptInvite). Once this Mac is in a team it stays in that
10
+ // team: an invite, or an older team somebody made, never moves it;
11
+ // - syncs every few seconds, and at once when a teammate writes;
12
+ // - answers the window's questions: who am I, who is on my team, what are
13
+ // my teammates' private tasks doing.
14
+ import path from 'node:path';
15
+ import { createTeamSync, fileSyncState } from './sync.mjs';
16
+ import { listSharedProjects, joinSharedProject, markShared, makeDirect } from './projects.mjs';
17
+ import { firstSentence } from '../../shared/thread-cards.mjs';
18
+ import { cardsFor } from '../../shared/thread-cards.mjs';
19
+ import fs from 'node:fs';
20
+ import { hasLapsed, statusEnds } from '../../shared/team-status.mjs';
21
+
22
+ // `signingIn` is { url } while a Google sign-in waits on the browser, so the
23
+ // page can offer that link again rather than a button that does nothing.
24
+ const EMPTY = { configured: false, started: false, signedIn: false, signingIn: null, me: null, team: null, invites: [], sent: [], people: [], cards: [], lastSyncAt: null, error: null };
25
+
26
+ export function createTeamService({
27
+ session, store, disk, accountRoot, stateFile, onChange = () => {}, log = () => {}, intervalMs = 5000, startRetryMs = 1500,
28
+ // The clock, so a test can move past the moment a status runs out.
29
+ now = Date.now,
30
+ }) {
31
+ let state = { ...EMPTY, configured: !!session?.configured };
32
+ let backend = null;
33
+ let sync = null;
34
+ let timer = null;
35
+ let unsubscribe = null;
36
+ let pending = null;
37
+ // The sync cursors and which team this Mac is in, in one file.
38
+ let local = null;
39
+ const kept = () => (local ??= fileSyncState(stateFile, fs));
40
+
41
+ const set = (patch) => { state = { ...state, ...patch }; onChange(); };
42
+
43
+ function products() {
44
+ try { return store.listProducts(); } catch { return []; }
45
+ }
46
+
47
+ // A status the owner set has run out: wipe it where it is stored, so old
48
+ // text does not sit in the cloud. Everyone who READS it already saw it go,
49
+ // on their own clock (shared/team-status.mjs); this is the tidying.
50
+ async function clearLapsedStatus() {
51
+ if (!backend || !hasLapsed(state.me?.status, now())) return;
52
+ await backend.setStatus({ text: null, until: null });
53
+ set({ me: { ...state.me, status: null } });
54
+ }
55
+
56
+ // THE MESSAGE RECORD OF EXACTLY TWO PEOPLE, made by one of them (see
57
+ // `message` below for why both halves matter).
58
+ // `to` is one person or several (2026-10-01: messages to a few people at
59
+ // once). The record must hold EXACTLY you and them, and one of you made it.
60
+ function directWith(me, to) {
61
+ const two = new Set([me, ...[].concat(to)]);
62
+ const justUs = (t) => {
63
+ const on = new Set([...t.people, ...(t.sharedBy ? [t.sharedBy] : [])]);
64
+ return two.has(t.sharedBy) && on.size === two.size && [...on].every((p) => two.has(p));
65
+ };
66
+ // ONE RECORD FOR THE TWO OF YOU, THE SAME ONE ON BOTH MACS. Two people who
67
+ // write each other first within the same second each make one (measured
68
+ // 2026-10-01 on two test copies), and each Mac then kept writing into its
69
+ // own. Both now settle on the record with the lowest id, so the talk
70
+ // comes back together the moment both records are known.
71
+ return products().filter((p) => p.team?.direct && justUs(p.team))
72
+ .sort((a, b) => String(a.team.projectId).localeCompare(String(b.team.projectId)))[0] ?? null;
73
+ }
74
+
75
+ // THE CONVERSATION IN A MESSAGE RECORD: its most recently touched row. Older
76
+ // records may hold one row per message, from before a message continued the
77
+ // conversation, and the newest of those is where the talk is.
78
+ function conversationIn(slug) {
79
+ let rows = [];
80
+ try { rows = store.listItems().filter((i) => i.product === slug && !i.agent); } catch { rows = []; }
81
+ return rows.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0] ?? null;
82
+ }
83
+
84
+ // THE TEAM THIS MAC IS IN IS THE ONE IT REMEMBERS. Found by review
85
+ // 2026-10-01: the app took whichever of your teams was oldest, and joined
86
+ // every team that invited you at each start, so a team made with a
87
+ // backdated created_at and an invite moved your work into it unasked. Now
88
+ // the first team is remembered, and a remembered team you are no longer on
89
+ // leaves you in no team rather than in another one.
90
+ async function refreshTeam() {
91
+ const me = state.me?.id;
92
+ const teams = await backend.myTeams();
93
+ const remembered = me ? kept().getTeam(me) : null;
94
+ // '' is "left a team, and joins nothing until asked": never quietly the next one.
95
+ const team = remembered === '' ? null : remembered ? teams.find((t) => t.id === remembered) ?? null : teams[0] ?? null;
96
+ if (team && !remembered && me) kept().setTeam(me, team.id);
97
+ // The moment sharing starts on this Mac, kept so it never moves later.
98
+ if (team && me && !kept().getSince(me)) kept().setSince(me, Date.now());
99
+ const people = team ? await backend.teamPeople(team.id) : [];
100
+ const cards = team ? await backend.listCards() : [];
101
+ // An invite lookup that fails (an older cloud, a dropped request) leaves
102
+ // you signed in with nothing offered, never signed out.
103
+ const invites = team ? [] : await backend.pendingInvites().catch((err) => { log(`team: invites: ${err.message}`); return []; });
104
+ // The invites this team has out, so the settings page can say who has not
105
+ // joined yet and let them be cancelled.
106
+ const sent = team && backend.listInvites ? await backend.listInvites(team.id).catch(() => []) : [];
107
+ set({ team, people, cards, invites, sent, since: me ? kept().getSince(me) : null });
108
+ }
109
+
110
+ async function syncNow() {
111
+ if (!sync) return null;
112
+ if (pending) return pending;
113
+ // THE BODY WAITS A MICROTASK, SO `pending` IS SET BEFORE ANY OF IT RUNS.
114
+ // Written as `pending = (async () => {...})()` the body's synchronous
115
+ // prefix runs BEFORE the assignment, so `pending` was still null while it
116
+ // ran. That was harmless until a pass wrote to the cloud: clearing a
117
+ // lapsed status makes the memory cloud emit, the emit calls back into
118
+ // syncNow, the guard is still null, and it recurses until the stack ends.
119
+ // Measured on the first run of the lapse test: a stack overflow, every
120
+ // frame this function.
121
+ pending = Promise.resolve().then(async () => {
122
+ try {
123
+ // An invite sent after this person signed in shows up here, not at
124
+ // their next sign-in. It is offered, never taken up.
125
+ if (!state.team) await refreshTeam();
126
+ await clearLapsedStatus();
127
+ const report = await sync.syncOnce();
128
+ // The people are read again on every pass, with the activity, so a
129
+ // teammate who joined after this Mac signed in has a name and a face
130
+ // on the first line of theirs that arrives here.
131
+ const [cards, people, me] = state.team
132
+ ? await Promise.all([backend.listCards(), backend.teamPeople(state.team.id), backend.me()])
133
+ : [[], [], state.me];
134
+ set({ lastSyncAt: Date.now(), error: null, cards, people, me: me ?? state.me });
135
+ if (report.joined.length || report.pulled) log(`team: joined ${report.joined.length}, pulled ${report.pulled}, pushed ${report.pushed}`);
136
+ if (report.notJoined) log(`team: left ${report.notJoined} shared project(s) unjoined, this computer already holds the most it takes on by itself`);
137
+ return report;
138
+ } catch (err) {
139
+ set({ error: String(err?.message ?? err) });
140
+ return null;
141
+ } finally {
142
+ pending = null;
143
+ }
144
+ });
145
+ return pending;
146
+ }
147
+
148
+ function stopLoop() {
149
+ if (timer) clearInterval(timer);
150
+ timer = null;
151
+ unsubscribe?.();
152
+ unsubscribe = null;
153
+ }
154
+
155
+ async function signedIn(b) {
156
+ backend = b;
157
+ const me = await backend.me();
158
+ if (!me) throw new Error('signed in, but no person record came back');
159
+ disk.setLineAuthor(me.id);
160
+ process.env.AGENTBOX_PERSON_ID = me.id;
161
+ set({ signedIn: true, me, error: null });
162
+ // MY WORKSPACE IS NOT MADE HERE ANY MORE (w-f8d123be62, approved
163
+ // 2026-10-05). It was, and that was the whole bug: the only caller was this
164
+ // one, inside signing in, and this service is not even built on a Mac with
165
+ // no team cloud. So the single-person app opened on no projects at all.
166
+ // Everybody gets one at boot now (main/main.mjs), signed in or not.
167
+ await refreshTeam();
168
+ sync = createTeamSync({
169
+ backend,
170
+ disk,
171
+ state: kept(),
172
+ // ARCHIVED ONES COUNT AS HERE. Asked of the visible list, a shared
173
+ // project you archived read as missing and was joined again beside it.
174
+ listShared: () => { try { return listSharedProjects(store.listProducts({ includeArchived: true })); } catch { return []; } },
175
+ joinProject: (project) => joinSharedProject(accountRoot, project),
176
+ listCards: () => cardsFor({ products: products(), readItems: (p) => disk.readWorkItems(p.dir), since: state.me ? kept().getSince(state.me.id) : null }),
177
+ teamIdOf: () => state.team?.id ?? null,
178
+ });
179
+ stopLoop();
180
+ timer = setInterval(() => { syncNow(); }, intervalMs);
181
+ timer.unref?.();
182
+ unsubscribe = backend.subscribe(() => { syncNow(); });
183
+ await syncNow();
184
+ }
185
+
186
+ return {
187
+ state: () => state,
188
+
189
+ // THREE TRIES AT START, because one dropped request left the app signed
190
+ // out until it was restarted (measured 2026-10-01 on a loaded Mac: the
191
+ // sign-in went through and the very next call failed). A session that is
192
+ // simply not there is not an error and is not retried.
193
+ // `started` says the first look for a saved sign-in is over, so the window
194
+ // can tell "signed out" from "still finding out" and never flashes the
195
+ // sign-in page at somebody who is signed in.
196
+ async start() {
197
+ if (!session?.configured) return state;
198
+ for (let attempt = 0; attempt < 3; attempt += 1) {
199
+ try {
200
+ const b = await session.restore();
201
+ if (b) await signedIn(b);
202
+ set({ started: true });
203
+ return state;
204
+ } catch (err) {
205
+ set({ error: String(err?.message ?? err) });
206
+ if (attempt < 2) await new Promise((r) => setTimeout(r, startRetryMs * (attempt + 1)));
207
+ }
208
+ }
209
+ set({ started: true });
210
+ return state;
211
+ },
212
+
213
+ async signIn() {
214
+ let b;
215
+ try {
216
+ b = await session.signIn({ onUrl: (url) => set({ signingIn: { url } }), log });
217
+ } finally {
218
+ if (state.signingIn) set({ signingIn: null });
219
+ }
220
+ await signedIn(b);
221
+ return state;
222
+ },
223
+
224
+ /** Open the waiting Google sign-in's own link again. False if none waits. */
225
+ async reopenSignIn() {
226
+ const url = state.signingIn?.url;
227
+ if (!url || !session?.reopen) return false;
228
+ log('team: Google sign-in: opening the browser again');
229
+ await session.reopen(url);
230
+ return true;
231
+ },
232
+
233
+ cancelSignIn() {
234
+ return session?.cancelSignIn?.() ?? false;
235
+ },
236
+
237
+ async signInWithEmail(email, password) {
238
+ if (!session?.signInWithPassword) throw new Error('this build cannot sign in with email');
239
+ const b = await session.signInWithPassword(email, password);
240
+ await signedIn(b);
241
+ return state;
242
+ },
243
+
244
+ /** A new account: signed in at once, or { confirm: true } until the email is confirmed. */
245
+ async signUp(email, password, name) {
246
+ if (!session?.signUp) throw new Error('this build cannot make accounts');
247
+ const out = await session.signUp({ email, password, name });
248
+ if (out?.backend) { await signedIn(out.backend); return state; }
249
+ return { ...state, confirm: true };
250
+ },
251
+
252
+ async signOut() {
253
+ stopLoop();
254
+ await session.signOut?.();
255
+ disk.setLineAuthor(null);
256
+ delete process.env.AGENTBOX_PERSON_ID;
257
+ backend = null;
258
+ sync = null;
259
+ // Signed out is a known answer, so the window shows the sign-in page at once.
260
+ state = { ...EMPTY, configured: !!session?.configured, started: true };
261
+ onChange();
262
+ return state;
263
+ },
264
+
265
+ async createTeam(name) {
266
+ if (!backend) throw new Error('sign in first');
267
+ const clean = String(name ?? '').trim();
268
+ if (!clean) throw new Error('a team needs a name');
269
+ const made = await backend.createTeam(clean);
270
+ kept().setTeam(state.me.id, made.id);
271
+ await refreshTeam();
272
+ return state;
273
+ },
274
+
275
+ // TEAM SETTINGS (2026-10-01): an invite page and team settings beside it.
276
+ // The database decides who may (owners rename,
277
+ // remove and cancel; anyone leaves); these only ask it and say what it said.
278
+ async renameTeam(name) {
279
+ if (!backend || !state.team) throw new Error('start or join a team first');
280
+ const clean = String(name ?? '').trim();
281
+ if (!clean) throw new Error('a team needs a name');
282
+ await backend.renameTeam(state.team.id, clean);
283
+ await refreshTeam();
284
+ return state;
285
+ },
286
+
287
+ // A line you write about yourself, held until the end of today, tomorrow
288
+ // or this week, or until you clear it. Saying nothing clears it.
289
+ async setStatus({ text, hold = 'open', until } = {}) {
290
+ if (!backend) throw new Error('sign in first');
291
+ const status = await backend.setStatus({ text, until: statusEnds({ hold, until }, now()) });
292
+ set({ me: { ...state.me, status } });
293
+ return state;
294
+ },
295
+
296
+ async removeMember(personId) {
297
+ if (!backend || !state.team) throw new Error('start or join a team first');
298
+ if (personId === state.me?.id) throw new Error('to leave the team, use Leave team');
299
+ await backend.removeMember(state.team.id, personId);
300
+ await refreshTeam();
301
+ return state;
302
+ },
303
+
304
+ async leaveTeam() {
305
+ if (!backend || !state.team || !state.me) throw new Error('you are not on a team');
306
+ await backend.removeMember(state.team.id, state.me.id);
307
+ kept().setTeam(state.me.id, '');
308
+ await refreshTeam();
309
+ return state;
310
+ },
311
+
312
+ async cancelInvite(email) {
313
+ if (!backend || !state.team) throw new Error('start or join a team first');
314
+ await backend.cancelInvite(state.team.id, email);
315
+ await refreshTeam();
316
+ return state;
317
+ },
318
+
319
+ // JOIN A TEAM BECAUSE ITS INVITE WAS ANSWERED YES, and only then. A Mac
320
+ // already in a team does not move: that is a choice nobody offers here.
321
+ async acceptInvite(teamId) {
322
+ if (!backend || !state.me) throw new Error('sign in first');
323
+ if (state.team) throw new Error(`this computer is already in ${state.team.name}`);
324
+ if (!state.invites.some((i) => i.teamId === teamId)) throw new Error('that invite is no longer waiting');
325
+ await backend.acceptInvite(teamId);
326
+ kept().setTeam(state.me.id, teamId);
327
+ await refreshTeam();
328
+ await syncNow();
329
+ return state;
330
+ },
331
+
332
+ async invite(email) {
333
+ if (!backend || !state.team) throw new Error('start or join a team first');
334
+ const clean = String(email ?? '').trim();
335
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clean)) throw new Error(`${clean || 'that'} is not an email address`);
336
+ await backend.invite(state.team.id, clean);
337
+ // So the settings page lists it as invited straight away.
338
+ await refreshTeam().catch(() => {});
339
+ return state;
340
+ },
341
+
342
+ // Share a local project with the whole team or with specific people. The
343
+ // project keeps one cloud id for good (markShared), so changing who sees
344
+ // it is the same project.
345
+ async share(slug, { visibility = 'team', people = [] } = {}) {
346
+ if (!backend || !state.team) throw new Error('start or join a team first');
347
+ const product = products().find((p) => p.slug === slug);
348
+ if (!product) throw new Error(`no project ${slug}`);
349
+ const team = markShared(product.dir, { teamId: state.team.id, visibility, people, sharedBy: state.me.id });
350
+ await backend.shareProject({ id: team.projectId, teamId: state.team.id, name: product.name, visibility: team.visibility, people: team.people });
351
+ onChange();
352
+ await syncNow();
353
+ return state;
354
+ },
355
+
356
+ // A MESSAGE TO A PERSON (approved 2026-10-01: people get messages, never
357
+ // tasks). It goes into the record the two of them share, made the first
358
+ // time and reused after, and it waits in their inbox until they answer.
359
+ /** The conversation in a message record: its newest row, if it has one. */
360
+ conversation(to) {
361
+ const me = state.me?.id;
362
+ if (!me || !to) return null;
363
+ const product = directWith(me, to);
364
+ const convo = product ? conversationIn(product.slug) : null;
365
+ return convo ? { product: product.slug, id: convo.id } : null;
366
+ },
367
+
368
+ /**
369
+ * `priority` is the level the sender picked on the card, and it counts on
370
+ * the FIRST message only (w-7ba439c883): the conversation's place in the
371
+ * other person's inbox was settled when it opened, and a reply three days
372
+ * later is not a chance to lift it. Nothing picked means nothing sent, so
373
+ * the row keeps composeItem's 0, which the inbox reads as Medium.
374
+ */
375
+ async message(toIn, body, priority) {
376
+ if (!backend || !state.team || !state.me) throw new Error('start or join a team first');
377
+ const me = state.me.id;
378
+ // One person or a few: the same conversation for the same people.
379
+ const others = [...new Set([].concat(toIn ?? []).filter((p) => p && p !== me))].sort();
380
+ if (!others.length) throw new Error('pick someone to message');
381
+ if (others.length > 11) throw new Error('a message goes to at most eleven people');
382
+ if (others.some((p) => !state.people.some((q) => q.id === p))) throw new Error('that person is not on your team');
383
+ const to = others.length === 1 ? others[0] : others;
384
+ const text = String(body ?? '').trim();
385
+ if (!text) throw new Error('a message needs some words');
386
+ // THE RECORD IS REUSED ONLY IF IT IS EXACTLY THE TWO OF YOU AND ONE OF
387
+ // YOU MADE IT. Found by review 2026-10-01: a teammate could make a
388
+ // "direct" record holding you and somebody else, and the next message
389
+ // either of you sent the other went into it, where its maker read it.
390
+ let product = directWith(me, to);
391
+ // Before making a record, look once more: the other person may have just
392
+ // made one, and the pull is what brings it here.
393
+ if (!product) { await syncNow(); product = directWith(me, to); }
394
+ if (!product) {
395
+ const made = makeDirect(accountRoot, { teamId: state.team.id, me, others });
396
+ // A START THAT FAILS LEAVES NOTHING BEHIND. A record shared only in
397
+ // part (the cloud refused its people) was reused by the next send and
398
+ // never reached anyone (found 2026-10-01 on a test copy).
399
+ try {
400
+ await backend.shareProject({ id: made.projectId, teamId: state.team.id, name: 'Direct', visibility: 'people', people: others, direct: true });
401
+ } catch (err) {
402
+ try { fs.rmSync(made.dir, { recursive: true, force: true }); } catch { /* the error below still says what failed */ }
403
+ throw err;
404
+ }
405
+ product = products().find((p) => p.dir === made.dir) ?? { slug: made.slug, dir: made.dir };
406
+ }
407
+ // ONE CONVERSATION PER PERSON (decided 2026-10-01 from user research:
408
+ // people expect to click a name and see what was said before, and a
409
+ // subject line on every message between two coworkers makes talking
410
+ // feel like filing tickets). So a message goes on the end of the
411
+ // conversation the two of you already have, reopened if it was put away,
412
+ // and only the first message ever starts one. Work stays in threads; this
413
+ // is talk.
414
+ const title = firstSentence(text, 90) || text.slice(0, 90);
415
+ const convo = conversationIn(product.slug);
416
+ if (convo) {
417
+ store.answerItem(product.slug, convo.id, { answer: text });
418
+ // Who answers next is whoever did not speak last (shared/team-rules.mjs,
419
+ // `inMyInbox`); the assignee stays for a Mac on an older build.
420
+ store.teamPatch(product.slug, convo.id, { assignee: others[0] });
421
+ onChange();
422
+ await syncNow();
423
+ return store.readItem(product.slug, convo.id);
424
+ }
425
+ const level = Number.isFinite(priority) && priority > 0 ? Math.trunc(priority) : 0;
426
+ const item = store.composeItem(product.slug, { title, body: text, priority: level, assignee: others[0], people: [me, ...others] });
427
+ onChange();
428
+ await syncNow();
429
+ return item;
430
+ },
431
+
432
+ syncNow,
433
+ stop: stopLoop,
434
+ };
435
+ }
436
+
437
+ // Where the service keeps its sync cursors: beside the store's own records.
438
+ export function teamStateFile(storeRoot) {
439
+ return path.join(storeRoot, '.team-sync.json');
440
+ }