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.
Files changed (47) hide show
  1. package/dist/index.d.ts +15 -1
  2. package/dist/index.js +13 -7
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/api/summary.md +1 -1
  5. package/docs/framework/guides/bring-your-own-tool.md +2 -0
  6. package/docs/framework/guides/production.md +20 -0
  7. package/docs/framework/workshop/README.md +177 -274
  8. package/docs/framework/workshop/facilitator.md +107 -84
  9. package/lib/{audit-Dw-siYTp.d.ts → audit-BFpULBCj.d.ts} +15 -3
  10. package/lib/cordis/door.d.ts +3 -1
  11. package/lib/cordis/door.js +11 -1
  12. package/lib/cordis/state.d.ts +3 -2
  13. package/lib/cordis/state.js +370 -6
  14. package/lib/db/conformance.d.ts +6 -2
  15. package/lib/db/conformance.js +14 -2
  16. package/lib/door/host.d.ts +1 -1
  17. package/lib/door/host.js +1 -1
  18. package/lib/door/page.d.ts +10 -0
  19. package/lib/door/page.js +29 -7
  20. package/lib/kv/conformance.d.ts +6 -2
  21. package/lib/kv/conformance.js +14 -2
  22. package/lib/{snapshot-text-CBi3EMnj.js → snapshot-text-BwM6T2nG.js} +37 -5
  23. package/lib/vitest-binding-CctNu3jT.d.ts +15 -0
  24. package/lib/vitest-binding-CrH7rKg2.js +17 -0
  25. package/package.json +1 -5
  26. package/plugins/acryl-door/src/host/channel.ts +43 -5
  27. package/plugins/acryl-door/src/host/door.ts +5 -0
  28. package/plugins/acryl-door/src/host/page-routes.ts +1 -1
  29. package/plugins/acryl-door/src/host/page-socket.ts +1 -1
  30. package/plugins/acryl-door/src/page/channel.ts +8 -0
  31. package/plugins/acryl-door/src/page/driver/driver.ts +17 -3
  32. package/plugins/acryl-door/src/page/index.ts +6 -1
  33. package/plugins/acryl-door/src/page/indicator.ts +14 -6
  34. package/plugins/acryl-state/plugin.json +4 -3
  35. package/plugins/acryl-state/src/client-source.ts +244 -0
  36. package/plugins/acryl-state/src/service.ts +33 -6
  37. package/plugins/acryl-state/src/socket.ts +96 -0
  38. package/plugins/agent-panel/client/main.ts +105 -55
  39. package/plugins/agent-panel/index.ts +42 -5
  40. package/plugins/desktop/main.ts +5 -1
  41. package/plugins/instance/index.ts +6 -3
  42. package/templates/agent/AGENTS.md +3 -1
  43. package/templates/agent/src/routes.ts +38 -5
  44. package/templates/agent/test/app.test.ts +38 -2
  45. package/templates/chat/src/main.ts +1 -6
  46. package/templates/nodejs/src/main.ts +1 -6
  47. 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 the Host just sent somewhere (it comes back as a new page, `prefer`), else the
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 rank = (connection) => (connection.suspect ? 0 : 4) + (connection.windowId === this.preferred ? 2 : 0) + (connection.focused ? 1 : 0);
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.9",
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 the Host just sent somewhere (it comes back as a new page, `prefer`), else the
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
- const rank = (connection: Connection): number => (connection.suspect ? 0 : 4) + (connection.windowId === this.preferred ? 2 : 0) + (connection.focused ? 1 : 0)
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 while
3
- * a call runs and after the person stopped the agent. It is marked as driver UI (so snapshots skip it) and as off limits to the agent (so
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) driver.kill()
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', () => { driver.kill() })
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 and server-sent event routes on the router. Vendored from ACRYL.",
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",