acrylblends 1.4.0 → 1.5.1

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 (57) hide show
  1. package/README.md +5 -2
  2. package/dist/index.d.ts +7 -4
  3. package/dist/index.js +191 -62
  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/blank/a53d41e6ef42/src/app.ts +160 -0
  9. package/legacy/blank/a53d41e6ef42/src/routes.ts +79 -0
  10. package/legacy/layouts.json +30 -0
  11. package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
  12. package/lib/cordis/door.js +1 -1
  13. package/lib/node-server/register.js +6 -1
  14. package/lib/node-server.js +1 -1
  15. package/lib/{transpile-QUK9b3eG.js → transpile-CRRaixyS.js} +19 -4
  16. package/package.json +1 -1
  17. package/plugins/acryl-door/src/cli/acryl.ts +6 -2
  18. package/plugins/acryl-node-server/src/register.ts +10 -1
  19. package/plugins/acryl-node-server/src/transpile.ts +25 -6
  20. package/plugins/acryl-page/src/favicon.ts +19 -0
  21. package/plugins/acryl-page/src/index.ts +18 -13
  22. package/plugins/agent-panel/client/main.ts +9 -0
  23. package/plugins/agent-panel/headless.ts +2 -2
  24. package/plugins/desktop/app-page.ts +151 -0
  25. package/plugins/desktop/assets/acrylblends-logo.png +0 -0
  26. package/plugins/desktop/brand.ts +164 -0
  27. package/plugins/desktop/frame.ts +88 -7
  28. package/plugins/desktop/git.ts +44 -0
  29. package/plugins/desktop/host-supervisor.ts +63 -0
  30. package/plugins/desktop/launch.ts +17 -1
  31. package/plugins/desktop/main.ts +268 -17
  32. package/plugins/desktop/preload.cjs +8 -0
  33. package/plugins/desktop/product.ts +117 -0
  34. package/plugins/desktop/window.ts +4 -0
  35. package/plugins/desktop-web/main.ts +3 -2
  36. package/plugins/desktop-web/plugin.json +1 -1
  37. package/plugins/extensions/index.ts +52 -7
  38. package/plugins/instance/archive.ts +395 -0
  39. package/plugins/instance/bundle-overlay.ts +10 -0
  40. package/plugins/instance/bundle.ts +201 -0
  41. package/plugins/instance/home.ts +395 -0
  42. package/plugins/instance/index.ts +132 -24
  43. package/plugins/instance/storage.ts +413 -0
  44. package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
  45. package/templates/agent/README.md +1 -1
  46. package/templates/agent/gitignore +2 -0
  47. package/templates/blank/README.md +1 -1
  48. package/templates/blank/gitignore +2 -0
  49. package/templates/blank/src/routes.ts +9 -5
  50. package/templates/chat/gitignore +2 -0
  51. package/templates/chat/src/chat-app.ts +7 -3
  52. package/templates/chat/src/llm.ts +7 -1
  53. package/templates/chat/test/chat.test.ts +16 -1
  54. package/templates/nodejs/gitignore +2 -0
  55. package/templates/notes/gitignore +2 -0
  56. package/templates/todo/README.md +1 -1
  57. package/templates/todo/gitignore +2 -0
@@ -0,0 +1,79 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import { Acryl } from '../plugins/acryl-router/src/index.ts'
3
+ import { type Home, renderWidgets } from '../plugins/home/index.ts'
4
+ import { aboutPage } from './about.tsx'
5
+ import { FIRST_PAGE_MARKER } from './first-page.ts'
6
+
7
+ export const APP_NAME = '__APP_NAME__'
8
+
9
+ /**
10
+ * The only place the app's mount prefix is named. On its own server (production, `npm start`) the app mounts at the root and
11
+ * its public URLs are `/`, `/about`, `/health`; when the ACRYL frame hosts it (`npm run dev`) the routes mount here, and the
12
+ * frame opens its tile at `/app` (`/app/` answers too). Every page link and asset URL this template writes is derived from the prefix it is
13
+ * mounted at, so the same source serves both.
14
+ */
15
+ export const FRAME_MOUNT_PREFIX = '/app'
16
+
17
+ /** The scaffold's name comes from the folder, so the first screen escapes it like any other user text. */
18
+ const escapeHtml = (text: string): string =>
19
+ text
20
+ .replaceAll('&', '&')
21
+ .replaceAll('<', '&lt;')
22
+ .replaceAll('>', '&gt;')
23
+ .replaceAll('"', '&quot;')
24
+
25
+ /**
26
+ * The first screen: the page a browser gets at the root, which is what the ACRYL frame's first tile shows. A blank app whose root answered JSON to
27
+ * every client gave the tile Chrome's JSON viewer, nearly invisible in the frame's dark surface (owner review, 2026-11-02); the root now answers a page
28
+ * to a browser and the JSON hello to everything else, the way the agent template does. The About link is relative, so it works under the frame's /app
29
+ * mount and on the app's own server alike.
30
+ */
31
+ const homePage = (name: string, widgets: string): string => `<!doctype html>
32
+ <html lang="en">
33
+ <head>
34
+ <meta charset="utf-8">
35
+ <meta name="viewport" content="width=device-width, initial-scale=1">
36
+ <title>${escapeHtml(name)}</title>
37
+ <style>:root{color-scheme:light dark;background:Canvas;color:CanvasText}body{margin:2rem auto;max-width:44rem;padding:0 1rem;font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}h1{font-size:1.6rem;margin:0 0 .5rem}a{color:#5468ff}code{font:.9em ui-monospace,"SF Mono",Menlo,monospace}</style>
38
+ </head>
39
+ <body>
40
+ <main>
41
+ <h1>${escapeHtml(name)}</h1>
42
+ <p>Your app is running. ${FIRST_PAGE_MARKER}; edit <code>src/routes.ts</code> to change it.</p>
43
+ <p><a href="./about">About</a></p>
44
+ ${widgets}
45
+ </main>
46
+ </body>
47
+ </html>`
48
+
49
+ /**
50
+ * The app's own pages and routes, mountable at any prefix: the standalone server mounts it at the root, the frame host at {@link FRAME_MOUNT_PREFIX}.
51
+ * The first screen shows the widgets of the app's plugins (`ctx.home`), read at each request, so a plugin switched on or off shows at the next load.
52
+ */
53
+ export function makeApp(home: Home, name: () => string = () => APP_NAME): Acryl {
54
+ return (
55
+ new Acryl()
56
+ // A browser gets the first screen; an API client gets the JSON hello.
57
+ .get('/', async (c) =>
58
+ (c.req.header('accept') ?? '').includes('text/html')
59
+ ? c.html(homePage(name(), await renderWidgets(home)))
60
+ : c.json({ app: name(), ok: true }),
61
+ )
62
+ .get('/health', (c) => c.json({ ok: true }))
63
+ .get('/about', (c) => c.html(aboutPage()))
64
+ )
65
+ }
66
+
67
+ /** The app's own routes: an `App` mounted on the router at `options.prefix` (empty or absent: the root). The mount is removed when this plugin is disposed. */
68
+ export const routes = {
69
+ name: 'routes',
70
+ inject: ['router', 'home'],
71
+ apply(ctx: Context, options: { readonly prefix?: string } = {}) {
72
+ const prefix = options.prefix ?? ''
73
+ // The name the app has now (Settings > App renames it), else the one the scaffold was made with.
74
+ ctx.router.route(
75
+ prefix === '' ? '/' : prefix,
76
+ makeApp(ctx.home, () => ctx.get('appInstance')?.name ?? APP_NAME),
77
+ )
78
+ },
79
+ }
@@ -430,6 +430,28 @@
430
430
  "notes": "fb43a1c5f3b4",
431
431
  "todo": "164274ffcf7f"
432
432
  }
433
+ },
434
+ {
435
+ "version": "1.4.0",
436
+ "layouts": {
437
+ "agent": "46c6eadf9230",
438
+ "blank": "a7e8f539ffee",
439
+ "chat": "d4464dfb3b04",
440
+ "nodejs": "a6a340b4d3a6",
441
+ "notes": "fb43a1c5f3b4",
442
+ "todo": "164274ffcf7f"
443
+ }
444
+ },
445
+ {
446
+ "version": "1.5.0",
447
+ "layouts": {
448
+ "agent": "46c6eadf9230",
449
+ "blank": "a53d41e6ef42",
450
+ "chat": "d4464dfb3b04",
451
+ "nodejs": "a6a340b4d3a6",
452
+ "notes": "fb43a1c5f3b4",
453
+ "todo": "164274ffcf7f"
454
+ }
433
455
  }
434
456
  ],
435
457
  "templates": {
@@ -485,6 +507,10 @@
485
507
  "ecbc84f1c1e5": [
486
508
  "src/app.ts",
487
509
  "src/routes.ts"
510
+ ],
511
+ "46c6eadf9230": [
512
+ "src/app.ts",
513
+ "src/routes.ts"
488
514
  ]
489
515
  },
490
516
  "blank": {
@@ -519,6 +545,10 @@
519
545
  "a7e8f539ffee": [
520
546
  "src/app.ts",
521
547
  "src/routes.ts"
548
+ ],
549
+ "a53d41e6ef42": [
550
+ "src/app.ts",
551
+ "src/routes.ts"
522
552
  ]
523
553
  },
524
554
  "chat": {
@@ -2,7 +2,7 @@ import { t as Acryl } from "./app-CQcGWjrk.js";
2
2
  import "./core-BOu1jYua.js";
3
3
  import { t as basePath } from "./route-D8m-a2I1.js";
4
4
  import "./helpers-BEjcdbgw.js";
5
- import { n as compileModule, t as MissingTypeScriptError } from "./transpile-QUK9b3eG.js";
5
+ import { n as compileModule, t as MissingTypeScriptError } from "./transpile-CRRaixyS.js";
6
6
  import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { copyFileSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
@@ -2,7 +2,7 @@ import { t as Acryl } from "../app-CQcGWjrk.js";
2
2
  import { r as streamSSE } from "../streaming-BlDaph8U.js";
3
3
  import { r as loopbackAuthority } from "../loopback-only-BbasoF7R.js";
4
4
  import "../helpers-BEjcdbgw.js";
5
- import { n as clientModules } from "../client-modules-Bkdr83jO.js";
5
+ import { n as clientModules } from "../client-modules-C5qiVroz.js";
6
6
  import { a as MAX_WAIT_MS, d as parseChannelToHost, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES, u as WINDOW_ID } from "../protocol-CQ_if7Dg.js";
7
7
  import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-CXTnjxGx.js";
8
8
  import "../src-iBJ2ysV9.js";
@@ -1,4 +1,4 @@
1
- import { n as compileModule } from "../transpile-QUK9b3eG.js";
1
+ import { n as compileModule, r as usePackagesOfProgram } from "../transpile-CRRaixyS.js";
2
2
  import { registerHooks } from "node:module";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -35,6 +35,11 @@ registerHooks({ load(url, context, nextLoad) {
35
35
  */
36
36
  const plugins = new URL("../../", import.meta.url);
37
37
  const appRoot = new URL("../", plugins).href;
38
+ const family = new URL("instance/bundle.ts", plugins);
39
+ if (existsSync(fileURLToPath(family))) {
40
+ const { appTree } = await import(family.href);
41
+ if (appTree(process.env, fileURLToPath(appRoot)) !== void 0) usePackagesOfProgram(fileURLToPath(appRoot));
42
+ }
38
43
  const resolver = new URL("extensions/resolve.ts", plugins);
39
44
  if (existsSync(fileURLToPath(new URL("acryl-router/src/index.ts", plugins))) && existsSync(fileURLToPath(resolver))) {
40
45
  const { routerAliases } = await import(resolver.href);
@@ -1,5 +1,5 @@
1
1
  import { a as serve, d as RequestError, i as createAdaptorServer, n as UpgradeEmitter, o as upgradeWebSocket, r as UpgradeHub, s as getRequestListener, t as testWebSocket } from "./test-websocket-CQOSvBth.js";
2
2
  import { t as serveStatic } from "./serve-static-C1Nkj_xB.js";
3
3
  import { t as getConnInfo } from "./conninfo-CWJwieh4.js";
4
- import { n as clientModules, t as buildClientModules } from "./client-modules-Bkdr83jO.js";
4
+ import { n as clientModules, t as buildClientModules } from "./client-modules-C5qiVroz.js";
5
5
  export { RequestError, UpgradeEmitter, UpgradeHub, buildClientModules, clientModules, createAdaptorServer, getConnInfo, getRequestListener, serve, serveStatic, testWebSocket, upgradeWebSocket };
@@ -23,10 +23,25 @@ const importSources = /* @__PURE__ */ new Map();
23
23
  * @returns the compiler.
24
24
  * @throws MissingTypeScriptError when the app has no `typescript`.
25
25
  */
26
+ /** The nearest folder from `start` up whose node_modules has `typescript`, or undefined. */
27
+ function typescriptOwner(start) {
28
+ for (let dir = start;; dir = dirname(dir)) {
29
+ if (existsSync(join(dir, "node_modules", "typescript", "package.json"))) return dir;
30
+ if (dirname(dir) === dir) return void 0;
31
+ }
32
+ }
33
+ /** The Program's folder when this process is a desktop product's frame host: the person's source has no packages, the bundle has them. */
34
+ let programPackages;
35
+ /**
36
+ * In a desktop product's frame host, compile with the Program's packages when a file's own chain has none (`register.ts` calls this once it finds the
37
+ * product's app tree, plugins/instance/bundle.ts). Nowhere else: an app without `typescript` is told so.
38
+ */
39
+ function usePackagesOfProgram(dir) {
40
+ programPackages = dir;
41
+ }
26
42
  function compilerFor(file, what) {
27
- let owner;
28
- for (let dir = dirname(file); owner === void 0; dir = dirname(dir)) if (existsSync(join(dir, "node_modules", "typescript", "package.json"))) owner = dir;
29
- else if (dirname(dir) === dir) throw new MissingTypeScriptError(file, what);
43
+ const owner = typescriptOwner(dirname(file)) ?? (programPackages === void 0 ? void 0 : typescriptOwner(programPackages));
44
+ if (owner === void 0) throw new MissingTypeScriptError(file, what);
30
45
  const path = createRequire(join(owner, "package.json")).resolve("typescript");
31
46
  let compiler = compilers.get(path);
32
47
  if (compiler === void 0) {
@@ -87,4 +102,4 @@ function rewriteSpecifiers(ts, code, rewrite) {
87
102
  return result + code.slice(last);
88
103
  }
89
104
  //#endregion
90
- export { compileModule as n, MissingTypeScriptError as t };
105
+ export { compileModule as n, usePackagesOfProgram as r, MissingTypeScriptError as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -196,13 +196,17 @@ export function parseInvocation(argv: readonly string[], env: NodeJS.ProcessEnv
196
196
  }
197
197
  }
198
198
 
199
- /** The app folder: `--dir`, or the nearest folder upward from `cwd` that has `.acryl/door.json` (or at least `.acryl/`). */
199
+ /**
200
+ * The app folder: `--dir`, or the nearest folder upward from `cwd` that has `.acryl/door.json`. When no app is running, the nearest folder that is an app (it
201
+ * has a `blend.yaml`), so "no running app in <that folder>" names the app. A folder that only has a `.acryl/` is not an app: the person's `~/.acryl` is ACRYL's
202
+ * own home, and it must never be taken for the app the command was run in.
203
+ */
200
204
  export function findApp(cwd: string, dir: string | undefined): string | undefined {
201
205
  if (dir !== undefined) return resolve(cwd, dir)
202
206
  let fallback: string | undefined
203
207
  for (let at = resolve(cwd); ; at = dirname(at)) {
204
208
  if (existsSync(join(at, STATE_DIR, DOOR_FILE))) return at
205
- if (fallback === undefined && existsSync(join(at, STATE_DIR))) fallback = at
209
+ if (fallback === undefined && existsSync(join(at, 'blend.yaml'))) fallback = at
206
210
  if (dirname(at) === at) return fallback
207
211
  }
208
212
  }
@@ -10,7 +10,7 @@
10
10
  import { existsSync, readFileSync } from 'node:fs'
11
11
  import { registerHooks } from 'node:module'
12
12
  import { fileURLToPath, pathToFileURL } from 'node:url'
13
- import { compileModule } from './transpile.ts'
13
+ import { compileModule, usePackagesOfProgram } from './transpile.ts'
14
14
 
15
15
  /** The JSX runtime the docs set up; an app's tsconfig.json (or a file's own `@jsxImportSource` pragma) chooses another. */
16
16
  const DEFAULT_IMPORT_SOURCE = 'acrylblends/jsx'
@@ -36,6 +36,15 @@ registerHooks({
36
36
  */
37
37
  const plugins = new URL('../../', import.meta.url)
38
38
  const appRoot = new URL('../', plugins).href
39
+ // A desktop product's frame host runs the Program from the bundle on an app's source, which holds only the person's files: their JSX compiles with the
40
+ // Program's `typescript` (the instance family says whether this is such a host; a published framework has no family beside it).
41
+ const family = new URL('instance/bundle.ts', plugins)
42
+ if (existsSync(fileURLToPath(family))) {
43
+ const { appTree } = (await import(family.href)) as {
44
+ appTree(env: NodeJS.ProcessEnv, bundle: string): unknown
45
+ }
46
+ if (appTree(process.env, fileURLToPath(appRoot)) !== undefined) usePackagesOfProgram(fileURLToPath(appRoot))
47
+ }
39
48
  const resolver = new URL('extensions/resolve.ts', plugins)
40
49
  if (existsSync(fileURLToPath(new URL('acryl-router/src/index.ts', plugins))) && existsSync(fileURLToPath(resolver))) {
41
50
  const { routerAliases } = (await import(resolver.href)) as { routerAliases(): Record<string, string> }
@@ -36,13 +36,32 @@ const importSources = new Map<string, string>()
36
36
  * @returns the compiler.
37
37
  * @throws MissingTypeScriptError when the app has no `typescript`.
38
38
  */
39
- function compilerFor(file: string, what: string): Compiler {
40
- // Only the app's own node_modules chain counts: Node's global folders (NODE_PATH, which a package manager's shims may set) are not the app.
41
- let owner: string | undefined
42
- for (let dir = dirname(file); owner === undefined; dir = dirname(dir)) {
43
- if (existsSync(join(dir, 'node_modules', 'typescript', 'package.json'))) owner = dir
44
- else if (dirname(dir) === dir) throw new MissingTypeScriptError(file, what)
39
+ /** The nearest folder from `start` up whose node_modules has `typescript`, or undefined. */
40
+ function typescriptOwner(start: string): string | undefined {
41
+ for (let dir = start; ; dir = dirname(dir)) {
42
+ if (existsSync(join(dir, 'node_modules', 'typescript', 'package.json'))) return dir
43
+ if (dirname(dir) === dir) return undefined
45
44
  }
45
+ }
46
+
47
+ /** The Program's folder when this process is a desktop product's frame host: the person's source has no packages, the bundle has them. */
48
+ let programPackages: string | undefined
49
+
50
+ /**
51
+ * In a desktop product's frame host, compile with the Program's packages when a file's own chain has none (`register.ts` calls this once it finds the
52
+ * product's app tree, plugins/instance/bundle.ts). Nowhere else: an app without `typescript` is told so.
53
+ */
54
+ export function usePackagesOfProgram(dir: string): void {
55
+ programPackages = dir
56
+ }
57
+
58
+ function compilerFor(file: string, what: string): Compiler {
59
+ // Only the app's own node_modules chain counts: Node's global folders (NODE_PATH, which a package manager's shims may set) are not the app. In a
60
+ // desktop product the app is the person's source with the Program's packages.
61
+ const owner =
62
+ typescriptOwner(dirname(file)) ??
63
+ (programPackages === undefined ? undefined : typescriptOwner(programPackages))
64
+ if (owner === undefined) throw new MissingTypeScriptError(file, what)
46
65
  const path = createRequire(join(owner, 'package.json')).resolve('typescript')
47
66
  let compiler = compilers.get(path)
48
67
  if (compiler === undefined) {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The page's own icon: the ACRYL Blends logo (plugins/desktop/assets/acrylblends-logo.png at 64 px) inside an SVG, which is what a browser tab reads at 16 px. The wordmark is
3
+ * unreadable that small. The frontend package the page borrows its scripts from ships DeepSeek's whale as favicon.svg, so the page answers every icon request itself and
4
+ * never serves that file.
5
+ */
6
+ export const ACRYL_FAVICON_SVG =
7
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 64 64\" role=\"img\" aria-label=\"ACRYL Blends\"><image width=\"64\" height=\"64\" xlink:href=\"data:image/png;base64,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\"/></svg>"
8
+
9
+ /** The web manifest of an app page: its title and the icon above, not the frontend package's own. */
10
+ export function pageManifest(title: string): string {
11
+ return JSON.stringify({
12
+ name: title,
13
+ short_name: title,
14
+ start_url: './',
15
+ scope: './',
16
+ display: 'standalone',
17
+ icons: [{ src: 'favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
18
+ })
19
+ }
@@ -10,6 +10,7 @@ import { dirname, extname, join, normalize, sep } from 'node:path'
10
10
  import type { IncomingMessage, ServerResponse } from 'node:http'
11
11
  import type { Context } from '@deepseek-ai/cordis'
12
12
  import type {} from 'acryl-web-server'
13
+ import { ACRYL_FAVICON_SVG, pageManifest } from './favicon.ts'
13
14
 
14
15
  /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
15
16
  export const name = 'acryl-page'
@@ -64,12 +65,17 @@ export function clientAssetTags(indexHtml: string): string {
64
65
 
65
66
  /** Our document: minimal on purpose; the frame renders into `#root`, and the taps add the boot wire before `</head>`. */
66
67
  export function pageDocument(tags: string, title: string, taps: (html: string) => string): string {
68
+ // The title is an app's name, typed by a person: markup in it is text, never the page's.
69
+ const text = title.replace(/[&<>"']/gu, (character) => `&#${String(character.charCodeAt(0))};`)
67
70
  const document = `<!doctype html>
68
71
  <html lang="en">
69
72
  <head>
70
73
  <meta charset="utf-8" />
71
74
  <meta name="viewport" content="width=device-width, initial-scale=1" />
72
- <title>${title}</title>
75
+ <title>${text}</title>
76
+ <meta name="application-name" content="${text}" />
77
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
78
+ <link rel="manifest" href="/manifest.webmanifest" />
73
79
  ${tags}
74
80
  </head>
75
81
  <body>
@@ -143,21 +149,20 @@ export function apply(ctx: Context, config: Partial<Config> = {}): void {
143
149
  await serveFile(assets, pathname.slice('/assets'.length), res, req.method ?? 'GET')
144
150
  },
145
151
  })
146
- // Browsers ask for /favicon.ico unconditionally; the built light icon answers it.
152
+ // Browsers ask for /favicon.ico unconditionally. The icon is ours (the ACRYL mark), never the frontend package's DeepSeek whale.
153
+ const sendIcon = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
154
+ res.writeHead(200, { 'content-type': 'image/svg+xml', 'content-length': String(Buffer.byteLength(ACRYL_FAVICON_SVG)), 'cache-control': 'no-cache' })
155
+ res.end(req.method === 'HEAD' ? undefined : ACRYL_FAVICON_SVG)
156
+ }
157
+ for (const path of ['/favicon.ico', '/favicon.svg', '/favicon-dark.svg'])
158
+ ctx.webServer.register({ kind: 'exact', path, handler: sendIcon })
147
159
  ctx.webServer.register({
148
160
  kind: 'exact',
149
- path: '/favicon.ico',
161
+ path: '/manifest.webmanifest',
150
162
  handler: async (req: IncomingMessage, res: ServerResponse) => {
151
- await serveFile(dist, 'favicon.svg', res, req.method ?? 'GET')
163
+ const manifest = pageManifest(title)
164
+ res.writeHead(200, { 'content-type': 'application/manifest+json', 'content-length': String(Buffer.byteLength(manifest)) })
165
+ res.end(req.method === 'HEAD' ? undefined : manifest)
152
166
  },
153
167
  })
154
- for (const file of ['favicon.svg', 'favicon-dark.svg', 'manifest.webmanifest']) {
155
- ctx.webServer.register({
156
- kind: 'exact',
157
- path: `/${file}`,
158
- handler: async (req: IncomingMessage, res: ServerResponse) => {
159
- await serveFile(dist, file, res, req.method ?? 'GET')
160
- },
161
- })
162
- }
163
168
  }
@@ -525,6 +525,8 @@ export async function start(prefix: string = '/api/agent-panel'): Promise<void>
525
525
  const watch = async (source: RunSource, own: boolean): Promise<void> => {
526
526
  let live = true
527
527
  let changed = false
528
+ // Every run the server finishes ends with `done` or `error`; a stream that stops without either lost its server, and the run with it.
529
+ let finished = false
528
530
  let answer: HTMLElement | undefined
529
531
  let answered = ''
530
532
  // The Assistant's steps on the page, shown under its answer in one line, as the person reads what it did.
@@ -592,11 +594,18 @@ export async function start(prefix: string = '/api/agent-panel'): Promise<void>
592
594
  changed = true
593
595
  line(`plugin ${String(data.extension)}: ${String(data.action)}`, 'tool')
594
596
  } else if (event === 'error') {
597
+ finished = true
595
598
  line(String(data.message ?? 'The run failed.'), 'err')
596
599
  } else if (event === 'done') {
600
+ finished = true
597
601
  changed = changed || data.changed === true
598
602
  }
599
603
  }
604
+ if (live && working !== undefined && !finished)
605
+ line(
606
+ 'The app stopped while the agent was working, and the run ended with it (`npm run dev` restarts the app when a file it runs changes). Ask again to go on.',
607
+ 'err',
608
+ )
600
609
  } catch (error) {
601
610
  line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
602
611
  } finally {
@@ -1,9 +1,9 @@
1
1
  /** The panel's own drivers, all `chat`: the app's agent, and the coding agents (`claude`, `codex`, `pi`) started headless in the app folder. */
2
2
  import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
3
3
  import { readFileSync } from 'node:fs'
4
- import { homedir } from 'node:os'
5
4
  import { join } from 'node:path'
6
5
  import type { Context } from '@deepseek-ai/cordis'
6
+ import { personHome } from '../instance/storage.ts'
7
7
  import type { ChatDriver, ChatEvent } from './drivers.ts'
8
8
  import { resolveLaunch } from './launch.ts'
9
9
 
@@ -78,7 +78,7 @@ export function onPath(program: string): boolean {
78
78
  /** Whether pi can reach DeepSeek: the key in the environment, or the one pi stored for itself when its owner logged in (`~/.pi/agent/auth.json`, a `deepseek` entry). */
79
79
  export function piHasDeepseekKey(
80
80
  env: NodeJS.ProcessEnv = process.env,
81
- home: string = homedir(),
81
+ home: string = personHome(env),
82
82
  ): boolean {
83
83
  if (env.DEEPSEEK_API_KEY) return true
84
84
  try {
@@ -0,0 +1,151 @@
1
+ /**
2
+ * What the frame's App pages ask of a desktop product (app-storage.md, "What the person sees"): the first start's "Name your app" and Settings > App
3
+ * (name, size, show the folder, export, import, start over, the other apps, git's licence). The page asks through one IPC channel that `preload.cjs`
4
+ * exposes as `window.acrylBlendsApp`; this module answers, over `AppStorage` and the shell's own ports, so it is tested without Electron.
5
+ *
6
+ * Every answer is a plain result, never a thrown IPC error: the page shows `message` as it is.
7
+ */
8
+
9
+ import type { AppInstance } from '../instance/index.ts'
10
+ import type { AppSize, AppStorage, RegisteredApp } from '../instance/storage.ts'
11
+ import type { ProductGit } from './git.ts'
12
+
13
+ /** The IPC channel; `preload.cjs` names the same string. */
14
+ export const APP_PAGE_CHANNEL = 'acrylblends:app'
15
+
16
+ /** What the page shows. `named` is false until the person names the app (the first start asks). */
17
+ export interface AppPageState {
18
+ readonly id: string
19
+ readonly name: string
20
+ readonly named: boolean
21
+ /** The other apps on this computer, to open one instead. */
22
+ readonly others: readonly {
23
+ readonly id: string
24
+ readonly name: string
25
+ readonly created: string
26
+ }[]
27
+ /** What the OS calls its file manager: the Show button's label. */
28
+ readonly fileManager: 'Finder' | 'Explorer' | 'Files'
29
+ /** The product ships git (its licence and source are offered on the page). */
30
+ readonly git: boolean
31
+ }
32
+
33
+ export type AppPageResult =
34
+ | { readonly ok: true; readonly value?: unknown }
35
+ | { readonly ok: false; readonly message: string }
36
+
37
+ export interface AppPagePorts {
38
+ readonly storage: AppStorage
39
+ /** The id of the app the window shows now. */
40
+ current(): string
41
+ /** A new app's starting files (the first start's seed). */
42
+ readonly seed: (source: string) => void
43
+ /**
44
+ * Stop the frame host, run `next` (which may move the old app's folders), and start the host on the app it returns, reloading the windows. When
45
+ * `next` fails, the app that ran comes back and the failure is the answer.
46
+ */
47
+ switchTo(next: () => AppInstance): Promise<void>
48
+ /** The save dialog for an export (`suggested` is the file name), or undefined when the person cancels. */
49
+ chooseExportFile(suggested: string): Promise<string | undefined>
50
+ /** The open dialog for an import, or undefined when the person cancels. */
51
+ chooseImportFile(): Promise<string | undefined>
52
+ /** Open a file with the OS's own application for it. */
53
+ openPath(path: string): Promise<void>
54
+ /** The app's label changed: the process name and the window titles follow. */
55
+ renamed(app: RegisteredApp): void
56
+ readonly platform: NodeJS.Platform
57
+ readonly git?: ProductGit
58
+ }
59
+
60
+ const fileManagerOf = (platform: NodeJS.Platform): AppPageState['fileManager'] =>
61
+ platform === 'darwin' ? 'Finder' : platform === 'win32' ? 'Explorer' : 'Files'
62
+
63
+ /** A file name every OS accepts, from the app's label: the export's suggested name. */
64
+ export function exportFileName(name: string): string {
65
+ const safe = [...name]
66
+ .filter((character) => (character.codePointAt(0) ?? 0) >= 0x20)
67
+ .join('')
68
+ .replace(/[<>:"/\\|?*]/gu, '-')
69
+ .replace(/[. ]+$/u, '')
70
+ .trim()
71
+ return `${safe === '' ? 'app' : safe}.acrylblend`
72
+ }
73
+
74
+ /** The handler of the page's channel: one operation and its argument, one result. */
75
+ export function appPageHandler(
76
+ ports: AppPagePorts,
77
+ ): (op: unknown, arg: unknown) => Promise<AppPageResult> {
78
+ const state = (): AppPageState => {
79
+ const id = ports.current()
80
+ const app = ports.storage.app(id)
81
+ return {
82
+ id,
83
+ name: app.name,
84
+ named: app.named === true,
85
+ others: ports.storage
86
+ .apps()
87
+ .filter((other) => other.id !== id)
88
+ .map((other) => ({ id: other.id, name: other.name, created: other.created })),
89
+ fileManager: fileManagerOf(ports.platform),
90
+ git: ports.git !== undefined,
91
+ }
92
+ }
93
+ const run = async (op: unknown, arg: unknown): Promise<unknown> => {
94
+ switch (op) {
95
+ case 'state':
96
+ return state()
97
+ case 'size':
98
+ return (await ports.storage.size(ports.current())) satisfies AppSize
99
+ case 'rename': {
100
+ if (typeof arg !== 'string') throw new Error('a name is text')
101
+ ports.renamed(ports.storage.rename(ports.current(), arg))
102
+ return state()
103
+ }
104
+ case 'reveal':
105
+ ports.storage.reveal(ports.current())
106
+ return undefined
107
+ case 'export': {
108
+ const data = (arg as { data?: unknown } | undefined)?.data === true
109
+ const id = ports.current()
110
+ const file = await ports.chooseExportFile(exportFileName(ports.storage.app(id).name))
111
+ if (file === undefined) return { cancelled: true }
112
+ ports.storage.export(id, file, { data })
113
+ return { file }
114
+ }
115
+ case 'import': {
116
+ const file = await ports.chooseImportFile()
117
+ if (file === undefined) return { cancelled: true }
118
+ // Made beside the running app, which keeps running until the new one is ready to open.
119
+ const made = ports.storage.import(file)
120
+ await ports.switchTo(() => ports.storage.open(made.id))
121
+ return undefined
122
+ }
123
+ case 'startOver': {
124
+ const id = ports.current()
125
+ await ports.switchTo(() => ports.storage.open(ports.storage.reset(id, ports.seed).id))
126
+ return undefined
127
+ }
128
+ case 'open': {
129
+ if (typeof arg !== 'string' || !ports.storage.apps().some((app) => app.id === arg))
130
+ throw new Error('there is no such app on this computer')
131
+ if (arg !== ports.current()) await ports.switchTo(() => ports.storage.open(arg))
132
+ return undefined
133
+ }
134
+ case 'gitLicence':
135
+ case 'gitSource': {
136
+ if (ports.git === undefined) throw new Error('this app ships no git of its own')
137
+ await ports.openPath(op === 'gitLicence' ? ports.git.licence : ports.git.source)
138
+ return undefined
139
+ }
140
+ default:
141
+ throw new Error(`the app page cannot ${String(op)}`)
142
+ }
143
+ }
144
+ return async (op, arg) => {
145
+ try {
146
+ return { ok: true, value: await run(op, arg) }
147
+ } catch (cause) {
148
+ return { ok: false, message: cause instanceof Error ? cause.message : String(cause) }
149
+ }
150
+ }
151
+ }