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.
- package/README.md +9 -0
- package/dist/index.d.ts +45 -1
- package/dist/index.js +1980 -27
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +1 -1
- package/docs/framework/workshop/README.md +138 -152
- package/docs/framework/workshop/facilitator.md +69 -101
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +42 -7
- package/lib/cordis/door.js +204 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.js +1 -1
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -1
- package/lib/door/page.js +46 -7
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/src/host/channel.ts +86 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +53 -23
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +3 -0
- package/plugins/acryl-door/src/index.ts +166 -29
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +41 -2
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +238 -26
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +146 -57
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +26 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +6 -0
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/blend.yaml +4 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +150 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +38 -19
- package/templates/agent/template.json +2 -0
- package/templates/agent/test/app.test.ts +45 -3
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- package/shared/SKILL.md +0 -14
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync } from 'node:fs'
|
|
8
8
|
import { isAbsolute, relative, resolve } from 'node:path'
|
|
9
|
+
import { underAppBase } from '../page/base.ts'
|
|
9
10
|
import { parseUiRequest, UiControlError, type AuditEntry, type UiActionResult, type UiRequest, type UiResult, type UiSnapshot } from '../page/protocol.ts'
|
|
10
11
|
import { renderSnapshot } from '../page/snapshot-text.ts'
|
|
11
12
|
import type { AuditLog } from './audit.ts'
|
|
@@ -37,7 +38,7 @@ export interface ExtensionsPort {
|
|
|
37
38
|
/** The slice of acrylblends' `tools` registry (the `agent` plugin's) the tool verbs use, and where the built-in chat finds the verbs. */
|
|
38
39
|
export interface ToolsPort {
|
|
39
40
|
/** @returns the function that removes the tool. @throws when the name is taken. */
|
|
40
|
-
register(name: string, tool: { readonly description: string; readonly parameters?: Readonly<Record<string, unknown>>; run(input: unknown): unknown }): () => void
|
|
41
|
+
register(name: string, tool: { readonly description: string; readonly parameters?: Readonly<Record<string, unknown>>; run(input: unknown, options?: { signal?: AbortSignal; page?: string }): unknown }): () => void
|
|
41
42
|
list(): ReadonlyArray<{ readonly name: string; readonly description: string }>
|
|
42
43
|
call(name: string, input: unknown): Promise<unknown>
|
|
43
44
|
}
|
|
@@ -49,6 +50,8 @@ export interface DoorDeps {
|
|
|
49
50
|
readonly audit: AuditLog
|
|
50
51
|
/** The app's name, folder and, when it listens, its address (what `brief` prints so an agent can curl the running app). */
|
|
51
52
|
readonly app: { readonly name: string; readonly root: string; readonly url?: string | undefined }
|
|
53
|
+
/** The prefix the app's pages answer under (the frame's canvas, `/app`); absent on the app's own server, at the root. */
|
|
54
|
+
readonly appBase?: string | undefined
|
|
52
55
|
/** Read at each call: the services may come and go while the door runs. */
|
|
53
56
|
readonly extensions: () => ExtensionsPort | undefined
|
|
54
57
|
readonly tools: () => ToolsPort | undefined
|
|
@@ -60,6 +63,11 @@ export interface DoorDeps {
|
|
|
60
63
|
readonly pageBackMs?: number
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
/** Who a verb runs for: `page`, the door's id of the page a run was asked from; its look and act go to that page alone (followed into its tab), never to another one. */
|
|
67
|
+
export interface VerbScope {
|
|
68
|
+
readonly page?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
/** What one verb answered: the value for `--json` and programs, the text for an agent reading a terminal. */
|
|
64
72
|
export interface VerbAnswer {
|
|
65
73
|
readonly value: unknown
|
|
@@ -96,16 +104,16 @@ export class Door {
|
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
/** Run one verb. @throws DoorError or UiControlError. */
|
|
99
|
-
async run(verb: string, args: unknown, signal: AbortSignal): Promise<VerbAnswer> {
|
|
107
|
+
async run(verb: string, args: unknown, signal: AbortSignal, scope: VerbScope = {}): Promise<VerbAnswer> {
|
|
100
108
|
const input = (typeof args === 'object' && args !== null && !Array.isArray(args) ? args : {}) as Record<string, unknown>
|
|
101
109
|
switch (verb) {
|
|
102
110
|
case 'brief': return this.brief()
|
|
103
111
|
case 'look': {
|
|
104
|
-
const snapshot = await this.look(input, signal)
|
|
112
|
+
const snapshot = await this.look(input, signal, scope)
|
|
105
113
|
return { value: snapshot, text: renderSnapshot(snapshot, cursor => `run look --cursor ${String(cursor)}`) }
|
|
106
114
|
}
|
|
107
115
|
case 'act': {
|
|
108
|
-
const result = await this.act(input, signal)
|
|
116
|
+
const result = await this.act(input, signal, scope)
|
|
109
117
|
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` }
|
|
110
118
|
}
|
|
111
119
|
case 'plugin-list': return this.plugin(extensions => extensions.list(), false)
|
|
@@ -151,30 +159,47 @@ export class Door {
|
|
|
151
159
|
return { value, text }
|
|
152
160
|
}
|
|
153
161
|
|
|
154
|
-
look(input: Record<string, unknown>, signal: AbortSignal): Promise<UiSnapshot> {
|
|
155
|
-
const { window, rest } = this.windowOf(input)
|
|
162
|
+
look(input: Record<string, unknown>, signal: AbortSignal, scope: VerbScope = {}): Promise<UiSnapshot> {
|
|
163
|
+
const { window, rest } = this.windowOf(input, scope)
|
|
156
164
|
const request = this.parse({ ...rest, op: 'snapshot' })
|
|
157
|
-
return this.callPage('look', request, signal, window) as Promise<UiSnapshot>
|
|
165
|
+
return this.callPage('look', request, signal, window, scope.page) as Promise<UiSnapshot>
|
|
158
166
|
}
|
|
159
167
|
|
|
160
|
-
async act(input: Record<string, unknown>, signal: AbortSignal): Promise<UiActionResult> {
|
|
168
|
+
async act(input: Record<string, unknown>, signal: AbortSignal, scope: VerbScope = {}): Promise<UiActionResult> {
|
|
161
169
|
if (input.op === 'snapshot') throw new DoorError('invalid', 'use look to see the page')
|
|
162
|
-
const { window, rest } = this.windowOf(input)
|
|
170
|
+
const { window, rest } = this.windowOf(input, scope)
|
|
163
171
|
const request = this.parse(rest)
|
|
164
|
-
|
|
165
|
-
//
|
|
166
|
-
const
|
|
172
|
+
const page = scope.page
|
|
173
|
+
// A reload or a navigate replaces the page; a click, a key or typing may (a link, a form that submits), and the page says so when it does.
|
|
174
|
+
const moves = request.op === 'reload' || request.op === 'navigate'
|
|
175
|
+
if (!moves && request.op !== 'click' && request.op !== 'press' && request.op !== 'type')
|
|
176
|
+
return await this.callPage(`act ${String(input.op)}`, request, signal, window, page) as UiActionResult
|
|
177
|
+
// Wait for the new page to connect, so the next look sees it; the wait starts before the act, or a fast page is missed. The page may drop before it has answered;
|
|
178
|
+
// that is the move working. A run's own page comes back in its own tab; no other page that happens to connect meanwhile is taken for it.
|
|
179
|
+
const backMs = this.deps.pageBackMs ?? PAGE_BACK_MS
|
|
180
|
+
const stay = new AbortController()
|
|
181
|
+
const back = page === undefined ? this.deps.channel.expectNewPage(backMs, stay.signal) : this.deps.channel.expectPageBack(page, backMs, stay.signal)
|
|
167
182
|
let answer: UiActionResult
|
|
183
|
+
let left = moves
|
|
168
184
|
try {
|
|
169
|
-
answer = await this.callPage(`act ${request.op}`, request, signal, window) as UiActionResult
|
|
185
|
+
answer = await this.callPage(`act ${request.op}`, request, signal, window, page) as UiActionResult
|
|
186
|
+
left ||= answer.leaving === true
|
|
170
187
|
} catch (cause) {
|
|
171
|
-
if (!(cause instanceof UiControlError && cause.code === 'disconnected')) {
|
|
172
|
-
answer = { ok: true, detail: request.op === 'reload' ? 'reloading' : `going to ${request.path}` }
|
|
188
|
+
if (!(cause instanceof UiControlError && cause.code === 'disconnected')) { stay.abort(); throw cause }
|
|
189
|
+
answer = { ok: true, detail: request.op === 'reload' ? 'reloading' : request.op === 'navigate' ? `going to ${request.path}` : 'the page is going to another address' }
|
|
190
|
+
left = true
|
|
191
|
+
}
|
|
192
|
+
if (!left) {
|
|
193
|
+
stay.abort()
|
|
194
|
+
return answer
|
|
173
195
|
}
|
|
174
196
|
const came = await back
|
|
175
|
-
// The page that came back is the one this agent was driving: it stays the target until the person focuses another page.
|
|
176
|
-
if (came !== undefined) this.deps.channel.prefer(came)
|
|
177
|
-
if (came === undefined)
|
|
197
|
+
// The page that came back is the one this agent was driving: it stays the target until the person focuses another page. A run that names its page follows that page's tab instead.
|
|
198
|
+
if (came !== undefined && page === undefined) this.deps.channel.prefer(came)
|
|
199
|
+
if (came === undefined)
|
|
200
|
+
throw new UiControlError('timeout', request.op === 'reload' ? 'the page did not come back after the reload'
|
|
201
|
+
: request.op === 'navigate' ? `the page did not come back after going to ${request.path}: is there a page at that path, and does it load the door's script?`
|
|
202
|
+
: `the ${request.op} took the page to another address, and no page came back: does the page there load the door's script?`)
|
|
178
203
|
const landed = request.op === 'navigate' ? await this.statusAt(request.path) : undefined
|
|
179
204
|
return { ...answer, detail: `${answer.detail ?? 'done'}${landed === undefined ? '' : `; ${landed}`}; the page is back: look again` }
|
|
180
205
|
}
|
|
@@ -187,7 +212,10 @@ export class Door {
|
|
|
187
212
|
const base = this.deps.app.url
|
|
188
213
|
if (base === undefined) return undefined
|
|
189
214
|
try {
|
|
190
|
-
|
|
215
|
+
// The app's own path, under the host prefix when the pages answer there (the frame's canvas): /board is /app/board in the canvas and /board at the
|
|
216
|
+
// root, and the app's root is the prefix itself (`/app`, where its rows mount `/`), as the page's navigation join does it.
|
|
217
|
+
const target = underAppBase(this.deps.appBase ?? '', path)
|
|
218
|
+
const response = await fetch(new URL(target, base), { headers: { accept: 'text/html' }, signal: AbortSignal.timeout(3000) })
|
|
191
219
|
await response.body?.cancel()
|
|
192
220
|
return response.ok ? `${response.status} at ${path}` : `${response.status} at ${path}: the app serves no page there, look at what is shown and navigate to a path it does`
|
|
193
221
|
} catch {
|
|
@@ -237,9 +265,11 @@ export class Door {
|
|
|
237
265
|
}
|
|
238
266
|
|
|
239
267
|
/** `window` on look and act: the desktop window (the number `window list` prints) whose page the call is for; without it, the page used last. */
|
|
240
|
-
private windowOf(input: Record<string, unknown
|
|
268
|
+
private windowOf(input: Record<string, unknown>, scope: VerbScope = {}): { window: number | undefined; rest: Record<string, unknown> } {
|
|
241
269
|
const { window, ...rest } = input
|
|
242
270
|
if (window === undefined) return { window: undefined, rest }
|
|
271
|
+
// A run that is for one page cannot be pointed at a desktop window instead.
|
|
272
|
+
if (scope.page !== undefined) throw new DoorError('invalid', 'this run drives only the page it was asked from; window does not apply')
|
|
243
273
|
if (typeof window !== 'number' || !Number.isSafeInteger(window) || window < 1) throw new DoorError('invalid', 'window must be the number `window list` prints for the window')
|
|
244
274
|
return { window, rest }
|
|
245
275
|
}
|
|
@@ -253,10 +283,10 @@ export class Door {
|
|
|
253
283
|
}
|
|
254
284
|
|
|
255
285
|
/** Run one call on the page and record it. The audit holds which control was touched and how it ended, never what was typed. */
|
|
256
|
-
private async callPage(verb: string, request: UiRequest, signal: AbortSignal, window?: number): Promise<UiResult> {
|
|
286
|
+
private async callPage(verb: string, request: UiRequest, signal: AbortSignal, window?: number, page?: string): Promise<UiResult> {
|
|
257
287
|
const approval: AuditEntry['approval'] = 'token'
|
|
258
288
|
try {
|
|
259
|
-
const value = await this.deps.channel.call(request, { signal, ...(window === undefined ? {} : { window }), ...(request.op === 'wait' ? { timeoutMs: (request.timeoutMs ?? 3000) + 5000 } : {}) })
|
|
289
|
+
const value = await this.deps.channel.call(request, { signal, ...(window === undefined ? {} : { window }), ...(page === undefined ? {} : { page }), ...(request.op === 'wait' ? { timeoutMs: (request.timeoutMs ?? 3000) + 5000 } : {}) })
|
|
260
290
|
const target = 'target' in value ? value.target : undefined
|
|
261
291
|
this.deps.audit.record({ at: new Date().toISOString(), tool: verb, ...(target === undefined ? {} : { target }), ...(window === undefined ? {} : { window }), outcome: 'ok', approval })
|
|
262
292
|
return value
|
|
@@ -270,7 +300,7 @@ export class Door {
|
|
|
270
300
|
|
|
271
301
|
private async plugin(run: (extensions: ExtensionsPort) => unknown, mutates = true): Promise<VerbAnswer> {
|
|
272
302
|
const extensions = this.deps.extensions()
|
|
273
|
-
if (extensions === undefined) throw new DoorError('unavailable', 'this
|
|
303
|
+
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')
|
|
274
304
|
const value = await run(extensions)
|
|
275
305
|
// 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.
|
|
276
306
|
const unchanged = typeof value === 'object' && value !== null && (value as { action?: unknown }).action === 'unchanged'
|
|
@@ -34,6 +34,8 @@ export function pageChannelRoutes(channel: UiChannel, options: PageRoutesOptions
|
|
|
34
34
|
const focused = c.req.query('focused') === '1'
|
|
35
35
|
const shellQuery = Number(c.req.query('shell'))
|
|
36
36
|
const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : undefined
|
|
37
|
+
const tabQuery = c.req.query('tab')
|
|
38
|
+
const tab = tabQuery !== undefined && WINDOW_ID.test(tabQuery) ? tabQuery : undefined
|
|
37
39
|
const signal = c.req.raw.signal
|
|
38
40
|
return streamSSE(c, async (stream) => {
|
|
39
41
|
let end = (): void => {}
|
|
@@ -43,7 +45,7 @@ export function pageChannelRoutes(channel: UiChannel, options: PageRoutesOptions
|
|
|
43
45
|
const write = (event: string, data: string): void => {
|
|
44
46
|
stream.writeSSE({ event, data }).catch(end)
|
|
45
47
|
}
|
|
46
|
-
const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, notify: write, close: end }, shellWindow)
|
|
48
|
+
const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, notify: write, close: end }, shellWindow, tab)
|
|
47
49
|
const beat = setInterval(() => { write('ping', '') }, heartbeatMs)
|
|
48
50
|
try {
|
|
49
51
|
write('ready', '')
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { Acryl, type MiddlewareHandler } from 'acryl-router'
|
|
12
|
-
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
12
|
+
import type { UpgradeWebSocket, WSEvents } from 'acryl-router/helpers'
|
|
13
13
|
import { loopbackAuthority } from 'acryl-router/middleware'
|
|
14
14
|
import { parseChannelToHost, WINDOW_ID } from '../page/protocol.ts'
|
|
15
15
|
import type { UiChannel } from './channel.ts'
|
|
@@ -20,7 +20,8 @@ export const SOCKET_HEARTBEAT_MS = 15_000
|
|
|
20
20
|
|
|
21
21
|
export interface PageSocketOptions {
|
|
22
22
|
readonly prefix: string
|
|
23
|
-
|
|
23
|
+
/** The live upgrade helper: the route is mounted once, so the helper is read per request (a restarting host has a new one). */
|
|
24
|
+
readonly upgrade: () => UpgradeWebSocket | undefined
|
|
24
25
|
readonly heartbeatMs?: number
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -39,14 +40,13 @@ export const ownOrigin: MiddlewareHandler = async (c, next) => {
|
|
|
39
40
|
|
|
40
41
|
export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions): Acryl {
|
|
41
42
|
const heartbeatMs = options.heartbeatMs ?? SOCKET_HEARTBEAT_MS
|
|
42
|
-
|
|
43
|
-
`${options.prefix}/channel/ws`,
|
|
44
|
-
ownOrigin,
|
|
45
|
-
options.upgrade((c) => {
|
|
43
|
+
const events = (c: Parameters<MiddlewareHandler>[0]): WSEvents => {
|
|
46
44
|
const windowId = c.req.query('window') as string
|
|
47
45
|
const focused = c.req.query('focused') === '1'
|
|
48
46
|
const shellQuery = Number(c.req.query('shell'))
|
|
49
47
|
const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : undefined
|
|
48
|
+
const tabQuery = c.req.query('tab')
|
|
49
|
+
const tab = tabQuery !== undefined && WINDOW_ID.test(tabQuery) ? tabQuery : undefined
|
|
50
50
|
let detach: (() => void) | undefined
|
|
51
51
|
let beat: NodeJS.Timeout | undefined
|
|
52
52
|
return {
|
|
@@ -55,7 +55,7 @@ export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions)
|
|
|
55
55
|
ws.send(JSON.stringify(frame))
|
|
56
56
|
}
|
|
57
57
|
// A call that cannot be written fails at once (`disconnected`), and a socket that is gone is dropped by its close: the door never holds a call for it.
|
|
58
|
-
detach = channel.attach(windowId, focused, { send: data => { write({ e: 'call', d: data }) }, notify: (event, data) => { write({ e: event, d: data }) }, close: () => { ws.close(1000, 'replaced') } }, shellWindow)
|
|
58
|
+
detach = channel.attach(windowId, focused, { send: data => { write({ e: 'call', d: data }) }, notify: (event, data) => { write({ e: event, d: data }) }, close: () => { ws.close(1000, 'replaced') } }, shellWindow, tab)
|
|
59
59
|
write({ e: 'ready' })
|
|
60
60
|
beat = setInterval(() => {
|
|
61
61
|
try { write({ e: 'ping' }) } catch { /* the close event follows */ }
|
|
@@ -73,6 +73,16 @@ export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions)
|
|
|
73
73
|
detach?.()
|
|
74
74
|
},
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
}
|
|
77
|
+
// Mounted once, ahead of the private routes: the route must keep its precedence across a host restart, so the middleware asks for the live helper
|
|
78
|
+
// at request time and refuses cleanly while none is ready.
|
|
79
|
+
return new Acryl().get(
|
|
80
|
+
`${options.prefix}/channel/ws`,
|
|
81
|
+
ownOrigin,
|
|
82
|
+
async (c, next) => {
|
|
83
|
+
const allow = options.upgrade()
|
|
84
|
+
if (allow === undefined) return c.json(error('the page channel is not ready yet'), 503)
|
|
85
|
+
return allow(events, { textOnly: true })(c, next)
|
|
86
|
+
},
|
|
77
87
|
)
|
|
78
88
|
}
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
import { MAX_SNAPSHOT_NODES, MAX_WAIT_MS } from '../page/protocol.ts'
|
|
7
7
|
|
|
8
8
|
/** Every verb, in the order `brief` lists them. */
|
|
9
|
+
/** The verbs that only use the page: what a page-only door (a production assistant) offers. */
|
|
10
|
+
export const PAGE_VERBS = ['look', 'act'] as const satisfies readonly Verb[]
|
|
11
|
+
|
|
9
12
|
export const VERBS = ['brief', 'look', 'act', 'plugin-list', 'plugin-add', 'plugin-remove', 'plugin-reload', 'tool-list', 'tool-call', 'window-list', 'window-open', 'window-focus', 'window-close', 'window-resize', 'menu-list', 'menu-click'] as const
|
|
10
13
|
export type Verb = (typeof VERBS)[number]
|
|
11
14
|
|
|
@@ -18,8 +18,9 @@ import { clientModules } from 'acryl-node-server/client-modules'
|
|
|
18
18
|
import { auditPath, AuditLog } from './host/audit.ts'
|
|
19
19
|
import { UiChannel } from './host/channel.ts'
|
|
20
20
|
import { registerChatTools } from './host/chat-tools.ts'
|
|
21
|
+
import { PAGE_VERBS, VERBS } from './host/verbs.ts'
|
|
21
22
|
import { Door, type ExtensionsPort, type ToolsPort } from './host/door.ts'
|
|
22
|
-
import { newSecret, removeDoorFile, stateDir, writeDoorFile } from './host/door-file.ts'
|
|
23
|
+
import { assertDoorIsFree, newSecret, removeDoorFile, stateDir, writeDoorFile } from './host/door-file.ts'
|
|
23
24
|
import { DOOR_PREFIX, privateApi } from './host/http.ts'
|
|
24
25
|
import { mcpRoutes } from './host/mcp-routes.ts'
|
|
25
26
|
import { operatorRoutes } from './host/operator-routes.ts'
|
|
@@ -37,9 +38,10 @@ interface AppFolder {
|
|
|
37
38
|
readonly name: string
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
/** The
|
|
41
|
+
/** The address the carrier listens on, as the `acrylWeb` service gives it (host and port; DSH's webServer serves it today). */
|
|
41
42
|
interface Listening {
|
|
42
|
-
readonly
|
|
43
|
+
readonly host: string
|
|
44
|
+
readonly port: number
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
declare module '@deepseek-ai/cordis' {
|
|
@@ -48,7 +50,17 @@ declare module '@deepseek-ai/cordis' {
|
|
|
48
50
|
}
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
|
|
53
|
+
// Two hosts serve the same door. On DSH (slice 1 of the IDE port) the carrier's address comes from `acrylWeb`, upgrades from `routerHttp`, and the page script
|
|
54
|
+
// goes into DSH's own index through the web server's index-inject hook (the framework's router.wrap never sees that page). On the plain node-server host the
|
|
55
|
+
// address and upgrades come from `nodeServer`, and `router.wrap` adds the script. Only `router` and `appInstance` are required; a host without the optional
|
|
56
|
+
// services simply takes the other path, so the door never stays PENDING beside a server that does not provide them.
|
|
57
|
+
export const inject = ['router', 'appInstance']
|
|
58
|
+
|
|
59
|
+
/** The services a host may provide in place of `acrylWeb`/`routerHttp` (the plain node-server host). */
|
|
60
|
+
interface NodeServerLike {
|
|
61
|
+
readonly url?: string
|
|
62
|
+
upgrades?: () => UpgradeWebSocket
|
|
63
|
+
}
|
|
52
64
|
|
|
53
65
|
export interface Config {
|
|
54
66
|
tools?: { expose?: string[] }
|
|
@@ -57,13 +69,34 @@ export interface Config {
|
|
|
57
69
|
* run it in production (a packaged desktop app whose user drives it with an agent) says so here; `ACRYL_DOOR=off` still wins.
|
|
58
70
|
*/
|
|
59
71
|
allowInProduction?: boolean
|
|
72
|
+
/**
|
|
73
|
+
* Page only, for a production assistant: the page channel and two chat tools, `door_look` and `door_act`, and nothing else - no operator routes, no MCP, no desktop
|
|
74
|
+
* shell channel, no `.acryl/` file, no plugin, tool or window verbs. The app's agent can then use the page for a visitor and cannot change the app. Runs in production
|
|
75
|
+
* without `allowInProduction`; `ACRYL_DOOR=off` still wins.
|
|
76
|
+
*/
|
|
77
|
+
pageOnly?: boolean
|
|
60
78
|
/** Reload the open pages after a live plugin change, so an agent that built a feature sees it on the page it has open. Default: true. */
|
|
61
79
|
reloadOnChange?: boolean
|
|
80
|
+
/**
|
|
81
|
+
* Put the page script in the host's own index page (DSH's web UI) too, so an agent can drive that UI. Default: true. A host whose index is a frame
|
|
82
|
+
* around the app (the ACRYL Blends frame, whose centre shows the app's page) turns it off: the door then drives the app's page in the canvas, and an
|
|
83
|
+
* agent's `act navigate` or a reload after a plugin change never takes the person's tab out of the frame.
|
|
84
|
+
*/
|
|
85
|
+
hostIndex?: boolean
|
|
86
|
+
/**
|
|
87
|
+
* The host prefix the app's pages are served under (the frame's canvas, `/app`); absent on the app's own server, where they are at the root. The
|
|
88
|
+
* door puts it on every page as `<meta name="acryl-app-base">`, and `act navigate` with an app path goes under it: a page the app serves at
|
|
89
|
+
* `/board` is reached at `/app/board` in the frame and at `/board` in production, and the same verb works in both.
|
|
90
|
+
*/
|
|
91
|
+
appBase?: string
|
|
62
92
|
}
|
|
63
93
|
|
|
64
94
|
export const Config: z<Config> = z.object({
|
|
65
95
|
allowInProduction: z.boolean().default(false).description('Run in production too (NODE_ENV=production); ACRYL_DOOR=off still turns it off.'),
|
|
96
|
+
pageOnly: z.boolean().default(false).description('Only the page and the look and act chat tools (a production assistant); runs in production.'),
|
|
66
97
|
reloadOnChange: z.boolean().default(true).description('Reload the open pages after a plugin was added, removed or reloaded while the app runs.'),
|
|
98
|
+
hostIndex: z.boolean().default(true).description("Put the page script in the host's own index page too; off when that index is a frame around the app."),
|
|
99
|
+
appBase: z.string().default('').description('The prefix the app pages are served under (the frame canvas), without a trailing slash; empty at the root.'),
|
|
67
100
|
tools: z.object({
|
|
68
101
|
// Not a `door_` name: those are the door's own verbs in the registry, never offered back as app tools.
|
|
69
102
|
expose: z.array(z.string().pattern(/^(?!door_)[A-Za-z][A-Za-z0-9_-]{0,63}$/u)).description('App tools (from the tools registry) the door offers; none by default.'),
|
|
@@ -107,23 +140,47 @@ export function notFoundPage(path: string, scriptTag: string): string {
|
|
|
107
140
|
export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
108
141
|
const config = Config(rawConfig)
|
|
109
142
|
// Development only: nothing is started, mounted or written in production, or when switched off.
|
|
110
|
-
|
|
143
|
+
const pageOnly = config.pageOnly === true
|
|
144
|
+
if (process.env.ACRYL_DOOR === 'off' || (process.env.NODE_ENV === 'production' && config.allowInProduction !== true && !pageOnly)) return
|
|
111
145
|
const app = ctx.get('appInstance') as unknown as AppFolder
|
|
112
|
-
|
|
146
|
+
// The carrier, as the host provides it: DSH's `acrylWeb`, or the plain node server's own url. Read when used, never captured at apply time.
|
|
147
|
+
const carrier = (): Listening | undefined => {
|
|
148
|
+
const web = ctx.get('acrylWeb') as unknown as Listening | undefined
|
|
149
|
+
if (web !== undefined) return web
|
|
150
|
+
const node = ctx.get('nodeServer') as unknown as NodeServerLike | undefined
|
|
151
|
+
if (typeof node?.url !== 'string') return undefined
|
|
152
|
+
try {
|
|
153
|
+
const parsed = new URL(node.url)
|
|
154
|
+
return { host: parsed.hostname, port: Number(parsed.port) }
|
|
155
|
+
} catch {
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
158
|
+
}
|
|
113
159
|
if (typeof app?.root !== 'string') throw new Error('acryl-door: the appInstance service has no app folder (root)')
|
|
114
160
|
const reportError = (operation: string, cause: unknown): void => {
|
|
115
161
|
ctx.logger?.error?.(`acryl-door: failed to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
116
162
|
}
|
|
163
|
+
const address: { url: string } = { url: '' }
|
|
164
|
+
// The rival check runs at activation, not in the deferred write: a folder another live process serves fails the row at once (the write waits for a bound port).
|
|
165
|
+
// A page-only door writes no file, so it has no rival to check.
|
|
166
|
+
if (!pageOnly) assertDoorIsFree(app.root)
|
|
117
167
|
const secret = newSecret()
|
|
118
168
|
// A look right after a navigate or reload waits up to two seconds for the page that is moving to register again, and a read-only call that no page acknowledged gives a page that just registered one more try.
|
|
119
|
-
|
|
169
|
+
// A page-only door serves every visitor of the app on one channel: each call goes to the page of the visitor whose run made it, never to another visitor's page.
|
|
170
|
+
const channel = new UiChannel({ attachWaitMs: 2000, requirePage: pageOnly })
|
|
120
171
|
const shell = new ShellChannel()
|
|
121
172
|
const audit = new AuditLog(auditPath(stateDir(app.root)))
|
|
173
|
+
// Where the app's pages answer under this host (`/app` in the frame): `act navigate` sends an app path there, and a page may read it from the
|
|
174
|
+
// head. The app's own server has no prefix and no meta.
|
|
175
|
+
const rawBase = typeof config.appBase === 'string' ? config.appBase.trim() : ''
|
|
176
|
+
const appBase = rawBase.startsWith('/') && rawBase !== '/' ? rawBase.replace(/\/+$/u, '') : ''
|
|
122
177
|
const door = new Door({
|
|
123
178
|
channel,
|
|
124
179
|
shell,
|
|
125
180
|
audit,
|
|
126
|
-
|
|
181
|
+
// The address is read when it is used, not when the plugin applies: the carrier binds after the fiber starts (DSH's webServer reports its listening port only then).
|
|
182
|
+
app: { name: app.name, root: app.root, get url() { return address.url } },
|
|
183
|
+
...(appBase === '' ? {} : { appBase }),
|
|
127
184
|
extensions: () => ctx.get('extensions') as unknown as ExtensionsPort | undefined,
|
|
128
185
|
tools: () => ctx.get('tools') as unknown as ToolsPort | undefined,
|
|
129
186
|
expose: config.tools?.expose ?? [],
|
|
@@ -134,10 +191,11 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
134
191
|
// One script tag for the page (`page.js`): it imports the module tree served below it.
|
|
135
192
|
const pageModule = new Acryl()
|
|
136
193
|
.get(`${DOOR_PREFIX}/page.js`, c => c.body(`import '${DOOR_PREFIX}/page/main.ts'\n`, 200, { 'content-type': 'text/javascript; charset=utf-8' }))
|
|
137
|
-
.route(`${DOOR_PREFIX}/page`, clientModules({ root: PAGE_DIR }))
|
|
194
|
+
.route(`${DOOR_PREFIX}/page`, clientModules({ root: PAGE_DIR, mount: `${DOOR_PREFIX}/page` }))
|
|
138
195
|
// The page script on every HTML page the app answers, whenever its plugin was mounted (the pages of a plugin that loaded at boot, before this one, included): a page an agent writes
|
|
139
|
-
// can be looked at and driven without anyone adding the tag. A page that already carries it
|
|
196
|
+
// can be looked at and driven without anyone adding the tag. A page that already carries it keeps one copy. Development only, like the rest of the door.
|
|
140
197
|
const tag = `<script type="module" src="${DOOR_PREFIX}/page.js"></script>`
|
|
198
|
+
const meta = appBase === '' ? '' : `<meta name="acryl-app-base" content="${appBase}">`
|
|
141
199
|
const addScript = async (c: { res: Response; req: { path: string; header(name: string): string | undefined } }, next: () => Promise<void>): Promise<void> => {
|
|
142
200
|
await next()
|
|
143
201
|
if (!(c.res.headers.get('content-type') ?? '').includes('text/html')) {
|
|
@@ -147,48 +205,127 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
147
205
|
c.res = new Response(notFoundPage(c.req.path, tag), { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
148
206
|
return
|
|
149
207
|
}
|
|
150
|
-
|
|
208
|
+
let html = await c.res.text()
|
|
151
209
|
const headers = new Headers(c.res.headers)
|
|
152
210
|
headers.delete('content-length')
|
|
153
|
-
|
|
154
|
-
|
|
211
|
+
if (meta !== '' && !html.includes('name="acryl-app-base"'))
|
|
212
|
+
html = html.includes('</head>') ? html.replace('</head>', `${meta}</head>`) : meta + html
|
|
213
|
+
if (!html.includes(`${DOOR_PREFIX}/page.js`))
|
|
214
|
+
html = html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag
|
|
215
|
+
c.res = new Response(html, { status: c.res.status, headers })
|
|
155
216
|
}
|
|
156
217
|
const router = ctx.router as unknown as { wrap?: (middleware: typeof addScript) => unknown }
|
|
157
218
|
// An app whose router copy is older than this plugin has no `wrap`: the middleware then covers the routes mounted after the door, as the agent panel's does.
|
|
158
219
|
if (typeof router.wrap === 'function') router.wrap(addScript)
|
|
159
220
|
else ctx.router.route('/', new Acryl().use('*', addScript as never))
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
221
|
+
// DSH's own index.html is not a framework route, so `router.wrap` never sees it: the tag goes in through the web server's index-inject hook instead. The
|
|
222
|
+
// tap is owned by this plugin's fiber, so unloading the door takes the tag with it, and the new activation's page routes answer under the new secret.
|
|
223
|
+
if (config.hostIndex !== false) ctx.inject(['webServer'], inner => {
|
|
224
|
+
inner.effect(() => {
|
|
225
|
+
const taps = inner.get('webServer') as unknown as { tapIndex?: (transform: (html: string) => string) => () => void } | undefined
|
|
226
|
+
if (typeof taps?.tapIndex !== 'function') return () => {}
|
|
227
|
+
return taps.tapIndex(html => {
|
|
228
|
+
if (html.includes(`${DOOR_PREFIX}/page.js`)) return html
|
|
229
|
+
return html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag
|
|
230
|
+
})
|
|
231
|
+
}, 'acryl-door: the page script in the host index')
|
|
232
|
+
})
|
|
233
|
+
// The page channel also runs over a WebSocket, which is not one of the browser's six connections per host. Without `ws` or an adapter that
|
|
234
|
+
// upgrades, the pages use the stream. The socket route is mounted ONCE here, ahead of the private routes, so it keeps its precedence when the host
|
|
235
|
+
// restarts; what moves is the upgrade helper it reads per request. While routerHttp exists its inject scope owns the prefix claim and sets the
|
|
236
|
+
// helper (Cordis disposes the claim exactly when the service goes away); the node server's hub is the helper only while routerHttp is absent.
|
|
237
|
+
// An upgrade while no helper is ready is refused 503 by the route, never answered twice.
|
|
238
|
+
let pageUpgrade: UpgradeWebSocket | undefined
|
|
239
|
+
let nodeUpgrade: UpgradeWebSocket | undefined
|
|
240
|
+
const endNodeOwner = (): void => {
|
|
241
|
+
if (pageUpgrade === nodeUpgrade) pageUpgrade = undefined
|
|
242
|
+
nodeUpgrade = undefined
|
|
243
|
+
}
|
|
244
|
+
const nodeFallback = (): void => {
|
|
245
|
+
if (ctx.get('routerHttp') !== undefined || nodeUpgrade !== undefined) return
|
|
246
|
+
try {
|
|
247
|
+
const allow = (ctx.get('nodeServer') as unknown as NodeServerLike | undefined)?.upgrades?.()
|
|
248
|
+
if (allow !== undefined) {
|
|
249
|
+
nodeUpgrade = allow
|
|
250
|
+
pageUpgrade = allow
|
|
251
|
+
}
|
|
252
|
+
} catch (cause) {
|
|
253
|
+
reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
|
|
254
|
+
}
|
|
166
255
|
}
|
|
256
|
+
ctx.inject(['routerHttp'], (inner) => {
|
|
257
|
+
// The adapter is here: the node helper gives way, and this scope owns the prefix claim and the helper until Cordis disposes it with the service.
|
|
258
|
+
endNodeOwner()
|
|
259
|
+
const http = inner.get('routerHttp') as unknown as
|
|
260
|
+
| { upgrades?: () => UpgradeWebSocket; mount?: (prefix: string, options: { upgrade: string[] }) => () => void }
|
|
261
|
+
| undefined
|
|
262
|
+
try {
|
|
263
|
+
const allow = http?.upgrades?.()
|
|
264
|
+
if (allow === undefined || http === undefined || typeof http.mount !== 'function') return
|
|
265
|
+
pageUpgrade = allow
|
|
266
|
+
const remove = http.mount(DOOR_PREFIX, { upgrade: [`${DOOR_PREFIX}/channel/ws`] })
|
|
267
|
+
inner.effect(() => () => {
|
|
268
|
+
remove()
|
|
269
|
+
if (pageUpgrade === allow) pageUpgrade = undefined
|
|
270
|
+
nodeFallback()
|
|
271
|
+
}, 'acryl-door: the page socket via routerHttp')
|
|
272
|
+
} catch (cause) {
|
|
273
|
+
reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
ctx.inject(['nodeServer'], (inner) => {
|
|
277
|
+
inner.effect(() => {
|
|
278
|
+
nodeFallback()
|
|
279
|
+
const onService = inner.on as unknown as (name: string, listener: () => void) => () => void
|
|
280
|
+
const off = onService('internal/service', () => {
|
|
281
|
+
if (ctx.get('routerHttp') !== undefined) endNodeOwner()
|
|
282
|
+
else nodeFallback()
|
|
283
|
+
})
|
|
284
|
+
return () => {
|
|
285
|
+
off()
|
|
286
|
+
endNodeOwner()
|
|
287
|
+
}
|
|
288
|
+
}, 'acryl-door: the page socket on the node server')
|
|
289
|
+
})
|
|
290
|
+
ctx.router.route('/', pageSocketRoutes(channel, { prefix: DOOR_PREFIX, upgrade: () => pageUpgrade }))
|
|
167
291
|
ctx.router.route('/', privateApi(
|
|
168
292
|
DOOR_PREFIX,
|
|
169
293
|
pageModule,
|
|
170
294
|
pageChannelRoutes(channel, { prefix: DOOR_PREFIX }),
|
|
171
|
-
...(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
295
|
+
...(pageOnly ? [] : [
|
|
296
|
+
shellRoutes(DOOR_PREFIX, secret, shell),
|
|
297
|
+
operatorRoutes(DOOR_PREFIX, secret, door, reportError),
|
|
298
|
+
mcpRoutes(DOOR_PREFIX, secret, door, reportError),
|
|
299
|
+
]),
|
|
175
300
|
))
|
|
176
301
|
// The built-in chat's agent reads the `tools` registry: the verbs are in it while both are up.
|
|
177
302
|
ctx.inject(['tools'], (inner) => {
|
|
178
|
-
inner.effect(() => registerChatTools(inner.get('tools') as unknown as ToolsPort, door), 'acryl-door: the verbs in the tools registry')
|
|
303
|
+
inner.effect(() => registerChatTools(inner.get('tools') as unknown as ToolsPort, door, pageOnly ? PAGE_VERBS : VERBS), 'acryl-door: the verbs in the tools registry')
|
|
179
304
|
})
|
|
180
305
|
ctx.effect(function* () {
|
|
181
306
|
yield ctx.reflect.provide('door', door)
|
|
182
|
-
|
|
307
|
+
}, 'acryl-door: the door service')
|
|
308
|
+
// A page-only door leaves no file and no command for an operator.
|
|
309
|
+
if (pageOnly) return
|
|
310
|
+
// The carrier binds asynchronously (DSH's webServer, the node server before it): the address and the file are written once it reports a real port.
|
|
311
|
+
ctx.effect(async () => {
|
|
312
|
+
let bound = carrier()
|
|
313
|
+
for (let at = 0; at < 500 && (bound === undefined || !(bound.port > 0)); at += 1) {
|
|
314
|
+
await new Promise((done) => setTimeout(done, 10))
|
|
315
|
+
bound = carrier()
|
|
316
|
+
}
|
|
317
|
+
if (bound === undefined) throw new Error('acryl-door: no carrier bound the app (neither acrylWeb nor nodeServer answered)')
|
|
318
|
+
address.url = loopbackUrl(`http://${bound.host}:${String(bound.port)}`)
|
|
319
|
+
writeDoorFile(app.root, address.url, secret)
|
|
183
320
|
// A shim that cannot be replaced (Windows, held open by a running `acryl`) leaves the old one in place; it must not stop the app from starting.
|
|
184
321
|
try { writeShims(app.root, shimTarget()) } catch (cause) { reportError('write .acryl/bin', cause) }
|
|
185
|
-
|
|
186
|
-
}, 'acryl-door:
|
|
322
|
+
return () => { removeDoorFile(app.root, secret) }
|
|
323
|
+
}, 'acryl-door: .acryl/door.json and .acryl/bin/acryl')
|
|
187
324
|
}
|
|
188
325
|
|
|
189
326
|
export { Door, DoorError, type VerbAnswer } from './host/door.ts'
|
|
190
|
-
export { chatToolName, VERB_SPECS, VERBS, type Verb, type VerbSpec } from './host/verbs.ts'
|
|
327
|
+
export { chatToolName, PAGE_VERBS, VERB_SPECS, VERBS, type Verb, type VerbSpec } from './host/verbs.ts'
|
|
191
328
|
export { mcpTools } from './host/mcp-routes.ts'
|
|
192
329
|
export { DOOR_PREFIX } from './host/http.ts'
|
|
193
|
-
export { doorFilePath, readDoorFile, STATE_DIR, type DoorFile } from './host/door-file.ts'
|
|
330
|
+
export { assertDoorIsFree, doorFilePath, readDoorFile, STATE_DIR, type DoorFile } from './host/door-file.ts'
|
|
194
331
|
export type { CallResponse } from './host/operator-routes.ts'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a host serves the app's pages under a prefix (the ACRYL frame's canvas, `/app`): the meta the door puts on each page, and where an app path
|
|
3
|
+
* lands under that prefix. Empty at the root, which is the app's own server.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one thing this file needs from a page's document. Spelled without DOM types so the Host imports `underAppBase` from here too, instead of keeping
|
|
8
|
+
* a second copy of the join; a real Document satisfies it.
|
|
9
|
+
*/
|
|
10
|
+
interface MetaHost {
|
|
11
|
+
querySelector(selectors: string): { getAttribute(name: string): string | null } | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The app base a page carries (`<meta name="acryl-app-base">`, the host's prefix for the app); '' at the root. */
|
|
15
|
+
export function appBaseOf(document: MetaHost): string {
|
|
16
|
+
const content = document.querySelector('meta[name="acryl-app-base"]')?.getAttribute('content')?.trim() ?? ''
|
|
17
|
+
return content.startsWith('/') && content !== '/' ? content.replace(/\/+$/u, '') : ''
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `path` (an app path, optionally with a query) under `base`: '/board' under '/app' is '/app/board'; the app's root is the base itself, so '/' is
|
|
22
|
+
* '/app' (its mount) and not '/app/'; without a base, the path as written.
|
|
23
|
+
*/
|
|
24
|
+
export function underAppBase(base: string, path: string): string {
|
|
25
|
+
if (base === '') return path
|
|
26
|
+
const at = path.search(/[?#]/u)
|
|
27
|
+
const route = at === -1 ? path : path.slice(0, at)
|
|
28
|
+
const rest = at === -1 ? '' : path.slice(at)
|
|
29
|
+
if (route === base || route.startsWith(`${base}/`)) return path
|
|
30
|
+
if (route === '/' || route === '') return `${base}${rest}`
|
|
31
|
+
return `${base}${route.startsWith('/') ? '' : '/'}${route}${rest}`
|
|
32
|
+
}
|
|
@@ -27,6 +27,8 @@ const BACKOFF_MAX_MS = 10_000
|
|
|
27
27
|
|
|
28
28
|
export interface PageChannelOptions {
|
|
29
29
|
readonly windowId: string
|
|
30
|
+
/** The browser tab the page is in (kept across the tab's reloads and moves), so the Host follows a run's page into the next document of its tab. */
|
|
31
|
+
readonly tab?: string
|
|
30
32
|
/** The desktop window this page is in (`AcrylWindow/<n>` in the user agent, set by the Electron shell), so the Host can drive it by the number `window list` prints. */
|
|
31
33
|
readonly shellWindow?: number
|
|
32
34
|
readonly focused: () => boolean
|
|
@@ -70,7 +72,7 @@ export class PageChannel {
|
|
|
70
72
|
|
|
71
73
|
connect(): void {
|
|
72
74
|
if (this.disposed || this.source !== undefined) return
|
|
73
|
-
const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? '1' : '0'}${this.options.shellWindow === undefined ? '' : `&shell=${String(this.options.shellWindow)}`}`
|
|
75
|
+
const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? '1' : '0'}${this.options.shellWindow === undefined ? '' : `&shell=${String(this.options.shellWindow)}`}${this.options.tab === undefined ? '' : `&tab=${encodeURIComponent(this.options.tab)}`}`
|
|
74
76
|
const viaSocket = this.socketAllowed
|
|
75
77
|
this.socketReady = false
|
|
76
78
|
const source: ChannelSource = viaSocket
|
|
@@ -147,6 +147,17 @@ export function valueOf(el: Element, role: string): string | undefined {
|
|
|
147
147
|
return value === undefined || value === '' ? undefined : clip(value, 100)
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/** Choices of a select listed at most; a longer list ends with how many more there are. */
|
|
151
|
+
const MAX_OPTIONS = 20
|
|
152
|
+
|
|
153
|
+
/** The choices of a native select (what `act select` takes), or undefined when it is not one or is sensitive. */
|
|
154
|
+
export function optionsOf(el: Element): string[] | undefined {
|
|
155
|
+
if (!(el instanceof HTMLSelectElement) || isSensitive(el)) return undefined
|
|
156
|
+
const labels = [...el.options].filter(option => !option.hidden).map(option => clip((option.textContent ?? '').trim(), 60)).filter(label => label !== '')
|
|
157
|
+
if (labels.length <= MAX_OPTIONS) return labels
|
|
158
|
+
return [...labels.slice(0, MAX_OPTIONS), `(${String(labels.length - MAX_OPTIONS)} more)`]
|
|
159
|
+
}
|
|
160
|
+
|
|
150
161
|
/** Heading level, when the element is a heading. */
|
|
151
162
|
export function levelOf(el: Element): number | undefined {
|
|
152
163
|
const explicit = Number(el.getAttribute('aria-level'))
|