acrylblends 1.4.0 → 1.5.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 (55) hide show
  1. package/README.md +5 -2
  2. package/dist/index.d.ts +7 -4
  3. package/dist/index.js +83 -33
  4. package/dist/index.js.map +1 -1
  5. package/legacy/README.md +1 -1
  6. package/legacy/agent/46c6eadf9230/src/app.ts +293 -0
  7. package/legacy/agent/46c6eadf9230/src/routes.ts +163 -0
  8. package/legacy/layouts.json +15 -0
  9. package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
  10. package/lib/cordis/door.js +1 -1
  11. package/lib/node-server/register.js +6 -1
  12. package/lib/node-server.js +1 -1
  13. package/lib/{transpile-QUK9b3eG.js → transpile-CRRaixyS.js} +19 -4
  14. package/package.json +1 -1
  15. package/plugins/acryl-door/src/cli/acryl.ts +6 -2
  16. package/plugins/acryl-node-server/src/register.ts +10 -1
  17. package/plugins/acryl-node-server/src/transpile.ts +25 -6
  18. package/plugins/acryl-page/src/favicon.ts +19 -0
  19. package/plugins/acryl-page/src/index.ts +18 -13
  20. package/plugins/agent-panel/client/main.ts +9 -0
  21. package/plugins/agent-panel/headless.ts +2 -2
  22. package/plugins/desktop/app-page.ts +151 -0
  23. package/plugins/desktop/assets/acrylblends-logo.png +0 -0
  24. package/plugins/desktop/brand.ts +164 -0
  25. package/plugins/desktop/frame.ts +88 -7
  26. package/plugins/desktop/git.ts +44 -0
  27. package/plugins/desktop/host-supervisor.ts +63 -0
  28. package/plugins/desktop/launch.ts +17 -1
  29. package/plugins/desktop/main.ts +268 -17
  30. package/plugins/desktop/preload.cjs +8 -0
  31. package/plugins/desktop/product.ts +117 -0
  32. package/plugins/desktop/window.ts +4 -0
  33. package/plugins/desktop-web/main.ts +3 -2
  34. package/plugins/desktop-web/plugin.json +1 -1
  35. package/plugins/extensions/index.ts +52 -7
  36. package/plugins/instance/archive.ts +395 -0
  37. package/plugins/instance/bundle-overlay.ts +10 -0
  38. package/plugins/instance/bundle.ts +201 -0
  39. package/plugins/instance/home.ts +386 -0
  40. package/plugins/instance/index.ts +132 -24
  41. package/plugins/instance/storage.ts +413 -0
  42. package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
  43. package/templates/agent/README.md +1 -1
  44. package/templates/agent/gitignore +2 -0
  45. package/templates/blank/README.md +1 -1
  46. package/templates/blank/gitignore +2 -0
  47. package/templates/blank/src/routes.ts +9 -5
  48. package/templates/chat/gitignore +2 -0
  49. package/templates/chat/src/chat-app.ts +7 -3
  50. package/templates/chat/src/llm.ts +7 -1
  51. package/templates/chat/test/chat.test.ts +16 -1
  52. package/templates/nodejs/gitignore +2 -0
  53. package/templates/notes/gitignore +2 -0
  54. package/templates/todo/README.md +1 -1
  55. package/templates/todo/gitignore +2 -0
@@ -1,12 +1,14 @@
1
1
  /**
2
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
3
+ * `npm run desktop` shows that same frame: it attaches to a host that already runs (the host's address record, `frame-url.json` in the app's data folder), or starts the host
4
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
5
  */
6
6
  import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
7
7
  import { existsSync, readFileSync } from 'node:fs'
8
8
  import { connect } from 'node:net'
9
9
  import { join, resolve } from 'node:path'
10
+ import { pathToFileURL } from 'node:url'
11
+ import { appDataHome } from '../instance/index.ts'
10
12
 
11
13
  /** The launcher every frame app has; without it the app runs on its own server (made before the frame). */
12
14
  export const HOST_SCRIPT = join('scripts', 'ide-host.mjs')
@@ -23,11 +25,11 @@ export function isFrameApp(root: string, exists: (path: string) => boolean = exi
23
25
  return exists(join(root, HOST_SCRIPT))
24
26
  }
25
27
 
26
- /** The app home the host uses: `ACRYLBLENDS_HOME` when set, else `<app>/.acrylblends` (the launcher's own rule). */
28
+ /** The app home the host uses: `ACRYLBLENDS_HOME` when set, else the app's data folder under ~/.acryl (`appDataHome`, the one place that decides it). */
27
29
  export function frameHome(root: string, env: Record<string, string | undefined>): string {
28
30
  const pinned = env.ACRYLBLENDS_HOME?.trim()
29
31
  return pinned === undefined || pinned === ''
30
- ? join(resolve(root), '.acrylblends')
32
+ ? appDataHome(resolve(root), process.platform, env)
31
33
  : resolve(pinned)
32
34
  }
33
35
 
@@ -147,25 +149,93 @@ export function hostCommand(
147
149
  : { file: 'npm', args: ['run', 'dev'], shell: false }
148
150
  }
149
151
 
152
+ /** How a host ended: its exit, the last of its output, and whether the window asked for it (`stop`) or it ended by itself. */
153
+ export interface HostEnd {
154
+ readonly code: number | null
155
+ readonly signal: NodeJS.Signals | null
156
+ readonly tail: string
157
+ readonly requested: boolean
158
+ }
159
+
150
160
  export interface FrameHost {
151
161
  /** The frame address the host printed, or a rejection with the host's output when it ended first. */
152
162
  readonly ready: Promise<string>
163
+ /** Resolves once, when the host has ended, whenever that is: after it served the frame too (a stop it did not get asked for is a death). */
164
+ readonly ended: Promise<HostEnd>
153
165
  /** Stop the host and what it started; resolves once it has exited. */
154
166
  stop(): Promise<void>
155
167
  }
156
168
 
169
+ export interface FrameHostLaunch {
170
+ /**
171
+ * A packaged product has no npm to run `npm run dev` with: the host runs on the app's own Electron runtime as Node, with the same loader the dev script uses.
172
+ */
173
+ readonly runtime?: string
174
+ /**
175
+ * Where the Program is when it is not in the app folder: a product's bundle. The host, its loader and the resolver that serves the Program from there
176
+ * and the person's files from the app's source (plugins/instance/bundle-overlay.ts) start from it; the host still runs in the app folder.
177
+ */
178
+ readonly program?: string
179
+ }
180
+
181
+ /**
182
+ * What a packaged host watches: the person's own files that make the app, and nothing else. `node --watch` alone follows only the modules the host has loaded,
183
+ * so a new plugin folder, or a row added to `blend.yaml`, stayed unseen until some loaded file changed (found on a packed app: a plugin written into
184
+ * `plugins/notes/` was not served after 15 s). Naming a path turns the module watching off, so the folders are named whole; `.git` (a commit must not restart
185
+ * the app) and the app's data are not among them.
186
+ */
187
+ export const WATCHED_IN_SOURCE = ['src', 'plugins', 'blend.yaml'] as const
188
+
189
+ /**
190
+ * The arguments of a packaged product's host (the runtime is Electron as Node): `--watch` first, as the app's `dev` script has it, so a saved change in the
191
+ * person's source or plugins restarts the host and the app shows it, with no extensions plugin in the frame to do it (found on a packed app: an edit of
192
+ * `src/routes.ts` stayed unseen until the app was quit). Then the loader and, when the Program is in a bundle, the overlay that serves it.
193
+ */
194
+ export function packagedHostArgs(
195
+ program: string,
196
+ root: string,
197
+ exists: (path: string) => boolean = existsSync,
198
+ ): string[] {
199
+ return [
200
+ '--watch',
201
+ ...WATCHED_IN_SOURCE.map((name) => join(root, name))
202
+ .filter((path) => exists(path))
203
+ .map((path) => `--watch-path=${path}`),
204
+ // The loader first and the overlay second: a hook registered later runs first, so the overlay sees every import before the loader's aliases.
205
+ '--import',
206
+ pathToFileURL(join(program, 'plugins', 'acryl-node-server', 'src', 'register.ts')).href,
207
+ ...(program === root
208
+ ? []
209
+ : [
210
+ '--import',
211
+ pathToFileURL(join(program, 'plugins', 'instance', 'bundle-overlay.ts')).href,
212
+ ]),
213
+ join(program, HOST_SCRIPT),
214
+ ]
215
+ }
216
+
157
217
  /** Start the app's frame host as `npm run dev` does, in the app folder; its output is passed through to `write`. */
158
218
  export function startFrameHost(
159
219
  root: string,
160
220
  env: NodeJS.ProcessEnv,
161
221
  write: (text: string) => void,
162
222
  platform: NodeJS.Platform = process.platform,
223
+ launch: FrameHostLaunch = {},
163
224
  ): FrameHost {
164
- const command = hostCommand(env, platform)
225
+ const program = launch.program ?? root
226
+ const command =
227
+ launch.runtime === undefined
228
+ ? hostCommand(env, platform)
229
+ : { file: launch.runtime, args: packagedHostArgs(program, root), shell: false }
165
230
  // 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
231
  const child: ChildProcess = spawn(command.file, command.args, {
167
232
  cwd: root,
168
- env: { ...env, ELECTRON_RUN_AS_NODE: undefined },
233
+ env: {
234
+ ...env,
235
+ ...(launch.runtime === undefined
236
+ ? { ELECTRON_RUN_AS_NODE: undefined }
237
+ : { ELECTRON_RUN_AS_NODE: '1' }),
238
+ },
169
239
  stdio: ['ignore', 'pipe', 'pipe'],
170
240
  shell: command.shell,
171
241
  detached: platform !== 'win32',
@@ -173,16 +243,21 @@ export function startFrameHost(
173
243
  })
174
244
  let output = ''
175
245
  let exited = false
246
+ let requested = false
247
+ let outcome: Pick<HostEnd, 'code' | 'signal'> = { code: null, signal: null }
176
248
  const exit = new Promise<void>((done) => {
177
- child.once('exit', () => {
249
+ child.once('exit', (code, signal) => {
178
250
  exited = true
251
+ outcome = { code, signal }
179
252
  done()
180
253
  })
181
- child.once('error', () => {
254
+ child.once('error', (cause) => {
182
255
  exited = true
256
+ output += `\n${cause.message}`
183
257
  done()
184
258
  })
185
259
  })
260
+ const ended = exit.then((): HostEnd => ({ ...outcome, tail: output.slice(-3000), requested }))
186
261
  const ready = new Promise<string>((done, fail) => {
187
262
  const take = (chunk: Buffer) => {
188
263
  const text = chunk.toString()
@@ -213,9 +288,15 @@ export function startFrameHost(
213
288
  // The group is already gone.
214
289
  }
215
290
  }
291
+ // What the host left running (its terminals, the watcher's child) does not outlive a host that ended by itself.
292
+ void exit.then(() => {
293
+ if (!requested) signal('SIGTERM')
294
+ })
216
295
  return {
217
296
  ready,
297
+ ended,
218
298
  async stop() {
299
+ requested = true
219
300
  if (exited) return
220
301
  signal('SIGTERM')
221
302
  const graceful = await Promise.race([
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The git a desktop product ships (desktop/dugite-native, packed by scripts/pack-blank-mac.mjs into `Resources/git`, beside the app). A clean Mac has only
3
+ * the shim that asks to install the Command Line Tools, and Windows has no git, while versions, export, the terminals and every agent run `git`. The
4
+ * frame host gets it first on its PATH, so everything it starts (versions, the workspace's git, terminals, the chat's shell, PTY agents) finds the same git
5
+ * by name. Outside a product, or in a product packed without it, the person's own git is used as before.
6
+ */
7
+
8
+ import { existsSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ export interface ProductGit {
12
+ /** The executable to run (on macOS and Linux a wrapper that finds git's helpers from where it lies). */
13
+ readonly executable: string
14
+ /** The folder to put first on PATH. */
15
+ readonly bin: string
16
+ /** Git's licence and the offer of its source, shown from Settings > App. */
17
+ readonly licence: string
18
+ readonly source: string
19
+ }
20
+
21
+ /** The product's git under `resources` (Electron's `process.resourcesPath`), or undefined when the product carries none. */
22
+ export function productGit(resources: string, platform: NodeJS.Platform): ProductGit | undefined {
23
+ const dir = join(resources, 'git')
24
+ // dugite-native's Windows build is MinGit: `cmd/git.exe` finds its own helpers.
25
+ const bin = join(dir, platform === 'win32' ? 'cmd' : 'bin')
26
+ const executable = join(bin, platform === 'win32' ? 'git.exe' : 'git')
27
+ if (!existsSync(executable)) return undefined
28
+ return { executable, bin, licence: join(dir, 'COPYING.txt'), source: join(dir, 'SOURCE.txt') }
29
+ }
30
+
31
+ /** `env` with the product's git first on its PATH (Windows names the variable `Path`, in any case; the existing name is kept). */
32
+ export function withGitFirst(
33
+ env: Readonly<Record<string, string | undefined>>,
34
+ git: ProductGit,
35
+ platform: NodeJS.Platform,
36
+ ): Record<string, string | undefined> {
37
+ const key =
38
+ platform === 'win32'
39
+ ? (Object.keys(env).find((name) => name.toUpperCase() === 'PATH') ?? 'Path')
40
+ : 'PATH'
41
+ const separator = platform === 'win32' ? ';' : ':'
42
+ const rest = (env[key] ?? '').split(separator).filter((dir) => dir !== '' && dir !== git.bin)
43
+ return { ...env, [key]: [git.bin, ...rest].join(separator) }
44
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * What the window does when its frame host stops by itself (found on an installed app 2026-11-09: the host had ended days before, nothing restarted it, and
3
+ * the window kept asking a closed port, a thousand errors and a dead page). Two small rules, both pure of Electron so they are tested without a window:
4
+ * when to start the host again, and where its output is kept so the next stop has a cause on disk.
5
+ */
6
+ import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ export interface RestartPolicy {
10
+ /** Stops inside `windowMs` that are forgiven; one more and the host is not started again by itself. */
11
+ readonly maxInWindow: number
12
+ readonly windowMs: number
13
+ /** The wait before the n-th restart inside the window: short first, then longer. */
14
+ readonly delaysMs: readonly number[]
15
+ }
16
+
17
+ export const RESTART_POLICY: RestartPolicy = Object.freeze({
18
+ maxInWindow: 4,
19
+ windowMs: 120_000,
20
+ delaysMs: [500, 2_000, 5_000, 10_000],
21
+ })
22
+
23
+ export type RestartDecision = { restart: true; delayMs: number } | { restart: false }
24
+
25
+ /** `stops` are the times (ms) the host stopped by itself, oldest or newest first; those older than the window are forgotten. */
26
+ export function nextRestart(
27
+ stops: readonly number[],
28
+ now: number,
29
+ policy: RestartPolicy = RESTART_POLICY,
30
+ ): RestartDecision {
31
+ const recent = stops.filter((at) => now - at < policy.windowMs).length
32
+ if (recent >= policy.maxInWindow) return { restart: false }
33
+ return {
34
+ restart: true,
35
+ delayMs: policy.delaysMs[Math.min(recent, policy.delaysMs.length - 1)] ?? 0,
36
+ }
37
+ }
38
+
39
+ const KEEP_DAYS = 14
40
+
41
+ /** The host's output, one file a day (`host-YYYY-MM-DD.log`) in the app's logs folder; files older than two weeks go. Never throws: a log must not stop the app. */
42
+ export function appendHostLog(dir: string, text: string, now: Date = new Date()): void {
43
+ try {
44
+ mkdirSync(dir, { recursive: true })
45
+ appendFileSync(join(dir, `host-${now.toISOString().slice(0, 10)}.log`), text)
46
+ for (const name of readdirSync(dir)) {
47
+ if (!/^host-\d{4}-\d{2}-\d{2}\.log$/u.test(name)) continue
48
+ const file = join(dir, name)
49
+ if (now.getTime() - statSync(file).mtimeMs > KEEP_DAYS * 86_400_000)
50
+ rmSync(file, { force: true })
51
+ }
52
+ } catch {
53
+ // The disk is full or the folder is gone: the host goes on.
54
+ }
55
+ }
56
+
57
+ /**
58
+ * `node --watch` does not end when the host it runs crashes: it prints this line and waits for a saved file (reproduced on a packed app by killing the host's
59
+ * child: the watcher stayed, the port was closed, nothing answered and nothing restarted). So the window reads the line as the host's death.
60
+ */
61
+ export function hostCrashed(output: string): boolean {
62
+ return /Failed running '/u.test(output)
63
+ }
@@ -8,6 +8,8 @@ import { readFileSync } from 'node:fs'
8
8
  import { createRequire } from 'node:module'
9
9
  import { join } from 'node:path'
10
10
  import { fileURLToPath } from 'node:url'
11
+ import { acrylRoot, appIdOf, personHome } from '../instance/home.ts'
12
+ import { appTitle, brandedElectron, iconFile } from './brand.ts'
11
13
 
12
14
  /** The sysctl that makes the sandbox fail for an Electron without an AppArmor profile; its value, or `undefined` where it does not exist (every system but a recent Ubuntu). */
13
15
  export const RESTRICT_USERNS = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
@@ -76,8 +78,22 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
76
78
  const blocked =
77
79
  process.env.ACRYLBLENDS_DESKTOP_SANDBOX !== '1' && sandboxBlocked(process.platform)
78
80
  if (blocked) console.warn(SANDBOX_NOTICE)
81
+ // macOS: the Dock and Cmd+Tab name the app and draw its icon only when the bundle says so (brand.ts); a copy that cannot be made leaves the stock Electron, with a note.
82
+ const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
83
+ const branded = brandedElectron({
84
+ binary: electron,
85
+ folder: join(
86
+ acrylRoot(process.platform, process.env, personHome(process.env, process.platform)),
87
+ 'electron',
88
+ ),
89
+ id: appIdOf(projectRoot),
90
+ name: appTitle(projectRoot),
91
+ icon: iconFile(projectRoot),
92
+ })
93
+ if (branded.problem !== undefined)
94
+ console.warn(`desktop: the Dock shows Electron's own name and icon (${branded.problem})`)
79
95
  runElectron(
80
- electron,
96
+ branded.binary,
81
97
  electronArgs(join(import.meta.dirname, 'main.ts'), blocked, process.argv.slice(2)),
82
98
  )
83
99
  }