acrylblends 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/index.d.ts +47 -2
- package/dist/index.js +2017 -32
- package/dist/index.js.map +1 -1
- package/docs/framework/agents/index.md +1 -1
- package/docs/framework/getting-started/desktop.md +23 -4
- package/docs/framework/guides/create-acrylblends.md +5 -3
- package/docs/framework/workshop/README.md +139 -153
- package/docs/framework/workshop/facilitator.md +69 -101
- package/legacy/README.md +9 -0
- package/legacy/agent/157ce3db1112/src/app.ts +98 -0
- package/legacy/agent/157ce3db1112/src/routes.ts +63 -0
- package/legacy/agent/24c681a00fca/src/app.ts +160 -0
- package/legacy/agent/24c681a00fca/src/routes.ts +83 -0
- package/legacy/agent/2e790da977b2/src/app.ts +98 -0
- package/legacy/agent/2e790da977b2/src/routes.ts +63 -0
- package/legacy/agent/6307045545dc/src/app.ts +168 -0
- package/legacy/agent/6307045545dc/src/routes.ts +83 -0
- package/legacy/agent/6a5fb9c15c53/src/app.ts +158 -0
- package/legacy/agent/6a5fb9c15c53/src/routes.ts +83 -0
- package/legacy/agent/71fb54a5a942/src/app.ts +111 -0
- package/legacy/agent/71fb54a5a942/src/routes.ts +63 -0
- package/legacy/agent/74ece148269c/src/app.ts +168 -0
- package/legacy/agent/74ece148269c/src/routes.ts +93 -0
- package/legacy/agent/855359304707/src/app.ts +168 -0
- package/legacy/agent/855359304707/src/routes.ts +126 -0
- package/legacy/agent/b5ef3c786ae4/src/app.ts +127 -0
- package/legacy/agent/b5ef3c786ae4/src/routes.ts +76 -0
- package/legacy/agent/c2f30958043b/src/app.ts +98 -0
- package/legacy/agent/c2f30958043b/src/routes.ts +53 -0
- package/legacy/blank/5a91c558af22/src/routes.ts +18 -0
- package/legacy/blank/9c650cf52bf3/src/routes.ts +14 -0
- package/legacy/blank/aea589283445/src/routes.ts +16 -0
- package/legacy/blank/b2cbb759c889/src/routes.ts +16 -0
- package/legacy/blank/d6d371c9a65e/src/routes.ts +18 -0
- package/legacy/chat/7076cc821e2c/src/app.ts +72 -0
- package/legacy/chat/7076cc821e2c/src/routes.ts +13 -0
- package/legacy/chat/7dbe6d5170df/src/app.ts +107 -0
- package/legacy/chat/7dbe6d5170df/src/routes.ts +19 -0
- package/legacy/chat/a9a9ab44c58e/src/app.ts +107 -0
- package/legacy/chat/a9a9ab44c58e/src/routes.ts +15 -0
- package/legacy/chat/f20ad9a51b06/src/app.ts +106 -0
- package/legacy/chat/f20ad9a51b06/src/routes.ts +15 -0
- package/legacy/layouts.json +524 -0
- package/legacy/nodejs/1d477f37aaf4/src/app.ts +92 -0
- package/legacy/nodejs/1d477f37aaf4/src/routes.ts +40 -0
- package/legacy/nodejs/b1bd9462496c/src/app.ts +61 -0
- package/legacy/nodejs/b1bd9462496c/src/routes.ts +37 -0
- package/legacy/nodejs/d5bab75076ef/src/app.ts +95 -0
- package/legacy/nodejs/d5bab75076ef/src/routes.ts +40 -0
- package/legacy/notes/4edc36949c15/src/app.ts +107 -0
- package/legacy/notes/4edc36949c15/src/routes.ts +14 -0
- package/legacy/notes/6cab2d09747c/src/app.ts +72 -0
- package/legacy/notes/6cab2d09747c/src/routes.ts +12 -0
- package/legacy/notes/7ca2dfed623a/src/app.ts +106 -0
- package/legacy/notes/7ca2dfed623a/src/routes.ts +14 -0
- package/legacy/todo/02e613b3625c/src/app.ts +71 -0
- package/legacy/todo/02e613b3625c/src/todo.ts +123 -0
- package/legacy/todo/45694a8d9e15/src/app.ts +71 -0
- package/legacy/todo/45694a8d9e15/src/todo.ts +96 -0
- package/legacy/todo/5923d3a6099f/src/app.ts +71 -0
- package/legacy/todo/5923d3a6099f/src/todo.ts +125 -0
- package/legacy/todo/849a18d66dc1/src/app.ts +91 -0
- package/legacy/todo/849a18d66dc1/src/todo.ts +125 -0
- package/legacy/todo/8e64211312f3/src/app.ts +71 -0
- package/legacy/todo/8e64211312f3/src/todo.ts +125 -0
- package/legacy/todo/b79078a87565/src/app.ts +71 -0
- package/legacy/todo/b79078a87565/src/todo.ts +125 -0
- package/lib/{audit-BFpULBCj.d.ts → audit-Bok_UNCn.d.ts} +26 -3
- package/lib/{client-modules-drj4mjhj.js → client-modules-Bkdr83jO.js} +1 -1
- package/lib/cordis/door.d.ts +87 -9
- package/lib/cordis/door.js +324 -60
- package/lib/cordis/inspect.js +1 -1
- package/lib/cordis/node-server.d.ts +101 -3
- package/lib/cordis/node-server.js +3 -3
- package/lib/cordis/router-http.d.ts +7 -1
- package/lib/cordis/router-http.js +44 -3
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +1 -1
- package/lib/cordis/state.js +1 -1
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +10 -1
- package/lib/door/page.js +46 -7
- package/lib/door/protocol.d.ts +1 -1
- package/lib/drivers.d.ts +10 -1
- package/lib/{index-DMOtA-NJ.d.ts → index-DGq7H0lb.d.ts} +11 -1
- package/lib/node-server.d.ts +7 -1
- package/lib/node-server.js +2 -2
- package/lib/{protocol-a2aqrp2X.d.ts → protocol-BeIYCgTo.d.ts} +4 -0
- package/lib/{snapshot-text-BwM6T2nG.js → snapshot-text-CXTnjxGx.js} +87 -7
- package/lib/src-CtkIX_Gg.js +427 -0
- package/lib/{src-BTBm4OPD.js → src-iBJ2ysV9.js} +77 -14
- package/lib/{test-websocket-BLeEvybj.js → test-websocket-CQOSvBth.js} +17 -7
- package/lib/{test-websocket-DyeS50Tr.d.ts → test-websocket-GUTAkFQT.d.ts} +3 -1
- package/package.json +2 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/cli/acryl.ts +63 -6
- package/plugins/acryl-door/src/host/channel.ts +86 -9
- package/plugins/acryl-door/src/host/chat-tools.ts +8 -6
- package/plugins/acryl-door/src/host/door-file.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +128 -24
- package/plugins/acryl-door/src/host/operator-routes.ts +21 -1
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/page-socket.ts +18 -8
- package/plugins/acryl-door/src/host/verbs.ts +14 -1
- package/plugins/acryl-door/src/index.ts +168 -30
- package/plugins/acryl-door/src/page/base.ts +32 -0
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/driver/accessibility.ts +11 -0
- package/plugins/acryl-door/src/page/driver/actions.ts +4 -0
- package/plugins/acryl-door/src/page/driver/driver.ts +32 -4
- package/plugins/acryl-door/src/page/driver/snapshot.ts +5 -2
- package/plugins/acryl-door/src/page/index.ts +41 -2
- package/plugins/acryl-door/src/page/outline.ts +56 -0
- package/plugins/acryl-door/src/page/protocol.ts +4 -0
- package/plugins/acryl-door/src/page/snapshot-text.ts +2 -1
- package/plugins/acryl-node-server/src/adapter/listener.ts +4 -0
- package/plugins/acryl-node-server/src/adapter/raw.ts +18 -0
- package/plugins/acryl-node-server/src/adapter/websocket.ts +15 -9
- package/plugins/acryl-node-server/src/client-modules.ts +7 -1
- package/plugins/acryl-node-server/src/index.ts +304 -16
- package/plugins/acryl-node-server/src/upgrade-hub.ts +2 -2
- package/plugins/acryl-page/plugin.json +12 -0
- package/plugins/acryl-page/src/index.ts +163 -0
- package/plugins/acryl-router/src/service.ts +77 -14
- package/plugins/acryl-router-http/src/index.ts +15 -1
- package/plugins/acryl-router-http/src/serve.ts +31 -0
- package/plugins/acryl-web-server/plugin.json +14 -0
- package/plugins/acryl-web-server/src/index.ts +164 -0
- package/plugins/agent/agent.ts +38 -7
- package/plugins/agent/index.ts +1 -1
- package/plugins/agent/model.ts +10 -2
- package/plugins/agent/tools.ts +9 -3
- package/plugins/agent-panel/client/describe.ts +57 -0
- package/plugins/agent-panel/client/driver-pick.ts +22 -0
- package/plugins/agent-panel/client/frame.ts +51 -0
- package/plugins/agent-panel/client/main.ts +238 -26
- package/plugins/agent-panel/drivers.ts +7 -2
- package/plugins/agent-panel/headless.ts +5 -2
- package/plugins/agent-panel/index.ts +146 -57
- package/plugins/agent-panel/mount.ts +27 -0
- package/plugins/agent-panel/visitors.ts +231 -0
- package/plugins/builder/index.ts +13 -35
- package/plugins/builder/package.ts +186 -0
- package/plugins/builder/plugin.json +3 -1
- package/plugins/desktop/frame.ts +231 -0
- package/plugins/desktop/launch.ts +44 -9
- package/plugins/desktop/main.ts +100 -32
- package/plugins/desktop/window.ts +21 -4
- package/plugins/driver-pty/client/terminal.ts +4 -1
- package/plugins/driver-pty/index.ts +38 -11
- package/plugins/driver-pty/spawn.ts +38 -0
- package/plugins/driver-pty/terminal.ts +2 -0
- package/plugins/extensions/index.ts +47 -18
- package/plugins/extensions/plugin-store.ts +27 -0
- package/plugins/extensions/plugin.json +1 -1
- package/plugins/extensions/resolve.ts +13 -0
- package/plugins/extensions/stage.ts +15 -5
- package/plugins/feedback/client.d.ts +22 -0
- package/plugins/feedback/client.js +471 -0
- package/plugins/feedback/index.ts +143 -0
- package/plugins/feedback/plugin.json +11 -0
- package/plugins/feedback/store.ts +307 -0
- package/plugins/home/index.ts +122 -0
- package/plugins/home/plugin.json +11 -0
- package/plugins/instance/index.ts +6 -0
- package/plugins/manifest/app-plugins.ts +86 -0
- package/plugins/manifest/index.ts +12 -2
- package/plugins/manifest/plugin.json +1 -1
- package/plugins/manifest/snapshot.ts +275 -0
- package/plugins/openai/index.ts +64 -5
- package/plugins/openai/prompt.ts +23 -0
- package/plugins/plugin-share/README.md +51 -0
- package/plugins/plugin-share/index.ts +467 -0
- package/plugins/plugin-share/plugin.json +15 -0
- package/plugins/plugin-share/publish.ts +65 -0
- package/plugins/plugin-share/pull-check.ts +129 -0
- package/plugins/plugin-share/pulled-records.ts +70 -0
- package/shared/AGENTS.core.md +36 -16
- package/shared/skills/acryl-add-ui/SKILL.md +19 -0
- package/shared/skills/acryl-app/SKILL.md +13 -0
- package/shared/skills/acryl-build-plugin/SKILL.md +22 -0
- package/shared/skills/acryl-change-plugin/SKILL.md +14 -0
- package/shared/skills/acryl-diagnose-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-fix-plugin/SKILL.md +13 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +12 -0
- package/shared/skills/acryl-share-plugin/SKILL.md +16 -0
- package/shared/skills/cordis-plugin/SKILL.md +26 -0
- package/templates/agent/.gitattributes +2 -0
- package/templates/agent/AGENTS.md +1 -1
- package/templates/agent/blend.yaml +6 -0
- package/templates/agent/gitignore +1 -0
- package/templates/agent/package.json +1 -0
- package/templates/agent/src/app.ts +154 -33
- package/templates/agent/src/frame.ts +17 -0
- package/templates/agent/src/main.ts +5 -3
- package/templates/agent/src/routes.ts +38 -19
- package/templates/agent/template.json +3 -0
- package/templates/agent/test/app.test.ts +45 -3
- package/templates/blank/.gitattributes +2 -0
- package/templates/blank/AGENTS.md +3 -2
- package/templates/blank/blend.yaml +11 -0
- package/templates/blank/gitignore +1 -0
- package/templates/blank/package.json +1 -0
- package/templates/blank/src/app.ts +100 -29
- package/templates/blank/src/door.ts +7 -0
- package/templates/blank/src/first-page.ts +5 -0
- package/templates/blank/src/frame.ts +17 -0
- package/templates/blank/src/routes.ts +65 -8
- package/templates/blank/template.json +14 -1
- package/templates/blank/test/app.test.ts +103 -16
- package/templates/chat/.gitattributes +2 -0
- package/templates/chat/blend.yaml +2 -1
- package/templates/chat/gitignore +1 -0
- package/templates/chat/package.json +1 -0
- package/templates/chat/src/app.ts +72 -22
- package/templates/chat/src/chat-app.ts +11 -2
- package/templates/chat/src/client/chat.tsx +4 -2
- package/templates/chat/src/frame.ts +17 -0
- package/templates/chat/src/page.tsx +6 -4
- package/templates/chat/src/routes.ts +14 -4
- package/templates/chat/src/runtime.ts +9 -3
- package/templates/chat/template.json +3 -1
- package/templates/chat/test/chat.test.ts +46 -1
- package/templates/chat/test/server.test.ts +17 -3
- package/templates/nodejs/.gitattributes +2 -0
- package/templates/nodejs/blend.yaml +2 -1
- package/templates/nodejs/gitignore +1 -0
- package/templates/nodejs/package.json +1 -0
- package/templates/nodejs/src/app.ts +76 -21
- package/templates/nodejs/src/frame.ts +17 -0
- package/templates/nodejs/src/page.tsx +3 -3
- package/templates/nodejs/src/routes.ts +32 -13
- package/templates/nodejs/template.json +3 -1
- package/templates/nodejs/test/app.test.ts +38 -3
- package/templates/nodejs/test/server.test.ts +17 -3
- package/templates/notes/.gitattributes +2 -0
- package/templates/notes/blend.yaml +2 -1
- package/templates/notes/gitignore +1 -0
- package/templates/notes/package.json +1 -0
- package/templates/notes/src/app.ts +72 -22
- package/templates/notes/src/frame.ts +17 -0
- package/templates/notes/src/notes-app.ts +24 -17
- package/templates/notes/src/routes.ts +11 -4
- package/templates/notes/src/runtime.ts +9 -3
- package/templates/notes/src/views.tsx +26 -17
- package/templates/notes/template.json +3 -1
- package/templates/notes/test/notes.test.ts +39 -1
- package/templates/notes/test/server.test.ts +17 -3
- package/templates/todo/.gitattributes +2 -0
- package/templates/todo/blend.yaml +3 -0
- package/templates/todo/gitignore +1 -0
- package/templates/todo/package.json +1 -0
- package/templates/todo/src/app.ts +81 -27
- package/templates/todo/src/door.ts +7 -0
- package/templates/todo/src/frame.ts +17 -0
- package/templates/todo/src/page.ts +13 -6
- package/templates/todo/src/todo.ts +18 -6
- package/templates/todo/template.json +11 -1
- package/templates/todo/test/todo.test.ts +67 -9
- package/lib/src-LX0E4ec4.js +0 -117
- package/shared/SKILL.md +0 -14
package/plugins/desktop/main.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The desktop shell: `npm run desktop`
|
|
3
|
-
*
|
|
2
|
+
* The desktop shell: `npm run desktop` opens the app in a window. A frame app (`scripts/ide-host.mjs`) gets the whole ACRYL frame: the window attaches to the
|
|
3
|
+
* frame host that runs for this folder, or starts it as `npm run dev` does (`frame.ts`, docs/plans/ide-host-inversion.md section 27). An app made before the frame
|
|
4
|
+
* starts its own server in this Electron main process and opens one window on its address. Nothing in the app imports electron; every Electron call of the app
|
|
5
|
+
* is in this file and `window.ts`, so a different shell is a change here only.
|
|
4
6
|
*
|
|
5
7
|
* Order matters: the user-data folder is set before anything else (and before the single-instance lock), so one app never shares window state, logs or the lock with
|
|
6
8
|
* another app or with the real user's data.
|
|
@@ -20,10 +22,20 @@ import {
|
|
|
20
22
|
startDoorShell,
|
|
21
23
|
type WindowPort,
|
|
22
24
|
} from './door-shell.ts'
|
|
25
|
+
import {
|
|
26
|
+
type FrameHost,
|
|
27
|
+
frameHome,
|
|
28
|
+
isFrameApp,
|
|
29
|
+
navigation,
|
|
30
|
+
openFrameSettings,
|
|
31
|
+
runningFrame,
|
|
32
|
+
startFrameHost,
|
|
33
|
+
withoutToken,
|
|
34
|
+
} from './frame.ts'
|
|
23
35
|
import { hasSettings, settingsPlacement } from './menu.ts'
|
|
24
36
|
import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
|
|
25
37
|
import { resolveDesktopUserDataOverride } from './user-data.ts'
|
|
26
|
-
import { cascade, windowOptions } from './window.ts'
|
|
38
|
+
import { cascade, windowOptions, windowSize } from './window.ts'
|
|
27
39
|
|
|
28
40
|
const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
|
|
29
41
|
const chosen = selectInstance({ projectRoot, env: process.env })
|
|
@@ -47,6 +59,7 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
47
59
|
app.quit()
|
|
48
60
|
} else {
|
|
49
61
|
let running: App | undefined
|
|
62
|
+
let host: FrameHost | undefined
|
|
50
63
|
let window: BrowserWindow | undefined
|
|
51
64
|
let stopDoorShell: (() => void) | undefined
|
|
52
65
|
|
|
@@ -54,6 +67,8 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
54
67
|
async () => {
|
|
55
68
|
stopDoorShell?.()
|
|
56
69
|
await running?.stop()
|
|
70
|
+
// The frame host this window started (never one it attached to) goes with the window, with every terminal it runs.
|
|
71
|
+
await host?.stop()
|
|
57
72
|
},
|
|
58
73
|
(code) => {
|
|
59
74
|
app.exit(code)
|
|
@@ -77,9 +92,33 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
77
92
|
// The hook above is registered before the app's modules are loaded, so the app may have .tsx pages: they are loaded here, not as a static import.
|
|
78
93
|
// A dev server is already running from this folder (`npm run dev`): the door allows one process per folder, so the window opens on that server instead of starting a second one,
|
|
79
94
|
// and closing the window leaves it running.
|
|
80
|
-
const
|
|
95
|
+
const frame = isFrameApp(projectRoot)
|
|
96
|
+
const attached = frame ? undefined : await runningApp(projectRoot)
|
|
81
97
|
let url: string
|
|
82
|
-
if (
|
|
98
|
+
if (frame) {
|
|
99
|
+
// The frame that already runs for this folder (`npm run dev`, or another window's host), else a host of this window's own.
|
|
100
|
+
const runningAddress = await runningFrame(frameHome(projectRoot, process.env))
|
|
101
|
+
if (runningAddress !== undefined) {
|
|
102
|
+
console.log(
|
|
103
|
+
`desktop: attached to the frame already running from this folder at ${new URL(runningAddress).origin} (it keeps running when the window closes)`,
|
|
104
|
+
)
|
|
105
|
+
url = runningAddress
|
|
106
|
+
} else {
|
|
107
|
+
host = startFrameHost(projectRoot, process.env, (text) => {
|
|
108
|
+
process.stdout.write(text)
|
|
109
|
+
})
|
|
110
|
+
try {
|
|
111
|
+
url = await host.ready
|
|
112
|
+
} catch (cause) {
|
|
113
|
+
console.error(`desktop: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
114
|
+
await shutdown.request(1)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
console.log(
|
|
118
|
+
`desktop: the frame host serves ${new URL(url).origin}; closing the window stops it`,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
} else if (attached !== undefined) {
|
|
83
122
|
console.log(
|
|
84
123
|
`desktop: attached to the app already running from this folder at ${attached.url} (its server keeps running when the window closes)`,
|
|
85
124
|
)
|
|
@@ -90,14 +129,23 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
90
129
|
url = running.url
|
|
91
130
|
}
|
|
92
131
|
const origin = new URL(url).origin
|
|
93
|
-
/**
|
|
132
|
+
/** Where a window opens: the frame's address (with its token) for a frame app, else a path of the app (`acrylblends.desktopPath`, else the root). */
|
|
133
|
+
const addressOf = (path: string | undefined): string =>
|
|
134
|
+
frame && path === undefined ? url : new URL(path ?? startPath(), url).href
|
|
135
|
+
/** One window on an address of the app. Navigation follows `navigation` in frame.ts. The user agent names the window to the door (`AcrylWindow/<id>`), so an agent can drive it with `--window <id>`. */
|
|
94
136
|
// Where each window was opened: `window list` shows it until the page reports its own address (Electron gives none while the first load has not started).
|
|
95
137
|
const openedOn = new Map<number, string>()
|
|
96
|
-
const createWindow = (path: string): BrowserWindow => {
|
|
138
|
+
const createWindow = (path: string | undefined): BrowserWindow => {
|
|
97
139
|
// A window opened while another is open sits a step down and to the right of it, not exactly on top.
|
|
98
140
|
const beside =
|
|
99
141
|
BrowserWindow.getFocusedWindow() ??
|
|
100
142
|
BrowserWindow.getAllWindows().find((other) => !other.isDestroyed())
|
|
143
|
+
const work = (
|
|
144
|
+
beside === undefined
|
|
145
|
+
? screen.getPrimaryDisplay()
|
|
146
|
+
: screen.getDisplayMatching(beside.getBounds())
|
|
147
|
+
).workArea
|
|
148
|
+
const size = windowSize(work)
|
|
101
149
|
const where = (() => {
|
|
102
150
|
if (beside === undefined) return {}
|
|
103
151
|
const bounds = beside.getBounds()
|
|
@@ -106,44 +154,49 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
106
154
|
.filter((other) => !other.isDestroyed())
|
|
107
155
|
.map((other) => other.getPosition())
|
|
108
156
|
.map(([x = 0, y = 0]) => ({ x, y }))
|
|
109
|
-
return cascade(
|
|
110
|
-
bounds,
|
|
111
|
-
screen.getDisplayMatching(bounds).workArea,
|
|
112
|
-
{ width: 1100, height: 760 },
|
|
113
|
-
taken,
|
|
114
|
-
)
|
|
157
|
+
return cascade(bounds, work, size, taken)
|
|
115
158
|
})()
|
|
116
|
-
const created = new BrowserWindow(windowOptions({ title: chosen.name, ...where }))
|
|
117
|
-
|
|
159
|
+
const created = new BrowserWindow(windowOptions({ title: chosen.name, ...size, ...where }))
|
|
160
|
+
const address = addressOf(path)
|
|
161
|
+
openedOn.set(created.id, new URL(address).pathname)
|
|
118
162
|
created.webContents.setUserAgent(
|
|
119
163
|
`${created.webContents.getUserAgent()} AcrylWindow/${String(created.id)}`,
|
|
120
164
|
)
|
|
121
165
|
created.once('ready-to-show', () => created.show())
|
|
122
|
-
|
|
123
|
-
|
|
166
|
+
// A new window of the page never opens inside the app: an http(s) or mailto address goes to the system browser, anything else nowhere.
|
|
167
|
+
created.webContents.setWindowOpenHandler(({ url: target }) => {
|
|
168
|
+
if (navigation(target, '') === 'external') void shell.openExternal(target)
|
|
124
169
|
return { action: 'deny' }
|
|
125
170
|
})
|
|
126
|
-
|
|
127
|
-
|
|
171
|
+
// Only the main frame is checked, so the frame's tiles (the app's page in an iframe) navigate freely; the window itself stays on the app's origin.
|
|
172
|
+
created.webContents.on('will-frame-navigate', (event) => {
|
|
173
|
+
const decision = navigation(event.url, origin, event.isMainFrame)
|
|
174
|
+
if (decision === 'stay') return
|
|
128
175
|
event.preventDefault()
|
|
129
|
-
void shell.openExternal(url)
|
|
176
|
+
if (decision === 'external') void shell.openExternal(event.url)
|
|
177
|
+
})
|
|
178
|
+
created.webContents.on('will-redirect', (event, target, _inPlace, isMainFrame) => {
|
|
179
|
+
if (navigation(target, origin, isMainFrame) !== 'stay') event.preventDefault()
|
|
130
180
|
})
|
|
131
|
-
created.loadURL(
|
|
181
|
+
created.loadURL(address).catch((cause: unknown) => {
|
|
132
182
|
console.warn(
|
|
133
|
-
`desktop: the window could not load ${
|
|
183
|
+
`desktop: the window could not load ${new URL(address).pathname}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
134
184
|
)
|
|
135
185
|
})
|
|
136
186
|
return created
|
|
137
187
|
}
|
|
138
|
-
window = createWindow(startPath())
|
|
188
|
+
window = createWindow(frame ? undefined : startPath())
|
|
139
189
|
// The door (development) reaches the windows and the menu through this port: `acryl window list`, `acryl menu click`.
|
|
140
190
|
const live = (): BrowserWindow[] =>
|
|
141
191
|
BrowserWindow.getAllWindows().filter((candidate) => !candidate.isDestroyed())
|
|
142
192
|
const port = (candidate: BrowserWindow): WindowPort => ({
|
|
143
193
|
id: candidate.id,
|
|
144
194
|
getTitle: () => candidate.getTitle(),
|
|
195
|
+
// Without the frame's launch token: the door prints this address and its audit keeps it.
|
|
145
196
|
getURL: () =>
|
|
146
|
-
|
|
197
|
+
withoutToken(
|
|
198
|
+
candidate.webContents.getURL() || new URL(openedOn.get(candidate.id) ?? '/', url).href,
|
|
199
|
+
),
|
|
147
200
|
isFocused: () => candidate.isFocused(),
|
|
148
201
|
isMinimized: () => candidate.isMinimized(),
|
|
149
202
|
getSize: () => candidate.getSize(),
|
|
@@ -169,17 +222,20 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
169
222
|
})
|
|
170
223
|
const desktop: DesktopPort = {
|
|
171
224
|
windows: () => live().map(port),
|
|
172
|
-
open: (path) => port(createWindow(path ?? startPath())),
|
|
225
|
+
open: (path) => port(createWindow(path ?? (frame ? undefined : startPath()))),
|
|
173
226
|
menu: () => (Menu.getApplicationMenu()?.items ?? []).map(menuItem),
|
|
174
227
|
}
|
|
175
228
|
// An app that serves a /settings page gets a Settings item in the menu (Electron's own menu otherwise): it opens that page, in the window that already shows it when there is one.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
229
|
+
// A frame window's item opens the frame's own Settings (the dialog the left panel's button opens), in the window that has focus.
|
|
230
|
+
const settingsServed = frame
|
|
231
|
+
? true
|
|
232
|
+
: await fetch(new URL('/settings', url), {
|
|
233
|
+
headers: { accept: 'text/html' },
|
|
234
|
+
signal: AbortSignal.timeout(2000),
|
|
235
|
+
}).then(
|
|
236
|
+
(answer) => answer.ok,
|
|
237
|
+
() => false,
|
|
238
|
+
)
|
|
183
239
|
const menu = Menu.getApplicationMenu()
|
|
184
240
|
const placement =
|
|
185
241
|
menu === null || !settingsServed || hasSettings(menu.items)
|
|
@@ -188,6 +244,18 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
188
244
|
if (menu !== null && placement !== undefined) {
|
|
189
245
|
const target = menu.items[placement.menu]?.submenu
|
|
190
246
|
const openSettings = (): void => {
|
|
247
|
+
if (frame) {
|
|
248
|
+
const target = BrowserWindow.getFocusedWindow() ?? live()[0]
|
|
249
|
+
if (target === undefined) return
|
|
250
|
+
if (target.isMinimized()) target.restore()
|
|
251
|
+
target.focus()
|
|
252
|
+
target.webContents.executeJavaScript(openFrameSettings).catch((cause: unknown) => {
|
|
253
|
+
console.warn(
|
|
254
|
+
`desktop: Settings could not be opened in the frame: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
255
|
+
)
|
|
256
|
+
})
|
|
257
|
+
return
|
|
258
|
+
}
|
|
191
259
|
const shown = live().find(
|
|
192
260
|
(candidate) =>
|
|
193
261
|
new URL(candidate.webContents.getURL() || 'about:blank', url).pathname === '/settings',
|
|
@@ -19,6 +19,23 @@ export interface Rect {
|
|
|
19
19
|
/** How far each new window sits from the one it was opened from. */
|
|
20
20
|
export const CASCADE_STEP = 32
|
|
21
21
|
|
|
22
|
+
/** A window's size when nothing asks for another, and the smallest it may be. */
|
|
23
|
+
export const WINDOW_SIZE = { width: 1100, height: 760 } as const
|
|
24
|
+
export const WINDOW_MIN_SIZE = { width: 480, height: 360 } as const
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The size of a new window on a work area: the usual size, made smaller on a screen where it would not leave room for a few cascade steps, never below the
|
|
28
|
+
* smallest size. macOS moves a window that runs past the screen's edge back onto it, so on a small screen (CI's 1024x768) a window of the usual size that
|
|
29
|
+
* `cascade` placed a step from another lands exactly on it; a window of this size fits at its place.
|
|
30
|
+
*/
|
|
31
|
+
export function windowSize(work: Rect): { width: number; height: number } {
|
|
32
|
+
const room = CASCADE_STEP * 3
|
|
33
|
+
return {
|
|
34
|
+
width: Math.max(WINDOW_MIN_SIZE.width, Math.min(WINDOW_SIZE.width, work.width - room)),
|
|
35
|
+
height: Math.max(WINDOW_MIN_SIZE.height, Math.min(WINDOW_SIZE.height, work.height - room)),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
/**
|
|
23
40
|
* Where a new window goes so that it does not cover another one exactly. The first free place of: a step down and to the right of `from`, and again, while the window still fits on the
|
|
24
41
|
* screen; then the same steps from the top left of the work area (a row of windows cascades, then starts again); and, on a screen no bigger than the window, the steps from `from` even
|
|
@@ -73,10 +90,10 @@ export interface WindowOptions {
|
|
|
73
90
|
export function windowOptions(spec: WindowSpec): WindowOptions {
|
|
74
91
|
return {
|
|
75
92
|
title: spec.title,
|
|
76
|
-
width: spec.width ??
|
|
77
|
-
height: spec.height ??
|
|
78
|
-
minWidth:
|
|
79
|
-
minHeight:
|
|
93
|
+
width: spec.width ?? WINDOW_SIZE.width,
|
|
94
|
+
height: spec.height ?? WINDOW_SIZE.height,
|
|
95
|
+
minWidth: WINDOW_MIN_SIZE.width,
|
|
96
|
+
minHeight: WINDOW_MIN_SIZE.height,
|
|
80
97
|
...(spec.x === undefined || spec.y === undefined ? {} : { x: spec.x, y: spec.y }),
|
|
81
98
|
show: false,
|
|
82
99
|
webPreferences: {
|
|
@@ -4,7 +4,7 @@ import { FitAddon } from './vendor/addon-fit.mjs'
|
|
|
4
4
|
import { Terminal } from './vendor/xterm.mjs'
|
|
5
5
|
import css from './vendor/xterm-css.mjs'
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
let PREFIX = '/api/driver-pty'
|
|
8
8
|
const REMEMBERED = 'driver-pty-session'
|
|
9
9
|
|
|
10
10
|
export interface TerminalView {
|
|
@@ -21,6 +21,8 @@ interface Options {
|
|
|
21
21
|
token: string
|
|
22
22
|
/** The terminal driver's name (`pty:claude`). */
|
|
23
23
|
driver: string
|
|
24
|
+
/** The URL prefix the driver's endpoints live under (`/api/driver-pty`, or the app's mount followed by it). */
|
|
25
|
+
prefix: string
|
|
24
26
|
/** Called with a line of status for the panel to show (`The terminal ended (exit 0).`). */
|
|
25
27
|
status(text: string): void
|
|
26
28
|
/** A plugin was installed or removed while the terminal was open. */
|
|
@@ -57,6 +59,7 @@ async function* frames(response: Response): AsyncGenerator<Frame> {
|
|
|
57
59
|
|
|
58
60
|
export async function openTerminal(options: Options): Promise<TerminalView> {
|
|
59
61
|
const { container, token, driver, status, onChange } = options
|
|
62
|
+
PREFIX = options.prefix
|
|
60
63
|
const headers = { 'content-type': 'application/json', 'x-agent-panel-token': token }
|
|
61
64
|
const call = (path: string, body: unknown = {}): Promise<Response> =>
|
|
62
65
|
fetch(`${PREFIX}${path}`, { method: 'POST', headers, body: JSON.stringify(body) })
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* - The drivers are `terminal` entries of the panel's `drivers` service (`pty:claude`, ...). A panel without this plugin lists none; this plugin without a panel is pending.
|
|
6
6
|
* - Transport is server-sent events down and POST up (no WebSocket): `POST /api/driver-pty/sessions`, `GET /api/driver-pty/<id>/output` (events with ids; a reconnect replays
|
|
7
7
|
* from `Last-Event-ID`, from a ring buffer of at least 64 KB), `POST .../input`, `.../resize`, `.../stop`. Same origin check and token as the panel, development only.
|
|
8
|
+
* - The routes live under the app's mount, like the panel's: `/api/driver-pty` on the app's own server, `<mount>/api/driver-pty` when a host serves the app at a prefix
|
|
9
|
+
* (the frame's `/app`). The row's `mount` config names it, and each driver carries its view (`view: { module, prefix }`) so the panel calls the mounted paths.
|
|
8
10
|
* - Every process is acquired inside one `ctx.effect`; its disposer ends the process (group), closes the streams and writes the audit line. Unloading or reloading the
|
|
9
11
|
* plugin with a session open leaves no process behind.
|
|
10
12
|
*/
|
|
@@ -16,8 +18,9 @@ import { streamSSE } from '../acryl-router/src/helpers/index.ts'
|
|
|
16
18
|
import { Acryl } from '../acryl-router/src/index.ts'
|
|
17
19
|
import type { TerminalDriver, TerminalSession } from '../agent-panel/drivers.ts'
|
|
18
20
|
import { onPath } from '../agent-panel/headless.ts'
|
|
21
|
+
import { mountedBase, mountedPrefix } from '../agent-panel/mount.ts'
|
|
19
22
|
import type {} from '../agent-panel/registry.ts'
|
|
20
|
-
import { loadNodePty, type PtySpawn } from './spawn.ts'
|
|
23
|
+
import { loadNodePty, type PtySpawn, resolveProgram } from './spawn.ts'
|
|
21
24
|
import { terminalDriver } from './terminal.ts'
|
|
22
25
|
|
|
23
26
|
export const name = 'driver-pty'
|
|
@@ -26,7 +29,7 @@ export const inject = ['drivers', 'router', 'appInstance']
|
|
|
26
29
|
export type { PtyProcess, PtySpawn } from './spawn.ts'
|
|
27
30
|
export { terminalDriver } from './terminal.ts'
|
|
28
31
|
|
|
29
|
-
const
|
|
32
|
+
const DRIVER_PATH = '/api/driver-pty'
|
|
30
33
|
const CLIENT_DIR = join(import.meta.dirname, 'client')
|
|
31
34
|
/** The least output a reconnecting page can be given again. */
|
|
32
35
|
const SCROLLBACK = 64 * 1024
|
|
@@ -36,6 +39,11 @@ export interface DriverPtyConfig {
|
|
|
36
39
|
programs?: Record<string, string[]>
|
|
37
40
|
/** The PTY implementation; default node-pty. Tests give a fake. */
|
|
38
41
|
spawn?: PtySpawn
|
|
42
|
+
/**
|
|
43
|
+
* The URL prefix the app is mounted at: empty on the app's own server, `/app` when the ACRYL frame hosts it (the same `mount` the panel takes). The
|
|
44
|
+
* terminal's routes and its browser view live under it, so the terminal belongs to the app and is served wherever the app is.
|
|
45
|
+
*/
|
|
46
|
+
mount?: string
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
interface Chunk {
|
|
@@ -61,6 +69,9 @@ interface Live {
|
|
|
61
69
|
|
|
62
70
|
export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise<void> {
|
|
63
71
|
if (process.env.NODE_ENV === 'production' || process.env.ACRYL_DOOR === 'off') return
|
|
72
|
+
const PREFIX = mountedPrefix(config.mount, DRIVER_PATH, 'driver-pty')
|
|
73
|
+
// The routes mount at the app's own mount and keep the fixed driver path inside (the frame's router prefixes the mount, not the routes).
|
|
74
|
+
const MOUNT = mountedBase(config.mount, 'driver-pty')
|
|
64
75
|
const root = ctx.appInstance.root
|
|
65
76
|
let spawn = config.spawn
|
|
66
77
|
if (!spawn) {
|
|
@@ -80,7 +91,15 @@ export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise
|
|
|
80
91
|
(['claude', 'codex', 'pi'] as const).filter(onPath).map((p) => [p, [] as string[]]),
|
|
81
92
|
)
|
|
82
93
|
for (const [program, args] of Object.entries(programs))
|
|
83
|
-
ctx.effect(() =>
|
|
94
|
+
ctx.effect(() =>
|
|
95
|
+
ctx.drivers.register(
|
|
96
|
+
// The name stays the program the app configured; the PTY runs its full path (Windows does not search PATH itself).
|
|
97
|
+
terminalDriver(`pty:${program}`, resolveProgram(program), args, spawn, {
|
|
98
|
+
module: `${PREFIX}/client/terminal.ts`,
|
|
99
|
+
prefix: PREFIX,
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
84
103
|
|
|
85
104
|
const auditFile = join(root, '.acryl', 'audit', 'driver-pty.jsonl')
|
|
86
105
|
const audit = (entry: Record<string, unknown>): void => {
|
|
@@ -162,8 +181,8 @@ export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise
|
|
|
162
181
|
ctx.effect(() => off)
|
|
163
182
|
|
|
164
183
|
const app = new Acryl()
|
|
165
|
-
.route(`${
|
|
166
|
-
.post(`${
|
|
184
|
+
.route(`${DRIVER_PATH}/client`, clientModules({ root: CLIENT_DIR, mount: `${PREFIX}/client` }))
|
|
185
|
+
.post(`${DRIVER_PATH}/sessions`, async (c) => {
|
|
167
186
|
if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
|
|
168
187
|
const body = (await c.req.json().catch(() => ({}))) as {
|
|
169
188
|
driver?: unknown
|
|
@@ -177,10 +196,18 @@ export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise
|
|
|
177
196
|
return c.json({ error: 'a terminal is already open; stop it first' }, 409)
|
|
178
197
|
const num = (value: unknown, fallback: number) =>
|
|
179
198
|
typeof value === 'number' && value >= 1 && value <= 1000 ? Math.floor(value) : fallback
|
|
180
|
-
|
|
199
|
+
let session: Live
|
|
200
|
+
try {
|
|
201
|
+
session = startSession(driver, num(body.cols, 80), num(body.rows, 24))
|
|
202
|
+
} catch (error) {
|
|
203
|
+
// The cause (a native module that cannot spawn, a program that is gone) goes to the app's log; the page gets a plain answer, as everywhere else.
|
|
204
|
+
console.error('driver-pty: the terminal could not start:', error)
|
|
205
|
+
return c.json({ error: 'the terminal could not start; the app log has the reason' }, 500)
|
|
206
|
+
}
|
|
207
|
+
live = session
|
|
181
208
|
return c.json({ id: live.id })
|
|
182
209
|
})
|
|
183
|
-
.get(`${
|
|
210
|
+
.get(`${DRIVER_PATH}/:id/output`, (c) => {
|
|
184
211
|
if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
|
|
185
212
|
const current = find(c.req.param('id'))
|
|
186
213
|
if (!current) return c.json({ error: 'no such terminal' }, 404)
|
|
@@ -216,7 +243,7 @@ export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise
|
|
|
216
243
|
})
|
|
217
244
|
})
|
|
218
245
|
})
|
|
219
|
-
.post(`${
|
|
246
|
+
.post(`${DRIVER_PATH}/:id/input`, async (c) => {
|
|
220
247
|
if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
|
|
221
248
|
const current = find(c.req.param('id'))
|
|
222
249
|
if (!current || current.exit) return c.json({ error: 'no such terminal' }, 404)
|
|
@@ -226,7 +253,7 @@ export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise
|
|
|
226
253
|
current.session.write(data)
|
|
227
254
|
return c.json({ ok: true })
|
|
228
255
|
})
|
|
229
|
-
.post(`${
|
|
256
|
+
.post(`${DRIVER_PATH}/:id/resize`, async (c) => {
|
|
230
257
|
if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
|
|
231
258
|
const current = find(c.req.param('id'))
|
|
232
259
|
if (!current || current.exit) return c.json({ error: 'no such terminal' }, 404)
|
|
@@ -239,12 +266,12 @@ export async function apply(ctx: Context, config: DriverPtyConfig = {}): Promise
|
|
|
239
266
|
current.session.resize(cols, rows)
|
|
240
267
|
return c.json({ ok: true })
|
|
241
268
|
})
|
|
242
|
-
.post(`${
|
|
269
|
+
.post(`${DRIVER_PATH}/:id/stop`, async (c) => {
|
|
243
270
|
if (!ctx.drivers.authorized(c.req.raw.headers)) return refuse(c)
|
|
244
271
|
const current = find(c.req.param('id'))
|
|
245
272
|
if (!current) return c.json({ error: 'no such terminal' }, 404)
|
|
246
273
|
await current.dispose()
|
|
247
274
|
return c.json({ ok: true })
|
|
248
275
|
})
|
|
249
|
-
ctx.router.route('/', app)
|
|
276
|
+
ctx.router.route(MOUNT === '' ? '/' : MOUNT, app)
|
|
250
277
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/** The one place that touches `node-pty`, a native module. Everything else works on this small seam, so tests run on a fake process. */
|
|
2
|
+
import { statSync } from 'node:fs'
|
|
3
|
+
import { delimiter, join } from 'node:path'
|
|
2
4
|
|
|
3
5
|
export interface PtyProcess {
|
|
4
6
|
readonly pid: number
|
|
@@ -16,6 +18,42 @@ export type PtySpawn = (
|
|
|
16
18
|
options: { cwd: string; env: NodeJS.ProcessEnv; cols: number; rows: number },
|
|
17
19
|
) => PtyProcess
|
|
18
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The file a terminal runs: a full path. On Windows node-pty runs the file as it is given and does not search PATH - a bare `node` is "File not found" - so
|
|
23
|
+
* a bare program name is looked up once, with the platform's PATH separator and, on Windows, PATHEXT. A name that is not found comes back unchanged, and
|
|
24
|
+
* the start fails with the PTY's own error.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveProgram(
|
|
27
|
+
program: string,
|
|
28
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
29
|
+
platform: NodeJS.Platform = process.platform,
|
|
30
|
+
): string {
|
|
31
|
+
if (program === '' || program.includes('/') || (platform === 'win32' && program.includes('\\')))
|
|
32
|
+
return program
|
|
33
|
+
const windows = platform === 'win32'
|
|
34
|
+
const folders = (env.PATH ?? env.Path ?? '').split(windows ? ';' : delimiter)
|
|
35
|
+
const lower = program.toLowerCase()
|
|
36
|
+
const known = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
37
|
+
const suffixes = !windows
|
|
38
|
+
? ['']
|
|
39
|
+
: known.some((extension) => lower.endsWith(extension.toLowerCase()))
|
|
40
|
+
? ['']
|
|
41
|
+
: ['', ...new Set(known)]
|
|
42
|
+
for (const folder of folders) {
|
|
43
|
+
if (folder === '') continue
|
|
44
|
+
for (const suffix of suffixes) {
|
|
45
|
+
const candidate = join(folder, `${program}${suffix}`)
|
|
46
|
+
try {
|
|
47
|
+
const stat = statSync(candidate)
|
|
48
|
+
if (stat.isFile() && (windows || (stat.mode & 0o111) !== 0)) return candidate
|
|
49
|
+
} catch {
|
|
50
|
+
// Not here: the next folder, or the next extension.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return program
|
|
55
|
+
}
|
|
56
|
+
|
|
19
57
|
/** Loads node-pty. Rejects with the reason (the error and the platform), so a log says why the driver is not offered. */
|
|
20
58
|
export async function loadNodePty(): Promise<PtySpawn> {
|
|
21
59
|
try {
|
|
@@ -22,10 +22,12 @@ export function terminalDriver(
|
|
|
22
22
|
program: string,
|
|
23
23
|
args: string[],
|
|
24
24
|
spawn: PtySpawn,
|
|
25
|
+
view: TerminalDriver['view'],
|
|
25
26
|
): TerminalDriver {
|
|
26
27
|
return {
|
|
27
28
|
kind: 'terminal',
|
|
28
29
|
name,
|
|
30
|
+
view,
|
|
29
31
|
start({ cwd, env, cols, rows }): TerminalSession {
|
|
30
32
|
// The agent runs `acryl look` without the folder prefix.
|
|
31
33
|
const path = [join(cwd, '.acryl', 'bin'), env.PATH].filter(Boolean).join(delimiter)
|