@songsid/agend 2.3.0-beta.1 → 2.3.0-beta.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 (357) hide show
  1. package/dist/agent-cli-instructions.md +5 -2
  2. package/dist/agent-cli.js +43 -7
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/agent-endpoint.js +4 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-file-read.d.ts +14 -6
  7. package/dist/agent-file-read.js +207 -19
  8. package/dist/agent-file-read.js.map +1 -1
  9. package/dist/agent-whats-new.d.ts +11 -0
  10. package/dist/agent-whats-new.js +29 -15
  11. package/dist/agent-whats-new.js.map +1 -1
  12. package/dist/auth-api.d.ts +30 -1
  13. package/dist/auth-api.js +45 -13
  14. package/dist/auth-api.js.map +1 -1
  15. package/dist/authority/authority-state.d.ts +38 -0
  16. package/dist/authority/authority-state.js +114 -0
  17. package/dist/authority/authority-state.js.map +1 -0
  18. package/dist/authority/session-authorizations.d.ts +43 -0
  19. package/dist/authority/session-authorizations.js +102 -0
  20. package/dist/authority/session-authorizations.js.map +1 -0
  21. package/dist/authority/web-authority.d.ts +120 -0
  22. package/dist/authority/web-authority.js +157 -0
  23. package/dist/authority/web-authority.js.map +1 -0
  24. package/dist/backend/claude-code.d.ts +2 -2
  25. package/dist/backend/claude-code.js +10 -5
  26. package/dist/backend/claude-code.js.map +1 -1
  27. package/dist/backend/types.d.ts +14 -0
  28. package/dist/backend/types.js +25 -0
  29. package/dist/backend/types.js.map +1 -1
  30. package/dist/backend-availability.d.ts +120 -0
  31. package/dist/backend-availability.js +396 -0
  32. package/dist/backend-availability.js.map +1 -0
  33. package/dist/backend-command.d.ts +21 -3
  34. package/dist/backend-command.js +23 -4
  35. package/dist/backend-command.js.map +1 -1
  36. package/dist/bundled-skills.d.ts +14 -0
  37. package/dist/bundled-skills.js +29 -0
  38. package/dist/bundled-skills.js.map +1 -0
  39. package/dist/cache-analysis.d.ts +47 -1
  40. package/dist/cache-analysis.js +68 -8
  41. package/dist/cache-analysis.js.map +1 -1
  42. package/dist/cache-ledger.d.ts +16 -3
  43. package/dist/cache-ledger.js +33 -13
  44. package/dist/cache-ledger.js.map +1 -1
  45. package/dist/cache-report-tool.d.ts +17 -0
  46. package/dist/cache-report-tool.js +40 -0
  47. package/dist/cache-report-tool.js.map +1 -0
  48. package/dist/cache-service.d.ts +70 -3
  49. package/dist/cache-service.js +178 -17
  50. package/dist/cache-service.js.map +1 -1
  51. package/dist/channel/adapters/discord.d.ts +7 -0
  52. package/dist/channel/adapters/discord.js +35 -0
  53. package/dist/channel/adapters/discord.js.map +1 -1
  54. package/dist/channel/mcp-tools.js +22 -10
  55. package/dist/channel/mcp-tools.js.map +1 -1
  56. package/dist/channel/types.d.ts +6 -0
  57. package/dist/classic-channel-manager.d.ts +10 -5
  58. package/dist/classic-channel-manager.js +12 -9
  59. package/dist/classic-channel-manager.js.map +1 -1
  60. package/dist/cli.js +130 -31
  61. package/dist/cli.js.map +1 -1
  62. package/dist/config-validator.js +103 -4
  63. package/dist/config-validator.js.map +1 -1
  64. package/dist/daemon.d.ts +7 -0
  65. package/dist/daemon.js +63 -16
  66. package/dist/daemon.js.map +1 -1
  67. package/dist/decision-list.d.ts +25 -0
  68. package/dist/decision-list.js +30 -0
  69. package/dist/decision-list.js.map +1 -0
  70. package/dist/delivery-outbox.d.ts +10 -0
  71. package/dist/delivery-outbox.js +12 -1
  72. package/dist/delivery-outbox.js.map +1 -1
  73. package/dist/fleet-context.d.ts +10 -0
  74. package/dist/fleet-manager.d.ts +417 -11
  75. package/dist/fleet-manager.js +1387 -109
  76. package/dist/fleet-manager.js.map +1 -1
  77. package/dist/fleet-traffic.d.ts +74 -0
  78. package/dist/fleet-traffic.js +196 -0
  79. package/dist/fleet-traffic.js.map +1 -0
  80. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +20 -5
  81. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +6 -1
  82. package/dist/general-knowledge/skills/multi-channel/SKILL.md +3 -2
  83. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
  84. package/dist/general-knowledge/skills/web-appearance/SKILL.md +257 -23
  85. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +478 -0
  86. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  87. package/dist/human-delivery-receipts.d.ts +24 -1
  88. package/dist/human-delivery-receipts.js +64 -11
  89. package/dist/human-delivery-receipts.js.map +1 -1
  90. package/dist/instance-config-impact.js +3 -1
  91. package/dist/instance-config-impact.js.map +1 -1
  92. package/dist/instance-names.d.ts +4 -0
  93. package/dist/instance-names.js +12 -0
  94. package/dist/instance-names.js.map +1 -0
  95. package/dist/instructions.d.ts +12 -1
  96. package/dist/instructions.js +13 -2
  97. package/dist/instructions.js.map +1 -1
  98. package/dist/locale.js +100 -8
  99. package/dist/locale.js.map +1 -1
  100. package/dist/ls-attach-resolve.d.ts +95 -0
  101. package/dist/ls-attach-resolve.js +174 -0
  102. package/dist/ls-attach-resolve.js.map +1 -0
  103. package/dist/memory-hogs.d.ts +27 -0
  104. package/dist/memory-hogs.js +124 -0
  105. package/dist/memory-hogs.js.map +1 -0
  106. package/dist/outbound-handlers.d.ts +10 -0
  107. package/dist/outbound-handlers.js +122 -2
  108. package/dist/outbound-handlers.js.map +1 -1
  109. package/dist/outbound-schemas.d.ts +164 -4
  110. package/dist/outbound-schemas.js +99 -3
  111. package/dist/outbound-schemas.js.map +1 -1
  112. package/dist/public-web-gateway.js +8 -2
  113. package/dist/public-web-gateway.js.map +1 -1
  114. package/dist/public-web-link.d.ts +9 -0
  115. package/dist/public-web-link.js +8 -0
  116. package/dist/public-web-link.js.map +1 -1
  117. package/dist/quickstart-api.d.ts +10 -1
  118. package/dist/quickstart-api.js +12 -4
  119. package/dist/quickstart-api.js.map +1 -1
  120. package/dist/reply-buttons-controller.d.ts +4 -0
  121. package/dist/reply-buttons-controller.js +2 -2
  122. package/dist/reply-buttons-controller.js.map +1 -1
  123. package/dist/restart-progress.d.ts +65 -1
  124. package/dist/restart-progress.js +236 -10
  125. package/dist/restart-progress.js.map +1 -1
  126. package/dist/rollout-index.d.ts +5 -0
  127. package/dist/rollout-index.js +26 -5
  128. package/dist/rollout-index.js.map +1 -1
  129. package/dist/scheduler/scheduler.d.ts +12 -0
  130. package/dist/scheduler/scheduler.js +83 -10
  131. package/dist/scheduler/scheduler.js.map +1 -1
  132. package/dist/service-installer.d.ts +2 -1
  133. package/dist/service-installer.js +18 -3
  134. package/dist/service-installer.js.map +1 -1
  135. package/dist/settings-api.d.ts +19 -0
  136. package/dist/settings-api.js +137 -2
  137. package/dist/settings-api.js.map +1 -1
  138. package/dist/settings-change.js +104 -0
  139. package/dist/settings-change.js.map +1 -1
  140. package/dist/settings-effect.js +17 -1
  141. package/dist/settings-effect.js.map +1 -1
  142. package/dist/tmux-manager.d.ts +5 -0
  143. package/dist/tmux-manager.js +16 -0
  144. package/dist/tmux-manager.js.map +1 -1
  145. package/dist/tool-permissions.d.ts +4 -6
  146. package/dist/tool-permissions.js +53 -5
  147. package/dist/tool-permissions.js.map +1 -1
  148. package/dist/topic-commands.d.ts +7 -1
  149. package/dist/topic-commands.js +18 -4
  150. package/dist/topic-commands.js.map +1 -1
  151. package/dist/types.d.ts +48 -1
  152. package/dist/ui/app-effects.js +77 -31
  153. package/dist/ui/chat-ambient.js +307 -0
  154. package/dist/ui/chat-effect-posters.js +8 -8
  155. package/dist/ui/chat-effects.js +101 -13
  156. package/dist/ui/chat-render.js +21 -3
  157. package/dist/ui/chat-strings.js +59 -6
  158. package/dist/ui/chat-thread.js +159 -21
  159. package/dist/ui/effect-trial-controller.js +13 -8
  160. package/dist/ui/effect-trial-panel.js +19 -16
  161. package/dist/ui/fleet-cache.js +23 -1
  162. package/dist/ui/fleet-canvas-layout.js +111 -0
  163. package/dist/ui/fleet-canvas.js +525 -0
  164. package/dist/ui/fleet-org.js +27 -12
  165. package/dist/ui/fleet-traffic.js +343 -0
  166. package/dist/ui/fleet-usage.js +401 -0
  167. package/dist/ui/panel-chat.js +154 -49
  168. package/dist/ui/panel-details.js +2 -1
  169. package/dist/ui/panel-fleet.js +43 -17
  170. package/dist/ui/panel-settings.js +26 -8
  171. package/dist/ui/preview.js +3 -2
  172. package/dist/ui/settings-appearance.js +42 -18
  173. package/dist/ui/settings-catalog.js +92 -0
  174. package/dist/ui/settings-dialogs.js +40 -12
  175. package/dist/ui/settings-effects.js +4 -3
  176. package/dist/ui/settings-guests.js +117 -0
  177. package/dist/ui/settings-learn.js +25 -0
  178. package/dist/ui/settings-model.js +15 -6
  179. package/dist/ui/settings-mods.js +23 -9
  180. package/dist/ui/settings-owner-effects.js +9 -9
  181. package/dist/ui/settings-strings.js +120 -42
  182. package/dist/ui/settings-web-authority.js +91 -0
  183. package/dist/ui/shared/agend-auth.js +1 -1
  184. package/dist/ui/shared/app-i18n.js +4 -2
  185. package/dist/ui/shared/app-prefs.js +5 -4
  186. package/dist/ui/shared/app-route.js +19 -1
  187. package/dist/ui/shared/app-session.js +2 -0
  188. package/dist/ui/shared/app-shell.js +92 -27
  189. package/dist/ui/shared/app-store.js +26 -1
  190. package/dist/ui/shared/app-stream.js +3 -1
  191. package/dist/ui/shared/app-themes.js +24 -6
  192. package/dist/ui/shared/app.css +365 -17
  193. package/dist/ui/shared/app.js +21 -14
  194. package/dist/ui/shared/card-strings.js +4 -0
  195. package/dist/ui/shared/effect-app-runtime.js +68 -19
  196. package/dist/ui/shared/effect-chat-runtime.js +16 -3
  197. package/dist/ui/shared/effect-frame.js +26 -7
  198. package/dist/ui/shared/effect-geometry.js +24 -9
  199. package/dist/ui/shared/effect-policy.js +21 -7
  200. package/dist/ui/shared/effect-profiler.js +31 -9
  201. package/dist/ui/shared/effect-registry.js +87 -48
  202. package/dist/ui/shared/effect-stock-snapshot.js +9 -0
  203. package/dist/ui/shared/effect-stock-trust.js +62 -10
  204. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  205. package/dist/ui/shared/effect-trial-source.js +1 -1
  206. package/dist/ui/shared/effect-trial-stage.js +54 -31
  207. package/dist/ui/shared/effect-trial.css +40 -0
  208. package/dist/ui/shared/fx-motion.js +67 -0
  209. package/dist/ui/shared/identity-card.js +75 -4
  210. package/dist/ui/shared/identity-color.js +3 -3
  211. package/dist/ui/shared/instance-nav.js +194 -28
  212. package/dist/ui/shared/panel-view.js +24 -4
  213. package/dist/ui/shared/tokens.css +9 -3
  214. package/dist/ui/shared/ui-icons.js +1 -0
  215. package/dist/ui/shared/usage-dialog.js +22 -2
  216. package/dist/ui/shared/view-strings.js +68 -2
  217. package/dist/ui/shared/web-authorize.js +151 -0
  218. package/dist/ui/signin.html +1 -1
  219. package/dist/ui/web-terminal/terminal.css +4 -4
  220. package/dist/usage/providers.d.ts +28 -0
  221. package/dist/usage/providers.js +126 -36
  222. package/dist/usage/providers.js.map +1 -1
  223. package/dist/usage-report.d.ts +158 -0
  224. package/dist/usage-report.js +226 -0
  225. package/dist/usage-report.js.map +1 -0
  226. package/dist/usage-rollup.d.ts +51 -0
  227. package/dist/usage-rollup.js +168 -0
  228. package/dist/usage-rollup.js.map +1 -0
  229. package/dist/view-api.d.ts +39 -0
  230. package/dist/view-api.js +65 -4
  231. package/dist/view-api.js.map +1 -1
  232. package/dist/web-ambient.d.ts +72 -0
  233. package/dist/web-ambient.js +161 -0
  234. package/dist/web-ambient.js.map +1 -0
  235. package/dist/web-api.d.ts +69 -5
  236. package/dist/web-api.js +362 -45
  237. package/dist/web-api.js.map +1 -1
  238. package/dist/web-appearance-policy.d.ts +4 -6
  239. package/dist/web-appearance-policy.js +5 -12
  240. package/dist/web-appearance-policy.js.map +1 -1
  241. package/dist/web-appearance-tools.d.ts +3 -0
  242. package/dist/web-appearance-tools.js +22 -3
  243. package/dist/web-appearance-tools.js.map +1 -1
  244. package/dist/web-appearance-view.d.ts +27 -2
  245. package/dist/web-appearance-view.js +129 -14
  246. package/dist/web-appearance-view.js.map +1 -1
  247. package/dist/web-appearance.d.ts +17 -3
  248. package/dist/web-appearance.js +82 -7
  249. package/dist/web-appearance.js.map +1 -1
  250. package/dist/web-asset-parts.d.ts +5 -10
  251. package/dist/web-asset-parts.js +3 -3
  252. package/dist/web-asset-parts.js.map +1 -1
  253. package/dist/web-auth.js +3 -1
  254. package/dist/web-auth.js.map +1 -1
  255. package/dist/web-card.d.ts +14 -0
  256. package/dist/web-card.js +40 -6
  257. package/dist/web-card.js.map +1 -1
  258. package/dist/web-channel-echo.d.ts +7 -1
  259. package/dist/web-channel-echo.js +6 -2
  260. package/dist/web-channel-echo.js.map +1 -1
  261. package/dist/web-chat-history.d.ts +50 -0
  262. package/dist/web-chat-history.js +87 -5
  263. package/dist/web-chat-history.js.map +1 -1
  264. package/dist/web-commands.d.ts +1 -0
  265. package/dist/web-effect-bootstrap.d.ts +12 -0
  266. package/dist/web-effect-bootstrap.js +49 -0
  267. package/dist/web-effect-bootstrap.js.map +1 -0
  268. package/dist/web-effect-bundle.js +46 -1
  269. package/dist/web-effect-bundle.js.map +1 -1
  270. package/dist/web-effect-playback.d.ts +30 -0
  271. package/dist/web-effect-playback.js +71 -0
  272. package/dist/web-effect-playback.js.map +1 -0
  273. package/dist/web-effect-profiles.d.ts +4 -4
  274. package/dist/web-effect-service.d.ts +11 -4
  275. package/dist/web-effect-service.js +28 -5
  276. package/dist/web-effect-service.js.map +1 -1
  277. package/dist/web-effect-tools.d.ts +1 -0
  278. package/dist/web-effect-tools.js +8 -5
  279. package/dist/web-effect-tools.js.map +1 -1
  280. package/dist/web-effect-trials.d.ts +4 -4
  281. package/dist/web-effect-trials.js +6 -6
  282. package/dist/web-effect-trials.js.map +1 -1
  283. package/dist/web-effect-upload.d.ts +2 -4
  284. package/dist/web-effect-upload.js +3 -8
  285. package/dist/web-effect-upload.js.map +1 -1
  286. package/dist/web-effect-worker.d.ts +1 -1
  287. package/dist/web-effect-worker.js +2 -2
  288. package/dist/web-effect-worker.js.map +1 -1
  289. package/dist/web-effect.d.ts +14 -0
  290. package/dist/web-effect.js +21 -0
  291. package/dist/web-effect.js.map +1 -1
  292. package/dist/web-guest-access.d.ts +125 -0
  293. package/dist/web-guest-access.js +234 -0
  294. package/dist/web-guest-access.js.map +1 -0
  295. package/dist/web-guest-rooms.d.ts +82 -0
  296. package/dist/web-guest-rooms.js +135 -0
  297. package/dist/web-guest-rooms.js.map +1 -0
  298. package/dist/web-identity-color.d.ts +1 -1
  299. package/dist/web-identity-color.js +2 -2
  300. package/dist/web-identity-color.js.map +1 -1
  301. package/dist/web-identity-palette.d.ts +1 -1
  302. package/dist/web-identity-palette.js +2 -2
  303. package/dist/web-identity-palette.js.map +1 -1
  304. package/dist/web-image-meta.d.ts +13 -0
  305. package/dist/web-image-meta.js +62 -0
  306. package/dist/web-image-meta.js.map +1 -1
  307. package/dist/web-login.d.ts +47 -0
  308. package/dist/web-login.js +115 -0
  309. package/dist/web-login.js.map +1 -1
  310. package/dist/web-mod-agent.d.ts +1 -1
  311. package/dist/web-mod-agent.js +12 -8
  312. package/dist/web-mod-agent.js.map +1 -1
  313. package/dist/web-mod-bundle.d.ts +14 -1
  314. package/dist/web-mod-bundle.js +68 -3
  315. package/dist/web-mod-bundle.js.map +1 -1
  316. package/dist/web-mod-export.d.ts +9 -0
  317. package/dist/web-mod-export.js +21 -0
  318. package/dist/web-mod-export.js.map +1 -1
  319. package/dist/web-mod-store.d.ts +7 -1
  320. package/dist/web-mod-store.js +21 -7
  321. package/dist/web-mod-store.js.map +1 -1
  322. package/dist/web-prefs.d.ts +14 -0
  323. package/dist/web-prefs.js +24 -4
  324. package/dist/web-prefs.js.map +1 -1
  325. package/dist/web-preview.d.ts +6 -2
  326. package/dist/web-preview.js +4 -2
  327. package/dist/web-preview.js.map +1 -1
  328. package/dist/web-returning-device.d.ts +41 -0
  329. package/dist/web-returning-device.js +57 -5
  330. package/dist/web-returning-device.js.map +1 -1
  331. package/dist/web-session-authorization.d.ts +132 -0
  332. package/dist/web-session-authorization.js +193 -0
  333. package/dist/web-session-authorization.js.map +1 -0
  334. package/dist/web-session.d.ts +70 -10
  335. package/dist/web-session.js +137 -22
  336. package/dist/web-session.js.map +1 -1
  337. package/dist/web-settings-patch.d.ts +49 -2
  338. package/dist/web-settings-patch.js +145 -1
  339. package/dist/web-settings-patch.js.map +1 -1
  340. package/dist/web-settings-tools.d.ts +89 -0
  341. package/dist/web-settings-tools.js +270 -0
  342. package/dist/web-settings-tools.js.map +1 -0
  343. package/dist/web-shell-routes.d.ts +1 -1
  344. package/dist/web-shell-routes.js +1 -1
  345. package/dist/web-shell-routes.js.map +1 -1
  346. package/dist/web-theme-store.d.ts +39 -0
  347. package/dist/web-theme-store.js +122 -11
  348. package/dist/web-theme-store.js.map +1 -1
  349. package/dist/web-theme.d.ts +2 -0
  350. package/dist/web-theme.js +10 -1
  351. package/dist/web-theme.js.map +1 -1
  352. package/dist/web-upload.d.ts +13 -0
  353. package/dist/web-upload.js +39 -3
  354. package/dist/web-upload.js.map +1 -1
  355. package/dist/workflow-templates/default.md +1 -1
  356. package/package.json +1 -1
  357. package/templates/systemd.service.ejs +8 -0
@@ -39,6 +39,41 @@ export const msgKey = (x) => `${x.boot}-${x.id}`;
39
39
  */
40
40
  export const THREAD_TAIL = 24;
41
41
  export const THREAD_CHUNK = 40;
42
+ /** #2158: a reader's scroll (smooth, momentum) is theirs until it has been this quiet since their last input or scroll. */
43
+ export const READER_QUIET_MS = 250;
44
+ const SCROLL_KEYS = new Set(["PageUp", "PageDown", "ArrowUp", "ArrowDown", "Home", "End", " "]);
45
+
46
+ /**
47
+ * #2053: a message's rendered Markdown, kept across renders and chat switches. A draw compares each message's html with
48
+ * its node's, so every render of a thread rendered every drawn message's Markdown again (~320 calls per 200-message
49
+ * switch). renderMarkdown's output depends only on the text, whether its HTML cards are on (an agent's message), and —
50
+ * for a text that has them — whether the page knows each server-emoji id and the name it has for each mention: the key
51
+ * holds exactly those, so a cached html is always what renderMarkdown would return now. Bounded: least recently used out.
52
+ */
53
+ export const MD_CACHE_MAX = 4000;
54
+ const mdCache = new Map();
55
+ const EMOJI_IDS = /<a?:[A-Za-z0-9_]{2,32}:(\d{15,25})>/g;
56
+ const MENTION_IDS = /<(@!?|@&|#)(\d{15,25})>/g;
57
+ const MENTION_KINDS = { "@": "u", "@!": "u", "@&": "r", "#": "c" };
58
+ export function renderedMarkdown(text, cards, emojis, mentions) {
59
+ const src = typeof text === "string" ? text : String(text == null ? "" : text);
60
+ let sig = cards ? "c" : "-";
61
+ if (src.includes("<")) {
62
+ // Read as renderMarkdown reads it: it drops NUL first (its placeholder mark), so "<@\0id>" is a mention there too.
63
+ const seen = src.includes("\u0000") ? src.replace(/\u0000/g, "") : src;
64
+ for (const m of seen.matchAll(EMOJI_IDS)) sig += `|e${m[1]}${emojis && emojis(m[1]) ? 1 : 0}`;
65
+ for (const m of seen.matchAll(MENTION_IDS)) { const name = mentions ? mentions(MENTION_KINDS[m[1]], m[2]) : null; sig += `|m${m[1]}${m[2]}=${typeof name === "string" ? name.length + ":" + name : "-"}`; }
66
+ }
67
+ const key = `${sig}\u0000${src}`;
68
+ let html = mdCache.get(key);
69
+ if (html === undefined) html = R().renderMarkdown(src, { htmlCards: cards, emojis, mentions });
70
+ else mdCache.delete(key);
71
+ mdCache.set(key, html);
72
+ while (mdCache.size > MD_CACHE_MAX) mdCache.delete(mdCache.keys().next().value);
73
+ return html;
74
+ }
75
+ /** Tests only: forget every rendered Markdown. */
76
+ export function clearRenderedMarkdown() { mdCache.clear(); }
42
77
 
43
78
  /**
44
79
  * Bind a renderer to `list` (the thread element) inside `scroller`. opts: { t, tf, isUser(x), onJump({show, unseen}),
@@ -47,6 +82,17 @@ export const THREAD_CHUNK = 40;
47
82
  */
48
83
  export function createThread(list, scroller, opts) {
49
84
  const nodes = new Map(); // key → { html, node }
85
+ // A server emoji's picture that does not load reads `:name:` (its alt) instead of an empty box (error does not bubble:
86
+ // caught on the way down).
87
+ list.addEventListener("error", (e) => {
88
+ const img = e.target;
89
+ if (!img || img.tagName !== "IMG" || !img.classList || !img.classList.contains("emoji")) return;
90
+ const span = list.ownerDocument.createElement("span");
91
+ span.className = "emoji-text";
92
+ span.textContent = img.getAttribute("alt") || "";
93
+ span.title = img.getAttribute("title") || span.textContent;
94
+ img.replaceWith(span);
95
+ }, true);
50
96
  let msgs = [];
51
97
  let unseen = 0;
52
98
  // The reader is at the newest message. While pinned, content that grows after it was drawn — an image loading, a
@@ -54,12 +100,15 @@ export function createThread(list, scroller, opts) {
54
100
  // Only a move UP unpins: the browser's own scroll anchoring also fires scroll events when content grows (the view
55
101
  // moves down or stays), and those must not count as the reader leaving the bottom. Reaching the bottom pins again.
56
102
  let pinned = true;
57
- let lastTop = 0;
103
+ // Where the view was (and how tall it was) at the last scroll the page saw or made: a move up from there unpins.
104
+ let lastTop = 0, lastView = 0;
105
+ const mark = () => { lastTop = scroller.scrollTop; lastView = scroller.clientHeight; };
58
106
  // The oldest message drawn (by key): everything after it is drawn, so new messages never push the window. Null: none
59
107
  // drawn yet — the next render draws the newest THREAD_TAIL and the rest follows (grow).
60
108
  let firstKey = null;
61
- let growTimer = null, growFrame = null, disposed = false;
109
+ let growTimer = null, growFrame = null, disposed = false, parked = false;
62
110
  const cardRefresh = new WeakMap(); // card node → re-read availability (weak: a card that left takes its source with it)
111
+ const cardDrop = new WeakMap(); // card node → drop the HTML read it is waiting on (the chat left: park)
63
112
  const tr = opts.t, trf = opts.tf;
64
113
  const stopIn = (node) => (P() ? P().stopIn(node) : []);
65
114
  /** #1770: write a message node's time label for now; unchanged text is not touched. */
@@ -92,7 +141,7 @@ export function createThread(list, scroller, opts) {
92
141
  const buttons = x.role === "agent" ? replyButtonsHtml(x.buttons) : "";
93
142
  // A person's click on a reply button shows as the button's label, never the line the agent was given (its value).
94
143
  const clicked = x.role === "user" && x.click && typeof x.click.label === "string";
95
- return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span>${time}${ticks}</div><div class="body">${clicked ? clickHtml(x.click) : `<div class="md">${R().renderMarkdown(x.text, { htmlCards: x.role === "agent", emojis: opts.emojis, mentions: opts.mentions })}</div>`}${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
144
+ return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span>${time}${ticks}</div><div class="body">${clicked ? clickHtml(x.click) : `<div class="md">${renderedMarkdown(x.text, x.role === "agent", opts.emojis, opts.mentions)}</div>`}${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
96
145
  }
97
146
  /**
98
147
  * A button click as a chip: 🔘 and the label (escaped: the label is the agent's text). With `of` (live, while its
@@ -106,10 +155,18 @@ export function createThread(list, scroller, opts) {
106
155
  : `<span class="click-text">${inner}</span>`}</div>`;
107
156
  }
108
157
  function trialNow() { try { const t = opts.serverNow ? opts.serverNow() : Date.now(); return Number.isFinite(t) ? t : Infinity; } catch { return Infinity; } }
158
+ const trialReasons = new Set(["checking", "localAdmin", "chatChanged", "busy", "safe", "effectsOff", "reducedMotion", "saveData", "unsupported", "transport", "inactive", "dialog", "expired"]);
159
+ function trialStatus(message) {
160
+ const reason = opts.effectTrialStatus?.(message);
161
+ return reason === "ready" || trialReasons.has(reason) ? reason : opts.canTryEffect?.(message) === true ? "ready" : "localAdmin";
162
+ }
109
163
  function trialHtml(offer, time, message) {
110
- const expired = trialNow() >= offer.expiresAt, available = !PUBLIC_LINK() && !expired && opts.canTryEffect?.(message) === true;
111
- const button = available ? `<button type="button" class="btn btn-sm effect-trial-start" data-act="effectTrial" data-arg="${escAttr(msgKey(message))}">${escAttr(tr("chat.effectTrialStart"))}</button>` : "";
112
- return `<div class="msg status-line"><div class="meta">${time}</div><div class="body"><div class="effect-trial-offer" data-trust="thread-control"><strong>${escAttr(tr("chat.effectTrial"))}: ${escAttr(offer.title)}</strong><div>${escAttr(tr(expired ? "chat.effectTrialExpired" : "chat.effectTrialLocal"))}</div>${button}</div></div></div>`;
164
+ const reason = trialNow() >= offer.expiresAt ? "expired" : PUBLIC_LINK() ? "localAdmin" : trialStatus(message);
165
+ const rawFailure = opts.effectTrialFailure?.(message), failure = typeof rawFailure === "string" ? rawFailure.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80) : null;
166
+ const available = reason === "ready", fix = !PUBLIC_LINK() && !available && reason !== "expired" && reason !== "checking" && typeof opts.fixEffectTrial === "function";
167
+ const label = available ? "chat.effectTrialStart" : reason === "busy" ? "chat.effectTrialStopOther" : reason === "localAdmin" ? "chat.effectTrialSignIn" : reason === "reducedMotion" ? "chat.effectTrialPlayMotion" : "chat.effectTrialSettings";
168
+ const button = available || fix ? `<button type="button" class="btn btn-sm effect-trial-start" data-act="effectTrial" data-arg="${escAttr(msgKey(message))}">${escAttr(tr(label))}</button>` : "";
169
+ return `<div class="msg status-line"><div class="meta">${time}</div><div class="body"><div class="effect-trial-offer" data-trust="thread-control"><strong>${escAttr(tr("chat.effectTrial"))}: ${escAttr(offer.title)}</strong><div>${escAttr(tr(available ? "chat.effectTrialLocal" : "chat.effectTrialReason_" + reason))}</div>${failure ? `<div class="effect-trial-failure" role="status">${escAttr(tr("chat.effectTrialStopped"))}: ${escAttr(failure)}</div>` : ""}${button}</div></div></div>`;
113
170
  }
114
171
  function armTrials() {
115
172
  if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
@@ -269,7 +326,7 @@ export function createThread(list, scroller, opts) {
269
326
  const note = el("div", "pv-note"); note.setAttribute("data-trust", "thread-control");
270
327
  const body = el("div", "tc-body"); body.hidden = true;
271
328
  ph.append(head, note, body);
272
- let text = null, reading = false, all = false;
329
+ let text = null, reading = 0, reads = 0, all = false;
273
330
  const draw = () => {
274
331
  body.textContent = "";
275
332
  const lines = text.split(/\r?\n/);
@@ -305,12 +362,15 @@ export function createThread(list, scroller, opts) {
305
362
  body.append(more);
306
363
  }
307
364
  };
365
+ // #2053: as an HTML card's read (cardDrop), one still waiting when the chat left is dropped, the card not left Loading.
366
+ cardDrop.set(ph, () => { if (reading) { reading = 0; note.textContent = ""; } });
308
367
  show.onclick = async () => {
309
368
  if (reading) return;
310
369
  if (text !== null) { body.hidden = !body.hidden; show.textContent = tr(body.hidden ? "chat.tcShow" : "chat.tcHide"); return; }
311
- reading = true; note.textContent = tr("chat.pvLoading");
370
+ const mine = reading = ++reads; note.textContent = tr("chat.pvLoading");
312
371
  const r = await loadHtmlAttachment({ id: att.id, name: att.name, size: att.size });
313
- reading = false;
372
+ if (reading !== mine) return; // dropped (its chat left meanwhile): a new click reads again
373
+ reading = 0;
314
374
  if (!ph.isConnected) return; // the card left the page while it was read: nothing to draw
315
375
  if (!r.ok) { note.textContent = r.reason === "over" ? tr("chat.tcOver") : attReason(r.reason); return; }
316
376
  note.textContent = "";
@@ -448,6 +508,10 @@ export function createThread(list, scroller, opts) {
448
508
  optB.onclick = () => { menu.open = false; opts.setPreviewOptIn(!P().optedIn()); };
449
509
  neverB.onclick = () => { menu.open = false; P().setNever(!P().never()); refreshCards(); };
450
510
  cardRefresh.set(ph, refresh);
511
+ // #2053: a kept card is shown again later. A read still waiting when its chat left is dropped then: its answer —
512
+ // whenever it comes — starts nothing (no preview, no panel without a new click), and the card is not left Loading
513
+ // (shown again, it is refreshed: refreshCards).
514
+ cardDrop.set(ph, () => { reading = 0; });
451
515
  refresh(stoppedKeys.includes(key) ? tr("chat.pvChanged") : null);
452
516
  }
453
517
  /** Re-read this device's preview choice on every card in this thread. */
@@ -462,12 +526,42 @@ export function createThread(list, scroller, opts) {
462
526
  // newest bubble clipped behind it, and the next growth past 48 px put the pill over it.
463
527
  const atBottom = () => R().isNearBottom(scroller.scrollTop, scroller.clientHeight, scroller.scrollHeight, 1);
464
528
  function reportJump() { opts.onJump({ show: !nearBottom(), unseen }); }
465
- function toBottom() { scroller.scrollTop = scroller.scrollHeight; pinned = true; lastTop = scroller.scrollTop; }
529
+ function toBottom() { snap(); scroller.scrollTop = scroller.scrollHeight; pinned = true; mark(); }
530
+ /**
531
+ * #2184: a thread's height is rarely a whole number of pixels, and a pinned view's offset is: so each time older messages
532
+ * were drawn above, the whole view moved by the change in that fraction (0.6–0.7 px — the faint jitter of a chat
533
+ * opened cold). A sub-pixel margin above the list keeps the thread's height whole, so the bottom never moves.
534
+ */
535
+ function snap() {
536
+ list.style.setProperty("--thread-snap", "0px");
537
+ const h = list.getBoundingClientRect().height, f = h - Math.floor(h);
538
+ if (f > 0.001 && f < 0.999) list.style.setProperty("--thread-snap", (1 - f) + "px");
539
+ }
466
540
  // Sizes change without a scroll event (layout after the render, the dock under the thread): while pinned, the view
467
541
  // stays flush with the bottom; a reader who scrolled up is never moved.
468
542
  const resized = () => { if (pinned && !atBottom()) toBottom(); reportJump(); };
469
- const ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
543
+ let ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
470
544
  if (ro) { ro.observe(list); ro.observe(scroller); }
545
+ // #2158: the reader's own hand on the thread — a wheel, a touch, the scrollbar (a pointer held), a scrolling key
546
+ // outside a text field — and the scroll that follows it until it has been quiet READER_QUIET_MS.
547
+ let inputAt = -Infinity, held = false;
548
+ const touched = () => { inputAt = performance.now(); };
549
+ const hold = (on) => () => { held = on; touched(); };
550
+ const onKey = (ev) => {
551
+ const el = ev.target;
552
+ if (SCROLL_KEYS.has(ev.key) && !(el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || "")))) touched();
553
+ };
554
+ const onScroller = [["wheel", touched], ["touchstart", hold(true)], ["touchend", hold(false)], ["touchcancel", hold(false)], ["pointerdown", hold(true)]];
555
+ const onDocument = [["pointerup", hold(false)], ["pointercancel", hold(false)], ["keydown", onKey]];
556
+ /** Listen for the reader's input on `sc` (and its document: a pointer let go anywhere, a key), or stop. */
557
+ function watchReader(sc, on) {
558
+ const doc = sc.ownerDocument, how = on ? "addEventListener" : "removeEventListener";
559
+ for (const [type, fn] of onScroller) sc[how](type, fn, { passive: true });
560
+ if (doc) for (const [type, fn] of onDocument) doc[how](type, fn, true);
561
+ if (!on) held = false;
562
+ }
563
+ const readerActive = () => held || performance.now() - inputAt < READER_QUIET_MS;
564
+ watchReader(scroller, true);
471
565
 
472
566
  /** Where drawing starts in `msgs`: at the oldest drawn message, or (none yet) the newest THREAD_TAIL. */
473
567
  function startIndex() {
@@ -481,14 +575,14 @@ export function createThread(list, scroller, opts) {
481
575
  /** Draw the next chunk of older messages, keeping the reader where they were; then the next, until all are drawn. */
482
576
  function grow() {
483
577
  growTimer = growFrame = null;
484
- if (disposed) return; // the chat left: nothing more is drawn
578
+ if (disposed || parked) return; // the chat left: nothing more is drawn (a kept one fills in when shown again)
485
579
  const at = startIndex();
486
580
  if (at <= 0) return;
487
581
  firstKey = msgKey(msgs[Math.max(0, at - THREAD_CHUNK)]);
488
582
  const fromBottom = scroller.scrollHeight - scroller.scrollTop;
489
583
  draw({ older: true }); // drawn above the reader: never "new below" (render alone counts those)
490
584
  if (pinned) toBottom();
491
- else { scroller.scrollTop = scroller.scrollHeight - fromBottom; lastTop = scroller.scrollTop; }
585
+ else { scroller.scrollTop = scroller.scrollHeight - fromBottom; mark(); }
492
586
  scheduleGrow();
493
587
  }
494
588
  /** After the next frame has painted (the click's own frame stays light), draw a chunk more. */
@@ -529,7 +623,7 @@ export function createThread(list, scroller, opts) {
529
623
  if ("restore" in renderOpts) {
530
624
  unseen = 0;
531
625
  if (renderOpts.restore == null) toBottom();
532
- else { scroller.scrollTop = renderOpts.restore; pinned = nearBottom(); lastTop = scroller.scrollTop; }
626
+ else { scroller.scrollTop = renderOpts.restore; pinned = nearBottom(); mark(); }
533
627
  } else if (stick) toBottom();
534
628
  else unseen += added;
535
629
  reportJump();
@@ -563,10 +657,23 @@ export function createThread(list, scroller, opts) {
563
657
  }
564
658
 
565
659
  function onScroll() {
660
+ // #2158: the thread got shorter under a pinned view (text settling on a cold load, a card) and the browser clamped
661
+ // scrollTop to its new end — for the view as tall as it was then; the view got shorter too before WebKit sent that
662
+ // clamp's scroll event (it comes before the ResizeObserver). A view resting exactly at the bottom of the view as it
663
+ // was at the last mark, with no hand of the reader's on the thread, was put there by the browser: still pinned, back
664
+ // at the bottom. Geometry alone can't tell (a reader's move up by the dock's growth lands there too): any input of
665
+ // theirs, and the scroll after it, is theirs and unpins as before. The page's own scrolls (reveal) have no input but
666
+ // don't land there.
667
+ const reader = readerActive();
668
+ if (reader) touched(); // the reader's scroll goes on (smooth, momentum): still theirs while it does
669
+ else if (pinned && !atBottom() && Math.abs(scroller.scrollHeight - lastView - scroller.scrollTop) <= 1) toBottom();
566
670
  const bottom = nearBottom(), top = scroller.scrollTop;
567
- if (bottom) pinned = true;
671
+ // #2087: pinned only at the exact bottom — a reader still wheeling through the last 48 px was pinned there, so
672
+ // the next resize (a picture, a card, the dock) pulled the view the rest of the way down under them. Near the bottom
673
+ // still counts for a new message (render follows it) and for the pill; nothing else moves a view the reader moves.
674
+ if (atBottom()) pinned = true;
568
675
  else if (top < lastTop) pinned = false;
569
- lastTop = top;
676
+ mark();
570
677
  if (bottom) unseen = 0;
571
678
  reportJump();
572
679
  return bottom ? null : scroller.scrollTop;
@@ -628,21 +735,52 @@ export function createThread(list, scroller, opts) {
628
735
  // Only the current host-produced keyed card and a real local click can request admission. No token is read from DOM.
629
736
  const key = el.dataset.arg, row = msgs.find(x => msgKey(x) === key), owner = nodes.get(key), node = owner?.node, offer = effectTrialMessage(row);
630
737
  if (PUBLIC_LINK() || e.defaultPrevented || e.isTrusted !== true || owner?.trialButton !== el || el.className !== "btn btn-sm effect-trial-start" || !node?.contains(el) || el.closest(".effect-trial-offer")?.getAttribute("data-trust") !== "thread-control"
631
- || !offer || trialNow() >= offer.expiresAt || opts.canTryEffect?.(row) !== true) return;
632
- opts.startEffectTrial?.(row);
738
+ || !offer || trialNow() >= offer.expiresAt) return;
739
+ if (trialStatus(row) === "ready" && opts.canTryEffect?.(row) === true) opts.startEffectTrial?.(row);
740
+ else opts.fixEffectTrial?.(row, trialStatus(row));
633
741
  } else if (act === "showButtons") { const x = msgs.find(y => y.buttons && y.buttons.id === el.dataset.arg); if (x) reveal(msgKey(x)); }
634
742
  }
635
743
 
636
744
  /** The thread goes (the chat left, or another instance opened): every preview in it stops first. */
637
- function dispose() {
745
+ /**
746
+ * #2053: kept for a later visit — the chat left as it always did (every preview stops: "leave"), but its drawn nodes
747
+ * stay. Nothing of it runs meanwhile: no timer (day labels, polls, effect trials, filling in), no size observer; the
748
+ * caller drops its subscriptions. Shown again: attach, then render — only what changed is drawn.
749
+ */
750
+ function park() {
751
+ parked = true;
752
+ watchReader(scroller, false);
753
+ if (P()) P().stopAll("leave");
754
+ for (const node of list.querySelectorAll(".html-card[data-att], .text-card[data-att]")) { const f = cardDrop.get(node); if (f) f(); }
755
+ if (ro) { ro.disconnect(); ro = null; }
756
+ if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
757
+ if (growFrame !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(growFrame);
758
+ if (growTimer !== null) clearTimeout(growTimer);
759
+ growTimer = growFrame = null;
760
+ if (offDay) { offDay(); offDay = null; }
761
+ if (pollTick) { clearInterval(pollTick); pollTick = null; }
762
+ }
763
+ /** In view in `sc` (the view's scroller): sizes watched there from now on; the caller restores the position. */
764
+ function attach(sc) {
765
+ if (!parked) watchReader(scroller, false); // moved from the scroller it was made with
766
+ parked = false;
767
+ scroller = sc;
768
+ watchReader(sc, true);
769
+ if (ro) ro.disconnect();
770
+ ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
771
+ if (ro) { ro.observe(list); ro.observe(sc); }
772
+ }
773
+ /** `own`: only this thread's previews stop (a thread let go while another chat is in view); else every one, as leaving. */
774
+ function dispose(own = false) {
638
775
  disposed = true;
776
+ if (!parked) watchReader(scroller, false);
639
777
  if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
640
778
  if (ro) ro.disconnect();
641
- if (P()) P().stopAll("leave");
779
+ if (P()) { if (own) P().stopIn(list); else P().stopAll("leave"); }
642
780
  nodes.clear();
643
781
  armDay();
644
782
  armPolls();
645
783
  }
646
784
 
647
- return { render, onScroll, onClick, jumpLatest, reveal, refreshCards, dispose, nodes, get unseen() { return unseen; } };
785
+ return { render, onScroll, onClick, jumpLatest, reveal, refreshCards, park, attach, dispose, nodes, get unseen() { return unseen; } };
648
786
  }
@@ -5,7 +5,7 @@ import { chatEffectIdentity } from "/assets/effect-chat-runtime.js";
5
5
  const owners = new WeakSet(), safe = fn => { try { return fn(); } catch { return null; } };
6
6
  const LIMIT = 128 * 1024;
7
7
  export function createEffectTrialController({ window: host, current, allowed, onStart, onChange = () => {} }) {
8
- let dead = false, active = null, physical = null, previous = null, broken = false, clockBusy = false;
8
+ let dead = false, active = null, physical = null, previous = null, broken = false, clockBusy = false, endedReason = null, endedOffer = null;
9
9
  const cleanup = [], releases = [], identity = safe(() => chatEffectIdentity(host));
10
10
  const tell = () => { if (!dead) safe(onChange); };
11
11
  function clock() {
@@ -26,11 +26,16 @@ export function createEffectTrialController({ window: host, current, allowed, on
26
26
  }
27
27
  function end(op, reason = "stopped") {
28
28
  if (!op || op.dead) return false;
29
- op.dead = true; if (active === op) active = null; safe(() => op.resolve?.(false));
29
+ op.dead = true; if (active === op) active = null;
30
+ endedReason = typeof reason === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(reason) ? reason : "trial_failed";
31
+ endedOffer = op.offer.offer;
32
+ safe(() => op.resolve?.(false));
30
33
  safe(() => host.clearTimeout(op.timer)); safe(() => op.controller.abort());
31
34
  if (op.reply && !op.stopQueued) { op.stopQueued = true; cleanup.push(op); }
32
35
  // Physical paint is removed before callbacks, cleanup I/O or another explicit click.
33
- safe(() => op.dispose?.()); op.dispose = null; tell(); drain(); return true;
36
+ safe(() => op.dispose?.()); op.dispose = null;
37
+ if (!["stopped", "replaced"].includes(endedReason)) safe(() => host.console?.warn("[AgEnD effects] Try stopped:", endedReason));
38
+ tell(); drain(); return true;
34
39
  }
35
40
  function arm(op) {
36
41
  safe(() => host.clearTimeout(op.timer));
@@ -67,7 +72,7 @@ export function createEffectTrialController({ window: host, current, allowed, on
67
72
  if (dead) return; dead = true; end(active); for (const off of releases.splice(0)) safe(off); tell();
68
73
  }
69
74
  const api = { ok: false, start: async () => false, stop: () => end(active), sync() { const op = active; if (op && !eligible(op)) end(op, "trial_ended"); },
70
- snapshot: () => Object.freeze({ busy: !!physical || cleanup.length > 0, phase: active ? active.reply ? "running" : "starting" : "idle" }), dispose };
75
+ snapshot: () => Object.freeze({ busy: !!physical || cleanup.length > 0, phase: active ? active.reply ? "running" : "starting" : "idle", reason: active ? null : endedReason, offer: active ? active.offer.offer : endedOffer }), dispose };
71
76
  if (!identity || owners.has(host) || typeof current !== "function" || typeof allowed !== "function" || typeof onStart !== "function"
72
77
  || typeof host.AgendAuth?.trialSession !== "function" || typeof host.AgendAuth?.onSessionEnd !== "function") return Object.freeze(api);
73
78
  owners.add(host);
@@ -75,7 +80,7 @@ export function createEffectTrialController({ window: host, current, allowed, on
75
80
  const off = host.AgendAuth.onSessionEnd(dispose);
76
81
  if (dead) { safe(off); return Object.freeze(api); }
77
82
  releases.push(off);
78
- for (const [target, type, fn] of [[host, "blur", () => end(active)], [host, "pagehide", dispose], [host.document, "visibilitychange", () => { if (host.document.visibilityState !== "visible") end(active); }]]) {
83
+ for (const [target, type, fn] of [[host, "blur", () => end(active, "trial_window_blur")], [host, "pagehide", dispose], [host.document, "visibilitychange", () => { if (host.document.visibilityState !== "visible") end(active, "hidden"); }]]) {
79
84
  target.addEventListener(type, fn); releases.push(() => target.removeEventListener(type, fn));
80
85
  }
81
86
  } catch { dispose(); return Object.freeze(api); }
@@ -86,7 +91,7 @@ export function createEffectTrialController({ window: host, current, allowed, on
86
91
  const offer = safe(() => effectTrialMessage(message)), instance = safe(() => message.instance), began = clock();
87
92
  if (!offer || began === null || began + 10000 > Number.MAX_SAFE_INTEGER || safe(() => host.Date.now() < offer.expiresAt) !== true) return false;
88
93
  const op = { instance, offer, began, until: began + 10000, controller: new host.AbortController(), timer: null, reply: null, dead: false, stopQueued: false, dispose: null };
89
- active = op; const token = {}; physical = token;
94
+ endedReason = null; endedOffer = null; active = op; const token = {}; physical = token;
90
95
  const cancelled = new Promise(resolve => { op.resolve = resolve; });
91
96
  const work = Promise.resolve().then(async () => {
92
97
  if (!eligible(op)) return false;
@@ -105,12 +110,12 @@ export function createEffectTrialController({ window: host, current, allowed, on
105
110
  op.until = began + reply.remainingMs;
106
111
  if (op.until > Number.MAX_SAFE_INTEGER || !eligible(op)) return false;
107
112
  arm(op);
108
- const handle = Object.freeze({ reply, startedAt: began, instance, current: () => eligible(op), stop: () => end(op), });
113
+ const handle = Object.freeze({ reply, startedAt: began, instance, current: () => eligible(op), stop: (reason = "stopped") => end(op, reason), });
109
114
  const disposer = onStart(handle);
110
115
  if (typeof disposer !== "function") return false;
111
116
  if (!eligible(op)) { safe(disposer); return false; }
112
117
  op.dispose = disposer; tell(); return eligible(op);
113
- }).catch(() => false).then(ok => { if (!ok) end(op); return ok; }).finally(() => { if (physical === token) physical = null; tell(); drain(); });
118
+ }).catch(() => false).then(ok => { if (!ok) end(op, "trial_start_failed"); return ok; }).finally(() => { if (physical === token) physical = null; tell(); drain(); });
114
119
  // The logical caller may stop immediately, while the physical request/cancel still owns the lane.
115
120
  tell();
116
121
  return Promise.race([work, cancelled]);
@@ -1,15 +1,16 @@
1
1
  /** #1629: fixed host producer. Creates no frame or request; the admitted app controller owns its lease. */
2
2
  import { html, useLayoutEffect, useRef } from "/assets/app-html.js";
3
3
  import { register, t } from "/assets/app-i18n.js";
4
+ import { createEffectTrialShadow } from "/assets/effect-trial-shadow.js";
4
5
  import { createEffectTrialStage } from "/assets/effect-trial-stage.js";
5
6
  register("effectTrial", {
6
7
  en: { label: "Trial", stop: "Stop trial", close: "Close", note: "A bounded sample. Nothing is applied or saved." },
7
8
  "zh-TW": { label: "試播", stop: "停止試播", close: "關閉", note: "僅在此區塊試播,不會套用或儲存。" },
8
9
  });
9
10
  /** The caller owns authenticated admission, lease/deadline and shared pool. No script/HTML is rendered here. */
10
- export function EffectTrialPanel({ window: host = globalThis.window, binding, title, current, ownerCss, onStage, onStop, onClose }) {
11
- const panel = useRef(null), stage = useRef(null), life = useRef(null), latest = useRef(null);
12
- latest.current = { current, ownerCss, onStage, onStop, onClose };
11
+ export function EffectTrialPanel({ window: host = globalThis.window, binding, title, current, onStage, onStop, onClose }) {
12
+ const panel = useRef(null), life = useRef(null), latest = useRef(null);
13
+ latest.current = { current, onStage, onStop, onClose };
13
14
  const stop = close => {
14
15
  const entry = life.current; if (!entry || (close ? entry.closed : entry.dead)) return;
15
16
  if (close) entry.closed = true;
@@ -17,21 +18,23 @@ export function EffectTrialPanel({ window: host = globalThis.window, binding, ti
17
18
  try { (close ? entry.onClose : entry.onStop)?.("stopped"); } catch { /* lease already ended */ }
18
19
  };
19
20
  useLayoutEffect(() => {
20
- const entry = { dead: false, closed: false, api: null, onStop, onClose }; life.current = entry;
21
+ const entry = { dead: false, closed: false, api: null, shadow: null, onStop, onClose }; life.current = entry;
21
22
  const ended = reason => { if (entry.dead) return; entry.dead = true; try { entry.onStop?.(reason); } catch { /* never revive */ } };
22
23
  try {
23
- entry.api = createEffectTrialStage({ window: host, panel: panel.current, stage: stage.current, binding,
24
- current: own => !entry.dead && life.current === entry && latest.current.current?.(own) === true,
25
- ownerCss: () => latest.current.ownerCss?.(), onStop: ended });
26
- if (!entry.api) ended("stage_unavailable");
27
- else { latest.current.onStage?.(entry.api); }
24
+ entry.shadow = createEffectTrialShadow({ window: host, panel: panel.current, title,
25
+ labels: { label: t("effectTrial.label"), close: t("effectTrial.close"), stop: t("effectTrial.stop"), note: t("effectTrial.note") },
26
+ onStop: () => stop(false), onClose: () => stop(true), onError: ended,
27
+ onReady(shell) {
28
+ if (entry.dead || life.current !== entry) return;
29
+ entry.api = createEffectTrialStage({ window: host, panel: panel.current, stage: shell.stage, binding,
30
+ current: own => !entry.dead && life.current === entry && latest.current.current?.(own) === true, onStop: ended });
31
+ if (!entry.api) ended("stage_unavailable");
32
+ else { try { latest.current.onStage?.(entry.api); } catch { entry.api.dispose(); ended("stage_unavailable"); } }
33
+ } });
34
+ if (!entry.shadow) ended("stage_unavailable");
28
35
  } catch { entry.api?.dispose(); ended("stage_unavailable"); }
29
- return () => { entry.api?.dispose(); entry.dead = true; if (life.current === entry) life.current = null; };
36
+ return () => { entry.api?.dispose(); ended("stopped"); entry.shadow?.dispose(); entry.dead = true; if (life.current === entry) life.current = null; };
30
37
  }, [host, binding]);
31
- return html`<aside ref=${panel} data-trust="preview" class="pv-panel effect-trial-panel" aria-label=${t("effectTrial.label")}>
32
- <header class="effect-trial-head"><strong>${t("effectTrial.label")}</strong><span>${typeof title === "string" ? title : ""}</span><button type="button" class="btn" onClick=${() => stop(true)}>${t("effectTrial.close")}</button></header>
33
- <div class="effect-trial-controls"><button type="button" class="btn" onClick=${() => stop(false)}>${t("effectTrial.stop")}</button></div>
34
- <div class="effect-trial-body"><div ref=${stage} class="effect-trial-stage" aria-hidden="true"></div></div>
35
- <p class="effect-trial-note">${t("effectTrial.note")}</p>
36
- </aside>`;
38
+ useLayoutEffect(() => { life.current?.shadow?.text(title, { label: t("effectTrial.label"), close: t("effectTrial.close"), stop: t("effectTrial.stop"), note: t("effectTrial.note") }); });
39
+ return html`<aside ref=${panel} data-trust="preview" class="pv-panel effect-trial-panel" aria-label=${t("effectTrial.label")}></aside>`;
37
40
  }
@@ -31,6 +31,11 @@ register("cache", {
31
31
  shared: "shares its working directory with {0}; their transcripts cannot be told apart",
32
32
  empty: "No instances", emptyHint: "Instances show here once the fleet has them.",
33
33
  b0: "≤5m", b1: "≤30m", b2: "≤1h", b3: "≤2h", b4: "≤6h", b5: "≤24h", b6: ">24h", tokensUnit: "input-token equivalents",
34
+ writeMix: "Cache writes 5 min / 1 h", effectiveTtl: "Behaves as", gapMiss: "{0} of {1} 5–60 min gaps rewrote it", gapMissNone: "no 5–60 min gaps yet",
35
+ effMeasured: "{0} (measured)", effLabel: "{0} (as written)", effUnproven: "not known yet",
36
+ fleetSwitch: "Switch to 1-hour cache",
37
+ switchTo1h: "Its 5-minute cache really expires: {0} of {1} gaps of 5–60 minutes rewrote it. Set prompt_cache_ttl: \"1h\" for this instance (backend_options.claude-code) rather than keep-warm.",
38
+ unproven: "Written with a 5-minute TTL, but only {0} gaps of 5–60 minutes so far — too few to tell how long it really lasts. The keep-warm figures assume 5 minutes.",
34
39
  },
35
40
  "zh-TW": {
36
41
  title: "快取", w24h: "24 小時", w7d: "7 天", w30d: "30 天", window: "期間",
@@ -53,6 +58,11 @@ register("cache", {
53
58
  shared: "與 {0} 共用工作目錄,無法分辨各自的 transcript",
54
59
  empty: "沒有 instance", emptyHint: "fleet 有 instance 後會顯示在這裡。",
55
60
  b0: "≤5分", b1: "≤30分", b2: "≤1時", b3: "≤2時", b4: "≤6時", b5: "≤24時", b6: ">24時", tokensUnit: "輸入 token 當量",
61
+ writeMix: "快取寫入 5 分 / 1 時", effectiveTtl: "實際存活", gapMiss: "{1} 個 5–60 分鐘空檔中有 {0} 個重寫", gapMissNone: "還沒有 5–60 分鐘的空檔",
62
+ effMeasured: "{0}(實測)", effLabel: "{0}(依寫入標記)", effUnproven: "尚無法判斷",
63
+ fleetSwitch: "建議改用 1 小時快取",
64
+ switchTo1h: "它的 5 分鐘快取確實會過期:{1} 個 5–60 分鐘空檔中有 {0} 個重寫。請為這個 instance 設定 prompt_cache_ttl: \"1h\"(backend_options.claude-code),而不是保溫。",
65
+ unproven: "以 5 分鐘 TTL 寫入,但目前只有 {0} 個 5–60 分鐘空檔,太少,無法判斷實際存活多久。保溫的數字以 5 分鐘估算。",
56
66
  },
57
67
  });
58
68
  const tc = (k, ...v) => t(`cache.${k}`, ...v);
@@ -79,6 +89,8 @@ const pct = (x) => `${x >= 0.1 ? Math.round(x * 100) : (x * 100).toFixed(1)}%`;
79
89
  export function reasonText(a) {
80
90
  const r = a.recommendation, ttl = ttlText(a.ttlSec);
81
91
  if (r.reason === "quiet") return tc("quiet");
92
+ if (r.reason === "switch_to_1h") return tc("switchTo1h", a.effectiveTtl.misses, a.effectiveTtl.sample);
93
+ if (r.reason === "insufficient_evidence") return tc("unproven", a.effectiveTtl.sample);
82
94
  if (r.reason === "never_expired") {
83
95
  if (a.sim.pastTtl) return tc("survived", a.sim.pastTtl, ttl);
84
96
  return a.sim.tailPings ? tc("onlyIdle", a.sim.tailPings.toLocaleString("en-US")) : tc("neverExpired", ttl);
@@ -107,6 +119,12 @@ function Histogram({ buckets, ttlSec }) {
107
119
  </svg>`;
108
120
  }
109
121
 
122
+ /** #2010: what the TTL behaves as — measured, as written, or not known yet. */
123
+ export function effectiveText(e) {
124
+ if (e.ttlSec === null) return tc("effUnproven");
125
+ return e.basis === "measured" ? tc("effMeasured", ttlText(e.ttlSec)) : tc("effLabel", ttlText(e.ttlSec));
126
+ }
127
+
110
128
  function Metric({ label, value, sub }) {
111
129
  return html`<div class="ch-metric"><dt>${label}</dt><dd>${value}${sub ? html`<span class="ch-sub">${sub}</span>` : null}</dd></div>`;
112
130
  }
@@ -130,8 +148,11 @@ function InstanceCard({ r }) {
130
148
  <${Metric} label=${tc("pings")} value=${(s.pings + s.tailPings).toLocaleString("en-US")} sub=${s.tailPings ? tc("pingsAfter", s.tailPings) : null} />
131
149
  <${Metric} label=${tc("pingCost")} value=${$(s.pingCost + s.tailCost)} />
132
150
  <${Metric} label=${tc("net")} value=${a.priced ? `${s.net >= 0 ? "+" : "−"}${money(Math.abs(s.net))}` : "—"} />
151
+ ${a.writeMix["5m"] + a.writeMix["1h"] > 0 ? html`<${Metric} label=${tc("writeMix")} value=${`${tokens(a.writeMix["5m"])} / ${tokens(a.writeMix["1h"])}`} />` : null}
152
+ ${r.backend === "claude-code" ? html`<${Metric} label=${tc("effectiveTtl")} value=${effectiveText(a.effectiveTtl)}
153
+ sub=${a.effectiveTtl.sample ? tc("gapMiss", a.effectiveTtl.misses, a.effectiveTtl.sample) : tc("gapMissNone")} />` : null}
133
154
  </dl>
134
- <${Histogram} buckets=${a.gapBuckets} ttlSec=${a.ttlSec} />
155
+ <${Histogram} buckets=${a.gapBuckets} ttlSec=${a.effectiveTtl.basis === "measured" && a.effectiveTtl.ttlSec !== null ? a.effectiveTtl.ttlSec : a.ttlSec} />
135
156
  </article>`;
136
157
  }
137
158
 
@@ -154,6 +175,7 @@ export function CacheReport({ report, win, setWin, refresh }) {
154
175
  <${Metric} label=${tc("fleetExpiry")} value=${money(f.expiryCost)} />
155
176
  <${Metric} label=${tc("fleetSaving")} value=${money(f.saving)} />
156
177
  <${Metric} label=${tc("fleetRecommended")} value=${tc("ofN", f.recommended, f.analysed)} />
178
+ ${f.switchTo1h ? html`<${Metric} label=${tc("fleetSwitch")} value=${tc("ofN", f.switchTo1h, f.analysed)} />` : null}
157
179
  </section>` : html`<${Empty} icon="chart" title=${tc("empty")} hint=${tc("emptyHint")} />`}
158
180
  <div class="ch-notes note">
159
181
  <p>${tc("pricesNote", report.pricesChecked)} ${tc("quotaNote")}</p>
@@ -0,0 +1,111 @@
1
+ // #1918: the org canvas's layout (fleet-canvas.js draws it; fleet-traffic.js routes lines through its empty lanes).
2
+ // Fixed slots, no layout engine: the same structure always gives the same picture.
3
+
4
+ /** Sizes in canvas units. NW×NH a member card, LW×LH a General card; FOLDED a folded (or empty) team's height. */
5
+ export const OC = { NW: 184, NH: 56, GAP: 18, PAD: 24, HEAD: 34, TEAM_GAP: 56, ROW_GAP: 96, ROW_MAX: 1400, ROW_MAX_NARROW: 460, LW: 232, LH: 68, TOP_Y: 20, BUS: 44, FOLDED: 64, TRUNK: -30 };
6
+ const colsFor = (n, narrow) => (n <= 1 ? 1 : n <= 4 || narrow ? 2 : 3);
7
+ /** The key a team folds by (per device): its name; the instances in no team have one of their own no team name can be. */
8
+ export const REST_KEY = "\u0000rest";
9
+
10
+ /**
11
+ * Where everything goes, from buildOrgView's answer and the folded teams' keys: `tops` (General's cards), `boxes`
12
+ * (each team: its slot, its header, its member cells unless folded), the tree lines (`tree`: SVG path data) and the
13
+ * size of the whole. `narrow`: a phone's packing.
14
+ */
15
+ export function canvasLayout(view, folded = new Set(), { narrow = false, label = (s, n) => `${n} ${s}` } = {}) {
16
+ const { NW, NH, GAP, PAD, HEAD } = OC;
17
+ const groups = [...view.teams.map((tm) => ({ key: tm.name, name: tm.name, loose: false, nodes: tm.nodes, counts: tm.counts })),
18
+ ...(view.rest.nodes.length ? [{ key: REST_KEY, name: null, loose: true, nodes: view.rest.nodes, counts: view.rest.counts }] : [])];
19
+ const rows = [];
20
+ let row = [], x = 0;
21
+ for (const g of groups) {
22
+ const n = g.nodes.length, cols = colsFor(n, narrow), lines = Math.ceil(n / cols);
23
+ const w = PAD * 2 + cols * NW + (cols - 1) * GAP;
24
+ const fullH = n ? HEAD + PAD + lines * NH + (lines - 1) * GAP + PAD : OC.FOLDED;
25
+ if (row.length && x + w > (narrow ? OC.ROW_MAX_NARROW : OC.ROW_MAX)) { rows.push(row); row = []; x = 0; }
26
+ row.push({ ...g, cols, w, fullH, x });
27
+ x += w + OC.TEAM_GAP;
28
+ }
29
+ if (row.length) rows.push(row);
30
+ const boxes = [];
31
+ let y = view.top.length ? OC.TOP_Y + OC.LH + 100 : OC.BUS + 16;
32
+ for (const r of rows) {
33
+ const slotH = Math.max(...r.map((b) => b.fullH));
34
+ for (const b of r) {
35
+ const isFolded = b.nodes.length > 0 && folded.has(b.key);
36
+ // Folded: its members' states in rows that fit the box (never wider; never taller than its open slot).
37
+ const summary = isFolded ? summaryRows(b.counts, b.w, label) : [];
38
+ const box = { key: b.key, name: b.name, loose: b.loose, nodes: b.nodes, counts: b.counts, cols: b.cols, x: b.x, y, w: b.w,
39
+ h: isFolded ? Math.min(b.fullH, Math.max(OC.FOLDED, HEAD + 10 + summary.length * SUM_LINE)) : b.fullH, slotH, folded: isFolded, summary,
40
+ busY: y - OC.BUS, cells: [] };
41
+ if (!isFolded) b.nodes.forEach((node, i) => box.cells.push({ node, key: `${b.key}/${node.name}`,
42
+ x: b.x + PAD + (i % b.cols) * (NW + GAP), y: y + HEAD + PAD + Math.floor(i / b.cols) * (NH + GAP), w: NW, h: NH }));
43
+ boxes.push(box);
44
+ }
45
+ y += slotH + OC.ROW_GAP;
46
+ }
47
+ const rowsW = boxes.length ? Math.max(...boxes.map((b) => b.x + b.w)) : 0;
48
+ const topW = view.top.length * OC.LW + Math.max(0, view.top.length - 1) * GAP;
49
+ const width = Math.max(rowsW, topW);
50
+ const tops = view.top.map((node, i) => ({ node, key: `\u0000top/${node.name}`, x: width / 2 - topW / 2 + i * (OC.LW + GAP), y: OC.TOP_Y, w: OC.LW, h: OC.LH }));
51
+ const height = boxes.length ? y - OC.ROW_GAP : tops.length ? OC.TOP_Y + OC.LH : 0;
52
+ return { tops, boxes, width, height, tree: treeLines(tops, boxes) };
53
+ }
54
+
55
+ /** A folded team's summary line height, and a label's width estimate (canvas units: wide for CJK, narrow otherwise). */
56
+ export const SUM_LINE = 20;
57
+ const textWidth = (s) => [...s].reduce((w, ch) => w + (ch.codePointAt(0) >= 0x2e80 ? 12 : 6.6), 0);
58
+ /**
59
+ * A folded team's states as rows of { s, n, label, dx } that fit a box `w` wide (dx from the box's left), most pressing
60
+ * first; an item never splits, and one wider than a row has the row to itself.
61
+ */
62
+ export function summaryRows(counts, w, label) {
63
+ const rows = [];
64
+ let row = [], x = 14;
65
+ for (const [s, n] of counts) {
66
+ const text = label(s, n), iw = 24 + textWidth(text);
67
+ if (row.length && x + iw > w - 14) { rows.push(row); row = []; x = 14; }
68
+ row.push({ s, n, label: text, dx: x });
69
+ x += iw;
70
+ }
71
+ if (row.length) rows.push(row);
72
+ return rows;
73
+ }
74
+
75
+ /** General's stem, a bus over each row of teams, the trunk joining the rows at the left, one drop per team. */
76
+ function treeLines(tops, boxes) {
77
+ const d = [];
78
+ const teamed = boxes.filter((b) => !b.loose);
79
+ if (!teamed.length) return d;
80
+ const buses = [...new Set(teamed.map((b) => b.busY))].sort((a, b) => a - b);
81
+ const first = buses[0], last = buses[buses.length - 1];
82
+ let stemX = null;
83
+ if (tops.length) {
84
+ const cx = tops.map((tp) => tp.x + tp.w / 2), bottom = tops[0].y + tops[0].h;
85
+ stemX = (Math.min(...cx) + Math.max(...cx)) / 2;
86
+ if (tops.length > 1) {
87
+ const joinY = bottom + 16;
88
+ for (const x of cx) d.push(`M${x} ${bottom} V${joinY}`);
89
+ d.push(`M${Math.min(...cx)} ${joinY} H${Math.max(...cx)}`, `M${stemX} ${joinY} V${first}`);
90
+ } else d.push(`M${stemX} ${bottom} V${first}`);
91
+ }
92
+ if (last > first) d.push(`M${OC.TRUNK} ${first} V${last}`);
93
+ for (const busY of buses) {
94
+ const xs = teamed.filter((b) => b.busY === busY).map((b) => b.x + b.w / 2);
95
+ const ends = busY === first ? [...xs, ...(stemX === null ? [] : [stemX]), ...(last > first ? [OC.TRUNK] : [])] : [...xs, OC.TRUNK];
96
+ d.push(`M${Math.min(...ends)} ${busY} H${Math.max(...ends)}`);
97
+ }
98
+ for (const b of teamed) d.push(`M${b.x + b.w / 2} ${b.busY} V${b.y}`);
99
+ return d;
100
+ }
101
+
102
+ /** The region the canvas opens on: General and the first row of teams (the whole canvas when there is neither). */
103
+ export function openingRegion(L) {
104
+ const firstBus = L.boxes.length ? Math.min(...L.boxes.map((b) => b.busY)) : null;
105
+ // The trunk at the left joins the rows: in view too, not cut by the edge.
106
+ const trunk = L.boxes.some((b) => b.busY !== firstBus) ? [{ x: OC.TRUNK - 8, y: firstBus, w: 1, h: 1 }] : [];
107
+ const parts = [...L.tops, ...L.boxes.filter((b) => b.busY === firstBus).map((b) => ({ x: b.x, y: b.y, w: b.w, h: b.slotH })), ...trunk];
108
+ if (!parts.length) return { x: 0, y: 0, w: Math.max(1, L.width), h: Math.max(1, L.height) };
109
+ const x0 = Math.min(...parts.map((p) => p.x)), y0 = Math.min(...parts.map((p) => p.y));
110
+ return { x: x0, y: y0, w: Math.max(...parts.map((p) => p.x + p.w)) - x0, h: Math.max(...parts.map((p) => p.y + p.h)) - y0 };
111
+ }