acrylblends 1.0.10 → 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 +60 -2
- package/dist/index.js +1992 -33
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/api/summary.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +1 -1
- package/docs/framework/guides/production.md +20 -0
- package/docs/framework/workshop/README.md +174 -285
- package/docs/framework/workshop/facilitator.md +81 -90
- 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-Dw-siYTp.d.ts → audit-Bok_UNCn.d.ts} +41 -6
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +44 -7
- package/lib/cordis/door.js +214 -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.d.ts +3 -2
- package/lib/cordis/state.js +371 -7
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +20 -1
- package/lib/door/page.js +75 -14
- 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-BfEbEOjA.js → snapshot-text-CXTnjxGx.js} +121 -9
- 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 +124 -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 +58 -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 +11 -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 +49 -7
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +46 -2
- package/plugins/acryl-door/src/page/indicator.ts +14 -6
- 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-state/plugin.json +4 -3
- package/plugins/acryl-state/src/client-source.ts +244 -0
- package/plugins/acryl-state/src/service.ts +33 -6
- package/plugins/acryl-state/src/socket.ts +96 -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 +343 -81
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +187 -61
- 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 +12 -3
- 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 +4 -2
- 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 +73 -21
- package/templates/agent/template.json +2 -0
- package/templates/agent/test/app.test.ts +83 -5
- 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/main.ts +1 -6
- 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/main.ts +1 -6
- 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/main.ts +1 -6
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acrylblends",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
@@ -405,6 +405,7 @@
|
|
|
405
405
|
"dist",
|
|
406
406
|
"lib",
|
|
407
407
|
"templates",
|
|
408
|
+
"legacy",
|
|
408
409
|
"shared",
|
|
409
410
|
"plugins",
|
|
410
411
|
"docs"
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
/** The slice of a page's stream the channel needs, so tests can supply their own. */
|
|
18
18
|
export interface PageStream {
|
|
19
19
|
send(data: string): void
|
|
20
|
+
/** Tell the page something that is not a call (`run`: `1` while a run asked for from its panel goes, `0` when it ends). A stream that cannot (a test's) leaves it out. */
|
|
21
|
+
notify?(event: string, data: string): void
|
|
20
22
|
close(): void
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -42,6 +44,8 @@ interface Connection {
|
|
|
42
44
|
readonly windowId: string
|
|
43
45
|
/** The desktop window the page is in, when the Electron shell tagged it. */
|
|
44
46
|
readonly shellWindow: number | undefined
|
|
47
|
+
/** The browser tab the page is in (the page keeps it for the tab's lifetime): a page that reloads or moves to another path comes back with a new window id and the same tab. */
|
|
48
|
+
readonly tab: string | undefined
|
|
45
49
|
focused: boolean
|
|
46
50
|
/** Order of last focus (or connection), so the most recent wins. */
|
|
47
51
|
lastActive: number
|
|
@@ -62,21 +66,39 @@ export interface ChannelOptions {
|
|
|
62
66
|
* address registers again a moment later). 0 (the default) fails at once.
|
|
63
67
|
*/
|
|
64
68
|
readonly attachWaitMs?: number
|
|
69
|
+
/**
|
|
70
|
+
* Every call must name the page it is for (`page`), and goes to that page alone: never to the page in front or the one used last, which may be another visitor's. A page-only door
|
|
71
|
+
* (a production assistant, one channel for every visitor of the app) sets it.
|
|
72
|
+
*/
|
|
73
|
+
readonly requirePage?: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A page whose panel asked for a run: the id the run knows it by, and where that page is now (the same tab after a reload or a move). */
|
|
77
|
+
interface Asker {
|
|
78
|
+
/** The window id the run was asked from: what its calls name as `page`. */
|
|
79
|
+
readonly origin: string
|
|
80
|
+
windowId: string
|
|
81
|
+
tab: string | undefined
|
|
65
82
|
}
|
|
66
83
|
|
|
67
84
|
/**
|
|
68
|
-
* The pages and who drives which. One page answers a call, chosen the same way every time: the page
|
|
69
|
-
* one in front (it reports `focused`), else the most recently active; a page that did not acknowledge a call in time goes to the end of the line. An explicit desktop `window` picks its page.
|
|
85
|
+
* The pages and who drives which. One page answers a call, chosen the same way every time: the page whose panel asked for the run that is making the call (`driveFor`), else the page the
|
|
86
|
+
* Host just sent somewhere (it comes back as a new page, `prefer`), else the one in front (it reports `focused`), else the most recently active; a page that did not acknowledge a call in time goes to the end of the line. An explicit desktop `window` picks its page.
|
|
87
|
+
* A call that names its `page` (the page a run was asked from) goes to that page alone, followed into its tab when it reloads or moves, and to no other.
|
|
70
88
|
*/
|
|
71
89
|
export class UiChannel {
|
|
72
90
|
private readonly ackMs: number
|
|
73
91
|
private readonly attachWaitMs: number
|
|
92
|
+
private readonly requirePage: boolean
|
|
74
93
|
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
75
94
|
private preferred: string | undefined
|
|
95
|
+
/** The pages whose panels have a run going, the latest last: a call with no `window` goes to the latest of them that is still connected. */
|
|
96
|
+
private readonly askers: Asker[] = []
|
|
76
97
|
|
|
77
98
|
constructor(options: ChannelOptions = {}) {
|
|
78
99
|
this.ackMs = options.ackMs ?? DEFAULT_ACK_MS
|
|
79
100
|
this.attachWaitMs = options.attachWaitMs ?? 0
|
|
101
|
+
this.requirePage = options.requirePage === true
|
|
80
102
|
}
|
|
81
103
|
|
|
82
104
|
private readonly connections = new Map<string, Connection>()
|
|
@@ -91,12 +113,19 @@ export class UiChannel {
|
|
|
91
113
|
* Register a page's stream. A stream already open for the same window is closed first, and its calls settle as `disconnected`.
|
|
92
114
|
* @returns the function to call when this stream ends.
|
|
93
115
|
*/
|
|
94
|
-
attach(windowId: string, focused: boolean, stream: PageStream, shellWindow?: number): () => void {
|
|
116
|
+
attach(windowId: string, focused: boolean, stream: PageStream, shellWindow?: number, tab?: string): () => void {
|
|
95
117
|
const previous = this.connections.get(windowId)
|
|
96
118
|
if (previous !== undefined) this.drop(previous, true)
|
|
97
119
|
// A page that did not answer stays last in line when its stream reconnects: a tab that cannot answer reconnects just as readily, and would otherwise be first again.
|
|
98
|
-
const connection: Connection = { stream, windowId, shellWindow, focused, lastActive: ++this.clock, suspect: previous?.suspect === true }
|
|
120
|
+
const connection: Connection = { stream, windowId, shellWindow, tab, focused, lastActive: ++this.clock, suspect: previous?.suspect === true }
|
|
99
121
|
this.connections.set(windowId, connection)
|
|
122
|
+
// A run's page that reloaded or moved comes back as a new page in the same tab: the run goes on with it.
|
|
123
|
+
for (const asker of this.askers) {
|
|
124
|
+
if (asker.windowId === windowId) asker.tab ??= tab
|
|
125
|
+
else if (tab !== undefined && asker.tab === tab) asker.windowId = windowId
|
|
126
|
+
}
|
|
127
|
+
// A page that reloaded or came back during its own run is told the run is still going.
|
|
128
|
+
if (this.askers.some(asker => asker.windowId === windowId)) this.tell(connection, '1')
|
|
100
129
|
for (const waiter of [...this.attachWaiters]) waiter(windowId)
|
|
101
130
|
return () => { if (this.connections.get(windowId) === connection) this.drop(connection, false) }
|
|
102
131
|
}
|
|
@@ -137,12 +166,71 @@ export class UiChannel {
|
|
|
137
166
|
return this.ordered().map(({ windowId, focused, shellWindow }) => ({ windowId, focused, ...(shellWindow === undefined ? {} : { shellWindow }) }))
|
|
138
167
|
}
|
|
139
168
|
|
|
169
|
+
/**
|
|
170
|
+
* A run asked for from this page's panel is going: its calls (which name no window) are for this page, whatever else is open and whichever was driven last, until the run ends.
|
|
171
|
+
* An explicit desktop `window` still picks its own page.
|
|
172
|
+
* @returns the function to call when the run ends.
|
|
173
|
+
*/
|
|
174
|
+
driveFor(windowId: string): () => void {
|
|
175
|
+
const asker: Asker = { origin: windowId, windowId, tab: this.connections.get(windowId)?.tab }
|
|
176
|
+
this.askers.push(asker)
|
|
177
|
+
this.tellPage(windowId, '1')
|
|
178
|
+
return () => {
|
|
179
|
+
const at = this.askers.indexOf(asker)
|
|
180
|
+
if (at === -1) return
|
|
181
|
+
this.askers.splice(at, 1)
|
|
182
|
+
// The page keeps the bar if another run of its own is still going.
|
|
183
|
+
if (!this.askers.some(other => other.windowId === asker.windowId)) this.tellPage(asker.windowId, '0')
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Where the page a run was asked from is now: the same window, or the page that replaced it in the same tab. */
|
|
188
|
+
private whereIs(page: string): { windowId: string; tab: string | undefined } {
|
|
189
|
+
const asker = [...this.askers].reverse().find(entry => entry.origin === page)
|
|
190
|
+
return asker === undefined ? { windowId: page, tab: this.connections.get(page)?.tab } : { windowId: asker.windowId, tab: asker.tab }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A promise for the run's page coming back after a reload or a move: the next page that connects in its tab (or with its window id). Ask for it BEFORE the call that makes the page
|
|
195
|
+
* leave. Pages of other tabs, another visitor's among them, are never taken for it.
|
|
196
|
+
* @param signal - stop waiting (the act did not move the page after all); the promise then answers `undefined`.
|
|
197
|
+
* @returns the window id of the page that came back, or `undefined` when it did not within `timeoutMs`.
|
|
198
|
+
*/
|
|
199
|
+
expectPageBack(page: string, timeoutMs: number, signal?: AbortSignal): Promise<string | undefined> {
|
|
200
|
+
const { windowId, tab } = this.whereIs(page)
|
|
201
|
+
return new Promise((resolve) => {
|
|
202
|
+
const done = (value: string | undefined): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); signal?.removeEventListener('abort', stop); resolve(value) }
|
|
203
|
+
const stop = (): void => { done(undefined) }
|
|
204
|
+
signal?.addEventListener('abort', stop, { once: true })
|
|
205
|
+
const waiter = (attached: string): void => {
|
|
206
|
+
const connection = this.connections.get(attached)
|
|
207
|
+
if (attached === windowId || (tab !== undefined && connection?.tab === tab)) done(attached)
|
|
208
|
+
}
|
|
209
|
+
const timer = setTimeout(() => { done(undefined) }, timeoutMs)
|
|
210
|
+
this.attachWaiters.add(waiter)
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private tellPage(windowId: string, running: '0' | '1'): void {
|
|
215
|
+
const connection = this.connections.get(windowId)
|
|
216
|
+
if (connection !== undefined) this.tell(connection, running)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private tell(connection: Connection, running: '0' | '1'): void {
|
|
220
|
+
try { connection.stream.notify?.('run', running) } catch { /* a stream that is going away says so by closing */ }
|
|
221
|
+
}
|
|
222
|
+
|
|
140
223
|
/** Drive this page from now on (it is the one the Host just sent to a new place), until the person focuses another. */
|
|
141
224
|
prefer(windowId: string): void {
|
|
142
225
|
const connection = this.connections.get(windowId)
|
|
143
226
|
if (connection === undefined) return
|
|
144
227
|
this.preferred = windowId
|
|
145
228
|
connection.lastActive = ++this.clock
|
|
229
|
+
// A run's own page that reloaded or navigated comes back as a new page: the run goes on with that one. A run whose page has a tab follows only that tab, so
|
|
230
|
+
// another visitor's page is never taken for it.
|
|
231
|
+
for (const asker of this.askers)
|
|
232
|
+
if (!this.connections.has(asker.windowId) && (asker.tab === undefined || asker.tab === connection.tab)) asker.windowId = windowId
|
|
233
|
+
if (this.askers.some(asker => asker.windowId === windowId)) this.tellPage(windowId, '1')
|
|
146
234
|
}
|
|
147
235
|
|
|
148
236
|
/**
|
|
@@ -150,9 +238,11 @@ export class UiChannel {
|
|
|
150
238
|
* until it speaks; a call that changes the page is never sent to a second page, because the first might still run it.
|
|
151
239
|
* @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
|
|
152
240
|
*/
|
|
153
|
-
async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number } = {}, retried = false): Promise<UiResult> {
|
|
241
|
+
async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number; page?: string } = {}, retried = false): Promise<UiResult> {
|
|
154
242
|
if (this.closed) throw new UiControlError('unloaded', 'the door was unloaded')
|
|
155
243
|
const readOnlyCall = request.op === 'snapshot' || request.op === 'wait'
|
|
244
|
+
if (options.page !== undefined) return await this.callPage(request, options.page, options, readOnlyCall)
|
|
245
|
+
if (this.requirePage) throw new UiControlError('no-window', 'this door drives only the page whose visitor asked, and the call names no page')
|
|
156
246
|
// A page that is moving to another address registers again a moment later: a read-only call right after a navigate or reload waits for it, rather than failing.
|
|
157
247
|
if (readOnlyCall && this.attachWaitMs > 0 && this.connections.size === 0) await this.expectNewPage(this.attachWaitMs)
|
|
158
248
|
const ordered = this.ordered()
|
|
@@ -182,6 +272,26 @@ export class UiChannel {
|
|
|
182
272
|
throw new UiControlError('timeout', `no page of the app answered within ${String(Math.round(this.ackMs / 1000))} s: the app's pages are open but none responded. Likely causes: a tab in the background, a page that is still loading after a navigation (look again), a page busy running a long script, or a page that holds the browser's six connections with event streams of its own (a page should keep to two). Bring the app's tab to the front, or navigate it, then look again`)
|
|
183
273
|
}
|
|
184
274
|
|
|
275
|
+
/**
|
|
276
|
+
* A call for one page (the page a run was asked from, followed into its tab): it goes to that page alone and never fails over to another one. A page that is between two
|
|
277
|
+
* documents (it reloads, or moves to another path) is waited for, up to the attach wait.
|
|
278
|
+
*/
|
|
279
|
+
private async callPage(request: UiRequest, page: string, options: { signal?: AbortSignal; timeoutMs?: number }, readOnly: boolean): Promise<UiResult> {
|
|
280
|
+
let target = this.connections.get(this.whereIs(page).windowId)
|
|
281
|
+
if (target === undefined && this.attachWaitMs > 0) {
|
|
282
|
+
const back = await this.expectPageBack(page, this.attachWaitMs)
|
|
283
|
+
if (back !== undefined) target = this.connections.get(back)
|
|
284
|
+
}
|
|
285
|
+
if (target === undefined) throw new UiControlError('no-window', 'the page this run is for is not open any more')
|
|
286
|
+
if (options.signal?.aborted === true) throw new UiControlError('aborted', 'the call was cancelled')
|
|
287
|
+
try {
|
|
288
|
+
return await this.callOne(target, request, options, readOnly)
|
|
289
|
+
} catch (cause) {
|
|
290
|
+
if (cause instanceof Silent) throw new UiControlError('timeout', 'the page this run is for did not answer: is its tab in the background or still loading? Look again')
|
|
291
|
+
throw cause
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
185
295
|
private callOne(target: Connection, request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number }, failOver: boolean): Promise<UiResult> {
|
|
186
296
|
const signal = options.signal
|
|
187
297
|
const id = this.nextId
|
|
@@ -224,12 +334,15 @@ export class UiChannel {
|
|
|
224
334
|
|
|
225
335
|
/**
|
|
226
336
|
* A promise for the window id of the next page that connects and was not connected when this was asked (the page that came back after a reload or a move).
|
|
337
|
+
* @param signal - stop waiting; the promise then answers `undefined`.
|
|
227
338
|
* @returns that id, or `undefined` when no new page connected within `timeoutMs`.
|
|
228
339
|
*/
|
|
229
|
-
expectNewPage(timeoutMs: number): Promise<string | undefined> {
|
|
340
|
+
expectNewPage(timeoutMs: number, signal?: AbortSignal): Promise<string | undefined> {
|
|
230
341
|
const known = new Set(this.connections.keys())
|
|
231
342
|
return new Promise((resolve) => {
|
|
232
|
-
const done = (value: string | undefined): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); resolve(value) }
|
|
343
|
+
const done = (value: string | undefined): void => { clearTimeout(timer); this.attachWaiters.delete(waiter); signal?.removeEventListener('abort', stop); resolve(value) }
|
|
344
|
+
const stop = (): void => { done(undefined) }
|
|
345
|
+
signal?.addEventListener('abort', stop, { once: true })
|
|
233
346
|
const waiter = (windowId: string): void => { if (!known.has(windowId)) done(windowId) }
|
|
234
347
|
const timer = setTimeout(() => { done(undefined) }, timeoutMs)
|
|
235
348
|
this.attachWaiters.add(waiter)
|
|
@@ -263,9 +376,11 @@ export class UiChannel {
|
|
|
263
376
|
this.connections.clear()
|
|
264
377
|
}
|
|
265
378
|
|
|
266
|
-
/** The order pages are driven in: the one the Host sent somewhere, suspect ones last, the one in front, then the most recently active. */
|
|
379
|
+
/** The order pages are driven in: the one whose panel asked, the one the Host sent somewhere, suspect ones last, the one in front, then the most recently active. */
|
|
267
380
|
private ordered(): Connection[] {
|
|
268
|
-
|
|
381
|
+
// The latest asker that is still connected; a page that went away is skipped, and the run goes on with whatever is open.
|
|
382
|
+
const asker = [...this.askers].reverse().find(entry => this.connections.has(entry.windowId))?.windowId
|
|
383
|
+
const rank = (connection: Connection): number => (connection.suspect ? 0 : 4) + (connection.windowId === this.preferred ? 2 : 0) + (connection.focused ? 1 : 0) + (connection.windowId === asker && !connection.suspect ? 8 : 0)
|
|
269
384
|
return [...this.connections.values()].sort((a, b) => rank(b) - rank(a) || b.lastActive - a.lastActive)
|
|
270
385
|
}
|
|
271
386
|
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
import { UiControlError } from '../page/protocol.ts'
|
|
8
8
|
import { DoorError, type Door, type ToolsPort } from './door.ts'
|
|
9
|
-
import { chatToolName, VERB_SPECS, VERBS } from './verbs.ts'
|
|
9
|
+
import { chatToolName, VERB_SPECS, VERBS, type Verb } from './verbs.ts'
|
|
10
10
|
|
|
11
|
-
/** Register every
|
|
12
|
-
export function registerChatTools(tools: ToolsPort, door: Door): () => void {
|
|
11
|
+
/** Register the verbs (default: every one). @returns the function that removes them all; it also aborts the calls still running. */
|
|
12
|
+
export function registerChatTools(tools: ToolsPort, door: Door, verbs: readonly Verb[] = VERBS): () => void {
|
|
13
13
|
const running = new AbortController()
|
|
14
14
|
const removers: Array<() => void> = []
|
|
15
15
|
const removeAll = (): void => {
|
|
@@ -17,14 +17,16 @@ export function registerChatTools(tools: ToolsPort, door: Door): () => void {
|
|
|
17
17
|
for (const remove of removers.splice(0).reverse()) remove()
|
|
18
18
|
}
|
|
19
19
|
try {
|
|
20
|
-
for (const verb of
|
|
20
|
+
for (const verb of verbs) {
|
|
21
21
|
removers.push(tools.register(chatToolName(verb), {
|
|
22
22
|
description: VERB_SPECS[verb].description,
|
|
23
23
|
// The model is told which fields the verb takes (an `act` needs its `op`), the same schema MCP lists.
|
|
24
24
|
parameters: VERB_SPECS[verb].arguments as unknown as Readonly<Record<string, unknown>>,
|
|
25
|
-
run
|
|
25
|
+
// The run's own signal and page come with the call: its look and act go to the page the run was asked from.
|
|
26
|
+
run: async (input: unknown, options?: { signal?: AbortSignal; page?: string }) => {
|
|
27
|
+
const signal = options?.signal === undefined ? running.signal : AbortSignal.any([running.signal, options.signal])
|
|
26
28
|
try {
|
|
27
|
-
return (await door.run(verb, input ?? {},
|
|
29
|
+
return (await door.run(verb, input ?? {}, signal, options?.page === undefined ? {} : { page: options.page })).text
|
|
28
30
|
} catch (cause) {
|
|
29
31
|
if (cause instanceof DoorError || cause instanceof UiControlError) throw new Error(`${cause.code}: ${cause.message}`, { cause })
|
|
30
32
|
throw cause
|
|
@@ -73,12 +73,17 @@ function alive(pid: number): boolean {
|
|
|
73
73
|
* Write this run's connection file, replacing a stale one.
|
|
74
74
|
* @throws when another live process already serves this folder's door: two apps from one folder would race for one file.
|
|
75
75
|
*/
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
/** @throws when another live process already serves this folder's door: two apps from one folder would race for one file. */
|
|
77
|
+
export function assertDoorIsFree(appRoot: string): void {
|
|
78
78
|
const existing = readDoorFile(appRoot)
|
|
79
79
|
if (existing !== undefined && existing.pid !== process.pid && alive(existing.pid)) {
|
|
80
80
|
throw new Error(`acryl-door: another process (pid ${String(existing.pid)}) serves the door of ${appRoot}; stop it first`)
|
|
81
81
|
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function writeDoorFile(appRoot: string, url: string, secret: string): string {
|
|
85
|
+
const dir = ensureStateDir(appRoot)
|
|
86
|
+
assertDoorIsFree(appRoot)
|
|
82
87
|
const file = doorFilePath(appRoot)
|
|
83
88
|
const temporary = join(dir, `${DOOR_FILE}.${String(process.pid)}.tmp`)
|
|
84
89
|
const content: DoorFile = { version: 1, url, secret, pid: process.pid }
|
|
@@ -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
|
|
@@ -90,17 +98,22 @@ export class Door {
|
|
|
90
98
|
this.deps = deps
|
|
91
99
|
}
|
|
92
100
|
|
|
101
|
+
/** A run asked for from the panel of this page is going: its verbs that name no window are for that page. @returns the function to call when the run ends. */
|
|
102
|
+
driveFor(windowId: string): () => void {
|
|
103
|
+
return this.deps.channel.driveFor(windowId)
|
|
104
|
+
}
|
|
105
|
+
|
|
93
106
|
/** Run one verb. @throws DoorError or UiControlError. */
|
|
94
|
-
async run(verb: string, args: unknown, signal: AbortSignal): Promise<VerbAnswer> {
|
|
107
|
+
async run(verb: string, args: unknown, signal: AbortSignal, scope: VerbScope = {}): Promise<VerbAnswer> {
|
|
95
108
|
const input = (typeof args === 'object' && args !== null && !Array.isArray(args) ? args : {}) as Record<string, unknown>
|
|
96
109
|
switch (verb) {
|
|
97
110
|
case 'brief': return this.brief()
|
|
98
111
|
case 'look': {
|
|
99
|
-
const snapshot = await this.look(input, signal)
|
|
112
|
+
const snapshot = await this.look(input, signal, scope)
|
|
100
113
|
return { value: snapshot, text: renderSnapshot(snapshot, cursor => `run look --cursor ${String(cursor)}`) }
|
|
101
114
|
}
|
|
102
115
|
case 'act': {
|
|
103
|
-
const result = await this.act(input, signal)
|
|
116
|
+
const result = await this.act(input, signal, scope)
|
|
104
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` }
|
|
105
118
|
}
|
|
106
119
|
case 'plugin-list': return this.plugin(extensions => extensions.list(), false)
|
|
@@ -146,30 +159,47 @@ export class Door {
|
|
|
146
159
|
return { value, text }
|
|
147
160
|
}
|
|
148
161
|
|
|
149
|
-
look(input: Record<string, unknown>, signal: AbortSignal): Promise<UiSnapshot> {
|
|
150
|
-
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)
|
|
151
164
|
const request = this.parse({ ...rest, op: 'snapshot' })
|
|
152
|
-
return this.callPage('look', request, signal, window) as Promise<UiSnapshot>
|
|
165
|
+
return this.callPage('look', request, signal, window, scope.page) as Promise<UiSnapshot>
|
|
153
166
|
}
|
|
154
167
|
|
|
155
|
-
async act(input: Record<string, unknown>, signal: AbortSignal): Promise<UiActionResult> {
|
|
168
|
+
async act(input: Record<string, unknown>, signal: AbortSignal, scope: VerbScope = {}): Promise<UiActionResult> {
|
|
156
169
|
if (input.op === 'snapshot') throw new DoorError('invalid', 'use look to see the page')
|
|
157
|
-
const { window, rest } = this.windowOf(input)
|
|
170
|
+
const { window, rest } = this.windowOf(input, scope)
|
|
158
171
|
const request = this.parse(rest)
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
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)
|
|
162
182
|
let answer: UiActionResult
|
|
183
|
+
let left = moves
|
|
163
184
|
try {
|
|
164
|
-
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
|
|
165
187
|
} catch (cause) {
|
|
166
|
-
if (!(cause instanceof UiControlError && cause.code === 'disconnected')) {
|
|
167
|
-
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
|
|
168
195
|
}
|
|
169
196
|
const came = await back
|
|
170
|
-
// The page that came back is the one this agent was driving: it stays the target until the person focuses another page.
|
|
171
|
-
if (came !== undefined) this.deps.channel.prefer(came)
|
|
172
|
-
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?`)
|
|
173
203
|
const landed = request.op === 'navigate' ? await this.statusAt(request.path) : undefined
|
|
174
204
|
return { ...answer, detail: `${answer.detail ?? 'done'}${landed === undefined ? '' : `; ${landed}`}; the page is back: look again` }
|
|
175
205
|
}
|
|
@@ -182,7 +212,10 @@ export class Door {
|
|
|
182
212
|
const base = this.deps.app.url
|
|
183
213
|
if (base === undefined) return undefined
|
|
184
214
|
try {
|
|
185
|
-
|
|
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) })
|
|
186
219
|
await response.body?.cancel()
|
|
187
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`
|
|
188
221
|
} catch {
|
|
@@ -232,9 +265,11 @@ export class Door {
|
|
|
232
265
|
}
|
|
233
266
|
|
|
234
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. */
|
|
235
|
-
private windowOf(input: Record<string, unknown
|
|
268
|
+
private windowOf(input: Record<string, unknown>, scope: VerbScope = {}): { window: number | undefined; rest: Record<string, unknown> } {
|
|
236
269
|
const { window, ...rest } = input
|
|
237
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')
|
|
238
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')
|
|
239
274
|
return { window, rest }
|
|
240
275
|
}
|
|
@@ -248,10 +283,10 @@ export class Door {
|
|
|
248
283
|
}
|
|
249
284
|
|
|
250
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. */
|
|
251
|
-
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> {
|
|
252
287
|
const approval: AuditEntry['approval'] = 'token'
|
|
253
288
|
try {
|
|
254
|
-
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 } : {}) })
|
|
255
290
|
const target = 'target' in value ? value.target : undefined
|
|
256
291
|
this.deps.audit.record({ at: new Date().toISOString(), tool: verb, ...(target === undefined ? {} : { target }), ...(window === undefined ? {} : { window }), outcome: 'ok', approval })
|
|
257
292
|
return value
|
|
@@ -265,7 +300,7 @@ export class Door {
|
|
|
265
300
|
|
|
266
301
|
private async plugin(run: (extensions: ExtensionsPort) => unknown, mutates = true): Promise<VerbAnswer> {
|
|
267
302
|
const extensions = this.deps.extensions()
|
|
268
|
-
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')
|
|
269
304
|
const value = await run(extensions)
|
|
270
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.
|
|
271
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) }, 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 }) }, 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
|
|