acrylblends 1.0.4 → 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/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/{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 +9 -1
- package/lib/cordis/door.js +39 -8
- 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.js +2 -2
- 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.js +1 -1
- package/lib/door/page.js +1 -1
- package/lib/door/protocol.js +1 -212
- 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-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-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-BLz8nlSc.js → snapshot-text-Bbx43mN0.js} +9 -9
- package/lib/{src-dy3Fk9tA.js → src-BTBm4OPD.js} +6 -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/host/chat-tools.ts +2 -0
- package/plugins/acryl-door/src/host/door.ts +19 -2
- package/plugins/acryl-door/src/index.ts +15 -2
- package/plugins/acryl-node-server/src/register.ts +29 -2
- 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 +4 -1
- package/plugins/agent-panel/index.ts +22 -10
- 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/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
|
@@ -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 {
|
|
@@ -181,7 +183,8 @@ async function start(): Promise<void> {
|
|
|
181
183
|
answer ??= line('')
|
|
182
184
|
answer.textContent += String(data.text ?? '')
|
|
183
185
|
} else if (event === 'tool') {
|
|
184
|
-
|
|
186
|
+
const described = describeTool(data)
|
|
187
|
+
if (described !== undefined) line(described, 'tool')
|
|
185
188
|
} else if (event === 'change') {
|
|
186
189
|
changed = true
|
|
187
190
|
line(`plugin ${String(data.extension)}: ${String(data.action)}`, 'tool')
|
|
@@ -73,18 +73,30 @@ export function apply(ctx: Context, config: PanelConfig = {}): void {
|
|
|
73
73
|
ctx.effect(() => registry.register(headlessDriver(program, root)))
|
|
74
74
|
|
|
75
75
|
const tag = `<script type="module" src="${PREFIX}/panel.js"></script>`
|
|
76
|
+
const addScript = async (c: { res: Response }, next: () => Promise<void>): Promise<void> => {
|
|
77
|
+
await next()
|
|
78
|
+
if (!(c.res.headers.get('content-type') ?? '').includes('text/html')) return
|
|
79
|
+
const html = await c.res.text()
|
|
80
|
+
if (html.includes(`${PREFIX}/panel.js`)) {
|
|
81
|
+
c.res = new Response(html, c.res)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
const headers = new Headers(c.res.headers)
|
|
85
|
+
headers.delete('content-length')
|
|
86
|
+
c.res = new Response(
|
|
87
|
+
html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag,
|
|
88
|
+
{ status: c.res.status, headers },
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
// The script tag on every HTML page the app answers, whenever the plugin that serves the page was mounted (a plugin an agent built loads at boot, before this one, after a restart).
|
|
92
|
+
// An app whose router copy is older has no `wrap`: the tag is then added to the pages of the routes mounted after this plugin only.
|
|
93
|
+
const router = ctx.router as unknown as { wrap?: (middleware: typeof addScript) => unknown }
|
|
94
|
+
const wrapped = typeof router.wrap === 'function'
|
|
95
|
+
if (wrapped) router.wrap?.(addScript)
|
|
76
96
|
const app = new Acryl()
|
|
77
|
-
// The script tag, on every HTML page the routes mounted after this plugin answer.
|
|
78
97
|
.use('*', async (c, next) => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const html = await c.res.text()
|
|
82
|
-
const headers = new Headers(c.res.headers)
|
|
83
|
-
headers.delete('content-length')
|
|
84
|
-
c.res = new Response(
|
|
85
|
-
html.includes('</body>') ? html.replace('</body>', `${tag}</body>`) : html + tag,
|
|
86
|
-
{ status: c.res.status, headers },
|
|
87
|
-
)
|
|
98
|
+
if (wrapped) return next()
|
|
99
|
+
await addScript(c, next)
|
|
88
100
|
})
|
|
89
101
|
.get(`${PREFIX}/panel.js`, (c) =>
|
|
90
102
|
c.body(`import '${PREFIX}/client/main.ts'\n`, 200, {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npm run desktop` while `npm run dev` runs: the door allows one process per app folder, so a second server cannot start. Instead of failing, the window attaches to the dev server
|
|
3
|
+
* that is running, and its door (the same pages, the same agent, the same data); closing the window then leaves that server running.
|
|
4
|
+
*
|
|
5
|
+
* The running server is found where the door says it is: `.acryl/door.json` (its loopback address and pid). Only a live process on a loopback address that answers is attached to.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
export interface RunningApp {
|
|
11
|
+
readonly url: string
|
|
12
|
+
readonly pid: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AttachDeps {
|
|
16
|
+
readonly read?: (file: string) => string
|
|
17
|
+
/** True when a process with this pid is alive. */
|
|
18
|
+
readonly alive?: (pid: number) => boolean
|
|
19
|
+
/** Whether something answers HTTP at the address. */
|
|
20
|
+
readonly answers?: (url: string) => Promise<boolean>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]'])
|
|
24
|
+
|
|
25
|
+
const processAlive = (pid: number): boolean => {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0)
|
|
28
|
+
return true
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const httpAnswers = async (url: string): Promise<boolean> => {
|
|
35
|
+
try {
|
|
36
|
+
await fetch(url, { signal: AbortSignal.timeout(1500), redirect: 'manual' })
|
|
37
|
+
return true
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @returns the dev server running from `root`, or `undefined` when there is none (no door file, a dead process, a stranger's address, nothing answering). */
|
|
44
|
+
export async function runningApp(
|
|
45
|
+
root: string,
|
|
46
|
+
deps: AttachDeps = {},
|
|
47
|
+
): Promise<RunningApp | undefined> {
|
|
48
|
+
const read = deps.read ?? ((file: string) => readFileSync(file, 'utf8'))
|
|
49
|
+
let found: { url?: unknown; pid?: unknown }
|
|
50
|
+
try {
|
|
51
|
+
found = JSON.parse(read(join(root, '.acryl', 'door.json'))) as { url?: unknown; pid?: unknown }
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
typeof found.url !== 'string' ||
|
|
57
|
+
typeof found.pid !== 'number' ||
|
|
58
|
+
!Number.isInteger(found.pid) ||
|
|
59
|
+
found.pid <= 0
|
|
60
|
+
)
|
|
61
|
+
return undefined
|
|
62
|
+
let parsed: URL
|
|
63
|
+
try {
|
|
64
|
+
parsed = new URL(found.url)
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
if (parsed.protocol !== 'http:' || !LOOPBACK.has(parsed.hostname)) return undefined
|
|
69
|
+
if (!(deps.alive ?? processAlive)(found.pid)) return undefined
|
|
70
|
+
if (!(await (deps.answers ?? httpAnswers)(parsed.origin))) return undefined
|
|
71
|
+
return { url: parsed.origin, pid: found.pid }
|
|
72
|
+
}
|
package/plugins/desktop/main.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { app, BrowserWindow, shell } from 'electron'
|
|
|
13
13
|
import type { App } from '../../src/app.ts'
|
|
14
14
|
import '../acryl-node-server/src/register.ts'
|
|
15
15
|
import { selectInstance } from '../instance/index.ts'
|
|
16
|
+
import { runningApp } from './attach.ts'
|
|
16
17
|
import { createDesktopShutdown, installShutdownRequests } from './shutdown.ts'
|
|
17
18
|
import { resolveDesktopUserDataOverride } from './user-data.ts'
|
|
18
19
|
import { windowOptions } from './window.ts'
|
|
@@ -65,9 +66,21 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
65
66
|
|
|
66
67
|
void app.whenReady().then(async () => {
|
|
67
68
|
// 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.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
69
|
+
// 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,
|
|
70
|
+
// and closing the window leaves it running.
|
|
71
|
+
const attached = await runningApp(projectRoot)
|
|
72
|
+
let url: string
|
|
73
|
+
if (attached !== undefined) {
|
|
74
|
+
console.log(
|
|
75
|
+
`desktop: attached to the app already running from this folder at ${attached.url} (its server keeps running when the window closes)`,
|
|
76
|
+
)
|
|
77
|
+
url = attached.url
|
|
78
|
+
} else {
|
|
79
|
+
const { createApp } = await import('../../src/app.ts')
|
|
80
|
+
running = await createApp(chosen)
|
|
81
|
+
url = running.url
|
|
82
|
+
}
|
|
83
|
+
const origin = new URL(url).origin
|
|
71
84
|
window = new BrowserWindow(windowOptions({ title: chosen.name }))
|
|
72
85
|
window.once('ready-to-show', () => window?.show())
|
|
73
86
|
// Navigation stays on the app's own server; any other address opens in the system browser.
|
|
@@ -80,6 +93,6 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
80
93
|
event.preventDefault()
|
|
81
94
|
void shell.openExternal(url)
|
|
82
95
|
})
|
|
83
|
-
await window.loadURL(new URL(startPath(),
|
|
96
|
+
await window.loadURL(new URL(startPath(), url).href)
|
|
84
97
|
})
|
|
85
98
|
}
|
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
readdirSync,
|
|
22
22
|
readFileSync,
|
|
23
23
|
renameSync,
|
|
24
|
-
rmSync,
|
|
25
24
|
statSync,
|
|
26
25
|
writeFileSync,
|
|
27
26
|
} from 'node:fs'
|
|
@@ -33,7 +32,7 @@ import type {} from '../instance/index.ts'
|
|
|
33
32
|
import type { Sandbox } from '../sandbox/index.ts'
|
|
34
33
|
import { appendLedger, type LedgerEntry, readLedger } from './ledger.ts'
|
|
35
34
|
import { resolveFromApp, routerAliases } from './resolve.ts'
|
|
36
|
-
import { hashFolder, prune, stage } from './stage.ts'
|
|
35
|
+
import { discard, hashFolder, prune, stage } from './stage.ts'
|
|
37
36
|
|
|
38
37
|
/** A fiber's lifecycle state, by name (Cordis's own enum is a `const enum`, which does not exist at run time). */
|
|
39
38
|
export type ExtensionState = 'pending' | 'loading' | 'active' | 'failed' | 'disposed' | 'unloading'
|
|
@@ -611,7 +610,7 @@ export class Extensions extends Service {
|
|
|
611
610
|
try {
|
|
612
611
|
module = await this.load(staged.dir, name)
|
|
613
612
|
} catch (error) {
|
|
614
|
-
|
|
613
|
+
discard(staged.dir)
|
|
615
614
|
this.record({ kind: 'refused', name, version: staged.version, error: message(error) })
|
|
616
615
|
return {
|
|
617
616
|
ok: false,
|
|
@@ -638,7 +637,7 @@ export class Extensions extends Service {
|
|
|
638
637
|
: undefined
|
|
639
638
|
if (failure) {
|
|
640
639
|
await mounted.fiber.dispose().catch(() => undefined)
|
|
641
|
-
|
|
640
|
+
discard(staged.dir)
|
|
642
641
|
const error = failure.error
|
|
643
642
|
this.record({ kind: 'refused', name, version: staged.version, error })
|
|
644
643
|
let restored = false
|
|
@@ -5,32 +5,60 @@
|
|
|
5
5
|
* code; a copy at a new path has fresh URLs for the entry and everything it imports, so it is evaluated anew. The author's source folder
|
|
6
6
|
* is never modified. The hash makes an unchanged source recognisable; the stamp makes "newest" well defined.
|
|
7
7
|
*/
|
|
8
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
|
|
8
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs'
|
|
9
9
|
import { basename, join } from 'node:path'
|
|
10
10
|
import { digestFolder, FOLDER_DIGEST_SKIPPED } from '@webboxes/blends-core'
|
|
11
11
|
|
|
12
12
|
/** A short content digest of a folder, so an unchanged source keeps its version. */
|
|
13
13
|
export const hashFolder = (dir: string, length = 12): string => digestFolder(dir, length)
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Whether `node --watch` runs this process. It watches every module the process has imported, and keeps watching the paths of the runs before; a staged copy that is deleted
|
|
17
|
+
* while it is watched restarts the app, and the restart stages and prunes again: a loop. So under the watcher a staged copy is never deleted (a later start without it prunes).
|
|
18
|
+
*/
|
|
19
|
+
export const watched = (): boolean => process.env.WATCH_REPORT_DEPENDENCIES === '1'
|
|
20
|
+
|
|
21
|
+
/** Delete a staged folder, unless the watcher would take the deletion for a change of the app. */
|
|
22
|
+
export function discard(dir: string): void {
|
|
23
|
+
if (!watched()) rmSync(dir, { recursive: true, force: true })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The newest complete staged copy of `version` (copies are finished under a temporary name and renamed, so a folder with the final name is whole). */
|
|
27
|
+
function staged(stageRoot: string, name: string, version: string): string | undefined {
|
|
28
|
+
const folder = join(stageRoot, name)
|
|
29
|
+
if (!existsSync(folder)) return undefined
|
|
30
|
+
const found = readdirSync(folder)
|
|
31
|
+
.filter((entry) => !entry.startsWith('.') && entry.endsWith(`-${version}`))
|
|
32
|
+
.sort()
|
|
33
|
+
.pop()
|
|
34
|
+
return found === undefined ? undefined : join(folder, found)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Copy `source` into a version folder under `stageRoot/<name>/` and return its path and version. An unchanged source reuses its copy: a restart stages nothing new. */
|
|
16
38
|
export function stage(
|
|
17
39
|
source: string,
|
|
18
40
|
stageRoot: string,
|
|
19
41
|
name: string,
|
|
20
42
|
): { dir: string; version: string } {
|
|
21
43
|
const version = hashFolder(source)
|
|
44
|
+
const existing = staged(stageRoot, name, version)
|
|
45
|
+
if (existing !== undefined) return { dir: existing, version }
|
|
22
46
|
const stamp = String(Date.now()).padStart(15, '0')
|
|
23
47
|
const dir = join(stageRoot, name, `${stamp}-${version}`)
|
|
24
|
-
|
|
25
|
-
|
|
48
|
+
const partial = join(stageRoot, name, `.${stamp}-${version}.partial`)
|
|
49
|
+
rmSync(partial, { recursive: true, force: true })
|
|
50
|
+
mkdirSync(partial, { recursive: true })
|
|
51
|
+
cpSync(source, partial, {
|
|
26
52
|
recursive: true,
|
|
27
53
|
filter: (path) => !FOLDER_DIGEST_SKIPPED.has(basename(path)),
|
|
28
54
|
})
|
|
55
|
+
renameSync(partial, dir)
|
|
29
56
|
return { dir, version }
|
|
30
57
|
}
|
|
31
58
|
|
|
32
|
-
/** Delete staged versions of `name` except the ones to keep (best effort). Modules already loaded stay in memory until the app restarts. */
|
|
59
|
+
/** Delete staged versions of `name` except the ones to keep (best effort; none under the watcher, see `watched`). Modules already loaded stay in memory until the app restarts. */
|
|
33
60
|
export function prune(stageRoot: string, name: string, keep: readonly string[]): void {
|
|
61
|
+
if (watched()) return
|
|
34
62
|
const folder = join(stageRoot, name)
|
|
35
63
|
if (!existsSync(folder)) return
|
|
36
64
|
if (keep.length === 0) {
|
package/plugins/openai/index.ts
CHANGED
|
@@ -102,7 +102,7 @@ export async function complete(
|
|
|
102
102
|
function: {
|
|
103
103
|
name: tool.name,
|
|
104
104
|
description: tool.description,
|
|
105
|
-
parameters: { type: 'object', additionalProperties: true },
|
|
105
|
+
parameters: tool.parameters ?? { type: 'object', additionalProperties: true },
|
|
106
106
|
},
|
|
107
107
|
})),
|
|
108
108
|
}),
|
|
@@ -5,4 +5,6 @@ Besides `src/routes.ts`, the app runs an agent (`POST /ask`) that can write plug
|
|
|
5
5
|
rollback; `GET /extensions` and `GET /tasks` show what it did. A plugin there is a folder with an `index.ts` exporting `name`, `inject`, `apply(ctx)`
|
|
6
6
|
and an optional `check(ctx)` that receives the plugin's own context (so `ctx.router` reads as in `apply`). It may import `acryl-router`.
|
|
7
7
|
|
|
8
|
+
**Write an extension, not a row.** A feature you build while the app runs is a folder `plugins/<name>/` installed with `.acryl/bin/acryl plugin add plugins/<name>` (the `extensions` plugin loads it again at every start). Add a row to `blend.yaml` and an entry to the `registry` of `src/app.ts` only when the person asks for a plugin that is part of the app itself. Both kinds may import the framework by name (`acrylblends`, `acrylblends/body-limit`, `acryl-router`); a row's relative imports of its sibling plugins also work.
|
|
9
|
+
|
|
8
10
|
**Production.** The agent, the builder and the extensions are development tools: `POST /ask` spends the model key and runs tools, a plugin installed live runs in this process, and none of it is behind a login. With `NODE_ENV=production` `src/app.ts` leaves those three rows out, so `/ask`, `/extensions*` and `/tasks*` are not mounted; the page and `GET /health` stay. `ACRYL_PRODUCTION_KEEP=agent,builder,extensions` keeps the named rows, and each one kept prints a `WARNING` line at start.
|
|
@@ -14,7 +14,7 @@ spec:
|
|
|
14
14
|
# The address the server listens on.
|
|
15
15
|
hostname: { type: string, default: 127.0.0.1 }
|
|
16
16
|
# Steps an agent run may take.
|
|
17
|
-
steps: { type: number, default:
|
|
17
|
+
steps: { type: number, default: 12 }
|
|
18
18
|
# Where task records are kept: jsonl (a text file) or sqlite.
|
|
19
19
|
taskStore: { type: string, default: jsonl }
|
|
20
20
|
# When true, a plugin the agent installs waits for POST /extensions/approve.
|
|
@@ -48,6 +48,9 @@ spec:
|
|
|
48
48
|
name: builder
|
|
49
49
|
- id: save
|
|
50
50
|
name: save
|
|
51
|
+
# The panel: a chat button on every page in development (the app's own agent, or Codex, Claude or pi started in this folder). It starts nothing in production.
|
|
52
|
+
- id: agent-panel
|
|
53
|
+
name: agent-panel
|
|
51
54
|
- id: routes
|
|
52
55
|
name: routes
|
|
53
56
|
- id: server
|
|
@@ -7,6 +7,7 @@ import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
|
|
|
7
7
|
import * as router from '../plugins/acryl-router/src/index.ts'
|
|
8
8
|
import * as state from '../plugins/acryl-state/src/index.ts'
|
|
9
9
|
import { agent, memory, model, tools } from '../plugins/agent/index.ts'
|
|
10
|
+
import * as panel from '../plugins/agent-panel/index.ts'
|
|
10
11
|
import { builder } from '../plugins/builder/index.ts'
|
|
11
12
|
import { extensions } from '../plugins/extensions/index.ts'
|
|
12
13
|
import { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
@@ -37,6 +38,7 @@ export const registry = {
|
|
|
37
38
|
sandbox: sandboxPlugin,
|
|
38
39
|
routes,
|
|
39
40
|
'acryl-node-server': nodeServer,
|
|
41
|
+
'agent-panel': panel,
|
|
40
42
|
'acryl-door': door,
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -37,7 +37,7 @@ export const routes = {
|
|
|
37
37
|
.get('/', (c) =>
|
|
38
38
|
(c.req.header('accept') ?? '').includes('text/html')
|
|
39
39
|
? c.html(
|
|
40
|
-
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title></head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). Ask it for a feature
|
|
40
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${APP_NAME}</title></head><body><h1>${APP_NAME}</h1><p>This app runs an agent unless it is switched off (production switches it off, see ACRYL_PRODUCTION_KEEP in AGENTS.md). Ask it for a feature in the agent panel on this page (or with <code>POST /ask</code>).</p>${DOOR_TAG}</body></html>`,
|
|
41
41
|
)
|
|
42
42
|
: c.json({ app: APP_NAME, ok: true }),
|
|
43
43
|
)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
2
3
|
import { mkdirSync } from 'node:fs'
|
|
3
4
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
5
|
import { join } from 'node:path'
|
|
@@ -7,6 +8,9 @@ import { fileURLToPath } from 'node:url'
|
|
|
7
8
|
import { pinnedInstance, withPort } from '../plugins/instance/index.ts'
|
|
8
9
|
import { createApp } from '../src/app.ts'
|
|
9
10
|
|
|
11
|
+
/** The plugin this test has the agent build: a name of its own, so a route the app has (a person's /hello) can never shadow it and fail the template's tests. */
|
|
12
|
+
const NAME = `builder-probe-${randomBytes(3).toString('hex')}`
|
|
13
|
+
|
|
10
14
|
/** Throwaway homes live inside the project (`.acrylblends/` is ignored by git): a sandboxed agent may not write to the system temp folder. */
|
|
11
15
|
const scratch = join(fileURLToPath(new URL('..', import.meta.url)), '.acrylblends', 'test')
|
|
12
16
|
mkdirSync(scratch, { recursive: true })
|
|
@@ -24,28 +28,28 @@ test('the agent builds, updates, protects and removes a plugin in the running ap
|
|
|
24
28
|
})
|
|
25
29
|
return ((await response.json()) as { output: string }).output
|
|
26
30
|
}
|
|
27
|
-
const
|
|
28
|
-
const response = await app.root.router.request(
|
|
31
|
+
const served = async () => {
|
|
32
|
+
const response = await app.root.router.request(`/${NAME}`)
|
|
29
33
|
return response.status === 200
|
|
30
34
|
? ((await response.json()) as { text: string }).text
|
|
31
35
|
: response.status
|
|
32
36
|
}
|
|
33
37
|
try {
|
|
34
|
-
assert.equal(await ask(
|
|
35
|
-
assert.equal(await
|
|
36
|
-
assert.equal(await ask(
|
|
37
|
-
assert.equal(await
|
|
38
|
-
assert.match(await ask(
|
|
39
|
-
assert.equal(await
|
|
38
|
+
assert.equal(await ask(`create route ${NAME} /${NAME} Hello`), `${NAME}: installed, active.`)
|
|
39
|
+
assert.equal(await served(), 'Hello')
|
|
40
|
+
assert.equal(await ask(`update route ${NAME} /${NAME} Bonjour`), `${NAME}: updated, active.`)
|
|
41
|
+
assert.equal(await served(), 'Bonjour')
|
|
42
|
+
assert.match(await ask(`break plugin ${NAME}`), /Previous version restored: true/)
|
|
43
|
+
assert.equal(await served(), 'Bonjour')
|
|
40
44
|
|
|
41
45
|
// The broken version stays on disk, so put the working one back before restarting.
|
|
42
|
-
await ask(
|
|
46
|
+
await ask(`update route ${NAME} /${NAME} Bonjour`)
|
|
43
47
|
await app.stop()
|
|
44
48
|
app = await createApp(chosen, { listen: false })
|
|
45
|
-
assert.equal(await
|
|
49
|
+
assert.equal(await served(), 'Bonjour')
|
|
46
50
|
|
|
47
|
-
assert.equal(await ask(
|
|
48
|
-
assert.equal(await
|
|
51
|
+
assert.equal(await ask(`remove plugin ${NAME}`), `${NAME}: done.`)
|
|
52
|
+
assert.equal(await served(), 404)
|
|
49
53
|
|
|
50
54
|
// Every run is a durable task that owns its tool calls, and the records survived the restart.
|
|
51
55
|
const records = (await (await app.root.router.request(`/tasks`)).json()) as Array<{
|
|
@@ -81,22 +85,22 @@ test('with approval on, an install waits for the owner; approving it makes it li
|
|
|
81
85
|
).json()) as Record<string, unknown>
|
|
82
86
|
try {
|
|
83
87
|
assert.equal(
|
|
84
|
-
(await post('/ask', { question:
|
|
85
|
-
|
|
88
|
+
(await post('/ask', { question: `create route ${NAME} /${NAME} Hello` })).output,
|
|
89
|
+
`${NAME}: waiting for the owner's approval.`,
|
|
86
90
|
)
|
|
87
|
-
assert.equal((await app.root.router.request(
|
|
91
|
+
assert.equal((await app.root.router.request(`/${NAME}`)).status, 404)
|
|
88
92
|
const listed = (await (await app.root.router.request(`/extensions`)).json()) as {
|
|
89
93
|
waitingForApproval: Array<{ name: string }>
|
|
90
94
|
}
|
|
91
95
|
assert.deepEqual(
|
|
92
96
|
listed.waitingForApproval.map((request) => request.name),
|
|
93
|
-
[
|
|
97
|
+
[NAME],
|
|
94
98
|
)
|
|
95
99
|
assert.equal(
|
|
96
|
-
((await post('/extensions/approve', { name:
|
|
100
|
+
((await post('/extensions/approve', { name: NAME })) as { state?: string }).state,
|
|
97
101
|
'active',
|
|
98
102
|
)
|
|
99
|
-
assert.deepEqual(await (await app.root.router.request(
|
|
103
|
+
assert.deepEqual(await (await app.root.router.request(`/${NAME}`)).json(), { text: 'Hello' })
|
|
100
104
|
const tasks = (await (await app.root.router.request(`/tasks`)).json()) as Array<{
|
|
101
105
|
manifest?: string
|
|
102
106
|
}>
|
|
@@ -168,12 +172,13 @@ test('what happens is streamed from the state cells: the run as a task, and the
|
|
|
168
172
|
await app.root.router.request(`/ask`, {
|
|
169
173
|
method: 'POST',
|
|
170
174
|
headers: { 'content-type': 'application/json' },
|
|
171
|
-
body: JSON.stringify({ question:
|
|
175
|
+
body: JSON.stringify({ question: `create route ${NAME} /${NAME} Hello` }),
|
|
172
176
|
})
|
|
173
177
|
const arrived = (stream: { text: string }, what: RegExp) => what.test(stream.text)
|
|
174
178
|
for (
|
|
175
179
|
let waited = 0;
|
|
176
|
-
waited < 3000 &&
|
|
180
|
+
waited < 3000 &&
|
|
181
|
+
!arrived(extensionsStream.seen, new RegExp(`"name":"${NAME}"[^\\n]*"state":"active"`));
|
|
177
182
|
waited += 25
|
|
178
183
|
) {
|
|
179
184
|
await new Promise((done) => setTimeout(done, 25))
|
|
@@ -189,7 +194,7 @@ test('what happens is streamed from the state cells: the run as a task, and the
|
|
|
189
194
|
)
|
|
190
195
|
assert.ok(arrived(tasksStream.seen, /"kind":"tool:install_plugin"/), 'the install is a task')
|
|
191
196
|
assert.ok(
|
|
192
|
-
arrived(extensionsStream.seen,
|
|
197
|
+
arrived(extensionsStream.seen, new RegExp(`"name":"${NAME}"[^\\n]*"state":"active"`)),
|
|
193
198
|
'the extension is reported',
|
|
194
199
|
)
|
|
195
200
|
// In process there is no socket to close: the readers are cancelled, which ends the streams and lets the pumps finish.
|
package/lib/helpers-CvTauQT5.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import "./basic-auth.js";
|
|
2
|
-
import "./combine.js";
|
|
3
|
-
import "./context-storage.js";
|
|
4
|
-
import "./ip-restriction.js";
|
|
5
|
-
import "./jsx-renderer.js";
|
|
6
|
-
import "./jwk-LkzeoaU8.js";
|
|
7
|
-
import "./jwt-CskdhEW9.js";
|
|
8
|
-
import "./method-not-allowed.js";
|
|
9
|
-
import "./serve-static-Cp5adzGo.js";
|
|
10
|
-
import "./timeout.js";
|
|
11
|
-
import "./loopback-only.js";
|
|
12
|
-
export {};
|