@songsid/agend 2.3.0-beta.1 → 2.3.0-beta.10

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 (351) 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 +476 -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 +5 -2
  157. package/dist/ui/chat-strings.js +59 -6
  158. package/dist/ui/chat-thread.js +149 -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 +127 -49
  168. package/dist/ui/panel-details.js +2 -1
  169. package/dist/ui/panel-fleet.js +41 -16
  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 +1 -1
  187. package/dist/ui/shared/app-session.js +2 -0
  188. package/dist/ui/shared/app-shell.js +75 -9
  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 +364 -16
  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 +46 -0
  262. package/dist/web-chat-history.js +80 -3
  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-login.d.ts +47 -0
  305. package/dist/web-login.js +115 -0
  306. package/dist/web-login.js.map +1 -1
  307. package/dist/web-mod-agent.d.ts +1 -1
  308. package/dist/web-mod-agent.js +12 -8
  309. package/dist/web-mod-agent.js.map +1 -1
  310. package/dist/web-mod-bundle.d.ts +14 -1
  311. package/dist/web-mod-bundle.js +68 -3
  312. package/dist/web-mod-bundle.js.map +1 -1
  313. package/dist/web-mod-export.d.ts +9 -0
  314. package/dist/web-mod-export.js +21 -0
  315. package/dist/web-mod-export.js.map +1 -1
  316. package/dist/web-mod-store.d.ts +7 -1
  317. package/dist/web-mod-store.js +21 -7
  318. package/dist/web-mod-store.js.map +1 -1
  319. package/dist/web-prefs.d.ts +14 -0
  320. package/dist/web-prefs.js +24 -4
  321. package/dist/web-prefs.js.map +1 -1
  322. package/dist/web-preview.d.ts +6 -2
  323. package/dist/web-preview.js +4 -2
  324. package/dist/web-preview.js.map +1 -1
  325. package/dist/web-returning-device.d.ts +41 -0
  326. package/dist/web-returning-device.js +57 -5
  327. package/dist/web-returning-device.js.map +1 -1
  328. package/dist/web-session-authorization.d.ts +132 -0
  329. package/dist/web-session-authorization.js +193 -0
  330. package/dist/web-session-authorization.js.map +1 -0
  331. package/dist/web-session.d.ts +70 -10
  332. package/dist/web-session.js +137 -22
  333. package/dist/web-session.js.map +1 -1
  334. package/dist/web-settings-patch.d.ts +49 -2
  335. package/dist/web-settings-patch.js +145 -1
  336. package/dist/web-settings-patch.js.map +1 -1
  337. package/dist/web-settings-tools.d.ts +89 -0
  338. package/dist/web-settings-tools.js +270 -0
  339. package/dist/web-settings-tools.js.map +1 -0
  340. package/dist/web-shell-routes.d.ts +1 -1
  341. package/dist/web-shell-routes.js +1 -1
  342. package/dist/web-shell-routes.js.map +1 -1
  343. package/dist/web-theme-store.d.ts +39 -0
  344. package/dist/web-theme-store.js +122 -11
  345. package/dist/web-theme-store.js.map +1 -1
  346. package/dist/web-theme.d.ts +2 -0
  347. package/dist/web-theme.js +10 -1
  348. package/dist/web-theme.js.map +1 -1
  349. package/dist/workflow-templates/default.md +1 -1
  350. package/package.json +1 -1
  351. 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,32 @@ 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() { scroller.scrollTop = scroller.scrollHeight; pinned = true; mark(); }
466
530
  // Sizes change without a scroll event (layout after the render, the dock under the thread): while pinned, the view
467
531
  // stays flush with the bottom; a reader who scrolled up is never moved.
468
532
  const resized = () => { if (pinned && !atBottom()) toBottom(); reportJump(); };
469
- const ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
533
+ let ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
470
534
  if (ro) { ro.observe(list); ro.observe(scroller); }
535
+ // #2158: the reader's own hand on the thread — a wheel, a touch, the scrollbar (a pointer held), a scrolling key
536
+ // outside a text field — and the scroll that follows it until it has been quiet READER_QUIET_MS.
537
+ let inputAt = -Infinity, held = false;
538
+ const touched = () => { inputAt = performance.now(); };
539
+ const hold = (on) => () => { held = on; touched(); };
540
+ const onKey = (ev) => {
541
+ const el = ev.target;
542
+ if (SCROLL_KEYS.has(ev.key) && !(el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || "")))) touched();
543
+ };
544
+ const onScroller = [["wheel", touched], ["touchstart", hold(true)], ["touchend", hold(false)], ["touchcancel", hold(false)], ["pointerdown", hold(true)]];
545
+ const onDocument = [["pointerup", hold(false)], ["pointercancel", hold(false)], ["keydown", onKey]];
546
+ /** Listen for the reader's input on `sc` (and its document: a pointer let go anywhere, a key), or stop. */
547
+ function watchReader(sc, on) {
548
+ const doc = sc.ownerDocument, how = on ? "addEventListener" : "removeEventListener";
549
+ for (const [type, fn] of onScroller) sc[how](type, fn, { passive: true });
550
+ if (doc) for (const [type, fn] of onDocument) doc[how](type, fn, true);
551
+ if (!on) held = false;
552
+ }
553
+ const readerActive = () => held || performance.now() - inputAt < READER_QUIET_MS;
554
+ watchReader(scroller, true);
471
555
 
472
556
  /** Where drawing starts in `msgs`: at the oldest drawn message, or (none yet) the newest THREAD_TAIL. */
473
557
  function startIndex() {
@@ -481,14 +565,14 @@ export function createThread(list, scroller, opts) {
481
565
  /** Draw the next chunk of older messages, keeping the reader where they were; then the next, until all are drawn. */
482
566
  function grow() {
483
567
  growTimer = growFrame = null;
484
- if (disposed) return; // the chat left: nothing more is drawn
568
+ if (disposed || parked) return; // the chat left: nothing more is drawn (a kept one fills in when shown again)
485
569
  const at = startIndex();
486
570
  if (at <= 0) return;
487
571
  firstKey = msgKey(msgs[Math.max(0, at - THREAD_CHUNK)]);
488
572
  const fromBottom = scroller.scrollHeight - scroller.scrollTop;
489
573
  draw({ older: true }); // drawn above the reader: never "new below" (render alone counts those)
490
574
  if (pinned) toBottom();
491
- else { scroller.scrollTop = scroller.scrollHeight - fromBottom; lastTop = scroller.scrollTop; }
575
+ else { scroller.scrollTop = scroller.scrollHeight - fromBottom; mark(); }
492
576
  scheduleGrow();
493
577
  }
494
578
  /** After the next frame has painted (the click's own frame stays light), draw a chunk more. */
@@ -529,7 +613,7 @@ export function createThread(list, scroller, opts) {
529
613
  if ("restore" in renderOpts) {
530
614
  unseen = 0;
531
615
  if (renderOpts.restore == null) toBottom();
532
- else { scroller.scrollTop = renderOpts.restore; pinned = nearBottom(); lastTop = scroller.scrollTop; }
616
+ else { scroller.scrollTop = renderOpts.restore; pinned = nearBottom(); mark(); }
533
617
  } else if (stick) toBottom();
534
618
  else unseen += added;
535
619
  reportJump();
@@ -563,10 +647,23 @@ export function createThread(list, scroller, opts) {
563
647
  }
564
648
 
565
649
  function onScroll() {
650
+ // #2158: the thread got shorter under a pinned view (text settling on a cold load, a card) and the browser clamped
651
+ // scrollTop to its new end — for the view as tall as it was then; the view got shorter too before WebKit sent that
652
+ // clamp's scroll event (it comes before the ResizeObserver). A view resting exactly at the bottom of the view as it
653
+ // was at the last mark, with no hand of the reader's on the thread, was put there by the browser: still pinned, back
654
+ // at the bottom. Geometry alone can't tell (a reader's move up by the dock's growth lands there too): any input of
655
+ // theirs, and the scroll after it, is theirs and unpins as before. The page's own scrolls (reveal) have no input but
656
+ // don't land there.
657
+ const reader = readerActive();
658
+ if (reader) touched(); // the reader's scroll goes on (smooth, momentum): still theirs while it does
659
+ else if (pinned && !atBottom() && Math.abs(scroller.scrollHeight - lastView - scroller.scrollTop) <= 1) toBottom();
566
660
  const bottom = nearBottom(), top = scroller.scrollTop;
567
- if (bottom) pinned = true;
661
+ // #2087: pinned only at the exact bottom — a reader still wheeling through the last 48 px was pinned there, so
662
+ // the next resize (a picture, a card, the dock) pulled the view the rest of the way down under them. Near the bottom
663
+ // still counts for a new message (render follows it) and for the pill; nothing else moves a view the reader moves.
664
+ if (atBottom()) pinned = true;
568
665
  else if (top < lastTop) pinned = false;
569
- lastTop = top;
666
+ mark();
570
667
  if (bottom) unseen = 0;
571
668
  reportJump();
572
669
  return bottom ? null : scroller.scrollTop;
@@ -628,21 +725,52 @@ export function createThread(list, scroller, opts) {
628
725
  // Only the current host-produced keyed card and a real local click can request admission. No token is read from DOM.
629
726
  const key = el.dataset.arg, row = msgs.find(x => msgKey(x) === key), owner = nodes.get(key), node = owner?.node, offer = effectTrialMessage(row);
630
727
  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);
728
+ || !offer || trialNow() >= offer.expiresAt) return;
729
+ if (trialStatus(row) === "ready" && opts.canTryEffect?.(row) === true) opts.startEffectTrial?.(row);
730
+ else opts.fixEffectTrial?.(row, trialStatus(row));
633
731
  } else if (act === "showButtons") { const x = msgs.find(y => y.buttons && y.buttons.id === el.dataset.arg); if (x) reveal(msgKey(x)); }
634
732
  }
635
733
 
636
734
  /** The thread goes (the chat left, or another instance opened): every preview in it stops first. */
637
- function dispose() {
735
+ /**
736
+ * #2053: kept for a later visit — the chat left as it always did (every preview stops: "leave"), but its drawn nodes
737
+ * stay. Nothing of it runs meanwhile: no timer (day labels, polls, effect trials, filling in), no size observer; the
738
+ * caller drops its subscriptions. Shown again: attach, then render — only what changed is drawn.
739
+ */
740
+ function park() {
741
+ parked = true;
742
+ watchReader(scroller, false);
743
+ if (P()) P().stopAll("leave");
744
+ for (const node of list.querySelectorAll(".html-card[data-att], .text-card[data-att]")) { const f = cardDrop.get(node); if (f) f(); }
745
+ if (ro) { ro.disconnect(); ro = null; }
746
+ if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
747
+ if (growFrame !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(growFrame);
748
+ if (growTimer !== null) clearTimeout(growTimer);
749
+ growTimer = growFrame = null;
750
+ if (offDay) { offDay(); offDay = null; }
751
+ if (pollTick) { clearInterval(pollTick); pollTick = null; }
752
+ }
753
+ /** In view in `sc` (the view's scroller): sizes watched there from now on; the caller restores the position. */
754
+ function attach(sc) {
755
+ if (!parked) watchReader(scroller, false); // moved from the scroller it was made with
756
+ parked = false;
757
+ scroller = sc;
758
+ watchReader(sc, true);
759
+ if (ro) ro.disconnect();
760
+ ro = typeof ResizeObserver === "function" ? new ResizeObserver(resized) : null;
761
+ if (ro) { ro.observe(list); ro.observe(sc); }
762
+ }
763
+ /** `own`: only this thread's previews stop (a thread let go while another chat is in view); else every one, as leaving. */
764
+ function dispose(own = false) {
638
765
  disposed = true;
766
+ if (!parked) watchReader(scroller, false);
639
767
  if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
640
768
  if (ro) ro.disconnect();
641
- if (P()) P().stopAll("leave");
769
+ if (P()) { if (own) P().stopIn(list); else P().stopAll("leave"); }
642
770
  nodes.clear();
643
771
  armDay();
644
772
  armPolls();
645
773
  }
646
774
 
647
- return { render, onScroll, onClick, jumpLatest, reveal, refreshCards, dispose, nodes, get unseen() { return unseen; } };
775
+ return { render, onScroll, onClick, jumpLatest, reveal, refreshCards, park, attach, dispose, nodes, get unseen() { return unseen; } };
648
776
  }
@@ -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
+ }