acrylblends 1.0.9 → 1.1.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/dist/index.d.ts +15 -1
- package/dist/index.js +13 -7
- package/dist/index.js.map +1 -1
- package/docs/framework/api/summary.md +1 -1
- package/docs/framework/guides/bring-your-own-tool.md +2 -0
- package/docs/framework/guides/production.md +20 -0
- package/docs/framework/workshop/README.md +177 -274
- package/docs/framework/workshop/facilitator.md +107 -84
- package/lib/{audit-Dw-siYTp.d.ts → audit-BFpULBCj.d.ts} +15 -3
- package/lib/cordis/door.d.ts +3 -1
- package/lib/cordis/door.js +11 -1
- package/lib/cordis/state.d.ts +3 -2
- package/lib/cordis/state.js +370 -6
- package/lib/db/conformance.d.ts +6 -2
- package/lib/db/conformance.js +14 -2
- package/lib/door/host.d.ts +1 -1
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -0
- package/lib/door/page.js +29 -7
- package/lib/kv/conformance.d.ts +6 -2
- package/lib/kv/conformance.js +14 -2
- package/lib/{snapshot-text-CBi3EMnj.js → snapshot-text-BwM6T2nG.js} +37 -5
- package/lib/vitest-binding-CctNu3jT.d.ts +15 -0
- package/lib/vitest-binding-CrH7rKg2.js +17 -0
- package/package.json +1 -5
- package/plugins/acryl-door/src/host/channel.ts +43 -5
- package/plugins/acryl-door/src/host/door.ts +5 -0
- package/plugins/acryl-door/src/host/page-routes.ts +1 -1
- package/plugins/acryl-door/src/host/page-socket.ts +1 -1
- package/plugins/acryl-door/src/page/channel.ts +8 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +17 -3
- package/plugins/acryl-door/src/page/index.ts +6 -1
- package/plugins/acryl-door/src/page/indicator.ts +14 -6
- 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/agent-panel/client/main.ts +105 -55
- package/plugins/agent-panel/index.ts +42 -5
- package/plugins/desktop/main.ts +5 -1
- package/plugins/instance/index.ts +6 -3
- package/templates/agent/AGENTS.md +3 -1
- package/templates/agent/src/routes.ts +38 -5
- package/templates/agent/test/app.test.ts +38 -2
- package/templates/chat/src/main.ts +1 -6
- package/templates/nodejs/src/main.ts +1 -6
- package/templates/notes/src/main.ts +1 -6
|
@@ -29,14 +29,16 @@ const DEFAULT_CALL_TIMEOUT_MS = 3e4;
|
|
|
29
29
|
/** How long a page has to say it heard a read-only call before the call goes to the next page. */
|
|
30
30
|
const DEFAULT_ACK_MS = 3e3;
|
|
31
31
|
/**
|
|
32
|
-
* The pages and who drives which. One page answers a call, chosen the same way every time: the page
|
|
33
|
-
* 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.
|
|
32
|
+
* 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
|
|
33
|
+
* 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.
|
|
34
34
|
*/
|
|
35
35
|
var UiChannel = class {
|
|
36
36
|
ackMs;
|
|
37
37
|
attachWaitMs;
|
|
38
38
|
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
39
39
|
preferred;
|
|
40
|
+
/** 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. */
|
|
41
|
+
askers = [];
|
|
40
42
|
constructor(options = {}) {
|
|
41
43
|
this.ackMs = options.ackMs ?? DEFAULT_ACK_MS;
|
|
42
44
|
this.attachWaitMs = options.attachWaitMs ?? 0;
|
|
@@ -64,6 +66,7 @@ var UiChannel = class {
|
|
|
64
66
|
suspect: previous?.suspect === true
|
|
65
67
|
};
|
|
66
68
|
this.connections.set(windowId, connection);
|
|
69
|
+
if (this.askers.some((asker) => asker.windowId === windowId)) this.tell(connection, "1");
|
|
67
70
|
for (const waiter of [...this.attachWaiters]) waiter(windowId);
|
|
68
71
|
return () => {
|
|
69
72
|
if (this.connections.get(windowId) === connection) this.drop(connection, false);
|
|
@@ -104,12 +107,39 @@ var UiChannel = class {
|
|
|
104
107
|
...shellWindow === void 0 ? {} : { shellWindow }
|
|
105
108
|
}));
|
|
106
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* 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.
|
|
112
|
+
* An explicit desktop `window` still picks its own page.
|
|
113
|
+
* @returns the function to call when the run ends.
|
|
114
|
+
*/
|
|
115
|
+
driveFor(windowId) {
|
|
116
|
+
const asker = { windowId };
|
|
117
|
+
this.askers.push(asker);
|
|
118
|
+
this.tellPage(windowId, "1");
|
|
119
|
+
return () => {
|
|
120
|
+
const at = this.askers.indexOf(asker);
|
|
121
|
+
if (at === -1) return;
|
|
122
|
+
this.askers.splice(at, 1);
|
|
123
|
+
if (!this.askers.some((other) => other.windowId === asker.windowId)) this.tellPage(asker.windowId, "0");
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
tellPage(windowId, running) {
|
|
127
|
+
const connection = this.connections.get(windowId);
|
|
128
|
+
if (connection !== void 0) this.tell(connection, running);
|
|
129
|
+
}
|
|
130
|
+
tell(connection, running) {
|
|
131
|
+
try {
|
|
132
|
+
connection.stream.notify?.("run", running);
|
|
133
|
+
} catch {}
|
|
134
|
+
}
|
|
107
135
|
/** Drive this page from now on (it is the one the Host just sent to a new place), until the person focuses another. */
|
|
108
136
|
prefer(windowId) {
|
|
109
137
|
const connection = this.connections.get(windowId);
|
|
110
138
|
if (connection === void 0) return;
|
|
111
139
|
this.preferred = windowId;
|
|
112
140
|
connection.lastActive = ++this.clock;
|
|
141
|
+
for (const asker of this.askers) if (!this.connections.has(asker.windowId)) asker.windowId = windowId;
|
|
142
|
+
if (this.askers.some((asker) => asker.windowId === windowId)) this.tellPage(windowId, "1");
|
|
113
143
|
}
|
|
114
144
|
/**
|
|
115
145
|
* Ask the current page to do something. A read-only call (`look`, `wait`) that the page does not acknowledge in time goes to the next page, and the silent one is last in line
|
|
@@ -142,7 +172,7 @@ var UiChannel = class {
|
|
|
142
172
|
const fresh = await this.expectNewPage(this.attachWaitMs);
|
|
143
173
|
if (fresh !== void 0 && !tried.has(fresh)) return await this.call(request, options, true);
|
|
144
174
|
}
|
|
145
|
-
throw new UiControlError("timeout", `no page of the app answered within ${String(Math.round(this.ackMs / 1e3))} 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), 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`);
|
|
175
|
+
throw new UiControlError("timeout", `no page of the app answered within ${String(Math.round(this.ackMs / 1e3))} 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`);
|
|
146
176
|
}
|
|
147
177
|
callOne(target, request, options, failOver) {
|
|
148
178
|
const signal = options.signal;
|
|
@@ -258,9 +288,10 @@ var UiChannel = class {
|
|
|
258
288
|
for (const connection of [...this.connections.values()]) connection.stream.close();
|
|
259
289
|
this.connections.clear();
|
|
260
290
|
}
|
|
261
|
-
/** The order pages are driven in: the one the Host sent somewhere, suspect ones last, the one in front, then the most recently active. */
|
|
291
|
+
/** 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. */
|
|
262
292
|
ordered() {
|
|
263
|
-
const
|
|
293
|
+
const asker = [...this.askers].reverse().find((entry) => this.connections.has(entry.windowId))?.windowId;
|
|
294
|
+
const rank = (connection) => (connection.suspect ? 0 : 4) + (connection.windowId === this.preferred ? 2 : 0) + (connection.focused ? 1 : 0) + (connection.windowId === asker && !connection.suspect ? 8 : 0);
|
|
264
295
|
return [...this.connections.values()].sort((a, b) => rank(b) - rank(a) || b.lastActive - a.lastActive);
|
|
265
296
|
}
|
|
266
297
|
/** Forget a connection; its calls in flight settle as `disconnected` at once rather than waiting for their timeout. */
|
|
@@ -441,6 +472,7 @@ function pageChannelRoutes(channel, options) {
|
|
|
441
472
|
send: (data) => {
|
|
442
473
|
write("call", data);
|
|
443
474
|
},
|
|
475
|
+
notify: write,
|
|
444
476
|
close: end
|
|
445
477
|
}, shellWindow);
|
|
446
478
|
const beat = setInterval(() => {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/vitest-binding.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The conformance suites (`acrylblends/db/conformance`, `acrylblends/kv/conformance`) run on the test runner's own vitest, which the test hands them. In the package build their
|
|
4
|
+
* `import ... from 'vitest'` is pointed here (`tsdown.config.ts`), so the package imports no vitest and does not declare one: it cannot put vitest, vite, rolldown or lightningcss into
|
|
5
|
+
* a production install, and the suite can never see a second copy of vitest that is not the one running the test.
|
|
6
|
+
*/
|
|
7
|
+
/** The four functions of vitest the suites use; a test passes `import * as vitest from 'vitest'`. */
|
|
8
|
+
interface VitestApi {
|
|
9
|
+
readonly afterEach: (...args: never[]) => unknown;
|
|
10
|
+
readonly describe: (...args: never[]) => unknown;
|
|
11
|
+
readonly expect: (...args: never[]) => unknown;
|
|
12
|
+
readonly it: (...args: never[]) => unknown;
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
export { VitestApi as t };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/vitest-binding.ts
|
|
2
|
+
let bound;
|
|
3
|
+
/** Use `api` for the suites that run next. They register their cases synchronously, so one call per suite is enough. */
|
|
4
|
+
function bind(api) {
|
|
5
|
+
if (api === void 0 || typeof api.describe !== "function" || typeof api.it !== "function" || typeof api.expect !== "function" || typeof api.afterEach !== "function") throw new Error("the conformance suite needs the vitest your tests run on: pass it as the third argument, `import * as vitest from 'vitest'` then `describeKvConformance(name, open, vitest)`");
|
|
6
|
+
bound = api;
|
|
7
|
+
}
|
|
8
|
+
const use = () => {
|
|
9
|
+
if (bound === void 0) throw new Error("the conformance suite ran before it was given vitest");
|
|
10
|
+
return bound;
|
|
11
|
+
};
|
|
12
|
+
const afterEach = (...args) => Reflect.apply(use().afterEach, void 0, args);
|
|
13
|
+
const describe = (...args) => Reflect.apply(use().describe, void 0, args);
|
|
14
|
+
const expect = (...args) => Reflect.apply(use().expect, void 0, args);
|
|
15
|
+
const it = (...args) => Reflect.apply(use().it, void 0, args);
|
|
16
|
+
//#endregion
|
|
17
|
+
export { it as a, expect as i, bind as n, describe as r, afterEach as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acrylblends",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.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",
|
|
@@ -436,7 +436,6 @@
|
|
|
436
436
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
437
437
|
"@deepseek-ai/schemastery": "~3.18.4",
|
|
438
438
|
"react": ">=18",
|
|
439
|
-
"vitest": ">=3",
|
|
440
439
|
"ws": ">=8"
|
|
441
440
|
},
|
|
442
441
|
"peerDependenciesMeta": {
|
|
@@ -449,9 +448,6 @@
|
|
|
449
448
|
"react": {
|
|
450
449
|
"optional": true
|
|
451
450
|
},
|
|
452
|
-
"vitest": {
|
|
453
|
-
"optional": true
|
|
454
|
-
},
|
|
455
451
|
"ws": {
|
|
456
452
|
"optional": true
|
|
457
453
|
}
|
|
@@ -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
|
|
|
@@ -65,14 +67,16 @@ export interface ChannelOptions {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/**
|
|
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.
|
|
70
|
+
* 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
|
|
71
|
+
* 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.
|
|
70
72
|
*/
|
|
71
73
|
export class UiChannel {
|
|
72
74
|
private readonly ackMs: number
|
|
73
75
|
private readonly attachWaitMs: number
|
|
74
76
|
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
75
77
|
private preferred: string | undefined
|
|
78
|
+
/** 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. */
|
|
79
|
+
private readonly askers: Array<{ windowId: string }> = []
|
|
76
80
|
|
|
77
81
|
constructor(options: ChannelOptions = {}) {
|
|
78
82
|
this.ackMs = options.ackMs ?? DEFAULT_ACK_MS
|
|
@@ -97,6 +101,8 @@ export class UiChannel {
|
|
|
97
101
|
// 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
102
|
const connection: Connection = { stream, windowId, shellWindow, focused, lastActive: ++this.clock, suspect: previous?.suspect === true }
|
|
99
103
|
this.connections.set(windowId, connection)
|
|
104
|
+
// A page that reloaded or came back during its own run is told the run is still going.
|
|
105
|
+
if (this.askers.some(asker => asker.windowId === windowId)) this.tell(connection, '1')
|
|
100
106
|
for (const waiter of [...this.attachWaiters]) waiter(windowId)
|
|
101
107
|
return () => { if (this.connections.get(windowId) === connection) this.drop(connection, false) }
|
|
102
108
|
}
|
|
@@ -137,12 +143,42 @@ export class UiChannel {
|
|
|
137
143
|
return this.ordered().map(({ windowId, focused, shellWindow }) => ({ windowId, focused, ...(shellWindow === undefined ? {} : { shellWindow }) }))
|
|
138
144
|
}
|
|
139
145
|
|
|
146
|
+
/**
|
|
147
|
+
* 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.
|
|
148
|
+
* An explicit desktop `window` still picks its own page.
|
|
149
|
+
* @returns the function to call when the run ends.
|
|
150
|
+
*/
|
|
151
|
+
driveFor(windowId: string): () => void {
|
|
152
|
+
const asker = { windowId }
|
|
153
|
+
this.askers.push(asker)
|
|
154
|
+
this.tellPage(windowId, '1')
|
|
155
|
+
return () => {
|
|
156
|
+
const at = this.askers.indexOf(asker)
|
|
157
|
+
if (at === -1) return
|
|
158
|
+
this.askers.splice(at, 1)
|
|
159
|
+
// The page keeps the bar if another run of its own is still going.
|
|
160
|
+
if (!this.askers.some(other => other.windowId === asker.windowId)) this.tellPage(asker.windowId, '0')
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private tellPage(windowId: string, running: '0' | '1'): void {
|
|
165
|
+
const connection = this.connections.get(windowId)
|
|
166
|
+
if (connection !== undefined) this.tell(connection, running)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private tell(connection: Connection, running: '0' | '1'): void {
|
|
170
|
+
try { connection.stream.notify?.('run', running) } catch { /* a stream that is going away says so by closing */ }
|
|
171
|
+
}
|
|
172
|
+
|
|
140
173
|
/** Drive this page from now on (it is the one the Host just sent to a new place), until the person focuses another. */
|
|
141
174
|
prefer(windowId: string): void {
|
|
142
175
|
const connection = this.connections.get(windowId)
|
|
143
176
|
if (connection === undefined) return
|
|
144
177
|
this.preferred = windowId
|
|
145
178
|
connection.lastActive = ++this.clock
|
|
179
|
+
// A run's own page that reloaded or navigated comes back as a new page: the run goes on with that one.
|
|
180
|
+
for (const asker of this.askers) if (!this.connections.has(asker.windowId)) asker.windowId = windowId
|
|
181
|
+
if (this.askers.some(asker => asker.windowId === windowId)) this.tellPage(windowId, '1')
|
|
146
182
|
}
|
|
147
183
|
|
|
148
184
|
/**
|
|
@@ -179,7 +215,7 @@ export class UiChannel {
|
|
|
179
215
|
const fresh = await this.expectNewPage(this.attachWaitMs)
|
|
180
216
|
if (fresh !== undefined && !tried.has(fresh)) return await this.call(request, options, true)
|
|
181
217
|
}
|
|
182
|
-
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), 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`)
|
|
218
|
+
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
219
|
}
|
|
184
220
|
|
|
185
221
|
private callOne(target: Connection, request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number }, failOver: boolean): Promise<UiResult> {
|
|
@@ -263,9 +299,11 @@ export class UiChannel {
|
|
|
263
299
|
this.connections.clear()
|
|
264
300
|
}
|
|
265
301
|
|
|
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. */
|
|
302
|
+
/** 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
303
|
private ordered(): Connection[] {
|
|
268
|
-
|
|
304
|
+
// The latest asker that is still connected; a page that went away is skipped, and the run goes on with whatever is open.
|
|
305
|
+
const asker = [...this.askers].reverse().find(entry => this.connections.has(entry.windowId))?.windowId
|
|
306
|
+
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
307
|
return [...this.connections.values()].sort((a, b) => rank(b) - rank(a) || b.lastActive - a.lastActive)
|
|
270
308
|
}
|
|
271
309
|
|
|
@@ -90,6 +90,11 @@ export class Door {
|
|
|
90
90
|
this.deps = deps
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** 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. */
|
|
94
|
+
driveFor(windowId: string): () => void {
|
|
95
|
+
return this.deps.channel.driveFor(windowId)
|
|
96
|
+
}
|
|
97
|
+
|
|
93
98
|
/** Run one verb. @throws DoorError or UiControlError. */
|
|
94
99
|
async run(verb: string, args: unknown, signal: AbortSignal): Promise<VerbAnswer> {
|
|
95
100
|
const input = (typeof args === 'object' && args !== null && !Array.isArray(args) ? args : {}) as Record<string, unknown>
|
|
@@ -43,7 +43,7 @@ export function pageChannelRoutes(channel: UiChannel, options: PageRoutesOptions
|
|
|
43
43
|
const write = (event: string, data: string): void => {
|
|
44
44
|
stream.writeSSE({ event, data }).catch(end)
|
|
45
45
|
}
|
|
46
|
-
const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, close: end }, shellWindow)
|
|
46
|
+
const detach = channel.attach(windowId, focused, { send: (data) => { write('call', data) }, notify: write, close: end }, shellWindow)
|
|
47
47
|
const beat = setInterval(() => { write('ping', '') }, heartbeatMs)
|
|
48
48
|
try {
|
|
49
49
|
write('ready', '')
|
|
@@ -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)
|
|
59
59
|
write({ e: 'ready' })
|
|
60
60
|
beat = setInterval(() => {
|
|
61
61
|
try { write({ e: 'ping' }) } catch { /* the close event follows */ }
|
|
@@ -41,6 +41,8 @@ export interface PageChannelOptions {
|
|
|
41
41
|
readonly post?: (url: string, body: string) => Promise<unknown>
|
|
42
42
|
readonly silenceMs?: number
|
|
43
43
|
readonly now?: () => number
|
|
44
|
+
/** The Host says a run asked for from this page's panel started (`true`) or ended (`false`); false too when the stream is lost, until the Host says again. */
|
|
45
|
+
readonly onRun?: (running: boolean) => void
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export class PageChannel {
|
|
@@ -85,6 +87,10 @@ export class PageChannel {
|
|
|
85
87
|
heard()
|
|
86
88
|
})
|
|
87
89
|
source.addEventListener('ping', heard)
|
|
90
|
+
source.addEventListener('run', (event) => {
|
|
91
|
+
heard()
|
|
92
|
+
this.options.onRun?.(event.data === '1')
|
|
93
|
+
})
|
|
88
94
|
source.addEventListener('call', (event) => {
|
|
89
95
|
heard()
|
|
90
96
|
if (typeof event.data === 'string') this.answer(event.data)
|
|
@@ -158,6 +164,8 @@ export class PageChannel {
|
|
|
158
164
|
if (source !== undefined) {
|
|
159
165
|
source.onerror = null
|
|
160
166
|
source.close()
|
|
167
|
+
// Without the stream the page cannot hear the run end: the Host says again if it is still going, once the stream is back.
|
|
168
|
+
this.options.onRun?.(false)
|
|
161
169
|
}
|
|
162
170
|
}
|
|
163
171
|
|
|
@@ -19,6 +19,8 @@ export const LEAVE_DELAY_MS = 150
|
|
|
19
19
|
export interface DriverActivity {
|
|
20
20
|
/** True from the moment a call starts until it ends. */
|
|
21
21
|
readonly busy: boolean
|
|
22
|
+
/** True from the moment a run asked for from this page's panel starts until it ends, between the calls too. */
|
|
23
|
+
readonly running: boolean
|
|
22
24
|
/** What the agent is doing, for the indicator ("click", "type", ...). */
|
|
23
25
|
readonly doing: string
|
|
24
26
|
/** True after the user pressed the kill switch. */
|
|
@@ -53,7 +55,8 @@ export class UiDriver {
|
|
|
53
55
|
private disposed = false
|
|
54
56
|
private busyCount = 0
|
|
55
57
|
private doing = ''
|
|
56
|
-
private current: DriverActivity = { busy: false, doing: '', killed: false }
|
|
58
|
+
private current: DriverActivity = { busy: false, running: false, doing: '', killed: false }
|
|
59
|
+
private runningFlag = false
|
|
57
60
|
|
|
58
61
|
private readonly options: UiDriverOptions
|
|
59
62
|
|
|
@@ -82,6 +85,17 @@ export class UiDriver {
|
|
|
82
85
|
this.emit()
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
/**
|
|
89
|
+
* A run asked for from this page's panel starts (`true`) or ends (`false`). The indicator stays up for the whole run, not only while a call is in flight. A new run is a new
|
|
90
|
+
* question from the person, so it also allows the agent to drive again after an earlier Stop.
|
|
91
|
+
*/
|
|
92
|
+
setRunning(running: boolean): void {
|
|
93
|
+
if (running === this.runningFlag) return
|
|
94
|
+
this.runningFlag = running
|
|
95
|
+
if (running) this.killedFlag = false
|
|
96
|
+
this.emit()
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
/** The user allowed the agent to drive again. */
|
|
86
100
|
resume(): void {
|
|
87
101
|
this.killedFlag = false
|
|
@@ -165,8 +179,8 @@ export class UiDriver {
|
|
|
165
179
|
}
|
|
166
180
|
|
|
167
181
|
private emit(): void {
|
|
168
|
-
const next: DriverActivity = { busy: this.busyCount > 0, doing: this.doing, killed: this.killedFlag }
|
|
169
|
-
if (next.busy === this.current.busy && next.doing === this.current.doing && next.killed === this.current.killed) return
|
|
182
|
+
const next: DriverActivity = { busy: this.busyCount > 0, running: this.runningFlag, doing: this.doing, killed: this.killedFlag }
|
|
183
|
+
if (next.busy === this.current.busy && next.running === this.current.running && next.doing === this.current.doing && next.killed === this.current.killed) return
|
|
170
184
|
this.current = next
|
|
171
185
|
const activity = next
|
|
172
186
|
for (const listener of [...this.listeners]) listener(activity)
|
|
@@ -11,6 +11,9 @@ import { UserInputClock } from './user-input.ts'
|
|
|
11
11
|
|
|
12
12
|
export const DOOR_BASE = '/api/acryl-door'
|
|
13
13
|
|
|
14
|
+
/** The key on the page's `window` under which the door publishes `{ windowId }`: the agent panel in the same page sends it with a question, and the door drives that page for the run. */
|
|
15
|
+
export const PAGE_KEY = Symbol.for('acryl.door.page')
|
|
16
|
+
|
|
14
17
|
/** Start the door on `document`. @returns the disposer. */
|
|
15
18
|
export function startDoor(document: Document, base: string = DOOR_BASE): () => void {
|
|
16
19
|
const view = document.defaultView
|
|
@@ -18,6 +21,7 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
|
|
|
18
21
|
const clock = new UserInputClock()
|
|
19
22
|
const driver = new UiDriver({ document: () => document, msSinceUserInput: clock.msSince })
|
|
20
23
|
const windowId = typeof view.crypto?.randomUUID === 'function' ? view.crypto.randomUUID() : `w-${String(Date.now())}-${String(Math.random()).slice(2, 8)}`
|
|
24
|
+
Object.defineProperty(view, PAGE_KEY, { value: Object.freeze({ windowId }), configurable: true })
|
|
21
25
|
const focused = (): boolean => document.visibilityState === 'visible' && document.hasFocus()
|
|
22
26
|
const detachInput = clock.attach(document)
|
|
23
27
|
const removeIndicator = mountIndicator(driver, document)
|
|
@@ -30,7 +34,7 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
|
|
|
30
34
|
return 'auto'
|
|
31
35
|
}
|
|
32
36
|
})()
|
|
33
|
-
const channel = new PageChannel(driver, { windowId, focused, base, transport, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
|
|
37
|
+
const channel = new PageChannel(driver, { windowId, focused, base, transport, onRun: running => { driver.setRunning(running) }, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
|
|
34
38
|
const reportFocus = (): void => { channel.reportFocus(focused()) }
|
|
35
39
|
view.addEventListener('focus', reportFocus)
|
|
36
40
|
view.addEventListener('blur', reportFocus)
|
|
@@ -47,6 +51,7 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
|
|
|
47
51
|
view.removeEventListener('pagehide', leave)
|
|
48
52
|
view.removeEventListener('pageshow', back)
|
|
49
53
|
document.removeEventListener('visibilitychange', reportFocus)
|
|
54
|
+
Reflect.deleteProperty(view, PAGE_KEY)
|
|
50
55
|
channel.dispose()
|
|
51
56
|
removeIndicator()
|
|
52
57
|
detachInput()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* "The agent is driving" bar with its kill switch, in plain DOM (the React one in `acryl-agent-control` shows the same states). Shown
|
|
3
|
-
* a call runs and after the person stopped the agent.
|
|
2
|
+
* "The agent is driving" bar with its kill switch, in plain DOM (the React one in `acryl-agent-control` shows the same states). Shown for the whole of a run asked for from
|
|
3
|
+
* this page's panel (between the calls too), while a call runs, and after the person stopped the agent. Stop kills the page's control and asks the panel to stop the run (the
|
|
4
|
+
* `acryl:stop-run` event on the window, which the panel listens for). It is marked as driver UI (so snapshots skip it) and as off limits to the agent (so
|
|
4
5
|
* it can never press its own Stop or Allow buttons). Escape stops the agent too, while it is working.
|
|
5
6
|
*/
|
|
6
7
|
|
|
@@ -8,6 +9,9 @@ import type { DriverActivity, UiDriver } from './driver/driver.ts'
|
|
|
8
9
|
|
|
9
10
|
const LABELS: Record<string, string> = { snapshot: 'looking at the page', click: 'clicking', type: 'typing', select: 'choosing an option', press: 'pressing a key', scroll: 'scrolling', wait: 'waiting' }
|
|
10
11
|
|
|
12
|
+
/** Dispatched on the page's window by the Stop button: the agent panel in the same page cancels its run. */
|
|
13
|
+
export const STOP_RUN_EVENT = 'acryl:stop-run'
|
|
14
|
+
|
|
11
15
|
const STYLE_ID = 'acryl-door-indicator-style'
|
|
12
16
|
const STYLES = `
|
|
13
17
|
.acrylDoorBar { position: fixed; z-index: 2147483647; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; align-items: center; gap: 10px;
|
|
@@ -32,13 +36,17 @@ export function mountIndicator(driver: UiDriver, document: Document): () => void
|
|
|
32
36
|
bar.setAttribute('role', 'status')
|
|
33
37
|
bar.setAttribute('aria-live', 'polite')
|
|
34
38
|
|
|
39
|
+
const stopAgent = (): void => {
|
|
40
|
+
driver.kill()
|
|
41
|
+
document.defaultView?.dispatchEvent(new CustomEvent(STOP_RUN_EVENT))
|
|
42
|
+
}
|
|
35
43
|
const onKey = (event: KeyboardEvent): void => {
|
|
36
|
-
if (event.key === 'Escape' && event.isTrusted && driver.activity.busy
|
|
44
|
+
if (event.key === 'Escape' && event.isTrusted && (driver.activity.busy || driver.activity.running)) stopAgent()
|
|
37
45
|
}
|
|
38
46
|
document.addEventListener('keydown', onKey, true)
|
|
39
47
|
|
|
40
48
|
const render = (activity: DriverActivity): void => {
|
|
41
|
-
if (!activity.busy && !activity.killed) { bar.remove(); return }
|
|
49
|
+
if (!activity.busy && !activity.running && !activity.killed) { bar.remove(); return }
|
|
42
50
|
const button = document.createElement('button')
|
|
43
51
|
button.type = 'button'
|
|
44
52
|
const text = document.createElement('span')
|
|
@@ -53,9 +61,9 @@ export function mountIndicator(driver: UiDriver, document: Document): () => void
|
|
|
53
61
|
const dot = document.createElement('span')
|
|
54
62
|
dot.className = 'acrylDoorDot'
|
|
55
63
|
dot.setAttribute('aria-hidden', 'true')
|
|
56
|
-
text.textContent = `Agent is driving: ${LABELS[activity.doing] ?? activity.doing}`
|
|
64
|
+
text.textContent = `Agent is driving: ${activity.busy ? (LABELS[activity.doing] ?? activity.doing) : 'working on your request'}`
|
|
57
65
|
button.textContent = 'Stop'
|
|
58
|
-
button.addEventListener('click',
|
|
66
|
+
button.addEventListener('click', stopAgent)
|
|
59
67
|
bar.replaceChildren(dot, text, button)
|
|
60
68
|
}
|
|
61
69
|
if (!bar.isConnected) document.body.append(bar)
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acryl-state",
|
|
3
|
-
"description": "Observable cells and derived cells, shared state with JSON
|
|
3
|
+
"description": "Observable cells and derived cells, shared state with JSON, server-sent event and WebSocket routes on the router, and a browser module (watch) that follows keys over one socket. Vendored from ACRYL.",
|
|
4
4
|
"requires": ["acryl-router"],
|
|
5
5
|
"inject": ["router"],
|
|
6
6
|
"provides": ["state"],
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@deepseek-ai/cordis": "4.0.4"
|
|
8
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
9
|
+
"ws": "8.21.3"
|
|
9
10
|
},
|
|
10
|
-
"mount": "root.plugin(state); ctx.state.cell('todos.count', 0), then count.update((n) => n + 1)",
|
|
11
|
+
"mount": "root.plugin(state); ctx.state.cell('todos.count', 0), then count.update((n) => n + 1); a page follows it with import { watch } from '/api/state/client.js'; watch('todos.count', (n) => { ... })",
|
|
11
12
|
"omit": ["src/react.ts", "**/*.test.*", "**/*.case.ts"],
|
|
12
13
|
"entries": {
|
|
13
14
|
"": "src/index.ts",
|