agentbox-app 0.1.7 → 0.1.11

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 (244) hide show
  1. package/README.md +209 -55
  2. package/bin/agentbox.mjs +6 -0
  3. package/briefs/message-rules.md +13 -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 +9 -10
  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 +114 -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/artifact-path.mjs +11 -0
  16. package/main/claude-bin.mjs +31 -5
  17. package/main/claude-input.mjs +45 -3
  18. package/main/claude-plan.mjs +11 -11
  19. package/main/code-change.mjs +3 -3
  20. package/main/codex-account.mjs +1 -1
  21. package/main/codex-bin.mjs +17 -0
  22. package/main/codex-memory-gate.mjs +260 -0
  23. package/main/codex-models.mjs +12 -7
  24. package/main/codex-threads.mjs +46 -23
  25. package/main/codex-usage-file.mjs +7 -7
  26. package/main/codex-usage.mjs +4 -4
  27. package/main/codex-watch.mjs +12 -12
  28. package/main/config.mjs +68 -8
  29. package/main/dev-window.mjs +9 -9
  30. package/main/doc-scheme.mjs +2 -0
  31. package/main/engine-setup.mjs +193 -0
  32. package/main/feedback.mjs +55 -0
  33. package/main/folder-pictures.mjs +48 -0
  34. package/main/frame-scrollbars.mjs +90 -0
  35. package/main/fresh-user.mjs +6 -3
  36. package/main/git-change-offthread.mjs +70 -0
  37. package/main/git-change-thread.mjs +11 -0
  38. package/main/git-change.mjs +1 -1
  39. package/main/img-scheme.mjs +70 -1
  40. package/main/instruction-settings.mjs +92 -54
  41. package/main/ipc.mjs +316 -42
  42. package/main/leftovers.mjs +518 -0
  43. package/main/live-replies.mjs +43 -5
  44. package/main/machine.mjs +47 -0
  45. package/main/main.mjs +119 -15
  46. package/main/memory-gate-history.mjs +254 -0
  47. package/main/memory-gate-server.mjs +461 -0
  48. package/main/memory-gate.mjs +206 -0
  49. package/main/message-priority.mjs +87 -0
  50. package/main/rail-note.mjs +2 -2
  51. package/main/refresh-agent-models.mjs +20 -0
  52. package/main/repeats.mjs +4 -4
  53. package/main/row-label.mjs +114 -51
  54. package/main/serve.mjs +67 -5
  55. package/main/settings.mjs +223 -26
  56. package/main/ship-queue.mjs +279 -0
  57. package/main/sign-in-files.mjs +39 -0
  58. package/main/source-updater.mjs +298 -0
  59. package/main/store/home.mjs +47 -2
  60. package/main/store/work-items.mjs +150 -6
  61. package/main/store.mjs +235 -9
  62. package/main/supervisor.mjs +1444 -406
  63. package/main/task-commands.mjs +8 -10
  64. package/main/task-folders.mjs +74 -13
  65. package/main/task-remote-control.mjs +3 -3
  66. package/main/task-terminals.mjs +2 -1
  67. package/main/team/index.mjs +440 -0
  68. package/main/team/memory-cloud.mjs +247 -0
  69. package/main/team/projects.mjs +162 -0
  70. package/main/team/session.mjs +225 -0
  71. package/main/team/sign-in.mjs +106 -0
  72. package/main/team/supabase-backend.mjs +231 -0
  73. package/main/team/sync.mjs +163 -0
  74. package/main/updater.mjs +15 -5
  75. package/main/write-before-reload.mjs +50 -0
  76. package/mcp/core/page.mjs +9 -11
  77. package/mcp/core/work.mjs +91 -25
  78. package/mcp/tools.mjs +12 -6
  79. package/package.json +21 -9
  80. package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
  81. package/renderer/dist/assets/index-B-2Az952.css +32 -0
  82. package/renderer/dist/assets/index-D6qncgai.js +1168 -0
  83. package/renderer/dist/index.html +2 -2
  84. package/scripts/memory-gate-hook.sh +59 -0
  85. package/shared/agent-import.mjs +38 -15
  86. package/shared/agents.d.mts +26 -8
  87. package/shared/agents.mjs +100 -108
  88. package/shared/analytics-events.mjs +19 -2
  89. package/shared/answers.mjs +12 -0
  90. package/shared/bridge-map.d.mts +12 -0
  91. package/shared/bridge-map.mjs +37 -1
  92. package/shared/claude-commands.generated.mjs +2 -2
  93. package/shared/claude-models.generated.mjs +4 -4
  94. package/shared/claude-usage.mjs +6 -6
  95. package/shared/codex-import.mjs +7 -7
  96. package/shared/codex-modes.d.mts +50 -0
  97. package/shared/codex-usage.mjs +7 -8
  98. package/shared/demo-world.mjs +5 -5
  99. package/shared/engines.mjs +27 -10
  100. package/shared/feedback.d.mts +14 -0
  101. package/shared/feedback.mjs +44 -0
  102. package/shared/first-run-examples.mjs +4 -4
  103. package/shared/first-run-practice.d.mts +16 -0
  104. package/shared/first-run-practice.mjs +176 -70
  105. package/shared/fresh-user-home.mjs +22 -3
  106. package/shared/instruction-defaults.json +2 -2
  107. package/shared/product-name.mjs +4 -2
  108. package/shared/project-folder-check.mjs +6 -0
  109. package/shared/rank.d.mts +1 -0
  110. package/shared/rank.mjs +22 -0
  111. package/shared/referenced-files.mjs +1 -1
  112. package/shared/schemes.d.mts +11 -0
  113. package/shared/schemes.mjs +1 -1
  114. package/shared/shortcuts.d.mts +41 -0
  115. package/shared/shortcuts.mjs +236 -0
  116. package/shared/spawn-trouble.d.mts +2 -0
  117. package/shared/spawn-trouble.mjs +39 -18
  118. package/shared/team-rules.d.mts +17 -0
  119. package/shared/team-rules.mjs +230 -0
  120. package/shared/team-status.d.mts +11 -0
  121. package/shared/team-status.mjs +90 -0
  122. package/shared/terminal-links.d.mts +2 -0
  123. package/shared/terminal-links.mjs +55 -0
  124. package/shared/terminal-state.d.mts +1 -0
  125. package/shared/terminal-state.mjs +16 -0
  126. package/shared/thread-cards.d.mts +19 -0
  127. package/shared/thread-cards.mjs +254 -0
  128. package/shared/usage.d.mts +1 -1
  129. package/shared/usage.mjs +6 -7
  130. package/shared/work-items.mjs +255 -27
  131. package/shared/work-lines.mjs +393 -16
  132. package/worker-permissions.json +1 -1
  133. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  134. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  135. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  136. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  137. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  138. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  139. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  140. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  141. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  142. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  143. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  144. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  145. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  146. package/renderer/dist/assets/index-B7hhfHrT.js +0 -684
  147. package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
  148. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  149. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  150. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  151. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  152. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  153. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  154. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  155. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  156. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  157. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  158. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  159. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  160. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  161. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  162. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  163. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  164. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  165. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  166. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  167. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  168. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  169. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  170. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  171. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  172. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  173. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  174. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  175. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  176. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  177. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  178. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  179. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  180. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  181. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  182. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  183. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  184. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  185. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  186. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  187. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  188. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  189. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  190. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  191. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  192. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  193. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  194. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  195. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  196. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  197. package/renderer/dist/emulator/audio-rules.mjs +0 -74
  198. package/renderer/dist/emulator/data/compression/README.md +0 -11
  199. package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
  200. package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
  201. package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
  202. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  203. package/renderer/dist/emulator/data/cores/README.md +0 -22
  204. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  205. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  206. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  207. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  208. package/renderer/dist/emulator/data/cores/package.json +0 -65
  209. package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
  210. package/renderer/dist/emulator/data/emulator.css +0 -1614
  211. package/renderer/dist/emulator/data/loader.js +0 -170
  212. package/renderer/dist/emulator/data/localization/README.md +0 -63
  213. package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
  214. package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
  215. package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
  216. package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
  217. package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
  218. package/renderer/dist/emulator/data/localization/en-US.json +0 -339
  219. package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
  220. package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
  221. package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
  222. package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
  223. package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
  224. package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
  225. package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
  226. package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
  227. package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
  228. package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
  229. package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
  230. package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
  231. package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
  232. package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
  233. package/renderer/dist/emulator/data/src/GameManager.js +0 -464
  234. package/renderer/dist/emulator/data/src/compression.js +0 -142
  235. package/renderer/dist/emulator/data/src/emulator.js +0 -6158
  236. package/renderer/dist/emulator/data/src/gamepad.js +0 -167
  237. package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
  238. package/renderer/dist/emulator/data/src/shaders.js +0 -236
  239. package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
  240. package/renderer/dist/emulator/data/src/storage.js +0 -125
  241. package/renderer/dist/emulator/data/version.json +0 -5
  242. package/renderer/dist/emulator/host.js +0 -299
  243. package/renderer/dist/emulator/index.html +0 -28
  244. package/renderer/dist/emulator/provenance.json +0 -6
@@ -26,10 +26,10 @@ const exec = promisify(execFile);
26
26
  * a chat of its own, that takes over and nothing forks twice.
27
27
  */
28
28
  /**
29
- * A row name out of the sentence she typed after `/fork`: her first sentence,
29
+ * A row name out of the sentence typed after `/fork`: its first sentence,
30
30
  * or the first sixty characters of it, so the inbox line says which fork this
31
31
  * is rather than which row it came from. Whitespace flattened because a title
32
- * is one line and she can paste a paragraph.
32
+ * is one line and a paragraph can be pasted.
33
33
  */
34
34
  export function forkTitle(words, cap = 60) {
35
35
  const flat = String(words ?? '').replace(/\s+/g, ' ').trim();
@@ -45,18 +45,16 @@ function forkTask(sup, productSlug, id, item, engine, op, publish) {
45
45
  }
46
46
  // NAMING THE KEY, because the menu closes at the space and plain return then
47
47
  // writes a newline, the way it does on every other reply here. Without that
48
- // sentence she types the words, presses return, and is back to a key that
49
- // does nothing (hers, 2026-09-23: "Fork does nothing when I type /fork then
50
- // hit return").
48
+ // sentence the user types the words, presses return, and is back to a key
49
+ // that does nothing, so a bare /fork looks as if it did nothing.
51
50
  if (!op.args) return publish('failed', 'Say what the fork should try, as in /fork try it without the modal, then press command and return.', op.name);
52
51
  const chat = sup.rowSessionFor(item, sup.store.listProducts().find((p) => p.slug === productSlug));
53
52
  if (!chat?.sessionId) return publish('failed', 'This task has no saved conversation to fork yet. Send a message first.', op.name);
54
53
  const made = sup.store.composeItem(productSlug, {
55
- // THE FORK IS NAMED AFTER WHAT SHE ASKED IT TO TRY, not after the row it
56
- // came from. Naming it `<parent title> · fork` put two rows in her inbox
57
- // whose names differ by one word at the end, and the first thing she said
58
- // after using it was that she could not tell which agent she was talking to
59
- // (2026-09-23). The instruction is the only thing that distinguishes them,
54
+ // THE FORK IS NAMED AFTER WHAT IT WAS ASKED TO TRY, not after the row it
55
+ // came from. Naming it `<parent title> · fork` put two rows in the inbox
56
+ // whose names differ by one word at the end, so there was no telling which
57
+ // agent was which. The instruction is the only thing that distinguishes them,
60
58
  // so it is what the row is called; the parent is still on the row as the
61
59
  // `fork:` label, which is where the machinery reads it anyway.
62
60
  title: forkTitle(op.args),
@@ -1,8 +1,7 @@
1
1
  // ONE FOLDER PER TASK, OWNED BY THE APP RATHER THAN BY THE AGENT IN IT.
2
2
  //
3
- // the founder, 2026-09-22, choosing between letting Claude Code make its own
4
- // worktree and doing it ourselves: "the app makes the folder for both agents".
5
- // The two engines are not equal here. Claude Code has isolation built in
3
+ // Between letting Claude Code make its own worktree and doing it ourselves, the
4
+ // app makes the folder for both agents. The two engines are not equal here. Claude Code has isolation built in
6
5
  // (`--worktree`), makes the folder under `.claude/worktrees/`, locks it, reuses
7
6
  // it by name and deletes it when it leaves nothing behind. Codex has none of
8
7
  // that, only `--cd` for choosing where to work. So the rules have to live on
@@ -11,7 +10,7 @@
11
10
  // exactly where Claude Code would, so a person who opens the repository in a
12
11
  // terminal sees the layout they already know.
13
12
  //
14
- // WHAT WAS MEASURED, ON HER MAC, 2026-09-22. This repository had 25 leftover
13
+ // WHAT WAS MEASURED, 2026-09-22. This repository had 25 leftover
15
14
  // agent copies holding 26 GB: twelve with edits committed nowhere, twelve on
16
15
  // branches never merged, one safe to delete. Isolation was a rule in the worker
17
16
  // brief; the rule's first half (make a copy) was kept and its second half (fold
@@ -51,6 +50,9 @@ const BRANCH_PREFIX = `${nameSlug}/`;
51
50
  /** Ids are `w-` plus hex today. This is the guard for the day they are not. */
52
51
  const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
53
52
 
53
+ /** What an unfinished folder is called. A task name may never start with a dot. */
54
+ const BUILDING = '.building-';
55
+
54
56
  const git = (cwd, args) => execFileSync('git', args, {
55
57
  cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
56
58
  }).trim();
@@ -81,6 +83,32 @@ export function taskFolderPath(repoPath, id) {
81
83
  return path.join(real(repoPath), ...WORKTREES, safeTaskName(id));
82
84
  }
83
85
 
86
+ /**
87
+ * WHERE A FOLDER IS BUILT, WHICH IS NEVER WHERE IT IS RUN IN.
88
+ *
89
+ * Making one is a worktree plus a block clone of the checkout. It used to be
90
+ * built AT the path a session runs in, so for the whole of that a directory sat
91
+ * there, registered with git and holding almost none of the code. MEASURED on
92
+ * astral-video, the repository this happened in, 2026-10-01: the build takes 115
93
+ * seconds now that the checkout is 14 GB, and the path a session runs in
94
+ * appeared 2.7 seconds in. That is 112 seconds of a folder that looked ready and
95
+ * was not, against a supervisor tick of fifteen seconds, so seven ticks could
96
+ * read it. Everything that asks whether a row's folder is ready asks the disk,
97
+ * which means for those 112 seconds every one of them said yes:
98
+ * the supervisor started a session in a half written folder, and when the build
99
+ * finished it started a second session on the same row (2026-09-30,
100
+ * w-4d722ecf92, where the second one's claim came back refused and the first
101
+ * one's folder was deleted under it a minute in).
102
+ *
103
+ * Built here and MOVED into place once it is finished and checked, so a
104
+ * directory at the real path means a folder that is complete. That makes every
105
+ * existing question correct without any of them learning a new one, and it
106
+ * leaves the rollback below nothing to delete but its own unfinished copy.
107
+ */
108
+ export function buildingFolderPath(repoPath, id) {
109
+ return path.join(real(repoPath), ...WORKTREES, `${BUILDING}${safeTaskName(id)}`);
110
+ }
111
+
84
112
  export function taskBranch(id) { return `${BRANCH_PREFIX}${safeTaskName(id)}`; }
85
113
 
86
114
  /** The repository root, or null when this is not a checkout at all. */
@@ -156,20 +184,48 @@ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = tr
156
184
 
157
185
  hideFromTheCheckout(root);
158
186
 
187
+ // EVERY STEP BELOW HAPPENS SOMEWHERE ELSE, and the folder appears at its own
188
+ // path in one move at the end. `buildingFolderPath` is why. A build the app
189
+ // died in the middle of leaves an unfinished copy there, which nobody wants
190
+ // and which would refuse the next attempt, so it goes first.
191
+ const staging = buildingFolderPath(root, name);
192
+ clearUnfinished(root, staging);
193
+
159
194
  const branchExists = tryGit(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).ok;
160
195
  const from = branchExists ? branch : baseRef(root);
161
- if (!cloneCheckout(root, folder, branch, from, branchExists)) {
196
+ if (!cloneCheckout(root, staging, branch, from, branchExists)) {
162
197
  const add = branchExists
163
- ? tryGit(root, ['worktree', 'add', folder, branch])
164
- : tryGit(root, ['worktree', 'add', '-b', branch, folder, baseRef(root)]);
198
+ ? tryGit(root, ['worktree', 'add', staging, branch])
199
+ : tryGit(root, ['worktree', 'add', '-b', branch, staging, baseRef(root)]);
165
200
  if (!add.ok) throw Error(`Could not make a folder for ${name}: ${add.out}`);
166
- if (dependencies) cloneDependencies(root, folder);
201
+ if (dependencies) cloneDependencies(root, staging);
202
+ }
203
+
204
+ // `worktree move` rather than a rename, so git's own record of where this
205
+ // worktree lives moves with it. It refuses if anything is already at the
206
+ // destination, which is the last guard against walking over a live folder.
207
+ const moved = tryGit(root, ['worktree', 'move', staging, folder]);
208
+ if (!moved.ok) {
209
+ clearUnfinished(root, staging);
210
+ throw Error(`Could not make a folder for ${name}: ${moved.out}`);
167
211
  }
168
212
 
169
213
  lock(root, folder, pid);
170
214
  return { path: folder, branch, created: true };
171
215
  }
172
216
 
217
+ /**
218
+ * Throw away an unfinished copy. Nothing is ever lost here: a staging folder
219
+ * has never been handed to anybody, so the most it can hold is a checkout that
220
+ * did not finish. The branch is left alone, as everywhere else in this file.
221
+ */
222
+ function clearUnfinished(root, staging) {
223
+ if (!fs.existsSync(staging) && !record(root, staging)) return;
224
+ tryGit(root, ['worktree', 'remove', '--force', staging]);
225
+ try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* already gone */ }
226
+ tryGit(root, ['worktree', 'prune']);
227
+ }
228
+
173
229
  /**
174
230
  * MAKING THE FOLDER WITHOUT PAYING FOR IT TWICE.
175
231
  *
@@ -217,10 +273,12 @@ function cloneCheckout(root, folder, branch, from, branchExists) {
217
273
  if (!after.ok || after.out.length > 0) throw Error('the clone did not match the commit');
218
274
  return true;
219
275
  } catch {
220
- // Back to nothing, so the ordinary path starts from a clean sheet.
221
- tryGit(root, ['worktree', 'remove', '--force', folder]);
222
- try { fs.rmSync(folder, { recursive: true, force: true }); } catch { /* already gone */ }
223
- tryGit(root, ['worktree', 'prune']);
276
+ // Back to nothing, so the ordinary path starts from a clean sheet. What is
277
+ // thrown away here is the UNFINISHED COPY and never a folder anybody is in:
278
+ // `folder` is the staging path, which no session has ever been given. It
279
+ // used to be the real one, and a `remove --force` of that is how a running
280
+ // agent lost the ground under it.
281
+ clearUnfinished(root, folder);
224
282
  return false;
225
283
  }
226
284
  }
@@ -390,7 +448,10 @@ export function listTaskFolders(dir) {
390
448
  if (!root) return [];
391
449
  const home = path.join(root, ...WORKTREES);
392
450
  return registered(root)
393
- .filter((w) => path.dirname(w.path) === home && !w.prunable)
451
+ // A folder still being built is not one of hers: it has no row standing in
452
+ // it and nothing in it is wanted, so it may never be listed, shown or swept.
453
+ .filter((w) => path.dirname(w.path) === home && !w.prunable
454
+ && !path.basename(w.path).startsWith(BUILDING))
394
455
  .map((w) => {
395
456
  const { dirty, merged } = folderState(root, w.path);
396
457
  let touchedAt = null;
@@ -18,9 +18,9 @@ export async function taskRemoteControl(sup,product,id,action='status') {
18
18
  if(sup._engineFor(item)==='codex')throw Error('Remote Control is currently available for Claude Code conversations only.');
19
19
  if(action==='dismiss')return current()?.state==='failed'&&!current()?.mayBeActive?publish('off'):current();
20
20
  if(current()?.state==='connecting'||current()?.state==='disconnecting')return current();
21
- // Typing the command IS the decision, so it never stops at a card asking again.
22
- // Her words, w-79bffd7ec3: "should trigger it to turn on or off, depending on
23
- // its current state, without multiple confirmations."
21
+ // Typing the command IS the decision, so it never stops at a card asking again
22
+ // (w-79bffd7ec3): it turns remote control on or off depending on its current
23
+ // state, with no further confirmation.
24
24
  if(action==='toggle')action=['connected','ready','reconnecting'].includes(current()?.state)||current()?.mayBeActive?'disable':'enable';
25
25
  if(action==='enable'&&['connected','ready','reconnecting'].includes(current()?.state))return current();
26
26
  let session=sup.sessions.get(id);
@@ -133,7 +133,8 @@ export class TaskTerminals {
133
133
  }
134
134
  const place=this.resolve(key);
135
135
  const cwd=typeof place==='string'?place:place.cwd;
136
- const pty=this.spawn(this.shell,['-l'],{cwd,env:this.env,cols:80,rows:24,name:'xterm-256color'});
136
+ const command=place?.command;
137
+ const pty=this.spawn(command?.file??this.shell,command?.args??['-l'],{cwd,env:{...this.env,...command?.env},cols:80,rows:24,name:'xterm-256color'});
137
138
  let resolveExit;const exitPromise=new Promise(resolve=>{resolveExit=resolve;});
138
139
  const session={token:randomUUID(),pty,cwd,buffer:'',offset:0,exitCode:null,exited:false,exitPromise,lastReadAt:this.now()};this.sessions.set(key,session);
139
140
  /*
@@ -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 Mac 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 Mac 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
+ }