acrylblends 1.0.10 → 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 (252) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +60 -2
  3. package/dist/index.js +1992 -33
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/api/summary.md +1 -1
  7. package/docs/framework/getting-started/desktop.md +23 -4
  8. package/docs/framework/guides/create-acrylblends.md +1 -1
  9. package/docs/framework/guides/production.md +20 -0
  10. package/docs/framework/workshop/README.md +174 -285
  11. package/docs/framework/workshop/facilitator.md +81 -90
  12. package/legacy/README.md +9 -0
  13. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  14. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  15. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  16. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  17. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  18. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  19. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  20. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  21. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  22. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  23. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  24. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  25. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  26. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  27. package/legacy/agent/855359304707/src/app.ts +168 -0
  28. package/legacy/agent/855359304707/src/routes.ts +126 -0
  29. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  30. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  31. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  32. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  33. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  34. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  35. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  36. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  37. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  38. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  39. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  40. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  41. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  42. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  43. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  44. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  45. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  46. package/legacy/layouts.json +524 -0
  47. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  48. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  49. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  50. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  51. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  52. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  53. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  54. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  55. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  56. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  57. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  58. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  59. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  60. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  61. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  62. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  63. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  64. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  65. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  66. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  67. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  68. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  69. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  70. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  71. package/lib/{audit-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
  72. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  73. package/lib/cordis/door.d.ts +44 -7
  74. package/lib/cordis/door.js +214 -60
  75. package/lib/cordis/inspect.js +1 -1
  76. package/lib/cordis/node-server.d.ts +101 -3
  77. package/lib/cordis/node-server.js +3 -3
  78. package/lib/cordis/router-http.d.ts +7 -1
  79. package/lib/cordis/router-http.js +44 -3
  80. package/lib/cordis/router.d.ts +1 -1
  81. package/lib/cordis/router.js +1 -1
  82. package/lib/cordis/state.d.ts +3 -2
  83. package/lib/cordis/state.js +371 -7
  84. package/lib/door/host.d.ts +2 -2
  85. package/lib/door/host.js +1 -1
  86. package/lib/door/page.d.ts +20 -1
  87. package/lib/door/page.js +75 -14
  88. package/lib/door/protocol.d.ts +1 -1
  89. package/lib/drivers.d.ts +10 -1
  90. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  91. package/lib/node-server.d.ts +7 -1
  92. package/lib/node-server.js +2 -2
  93. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  94. package/lib/{snapshot-text-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
  95. package/lib/src-CtkIX_Gg.js +427 -0
  96. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  97. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  98. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  99. package/package.json +2 -1
  100. package/plugins/acryl-door/src/host/channel.ts +124 -9
  101. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  102. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  103. package/plugins/acryl-door/src/host/door.ts +58 -23
  104. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  105. package/plugins/acryl-door/src/host/page-socket.ts +18 -8
  106. package/plugins/acryl-door/src/host/verbs.ts +3 -0
  107. package/plugins/acryl-door/src/index.ts +166 -29
  108. package/plugins/acryl-door/src/page/base.ts +32 -0
  109. package/plugins/acryl-door/src/page/channel.ts +11 -1
  110. package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
  111. package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
  112. package/plugins/acryl-door/src/page/driver/driver.ts +49 -7
  113. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  114. package/plugins/acryl-door/src/page/index.ts +46 -2
  115. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  116. package/plugins/acryl-door/src/page/outline.ts +56 -0
  117. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  118. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  119. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  120. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  121. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  122. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  123. package/plugins/acryl-node-server/src/index.ts +304 -16
  124. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  125. package/plugins/acryl-page/plugin.json +12 -0
  126. package/plugins/acryl-page/src/index.ts +163 -0
  127. package/plugins/acryl-router/src/service.ts +77 -14
  128. package/plugins/acryl-router-http/src/index.ts +15 -1
  129. package/plugins/acryl-router-http/src/serve.ts +31 -0
  130. package/plugins/acryl-state/plugin.json +4 -3
  131. package/plugins/acryl-state/src/client-source.ts +244 -0
  132. package/plugins/acryl-state/src/service.ts +33 -6
  133. package/plugins/acryl-state/src/socket.ts +96 -0
  134. package/plugins/acryl-web-server/plugin.json +14 -0
  135. package/plugins/acryl-web-server/src/index.ts +164 -0
  136. package/plugins/agent/agent.ts +38 -7
  137. package/plugins/agent/index.ts +1 -1
  138. package/plugins/agent/model.ts +10 -2
  139. package/plugins/agent/tools.ts +9 -3
  140. package/plugins/agent-panel/client/describe.ts +57 -0
  141. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  142. package/plugins/agent-panel/client/frame.ts +51 -0
  143. package/plugins/agent-panel/client/main.ts +343 -81
  144. package/plugins/agent-panel/drivers.ts +7 -2
  145. package/plugins/agent-panel/headless.ts +5 -2
  146. package/plugins/agent-panel/index.ts +187 -61
  147. package/plugins/agent-panel/mount.ts +27 -0
  148. package/plugins/agent-panel/visitors.ts +231 -0
  149. package/plugins/desktop/frame.ts +231 -0
  150. package/plugins/desktop/launch.ts +44 -9
  151. package/plugins/desktop/main.ts +100 -32
  152. package/plugins/desktop/window.ts +21 -4
  153. package/plugins/driver-pty/client/terminal.ts +4 -1
  154. package/plugins/driver-pty/index.ts +38 -11
  155. package/plugins/driver-pty/spawn.ts +38 -0
  156. package/plugins/driver-pty/terminal.ts +2 -0
  157. package/plugins/extensions/index.ts +26 -1
  158. package/plugins/extensions/resolve.ts +13 -0
  159. package/plugins/feedback/client.d.ts +22 -0
  160. package/plugins/feedback/client.js +471 -0
  161. package/plugins/feedback/index.ts +143 -0
  162. package/plugins/feedback/plugin.json +11 -0
  163. package/plugins/feedback/store.ts +307 -0
  164. package/plugins/home/index.ts +122 -0
  165. package/plugins/home/plugin.json +11 -0
  166. package/plugins/instance/index.ts +12 -3
  167. package/plugins/manifest/app-plugins.ts +86 -0
  168. package/plugins/manifest/index.ts +12 -2
  169. package/plugins/manifest/snapshot.ts +275 -0
  170. package/plugins/openai/index.ts +64 -5
  171. package/plugins/openai/prompt.ts +23 -0
  172. package/shared/AGENTS.core.md +36 -16
  173. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  174. package/shared/skills/acryl-app/SKILL.md +13 -0
  175. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  176. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  177. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  178. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  179. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  180. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  181. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  182. package/templates/agent/AGENTS.md +4 -2
  183. package/templates/agent/blend.yaml +4 -0
  184. package/templates/agent/gitignore +1 -0
  185. package/templates/agent/package.json +1 -0
  186. package/templates/agent/src/app.ts +150 -33
  187. package/templates/agent/src/frame.ts +17 -0
  188. package/templates/agent/src/main.ts +5 -3
  189. package/templates/agent/src/routes.ts +73 -21
  190. package/templates/agent/template.json +2 -0
  191. package/templates/agent/test/app.test.ts +83 -5
  192. package/templates/blank/AGENTS.md +3 -2
  193. package/templates/blank/blend.yaml +11 -0
  194. package/templates/blank/gitignore +1 -0
  195. package/templates/blank/package.json +1 -0
  196. package/templates/blank/src/app.ts +100 -29
  197. package/templates/blank/src/door.ts +7 -0
  198. package/templates/blank/src/first-page.ts +5 -0
  199. package/templates/blank/src/frame.ts +17 -0
  200. package/templates/blank/src/routes.ts +65 -8
  201. package/templates/blank/template.json +14 -1
  202. package/templates/blank/test/app.test.ts +103 -16
  203. package/templates/chat/blend.yaml +2 -1
  204. package/templates/chat/gitignore +1 -0
  205. package/templates/chat/package.json +1 -0
  206. package/templates/chat/src/app.ts +72 -22
  207. package/templates/chat/src/chat-app.ts +11 -2
  208. package/templates/chat/src/client/chat.tsx +4 -2
  209. package/templates/chat/src/frame.ts +17 -0
  210. package/templates/chat/src/main.ts +1 -6
  211. package/templates/chat/src/page.tsx +6 -4
  212. package/templates/chat/src/routes.ts +14 -4
  213. package/templates/chat/src/runtime.ts +9 -3
  214. package/templates/chat/template.json +3 -1
  215. package/templates/chat/test/chat.test.ts +46 -1
  216. package/templates/chat/test/server.test.ts +17 -3
  217. package/templates/nodejs/blend.yaml +2 -1
  218. package/templates/nodejs/gitignore +1 -0
  219. package/templates/nodejs/package.json +1 -0
  220. package/templates/nodejs/src/app.ts +76 -21
  221. package/templates/nodejs/src/frame.ts +17 -0
  222. package/templates/nodejs/src/main.ts +1 -6
  223. package/templates/nodejs/src/page.tsx +3 -3
  224. package/templates/nodejs/src/routes.ts +32 -13
  225. package/templates/nodejs/template.json +3 -1
  226. package/templates/nodejs/test/app.test.ts +38 -3
  227. package/templates/nodejs/test/server.test.ts +17 -3
  228. package/templates/notes/blend.yaml +2 -1
  229. package/templates/notes/gitignore +1 -0
  230. package/templates/notes/package.json +1 -0
  231. package/templates/notes/src/app.ts +72 -22
  232. package/templates/notes/src/frame.ts +17 -0
  233. package/templates/notes/src/main.ts +1 -6
  234. package/templates/notes/src/notes-app.ts +24 -17
  235. package/templates/notes/src/routes.ts +11 -4
  236. package/templates/notes/src/runtime.ts +9 -3
  237. package/templates/notes/src/views.tsx +26 -17
  238. package/templates/notes/template.json +3 -1
  239. package/templates/notes/test/notes.test.ts +39 -1
  240. package/templates/notes/test/server.test.ts +17 -3
  241. package/templates/todo/blend.yaml +3 -0
  242. package/templates/todo/gitignore +1 -0
  243. package/templates/todo/package.json +1 -0
  244. package/templates/todo/src/app.ts +81 -27
  245. package/templates/todo/src/door.ts +7 -0
  246. package/templates/todo/src/frame.ts +17 -0
  247. package/templates/todo/src/page.ts +13 -6
  248. package/templates/todo/src/todo.ts +18 -6
  249. package/templates/todo/template.json +11 -1
  250. package/templates/todo/test/todo.test.ts +67 -9
  251. package/lib/src-LX0E4ec4.js +0 -117
  252. package/shared/SKILL.md +0 -14
@@ -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
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The desktop window on the ACRYL frame (docs/plans/ide-host-inversion.md, section 27). An app with `scripts/ide-host.mjs` runs inside the frame on `npm run dev`;
3
+ * `npm run desktop` shows that same frame: it attaches to a host that already runs (the host's address record, `.acrylblends/frame-url.json`), or starts the host
4
+ * the way `npm run dev` does and loads the address it prints. Nothing here imports electron: `main.ts` decides with these functions and owns the window.
5
+ */
6
+ import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { connect } from 'node:net'
9
+ import { join, resolve } from 'node:path'
10
+
11
+ /** The launcher every frame app has; without it the app runs on its own server (made before the frame). */
12
+ export const HOST_SCRIPT = join('scripts', 'ide-host.mjs')
13
+
14
+ /** The line the host prints once it serves the frame, with the launch token in the address. */
15
+ const HOST_LINE = /acrylblends host: (http:\/\/\S+)/u
16
+
17
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]'])
18
+
19
+ /** How long a stopping host gets before its tree is killed. */
20
+ export const HOST_STOP_GRACE_MS = 5_000
21
+
22
+ export function isFrameApp(root: string, exists: (path: string) => boolean = existsSync): boolean {
23
+ return exists(join(root, HOST_SCRIPT))
24
+ }
25
+
26
+ /** The app home the host uses: `ACRYLBLENDS_HOME` when set, else `<app>/.acrylblends` (the launcher's own rule). */
27
+ export function frameHome(root: string, env: Record<string, string | undefined>): string {
28
+ const pinned = env.ACRYLBLENDS_HOME?.trim()
29
+ return pinned === undefined || pinned === ''
30
+ ? join(resolve(root), '.acrylblends')
31
+ : resolve(pinned)
32
+ }
33
+
34
+ /** The frame address in the host's output so far, or `undefined` while it has not printed it. */
35
+ export function hostAddress(output: string): string | undefined {
36
+ return HOST_LINE.exec(output)?.[1]
37
+ }
38
+
39
+ /** A loopback `http:` address, or `undefined` for anything else: the window only ever opens a frame on this machine. */
40
+ export function loopbackAddress(url: unknown): URL | undefined {
41
+ if (typeof url !== 'string') return undefined
42
+ try {
43
+ const parsed = new URL(url)
44
+ return parsed.protocol === 'http:' && LOOPBACK.has(parsed.hostname) ? parsed : undefined
45
+ } catch {
46
+ return undefined
47
+ }
48
+ }
49
+
50
+ export interface RecordDeps {
51
+ readonly read?: (file: string) => string
52
+ /** Whether something accepts a connection at the address. */
53
+ readonly listening?: (url: URL) => Promise<boolean>
54
+ }
55
+
56
+ const tcpListening = (url: URL, timeoutMs = 500): Promise<boolean> =>
57
+ new Promise((done) => {
58
+ const socket = connect({
59
+ host: url.hostname.replace(/^\[|\]$/gu, ''),
60
+ port: Number(url.port || 80),
61
+ })
62
+ const finish = (listening: boolean) => {
63
+ socket.destroy()
64
+ done(listening)
65
+ }
66
+ socket.setTimeout(timeoutMs)
67
+ socket.once('connect', () => finish(true))
68
+ socket.once('timeout', () => finish(false))
69
+ socket.once('error', () => finish(false))
70
+ })
71
+
72
+ /**
73
+ * The frame that already runs for this app: the host's address record, when it names a loopback address and something listens there. A record a stopped host
74
+ * left behind is not a running frame.
75
+ */
76
+ export async function runningFrame(
77
+ home: string,
78
+ deps: RecordDeps = {},
79
+ ): Promise<string | undefined> {
80
+ const read = deps.read ?? ((file: string) => readFileSync(file, 'utf8'))
81
+ let record: { url?: unknown }
82
+ try {
83
+ record = JSON.parse(read(join(home, 'frame-url.json'))) as { url?: unknown }
84
+ } catch {
85
+ return undefined
86
+ }
87
+ const url = loopbackAddress(record.url)
88
+ if (url === undefined) return undefined
89
+ return (await (deps.listening ?? tcpListening)(url)) ? url.href : undefined
90
+ }
91
+
92
+ /** An address as the door may show it: the frame's launch token removed (the token is the way into the frame, so it never goes to a log). */
93
+ export function withoutToken(href: string): string {
94
+ try {
95
+ const parsed = new URL(href)
96
+ if (!parsed.searchParams.has('token')) return href
97
+ parsed.searchParams.delete('token')
98
+ return parsed.href
99
+ } catch {
100
+ return href
101
+ }
102
+ }
103
+
104
+ /**
105
+ * What the Settings... menu item runs in a frame window: the window event the page's root owner opens its own Settings on (`acryl-blends-frame`'s
106
+ * `OPEN_SETTINGS_EVENT`, and the IDE's `acryl-workspace`; keep the string in step). The same dialog as the top bar's gear, never a second one.
107
+ */
108
+ export const openFrameSettings = "window.dispatchEvent(new Event('acryl:open-settings'))"
109
+
110
+ /** What the window does with a navigation: stay on the frame's origin, hand the address to the system browser, or refuse it. */
111
+ export type Navigation = 'stay' | 'external' | 'block'
112
+
113
+ /**
114
+ * ACRYL Desktop's rule (`electron-shell-generation.ts`): only the main frame is checked, so the frame's tiles (the app's page in an iframe) navigate freely;
115
+ * the frame's own origin stays in the window, an `http(s)` or `mailto` address goes to the system browser, anything else is refused.
116
+ */
117
+ export function navigation(target: string, origin: string, mainFrame = true): Navigation {
118
+ if (!mainFrame) return 'stay'
119
+ let parsed: URL
120
+ try {
121
+ parsed = new URL(target)
122
+ } catch {
123
+ return 'block'
124
+ }
125
+ if (parsed.origin === origin) return 'stay'
126
+ return parsed.protocol === 'http:' ||
127
+ parsed.protocol === 'https:' ||
128
+ parsed.protocol === 'mailto:'
129
+ ? 'external'
130
+ : 'block'
131
+ }
132
+
133
+ /** How the host is started: the npm and Node that ran `npm run desktop`, never Electron's Node (node-pty and DSH's rows are built for Node's ABI). */
134
+ export function hostCommand(
135
+ env: Record<string, string | undefined>,
136
+ platform: NodeJS.Platform,
137
+ ): { file: string; args: string[]; shell: boolean } {
138
+ const node = env.npm_node_execpath
139
+ const npm = env.npm_execpath
140
+ if (node !== undefined && node !== '' && npm !== undefined && npm !== '')
141
+ return /\.c?js$/u.test(npm)
142
+ ? { file: node, args: [npm, 'run', 'dev'], shell: false }
143
+ : { file: npm, args: ['run', 'dev'], shell: platform === 'win32' }
144
+ // Started without npm (Electron run by hand): npm from the PATH, `npm.cmd` through the shell on Windows (scripts/lib/platform.mjs, npmSpawn).
145
+ return platform === 'win32'
146
+ ? { file: 'npm.cmd', args: ['run', 'dev'], shell: true }
147
+ : { file: 'npm', args: ['run', 'dev'], shell: false }
148
+ }
149
+
150
+ export interface FrameHost {
151
+ /** The frame address the host printed, or a rejection with the host's output when it ended first. */
152
+ readonly ready: Promise<string>
153
+ /** Stop the host and what it started; resolves once it has exited. */
154
+ stop(): Promise<void>
155
+ }
156
+
157
+ /** Start the app's frame host as `npm run dev` does, in the app folder; its output is passed through to `write`. */
158
+ export function startFrameHost(
159
+ root: string,
160
+ env: NodeJS.ProcessEnv,
161
+ write: (text: string) => void,
162
+ platform: NodeJS.Platform = process.platform,
163
+ ): FrameHost {
164
+ const command = hostCommand(env, platform)
165
+ // The host leads its own process group on macOS and Linux, so a stop reaches npm, the host and every terminal it runs; Windows stops the tree with taskkill.
166
+ const child: ChildProcess = spawn(command.file, command.args, {
167
+ cwd: root,
168
+ env: { ...env, ELECTRON_RUN_AS_NODE: undefined },
169
+ stdio: ['ignore', 'pipe', 'pipe'],
170
+ shell: command.shell,
171
+ detached: platform !== 'win32',
172
+ windowsHide: true,
173
+ })
174
+ let output = ''
175
+ let exited = false
176
+ const exit = new Promise<void>((done) => {
177
+ child.once('exit', () => {
178
+ exited = true
179
+ done()
180
+ })
181
+ child.once('error', () => {
182
+ exited = true
183
+ done()
184
+ })
185
+ })
186
+ const ready = new Promise<string>((done, fail) => {
187
+ const take = (chunk: Buffer) => {
188
+ const text = chunk.toString()
189
+ write(text)
190
+ if (output.length < 200_000) output += text
191
+ const address = hostAddress(output)
192
+ if (address !== undefined) done(address)
193
+ }
194
+ child.stdout?.on('data', take)
195
+ child.stderr?.on('data', take)
196
+ void exit.then(() =>
197
+ fail(new Error(`the frame host ended before it served the frame:\n${output.slice(-3000)}`)),
198
+ )
199
+ })
200
+ const signal = (name: NodeJS.Signals) => {
201
+ if (child.pid === undefined) return
202
+ // Windows has no process groups and no SIGTERM a console process can answer: the whole tree (npm, the host, its terminals) is ended at once.
203
+ if (platform === 'win32') {
204
+ spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
205
+ stdio: 'ignore',
206
+ windowsHide: true,
207
+ })
208
+ return
209
+ }
210
+ try {
211
+ process.kill(-child.pid, name)
212
+ } catch {
213
+ // The group is already gone.
214
+ }
215
+ }
216
+ return {
217
+ ready,
218
+ async stop() {
219
+ if (exited) return
220
+ signal('SIGTERM')
221
+ const graceful = await Promise.race([
222
+ exit.then(() => true),
223
+ new Promise<boolean>((done) => setTimeout(() => done(false), HOST_STOP_GRACE_MS).unref()),
224
+ ])
225
+ if (!graceful) {
226
+ signal('SIGKILL')
227
+ await exit
228
+ }
229
+ },
230
+ }
231
+ }
@@ -3,7 +3,7 @@
3
3
  * (Ubuntu 23.10 and later: `kernel.apparmor_restrict_unprivileged_userns=1`), Chromium's sandbox cannot start for an Electron that npm unpacked into node_modules, and Electron
4
4
  * aborts before any of our code runs. There, and only there, it starts with `--no-sandbox` and says so; everywhere else Electron starts exactly as it does with the sandbox on.
5
5
  */
6
- import { spawn } from 'node:child_process'
6
+ import { type ChildProcess, spawn } from 'node:child_process'
7
7
  import { readFileSync } from 'node:fs'
8
8
  import { createRequire } from 'node:module'
9
9
  import { join } from 'node:path'
@@ -29,9 +29,45 @@ export const SANDBOX_NOTICE =
29
29
  "desktop: this Linux restricts unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns=1), so Electron's sandbox cannot start; starting with --no-sandbox. " +
30
30
  'To keep the sandbox, give Electron an AppArmor profile that allows `userns` (the way the VS Code and Chrome packages do): see docs/framework/getting-started/desktop.md'
31
31
 
32
- /** The arguments for Electron: the entry file, with `--no-sandbox` first when the sandbox is blocked. */
33
- export function electronArgs(entry: string, blocked: boolean): string[] {
34
- return blocked ? ['--no-sandbox', entry] : [entry]
32
+ /**
33
+ * The arguments for Electron: the entry file, with `--no-sandbox` first when the sandbox is blocked. Chromium switches given to the script
34
+ * (`npm run desktop -- --remote-debugging-port=9222`) go before the entry; anything that is not a switch is dropped.
35
+ */
36
+ export function electronArgs(
37
+ entry: string,
38
+ blocked: boolean,
39
+ extra: readonly string[] = [],
40
+ ): string[] {
41
+ const switches = extra.filter((arg) => /^--[a-z][a-z0-9-]*(=.*)?$/u.test(arg))
42
+ return [...(blocked ? ['--no-sandbox'] : []), ...switches, entry]
43
+ }
44
+
45
+ /** The signals the launcher hands to Electron: Ctrl-C, a stop, and the terminal closing. */
46
+ export const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
47
+
48
+ /**
49
+ * Start Electron and make it end the way it was asked to. On macOS and Linux Electron leads its own process group, so a terminal's Ctrl-C (which goes to the
50
+ * whole foreground group) reaches only this launcher, which hands Electron exactly one signal. Electron answers a first SIGINT, SIGTERM or SIGHUP with a
51
+ * graceful quit, the one that stops the frame host the window started; a second signal kills it at once, before any of that runs, and a Ctrl-C that reached
52
+ * both processes was two, as is one that npm passes on after the terminal sent it. Later signals are not passed on. Windows has no process groups and no such signals; there Electron starts as before. This process ends with Electron's exit code.
53
+ */
54
+ export function runElectron(
55
+ file: string,
56
+ args: readonly string[],
57
+ platform: NodeJS.Platform = process.platform,
58
+ ): ChildProcess {
59
+ const child = spawn(file, args, { stdio: 'inherit', detached: platform !== 'win32' })
60
+ // One signal, ever: npm hands a Ctrl-C to this process a second time, and Electron's quit is bounded (DESKTOP_SHUTDOWN_TIMEOUT_MS) without another.
61
+ let forwarded = false
62
+ for (const signal of FORWARDED_SIGNALS)
63
+ if (platform !== 'win32' || signal !== 'SIGHUP')
64
+ process.on(signal, () => {
65
+ if (forwarded) return
66
+ forwarded = true
67
+ child.kill(signal)
68
+ })
69
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
70
+ return child
35
71
  }
36
72
 
37
73
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
@@ -40,9 +76,8 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
40
76
  const blocked =
41
77
  process.env.ACRYLBLENDS_DESKTOP_SANDBOX !== '1' && sandboxBlocked(process.platform)
42
78
  if (blocked) console.warn(SANDBOX_NOTICE)
43
- const child = spawn(electron, electronArgs(join(import.meta.dirname, 'main.ts'), blocked), {
44
- stdio: 'inherit',
45
- })
46
- for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => child.kill(signal))
47
- child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
79
+ runElectron(
80
+ electron,
81
+ electronArgs(join(import.meta.dirname, 'main.ts'), blocked, process.argv.slice(2)),
82
+ )
48
83
  }