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
@@ -1,6 +1,8 @@
1
1
  /**
2
- * The desktop shell: `npm run desktop` starts the app's own server in this Electron main process and opens one window on its address. Nothing in the app
3
- * imports electron; every Electron call of the app is in this file and `window.ts`, so a different shell is a change here only.
2
+ * The desktop shell: `npm run desktop` opens the app in a window. A frame app (`scripts/ide-host.mjs`) gets the whole ACRYL frame: the window attaches to the
3
+ * frame host that runs for this folder, or starts it as `npm run dev` does (`frame.ts`, docs/plans/ide-host-inversion.md section 27). An app made before the frame
4
+ * starts its own server in this Electron main process and opens one window on its address. Nothing in the app imports electron; every Electron call of the app
5
+ * is in this file and `window.ts`, so a different shell is a change here only.
4
6
  *
5
7
  * Order matters: the user-data folder is set before anything else (and before the single-instance lock), so one app never shares window state, logs or the lock with
6
8
  * another app or with the real user's data.
@@ -20,10 +22,20 @@ import {
20
22
  startDoorShell,
21
23
  type WindowPort,
22
24
  } from './door-shell.ts'
25
+ import {
26
+ type FrameHost,
27
+ frameHome,
28
+ isFrameApp,
29
+ navigation,
30
+ openFrameSettings,
31
+ runningFrame,
32
+ startFrameHost,
33
+ withoutToken,
34
+ } from './frame.ts'
23
35
  import { hasSettings, settingsPlacement } from './menu.ts'
24
36
  import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
25
37
  import { resolveDesktopUserDataOverride } from './user-data.ts'
26
- import { cascade, windowOptions } from './window.ts'
38
+ import { cascade, windowOptions, windowSize } from './window.ts'
27
39
 
28
40
  const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
29
41
  const chosen = selectInstance({ projectRoot, env: process.env })
@@ -47,6 +59,7 @@ if (!app.requestSingleInstanceLock()) {
47
59
  app.quit()
48
60
  } else {
49
61
  let running: App | undefined
62
+ let host: FrameHost | undefined
50
63
  let window: BrowserWindow | undefined
51
64
  let stopDoorShell: (() => void) | undefined
52
65
 
@@ -54,6 +67,8 @@ if (!app.requestSingleInstanceLock()) {
54
67
  async () => {
55
68
  stopDoorShell?.()
56
69
  await running?.stop()
70
+ // The frame host this window started (never one it attached to) goes with the window, with every terminal it runs.
71
+ await host?.stop()
57
72
  },
58
73
  (code) => {
59
74
  app.exit(code)
@@ -77,9 +92,33 @@ if (!app.requestSingleInstanceLock()) {
77
92
  // The hook above is registered before the app's modules are loaded, so the app may have .tsx pages: they are loaded here, not as a static import.
78
93
  // A dev server is already running from this folder (`npm run dev`): the door allows one process per folder, so the window opens on that server instead of starting a second one,
79
94
  // and closing the window leaves it running.
80
- const attached = await runningApp(projectRoot)
95
+ const frame = isFrameApp(projectRoot)
96
+ const attached = frame ? undefined : await runningApp(projectRoot)
81
97
  let url: string
82
- if (attached !== undefined) {
98
+ if (frame) {
99
+ // The frame that already runs for this folder (`npm run dev`, or another window's host), else a host of this window's own.
100
+ const runningAddress = await runningFrame(frameHome(projectRoot, process.env))
101
+ if (runningAddress !== undefined) {
102
+ console.log(
103
+ `desktop: attached to the frame already running from this folder at ${new URL(runningAddress).origin} (it keeps running when the window closes)`,
104
+ )
105
+ url = runningAddress
106
+ } else {
107
+ host = startFrameHost(projectRoot, process.env, (text) => {
108
+ process.stdout.write(text)
109
+ })
110
+ try {
111
+ url = await host.ready
112
+ } catch (cause) {
113
+ console.error(`desktop: ${cause instanceof Error ? cause.message : String(cause)}`)
114
+ await shutdown.request(1)
115
+ return
116
+ }
117
+ console.log(
118
+ `desktop: the frame host serves ${new URL(url).origin}; closing the window stops it`,
119
+ )
120
+ }
121
+ } else if (attached !== undefined) {
83
122
  console.log(
84
123
  `desktop: attached to the app already running from this folder at ${attached.url} (its server keeps running when the window closes)`,
85
124
  )
@@ -90,14 +129,23 @@ if (!app.requestSingleInstanceLock()) {
90
129
  url = running.url
91
130
  }
92
131
  const origin = new URL(url).origin
93
- /** One window on a path of the app. Navigation stays on the app's own server; any other address opens in the system browser. The user agent names the window to the door (`AcrylWindow/<id>`), so an agent can drive it with `--window <id>`. */
132
+ /** Where a window opens: the frame's address (with its token) for a frame app, else a path of the app (`acrylblends.desktopPath`, else the root). */
133
+ const addressOf = (path: string | undefined): string =>
134
+ frame && path === undefined ? url : new URL(path ?? startPath(), url).href
135
+ /** One window on an address of the app. Navigation follows `navigation` in frame.ts. The user agent names the window to the door (`AcrylWindow/<id>`), so an agent can drive it with `--window <id>`. */
94
136
  // Where each window was opened: `window list` shows it until the page reports its own address (Electron gives none while the first load has not started).
95
137
  const openedOn = new Map<number, string>()
96
- const createWindow = (path: string): BrowserWindow => {
138
+ const createWindow = (path: string | undefined): BrowserWindow => {
97
139
  // A window opened while another is open sits a step down and to the right of it, not exactly on top.
98
140
  const beside =
99
141
  BrowserWindow.getFocusedWindow() ??
100
142
  BrowserWindow.getAllWindows().find((other) => !other.isDestroyed())
143
+ const work = (
144
+ beside === undefined
145
+ ? screen.getPrimaryDisplay()
146
+ : screen.getDisplayMatching(beside.getBounds())
147
+ ).workArea
148
+ const size = windowSize(work)
101
149
  const where = (() => {
102
150
  if (beside === undefined) return {}
103
151
  const bounds = beside.getBounds()
@@ -106,44 +154,49 @@ if (!app.requestSingleInstanceLock()) {
106
154
  .filter((other) => !other.isDestroyed())
107
155
  .map((other) => other.getPosition())
108
156
  .map(([x = 0, y = 0]) => ({ x, y }))
109
- return cascade(
110
- bounds,
111
- screen.getDisplayMatching(bounds).workArea,
112
- { width: 1100, height: 760 },
113
- taken,
114
- )
157
+ return cascade(bounds, work, size, taken)
115
158
  })()
116
- const created = new BrowserWindow(windowOptions({ title: chosen.name, ...where }))
117
- openedOn.set(created.id, path)
159
+ const created = new BrowserWindow(windowOptions({ title: chosen.name, ...size, ...where }))
160
+ const address = addressOf(path)
161
+ openedOn.set(created.id, new URL(address).pathname)
118
162
  created.webContents.setUserAgent(
119
163
  `${created.webContents.getUserAgent()} AcrylWindow/${String(created.id)}`,
120
164
  )
121
165
  created.once('ready-to-show', () => created.show())
122
- created.webContents.setWindowOpenHandler(({ url }) => {
123
- void shell.openExternal(url)
166
+ // A new window of the page never opens inside the app: an http(s) or mailto address goes to the system browser, anything else nowhere.
167
+ created.webContents.setWindowOpenHandler(({ url: target }) => {
168
+ if (navigation(target, '') === 'external') void shell.openExternal(target)
124
169
  return { action: 'deny' }
125
170
  })
126
- created.webContents.on('will-navigate', (event, url) => {
127
- if (new URL(url).origin === origin) return
171
+ // Only the main frame is checked, so the frame's tiles (the app's page in an iframe) navigate freely; the window itself stays on the app's origin.
172
+ created.webContents.on('will-frame-navigate', (event) => {
173
+ const decision = navigation(event.url, origin, event.isMainFrame)
174
+ if (decision === 'stay') return
128
175
  event.preventDefault()
129
- void shell.openExternal(url)
176
+ if (decision === 'external') void shell.openExternal(event.url)
177
+ })
178
+ created.webContents.on('will-redirect', (event, target, _inPlace, isMainFrame) => {
179
+ if (navigation(target, origin, isMainFrame) !== 'stay') event.preventDefault()
130
180
  })
131
- created.loadURL(new URL(path, url).href).catch((cause: unknown) => {
181
+ created.loadURL(address).catch((cause: unknown) => {
132
182
  console.warn(
133
- `desktop: the window could not load ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
183
+ `desktop: the window could not load ${new URL(address).pathname}: ${cause instanceof Error ? cause.message : String(cause)}`,
134
184
  )
135
185
  })
136
186
  return created
137
187
  }
138
- window = createWindow(startPath())
188
+ window = createWindow(frame ? undefined : startPath())
139
189
  // The door (development) reaches the windows and the menu through this port: `acryl window list`, `acryl menu click`.
140
190
  const live = (): BrowserWindow[] =>
141
191
  BrowserWindow.getAllWindows().filter((candidate) => !candidate.isDestroyed())
142
192
  const port = (candidate: BrowserWindow): WindowPort => ({
143
193
  id: candidate.id,
144
194
  getTitle: () => candidate.getTitle(),
195
+ // Without the frame's launch token: the door prints this address and its audit keeps it.
145
196
  getURL: () =>
146
- candidate.webContents.getURL() || new URL(openedOn.get(candidate.id) ?? '/', url).href,
197
+ withoutToken(
198
+ candidate.webContents.getURL() || new URL(openedOn.get(candidate.id) ?? '/', url).href,
199
+ ),
147
200
  isFocused: () => candidate.isFocused(),
148
201
  isMinimized: () => candidate.isMinimized(),
149
202
  getSize: () => candidate.getSize(),
@@ -169,17 +222,20 @@ if (!app.requestSingleInstanceLock()) {
169
222
  })
170
223
  const desktop: DesktopPort = {
171
224
  windows: () => live().map(port),
172
- open: (path) => port(createWindow(path ?? startPath())),
225
+ open: (path) => port(createWindow(path ?? (frame ? undefined : startPath()))),
173
226
  menu: () => (Menu.getApplicationMenu()?.items ?? []).map(menuItem),
174
227
  }
175
228
  // An app that serves a /settings page gets a Settings item in the menu (Electron's own menu otherwise): it opens that page, in the window that already shows it when there is one.
176
- const settingsServed = await fetch(new URL('/settings', url), {
177
- headers: { accept: 'text/html' },
178
- signal: AbortSignal.timeout(2000),
179
- }).then(
180
- (answer) => answer.ok,
181
- () => false,
182
- )
229
+ // A frame window's item opens the frame's own Settings (the dialog the left panel's button opens), in the window that has focus.
230
+ const settingsServed = frame
231
+ ? true
232
+ : await fetch(new URL('/settings', url), {
233
+ headers: { accept: 'text/html' },
234
+ signal: AbortSignal.timeout(2000),
235
+ }).then(
236
+ (answer) => answer.ok,
237
+ () => false,
238
+ )
183
239
  const menu = Menu.getApplicationMenu()
184
240
  const placement =
185
241
  menu === null || !settingsServed || hasSettings(menu.items)
@@ -188,6 +244,18 @@ if (!app.requestSingleInstanceLock()) {
188
244
  if (menu !== null && placement !== undefined) {
189
245
  const target = menu.items[placement.menu]?.submenu
190
246
  const openSettings = (): void => {
247
+ if (frame) {
248
+ const target = BrowserWindow.getFocusedWindow() ?? live()[0]
249
+ if (target === undefined) return
250
+ if (target.isMinimized()) target.restore()
251
+ target.focus()
252
+ target.webContents.executeJavaScript(openFrameSettings).catch((cause: unknown) => {
253
+ console.warn(
254
+ `desktop: Settings could not be opened in the frame: ${cause instanceof Error ? cause.message : String(cause)}`,
255
+ )
256
+ })
257
+ return
258
+ }
191
259
  const shown = live().find(
192
260
  (candidate) =>
193
261
  new URL(candidate.webContents.getURL() || 'about:blank', url).pathname === '/settings',
@@ -19,6 +19,23 @@ export interface Rect {
19
19
  /** How far each new window sits from the one it was opened from. */
20
20
  export const CASCADE_STEP = 32
21
21
 
22
+ /** A window's size when nothing asks for another, and the smallest it may be. */
23
+ export const WINDOW_SIZE = { width: 1100, height: 760 } as const
24
+ export const WINDOW_MIN_SIZE = { width: 480, height: 360 } as const
25
+
26
+ /**
27
+ * The size of a new window on a work area: the usual size, made smaller on a screen where it would not leave room for a few cascade steps, never below the
28
+ * smallest size. macOS moves a window that runs past the screen's edge back onto it, so on a small screen (CI's 1024x768) a window of the usual size that
29
+ * `cascade` placed a step from another lands exactly on it; a window of this size fits at its place.
30
+ */
31
+ export function windowSize(work: Rect): { width: number; height: number } {
32
+ const room = CASCADE_STEP * 3
33
+ return {
34
+ width: Math.max(WINDOW_MIN_SIZE.width, Math.min(WINDOW_SIZE.width, work.width - room)),
35
+ height: Math.max(WINDOW_MIN_SIZE.height, Math.min(WINDOW_SIZE.height, work.height - room)),
36
+ }
37
+ }
38
+
22
39
  /**
23
40
  * Where a new window goes so that it does not cover another one exactly. The first free place of: a step down and to the right of `from`, and again, while the window still fits on the
24
41
  * screen; then the same steps from the top left of the work area (a row of windows cascades, then starts again); and, on a screen no bigger than the window, the steps from `from` even
@@ -73,10 +90,10 @@ export interface WindowOptions {
73
90
  export function windowOptions(spec: WindowSpec): WindowOptions {
74
91
  return {
75
92
  title: spec.title,
76
- width: spec.width ?? 1100,
77
- height: spec.height ?? 760,
78
- minWidth: 480,
79
- minHeight: 360,
93
+ width: spec.width ?? WINDOW_SIZE.width,
94
+ height: spec.height ?? WINDOW_SIZE.height,
95
+ minWidth: WINDOW_MIN_SIZE.width,
96
+ minHeight: WINDOW_MIN_SIZE.height,
80
97
  ...(spec.x === undefined || spec.y === undefined ? {} : { x: spec.x, y: spec.y }),
81
98
  show: false,
82
99
  webPreferences: {
@@ -4,7 +4,7 @@ import { FitAddon } from './vendor/addon-fit.mjs'
4
4
  import { Terminal } from './vendor/xterm.mjs'
5
5
  import css from './vendor/xterm-css.mjs'
6
6
 
7
- const PREFIX = '/api/driver-pty'
7
+ let PREFIX = '/api/driver-pty'
8
8
  const REMEMBERED = 'driver-pty-session'
9
9
 
10
10
  export interface TerminalView {
@@ -21,6 +21,8 @@ interface Options {
21
21
  token: string
22
22
  /** The terminal driver's name (`pty:claude`). */
23
23
  driver: string
24
+ /** The URL prefix the driver's endpoints live under (`/api/driver-pty`, or the app's mount followed by it). */
25
+ prefix: string
24
26
  /** Called with a line of status for the panel to show (`The terminal ended (exit 0).`). */
25
27
  status(text: string): void
26
28
  /** A plugin was installed or removed while the terminal was open. */
@@ -57,6 +59,7 @@ async function* frames(response: Response): AsyncGenerator<Frame> {
57
59
 
58
60
  export async function openTerminal(options: Options): Promise<TerminalView> {
59
61
  const { container, token, driver, status, onChange } = options
62
+ PREFIX = options.prefix
60
63
  const headers = { 'content-type': 'application/json', 'x-agent-panel-token': token }
61
64
  const call = (path: string, body: unknown = {}): Promise<Response> =>
62
65
  fetch(`${PREFIX}${path}`, { method: 'POST', headers, body: JSON.stringify(body) })
@@ -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)