acrylblends 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/README.md +9 -0
  2. package/dist/index.d.ts +45 -1
  3. package/dist/index.js +1980 -27
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/agents/index.md +1 -1
  6. package/docs/framework/getting-started/desktop.md +23 -4
  7. package/docs/framework/guides/create-acrylblends.md +1 -1
  8. package/docs/framework/workshop/README.md +138 -152
  9. package/docs/framework/workshop/facilitator.md +69 -101
  10. package/legacy/README.md +9 -0
  11. package/legacy/agent/157ce3db1112/src/app.ts +98 -0
  12. package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
  13. package/legacy/agent/24c681a00fca/src/app.ts +160 -0
  14. package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
  15. package/legacy/agent/2e790da977b2/src/app.ts +98 -0
  16. package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
  17. package/legacy/agent/6307045545dc/src/app.ts +168 -0
  18. package/legacy/agent/6307045545dc/src/routes.ts +83 -0
  19. package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
  20. package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
  21. package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
  22. package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
  23. package/legacy/agent/74ece148269c/src/app.ts +168 -0
  24. package/legacy/agent/74ece148269c/src/routes.ts +93 -0
  25. package/legacy/agent/855359304707/src/app.ts +168 -0
  26. package/legacy/agent/855359304707/src/routes.ts +126 -0
  27. package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
  28. package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
  29. package/legacy/agent/c2f30958043b/src/app.ts +98 -0
  30. package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
  31. package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
  32. package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
  33. package/legacy/blank/aea589283445/src/routes.ts +16 -0
  34. package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
  35. package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
  36. package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
  37. package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
  38. package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
  39. package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
  40. package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
  41. package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
  42. package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
  43. package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
  44. package/legacy/layouts.json +524 -0
  45. package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
  46. package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
  47. package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
  48. package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
  49. package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
  50. package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
  51. package/legacy/notes/4edc36949c15/src/app.ts +107 -0
  52. package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
  53. package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
  54. package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
  55. package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
  56. package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
  57. package/legacy/todo/02e613b3625c/src/app.ts +71 -0
  58. package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
  59. package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
  60. package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
  61. package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
  62. package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
  63. package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
  64. package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
  65. package/legacy/todo/8e64211312f3/src/app.ts +71 -0
  66. package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
  67. package/legacy/todo/b79078a87565/src/app.ts +71 -0
  68. package/legacy/todo/b79078a87565/src/todo.ts +125 -0
  69. package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
  70. package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
  71. package/lib/cordis/door.d.ts +42 -7
  72. package/lib/cordis/door.js +204 -60
  73. package/lib/cordis/inspect.js +1 -1
  74. package/lib/cordis/node-server.d.ts +101 -3
  75. package/lib/cordis/node-server.js +3 -3
  76. package/lib/cordis/router-http.d.ts +7 -1
  77. package/lib/cordis/router-http.js +44 -3
  78. package/lib/cordis/router.d.ts +1 -1
  79. package/lib/cordis/router.js +1 -1
  80. package/lib/cordis/state.js +1 -1
  81. package/lib/door/host.d.ts +2 -2
  82. package/lib/door/host.js +1 -1
  83. package/lib/door/page.d.ts +10 -1
  84. package/lib/door/page.js +46 -7
  85. package/lib/door/protocol.d.ts +1 -1
  86. package/lib/drivers.d.ts +10 -1
  87. package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
  88. package/lib/node-server.d.ts +7 -1
  89. package/lib/node-server.js +2 -2
  90. package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
  91. package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
  92. package/lib/src-CtkIX_Gg.js +427 -0
  93. package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
  94. package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
  95. package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
  96. package/package.json +2 -1
  97. package/plugins/acryl-door/src/host/channel.ts +86 -9
  98. package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
  99. package/plugins/acryl-door/src/host/door-file.ts +7 -2
  100. package/plugins/acryl-door/src/host/door.ts +53 -23
  101. package/plugins/acryl-door/src/host/page-routes.ts +3 -1
  102. package/plugins/acryl-door/src/host/page-socket.ts +18 -8
  103. package/plugins/acryl-door/src/host/verbs.ts +3 -0
  104. package/plugins/acryl-door/src/index.ts +166 -29
  105. package/plugins/acryl-door/src/page/base.ts +32 -0
  106. package/plugins/acryl-door/src/page/channel.ts +3 -1
  107. package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
  108. package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
  109. package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
  110. package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
  111. package/plugins/acryl-door/src/page/index.ts +41 -2
  112. package/plugins/acryl-door/src/page/outline.ts +56 -0
  113. package/plugins/acryl-door/src/page/protocol.ts +4 -0
  114. package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
  115. package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
  116. package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
  117. package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
  118. package/plugins/acryl-node-server/src/client-modules.ts +7 -1
  119. package/plugins/acryl-node-server/src/index.ts +304 -16
  120. package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
  121. package/plugins/acryl-page/plugin.json +12 -0
  122. package/plugins/acryl-page/src/index.ts +163 -0
  123. package/plugins/acryl-router/src/service.ts +77 -14
  124. package/plugins/acryl-router-http/src/index.ts +15 -1
  125. package/plugins/acryl-router-http/src/serve.ts +31 -0
  126. package/plugins/acryl-web-server/plugin.json +14 -0
  127. package/plugins/acryl-web-server/src/index.ts +164 -0
  128. package/plugins/agent/agent.ts +38 -7
  129. package/plugins/agent/index.ts +1 -1
  130. package/plugins/agent/model.ts +10 -2
  131. package/plugins/agent/tools.ts +9 -3
  132. package/plugins/agent-panel/client/describe.ts +57 -0
  133. package/plugins/agent-panel/client/driver-pick.ts +22 -0
  134. package/plugins/agent-panel/client/frame.ts +51 -0
  135. package/plugins/agent-panel/client/main.ts +238 -26
  136. package/plugins/agent-panel/drivers.ts +7 -2
  137. package/plugins/agent-panel/headless.ts +5 -2
  138. package/plugins/agent-panel/index.ts +146 -57
  139. package/plugins/agent-panel/mount.ts +27 -0
  140. package/plugins/agent-panel/visitors.ts +231 -0
  141. package/plugins/desktop/frame.ts +231 -0
  142. package/plugins/desktop/launch.ts +44 -9
  143. package/plugins/desktop/main.ts +100 -32
  144. package/plugins/desktop/window.ts +21 -4
  145. package/plugins/driver-pty/client/terminal.ts +4 -1
  146. package/plugins/driver-pty/index.ts +38 -11
  147. package/plugins/driver-pty/spawn.ts +38 -0
  148. package/plugins/driver-pty/terminal.ts +2 -0
  149. package/plugins/extensions/index.ts +26 -1
  150. package/plugins/extensions/resolve.ts +13 -0
  151. package/plugins/feedback/client.d.ts +22 -0
  152. package/plugins/feedback/client.js +471 -0
  153. package/plugins/feedback/index.ts +143 -0
  154. package/plugins/feedback/plugin.json +11 -0
  155. package/plugins/feedback/store.ts +307 -0
  156. package/plugins/home/index.ts +122 -0
  157. package/plugins/home/plugin.json +11 -0
  158. package/plugins/instance/index.ts +6 -0
  159. package/plugins/manifest/app-plugins.ts +86 -0
  160. package/plugins/manifest/index.ts +12 -2
  161. package/plugins/manifest/snapshot.ts +275 -0
  162. package/plugins/openai/index.ts +64 -5
  163. package/plugins/openai/prompt.ts +23 -0
  164. package/shared/AGENTS.core.md +36 -16
  165. package/shared/skills/acryl-add-ui/SKILL.md +19 -0
  166. package/shared/skills/acryl-app/SKILL.md +13 -0
  167. package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
  168. package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
  169. package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
  170. package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
  171. package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
  172. package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
  173. package/shared/skills/cordis-plugin/SKILL.md +26 -0
  174. package/templates/agent/AGENTS.md +1 -1
  175. package/templates/agent/blend.yaml +4 -0
  176. package/templates/agent/gitignore +1 -0
  177. package/templates/agent/package.json +1 -0
  178. package/templates/agent/src/app.ts +150 -33
  179. package/templates/agent/src/frame.ts +17 -0
  180. package/templates/agent/src/main.ts +5 -3
  181. package/templates/agent/src/routes.ts +38 -19
  182. package/templates/agent/template.json +2 -0
  183. package/templates/agent/test/app.test.ts +45 -3
  184. package/templates/blank/AGENTS.md +3 -2
  185. package/templates/blank/blend.yaml +11 -0
  186. package/templates/blank/gitignore +1 -0
  187. package/templates/blank/package.json +1 -0
  188. package/templates/blank/src/app.ts +100 -29
  189. package/templates/blank/src/door.ts +7 -0
  190. package/templates/blank/src/first-page.ts +5 -0
  191. package/templates/blank/src/frame.ts +17 -0
  192. package/templates/blank/src/routes.ts +65 -8
  193. package/templates/blank/template.json +14 -1
  194. package/templates/blank/test/app.test.ts +103 -16
  195. package/templates/chat/blend.yaml +2 -1
  196. package/templates/chat/gitignore +1 -0
  197. package/templates/chat/package.json +1 -0
  198. package/templates/chat/src/app.ts +72 -22
  199. package/templates/chat/src/chat-app.ts +11 -2
  200. package/templates/chat/src/client/chat.tsx +4 -2
  201. package/templates/chat/src/frame.ts +17 -0
  202. package/templates/chat/src/page.tsx +6 -4
  203. package/templates/chat/src/routes.ts +14 -4
  204. package/templates/chat/src/runtime.ts +9 -3
  205. package/templates/chat/template.json +3 -1
  206. package/templates/chat/test/chat.test.ts +46 -1
  207. package/templates/chat/test/server.test.ts +17 -3
  208. package/templates/nodejs/blend.yaml +2 -1
  209. package/templates/nodejs/gitignore +1 -0
  210. package/templates/nodejs/package.json +1 -0
  211. package/templates/nodejs/src/app.ts +76 -21
  212. package/templates/nodejs/src/frame.ts +17 -0
  213. package/templates/nodejs/src/page.tsx +3 -3
  214. package/templates/nodejs/src/routes.ts +32 -13
  215. package/templates/nodejs/template.json +3 -1
  216. package/templates/nodejs/test/app.test.ts +38 -3
  217. package/templates/nodejs/test/server.test.ts +17 -3
  218. package/templates/notes/blend.yaml +2 -1
  219. package/templates/notes/gitignore +1 -0
  220. package/templates/notes/package.json +1 -0
  221. package/templates/notes/src/app.ts +72 -22
  222. package/templates/notes/src/frame.ts +17 -0
  223. package/templates/notes/src/notes-app.ts +24 -17
  224. package/templates/notes/src/routes.ts +11 -4
  225. package/templates/notes/src/runtime.ts +9 -3
  226. package/templates/notes/src/views.tsx +26 -17
  227. package/templates/notes/template.json +3 -1
  228. package/templates/notes/test/notes.test.ts +39 -1
  229. package/templates/notes/test/server.test.ts +17 -3
  230. package/templates/todo/blend.yaml +3 -0
  231. package/templates/todo/gitignore +1 -0
  232. package/templates/todo/package.json +1 -0
  233. package/templates/todo/src/app.ts +81 -27
  234. package/templates/todo/src/door.ts +7 -0
  235. package/templates/todo/src/frame.ts +17 -0
  236. package/templates/todo/src/page.ts +13 -6
  237. package/templates/todo/src/todo.ts +18 -6
  238. package/templates/todo/template.json +11 -1
  239. package/templates/todo/test/todo.test.ts +67 -9
  240. package/lib/src-LX0E4ec4.js +0 -117
  241. package/shared/SKILL.md +0 -14
@@ -0,0 +1,231 @@
1
+ /**
2
+ * The desktop window on the ACRYL frame (docs/plans/ide-host-inversion.md, section 27). An app with `scripts/ide-host.mjs` runs inside the frame on `npm run dev`;
3
+ * `npm run desktop` shows that same frame: it attaches to a host that already runs (the host's address record, `.acrylblends/frame-url.json`), or starts the host
4
+ * the way `npm run dev` does and loads the address it prints. Nothing here imports electron: `main.ts` decides with these functions and owns the window.
5
+ */
6
+ import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { connect } from 'node:net'
9
+ import { join, resolve } from 'node:path'
10
+
11
+ /** The launcher every frame app has; without it the app runs on its own server (made before the frame). */
12
+ export const HOST_SCRIPT = join('scripts', 'ide-host.mjs')
13
+
14
+ /** The line the host prints once it serves the frame, with the launch token in the address. */
15
+ const HOST_LINE = /acrylblends host: (http:\/\/\S+)/u
16
+
17
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]'])
18
+
19
+ /** How long a stopping host gets before its tree is killed. */
20
+ export const HOST_STOP_GRACE_MS = 5_000
21
+
22
+ export function isFrameApp(root: string, exists: (path: string) => boolean = existsSync): boolean {
23
+ return exists(join(root, HOST_SCRIPT))
24
+ }
25
+
26
+ /** The app home the host uses: `ACRYLBLENDS_HOME` when set, else `<app>/.acrylblends` (the launcher's own rule). */
27
+ export function frameHome(root: string, env: Record<string, string | undefined>): string {
28
+ const pinned = env.ACRYLBLENDS_HOME?.trim()
29
+ return pinned === undefined || pinned === ''
30
+ ? join(resolve(root), '.acrylblends')
31
+ : resolve(pinned)
32
+ }
33
+
34
+ /** The frame address in the host's output so far, or `undefined` while it has not printed it. */
35
+ export function hostAddress(output: string): string | undefined {
36
+ return HOST_LINE.exec(output)?.[1]
37
+ }
38
+
39
+ /** A loopback `http:` address, or `undefined` for anything else: the window only ever opens a frame on this machine. */
40
+ export function loopbackAddress(url: unknown): URL | undefined {
41
+ if (typeof url !== 'string') return undefined
42
+ try {
43
+ const parsed = new URL(url)
44
+ return parsed.protocol === 'http:' && LOOPBACK.has(parsed.hostname) ? parsed : undefined
45
+ } catch {
46
+ return undefined
47
+ }
48
+ }
49
+
50
+ export interface RecordDeps {
51
+ readonly read?: (file: string) => string
52
+ /** Whether something accepts a connection at the address. */
53
+ readonly listening?: (url: URL) => Promise<boolean>
54
+ }
55
+
56
+ const tcpListening = (url: URL, timeoutMs = 500): Promise<boolean> =>
57
+ new Promise((done) => {
58
+ const socket = connect({
59
+ host: url.hostname.replace(/^\[|\]$/gu, ''),
60
+ port: Number(url.port || 80),
61
+ })
62
+ const finish = (listening: boolean) => {
63
+ socket.destroy()
64
+ done(listening)
65
+ }
66
+ socket.setTimeout(timeoutMs)
67
+ socket.once('connect', () => finish(true))
68
+ socket.once('timeout', () => finish(false))
69
+ socket.once('error', () => finish(false))
70
+ })
71
+
72
+ /**
73
+ * The frame that already runs for this app: the host's address record, when it names a loopback address and something listens there. A record a stopped host
74
+ * left behind is not a running frame.
75
+ */
76
+ export async function runningFrame(
77
+ home: string,
78
+ deps: RecordDeps = {},
79
+ ): Promise<string | undefined> {
80
+ const read = deps.read ?? ((file: string) => readFileSync(file, 'utf8'))
81
+ let record: { url?: unknown }
82
+ try {
83
+ record = JSON.parse(read(join(home, 'frame-url.json'))) as { url?: unknown }
84
+ } catch {
85
+ return undefined
86
+ }
87
+ const url = loopbackAddress(record.url)
88
+ if (url === undefined) return undefined
89
+ return (await (deps.listening ?? tcpListening)(url)) ? url.href : undefined
90
+ }
91
+
92
+ /** An address as the door may show it: the frame's launch token removed (the token is the way into the frame, so it never goes to a log). */
93
+ export function withoutToken(href: string): string {
94
+ try {
95
+ const parsed = new URL(href)
96
+ if (!parsed.searchParams.has('token')) return href
97
+ parsed.searchParams.delete('token')
98
+ return parsed.href
99
+ } catch {
100
+ return href
101
+ }
102
+ }
103
+
104
+ /**
105
+ * What the Settings... menu item runs in a frame window: the window event the page's root owner opens its own Settings on (`acryl-blends-frame`'s
106
+ * `OPEN_SETTINGS_EVENT`, and the IDE's `acryl-workspace`; keep the string in step). The same dialog as the top bar's gear, never a second one.
107
+ */
108
+ export const openFrameSettings = "window.dispatchEvent(new Event('acryl:open-settings'))"
109
+
110
+ /** What the window does with a navigation: stay on the frame's origin, hand the address to the system browser, or refuse it. */
111
+ export type Navigation = 'stay' | 'external' | 'block'
112
+
113
+ /**
114
+ * ACRYL Desktop's rule (`electron-shell-generation.ts`): only the main frame is checked, so the frame's tiles (the app's page in an iframe) navigate freely;
115
+ * the frame's own origin stays in the window, an `http(s)` or `mailto` address goes to the system browser, anything else is refused.
116
+ */
117
+ export function navigation(target: string, origin: string, mainFrame = true): Navigation {
118
+ if (!mainFrame) return 'stay'
119
+ let parsed: URL
120
+ try {
121
+ parsed = new URL(target)
122
+ } catch {
123
+ return 'block'
124
+ }
125
+ if (parsed.origin === origin) return 'stay'
126
+ return parsed.protocol === 'http:' ||
127
+ parsed.protocol === 'https:' ||
128
+ parsed.protocol === 'mailto:'
129
+ ? 'external'
130
+ : 'block'
131
+ }
132
+
133
+ /** How the host is started: the npm and Node that ran `npm run desktop`, never Electron's Node (node-pty and DSH's rows are built for Node's ABI). */
134
+ export function hostCommand(
135
+ env: Record<string, string | undefined>,
136
+ platform: NodeJS.Platform,
137
+ ): { file: string; args: string[]; shell: boolean } {
138
+ const node = env.npm_node_execpath
139
+ const npm = env.npm_execpath
140
+ if (node !== undefined && node !== '' && npm !== undefined && npm !== '')
141
+ return /\.c?js$/u.test(npm)
142
+ ? { file: node, args: [npm, 'run', 'dev'], shell: false }
143
+ : { file: npm, args: ['run', 'dev'], shell: platform === 'win32' }
144
+ // Started without npm (Electron run by hand): npm from the PATH, `npm.cmd` through the shell on Windows (scripts/lib/platform.mjs, npmSpawn).
145
+ return platform === 'win32'
146
+ ? { file: 'npm.cmd', args: ['run', 'dev'], shell: true }
147
+ : { file: 'npm', args: ['run', 'dev'], shell: false }
148
+ }
149
+
150
+ export interface FrameHost {
151
+ /** The frame address the host printed, or a rejection with the host's output when it ended first. */
152
+ readonly ready: Promise<string>
153
+ /** Stop the host and what it started; resolves once it has exited. */
154
+ stop(): Promise<void>
155
+ }
156
+
157
+ /** Start the app's frame host as `npm run dev` does, in the app folder; its output is passed through to `write`. */
158
+ export function startFrameHost(
159
+ root: string,
160
+ env: NodeJS.ProcessEnv,
161
+ write: (text: string) => void,
162
+ platform: NodeJS.Platform = process.platform,
163
+ ): FrameHost {
164
+ const command = hostCommand(env, platform)
165
+ // The host leads its own process group on macOS and Linux, so a stop reaches npm, the host and every terminal it runs; Windows stops the tree with taskkill.
166
+ const child: ChildProcess = spawn(command.file, command.args, {
167
+ cwd: root,
168
+ env: { ...env, ELECTRON_RUN_AS_NODE: undefined },
169
+ stdio: ['ignore', 'pipe', 'pipe'],
170
+ shell: command.shell,
171
+ detached: platform !== 'win32',
172
+ windowsHide: true,
173
+ })
174
+ let output = ''
175
+ let exited = false
176
+ const exit = new Promise<void>((done) => {
177
+ child.once('exit', () => {
178
+ exited = true
179
+ done()
180
+ })
181
+ child.once('error', () => {
182
+ exited = true
183
+ done()
184
+ })
185
+ })
186
+ const ready = new Promise<string>((done, fail) => {
187
+ const take = (chunk: Buffer) => {
188
+ const text = chunk.toString()
189
+ write(text)
190
+ if (output.length < 200_000) output += text
191
+ const address = hostAddress(output)
192
+ if (address !== undefined) done(address)
193
+ }
194
+ child.stdout?.on('data', take)
195
+ child.stderr?.on('data', take)
196
+ void exit.then(() =>
197
+ fail(new Error(`the frame host ended before it served the frame:\n${output.slice(-3000)}`)),
198
+ )
199
+ })
200
+ const signal = (name: NodeJS.Signals) => {
201
+ if (child.pid === undefined) return
202
+ // Windows has no process groups and no SIGTERM a console process can answer: the whole tree (npm, the host, its terminals) is ended at once.
203
+ if (platform === 'win32') {
204
+ spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
205
+ stdio: 'ignore',
206
+ windowsHide: true,
207
+ })
208
+ return
209
+ }
210
+ try {
211
+ process.kill(-child.pid, name)
212
+ } catch {
213
+ // The group is already gone.
214
+ }
215
+ }
216
+ return {
217
+ ready,
218
+ async stop() {
219
+ if (exited) return
220
+ signal('SIGTERM')
221
+ const graceful = await Promise.race([
222
+ exit.then(() => true),
223
+ new Promise<boolean>((done) => setTimeout(() => done(false), HOST_STOP_GRACE_MS).unref()),
224
+ ])
225
+ if (!graceful) {
226
+ signal('SIGKILL')
227
+ await exit
228
+ }
229
+ },
230
+ }
231
+ }
@@ -3,7 +3,7 @@
3
3
  * (Ubuntu 23.10 and later: `kernel.apparmor_restrict_unprivileged_userns=1`), Chromium's sandbox cannot start for an Electron that npm unpacked into node_modules, and Electron
4
4
  * aborts before any of our code runs. There, and only there, it starts with `--no-sandbox` and says so; everywhere else Electron starts exactly as it does with the sandbox on.
5
5
  */
6
- import { spawn } from 'node:child_process'
6
+ import { type ChildProcess, spawn } from 'node:child_process'
7
7
  import { readFileSync } from 'node:fs'
8
8
  import { createRequire } from 'node:module'
9
9
  import { join } from 'node:path'
@@ -29,9 +29,45 @@ export const SANDBOX_NOTICE =
29
29
  "desktop: this Linux restricts unprivileged user namespaces (kernel.apparmor_restrict_unprivileged_userns=1), so Electron's sandbox cannot start; starting with --no-sandbox. " +
30
30
  'To keep the sandbox, give Electron an AppArmor profile that allows `userns` (the way the VS Code and Chrome packages do): see docs/framework/getting-started/desktop.md'
31
31
 
32
- /** The arguments for Electron: the entry file, with `--no-sandbox` first when the sandbox is blocked. */
33
- export function electronArgs(entry: string, blocked: boolean): string[] {
34
- return blocked ? ['--no-sandbox', entry] : [entry]
32
+ /**
33
+ * The arguments for Electron: the entry file, with `--no-sandbox` first when the sandbox is blocked. Chromium switches given to the script
34
+ * (`npm run desktop -- --remote-debugging-port=9222`) go before the entry; anything that is not a switch is dropped.
35
+ */
36
+ export function electronArgs(
37
+ entry: string,
38
+ blocked: boolean,
39
+ extra: readonly string[] = [],
40
+ ): string[] {
41
+ const switches = extra.filter((arg) => /^--[a-z][a-z0-9-]*(=.*)?$/u.test(arg))
42
+ return [...(blocked ? ['--no-sandbox'] : []), ...switches, entry]
43
+ }
44
+
45
+ /** The signals the launcher hands to Electron: Ctrl-C, a stop, and the terminal closing. */
46
+ export const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
47
+
48
+ /**
49
+ * Start Electron and make it end the way it was asked to. On macOS and Linux Electron leads its own process group, so a terminal's Ctrl-C (which goes to the
50
+ * whole foreground group) reaches only this launcher, which hands Electron exactly one signal. Electron answers a first SIGINT, SIGTERM or SIGHUP with a
51
+ * graceful quit, the one that stops the frame host the window started; a second signal kills it at once, before any of that runs, and a Ctrl-C that reached
52
+ * both processes was two, as is one that npm passes on after the terminal sent it. Later signals are not passed on. Windows has no process groups and no such signals; there Electron starts as before. This process ends with Electron's exit code.
53
+ */
54
+ export function runElectron(
55
+ file: string,
56
+ args: readonly string[],
57
+ platform: NodeJS.Platform = process.platform,
58
+ ): ChildProcess {
59
+ const child = spawn(file, args, { stdio: 'inherit', detached: platform !== 'win32' })
60
+ // One signal, ever: npm hands a Ctrl-C to this process a second time, and Electron's quit is bounded (DESKTOP_SHUTDOWN_TIMEOUT_MS) without another.
61
+ let forwarded = false
62
+ for (const signal of FORWARDED_SIGNALS)
63
+ if (platform !== 'win32' || signal !== 'SIGHUP')
64
+ process.on(signal, () => {
65
+ if (forwarded) return
66
+ forwarded = true
67
+ child.kill(signal)
68
+ })
69
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
70
+ return child
35
71
  }
36
72
 
37
73
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
@@ -40,9 +76,8 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
40
76
  const blocked =
41
77
  process.env.ACRYLBLENDS_DESKTOP_SANDBOX !== '1' && sandboxBlocked(process.platform)
42
78
  if (blocked) console.warn(SANDBOX_NOTICE)
43
- const child = spawn(electron, electronArgs(join(import.meta.dirname, 'main.ts'), blocked), {
44
- stdio: 'inherit',
45
- })
46
- for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => child.kill(signal))
47
- child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
79
+ runElectron(
80
+ electron,
81
+ electronArgs(join(import.meta.dirname, 'main.ts'), blocked, process.argv.slice(2)),
82
+ )
48
83
  }
@@ -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) })