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
@@ -73,12 +73,17 @@ function alive(pid: number): boolean {
73
73
  * Write this run's connection file, replacing a stale one.
74
74
  * @throws when another live process already serves this folder's door: two apps from one folder would race for one file.
75
75
  */
76
- export function writeDoorFile(appRoot: string, url: string, secret: string): string {
77
- const dir = ensureStateDir(appRoot)
76
+ /** @throws when another live process already serves this folder's door: two apps from one folder would race for one file. */
77
+ export function assertDoorIsFree(appRoot: string): void {
78
78
  const existing = readDoorFile(appRoot)
79
79
  if (existing !== undefined && existing.pid !== process.pid && alive(existing.pid)) {
80
80
  throw new Error(`acryl-door: another process (pid ${String(existing.pid)}) serves the door of ${appRoot}; stop it first`)
81
81
  }
82
+ }
83
+
84
+ export function writeDoorFile(appRoot: string, url: string, secret: string): string {
85
+ const dir = ensureStateDir(appRoot)
86
+ assertDoorIsFree(appRoot)
82
87
  const file = doorFilePath(appRoot)
83
88
  const temporary = join(dir, `${DOOR_FILE}.${String(process.pid)}.tmp`)
84
89
  const content: DoorFile = { version: 1, url, secret, pid: process.pid }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { existsSync } from 'node:fs'
8
8
  import { isAbsolute, relative, resolve } from 'node:path'
9
+ import { underAppBase } from '../page/base.ts'
9
10
  import { parseUiRequest, UiControlError, type AuditEntry, type UiActionResult, type UiRequest, type UiResult, type UiSnapshot } from '../page/protocol.ts'
10
11
  import { renderSnapshot } from '../page/snapshot-text.ts'
11
12
  import type { AuditLog } from './audit.ts'
@@ -14,7 +15,7 @@ import type { UiChannel } from './channel.ts'
14
15
  import { NO_DESKTOP, type ShellChannel } from './shell.ts'
15
16
  import { VERB_SPECS, VERBS } from './verbs.ts'
16
17
 
17
- export type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed' | ShellErrorCode
18
+ export type DoorErrorCode = 'invalid' | 'unknown-verb' | 'unavailable' | 'not-exposed' | 'outside-app' | 'failed' | 'refused' | ShellErrorCode
18
19
 
19
20
  export class DoorError extends Error {
20
21
  readonly code: DoorErrorCode
@@ -34,10 +35,45 @@ export interface ExtensionsPort {
34
35
  list(): readonly unknown[]
35
36
  }
36
37
 
38
+ /** The slice of acrylblends' `pluginShare` service the pull verb uses. */
39
+ export interface PluginSharePort {
40
+ pull(source: string): Promise<{ readonly ok: boolean; readonly action?: string; readonly reason?: string; readonly next?: string }>
41
+ planPublish(name: string, options?: PublishOptionsView): Promise<PublishPlanView | { readonly ok: false; readonly reason: string; readonly next: string }>
42
+ publish(name: string, confirmed: PublishPlanView, options?: PublishOptionsView): Promise<{ readonly ok: boolean; readonly reason?: string }>
43
+ }
44
+
45
+ /** Who a plan publishes as, and under which license; the service's defaults when absent. */
46
+ export interface PublishOptionsView {
47
+ readonly owner?: string
48
+ readonly license?: string
49
+ }
50
+
51
+ /** A plan as the door shows it. The service owns it; the door passes it back unchanged. */
52
+ export interface PublishPlanView {
53
+ readonly owner: string
54
+ readonly repo: string
55
+ readonly version: string
56
+ readonly visibility: string
57
+ readonly license: string
58
+ readonly createsRepository: boolean
59
+ readonly files: readonly string[]
60
+ readonly digest: string
61
+ readonly pullLine: string
62
+ }
63
+
64
+ /** What a plan says, for a person deciding. */
65
+ export function renderPublishPlan(plan: PublishPlanView): string {
66
+ return [
67
+ `Publish to ${plan.owner}/${plan.repo}${plan.createsRepository ? ' (a new repository)' : ''}, ${plan.visibility}, version ${plan.version}, license ${plan.license}.`,
68
+ `Files: ${plan.files.join(', ')}.`,
69
+ `A friend gets exactly these bytes with: ${plan.pullLine}`,
70
+ ].join('\n')
71
+ }
72
+
37
73
  /** The slice of acrylblends' `tools` registry (the `agent` plugin's) the tool verbs use, and where the built-in chat finds the verbs. */
38
74
  export interface ToolsPort {
39
75
  /** @returns the function that removes the tool. @throws when the name is taken. */
40
- register(name: string, tool: { readonly description: string; readonly parameters?: Readonly<Record<string, unknown>>; run(input: unknown): unknown }): () => void
76
+ register(name: string, tool: { readonly description: string; readonly parameters?: Readonly<Record<string, unknown>>; run(input: unknown, options?: { signal?: AbortSignal; page?: string }): unknown }): () => void
41
77
  list(): ReadonlyArray<{ readonly name: string; readonly description: string }>
42
78
  call(name: string, input: unknown): Promise<unknown>
43
79
  }
@@ -49,8 +85,12 @@ export interface DoorDeps {
49
85
  readonly audit: AuditLog
50
86
  /** The app's name, folder and, when it listens, its address (what `brief` prints so an agent can curl the running app). */
51
87
  readonly app: { readonly name: string; readonly root: string; readonly url?: string | undefined }
88
+ /** The prefix the app's pages answer under (the frame's canvas, `/app`); absent on the app's own server, at the root. */
89
+ readonly appBase?: string | undefined
52
90
  /** Read at each call: the services may come and go while the door runs. */
53
91
  readonly extensions: () => ExtensionsPort | undefined
92
+ /** Read at each call, as `extensions` is; absent where the app has no plugin-share row. */
93
+ readonly pluginShare?: () => PluginSharePort | undefined
54
94
  readonly tools: () => ToolsPort | undefined
55
95
  /** The app tools the door offers; anything else is refused before it reaches the registry. */
56
96
  readonly expose: readonly string[]
@@ -60,6 +100,11 @@ export interface DoorDeps {
60
100
  readonly pageBackMs?: number
61
101
  }
62
102
 
103
+ /** Who a verb runs for: `page`, the door's id of the page a run was asked from; its look and act go to that page alone (followed into its tab), never to another one. */
104
+ export interface VerbScope {
105
+ readonly page?: string
106
+ }
107
+
63
108
  /** What one verb answered: the value for `--json` and programs, the text for an agent reading a terminal. */
64
109
  export interface VerbAnswer {
65
110
  readonly value: unknown
@@ -96,20 +141,22 @@ export class Door {
96
141
  }
97
142
 
98
143
  /** Run one verb. @throws DoorError or UiControlError. */
99
- async run(verb: string, args: unknown, signal: AbortSignal): Promise<VerbAnswer> {
144
+ async run(verb: string, args: unknown, signal: AbortSignal, scope: VerbScope = {}): Promise<VerbAnswer> {
100
145
  const input = (typeof args === 'object' && args !== null && !Array.isArray(args) ? args : {}) as Record<string, unknown>
101
146
  switch (verb) {
102
147
  case 'brief': return this.brief()
103
148
  case 'look': {
104
- const snapshot = await this.look(input, signal)
149
+ const snapshot = await this.look(input, signal, scope)
105
150
  return { value: snapshot, text: renderSnapshot(snapshot, cursor => `run look --cursor ${String(cursor)}`) }
106
151
  }
107
152
  case 'act': {
108
- const result = await this.act(input, signal)
153
+ const result = await this.act(input, signal, scope)
109
154
  return { value: result, text: `done${result.target === undefined ? '' : `: ${result.target.role} "${result.target.name}"`}${result.detail === undefined ? '' : ` (${result.detail})`}; look again before the next act` }
110
155
  }
111
156
  case 'plugin-list': return this.plugin(extensions => extensions.list(), false)
112
157
  case 'plugin-add': return this.plugin(extensions => extensions.install(this.insideApp(input.folder)))
158
+ case 'plugin-pull': return this.pull(input.source)
159
+ case 'plugin-publish': return this.planPublish(input)
113
160
  case 'plugin-remove': {
114
161
  const name = input.name
115
162
  if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'plugin remove needs a plugin name')
@@ -151,30 +198,47 @@ export class Door {
151
198
  return { value, text }
152
199
  }
153
200
 
154
- look(input: Record<string, unknown>, signal: AbortSignal): Promise<UiSnapshot> {
155
- const { window, rest } = this.windowOf(input)
201
+ look(input: Record<string, unknown>, signal: AbortSignal, scope: VerbScope = {}): Promise<UiSnapshot> {
202
+ const { window, rest } = this.windowOf(input, scope)
156
203
  const request = this.parse({ ...rest, op: 'snapshot' })
157
- return this.callPage('look', request, signal, window) as Promise<UiSnapshot>
204
+ return this.callPage('look', request, signal, window, scope.page) as Promise<UiSnapshot>
158
205
  }
159
206
 
160
- async act(input: Record<string, unknown>, signal: AbortSignal): Promise<UiActionResult> {
207
+ async act(input: Record<string, unknown>, signal: AbortSignal, scope: VerbScope = {}): Promise<UiActionResult> {
161
208
  if (input.op === 'snapshot') throw new DoorError('invalid', 'use look to see the page')
162
- const { window, rest } = this.windowOf(input)
209
+ const { window, rest } = this.windowOf(input, scope)
163
210
  const request = this.parse(rest)
164
- if (request.op !== 'reload' && request.op !== 'navigate') return await this.callPage(`act ${String(input.op)}`, request, signal, window) as UiActionResult
165
- // The page is replaced: wait for the new one to connect, so the next look sees it. The page may drop before it has answered; that is the reload working.
166
- const back = this.deps.channel.expectNewPage(this.deps.pageBackMs ?? PAGE_BACK_MS)
211
+ const page = scope.page
212
+ // A reload or a navigate replaces the page; a click, a key or typing may (a link, a form that submits), and the page says so when it does.
213
+ const moves = request.op === 'reload' || request.op === 'navigate'
214
+ if (!moves && request.op !== 'click' && request.op !== 'press' && request.op !== 'type')
215
+ return await this.callPage(`act ${String(input.op)}`, request, signal, window, page) as UiActionResult
216
+ // Wait for the new page to connect, so the next look sees it; the wait starts before the act, or a fast page is missed. The page may drop before it has answered;
217
+ // that is the move working. A run's own page comes back in its own tab; no other page that happens to connect meanwhile is taken for it.
218
+ const backMs = this.deps.pageBackMs ?? PAGE_BACK_MS
219
+ const stay = new AbortController()
220
+ const back = page === undefined ? this.deps.channel.expectNewPage(backMs, stay.signal) : this.deps.channel.expectPageBack(page, backMs, stay.signal)
167
221
  let answer: UiActionResult
222
+ let left = moves
168
223
  try {
169
- answer = await this.callPage(`act ${request.op}`, request, signal, window) as UiActionResult
224
+ answer = await this.callPage(`act ${request.op}`, request, signal, window, page) as UiActionResult
225
+ left ||= answer.leaving === true
170
226
  } catch (cause) {
171
- if (!(cause instanceof UiControlError && cause.code === 'disconnected')) { void back; throw cause }
172
- answer = { ok: true, detail: request.op === 'reload' ? 'reloading' : `going to ${request.path}` }
227
+ if (!(cause instanceof UiControlError && cause.code === 'disconnected')) { stay.abort(); throw cause }
228
+ answer = { ok: true, detail: request.op === 'reload' ? 'reloading' : request.op === 'navigate' ? `going to ${request.path}` : 'the page is going to another address' }
229
+ left = true
230
+ }
231
+ if (!left) {
232
+ stay.abort()
233
+ return answer
173
234
  }
174
235
  const came = await back
175
- // The page that came back is the one this agent was driving: it stays the target until the person focuses another page.
176
- if (came !== undefined) this.deps.channel.prefer(came)
177
- if (came === undefined) throw new UiControlError('timeout', request.op === 'reload' ? 'the page did not come back after the reload' : `the page did not come back after going to ${request.path}: is there a page at that path, and does it load the door's script?`)
236
+ // The page that came back is the one this agent was driving: it stays the target until the person focuses another page. A run that names its page follows that page's tab instead.
237
+ if (came !== undefined && page === undefined) this.deps.channel.prefer(came)
238
+ if (came === undefined)
239
+ throw new UiControlError('timeout', request.op === 'reload' ? 'the page did not come back after the reload'
240
+ : request.op === 'navigate' ? `the page did not come back after going to ${request.path}: is there a page at that path, and does it load the door's script?`
241
+ : `the ${request.op} took the page to another address, and no page came back: does the page there load the door's script?`)
178
242
  const landed = request.op === 'navigate' ? await this.statusAt(request.path) : undefined
179
243
  return { ...answer, detail: `${answer.detail ?? 'done'}${landed === undefined ? '' : `; ${landed}`}; the page is back: look again` }
180
244
  }
@@ -187,7 +251,10 @@ export class Door {
187
251
  const base = this.deps.app.url
188
252
  if (base === undefined) return undefined
189
253
  try {
190
- const response = await fetch(new URL(path, base), { headers: { accept: 'text/html' }, signal: AbortSignal.timeout(3000) })
254
+ // The app's own path, under the host prefix when the pages answer there (the frame's canvas): /board is /app/board in the canvas and /board at the
255
+ // root, and the app's root is the prefix itself (`/app`, where its rows mount `/`), as the page's navigation join does it.
256
+ const target = underAppBase(this.deps.appBase ?? '', path)
257
+ const response = await fetch(new URL(target, base), { headers: { accept: 'text/html' }, signal: AbortSignal.timeout(3000) })
191
258
  await response.body?.cancel()
192
259
  return response.ok ? `${response.status} at ${path}` : `${response.status} at ${path}: the app serves no page there, look at what is shown and navigate to a path it does`
193
260
  } catch {
@@ -237,9 +304,11 @@ export class Door {
237
304
  }
238
305
 
239
306
  /** `window` on look and act: the desktop window (the number `window list` prints) whose page the call is for; without it, the page used last. */
240
- private windowOf(input: Record<string, unknown>): { window: number | undefined; rest: Record<string, unknown> } {
307
+ private windowOf(input: Record<string, unknown>, scope: VerbScope = {}): { window: number | undefined; rest: Record<string, unknown> } {
241
308
  const { window, ...rest } = input
242
309
  if (window === undefined) return { window: undefined, rest }
310
+ // A run that is for one page cannot be pointed at a desktop window instead.
311
+ if (scope.page !== undefined) throw new DoorError('invalid', 'this run drives only the page it was asked from; window does not apply')
243
312
  if (typeof window !== 'number' || !Number.isSafeInteger(window) || window < 1) throw new DoorError('invalid', 'window must be the number `window list` prints for the window')
244
313
  return { window, rest }
245
314
  }
@@ -253,10 +322,10 @@ export class Door {
253
322
  }
254
323
 
255
324
  /** Run one call on the page and record it. The audit holds which control was touched and how it ended, never what was typed. */
256
- private async callPage(verb: string, request: UiRequest, signal: AbortSignal, window?: number): Promise<UiResult> {
325
+ private async callPage(verb: string, request: UiRequest, signal: AbortSignal, window?: number, page?: string): Promise<UiResult> {
257
326
  const approval: AuditEntry['approval'] = 'token'
258
327
  try {
259
- const value = await this.deps.channel.call(request, { signal, ...(window === undefined ? {} : { window }), ...(request.op === 'wait' ? { timeoutMs: (request.timeoutMs ?? 3000) + 5000 } : {}) })
328
+ const value = await this.deps.channel.call(request, { signal, ...(window === undefined ? {} : { window }), ...(page === undefined ? {} : { page }), ...(request.op === 'wait' ? { timeoutMs: (request.timeoutMs ?? 3000) + 5000 } : {}) })
260
329
  const target = 'target' in value ? value.target : undefined
261
330
  this.deps.audit.record({ at: new Date().toISOString(), tool: verb, ...(target === undefined ? {} : { target }), ...(window === undefined ? {} : { window }), outcome: 'ok', approval })
262
331
  return value
@@ -270,7 +339,7 @@ export class Door {
270
339
 
271
340
  private async plugin(run: (extensions: ExtensionsPort) => unknown, mutates = true): Promise<VerbAnswer> {
272
341
  const extensions = this.deps.extensions()
273
- if (extensions === undefined) throw new DoorError('unavailable', 'this app has no extensions plugin, so plugins cannot be installed while it runs')
342
+ if (extensions === undefined) throw new DoorError('unavailable', 'this door has no extensions plugin to drive. In ACRYL\'s frame none is needed: a saved change in src/ or plugins/ reloads the app by itself, so do not restart it. Elsewhere, the app has no way to install plugins while it runs')
274
343
  const value = await run(extensions)
275
344
  // The app changed under the open pages: reload them (not when the plugin was already as it should be), and answer once they are back.
276
345
  const unchanged = typeof value === 'object' && value !== null && (value as { action?: unknown }).action === 'unchanged'
@@ -278,6 +347,41 @@ export class Door {
278
347
  return { value, text: JSON.stringify(value, null, 2) }
279
348
  }
280
349
 
350
+ private share(): PluginSharePort {
351
+ const share = this.deps.pluginShare?.()
352
+ if (share === undefined) throw new DoorError('unavailable', 'plugin-share is not running in this app. If the app just changed it is restarting, so try again in a moment. An app made by an older version does not have it, and upgrade does not add it: run `npx acrylblends add plugin-share` and follow its README')
353
+ return share
354
+ }
355
+
356
+ /** Only a plan: no argument of any verb can publish, so an agent never says yes for a person. */
357
+ private async planPublish(input: Record<string, unknown>): Promise<VerbAnswer> {
358
+ const { name, owner, license } = input
359
+ if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'plugin publish needs a plugin name, e.g. plugin publish pomodoro')
360
+ const value = await this.share().planPublish(name, {
361
+ ...(typeof owner === 'string' && owner !== '' ? { owner } : {}),
362
+ ...(typeof license === 'string' && license !== '' ? { license } : {}),
363
+ })
364
+ if ('ok' in value) throw new DoorError('refused', `${value.reason}. ${value.next}.`)
365
+ return { value, text: `${renderPublishPlan(value)}\nNothing was sent. Only a person can say yes: they run \`npm run acryl -- plugin publish ${name}\` in their own terminal, or press Share in the frame. \`--license <id>\` changes the license before the yes.` }
366
+ }
367
+
368
+ /** The person's yes, from their own terminal. Not a verb: MCP and the chat do not have it. */
369
+ async publishConfirmed(name: string, confirmed: PublishPlanView, options: PublishOptionsView): Promise<unknown> {
370
+ const value = await this.share().publish(name, confirmed, options)
371
+ this.deps.audit.record({ at: new Date().toISOString(), tool: 'plugin-publish', target: { role: 'plugin', name: `${confirmed.owner}/${confirmed.repo}@${confirmed.version}` }, outcome: value.ok ? 'ok' : 'refused', approval: 'token' })
372
+ return value
373
+ }
374
+
375
+ private async pull(source: unknown): Promise<VerbAnswer> {
376
+ if (typeof source !== 'string' || source === '') throw new DoorError('invalid', 'plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0')
377
+ const value = await this.share().pull(source)
378
+ // A refusal is a failed command, so a script does not read it as done; it changed nothing, and the open pages stay.
379
+ if (!value.ok) throw new DoorError('refused', `${value.reason ?? 'the pull was refused'}. ${value.next ?? 'nothing changed'}.`)
380
+ // Neither did pulling what is already there change anything.
381
+ if (value.action !== 'unchanged' && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll()
382
+ return { value, text: JSON.stringify(value, null, 2) }
383
+ }
384
+
281
385
  /** A folder named by an agent, resolved from the app folder; it must be inside the app. */
282
386
  private insideApp(folder: unknown): string {
283
387
  if (typeof folder !== 'string' || folder === '') throw new DoorError('invalid', 'plugin add needs a folder, e.g. plugins/pomodoro')
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { Acryl } from 'acryl-router'
9
9
  import { UiControlError } from '../page/protocol.ts'
10
- import { Door, DoorError } from './door.ts'
10
+ import { Door, DoorError, type PublishPlanView } from './door.ts'
11
11
  import { error, INVALID_JSON, jsonBody, operatorOnly, readJson, type ReportError } from './http.ts'
12
12
  import { VERBS } from './verbs.ts'
13
13
 
@@ -35,4 +35,24 @@ export function operatorRoutes(prefix: string, secret: string, door: Door, repor
35
35
  return c.json(error('the verb could not be run'), 500)
36
36
  }
37
37
  })
38
+ // A person's yes to a plan, from the prompt in their own terminal. Not a verb, so MCP and the chat do not offer it. An agent with this machine's
39
+ // secret could still reach it: the door keeps honest agents from publishing by accident, the person's account is the real boundary.
40
+ .post(`${prefix}/publish-confirmed`, operatorOnly(secret), jsonBody, async (c) => {
41
+ const body = await readJson(c)
42
+ const { name, confirm, owner, license } = (body === INVALID_JSON || typeof body !== 'object' || body === null ? {} : body) as { name?: unknown; confirm?: unknown; owner?: unknown; license?: unknown }
43
+ if (typeof name !== 'string' || typeof confirm !== 'object' || confirm === null) {
44
+ return c.json({ ok: false, code: 'invalid', message: 'the body is { name, confirm }' } satisfies CallResponse, 400)
45
+ }
46
+ try {
47
+ const value = await door.publishConfirmed(name, confirm as PublishPlanView, {
48
+ ...(typeof owner === 'string' ? { owner } : {}),
49
+ ...(typeof license === 'string' ? { license } : {}),
50
+ })
51
+ return c.json({ ok: true, value, text: JSON.stringify(value, null, 2) } satisfies CallResponse)
52
+ } catch (cause) {
53
+ if (cause instanceof DoorError) return c.json({ ok: false, code: cause.code, message: cause.message } satisfies CallResponse, 409)
54
+ reportError('publish a plugin', cause)
55
+ return c.json(error('the plugin could not be published'), 500)
56
+ }
57
+ })
38
58
  }
@@ -34,6 +34,8 @@ export function pageChannelRoutes(channel: UiChannel, options: PageRoutesOptions
34
34
  const focused = c.req.query('focused') === '1'
35
35
  const shellQuery = Number(c.req.query('shell'))
36
36
  const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : undefined
37
+ const tabQuery = c.req.query('tab')
38
+ const tab = tabQuery !== undefined && WINDOW_ID.test(tabQuery) ? tabQuery : undefined
37
39
  const signal = c.req.raw.signal
38
40
  return streamSSE(c, async (stream) => {
39
41
  let end = (): void => {}
@@ -43,7 +45,7 @@ export function pageChannelRoutes(channel: UiChannel, options: PageRoutesOptions
43
45
  const write = (event: string, data: string): void => {
44
46
  stream.writeSSE({ event, data }).catch(end)
45
47
  }
46
- const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, notify: write, close: end }, shellWindow)
48
+ const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, notify: write, close: end }, shellWindow, tab)
47
49
  const beat = setInterval(() => { write('ping', '') }, heartbeatMs)
48
50
  try {
49
51
  write('ready', '')
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { Acryl, type MiddlewareHandler } from 'acryl-router'
12
- import type { UpgradeWebSocket } from 'acryl-router/helpers'
12
+ import type { UpgradeWebSocket, WSEvents } from 'acryl-router/helpers'
13
13
  import { loopbackAuthority } from 'acryl-router/middleware'
14
14
  import { parseChannelToHost, WINDOW_ID } from '../page/protocol.ts'
15
15
  import type { UiChannel } from './channel.ts'
@@ -20,7 +20,8 @@ export const SOCKET_HEARTBEAT_MS = 15_000
20
20
 
21
21
  export interface PageSocketOptions {
22
22
  readonly prefix: string
23
- readonly upgrade: UpgradeWebSocket
23
+ /** The live upgrade helper: the route is mounted once, so the helper is read per request (a restarting host has a new one). */
24
+ readonly upgrade: () => UpgradeWebSocket | undefined
24
25
  readonly heartbeatMs?: number
25
26
  }
26
27
 
@@ -39,14 +40,13 @@ export const ownOrigin: MiddlewareHandler = async (c, next) => {
39
40
 
40
41
  export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions): Acryl {
41
42
  const heartbeatMs = options.heartbeatMs ?? SOCKET_HEARTBEAT_MS
42
- return new Acryl().get(
43
- `${options.prefix}/channel/ws`,
44
- ownOrigin,
45
- options.upgrade((c) => {
43
+ const events = (c: Parameters<MiddlewareHandler>[0]): WSEvents => {
46
44
  const windowId = c.req.query('window') as string
47
45
  const focused = c.req.query('focused') === '1'
48
46
  const shellQuery = Number(c.req.query('shell'))
49
47
  const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : undefined
48
+ const tabQuery = c.req.query('tab')
49
+ const tab = tabQuery !== undefined && WINDOW_ID.test(tabQuery) ? tabQuery : undefined
50
50
  let detach: (() => void) | undefined
51
51
  let beat: NodeJS.Timeout | undefined
52
52
  return {
@@ -55,7 +55,7 @@ export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions)
55
55
  ws.send(JSON.stringify(frame))
56
56
  }
57
57
  // A call that cannot be written fails at once (`disconnected`), and a socket that is gone is dropped by its close: the door never holds a call for it.
58
- detach = channel.attach(windowId, focused, { send: data => { write({ e: 'call', d: data }) }, notify: (event, data) => { write({ e: event, d: data }) }, close: () => { ws.close(1000, 'replaced') } }, shellWindow)
58
+ detach = channel.attach(windowId, focused, { send: data => { write({ e: 'call', d: data }) }, notify: (event, data) => { write({ e: event, d: data }) }, close: () => { ws.close(1000, 'replaced') } }, shellWindow, tab)
59
59
  write({ e: 'ready' })
60
60
  beat = setInterval(() => {
61
61
  try { write({ e: 'ping' }) } catch { /* the close event follows */ }
@@ -73,6 +73,16 @@ export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions)
73
73
  detach?.()
74
74
  },
75
75
  }
76
- }, { textOnly: true }),
76
+ }
77
+ // Mounted once, ahead of the private routes: the route must keep its precedence across a host restart, so the middleware asks for the live helper
78
+ // at request time and refuses cleanly while none is ready.
79
+ return new Acryl().get(
80
+ `${options.prefix}/channel/ws`,
81
+ ownOrigin,
82
+ async (c, next) => {
83
+ const allow = options.upgrade()
84
+ if (allow === undefined) return c.json(error('the page channel is not ready yet'), 503)
85
+ return allow(events, { textOnly: true })(c, next)
86
+ },
77
87
  )
78
88
  }
@@ -6,7 +6,10 @@
6
6
  import { MAX_SNAPSHOT_NODES, MAX_WAIT_MS } from '../page/protocol.ts'
7
7
 
8
8
  /** Every verb, in the order `brief` lists them. */
9
- export const VERBS = ['brief', 'look', 'act', 'plugin-list', 'plugin-add', 'plugin-remove', 'plugin-reload', 'tool-list', 'tool-call', 'window-list', 'window-open', 'window-focus', 'window-close', 'window-resize', 'menu-list', 'menu-click'] as const
9
+ /** The verbs that only use the page: what a page-only door (a production assistant) offers. */
10
+ export const PAGE_VERBS = ['look', 'act'] as const satisfies readonly Verb[]
11
+
12
+ export const VERBS = ['brief', 'look', 'act', 'plugin-list', 'plugin-add', 'plugin-pull', 'plugin-publish', 'plugin-remove', 'plugin-reload', 'tool-list', 'tool-call', 'window-list', 'window-open', 'window-focus', 'window-close', 'window-resize', 'menu-list', 'menu-click'] as const
10
13
  export type Verb = (typeof VERBS)[number]
11
14
 
12
15
  /** The JSON Schema of a verb's arguments, as MCP lists it. */
@@ -83,6 +86,16 @@ export const VERB_SPECS: Readonly<Record<Verb, VerbSpec>> = {
83
86
  description: 'Check, install and start a plugin folder inside the app, without a restart.',
84
87
  arguments: { type: 'object', properties: { folder: { type: 'string', description: 'a folder inside the app, e.g. plugins/pomodoro' } }, required: ['folder'], additionalProperties: false },
85
88
  },
89
+ 'plugin-pull': {
90
+ usage: 'plugin pull <source>: fetch a plugin someone released (github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz), check it and start it, live',
91
+ description: 'Fetch a plugin someone released, without running anything; check its digest, that it works on every OS and that this app has the services it needs; then install and start it without a restart. A refusal says why and changes nothing.',
92
+ arguments: { type: 'object', properties: { source: { type: 'string', description: 'github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz path or https address' } }, required: ['source'], additionalProperties: false },
93
+ },
94
+ 'plugin-publish': {
95
+ usage: 'plugin publish <name> [--owner <account>] [--license <id>]: show what publishing this plugin would send; in a person\'s own terminal, ask them',
96
+ description: 'Show what publishing a plugin would send: the account, repository, version, license, files and the line a friend pulls it with. Sends nothing. Only a person can say yes, in their own terminal or with Share in the frame; tell them the plan and how.',
97
+ arguments: { type: 'object', properties: { name: { type: 'string', description: 'a plugin of plugins/' }, owner: { type: 'string', description: 'the GitHub account or organization; the signed-in one when absent' }, license: { type: 'string', description: 'an SPDX license id; MIT when absent' } }, required: ['name'], additionalProperties: false },
98
+ },
86
99
  'plugin-remove': {
87
100
  usage: 'plugin remove <name>: stop and remove an installed plugin',
88
101
  description: 'Stop and remove an installed plugin.',