acrylblends 1.3.0 → 1.3.2

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 (68) hide show
  1. package/dist/index.js +5 -3
  2. package/dist/index.js.map +1 -1
  3. package/docs/framework/workshop/README.md +2 -0
  4. package/legacy/README.md +4 -1
  5. package/legacy/agent/858de7a1ad0b/src/app.ts +289 -0
  6. package/legacy/agent/858de7a1ad0b/src/routes.ts +145 -0
  7. package/legacy/agent/b96ba6e3598a/src/app.ts +285 -0
  8. package/legacy/agent/b96ba6e3598a/src/routes.ts +145 -0
  9. package/legacy/agent/ecbc84f1c1e5/src/app.ts +291 -0
  10. package/legacy/agent/ecbc84f1c1e5/src/routes.ts +145 -0
  11. package/legacy/blank/2447ac6d5d24/src/app.ts +61 -0
  12. package/legacy/blank/387f7e25aaeb/src/app.ts +81 -0
  13. package/legacy/blank/5b9f10e1ba5e/src/app.ts +61 -0
  14. package/legacy/blank/5f6dd8910c50/src/app.ts +152 -0
  15. package/legacy/blank/5f6dd8910c50/src/routes.ts +75 -0
  16. package/legacy/blank/a7e8f539ffee/src/app.ts +160 -0
  17. package/legacy/blank/a7e8f539ffee/src/routes.ts +75 -0
  18. package/legacy/blank/bdb9735e013b/src/app.ts +61 -0
  19. package/legacy/blank/c3be1d735b1a/src/app.ts +61 -0
  20. package/legacy/blank/c3be1d735b1a/src/routes.ts +18 -0
  21. package/legacy/blank/e04303a7f8dd/src/app.ts +61 -0
  22. package/legacy/chat/5120c4a9fa61/src/app.ts +157 -0
  23. package/legacy/chat/5120c4a9fa61/src/routes.ts +29 -0
  24. package/legacy/chat/d4464dfb3b04/src/app.ts +165 -0
  25. package/legacy/chat/d4464dfb3b04/src/routes.ts +29 -0
  26. package/legacy/frozen.json +7 -0
  27. package/legacy/layouts.json +135 -45
  28. package/legacy/nodejs/9d59115118c7/src/app.ts +150 -0
  29. package/legacy/nodejs/9d59115118c7/src/routes.ts +59 -0
  30. package/legacy/nodejs/a6a340b4d3a6/src/app.ts +158 -0
  31. package/legacy/nodejs/a6a340b4d3a6/src/routes.ts +59 -0
  32. package/legacy/notes/fb43a1c5f3b4/src/app.ts +165 -0
  33. package/legacy/notes/fb43a1c5f3b4/src/routes.ts +21 -0
  34. package/legacy/notes/fc3fe7c749d2/src/app.ts +157 -0
  35. package/legacy/notes/fc3fe7c749d2/src/routes.ts +21 -0
  36. package/legacy/todo/164274ffcf7f/src/app.ts +145 -0
  37. package/legacy/todo/164274ffcf7f/src/todo.ts +137 -0
  38. package/lib/cordis/door.d.ts +10 -1
  39. package/lib/cordis/door.js +11 -4
  40. package/package.json +1 -1
  41. package/plugins/acryl-door/src/host/door.ts +17 -5
  42. package/plugins/builder/index.ts +1 -1
  43. package/plugins/builder/plugin.json +1 -1
  44. package/plugins/extensions/index.ts +57 -2
  45. package/plugins/extensions/routes.ts +92 -0
  46. package/plugins/home/index.ts +3 -1
  47. package/plugins/plugin-share/README.md +16 -1
  48. package/plugins/plugin-share/index.ts +130 -16
  49. package/plugins/plugin-share/plugin.json +1 -1
  50. package/templates/agent/src/app.ts +2 -0
  51. package/templates/blank/blend.yaml +3 -0
  52. package/templates/blank/src/app.ts +8 -0
  53. package/templates/blank/template.json +1 -0
  54. package/templates/chat/blend.yaml +3 -0
  55. package/templates/chat/src/app.ts +8 -0
  56. package/templates/chat/template.json +1 -0
  57. package/templates/nodejs/blend.yaml +3 -0
  58. package/templates/nodejs/src/app.ts +8 -0
  59. package/templates/nodejs/template.json +1 -0
  60. package/templates/notes/blend.yaml +3 -0
  61. package/templates/notes/src/app.ts +8 -0
  62. package/templates/notes/template.json +1 -0
  63. /package/legacy/blank/{d6d371c9a65e → 2447ac6d5d24}/src/routes.ts +0 -0
  64. /package/legacy/blank/{5a91c558af22 → 387f7e25aaeb}/src/routes.ts +0 -0
  65. /package/legacy/blank/{b2cbb759c889 → 5b9f10e1ba5e}/src/routes.ts +0 -0
  66. /package/legacy/blank/{9c650cf52bf3 → bdb9735e013b}/src/routes.ts +0 -0
  67. /package/legacy/blank/{aea589283445 → e04303a7f8dd}/src/routes.ts +0 -0
  68. /package/plugins/{builder → plugin-share}/package.ts +0 -0
@@ -0,0 +1,145 @@
1
+ import { join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { Context, type Fiber } from '@deepseek-ai/cordis'
4
+ import * as door from '../plugins/acryl-door/src/index.ts'
5
+ import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
6
+ import * as router from '../plugins/acryl-router/src/index.ts'
7
+ import * as state from '../plugins/acryl-state/src/index.ts'
8
+ import { type AppInstance, instance } from '../plugins/instance/index.ts'
9
+ import {
10
+ appPluginNames,
11
+ blueprintsIn,
12
+ compose,
13
+ loadAppPlugins,
14
+ loadManifest,
15
+ type ParameterValue,
16
+ } from '../plugins/manifest/index.ts'
17
+ import { doorEnabled } from './door.ts'
18
+ import * as todo from './todo.ts'
19
+ import { FRAME_MOUNT_PREFIX } from './todo.ts'
20
+
21
+ /** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them, and adds the app's own plugins from `plugins/` by name. */
22
+ export const registry = {
23
+ 'acryl-router': router,
24
+ 'acryl-state': state,
25
+ todo,
26
+ 'acryl-node-server': nodeServer,
27
+ 'acryl-door': door,
28
+ }
29
+
30
+ export const APP_NAME = '__APP_NAME__'
31
+
32
+ /** The manifest next to this app's source. */
33
+ export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
34
+ /** The app's plugins: the library copies `registry` imports, and the app's own plugins (a feature each), which `blend.yaml` mounts by their folder name. */
35
+ const PLUGINS_DIR = join(fileURLToPath(new URL('..', import.meta.url)), 'plugins')
36
+
37
+ /**
38
+ * The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
39
+ * `createApp` and the tests read it the same way.
40
+ */
41
+ export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
42
+ return loadManifest(MANIFEST, {
43
+ known: [...Object.keys(registry), ...appPluginNames(PLUGINS_DIR)],
44
+ getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
45
+ ...(values ? { values } : {}),
46
+ })
47
+ }
48
+
49
+ /** The manifest without some rows: without its server (and so its door) the app is reached in process, through `root.router.request`; the door is also left out in production. */
50
+ function without<M extends { plugins: readonly { use: string }[] }>(
51
+ manifest: M,
52
+ ...uses: string[]
53
+ ): M {
54
+ return {
55
+ ...manifest,
56
+ plugins: manifest.plugins.map((row) =>
57
+ uses.includes(row.use) ? { ...row, disabled: true } : row,
58
+ ),
59
+ }
60
+ }
61
+
62
+ export interface App {
63
+ readonly root: Context
64
+ /** The address the server listens on; empty when the app was made with `listen: false`. */
65
+ readonly url: string
66
+ stop(): Promise<void>
67
+ }
68
+
69
+ export interface AppOptions {
70
+ readonly values?: Readonly<Record<string, ParameterValue>>
71
+ /** The path the app is mounted at; absent on its own server, where it keeps `/todo`, and {@link FRAME_MOUNT_PREFIX} in the frame. */
72
+ readonly prefix?: string
73
+ /** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
74
+ readonly listen?: boolean
75
+ }
76
+
77
+ /** The rows the frame host provides: the app contributes only the plugins that are its own. */
78
+ const HOST_ROWS = [
79
+ 'instance',
80
+ 'acryl-router',
81
+ 'acryl-node-server',
82
+ 'acryl-web-server',
83
+ 'acryl-page',
84
+ 'manifest',
85
+ 'save',
86
+ 'acryl-door',
87
+ ]
88
+
89
+ /**
90
+ * The app's rows, composed into `ctx`: the app's own server calls this from `createApp`, and the generated frame layer mounts it as one row, so the frame
91
+ * runs the app exactly as the app's own server does (same rows, same order, same config). `host: true` leaves the infrastructure rows to the frame host,
92
+ * which is where the door lives too, and mounts the app at {@link FRAME_MOUNT_PREFIX}.
93
+ */
94
+ export async function composeInto(
95
+ ctx: Context,
96
+ chosen: AppInstance,
97
+ options: AppOptions & { readonly host?: boolean } = {},
98
+ ): Promise<Fiber[]> {
99
+ const read = readManifest(options.values)
100
+ const own = options.host !== true && options.listen !== false
101
+ // The frame host mounts the app under its own prefix; the app's own server keeps the app's old path.
102
+ const prefix = options.prefix ?? (options.host === true ? FRAME_MOUNT_PREFIX : undefined)
103
+ const leaveOut = new Set<string>(options.host === true ? HOST_ROWS : [])
104
+ // The server, and the door that needs it, are not mounted when the app is reached in process or inside the frame host.
105
+ if (!own) leaveOut.add('acryl-node-server').add('acryl-door')
106
+ if (!doorEnabled()) leaveOut.add('acryl-door')
107
+ const manifest = without(read, ...leaveOut)
108
+ // The rows that name one of the app's own plugin folders (a feature: plugins/<name>/ and its row) rather than a registry import; each one that runs is imported here.
109
+ const ownRows = [
110
+ ...new Set(read.plugins.map((row) => row.use).filter((use) => !Object.hasOwn(registry, use))),
111
+ ]
112
+ const ownPlugins = await loadAppPlugins(
113
+ PLUGINS_DIR,
114
+ ownRows.filter((use) => manifest.plugins.some((row) => row.use === use && !row.disabled)),
115
+ )
116
+ return compose(
117
+ ctx,
118
+ manifest,
119
+ { ...registry, ...ownPlugins },
120
+ {
121
+ // Runtime values the manifest cannot hold: where the list is kept (in the app's home) and, in the frame, the prefix the app answers at.
122
+ todo: {
123
+ file: join(chosen.home, 'todo.json'),
124
+ ...(prefix === undefined ? {} : { prefix }),
125
+ },
126
+ // Only the app's own server listens; the frame host owns the server and its port.
127
+ ...(own ? { server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 } } : {}),
128
+ },
129
+ )
130
+ }
131
+
132
+ /** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
133
+ export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
134
+ const root = new Context()
135
+ const first = root.plugin(instance, chosen)
136
+ await first.await()
137
+ const fibers: Fiber[] = [first, ...(await composeInto(root, chosen, options))]
138
+ return {
139
+ root,
140
+ url: options.listen === false ? '' : root.nodeServer.url,
141
+ async stop() {
142
+ for (const fiber of [...fibers].reverse()) await fiber.dispose()
143
+ },
144
+ }
145
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The reference app of the ACRYL Framework: a todo list in one plugin. It needs `router` and `state`, keeps the list in a cell, serves
3
+ * JSON CRUD plus a page, and writes the list to a file so it survives a restart. Nothing else.
4
+ */
5
+
6
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
7
+ import { dirname } from 'node:path'
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import { Acryl, HTTPException } from '../plugins/acryl-router/src/index.ts'
10
+ import { bodyLimit, loopbackOnly, trimTrailingSlash } from '../plugins/acryl-router/src/middleware/index.ts'
11
+ import { schemaValidator, streamSSE, type StandardSchema } from '../plugins/acryl-router/src/helpers/index.ts'
12
+ import type {} from '../plugins/acryl-state/src/index.ts'
13
+ import { pageHtml } from './page.ts'
14
+
15
+ /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
16
+ export const name = 'acryl-todo'
17
+ export const inject = ['router', 'state']
18
+
19
+ /**
20
+ * The only place the app's mount prefix is named: the app answers under `/todo` on its own server (`npm start`), and the ACRYL frame mounts the same app
21
+ * at `/app` (`npm run dev`). Every URL the page writes is derived from the prefix it is mounted at, so both are served by one source.
22
+ */
23
+ export const FRAME_MOUNT_PREFIX = '/app'
24
+
25
+ export interface Todo { id: number, title: string, done: boolean }
26
+ export interface Config {
27
+ file?: string
28
+ /**
29
+ * The path the app is mounted at, and the base of every URL its page writes. `/todo` on the app's own server (and the default); the ACRYL frame passes
30
+ * `/app` so the same app answers at the frame's app prefix, with nothing else of its URLs moved.
31
+ */
32
+ prefix?: string
33
+ }
34
+
35
+ /** Standard Schema for the body of a new todo, written by hand so the example has no schema library. */
36
+ const NewTodo: StandardSchema<{ title: string }> = {
37
+ '~standard': {
38
+ validate: value => {
39
+ const title = (value as { title?: unknown } | null)?.title
40
+ return typeof title === 'string' && title.trim() !== '' && title.length <= 200
41
+ ? { value: { title: title.trim() } }
42
+ : { issues: [{ message: 'title must be 1 to 200 characters', path: ['title'] }] }
43
+ },
44
+ },
45
+ }
46
+
47
+ /** Standard Schema for a change to a todo. */
48
+ const TodoChange: StandardSchema<{ done: boolean }> = {
49
+ '~standard': {
50
+ validate: value => {
51
+ const done = (value as { done?: unknown } | null)?.done
52
+ return typeof done === 'boolean' ? { value: { done } } : { issues: [{ message: 'done must be true or false', path: ['done'] }] }
53
+ },
54
+ },
55
+ }
56
+
57
+ /** A todo body is small; anything bigger is refused before it is read. */
58
+ const MAX_BODY_BYTES = 16 * 1024
59
+
60
+ /** A missing file is a new list. Any other failure (unreadable, corrupt, half-written) refuses to start: starting empty would overwrite the user's real list on the first save. */
61
+ function load(file: string | undefined): Todo[] {
62
+ if (file === undefined) return []
63
+ let text: string
64
+ try {
65
+ text = readFileSync(file, 'utf8')
66
+ } catch (error) {
67
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
68
+ throw new Error(`acryl-todo: cannot read ${file}: ${(error as Error).message}`, { cause: error })
69
+ }
70
+ try {
71
+ const parsed: unknown = JSON.parse(text)
72
+ if (!Array.isArray(parsed)) throw new Error('not a list')
73
+ return parsed as Todo[]
74
+ } catch (error) {
75
+ throw new Error(`acryl-todo: ${file} is not a valid todo list (${(error as Error).message}); fix or remove it, it was left untouched`, { cause: error })
76
+ }
77
+ }
78
+
79
+ function save(file: string, todos: readonly Todo[]): void {
80
+ mkdirSync(dirname(file), { recursive: true })
81
+ const temporary = `${file}.tmp`
82
+ writeFileSync(temporary, JSON.stringify(todos))
83
+ renameSync(temporary, file)
84
+ }
85
+
86
+ export function apply(ctx: Context, config: Config = {}): void {
87
+ const prefix = config.prefix ?? '/todo'
88
+ const todos = ctx.state.cell<readonly Todo[]>('todo.list', load(config.file))
89
+ if (config.file !== undefined) {
90
+ const file = config.file
91
+ ctx.effect(() => todos.subscribe(list => { save(file, list) }), 'acryl-todo: save to file')
92
+ }
93
+ const nextId = (): number => todos.get().reduce((max, todo) => Math.max(max, todo.id), 0) + 1
94
+
95
+ const find = (id: number): Todo => {
96
+ const todo = todos.get().find(item => item.id === id)
97
+ if (todo === undefined) throw new HTTPException(404, { message: 'no such todo' })
98
+ return todo
99
+ }
100
+
101
+ // `/todo/` is what people type: trimTrailingSlash answers a GET for an unknown path ending in `/` with a 301 to the path without it,
102
+ // so the page is found either way while each route keeps one canonical path (the router itself treats `/a` and `/a/` as different).
103
+ const api = new Acryl()
104
+ .use(loopbackOnly({ inProcess: 'allow' }), bodyLimit({ maxSize: MAX_BODY_BYTES }), trimTrailingSlash())
105
+ .get('/', c => c.html(pageHtml(prefix)))
106
+ .get('/api', c => c.json(todos.get()))
107
+ .post('/api', schemaValidator('json', NewTodo), c => {
108
+ const todo: Todo = { id: nextId(), title: c.req.valid('json').title, done: false }
109
+ todos.update(list => [...list, todo])
110
+ return c.json(todo, 201)
111
+ })
112
+ .patch('/api/:id', schemaValidator('json', TodoChange), c => {
113
+ const { id } = find(Number(c.req.param('id')))
114
+ const { done } = c.req.valid('json')
115
+ todos.update(list => list.map(todo => todo.id === id ? { ...todo, done } : todo))
116
+ return c.json(find(id))
117
+ })
118
+ .delete('/api/:id', c => {
119
+ const { id } = find(Number(c.req.param('id')))
120
+ todos.update(list => list.filter(todo => todo.id !== id))
121
+ return c.body(null, 204)
122
+ })
123
+ .get('/events', c => streamSSE(c, async stream => {
124
+ const ended = new Promise<void>(resolve => { stream.onAbort(resolve) })
125
+ // Writes are asynchronous; chaining them keeps events in the order the list changed.
126
+ let queue: Promise<unknown> = Promise.resolve()
127
+ const send = (list: readonly Todo[]): void => { queue = queue.then(() => stream.writeSSE({ data: JSON.stringify(list) })).catch(() => {}) }
128
+ send(todos.get())
129
+ const unsubscribe = todos.subscribe(send)
130
+ try {
131
+ await ended
132
+ } finally {
133
+ unsubscribe()
134
+ }
135
+ }))
136
+ ctx.router.route(prefix === '' ? '/' : prefix, api)
137
+ }
@@ -96,8 +96,12 @@ interface ExtensionsPort {
96
96
  reload(): Promise<unknown>;
97
97
  list(): readonly unknown[];
98
98
  }
99
- /** The slice of acrylblends' `pluginShare` service the pull verb uses. */
99
+ /** The slice of acrylblends' `pluginShare` service the pull, list and remove verbs use. */
100
100
  interface PluginSharePort {
101
+ /** The plugins installed while the app runs, a pulled one with its source and author. */
102
+ list(): readonly unknown[];
103
+ /** Take a plugin out and forget its record; a pulled folder still holding the release as pulled goes too. */
104
+ remove(name: string): Promise<unknown>;
101
105
  pull(source: string): Promise<{
102
106
  readonly ok: boolean;
103
107
  readonly action?: string;
@@ -207,6 +211,11 @@ declare class Door {
207
211
  private parse;
208
212
  /** Run one call on the page and record it. The audit holds which control was touched and how it ended, never what was typed. */
209
213
  private callPage;
214
+ /**
215
+ * Who lists and removes the app's plugins: plugin-share where the app has it, as it knows where each pulled plugin came from and is the one app service
216
+ * the frame's host reaches (extensions stays in the app's own realm there); extensions elsewhere.
217
+ */
218
+ private installed;
210
219
  private plugin;
211
220
  private share;
212
221
  /** Only a plan: no argument of any verb can publish, so an agent never says yes for a person. */
@@ -706,14 +706,14 @@ var Door = class {
706
706
  text: `done${result.target === void 0 ? "" : `: ${result.target.role} "${result.target.name}"`}${result.detail === void 0 ? "" : ` (${result.detail})`}; look again before the next act`
707
707
  };
708
708
  }
709
- case "plugin-list": return this.plugin((extensions) => extensions.list(), false);
709
+ case "plugin-list": return this.plugin((plugins) => plugins.list(), false, this.installed());
710
710
  case "plugin-add": return this.plugin((extensions) => extensions.install(this.insideApp(input.folder)));
711
711
  case "plugin-pull": return this.pull(input.source);
712
712
  case "plugin-publish": return this.planPublish(input);
713
713
  case "plugin-remove": {
714
714
  const name = input.name;
715
715
  if (typeof name !== "string" || name === "") throw new DoorError("invalid", "plugin remove needs a plugin name");
716
- return this.plugin((extensions) => extensions.remove(name));
716
+ return this.plugin((plugins) => plugins.remove(name), true, this.installed());
717
717
  }
718
718
  case "plugin-reload": return this.plugin((extensions) => extensions.reload());
719
719
  case "tool-list": {
@@ -942,8 +942,15 @@ var Door = class {
942
942
  throw cause;
943
943
  }
944
944
  }
945
- async plugin(run, mutates = true) {
946
- const extensions = this.deps.extensions();
945
+ /**
946
+ * Who lists and removes the app's plugins: plugin-share where the app has it, as it knows where each pulled plugin came from and is the one app service
947
+ * the frame's host reaches (extensions stays in the app's own realm there); extensions elsewhere.
948
+ */
949
+ installed() {
950
+ return this.deps.pluginShare?.() ?? this.deps.extensions();
951
+ }
952
+ async plugin(run, mutates = true, port = this.deps.extensions()) {
953
+ const extensions = port;
947
954
  if (extensions === void 0) throw new DoorError("unavailable", "this door has no extensions plugin to drive. In ACRYL's frame none is needed: a saved change in src/ or plugins/ reloads the app by itself, so do not restart it. Elsewhere, the app has no way to install plugins while it runs");
948
955
  const value = await run(extensions);
949
956
  const unchanged = typeof value === "object" && value !== null && value.action === "unchanged";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
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",
@@ -35,8 +35,12 @@ export interface ExtensionsPort {
35
35
  list(): readonly unknown[]
36
36
  }
37
37
 
38
- /** The slice of acrylblends' `pluginShare` service the pull verb uses. */
38
+ /** The slice of acrylblends' `pluginShare` service the pull, list and remove verbs use. */
39
39
  export interface PluginSharePort {
40
+ /** The plugins installed while the app runs, a pulled one with its source and author. */
41
+ list(): readonly unknown[]
42
+ /** Take a plugin out and forget its record; a pulled folder still holding the release as pulled goes too. */
43
+ remove(name: string): Promise<unknown>
40
44
  pull(source: string): Promise<{ readonly ok: boolean; readonly action?: string; readonly reason?: string; readonly next?: string }>
41
45
  planPublish(name: string, options?: PublishOptionsView): Promise<PublishPlanView | { readonly ok: false; readonly reason: string; readonly next: string }>
42
46
  publish(name: string, confirmed: PublishPlanView, options?: PublishOptionsView): Promise<{ readonly ok: boolean; readonly reason?: string }>
@@ -153,14 +157,14 @@ export class Door {
153
157
  const result = await this.act(input, signal, scope)
154
158
  return { value: result, text: `done${result.target === undefined ? '' : `: ${result.target.role} "${result.target.name}"`}${result.detail === undefined ? '' : ` (${result.detail})`}; look again before the next act` }
155
159
  }
156
- case 'plugin-list': return this.plugin(extensions => extensions.list(), false)
160
+ case 'plugin-list': return this.plugin(plugins => plugins.list(), false, this.installed())
157
161
  case 'plugin-add': return this.plugin(extensions => extensions.install(this.insideApp(input.folder)))
158
162
  case 'plugin-pull': return this.pull(input.source)
159
163
  case 'plugin-publish': return this.planPublish(input)
160
164
  case 'plugin-remove': {
161
165
  const name = input.name
162
166
  if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'plugin remove needs a plugin name')
163
- return this.plugin(extensions => extensions.remove(name))
167
+ return this.plugin(plugins => plugins.remove(name), true, this.installed())
164
168
  }
165
169
  case 'plugin-reload': return this.plugin(extensions => extensions.reload())
166
170
  case 'tool-list': {
@@ -337,8 +341,16 @@ export class Door {
337
341
  }
338
342
  }
339
343
 
340
- private async plugin(run: (extensions: ExtensionsPort) => unknown, mutates = true): Promise<VerbAnswer> {
341
- const extensions = this.deps.extensions()
344
+ /**
345
+ * Who lists and removes the app's plugins: plugin-share where the app has it, as it knows where each pulled plugin came from and is the one app service
346
+ * the frame's host reaches (extensions stays in the app's own realm there); extensions elsewhere.
347
+ */
348
+ private installed(): Pick<ExtensionsPort, 'list' | 'remove'> | undefined {
349
+ return this.deps.pluginShare?.() ?? this.deps.extensions()
350
+ }
351
+
352
+ private async plugin<Port = ExtensionsPort>(run: (plugins: Port) => unknown, mutates = true, port: Port | undefined = this.deps.extensions() as Port | undefined): Promise<VerbAnswer> {
353
+ const extensions = port
342
354
  if (extensions === undefined) throw new DoorError('unavailable', 'this door has no extensions plugin to drive. In ACRYL\'s frame none is needed: a saved change in src/ or plugins/ reloads the app by itself, so do not restart it. Elsewhere, the app has no way to install plugins while it runs')
343
355
  const value = await run(extensions)
344
356
  // The app changed under the open pages: reload them (not when the plugin was already as it should be), and answer once they are back.
@@ -11,7 +11,7 @@ import type { Context } from '@deepseek-ai/cordis'
11
11
  import { scripted, type Tools } from '../agent/index.ts'
12
12
  import { NAME } from '../extensions/index.ts'
13
13
  import type {} from '../instance/index.ts'
14
- import { packagePlugin } from './package.ts'
14
+ import { packagePlugin } from '../plugin-share/package.ts'
15
15
  import { builderRules } from './script.ts'
16
16
 
17
17
  export interface BuilderConfig {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "builder",
3
3
  "description": "The agent's tools for building the app it runs in: write, install, read, list and remove extensions, with real results.",
4
- "requires": ["agent", "extensions", "sandbox"],
4
+ "requires": ["agent", "extensions", "sandbox", "plugin-share"],
5
5
  "inject": ["extensions", "tools", "appInstance"],
6
6
  "provides": [],
7
7
  "dependencies": {
@@ -32,6 +32,13 @@ import type {} from '../instance/index.ts'
32
32
  import type { Sandbox } from '../sandbox/index.ts'
33
33
  import { appendLedger, type LedgerEntry, readLedger } from './ledger.ts'
34
34
  import { type PluginStore, pluginVersion } from './plugin-store.ts'
35
+ import {
36
+ addedRoutes,
37
+ type FiberRoutesView,
38
+ routeClash,
39
+ type ServedRoute,
40
+ servedRoutes,
41
+ } from './routes.ts'
35
42
  import { folderPluginStore } from './stage.ts'
36
43
 
37
44
  /** A fiber's lifecycle state, by name (Cordis's own enum is a `const enum`, which does not exist at run time). */
@@ -93,6 +100,8 @@ export interface ExtensionsConfig {
93
100
  * are not extensions, so they are neither found nor started here, and installing one is refused with that reason.
94
101
  */
95
102
  rows?: readonly string[]
103
+ /** The path the app is mounted under (the frame's canvas, `/app`): a message names a route as the plugin wrote it, without it. */
104
+ routePrefix?: string
96
105
  }
97
106
 
98
107
  const READ_ONLY =
@@ -121,7 +130,7 @@ export type InstallResult =
121
130
  | {
122
131
  readonly ok: false
123
132
  /** check: before anything changed; import: loading the code; activate: starting it; verify: its own `check` failed. */
124
- readonly stage: 'check' | 'import' | 'activate' | 'verify' | 'approval'
133
+ readonly stage: 'check' | 'import' | 'activate' | 'conflict' | 'verify' | 'approval'
125
134
  readonly name?: string
126
135
  readonly errors: readonly string[]
127
136
  /** Present for an update: whether the version that was running is running again. */
@@ -157,6 +166,8 @@ interface Installed {
157
166
  readonly dir: string
158
167
  readonly module: PluginModule
159
168
  fiber: Fiber
169
+ /** The routes its mount added, so a later plugin whose route is taken is told whose it is. */
170
+ readonly routes: readonly ServedRoute[]
160
171
  }
161
172
 
162
173
  interface Record_ {
@@ -245,11 +256,13 @@ export class Extensions extends Service {
245
256
  */
246
257
  private readonly owner: Context
247
258
  private readonly store: PluginStore
259
+ private readonly routePrefix: string
248
260
 
249
261
  constructor(ctx: Context, config: ExtensionsConfig, store: PluginStore) {
250
262
  super(ctx, 'extensions')
251
263
  this.owner = ctx
252
264
  this.store = store
265
+ this.routePrefix = (config.routePrefix ?? '').replace(/\/+$/u, '')
253
266
  this.readOnly = config.readOnly === true
254
267
  this.off = new Set(config.off ?? [])
255
268
  this.rows = new Set(config.rows ?? [])
@@ -588,6 +601,41 @@ export class Extensions extends Service {
588
601
  }
589
602
 
590
603
  /** Mount a module as a child fiber and wait until it settles. */
604
+ /** Every route the app serves now, read from the router's labels on every running fiber. */
605
+ private served(): ServedRoute[] {
606
+ const fibers = [...this.ctx.registry.values()].flatMap(
607
+ (runtime) => [...runtime.fibers] as unknown as FiberRoutesView[],
608
+ )
609
+ return servedRoutes(fibers)
610
+ }
611
+
612
+ /**
613
+ * A route the new plugin added that another plugin already serves: the router would answer with the other one, so the new route would never be
614
+ * reached. Refused before the plugin's own check, which the other plugin's answer would pass.
615
+ */
616
+ private conflict(
617
+ added: readonly ServedRoute[],
618
+ before: readonly ServedRoute[],
619
+ ): { stage: 'conflict'; error: string } | undefined {
620
+ const clash = routeClash(added, before)
621
+ if (clash === undefined) return undefined
622
+ const { route, taken } = clash
623
+ const owner =
624
+ [...this.installed.values()].find((entry) =>
625
+ entry.routes.some((own) => own.method === taken.method && own.path === taken.path),
626
+ )?.name ?? (taken.owner === '' ? 'the app' : taken.owner)
627
+ const shown = (path: string): string =>
628
+ this.routePrefix !== '' && path.startsWith(`${this.routePrefix}/`)
629
+ ? path.slice(this.routePrefix.length)
630
+ : path
631
+ const same =
632
+ taken.path === route.path ? '' : ` (it serves ${taken.method} ${shown(taken.path)})`
633
+ return {
634
+ stage: 'conflict',
635
+ error: `${route.method} ${shown(route.path)} is already served by ${owner}${same}; rename this plugin's route, or remove ${owner} first`,
636
+ }
637
+ }
638
+
591
639
  private async mount(module: PluginModule): Promise<{ fiber: Fiber; error?: unknown }> {
592
640
  // An extension is a plugin module whose config, if any, comes from its own defaults.
593
641
  const fiber = this.owner.plugin(module as unknown as Parameters<Context['plugin']>[0])
@@ -666,8 +714,10 @@ export class Extensions extends Service {
666
714
  }
667
715
 
668
716
  if (previous) await previous.fiber.dispose()
717
+ const before = this.served()
669
718
  const mounted = await this.mount(module)
670
719
  const state = STATES[mounted.fiber.state] ?? 'failed'
720
+ const added = state === 'active' ? addedRoutes(before, this.served()) : []
671
721
  // Commit before "done": a plugin that started still has to pass its own check before the install counts.
672
722
  const failure =
673
723
  mounted.error !== undefined || state === 'failed'
@@ -676,7 +726,7 @@ export class Extensions extends Service {
676
726
  error: message(mounted.error ?? 'the plugin failed to start'),
677
727
  }
678
728
  : state === 'active'
679
- ? await this.verify(module, mounted.fiber.ctx)
729
+ ? (this.conflict(added, before) ?? (await this.verify(module, mounted.fiber.ctx)))
680
730
  : undefined
681
731
  if (failure) {
682
732
  await mounted.fiber.dispose().catch(() => undefined)
@@ -714,6 +764,7 @@ export class Extensions extends Service {
714
764
  dir: staged.dir,
715
765
  module,
716
766
  fiber: mounted.fiber,
767
+ routes: added,
717
768
  })
718
769
  this.stale.delete(name)
719
770
  this.saveState()
@@ -758,6 +809,7 @@ export class Extensions extends Service {
758
809
  }
759
810
  try {
760
811
  const module = await this.load(folder.dir, folder.name)
812
+ const before = this.served()
761
813
  const mounted = await this.mount(module)
762
814
  this.installed.set(folder.name, {
763
815
  name: folder.name,
@@ -766,6 +818,7 @@ export class Extensions extends Service {
766
818
  dir: folder.dir,
767
819
  module,
768
820
  fiber: mounted.fiber,
821
+ routes: addedRoutes(before, this.served()),
769
822
  })
770
823
  if (mounted.error !== undefined)
771
824
  summary.failed.push({ name: folder.name, error: message(mounted.error) })
@@ -830,6 +883,7 @@ export class Extensions extends Service {
830
883
  continue
831
884
  }
832
885
  const module = await this.load(record.dir, name)
886
+ const before = this.served()
833
887
  const mounted = await this.mount(module)
834
888
  if (mounted.error !== undefined)
835
889
  summary.failed.push({ name, error: message(mounted.error) })
@@ -840,6 +894,7 @@ export class Extensions extends Service {
840
894
  dir: record.dir,
841
895
  module,
842
896
  fiber: mounted.fiber,
897
+ routes: addedRoutes(before, this.served()),
843
898
  })
844
899
  } catch (error) {
845
900
  summary.failed.push({ name, error: message(error) })
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Which routes a plugin brings, and whether one of them is already served (spec 049 T025). Plugins that do not know each other share one app: the router
3
+ * serves the first mount of a route and ignores the second without a word, so a plugin whose route is taken would look installed and answer nothing.
4
+ * The router labels each route it serves (`acryl-router: serves GET /notes`, under the mount's effect), so the routes are read from the running system.
5
+ * Pure functions over those labels; the service reads them from the registry.
6
+ */
7
+
8
+ /** One route as the router serves it. */
9
+ export interface ServedRoute {
10
+ readonly method: string
11
+ readonly path: string
12
+ /** The plugin whose fiber holds the label. */
13
+ readonly owner: string
14
+ }
15
+
16
+ /** The slice of a Cordis effect this module reads. */
17
+ export interface EffectView {
18
+ readonly label: string
19
+ readonly children: readonly EffectView[]
20
+ }
21
+
22
+ /** The slice of a Cordis fiber this module reads. */
23
+ export interface FiberRoutesView {
24
+ readonly runtime: { readonly name?: string | undefined } | null
25
+ getEffects(): readonly EffectView[]
26
+ }
27
+
28
+ const SERVES = /^acryl-router: serves ([A-Z]+) (\S+)$/u
29
+
30
+ /** Every route served by the given fibers, with the plugin that holds each. `ALL` is middleware or `all`, which wraps routes rather than taking them. */
31
+ export function servedRoutes(fibers: Iterable<FiberRoutesView>): ServedRoute[] {
32
+ const routes: ServedRoute[] = []
33
+ const walk = (effects: readonly EffectView[], owner: string): void => {
34
+ for (const effect of effects) {
35
+ const served = SERVES.exec(effect.label)
36
+ if (served?.[1] !== undefined && served[2] !== undefined && served[1] !== 'ALL')
37
+ routes.push({ method: served[1], path: served[2], owner })
38
+ walk(effect.children, owner)
39
+ }
40
+ }
41
+ for (const fiber of fibers) walk(fiber.getEffects(), fiber.runtime?.name ?? '')
42
+ return routes
43
+ }
44
+
45
+ const key = (route: ServedRoute): string => `${route.method} ${route.path}`
46
+
47
+ /** The routes in `after` that `before` did not have: what one mount added, counted, so a second mount of the same route counts as added. */
48
+ export function addedRoutes(
49
+ before: readonly ServedRoute[],
50
+ after: readonly ServedRoute[],
51
+ ): ServedRoute[] {
52
+ const left = new Map<string, number>()
53
+ for (const route of before) left.set(key(route), (left.get(key(route)) ?? 0) + 1)
54
+ const added: ServedRoute[] = []
55
+ for (const route of after) {
56
+ const count = left.get(key(route)) ?? 0
57
+ if (count > 0) left.set(key(route), count - 1)
58
+ else added.push(route)
59
+ }
60
+ return added
61
+ }
62
+
63
+ const segments = (path: string): string[] => path.split('/').filter((part) => part !== '')
64
+
65
+ /**
66
+ * Whether one request could match both paths: equal segments, a `:param` matching any one segment, a `*` matching the rest. `/notes` and `/notes/list` are
67
+ * two routes; `/notes/*` and `/notes/list` are not.
68
+ */
69
+ export function pathsOverlap(a: string, b: string): boolean {
70
+ const x = segments(a)
71
+ const y = segments(b)
72
+ for (let i = 0; ; i += 1) {
73
+ const p = x[i]
74
+ const q = y[i]
75
+ if (p === '*' || q === '*') return true
76
+ if (p === undefined || q === undefined) return p === q
77
+ if (p.startsWith(':') || q.startsWith(':')) continue
78
+ if (p !== q) return false
79
+ }
80
+ }
81
+
82
+ /** The first route `added` shares with one already served: same method and an overlapping path. */
83
+ export function routeClash(
84
+ added: readonly ServedRoute[],
85
+ served: readonly ServedRoute[],
86
+ ): { readonly route: ServedRoute; readonly taken: ServedRoute } | undefined {
87
+ for (const route of added)
88
+ for (const taken of served)
89
+ if (taken.method === route.method && pathsOverlap(taken.path, route.path))
90
+ return { route, taken }
91
+ return undefined
92
+ }