acrylblends 1.1.0 → 1.2.0

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 (241) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +45 -1
  3. package/dist/index.js +1980 -27
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/getting-started/desktop.md +23 -4
  7. package/docs/framework/guides/create-acrylblends.md +1 -1
  8. package/docs/framework/workshop/README.md +138 -152
  9. package/docs/framework/workshop/facilitator.md +69 -101
  10. package/legacy/README.md +9 -0
  11. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  12. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  13. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  14. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  15. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  16. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  17. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  18. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  19. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  20. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  21. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  22. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  23. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  24. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  25. package/legacy/agent/855359304707/src/app.ts +168 -0
  26. package/legacy/agent/855359304707/src/routes.ts +126 -0
  27. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  28. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  29. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  30. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  31. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  32. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  33. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  34. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  35. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  36. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  37. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  38. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  39. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  40. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  41. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  42. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  43. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  44. package/legacy/layouts.json +524 -0
  45. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  46. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  47. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  48. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  49. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  50. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  51. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  52. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  53. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  54. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  55. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  56. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  57. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  58. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  59. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  60. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  61. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  62. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  63. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  64. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  65. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  66. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  67. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  68. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  69. package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
  70. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  71. package/lib/cordis/door.d.ts +42 -7
  72. package/lib/cordis/door.js +204 -60
  73. package/lib/cordis/inspect.js +1 -1
  74. package/lib/cordis/node-server.d.ts +101 -3
  75. package/lib/cordis/node-server.js +3 -3
  76. package/lib/cordis/router-http.d.ts +7 -1
  77. package/lib/cordis/router-http.js +44 -3
  78. package/lib/cordis/router.d.ts +1 -1
  79. package/lib/cordis/router.js +1 -1
  80. package/lib/cordis/state.js +1 -1
  81. package/lib/door/host.d.ts +2 -2
  82. package/lib/door/host.js +1 -1
  83. package/lib/door/page.d.ts +10 -1
  84. package/lib/door/page.js +46 -7
  85. package/lib/door/protocol.d.ts +1 -1
  86. package/lib/drivers.d.ts +10 -1
  87. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  88. package/lib/node-server.d.ts +7 -1
  89. package/lib/node-server.js +2 -2
  90. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  91. package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
  92. package/lib/src-CtkIX_Gg.js +427 -0
  93. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  94. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  95. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  96. package/package.json +2 -1
  97. package/plugins/acryl-door/src/host/channel.ts +86 -9
  98. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  99. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  100. package/plugins/acryl-door/src/host/door.ts +53 -23
  101. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  102. package/plugins/acryl-door/src/host/page-socket.ts +18 -8
  103. package/plugins/acryl-door/src/host/verbs.ts +3 -0
  104. package/plugins/acryl-door/src/index.ts +166 -29
  105. package/plugins/acryl-door/src/page/base.ts +32 -0
  106. package/plugins/acryl-door/src/page/channel.ts +3 -1
  107. package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
  108. package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
  109. package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
  110. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  111. package/plugins/acryl-door/src/page/index.ts +41 -2
  112. package/plugins/acryl-door/src/page/outline.ts +56 -0
  113. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  114. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  115. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  116. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  117. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  118. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  119. package/plugins/acryl-node-server/src/index.ts +304 -16
  120. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  121. package/plugins/acryl-page/plugin.json +12 -0
  122. package/plugins/acryl-page/src/index.ts +163 -0
  123. package/plugins/acryl-router/src/service.ts +77 -14
  124. package/plugins/acryl-router-http/src/index.ts +15 -1
  125. package/plugins/acryl-router-http/src/serve.ts +31 -0
  126. package/plugins/acryl-web-server/plugin.json +14 -0
  127. package/plugins/acryl-web-server/src/index.ts +164 -0
  128. package/plugins/agent/agent.ts +38 -7
  129. package/plugins/agent/index.ts +1 -1
  130. package/plugins/agent/model.ts +10 -2
  131. package/plugins/agent/tools.ts +9 -3
  132. package/plugins/agent-panel/client/describe.ts +57 -0
  133. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  134. package/plugins/agent-panel/client/frame.ts +51 -0
  135. package/plugins/agent-panel/client/main.ts +238 -26
  136. package/plugins/agent-panel/drivers.ts +7 -2
  137. package/plugins/agent-panel/headless.ts +5 -2
  138. package/plugins/agent-panel/index.ts +146 -57
  139. package/plugins/agent-panel/mount.ts +27 -0
  140. package/plugins/agent-panel/visitors.ts +231 -0
  141. package/plugins/desktop/frame.ts +231 -0
  142. package/plugins/desktop/launch.ts +44 -9
  143. package/plugins/desktop/main.ts +100 -32
  144. package/plugins/desktop/window.ts +21 -4
  145. package/plugins/driver-pty/client/terminal.ts +4 -1
  146. package/plugins/driver-pty/index.ts +38 -11
  147. package/plugins/driver-pty/spawn.ts +38 -0
  148. package/plugins/driver-pty/terminal.ts +2 -0
  149. package/plugins/extensions/index.ts +26 -1
  150. package/plugins/extensions/resolve.ts +13 -0
  151. package/plugins/feedback/client.d.ts +22 -0
  152. package/plugins/feedback/client.js +471 -0
  153. package/plugins/feedback/index.ts +143 -0
  154. package/plugins/feedback/plugin.json +11 -0
  155. package/plugins/feedback/store.ts +307 -0
  156. package/plugins/home/index.ts +122 -0
  157. package/plugins/home/plugin.json +11 -0
  158. package/plugins/instance/index.ts +6 -0
  159. package/plugins/manifest/app-plugins.ts +86 -0
  160. package/plugins/manifest/index.ts +12 -2
  161. package/plugins/manifest/snapshot.ts +275 -0
  162. package/plugins/openai/index.ts +64 -5
  163. package/plugins/openai/prompt.ts +23 -0
  164. package/shared/AGENTS.core.md +36 -16
  165. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  166. package/shared/skills/acryl-app/SKILL.md +13 -0
  167. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  168. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  169. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  170. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  171. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  172. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  173. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  174. package/templates/agent/AGENTS.md +1 -1
  175. package/templates/agent/blend.yaml +4 -0
  176. package/templates/agent/gitignore +1 -0
  177. package/templates/agent/package.json +1 -0
  178. package/templates/agent/src/app.ts +150 -33
  179. package/templates/agent/src/frame.ts +17 -0
  180. package/templates/agent/src/main.ts +5 -3
  181. package/templates/agent/src/routes.ts +38 -19
  182. package/templates/agent/template.json +2 -0
  183. package/templates/agent/test/app.test.ts +45 -3
  184. package/templates/blank/AGENTS.md +3 -2
  185. package/templates/blank/blend.yaml +11 -0
  186. package/templates/blank/gitignore +1 -0
  187. package/templates/blank/package.json +1 -0
  188. package/templates/blank/src/app.ts +100 -29
  189. package/templates/blank/src/door.ts +7 -0
  190. package/templates/blank/src/first-page.ts +5 -0
  191. package/templates/blank/src/frame.ts +17 -0
  192. package/templates/blank/src/routes.ts +65 -8
  193. package/templates/blank/template.json +14 -1
  194. package/templates/blank/test/app.test.ts +103 -16
  195. package/templates/chat/blend.yaml +2 -1
  196. package/templates/chat/gitignore +1 -0
  197. package/templates/chat/package.json +1 -0
  198. package/templates/chat/src/app.ts +72 -22
  199. package/templates/chat/src/chat-app.ts +11 -2
  200. package/templates/chat/src/client/chat.tsx +4 -2
  201. package/templates/chat/src/frame.ts +17 -0
  202. package/templates/chat/src/page.tsx +6 -4
  203. package/templates/chat/src/routes.ts +14 -4
  204. package/templates/chat/src/runtime.ts +9 -3
  205. package/templates/chat/template.json +3 -1
  206. package/templates/chat/test/chat.test.ts +46 -1
  207. package/templates/chat/test/server.test.ts +17 -3
  208. package/templates/nodejs/blend.yaml +2 -1
  209. package/templates/nodejs/gitignore +1 -0
  210. package/templates/nodejs/package.json +1 -0
  211. package/templates/nodejs/src/app.ts +76 -21
  212. package/templates/nodejs/src/frame.ts +17 -0
  213. package/templates/nodejs/src/page.tsx +3 -3
  214. package/templates/nodejs/src/routes.ts +32 -13
  215. package/templates/nodejs/template.json +3 -1
  216. package/templates/nodejs/test/app.test.ts +38 -3
  217. package/templates/nodejs/test/server.test.ts +17 -3
  218. package/templates/notes/blend.yaml +2 -1
  219. package/templates/notes/gitignore +1 -0
  220. package/templates/notes/package.json +1 -0
  221. package/templates/notes/src/app.ts +72 -22
  222. package/templates/notes/src/frame.ts +17 -0
  223. package/templates/notes/src/notes-app.ts +24 -17
  224. package/templates/notes/src/routes.ts +11 -4
  225. package/templates/notes/src/runtime.ts +9 -3
  226. package/templates/notes/src/views.tsx +26 -17
  227. package/templates/notes/template.json +3 -1
  228. package/templates/notes/test/notes.test.ts +39 -1
  229. package/templates/notes/test/server.test.ts +17 -3
  230. package/templates/todo/blend.yaml +3 -0
  231. package/templates/todo/gitignore +1 -0
  232. package/templates/todo/package.json +1 -0
  233. package/templates/todo/src/app.ts +81 -27
  234. package/templates/todo/src/door.ts +7 -0
  235. package/templates/todo/src/frame.ts +17 -0
  236. package/templates/todo/src/page.ts +13 -6
  237. package/templates/todo/src/todo.ts +18 -6
  238. package/templates/todo/template.json +11 -1
  239. package/templates/todo/test/todo.test.ts +67 -9
  240. package/lib/src-LX0E4ec4.js +0 -117
  241. package/shared/SKILL.md +0 -14
@@ -7,9 +7,15 @@
7
7
  * - `GET /api/agent-panel/session` gives the page the drivers it may use and a token; `POST /api/agent-panel/ask` runs one message and answers server-sent events:
8
8
  * `text`, `tool`, `change`, `done`, `error`.
9
9
  * - Only the app's own page may ask (a same-origin request carrying the token), one run at a time, and every ask is written to `.acryl/audit/agent-panel.jsonl`.
10
- * - Development only: the plugin does nothing with `NODE_ENV=production` or `ACRYL_DOOR=off`.
10
+ * - Who asks (`visitors.ts`): in development one person, the owner, whose pages share one token and one run. As the Assistant each visitor has their own token, run and
11
+ * replay, named by an HttpOnly cookie: no visitor sees, follows or stops another's run, and the asks are bounded (per visitor per hour, per day, runs at once).
12
+ * - The routes live under the app's mount: the root of the app's own server, or the frame's prefix (`/app`) when the frame hosts it, where the host's own
13
+ * `/api` routes would otherwise answer first. The row's `mount` config names it.
14
+ * - Development only: the plugin does nothing with `NODE_ENV=production` or `ACRYL_DOOR=off`, except as the Assistant (`assistant: true`): then it runs in production too,
15
+ * docked open on the right, with the app's own agent only. The app gives that agent the page tools alone and a page-only door, so it uses the app for a visitor and
16
+ * cannot change it (src/app.ts, `ACRYL_ASSISTANT=on`). In the ACRYL frame's canvas the panel follows the frame's view: Build hides it, and Preview shows it only as
17
+ * the Assistant and only when the app's production environment names one (`assistantInProduction` in the session).
11
18
  */
12
- import { randomBytes, timingSafeEqual } from 'node:crypto'
13
19
  import { appendFileSync, mkdirSync } from 'node:fs'
14
20
  import { dirname, join } from 'node:path'
15
21
  import type { Context } from '@deepseek-ai/cordis'
@@ -19,7 +25,7 @@ import { Acryl, type MiddlewareHandler } from '../acryl-router/src/index.ts'
19
25
  import { loopbackAuthority } from '../acryl-router/src/middleware/index.ts'
20
26
  import type {} from '../agent/index.ts'
21
27
  import type {} from '../extensions/index.ts'
22
- import type { ChatDriver } from './drivers.ts'
28
+ import type { ChatDriver, Driver } from './drivers.ts'
23
29
  import {
24
30
  agentDriver,
25
31
  availableHeadless,
@@ -27,7 +33,9 @@ import {
27
33
  headlessDriver,
28
34
  whyNotChat,
29
35
  } from './headless.ts'
36
+ import { mountedBase, mountedPrefix } from './mount.ts'
30
37
  import { provideDrivers } from './registry.ts'
38
+ import { type AssistantLimits, type Visitor, Visitors } from './visitors.ts'
31
39
 
32
40
  export type {
33
41
  ChatDriver,
@@ -41,7 +49,11 @@ export type {
41
49
  export const name = 'agent-panel'
42
50
  export const inject = ['router', 'agent', 'extensions', 'appInstance']
43
51
 
44
- const PREFIX = '/api/agent-panel'
52
+ /** The panel's own routes, fixed relative to the app's mount. */
53
+ const PANEL_PATH = '/api/agent-panel'
54
+
55
+ /** The one driver the Assistant offers its visitors: the app's own agent, held to the page tools. */
56
+ const ASSISTANT_DRIVER = 'agent'
45
57
 
46
58
  /** The slice of the door the panel uses; the door is optional (it is a development plugin, and a different row). */
47
59
  interface DoorPort {
@@ -52,8 +64,25 @@ const CLIENT_DIR = join(import.meta.dirname, 'client')
52
64
  export interface PanelConfig {
53
65
  /** Headless coding agents to offer besides the app's own agent; default: the ones found on the machine. */
54
66
  drivers?: readonly HeadlessName[]
67
+ /**
68
+ * The URL prefix the app is mounted at: empty on the app's own server, `/app` when the ACRYL frame hosts it (the same prefix `makeApp` and
69
+ * `routerHttp.mount` take). The panel's routes, script tag and client live under it, so they belong to the app and are served wherever it is.
70
+ */
71
+ mount?: string
72
+ /** The Assistant: a production panel for the app's visitors, with the app's own agent only (no coding agent, no terminal). Runs with `NODE_ENV=production`. */
73
+ assistant?: boolean
74
+ /** The Assistant's limits on what visitors may ask (`visitors.ts` has the defaults); the development panel has none but one run at a time. */
75
+ limits?: AssistantLimits
55
76
  }
56
77
 
78
+ /** Whether the page came over https (directly, or through a proxy that says so): the visitor cookie is then Secure. */
79
+ const overHttps = (url: string, headers: Headers): boolean =>
80
+ url.startsWith('https:') || headers.get('x-forwarded-proto')?.split(',')[0]?.trim() === 'https'
81
+
82
+ /** The panel's routes under a checked mount: empty, or an absolute path such as `/app` with no trailing slash (the `routerHttp.mount` rule). */
83
+ const panelPrefix = (mount: string | undefined): string =>
84
+ mountedPrefix(mount, PANEL_PATH, 'agent-panel')
85
+
57
86
  /** Whether a request comes from the app's own page: a browser says so itself (`Sec-Fetch-Site: same-origin`), or the Origin is the Host. */
58
87
  export function fromOwnPage(headers: Headers): boolean {
59
88
  const site = headers.get('sec-fetch-site')
@@ -64,9 +93,24 @@ export function fromOwnPage(headers: Headers): boolean {
64
93
  }
65
94
 
66
95
  export function apply(ctx: Context, config: PanelConfig = {}): void {
67
- if (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off') return
96
+ const assistant = config.assistant === true
97
+ if (process.env.ACRYL_DOOR === 'off' || (process.env.NODE_ENV === 'production' && !assistant))
98
+ return
99
+ const PREFIX = panelPrefix(config.mount)
100
+ // The panel's routes mount at the app's own mount and keep the fixed path inside (`/app` + `/api/agent-panel/...`); the frame's router prefixes the
101
+ // mount path itself, so a prefix inside the routes too would become `/app/app/...`. PREFIX stays absolute for the page's script tag and client URLs.
102
+ const MOUNT = mountedBase(config.mount, 'agent-panel')
68
103
  const root = ctx.appInstance.root
69
- const token = randomBytes(24).toString('hex')
104
+ const visitors = new Visitors<Run>({
105
+ shared: !assistant,
106
+ ...(config.limits === undefined ? {} : { limits: config.limits }),
107
+ })
108
+ /** The visitor of a request that must carry its token, or `undefined` when it does not come from the app's own page with the right one. */
109
+ const caller = (headers: Headers, presented: unknown): Visitor<Run> | undefined => {
110
+ if (!fromOwnPage(headers)) return undefined
111
+ const visitor = visitors.of(headers.get('cookie'))
112
+ return visitors.accepts(visitor, presented) ? visitor : undefined
113
+ }
70
114
  const auditFile = join(root, '.acryl', 'audit', 'agent-panel.jsonl')
71
115
  const audit = (entry: Record<string, unknown>): void => {
72
116
  try {
@@ -76,13 +120,17 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
76
120
  // The audit never stops a run.
77
121
  }
78
122
  }
123
+ // The Assistant's visitors use the app's own agent and nothing else: no plugin's driver (a terminal, a coding agent) is open to them, whatever plugins the app mounts.
79
124
  const registry = provideDrivers(
80
125
  ctx,
81
- (headers) => fromOwnPage(headers) && headers.get('x-agent-panel-token') === token,
126
+ (headers) => !assistant && caller(headers, headers.get('x-agent-panel-token')) !== undefined,
82
127
  )
128
+ const offered = (): Driver[] =>
129
+ registry.list().filter((driver) => !assistant || driver.name === ASSISTANT_DRIVER)
83
130
  ctx.effect(() => registry.register(agentDriver(ctx)))
84
- for (const program of availableHeadless(config.drivers))
85
- ctx.effect(() => registry.register(headlessDriver(program, root)))
131
+ if (!assistant)
132
+ for (const program of availableHeadless(config.drivers))
133
+ ctx.effect(() => registry.register(headlessDriver(program, root)))
86
134
 
87
135
  const tag = `<script type="module" src="${PREFIX}/panel.js"></script>`
88
136
  const addScript = async (c: { res: Response }, next: () => Promise<void>): Promise<void> => {
@@ -110,62 +158,95 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
110
158
  if (wrapped) return next()
111
159
  await addScript(c, next)
112
160
  })
113
- .get(`${PREFIX}/panel.js`, (c) =>
114
- c.body(`import '${PREFIX}/client/main.ts'\n`, 200, {
115
- 'content-type': 'text/javascript; charset=utf-8',
116
- 'cache-control': 'no-cache',
117
- }),
161
+ .get(`${PANEL_PATH}/panel.js`, (c) =>
162
+ c.body(
163
+ `import { start } from '${PREFIX}/client/main.ts'\nstart(${JSON.stringify(PREFIX)})\n`,
164
+ 200,
165
+ {
166
+ 'content-type': 'text/javascript; charset=utf-8',
167
+ 'cache-control': 'no-cache',
168
+ },
169
+ ),
118
170
  )
119
- .route(`${PREFIX}/client`, clientModules({ root: CLIENT_DIR }))
120
- .get(`${PREFIX}/session`, (c) => {
171
+ .route(`${PANEL_PATH}/client`, clientModules({ root: CLIENT_DIR, mount: `${PREFIX}/client` }))
172
+ .get(`${PANEL_PATH}/session`, (c) => {
121
173
  if (!fromOwnPage(c.req.raw.headers))
122
174
  return c.json({ error: "only the app's own page may use the panel" }, 403)
175
+ const { visitor, setCookie } = visitors.session(
176
+ c.req.header('cookie'),
177
+ overHttps(c.req.url, c.req.raw.headers),
178
+ )
179
+ if (setCookie !== undefined) c.header('set-cookie', setCookie)
123
180
  return c.json({
124
- token,
125
- drivers: registry.list().map((driver) => ({ name: driver.name, kind: driver.kind })),
181
+ token: visitor.token,
182
+ ...(assistant ? { assistant: true } : {}),
183
+ // The frame's Preview shows the app as production would. This is the fact that lets its page show the Assistant when the app's production
184
+ // environment names it (ACRYL_ASSISTANT=on), even though the frame host itself runs in development and never mounts the Assistant row.
185
+ ...(process.env.ACRYL_ASSISTANT === 'on' ? { assistantInProduction: true } : {}),
186
+ // A terminal driver names the browser module that draws its view and the prefix its endpoints live under: the plugin that owns the view
187
+ // also knows where the app is mounted, so the panel never guesses either.
188
+ drivers: offered().map((driver) => ({
189
+ name: driver.name,
190
+ kind: driver.kind,
191
+ ...(driver.kind === 'terminal' ? { view: driver.view } : {}),
192
+ })),
126
193
  })
127
194
  })
128
- .post(`${PREFIX}/ask`, async (c) => {
129
- if (!fromOwnPage(c.req.raw.headers) || c.req.header('x-agent-panel-token') !== token)
130
- return c.json({ error: "only the app's own page may ask" }, 403)
195
+ .post(`${PANEL_PATH}/ask`, async (c) => {
196
+ const visitor = caller(c.req.raw.headers, c.req.header('x-agent-panel-token'))
197
+ if (visitor === undefined) return c.json({ error: "only the app's own page may ask" }, 403)
131
198
  const body = (await c.req.json().catch(() => ({}))) as {
132
199
  message?: unknown
133
200
  driver?: unknown
134
201
  page?: unknown
135
202
  }
136
- const checked = checkAsk(body.message, body.driver)
203
+ const checked = checkAsk(visitor, body.message, body.driver)
137
204
  if ('error' in checked) return c.json({ error: checked.error }, checked.status)
138
- const run = startRun(checked.driverName, checked.message, checked.driver, pageOf(body.page))
205
+ const run = startRun(
206
+ visitor,
207
+ checked.driverName,
208
+ checked.message,
209
+ checked.driver,
210
+ pageOf(body.page),
211
+ )
139
212
  return follow(c, run, false)
140
213
  })
141
214
  // Stop: the only thing that cancels a run. A page that goes away (a reload, a move to another page, a closed tab) does not: the agent goes on, and the page picks it up again.
142
- .post(`${PREFIX}/stop`, (c) => {
143
- if (!fromOwnPage(c.req.raw.headers) || c.req.header('x-agent-panel-token') !== token)
215
+ .post(`${PANEL_PATH}/stop`, (c) => {
216
+ const visitor = caller(c.req.raw.headers, c.req.header('x-agent-panel-token'))
217
+ if (visitor === undefined)
144
218
  return c.json({ error: "only the app's own page may stop a run" }, 403)
145
- current?.cancel.abort()
219
+ // A visitor's own run, never another's.
220
+ visitor.current?.cancel.abort()
146
221
  return c.json({ ok: true })
147
222
  })
148
223
  // The latest run, for a page that just loaded: what was asked, then every event so far, then the rest as it happens (until the run ends).
149
- .get(`${PREFIX}/run`, (c) => {
150
- if (!fromOwnPage(c.req.raw.headers) || c.req.header('x-agent-panel-token') !== token)
224
+ .get(`${PANEL_PATH}/run`, (c) => {
225
+ const visitor = caller(c.req.raw.headers, c.req.header('x-agent-panel-token'))
226
+ if (visitor === undefined)
151
227
  return c.json({ error: "only the app's own page may read the run" }, 403)
152
- return current === undefined ? c.json({ run: null }) : follow(c, current, true)
228
+ return visitor.current === undefined
229
+ ? c.json({ run: null })
230
+ : follow(c, visitor.current, true)
153
231
  })
154
- /** What `/ask` and the socket's `ask` both check: a message of a sensible size, a chat driver that is here, and no run going. */
232
+ /**
233
+ * What `/ask` and the socket's `ask` both check: a message of a sensible size, a chat driver that is here, and no run of this visitor going; for the Assistant also its
234
+ * limits (an admitted ask is counted).
235
+ */
155
236
  const checkAsk = (
237
+ visitor: Visitor<Run>,
156
238
  rawMessage: unknown,
157
239
  rawDriver: unknown,
158
240
  ):
159
241
  | { message: string; driverName: string; driver: ChatDriver }
160
- | { error: string; status: 400 | 409 } => {
242
+ | { error: string; status: 400 | 409 | 429 | 503 } => {
161
243
  const message = typeof rawMessage === 'string' ? rawMessage.trim() : ''
162
244
  const driverName = typeof rawDriver === 'string' ? rawDriver : 'agent'
163
- if (message === '' || message.length > 8000)
164
- return { error: 'write a message of at most 8000 characters', status: 400 }
165
- const driver = registry.get(driverName)
245
+ const tooLong = visitors.checkMessage(message)
246
+ if (tooLong !== undefined) return tooLong
247
+ const driver = offered().find((one) => one.name === driverName)
166
248
  if (driver?.kind !== 'chat') {
167
- const chat = registry
168
- .list()
249
+ const chat = offered()
169
250
  .filter((d) => d.kind === 'chat')
170
251
  .map((d) => d.name)
171
252
  .join(', ')
@@ -175,8 +256,11 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
175
256
  status: 400,
176
257
  }
177
258
  }
178
- if (current !== undefined && !current.ended)
179
- return { error: 'a run is already going; wait for it to end', status: 409 }
259
+ const refused = visitors.admit(visitor)
260
+ if (refused !== undefined) {
261
+ if (assistant) audit({ ask: 'refused', visitor: visitor.tag, status: refused.status })
262
+ return refused
263
+ }
180
264
  return { message, driverName, driver }
181
265
  }
182
266
  /** One run of an agent: its events as they came, and who is following. It lives in the app, not in a page. */
@@ -191,12 +275,12 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
191
275
  ended: boolean
192
276
  endedAt?: number
193
277
  }
194
- let current: Run | undefined
195
278
  let runs = 0
196
279
  /** The door's id of the page that asked, when it sent one that looks like it. */
197
280
  const pageOf = (raw: unknown): string | undefined =>
198
281
  typeof raw === 'string' && /^[A-Za-z0-9-]{1,80}$/u.test(raw) ? raw : undefined
199
282
  const startRun = (
283
+ visitor: Visitor<Run>,
200
284
  driverName: string,
201
285
  message: string,
202
286
  driver: ChatDriver,
@@ -213,7 +297,8 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
213
297
  cancel: new AbortController(),
214
298
  ended: false,
215
299
  }
216
- current = run
300
+ visitor.current = run
301
+ const who = assistant ? { visitor: visitor.tag } : {}
217
302
  const emit = (event: string, data: unknown): void => {
218
303
  const item = { event, data }
219
304
  run.events.push(item)
@@ -224,14 +309,19 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
224
309
  changed = true
225
310
  emit('change', { extension, action })
226
311
  })
227
- ctx.effect(() => () => run.cancel.abort(), `agent-panel: run ${runs}`)
228
- audit({ ask: 'start', driver: driverName, characters: message.length })
312
+ // The run ends when the plugin unloads; the effect goes when the run ends, so runs do not pile up on the plugin.
313
+ const unloadStops = ctx.effect(() => () => run.cancel.abort(), `agent-panel: run ${runs}`)
314
+ audit({ ask: 'start', ...who, driver: driverName, characters: message.length })
229
315
  // The agent's door calls name no window: while this run goes, they are for the page whose panel asked, not for another tab or window that happens to be open.
230
316
  const door = page === undefined ? undefined : (ctx.get('door') as DoorPort | undefined)
231
317
  const releasePage = door?.driveFor(page as string)
232
318
  void (async () => {
233
319
  try {
234
- for await (const { event, data } of driver.start({ message, signal: run.cancel.signal }))
320
+ for await (const { event, data } of driver.start({
321
+ message,
322
+ signal: run.cancel.signal,
323
+ ...(page === undefined ? {} : { page }),
324
+ }))
235
325
  emit(event, data)
236
326
  emit('done', { changed })
237
327
  } catch (error) {
@@ -241,8 +331,9 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
241
331
  releasePage?.()
242
332
  run.ended = true
243
333
  run.endedAt = Date.now()
244
- audit({ ask: 'end', driver: driverName, changed })
334
+ audit({ ask: 'end', ...who, driver: driverName, changed })
245
335
  for (const follower of [...run.followers]) follower({ event: 'end', data: {} })
336
+ void unloadStops()
246
337
  }
247
338
  })()
248
339
  return run
@@ -340,17 +431,13 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
340
431
  return c.json({ error: "only the app's own page may use the panel" }, 403)
341
432
  await next()
342
433
  }
343
- const sameToken = (presented: unknown): boolean => {
344
- if (typeof presented !== 'string') return false
345
- const a = Buffer.from(presented)
346
- const b = Buffer.from(token)
347
- return a.length === b.length && timingSafeEqual(a, b)
348
- }
349
434
  ws.get(
350
435
  `${PREFIX}/run/ws`,
351
436
  own,
352
437
  upgrade(
353
- () => {
438
+ (c) => {
439
+ // The visitor the socket's cookie names (the owner in development): its hello must carry that visitor's token, and it reaches that visitor's runs only.
440
+ const visitor = visitors.of(c.req.header('cookie'))
354
441
  let authed = false
355
442
  let following: { detach(): void } | undefined
356
443
  let hello: NodeJS.Timeout | undefined
@@ -373,7 +460,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
373
460
  return
374
461
  }
375
462
  if (!authed) {
376
- if (message.t !== 'hello' || !sameToken(message.token)) {
463
+ if (message.t !== 'hello' || !visitors.accepts(visitor, message.token)) {
377
464
  ws.close(4401, 'token')
378
465
  return
379
466
  }
@@ -386,8 +473,9 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
386
473
  const send = (e: string, d: unknown): void => {
387
474
  ws.send(JSON.stringify({ e, d }))
388
475
  }
476
+ if (visitor === undefined) return
389
477
  if (message.t === 'stop') {
390
- current?.cancel.abort()
478
+ visitor.current?.cancel.abort()
391
479
  return
392
480
  }
393
481
  if (following !== undefined) return
@@ -397,7 +485,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
397
485
  void pumped.done.then(() => ws.close(1000, 'run ended'))
398
486
  }
399
487
  if (message.t === 'ask') {
400
- const checked = checkAsk(message.message, message.driver)
488
+ const checked = checkAsk(visitor, message.message, message.driver)
401
489
  if ('error' in checked) {
402
490
  send('error', { message: checked.error })
403
491
  ws.close(1000, 'refused')
@@ -405,6 +493,7 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
405
493
  }
406
494
  watch(
407
495
  startRun(
496
+ visitor,
408
497
  checked.driverName,
409
498
  checked.message,
410
499
  checked.driver,
@@ -413,10 +502,10 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
413
502
  false,
414
503
  )
415
504
  } else if (message.t === 'follow') {
416
- if (current === undefined) {
505
+ if (visitor.current === undefined) {
417
506
  send('idle', {})
418
507
  ws.close(1000, 'no run')
419
- } else watch(current, true)
508
+ } else watch(visitor.current, true)
420
509
  }
421
510
  },
422
511
  onClose() {
@@ -430,5 +519,5 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
430
519
  )
431
520
  inner.router.route('/', ws)
432
521
  })
433
- ctx.router.route('/', app)
522
+ ctx.router.route(MOUNT === '' ? '/' : MOUNT, app)
434
523
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A plugin's own route prefix under the app's mount. The app is served at the root of its own server `''`, or at the prefix a host serves it at (`/app` in
3
+ * the ACRYL frame); a plugin that mounts routes of its own (the panel, the PTY driver) takes that mount as config and derives its prefix from it, so its
4
+ * routes belong to the app wherever the app is served. The same rule `routerHttp.mount` applies.
5
+ */
6
+ export function mountedPrefix(mount: string | undefined, path: string, plugin: string): string {
7
+ if (mount === undefined || mount === '') return path
8
+ if (!/^\/[A-Za-z0-9._~\-/]*[A-Za-z0-9._~-]$/u.test(mount))
9
+ throw new TypeError(
10
+ `${plugin}: the app's mount "${mount}" must be empty or an absolute path such as "/app"`,
11
+ )
12
+ return `${mount}${path}`
13
+ }
14
+
15
+ /**
16
+ * The app's own mount, validated like {@link mountedPrefix}: empty at the root or the prefix a host serves the app at (`/app`). A plugin's routes are
17
+ * mounted at this path and keep only their own fixed path inside, so the frame's router (which prefixes a mount with the app's base) composes
18
+ * `/app` + `/api/agent-panel/panel.js` once, not twice.
19
+ */
20
+ export function mountedBase(mount: string | undefined, plugin: string): string {
21
+ if (mount === undefined || mount === '') return ''
22
+ if (!/^\/[A-Za-z0-9._~\-/]*[A-Za-z0-9._~-]$/u.test(mount))
23
+ throw new TypeError(
24
+ `${plugin}: the app's mount "${mount}" must be empty or an absolute path such as "/app"`,
25
+ )
26
+ return mount
27
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Who uses the panel, and what each one may do.
3
+ *
4
+ * - In development one person does, the app's owner: every page of the app shares one token and one run, so a run asked in the frame is followed from any tab of the app.
5
+ * - As the Assistant every visitor of the app is their own: a visitor is known by an HttpOnly cookie that the panel's session sets, and has their own token, their own run and
6
+ * their own limits. A visitor never sees, follows or stops another visitor's run, and never waits for one.
7
+ *
8
+ * The Assistant spends the owner's model key for anyone who opens the app, so its asks are bounded: per visitor per hour, for all visitors per day, runs at once, and the length of
9
+ * a question. The agent's own step budget bounds each run.
10
+ */
11
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
12
+
13
+ /** The cookie that names a visitor of the Assistant. HttpOnly: the page's scripts never read it. */
14
+ export const VISITOR_COOKIE = 'acryl-visitor'
15
+
16
+ /** The Assistant's limits; each one has a default. */
17
+ export interface AssistantLimits {
18
+ /** Questions one visitor may ask in an hour. */
19
+ readonly perVisitorPerHour?: number
20
+ /** Questions all visitors together may ask in a day: every one of them spends the owner's model key. */
21
+ readonly perDay?: number
22
+ /** Runs going at once, across visitors. */
23
+ readonly concurrent?: number
24
+ /** Characters in one question. */
25
+ readonly messageLength?: number
26
+ }
27
+
28
+ export const DEFAULT_LIMITS: Required<AssistantLimits> = {
29
+ perVisitorPerHour: 20,
30
+ perDay: 300,
31
+ concurrent: 4,
32
+ messageLength: 1000,
33
+ }
34
+
35
+ /** The question length the development panel accepts (one person, who owns the key). */
36
+ const OWNER_MESSAGE_LENGTH = 8000
37
+ const HOUR_MS = 60 * 60 * 1000
38
+ const DAY_MS = 24 * HOUR_MS
39
+ /** Visitors kept at most; the one seen longest ago without a run going is let go first. */
40
+ const DEFAULT_CAPACITY = 5000
41
+
42
+ /** What a visitor's run must say about itself. */
43
+ export interface RunState {
44
+ readonly ended: boolean
45
+ }
46
+
47
+ export interface Visitor<R extends RunState> {
48
+ readonly id: string
49
+ readonly token: string
50
+ /** The visitor in the audit: a short hash, never the cookie itself. */
51
+ readonly tag: string
52
+ /** The visitor's latest run, going or ended (what a page that loads shows again). */
53
+ current: R | undefined
54
+ /** When this visitor's asks of the last hour started. */
55
+ readonly asks: number[]
56
+ lastSeen: number
57
+ }
58
+
59
+ /** Why an ask is refused, with the HTTP status to answer. */
60
+ export interface Refusal {
61
+ readonly error: string
62
+ readonly status: 400 | 409 | 429 | 503
63
+ }
64
+
65
+ export interface VisitorsOptions {
66
+ /** One visitor for every page (development); otherwise one per cookie (the Assistant). */
67
+ readonly shared: boolean
68
+ readonly limits?: AssistantLimits
69
+ readonly now?: () => number
70
+ readonly capacity?: number
71
+ }
72
+
73
+ const newSecret = (): string => randomBytes(24).toString('base64url')
74
+
75
+ /** The value of one cookie in a `Cookie` header. */
76
+ export function cookieValue(header: string | undefined | null, name: string): string | undefined {
77
+ if (header === undefined || header === null) return undefined
78
+ for (const part of header.split(';')) {
79
+ const at = part.indexOf('=')
80
+ if (at === -1) continue
81
+ if (part.slice(0, at).trim() === name) return part.slice(at + 1).trim()
82
+ }
83
+ return undefined
84
+ }
85
+
86
+ /** Whether two secrets are the same, in constant time. */
87
+ export function sameSecret(presented: unknown, expected: string): boolean {
88
+ if (typeof presented !== 'string') return false
89
+ const a = Buffer.from(presented)
90
+ const b = Buffer.from(expected)
91
+ return a.length === b.length && timingSafeEqual(a, b)
92
+ }
93
+
94
+ const minutes = (ms: number): string => {
95
+ const count = Math.max(1, Math.ceil(ms / 60_000))
96
+ return `${String(count)} minute${count === 1 ? '' : 's'}`
97
+ }
98
+
99
+ export class Visitors<R extends RunState> {
100
+ private readonly shared: boolean
101
+ private readonly limits: Required<AssistantLimits>
102
+ private readonly now: () => number
103
+ private readonly capacity: number
104
+ private readonly byId = new Map<string, Visitor<R>>()
105
+ /** When every ask of the last day started, across visitors. */
106
+ private readonly day: number[] = []
107
+ private readonly owner: Visitor<R> | undefined
108
+
109
+ constructor(options: VisitorsOptions) {
110
+ this.shared = options.shared
111
+ this.limits = { ...DEFAULT_LIMITS, ...options.limits }
112
+ this.now = options.now ?? Date.now
113
+ this.capacity = options.capacity ?? DEFAULT_CAPACITY
114
+ this.owner = this.shared ? this.make() : undefined
115
+ }
116
+
117
+ /** The visitor a request belongs to: the owner in development, else the one its cookie names; `undefined` when it names none this app knows. */
118
+ of(cookieHeader: string | undefined | null): Visitor<R> | undefined {
119
+ if (this.owner !== undefined) return this.owner
120
+ const id = cookieValue(cookieHeader, VISITOR_COOKIE)
121
+ const visitor = id === undefined ? undefined : this.byId.get(id)
122
+ if (visitor !== undefined) visitor.lastSeen = this.now()
123
+ return visitor
124
+ }
125
+
126
+ /**
127
+ * The visitor of a session request, made when the request names none. `setCookie` is the header to answer with, only for a new visitor: HttpOnly, SameSite=Strict, the whole
128
+ * app (`Path=/`), a day long, and Secure when the page came over https.
129
+ */
130
+ session(
131
+ cookieHeader: string | undefined | null,
132
+ secure: boolean,
133
+ ): { visitor: Visitor<R>; setCookie?: string } {
134
+ const known = this.of(cookieHeader)
135
+ if (known !== undefined) return { visitor: known }
136
+ const visitor = this.make()
137
+ this.keep(visitor)
138
+ const setCookie = `${VISITOR_COOKIE}=${visitor.id}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${String(DAY_MS / 1000)}${secure ? '; Secure' : ''}`
139
+ return { visitor, setCookie }
140
+ }
141
+
142
+ /** Whether `presented` is this visitor's token. */
143
+ accepts(visitor: Visitor<R> | undefined, presented: unknown): visitor is Visitor<R> {
144
+ return visitor !== undefined && sameSecret(presented, visitor.token)
145
+ }
146
+
147
+ /** Whether any visitor holds this token (a terminal driver checks a request with no other context). */
148
+ knows(cookieHeader: string | undefined | null, presented: unknown): boolean {
149
+ return this.accepts(this.of(cookieHeader), presented)
150
+ }
151
+
152
+ /** The question's length check, which comes before anything is counted. */
153
+ checkMessage(message: string): Refusal | undefined {
154
+ const most = this.shared ? OWNER_MESSAGE_LENGTH : this.limits.messageLength
155
+ if (message === '' || message.length > most)
156
+ return { error: `write a message of at most ${String(most)} characters`, status: 400 }
157
+ return undefined
158
+ }
159
+
160
+ /**
161
+ * Whether this visitor may start a run now. The visitor's own run going is refused first (409); then, for the Assistant, the visitor's hour, the runs going at once and the
162
+ * day. An admitted ask is counted.
163
+ */
164
+ admit(visitor: Visitor<R>): Refusal | undefined {
165
+ if (visitor.current !== undefined && !visitor.current.ended)
166
+ return { error: 'a run is already going; wait for it to end', status: 409 }
167
+ if (this.shared) return undefined
168
+ const now = this.now()
169
+ prune(visitor.asks, now - HOUR_MS)
170
+ prune(this.day, now - DAY_MS)
171
+ const hour = visitor.asks[0]
172
+ if (visitor.asks.length >= this.limits.perVisitorPerHour && hour !== undefined)
173
+ return {
174
+ error: `You have asked ${String(this.limits.perVisitorPerHour)} questions this hour. Ask again in ${minutes(hour + HOUR_MS - now)}.`,
175
+ status: 429,
176
+ }
177
+ let going = 0
178
+ for (const other of this.byId.values())
179
+ if (other.current !== undefined && !other.current.ended) going += 1
180
+ if (going >= this.limits.concurrent)
181
+ return {
182
+ error: 'The assistant is helping other people right now. Ask again in a moment.',
183
+ status: 503,
184
+ }
185
+ const first = this.day[0]
186
+ if (this.day.length >= this.limits.perDay && first !== undefined)
187
+ return {
188
+ error: `The assistant has answered all the questions it can today. It is back in ${minutes(first + DAY_MS - now)}.`,
189
+ status: 429,
190
+ }
191
+ visitor.asks.push(now)
192
+ this.day.push(now)
193
+ return undefined
194
+ }
195
+
196
+ /** How many visitors are kept (for tests and diagnostics). */
197
+ get size(): number {
198
+ return this.owner === undefined ? this.byId.size : 1
199
+ }
200
+
201
+ private make(): Visitor<R> {
202
+ const id = newSecret()
203
+ return {
204
+ id,
205
+ token: newSecret(),
206
+ tag: createHash('sha256').update(id).digest('hex').slice(0, 12),
207
+ current: undefined,
208
+ asks: [],
209
+ lastSeen: this.now(),
210
+ }
211
+ }
212
+
213
+ /** Keep a new visitor; past the capacity, let go of the visitors seen longest ago that have no run going. */
214
+ private keep(visitor: Visitor<R>): void {
215
+ if (this.byId.size >= this.capacity) {
216
+ const idle = [...this.byId.values()]
217
+ .filter((other) => other.current === undefined || other.current.ended)
218
+ .sort((a, b) => a.lastSeen - b.lastSeen)
219
+ for (const other of idle.slice(0, this.byId.size - this.capacity + 1))
220
+ this.byId.delete(other.id)
221
+ }
222
+ this.byId.set(visitor.id, visitor)
223
+ }
224
+ }
225
+
226
+ /** Drop the times up to `since` from the front of an ascending list: an ask exactly an hour (or a day) old is out of the window. */
227
+ function prune(times: number[], since: number): void {
228
+ let drop = 0
229
+ while (drop < times.length && (times[drop] ?? 0) <= since) drop += 1
230
+ if (drop > 0) times.splice(0, drop)
231
+ }