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
@@ -5,6 +5,8 @@
5
5
  * - The drivers are `terminal` entries of the panel's `drivers` service (`pty:claude`, ...). A panel without this plugin lists none; this plugin without a panel is pending.
6
6
  * - Transport is server-sent events down and POST up (no WebSocket): `POST /api/driver-pty/sessions`, `GET /api/driver-pty/<id>/output` (events with ids; a reconnect replays
7
7
  * from `Last-Event-ID`, from a ring buffer of at least 64 KB), `POST .../input`, `.../resize`, `.../stop`. Same origin check and token as the panel, development only.
8
+ * - The routes live under the app's mount, like the panel's: `/api/driver-pty` on the app's own server, `<mount>/api/driver-pty` when a host serves the app at a prefix
9
+ * (the frame's `/app`). The row's `mount` config names it, and each driver carries its view (`view: { module, prefix }`) so the panel calls the mounted paths.
8
10
  * - Every process is acquired inside one `ctx.effect`; its disposer ends the process (group), closes the streams and writes the audit line. Unloading or reloading the
9
11
  * plugin with a session open leaves no process behind.
10
12
  */
@@ -16,8 +18,9 @@ import { streamSSE } from '../acryl-router/src/helpers/index.ts'
16
18
  import { Acryl } from '../acryl-router/src/index.ts'
17
19
  import type { TerminalDriver, TerminalSession } from '../agent-panel/drivers.ts'
18
20
  import { onPath } from '../agent-panel/headless.ts'
21
+ import { mountedBase, mountedPrefix } from '../agent-panel/mount.ts'
19
22
  import type {} from '../agent-panel/registry.ts'
20
- import { loadNodePty, type PtySpawn } from './spawn.ts'
23
+ import { loadNodePty, type PtySpawn, resolveProgram } from './spawn.ts'
21
24
  import { terminalDriver } from './terminal.ts'
22
25
 
23
26
  export const name = 'driver-pty'
@@ -26,7 +29,7 @@ export const inject = ['drivers', 'router', 'appInstance']
26
29
  export type { PtyProcess, PtySpawn } from './spawn.ts'
27
30
  export { terminalDriver } from './terminal.ts'
28
31
 
29
- const PREFIX = '/api/driver-pty'
32
+ const DRIVER_PATH = '/api/driver-pty'
30
33
  const CLIENT_DIR = join(import.meta.dirname, 'client')
31
34
  /** The least output a reconnecting page can be given again. */
32
35
  const SCROLLBACK = 64 * 1024
@@ -36,6 +39,11 @@ export interface DriverPtyConfig {
36
39
  programs?: Record<string, string[]>
37
40
  /** The PTY implementation; default node-pty. Tests give a fake. */
38
41
  spawn?: PtySpawn
42
+ /**
43
+ * The URL prefix the app is mounted at: empty on the app's own server, `/app` when the ACRYL frame hosts it (the same `mount` the panel takes). The
44
+ * terminal's routes and its browser view live under it, so the terminal belongs to the app and is served wherever the app is.
45
+ */
46
+ mount?: string
39
47
  }
40
48
 
41
49
  interface Chunk {
@@ -61,6 +69,9 @@ interface Live {
61
69
 
62
70
  export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise<void> {
63
71
  if (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off') return
72
+ const PREFIX = mountedPrefix(config.mount, DRIVER_PATH, 'driver-pty')
73
+ // The routes mount at the app's own mount and keep the fixed driver path inside (the frame's router prefixes the mount, not the routes).
74
+ const MOUNT = mountedBase(config.mount, 'driver-pty')
64
75
  const root = ctx.appInstance.root
65
76
  let spawn = config.spawn
66
77
  if (!spawn) {
@@ -80,7 +91,15 @@ export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise
80
91
  (['claude', 'codex', 'pi'] as const).filter(onPath).map((p) => [p, [] as string[]]),
81
92
  )
82
93
  for (const [program, args] of Object.entries(programs))
83
- ctx.effect(() => ctx.drivers.register(terminalDriver(`pty:${program}`, program, args, spawn)))
94
+ ctx.effect(() =>
95
+ ctx.drivers.register(
96
+ // The name stays the program the app configured; the PTY runs its full path (Windows does not search PATH itself).
97
+ terminalDriver(`pty:${program}`, resolveProgram(program), args, spawn, {
98
+ module: `${PREFIX}/client/terminal.ts`,
99
+ prefix: PREFIX,
100
+ }),
101
+ ),
102
+ )
84
103
 
85
104
  const auditFile = join(root, '.acryl', 'audit', 'driver-pty.jsonl')
86
105
  const audit = (entry: Record<string, unknown>): void => {
@@ -162,8 +181,8 @@ export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise
162
181
  ctx.effect(() => off)
163
182
 
164
183
  const app = new Acryl()
165
- .route(`${PREFIX}/client`, clientModules({ root: CLIENT_DIR }))
166
- .post(`${PREFIX}/sessions`, async (c) => {
184
+ .route(`${DRIVER_PATH}/client`, clientModules({ root: CLIENT_DIR, mount: `${PREFIX}/client` }))
185
+ .post(`${DRIVER_PATH}/sessions`, async (c) => {
167
186
  if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
168
187
  const body = (await c.req.json().catch(() => ({}))) as {
169
188
  driver?: unknown
@@ -177,10 +196,18 @@ export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise
177
196
  return c.json({ error: 'a terminal is already open; stop it first' }, 409)
178
197
  const num = (value: unknown, fallback: number) =>
179
198
  typeof value === 'number' && value >= 1 && value <= 1000 ? Math.floor(value) : fallback
180
- live = startSession(driver, num(body.cols, 80), num(body.rows, 24))
199
+ let session: Live
200
+ try {
201
+ session = startSession(driver, num(body.cols, 80), num(body.rows, 24))
202
+ } catch (error) {
203
+ // The cause (a native module that cannot spawn, a program that is gone) goes to the app's log; the page gets a plain answer, as everywhere else.
204
+ console.error('driver-pty: the terminal could not start:', error)
205
+ return c.json({ error: 'the terminal could not start; the app log has the reason' }, 500)
206
+ }
207
+ live = session
181
208
  return c.json({ id: live.id })
182
209
  })
183
- .get(`${PREFIX}/:id/output`, (c) => {
210
+ .get(`${DRIVER_PATH}/:id/output`, (c) => {
184
211
  if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
185
212
  const current = find(c.req.param('id'))
186
213
  if (!current) return c.json({ error: 'no such terminal' }, 404)
@@ -216,7 +243,7 @@ export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise
216
243
  })
217
244
  })
218
245
  })
219
- .post(`${PREFIX}/:id/input`, async (c) => {
246
+ .post(`${DRIVER_PATH}/:id/input`, async (c) => {
220
247
  if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
221
248
  const current = find(c.req.param('id'))
222
249
  if (!current || current.exit) return c.json({ error: 'no such terminal' }, 404)
@@ -226,7 +253,7 @@ export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise
226
253
  current.session.write(data)
227
254
  return c.json({ ok: true })
228
255
  })
229
- .post(`${PREFIX}/:id/resize`, async (c) => {
256
+ .post(`${DRIVER_PATH}/:id/resize`, async (c) => {
230
257
  if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
231
258
  const current = find(c.req.param('id'))
232
259
  if (!current || current.exit) return c.json({ error: 'no such terminal' }, 404)
@@ -239,12 +266,12 @@ export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise
239
266
  current.session.resize(cols, rows)
240
267
  return c.json({ ok: true })
241
268
  })
242
- .post(`${PREFIX}/:id/stop`, async (c) => {
269
+ .post(`${DRIVER_PATH}/:id/stop`, async (c) => {
243
270
  if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
244
271
  const current = find(c.req.param('id'))
245
272
  if (!current) return c.json({ error: 'no such terminal' }, 404)
246
273
  await current.dispose()
247
274
  return c.json({ ok: true })
248
275
  })
249
- ctx.router.route('/', app)
276
+ ctx.router.route(MOUNT === '' ? '/' : MOUNT, app)
250
277
  }
@@ -1,4 +1,6 @@
1
1
  /** The one place that touches `node-pty`, a native module. Everything else works on this small seam, so tests run on a fake process. */
2
+ import { statSync } from 'node:fs'
3
+ import { delimiter, join } from 'node:path'
2
4
 
3
5
  export interface PtyProcess {
4
6
  readonly pid: number
@@ -16,6 +18,42 @@ export type PtySpawn = (
16
18
  options: { cwd: string; env: NodeJS.ProcessEnv; cols: number; rows: number },
17
19
  ) => PtyProcess
18
20
 
21
+ /**
22
+ * The file a terminal runs: a full path. On Windows node-pty runs the file as it is given and does not search PATH - a bare `node` is "File not found" - so
23
+ * a bare program name is looked up once, with the platform's PATH separator and, on Windows, PATHEXT. A name that is not found comes back unchanged, and
24
+ * the start fails with the PTY's own error.
25
+ */
26
+ export function resolveProgram(
27
+ program: string,
28
+ env: NodeJS.ProcessEnv = process.env,
29
+ platform: NodeJS.Platform = process.platform,
30
+ ): string {
31
+ if (program === '' || program.includes('/') || (platform === 'win32' && program.includes('\\')))
32
+ return program
33
+ const windows = platform === 'win32'
34
+ const folders = (env.PATH ?? env.Path ?? '').split(windows ? ';' : delimiter)
35
+ const lower = program.toLowerCase()
36
+ const known = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
37
+ const suffixes = !windows
38
+ ? ['']
39
+ : known.some((extension) => lower.endsWith(extension.toLowerCase()))
40
+ ? ['']
41
+ : ['', ...new Set(known)]
42
+ for (const folder of folders) {
43
+ if (folder === '') continue
44
+ for (const suffix of suffixes) {
45
+ const candidate = join(folder, `${program}${suffix}`)
46
+ try {
47
+ const stat = statSync(candidate)
48
+ if (stat.isFile() && (windows || (stat.mode & 0o111) !== 0)) return candidate
49
+ } catch {
50
+ // Not here: the next folder, or the next extension.
51
+ }
52
+ }
53
+ }
54
+ return program
55
+ }
56
+
19
57
  /** Loads node-pty. Rejects with the reason (the error and the platform), so a log says why the driver is not offered. */
20
58
  export async function loadNodePty(): Promise<PtySpawn> {
21
59
  try {
@@ -22,10 +22,12 @@ export function terminalDriver(
22
22
  program: string,
23
23
  args: string[],
24
24
  spawn: PtySpawn,
25
+ view: TerminalDriver['view'],
25
26
  ): TerminalDriver {
26
27
  return {
27
28
  kind: 'terminal',
28
29
  name,
30
+ view,
29
31
  start({ cwd, env, cols, rows }): TerminalSession {
30
32
  // The agent runs `acryl look` without the folder prefix.
31
33
  const path = [join(cwd, '.acryl', 'bin'), env.PATH].filter(Boolean).join(delimiter)
@@ -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 =
@@ -227,6 +237,8 @@ export class Extensions extends Service {
227
237
  private tail: Promise<unknown> = Promise.resolve()
228
238
  private disposed = false
229
239
  private readonly readOnly: boolean
240
+ private readonly off: ReadonlySet<string>
241
+ private readonly rows: ReadonlySet<string>
230
242
  private readonly say: (line: string) => void
231
243
  /**
232
244
  * The context the service was constructed with. Inside a service method `this.ctx` is Cordis's shadow context, which resolves services as the
@@ -238,6 +250,8 @@ export class Extensions extends Service {
238
250
  super(ctx, 'extensions')
239
251
  this.owner = ctx
240
252
  this.readOnly = config.readOnly === true
253
+ this.off = new Set(config.off ?? [])
254
+ this.rows = new Set(config.rows ?? [])
241
255
  this.say = config.log ?? ((line) => console.log(line))
242
256
  const { home, root } = ctx.appInstance
243
257
  this.sources = (config.sources ?? [{ dir: join(home, 'extensions'), trust: 'install' }]).map(
@@ -258,8 +272,9 @@ export class Extensions extends Service {
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',
@@ -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(', ')}`)
@@ -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)
@@ -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
  }
@@ -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