agent-canvas 0.2.1 → 0.6.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 (99) hide show
  1. package/README.md +42 -17
  2. package/bin/agent-canvas.js +7 -7
  3. package/bin/agent-canvas.ts +48 -37
  4. package/bin/lib/commands/daemon.ts +46 -43
  5. package/bin/lib/commands/exec.ts +100 -100
  6. package/bin/lib/commands/fetch.ts +15 -15
  7. package/bin/lib/commands/install.ts +36 -36
  8. package/bin/lib/commands/instructions.ts +51 -51
  9. package/bin/lib/commands/push.ts +115 -97
  10. package/bin/lib/commands/review.ts +190 -0
  11. package/bin/lib/commands/watch.ts +24 -24
  12. package/bin/lib/config.ts +12 -12
  13. package/bin/lib/daemon-lifecycle.ts +47 -51
  14. package/bin/lib/helpers.ts +82 -76
  15. package/bin/lib/instructions/app-review.md +88 -0
  16. package/bin/lib/instructions/component-checklist.md +7 -5
  17. package/bin/lib/instructions/component-custom.md +20 -14
  18. package/bin/lib/instructions/component-diff.md +4 -3
  19. package/bin/lib/instructions/component-item.md +18 -8
  20. package/bin/lib/instructions/component-markdown.md +4 -2
  21. package/bin/lib/instructions/component-mermaid.md +12 -6
  22. package/bin/lib/instructions/component-multichoice.md +6 -2
  23. package/bin/lib/instructions/component-note.md +1 -1
  24. package/bin/lib/instructions/component-priority.md +1 -1
  25. package/bin/lib/instructions/component-rangeinput.md +9 -2
  26. package/bin/lib/instructions/component-secretinput.md +12 -11
  27. package/bin/lib/instructions/component-svg.md +62 -16
  28. package/bin/lib/instructions/component-table.md +6 -6
  29. package/bin/lib/instructions/component-usefeedback.md +8 -8
  30. package/bin/lib/instructions/component-userinput.md +6 -2
  31. package/bin/lib/instructions/flow-feature.md +22 -5
  32. package/bin/lib/instructions/flow-plan.md +5 -5
  33. package/bin/lib/instructions/flow-runbook.md +28 -25
  34. package/bin/lib/instructions/overview.md +27 -18
  35. package/bin/lib/instructions/theming.md +5 -0
  36. package/daemon/build.ts +171 -152
  37. package/daemon/client/App.tsx +1205 -1077
  38. package/daemon/client/CompareView.tsx +478 -458
  39. package/daemon/client/FileBrowser.tsx +241 -203
  40. package/daemon/client/FileViewer.tsx +220 -211
  41. package/daemon/client/OverviewView.tsx +283 -267
  42. package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
  43. package/daemon/client/RevisionSelect.tsx +111 -95
  44. package/daemon/client/SessionSwitcher.tsx +81 -81
  45. package/daemon/client/ShareDialog.tsx +211 -212
  46. package/daemon/client/apiSchemas.ts +78 -0
  47. package/daemon/client/canvas-runtime.ts +1 -1
  48. package/daemon/client/clientApi.ts +194 -206
  49. package/daemon/client/components/SecretInput.tsx +138 -138
  50. package/daemon/client/components/index.ts +2 -2
  51. package/daemon/client/domDiff.ts +341 -333
  52. package/daemon/client/errorMessage.ts +3 -0
  53. package/daemon/client/exportMarkdown.ts +222 -201
  54. package/daemon/client/lcs.ts +19 -14
  55. package/daemon/client/shareCrypto.ts +57 -57
  56. package/daemon/client/unifiedDiff.ts +660 -649
  57. package/daemon/dist/annotations-runtime.js +1 -1
  58. package/daemon/dist/client.css +2 -2
  59. package/daemon/dist/client.js +77 -80
  60. package/daemon/dist/components.js +59 -59
  61. package/daemon/dist/preact-compat.js +1 -1
  62. package/daemon/dist/review.js +361 -0
  63. package/daemon/dist/runtime.js +1 -1
  64. package/daemon/langMap.ts +24 -6
  65. package/daemon/review-widget/anchor.ts +187 -0
  66. package/daemon/review-widget/client.ts +95 -0
  67. package/daemon/review-widget/index.tsx +36 -0
  68. package/daemon/review-widget/markup.ts +71 -0
  69. package/daemon/review-widget/screenshot.ts +91 -0
  70. package/daemon/review-widget/source.ts +103 -0
  71. package/daemon/review-widget/styles.css +359 -0
  72. package/daemon/review-widget/text-modules.d.ts +4 -0
  73. package/daemon/review-widget/tsconfig.json +16 -0
  74. package/daemon/review-widget/widget.tsx +436 -0
  75. package/daemon/src/components.ts +3 -3
  76. package/daemon/src/handlers/api.ts +330 -328
  77. package/daemon/src/handlers/files.ts +112 -106
  78. package/daemon/src/handlers/static.ts +47 -46
  79. package/daemon/src/handlers/uploads.ts +61 -56
  80. package/daemon/src/paths.ts +13 -10
  81. package/daemon/src/remote-feedback.ts +192 -192
  82. package/daemon/src/review/markdown.ts +52 -0
  83. package/daemon/src/review/model.ts +132 -0
  84. package/daemon/src/review/proxy.ts +177 -0
  85. package/daemon/src/review/server.ts +199 -0
  86. package/daemon/src/review/store.ts +151 -0
  87. package/daemon/src/server.ts +127 -86
  88. package/daemon/src/share.ts +163 -169
  89. package/daemon/src/stubFetch.ts +7 -0
  90. package/daemon/tsconfig.json +19 -19
  91. package/package.json +47 -47
  92. package/skills/canvas/SKILL.md +5 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/files-api.test.ts +0 -78
  96. package/daemon/src/remote-feedback.test.ts +0 -165
  97. package/daemon/src/secrets-api.test.ts +0 -123
  98. package/daemon/src/share-e2e.test.ts +0 -277
  99. package/daemon/src/share.test.ts +0 -289
@@ -0,0 +1,177 @@
1
+ import type { ReviewStore } from './store'
2
+
3
+ /** One browser socket proxied to the application, e.g. its dev server's hot reload. */
4
+ interface ProxySocketData {
5
+ upstream: WebSocket
6
+ /** Frames the application sent before the browser's socket opened. */
7
+ early: Array<string | ArrayBuffer>
8
+ browser: Bun.ServerWebSocket<ProxySocketData> | null
9
+ }
10
+
11
+ type ProxyServer = Bun.Server<ProxySocketData>
12
+
13
+ interface RunningProxy {
14
+ target: string
15
+ server: ProxyServer
16
+ }
17
+
18
+ /** Puts the widget's tag before `</body>`, or at the end of a document without one. */
19
+ export function withWidget(html: string, tag: string): string {
20
+ const bodyEnd = html.toLowerCase().lastIndexOf('</body>')
21
+ return bodyEnd === -1 ? `${html}${tag}` : `${html.slice(0, bodyEnd)}${tag}${html.slice(bodyEnd)}`
22
+ }
23
+
24
+ function connectUpstream(url: URL, protocols: string[]): Promise<WebSocket> {
25
+ return new Promise((resolve, reject) => {
26
+ const socket = new WebSocket(url, protocols)
27
+ socket.binaryType = 'arraybuffer'
28
+ socket.onopen = () => resolve(socket)
29
+ socket.onerror = () => reject(new Error(`WebSocket ${url} failed`))
30
+ })
31
+ }
32
+
33
+ /**
34
+ * Review proxies: each project's application, served on a port of its own with the review widget added to every HTML
35
+ * page, so a reviewer opens a URL instead of editing the application. The proxy's origin is a loopback one, which the
36
+ * review routes accept; it forwards to applications on this machine only.
37
+ */
38
+ export class ReviewProxies {
39
+ private readonly running = new Map<string, RunningProxy>()
40
+
41
+ constructor(private readonly store: ReviewStore, private readonly widgetScript: string) {}
42
+
43
+ /** Serves the project's application at `target` and returns where the reviewer opens it, `target`'s path kept. */
44
+ open(project: string, target: string): string {
45
+ const targetUrl = new URL(target)
46
+ const current = this.running.get(project)
47
+ if (current === undefined || current.target !== targetUrl.origin) {
48
+ const port = current?.server.port ?? this.store.proxyOf(project)?.port
49
+ current?.server.stop(true)
50
+ this.listen(project, targetUrl.origin, port)
51
+ }
52
+ const proxy = this.running.get(project)
53
+ if (proxy === undefined) throw new Error(`Review proxy for ${project} did not start`)
54
+ return `http://localhost:${proxy.server.port}${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`
55
+ }
56
+
57
+ /** Serves every proxy a previous daemon served, on the same ports. */
58
+ restore(): void {
59
+ for (const project of this.store.proxiedProjects()) {
60
+ try {
61
+ const proxy = this.store.proxyOf(project)
62
+ if (proxy !== undefined) this.listen(project, proxy.target, proxy.port)
63
+ } catch (error) {
64
+ console.error(`Review proxy for ${project} not restored: ${error instanceof Error ? error.message : String(error)}`)
65
+ }
66
+ }
67
+ }
68
+
69
+ stop(): void {
70
+ for (const proxy of this.running.values()) proxy.server.stop(true)
71
+ this.running.clear()
72
+ }
73
+
74
+ private listen(project: string, target: string, port: number | undefined): void {
75
+ let server: ProxyServer
76
+ try {
77
+ server = this.serve(project, target, port ?? 0)
78
+ } catch (error) {
79
+ if (port === undefined) throw error
80
+ // The previous port is taken; the reviewer opens the new URL the CLI prints.
81
+ server = this.serve(project, target, 0)
82
+ }
83
+ this.running.set(project, { target, server })
84
+ this.store.saveProxy(project, { target, port: server.port ?? 0 })
85
+ }
86
+
87
+ private serve(project: string, target: string, port: number): ProxyServer {
88
+ const widgetTag = `<script src="${this.widgetScript}" data-project="${project}"></script>`
89
+ return Bun.serve<ProxySocketData>({
90
+ port,
91
+ hostname: 'localhost',
92
+ fetch: async (req, server) => {
93
+ const url = new URL(req.url)
94
+ const upstreamUrl = new URL(`${url.pathname}${url.search}`, target)
95
+ if (req.headers.get('Upgrade')?.toLowerCase() === 'websocket') {
96
+ upstreamUrl.protocol = upstreamUrl.protocol === 'https:' ? 'wss:' : 'ws:'
97
+ return await this.upgrade(req, server, upstreamUrl)
98
+ }
99
+ return await forward(req, upstreamUrl, url.origin, widgetTag)
100
+ },
101
+ websocket: {
102
+ open(socket) {
103
+ socket.data.browser = socket
104
+ for (const frame of socket.data.early) socket.send(frame)
105
+ socket.data.early = []
106
+ if (socket.data.upstream.readyState !== WebSocket.OPEN) socket.close()
107
+ },
108
+ message(socket, message) {
109
+ if (socket.data.upstream.readyState === WebSocket.OPEN) socket.data.upstream.send(message)
110
+ },
111
+ close(socket) {
112
+ socket.data.upstream.close()
113
+ },
114
+ },
115
+ })
116
+ }
117
+
118
+ /** Connects to the application first, so the browser gets the subprotocol the application chose. */
119
+ private async upgrade(req: Request, server: ProxyServer, upstreamUrl: URL): Promise<Response | undefined> {
120
+ const protocols = (req.headers.get('Sec-WebSocket-Protocol') ?? '').split(',').map((protocol) => protocol.trim()).filter((protocol) =>
121
+ protocol !== ''
122
+ )
123
+ let upstream: WebSocket
124
+ try {
125
+ upstream = await connectUpstream(upstreamUrl, protocols)
126
+ } catch {
127
+ return new Response('agent-canvas review: the application refused the WebSocket', { status: 502 })
128
+ }
129
+ const data: ProxySocketData = { upstream, early: [], browser: null }
130
+ upstream.onmessage = (event) => {
131
+ if (typeof event.data !== 'string' && !(event.data instanceof ArrayBuffer)) return
132
+ if (data.browser === null) data.early.push(event.data)
133
+ else data.browser.send(event.data)
134
+ }
135
+ upstream.onclose = () => data.browser?.close()
136
+ // Bun rejects an empty `headers` object, so a socket without a subprotocol (Bun's own hot reload) passes none.
137
+ const upgraded = upstream.protocol === ''
138
+ ? server.upgrade(req, { data })
139
+ : server.upgrade(req, { data, headers: { 'Sec-WebSocket-Protocol': upstream.protocol } })
140
+ if (upgraded) return undefined
141
+ upstream.close()
142
+ return new Response('WebSocket upgrade failed', { status: 400 })
143
+ }
144
+ }
145
+
146
+ async function forward(req: Request, upstreamUrl: URL, proxyOrigin: string, widgetTag: string): Promise<Response> {
147
+ const headers = new Headers(req.headers)
148
+ headers.delete('Host')
149
+ // Uncompressed, so an HTML page can take the widget's tag.
150
+ headers.set('Accept-Encoding', 'identity')
151
+ if (headers.get('Origin') === proxyOrigin) headers.set('Origin', upstreamUrl.origin)
152
+ let upstream: Response
153
+ try {
154
+ upstream = await fetch(upstreamUrl, {
155
+ method: req.method,
156
+ headers,
157
+ body: req.method === 'GET' || req.method === 'HEAD' ? null : req.body,
158
+ redirect: 'manual',
159
+ })
160
+ } catch {
161
+ return new Response(`agent-canvas review: ${upstreamUrl.origin} does not answer. Is the application running?`, {
162
+ status: 502,
163
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
164
+ })
165
+ }
166
+
167
+ const responseHeaders = new Headers(upstream.headers)
168
+ // fetch hands over a decoded body, whatever the application sent.
169
+ responseHeaders.delete('Content-Encoding')
170
+ responseHeaders.delete('Content-Length')
171
+ const location = responseHeaders.get('Location')
172
+ if (location?.startsWith(upstreamUrl.origin)) responseHeaders.set('Location', `${proxyOrigin}${location.slice(upstreamUrl.origin.length)}`)
173
+
174
+ const init = { status: upstream.status, statusText: upstream.statusText, headers: responseHeaders }
175
+ if (req.method === 'HEAD' || !(responseHeaders.get('Content-Type') ?? '').startsWith('text/html')) return new Response(upstream.body, init)
176
+ return new Response(withWidget(await upstream.text(), widgetTag), init)
177
+ }
@@ -0,0 +1,199 @@
1
+ import { type ChannelSocket, createNotificationChannel, dispatch, jsonResponse, type Route } from '@fabrika/daemon-kit'
2
+ import type { z } from 'zod'
3
+ import { renderReviewRequests } from './markdown'
4
+ import {
5
+ addCommentBodySchema,
6
+ agentReplyBodySchema,
7
+ createThreadBodySchema,
8
+ isLoopbackOrigin,
9
+ openProxyBodySchema,
10
+ PROJECT_RE,
11
+ type ReviewBrowserFrame,
12
+ type ReviewWaitFrame,
13
+ } from './model'
14
+ import type { ReviewProxies } from './proxy'
15
+ import { ReviewError, type ReviewStore } from './store'
16
+
17
+ export interface ReviewSocketData {
18
+ type: 'review'
19
+ role: 'browser' | 'wait'
20
+ project: string
21
+ }
22
+
23
+ export interface ReviewSocket extends ChannelSocket {
24
+ data: ReviewSocketData
25
+ }
26
+
27
+ function corsHeaders(origin: string): Record<string, string> {
28
+ return {
29
+ 'Access-Control-Allow-Origin': origin,
30
+ 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
31
+ 'Access-Control-Allow-Headers': 'Content-Type',
32
+ Vary: 'Origin',
33
+ }
34
+ }
35
+
36
+ async function parseBody<T extends z.ZodTypeAny>(req: Request, schema: T): Promise<z.infer<T>> {
37
+ let body: unknown
38
+ try {
39
+ body = await req.json()
40
+ } catch {
41
+ throw new ReviewError('Body must be JSON', 400)
42
+ }
43
+ const parsed = schema.safeParse(body)
44
+ if (!parsed.success) throw new ReviewError(parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '), 400)
45
+ return parsed.data
46
+ }
47
+
48
+ function projectOf(match: URLPatternResult): string {
49
+ const project = match.pathname.groups['project'] ?? ''
50
+ if (!PROJECT_RE.test(project)) throw new ReviewError(`Invalid project name ${project}`, 400)
51
+ return project
52
+ }
53
+
54
+ function threadIdOf(match: URLPatternResult): string {
55
+ return match.pathname.groups['thread'] ?? ''
56
+ }
57
+
58
+ type Audience = 'browser' | 'agent'
59
+
60
+ /** Review mode: threads a person leaves on a running application through the injected widget, and their delivery to the agent. */
61
+ export function createReviewServer(store: ReviewStore, proxies: ReviewProxies) {
62
+ const channel = createNotificationChannel<ReviewSocket>({
63
+ roleOf: (socket) => socket.data.role,
64
+ topicOf: (socket) => socket.data.project,
65
+ presenceFrame: (watching): ReviewBrowserFrame => ({ type: 'agent-status', watching }),
66
+ })
67
+
68
+ function publish(project: string): void {
69
+ const frame: ReviewBrowserFrame = { type: 'threads', threads: store.list(project) }
70
+ channel.broadcast(project, frame)
71
+ }
72
+
73
+ function notifyAgent(project: string): void {
74
+ const frame: ReviewWaitFrame = { type: 'queued' }
75
+ channel.relayToWaiters(project, frame)
76
+ }
77
+
78
+ /**
79
+ * Browser routes answer only loopback origins. Agent routes refuse any request a browser makes: browsers send
80
+ * `Origin` on every cross-origin request, the CLI sends none.
81
+ */
82
+ function route(
83
+ method: Route['method'],
84
+ pathname: string,
85
+ audience: Audience,
86
+ handle: (req: Request, match: URLPatternResult) => Promise<unknown>,
87
+ ): Route {
88
+ return {
89
+ method,
90
+ pattern: new URLPattern({ pathname }),
91
+ handler: async (req, _url, match) => {
92
+ const origin = req.headers.get('Origin')
93
+ if (audience === 'agent' && origin !== null) return jsonResponse({ error: 'Agent endpoints do not accept browser requests' }, 403)
94
+ if (origin !== null && !isLoopbackOrigin(origin)) return jsonResponse({ error: 'Origin not allowed' }, 403)
95
+ let response: Response
96
+ try {
97
+ response = jsonResponse(await handle(req, match))
98
+ } catch (error) {
99
+ if (!(error instanceof ReviewError)) throw error
100
+ response = jsonResponse({ error: error.message }, error.status)
101
+ }
102
+ if (origin !== null) { for (const [name, value] of Object.entries(corsHeaders(origin))) response.headers.set(name, value) }
103
+ return response
104
+ },
105
+ }
106
+ }
107
+
108
+ const routes: Route[] = [
109
+ route('GET', '/api/review/:project/threads', 'browser', async (_req, match) => ({ threads: store.list(projectOf(match)) })),
110
+ route('POST', '/api/review/:project/threads', 'browser', async (req, match) => {
111
+ const project = projectOf(match)
112
+ const body = await parseBody(req, createThreadBodySchema)
113
+ const thread = store.create(project, body.anchor, body.body, body.send, body.screenshot)
114
+ publish(project)
115
+ if (body.send) notifyAgent(project)
116
+ return { thread }
117
+ }),
118
+ route('POST', '/api/review/:project/threads/:thread/comments', 'browser', async (req, match) => {
119
+ const project = projectOf(match)
120
+ const body = await parseBody(req, addCommentBodySchema)
121
+ const thread = store.addUserComment(project, threadIdOf(match), body.body, body.send)
122
+ publish(project)
123
+ if (body.send) notifyAgent(project)
124
+ return { thread }
125
+ }),
126
+ route('POST', '/api/review/:project/threads/:thread/send', 'browser', async (_req, match) => {
127
+ const project = projectOf(match)
128
+ const thread = store.send(project, threadIdOf(match))
129
+ publish(project)
130
+ notifyAgent(project)
131
+ return { thread }
132
+ }),
133
+ route('POST', '/api/review/:project/threads/:thread/resolve', 'browser', async (_req, match) => {
134
+ const project = projectOf(match)
135
+ const thread = store.setResolved(project, threadIdOf(match), true)
136
+ publish(project)
137
+ return { thread }
138
+ }),
139
+ route('POST', '/api/review/:project/threads/:thread/reopen', 'browser', async (_req, match) => {
140
+ const project = projectOf(match)
141
+ const thread = store.setResolved(project, threadIdOf(match), false)
142
+ publish(project)
143
+ return { thread }
144
+ }),
145
+ route('DELETE', '/api/review/:project/threads/:thread', 'browser', async (_req, match) => {
146
+ const project = projectOf(match)
147
+ store.delete(project, threadIdOf(match))
148
+ publish(project)
149
+ return { ok: true }
150
+ }),
151
+ route('POST', '/api/review/:project/claim', 'agent', async (_req, match) => {
152
+ const project = projectOf(match)
153
+ const threads = store.claimQueued(project)
154
+ if (threads.length === 0) return { found: false }
155
+ publish(project)
156
+ return { found: true, markdown: renderReviewRequests(project, threads) }
157
+ }),
158
+ route('POST', '/api/review/:project/proxy', 'agent', async (req, match) => {
159
+ const body = await parseBody(req, openProxyBodySchema)
160
+ return { url: proxies.open(projectOf(match), body.target) }
161
+ }),
162
+ route('POST', '/api/review/:project/threads/:thread/reply', 'agent', async (req, match) => {
163
+ const project = projectOf(match)
164
+ const body = await parseBody(req, agentReplyBodySchema)
165
+ const thread = store.agentReply(project, threadIdOf(match), body.body, body.resolve)
166
+ publish(project)
167
+ return { thread }
168
+ }),
169
+ ]
170
+
171
+ return {
172
+ channel,
173
+ /** Answers the request when it is review mode's: CORS preflight, a route, or a WebSocket upgrade. */
174
+ fetch(
175
+ req: Request,
176
+ url: URL,
177
+ server: { upgrade(req: Request, options: { data: ReviewSocketData }): boolean },
178
+ ): Response | Promise<Response> | 'upgraded' | null {
179
+ const socketMatch = url.pathname.match(/^\/ws\/review(-wait)?\/([^/]+)$/)
180
+ if (socketMatch !== null) {
181
+ const origin = req.headers.get('Origin')
182
+ const project = socketMatch[2] ?? ''
183
+ const role = socketMatch[1] === undefined ? 'browser' : 'wait'
184
+ if (!PROJECT_RE.test(project)) return jsonResponse({ error: 'Invalid project name' }, 400)
185
+ if (role === 'browser' ? origin === null || !isLoopbackOrigin(origin) : origin !== null) return jsonResponse({ error: 'Origin not allowed' }, 403)
186
+ return server.upgrade(req, { data: { type: 'review', role, project } }) ? 'upgraded' : new Response('WebSocket upgrade failed', { status: 400 })
187
+ }
188
+ if (!url.pathname.startsWith('/api/review/')) return null
189
+ if (req.method === 'OPTIONS') {
190
+ const origin = req.headers.get('Origin')
191
+ if (origin === null || !isLoopbackOrigin(origin)) return new Response(null, { status: 403 })
192
+ return new Response(null, { status: 204, headers: corsHeaders(origin) })
193
+ }
194
+ return dispatch(routes, req, url)
195
+ },
196
+ }
197
+ }
198
+
199
+ export type ReviewServer = ReturnType<typeof createReviewServer>
@@ -0,0 +1,151 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs'
2
+ import { join } from 'path'
3
+ import {
4
+ PROJECT_RE,
5
+ type ReviewAnchor,
6
+ type ReviewAuthor,
7
+ type ReviewProxy,
8
+ reviewProxySchema,
9
+ type ReviewThread,
10
+ reviewThreadsFileSchema,
11
+ type ReviewThreadState,
12
+ } from './model'
13
+
14
+ export class ReviewError extends Error {
15
+ constructor(message: string, readonly status: 400 | 404 | 409) {
16
+ super(message)
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Review threads of each project: `<directory>/<project>/threads.json`, attached screenshots beside it, and the
22
+ * project's review proxy in `proxy.json`.
23
+ */
24
+ export class ReviewStore {
25
+ constructor(private readonly directory: string, private readonly now: () => Date = () => new Date()) {}
26
+
27
+ list(project: string): readonly ReviewThread[] {
28
+ const path = join(this.projectDirectory(project), 'threads.json')
29
+ if (!existsSync(path)) return []
30
+ return reviewThreadsFileSchema.parse(JSON.parse(readFileSync(path, 'utf-8'))).threads
31
+ }
32
+
33
+ create(project: string, anchor: ReviewAnchor, body: string, send: boolean, screenshot?: Uint8Array): ReviewThread {
34
+ const createdAt = this.timestamp()
35
+ const id = Bun.randomUUIDv7()
36
+ const thread: ReviewThread = {
37
+ id,
38
+ anchor,
39
+ state: send ? 'queued' : 'open',
40
+ comments: [{ id: Bun.randomUUIDv7(), author: 'user', body, createdAt }],
41
+ ...(screenshot === undefined ? {} : { screenshot: this.writeScreenshot(project, id, screenshot) }),
42
+ createdAt,
43
+ updatedAt: createdAt,
44
+ }
45
+ this.save(project, [...this.list(project), thread])
46
+ return thread
47
+ }
48
+
49
+ /** A comment from the person reopens a resolved thread; with `send` it goes to the agent. */
50
+ addUserComment(project: string, threadId: string, body: string, send: boolean): ReviewThread {
51
+ return this.update(project, threadId, (thread) => {
52
+ const state: ReviewThreadState = send ? 'queued' : thread.state === 'resolved' ? 'open' : thread.state
53
+ return { ...this.withComment(thread, 'user', body), state }
54
+ })
55
+ }
56
+
57
+ send(project: string, threadId: string): ReviewThread {
58
+ return this.update(project, threadId, (thread) => {
59
+ if (thread.state === 'working') throw new ReviewError('The agent is already working on this thread', 409)
60
+ return { ...thread, state: 'queued', updatedAt: this.timestamp() }
61
+ })
62
+ }
63
+
64
+ setResolved(project: string, threadId: string, resolved: boolean): ReviewThread {
65
+ return this.update(project, threadId, (thread) => ({ ...thread, state: resolved ? 'resolved' : 'open', updatedAt: this.timestamp() }))
66
+ }
67
+
68
+ delete(project: string, threadId: string): void {
69
+ const threads = this.list(project)
70
+ const thread = threads.find((candidate) => candidate.id === threadId)
71
+ if (thread === undefined) throw new ReviewError(`No thread ${threadId}`, 404)
72
+ this.save(project, threads.filter((candidate) => candidate.id !== threadId))
73
+ if (thread.screenshot !== undefined) rmSync(thread.screenshot, { force: true })
74
+ }
75
+
76
+ /** Hands every queued thread to the agent: they become `working` and are returned in creation order. */
77
+ claimQueued(project: string): readonly ReviewThread[] {
78
+ const threads = this.list(project)
79
+ const claimed = threads.filter((thread) => thread.state === 'queued')
80
+ if (claimed.length === 0) return []
81
+ const updatedAt = this.timestamp()
82
+ const claimedIds = new Set(claimed.map((thread) => thread.id))
83
+ this.save(project, threads.map((thread) => (claimedIds.has(thread.id) ? { ...thread, state: 'working', updatedAt } : thread)))
84
+ return claimed.map((thread) => ({ ...thread, state: 'working', updatedAt }))
85
+ }
86
+
87
+ /** The agent's answer. Without `resolve` the thread goes back to the person as `open`. */
88
+ agentReply(project: string, threadId: string, body: string, resolve: boolean): ReviewThread {
89
+ return this.update(project, threadId, (thread) => ({ ...this.withComment(thread, 'agent', body), state: resolve ? 'resolved' : 'open' }))
90
+ }
91
+
92
+ proxyOf(project: string): ReviewProxy | undefined {
93
+ const path = join(this.projectDirectory(project), 'proxy.json')
94
+ if (!existsSync(path)) return undefined
95
+ return reviewProxySchema.parse(JSON.parse(readFileSync(path, 'utf-8')))
96
+ }
97
+
98
+ saveProxy(project: string, proxy: ReviewProxy): void {
99
+ this.writeJson(project, 'proxy.json', proxy)
100
+ }
101
+
102
+ /** Projects that have a review proxy, for a starting daemon to serve again. */
103
+ proxiedProjects(): string[] {
104
+ if (!existsSync(this.directory)) return []
105
+ return readdirSync(this.directory).filter((name) => PROJECT_RE.test(name) && existsSync(join(this.directory, name, 'proxy.json')))
106
+ }
107
+
108
+ private update(project: string, threadId: string, change: (thread: ReviewThread) => ReviewThread): ReviewThread {
109
+ const threads = this.list(project)
110
+ const current = threads.find((thread) => thread.id === threadId)
111
+ if (current === undefined) throw new ReviewError(`No thread ${threadId}`, 404)
112
+ const next = change(current)
113
+ this.save(project, threads.map((thread) => (thread.id === threadId ? next : thread)))
114
+ return next
115
+ }
116
+
117
+ private withComment(thread: ReviewThread, author: ReviewAuthor, body: string): ReviewThread {
118
+ const createdAt = this.timestamp()
119
+ return { ...thread, comments: [...thread.comments, { id: Bun.randomUUIDv7(), author, body, createdAt }], updatedAt: createdAt }
120
+ }
121
+
122
+ private writeScreenshot(project: string, threadId: string, png: Uint8Array): string {
123
+ const directory = join(this.projectDirectory(project), 'screenshots')
124
+ mkdirSync(directory, { recursive: true })
125
+ const path = join(directory, `${threadId}.png`)
126
+ writeFileSync(path, png)
127
+ return path
128
+ }
129
+
130
+ private save(project: string, threads: readonly ReviewThread[]): void {
131
+ this.writeJson(project, 'threads.json', { threads })
132
+ }
133
+
134
+ private writeJson(project: string, file: string, value: unknown): void {
135
+ const directory = this.projectDirectory(project)
136
+ mkdirSync(directory, { recursive: true })
137
+ const path = join(directory, file)
138
+ const temporary = `${path}.${process.pid}.tmp`
139
+ writeFileSync(temporary, `${JSON.stringify(value, null, '\t')}\n`)
140
+ renameSync(temporary, path)
141
+ }
142
+
143
+ private projectDirectory(project: string): string {
144
+ if (!PROJECT_RE.test(project)) throw new ReviewError(`Invalid project name ${project}`, 400)
145
+ return join(this.directory, project)
146
+ }
147
+
148
+ private timestamp(): string {
149
+ return this.now().toISOString()
150
+ }
151
+ }