acrylblends 1.0.7 → 1.0.9
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.js +6 -1
- package/dist/index.js.map +1 -1
- package/lib/{audit-BD8_2pkh.d.ts → audit-Dw-siYTp.d.ts} +7 -1
- package/lib/cordis/door.d.ts +4 -1
- package/lib/cordis/door.js +76 -5
- package/lib/door/host.d.ts +1 -1
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +16 -0
- package/lib/door/page.js +81 -4
- package/lib/{snapshot-text-m13Pk-AI.js → snapshot-text-CBi3EMnj.js} +19 -7
- package/package.json +1 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/host/channel.ts +20 -3
- package/plugins/acryl-door/src/host/door.ts +1 -1
- package/plugins/acryl-door/src/host/page-socket.ts +78 -0
- package/plugins/acryl-door/src/index.ts +12 -1
- package/plugins/acryl-door/src/page/channel.ts +47 -5
- package/plugins/acryl-door/src/page/index.ts +9 -1
- package/plugins/acryl-door/src/page/socket-source.ts +75 -0
- package/plugins/acryl-door/src/shell/protocol.ts +3 -0
- package/plugins/agent-panel/client/main.ts +154 -32
- package/plugins/agent-panel/headless.ts +20 -0
- package/plugins/agent-panel/index.ts +193 -50
- package/plugins/desktop/door-shell.ts +6 -0
- package/plugins/desktop/main.ts +58 -3
- package/plugins/desktop/menu.ts +60 -0
- package/plugins/desktop/window.ts +62 -2
- package/templates/agent/AGENTS.md +3 -1
- package/templates/agent/src/routes.ts +11 -1
- package/templates/agent/test/app.test.ts +20 -0
|
@@ -9,6 +9,11 @@ interface PageStream {
|
|
|
9
9
|
interface ChannelOptions {
|
|
10
10
|
/** How long a page has to acknowledge a read-only call (`look`, `wait`) before the next page is tried. */
|
|
11
11
|
readonly ackMs?: number;
|
|
12
|
+
/**
|
|
13
|
+
* How long a read-only call waits for a page to register when there is none, or when every page was silent and a new one may be on its way (a page that is moving to another
|
|
14
|
+
* address registers again a moment later). 0 (the default) fails at once.
|
|
15
|
+
*/
|
|
16
|
+
readonly attachWaitMs?: number;
|
|
12
17
|
}
|
|
13
18
|
/**
|
|
14
19
|
* 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
|
|
@@ -16,6 +21,7 @@ interface ChannelOptions {
|
|
|
16
21
|
*/
|
|
17
22
|
declare class UiChannel {
|
|
18
23
|
private readonly ackMs;
|
|
24
|
+
private readonly attachWaitMs;
|
|
19
25
|
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
20
26
|
private preferred;
|
|
21
27
|
constructor(options?: ChannelOptions);
|
|
@@ -52,7 +58,7 @@ declare class UiChannel {
|
|
|
52
58
|
signal?: AbortSignal;
|
|
53
59
|
timeoutMs?: number;
|
|
54
60
|
window?: number;
|
|
55
|
-
}): Promise<UiResult>;
|
|
61
|
+
}, retried?: boolean): Promise<UiResult>;
|
|
56
62
|
private callOne;
|
|
57
63
|
/**
|
|
58
64
|
* A promise for the next time a page connects (a page that reloaded or moved opens a new stream). Ask for it BEFORE the call that makes the page leave, or a fast page is missed.
|
package/lib/cordis/door.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { f as UiActionResult, y as UiSnapshot } from "../protocol-a2aqrp2X.js";
|
|
2
|
-
import { i as UiChannel, r as PageStream, t as AuditLog } from "../audit-
|
|
2
|
+
import { i as UiChannel, r as PageStream, t as AuditLog } from "../audit-Dw-siYTp.js";
|
|
3
3
|
import { Context } from "@deepseek-ai/cordis";
|
|
4
4
|
import z from "@deepseek-ai/schemastery";
|
|
5
5
|
|
|
@@ -37,6 +37,9 @@ interface ShellWindow {
|
|
|
37
37
|
readonly focused: boolean;
|
|
38
38
|
readonly width: number;
|
|
39
39
|
readonly height: number;
|
|
40
|
+
/** Where the window's top left corner is on the screen (so two windows on top of each other can be told). */
|
|
41
|
+
readonly x?: number;
|
|
42
|
+
readonly y?: number;
|
|
40
43
|
}
|
|
41
44
|
interface ShellMenuItem {
|
|
42
45
|
/** Labels from the top of the menu, joined with `>`: `File>New Note`. */
|
package/lib/cordis/door.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { t as Acryl } from "../app-CQcGWjrk.js";
|
|
2
2
|
import { r as streamSSE } from "../streaming-BlDaph8U.js";
|
|
3
|
+
import { r as loopbackAuthority } from "../loopback-only-BbasoF7R.js";
|
|
3
4
|
import "../helpers-BEjcdbgw.js";
|
|
4
5
|
import { n as clientModules } from "../client-modules-drj4mjhj.js";
|
|
5
|
-
import { a as MAX_WAIT_MS, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES } from "../protocol-CQ_if7Dg.js";
|
|
6
|
-
import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-
|
|
6
|
+
import { a as MAX_WAIT_MS, d as parseChannelToHost, l as UiControlError, p as parseUiRequest, r as MAX_SNAPSHOT_NODES, u as WINDOW_ID } from "../protocol-CQ_if7Dg.js";
|
|
7
|
+
import { a as INVALID_JSON, c as operatorOnly, d as AuditLog, f as auditPath, i as DOOR_PREFIX, l as privateApi, o as error, p as UiChannel, r as pageChannelRoutes, s as jsonBody, t as renderSnapshot, u as readJson } from "../snapshot-text-CBi3EMnj.js";
|
|
7
8
|
import "../src-BTBm4OPD.js";
|
|
9
|
+
import "../middleware-BfG_fp8P.js";
|
|
8
10
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
9
11
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
12
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -598,7 +600,7 @@ var DoorError = class extends Error {
|
|
|
598
600
|
};
|
|
599
601
|
/** A shell answer as text an agent reads: windows and menu items one to a line. */
|
|
600
602
|
function renderShell(result) {
|
|
601
|
-
if ("windows" in result) return result.windows.length === 0 ? "no windows are open" : result.windows.map((window) => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.focused ? " focused" : ""}`).join("\n");
|
|
603
|
+
if ("windows" in result) return result.windows.length === 0 ? "no windows are open" : result.windows.map((window) => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.x === void 0 || window.y === void 0 ? "" : `@${String(window.x)},${String(window.y)}`}${window.focused ? " focused" : ""}`).join("\n");
|
|
602
604
|
if ("items" in result) return result.items.length === 0 ? "the app has no menu" : result.items.map((item) => `${item.path}${item.enabled ? "" : " (disabled)"}${item.ends === true ? " (ends the app; needs force)" : ""}`).join("\n");
|
|
603
605
|
return result.detail;
|
|
604
606
|
}
|
|
@@ -1153,6 +1155,66 @@ function operatorRoutes(prefix, secret, door, reportError) {
|
|
|
1153
1155
|
}
|
|
1154
1156
|
});
|
|
1155
1157
|
}
|
|
1158
|
+
/** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
|
|
1159
|
+
const ownOrigin = async (c, next) => {
|
|
1160
|
+
const authority = loopbackAuthority(c);
|
|
1161
|
+
const origin = c.req.header("origin");
|
|
1162
|
+
const site = c.req.header("sec-fetch-site");
|
|
1163
|
+
if (authority === void 0 || origin === void 0 || origin !== authority.origin || site !== void 0 && site !== "same-origin") return c.json(error("only this app's own page may open the channel"), 403);
|
|
1164
|
+
const windowId = c.req.query("window");
|
|
1165
|
+
if (windowId === void 0 || !WINDOW_ID.test(windowId)) return c.json(error("a window id is required"), 400);
|
|
1166
|
+
await next();
|
|
1167
|
+
};
|
|
1168
|
+
function pageSocketRoutes(channel, options) {
|
|
1169
|
+
const heartbeatMs = options.heartbeatMs ?? 15e3;
|
|
1170
|
+
return new Acryl().get(`${options.prefix}/channel/ws`, ownOrigin, options.upgrade((c) => {
|
|
1171
|
+
const windowId = c.req.query("window");
|
|
1172
|
+
const focused = c.req.query("focused") === "1";
|
|
1173
|
+
const shellQuery = Number(c.req.query("shell"));
|
|
1174
|
+
const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : void 0;
|
|
1175
|
+
let detach;
|
|
1176
|
+
let beat;
|
|
1177
|
+
return {
|
|
1178
|
+
onOpen(_event, ws) {
|
|
1179
|
+
const write = (frame) => {
|
|
1180
|
+
ws.send(JSON.stringify(frame));
|
|
1181
|
+
};
|
|
1182
|
+
detach = channel.attach(windowId, focused, {
|
|
1183
|
+
send: (data) => {
|
|
1184
|
+
write({
|
|
1185
|
+
e: "call",
|
|
1186
|
+
d: data
|
|
1187
|
+
});
|
|
1188
|
+
},
|
|
1189
|
+
close: () => {
|
|
1190
|
+
ws.close(1e3, "replaced");
|
|
1191
|
+
}
|
|
1192
|
+
}, shellWindow);
|
|
1193
|
+
write({ e: "ready" });
|
|
1194
|
+
beat = setInterval(() => {
|
|
1195
|
+
try {
|
|
1196
|
+
write({ e: "ping" });
|
|
1197
|
+
} catch {}
|
|
1198
|
+
}, heartbeatMs);
|
|
1199
|
+
},
|
|
1200
|
+
onMessage(event) {
|
|
1201
|
+
if (typeof event.data !== "string") return;
|
|
1202
|
+
let message;
|
|
1203
|
+
try {
|
|
1204
|
+
message = JSON.parse(event.data);
|
|
1205
|
+
} catch {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
const parsed = parseChannelToHost(message);
|
|
1209
|
+
if (parsed !== null) channel.receive(windowId, parsed);
|
|
1210
|
+
},
|
|
1211
|
+
onClose() {
|
|
1212
|
+
if (beat !== void 0) clearInterval(beat);
|
|
1213
|
+
detach?.();
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
}, { textOnly: true }));
|
|
1217
|
+
}
|
|
1156
1218
|
/** The oldest Node the client runs on (the framework's own floor). */
|
|
1157
1219
|
const MIN_NODE = [22, 19];
|
|
1158
1220
|
/**
|
|
@@ -1318,7 +1380,7 @@ function apply(ctx, rawConfig = {}) {
|
|
|
1318
1380
|
ctx.logger?.error?.(`acryl-door: failed to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1319
1381
|
};
|
|
1320
1382
|
const secret = newSecret();
|
|
1321
|
-
const channel = new UiChannel();
|
|
1383
|
+
const channel = new UiChannel({ attachWaitMs: 2e3 });
|
|
1322
1384
|
const shell = new ShellChannel();
|
|
1323
1385
|
const door = new Door({
|
|
1324
1386
|
channel,
|
|
@@ -1363,7 +1425,16 @@ function apply(ctx, rawConfig = {}) {
|
|
|
1363
1425
|
const router = ctx.router;
|
|
1364
1426
|
if (typeof router.wrap === "function") router.wrap(addScript);
|
|
1365
1427
|
else ctx.router.route("/", new Acryl().use("*", addScript));
|
|
1366
|
-
|
|
1428
|
+
let upgrade;
|
|
1429
|
+
try {
|
|
1430
|
+
upgrade = ctx.nodeServer?.upgrades?.();
|
|
1431
|
+
} catch (cause) {
|
|
1432
|
+
reportError("open the page channel over a WebSocket (the pages use the event stream)", cause);
|
|
1433
|
+
}
|
|
1434
|
+
ctx.router.route("/", privateApi(DOOR_PREFIX, pageModule, pageChannelRoutes(channel, { prefix: DOOR_PREFIX }), ...upgrade === void 0 ? [] : [pageSocketRoutes(channel, {
|
|
1435
|
+
prefix: DOOR_PREFIX,
|
|
1436
|
+
upgrade
|
|
1437
|
+
})], shellRoutes(DOOR_PREFIX, secret, shell), operatorRoutes(DOOR_PREFIX, secret, door, reportError), mcpRoutes(DOOR_PREFIX, secret, door, reportError)));
|
|
1367
1438
|
ctx.inject(["tools"], (inner) => {
|
|
1368
1439
|
inner.effect(() => registerChatTools(inner.get("tools"), door), "acryl-door: the verbs in the tools registry");
|
|
1369
1440
|
});
|
package/lib/door/host.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as Acryl } from "../app-D8riatvv.js";
|
|
2
2
|
import { n as AuditEntry, y as UiSnapshot } from "../protocol-a2aqrp2X.js";
|
|
3
|
-
import { i as UiChannel, n as auditPath, r as PageStream, t as AuditLog } from "../audit-
|
|
3
|
+
import { i as UiChannel, n as auditPath, r as PageStream, t as AuditLog } from "../audit-Dw-siYTp.js";
|
|
4
4
|
|
|
5
5
|
//#region ../../plugins/acryl-door/src/host/page-routes.d.ts
|
|
6
6
|
/** How often the Host writes a `ping`; the page gives up on a stream that is silent for three of these. */
|
package/lib/door/host.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-
|
|
1
|
+
import { d as AuditLog, f as auditPath, n as HEARTBEAT_MS, p as UiChannel, r as pageChannelRoutes, t as renderSnapshot } from "../snapshot-text-CBi3EMnj.js";
|
|
2
2
|
export { AuditLog, HEARTBEAT_MS, UiChannel, auditPath, pageChannelRoutes, renderSnapshot };
|
package/lib/door/page.d.ts
CHANGED
|
@@ -55,6 +55,12 @@ declare class UiDriver {
|
|
|
55
55
|
private emit;
|
|
56
56
|
}
|
|
57
57
|
//#endregion
|
|
58
|
+
//#region ../../plugins/acryl-door/src/page/socket-source.d.ts
|
|
59
|
+
interface SocketSource extends ChannelSource {
|
|
60
|
+
/** Send one message up; false when the socket is not open (the caller posts it instead). */
|
|
61
|
+
send(text: string): boolean;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
58
64
|
//#region ../../plugins/acryl-door/src/page/channel.d.ts
|
|
59
65
|
/** The slice of an `EventSource` the channel uses, so tests can supply their own. */
|
|
60
66
|
interface ChannelSource {
|
|
@@ -73,6 +79,11 @@ interface PageChannelOptions {
|
|
|
73
79
|
/** Where the door's routes are, e.g. `/api/acryl-door`. */
|
|
74
80
|
readonly base: string;
|
|
75
81
|
readonly createSource?: (url: string) => ChannelSource;
|
|
82
|
+
/** Opens the WebSocket form of the channel (`<base>/channel/ws`); the browser's own by default. Pass `transport: 'stream'` to keep to the event stream. */
|
|
83
|
+
readonly createSocket?: (url: string) => SocketSource;
|
|
84
|
+
readonly transport?: 'auto' | 'stream';
|
|
85
|
+
/** Where the page says it fell back to the event stream (the console by default). */
|
|
86
|
+
readonly warn?: (message: string) => void;
|
|
76
87
|
readonly post?: (url: string, body: string) => Promise<unknown>;
|
|
77
88
|
readonly silenceMs?: number;
|
|
78
89
|
readonly now?: () => number;
|
|
@@ -82,6 +93,9 @@ declare class PageChannel {
|
|
|
82
93
|
private reconnectTimer;
|
|
83
94
|
private watchdog;
|
|
84
95
|
private backoff;
|
|
96
|
+
/** The WebSocket form is tried first; once it fails to open (a proxy that cannot upgrade, no `ws` on the server) the page keeps to the event stream. */
|
|
97
|
+
private socketAllowed;
|
|
98
|
+
private socketReady;
|
|
85
99
|
private lastHeard;
|
|
86
100
|
private disposed;
|
|
87
101
|
private readonly now;
|
|
@@ -89,6 +103,8 @@ declare class PageChannel {
|
|
|
89
103
|
private readonly options;
|
|
90
104
|
constructor(driver: UiDriver, options: PageChannelOptions);
|
|
91
105
|
connect(): void;
|
|
106
|
+
/** The WebSocket did not open: use the event stream from now on, and say why once. */
|
|
107
|
+
private fallBack;
|
|
92
108
|
/** Tell the Host whether this window is the one in front. */
|
|
93
109
|
reportFocus(focused: boolean): void;
|
|
94
110
|
/**
|
package/lib/door/page.js
CHANGED
|
@@ -1,4 +1,54 @@
|
|
|
1
1
|
import { a as MAX_WAIT_MS, f as parseChannelToPage, l as UiControlError, o as MUTATING_OPS, r as MAX_SNAPSHOT_NODES } from "../protocol-CQ_if7Dg.js";
|
|
2
|
+
//#region ../../plugins/acryl-door/src/page/socket-source.ts
|
|
3
|
+
function socketSource(socket) {
|
|
4
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
5
|
+
let closed = false;
|
|
6
|
+
const source = {
|
|
7
|
+
onerror: null,
|
|
8
|
+
get readyState() {
|
|
9
|
+
return closed || socket.readyState >= 2 ? 2 : socket.readyState;
|
|
10
|
+
},
|
|
11
|
+
addEventListener(type, listener) {
|
|
12
|
+
listeners.set(type, [...listeners.get(type) ?? [], listener]);
|
|
13
|
+
},
|
|
14
|
+
send(text) {
|
|
15
|
+
if (socket.readyState !== 1) return false;
|
|
16
|
+
try {
|
|
17
|
+
socket.send(text);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
close() {
|
|
24
|
+
closed = true;
|
|
25
|
+
socket.onmessage = null;
|
|
26
|
+
socket.onerror = null;
|
|
27
|
+
socket.onclose = null;
|
|
28
|
+
try {
|
|
29
|
+
socket.close(1e3, "page left");
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
socket.onmessage = (event) => {
|
|
34
|
+
if (typeof event.data !== "string") return;
|
|
35
|
+
let frame;
|
|
36
|
+
try {
|
|
37
|
+
frame = JSON.parse(event.data);
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (typeof frame.e !== "string") return;
|
|
42
|
+
for (const listener of listeners.get(frame.e) ?? []) listener({ data: typeof frame.d === "string" ? frame.d : "" });
|
|
43
|
+
};
|
|
44
|
+
const lost = (event) => {
|
|
45
|
+
if (!closed) source.onerror?.(event);
|
|
46
|
+
};
|
|
47
|
+
socket.onerror = lost;
|
|
48
|
+
socket.onclose = lost;
|
|
49
|
+
return source;
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
2
52
|
//#region ../../plugins/acryl-door/src/page/channel.ts
|
|
3
53
|
/**
|
|
4
54
|
* The page's end of the door's channel.
|
|
@@ -18,6 +68,9 @@ var PageChannel = class {
|
|
|
18
68
|
reconnectTimer;
|
|
19
69
|
watchdog;
|
|
20
70
|
backoff = BACKOFF_START_MS;
|
|
71
|
+
/** The WebSocket form is tried first; once it fails to open (a proxy that cannot upgrade, no `ws` on the server) the page keeps to the event stream. */
|
|
72
|
+
socketAllowed;
|
|
73
|
+
socketReady = false;
|
|
21
74
|
lastHeard = 0;
|
|
22
75
|
disposed = false;
|
|
23
76
|
now;
|
|
@@ -27,32 +80,49 @@ var PageChannel = class {
|
|
|
27
80
|
this.driver = driver;
|
|
28
81
|
this.options = options;
|
|
29
82
|
this.now = options.now ?? (() => Date.now());
|
|
83
|
+
this.socketAllowed = options.transport !== "stream" && (options.createSocket !== void 0 || options.createSource === void 0 && typeof WebSocket !== "undefined");
|
|
30
84
|
}
|
|
31
85
|
connect() {
|
|
32
86
|
if (this.disposed || this.source !== void 0) return;
|
|
33
|
-
const create = this.options.createSource ?? ((url) => new EventSource(url));
|
|
34
87
|
const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? "1" : "0"}${this.options.shellWindow === void 0 ? "" : `&shell=${String(this.options.shellWindow)}`}`;
|
|
35
|
-
const
|
|
88
|
+
const viaSocket = this.socketAllowed;
|
|
89
|
+
this.socketReady = false;
|
|
90
|
+
const source = viaSocket ? (this.options.createSocket ?? ((url) => socketSource(new WebSocket(url))))(`${socketBase(this.options.base)}/channel/ws?${query}`) : (this.options.createSource ?? ((url) => new EventSource(url)))(`${this.options.base}/channel?${query}`);
|
|
36
91
|
this.source = source;
|
|
37
92
|
this.lastHeard = this.now();
|
|
38
93
|
const heard = () => {
|
|
39
94
|
this.lastHeard = this.now();
|
|
40
95
|
this.backoff = BACKOFF_START_MS;
|
|
41
96
|
};
|
|
42
|
-
source.addEventListener("ready",
|
|
97
|
+
source.addEventListener("ready", () => {
|
|
98
|
+
this.socketReady = true;
|
|
99
|
+
heard();
|
|
100
|
+
});
|
|
43
101
|
source.addEventListener("ping", heard);
|
|
44
102
|
source.addEventListener("call", (event) => {
|
|
45
103
|
heard();
|
|
46
104
|
if (typeof event.data === "string") this.answer(event.data);
|
|
47
105
|
});
|
|
48
106
|
source.onerror = () => {
|
|
49
|
-
if (source.readyState
|
|
107
|
+
if (source.readyState !== CLOSED) return;
|
|
108
|
+
if (viaSocket && !this.socketReady) this.fallBack(source);
|
|
109
|
+
else this.restart(source);
|
|
50
110
|
};
|
|
51
111
|
const silence = this.options.silenceMs ?? 45e3;
|
|
52
112
|
this.watchdog = setInterval(() => {
|
|
53
113
|
if (this.now() - this.lastHeard > silence) this.restart(source);
|
|
54
114
|
}, Math.max(50, Math.floor(silence / 3)));
|
|
55
115
|
}
|
|
116
|
+
/** The WebSocket did not open: use the event stream from now on, and say why once. */
|
|
117
|
+
fallBack(source) {
|
|
118
|
+
if (this.source !== source) return;
|
|
119
|
+
this.socketAllowed = false;
|
|
120
|
+
(this.options.warn ?? ((message) => {
|
|
121
|
+
console.warn(message);
|
|
122
|
+
}))("acryl-door: the page channel could not open a WebSocket (the server has no upgrade route, or a proxy refused it); using server-sent events, which count against the browser's six connections per host");
|
|
123
|
+
this.stop();
|
|
124
|
+
this.connect();
|
|
125
|
+
}
|
|
56
126
|
/** Tell the Host whether this window is the one in front. */
|
|
57
127
|
reportFocus(focused) {
|
|
58
128
|
this.send({
|
|
@@ -130,6 +200,7 @@ var PageChannel = class {
|
|
|
130
200
|
});
|
|
131
201
|
}
|
|
132
202
|
send(message) {
|
|
203
|
+
if (this.source?.send?.(JSON.stringify(message)) === true) return;
|
|
133
204
|
(this.options.post ?? ((url, body) => fetch(url, {
|
|
134
205
|
method: "POST",
|
|
135
206
|
headers: { "content-type": "application/json" },
|
|
@@ -140,6 +211,12 @@ var PageChannel = class {
|
|
|
140
211
|
})).catch(() => {});
|
|
141
212
|
}
|
|
142
213
|
};
|
|
214
|
+
/** The WebSocket address of the door's routes: this page's own origin, `ws:` or `wss:`, then the base path. */
|
|
215
|
+
function socketBase(base) {
|
|
216
|
+
const here = globalThis.location;
|
|
217
|
+
if (here === void 0) return base;
|
|
218
|
+
return `${here.protocol === "https:" ? "wss" : "ws"}://${here.host}${base}`;
|
|
219
|
+
}
|
|
143
220
|
//#endregion
|
|
144
221
|
//#region ../../plugins/acryl-door/src/page/driver/sensitivity.ts
|
|
145
222
|
/**
|
|
@@ -34,10 +34,12 @@ const DEFAULT_ACK_MS = 3e3;
|
|
|
34
34
|
*/
|
|
35
35
|
var UiChannel = class {
|
|
36
36
|
ackMs;
|
|
37
|
+
attachWaitMs;
|
|
37
38
|
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
38
39
|
preferred;
|
|
39
40
|
constructor(options = {}) {
|
|
40
41
|
this.ackMs = options.ackMs ?? DEFAULT_ACK_MS;
|
|
42
|
+
this.attachWaitMs = options.attachWaitMs ?? 0;
|
|
41
43
|
}
|
|
42
44
|
connections = /* @__PURE__ */ new Map();
|
|
43
45
|
pending = /* @__PURE__ */ new Map();
|
|
@@ -114,8 +116,10 @@ var UiChannel = class {
|
|
|
114
116
|
* until it speaks; a call that changes the page is never sent to a second page, because the first might still run it.
|
|
115
117
|
* @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
|
|
116
118
|
*/
|
|
117
|
-
async call(request, options = {}) {
|
|
119
|
+
async call(request, options = {}, retried = false) {
|
|
118
120
|
if (this.closed) throw new UiControlError("unloaded", "the door was unloaded");
|
|
121
|
+
const readOnlyCall = request.op === "snapshot" || request.op === "wait";
|
|
122
|
+
if (readOnlyCall && this.attachWaitMs > 0 && this.connections.size === 0) await this.expectNewPage(this.attachWaitMs);
|
|
119
123
|
const ordered = this.ordered();
|
|
120
124
|
const candidates = options.window === void 0 ? ordered : ordered.filter((connection) => connection.shellWindow === options.window);
|
|
121
125
|
if (candidates.length === 0 && options.window !== void 0) {
|
|
@@ -124,13 +128,21 @@ var UiChannel = class {
|
|
|
124
128
|
}
|
|
125
129
|
if (candidates.length === 0) throw new UiControlError("no-window", "no page of the app is open to control");
|
|
126
130
|
if (options.signal?.aborted === true) throw new UiControlError("aborted", "the call was cancelled");
|
|
127
|
-
const readOnly =
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
const readOnly = readOnlyCall;
|
|
132
|
+
const tried = /* @__PURE__ */ new Set();
|
|
133
|
+
for (const target of candidates) {
|
|
134
|
+
tried.add(target.windowId);
|
|
135
|
+
try {
|
|
136
|
+
return await this.callOne(target, request, options, readOnly);
|
|
137
|
+
} catch (cause) {
|
|
138
|
+
if (!(cause instanceof Silent)) throw cause;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (readOnly && !retried && this.attachWaitMs > 0 && options.window === void 0) {
|
|
142
|
+
const fresh = await this.expectNewPage(this.attachWaitMs);
|
|
143
|
+
if (fresh !== void 0 && !tried.has(fresh)) return await this.call(request, options, true);
|
|
132
144
|
}
|
|
133
|
-
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
|
|
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`);
|
|
134
146
|
}
|
|
135
147
|
callOne(target, request, options, failOver) {
|
|
136
148
|
const signal = options.signal;
|
package/package.json
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
"@deepseek-ai/cordis": "4.0.4",
|
|
9
9
|
"@deepseek-ai/schemastery": "3.18.4"
|
|
10
10
|
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"ws": "8.21.3"
|
|
13
|
+
},
|
|
11
14
|
"mount": "mount it after acryl-node-server and extensions in development builds: root.plugin(door); add <script type=\"module\" src=\"/api/acryl-door/page.js\"></script> to the pages; the command is .acryl/bin/acryl",
|
|
12
15
|
"gitignore": [".acryl/"],
|
|
13
16
|
"omit": ["**/*.test.*", "**/*.spec.*", "**/*.case.ts"],
|
|
@@ -57,6 +57,11 @@ const DEFAULT_ACK_MS = 3000
|
|
|
57
57
|
export interface ChannelOptions {
|
|
58
58
|
/** How long a page has to acknowledge a read-only call (`look`, `wait`) before the next page is tried. */
|
|
59
59
|
readonly ackMs?: number
|
|
60
|
+
/**
|
|
61
|
+
* How long a read-only call waits for a page to register when there is none, or when every page was silent and a new one may be on its way (a page that is moving to another
|
|
62
|
+
* address registers again a moment later). 0 (the default) fails at once.
|
|
63
|
+
*/
|
|
64
|
+
readonly attachWaitMs?: number
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
/**
|
|
@@ -65,11 +70,13 @@ export interface ChannelOptions {
|
|
|
65
70
|
*/
|
|
66
71
|
export class UiChannel {
|
|
67
72
|
private readonly ackMs: number
|
|
73
|
+
private readonly attachWaitMs: number
|
|
68
74
|
/** The page the Host sent to a new place and that came back: it keeps being driven until the person focuses another page. */
|
|
69
75
|
private preferred: string | undefined
|
|
70
76
|
|
|
71
77
|
constructor(options: ChannelOptions = {}) {
|
|
72
78
|
this.ackMs = options.ackMs ?? DEFAULT_ACK_MS
|
|
79
|
+
this.attachWaitMs = options.attachWaitMs ?? 0
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
private readonly connections = new Map<string, Connection>()
|
|
@@ -143,8 +150,11 @@ export class UiChannel {
|
|
|
143
150
|
* until it speaks; a call that changes the page is never sent to a second page, because the first might still run it.
|
|
144
151
|
* @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
|
|
145
152
|
*/
|
|
146
|
-
async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number } = {}): Promise<UiResult> {
|
|
153
|
+
async call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number; window?: number } = {}, retried = false): Promise<UiResult> {
|
|
147
154
|
if (this.closed) throw new UiControlError('unloaded', 'the door was unloaded')
|
|
155
|
+
const readOnlyCall = request.op === 'snapshot' || request.op === 'wait'
|
|
156
|
+
// 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
|
+
if (readOnlyCall && this.attachWaitMs > 0 && this.connections.size === 0) await this.expectNewPage(this.attachWaitMs)
|
|
148
158
|
const ordered = this.ordered()
|
|
149
159
|
// `window` is the number `window list` prints: that desktop window's page, not the one used last.
|
|
150
160
|
const candidates = options.window === undefined ? ordered : ordered.filter(connection => connection.shellWindow === options.window)
|
|
@@ -154,15 +164,22 @@ export class UiChannel {
|
|
|
154
164
|
}
|
|
155
165
|
if (candidates.length === 0) throw new UiControlError('no-window', 'no page of the app is open to control')
|
|
156
166
|
if (options.signal?.aborted === true) throw new UiControlError('aborted', 'the call was cancelled')
|
|
157
|
-
const readOnly =
|
|
167
|
+
const readOnly = readOnlyCall
|
|
168
|
+
const tried = new Set<string>()
|
|
158
169
|
for (const target of candidates) {
|
|
170
|
+
tried.add(target.windowId)
|
|
159
171
|
try {
|
|
160
172
|
return await this.callOne(target, request, options, readOnly)
|
|
161
173
|
} catch (cause) {
|
|
162
174
|
if (!(cause instanceof Silent)) throw cause
|
|
163
175
|
}
|
|
164
176
|
}
|
|
165
|
-
|
|
177
|
+
// Every page was silent. A page that registered meanwhile (the one that was moving) gets the call once.
|
|
178
|
+
if (readOnly && !retried && this.attachWaitMs > 0 && options.window === undefined) {
|
|
179
|
+
const fresh = await this.expectNewPage(this.attachWaitMs)
|
|
180
|
+
if (fresh !== undefined && !tried.has(fresh)) return await this.call(request, options, true)
|
|
181
|
+
}
|
|
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`)
|
|
166
183
|
}
|
|
167
184
|
|
|
168
185
|
private callOne(target: Connection, request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number }, failOver: boolean): Promise<UiResult> {
|
|
@@ -71,7 +71,7 @@ function renderShell(result: ShellResult): string {
|
|
|
71
71
|
if ('windows' in result) {
|
|
72
72
|
return result.windows.length === 0
|
|
73
73
|
? 'no windows are open'
|
|
74
|
-
: result.windows.map(window => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.focused ? ' focused' : ''}`).join('\n')
|
|
74
|
+
: result.windows.map(window => `${String(window.id)} ${JSON.stringify(window.title)} ${window.path} ${String(window.width)}x${String(window.height)}${window.x === undefined || window.y === undefined ? '' : `@${String(window.x)},${String(window.y)}`}${window.focused ? ' focused' : ''}`).join('\n')
|
|
75
75
|
}
|
|
76
76
|
if ('items' in result) return result.items.length === 0 ? 'the app has no menu' : result.items.map(item => `${item.path}${item.enabled ? '' : ' (disabled)'}${item.ends === true ? ' (ends the app; needs force)' : ''}`).join('\n')
|
|
77
77
|
return result.detail
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page channel over a WebSocket. An event stream (`page-routes.ts`) holds one of the browser's six connections per host for as long as the page lives; two windows of an app that
|
|
3
|
+
* also stream their own state, with the panel following a run, use them all, and then no request of either window is sent (the door's answers, Stop). A WebSocket is not one of
|
|
4
|
+
* those six: it opens at once however many streams are open, and the page's answers travel on it too, so nothing is queued behind a stream.
|
|
5
|
+
*
|
|
6
|
+
* `GET <prefix>/channel/ws?window=<id>&focused=0|1[&shell=<n>]` upgrades. The messages are the same JSON as the stream's: down, `{ "e": "call", "d": "<the call>" }` and
|
|
7
|
+
* `{ "e": "ready" }` / `{ "e": "ping" }`; up, one {@link ChannelToHost} message per frame. A WebSocket has no CORS, so the `Origin` header is the boundary: it must be present and be
|
|
8
|
+
* exactly this app's own origin, or the upgrade is refused (403) before any handshake. A page on another port, a web site, and a program that sends no `Origin` are all refused.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Acryl, type MiddlewareHandler } from 'acryl-router'
|
|
12
|
+
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
13
|
+
import { loopbackAuthority } from 'acryl-router/middleware'
|
|
14
|
+
import { parseChannelToHost, WINDOW_ID } from '../page/protocol.ts'
|
|
15
|
+
import type { UiChannel } from './channel.ts'
|
|
16
|
+
import { error } from './http.ts'
|
|
17
|
+
|
|
18
|
+
/** How often the Host writes a `ping`; the page gives up on a socket that is silent for three of these. */
|
|
19
|
+
export const SOCKET_HEARTBEAT_MS = 15_000
|
|
20
|
+
|
|
21
|
+
export interface PageSocketOptions {
|
|
22
|
+
readonly prefix: string
|
|
23
|
+
readonly upgrade: UpgradeWebSocket
|
|
24
|
+
readonly heartbeatMs?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The upgrade request comes from this app's own page: a loopback peer on this port, and an `Origin` that is exactly this app's origin. Nothing else is read as proof. */
|
|
28
|
+
export const ownOrigin: MiddlewareHandler = async (c, next) => {
|
|
29
|
+
const authority = loopbackAuthority(c)
|
|
30
|
+
const origin = c.req.header('origin')
|
|
31
|
+
const site = c.req.header('sec-fetch-site')
|
|
32
|
+
if (authority === undefined || origin === undefined || origin !== authority.origin || (site !== undefined && site !== 'same-origin')) {
|
|
33
|
+
return c.json(error('only this app\'s own page may open the channel'), 403)
|
|
34
|
+
}
|
|
35
|
+
const windowId = c.req.query('window')
|
|
36
|
+
if (windowId === undefined || !WINDOW_ID.test(windowId)) return c.json(error('a window id is required'), 400)
|
|
37
|
+
await next()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function pageSocketRoutes(channel: UiChannel, options: PageSocketOptions): Acryl {
|
|
41
|
+
const heartbeatMs = options.heartbeatMs ?? SOCKET_HEARTBEAT_MS
|
|
42
|
+
return new Acryl().get(
|
|
43
|
+
`${options.prefix}/channel/ws`,
|
|
44
|
+
ownOrigin,
|
|
45
|
+
options.upgrade((c) => {
|
|
46
|
+
const windowId = c.req.query('window') as string
|
|
47
|
+
const focused = c.req.query('focused') === '1'
|
|
48
|
+
const shellQuery = Number(c.req.query('shell'))
|
|
49
|
+
const shellWindow = Number.isSafeInteger(shellQuery) && shellQuery > 0 ? shellQuery : undefined
|
|
50
|
+
let detach: (() => void) | undefined
|
|
51
|
+
let beat: NodeJS.Timeout | undefined
|
|
52
|
+
return {
|
|
53
|
+
onOpen(_event, ws) {
|
|
54
|
+
const write = (frame: { e: string; d?: string }): void => {
|
|
55
|
+
ws.send(JSON.stringify(frame))
|
|
56
|
+
}
|
|
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)
|
|
59
|
+
write({ e: 'ready' })
|
|
60
|
+
beat = setInterval(() => {
|
|
61
|
+
try { write({ e: 'ping' }) } catch { /* the close event follows */ }
|
|
62
|
+
}, heartbeatMs)
|
|
63
|
+
},
|
|
64
|
+
onMessage(event) {
|
|
65
|
+
if (typeof event.data !== 'string') return
|
|
66
|
+
let message: unknown
|
|
67
|
+
try { message = JSON.parse(event.data) } catch { return }
|
|
68
|
+
const parsed = parseChannelToHost(message)
|
|
69
|
+
if (parsed !== null) channel.receive(windowId, parsed)
|
|
70
|
+
},
|
|
71
|
+
onClose() {
|
|
72
|
+
if (beat !== undefined) clearInterval(beat)
|
|
73
|
+
detach?.()
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}, { textOnly: true }),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -13,6 +13,7 @@ import { join } from 'node:path'
|
|
|
13
13
|
import type { Context } from '@deepseek-ai/cordis'
|
|
14
14
|
import z from '@deepseek-ai/schemastery'
|
|
15
15
|
import { Acryl } from 'acryl-router'
|
|
16
|
+
import type { UpgradeWebSocket } from 'acryl-router/helpers'
|
|
16
17
|
import { clientModules } from 'acryl-node-server/client-modules'
|
|
17
18
|
import { auditPath, AuditLog } from './host/audit.ts'
|
|
18
19
|
import { UiChannel } from './host/channel.ts'
|
|
@@ -23,6 +24,7 @@ import { DOOR_PREFIX, privateApi } from './host/http.ts'
|
|
|
23
24
|
import { mcpRoutes } from './host/mcp-routes.ts'
|
|
24
25
|
import { operatorRoutes } from './host/operator-routes.ts'
|
|
25
26
|
import { pageChannelRoutes } from './host/page-routes.ts'
|
|
27
|
+
import { pageSocketRoutes } from './host/page-socket.ts'
|
|
26
28
|
import { ShellChannel, shellRoutes } from './host/shell.ts'
|
|
27
29
|
import { realNode, writeShims, type ShimTarget } from './host/shim.ts'
|
|
28
30
|
|
|
@@ -113,7 +115,8 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
113
115
|
ctx.logger?.error?.(`acryl-door: failed to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
114
116
|
}
|
|
115
117
|
const secret = newSecret()
|
|
116
|
-
|
|
118
|
+
// A look right after a navigate or reload waits up to two seconds for the page that is moving to register again, and a read-only call that no page acknowledged gives a page that just registered one more try.
|
|
119
|
+
const channel = new UiChannel({ attachWaitMs: 2000 })
|
|
117
120
|
const shell = new ShellChannel()
|
|
118
121
|
const audit = new AuditLog(auditPath(stateDir(app.root)))
|
|
119
122
|
const door = new Door({
|
|
@@ -154,10 +157,18 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
154
157
|
// An app whose router copy is older than this plugin has no `wrap`: the middleware then covers the routes mounted after the door, as the agent panel's does.
|
|
155
158
|
if (typeof router.wrap === 'function') router.wrap(addScript)
|
|
156
159
|
else ctx.router.route('/', new Acryl().use('*', addScript as never))
|
|
160
|
+
// The page channel also runs over a WebSocket, which is not one of the browser's six connections per host. Without the `ws` package or an adapter that upgrades, the pages use the stream.
|
|
161
|
+
let upgrade: UpgradeWebSocket | undefined
|
|
162
|
+
try {
|
|
163
|
+
upgrade = (ctx as unknown as { nodeServer?: { upgrades?: () => UpgradeWebSocket } }).nodeServer?.upgrades?.()
|
|
164
|
+
} catch (cause) {
|
|
165
|
+
reportError('open the page channel over a WebSocket (the pages use the event stream)', cause)
|
|
166
|
+
}
|
|
157
167
|
ctx.router.route('/', privateApi(
|
|
158
168
|
DOOR_PREFIX,
|
|
159
169
|
pageModule,
|
|
160
170
|
pageChannelRoutes(channel, { prefix: DOOR_PREFIX }),
|
|
171
|
+
...(upgrade === undefined ? [] : [pageSocketRoutes(channel, { prefix: DOOR_PREFIX, upgrade })]),
|
|
161
172
|
shellRoutes(DOOR_PREFIX, secret, shell),
|
|
162
173
|
operatorRoutes(DOOR_PREFIX, secret, door, reportError),
|
|
163
174
|
mcpRoutes(DOOR_PREFIX, secret, door, reportError),
|