acrylblends 0.11.0 → 1.0.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 (93) hide show
  1. package/README.md +131 -53
  2. package/dist/index.d.ts +29 -2
  3. package/dist/index.js +450 -69
  4. package/dist/index.js.map +1 -1
  5. package/docs/framework/README.md +5 -3
  6. package/docs/framework/agents/index.md +1 -1
  7. package/docs/framework/api/acryl.md +5 -17
  8. package/docs/framework/api/context.md +12 -61
  9. package/docs/framework/api/presets.md +1 -1
  10. package/docs/framework/api/request.md +3 -3
  11. package/docs/framework/api/summary.md +1 -1
  12. package/docs/framework/concepts/web-standard.md +6 -16
  13. package/docs/framework/cordis/acryl-in-a-cordis-app.md +13 -8
  14. package/docs/framework/cordis/migration-from-0-4.md +1 -1
  15. package/docs/framework/cordis/migration-to-1-0.md +45 -0
  16. package/docs/framework/cordis/node-server.md +2 -2
  17. package/docs/framework/getting-started/basic.md +15 -10
  18. package/docs/framework/getting-started/desktop.md +1 -1
  19. package/docs/framework/getting-started/other-runtimes.md +10 -14
  20. package/docs/framework/guides/create-acrylblends.md +4 -2
  21. package/docs/framework/guides/data.md +1 -1
  22. package/docs/framework/guides/faq.md +1 -1
  23. package/docs/framework/guides/jsx-dom.md +1 -1
  24. package/docs/framework/guides/production.md +43 -0
  25. package/docs/framework/guides/rpc.md +3 -11
  26. package/docs/framework/helpers/adapter.md +13 -43
  27. package/docs/framework/helpers/conninfo.md +1 -36
  28. package/docs/framework/helpers/streaming.md +1 -1
  29. package/docs/framework/helpers/websocket.md +4 -11
  30. package/docs/framework/middleware/builtin/basic-auth.md +1 -1
  31. package/docs/framework/middleware/builtin/cache.md +2 -22
  32. package/docs/framework/middleware/builtin/compress.md +1 -1
  33. package/docs/framework/middleware/builtin/context-storage.md +10 -14
  34. package/docs/framework/middleware/builtin/cors.md +1 -2
  35. package/docs/framework/middleware/builtin/ip-restriction.md +1 -1
  36. package/docs/framework/middleware/builtin/jsx-renderer.md +1 -1
  37. package/docs/framework/middleware/builtin/logger.md +1 -1
  38. package/docs/framework/middleware/builtin/loopback-only.md +1 -1
  39. package/docs/framework/middleware/builtin/mount.md +1 -1
  40. package/docs/framework/middleware/builtin/request-id.md +13 -20
  41. package/docs/framework/middleware/builtin/timing.md +1 -2
  42. package/lib/cordis/door.d.ts +5 -0
  43. package/lib/cordis/door.js +8 -4
  44. package/lib/cordis/inspect.js +1 -1
  45. package/lib/cordis/router-http.js +1 -1
  46. package/lib/cordis/router.d.ts +1 -2
  47. package/lib/cordis/router.js +2 -3
  48. package/lib/cordis/state.js +1 -1
  49. package/lib/door/host.js +1 -1
  50. package/lib/drivers.d.ts +66 -0
  51. package/lib/drivers.js +16 -0
  52. package/lib/index.d.ts +1 -2
  53. package/lib/index.js +1 -2
  54. package/lib/{snapshot-text-yaWlezbd.js → snapshot-text-DmE0YfeZ.js} +2 -2
  55. package/lib/{src-DNzw0Qsb.js → src-2Z4mJg0x.js} +0 -1
  56. package/package.json +5 -121
  57. package/plugins/acryl-door/src/index.ts +8 -0
  58. package/plugins/acryl-router/src/index.ts +0 -1
  59. package/plugins/agent-panel/drivers.ts +1 -16
  60. package/plugins/agent-panel/index.ts +1 -1
  61. package/plugins/agent-panel/registry.ts +16 -0
  62. package/plugins/driver-acp/README.md +53 -0
  63. package/plugins/driver-acp/chat.ts +258 -0
  64. package/plugins/driver-acp/connection.ts +239 -0
  65. package/plugins/driver-acp/index.ts +65 -0
  66. package/plugins/driver-acp/plugin.json +12 -0
  67. package/plugins/driver-acp/provenance.json +20 -0
  68. package/plugins/driver-pty/index.ts +1 -0
  69. package/shared/library-history.json +464 -0
  70. package/docs/framework/getting-started/aws-lambda.md +0 -115
  71. package/docs/framework/getting-started/cloudflare-pages.md +0 -53
  72. package/docs/framework/getting-started/cloudflare-workers.md +0 -67
  73. package/docs/framework/getting-started/lambda-edge.md +0 -41
  74. package/docs/framework/getting-started/netlify.md +0 -25
  75. package/docs/framework/getting-started/service-worker.md +0 -33
  76. package/docs/framework/getting-started/vercel.md +0 -28
  77. package/lib/aws-lambda.d.ts +0 -324
  78. package/lib/aws-lambda.js +0 -357
  79. package/lib/cloudflare-pages.d.ts +0 -58
  80. package/lib/cloudflare-pages.js +0 -78
  81. package/lib/cloudflare-workers.d.ts +0 -21
  82. package/lib/cloudflare-workers.js +0 -80
  83. package/lib/deprecated-BP4fPt38.js +0 -10
  84. package/lib/deprecated-CTy4N7ng.d.ts +0 -12
  85. package/lib/lambda-edge.d.ts +0 -92
  86. package/lib/lambda-edge.js +0 -105
  87. package/lib/netlify.d.ts +0 -29
  88. package/lib/netlify.js +0 -30
  89. package/lib/service-worker.d.ts +0 -48
  90. package/lib/service-worker.js +0 -38
  91. package/lib/vercel.d.ts +0 -10
  92. package/lib/vercel.js +0 -9
  93. package/plugins/acryl-router/src/deprecated.ts +0 -15
@@ -5,11 +5,11 @@ import { r as streamSSE } from "./streaming-DHST3Scg.js";
5
5
  import { loopbackAuthority, loopbackOnly } from "./loopback-only.js";
6
6
  import "./helpers-CvTauQT5.js";
7
7
  import { UiControlError, WINDOW_ID, parseChannelToHost } from "./door/protocol.js";
8
- import "./src-DNzw0Qsb.js";
8
+ import "./src-2Z4mJg0x.js";
9
9
  import "./middleware-CPQ8himN.js";
10
10
  import { dirname, join } from "node:path";
11
- import { timingSafeEqual } from "node:crypto";
12
11
  import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync } from "node:fs";
12
+ import { timingSafeEqual } from "node:crypto";
13
13
  //#region ../../plugins/acryl-door/src/host/channel.ts
14
14
  /**
15
15
  * The Host's side of the page channel: the connected pages of the app, and calls that go to the current one (moved from
@@ -4,7 +4,6 @@ import { COMPOSED_HANDLER } from "./utils/constants.js";
4
4
  import { t as TrieRouter } from "./trie-router-CfFbapzL.js";
5
5
  import { t as Acryl } from "./app-BM0rTCp7.js";
6
6
  import "./core-CaY8XDZ6.js";
7
- import "./deprecated-BP4fPt38.js";
8
7
  import "./connection-C7xgP1hP.js";
9
8
  import { Service } from "@deepseek-ai/cordis";
10
9
  import z from "@deepseek-ai/schemastery";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "0.11.0",
3
+ "version": "1.0.0",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,10 +22,6 @@
22
22
  "types": "./lib/types.d.ts",
23
23
  "default": "./lib/types.js"
24
24
  },
25
- "./acryl-base": {
26
- "types": "./lib/acryl-base.d.ts",
27
- "default": "./lib/acryl-base.js"
28
- },
29
25
  "./http-exception": {
30
26
  "types": "./lib/http-exception.d.ts",
31
27
  "default": "./lib/http-exception.js"
@@ -190,38 +186,10 @@
190
186
  "types": "./lib/testing.d.ts",
191
187
  "default": "./lib/testing.js"
192
188
  },
193
- "./aws-lambda": {
194
- "types": "./lib/aws-lambda.d.ts",
195
- "default": "./lib/aws-lambda.js"
196
- },
197
189
  "./bun": {
198
190
  "types": "./lib/bun.d.ts",
199
191
  "default": "./lib/bun.js"
200
192
  },
201
- "./cloudflare-pages": {
202
- "types": "./lib/cloudflare-pages.d.ts",
203
- "default": "./lib/cloudflare-pages.js"
204
- },
205
- "./cloudflare-workers": {
206
- "types": "./lib/cloudflare-workers.d.ts",
207
- "default": "./lib/cloudflare-workers.js"
208
- },
209
- "./lambda-edge": {
210
- "types": "./lib/lambda-edge.d.ts",
211
- "default": "./lib/lambda-edge.js"
212
- },
213
- "./netlify": {
214
- "types": "./lib/netlify.d.ts",
215
- "default": "./lib/netlify.js"
216
- },
217
- "./service-worker": {
218
- "types": "./lib/service-worker.d.ts",
219
- "default": "./lib/service-worker.js"
220
- },
221
- "./vercel": {
222
- "types": "./lib/vercel.d.ts",
223
- "default": "./lib/vercel.js"
224
- },
225
193
  "./ws": {
226
194
  "types": "./lib/ws.d.ts",
227
195
  "default": "./lib/ws.js"
@@ -306,94 +274,6 @@
306
274
  "types": "./lib/router/linear-router.d.ts",
307
275
  "default": "./lib/router/linear-router.js"
308
276
  },
309
- "./utils/jwt": {
310
- "types": "./lib/utils/jwt.d.ts",
311
- "default": "./lib/utils/jwt.js"
312
- },
313
- "./utils/accept": {
314
- "types": "./lib/utils/accept.d.ts",
315
- "default": "./lib/utils/accept.js"
316
- },
317
- "./utils/basic-auth": {
318
- "types": "./lib/utils/basic-auth.d.ts",
319
- "default": "./lib/utils/basic-auth.js"
320
- },
321
- "./utils/body": {
322
- "types": "./lib/utils/body.d.ts",
323
- "default": "./lib/utils/body.js"
324
- },
325
- "./utils/buffer": {
326
- "types": "./lib/utils/buffer.d.ts",
327
- "default": "./lib/utils/buffer.js"
328
- },
329
- "./utils/color": {
330
- "types": "./lib/utils/color.d.ts",
331
- "default": "./lib/utils/color.js"
332
- },
333
- "./utils/compress": {
334
- "types": "./lib/utils/compress.d.ts",
335
- "default": "./lib/utils/compress.js"
336
- },
337
- "./utils/concurrent": {
338
- "types": "./lib/utils/concurrent.d.ts",
339
- "default": "./lib/utils/concurrent.js"
340
- },
341
- "./utils/constants": {
342
- "types": "./lib/utils/constants.d.ts",
343
- "default": "./lib/utils/constants.js"
344
- },
345
- "./utils/cookie": {
346
- "types": "./lib/utils/cookie.d.ts",
347
- "default": "./lib/utils/cookie.js"
348
- },
349
- "./utils/crypto": {
350
- "types": "./lib/utils/crypto.d.ts",
351
- "default": "./lib/utils/crypto.js"
352
- },
353
- "./utils/encode": {
354
- "types": "./lib/utils/encode.d.ts",
355
- "default": "./lib/utils/encode.js"
356
- },
357
- "./utils/filepath": {
358
- "types": "./lib/utils/filepath.d.ts",
359
- "default": "./lib/utils/filepath.js"
360
- },
361
- "./utils/handler": {
362
- "types": "./lib/utils/handler.d.ts",
363
- "default": "./lib/utils/handler.js"
364
- },
365
- "./utils/headers": {
366
- "types": "./lib/utils/headers.d.ts",
367
- "default": "./lib/utils/headers.js"
368
- },
369
- "./utils/html": {
370
- "types": "./lib/utils/html.d.ts",
371
- "default": "./lib/utils/html.js"
372
- },
373
- "./utils/http-status": {
374
- "types": "./lib/utils/http-status.d.ts",
375
- "default": "./lib/utils/http-status.js"
376
- },
377
- "./utils/ipaddr": {
378
- "types": "./lib/utils/ipaddr.d.ts",
379
- "default": "./lib/utils/ipaddr.js"
380
- },
381
- "./utils/mime": {
382
- "types": "./lib/utils/mime.d.ts",
383
- "default": "./lib/utils/mime.js"
384
- },
385
- "./utils/stream": {
386
- "types": "./lib/utils/stream.d.ts",
387
- "default": "./lib/utils/stream.js"
388
- },
389
- "./utils/types": {
390
- "types": "./lib/utils/types.d.ts",
391
- "default": "./lib/utils/types.js"
392
- },
393
- "./utils/url": {
394
- "types": "./lib/utils/url.d.ts",
395
- "default": "./lib/utils/url.js"
396
- },
397
277
  "./loopback-only": {
398
278
  "types": "./lib/loopback-only.d.ts",
399
279
  "default": "./lib/loopback-only.js"
@@ -450,6 +330,10 @@
450
330
  "types": "./lib/kv/conformance.d.ts",
451
331
  "default": "./lib/kv/conformance.js"
452
332
  },
333
+ "./drivers": {
334
+ "types": "./lib/drivers.d.ts",
335
+ "default": "./lib/drivers.js"
336
+ },
453
337
  "./door/host": {
454
338
  "types": "./lib/door/host.d.ts",
455
339
  "default": "./lib/door/host.js"
@@ -49,9 +49,15 @@ export const inject = ['router', 'appInstance', 'nodeServer']
49
49
 
50
50
  export interface Config {
51
51
  tools?: { expose?: string[] }
52
+ /**
53
+ * The door is a development tool: with `NODE_ENV=production` the plugin starts nothing (no route, no `.acryl/` file, no service), and with `ACRYL_DOOR=off` it never does. A host that means to
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
+ */
56
+ allowInProduction?: boolean
52
57
  }
53
58
 
54
59
  export const Config: z<Config> = z.object({
60
+ allowInProduction: z.boolean().default(false).description('Run in production too (NODE_ENV=production); ACRYL_DOOR=off still turns it off.'),
55
61
  tools: z.object({
56
62
  // Not a `door_` name: those are the door's own verbs in the registry, never offered back as app tools.
57
63
  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.'),
@@ -84,6 +90,8 @@ function loopbackUrl(url: string): string {
84
90
 
85
91
  export function apply(ctx: Context, rawConfig: Config = {}): void {
86
92
  const config = Config(rawConfig)
93
+ // Development only: nothing is started, mounted or written in production, or when switched off.
94
+ if (process.env.ACRYL_DOOR === 'off' || (process.env.NODE_ENV === 'production' && config.allowInProduction !== true)) return
87
95
  const app = ctx.get('appInstance') as unknown as AppFolder
88
96
  const server = ctx.get('nodeServer') as unknown as Listening
89
97
  if (typeof app?.root !== 'string') throw new Error('acryl-door: the appInstance service has no app folder (root)')
@@ -1,7 +1,6 @@
1
1
  export { Router, Config, apply, name, type MountableApp, type RouterHttpService, type TrailingSlash } from './service.ts'
2
2
  export { connectionOf, getConnInfo, type ConnectionInfo, type RouterBindings, type RouterEnv } from './connection.ts'
3
3
  export { Acryl, Context } from './core/index.ts'
4
- export { App, type AppRequest } from './deprecated.ts'
5
4
  export type { AcrylRequest, ContextRenderer, ContextVariableMap, Env, ErrorHandler, ExecutionContext, Handler, Input, MiddlewareHandler, Next, NotFoundHandler, NotFoundResponse, Schema, ToSchema, TypedResponse, ValidationTargets } from './core/index.ts'
6
5
  export { HTTPException } from './core/http-exception.ts'
7
6
  export type { ContentfulStatusCode, StatusCode } from './core/utils/http-status.ts'
@@ -4,11 +4,9 @@
4
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
5
  * - `terminal`: bytes in, bytes out, a real pseudo-terminal (the `driver-pty` plugin). Nothing is forced to speak bytes.
6
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,
7
+ * This module is `acrylblends/drivers`: the contract a driver plugin writes against, with no dependency on Cordis. 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
8
  * and a panel without such a plugin simply lists none of its drivers.
9
9
  */
10
- import type { Context } from '@deepseek-ai/cordis'
11
-
12
10
  /** One event of a chat run. The panel adds `change` (a plugin was installed or removed) and `done` itself. */
13
11
  export type ChatEvent =
14
12
  | { readonly event: 'text'; readonly data: { readonly text: string } }
@@ -55,12 +53,6 @@ export interface Drivers {
55
53
  authorized(headers: Headers): boolean
56
54
  }
57
55
 
58
- declare module '@deepseek-ai/cordis' {
59
- interface Context {
60
- drivers: Drivers
61
- }
62
- }
63
-
64
56
  export function createDrivers(authorized: (headers: Headers) => boolean = () => true): Drivers {
65
57
  const table = new Map<string, Driver>()
66
58
  return {
@@ -74,10 +66,3 @@ export function createDrivers(authorized: (headers: Headers) => boolean = () =>
74
66
  authorized,
75
67
  }
76
68
  }
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
- }
@@ -18,8 +18,8 @@ import { streamSSE } from '../acryl-router/src/helpers/index.ts'
18
18
  import { Acryl } from '../acryl-router/src/index.ts'
19
19
  import type {} from '../agent/index.ts'
20
20
  import type {} from '../extensions/index.ts'
21
- import { provideDrivers } from './drivers.ts'
22
21
  import { agentDriver, availableHeadless, type HeadlessName, headlessDriver } from './headless.ts'
22
+ import { provideDrivers } from './registry.ts'
23
23
 
24
24
  export type {
25
25
  ChatDriver,
@@ -0,0 +1,16 @@
1
+ /** The `drivers` service of the agent panel: the registry (`drivers.ts`, published as `acrylblends/drivers`) provided on a Cordis context, and the `ctx.drivers` type. */
2
+ import type { Context } from '@deepseek-ai/cordis'
3
+ import { createDrivers, type Drivers } from './drivers.ts'
4
+
5
+ declare module '@deepseek-ai/cordis' {
6
+ interface Context {
7
+ drivers: Drivers
8
+ }
9
+ }
10
+
11
+ /** Provide the registry on `ctx` (the panel does). */
12
+ export function provideDrivers(ctx: Context, authorized: (headers: Headers) => boolean): Drivers {
13
+ const registry = createDrivers(authorized)
14
+ ;(ctx as Context).provide('drivers', registry)
15
+ return registry
16
+ }
@@ -0,0 +1,53 @@
1
+ # driver-acp
2
+
3
+ Any agent that speaks the [Agent Client Protocol](https://agentclientprotocol.com) (ACP) as a chat in the agent panel (M8, spec 046 T028).
4
+ The agent runs in the app folder and acts through the door, as the panel's other agents do. An agent needs no code here: it is a preset,
5
+ the command line that starts its ACP server. DeepSeek Harness is the first one, and other ACP agents are added by config.
6
+
7
+ ```yaml
8
+ - id: agent-panel
9
+ name: agent-panel
10
+ - id: driver-acp
11
+ name: driver-acp
12
+ config:
13
+ agents: # replaces the presets; default: the presets whose command is on the PATH
14
+ dsh: { command: dsh, args: [--profile, acp] }
15
+ permissions: reject # or allow: the answer to the agent's permission requests
16
+ ```
17
+
18
+ The row goes after the panel's, injects `drivers` and `appInstance`, and stays PENDING without the panel. Each agent becomes the
19
+ panel driver `acp:<name>`. An agent whose command is not on the PATH gets no driver, and the plugin logs one line saying why. With
20
+ `NODE_ENV=production` or `ACRYL_DOOR=off` the plugin starts nothing.
21
+
22
+ ## How it talks
23
+
24
+ ACP v1 over the agent's stdin and stdout, as newline-delimited JSON-RPC 2.0 (`connection.ts`). At the first message the driver starts the
25
+ agent, sends `initialize` and `session/new` with the app folder as `cwd`, and keeps that session, so later messages continue the
26
+ conversation. Each message is one `session/prompt`. Stop sends `session/cancel`. The client offers no file system or terminal
27
+ capabilities, so the agent uses its own tools. A `session/request_permission` gets the agent's own "reject once" option, or "allow once"
28
+ with `permissions: allow`.
29
+
30
+ What the panel shows (`chat.ts`):
31
+
32
+ - each `agent_message_chunk` as text;
33
+ - each tool call once, when it completes or fails: its title, its input, and its result or error;
34
+ - an error when a turn stops short (`refusal`, `max_tokens`, `max_turn_requests`) or the agent ends.
35
+
36
+ Thoughts and plans stay out of the chat.
37
+
38
+ ## Processes
39
+
40
+ Every agent process is started inside one `ctx.effect`. Its disposer closes the agent's stdin, then sends SIGTERM, then kills it (the
41
+ whole process tree on Windows), and waits until it is gone. Unloading or reloading the plugin leaves no agent running. An agent that ended
42
+ on its own is started again, with a new session, at the next message.
43
+
44
+ ## Provenance
45
+
46
+ The client is written from the ACP v1 specification, not ported from an SDK: the official SDK (`@agentclientprotocol/sdk`) is 8 MB with
47
+ two required peers, too much for a plugin copied into every app. To keep it honest, `test/conformance.test.ts` checks every message of a
48
+ session, sent and received, against the official ACP v1 JSON schema. The schema is vendored unmodified in `test/acp-v1/` with its Apache-2.0
49
+ license. It comes from `@agentclientprotocol/sdk@2.0.2` (`schema/v1/schema.json`); the version, source and checksum are in
50
+ `provenance.json`. To follow a newer schema, replace the file, update `provenance.json`, and run the test.
51
+
52
+ Checked live: `node scripts/verify-agent-panel.mjs --driver acp:dsh` (dsh on the PATH, a DeepSeek key) builds `/hello` from the panel
53
+ through `dsh --profile acp`.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * An ACP agent as a chat driver of the panel. The agent process starts at the first message and keeps one ACP session, so later messages continue the same conversation;
3
+ * an agent that ended is started again, with a new session, at the next message. One message runs at a time; a second one waits for the first.
4
+ *
5
+ * What the panel gets from the ACP updates: the agent's message chunks as `text`, each finished tool call as one `tool` (its title, input, and result or error), and an
6
+ * `error` for a turn that stopped short (a refusal, a token or request limit, an agent that ended). Thoughts, plans and the rest stay out of the chat.
7
+ */
8
+ import type { ChatDriver, ChatEvent } from '../agent-panel/drivers.ts'
9
+ import { type AcpAgent, type AcpConnection, AcpError } from './connection.ts'
10
+
11
+ /** Which answer a permission request gets: the agent's own "allow once" or "reject once" option. */
12
+ export type AcpPermissions = 'allow' | 'reject'
13
+
14
+ /** The ACP version this client speaks. */
15
+ const PROTOCOL_VERSION = 1
16
+
17
+ /** An agent connection with its session. */
18
+ interface Session {
19
+ readonly connection: AcpConnection
20
+ readonly id: string
21
+ }
22
+
23
+ /**
24
+ * Opens a connection to the agent and returns it with the function that closes it. The plugin acquires it inside one `ctx.effect`, so unloading the plugin ends the process.
25
+ */
26
+ export type OpenAgent = (
27
+ agent: AcpAgent,
28
+ handle: (method: string, params: unknown) => unknown,
29
+ ) => {
30
+ connection: AcpConnection
31
+ close(): Promise<void>
32
+ }
33
+
34
+ interface ToolUpdate {
35
+ toolCallId: string
36
+ title?: string | null
37
+ status?: string | null
38
+ rawInput?: unknown
39
+ rawOutput?: unknown
40
+ content?: Array<{ type?: string; content?: { type?: string; text?: string } }> | null
41
+ }
42
+
43
+ /** Why a turn stopped short, in the panel's words; `undefined` for a turn that ended normally or was cancelled. */
44
+ function stopMessage(name: string, stopReason: unknown): string | undefined {
45
+ switch (stopReason) {
46
+ case 'end_turn':
47
+ case 'cancelled':
48
+ return undefined
49
+ case 'refusal':
50
+ return `${name} refused to continue`
51
+ case 'max_tokens':
52
+ return `${name} stopped at its token limit`
53
+ case 'max_turn_requests':
54
+ return `${name} stopped at its limit of model requests for one message`
55
+ default:
56
+ return `${name} stopped (${String(stopReason)})`
57
+ }
58
+ }
59
+
60
+ /** The text an ACP tool call produced, when it reports text content and no raw output. */
61
+ function toolText(update: ToolUpdate): string | undefined {
62
+ const text = (update.content ?? [])
63
+ .map((item) =>
64
+ item.type === 'content' && item.content?.type === 'text' ? item.content.text : undefined,
65
+ )
66
+ .filter((part): part is string => typeof part === 'string')
67
+ .join('\n')
68
+ return text || undefined
69
+ }
70
+
71
+ /** The answer to `session/request_permission`: the agent's option of the wanted kind, or `cancelled` when it offers none or the turn was cancelled. */
72
+ function permissionAnswer(
73
+ params: unknown,
74
+ permissions: AcpPermissions,
75
+ cancelled: boolean,
76
+ ): unknown {
77
+ const options = ((params as { options?: Array<{ optionId: string; kind: string }> }).options ??
78
+ []) as Array<{ optionId: string; kind: string }>
79
+ const wanted =
80
+ permissions === 'allow' ? ['allow_once', 'allow_always'] : ['reject_once', 'reject_always']
81
+ const option = cancelled
82
+ ? undefined
83
+ : wanted.map((kind) => options.find((o) => o.kind === kind)).find(Boolean)
84
+ return {
85
+ outcome: option ? { outcome: 'selected', optionId: option.optionId } : { outcome: 'cancelled' },
86
+ }
87
+ }
88
+
89
+ export function acpDriver(options: {
90
+ /** The driver's name in the panel (`acp:dsh`). */
91
+ name: string
92
+ agent: AcpAgent
93
+ /** The app folder: the agent's working directory and its session's `cwd`. */
94
+ cwd: string
95
+ permissions: AcpPermissions
96
+ open: OpenAgent
97
+ }): ChatDriver {
98
+ const { name, agent, cwd, permissions, open } = options
99
+ let session: Promise<Session> | undefined
100
+ /** The connection `session` belongs to, so only its own end clears it. */
101
+ let live: AcpConnection | undefined
102
+ let turnCancelled = false
103
+ let queue: Promise<void> = Promise.resolve()
104
+
105
+ const startSession = async (): Promise<Session> => {
106
+ const { connection, close } = open(agent, (method, params) => {
107
+ if (method === 'session/request_permission')
108
+ return permissionAnswer(params, permissions, turnCancelled)
109
+ throw new AcpError(`${method} is not offered by this client`, -32601)
110
+ })
111
+ // An agent that ends is started again at the next message.
112
+ live = connection
113
+ void connection.ended.then(() => {
114
+ if (live !== connection) return
115
+ live = undefined
116
+ session = undefined
117
+ })
118
+ try {
119
+ await connection.request('initialize', {
120
+ protocolVersion: PROTOCOL_VERSION,
121
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
122
+ clientInfo: { name: 'acrylblends-driver-acp', version: '1' },
123
+ })
124
+ const created = await connection.request<{ sessionId: string }>('session/new', {
125
+ cwd,
126
+ mcpServers: [],
127
+ })
128
+ return { connection, id: created.sessionId }
129
+ } catch (error) {
130
+ await close()
131
+ throw error
132
+ }
133
+ }
134
+
135
+ return {
136
+ kind: 'chat',
137
+ name,
138
+ async *start({ message, signal }) {
139
+ // One message at a time: this one waits for the one before it.
140
+ const previous = queue
141
+ let finished!: () => void
142
+ queue = new Promise((resolve) => (finished = resolve))
143
+ try {
144
+ await previous
145
+ if (signal.aborted) return
146
+ let current: Session
147
+ try {
148
+ session ??= startSession()
149
+ current = await session
150
+ } catch (error) {
151
+ session = undefined
152
+ yield {
153
+ event: 'error',
154
+ data: { message: error instanceof Error ? error.message : String(error) },
155
+ }
156
+ return
157
+ }
158
+ yield* turn(current, message, signal)
159
+ } finally {
160
+ finished()
161
+ }
162
+ },
163
+ }
164
+
165
+ async function* turn(
166
+ current: Session,
167
+ message: string,
168
+ signal: AbortSignal,
169
+ ): AsyncGenerator<ChatEvent> {
170
+ const events: ChatEvent[] = []
171
+ let wake: (() => void) | undefined
172
+ let settled = false
173
+ const push = (event: ChatEvent): void => {
174
+ events.push(event)
175
+ wake?.()
176
+ }
177
+ /** What earlier updates said about each tool call: its title and its input arrive before its result. */
178
+ const calls = new Map<string, { title?: string; input?: unknown }>()
179
+ const off = current.connection.onNotification((method, params) => {
180
+ const { sessionId, update } = params as {
181
+ sessionId?: string
182
+ update?: { sessionUpdate?: string } & ToolUpdate & { content?: unknown }
183
+ }
184
+ if (method !== 'session/update' || sessionId !== current.id || !update) return
185
+ if (update.sessionUpdate === 'agent_message_chunk') {
186
+ const content = update.content as { type?: string; text?: string } | undefined
187
+ if (content?.type === 'text' && content.text)
188
+ push({ event: 'text', data: { text: content.text } })
189
+ return
190
+ }
191
+ if (update.sessionUpdate !== 'tool_call' && update.sessionUpdate !== 'tool_call_update')
192
+ return
193
+ const call = calls.get(update.toolCallId) ?? {}
194
+ if (update.title) call.title = update.title
195
+ if (update.rawInput !== undefined) call.input = update.rawInput
196
+ calls.set(update.toolCallId, call)
197
+ if (update.status !== 'completed' && update.status !== 'failed') return
198
+ const tool = call.title ?? update.toolCallId
199
+ const output = update.rawOutput ?? toolText(update)
200
+ push({
201
+ event: 'tool',
202
+ data: {
203
+ tool,
204
+ ...(call.input === undefined ? {} : { input: call.input }),
205
+ ...(update.status === 'failed'
206
+ ? {
207
+ error:
208
+ output === undefined
209
+ ? 'failed'
210
+ : String(typeof output === 'string' ? output : JSON.stringify(output)),
211
+ }
212
+ : { result: output }),
213
+ },
214
+ })
215
+ })
216
+ turnCancelled = false
217
+ const cancel = (): void => {
218
+ turnCancelled = true
219
+ current.connection.notify('session/cancel', { sessionId: current.id })
220
+ }
221
+ signal.addEventListener('abort', cancel, { once: true })
222
+ current.connection
223
+ .request<{ stopReason?: unknown }>('session/prompt', {
224
+ sessionId: current.id,
225
+ prompt: [{ type: 'text', text: message }],
226
+ })
227
+ .then(
228
+ (result) => {
229
+ const stopped = signal.aborted ? undefined : stopMessage(name, result.stopReason)
230
+ if (stopped) push({ event: 'error', data: { message: stopped } })
231
+ },
232
+ (error: unknown) => {
233
+ if (!signal.aborted)
234
+ push({
235
+ event: 'error',
236
+ data: { message: error instanceof Error ? error.message : String(error) },
237
+ })
238
+ },
239
+ )
240
+ .finally(() => {
241
+ settled = true
242
+ wake?.()
243
+ })
244
+ try {
245
+ for (;;) {
246
+ const next = events.shift()
247
+ if (next) yield next
248
+ else if (settled) return
249
+ else await new Promise<void>((resolve) => (wake = resolve))
250
+ }
251
+ } finally {
252
+ off()
253
+ signal.removeEventListener('abort', cancel)
254
+ // The panel stopped reading before the turn ended: the agent is told to stop it.
255
+ if (!settled) cancel()
256
+ }
257
+ }
258
+ }