agentbox-app 0.1.13 → 0.1.16

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 (278) hide show
  1. package/README.md +24 -9
  2. package/bin/agentbox.mjs +23 -1
  3. package/briefs/message-rules.md +36 -8
  4. package/briefs/worker.md +46 -23
  5. package/main/account-discovery.mjs +122 -30
  6. package/main/account-tooling.mjs +29 -1
  7. package/main/account-usage.mjs +46 -0
  8. package/main/agent-sessions.mjs +282 -268
  9. package/main/agents.mjs +179 -466
  10. package/main/analytics.mjs +12 -2
  11. package/main/answer-modes.mjs +103 -37
  12. package/main/approval-prompt-core.mjs +197 -0
  13. package/main/approval-prompt-server.mjs +4 -138
  14. package/main/approvals.mjs +101 -297
  15. package/main/artifact-path.mjs +47 -18
  16. package/main/claude-input.mjs +48 -4
  17. package/main/claude-plan.mjs +48 -0
  18. package/main/claude-session-open.mjs +28 -0
  19. package/main/claude-usage-process.mjs +40 -0
  20. package/main/claude-usage.mjs +18 -40
  21. package/main/cloud/account.mjs +179 -0
  22. package/main/cloud/aws.mjs +127 -0
  23. package/main/cloud/browser-return.mjs +58 -0
  24. package/main/cloud/clients.mjs +15 -0
  25. package/main/cloud/codex-cloud.mjs +53 -0
  26. package/main/cloud/config.mjs +107 -0
  27. package/main/cloud/docs.mjs +219 -0
  28. package/main/cloud/exec.mjs +27 -0
  29. package/main/cloud/host.mjs +165 -0
  30. package/main/cloud/kit.mjs +80 -0
  31. package/main/cloud/logins.mjs +543 -0
  32. package/main/cloud/managed.mjs +115 -0
  33. package/main/cloud/marker.mjs +104 -0
  34. package/main/cloud/org.mjs +53 -0
  35. package/main/cloud/reuse.mjs +208 -0
  36. package/main/cloud/run-cli.mjs +177 -0
  37. package/main/cloud/run.mjs +1934 -0
  38. package/main/cloud/usage.mjs +76 -0
  39. package/main/cloud-permission.mjs +51 -0
  40. package/main/codex-account.mjs +78 -0
  41. package/main/codex-app-server.mjs +276 -409
  42. package/main/codex-approvals.mjs +148 -433
  43. package/main/codex-models.mjs +20 -2
  44. package/main/codex-session.mjs +212 -507
  45. package/main/codex-threads-worker.mjs +12 -0
  46. package/main/codex-threads.mjs +71 -3
  47. package/main/codex-usage-file.mjs +5 -3
  48. package/main/codex-usage.mjs +56 -7
  49. package/main/codex-watch.mjs +17 -3
  50. package/main/codex.mjs +95 -363
  51. package/main/config.mjs +11 -1
  52. package/main/corner-tag.mjs +340 -19
  53. package/main/dev-window.mjs +1 -1
  54. package/main/engine-bin.mjs +232 -282
  55. package/main/engine-setup.mjs +21 -9
  56. package/main/feedback.mjs +3 -25
  57. package/main/folder-pictures.mjs +2 -2
  58. package/main/freeze-recorder-thread.mjs +86 -0
  59. package/main/freeze-recorder.mjs +146 -0
  60. package/main/fresh-user.mjs +20 -9
  61. package/main/git-change-offthread.mjs +17 -3
  62. package/main/git-change-thread.mjs +5 -2
  63. package/main/harnesses/claude.mjs +32 -11
  64. package/main/harnesses/codex.mjs +9 -2
  65. package/main/importable-threads.mjs +64 -6
  66. package/main/ipc.mjs +362 -111
  67. package/main/leftovers.mjs +117 -25
  68. package/main/liquid-glass.mjs +26 -0
  69. package/main/live-replies.mjs +10 -4
  70. package/main/main-went-red.mjs +330 -0
  71. package/main/main.mjs +199 -84
  72. package/main/memory-gate-history.mjs +36 -10
  73. package/main/memory-gate-server.mjs +62 -0
  74. package/main/memory-gate.mjs +14 -1
  75. package/main/menu.mjs +4 -2
  76. package/main/message-priority.mjs +2 -2
  77. package/main/mic.mjs +38 -0
  78. package/main/native/liquid-glass/build/liquid-glass.node +0 -0
  79. package/main/new-chats-watch.mjs +110 -0
  80. package/main/new-user-window.mjs +25 -0
  81. package/main/notify.mjs +24 -8
  82. package/main/phone-alerts.mjs +117 -0
  83. package/main/phone-inbox.mjs +625 -0
  84. package/main/phone-link.mjs +29 -9
  85. package/main/phone-uploads.mjs +476 -0
  86. package/main/picture-shape.mjs +114 -0
  87. package/main/quit-sequence.mjs +143 -0
  88. package/main/refresh-agent-models.mjs +15 -2
  89. package/main/relay-client.mjs +69 -1
  90. package/main/repeats.mjs +10 -4
  91. package/main/row-label.mjs +12 -41
  92. package/main/run-change.mjs +13 -0
  93. package/main/run-ledger.mjs +135 -0
  94. package/main/save-attachments.mjs +41 -0
  95. package/main/serve.mjs +45 -3
  96. package/main/settings.mjs +220 -44
  97. package/main/shell-path.mjs +185 -37
  98. package/main/ship-conflict-brief.mjs +119 -0
  99. package/main/ship-queue.mjs +115 -19
  100. package/main/source-updater.mjs +18 -10
  101. package/main/stage-attachment.mjs +68 -0
  102. package/main/stall-watch.mjs +50 -0
  103. package/main/store/home.mjs +87 -232
  104. package/main/store/paths.mjs +11 -0
  105. package/main/store/teammates-files.mjs +99 -0
  106. package/main/store/work-items.mjs +231 -13
  107. package/main/store.mjs +226 -52
  108. package/main/supervisor.mjs +3103 -341
  109. package/main/task-folders.mjs +430 -30
  110. package/main/task-terminals.mjs +110 -34
  111. package/main/team/index.mjs +114 -10
  112. package/main/team/memory-cloud.mjs +183 -1
  113. package/main/team/room-hours.mjs +48 -0
  114. package/main/team/room-setup.mjs +31 -0
  115. package/main/team/rooms-memory.mjs +454 -0
  116. package/main/team/rooms-shape.mjs +42 -0
  117. package/main/team/rooms-supabase.mjs +324 -0
  118. package/main/team/rooms.mjs +575 -0
  119. package/main/team/session.mjs +96 -26
  120. package/main/team/supabase-backend.mjs +180 -0
  121. package/main/team/sync.mjs +109 -1
  122. package/main/teammates.mjs +580 -0
  123. package/main/terminal-host.mjs +143 -0
  124. package/main/terminal-hosts.mjs +261 -0
  125. package/main/transcript-search.mjs +80 -0
  126. package/main/webview-guard.mjs +45 -0
  127. package/main/worktree-include.mjs +90 -219
  128. package/mcp/core/documents.mjs +3 -1
  129. package/mcp/core/repeats.mjs +88 -0
  130. package/mcp/core/teammates.mjs +222 -0
  131. package/mcp/core/work.mjs +76 -4
  132. package/mcp/stdio.mjs +24 -11
  133. package/mcp/tools.mjs +116 -8
  134. package/package.json +21 -5
  135. package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
  136. package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
  137. package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
  138. package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
  139. package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
  140. package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
  141. package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
  142. package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
  143. package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
  144. package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
  145. package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
  146. package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
  147. package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
  148. package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
  149. package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
  150. package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
  151. package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
  152. package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
  153. package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
  154. package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
  155. package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
  156. package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
  157. package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
  158. package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
  159. package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
  160. package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
  161. package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
  162. package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
  163. package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
  164. package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
  165. package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
  166. package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
  167. package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
  168. package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
  169. package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
  170. package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
  171. package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
  172. package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
  173. package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
  174. package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
  175. package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
  176. package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
  177. package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
  178. package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
  179. package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
  180. package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
  181. package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
  182. package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
  183. package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
  184. package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
  185. package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
  186. package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
  187. package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
  188. package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
  189. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
  190. package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
  191. package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
  192. package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
  193. package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
  194. package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
  195. package/renderer/dist/corner-tag.html +3 -3
  196. package/renderer/dist/index.html +3 -3
  197. package/scripts/after-install.mjs +30 -8
  198. package/scripts/cloud-shim.pl +57 -0
  199. package/shared/account-names.d.mts +6 -0
  200. package/shared/account-names.mjs +15 -0
  201. package/shared/agent-hours.mjs +136 -0
  202. package/shared/agent-import.mjs +60 -34
  203. package/shared/agents.mjs +126 -522
  204. package/shared/analytics-events.mjs +12 -0
  205. package/shared/approval-card.mjs +36 -0
  206. package/shared/bridge-map.mjs +27 -1
  207. package/shared/claude-code-shape.mjs +6 -0
  208. package/shared/claude-usage.d.mts +1 -0
  209. package/shared/claude-usage.mjs +42 -1
  210. package/shared/cloud-hand-back.d.mts +7 -0
  211. package/shared/cloud-hand-back.mjs +20 -0
  212. package/shared/cloud-kit.d.mts +219 -0
  213. package/shared/cloud-kit.mjs +341 -0
  214. package/shared/cloud-login-name.mjs +4 -0
  215. package/shared/cloud-start.mjs +17 -0
  216. package/shared/cloud-versions.mjs +23 -0
  217. package/shared/codex-import.mjs +2 -2
  218. package/shared/codex-modes.d.mts +22 -9
  219. package/shared/codex-modes.mjs +67 -11
  220. package/shared/codex-usage.mjs +108 -2
  221. package/shared/corner-tag.d.mts +23 -1
  222. package/shared/corner-tag.mjs +284 -29
  223. package/shared/dead-run-trace.mjs +19 -2
  224. package/shared/engines.mjs +70 -309
  225. package/shared/feedback.d.mts +7 -0
  226. package/shared/feedback.mjs +25 -0
  227. package/shared/first-run-practice.mjs +17 -24
  228. package/shared/harness-definitions.mjs +8 -0
  229. package/shared/instruction-defaults.json +2 -2
  230. package/shared/key-caps.d.mts +14 -0
  231. package/shared/key-caps.mjs +68 -0
  232. package/shared/message-edits.d.mts +7 -0
  233. package/shared/message-edits.mjs +35 -0
  234. package/shared/move-thread.d.mts +24 -0
  235. package/shared/move-thread.mjs +112 -0
  236. package/shared/named-threads.mjs +72 -0
  237. package/shared/not-shipped.d.mts +22 -0
  238. package/shared/not-shipped.mjs +89 -0
  239. package/shared/notify-rules.d.mts +6 -0
  240. package/shared/notify-rules.mjs +36 -8
  241. package/shared/phone-files.mjs +28 -0
  242. package/shared/phone-settings.mjs +118 -0
  243. package/shared/phone-snapshot.d.mts +16 -0
  244. package/shared/phone-snapshot.mjs +348 -0
  245. package/shared/picture-formats.d.mts +1 -0
  246. package/shared/picture-formats.mjs +13 -0
  247. package/shared/rank.d.mts +2 -1
  248. package/shared/rank.mjs +32 -19
  249. package/shared/repeats.d.mts +8 -0
  250. package/shared/repeats.mjs +33 -2
  251. package/shared/room-cards.mjs +167 -0
  252. package/shared/room-links.d.mts +4 -0
  253. package/shared/room-links.mjs +21 -0
  254. package/shared/runs-on.d.mts +3 -3
  255. package/shared/runs-on.mjs +8 -5
  256. package/shared/shipped-note.d.mts +4 -0
  257. package/shared/shipped-note.mjs +6 -0
  258. package/shared/shortcuts.mjs +14 -6
  259. package/shared/spawn-trouble.d.mts +4 -0
  260. package/shared/spawn-trouble.mjs +443 -40
  261. package/shared/team-rules.mjs +9 -2
  262. package/shared/teammates.d.mts +19 -0
  263. package/shared/teammates.mjs +756 -0
  264. package/shared/terminal-focus.d.mts +1 -0
  265. package/shared/terminal-focus.mjs +13 -0
  266. package/shared/thread-cards.mjs +4 -1
  267. package/shared/usage.d.mts +9 -0
  268. package/shared/usage.mjs +49 -3
  269. package/shared/websites.d.mts +15 -0
  270. package/shared/websites.mjs +61 -0
  271. package/shared/work-items.mjs +127 -30
  272. package/shared/work-lines.mjs +92 -244
  273. package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
  274. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
  275. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
  276. package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
  277. package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
  278. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
@@ -4,6 +4,7 @@ import {randomUUID} from 'node:crypto';
4
4
  import {execFileSync} from 'node:child_process';
5
5
  import {createRequire} from 'node:module';
6
6
  import {commandRunning} from '../shared/terminal-state.mjs';
7
+ import {loginShell} from './shell-path.mjs';
7
8
  const require=createRequire(import.meta.url);
8
9
  const LIMIT=262144;
9
10
  export function terminalEnv(source=process.env){
@@ -37,17 +38,6 @@ export function terminalPlace({store,supervisor,product,id,agent}){
37
38
  return {cwd,notice};
38
39
  }
39
40
  export function terminalFolder(args){return terminalPlace(args).cwd;}
40
- // The shell a person actually has. `$SHELL` when that file exists, then
41
- // zsh, bash, and sh. A machine with no zsh used to spawn `/bin/zsh` and
42
- // the terminal never came up.
43
- export function loginShell(env=process.env,exists=fs.existsSync){
44
- const preferred=env.SHELL;
45
- if(preferred&&exists(preferred))return preferred;
46
- for(const candidate of ['/bin/zsh','/bin/bash','/bin/sh']){
47
- if(exists(candidate))return candidate;
48
- }
49
- return preferred||'/bin/sh';
50
- }
51
41
  // Enumerate only descendants of our own shell, including background process
52
42
  // groups. Kill children before shell; closing a pane never calls this.
53
43
  export function disposeTerminalProcess(pty){
@@ -79,20 +69,62 @@ export function disposeTerminalProcess(pty){
79
69
  * (TaskTerminal.tsx). So `lastReadAt` is not a guess about attention, it is the
80
70
  * last time the app was actually asked what this shell had to say.
81
71
  *
82
- * Idle AND unwatched for ten minutes is a shell that costs something and is
83
- * worth nothing, and it goes. Everything else stays until she or the app quits.
72
+ * Idle AND unwatched for an hour is a shell that costs something and is
73
+ * worth nothing, and it goes. Everything else stays until its task is closed:
74
+ * task terminals run in ./terminal-host.mjs, which outlives the app.
75
+ *
76
+ * IT WAS TEN MINUTES, AND THAT WAS TOO SHORT (w-6841800f17): "I come back after
77
+ * maybe 15 minutes and it's gone ... I don't see the error message." A deploy
78
+ * that fails leaves an idle shell with the error on it, and ten minutes away
79
+ * from the task took the error with the shell. So the wait is an hour, and when
80
+ * a shell does go, the end of what it showed comes back with the next one.
81
+ *
82
+ * AND A SHELL THAT PRINTED SOMETHING SHE HAS NOT SEEN IS NOT WORTH NOTHING
83
+ * (w-f147e487a1). She started the AWS tools install, left the thread while it
84
+ * ran, and came back to a new shell: the install finished while she was away,
85
+ * and since the idle clock ran from her last look, the shell went on the next
86
+ * sweep with its output unread. "I was running it." So the hour starts only
87
+ * once she has read everything it printed, and at the cap a shell she has read
88
+ * goes before one she has not; when all twenty hold unread output, the least
89
+ * recently watched still makes room.
84
90
  */
85
- const IDLE_MS=600000;
91
+ const IDLE_MS=3600000;
92
+ // How much of a closed shell's screen comes back. The end is where the error
93
+ // is; 64K characters is several hundred lines of it.
94
+ const KEPT_SCREEN=65536;
95
+ // Closed shells whose screen is still held for her. Twice the cap is every
96
+ // terminal open now plus as many again that the app has tidied away.
97
+ const KEPT_SHELLS=40;
98
+ /*
99
+ * THE END OF A SCREEN, CUT AT A LINE. Cutting mid-line would start the replay
100
+ halfway through a word or an escape sequence. */
101
+ function keptScreen(buffer){
102
+ if(buffer.length<=KEPT_SCREEN)return buffer;
103
+ const tail=buffer.slice(-KEPT_SCREEN);
104
+ const line=tail.indexOf('\n');
105
+ return line<0?tail:tail.slice(line+1);
106
+ }
86
107
  // The longest a read waits for output before answering with none, so the panel
87
108
  // still hears about a command that finished quietly and the sweep still sees
88
109
  // it watching.
89
110
  const READ_WAIT_MS=1000;
90
111
  const SWEEP_MS=60000;
91
112
  const MAX_TERMINALS=20;
113
+ /*
114
+ * A TERMINAL LIVES AS LONG AS ITS TASK IS OPEN. Closing the task ends it; so,
115
+ * while memory is critical, does going unwatched. Each says why on the next
116
+ * terminal, in these words.
117
+ */
118
+ export const ENDED={
119
+ closed:'This task was closed, so its terminal was ended.',
120
+ memory:'This terminal was ended because this computer was running out of memory.',
121
+ };
122
+ // While memory is critical, a terminal looked at this recently is still in use.
123
+ const WATCHED_MS=600000;
92
124
  export class TaskTerminals {
93
- constructor({spawn,resolve,disposeProcess=disposeTerminalProcess,env=terminalEnv(),shell=loginShell(),idleMs=IDLE_MS,sweepMs=SWEEP_MS,now=()=>Date.now()}={}){
125
+ constructor({spawn,resolve,disposeProcess=disposeTerminalProcess,env=terminalEnv(),shell=loginShell(),idleMs=IDLE_MS,sweepMs=SWEEP_MS,now=()=>Date.now(),readPressure=null,watchedMs=WATCHED_MS}={}){
94
126
  this.spawn=spawn??((...args)=>require('node-pty').spawn(...args));this.resolve=resolve;this.disposeProcess=disposeProcess;this.env=env;this.shell=shell;this.sessions=new Map();
95
- this.idleMs=idleMs;this.now=now;
127
+ this.idleMs=idleMs;this.now=now;this.readPressure=readPressure;this.watchedMs=watchedMs;
96
128
  // Keys whose shell this class ended on its own, and WHY. The next open on
97
129
  // one says so rather than handing her a blank screen where her scrollback
98
130
  // was, and the two reasons are different sentences because they are
@@ -102,7 +134,7 @@ export class TaskTerminals {
102
134
  // `unref` so a sweep that is merely pending never holds the app open, and
103
135
  // never in a test: a suite that leaves an interval behind hangs the runner.
104
136
  if(sweepMs>0&&typeof setInterval==='function'){
105
- this.timer=setInterval(()=>{try{this.sweepIdle();}catch{}},sweepMs);
137
+ this.timer=setInterval(()=>{try{this.sweepIdle();}catch{}this.relieveMemory().catch(()=>{});},sweepMs);
106
138
  this.timer?.unref?.();
107
139
  }
108
140
  }
@@ -115,29 +147,69 @@ export class TaskTerminals {
115
147
  if(session.exited)return false;
116
148
  try{return commandRunning(session.pty.process||'Shell',false);}catch{return false;}
117
149
  }
150
+ // Output past the last byte a read handed to the panel.
151
+ unread(session){return session.offset+session.buffer.length>(session.seenTo??0);}
118
152
  /*
119
- * THE ONE THE CAP TAKES: least recently watched first, and never a busy one.
120
- Null means every slot is genuinely working, which is the only case left
121
- where she is told no. */
153
+ * THE ONE THE CAP TAKES: one she has read everything in before one she has
154
+ not, then least recently watched, and never a busy one. Null means every
155
+ slot is genuinely working, which is the only case left where she is told
156
+ no. */
122
157
  oldestIdle(){
123
- let pick=null,at=Infinity;
158
+ let pick=null,pickUnread=true,at=Infinity;
124
159
  for(const [key,s] of this.sessions){
125
160
  if(this.busy(s))continue;
126
- const seen=s.lastReadAt??0;
127
- if(seen<at){at=seen;pick=key;}
161
+ const unread=this.unread(s),seen=s.lastReadAt??0;
162
+ if(pick===null||(pickUnread&&!unread)||(unread===pickUnread&&seen<at)){pick=key;pickUnread=unread;at=seen;}
128
163
  }
129
164
  return pick;
130
165
  }
166
+ /*
167
+ * THE APP ENDS A SHELL, AND KEEPS WHAT IT SAID. Only these two paths keep the
168
+ screen: when she presses End shell herself, `close` is called directly and
169
+ nothing comes back, because she chose to throw it away. */
170
+ retire(key,why){
171
+ const s=this.sessions.get(key);
172
+ const screen=s?keptScreen(s.buffer):'';
173
+ this.close(key);
174
+ this.reaped.delete(key);
175
+ this.reaped.set(key,{why,screen});
176
+ while(this.reaped.size>KEPT_SHELLS)this.reaped.delete(this.reaped.keys().next().value);
177
+ }
131
178
  sweepIdle(){
132
179
  const cutoff=this.now()-this.idleMs;
133
180
  for(const [key,s] of [...this.sessions]){
134
- if(this.busy(s))continue;
181
+ if(this.busy(s)||this.unread(s))continue;
135
182
  if((s.lastReadAt??0)>cutoff)continue;
136
- this.close(key);
137
- this.reaped.set(key,'The previous shell here was closed after ten minutes idle.');
183
+ this.retire(key,'The previous shell here was closed after an hour idle.');
184
+ }
185
+ }
186
+ end(key,why){if(this.sessions.has(key))this.retire(key,why);}
187
+ list(){
188
+ return [...this.sessions].map(([key,s])=>({key,busy:this.busy(s),exited:s.exited,unread:this.unread(s),lastReadAt:s.lastReadAt??0,cwd:s.cwd,pid:s.pty.pid}));
189
+ }
190
+ /*
191
+ * TOO MUCH FOR THIS COMPUTER. Only while memory is critical, checked once a
192
+ minute: every shell at its prompt that nobody has looked at for ten
193
+ minutes, and one terminal running something, the one looked at least
194
+ recently. Never one looked at inside those ten minutes. */
195
+ async relieveMemory(){
196
+ if(!this.readPressure||!this.sessions.size)return [];
197
+ let pressure='normal';
198
+ try{pressure=await this.readPressure();}catch{}
199
+ if(pressure!=='critical')return [];
200
+ const cutoff=this.now()-this.watchedMs,idle=[];let busy=null,at=Infinity;
201
+ for(const [key,s] of this.sessions){
202
+ if(s.exited||(s.lastReadAt??0)>cutoff)continue;
203
+ if(!this.busy(s)){idle.push(key);continue;}
204
+ if((s.lastReadAt??0)<at){busy=key;at=s.lastReadAt??0;}
138
205
  }
206
+ const ended=busy?[...idle,busy]:idle;
207
+ for(const key of ended)this.retire(key,ENDED.memory);
208
+ return ended;
139
209
  }
140
- open(key){
210
+ // `given` is where to open it when the caller has already worked that out:
211
+ // the terminal host (./terminal-host.mjs) cannot read the store.
212
+ open(key,given){
141
213
  if(this.sessions.has(key))return this.read(key,0);
142
214
  if(this.sessions.size>=MAX_TERMINALS){
143
215
  /*
@@ -149,22 +221,25 @@ export class TaskTerminals {
149
221
  and says so in those words. */
150
222
  const victim=this.oldestIdle();
151
223
  if(victim===null)throw Error('20 terminals are open and every one of them is running something. Stop one of those commands before opening another terminal.');
152
- this.close(victim);
153
- this.reaped.set(victim,'This shell was closed to make room for a newer terminal.');
224
+ this.retire(victim,'This shell was closed to make room for a newer terminal.');
154
225
  }
155
- const place=this.resolve(key);
226
+ const place=given??this.resolve(key);
156
227
  const cwd=typeof place==='string'?place:place.cwd;
157
228
  const command=place?.command;
158
- const pty=this.spawn(command?.file??this.shell,command?.args??['-l'],{cwd,env:{...this.env,...command?.env},cols:80,rows:24,name:'xterm-256color'});
229
+ const pty=this.spawn(command?.file??place?.shell??this.shell,command?.args??['-l'],{cwd,env:{...(place?.env??this.env),...command?.env},cols:80,rows:24,name:'xterm-256color'});
159
230
  let resolveExit;const exitPromise=new Promise(resolve=>{resolveExit=resolve;});
160
231
  const session={token:randomUUID(),pty,cwd,buffer:'',offset:0,exitCode:null,exited:false,exitPromise,lastReadAt:this.now(),waiters:new Set()};this.sessions.set(key,session);
161
232
  /*
162
233
  * AND IT SAYS WHAT HAPPENED TO THE LAST ONE. Without this line, reopening a
163
234
  terminal the app tidied away looks exactly like the app losing her
164
235
  scrollback, which is the same event read as a bug. Dim, one line, above a
165
- real prompt. */
166
- const why=this.reaped.get(key);
167
- if(why!==undefined){this.reaped.delete(key);session.buffer=`\x1b[2m[${why} This is a new one.]\x1b[0m\r\n`;}
236
+ real prompt, under whatever the old shell last showed. */
237
+ const gone=this.reaped.get(key);
238
+ if(gone!==undefined){
239
+ this.reaped.delete(key);
240
+ const old=gone.screen?`${gone.screen}\x1b[0m${gone.screen.endsWith('\n')?'':'\r\n'}`:'';
241
+ session.buffer=`${old}\x1b[2m[${gone.why}${old?' Its output is above.':''} This is a new one.]\x1b[0m\r\n`;
242
+ }
168
243
  if(place?.notice)session.buffer+=`\x1b[2m[${place.notice}]\x1b[0m\r\n`;
169
244
  pty.onData(data=>{session.buffer+=data;const trim=Math.max(0,session.buffer.length-LIMIT);session.buffer=session.buffer.slice(trim);session.offset+=trim;this.wake(session);});
170
245
  pty.onExit(({exitCode})=>{session.exited=true;session.exitCode=exitCode;resolveExit();this.wake(session);});
@@ -188,6 +263,7 @@ export class TaskTerminals {
188
263
  const timer=setTimeout(done,Math.min(wait,READ_WAIT_MS));
189
264
  s.waiters.add(done);
190
265
  });
266
+ s.seenTo=end;
191
267
  const state={token:s.token,cwd:s.cwd,data:s.buffer.slice(Math.max(0,offset-s.offset)),offset:end,truncated:offset<s.offset,exited:s.exited,exitCode:s.exitCode,process:s.exited?'Exited':s.pty.process||'Shell'};
192
268
  return wait>0?{...state,live:true}:state;
193
269
  }
@@ -17,28 +17,58 @@ import { listSharedProjects, joinSharedProject, markShared, makeDirect } from '.
17
17
  import { firstSentence } from '../../shared/thread-cards.mjs';
18
18
  import { cardsFor } from '../../shared/thread-cards.mjs';
19
19
  import fs from 'node:fs';
20
+ import { saveAttachments, attachmentMarkdown } from '../save-attachments.mjs';
20
21
  import { hasLapsed, statusEnds } from '../../shared/team-status.mjs';
22
+ import QRCode from 'qrcode';
23
+ import { createPhoneInbox } from '../phone-inbox.mjs';
24
+ import { SNAPSHOT_LIMITS, phoneProjectsOf, phoneEligible } from '../../shared/phone-snapshot.mjs';
25
+ import { createRooms } from './rooms.mjs';
21
26
 
22
27
  // `signingIn` is { url } while a Google sign-in waits on the browser, so the
23
28
  // 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 };
29
+ const EMPTY = { configured: false, canSignIn: false, started: false, settling: false, signedIn: false, signingIn: null, me: null, team: null, invites: [], sent: [], people: [], cards: [], lastSyncAt: null, error: null };
30
+
31
+ // With Alpha features › Multiplayer off the window gets the sign-in the phone
32
+ // needs and no team, so it draws the single-person app.
33
+ export function teamForWindow(state, multiplayer) {
34
+ if (multiplayer || !state) return state;
35
+ const { rooms, ...rest } = state;
36
+ return { ...rest, configured: false, team: null, invites: [], sent: [], people: [], cards: [] };
37
+ }
25
38
 
26
39
  export function createTeamService({
27
40
  session, store, disk, accountRoot, stateFile, onChange = () => {}, log = () => {}, intervalMs = 5000, startRetryMs = 1500,
28
41
  // The clock, so a test can move past the moment a status runs out.
29
42
  now = Date.now,
43
+ // HER PHONE WITH THE MAC SHUT (docs/cloud/PHONE-PLAN.md, step 1): which
44
+ // projects she put on it (slugs), how a command from it is done, and where
45
+ // this Mac remembers what it sent. Needs no team, only a sign-in.
46
+ phone = null,
47
+ // ROOMS (alpha, docs/team/ROOMS-PLAN.md): { enabled, device, presenceFacts, prefsFile }.
48
+ rooms: roomsSetup = null,
30
49
  }) {
31
- let state = { ...EMPTY, configured: !!session?.configured };
50
+ // The phone site's address, only an https one, and its code for Settings.
51
+ const site = typeof phone?.site === 'string' && /^https:\/\/[^\s/]+(\/[^\s]*)?$/.test(phone.site) ? phone.site : null;
52
+ let siteQr = null;
53
+ // `settling` holds the window's boot screen until the team is known, so it
54
+ // never draws the single-person app first and then redraws with the team.
55
+ // `canSignIn` survives teamForWindow, so a build with Multiplayer off still asks.
56
+ let state = { ...EMPTY, configured: !!session?.configured, canSignIn: !!session?.configured, settling: !!session?.configured, phoneSite: site, phoneSiteQr: null };
32
57
  let backend = null;
33
58
  let sync = null;
34
59
  let timer = null;
35
60
  let unsubscribe = null;
36
61
  let pending = null;
62
+ let phoneInbox = null;
37
63
  // The sync cursors and which team this Mac is in, in one file.
38
64
  let local = null;
39
65
  const kept = () => (local ??= fileSyncState(stateFile, fs));
40
66
 
41
67
  const set = (patch) => { state = { ...state, ...patch }; onChange(); };
68
+ const rooms = roomsSetup ? createRooms({
69
+ backend: () => backend, me: () => state.me, team: () => state, now, log, onChange, ...roomsSetup,
70
+ }) : null;
71
+ if (site) QRCode.toString(site, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' }).then((qr) => { siteQr = qr; set({ phoneSiteQr: qr }); }).catch(() => {});
42
72
 
43
73
  function products() {
44
74
  try { return store.listProducts(); } catch { return []; }
@@ -139,12 +169,71 @@ export function createTeamService({
139
169
  set({ error: String(err?.message ?? err) });
140
170
  return null;
141
171
  } finally {
172
+ // On its own, so a team sync that failed does not hold up her phone,
173
+ // nor her phone the team.
174
+ await phoneOnce();
175
+ await rooms?.tick().catch((err) => log(`rooms: ${err.message}`));
142
176
  pending = null;
143
177
  }
144
178
  });
145
179
  return pending;
146
180
  }
147
181
 
182
+ async function phoneOnce() {
183
+ // Not before the app's handlers exist: a command taken then would be
184
+ // marked refused for no reason of hers.
185
+ if (!phoneInbox || phone.ready?.() === false) return;
186
+ try {
187
+ await phoneInbox.syncOnce();
188
+ if (state.phoneError) set({ phoneError: null });
189
+ } catch (err) {
190
+ set({ phoneError: String(err?.message ?? err) });
191
+ }
192
+ }
193
+
194
+ function makePhoneInbox() {
195
+ if (!phone) return null;
196
+ const productOf = (slug) => products().find((p) => p.slug === slug) ?? null;
197
+ // WHO IT SENDS AS, fixed now, and whether that is still who is signed in
198
+ // (Codex's second review, finding 1).
199
+ const owner = state.me?.id;
200
+ const signedInAs = backend;
201
+ if (!owner) return null;
202
+ let inbox = null;
203
+ inbox = createPhoneInbox({
204
+ backend,
205
+ owner,
206
+ alive: () => phoneInbox === inbox && backend === signedInAs && state.me?.id === owner,
207
+ device: phone.device?.() ?? null,
208
+ projects: () => {
209
+ const on = new Set(phoneProjectsOf(phone.projects(), owner, phoneEligible(products())));
210
+ return products().filter((p) => on.has(p.slug)).map((p) => ({ slug: p.slug, name: p.name || p.slug, dir: p.dir || null, repoPath: p.repoPath || null }));
211
+ },
212
+ listItems: (slug) => { const p = productOf(slug); try { return p ? disk.readWorkItems(p.dir) : []; } catch { return []; } },
213
+ historyOf: (slug, id, { all = false } = {}) => { try { return store.readHistory(slug, id, { max: all ? Number.MAX_SAFE_INTEGER : SNAPSHOT_LIMITS.lines * 4 }); } catch { return []; } },
214
+ apply: phone.apply,
215
+ liveOf: (slug, id) => phone.live?.(slug, id) ?? null,
216
+ approvalsOf: () => phone.approvals?.() ?? [],
217
+ readSettings: phone.readSettings ?? null,
218
+ version: phone.version ?? '',
219
+ idleMs: phone.idleMs ?? null,
220
+ // The lid shut: projects whose threads carry on in the cloud, and runs it started (shared/cloud-kit.mjs).
221
+ carrying: phone.carrying ?? null,
222
+ kitsFor: phone.kitsFor ?? null,
223
+ adopt: phone.adopt ?? null,
224
+ cloudReplies: phone.cloudReplies ?? null,
225
+ runHome: phone.runHome ?? null,
226
+ newThreadKits: phone.newThreadKits ?? null,
227
+ adoptNew: phone.adoptNew ?? null,
228
+ // One file per person: what this Mac sent up for one account is not
229
+ // what the next one to sign in here has.
230
+ stateFile: phone.stateFile.replace(/(\.json)?$/, `.${owner}.json`),
231
+ now,
232
+ log,
233
+ });
234
+ return inbox;
235
+ }
236
+
148
237
  function stopLoop() {
149
238
  if (timer) clearInterval(timer);
150
239
  timer = null;
@@ -165,9 +254,12 @@ export function createTeamService({
165
254
  // no team cloud. So the single-person app opened on no projects at all.
166
255
  // Everybody gets one at boot now (main/main.mjs), signed in or not.
167
256
  await refreshTeam();
257
+ if (state.settling) set({ settling: false });
258
+ phoneInbox = makePhoneInbox();
168
259
  sync = createTeamSync({
169
260
  backend,
170
261
  disk,
262
+ log,
171
263
  state: kept(),
172
264
  // ARCHIVED ONES COUNT AS HERE. Asked of the visible list, a shared
173
265
  // project you archived read as missing and was joined again beside it.
@@ -184,7 +276,10 @@ export function createTeamService({
184
276
  }
185
277
 
186
278
  return {
187
- state: () => state,
279
+ state: () => (rooms ? { ...state, rooms: rooms.state() } : state),
280
+ rooms,
281
+ /** What the desktop's banner was told arrived, for her phone's alerts (main/phone-alerts.mjs). */
282
+ phoneArrivals: (list, needsYou) => phoneInbox?.arrivals(list, needsYou),
188
283
 
189
284
  // THREE TRIES AT START, because one dropped request left the app signed
190
285
  // out until it was restarted (measured 2026-10-01 on a loaded Mac: the
@@ -199,10 +294,10 @@ export function createTeamService({
199
294
  try {
200
295
  const b = await session.restore();
201
296
  if (b) await signedIn(b);
202
- set({ started: true });
297
+ set({ started: true, settling: false });
203
298
  return state;
204
299
  } catch (err) {
205
- set({ error: String(err?.message ?? err) });
300
+ set({ error: String(err?.message ?? err), settling: false });
206
301
  if (attempt < 2) await new Promise((r) => setTimeout(r, startRetryMs * (attempt + 1)));
207
302
  }
208
303
  }
@@ -251,13 +346,16 @@ export function createTeamService({
251
346
 
252
347
  async signOut() {
253
348
  stopLoop();
349
+ // While still signed in, so this Mac can say it has gone.
350
+ await rooms?.stop().catch(() => {});
254
351
  await session.signOut?.();
255
352
  disk.setLineAuthor(null);
256
353
  delete process.env.AGENTBOX_PERSON_ID;
257
354
  backend = null;
258
355
  sync = null;
356
+ phoneInbox = null;
259
357
  // Signed out is a known answer, so the window shows the sign-in page at once.
260
- state = { ...EMPTY, configured: !!session?.configured, started: true };
358
+ state = { ...EMPTY, configured: !!session?.configured, started: true, phoneSite: site, phoneSiteQr: siteQr };
261
359
  onChange();
262
360
  return state;
263
361
  },
@@ -372,7 +470,7 @@ export function createTeamService({
372
470
  * later is not a chance to lift it. Nothing picked means nothing sent, so
373
471
  * the row keeps composeItem's 0, which the inbox reads as Medium.
374
472
  */
375
- async message(toIn, body, priority) {
473
+ async message(toIn, body, priority, files = []) {
376
474
  if (!backend || !state.team || !state.me) throw new Error('start or join a team first');
377
475
  const me = state.me.id;
378
476
  // One person or a few: the same conversation for the same people.
@@ -381,8 +479,9 @@ export function createTeamService({
381
479
  if (others.length > 11) throw new Error('a message goes to at most eleven people');
382
480
  if (others.some((p) => !state.people.some((q) => q.id === p))) throw new Error('that person is not on your team');
383
481
  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');
482
+ const words = String(body ?? '').trim();
483
+ const carried = (Array.isArray(files) ? files : []).filter((f) => f && (f.dataBase64 || f.srcPath));
484
+ if (!words && !carried.length) throw new Error('a message needs some words');
386
485
  // THE RECORD IS REUSED ONLY IF IT IS EXACTLY THE TWO OF YOU AND ONE OF
387
486
  // YOU MADE IT. Found by review 2026-10-01: a teammate could make a
388
487
  // "direct" record holding you and somebody else, and the next message
@@ -404,6 +503,10 @@ export function createTeamService({
404
503
  }
405
504
  product = products().find((p) => p.dir === made.dir) ?? { slug: made.slug, dir: made.dir };
406
505
  }
506
+ // ITS FILES, saved into the conversation now that there is one, and
507
+ // linked under the words the way the reply box links them. The sync
508
+ // sends each one on before the line that names it (./sync.mjs).
509
+ const text = [words, attachmentMarkdown(saveAttachments(product.dir, carried))].filter(Boolean).join('\n\n');
407
510
  // ONE CONVERSATION PER PERSON (decided 2026-10-01 from user research:
408
511
  // people expect to click a name and see what was said before, and a
409
512
  // subject line on every message between two coworkers makes talking
@@ -411,7 +514,8 @@ export function createTeamService({
411
514
  // conversation the two of you already have, reopened if it was put away,
412
515
  // and only the first message ever starts one. Work stays in threads; this
413
516
  // is talk.
414
- const title = firstSentence(text, 90) || text.slice(0, 90);
517
+ // Named by its words; a message that is only files is named by them.
518
+ const title = firstSentence(words, 90) || words.slice(0, 90) || (carried.length === 1 ? String(carried[0].name ?? 'A file') : `${carried.length} files`);
415
519
  const convo = conversationIn(product.slug);
416
520
  if (convo) {
417
521
  store.answerItem(product.slug, convo.id, { answer: text });