acrylblends 1.0.3 → 1.0.5
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/docs/framework/agents/index.md +6 -4
- package/docs/framework/workshop/README.md +3 -3
- package/docs/framework/workshop/facilitator.md +1 -0
- package/lib/accept-CNNHu4Cf.js +193 -0
- package/lib/{accepts-XBuZPVRI.js → accepts-Btq83xPd.js} +1 -1
- package/lib/accepts.js +1 -1
- package/lib/acryl-base.js +1 -331
- package/lib/adapter-CaZ6FZUU.js +37 -0
- package/lib/adapter.js +1 -36
- package/lib/{app-BM0rTCp7.js → app-CQcGWjrk.js} +4 -4
- package/lib/app-base-CpRMZL4v.js +332 -0
- package/lib/{audit-BXnYsLge.d.ts → audit-D5z9uwOP.d.ts} +14 -1
- package/lib/{base-NSN_SNv-.js → base-D_6kfOr-.js} +1 -1
- package/lib/basic-auth-C0HTRpXh.js +20 -0
- package/lib/basic-auth-DFLflXNe.js +95 -0
- package/lib/basic-auth.js +1 -94
- package/lib/bearer-auth-Cwxt-vgS.js +94 -0
- package/lib/bearer-auth.js +1 -93
- package/lib/body-D7f4e7w_.js +94 -0
- package/lib/body-limit-WmIjWRLt.js +66 -0
- package/lib/body-limit.js +1 -65
- package/lib/buffer-CkEOJCcY.js +51 -0
- package/lib/bun.js +3 -3
- package/lib/cache-B0bHH7s5.js +169 -0
- package/lib/cache.js +1 -168
- package/lib/{client-DDLXwhBo.js → client-BrMFnMEd.js} +1 -1
- package/lib/{client-modules-CLvWOjbd.js → client-modules-drj4mjhj.js} +4 -4
- package/lib/client.js +1 -1
- package/lib/color-COa3DplO.js +37 -0
- package/lib/combine-TWuGdcbC.js +137 -0
- package/lib/combine.js +1 -136
- package/lib/{components-cEW3ovK0.js → components-B2ka9Ax1.js} +4 -4
- package/lib/{components-JXVm45kK.js → components-PVXwaOaC.js} +2 -2
- package/lib/compress-BzWqsHF1.js +79 -0
- package/lib/compress-C-9vK4B2.d.ts +11 -0
- package/lib/compress-DCyrrQ5D.js +11 -0
- package/lib/compress.d.ts +1 -1
- package/lib/compress.js +2 -78
- package/lib/concurrent-DrF3d2c-.js +30 -0
- package/lib/conninfo-BCROmdnf.d.ts +11 -0
- package/lib/conninfo-CWJwieh4.js +19 -0
- package/lib/constants-CBSEudrb.js +7 -0
- package/lib/{context-DDO0cfuP.js → context-CkOh7Ygv.js} +2 -2
- package/lib/context-storage-iuPoI_CR.js +52 -0
- package/lib/context-storage.js +1 -51
- package/lib/cookie-B1LlkzOe.js +125 -0
- package/lib/cookie-BHIduy2T.js +82 -0
- package/lib/cookie.js +1 -81
- package/lib/cordis/db-sqlite.d.ts +1 -1
- package/lib/cordis/door.d.ts +17 -3
- package/lib/cordis/door.js +123 -16
- package/lib/cordis/inspect.js +4 -4
- package/lib/cordis/kv-db.d.ts +1 -1
- package/lib/cordis/kv-db.js +1 -1
- package/lib/cordis/kv-json.d.ts +1 -1
- package/lib/cordis/kv-json.js +1 -1
- package/lib/cordis/node-server.d.ts +1 -1
- package/lib/cordis/node-server.js +3 -116
- package/lib/cordis/router-http.d.ts +1 -1
- package/lib/cordis/router-http.js +2 -2
- package/lib/cordis/router.d.ts +1 -1
- package/lib/cordis/router.js +4 -4
- package/lib/cordis/state.js +7 -7
- package/lib/{core-CaY8XDZ6.js → core-BOu1jYua.js} +1 -1
- package/lib/cors-CbAQ-8qU.js +106 -0
- package/lib/cors.js +1 -105
- package/lib/crypto-iVP-N-tS.js +34 -0
- package/lib/csrf-DqqOZtgy.js +101 -0
- package/lib/csrf.js +1 -100
- package/lib/css-C5CPVMvM.js +134 -0
- package/lib/css.js +2 -133
- package/lib/db/sqlite.d.ts +1 -25
- package/lib/dev-CLgu-JRe.js +50 -0
- package/lib/dev.js +1 -49
- package/lib/dom-YjND9Jt3.js +74 -0
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +12 -1
- package/lib/door/page.js +22 -1
- package/lib/door/protocol.d.ts +2 -2
- package/lib/door/protocol.js +2 -197
- package/lib/encode-BhbNLrjp.js +33 -0
- package/lib/factory-CLdSf-xS.js +30 -0
- package/lib/factory.js +1 -29
- package/lib/handler-C0wZ0KwO.js +12 -0
- package/lib/helpers-BEjcdbgw.js +5 -0
- package/lib/{hooks-w72iLeFR.js → hooks-CfC07ri0.js} +1 -1
- package/lib/html-CgNa89xw.js +31 -0
- package/lib/html-DGExBFNg.js +103 -0
- package/lib/html.d.ts +2 -5
- package/lib/html.js +2 -30
- package/lib/http-exception-MjWkT89V.js +54 -0
- package/lib/http-exception.js +1 -53
- package/lib/{index-D4nVR3wu.d.ts → index-DMOtA-NJ.d.ts} +7 -1
- package/lib/index-dAzQsJG9.d.ts +6 -0
- package/lib/index.js +3 -3
- package/lib/ip-restriction-jKtKCRM_.js +166 -0
- package/lib/ip-restriction.js +1 -165
- package/lib/ipaddr-DCoIo528.js +238 -0
- package/lib/jsx/dom/client.js +1 -1
- package/lib/jsx/dom/jsx-dev-runtime.js +1 -1
- package/lib/jsx/dom/jsx-runtime.js +2 -1
- package/lib/jsx/dom/server.js +3 -3
- package/lib/jsx/dom.js +5 -73
- package/lib/jsx/jsx-dev-runtime.d.ts +2 -5
- package/lib/jsx/jsx-dev-runtime.js +2 -13
- package/lib/jsx/jsx-runtime.d.ts +2 -2
- package/lib/jsx/jsx-runtime.js +4 -4
- package/lib/jsx/streaming.js +1 -1
- package/lib/jsx-CUQgttxU.js +47 -0
- package/lib/jsx-dev-runtime-B4WTyq8r.js +14 -0
- package/lib/{jsx-dev-runtime-CWLRA2F3.js → jsx-dev-runtime-BLfA-EhI.js} +2 -2
- package/lib/jsx-dev-runtime-CGt4kNYr.d.ts +6 -0
- package/lib/jsx-renderer-CfifM4hT.js +99 -0
- package/lib/jsx-renderer.js +1 -98
- package/lib/jsx-runtime-COCRWBJ9.js +2 -0
- package/lib/jsx.js +5 -46
- package/lib/{jwk-LkzeoaU8.js → jwk-CDPpYQpD.js} +3 -3
- package/lib/jwk.js +1 -1
- package/lib/{jwt-BGJBw5AF.js → jwt-BXoIA2fQ.js} +2 -2
- package/lib/{jwt-CskdhEW9.js → jwt-lyS4Q3mT.js} +3 -3
- package/lib/jwt.js +2 -2
- package/lib/kv/db.d.ts +1 -10
- package/lib/kv/db.js +1 -56
- package/lib/kv/json.d.ts +1 -9
- package/lib/kv/json.js +1 -223
- package/lib/{language-D9a4zqvn.js → language-DeGRtLJt.js} +2 -2
- package/lib/language.js +1 -1
- package/lib/{linear-router-C3FPib6h.js → linear-router-CmZn_JLV.js} +2 -2
- package/lib/logger-Ckxu8diz.js +54 -0
- package/lib/logger.js +1 -53
- package/lib/loopback-only-BbasoF7R.js +90 -0
- package/lib/loopback-only.js +1 -89
- package/lib/method-not-allowed-TO2_hnfl.js +82 -0
- package/lib/method-not-allowed.js +1 -81
- package/lib/method-override-DnZKfJbV.js +103 -0
- package/lib/method-override.js +1 -102
- package/lib/middleware-BfG_fp8P.js +12 -0
- package/lib/mime-BnScXkQn.js +77 -0
- package/lib/mount-QkK_3NtJ.js +67 -0
- package/lib/mount.js +1 -66
- package/lib/node-server/conninfo.d.ts +1 -10
- package/lib/node-server/conninfo.js +1 -18
- package/lib/node-server/register.js +25 -2
- package/lib/node-server/serve-static.d.ts +1 -17
- package/lib/node-server/serve-static.js +1 -1
- package/lib/node-server.d.ts +2 -2
- package/lib/node-server.js +4 -4
- package/lib/open-BqK4D2Wn.d.ts +11 -0
- package/lib/open-But9E2rL.js +57 -0
- package/lib/open-CULVJgOc.d.ts +12 -0
- package/lib/open-Dp4sxspc.js +224 -0
- package/lib/open-T7kgbO2O.d.ts +27 -0
- package/lib/{pattern-router-HZxjDGEQ.js → pattern-router-CJBecJBd.js} +1 -1
- package/lib/powered-by-CXQNjNIX.js +24 -0
- package/lib/powered-by.js +1 -23
- package/lib/pretty-json-CeC1yvYt.js +39 -0
- package/lib/pretty-json.js +1 -38
- package/lib/{protocol-BizVt4J6.d.ts → protocol-C37gPePD.d.ts} +9 -2
- package/lib/protocol-Dt0AZBOz.js +213 -0
- package/lib/proxy-C2w_mH7k.js +121 -0
- package/lib/proxy.js +1 -120
- package/lib/quick.js +4 -4
- package/lib/{reg-exp-router-DC1R3qoY.js → reg-exp-router-C_WV84eV.js} +2 -2
- package/lib/request-DvMgGtlW.js +323 -0
- package/lib/request.js +1 -322
- package/lib/route-D8m-a2I1.js +126 -0
- package/lib/route.js +1 -125
- package/lib/router/linear-router.js +1 -1
- package/lib/router/pattern-router.js +1 -1
- package/lib/router/reg-exp-router.js +1 -1
- package/lib/router/smart-router.js +1 -1
- package/lib/router/trie-router.js +1 -1
- package/lib/router-Be_o6nFu.js +35 -0
- package/lib/router.js +1 -34
- package/lib/schema-validator-EK3cUUCt.js +22 -0
- package/lib/schema-validator.js +1 -21
- package/lib/{secure-headers-mP3j6Hm6.js → secure-headers-C5ZOcXqk.js} +1 -1
- package/lib/secure-headers.js +1 -1
- package/lib/serve-static-B7sqnftW.d.ts +18 -0
- package/lib/{serve-static-3uo_9rjF.js → serve-static-C1Nkj_xB.js} +2 -2
- package/lib/{serve-static-Cp5adzGo.js → serve-static-EiJf1zkO.js} +4 -4
- package/lib/serve-static.js +1 -1
- package/lib/{smart-router-C5CZ5XSM.js → smart-router-BjxkDQCE.js} +1 -1
- package/lib/{snapshot-text-DmE0YfeZ.js → snapshot-text-Bbx43mN0.js} +57 -9
- package/lib/{src-2Z4mJg0x.js → src-BTBm4OPD.js} +22 -6
- package/lib/src-LX0E4ec4.js +117 -0
- package/lib/{ssg-BwjcA-Ge.js → ssg-BEl7MHqg.js} +5 -5
- package/lib/ssg.js +1 -1
- package/lib/stream-CzM_SHkq.js +88 -0
- package/lib/{streaming-DHST3Scg.js → streaming-BlDaph8U.js} +3 -3
- package/lib/streaming.js +1 -1
- package/lib/{test-websocket-D8WWqE5G.js → test-websocket-BLeEvybj.js} +2 -2
- package/lib/testing-BQx5T_UX.js +17 -0
- package/lib/testing.js +1 -16
- package/lib/timeout-FeqZ9A-0.js +42 -0
- package/lib/timeout.js +1 -41
- package/lib/tiny.js +2 -2
- package/lib/trailing-slash-DwTx-1sk.js +85 -0
- package/lib/trailing-slash.js +1 -84
- package/lib/{trie-router-CfFbapzL.js → trie-router-JydLKUa2.js} +2 -2
- package/lib/url-BPh4d6j-.js +197 -0
- package/lib/utils/accept.js +1 -192
- package/lib/utils/basic-auth.js +1 -19
- package/lib/utils/body.js +1 -93
- package/lib/utils/buffer.js +1 -50
- package/lib/utils/color.js +1 -36
- package/lib/utils/compress.d.ts +1 -10
- package/lib/utils/compress.js +1 -10
- package/lib/utils/concurrent.js +1 -29
- package/lib/utils/constants.js +1 -6
- package/lib/utils/cookie.js +1 -124
- package/lib/utils/crypto.js +1 -33
- package/lib/utils/encode.js +1 -32
- package/lib/utils/handler.js +1 -11
- package/lib/utils/html.js +1 -102
- package/lib/utils/ipaddr.js +1 -237
- package/lib/utils/jwt.js +1 -1
- package/lib/utils/mime.js +1 -76
- package/lib/utils/stream.js +1 -87
- package/lib/utils/url.js +1 -196
- package/lib/{validator-D84mq-dO.js → validator-Btiyt5VJ.js} +3 -3
- package/lib/validator.js +1 -1
- package/lib/websocket-DsyBIKVA.js +56 -0
- package/lib/ws.js +1 -55
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +5 -3
- package/plugins/acryl-door/src/host/channel.ts +36 -0
- package/plugins/acryl-door/src/host/chat-tools.ts +2 -0
- package/plugins/acryl-door/src/host/door.ts +44 -5
- package/plugins/acryl-door/src/host/shim.ts +23 -0
- package/plugins/acryl-door/src/host/verbs.ts +4 -3
- package/plugins/acryl-door/src/index.ts +39 -3
- package/plugins/acryl-door/src/page/driver/driver.ts +22 -1
- package/plugins/acryl-door/src/page/protocol.ts +13 -2
- package/plugins/acryl-node-server/src/register.ts +29 -2
- package/plugins/acryl-router/src/service.ts +19 -1
- package/plugins/agent/model.ts +6 -1
- package/plugins/agent/tools.ts +12 -2
- package/plugins/agent-panel/client/describe.ts +44 -0
- package/plugins/agent-panel/client/main.ts +67 -35
- package/plugins/agent-panel/index.ts +137 -35
- package/plugins/desktop/attach.ts +72 -0
- package/plugins/desktop/main.ts +17 -4
- package/plugins/extensions/index.ts +3 -4
- package/plugins/extensions/stage.ts +33 -5
- package/plugins/openai/index.ts +1 -1
- package/shared/DEFAULTS.md +4 -0
- package/templates/agent/AGENTS.md +2 -0
- package/templates/agent/blend.yaml +4 -1
- package/templates/agent/src/app.ts +2 -0
- package/templates/agent/src/routes.ts +1 -1
- package/templates/agent/template.json +1 -0
- package/templates/agent/test/builder.test.ts +26 -21
- package/lib/helpers-CvTauQT5.js +0 -5
- package/lib/middleware-CPQ8himN.js +0 -12
|
@@ -23,7 +23,7 @@ import { DOOR_PREFIX, privateApi } from './host/http.ts'
|
|
|
23
23
|
import { mcpRoutes } from './host/mcp-routes.ts'
|
|
24
24
|
import { operatorRoutes } from './host/operator-routes.ts'
|
|
25
25
|
import { pageChannelRoutes } from './host/page-routes.ts'
|
|
26
|
-
import { writeShims, type ShimTarget } from './host/shim.ts'
|
|
26
|
+
import { realNode, writeShims, type ShimTarget } from './host/shim.ts'
|
|
27
27
|
|
|
28
28
|
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
29
29
|
export const name = 'acryl-door'
|
|
@@ -54,10 +54,13 @@ export interface Config {
|
|
|
54
54
|
* run it in production (a packaged desktop app whose user drives it with an agent) says so here; `ACRYL_DOOR=off` still wins.
|
|
55
55
|
*/
|
|
56
56
|
allowInProduction?: boolean
|
|
57
|
+
/** Reload the open pages after a live plugin change, so an agent that built a feature sees it on the page it has open. Default: true. */
|
|
58
|
+
reloadOnChange?: boolean
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
export const Config: z<Config> = z.object({
|
|
60
62
|
allowInProduction: z.boolean().default(false).description('Run in production too (NODE_ENV=production); ACRYL_DOOR=off still turns it off.'),
|
|
63
|
+
reloadOnChange: z.boolean().default(true).description('Reload the open pages after a plugin was added, removed or reloaded while the app runs.'),
|
|
61
64
|
tools: z.object({
|
|
62
65
|
// Not a `door_` name: those are the door's own verbs in the registry, never offered back as app tools.
|
|
63
66
|
expose: z.array(z.string().pattern(/^(?!door_)[A-Za-z][A-Za-z0-9_-]{0,63}$/u)).description('App tools (from the tools registry) the door offers; none by default.'),
|
|
@@ -73,11 +76,14 @@ const CLIENT = import.meta.url.endsWith('.ts') ? join(import.meta.dirname, 'cli'
|
|
|
73
76
|
/** How the folder's `.acryl/bin/acryl` starts the client: with the Node that runs the app, and its type-stripping flag when run from source. */
|
|
74
77
|
function shimTarget(): ShimTarget {
|
|
75
78
|
const fromSource = CLIENT.endsWith('.ts')
|
|
79
|
+
const inElectron = process.versions.electron !== undefined
|
|
80
|
+
// Inside Electron the command prefers a real Node (no code-signing noise on every call); without one, Electron runs the client.
|
|
81
|
+
const node = inElectron ? realNode() : undefined
|
|
76
82
|
return {
|
|
77
|
-
node: process.execPath,
|
|
83
|
+
node: node ?? process.execPath,
|
|
78
84
|
nodeOptions: fromSource ? process.execArgv.filter(option => /^--experimental-(strip|transform)-types$/u.test(option)) : [],
|
|
79
85
|
client: CLIENT,
|
|
80
|
-
electron:
|
|
86
|
+
electron: inElectron && node === undefined,
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
|
|
@@ -88,6 +94,13 @@ function loopbackUrl(url: string): string {
|
|
|
88
94
|
return parsed.origin
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
const escapeHtml = (text: string): string => text.replace(/[&<>"']/gu, char => `&#${char.charCodeAt(0)};`)
|
|
98
|
+
|
|
99
|
+
/** The page of a path the app does not serve, development only: what is missing, a way back, and the door's script. */
|
|
100
|
+
export function notFoundPage(path: string, scriptTag: string): string {
|
|
101
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 Not Found</title></head><body><main><h1>404 Not Found</h1><p>This app has no page at <code>${escapeHtml(path)}</code>.</p><p><a href="/">Back to the app</a></p></main>${scriptTag}</body></html>`
|
|
102
|
+
}
|
|
103
|
+
|
|
91
104
|
export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
92
105
|
const config = Config(rawConfig)
|
|
93
106
|
// Development only: nothing is started, mounted or written in production, or when switched off.
|
|
@@ -108,12 +121,35 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
108
121
|
extensions: () => ctx.get('extensions') as unknown as ExtensionsPort | undefined,
|
|
109
122
|
tools: () => ctx.get('tools') as unknown as ToolsPort | undefined,
|
|
110
123
|
expose: config.tools?.expose ?? [],
|
|
124
|
+
reloadOnChange: config.reloadOnChange !== false,
|
|
111
125
|
})
|
|
112
126
|
ctx.effect(() => () => { channel.close() }, 'acryl-door: page channel')
|
|
113
127
|
// One script tag for the page (`page.js`): it imports the module tree served below it.
|
|
114
128
|
const pageModule = new Acryl()
|
|
115
129
|
.get(`${DOOR_PREFIX}/page.js`, c => c.body(`import '${DOOR_PREFIX}/page/main.ts'\n`, 200, { 'content-type': 'text/javascript; charset=utf-8' }))
|
|
116
130
|
.route(`${DOOR_PREFIX}/page`, clientModules({ root: PAGE_DIR }))
|
|
131
|
+
// The page script on every HTML page the app answers, whenever its plugin was mounted (the pages of a plugin that loaded at boot, before this one, included): a page an agent writes
|
|
132
|
+
// can be looked at and driven without anyone adding the tag. A page that already carries it (the templates' own pages) is left as it is. Development only, like the rest of the door.
|
|
133
|
+
const tag = `<script type="module" src="${DOOR_PREFIX}/page.js"></script>`
|
|
134
|
+
const addScript = async (c: { res: Response; req: { path: string; header(name: string): string | undefined } }, next: () => Promise<void>): Promise<void> => {
|
|
135
|
+
await next()
|
|
136
|
+
if (!(c.res.headers.get('content-type') ?? '').includes('text/html')) {
|
|
137
|
+
// A path the app does not serve, opened as a page: the browser would show a bare "404 Not Found" with no script, and the window would be left where nothing can look at it or
|
|
138
|
+
// act on it (an agent that mistypes a path would need a person to bring the window back). The page says what is missing, carries the script, and keeps the 404.
|
|
139
|
+
if (c.res.status === 404 && (c.req.header('accept') ?? '').includes('text/html') && !(c.res.headers.get('content-type') ?? '').includes('json'))
|
|
140
|
+
c.res = new Response(notFoundPage(c.req.path, tag), { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const html = await c.res.text()
|
|
144
|
+
const headers = new Headers(c.res.headers)
|
|
145
|
+
headers.delete('content-length')
|
|
146
|
+
const carries = html.includes(`${DOOR_PREFIX}/page.js`)
|
|
147
|
+
c.res = new Response(carries ? html : html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag, { status: c.res.status, headers })
|
|
148
|
+
}
|
|
149
|
+
const router = ctx.router as unknown as { wrap?: (middleware: typeof addScript) => unknown }
|
|
150
|
+
// 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.
|
|
151
|
+
if (typeof router.wrap === 'function') router.wrap(addScript)
|
|
152
|
+
else ctx.router.route('/', new Acryl().use('*', addScript as never))
|
|
117
153
|
ctx.router.route('/', privateApi(
|
|
118
154
|
DOOR_PREFIX,
|
|
119
155
|
pageModule,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* shows. It touches only the document it is given, never another window and never the OS.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { UiControlError, MUTATING_OPS, type UiRequest, type UiResult } from '../protocol.ts'
|
|
8
|
+
import { UiControlError, MUTATING_OPS, type UiActionResult, type UiRequest, type UiResult } from '../protocol.ts'
|
|
9
9
|
import { click, pressKey, scroll, selectOption, typeText, wait, type ActionEnvironment } from './actions.ts'
|
|
10
10
|
import { RefTable } from './ref-table.ts'
|
|
11
11
|
import { SnapshotBuilder } from './snapshot.ts'
|
|
@@ -13,6 +13,8 @@ import { SnapshotBuilder } from './snapshot.ts'
|
|
|
13
13
|
/** After the user touches the page, agent actions wait this long, so the user always wins. */
|
|
14
14
|
const YIELD_MS = 400
|
|
15
15
|
const MAX_YIELD_MS = 2000
|
|
16
|
+
/** How long after the answer a reload or a move starts, so the answer is on its way first. */
|
|
17
|
+
export const LEAVE_DELAY_MS = 150
|
|
16
18
|
|
|
17
19
|
export interface DriverActivity {
|
|
18
20
|
/** True from the moment a call starts until it ends. */
|
|
@@ -28,6 +30,8 @@ export interface UiDriverOptions {
|
|
|
28
30
|
/** Milliseconds since the user last used the page (mouse, keyboard); Infinity when never. */
|
|
29
31
|
readonly msSinceUserInput?: () => number
|
|
30
32
|
readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>
|
|
33
|
+
/** Leave the page: reload it, or go to a path of the app. Default: the document's own `location`, a moment after the answer is sent. */
|
|
34
|
+
readonly leave?: (to: { readonly reload: true } | { readonly path: string }) => void
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
function realSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
@@ -130,9 +134,26 @@ export class UiDriver {
|
|
|
130
134
|
case 'press': return pressKey(this.env, request)
|
|
131
135
|
case 'scroll': return scroll(this.env, request)
|
|
132
136
|
case 'wait': return wait(this.env, request, signal)
|
|
137
|
+
case 'reload': return this.leave({ reload: true }, 'reloading')
|
|
138
|
+
case 'navigate': return this.leave({ path: request.path }, `going to ${request.path}`)
|
|
133
139
|
}
|
|
134
140
|
}
|
|
135
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The page is about to be replaced, so the answer is given first and the move happens a moment later (the answer is a POST that the page's own unload would cut off).
|
|
144
|
+
* The Host does not wait for this answer to be the last word: it waits for the page to come back.
|
|
145
|
+
*/
|
|
146
|
+
private leave(to: { readonly reload: true } | { readonly path: string }, detail: string): UiActionResult {
|
|
147
|
+
const go = this.options.leave ?? ((target) => {
|
|
148
|
+
const location = this.options.document().defaultView?.location
|
|
149
|
+
if (location === undefined) return
|
|
150
|
+
if ('reload' in target) location.reload()
|
|
151
|
+
else location.assign(target.path)
|
|
152
|
+
})
|
|
153
|
+
setTimeout(() => { if (!this.disposed) go(to) }, LEAVE_DELAY_MS)
|
|
154
|
+
return { ok: true, detail }
|
|
155
|
+
}
|
|
156
|
+
|
|
136
157
|
private async yieldToUser(signal: AbortSignal): Promise<void> {
|
|
137
158
|
const since = this.options.msSinceUserInput
|
|
138
159
|
if (since === undefined) return
|
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
/** The operations the driver performs on the page. */
|
|
10
|
-
export const UI_OPS = ['snapshot', 'click', 'type', 'select', 'press', 'scroll', 'wait'] as const
|
|
10
|
+
export const UI_OPS = ['snapshot', 'click', 'type', 'select', 'press', 'scroll', 'wait', 'reload', 'navigate'] as const
|
|
11
11
|
export type UiOp = (typeof UI_OPS)[number]
|
|
12
12
|
|
|
13
13
|
/** Operations that change the page, so each call needs approval. `snapshot`, `scroll` and `wait` only look. */
|
|
14
|
-
export const MUTATING_OPS: readonly UiOp[] = ['click', 'type', 'select', 'press']
|
|
14
|
+
export const MUTATING_OPS: readonly UiOp[] = ['click', 'type', 'select', 'press', 'reload', 'navigate']
|
|
15
|
+
|
|
16
|
+
/** A place in the app to go to: a path on the page's own origin (`/demo`, `/notes?q=a#top`), never another site and never a `//host` address. */
|
|
17
|
+
export const APP_PATH = /^\/(?![/\\])[^\0\\]{0,2000}$/u
|
|
15
18
|
|
|
16
19
|
export const MAX_TEXT_LENGTH = 10_000
|
|
17
20
|
export const MAX_WAIT_MS = 10_000
|
|
@@ -77,6 +80,8 @@ export type UiRequest =
|
|
|
77
80
|
| { readonly op: 'press'; readonly key: string; readonly ref?: string }
|
|
78
81
|
| { readonly op: 'scroll'; readonly direction: 'up' | 'down' | 'left' | 'right'; readonly ref?: string; readonly amount?: number }
|
|
79
82
|
| { readonly op: 'wait'; readonly text?: string; readonly role?: string; readonly name?: string; readonly gone?: boolean; readonly timeoutMs?: number }
|
|
83
|
+
| { readonly op: 'reload' }
|
|
84
|
+
| { readonly op: 'navigate'; readonly path: string }
|
|
80
85
|
|
|
81
86
|
/** What the page did, and to which control (for the audit log and the approval prompt). */
|
|
82
87
|
export interface UiActionResult {
|
|
@@ -164,6 +169,12 @@ export function parseUiRequest(value: unknown): UiRequest {
|
|
|
164
169
|
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
165
170
|
}
|
|
166
171
|
}
|
|
172
|
+
case 'reload': only([]); return { op }
|
|
173
|
+
case 'navigate': {
|
|
174
|
+
only(['path'])
|
|
175
|
+
if (typeof value.path !== 'string' || !APP_PATH.test(value.path)) return invalid('path must be a path of this app, such as /demo (it starts with one /, and names no other site)')
|
|
176
|
+
return { op, path: value.path }
|
|
177
|
+
}
|
|
167
178
|
default: return invalid(`unknown op ${String(op)}`)
|
|
168
179
|
}
|
|
169
180
|
}
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* `typescript` is loaded from the app, on the first JSX file only; the framework itself has no dependencies.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
10
11
|
import { registerHooks } from 'node:module'
|
|
11
|
-
import {
|
|
12
|
-
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
13
13
|
import { compileModule } from './transpile.ts'
|
|
14
14
|
|
|
15
15
|
/** The JSX runtime the docs set up; an app's tsconfig.json (or a file's own `@jsxImportSource` pragma) chooses another. */
|
|
@@ -27,3 +27,30 @@ registerHooks({
|
|
|
27
27
|
return { format: 'module', source, shortCircuit: true }
|
|
28
28
|
},
|
|
29
29
|
})
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The framework's names for a file of the app: in an app the framework plugins are source under `plugins/`, not packages, so `acrylblends`, `acrylblends/body-limit` or `acryl-router`
|
|
33
|
+
* have nowhere to resolve from. A plugin installed while the app runs gets them from the extensions plugin; a plugin that is a ROW of `blend.yaml` is imported by `src/app.ts` before
|
|
34
|
+
* any of that exists, so the same names are resolved here, in the preload every `dev`, `start` and `test` script runs. Only a name that Node cannot resolve itself, only for the
|
|
35
|
+
* app's own files (never for something in `node_modules`), and only in an app that has the framework as vendored source: the published package resolves its own names.
|
|
36
|
+
*/
|
|
37
|
+
const plugins = new URL('../../', import.meta.url)
|
|
38
|
+
const appRoot = new URL('../', plugins).href
|
|
39
|
+
const resolver = new URL('extensions/resolve.ts', plugins)
|
|
40
|
+
if (existsSync(fileURLToPath(new URL('acryl-router/src/index.ts', plugins))) && existsSync(fileURLToPath(resolver))) {
|
|
41
|
+
const { routerAliases } = (await import(resolver.href)) as { routerAliases(): Record<string, string> }
|
|
42
|
+
const aliases = routerAliases()
|
|
43
|
+
registerHooks({
|
|
44
|
+
resolve(specifier, context, nextResolve) {
|
|
45
|
+
try {
|
|
46
|
+
return nextResolve(specifier, context)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const alias = aliases[specifier]
|
|
49
|
+
const parent = context.parentURL
|
|
50
|
+
if (alias !== undefined && parent?.startsWith(appRoot) && !parent.includes('/node_modules/'))
|
|
51
|
+
return nextResolve(pathToFileURL(alias).href, context)
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
}
|
|
@@ -94,6 +94,8 @@ interface Mount {
|
|
|
94
94
|
|
|
95
95
|
interface RouterState {
|
|
96
96
|
readonly mounts: Set<Mount>
|
|
97
|
+
/** Middleware around every request, whenever it was added (see {@link Router.wrap}). */
|
|
98
|
+
readonly wrappers: Set<H>
|
|
97
99
|
errorHandler: ErrorHandler | undefined
|
|
98
100
|
notFoundHandler: NotFoundHandler | undefined
|
|
99
101
|
/** The app composed from the live mounts; dropped on every change and composed again by the next request. */
|
|
@@ -125,7 +127,7 @@ async function drain(tasks: readonly Promise<unknown>[]): Promise<void> {
|
|
|
125
127
|
|
|
126
128
|
export class Router extends Service {
|
|
127
129
|
/** Everything that changes after construction, kept in one object so the service's live state is in one place. */
|
|
128
|
-
private readonly state: RouterState = { mounts: new Set(), errorHandler: undefined, notFoundHandler: undefined, composed: undefined, closing: false }
|
|
130
|
+
private readonly state: RouterState = { mounts: new Set(), wrappers: new Set(), errorHandler: undefined, notFoundHandler: undefined, composed: undefined, closing: false }
|
|
129
131
|
private readonly flights = new Set<Flight>()
|
|
130
132
|
/** The flight of each request the router dispatched, keyed by the request its handlers see as `c.req.raw`. */
|
|
131
133
|
private readonly flightOf = new WeakMap<Request, Flight>()
|
|
@@ -185,6 +187,21 @@ export class Router extends Service {
|
|
|
185
187
|
return () => { void remove() }
|
|
186
188
|
}
|
|
187
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Run `middleware` around every request the router serves, whenever the plugin that asks was mounted: a middleware in a plain mount wraps only the routes mounted after it,
|
|
192
|
+
* which is the wrong tool for a plugin that must see every page (the door adds its script to each HTML response, the pages of a plugin that loaded earlier included). It goes
|
|
193
|
+
* outside all mounts, in the order wrappers were added, and goes away when the calling plugin unloads or when the returned function is called.
|
|
194
|
+
*/
|
|
195
|
+
wrap(middleware: H): () => void {
|
|
196
|
+
this.state.wrappers.add(middleware)
|
|
197
|
+
this.state.composed = undefined
|
|
198
|
+
const remove = (): void => {
|
|
199
|
+
if (this.state.wrappers.delete(middleware)) this.state.composed = undefined
|
|
200
|
+
}
|
|
201
|
+
this.ctx.effect(() => remove, 'acryl-router: wrapper')
|
|
202
|
+
return remove
|
|
203
|
+
}
|
|
204
|
+
|
|
188
205
|
/**
|
|
189
206
|
* The mount's own copy of `app`'s routes as they are now (like the core's `app.route`), each handler wrapped so the router knows which
|
|
190
207
|
* mount's code a request is in. The core's `app.route` runs first so a sub-app's own `onError` still applies to its routes.
|
|
@@ -331,6 +348,7 @@ export class Router extends Service {
|
|
|
331
348
|
// over all routes on its first request, which grows faster than the route count (measured: 22 ms at 1000 routes, 163 ms at 2000),
|
|
332
349
|
// while the trie rebuilds in about 1 ms per 1000 routes and matches as fast (scripts/bench-remount.mjs).
|
|
333
350
|
const root = new Acryl({ strict: this.strict, router: new TrieRouter() })
|
|
351
|
+
for (const wrapper of this.state.wrappers) root.use('*', wrapper)
|
|
334
352
|
for (const mount of this.state.mounts) root.route(mount.path, mount.app)
|
|
335
353
|
root.onError(this.state.errorHandler ?? this.logError)
|
|
336
354
|
if (this.state.notFoundHandler !== undefined) root.notFound(this.state.notFoundHandler)
|
package/plugins/agent/model.ts
CHANGED
|
@@ -21,7 +21,12 @@ export interface ModelInput {
|
|
|
21
21
|
readonly reasoning?: string
|
|
22
22
|
}>
|
|
23
23
|
/** The tools the model may call. */
|
|
24
|
-
readonly tools?: ReadonlyArray<{
|
|
24
|
+
readonly tools?: ReadonlyArray<{
|
|
25
|
+
readonly name: string
|
|
26
|
+
readonly description: string
|
|
27
|
+
/** The JSON Schema of the tool's input, when it has one. */
|
|
28
|
+
readonly parameters?: Readonly<Record<string, unknown>>
|
|
29
|
+
}>
|
|
25
30
|
/** Aborts when the run is cancelled: a provider that can stop its request passes this on. */
|
|
26
31
|
readonly signal?: AbortSignal
|
|
27
32
|
}
|
package/plugins/agent/tools.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
3
3
|
/** One tool: what it does (for the model) and how to run it. */
|
|
4
4
|
export interface Tool {
|
|
5
5
|
readonly description: string
|
|
6
|
+
/** The JSON Schema of the input (an object schema), sent to a real model so it knows which fields the tool takes. A tool without one takes any object. */
|
|
7
|
+
readonly parameters?: Readonly<Record<string, unknown>>
|
|
6
8
|
/** `options.signal` aborts when the run is cancelled; a tool that can stop its work listens to it. */
|
|
7
9
|
run(input: unknown, options?: { signal?: AbortSignal }): unknown | Promise<unknown>
|
|
8
10
|
}
|
|
@@ -11,7 +13,11 @@ export interface Tool {
|
|
|
11
13
|
export interface Tools {
|
|
12
14
|
/** Register a tool. Returns the function that removes it. */
|
|
13
15
|
register(name: string, tool: Tool): () => void
|
|
14
|
-
list(): Array<{
|
|
16
|
+
list(): Array<{
|
|
17
|
+
name: string
|
|
18
|
+
description: string
|
|
19
|
+
parameters?: Readonly<Record<string, unknown>>
|
|
20
|
+
}>
|
|
15
21
|
call(name: string, input: unknown, options?: { signal?: AbortSignal }): Promise<unknown>
|
|
16
22
|
}
|
|
17
23
|
|
|
@@ -31,7 +37,11 @@ export function tools(ctx: Context) {
|
|
|
31
37
|
return () => void table.delete(name)
|
|
32
38
|
},
|
|
33
39
|
list: () =>
|
|
34
|
-
[...table.entries()].map(([name, tool]) => ({
|
|
40
|
+
[...table.entries()].map(([name, tool]) => ({
|
|
41
|
+
name,
|
|
42
|
+
description: tool.description,
|
|
43
|
+
...(tool.parameters === undefined ? {} : { parameters: tool.parameters }),
|
|
44
|
+
})),
|
|
35
45
|
async call(name, input, options) {
|
|
36
46
|
const tool = table.get(name)
|
|
37
47
|
if (!tool) throw new Error(`unknown tool "${name}"`)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// One line for a tool event of the panel's conversation: what the tool is doing, not only its name. A coding agent started in the folder reports its own log (`{ tool: 'codex', info }`):
|
|
2
|
+
// its bookkeeping is dropped and its command or sentence is kept. The app's own agent reports a call (`{ tool, input, result | error }`): the name and a short form of the arguments.
|
|
3
|
+
|
|
4
|
+
const MAX = 160
|
|
5
|
+
|
|
6
|
+
const clip = (text: string): string => (text.length > MAX ? `${text.slice(0, MAX - 1)}…` : text)
|
|
7
|
+
|
|
8
|
+
/** A shell command as the agent's log writes it: `/bin/zsh -lc 'npm test' in /path/to/app` becomes `npm test`. */
|
|
9
|
+
function commandOf(line: string): string {
|
|
10
|
+
const withoutCwd = line.replace(/ in \/\S+\s*$/u, '')
|
|
11
|
+
const wrapped = /^(?:\/\S*\/)?(?:ba|z|da)?sh -l?c (['"])([\s\S]*)\1$/u.exec(withoutCwd.trim())
|
|
12
|
+
return wrapped?.[2] ?? withoutCwd.trim()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @returns the line to show, or `undefined` when the event says nothing worth a line (a hook ran, a token count). */
|
|
16
|
+
export function describeTool(data: Record<string, unknown>): string | undefined {
|
|
17
|
+
const tool = String(data.tool ?? '')
|
|
18
|
+
if (typeof data.info === 'string') {
|
|
19
|
+
const lines = data.info
|
|
20
|
+
.split('\n')
|
|
21
|
+
.map((line) => line.trim())
|
|
22
|
+
.filter(
|
|
23
|
+
(line) =>
|
|
24
|
+
line !== '' &&
|
|
25
|
+
!/^hook:/u.test(line) &&
|
|
26
|
+
!/^tokens used/u.test(line) &&
|
|
27
|
+
!/^[\d,]+$/u.test(line),
|
|
28
|
+
)
|
|
29
|
+
if (lines.length === 0) return undefined
|
|
30
|
+
const [first = '', second = ''] = lines
|
|
31
|
+
if (first === 'exec') return second === '' ? undefined : `${tool}: $ ${clip(commandOf(second))}`
|
|
32
|
+
// The output of a command that ran: only a failure is worth a line.
|
|
33
|
+
if (/^succeeded in /u.test(first)) return undefined
|
|
34
|
+
if (/^exited \d+/u.test(first)) return `${tool}: ${clip(first.replace(/:$/u, ''))}`
|
|
35
|
+
return `${tool}: ${clip(lines.join(' '))}`
|
|
36
|
+
}
|
|
37
|
+
const input =
|
|
38
|
+
data.input === undefined ||
|
|
39
|
+
(typeof data.input === 'object' && data.input !== null && Object.keys(data.input).length === 0)
|
|
40
|
+
? ''
|
|
41
|
+
: ` ${clip(JSON.stringify(data.input))}`
|
|
42
|
+
const failed = data.error ? ` failed: ${clip(String(data.error))}` : ''
|
|
43
|
+
return `${tool}${input}${failed}`
|
|
44
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// The panel in the page: a button at the corner opens a side panel (a shadow root, so the app's CSS cannot touch it and it cannot touch the app's). It asks the app's
|
|
2
2
|
// agent through POST /api/agent-panel/ask, shows the answer as it arrives, and reloads the page after a run that installed or removed a plugin.
|
|
3
|
+
import { describeTool } from './describe.ts'
|
|
4
|
+
|
|
3
5
|
const PREFIX = '/api/agent-panel'
|
|
4
6
|
|
|
5
7
|
interface Session {
|
|
@@ -88,6 +90,7 @@ async function start(): Promise<void> {
|
|
|
88
90
|
const termStop = $<HTMLButtonElement>('.termstop')
|
|
89
91
|
termStop.addEventListener('click', () => void terminal?.stop())
|
|
90
92
|
const form = $<HTMLFormElement>('form')
|
|
93
|
+
document.body.append(host)
|
|
91
94
|
const kindOf = (name: string): 'chat' | 'terminal' =>
|
|
92
95
|
session.drivers.find((d) => d.name === name)?.kind ?? 'chat'
|
|
93
96
|
let terminal: { detach(): void; stop(): Promise<void> } | undefined
|
|
@@ -147,40 +150,41 @@ async function start(): Promise<void> {
|
|
|
147
150
|
return el
|
|
148
151
|
}
|
|
149
152
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
153
|
+
// A run belongs to the app, not to this page: it goes on when the page reloads (the door reloads it after a plugin change, an agent may move it to another page) and the page that
|
|
154
|
+
// loads next follows it again. Only Stop cancels it.
|
|
155
|
+
const headers = { 'content-type': 'application/json', 'x-agent-panel-token': session.token }
|
|
156
|
+
let following = false
|
|
157
|
+
stop.addEventListener('click', () => void fetch(`${PREFIX}/stop`, { method: 'POST', headers }))
|
|
158
|
+
/**
|
|
159
|
+
* Show one run, as its events come, until it ends. `own` is true for a run this page just started (its question is on screen); a run found on load is shown from its question, and
|
|
160
|
+
* a run that had already ended before the page loaded is only shown, never reloads the page.
|
|
161
|
+
*/
|
|
162
|
+
const watch = async (response: Response, own: boolean): Promise<void> => {
|
|
163
|
+
let live = true
|
|
164
|
+
following = true
|
|
159
165
|
send.disabled = true
|
|
160
166
|
stop.disabled = false
|
|
161
167
|
let changed = false
|
|
162
168
|
let answer: HTMLElement | undefined
|
|
163
169
|
try {
|
|
164
|
-
const response = await fetch(`${PREFIX}/ask`, {
|
|
165
|
-
method: 'POST',
|
|
166
|
-
headers: { 'content-type': 'application/json', 'x-agent-panel-token': session.token },
|
|
167
|
-
body: JSON.stringify({ message, driver: driver.value }),
|
|
168
|
-
signal: controller.signal,
|
|
169
|
-
})
|
|
170
|
-
if (!response.ok) {
|
|
171
|
-
line(
|
|
172
|
-
((await response.json().catch(() => ({}))) as { error?: string }).error ??
|
|
173
|
-
`The panel answered ${response.status}.`,
|
|
174
|
-
'err',
|
|
175
|
-
)
|
|
176
|
-
return
|
|
177
|
-
}
|
|
178
170
|
for await (const { event, data } of events(response)) {
|
|
179
|
-
if (event === '
|
|
171
|
+
if (event === 'run') {
|
|
172
|
+
live = data.ended !== true
|
|
173
|
+
if (!own) {
|
|
174
|
+
if (typeof data.message === 'string') line(data.message, 'me')
|
|
175
|
+
if (typeof data.driver === 'string' && kindOf(data.driver) === 'chat')
|
|
176
|
+
driver.value = data.driver
|
|
177
|
+
const recent =
|
|
178
|
+
data.ended !== true ||
|
|
179
|
+
(typeof data.endedAt === 'number' && Date.now() - data.endedAt < 120_000)
|
|
180
|
+
if (recent) show(true)
|
|
181
|
+
}
|
|
182
|
+
} else if (event === 'text') {
|
|
180
183
|
answer ??= line('')
|
|
181
184
|
answer.textContent += String(data.text ?? '')
|
|
182
185
|
} else if (event === 'tool') {
|
|
183
|
-
|
|
186
|
+
const described = describeTool(data)
|
|
187
|
+
if (described !== undefined) line(described, 'tool')
|
|
184
188
|
} else if (event === 'change') {
|
|
185
189
|
changed = true
|
|
186
190
|
line(`plugin ${String(data.extension)}: ${String(data.action)}`, 'tool')
|
|
@@ -191,24 +195,52 @@ async function start(): Promise<void> {
|
|
|
191
195
|
}
|
|
192
196
|
}
|
|
193
197
|
} catch (error) {
|
|
194
|
-
line(
|
|
195
|
-
controller?.signal.aborted
|
|
196
|
-
? 'Stopped.'
|
|
197
|
-
: `The request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
198
|
-
'err',
|
|
199
|
-
)
|
|
198
|
+
line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
|
|
200
199
|
} finally {
|
|
201
|
-
|
|
200
|
+
following = false
|
|
202
201
|
send.disabled = false
|
|
203
202
|
stop.disabled = true
|
|
204
203
|
}
|
|
205
|
-
// A plugin was installed or removed: the new feature is served by the running app, so the page only needs to be loaded again (unless something is being typed).
|
|
206
|
-
|
|
204
|
+
// A plugin was installed or removed while this page watched: the new feature is served by the running app, so the page only needs to be loaded again (unless something is being typed).
|
|
205
|
+
// A run that ended before this page loaded was already shown after its reload, and does not reload it again.
|
|
206
|
+
if (live && changed && input.value.trim() === '') {
|
|
207
207
|
line('Reloading the page to show it.', 'tool')
|
|
208
208
|
setTimeout(() => location.reload(), 900)
|
|
209
209
|
}
|
|
210
|
+
}
|
|
211
|
+
form.addEventListener('submit', async (submitted) => {
|
|
212
|
+
submitted.preventDefault()
|
|
213
|
+
const message = input.value.trim()
|
|
214
|
+
if (message === '' || following) return
|
|
215
|
+
input.value = ''
|
|
216
|
+
line(message, 'me')
|
|
217
|
+
try {
|
|
218
|
+
const response = await fetch(`${PREFIX}/ask`, {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers,
|
|
221
|
+
body: JSON.stringify({ message, driver: driver.value }),
|
|
222
|
+
})
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
line(
|
|
225
|
+
((await response.json().catch(() => ({}))) as { error?: string }).error ??
|
|
226
|
+
`The panel answered ${response.status}.`,
|
|
227
|
+
'err',
|
|
228
|
+
)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
await watch(response, true)
|
|
232
|
+
} catch (error) {
|
|
233
|
+
line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
|
|
234
|
+
}
|
|
210
235
|
})
|
|
211
|
-
|
|
236
|
+
// The latest run, if there is one: shown again after a reload (its question, its events), and followed if it is still going.
|
|
237
|
+
try {
|
|
238
|
+
const latest = await fetch(`${PREFIX}/run`, { headers })
|
|
239
|
+
if (latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream'))
|
|
240
|
+
void watch(latest, false)
|
|
241
|
+
} catch {
|
|
242
|
+
// No run to show.
|
|
243
|
+
}
|
|
212
244
|
// A terminal that was open before a reload is still running on the server: show it again.
|
|
213
245
|
try {
|
|
214
246
|
const remembered = JSON.parse(sessionStorage.getItem('driver-pty-session') ?? 'null') as {
|