acrylblends 1.0.3 → 1.0.5

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 (256) hide show
  1. package/docs/framework/agents/index.md +6 -4
  2. package/docs/framework/workshop/README.md +3 -3
  3. package/docs/framework/workshop/facilitator.md +1 -0
  4. package/lib/accept-CNNHu4Cf.js +193 -0
  5. package/lib/{accepts-XBuZPVRI.js → accepts-Btq83xPd.js} +1 -1
  6. package/lib/accepts.js +1 -1
  7. package/lib/acryl-base.js +1 -331
  8. package/lib/adapter-CaZ6FZUU.js +37 -0
  9. package/lib/adapter.js +1 -36
  10. package/lib/{app-BM0rTCp7.js → app-CQcGWjrk.js} +4 -4
  11. package/lib/app-base-CpRMZL4v.js +332 -0
  12. package/lib/{audit-BXnYsLge.d.ts → audit-D5z9uwOP.d.ts} +14 -1
  13. package/lib/{base-NSN_SNv-.js → base-D_6kfOr-.js} +1 -1
  14. package/lib/basic-auth-C0HTRpXh.js +20 -0
  15. package/lib/basic-auth-DFLflXNe.js +95 -0
  16. package/lib/basic-auth.js +1 -94
  17. package/lib/bearer-auth-Cwxt-vgS.js +94 -0
  18. package/lib/bearer-auth.js +1 -93
  19. package/lib/body-D7f4e7w_.js +94 -0
  20. package/lib/body-limit-WmIjWRLt.js +66 -0
  21. package/lib/body-limit.js +1 -65
  22. package/lib/buffer-CkEOJCcY.js +51 -0
  23. package/lib/bun.js +3 -3
  24. package/lib/cache-B0bHH7s5.js +169 -0
  25. package/lib/cache.js +1 -168
  26. package/lib/{client-DDLXwhBo.js → client-BrMFnMEd.js} +1 -1
  27. package/lib/{client-modules-CLvWOjbd.js → client-modules-drj4mjhj.js} +4 -4
  28. package/lib/client.js +1 -1
  29. package/lib/color-COa3DplO.js +37 -0
  30. package/lib/combine-TWuGdcbC.js +137 -0
  31. package/lib/combine.js +1 -136
  32. package/lib/{components-cEW3ovK0.js → components-B2ka9Ax1.js} +4 -4
  33. package/lib/{components-JXVm45kK.js → components-PVXwaOaC.js} +2 -2
  34. package/lib/compress-BzWqsHF1.js +79 -0
  35. package/lib/compress-C-9vK4B2.d.ts +11 -0
  36. package/lib/compress-DCyrrQ5D.js +11 -0
  37. package/lib/compress.d.ts +1 -1
  38. package/lib/compress.js +2 -78
  39. package/lib/concurrent-DrF3d2c-.js +30 -0
  40. package/lib/conninfo-BCROmdnf.d.ts +11 -0
  41. package/lib/conninfo-CWJwieh4.js +19 -0
  42. package/lib/constants-CBSEudrb.js +7 -0
  43. package/lib/{context-DDO0cfuP.js → context-CkOh7Ygv.js} +2 -2
  44. package/lib/context-storage-iuPoI_CR.js +52 -0
  45. package/lib/context-storage.js +1 -51
  46. package/lib/cookie-B1LlkzOe.js +125 -0
  47. package/lib/cookie-BHIduy2T.js +82 -0
  48. package/lib/cookie.js +1 -81
  49. package/lib/cordis/db-sqlite.d.ts +1 -1
  50. package/lib/cordis/door.d.ts +17 -3
  51. package/lib/cordis/door.js +123 -16
  52. package/lib/cordis/inspect.js +4 -4
  53. package/lib/cordis/kv-db.d.ts +1 -1
  54. package/lib/cordis/kv-db.js +1 -1
  55. package/lib/cordis/kv-json.d.ts +1 -1
  56. package/lib/cordis/kv-json.js +1 -1
  57. package/lib/cordis/node-server.d.ts +1 -1
  58. package/lib/cordis/node-server.js +3 -116
  59. package/lib/cordis/router-http.d.ts +1 -1
  60. package/lib/cordis/router-http.js +2 -2
  61. package/lib/cordis/router.d.ts +1 -1
  62. package/lib/cordis/router.js +4 -4
  63. package/lib/cordis/state.js +7 -7
  64. package/lib/{core-CaY8XDZ6.js → core-BOu1jYua.js} +1 -1
  65. package/lib/cors-CbAQ-8qU.js +106 -0
  66. package/lib/cors.js +1 -105
  67. package/lib/crypto-iVP-N-tS.js +34 -0
  68. package/lib/csrf-DqqOZtgy.js +101 -0
  69. package/lib/csrf.js +1 -100
  70. package/lib/css-C5CPVMvM.js +134 -0
  71. package/lib/css.js +2 -133
  72. package/lib/db/sqlite.d.ts +1 -25
  73. package/lib/dev-CLgu-JRe.js +50 -0
  74. package/lib/dev.js +1 -49
  75. package/lib/dom-YjND9Jt3.js +74 -0
  76. package/lib/door/host.d.ts +2 -2
  77. package/lib/door/host.js +1 -1
  78. package/lib/door/page.d.ts +12 -1
  79. package/lib/door/page.js +22 -1
  80. package/lib/door/protocol.d.ts +2 -2
  81. package/lib/door/protocol.js +2 -197
  82. package/lib/encode-BhbNLrjp.js +33 -0
  83. package/lib/factory-CLdSf-xS.js +30 -0
  84. package/lib/factory.js +1 -29
  85. package/lib/handler-C0wZ0KwO.js +12 -0
  86. package/lib/helpers-BEjcdbgw.js +5 -0
  87. package/lib/{hooks-w72iLeFR.js → hooks-CfC07ri0.js} +1 -1
  88. package/lib/html-CgNa89xw.js +31 -0
  89. package/lib/html-DGExBFNg.js +103 -0
  90. package/lib/html.d.ts +2 -5
  91. package/lib/html.js +2 -30
  92. package/lib/http-exception-MjWkT89V.js +54 -0
  93. package/lib/http-exception.js +1 -53
  94. package/lib/{index-D4nVR3wu.d.ts → index-DMOtA-NJ.d.ts} +7 -1
  95. package/lib/index-dAzQsJG9.d.ts +6 -0
  96. package/lib/index.js +3 -3
  97. package/lib/ip-restriction-jKtKCRM_.js +166 -0
  98. package/lib/ip-restriction.js +1 -165
  99. package/lib/ipaddr-DCoIo528.js +238 -0
  100. package/lib/jsx/dom/client.js +1 -1
  101. package/lib/jsx/dom/jsx-dev-runtime.js +1 -1
  102. package/lib/jsx/dom/jsx-runtime.js +2 -1
  103. package/lib/jsx/dom/server.js +3 -3
  104. package/lib/jsx/dom.js +5 -73
  105. package/lib/jsx/jsx-dev-runtime.d.ts +2 -5
  106. package/lib/jsx/jsx-dev-runtime.js +2 -13
  107. package/lib/jsx/jsx-runtime.d.ts +2 -2
  108. package/lib/jsx/jsx-runtime.js +4 -4
  109. package/lib/jsx/streaming.js +1 -1
  110. package/lib/jsx-CUQgttxU.js +47 -0
  111. package/lib/jsx-dev-runtime-B4WTyq8r.js +14 -0
  112. package/lib/{jsx-dev-runtime-CWLRA2F3.js → jsx-dev-runtime-BLfA-EhI.js} +2 -2
  113. package/lib/jsx-dev-runtime-CGt4kNYr.d.ts +6 -0
  114. package/lib/jsx-renderer-CfifM4hT.js +99 -0
  115. package/lib/jsx-renderer.js +1 -98
  116. package/lib/jsx-runtime-COCRWBJ9.js +2 -0
  117. package/lib/jsx.js +5 -46
  118. package/lib/{jwk-LkzeoaU8.js → jwk-CDPpYQpD.js} +3 -3
  119. package/lib/jwk.js +1 -1
  120. package/lib/{jwt-BGJBw5AF.js → jwt-BXoIA2fQ.js} +2 -2
  121. package/lib/{jwt-CskdhEW9.js → jwt-lyS4Q3mT.js} +3 -3
  122. package/lib/jwt.js +2 -2
  123. package/lib/kv/db.d.ts +1 -10
  124. package/lib/kv/db.js +1 -56
  125. package/lib/kv/json.d.ts +1 -9
  126. package/lib/kv/json.js +1 -223
  127. package/lib/{language-D9a4zqvn.js → language-DeGRtLJt.js} +2 -2
  128. package/lib/language.js +1 -1
  129. package/lib/{linear-router-C3FPib6h.js → linear-router-CmZn_JLV.js} +2 -2
  130. package/lib/logger-Ckxu8diz.js +54 -0
  131. package/lib/logger.js +1 -53
  132. package/lib/loopback-only-BbasoF7R.js +90 -0
  133. package/lib/loopback-only.js +1 -89
  134. package/lib/method-not-allowed-TO2_hnfl.js +82 -0
  135. package/lib/method-not-allowed.js +1 -81
  136. package/lib/method-override-DnZKfJbV.js +103 -0
  137. package/lib/method-override.js +1 -102
  138. package/lib/middleware-BfG_fp8P.js +12 -0
  139. package/lib/mime-BnScXkQn.js +77 -0
  140. package/lib/mount-QkK_3NtJ.js +67 -0
  141. package/lib/mount.js +1 -66
  142. package/lib/node-server/conninfo.d.ts +1 -10
  143. package/lib/node-server/conninfo.js +1 -18
  144. package/lib/node-server/register.js +25 -2
  145. package/lib/node-server/serve-static.d.ts +1 -17
  146. package/lib/node-server/serve-static.js +1 -1
  147. package/lib/node-server.d.ts +2 -2
  148. package/lib/node-server.js +4 -4
  149. package/lib/open-BqK4D2Wn.d.ts +11 -0
  150. package/lib/open-But9E2rL.js +57 -0
  151. package/lib/open-CULVJgOc.d.ts +12 -0
  152. package/lib/open-Dp4sxspc.js +224 -0
  153. package/lib/open-T7kgbO2O.d.ts +27 -0
  154. package/lib/{pattern-router-HZxjDGEQ.js → pattern-router-CJBecJBd.js} +1 -1
  155. package/lib/powered-by-CXQNjNIX.js +24 -0
  156. package/lib/powered-by.js +1 -23
  157. package/lib/pretty-json-CeC1yvYt.js +39 -0
  158. package/lib/pretty-json.js +1 -38
  159. package/lib/{protocol-BizVt4J6.d.ts → protocol-C37gPePD.d.ts} +9 -2
  160. package/lib/protocol-Dt0AZBOz.js +213 -0
  161. package/lib/proxy-C2w_mH7k.js +121 -0
  162. package/lib/proxy.js +1 -120
  163. package/lib/quick.js +4 -4
  164. package/lib/{reg-exp-router-DC1R3qoY.js → reg-exp-router-C_WV84eV.js} +2 -2
  165. package/lib/request-DvMgGtlW.js +323 -0
  166. package/lib/request.js +1 -322
  167. package/lib/route-D8m-a2I1.js +126 -0
  168. package/lib/route.js +1 -125
  169. package/lib/router/linear-router.js +1 -1
  170. package/lib/router/pattern-router.js +1 -1
  171. package/lib/router/reg-exp-router.js +1 -1
  172. package/lib/router/smart-router.js +1 -1
  173. package/lib/router/trie-router.js +1 -1
  174. package/lib/router-Be_o6nFu.js +35 -0
  175. package/lib/router.js +1 -34
  176. package/lib/schema-validator-EK3cUUCt.js +22 -0
  177. package/lib/schema-validator.js +1 -21
  178. package/lib/{secure-headers-mP3j6Hm6.js → secure-headers-C5ZOcXqk.js} +1 -1
  179. package/lib/secure-headers.js +1 -1
  180. package/lib/serve-static-B7sqnftW.d.ts +18 -0
  181. package/lib/{serve-static-3uo_9rjF.js → serve-static-C1Nkj_xB.js} +2 -2
  182. package/lib/{serve-static-Cp5adzGo.js → serve-static-EiJf1zkO.js} +4 -4
  183. package/lib/serve-static.js +1 -1
  184. package/lib/{smart-router-C5CZ5XSM.js → smart-router-BjxkDQCE.js} +1 -1
  185. package/lib/{snapshot-text-DmE0YfeZ.js → snapshot-text-Bbx43mN0.js} +57 -9
  186. package/lib/{src-2Z4mJg0x.js → src-BTBm4OPD.js} +22 -6
  187. package/lib/src-LX0E4ec4.js +117 -0
  188. package/lib/{ssg-BwjcA-Ge.js → ssg-BEl7MHqg.js} +5 -5
  189. package/lib/ssg.js +1 -1
  190. package/lib/stream-CzM_SHkq.js +88 -0
  191. package/lib/{streaming-DHST3Scg.js → streaming-BlDaph8U.js} +3 -3
  192. package/lib/streaming.js +1 -1
  193. package/lib/{test-websocket-D8WWqE5G.js → test-websocket-BLeEvybj.js} +2 -2
  194. package/lib/testing-BQx5T_UX.js +17 -0
  195. package/lib/testing.js +1 -16
  196. package/lib/timeout-FeqZ9A-0.js +42 -0
  197. package/lib/timeout.js +1 -41
  198. package/lib/tiny.js +2 -2
  199. package/lib/trailing-slash-DwTx-1sk.js +85 -0
  200. package/lib/trailing-slash.js +1 -84
  201. package/lib/{trie-router-CfFbapzL.js → trie-router-JydLKUa2.js} +2 -2
  202. package/lib/url-BPh4d6j-.js +197 -0
  203. package/lib/utils/accept.js +1 -192
  204. package/lib/utils/basic-auth.js +1 -19
  205. package/lib/utils/body.js +1 -93
  206. package/lib/utils/buffer.js +1 -50
  207. package/lib/utils/color.js +1 -36
  208. package/lib/utils/compress.d.ts +1 -10
  209. package/lib/utils/compress.js +1 -10
  210. package/lib/utils/concurrent.js +1 -29
  211. package/lib/utils/constants.js +1 -6
  212. package/lib/utils/cookie.js +1 -124
  213. package/lib/utils/crypto.js +1 -33
  214. package/lib/utils/encode.js +1 -32
  215. package/lib/utils/handler.js +1 -11
  216. package/lib/utils/html.js +1 -102
  217. package/lib/utils/ipaddr.js +1 -237
  218. package/lib/utils/jwt.js +1 -1
  219. package/lib/utils/mime.js +1 -76
  220. package/lib/utils/stream.js +1 -87
  221. package/lib/utils/url.js +1 -196
  222. package/lib/{validator-D84mq-dO.js → validator-Btiyt5VJ.js} +3 -3
  223. package/lib/validator.js +1 -1
  224. package/lib/websocket-DsyBIKVA.js +56 -0
  225. package/lib/ws.js +1 -55
  226. package/package.json +1 -1
  227. package/plugins/acryl-door/src/cli/acryl.ts +5 -3
  228. package/plugins/acryl-door/src/host/channel.ts +36 -0
  229. package/plugins/acryl-door/src/host/chat-tools.ts +2 -0
  230. package/plugins/acryl-door/src/host/door.ts +44 -5
  231. package/plugins/acryl-door/src/host/shim.ts +23 -0
  232. package/plugins/acryl-door/src/host/verbs.ts +4 -3
  233. package/plugins/acryl-door/src/index.ts +39 -3
  234. package/plugins/acryl-door/src/page/driver/driver.ts +22 -1
  235. package/plugins/acryl-door/src/page/protocol.ts +13 -2
  236. package/plugins/acryl-node-server/src/register.ts +29 -2
  237. package/plugins/acryl-router/src/service.ts +19 -1
  238. package/plugins/agent/model.ts +6 -1
  239. package/plugins/agent/tools.ts +12 -2
  240. package/plugins/agent-panel/client/describe.ts +44 -0
  241. package/plugins/agent-panel/client/main.ts +67 -35
  242. package/plugins/agent-panel/index.ts +137 -35
  243. package/plugins/desktop/attach.ts +72 -0
  244. package/plugins/desktop/main.ts +17 -4
  245. package/plugins/extensions/index.ts +3 -4
  246. package/plugins/extensions/stage.ts +33 -5
  247. package/plugins/openai/index.ts +1 -1
  248. package/shared/DEFAULTS.md +4 -0
  249. package/templates/agent/AGENTS.md +2 -0
  250. package/templates/agent/blend.yaml +4 -1
  251. package/templates/agent/src/app.ts +2 -0
  252. package/templates/agent/src/routes.ts +1 -1
  253. package/templates/agent/template.json +1 -0
  254. package/templates/agent/test/builder.test.ts +26 -21
  255. package/lib/helpers-CvTauQT5.js +0 -5
  256. package/lib/middleware-CPQ8himN.js +0 -12
@@ -23,7 +23,7 @@ import { DOOR_PREFIX, privateApi } from './host/http.ts'
23
23
  import { mcpRoutes } from './host/mcp-routes.ts'
24
24
  import { operatorRoutes } from './host/operator-routes.ts'
25
25
  import { pageChannelRoutes } from './host/page-routes.ts'
26
- import { writeShims, type ShimTarget } from './host/shim.ts'
26
+ import { realNode, writeShims, type ShimTarget } from './host/shim.ts'
27
27
 
28
28
  /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
29
29
  export const name = 'acryl-door'
@@ -54,10 +54,13 @@ export interface Config {
54
54
  * run it in production (a packaged desktop app whose user drives it with an agent) says so here; `ACRYL_DOOR=off` still wins.
55
55
  */
56
56
  allowInProduction?: boolean
57
+ /** Reload the open pages after a live plugin change, so an agent that built a feature sees it on the page it has open. Default: true. */
58
+ reloadOnChange?: boolean
57
59
  }
58
60
 
59
61
  export const Config: z<Config> = z.object({
60
62
  allowInProduction: z.boolean().default(false).description('Run in production too (NODE_ENV=production); ACRYL_DOOR=off still turns it off.'),
63
+ reloadOnChange: z.boolean().default(true).description('Reload the open pages after a plugin was added, removed or reloaded while the app runs.'),
61
64
  tools: z.object({
62
65
  // Not a `door_` name: those are the door's own verbs in the registry, never offered back as app tools.
63
66
  expose: z.array(z.string().pattern(/^(?!door_)[A-Za-z][A-Za-z0-9_-]{0,63}$/u)).description('App tools (from the tools registry) the door offers; none by default.'),
@@ -73,11 +76,14 @@ const CLIENT = import.meta.url.endsWith('.ts') ? join(import.meta.dirname, 'cli'
73
76
  /** How the folder's `.acryl/bin/acryl` starts the client: with the Node that runs the app, and its type-stripping flag when run from source. */
74
77
  function shimTarget(): ShimTarget {
75
78
  const fromSource = CLIENT.endsWith('.ts')
79
+ const inElectron = process.versions.electron !== undefined
80
+ // Inside Electron the command prefers a real Node (no code-signing noise on every call); without one, Electron runs the client.
81
+ const node = inElectron ? realNode() : undefined
76
82
  return {
77
- node: process.execPath,
83
+ node: node ?? process.execPath,
78
84
  nodeOptions: fromSource ? process.execArgv.filter(option => /^--experimental-(strip|transform)-types$/u.test(option)) : [],
79
85
  client: CLIENT,
80
- electron: process.versions.electron !== undefined,
86
+ electron: inElectron && node === undefined,
81
87
  }
82
88
  }
83
89
 
@@ -88,6 +94,13 @@ function loopbackUrl(url: string): string {
88
94
  return parsed.origin
89
95
  }
90
96
 
97
+ const escapeHtml = (text: string): string => text.replace(/[&<>"']/gu, char => `&#${char.charCodeAt(0)};`)
98
+
99
+ /** The page of a path the app does not serve, development only: what is missing, a way back, and the door's script. */
100
+ export function notFoundPage(path: string, scriptTag: string): string {
101
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 Not Found</title></head><body><main><h1>404 Not Found</h1><p>This app has no page at <code>${escapeHtml(path)}</code>.</p><p><a href="/">Back to the app</a></p></main>${scriptTag}</body></html>`
102
+ }
103
+
91
104
  export function apply(ctx: Context, rawConfig: Config = {}): void {
92
105
  const config = Config(rawConfig)
93
106
  // Development only: nothing is started, mounted or written in production, or when switched off.
@@ -108,12 +121,35 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
108
121
  extensions: () => ctx.get('extensions') as unknown as ExtensionsPort | undefined,
109
122
  tools: () => ctx.get('tools') as unknown as ToolsPort | undefined,
110
123
  expose: config.tools?.expose ?? [],
124
+ reloadOnChange: config.reloadOnChange !== false,
111
125
  })
112
126
  ctx.effect(() => () => { channel.close() }, 'acryl-door: page channel')
113
127
  // One script tag for the page (`page.js`): it imports the module tree served below it.
114
128
  const pageModule = new Acryl()
115
129
  .get(`${DOOR_PREFIX}/page.js`, c => c.body(`import '${DOOR_PREFIX}/page/main.ts'\n`, 200, { 'content-type': 'text/javascript; charset=utf-8' }))
116
130
  .route(`${DOOR_PREFIX}/page`, clientModules({ root: PAGE_DIR }))
131
+ // The page script on every HTML page the app answers, whenever its plugin was mounted (the pages of a plugin that loaded at boot, before this one, included): a page an agent writes
132
+ // can be looked at and driven without anyone adding the tag. A page that already carries it (the templates' own pages) is left as it is. Development only, like the rest of the door.
133
+ const tag = `<script type="module" src="${DOOR_PREFIX}/page.js"></script>`
134
+ const addScript = async (c: { res: Response; req: { path: string; header(name: string): string | undefined } }, next: () => Promise<void>): Promise<void> => {
135
+ await next()
136
+ if (!(c.res.headers.get('content-type') ?? '').includes('text/html')) {
137
+ // A path the app does not serve, opened as a page: the browser would show a bare "404 Not Found" with no script, and the window would be left where nothing can look at it or
138
+ // act on it (an agent that mistypes a path would need a person to bring the window back). The page says what is missing, carries the script, and keeps the 404.
139
+ if (c.res.status === 404 && (c.req.header('accept') ?? '').includes('text/html') && !(c.res.headers.get('content-type') ?? '').includes('json'))
140
+ c.res = new Response(notFoundPage(c.req.path, tag), { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } })
141
+ return
142
+ }
143
+ const html = await c.res.text()
144
+ const headers = new Headers(c.res.headers)
145
+ headers.delete('content-length')
146
+ const carries = html.includes(`${DOOR_PREFIX}/page.js`)
147
+ c.res = new Response(carries ? html : html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag, { status: c.res.status, headers })
148
+ }
149
+ const router = ctx.router as unknown as { wrap?: (middleware: typeof addScript) => unknown }
150
+ // An app whose router copy is older than this plugin has no `wrap`: the middleware then covers the routes mounted after the door, as the agent panel's does.
151
+ if (typeof router.wrap === 'function') router.wrap(addScript)
152
+ else ctx.router.route('/', new Acryl().use('*', addScript as never))
117
153
  ctx.router.route('/', privateApi(
118
154
  DOOR_PREFIX,
119
155
  pageModule,
@@ -5,7 +5,7 @@
5
5
  * shows. It touches only the document it is given, never another window and never the OS.
6
6
  */
7
7
 
8
- import { UiControlError, MUTATING_OPS, type UiRequest, type UiResult } from '../protocol.ts'
8
+ import { UiControlError, MUTATING_OPS, type UiActionResult, type UiRequest, type UiResult } from '../protocol.ts'
9
9
  import { click, pressKey, scroll, selectOption, typeText, wait, type ActionEnvironment } from './actions.ts'
10
10
  import { RefTable } from './ref-table.ts'
11
11
  import { SnapshotBuilder } from './snapshot.ts'
@@ -13,6 +13,8 @@ import { SnapshotBuilder } from './snapshot.ts'
13
13
  /** After the user touches the page, agent actions wait this long, so the user always wins. */
14
14
  const YIELD_MS = 400
15
15
  const MAX_YIELD_MS = 2000
16
+ /** How long after the answer a reload or a move starts, so the answer is on its way first. */
17
+ export const LEAVE_DELAY_MS = 150
16
18
 
17
19
  export interface DriverActivity {
18
20
  /** True from the moment a call starts until it ends. */
@@ -28,6 +30,8 @@ export interface UiDriverOptions {
28
30
  /** Milliseconds since the user last used the page (mouse, keyboard); Infinity when never. */
29
31
  readonly msSinceUserInput?: () => number
30
32
  readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>
33
+ /** Leave the page: reload it, or go to a path of the app. Default: the document's own `location`, a moment after the answer is sent. */
34
+ readonly leave?: (to: { readonly reload: true } | { readonly path: string }) => void
31
35
  }
32
36
 
33
37
  function realSleep(ms: number, signal: AbortSignal): Promise<void> {
@@ -130,9 +134,26 @@ export class UiDriver {
130
134
  case 'press': return pressKey(this.env, request)
131
135
  case 'scroll': return scroll(this.env, request)
132
136
  case 'wait': return wait(this.env, request, signal)
137
+ case 'reload': return this.leave({ reload: true }, 'reloading')
138
+ case 'navigate': return this.leave({ path: request.path }, `going to ${request.path}`)
133
139
  }
134
140
  }
135
141
 
142
+ /**
143
+ * The page is about to be replaced, so the answer is given first and the move happens a moment later (the answer is a POST that the page's own unload would cut off).
144
+ * The Host does not wait for this answer to be the last word: it waits for the page to come back.
145
+ */
146
+ private leave(to: { readonly reload: true } | { readonly path: string }, detail: string): UiActionResult {
147
+ const go = this.options.leave ?? ((target) => {
148
+ const location = this.options.document().defaultView?.location
149
+ if (location === undefined) return
150
+ if ('reload' in target) location.reload()
151
+ else location.assign(target.path)
152
+ })
153
+ setTimeout(() => { if (!this.disposed) go(to) }, LEAVE_DELAY_MS)
154
+ return { ok: true, detail }
155
+ }
156
+
136
157
  private async yieldToUser(signal: AbortSignal): Promise<void> {
137
158
  const since = this.options.msSinceUserInput
138
159
  if (since === undefined) return
@@ -7,11 +7,14 @@
7
7
  */
8
8
 
9
9
  /** The operations the driver performs on the page. */
10
- export const UI_OPS = ['snapshot', 'click', 'type', 'select', 'press', 'scroll', 'wait'] as const
10
+ export const UI_OPS = ['snapshot', 'click', 'type', 'select', 'press', 'scroll', 'wait', 'reload', 'navigate'] as const
11
11
  export type UiOp = (typeof UI_OPS)[number]
12
12
 
13
13
  /** Operations that change the page, so each call needs approval. `snapshot`, `scroll` and `wait` only look. */
14
- export const MUTATING_OPS: readonly UiOp[] = ['click', 'type', 'select', 'press']
14
+ export const MUTATING_OPS: readonly UiOp[] = ['click', 'type', 'select', 'press', 'reload', 'navigate']
15
+
16
+ /** A place in the app to go to: a path on the page's own origin (`/demo`, `/notes?q=a#top`), never another site and never a `//host` address. */
17
+ export const APP_PATH = /^\/(?![/\\])[^\0\\]{0,2000}$/u
15
18
 
16
19
  export const MAX_TEXT_LENGTH = 10_000
17
20
  export const MAX_WAIT_MS = 10_000
@@ -77,6 +80,8 @@ export type UiRequest =
77
80
  | { readonly op: 'press'; readonly key: string; readonly ref?: string }
78
81
  | { readonly op: 'scroll'; readonly direction: 'up' | 'down' | 'left' | 'right'; readonly ref?: string; readonly amount?: number }
79
82
  | { readonly op: 'wait'; readonly text?: string; readonly role?: string; readonly name?: string; readonly gone?: boolean; readonly timeoutMs?: number }
83
+ | { readonly op: 'reload' }
84
+ | { readonly op: 'navigate'; readonly path: string }
80
85
 
81
86
  /** What the page did, and to which control (for the audit log and the approval prompt). */
82
87
  export interface UiActionResult {
@@ -164,6 +169,12 @@ export function parseUiRequest(value: unknown): UiRequest {
164
169
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
165
170
  }
166
171
  }
172
+ case 'reload': only([]); return { op }
173
+ case 'navigate': {
174
+ only(['path'])
175
+ if (typeof value.path !== 'string' || !APP_PATH.test(value.path)) return invalid('path must be a path of this app, such as /demo (it starts with one /, and names no other site)')
176
+ return { op, path: value.path }
177
+ }
167
178
  default: return invalid(`unknown op ${String(op)}`)
168
179
  }
169
180
  }
@@ -7,9 +7,9 @@
7
7
  * `typescript` is loaded from the app, on the first JSX file only; the framework itself has no dependencies.
8
8
  */
9
9
 
10
+ import { existsSync, readFileSync } from 'node:fs'
10
11
  import { registerHooks } from 'node:module'
11
- import { readFileSync } from 'node:fs'
12
- import { fileURLToPath } from 'node:url'
12
+ import { fileURLToPath, pathToFileURL } from 'node:url'
13
13
  import { compileModule } from './transpile.ts'
14
14
 
15
15
  /** The JSX runtime the docs set up; an app's tsconfig.json (or a file's own `@jsxImportSource` pragma) chooses another. */
@@ -27,3 +27,30 @@ registerHooks({
27
27
  return { format: 'module', source, shortCircuit: true }
28
28
  },
29
29
  })
30
+
31
+ /**
32
+ * The framework's names for a file of the app: in an app the framework plugins are source under `plugins/`, not packages, so `acrylblends`, `acrylblends/body-limit` or `acryl-router`
33
+ * have nowhere to resolve from. A plugin installed while the app runs gets them from the extensions plugin; a plugin that is a ROW of `blend.yaml` is imported by `src/app.ts` before
34
+ * any of that exists, so the same names are resolved here, in the preload every `dev`, `start` and `test` script runs. Only a name that Node cannot resolve itself, only for the
35
+ * app's own files (never for something in `node_modules`), and only in an app that has the framework as vendored source: the published package resolves its own names.
36
+ */
37
+ const plugins = new URL('../../', import.meta.url)
38
+ const appRoot = new URL('../', plugins).href
39
+ const resolver = new URL('extensions/resolve.ts', plugins)
40
+ if (existsSync(fileURLToPath(new URL('acryl-router/src/index.ts', plugins))) && existsSync(fileURLToPath(resolver))) {
41
+ const { routerAliases } = (await import(resolver.href)) as { routerAliases(): Record<string, string> }
42
+ const aliases = routerAliases()
43
+ registerHooks({
44
+ resolve(specifier, context, nextResolve) {
45
+ try {
46
+ return nextResolve(specifier, context)
47
+ } catch (error) {
48
+ const alias = aliases[specifier]
49
+ const parent = context.parentURL
50
+ if (alias !== undefined && parent?.startsWith(appRoot) && !parent.includes('/node_modules/'))
51
+ return nextResolve(pathToFileURL(alias).href, context)
52
+ throw error
53
+ }
54
+ },
55
+ })
56
+ }
@@ -94,6 +94,8 @@ interface Mount {
94
94
 
95
95
  interface RouterState {
96
96
  readonly mounts: Set<Mount>
97
+ /** Middleware around every request, whenever it was added (see {@link Router.wrap}). */
98
+ readonly wrappers: Set<H>
97
99
  errorHandler: ErrorHandler | undefined
98
100
  notFoundHandler: NotFoundHandler | undefined
99
101
  /** The app composed from the live mounts; dropped on every change and composed again by the next request. */
@@ -125,7 +127,7 @@ async function drain(tasks: readonly Promise<unknown>[]): Promise<void> {
125
127
 
126
128
  export class Router extends Service {
127
129
  /** Everything that changes after construction, kept in one object so the service's live state is in one place. */
128
- private readonly state: RouterState = { mounts: new Set(), errorHandler: undefined, notFoundHandler: undefined, composed: undefined, closing: false }
130
+ private readonly state: RouterState = { mounts: new Set(), wrappers: new Set(), errorHandler: undefined, notFoundHandler: undefined, composed: undefined, closing: false }
129
131
  private readonly flights = new Set<Flight>()
130
132
  /** The flight of each request the router dispatched, keyed by the request its handlers see as `c.req.raw`. */
131
133
  private readonly flightOf = new WeakMap<Request, Flight>()
@@ -185,6 +187,21 @@ export class Router extends Service {
185
187
  return () => { void remove() }
186
188
  }
187
189
 
190
+ /**
191
+ * Run `middleware` around every request the router serves, whenever the plugin that asks was mounted: a middleware in a plain mount wraps only the routes mounted after it,
192
+ * which is the wrong tool for a plugin that must see every page (the door adds its script to each HTML response, the pages of a plugin that loaded earlier included). It goes
193
+ * outside all mounts, in the order wrappers were added, and goes away when the calling plugin unloads or when the returned function is called.
194
+ */
195
+ wrap(middleware: H): () => void {
196
+ this.state.wrappers.add(middleware)
197
+ this.state.composed = undefined
198
+ const remove = (): void => {
199
+ if (this.state.wrappers.delete(middleware)) this.state.composed = undefined
200
+ }
201
+ this.ctx.effect(() => remove, 'acryl-router: wrapper')
202
+ return remove
203
+ }
204
+
188
205
  /**
189
206
  * The mount's own copy of `app`'s routes as they are now (like the core's `app.route`), each handler wrapped so the router knows which
190
207
  * mount's code a request is in. The core's `app.route` runs first so a sub-app's own `onError` still applies to its routes.
@@ -331,6 +348,7 @@ export class Router extends Service {
331
348
  // over all routes on its first request, which grows faster than the route count (measured: 22 ms at 1000 routes, 163 ms at 2000),
332
349
  // while the trie rebuilds in about 1 ms per 1000 routes and matches as fast (scripts/bench-remount.mjs).
333
350
  const root = new Acryl({ strict: this.strict, router: new TrieRouter() })
351
+ for (const wrapper of this.state.wrappers) root.use('*', wrapper)
334
352
  for (const mount of this.state.mounts) root.route(mount.path, mount.app)
335
353
  root.onError(this.state.errorHandler ?? this.logError)
336
354
  if (this.state.notFoundHandler !== undefined) root.notFound(this.state.notFoundHandler)
@@ -21,7 +21,12 @@ export interface ModelInput {
21
21
  readonly reasoning?: string
22
22
  }>
23
23
  /** The tools the model may call. */
24
- readonly tools?: ReadonlyArray<{ readonly name: string; readonly description: string }>
24
+ readonly tools?: ReadonlyArray<{
25
+ readonly name: string
26
+ readonly description: string
27
+ /** The JSON Schema of the tool's input, when it has one. */
28
+ readonly parameters?: Readonly<Record<string, unknown>>
29
+ }>
25
30
  /** Aborts when the run is cancelled: a provider that can stop its request passes this on. */
26
31
  readonly signal?: AbortSignal
27
32
  }
@@ -3,6 +3,8 @@ import type { Context } from '@deepseek-ai/cordis'
3
3
  /** One tool: what it does (for the model) and how to run it. */
4
4
  export interface Tool {
5
5
  readonly description: string
6
+ /** The JSON Schema of the input (an object schema), sent to a real model so it knows which fields the tool takes. A tool without one takes any object. */
7
+ readonly parameters?: Readonly<Record<string, unknown>>
6
8
  /** `options.signal` aborts when the run is cancelled; a tool that can stop its work listens to it. */
7
9
  run(input: unknown, options?: { signal?: AbortSignal }): unknown | Promise<unknown>
8
10
  }
@@ -11,7 +13,11 @@ export interface Tool {
11
13
  export interface Tools {
12
14
  /** Register a tool. Returns the function that removes it. */
13
15
  register(name: string, tool: Tool): () => void
14
- list(): Array<{ name: string; description: string }>
16
+ list(): Array<{
17
+ name: string
18
+ description: string
19
+ parameters?: Readonly<Record<string, unknown>>
20
+ }>
15
21
  call(name: string, input: unknown, options?: { signal?: AbortSignal }): Promise<unknown>
16
22
  }
17
23
 
@@ -31,7 +37,11 @@ export function tools(ctx: Context) {
31
37
  return () => void table.delete(name)
32
38
  },
33
39
  list: () =>
34
- [...table.entries()].map(([name, tool]) => ({ name, description: tool.description })),
40
+ [...table.entries()].map(([name, tool]) => ({
41
+ name,
42
+ description: tool.description,
43
+ ...(tool.parameters === undefined ? {} : { parameters: tool.parameters }),
44
+ })),
35
45
  async call(name, input, options) {
36
46
  const tool = table.get(name)
37
47
  if (!tool) throw new Error(`unknown tool "${name}"`)
@@ -0,0 +1,44 @@
1
+ // One line for a tool event of the panel's conversation: what the tool is doing, not only its name. A coding agent started in the folder reports its own log (`{ tool: 'codex', info }`):
2
+ // its bookkeeping is dropped and its command or sentence is kept. The app's own agent reports a call (`{ tool, input, result | error }`): the name and a short form of the arguments.
3
+
4
+ const MAX = 160
5
+
6
+ const clip = (text: string): string => (text.length > MAX ? `${text.slice(0, MAX - 1)}…` : text)
7
+
8
+ /** A shell command as the agent's log writes it: `/bin/zsh -lc 'npm test' in /path/to/app` becomes `npm test`. */
9
+ function commandOf(line: string): string {
10
+ const withoutCwd = line.replace(/ in \/\S+\s*$/u, '')
11
+ const wrapped = /^(?:\/\S*\/)?(?:ba|z|da)?sh -l?c (['"])([\s\S]*)\1$/u.exec(withoutCwd.trim())
12
+ return wrapped?.[2] ?? withoutCwd.trim()
13
+ }
14
+
15
+ /** @returns the line to show, or `undefined` when the event says nothing worth a line (a hook ran, a token count). */
16
+ export function describeTool(data: Record<string, unknown>): string | undefined {
17
+ const tool = String(data.tool ?? '')
18
+ if (typeof data.info === 'string') {
19
+ const lines = data.info
20
+ .split('\n')
21
+ .map((line) => line.trim())
22
+ .filter(
23
+ (line) =>
24
+ line !== '' &&
25
+ !/^hook:/u.test(line) &&
26
+ !/^tokens used/u.test(line) &&
27
+ !/^[\d,]+$/u.test(line),
28
+ )
29
+ if (lines.length === 0) return undefined
30
+ const [first = '', second = ''] = lines
31
+ if (first === 'exec') return second === '' ? undefined : `${tool}: $ ${clip(commandOf(second))}`
32
+ // The output of a command that ran: only a failure is worth a line.
33
+ if (/^succeeded in /u.test(first)) return undefined
34
+ if (/^exited \d+/u.test(first)) return `${tool}: ${clip(first.replace(/:$/u, ''))}`
35
+ return `${tool}: ${clip(lines.join(' '))}`
36
+ }
37
+ const input =
38
+ data.input === undefined ||
39
+ (typeof data.input === 'object' && data.input !== null && Object.keys(data.input).length === 0)
40
+ ? ''
41
+ : ` ${clip(JSON.stringify(data.input))}`
42
+ const failed = data.error ? ` failed: ${clip(String(data.error))}` : ''
43
+ return `${tool}${input}${failed}`
44
+ }
@@ -1,5 +1,7 @@
1
1
  // The panel in the page: a button at the corner opens a side panel (a shadow root, so the app's CSS cannot touch it and it cannot touch the app's). It asks the app's
2
2
  // agent through POST /api/agent-panel/ask, shows the answer as it arrives, and reloads the page after a run that installed or removed a plugin.
3
+ import { describeTool } from './describe.ts'
4
+
3
5
  const PREFIX = '/api/agent-panel'
4
6
 
5
7
  interface Session {
@@ -88,6 +90,7 @@ async function start(): Promise<void> {
88
90
  const termStop = $<HTMLButtonElement>('.termstop')
89
91
  termStop.addEventListener('click', () => void terminal?.stop())
90
92
  const form = $<HTMLFormElement>('form')
93
+ document.body.append(host)
91
94
  const kindOf = (name: string): 'chat' | 'terminal' =>
92
95
  session.drivers.find((d) => d.name === name)?.kind ?? 'chat'
93
96
  let terminal: { detach(): void; stop(): Promise<void> } | undefined
@@ -147,40 +150,41 @@ async function start(): Promise<void> {
147
150
  return el
148
151
  }
149
152
 
150
- let controller: AbortController | undefined
151
- stop.addEventListener('click', () => controller?.abort())
152
- shadow.querySelector('form')!.addEventListener('submit', async (submitted) => {
153
- submitted.preventDefault()
154
- const message = input.value.trim()
155
- if (message === '' || controller) return
156
- input.value = ''
157
- line(message, 'me')
158
- controller = new AbortController()
153
+ // A run belongs to the app, not to this page: it goes on when the page reloads (the door reloads it after a plugin change, an agent may move it to another page) and the page that
154
+ // loads next follows it again. Only Stop cancels it.
155
+ const headers = { 'content-type': 'application/json', 'x-agent-panel-token': session.token }
156
+ let following = false
157
+ stop.addEventListener('click', () => void fetch(`${PREFIX}/stop`, { method: 'POST', headers }))
158
+ /**
159
+ * Show one run, as its events come, until it ends. `own` is true for a run this page just started (its question is on screen); a run found on load is shown from its question, and
160
+ * a run that had already ended before the page loaded is only shown, never reloads the page.
161
+ */
162
+ const watch = async (response: Response, own: boolean): Promise<void> => {
163
+ let live = true
164
+ following = true
159
165
  send.disabled = true
160
166
  stop.disabled = false
161
167
  let changed = false
162
168
  let answer: HTMLElement | undefined
163
169
  try {
164
- const response = await fetch(`${PREFIX}/ask`, {
165
- method: 'POST',
166
- headers: { 'content-type': 'application/json', 'x-agent-panel-token': session.token },
167
- body: JSON.stringify({ message, driver: driver.value }),
168
- signal: controller.signal,
169
- })
170
- if (!response.ok) {
171
- line(
172
- ((await response.json().catch(() => ({}))) as { error?: string }).error ??
173
- `The panel answered ${response.status}.`,
174
- 'err',
175
- )
176
- return
177
- }
178
170
  for await (const { event, data } of events(response)) {
179
- if (event === 'text') {
171
+ if (event === 'run') {
172
+ live = data.ended !== true
173
+ if (!own) {
174
+ if (typeof data.message === 'string') line(data.message, 'me')
175
+ if (typeof data.driver === 'string' && kindOf(data.driver) === 'chat')
176
+ driver.value = data.driver
177
+ const recent =
178
+ data.ended !== true ||
179
+ (typeof data.endedAt === 'number' && Date.now() - data.endedAt < 120_000)
180
+ if (recent) show(true)
181
+ }
182
+ } else if (event === 'text') {
180
183
  answer ??= line('')
181
184
  answer.textContent += String(data.text ?? '')
182
185
  } else if (event === 'tool') {
183
- line(`${String(data.tool)}${data.error ? ` failed: ${String(data.error)}` : ''}`, 'tool')
186
+ const described = describeTool(data)
187
+ if (described !== undefined) line(described, 'tool')
184
188
  } else if (event === 'change') {
185
189
  changed = true
186
190
  line(`plugin ${String(data.extension)}: ${String(data.action)}`, 'tool')
@@ -191,24 +195,52 @@ async function start(): Promise<void> {
191
195
  }
192
196
  }
193
197
  } catch (error) {
194
- line(
195
- controller?.signal.aborted
196
- ? 'Stopped.'
197
- : `The request failed: ${error instanceof Error ? error.message : String(error)}`,
198
- 'err',
199
- )
198
+ line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
200
199
  } finally {
201
- controller = undefined
200
+ following = false
202
201
  send.disabled = false
203
202
  stop.disabled = true
204
203
  }
205
- // A plugin was installed or removed: the new feature is served by the running app, so the page only needs to be loaded again (unless something is being typed).
206
- if (changed && input.value.trim() === '') {
204
+ // A plugin was installed or removed while this page watched: the new feature is served by the running app, so the page only needs to be loaded again (unless something is being typed).
205
+ // A run that ended before this page loaded was already shown after its reload, and does not reload it again.
206
+ if (live && changed && input.value.trim() === '') {
207
207
  line('Reloading the page to show it.', 'tool')
208
208
  setTimeout(() => location.reload(), 900)
209
209
  }
210
+ }
211
+ form.addEventListener('submit', async (submitted) => {
212
+ submitted.preventDefault()
213
+ const message = input.value.trim()
214
+ if (message === '' || following) return
215
+ input.value = ''
216
+ line(message, 'me')
217
+ try {
218
+ const response = await fetch(`${PREFIX}/ask`, {
219
+ method: 'POST',
220
+ headers,
221
+ body: JSON.stringify({ message, driver: driver.value }),
222
+ })
223
+ if (!response.ok) {
224
+ line(
225
+ ((await response.json().catch(() => ({}))) as { error?: string }).error ??
226
+ `The panel answered ${response.status}.`,
227
+ 'err',
228
+ )
229
+ return
230
+ }
231
+ await watch(response, true)
232
+ } catch (error) {
233
+ line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
234
+ }
210
235
  })
211
- document.body.append(host)
236
+ // The latest run, if there is one: shown again after a reload (its question, its events), and followed if it is still going.
237
+ try {
238
+ const latest = await fetch(`${PREFIX}/run`, { headers })
239
+ if (latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream'))
240
+ void watch(latest, false)
241
+ } catch {
242
+ // No run to show.
243
+ }
212
244
  // A terminal that was open before a reload is still running on the server: show it again.
213
245
  try {
214
246
  const remembered = JSON.parse(sessionStorage.getItem('driver-pty-session') ?? 'null') as {