acrylblends 1.1.0 → 1.3.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 (263) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +47 -2
  3. package/dist/index.js +2017 -32
  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 +5 -3
  8. package/docs/framework/workshop/README.md +139 -153
  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 +87 -9
  72. package/lib/cordis/door.js +324 -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/plugin.json +3 -0
  98. package/plugins/acryl-door/src/cli/acryl.ts +63 -6
  99. package/plugins/acryl-door/src/host/channel.ts +86 -9
  100. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  101. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  102. package/plugins/acryl-door/src/host/door.ts +128 -24
  103. package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
  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 +14 -1
  107. package/plugins/acryl-door/src/index.ts +168 -30
  108. package/plugins/acryl-door/src/page/base.ts +32 -0
  109. package/plugins/acryl-door/src/page/channel.ts +3 -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 +32 -4
  113. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  114. package/plugins/acryl-door/src/page/index.ts +41 -2
  115. package/plugins/acryl-door/src/page/outline.ts +56 -0
  116. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  117. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  118. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  119. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  120. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  121. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  122. package/plugins/acryl-node-server/src/index.ts +304 -16
  123. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  124. package/plugins/acryl-page/plugin.json +12 -0
  125. package/plugins/acryl-page/src/index.ts +163 -0
  126. package/plugins/acryl-router/src/service.ts +77 -14
  127. package/plugins/acryl-router-http/src/index.ts +15 -1
  128. package/plugins/acryl-router-http/src/serve.ts +31 -0
  129. package/plugins/acryl-web-server/plugin.json +14 -0
  130. package/plugins/acryl-web-server/src/index.ts +164 -0
  131. package/plugins/agent/agent.ts +38 -7
  132. package/plugins/agent/index.ts +1 -1
  133. package/plugins/agent/model.ts +10 -2
  134. package/plugins/agent/tools.ts +9 -3
  135. package/plugins/agent-panel/client/describe.ts +57 -0
  136. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  137. package/plugins/agent-panel/client/frame.ts +51 -0
  138. package/plugins/agent-panel/client/main.ts +238 -26
  139. package/plugins/agent-panel/drivers.ts +7 -2
  140. package/plugins/agent-panel/headless.ts +5 -2
  141. package/plugins/agent-panel/index.ts +146 -57
  142. package/plugins/agent-panel/mount.ts +27 -0
  143. package/plugins/agent-panel/visitors.ts +231 -0
  144. package/plugins/builder/index.ts +13 -35
  145. package/plugins/builder/package.ts +186 -0
  146. package/plugins/builder/plugin.json +3 -1
  147. package/plugins/desktop/frame.ts +231 -0
  148. package/plugins/desktop/launch.ts +44 -9
  149. package/plugins/desktop/main.ts +100 -32
  150. package/plugins/desktop/window.ts +21 -4
  151. package/plugins/driver-pty/client/terminal.ts +4 -1
  152. package/plugins/driver-pty/index.ts +38 -11
  153. package/plugins/driver-pty/spawn.ts +38 -0
  154. package/plugins/driver-pty/terminal.ts +2 -0
  155. package/plugins/extensions/index.ts +47 -18
  156. package/plugins/extensions/plugin-store.ts +27 -0
  157. package/plugins/extensions/plugin.json +1 -1
  158. package/plugins/extensions/resolve.ts +13 -0
  159. package/plugins/extensions/stage.ts +15 -5
  160. package/plugins/feedback/client.d.ts +22 -0
  161. package/plugins/feedback/client.js +471 -0
  162. package/plugins/feedback/index.ts +143 -0
  163. package/plugins/feedback/plugin.json +11 -0
  164. package/plugins/feedback/store.ts +307 -0
  165. package/plugins/home/index.ts +122 -0
  166. package/plugins/home/plugin.json +11 -0
  167. package/plugins/instance/index.ts +6 -0
  168. package/plugins/manifest/app-plugins.ts +86 -0
  169. package/plugins/manifest/index.ts +12 -2
  170. package/plugins/manifest/plugin.json +1 -1
  171. package/plugins/manifest/snapshot.ts +275 -0
  172. package/plugins/openai/index.ts +64 -5
  173. package/plugins/openai/prompt.ts +23 -0
  174. package/plugins/plugin-share/README.md +51 -0
  175. package/plugins/plugin-share/index.ts +467 -0
  176. package/plugins/plugin-share/plugin.json +15 -0
  177. package/plugins/plugin-share/publish.ts +65 -0
  178. package/plugins/plugin-share/pull-check.ts +129 -0
  179. package/plugins/plugin-share/pulled-records.ts +70 -0
  180. package/shared/AGENTS.core.md +36 -16
  181. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  182. package/shared/skills/acryl-app/SKILL.md +13 -0
  183. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  184. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  185. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  186. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  187. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  188. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  189. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  190. package/templates/agent/.gitattributes +2 -0
  191. package/templates/agent/AGENTS.md +1 -1
  192. package/templates/agent/blend.yaml +6 -0
  193. package/templates/agent/gitignore +1 -0
  194. package/templates/agent/package.json +1 -0
  195. package/templates/agent/src/app.ts +154 -33
  196. package/templates/agent/src/frame.ts +17 -0
  197. package/templates/agent/src/main.ts +5 -3
  198. package/templates/agent/src/routes.ts +38 -19
  199. package/templates/agent/template.json +3 -0
  200. package/templates/agent/test/app.test.ts +45 -3
  201. package/templates/blank/.gitattributes +2 -0
  202. package/templates/blank/AGENTS.md +3 -2
  203. package/templates/blank/blend.yaml +11 -0
  204. package/templates/blank/gitignore +1 -0
  205. package/templates/blank/package.json +1 -0
  206. package/templates/blank/src/app.ts +100 -29
  207. package/templates/blank/src/door.ts +7 -0
  208. package/templates/blank/src/first-page.ts +5 -0
  209. package/templates/blank/src/frame.ts +17 -0
  210. package/templates/blank/src/routes.ts +65 -8
  211. package/templates/blank/template.json +14 -1
  212. package/templates/blank/test/app.test.ts +103 -16
  213. package/templates/chat/.gitattributes +2 -0
  214. package/templates/chat/blend.yaml +2 -1
  215. package/templates/chat/gitignore +1 -0
  216. package/templates/chat/package.json +1 -0
  217. package/templates/chat/src/app.ts +72 -22
  218. package/templates/chat/src/chat-app.ts +11 -2
  219. package/templates/chat/src/client/chat.tsx +4 -2
  220. package/templates/chat/src/frame.ts +17 -0
  221. package/templates/chat/src/page.tsx +6 -4
  222. package/templates/chat/src/routes.ts +14 -4
  223. package/templates/chat/src/runtime.ts +9 -3
  224. package/templates/chat/template.json +3 -1
  225. package/templates/chat/test/chat.test.ts +46 -1
  226. package/templates/chat/test/server.test.ts +17 -3
  227. package/templates/nodejs/.gitattributes +2 -0
  228. package/templates/nodejs/blend.yaml +2 -1
  229. package/templates/nodejs/gitignore +1 -0
  230. package/templates/nodejs/package.json +1 -0
  231. package/templates/nodejs/src/app.ts +76 -21
  232. package/templates/nodejs/src/frame.ts +17 -0
  233. package/templates/nodejs/src/page.tsx +3 -3
  234. package/templates/nodejs/src/routes.ts +32 -13
  235. package/templates/nodejs/template.json +3 -1
  236. package/templates/nodejs/test/app.test.ts +38 -3
  237. package/templates/nodejs/test/server.test.ts +17 -3
  238. package/templates/notes/.gitattributes +2 -0
  239. package/templates/notes/blend.yaml +2 -1
  240. package/templates/notes/gitignore +1 -0
  241. package/templates/notes/package.json +1 -0
  242. package/templates/notes/src/app.ts +72 -22
  243. package/templates/notes/src/frame.ts +17 -0
  244. package/templates/notes/src/notes-app.ts +24 -17
  245. package/templates/notes/src/routes.ts +11 -4
  246. package/templates/notes/src/runtime.ts +9 -3
  247. package/templates/notes/src/views.tsx +26 -17
  248. package/templates/notes/template.json +3 -1
  249. package/templates/notes/test/notes.test.ts +39 -1
  250. package/templates/notes/test/server.test.ts +17 -3
  251. package/templates/todo/.gitattributes +2 -0
  252. package/templates/todo/blend.yaml +3 -0
  253. package/templates/todo/gitignore +1 -0
  254. package/templates/todo/package.json +1 -0
  255. package/templates/todo/src/app.ts +81 -27
  256. package/templates/todo/src/door.ts +7 -0
  257. package/templates/todo/src/frame.ts +17 -0
  258. package/templates/todo/src/page.ts +13 -6
  259. package/templates/todo/src/todo.ts +18 -6
  260. package/templates/todo/template.json +11 -1
  261. package/templates/todo/test/todo.test.ts +67 -9
  262. package/lib/src-LX0E4ec4.js +0 -117
  263. package/shared/SKILL.md +0 -14
@@ -31,8 +31,8 @@ import { type Context, type Fiber, Service } from '@deepseek-ai/cordis'
31
31
  import type {} from '../instance/index.ts'
32
32
  import type { Sandbox } from '../sandbox/index.ts'
33
33
  import { appendLedger, type LedgerEntry, readLedger } from './ledger.ts'
34
- import { resolveFromApp, routerAliases } from './resolve.ts'
35
- import { discard, hashFolder, prune, stage } from './stage.ts'
34
+ import { type PluginStore, pluginVersion } from './plugin-store.ts'
35
+ import { folderPluginStore } from './stage.ts'
36
36
 
37
37
  /** A fiber's lifecycle state, by name (Cordis's own enum is a `const enum`, which does not exist at run time). */
38
38
  export type ExtensionState = 'pending' | 'loading' | 'active' | 'failed' | 'disposed' | 'unloading'
@@ -83,6 +83,16 @@ export interface ExtensionsConfig {
83
83
  readOnly?: boolean
84
84
  /** Where the read-only start says what it did (the console by default). */
85
85
  log?: (line: string) => void
86
+ /**
87
+ * Plugins of the sources that are switched off (Settings → Plugins writes this list into `blend.yaml`): they are not started, in development or
88
+ * production, until taken off the list. Their folders stay as they are.
89
+ */
90
+ off?: readonly string[]
91
+ /**
92
+ * Plugins of the sources that `blend.yaml` mounts as rows of their own (the app composes them in its row order, with their row's config): they
93
+ * are not extensions, so they are neither found nor started here, and installing one is refused with that reason.
94
+ */
95
+ rows?: readonly string[]
86
96
  }
87
97
 
88
98
  const READ_ONLY =
@@ -214,7 +224,6 @@ interface StateService {
214
224
 
215
225
  export class Extensions extends Service {
216
226
  readonly sources: readonly ExtensionSource[]
217
- readonly stageRoot: string
218
227
  readonly stateFile: string
219
228
  readonly ledgerFile: string
220
229
  /** Settles when the startup pass is done. */
@@ -227,17 +236,23 @@ export class Extensions extends Service {
227
236
  private tail: Promise<unknown> = Promise.resolve()
228
237
  private disposed = false
229
238
  private readonly readOnly: boolean
239
+ private readonly off: ReadonlySet<string>
240
+ private readonly rows: ReadonlySet<string>
230
241
  private readonly say: (line: string) => void
231
242
  /**
232
243
  * The context the service was constructed with. Inside a service method `this.ctx` is Cordis's shadow context, which resolves services as the
233
244
  * service's own fiber, so a plugin mounted from it would read `ctx.router` from the installer's fiber and fail. Extensions mount from this one.
234
245
  */
235
246
  private readonly owner: Context
247
+ private readonly store: PluginStore
236
248
 
237
- constructor(ctx: Context, config: ExtensionsConfig = {}) {
249
+ constructor(ctx: Context, config: ExtensionsConfig, store: PluginStore) {
238
250
  super(ctx, 'extensions')
239
251
  this.owner = ctx
252
+ this.store = store
240
253
  this.readOnly = config.readOnly === true
254
+ this.off = new Set(config.off ?? [])
255
+ this.rows = new Set(config.rows ?? [])
241
256
  this.say = config.log ?? ((line) => console.log(line))
242
257
  const { home, root } = ctx.appInstance
243
258
  this.sources = (config.sources ?? [{ dir: join(home, 'extensions'), trust: 'install' }]).map(
@@ -246,20 +261,20 @@ export class Extensions extends Service {
246
261
  trust: config.approval === true && source.trust === 'install' ? 'ask' : source.trust,
247
262
  }),
248
263
  )
249
- this.stageRoot = join(home, 'extensions-staged')
250
264
  this.stateFile = join(home, 'extensions.json')
251
265
  this.ledgerFile = join(home, 'extensions-ledger.jsonl')
252
266
  // Effects run their disposers newest first: settle pending operations, then dispose every mounted extension, then drop the hook.
253
267
  // Staged copies live in the app's home, outside the app: their bare imports are resolved from the app. A read-only start imports the folders where they are (inside the app).
254
268
  if (!this.readOnly)
255
269
  ctx.effect(
256
- () => resolveFromApp(this.stageRoot, root, routerAliases()),
270
+ () => this.store.resolveImportsFrom(root),
257
271
  'extensions: resolve staged imports from the app',
258
272
  )
259
273
  ctx.effect(
260
274
  () => async () => {
275
+ // A fiber already being disposed (an app reload) can return nothing here, not a promise.
261
276
  for (const entry of [...this.installed.values()].reverse())
262
- await entry.fiber.dispose().catch(() => undefined)
277
+ await Promise.resolve(entry.fiber.dispose()).catch(() => undefined)
263
278
  this.installed.clear()
264
279
  },
265
280
  'extensions: dispose mounted extensions',
@@ -302,7 +317,7 @@ export class Extensions extends Service {
302
317
  this.stale.add(entry.name)
303
318
  continue
304
319
  }
305
- if (hashFolder(entry.source) !== entry.version)
320
+ if (pluginVersion(entry.source) !== entry.version)
306
321
  results.push(await this.installNow(entry.source))
307
322
  }
308
323
  for (const found of this.discover()) {
@@ -350,7 +365,7 @@ export class Extensions extends Service {
350
365
  }
351
366
  }
352
367
  this.requests.delete(name)
353
- if (!existsSync(request.dir) || hashFolder(request.dir) !== request.version) {
368
+ if (!existsSync(request.dir) || pluginVersion(request.dir) !== request.version) {
354
369
  return {
355
370
  ok: false,
356
371
  stage: 'approval',
@@ -417,6 +432,8 @@ export class Extensions extends Service {
417
432
  entry.isDirectory() &&
418
433
  NAME.test(entry.name) &&
419
434
  !library.has(entry.name) &&
435
+ !this.off.has(entry.name) &&
436
+ !this.rows.has(entry.name) &&
420
437
  ENTRIES.some((file) => existsSync(join(dir, file)))
421
438
  ) {
422
439
  if (!found.some((other) => other.name === entry.name))
@@ -442,6 +459,10 @@ export class Extensions extends Service {
442
459
  errors.push(
443
460
  `the folder name "${name}" must be lowercase letters, digits and dashes; it is the extension's name`,
444
461
  )
462
+ if (this.rows.has(name))
463
+ errors.push(
464
+ `"${name}" is a row of blend.yaml, so the app starts it, in its row order and with its row's config; it is not installed here. Change its folder and the running app loads it again (npm run dev); npm test checks it`,
465
+ )
445
466
  if (!existsSync(dir) || !statSync(dir).isDirectory()) errors.push(`"${dir}" is not a folder`)
446
467
  else if (!ENTRIES.some((file) => existsSync(join(dir, file))))
447
468
  errors.push(`"${dir}" has no ${ENTRIES.join(', ')}`)
@@ -596,7 +617,7 @@ export class Extensions extends Service {
596
617
  const { name, dir, errors } = this.check(source)
597
618
  if (errors.length > 0) return { ok: false, stage: 'check', name, errors, next: NOTHING }
598
619
  const previous = this.installed.get(name)
599
- const version = hashFolder(dir)
620
+ const version = pluginVersion(dir)
600
621
  const running =
601
622
  previous && previous.version === version && STATES[previous.fiber.state] !== 'failed'
602
623
  // A source that asks: this exact version must have been approved. Anything else waits, and the ledger says so.
@@ -627,12 +648,12 @@ export class Extensions extends Service {
627
648
  }
628
649
 
629
650
  // Import the new version while the old one keeps running: a syntax error or a missing export changes nothing.
630
- const staged = stage(dir, this.stageRoot, name)
651
+ const staged = this.store.put(name, dir)
631
652
  let module: PluginModule
632
653
  try {
633
654
  module = await this.load(staged.dir, name)
634
655
  } catch (error) {
635
- discard(staged.dir)
656
+ this.store.discard(staged)
636
657
  this.record({ kind: 'refused', name, version: staged.version, error: message(error) })
637
658
  return {
638
659
  ok: false,
@@ -659,7 +680,7 @@ export class Extensions extends Service {
659
680
  : undefined
660
681
  if (failure) {
661
682
  await mounted.fiber.dispose().catch(() => undefined)
662
- discard(staged.dir)
683
+ this.store.discard(staged)
663
684
  const error = failure.error
664
685
  this.record({ kind: 'refused', name, version: staged.version, error })
665
686
  let restored = false
@@ -696,7 +717,7 @@ export class Extensions extends Service {
696
717
  })
697
718
  this.stale.delete(name)
698
719
  this.saveState()
699
- prune(this.stageRoot, name, [staged.dir])
720
+ this.store.keepOnly(name, staged)
700
721
  this.record({ kind: action, name, version: staged.version })
701
722
  this.ctx.emit('extensions/changed', name, action)
702
723
  return {
@@ -716,7 +737,7 @@ export class Extensions extends Service {
716
737
  this.installed.delete(name)
717
738
  this.stale.delete(name)
718
739
  this.saveState()
719
- prune(this.stageRoot, name, [])
740
+ this.store.keepOnly(name)
720
741
  this.record({ kind: 'removed', name, version: entry.version })
721
742
  this.ctx.emit('extensions/changed', name, 'removed')
722
743
  return { ok: true, name }
@@ -741,7 +762,7 @@ export class Extensions extends Service {
741
762
  this.installed.set(folder.name, {
742
763
  name: folder.name,
743
764
  source: folder.dir,
744
- version: hashFolder(folder.dir),
765
+ version: pluginVersion(folder.dir),
745
766
  dir: folder.dir,
746
767
  module,
747
768
  fiber: mounted.fiber,
@@ -788,6 +809,10 @@ export class Extensions extends Service {
788
809
  summary.failed.push({ name: 'extensions.json', error: message(error) })
789
810
  }
790
811
  for (const [name, record] of Object.entries(records)) {
812
+ // Switched off: not started, and dropped from the record; switched on again, its folder is found and installed as new.
813
+ if (this.off.has(name)) continue
814
+ // Now a row of blend.yaml: the app starts it, and the record of its install is dropped.
815
+ if (this.rows.has(name)) continue
791
816
  try {
792
817
  // What was installed before was approved then: an unchanged source starts again without asking.
793
818
  this.approved.set(name, record.version)
@@ -800,7 +825,7 @@ export class Extensions extends Service {
800
825
  // The source is gone: the last installed version still runs, and is reported as stale.
801
826
  summary.stale.push(name)
802
827
  this.stale.add(name)
803
- if (!existsSync(record.dir)) {
828
+ if (!this.store.holds(record)) {
804
829
  summary.failed.push({ name, error: 'neither the source nor the staged copy exists' })
805
830
  continue
806
831
  }
@@ -844,7 +869,11 @@ export const extensions = {
844
869
  name: 'extensions',
845
870
  inject: ['appInstance'],
846
871
  async apply(ctx: Context, config: ExtensionsConfig) {
847
- const service = new Extensions(ctx, config ?? {})
872
+ const service = new Extensions(
873
+ ctx,
874
+ config ?? {},
875
+ folderPluginStore(join(ctx.appInstance.home, 'extensions-staged')),
876
+ )
848
877
  await service.ready
849
878
  },
850
879
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Where installed versions of a plugin are kept, as the install transaction needs it. The transaction (`index.ts`) owns this port and says nothing about folders,
3
+ * copies or URLs; `stage.ts` is the adapter that keeps them on disk (Clean Architecture: a boundary owned by the use case; the layout is a detail).
4
+ */
5
+ import { digestFolder } from '@webboxes/blends-core'
6
+
7
+ /** One kept version of a plugin. `dir` is what the transaction imports it from. */
8
+ export interface StoredPlugin {
9
+ readonly dir: string
10
+ readonly version: string
11
+ }
12
+
13
+ export interface PluginStore {
14
+ /** Keep this source as a version of `name` and return it. An unchanged source returns the version already kept. */
15
+ put(name: string, source: string): StoredPlugin
16
+ /** Whether this version, recorded earlier, is still here. */
17
+ holds(plugin: StoredPlugin): boolean
18
+ /** Drop a version that was refused. */
19
+ discard(plugin: StoredPlugin): void
20
+ /** Drop every version of `name` but `plugin`; all of them when none is given. */
21
+ keepOnly(name: string, plugin?: StoredPlugin): void
22
+ /** Let kept plugins import what the app at `appRoot` provides, until the returned function is called. */
23
+ resolveImportsFrom(appRoot: string): () => void
24
+ }
25
+
26
+ /** A short content digest of a plugin's folder: an unchanged source keeps its version. */
27
+ export const pluginVersion = (dir: string): string => digestFolder(dir, 12)
@@ -6,7 +6,7 @@
6
6
  "provides": ["extensions"],
7
7
  "dependencies": {
8
8
  "@deepseek-ai/cordis": "4.0.4",
9
- "@webboxes/blends-core": "0.4.0"
9
+ "@webboxes/blends-core": "0.5.0"
10
10
  },
11
11
  "mount": "mount it after instance: root.plugin(extensions, { sources: [{ dir: join(root, 'plugins'), trust: 'install' }] })"
12
12
  }
@@ -101,6 +101,19 @@ export function routerAliases(): Record<string, string> {
101
101
  // outside the app folder, where Node would not find that entry.
102
102
  aliases[folder.name] ??= entry
103
103
  aliases[`#${folder.name}`] = entry
104
+ // Its other top-level modules by subpath, as its package exports them (`acryl-node-server/client-modules`): the door imports one before any extension is installed.
105
+ for (const file of readdirSync(fileURLToPath(new URL(`${folder.name}/src/`, plugins)))) {
106
+ if (
107
+ !file.endsWith('.ts') ||
108
+ file === 'index.ts' ||
109
+ file.endsWith('.test.ts') ||
110
+ file.endsWith('.d.ts')
111
+ )
112
+ continue
113
+ aliases[`${folder.name}/${file.slice(0, -3)}`] ??= fileURLToPath(
114
+ new URL(`${folder.name}/src/${file}`, plugins),
115
+ )
116
+ }
104
117
  const suite = fileURLToPath(new URL(`${folder.name}/src/conformance.ts`, plugins))
105
118
  if (existsSync(suite)) aliases[`#${folder.name}/conformance`] = suite
106
119
  }
@@ -7,10 +7,9 @@
7
7
  */
8
8
  import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs'
9
9
  import { basename, join } from 'node:path'
10
- import { digestFolder, FOLDER_DIGEST_SKIPPED } from '@webboxes/blends-core'
11
-
12
- /** A short content digest of a folder, so an unchanged source keeps its version. */
13
- export const hashFolder = (dir: string, length = 12): string => digestFolder(dir, length)
10
+ import { FOLDER_DIGEST_SKIPPED } from '@webboxes/blends-core'
11
+ import { type PluginStore, pluginVersion } from './plugin-store.ts'
12
+ import { resolveFromApp, routerAliases } from './resolve.ts'
14
13
 
15
14
  /**
16
15
  * Whether `node --watch` runs this process. It watches every module the process has imported, and keeps watching the paths of the runs before; a staged copy that is deleted
@@ -40,7 +39,7 @@ export function stage(
40
39
  stageRoot: string,
41
40
  name: string,
42
41
  ): { dir: string; version: string } {
43
- const version = hashFolder(source)
42
+ const version = pluginVersion(source)
44
43
  const existing = staged(stageRoot, name, version)
45
44
  if (existing !== undefined) return { dir: existing, version }
46
45
  const stamp = String(Date.now()).padStart(15, '0')
@@ -70,3 +69,14 @@ export function prune(stageRoot: string, name: string, keep: readonly string[]):
70
69
  if (!keep.includes(path)) rmSync(path, { recursive: true, force: true })
71
70
  }
72
71
  }
72
+
73
+ /** The plugin store of an app: its versions as staged copies under `stageRoot`. */
74
+ export function folderPluginStore(stageRoot: string): PluginStore {
75
+ return {
76
+ put: (name, source) => stage(source, stageRoot, name),
77
+ holds: ({ dir }) => existsSync(dir),
78
+ discard: ({ dir }) => discard(dir),
79
+ keepOnly: (name, plugin) => prune(stageRoot, name, plugin ? [plugin.dir] : []),
80
+ resolveImportsFrom: (appRoot) => resolveFromApp(stageRoot, appRoot, routerAliases()),
81
+ }
82
+ }
@@ -0,0 +1,22 @@
1
+ // Types for the browser client served as-is (client.js): the Host reads it as text and appends its start call; tests import the exported helpers.
2
+
3
+ /** The ACRYL frame's view handle, or undefined when the page is not in the frame's canvas. */
4
+ export function frameViewOf(win: {
5
+ readonly parent: unknown
6
+ }): { readonly mode: 'build' | 'preview' } | undefined
7
+
8
+ /** A short name for a picked element: what it says it is, else its heading, else the first words it shows, else its tag. */
9
+ export function elementLabel(element: Element): string
10
+
11
+ /** The stored size of a screenshot: the whole view, downscaled to `maxSide`. */
12
+ export function shotSize(
13
+ width: number,
14
+ height: number,
15
+ maxSide?: number,
16
+ ): { width: number; height: number }
17
+
18
+ /** The largest screenshot the page sends (decoded bytes); the Host refuses more than 512 KB. */
19
+ export const SHOT_MAX_BYTES: number
20
+
21
+ /** Whether this browser can capture the current tab (Chromium only; Firefox and Safari would offer the whole screen). */
22
+ export function canCaptureTab(userAgent: string, hasDisplayCapture: boolean): boolean