acrylblends 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/index.js.map +1 -1
  2. package/docs/framework/cordis/node-server.md +25 -3
  3. package/lib/bun.d.ts +1 -1
  4. package/lib/cloudflare-workers.d.ts +1 -1
  5. package/lib/cordis/door.d.ts +2 -1
  6. package/lib/cordis/door.js +7 -4
  7. package/lib/cordis/inspect.js +1 -1
  8. package/lib/cordis/node-server.d.ts +14 -2
  9. package/lib/cordis/node-server.js +27 -3
  10. package/lib/cordis/router-http.d.ts +25 -2
  11. package/lib/cordis/router-http.js +54 -6
  12. package/lib/cordis/router.d.ts +1 -1
  13. package/lib/cordis/router.js +1 -1
  14. package/lib/cordis/state.js +1 -1
  15. package/lib/door/host.js +1 -1
  16. package/lib/{index-BvFh1Gfx.d.ts → index-C-SklY6u.d.ts} +3 -0
  17. package/lib/{index-BqVQkTnu.d.ts → index-D4nVR3wu.d.ts} +18 -3
  18. package/lib/node-server.d.ts +2 -38
  19. package/lib/node-server.js +2 -2
  20. package/lib/{snapshot-text-D3NspDyT.js → snapshot-text-yaWlezbd.js} +1 -1
  21. package/lib/{src-CwABFr6r.js → src-DNzw0Qsb.js} +3 -2
  22. package/lib/test-websocket-DyeS50Tr.d.ts +136 -0
  23. package/lib/{server-DvgMGtzo.js → test-websocket-ZXHNHQSU.js} +225 -3
  24. package/lib/ws.d.ts +1 -1
  25. package/lib/ws.js +3 -0
  26. package/package.json +6 -2
  27. package/plugins/acryl-door/src/host/door.ts +5 -3
  28. package/plugins/acryl-door/src/index.ts +1 -1
  29. package/plugins/acryl-node-server/plugin.json +1 -1
  30. package/plugins/acryl-node-server/src/adapter/websocket-types.ts +5 -1
  31. package/plugins/acryl-node-server/src/adapter/websocket.ts +33 -5
  32. package/plugins/acryl-node-server/src/index.ts +35 -1
  33. package/plugins/acryl-node-server/src/test-websocket.ts +110 -0
  34. package/plugins/acryl-node-server/src/upgrade-hub.ts +152 -0
  35. package/plugins/acryl-router/src/core/helper/websocket/index.ts +5 -0
  36. package/plugins/acryl-router/src/service.ts +18 -4
  37. package/plugins/acryl-router-http/plugin.json +2 -2
  38. package/plugins/acryl-router-http/src/index.ts +70 -6
  39. package/plugins/agent-panel/client/main.ts +70 -2
  40. package/plugins/agent-panel/drivers.ts +83 -0
  41. package/plugins/agent-panel/headless.ts +154 -0
  42. package/plugins/agent-panel/index.ts +42 -125
  43. package/plugins/agent-panel/plugin.json +1 -1
  44. package/plugins/driver-pty/client/terminal.ts +188 -0
  45. package/plugins/driver-pty/client/vendor/LICENSE +21 -0
  46. package/plugins/driver-pty/client/vendor/LICENSE-addon-fit +19 -0
  47. package/plugins/driver-pty/client/vendor/VERSION +4 -0
  48. package/plugins/driver-pty/client/vendor/addon-fit.d.mts +6 -0
  49. package/plugins/driver-pty/client/vendor/addon-fit.mjs +18 -0
  50. package/plugins/driver-pty/client/vendor/xterm-css.d.mts +2 -0
  51. package/plugins/driver-pty/client/vendor/xterm-css.mjs +2 -0
  52. package/plugins/driver-pty/client/vendor/xterm.d.mts +17 -0
  53. package/plugins/driver-pty/client/vendor/xterm.mjs +53 -0
  54. package/plugins/driver-pty/index.ts +249 -0
  55. package/plugins/driver-pty/plugin.json +13 -0
  56. package/plugins/driver-pty/spawn.ts +36 -0
  57. package/plugins/driver-pty/terminal.ts +84 -0
  58. package/shared/AGENTS.core.md +1 -1
  59. package/shared/DEFAULTS.md +1 -1
  60. package/lib/error-t4N2js2I.d.ts +0 -8
@@ -0,0 +1,110 @@
1
+ /**
2
+ * An in-process WebSocket pair for tests (spec 047 T009): runs an upgrade route the way the adapters do, with no socket and no network. `testWebSocket(fetch, '/path', { headers })`
3
+ * answers either a refusal (`status`, no `socket`) or an accepted connection whose client side sends text frames, receives what the handler sent, and closes.
4
+ *
5
+ * const { socket } = await testWebSocket((request, env) => app.fetch(request, env), '/stream', { headers: { origin: 'http://127.0.0.1:3000' } })
6
+ * socket.send('hello'); expect(socket.received).toEqual(['echo hello'])
7
+ */
8
+ import type { IncomingMessage } from 'node:http'
9
+ import { CONNECTION_SYMBOL_KEY, WAIT_FOR_WEBSOCKET_SYMBOL } from './adapter/websocket.ts'
10
+ import type { WebSocketData, WebSocketLike } from './adapter/websocket-types.ts'
11
+
12
+ type Listener = (...args: never[]) => void
13
+
14
+ /** The client side of an accepted test connection. */
15
+ export interface TestSocket {
16
+ /** Frames the handler sent, in order (text only). */
17
+ readonly received: string[]
18
+ /** The code and reason the handler closed with, when it did. */
19
+ readonly closed: { readonly code: number | undefined; readonly reason: string | undefined } | undefined
20
+ /** Send a text frame to the handler. */
21
+ send(text: string): void
22
+ /** Close from the client side. */
23
+ close(code?: number, reason?: string): void
24
+ }
25
+
26
+ export interface TestWebSocketOptions {
27
+ readonly headers?: Record<string, string>
28
+ /** The socket's peer address and local port, as an adapter would pass them. */
29
+ readonly connection?: { readonly remoteAddress?: string; readonly localPort?: number }
30
+ /** The URL origin the request is made against; default `http://127.0.0.1`. */
31
+ readonly base?: string
32
+ }
33
+
34
+ export interface TestWebSocketResult {
35
+ /** The refusal's status, or 101 when the upgrade was accepted. */
36
+ readonly status: number
37
+ readonly socket?: TestSocket
38
+ }
39
+
40
+ /** A fake `ws` socket: events go both ways without a network. */
41
+ class FakeSocket implements WebSocketLike {
42
+ protocol = ''
43
+ readyState: 0 | 1 | 2 | 3 = 1
44
+ bufferedAmount = 0
45
+ readonly sent: string[] = []
46
+ closedWith: { code: number | undefined; reason: string | undefined } | undefined
47
+ private readonly listeners = new Map<string, Listener[]>()
48
+ on(event: string, listener: Listener): this {
49
+ this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener])
50
+ return this
51
+ }
52
+ off(event: string, listener: Listener): this {
53
+ this.listeners.set(event, (this.listeners.get(event) ?? []).filter(l => l !== listener))
54
+ return this
55
+ }
56
+ emit(event: string, ...args: unknown[]): void {
57
+ for (const listener of this.listeners.get(event) ?? []) (listener as (...a: unknown[]) => void)(...args)
58
+ }
59
+ send(data: WebSocketData | ArrayBufferView): void {
60
+ this.sent.push(typeof data === 'string' ? data : Buffer.from(data as Uint8Array).toString('utf8'))
61
+ }
62
+ close(code?: number, reason?: string): void {
63
+ if (this.readyState === 3) return
64
+ this.closedWith = { code, reason }
65
+ this.readyState = 3
66
+ this.emit('close', code ?? 1005, Buffer.from(reason ?? ''))
67
+ }
68
+ ping(): void {}
69
+ terminate(): void {
70
+ this.close(1006)
71
+ }
72
+ }
73
+
74
+ export async function testWebSocket(
75
+ fetch: (request: Request, env: object) => Promise<Response>,
76
+ path: string,
77
+ options: TestWebSocketOptions = {},
78
+ ): Promise<TestWebSocketResult> {
79
+ const fake = new FakeSocket()
80
+ let waiter: { connectionSymbol: symbol; resolve: (ws: WebSocketLike) => void } | undefined
81
+ const incoming = { headers: options.headers ?? {}, socket: { remoteAddress: options.connection?.remoteAddress, localPort: options.connection?.localPort } } as unknown as IncomingMessage
82
+ const env: Record<symbol | string, unknown> = {
83
+ incoming,
84
+ outgoing: undefined,
85
+ wss: {},
86
+ [WAIT_FOR_WEBSOCKET_SYMBOL]: (_request: IncomingMessage, connectionSymbol: symbol) =>
87
+ new Promise<WebSocketLike>(resolve => {
88
+ waiter = { connectionSymbol, resolve }
89
+ }),
90
+ }
91
+ const headers = new Headers({ upgrade: 'websocket', connection: 'Upgrade', ...options.headers })
92
+ const response = await fetch(new Request(new URL(path, options.base ?? 'http://127.0.0.1'), { headers }), env)
93
+ if (!waiter || waiter.connectionSymbol !== env[CONNECTION_SYMBOL_KEY]) return { status: response.status }
94
+ waiter.resolve(fake)
95
+ // The handler's open event and buffered messages run on the next turns.
96
+ await new Promise(resolve => setTimeout(resolve, 0))
97
+ const socket: TestSocket = {
98
+ received: fake.sent,
99
+ get closed() {
100
+ return fake.closedWith
101
+ },
102
+ send: text => fake.emit('message', text, false),
103
+ close: (code, reason) => {
104
+ if (fake.readyState === 3) return
105
+ fake.readyState = 3
106
+ fake.emit('close', code ?? 1005, Buffer.from(reason ?? ''))
107
+ },
108
+ }
109
+ return { status: 101, socket }
110
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * WebSocket upgrades for the Cordis adapters (spec 047 T009): one hub per HTTP transport. It owns the `ws` server (text and size limits, no compression), runs the ported
3
+ * `setupWebSocket` on whatever emits `upgrade` events (a `node:http` server, or the Host web server's exact-path upgrade routes), pings every socket and ends the ones that stop
4
+ * answering, and gives each mounting plugin an `upgradeWebSocket` whose accepted sockets belong to that plugin: when it unloads they are closed with 1001 within the grace, and
5
+ * what is left is terminated. The handler sees `close` exactly once.
6
+ */
7
+ import { EventEmitter } from 'node:events'
8
+ import { createRequire } from 'node:module'
9
+ import type { UpgradeWebSocket } from 'acryl-router/helpers'
10
+ import type { FetchCallback } from './adapter/types.ts'
11
+ import { type UpgradeSource, setupWebSocket, upgradeWebSocket as portedUpgradeWebSocket } from './adapter/websocket.ts'
12
+ import type { WebSocketLike, WebSocketServerLike } from './adapter/websocket-types.ts'
13
+
14
+ export interface UpgradeHubOptions {
15
+ /** The largest frame accepted, in bytes; a bigger one closes the socket with 1009. Default 1 MiB. */
16
+ maxPayload?: number
17
+ /** Milliseconds between pings; a peer that did not answer the previous one is terminated. Default 30 000; 0 turns the heartbeat off. */
18
+ heartbeat?: number
19
+ /** Milliseconds a closing socket gets to finish its close handshake before it is terminated. Default 2000. */
20
+ grace?: number
21
+ }
22
+
23
+ type WebSocketServerConstructor = new (options: {
24
+ noServer: true
25
+ maxPayload: number
26
+ perMessageDeflate: false
27
+ }) => WebSocketServerLike
28
+
29
+ /** The `ws` package, loaded when the first hub is made: an app that never upgrades does not need it. */
30
+ function loadWs(): WebSocketServerConstructor {
31
+ try {
32
+ return (createRequire(import.meta.url)('ws') as { WebSocketServer: WebSocketServerConstructor }).WebSocketServer
33
+ } catch (error) {
34
+ throw new Error(`WebSocket upgrades need the "ws" package (npm install ws): ${error instanceof Error ? error.message : String(error)}`)
35
+ }
36
+ }
37
+
38
+ /** The part of the router a hub calls: run a request with connection facts and the adapter's bindings. */
39
+ export interface UpgradeRouter {
40
+ fetch(request: Request, connection: { remoteAddress?: string | undefined; localPort?: number | undefined }, bindings?: object): Promise<Response>
41
+ }
42
+
43
+ /** What a hub needs of a Cordis context: tying a disposer to the plugin that asked. */
44
+ export interface Owner {
45
+ effect(execute: () => () => unknown, label?: string): unknown
46
+ }
47
+
48
+ export class UpgradeHub {
49
+ readonly server: WebSocketServerLike
50
+ private readonly all = new Set<WebSocketLike>()
51
+ private readonly alive = new WeakMap<WebSocketLike, boolean>()
52
+ private readonly grace: number
53
+ private timer: ReturnType<typeof setInterval> | undefined
54
+ private closed = false
55
+
56
+ constructor(options: UpgradeHubOptions = {}) {
57
+ const Server = loadWs()
58
+ this.server = new Server({ noServer: true, maxPayload: options.maxPayload ?? 1024 * 1024, perMessageDeflate: false })
59
+ this.grace = options.grace ?? 2000
60
+ this.server.on('connection', ws => {
61
+ this.all.add(ws)
62
+ this.alive.set(ws, true)
63
+ ws.on('pong', () => this.alive.set(ws, true))
64
+ ws.on('close', () => this.all.delete(ws))
65
+ })
66
+ const heartbeat = options.heartbeat ?? 30_000
67
+ if (heartbeat > 0) {
68
+ this.timer = setInterval(() => {
69
+ for (const ws of this.all) {
70
+ if (this.alive.get(ws) === false) {
71
+ ws.terminate()
72
+ continue
73
+ }
74
+ this.alive.set(ws, false)
75
+ ws.ping()
76
+ }
77
+ }, heartbeat)
78
+ this.timer.unref()
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Accept upgrades from `source` and answer them with `router`: the request runs through the router's middleware with the socket's peer address and local port as connection
84
+ * facts (so the same loopback, origin and host checks work), and the upgrade bindings travel in `c.env` for `upgradeWebSocket`.
85
+ */
86
+ attach(source: UpgradeSource, router: UpgradeRouter): void {
87
+ const fetchCallback: FetchCallback = (request, env) => {
88
+ const { incoming } = env as { incoming: { socket: { remoteAddress?: string; localPort?: number } } }
89
+ return router.fetch(request, { remoteAddress: incoming.socket.remoteAddress, localPort: incoming.socket.localPort }, env)
90
+ }
91
+ setupWebSocket({ server: source, fetchCallback, wss: this.server })
92
+ }
93
+
94
+ /** How many sockets are open. */
95
+ get size(): number {
96
+ return this.all.size
97
+ }
98
+
99
+ /** `upgradeWebSocket` for the plugin `owner`: the sockets it accepts are closed with 1001 when that plugin unloads. */
100
+ upgradeWebSocketFor(owner: Owner): UpgradeWebSocket<WebSocketLike> {
101
+ const mine = new Set<WebSocketLike>()
102
+ owner.effect(() => () => this.closeAll(mine, 1001, 'going away'), 'upgrade sockets')
103
+ const track = (ws: WebSocketLike): void => {
104
+ mine.add(ws)
105
+ ws.on('close', () => mine.delete(ws))
106
+ }
107
+ const owned = (...args: unknown[]): unknown => {
108
+ const [first, second, third] = args as [unknown, Record<string, unknown> | undefined, Record<string, unknown> | undefined]
109
+ return typeof first === 'function'
110
+ ? (portedUpgradeWebSocket as (...a: unknown[]) => unknown)(first, { ...second, track })
111
+ : (portedUpgradeWebSocket as (...a: unknown[]) => unknown)(first, second, { ...third, track })
112
+ }
113
+ return owned as UpgradeWebSocket<WebSocketLike>
114
+ }
115
+
116
+ /** Close `sockets` with `code`, wait for their close handshakes up to the grace, terminate what is left. */
117
+ async closeAll(sockets: Iterable<WebSocketLike>, code: number, reason: string): Promise<void> {
118
+ const open = [...sockets].filter(ws => ws.readyState < 3)
119
+ await Promise.all(
120
+ open.map(
121
+ ws =>
122
+ new Promise<void>(resolve => {
123
+ const timer = setTimeout(() => {
124
+ ws.terminate()
125
+ resolve()
126
+ }, this.grace)
127
+ ws.on('close', () => {
128
+ clearTimeout(timer)
129
+ resolve()
130
+ })
131
+ if (ws.readyState === 1) ws.close(code, reason)
132
+ else if (ws.readyState === 3) {
133
+ clearTimeout(timer)
134
+ resolve()
135
+ }
136
+ }),
137
+ ),
138
+ )
139
+ }
140
+
141
+ /** End everything: every socket closes with 1001, the heartbeat stops, the `ws` server closes. */
142
+ async close(): Promise<void> {
143
+ if (this.closed) return
144
+ this.closed = true
145
+ if (this.timer !== undefined) clearInterval(this.timer)
146
+ await this.closeAll(this.all, 1001, 'going away')
147
+ this.server.close()
148
+ }
149
+ }
150
+
151
+ /** A stand-in for a server's `upgrade` events, for transports that dispatch upgrades themselves (the Host web server's exact-path routes). */
152
+ export class UpgradeEmitter extends EventEmitter {}
@@ -54,6 +54,8 @@ export interface WSContextInit<T = unknown> {
54
54
 
55
55
  raw?: T
56
56
  readyState: WSReadyState
57
+ /** Bytes queued to send and not yet written to the socket; a sender that cares about back-pressure reads it. */
58
+ bufferedAmount?: number
57
59
  url?: string | URL | null
58
60
  protocol?: string | null
59
61
  }
@@ -84,6 +86,9 @@ export class WSContext<T = unknown> {
84
86
  get readyState(): WSReadyState {
85
87
  return this.#init.readyState
86
88
  }
89
+ get bufferedAmount(): number {
90
+ return this.#init.bufferedAmount ?? 0
91
+ }
87
92
  url: URL | null
88
93
  protocol: string | null
89
94
  close(code?: number, reason?: string) {
@@ -10,6 +10,7 @@
10
10
  import { Context, Service } from '@deepseek-ai/cordis'
11
11
  import z from '@deepseek-ai/schemastery'
12
12
  import type { ConnectionInfo, RouterBindings } from './connection.ts'
13
+ import type { UpgradeWebSocket } from './core/helper/websocket/index.ts'
13
14
  import { Acryl } from './core/app.ts'
14
15
  import type { ExecutionContext } from './core/context.ts'
15
16
  import { HTTPException } from './core/http-exception.ts'
@@ -40,8 +41,19 @@ export const Config: z<Partial<Config>, Config> = z.object({
40
41
  * served over HTTP without depending on the adapter package.
41
42
  */
42
43
  export interface RouterHttpService {
43
- /** Serve `prefix` and everything below it through the router; returns a remover. The mount also goes away when the calling plugin unloads. */
44
- mount(prefix: string): () => void
44
+ /**
45
+ * Serve `prefix` and everything below it through the router; returns a remover. The mount also goes away when the calling plugin unloads.
46
+ * `options.upgrade` lists the exact paths below the prefix that may be upgraded to a WebSocket (each must be inside the prefix); no other path of the mount accepts an upgrade.
47
+ */
48
+ mount(prefix: string, options?: { readonly upgrade?: readonly string[] }): () => void
49
+ /**
50
+ * `upgradeWebSocket` for the calling plugin: `app.get('/terminal', upgrade((c) => ({ onMessage(event, ws) { ... } })))` on a route the plugin mounted with `upgrade: ['/terminal']`.
51
+ * Checks go in ordinary middleware on the route; a handler that returns a `Response` refuses with that status before any handshake. The sockets it accepts belong to the calling
52
+ * plugin: when it unloads they are closed with 1001 and the handler sees `close` once. Needs the `ws` package.
53
+ */
54
+ upgrades(): UpgradeWebSocket
55
+ /** How many WebSocket connections are open now. */
56
+ readonly sockets: number
45
57
  }
46
58
 
47
59
  declare module '@deepseek-ai/cordis' {
@@ -260,8 +272,9 @@ export class Router extends Service {
260
272
  * Run a request in process. Never throws: failures become a 500, a disposed router answers 503. `connection` describes the transport
261
273
  * (an HTTP adapter passes the socket's peer address and local port); an in-process caller omits it. Handlers read it with
262
274
  * `connectionOf(c)`, abort with `c.req.raw.signal` and keep the request in flight with `c.executionCtx.waitUntil(task)`.
275
+ * `bindings` is an object an adapter hands to the handler as `c.env` (an upgrade request's socket hooks, for `upgradeWebSocket`); `connection` is added to it.
263
276
  */
264
- fetch(request: Request, connection: ConnectionInfo = {}): Promise<Response> {
277
+ fetch(request: Request, connection: ConnectionInfo = {}, bindings?: object): Promise<Response> {
265
278
  if (this.state.closing) return Promise.resolve(new Response('Service Unavailable', { status: 503 }))
266
279
  const controller = new AbortController()
267
280
  const forward = (): void => controller.abort()
@@ -273,7 +286,8 @@ export class Router extends Service {
273
286
  passThroughOnException: () => {},
274
287
  props: {},
275
288
  }
276
- const env: RouterBindings = { connection }
289
+ // `bindings` is used as `c.env` itself (not copied): the upgrade helper records its handshake state on it, and the adapter reads that state back after the handler answered.
290
+ const env: RouterBindings = bindings === undefined ? { connection } : Object.assign(bindings, { connection })
277
291
  let bodyDone: Promise<void> = Promise.resolve()
278
292
  let endBody = (): void => {}
279
293
  let resolveSettled!: (value: Promise<unknown>) => void
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "acryl-router-http",
3
- "description": "The serve() and request bridge that acryl-node-server uses to put the router on node:http. Mounting it as a plugin needs the DSH web server, which a generated app does not have. Vendored from ACRYL.",
4
- "requires": ["acryl-router"],
3
+ "description": "Mounts router prefixes on the ACRYL Host's web server, with exact-path WebSocket upgrades owned by the mounting plugin, and the serve() and request bridge. Mounting it as a plugin needs the Host web server, which a generated app does not have. Vendored from ACRYL.",
4
+ "requires": ["acryl-router", "acryl-node-server"],
5
5
  "inject": ["router"],
6
6
  "provides": [],
7
7
  "dependencies": {
@@ -5,8 +5,11 @@
5
5
  */
6
6
 
7
7
  import type { IncomingMessage, ServerResponse } from 'node:http'
8
+ import type { Duplex } from 'node:stream'
8
9
  import type { RouterHttpService } from 'acryl-router'
10
+ import type { UpgradeWebSocket } from 'acryl-router/helpers'
9
11
  import { Context, Service } from '@deepseek-ai/cordis'
12
+ import { UpgradeEmitter, UpgradeHub } from 'acryl-node-server'
10
13
  import { serve } from './serve.ts'
11
14
 
12
15
  /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
@@ -25,6 +28,11 @@ export interface WebServerPort {
25
28
  readonly path: string
26
29
  readonly handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
27
30
  }): () => void
31
+ /** One exact-path upgrade claim (`WebUpgradeRoute` of the web server); optional because an older web server has none. */
32
+ registerUpgrade?(route: {
33
+ readonly path: string
34
+ readonly handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
35
+ }): () => void
28
36
  }
29
37
 
30
38
  const PREFIX_PATTERN = /^\/[A-Za-z0-9._~\-/]*[A-Za-z0-9._~-]$/
@@ -32,9 +40,14 @@ const PREFIX_PATTERN = /^\/[A-Za-z0-9._~\-/]*[A-Za-z0-9._~-]$/
32
40
  export class RouterHttp extends Service implements RouterHttpService {
33
41
  private readonly router: Context['router']
34
42
  private readonly web: WebServerPort
43
+ private hub: UpgradeHub | undefined
44
+ /** The adapter's own context: the hub lives as long as the adapter, whichever plugin asked for it first (`this.ctx` is the caller's). */
45
+ private readonly home: Context
46
+ private readonly upgradesIn = new UpgradeEmitter()
35
47
 
36
48
  constructor(ctx: Context) {
37
49
  super(ctx, 'routerHttp')
50
+ this.home = ctx
38
51
  this.router = ctx.router
39
52
  this.web = ctx.get('webServer') as WebServerPort
40
53
  }
@@ -42,17 +55,68 @@ export class RouterHttp extends Service implements RouterHttpService {
42
55
  /**
43
56
  * Serve `prefix` and everything below it through the router. Returns a remover; the mount also goes away when the
44
57
  * calling plugin unloads. A prefix another route already claims throws, as the webServer treats it as misconfiguration.
58
+ * `options.upgrade` names the exact paths that may be upgraded to a WebSocket: each must be inside the prefix, and each is one
59
+ * exact-path upgrade route of the web server, removed with the mount. No other path of the mount accepts an upgrade.
45
60
  */
46
- mount(prefix: string): () => void {
61
+ mount(prefix: string, options: { readonly upgrade?: readonly string[] } = {}): () => void {
47
62
  if (!PREFIX_PATTERN.test(prefix)) throw new TypeError(`acryl-router-http: "${prefix}" is not a mountable path prefix`)
48
- const remove = this.web.register({
49
- kind: 'prefix',
50
- path: prefix,
51
- handler: (req, res) => serve(this.router, req, res),
52
- })
63
+ const upgrade = options.upgrade ?? []
64
+ for (const path of upgrade) {
65
+ if (!PREFIX_PATTERN.test(path) || !(path === prefix || path.startsWith(`${prefix}/`)))
66
+ throw new TypeError(`acryl-router-http: the upgrade path "${path}" is not inside the prefix "${prefix}"`)
67
+ }
68
+ if (upgrade.length > 0 && this.web.registerUpgrade === undefined)
69
+ throw new TypeError('acryl-router-http: this web server cannot take WebSocket upgrades (it has no registerUpgrade)')
70
+ const removers: Array<() => void> = []
71
+ // Claim the upgrade paths first: a duplicate throws before anything is mounted, so a refused mount leaves nothing behind.
72
+ try {
73
+ if (upgrade.length > 0) this.ensureHub()
74
+ for (const path of upgrade) {
75
+ removers.push(
76
+ (this.web.registerUpgrade as NonNullable<WebServerPort['registerUpgrade']>)({
77
+ path,
78
+ handler: (req, socket, head) => { this.upgradesIn.emit('upgrade', req, socket, head) },
79
+ }),
80
+ )
81
+ }
82
+ removers.push(
83
+ this.web.register({
84
+ kind: 'prefix',
85
+ path: prefix,
86
+ handler: (req, res) => serve(this.router, req, res),
87
+ }),
88
+ )
89
+ } catch (error) {
90
+ for (const remove of removers) remove()
91
+ throw error
92
+ }
93
+ const remove = (): void => { for (const undo of removers.splice(0)) undo() }
53
94
  this.ctx.effect(() => () => { remove() }, `acryl-router-http: mount ${prefix}`)
54
95
  return remove
55
96
  }
97
+
98
+ /**
99
+ * `upgradeWebSocket` for the calling plugin: the sockets its routes accept are closed with 1001 when it unloads. Needs the `ws` package.
100
+ * The route must be on a path the plugin mounted with `upgrade: [path]`.
101
+ */
102
+ upgrades(): UpgradeWebSocket {
103
+ return this.ensureHub().upgradeWebSocketFor(this.ctx) as unknown as UpgradeWebSocket
104
+ }
105
+
106
+ /** How many WebSocket connections are open now. */
107
+ get sockets(): number {
108
+ return this.hub?.size ?? 0
109
+ }
110
+
111
+ private ensureHub(): UpgradeHub {
112
+ if (!this.hub) {
113
+ const hub = new UpgradeHub()
114
+ hub.attach(this.upgradesIn, this.router)
115
+ this.hub = hub
116
+ this.home.effect(() => () => hub.close(), 'acryl-router-http: WebSocket hub')
117
+ }
118
+ return this.hub
119
+ }
56
120
  }
57
121
 
58
122
  export function apply(ctx: Context): void {
@@ -4,7 +4,7 @@ const PREFIX = '/api/agent-panel'
4
4
 
5
5
  interface Session {
6
6
  token: string
7
- drivers: string[]
7
+ drivers: Array<{ name: string; kind: 'chat' | 'terminal' }>
8
8
  }
9
9
  interface PanelEvent {
10
10
  event: string
@@ -42,6 +42,8 @@ section { position: fixed; top: 0; right: 0; bottom: 0; width: min(380px, 100vw)
42
42
  section.shown { display: flex }
43
43
  header { display: flex; gap: 8px; align-items: center; padding: 8px 12px; border-bottom: 1px solid #e5e7eb }
44
44
  header strong { flex: 1 }
45
+ #term { flex: 1; min-height: 0; background: #000 }
46
+ #term[hidden], form[hidden], #log[hidden] { display: none }
45
47
  #log { flex: 1; overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px }
46
48
  .msg { white-space: pre-wrap; padding: 8px 10px; border-radius: 8px; background: #f3f4f6 }
47
49
  .me { background: #dbeafe; align-self: flex-end }
@@ -65,8 +67,13 @@ async function start(): Promise<void> {
65
67
  shadow.innerHTML = `<style>${css}</style>
66
68
  <button class="open" type="button">Ask the agent</button>
67
69
  <section aria-label="Agent panel">
68
- <header><strong>Agent</strong><select aria-label="Agent">${session.drivers.map((d) => `<option>${d}</option>`).join('')}</select><button class="close" type="button" aria-label="Close">×</button></header>
70
+ <header><strong>Agent</strong><select aria-label="Agent">${session.drivers
71
+ .map((d) => `<option>${d.name}</option>`)
72
+ .join(
73
+ '',
74
+ )}</select><button class="termstop" type="button" hidden>Stop terminal</button><button class="close" type="button" aria-label="Close">×</button></header>
69
75
  <div id="log" role="log"></div>
76
+ <div id="term" hidden></div>
70
77
  <form><textarea aria-label="Message" placeholder="What should the app do?"></textarea>
71
78
  <div class="row"><button class="send" type="submit">Send</button><button class="stop" type="button" disabled>Stop</button></div></form>
72
79
  </section>`
@@ -77,6 +84,54 @@ async function start(): Promise<void> {
77
84
  const send = $<HTMLButtonElement>('.send')
78
85
  const stop = $<HTMLButtonElement>('.stop')
79
86
  const driver = $<HTMLSelectElement>('select')
87
+ const term = $<HTMLElement>('#term')
88
+ const termStop = $<HTMLButtonElement>('.termstop')
89
+ termStop.addEventListener('click', () => void terminal?.stop())
90
+ const form = $<HTMLFormElement>('form')
91
+ const kindOf = (name: string): 'chat' | 'terminal' =>
92
+ session.drivers.find((d) => d.name === name)?.kind ?? 'chat'
93
+ let terminal: { detach(): void; stop(): Promise<void> } | undefined
94
+ // A terminal driver swaps the chat for the driver's own terminal (loaded from the plugin that provides it, only when asked for).
95
+ const choose = async (): Promise<void> => {
96
+ terminal?.detach()
97
+ terminal = undefined
98
+ const isTerminal = kindOf(driver.value) === 'terminal'
99
+ log.hidden = isTerminal
100
+ form.hidden = isTerminal
101
+ term.hidden = !isTerminal
102
+ termStop.hidden = !isTerminal
103
+ if (!isTerminal) return
104
+ try {
105
+ const url = '/api/driver-pty/client/terminal.ts'
106
+ const { openTerminal } = (await import(url)) as {
107
+ openTerminal(options: {
108
+ container: HTMLElement
109
+ token: string
110
+ driver: string
111
+ status(text: string): void
112
+ onChange(change: { extension: string; action: string }): void
113
+ }): Promise<{ detach(): void; stop(): Promise<void> }>
114
+ }
115
+ terminal = await openTerminal({
116
+ container: term,
117
+ token: session.token,
118
+ driver: driver.value,
119
+ status: (text) => line(text, 'tool'),
120
+ onChange: (change) => {
121
+ line(`plugin ${change.extension}: ${change.action}`, 'tool')
122
+ line('Reloading the page to show it.', 'tool')
123
+ setTimeout(() => location.reload(), 900)
124
+ },
125
+ })
126
+ } catch (error) {
127
+ line(
128
+ `The terminal could not open: ${error instanceof Error ? error.message : String(error)}`,
129
+ 'err',
130
+ )
131
+ log.hidden = false
132
+ }
133
+ }
134
+ driver.addEventListener('change', () => void choose())
80
135
  const show = (open: boolean): void => {
81
136
  panel.classList.toggle('shown', open)
82
137
  if (open) input.focus()
@@ -154,6 +209,19 @@ async function start(): Promise<void> {
154
209
  }
155
210
  })
156
211
  document.body.append(host)
212
+ // A terminal that was open before a reload is still running on the server: show it again.
213
+ try {
214
+ const remembered = JSON.parse(sessionStorage.getItem('driver-pty-session') ?? 'null') as {
215
+ driver?: string
216
+ } | null
217
+ if (remembered?.driver && kindOf(remembered.driver) === 'terminal') {
218
+ driver.value = remembered.driver
219
+ show(true)
220
+ void choose()
221
+ }
222
+ } catch {
223
+ // No storage: nothing to show again.
224
+ }
157
225
  }
158
226
 
159
227
  void start()
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The drivers the agent panel can offer, in one registry (the `drivers` service the panel provides). A driver is one of two kinds of session:
3
+ *
4
+ * - `chat`: a message in, the panel's own events out (`text`, `tool`, `error`). The app's agent, the headless coding agents and a harness chat are chat drivers.
5
+ * - `terminal`: bytes in, bytes out, a real pseudo-terminal (the `driver-pty` plugin). Nothing is forced to speak bytes.
6
+ *
7
+ * The panel lists every registered driver and picks the view by `kind`. A plugin that provides drivers injects `drivers`: with no panel mounted it is pending,
8
+ * and a panel without such a plugin simply lists none of its drivers.
9
+ */
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+
12
+ /** One event of a chat run. The panel adds `change` (a plugin was installed or removed) and `done` itself. */
13
+ export type ChatEvent =
14
+ | { readonly event: 'text'; readonly data: { readonly text: string } }
15
+ | { readonly event: 'tool'; readonly data: Record<string, unknown> }
16
+ | { readonly event: 'error'; readonly data: { readonly message: string } }
17
+
18
+ export interface ChatDriver {
19
+ readonly kind: 'chat'
20
+ readonly name: string
21
+ /** Runs one message. Aborting `signal` stops the run. */
22
+ start(options: { message: string; signal: AbortSignal }): AsyncIterable<ChatEvent>
23
+ }
24
+
25
+ /** A live pseudo-terminal session. */
26
+ export interface TerminalSession {
27
+ readonly id: string
28
+ onData(listener: (chunk: string) => void): () => void
29
+ onExit(listener: (code: number | null) => void): () => void
30
+ write(data: string): void
31
+ resize(cols: number, rows: number): void
32
+ /** Resolves when the process (group) is gone. */
33
+ kill(): Promise<void>
34
+ }
35
+
36
+ export interface TerminalDriver {
37
+ readonly kind: 'terminal'
38
+ readonly name: string
39
+ start(options: {
40
+ cwd: string
41
+ env: NodeJS.ProcessEnv
42
+ cols: number
43
+ rows: number
44
+ }): TerminalSession
45
+ }
46
+
47
+ export type Driver = ChatDriver | TerminalDriver
48
+
49
+ export interface Drivers {
50
+ /** Add a driver. Returns the function that removes it; a plugin registers inside `ctx.effect` so the driver leaves with it. */
51
+ register(driver: Driver): () => void
52
+ list(): Driver[]
53
+ get(name: string): Driver | undefined
54
+ /** Whether a request may use the drivers' endpoints: it comes from the app's own page and carries the panel's token. */
55
+ authorized(headers: Headers): boolean
56
+ }
57
+
58
+ declare module '@deepseek-ai/cordis' {
59
+ interface Context {
60
+ drivers: Drivers
61
+ }
62
+ }
63
+
64
+ export function createDrivers(authorized: (headers: Headers) => boolean = () => true): Drivers {
65
+ const table = new Map<string, Driver>()
66
+ return {
67
+ register(driver) {
68
+ if (table.has(driver.name)) throw new Error(`driver "${driver.name}" is already registered`)
69
+ table.set(driver.name, driver)
70
+ return () => void (table.get(driver.name) === driver && table.delete(driver.name))
71
+ },
72
+ list: () => [...table.values()],
73
+ get: (name) => table.get(name),
74
+ authorized,
75
+ }
76
+ }
77
+
78
+ /** Provide the registry on `ctx` (the panel does). */
79
+ export function provideDrivers(ctx: Context, authorized: (headers: Headers) => boolean): Drivers {
80
+ const registry = createDrivers(authorized)
81
+ ;(ctx as Context).provide('drivers', registry)
82
+ return registry
83
+ }