acrylblends 0.7.8 → 0.8.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 (141) hide show
  1. package/dist/index.d.ts +13 -5
  2. package/dist/index.js +72 -13
  3. package/dist/index.js.map +1 -1
  4. package/docs/framework/README.md +2 -2
  5. package/docs/framework/UPSTREAM.md +1 -1
  6. package/docs/framework/getting-started/basic.md +1 -2
  7. package/docs/framework/getting-started/desktop.md +1 -1
  8. package/docs/framework/guides/bring-your-own-tool.md +216 -0
  9. package/docs/framework/guides/create-acrylblends.md +5 -4
  10. package/docs/framework/guides/data.md +2 -1
  11. package/docs/framework/upstream.json +3 -3
  12. package/lib/node-server.js +1 -0
  13. package/package.json +1 -1
  14. package/plugins/acryl-db/plugin.json +6 -1
  15. package/plugins/acryl-db/src/conformance.ts +224 -0
  16. package/plugins/acryl-db/upstream.json +1 -1
  17. package/plugins/acryl-db-sqlite/plugin.json +4 -1
  18. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  19. package/plugins/acryl-door/LICENSE +47 -0
  20. package/plugins/acryl-door/plugin.json +14 -0
  21. package/plugins/acryl-door/src/cli/acryl.ts +207 -0
  22. package/plugins/acryl-door/src/host/api.ts +9 -0
  23. package/plugins/acryl-door/src/host/audit.ts +64 -0
  24. package/plugins/acryl-door/src/host/channel.ts +142 -0
  25. package/plugins/acryl-door/src/host/chat-tools.ts +39 -0
  26. package/plugins/acryl-door/src/host/door-file.ts +94 -0
  27. package/plugins/acryl-door/src/host/door.ts +195 -0
  28. package/plugins/acryl-door/src/host/http.ts +80 -0
  29. package/plugins/acryl-door/src/host/mcp-routes.ts +83 -0
  30. package/plugins/acryl-door/src/host/operator-routes.ts +38 -0
  31. package/plugins/acryl-door/src/host/page-routes.ts +65 -0
  32. package/plugins/acryl-door/src/host/shim.ts +65 -0
  33. package/plugins/acryl-door/src/host/verbs.ts +109 -0
  34. package/plugins/acryl-door/src/index.ts +133 -0
  35. package/plugins/acryl-door/src/page/api.ts +9 -0
  36. package/plugins/acryl-door/src/page/channel.ts +135 -0
  37. package/plugins/acryl-door/src/page/driver/accessibility.ts +163 -0
  38. package/plugins/acryl-door/src/page/driver/actions.ts +186 -0
  39. package/plugins/acryl-door/src/page/driver/driver.ts +153 -0
  40. package/plugins/acryl-door/src/page/driver/ref-table.ts +78 -0
  41. package/plugins/acryl-door/src/page/driver/sensitivity.ts +49 -0
  42. package/plugins/acryl-door/src/page/driver/snapshot.ts +132 -0
  43. package/plugins/acryl-door/src/page/index.ts +40 -0
  44. package/plugins/acryl-door/src/page/indicator.ts +72 -0
  45. package/plugins/acryl-door/src/page/main.ts +7 -0
  46. package/plugins/acryl-door/src/page/protocol.ts +213 -0
  47. package/plugins/acryl-door/src/page/snapshot-text.ts +18 -0
  48. package/plugins/acryl-door/src/page/user-input.ts +24 -0
  49. package/plugins/acryl-door/upstream.json +28 -0
  50. package/plugins/acryl-inspect/upstream.json +1 -1
  51. package/plugins/acryl-kv/plugin.json +6 -1
  52. package/plugins/acryl-kv/src/conformance.ts +226 -0
  53. package/plugins/acryl-kv/upstream.json +1 -1
  54. package/plugins/acryl-kv-db/plugin.json +4 -1
  55. package/plugins/acryl-kv-db/upstream.json +1 -1
  56. package/plugins/acryl-kv-json/plugin.json +4 -1
  57. package/plugins/acryl-kv-json/upstream.json +1 -1
  58. package/plugins/acryl-node-server/src/adapter/listener.ts +5 -0
  59. package/plugins/acryl-node-server/upstream.json +2 -2
  60. package/plugins/acryl-router/src/service.ts +3 -0
  61. package/plugins/acryl-router/upstream.json +2 -2
  62. package/plugins/acryl-router-http/upstream.json +1 -1
  63. package/plugins/acryl-state/upstream.json +1 -1
  64. package/plugins/agent-panel/client/main.ts +159 -0
  65. package/plugins/agent-panel/index.ts +232 -0
  66. package/plugins/agent-panel/plugin.json +21 -0
  67. package/plugins/desktop/upstream.json +1 -1
  68. package/plugins/desktop-web/upstream.json +1 -1
  69. package/plugins/extensions/resolve.ts +16 -2
  70. package/plugins/tsconfig.json +6 -0
  71. package/shared/DEFAULTS.md +11 -6
  72. package/shared/SKILL.md +1 -1
  73. package/templates/agent/blend.yaml +3 -0
  74. package/templates/agent/src/app.ts +18 -2
  75. package/templates/agent/src/routes.ts +14 -1
  76. package/templates/agent/template.json +2 -1
  77. package/templates/agent/test/agent.test.ts +10 -4
  78. package/templates/agent/test/app.test.ts +29 -8
  79. package/templates/agent/test/builder.test.ts +34 -23
  80. package/templates/agent/test/model.test.ts +24 -19
  81. package/templates/blank/src/app.ts +25 -5
  82. package/templates/blank/test/app.test.ts +28 -9
  83. package/templates/chat/AGENTS.md +16 -0
  84. package/templates/chat/blend.yaml +29 -0
  85. package/templates/chat/gitignore +6 -0
  86. package/templates/chat/migrations/001-chat.sql +12 -0
  87. package/templates/chat/package.json +33 -0
  88. package/templates/chat/src/app.ts +106 -0
  89. package/templates/chat/src/chat-app.ts +101 -0
  90. package/templates/chat/src/client/chat.tsx +115 -0
  91. package/templates/chat/src/client/tsconfig.json +26 -0
  92. package/templates/chat/src/db.ts +43 -0
  93. package/templates/chat/src/door.ts +7 -0
  94. package/templates/chat/src/llm.ts +42 -0
  95. package/templates/chat/src/main.ts +28 -0
  96. package/templates/chat/src/page.tsx +40 -0
  97. package/templates/chat/src/routes.ts +15 -0
  98. package/templates/chat/src/runtime.ts +17 -0
  99. package/templates/chat/template.json +11 -0
  100. package/templates/chat/test/chat.test.ts +198 -0
  101. package/templates/chat/test/server.test.ts +78 -0
  102. package/templates/chat/tsconfig.json +20 -0
  103. package/templates/nodejs/AGENTS.md +9 -0
  104. package/templates/nodejs/blend.yaml +26 -0
  105. package/templates/nodejs/gitignore +3 -0
  106. package/templates/nodejs/package.json +30 -0
  107. package/templates/nodejs/src/app.ts +92 -0
  108. package/templates/nodejs/src/client/counter.tsx +12 -0
  109. package/templates/nodejs/src/client/tsconfig.json +26 -0
  110. package/templates/nodejs/src/door.ts +7 -0
  111. package/templates/nodejs/src/main.ts +25 -0
  112. package/templates/nodejs/src/page.tsx +29 -0
  113. package/templates/nodejs/src/routes.ts +40 -0
  114. package/templates/nodejs/template.json +3 -0
  115. package/templates/nodejs/test/app.test.ts +78 -0
  116. package/templates/nodejs/test/server.test.ts +83 -0
  117. package/templates/nodejs/tsconfig.json +20 -0
  118. package/templates/notes/AGENTS.md +26 -0
  119. package/templates/notes/blend.yaml +29 -0
  120. package/templates/notes/gitignore +6 -0
  121. package/templates/notes/migrations/001-notes.sql +18 -0
  122. package/templates/notes/package.json +33 -0
  123. package/templates/notes/src/app.ts +106 -0
  124. package/templates/notes/src/auth.ts +57 -0
  125. package/templates/notes/src/client/counter.tsx +21 -0
  126. package/templates/notes/src/client/tsconfig.json +26 -0
  127. package/templates/notes/src/db.ts +50 -0
  128. package/templates/notes/src/door.ts +7 -0
  129. package/templates/notes/src/main.ts +28 -0
  130. package/templates/notes/src/notes-app.ts +205 -0
  131. package/templates/notes/src/routes.ts +14 -0
  132. package/templates/notes/src/runtime.ts +17 -0
  133. package/templates/notes/src/schema.ts +38 -0
  134. package/templates/notes/src/views.tsx +136 -0
  135. package/templates/notes/template.json +11 -0
  136. package/templates/notes/test/notes.test.ts +280 -0
  137. package/templates/notes/test/server.test.ts +78 -0
  138. package/templates/notes/tsconfig.json +20 -0
  139. package/templates/todo/src/app.ts +27 -7
  140. package/templates/todo/test/todo.test.ts +47 -22
  141. package/templates/todo/upstream.json +1 -1
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The door's operator client: what `<app>/.acryl/bin/acryl` runs. It finds the app from the folder it is started in (the nearest
4
+ * `.acryl/door.json` upward, or `--dir`), never through PATH or a machine-wide registry, so it reaches only the app running from that
5
+ * folder. The secret is read from that file and sent as a `Bearer` header; it is never printed.
6
+ *
7
+ * acryl brief | look | act <op> ... | plugin list|add|remove|reload | tool list|call | verbs (--json for the raw value)
8
+ * acryl control <...> the same, the ACRYL Host form (`control snapshot`, `control click`, ... are look and act)
9
+ *
10
+ * Exit codes: 0 done, 1 the verb failed (its code and message are printed), 2 a usage error, 3 no running app in this folder.
11
+ */
12
+
13
+ import { existsSync, realpathSync } from 'node:fs'
14
+ import { dirname, join, resolve } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { readDoorFile, STATE_DIR, DOOR_FILE } from '../host/door-file.ts'
17
+
18
+ const ACT_OPS = new Set(['click', 'type', 'select', 'press', 'scroll', 'wait'])
19
+ const VALUED = new Set(['--dir', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--tool', '--args'])
20
+ const FLAGS = new Set(['--json', '--submit', '--no-clear', '--gone', '--help'])
21
+
22
+ export class UsageError extends Error {}
23
+
24
+ export interface Invocation {
25
+ readonly verb: string
26
+ readonly args: Record<string, unknown>
27
+ readonly json: boolean
28
+ readonly dir: string | undefined
29
+ }
30
+
31
+ const USAGE = `usage: acryl <verb> [options] [--json] [--dir <app folder>]
32
+ brief
33
+ look [--cursor <n>] [--max-nodes <n>]
34
+ act click --ref <r>
35
+ act type --ref <r> --text <t> [--submit] [--no-clear]
36
+ act select --ref <r> --option <o>
37
+ act press --key <key> [--ref <r>]
38
+ act scroll --direction up|down|left|right [--ref <r>] [--amount <px>]
39
+ act wait (--text <t> | --role <role> [--name <n>]) [--gone] [--timeout-ms <ms>]
40
+ plugin list | plugin add <folder> | plugin remove <name> | plugin reload
41
+ tool list | tool call --tool <name> [--args '<json>']
42
+ verbs`
43
+
44
+ function integer(values: Map<string, string>, flag: string): number | undefined {
45
+ const raw = values.get(flag)
46
+ if (raw === undefined) return undefined
47
+ const value = Number(raw)
48
+ if (!Number.isInteger(value) || value < 0) throw new UsageError(`${flag} must be a non-negative integer`)
49
+ return value
50
+ }
51
+
52
+ /** @throws UsageError. */
53
+ export function parseInvocation(argv: readonly string[]): Invocation {
54
+ const words: string[] = []
55
+ const values = new Map<string, string>()
56
+ const set = new Set<string>()
57
+ for (let at = 0; at < argv.length; at += 1) {
58
+ const arg = argv[at] as string
59
+ if (VALUED.has(arg)) {
60
+ const value = argv[at + 1]
61
+ if (value === undefined) throw new UsageError(`${arg} needs a value`)
62
+ values.set(arg, value)
63
+ at += 1
64
+ } else if (FLAGS.has(arg)) set.add(arg)
65
+ else if (arg.startsWith('--')) throw new UsageError(`unknown option ${arg}`)
66
+ else words.push(arg)
67
+ }
68
+ if (set.has('--help')) throw new UsageError(USAGE)
69
+ if (words[0] === 'control') words.shift()
70
+ // The ACRYL Host form: `control snapshot` is look, `control click` is act click.
71
+ if (words[0] === 'snapshot') words[0] = 'look'
72
+ else if (words[0] !== undefined && ACT_OPS.has(words[0])) words.unshift('act')
73
+ const [first, second, third] = words
74
+ const json = set.has('--json')
75
+ const dir = values.get('--dir')
76
+ const make = (verb: string, args: Record<string, unknown> = {}): Invocation => ({ verb, args, json, dir })
77
+ switch (first) {
78
+ case 'brief': return make('brief')
79
+ case 'verbs': return make('verbs')
80
+ case 'look': {
81
+ const cursor = integer(values, '--cursor')
82
+ const maxNodes = integer(values, '--max-nodes')
83
+ return make('look', { ...(cursor === undefined ? {} : { cursor }), ...(maxNodes === undefined ? {} : { maxNodes }) })
84
+ }
85
+ case 'act': {
86
+ if (second === undefined || !ACT_OPS.has(second)) throw new UsageError(`act needs one of ${[...ACT_OPS].join(', ')}\n${USAGE}`)
87
+ const amount = integer(values, '--amount')
88
+ const timeoutMs = integer(values, '--timeout-ms')
89
+ const pick = (flag: string, key: string): Record<string, string> => (values.has(flag) ? { [key]: values.get(flag) as string } : {})
90
+ return make('act', {
91
+ op: second,
92
+ ...pick('--ref', 'ref'), ...pick('--text', 'text'), ...pick('--option', 'option'), ...pick('--key', 'key'),
93
+ ...pick('--direction', 'direction'), ...pick('--role', 'role'), ...pick('--name', 'name'),
94
+ ...(amount === undefined ? {} : { amount }),
95
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
96
+ ...(set.has('--submit') ? { submit: true } : {}),
97
+ ...(set.has('--no-clear') ? { clear: false } : {}),
98
+ ...(set.has('--gone') ? { gone: true } : {}),
99
+ })
100
+ }
101
+ case 'plugin': {
102
+ if (second === 'list' || second === 'reload') return make(`plugin-${second}`)
103
+ if (second === 'add') { if (third === undefined) throw new UsageError('plugin add needs a folder, e.g. plugin add plugins/pomodoro'); return make('plugin-add', { folder: third }) }
104
+ if (second === 'remove') { if (third === undefined) throw new UsageError('plugin remove needs a plugin name'); return make('plugin-remove', { name: third }) }
105
+ throw new UsageError(`plugin needs list, add, remove or reload\n${USAGE}`)
106
+ }
107
+ case 'tool': {
108
+ if (second === 'list') return make('tool-list')
109
+ if (second === 'call') {
110
+ const tool = values.get('--tool')
111
+ if (tool === undefined) throw new UsageError('tool call needs --tool <name>')
112
+ const raw = values.get('--args')
113
+ let args: unknown = {}
114
+ if (raw !== undefined) {
115
+ try { args = JSON.parse(raw) } catch { throw new UsageError('--args must be JSON') }
116
+ }
117
+ return make('tool-call', { tool, args })
118
+ }
119
+ throw new UsageError(`tool needs list or call\n${USAGE}`)
120
+ }
121
+ default: throw new UsageError(USAGE)
122
+ }
123
+ }
124
+
125
+ /** The app folder: `--dir`, or the nearest folder upward from `cwd` that has `.acryl/door.json` (or at least `.acryl/`). */
126
+ export function findApp(cwd: string, dir: string | undefined): string | undefined {
127
+ if (dir !== undefined) return resolve(cwd, dir)
128
+ let fallback: string | undefined
129
+ for (let at = resolve(cwd); ; at = dirname(at)) {
130
+ if (existsSync(join(at, STATE_DIR, DOOR_FILE))) return at
131
+ if (fallback === undefined && existsSync(join(at, STATE_DIR))) fallback = at
132
+ if (dirname(at) === at) return fallback
133
+ }
134
+ }
135
+
136
+ export interface Io {
137
+ readonly cwd: string
138
+ readonly out: (text: string) => void
139
+ readonly err: (text: string) => void
140
+ readonly fetch?: typeof fetch
141
+ }
142
+
143
+ /** Run one invocation. @returns the exit code. */
144
+ export async function main(argv: readonly string[], io: Io): Promise<number> {
145
+ let invocation: Invocation
146
+ try {
147
+ invocation = parseInvocation(argv)
148
+ } catch (cause) {
149
+ if (cause instanceof UsageError) { io.err(`${cause.message}\n`); return 2 }
150
+ throw cause
151
+ }
152
+ const app = findApp(io.cwd, invocation.dir)
153
+ const file = app === undefined ? undefined : readDoorFile(app)
154
+ if (app === undefined || file === undefined) {
155
+ io.err(`no running app${app === undefined ? ` in ${io.cwd} or above it` : ` in ${app}`}: start it (npm run dev) in its folder, then try again\n`)
156
+ return 3
157
+ }
158
+ const request = io.fetch ?? fetch
159
+ const headers = { authorization: `Bearer ${file.secret}`, 'content-type': 'application/json' }
160
+ let response: Response
161
+ try {
162
+ response = invocation.verb === 'verbs'
163
+ ? await request(`${file.url}/api/acryl-door/verbs`, { headers })
164
+ : await request(`${file.url}/api/acryl-door/call`, { method: 'POST', headers, body: JSON.stringify({ verb: invocation.verb, args: invocation.args }) })
165
+ } catch {
166
+ io.err(`no running app in ${app}: ${file.url} does not answer (it stopped without removing .acryl/door.json?); start it again\n`)
167
+ return 3
168
+ }
169
+ if (response.status === 403) {
170
+ io.err(`refused by the app in ${app}: the secret did not match (the app restarted? run the command again)\n`)
171
+ return 1
172
+ }
173
+ const body = await response.json().catch(() => undefined) as { ok?: boolean; value?: unknown; text?: string; code?: string; message?: string; verbs?: unknown } | undefined
174
+ if (body === undefined) { io.err(`the app answered ${String(response.status)} with no readable body\n`); return 1 }
175
+ if (invocation.verb === 'verbs') {
176
+ io.out(invocation.json ? `${JSON.stringify(body.verbs)}\n` : `${(body.verbs as string[]).join('\n')}\n`)
177
+ return 0
178
+ }
179
+ if (body.ok !== true) {
180
+ io.err(invocation.json ? `${JSON.stringify(body)}\n` : `${body.code ?? 'error'}: ${body.message ?? JSON.stringify(body)}\n`)
181
+ return 1
182
+ }
183
+ io.out(invocation.json ? `${JSON.stringify(body.value)}\n` : `${body.text ?? ''}\n`)
184
+ return 0
185
+ }
186
+
187
+ /** True when this file is the program Node was started with (not imported by a test). */
188
+ function isProgram(): boolean {
189
+ const entry = process.argv[1]
190
+ if (entry === undefined) return false
191
+ try {
192
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url))
193
+ } catch {
194
+ return false
195
+ }
196
+ }
197
+
198
+ if (isProgram()) {
199
+ main(process.argv.slice(2), {
200
+ cwd: process.cwd(),
201
+ out: (text) => { process.stdout.write(text) },
202
+ err: (text) => { process.stderr.write(text) },
203
+ }).then((code) => { process.exitCode = code }, (cause: unknown) => {
204
+ process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`)
205
+ process.exitCode = 1
206
+ })
207
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The door's host core for another Host to build its own door on (`acryl-door/host`): the page channel and its routes, and the audit log.
3
+ * ACRYL's DSH Host uses it in `acryl-agent-control`, with its own tools, approval and secret.
4
+ */
5
+
6
+ export { UiChannel, type PageStream } from './channel.ts'
7
+ export { AuditLog, auditPath, type AuditEntry } from './audit.ts'
8
+ export { HEARTBEAT_MS, pageChannelRoutes, type PageRoutesOptions } from './page-routes.ts'
9
+ export { renderSnapshot } from '../page/snapshot-text.ts'
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The audit log: one JSON line per door call, append-only, in the app's own `.acryl/audit/` (moved from `acryl-agent-control`, spec 041).
3
+ *
4
+ * It is the record of what the agent did to the app. It never holds text the agent typed or values it read,
5
+ * only which control was touched and how the call ended.
6
+ */
7
+
8
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync } from 'node:fs'
9
+ import { dirname, join } from 'node:path'
10
+ import type { AuditEntry } from '../page/protocol.ts'
11
+
12
+ export type { AuditEntry } from '../page/protocol.ts'
13
+
14
+ const MAX_LOG_BYTES = 5 * 1024 * 1024
15
+
16
+ /** The audit log below a folder the caller owns (the app's `.acryl/` here; ACRYL's app home for the DSH face). */
17
+ export function auditPath(folder: string): string {
18
+ return join(folder, 'audit', 'ui-control.jsonl')
19
+ }
20
+
21
+ export class AuditLog {
22
+ private readonly path: string
23
+
24
+ constructor(path: string) {
25
+ this.path = path
26
+ }
27
+
28
+ /** Append one line; a failing disk never breaks the tool call. */
29
+ record(entry: AuditEntry): void {
30
+ try {
31
+ mkdirSync(dirname(this.path), { recursive: true })
32
+ this.rotateIfLarge()
33
+ const fd = openSync(this.path, 'a', 0o600)
34
+ try {
35
+ writeSync(fd, `${JSON.stringify(entry)}\n`)
36
+ } finally {
37
+ closeSync(fd)
38
+ }
39
+ } catch {
40
+ // Best effort: the record is important, but the app must keep working.
41
+ }
42
+ }
43
+
44
+ /** The most recent entries, oldest first. */
45
+ recent(limit = 100): AuditEntry[] {
46
+ try {
47
+ const lines = readFileSync(this.path, 'utf8').split('\n').filter(line => line !== '').slice(-limit)
48
+ return lines.flatMap((line) => {
49
+ try { return [JSON.parse(line) as AuditEntry] } catch { return [] }
50
+ })
51
+ } catch {
52
+ return []
53
+ }
54
+ }
55
+
56
+ private rotateIfLarge(): void {
57
+ try {
58
+ if (statSync(this.path).size >= MAX_LOG_BYTES) renameSync(this.path, `${this.path}.1`)
59
+ } catch {
60
+ // Nothing to rotate yet.
61
+ }
62
+ }
63
+ }
64
+
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The Host's side of the page channel: the connected pages of the app, and calls that go to the current one (moved from
3
+ * `acryl-agent-control`, spec 041).
4
+ *
5
+ * The Host cannot reach into a page, so each page opens one event stream to the Host and answers calls by POST. With several pages open,
6
+ * the one the user focused last is the one the agent drives. A page that reconnects with the same window id replaces its old stream.
7
+ */
8
+
9
+ import {
10
+ UiControlError,
11
+ type ChannelToHost,
12
+ type ChannelToPage,
13
+ type UiRequest,
14
+ type UiResult,
15
+ } from '../page/protocol.ts'
16
+
17
+ /** The slice of a page's stream the channel needs, so tests can supply their own. */
18
+ export interface PageStream {
19
+ send(data: string): void
20
+ close(): void
21
+ }
22
+
23
+ interface Pending {
24
+ readonly connection: Connection
25
+ readonly resolve: (value: UiResult) => void
26
+ readonly reject: (cause: UiControlError) => void
27
+ readonly timer: NodeJS.Timeout
28
+ readonly cleanup: () => void
29
+ }
30
+
31
+ interface Connection {
32
+ readonly stream: PageStream
33
+ readonly windowId: string
34
+ focused: boolean
35
+ /** Order of last focus (or connection), so the most recent wins. */
36
+ lastActive: number
37
+ }
38
+
39
+ /** How long a call may take before it is given up (a wait may take up to 10 seconds itself). */
40
+ const DEFAULT_CALL_TIMEOUT_MS = 30_000
41
+
42
+ export class UiChannel {
43
+ private readonly connections = new Map<string, Connection>()
44
+ private readonly pending = new Map<number, Pending>()
45
+ private nextId = 1
46
+ private clock = 0
47
+ private closed = false
48
+
49
+ /**
50
+ * Register a page's stream. A stream already open for the same window is closed first, and its calls settle as `disconnected`.
51
+ * @returns the function to call when this stream ends.
52
+ */
53
+ attach(windowId: string, focused: boolean, stream: PageStream): () => void {
54
+ const previous = this.connections.get(windowId)
55
+ if (previous !== undefined) this.drop(previous, true)
56
+ const connection: Connection = { stream, windowId, focused, lastActive: ++this.clock }
57
+ this.connections.set(windowId, connection)
58
+ return () => { if (this.connections.get(windowId) === connection) this.drop(connection, false) }
59
+ }
60
+
61
+ /** A message a page sent by POST. Only the window that was asked may answer a call, so another page cannot forge a result. */
62
+ receive(windowId: string, message: ChannelToHost): void {
63
+ const connection = this.connections.get(windowId)
64
+ if (connection === undefined) return
65
+ if (message.t === 'focus') {
66
+ connection.focused = message.focused
67
+ if (message.focused) connection.lastActive = ++this.clock
68
+ return
69
+ }
70
+ const entry = this.pending.get(message.id)
71
+ if (entry === undefined || entry.connection !== connection) return
72
+ if (message.t === 'result') this.settle(message.id, message.value)
73
+ else this.settle(message.id, undefined, new UiControlError(message.code, message.message))
74
+ }
75
+
76
+ /** True when at least one page is connected. */
77
+ get connected(): boolean {
78
+ return this.connections.size > 0
79
+ }
80
+
81
+ /** The windows currently connected, most recently active first (for diagnostics and tests). */
82
+ windows(): readonly { windowId: string; focused: boolean }[] {
83
+ return this.byRecency().map(({ windowId, focused }) => ({ windowId, focused }))
84
+ }
85
+
86
+ /**
87
+ * Ask the current page to do something.
88
+ * @throws UiControlError `no-window`, `timeout`, `aborted`, `unloaded`, `disconnected`, or whatever the page refused with.
89
+ */
90
+ call(request: UiRequest, options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<UiResult> {
91
+ if (this.closed) return Promise.reject(new UiControlError('unloaded', 'the door was unloaded'))
92
+ const target = this.byRecency()[0]
93
+ if (target === undefined) return Promise.reject(new UiControlError('no-window', 'no page of the app is open to control'))
94
+ const signal = options.signal
95
+ if (signal?.aborted === true) return Promise.reject(new UiControlError('aborted', 'the call was cancelled'))
96
+ const id = this.nextId
97
+ this.nextId += 1
98
+ return new Promise<UiResult>((resolve, reject) => {
99
+ const timer = setTimeout(() => { this.settle(id, undefined, new UiControlError('timeout', 'the page did not answer in time')) }, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS)
100
+ const onAbort = (): void => { this.settle(id, undefined, new UiControlError('aborted', 'the call was cancelled')) }
101
+ signal?.addEventListener('abort', onAbort, { once: true })
102
+ this.pending.set(id, { connection: target, resolve, reject, timer, cleanup: () => { signal?.removeEventListener('abort', onAbort) } })
103
+ const frame: ChannelToPage = { t: 'call', id, request }
104
+ try {
105
+ target.stream.send(JSON.stringify(frame))
106
+ } catch {
107
+ this.settle(id, undefined, new UiControlError('disconnected', 'the page could not be reached'))
108
+ }
109
+ })
110
+ }
111
+
112
+ /** Close every page's stream and settle every pending call as `unloaded`. */
113
+ close(): void {
114
+ this.closed = true
115
+ for (const id of [...this.pending.keys()]) this.settle(id, undefined, new UiControlError('unloaded', 'the door was unloaded'))
116
+ for (const connection of [...this.connections.values()]) connection.stream.close()
117
+ this.connections.clear()
118
+ }
119
+
120
+ private byRecency(): Connection[] {
121
+ return [...this.connections.values()].sort((a, b) => b.lastActive - a.lastActive)
122
+ }
123
+
124
+ /** Forget a connection; its calls in flight settle as `disconnected` at once rather than waiting for their timeout. */
125
+ private drop(connection: Connection, closeStream: boolean): void {
126
+ this.connections.delete(connection.windowId)
127
+ for (const [id, entry] of [...this.pending]) {
128
+ if (entry.connection === connection) this.settle(id, undefined, new UiControlError('disconnected', 'the page went away before it answered'))
129
+ }
130
+ if (closeStream) connection.stream.close()
131
+ }
132
+
133
+ private settle(id: number, value: UiResult | undefined, failure?: UiControlError): void {
134
+ const entry = this.pending.get(id)
135
+ if (entry === undefined) return
136
+ this.pending.delete(id)
137
+ clearTimeout(entry.timer)
138
+ entry.cleanup()
139
+ if (failure !== undefined) entry.reject(failure)
140
+ else if (value !== undefined) entry.resolve(value)
141
+ }
142
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The door in the built-in chat: each verb registered in the app's `tools` registry (the one the chat's agent reads), as
3
+ * `door_<verb>`. A tool answers the verb's text, what an agent reads in a terminal; a failed verb throws with its code, as the operator
4
+ * client prints it.
5
+ */
6
+
7
+ import { UiControlError } from '../page/protocol.ts'
8
+ import { DoorError, type Door, type ToolsPort } from './door.ts'
9
+ import { chatToolName, VERB_SPECS, VERBS } from './verbs.ts'
10
+
11
+ /** Register every verb. @returns the function that removes them all; it also aborts the calls still running. */
12
+ export function registerChatTools(tools: ToolsPort, door: Door): () => void {
13
+ const running = new AbortController()
14
+ const removers: Array<() => void> = []
15
+ const removeAll = (): void => {
16
+ running.abort()
17
+ for (const remove of removers.splice(0).reverse()) remove()
18
+ }
19
+ try {
20
+ for (const verb of VERBS) {
21
+ removers.push(tools.register(chatToolName(verb), {
22
+ description: VERB_SPECS[verb].description,
23
+ run: async (input: unknown) => {
24
+ try {
25
+ return (await door.run(verb, input ?? {}, running.signal)).text
26
+ } catch (cause) {
27
+ if (cause instanceof DoorError || cause instanceof UiControlError) throw new Error(`${cause.code}: ${cause.message}`, { cause })
28
+ throw cause
29
+ }
30
+ },
31
+ }))
32
+ }
33
+ } catch (cause) {
34
+ // A name already taken: leave nothing half registered.
35
+ removeAll()
36
+ throw cause
37
+ }
38
+ return removeAll
39
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The door's connection file, `<app>/.acryl/door.json`: where the running app listens and this run's secret, for the operator client in
3
+ * the same folder. The secret is never on a command line or in an environment variable another process can read.
4
+ *
5
+ * Protection: on macOS and Linux the folder is `0700` and the file `0600`, so only the OS user who started the app can read it. On
6
+ * Windows file modes mean nothing; the file is protected by where it lives, the app folder in the user's profile, whose ACL gives access
7
+ * to that user, SYSTEM and Administrators only. The door does not widen it. The folder also carries its own `.gitignore` of `*`, so
8
+ * `git add -A` never stages the secret even in an app whose `.gitignore` lacks the `.acryl/` line.
9
+ */
10
+
11
+ import { randomBytes } from 'node:crypto'
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+
15
+ /** The folder of the door's state below the app folder. */
16
+ export const STATE_DIR = '.acryl'
17
+ export const DOOR_FILE = 'door.json'
18
+
19
+ export interface DoorFile {
20
+ readonly version: 1
21
+ /** The origin the operator reaches, on a loopback address. */
22
+ readonly url: string
23
+ readonly secret: string
24
+ /** The process that wrote it; a file whose process is gone is stale. */
25
+ readonly pid: number
26
+ }
27
+
28
+ export function stateDir(appRoot: string): string {
29
+ return join(appRoot, STATE_DIR)
30
+ }
31
+
32
+ export function doorFilePath(appRoot: string): string {
33
+ return join(stateDir(appRoot), DOOR_FILE)
34
+ }
35
+
36
+ /** A fresh secret for this run; a restart rotates it. */
37
+ export function newSecret(): string {
38
+ return randomBytes(24).toString('hex')
39
+ }
40
+
41
+ /** The state folder, private, with its own `.gitignore`. */
42
+ export function ensureStateDir(appRoot: string): string {
43
+ const dir = stateDir(appRoot)
44
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
45
+ if (process.platform !== 'win32') chmodSync(dir, 0o700)
46
+ const ignore = join(dir, '.gitignore')
47
+ if (!existsSync(ignore)) writeFileSync(ignore, '# The door\'s state: this run\'s secret and the audit. Never commit it.\n*\n')
48
+ return dir
49
+ }
50
+
51
+ /** The connection file as written, or undefined when there is none or it does not parse. */
52
+ export function readDoorFile(appRoot: string): DoorFile | undefined {
53
+ try {
54
+ const value = JSON.parse(readFileSync(doorFilePath(appRoot), 'utf8')) as Partial<DoorFile>
55
+ if (value.version === 1 && typeof value.url === 'string' && typeof value.secret === 'string' && typeof value.pid === 'number') return value as DoorFile
56
+ } catch {
57
+ // Missing or unreadable: there is no door to reach.
58
+ }
59
+ return undefined
60
+ }
61
+
62
+ /** True while the process is alive (a process of another user still counts: `EPERM`). */
63
+ function alive(pid: number): boolean {
64
+ try {
65
+ process.kill(pid, 0)
66
+ return true
67
+ } catch (cause) {
68
+ return (cause as NodeJS.ErrnoException).code === 'EPERM'
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Write this run's connection file, replacing a stale one.
74
+ * @throws when another live process already serves this folder's door: two apps from one folder would race for one file.
75
+ */
76
+ export function writeDoorFile(appRoot: string, url: string, secret: string): string {
77
+ const dir = ensureStateDir(appRoot)
78
+ const existing = readDoorFile(appRoot)
79
+ if (existing !== undefined && existing.pid !== process.pid && alive(existing.pid)) {
80
+ throw new Error(`acryl-door: another process (pid ${String(existing.pid)}) serves the door of ${appRoot}; stop it first`)
81
+ }
82
+ const file = doorFilePath(appRoot)
83
+ const temporary = join(dir, `${DOOR_FILE}.${String(process.pid)}.tmp`)
84
+ const content: DoorFile = { version: 1, url, secret, pid: process.pid }
85
+ writeFileSync(temporary, `${JSON.stringify(content)}\n`, { mode: 0o600 })
86
+ if (process.platform !== 'win32') chmodSync(temporary, 0o600)
87
+ renameSync(temporary, file)
88
+ return file
89
+ }
90
+
91
+ /** Remove the connection file, only when it is this run's: a newer run's file is left alone. */
92
+ export function removeDoorFile(appRoot: string, secret: string): void {
93
+ if (readDoorFile(appRoot)?.secret === secret) rmSync(doorFilePath(appRoot), { force: true })
94
+ }