agent-canvas 0.2.0 → 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 (98) 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 -105
  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 +9 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/remote-feedback.test.ts +0 -165
  96. package/daemon/src/secrets-api.test.ts +0 -123
  97. package/daemon/src/share-e2e.test.ts +0 -277
  98. package/daemon/src/share.test.ts +0 -289
@@ -1,6 +1,7 @@
1
- import type { SessionManager, RemoteFeedbackEntry, RemoteAnnotation } from "@fabrika/canvas-kernel/server";
2
- import { loadShareConfig } from "./share";
3
- import { importShareKey, decryptString } from "../client/shareCrypto";
1
+ import type { RemoteAnnotation, RemoteFeedbackEntry, SessionManager } from '@fabrika/canvas-kernel/server'
2
+ import { errorMessage } from '../client/errorMessage'
3
+ import { decryptString, importShareKey } from '../client/shareCrypto'
4
+ import { loadShareConfig } from './share'
4
5
 
5
6
  /**
6
7
  * Background polling loop for remote feedback.
@@ -17,212 +18,211 @@ import { importShareKey, decryptString } from "../client/shareCrypto";
17
18
  * as soon as the worker comes back.
18
19
  */
19
20
 
20
- const POLL_INTERVAL_MS = 5_000;
21
- const MAX_BACKOFF_MS = 60_000;
21
+ const POLL_INTERVAL_MS = 5_000
22
+ const MAX_BACKOFF_MS = 60_000
22
23
 
23
24
  /** Wire-format feedback entry as returned by the worker. When the
24
25
  * reviewer submitted from an encrypted share their browser ciphertexted
25
26
  * the secret fields and stamped this with `encryption`; the daemon
26
27
  * decrypts using the key stashed on the local ShareEntry. */
27
28
  interface WireFeedbackEntry {
28
- id: string;
29
- shareId: string;
30
- revision: number;
31
- submittedAt: string;
32
- author: { id: string; name: string };
33
- annotations: Array<{
34
- id: string;
35
- snippet: string;
36
- note: string;
37
- createdAt: string;
38
- filePath?: string;
39
- canvasFile?: string;
40
- context?: unknown;
41
- attachments?: Array<{ url: string; mime?: string }>;
42
- }>;
43
- generalNote?: string;
44
- encryption?: { alg: string; v: number };
29
+ id: string
30
+ shareId: string
31
+ revision: number
32
+ submittedAt: string
33
+ author: { id: string; name: string }
34
+ annotations: Array<{
35
+ id: string
36
+ snippet: string
37
+ note: string
38
+ createdAt: string
39
+ filePath?: string
40
+ canvasFile?: string
41
+ context?: unknown
42
+ attachments?: Array<{ url: string; mime?: string }>
43
+ }>
44
+ generalNote?: string
45
+ encryption?: { alg: string; v: number }
45
46
  }
46
47
 
47
48
  interface WorkerFeedbackResponse {
48
- entries: WireFeedbackEntry[];
49
- /** Server-reported high water mark — echo back in the next `since` param */
50
- latestAt?: string;
49
+ entries: WireFeedbackEntry[]
50
+ /** Server-reported high water mark — echo back in the next `since` param */
51
+ latestAt?: string
51
52
  }
52
53
 
53
54
  /** Decrypt an encrypted-wire feedback entry into the plaintext shape used
54
55
  * throughout the rest of the daemon. Throws if any field fails to
55
56
  * decrypt — caller treats that as a transient error so we re-try later. */
56
57
  async function decryptFeedbackEntry(entry: WireFeedbackEntry, key: CryptoKey): Promise<RemoteFeedbackEntry> {
57
- const annotations: RemoteAnnotation[] = await Promise.all(
58
- entry.annotations.map(async (a) => {
59
- const out: RemoteAnnotation = {
60
- id: a.id,
61
- snippet: await decryptString(key, a.snippet),
62
- note: await decryptString(key, a.note),
63
- createdAt: a.createdAt,
64
- };
65
- if (a.filePath) out.filePath = await decryptString(key, a.filePath);
66
- if (a.canvasFile) out.canvasFile = await decryptString(key, a.canvasFile);
67
- if (typeof a.context === "string" && a.context.length > 0) {
68
- out.context = JSON.parse(await decryptString(key, a.context));
69
- }
70
- if (a.attachments) out.attachments = a.attachments;
71
- return out;
72
- }),
73
- );
74
-
75
- return {
76
- id: entry.id,
77
- shareId: entry.shareId,
78
- revision: entry.revision,
79
- submittedAt: entry.submittedAt,
80
- author: {
81
- id: entry.author.id,
82
- name: await decryptString(key, entry.author.name),
83
- },
84
- annotations,
85
- ...(entry.generalNote ? { generalNote: await decryptString(key, entry.generalNote) } : {}),
86
- };
58
+ const annotations: RemoteAnnotation[] = await Promise.all(
59
+ entry.annotations.map(async (a) => {
60
+ const out: RemoteAnnotation = {
61
+ id: a.id,
62
+ snippet: await decryptString(key, a.snippet),
63
+ note: await decryptString(key, a.note),
64
+ createdAt: a.createdAt,
65
+ }
66
+ if (a.filePath) out.filePath = await decryptString(key, a.filePath)
67
+ if (a.canvasFile) out.canvasFile = await decryptString(key, a.canvasFile)
68
+ if (typeof a.context === 'string' && a.context.length > 0) {
69
+ out.context = JSON.parse(await decryptString(key, a.context))
70
+ }
71
+ if (a.attachments) out.attachments = a.attachments
72
+ return out
73
+ }),
74
+ )
75
+
76
+ return {
77
+ id: entry.id,
78
+ shareId: entry.shareId,
79
+ revision: entry.revision,
80
+ submittedAt: entry.submittedAt,
81
+ author: {
82
+ id: entry.author.id,
83
+ name: await decryptString(key, entry.author.name),
84
+ },
85
+ annotations,
86
+ ...(entry.generalNote ? { generalNote: await decryptString(key, entry.generalNote) } : {}),
87
+ }
87
88
  }
88
89
 
89
90
  export function startRemoteFeedbackPoller(
90
- sessionManager: SessionManager,
91
- broadcastRemoteFeedback: (sessionId: string, revision: number, entries: RemoteFeedbackEntry[]) => void,
92
- version: string,
91
+ sessionManager: SessionManager,
92
+ broadcastRemoteFeedback: (sessionId: string, revision: number, entries: RemoteFeedbackEntry[]) => void,
93
+ version: string,
93
94
  ): { stop: () => void } {
94
- let stopped = false;
95
-
96
- // Per-share CryptoKey cache — `importKey` is async and pure given the
97
- // same encoded key, so we memoize it for the lifetime of the poller.
98
- const keyCache = new Map<string, Promise<CryptoKey>>();
99
- function getKey(shareId: string, encoded: string): Promise<CryptoKey> {
100
- let cached = keyCache.get(shareId);
101
- if (!cached) {
102
- cached = importShareKey(encoded);
103
- keyCache.set(shareId, cached);
104
- }
105
- return cached;
106
- }
107
-
108
- // Per-share consecutive error count → exponential backoff so a dead
109
- // worker doesn't generate a fire-hose of failed requests.
110
- const errorCounts = new Map<string, number>();
111
- function backoffFor(shareId: string): number {
112
- const errors = errorCounts.get(shareId) || 0;
113
- if (errors === 0) return 0;
114
- return Math.min(MAX_BACKOFF_MS, POLL_INTERVAL_MS * Math.pow(2, errors - 1));
115
- }
116
-
117
- async function pollShare(
118
- sessionId: string,
119
- share: { shareId: string; lastFeedbackAt?: string; encryptionKey?: string },
120
- endpoint: string,
121
- ) {
122
- try {
123
- const since = share.lastFeedbackAt ?? "";
124
- const url = `${endpoint}/shares/${share.shareId}/feedback${since ? `?since=${encodeURIComponent(since)}` : ""}`;
125
- const res = await fetch(url);
126
- if (!res.ok) {
127
- if (res.status === 404) {
128
- // Share no longer exists on the worker — likely revoked or expired.
129
- // Stop trying so we don't hammer it forever.
130
- errorCounts.set(share.shareId, 99);
131
- return;
132
- }
133
- throw new Error(`HTTP ${res.status}`);
134
- }
135
- const data = (await res.json()) as WorkerFeedbackResponse;
136
- errorCounts.delete(share.shareId);
137
-
138
- if (!data.entries || data.entries.length === 0) return;
139
-
140
- // Decrypt any entries that came in encrypted. Plaintext entries
141
- // (legacy submissions from non-encrypted shares) pass through.
142
- const decrypted: RemoteFeedbackEntry[] = [];
143
- for (const e of data.entries) {
144
- if (e.encryption) {
145
- if (!share.encryptionKey) {
146
- console.warn(`[remote-feedback] ${share.shareId}: received encrypted feedback but no local key — skipping entry ${e.id}`);
147
- continue;
148
- }
149
- try {
150
- const key = await getKey(share.shareId, share.encryptionKey);
151
- decrypted.push(await decryptFeedbackEntry(e, key));
152
- } catch (err: any) {
153
- console.warn(`[remote-feedback] ${share.shareId}: failed to decrypt entry ${e.id}: ${err.message || err}`);
154
- continue;
155
- }
156
- } else {
157
- decrypted.push(e as RemoteFeedbackEntry);
158
- }
159
- }
160
-
161
- if (decrypted.length === 0) return;
162
-
163
- const byRev = new Map<number, RemoteFeedbackEntry[]>();
164
- for (const e of decrypted) {
165
- if (!byRev.has(e.revision)) byRev.set(e.revision, []);
166
- byRev.get(e.revision)!.push(e);
167
- }
168
-
169
- for (const [rev, entries] of byRev) {
170
- sessionManager.appendRemoteFeedback(sessionId, rev, entries);
171
- broadcastRemoteFeedback(sessionId, rev, entries);
172
- }
173
-
174
- const latest = data.latestAt
175
- ?? data.entries.reduce((acc, e) => (e.submittedAt > acc ? e.submittedAt : acc), share.lastFeedbackAt ?? "");
176
- if (latest) {
177
- sessionManager.updateShareLastFeedback(sessionId, share.shareId, latest);
178
- }
179
- } catch (e: any) {
180
- errorCounts.set(share.shareId, (errorCounts.get(share.shareId) || 0) + 1);
181
- console.warn(`[remote-feedback] ${share.shareId}: ${e.message || e} (will retry with backoff)`);
182
- }
183
- }
184
-
185
- // Per-share next-poll timestamps so backoffs are independent.
186
- const nextPollAt = new Map<string, number>();
187
-
188
- async function pollOnce() {
189
- const config = loadShareConfig(version);
190
- if (!config) return;
191
-
192
- const sessions = sessionManager.listSessionsWithShares();
193
- if (sessions.length === 0) return;
194
-
195
- const now = Date.now();
196
- const tasks: Promise<void>[] = [];
197
- for (const session of sessions) {
198
- for (const share of session.shares ?? []) {
199
- const next = nextPollAt.get(share.shareId) || 0;
200
- if (now < next) continue;
201
- // Skip "permanently dead" shares (after a 404).
202
- if ((errorCounts.get(share.shareId) || 0) >= 99) continue;
203
- nextPollAt.set(share.shareId, now + Math.max(POLL_INTERVAL_MS, backoffFor(share.shareId)));
204
- tasks.push(pollShare(session.id, share, config.endpoint));
205
- }
206
- }
207
- await Promise.all(tasks);
208
- }
209
-
210
- async function loop() {
211
- while (!stopped) {
212
- try {
213
- await pollOnce();
214
- } catch (e: any) {
215
- console.warn(`[remote-feedback] loop error: ${e.message || e}`);
216
- }
217
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
218
- }
219
- }
220
-
221
- loop();
222
-
223
- return {
224
- stop: () => {
225
- stopped = true;
226
- },
227
- };
95
+ let stopped = false
96
+
97
+ // Per-share CryptoKey cache — `importKey` is async and pure given the
98
+ // same encoded key, so we memoize it for the lifetime of the poller.
99
+ const keyCache = new Map<string, Promise<CryptoKey>>()
100
+ function getKey(shareId: string, encoded: string): Promise<CryptoKey> {
101
+ let cached = keyCache.get(shareId)
102
+ if (!cached) {
103
+ cached = importShareKey(encoded)
104
+ keyCache.set(shareId, cached)
105
+ }
106
+ return cached
107
+ }
108
+
109
+ // Per-share consecutive error count → exponential backoff so a dead
110
+ // worker doesn't generate a fire-hose of failed requests.
111
+ const errorCounts = new Map<string, number>()
112
+ function backoffFor(shareId: string): number {
113
+ const errors = errorCounts.get(shareId) || 0
114
+ if (errors === 0) return 0
115
+ return Math.min(MAX_BACKOFF_MS, POLL_INTERVAL_MS * Math.pow(2, errors - 1))
116
+ }
117
+
118
+ async function pollShare(
119
+ sessionId: string,
120
+ share: { shareId: string; lastFeedbackAt?: string; encryptionKey?: string },
121
+ endpoint: string,
122
+ ) {
123
+ try {
124
+ const since = share.lastFeedbackAt ?? ''
125
+ const url = `${endpoint}/shares/${share.shareId}/feedback${since ? `?since=${encodeURIComponent(since)}` : ''}`
126
+ const res = await fetch(url)
127
+ if (!res.ok) {
128
+ if (res.status === 404) {
129
+ // Share no longer exists on the worker — likely revoked or expired.
130
+ // Stop trying so we don't hammer it forever.
131
+ errorCounts.set(share.shareId, 99)
132
+ return
133
+ }
134
+ throw new Error(`HTTP ${res.status}`)
135
+ }
136
+ const data = (await res.json()) as WorkerFeedbackResponse
137
+ errorCounts.delete(share.shareId)
138
+
139
+ if (!data.entries || data.entries.length === 0) return
140
+
141
+ // Decrypt any entries that came in encrypted. Plaintext entries
142
+ // (legacy submissions from non-encrypted shares) pass through.
143
+ const decrypted: RemoteFeedbackEntry[] = []
144
+ for (const e of data.entries) {
145
+ if (e.encryption) {
146
+ if (!share.encryptionKey) {
147
+ console.warn(`[remote-feedback] ${share.shareId}: received encrypted feedback but no local key — skipping entry ${e.id}`)
148
+ continue
149
+ }
150
+ try {
151
+ const key = await getKey(share.shareId, share.encryptionKey)
152
+ decrypted.push(await decryptFeedbackEntry(e, key))
153
+ } catch (err) {
154
+ console.warn(`[remote-feedback] ${share.shareId}: failed to decrypt entry ${e.id}: ${errorMessage(err)}`)
155
+ }
156
+ } else {
157
+ decrypted.push(e as RemoteFeedbackEntry)
158
+ }
159
+ }
160
+
161
+ if (decrypted.length === 0) return
162
+
163
+ const byRev = new Map<number, RemoteFeedbackEntry[]>()
164
+ for (const e of decrypted) {
165
+ if (!byRev.has(e.revision)) byRev.set(e.revision, [])
166
+ byRev.get(e.revision)!.push(e)
167
+ }
168
+
169
+ for (const [rev, entries] of byRev) {
170
+ sessionManager.appendRemoteFeedback(sessionId, rev, entries)
171
+ broadcastRemoteFeedback(sessionId, rev, entries)
172
+ }
173
+
174
+ const latest = data.latestAt
175
+ ?? data.entries.reduce((acc, e) => (e.submittedAt > acc ? e.submittedAt : acc), share.lastFeedbackAt ?? '')
176
+ if (latest) {
177
+ sessionManager.updateShareLastFeedback(sessionId, share.shareId, latest)
178
+ }
179
+ } catch (e) {
180
+ errorCounts.set(share.shareId, (errorCounts.get(share.shareId) || 0) + 1)
181
+ console.warn(`[remote-feedback] ${share.shareId}: ${errorMessage(e)} (will retry with backoff)`)
182
+ }
183
+ }
184
+
185
+ // Per-share next-poll timestamps so backoffs are independent.
186
+ const nextPollAt = new Map<string, number>()
187
+
188
+ async function pollOnce() {
189
+ const config = loadShareConfig(version)
190
+ if (!config) return
191
+
192
+ const sessions = sessionManager.listSessionsWithShares()
193
+ if (sessions.length === 0) return
194
+
195
+ const now = Date.now()
196
+ const tasks: Promise<void>[] = []
197
+ for (const session of sessions) {
198
+ for (const share of session.shares ?? []) {
199
+ const next = nextPollAt.get(share.shareId) || 0
200
+ if (now < next) continue
201
+ // Skip "permanently dead" shares (after a 404).
202
+ if ((errorCounts.get(share.shareId) || 0) >= 99) continue
203
+ nextPollAt.set(share.shareId, now + Math.max(POLL_INTERVAL_MS, backoffFor(share.shareId)))
204
+ tasks.push(pollShare(session.id, share, config.endpoint))
205
+ }
206
+ }
207
+ await Promise.all(tasks)
208
+ }
209
+
210
+ async function loop() {
211
+ while (!stopped) {
212
+ try {
213
+ await pollOnce()
214
+ } catch (e) {
215
+ console.warn(`[remote-feedback] loop error: ${errorMessage(e)}`)
216
+ }
217
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
218
+ }
219
+ }
220
+
221
+ loop()
222
+
223
+ return {
224
+ stop: () => {
225
+ stopped = true
226
+ },
227
+ }
228
228
  }
@@ -0,0 +1,52 @@
1
+ import type { ReviewAnchor, ReviewThread } from './model'
2
+
3
+ function fence(language: string, text: string): string {
4
+ const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((match) => match[0].length))
5
+ const marker = '`'.repeat(longest + 1)
6
+ return `${marker}${language}\n${text}\n${marker}`
7
+ }
8
+
9
+ function describeAnchor(anchor: ReviewAnchor, screenshot: string | undefined): string[] {
10
+ const lines = [`- Page: ${anchor.url}`]
11
+ if (anchor.context !== undefined) lines.push(`- Where: ${anchor.context}`)
12
+ const accessible = [anchor.role, anchor.name === undefined ? undefined : `"${anchor.name}"`].filter((part) => part !== undefined).join(' ')
13
+ if (accessible !== '') lines.push(`- Element: ${accessible}`)
14
+ if (anchor.selector !== '') lines.push(`- Selector: \`${anchor.selector}\``)
15
+ if (anchor.components !== undefined && anchor.components.length > 0) lines.push(`- Components: ${anchor.components.join(' › ')}`)
16
+ if (anchor.sources !== undefined && anchor.sources.length > 0) {
17
+ lines.push('- Source, innermost first:')
18
+ for (const source of anchor.sources) lines.push(` - ${source.label}: \`${source.file}:${source.line}:${source.column}\``)
19
+ }
20
+ if (anchor.quote !== undefined) {
21
+ lines.push(`- Selected text: …${anchor.quote.before}**${anchor.quote.exact}**${anchor.quote.after}…`)
22
+ } else if (anchor.text !== undefined && anchor.text !== '') {
23
+ lines.push(`- Text: "${anchor.text}"`)
24
+ }
25
+ if (screenshot !== undefined) lines.push(`- Screenshot, the element outlined in red: \`${screenshot}\` (read the file to see it)`)
26
+ if (anchor.html !== undefined) lines.push('- Markup, trimmed:', '', fence('html', anchor.html))
27
+ return lines
28
+ }
29
+
30
+ function quoteBody(body: string): string {
31
+ return body.split('\n').map((line) => `> ${line}`).join('\n')
32
+ }
33
+
34
+ /** The requests a waiting agent receives: each thread with its place in the page, its conversation and how to answer. */
35
+ export function renderReviewRequests(project: string, threads: readonly ReviewThread[]): string {
36
+ const sections = threads.map((thread, index) => {
37
+ const conversation = thread.comments.map((comment) => `**${comment.author === 'user' ? 'Reviewer' : 'You'}:**\n${quoteBody(comment.body)}`)
38
+ return [`## ${index + 1}. Thread ${thread.id}`, '', ...describeAnchor(thread.anchor, thread.screenshot), '', ...conversation].join('\n')
39
+ })
40
+ return [
41
+ `# Review requests for ${project}`,
42
+ '',
43
+ `The reviewer sent ${threads.length === 1 ? 'one thread' : `${threads.length} threads`} from the running application.`,
44
+ 'Make the change each thread asks for, then answer it so the reviewer sees what happened:',
45
+ '',
46
+ ` bunx agent-canvas review reply <thread-id> "<what you changed>" --resolve --project ${project}`,
47
+ '',
48
+ 'Leave out `--resolve` when you ask a question back instead.',
49
+ '',
50
+ ...sections.flatMap((section) => [section, '']),
51
+ ].join('\n')
52
+ }
@@ -0,0 +1,132 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Review threads: comments a person leaves on a running application. A thread is a note until the person sends it
5
+ * to the agent; the agent picks it up, answers and may resolve it.
6
+ */
7
+
8
+ export const PROJECT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
9
+
10
+ /**
11
+ * Loopback origins only: a page on any other origin must not be able to put a request in front of the agent.
12
+ * The application under review runs on its own dev server, so its port is not the daemon's.
13
+ */
14
+ export function isLoopbackOrigin(origin: string): boolean {
15
+ let url: URL
16
+ try {
17
+ url = new URL(origin)
18
+ } catch {
19
+ return false
20
+ }
21
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
22
+ return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' || url.hostname.endsWith('.localhost')
23
+ }
24
+
25
+ const shortText = z.string().max(500)
26
+
27
+ export const MAX_HTML = 4000
28
+
29
+ /** A position in the application's source, resolved from a development stack through the page's source maps. */
30
+ export const reviewSourceSchema = z.object({
31
+ /** What was created there, e.g. `<a> in ProductListCell` or `<ProductListCell> in DataGrid`. */
32
+ label: shortText,
33
+ file: z.string().max(1000),
34
+ line: z.number().int().positive(),
35
+ column: z.number().int().nonnegative(),
36
+ })
37
+ export type ReviewSource = z.infer<typeof reviewSourceSchema>
38
+
39
+ /** Where in the page a thread points. Every field is best effort; the agent reads them, the widget re-finds the element. */
40
+ export const reviewAnchorSchema = z.object({
41
+ /** The page's URL when the thread was created, hash routes included. */
42
+ url: z.string().min(1).max(2000),
43
+ /** CSS path from the nearest element with an id, or from `body`. */
44
+ selector: z.string().max(2000),
45
+ /** ARIA role and accessible name, as the agent would target the element in a browser. */
46
+ role: shortText.optional(),
47
+ name: shortText.optional(),
48
+ /** The element's visible text, shortened. */
49
+ text: shortText.optional(),
50
+ /** Owner components from the outermost to the element, read off React's development fibers. */
51
+ components: z.array(z.string().max(200)).max(20).optional(),
52
+ /** Source positions of the element and its nearest owner components, innermost first. */
53
+ sources: z.array(reviewSourceSchema).max(10).optional(),
54
+ /** The landmarks around the element: page heading, section, dialog. */
55
+ context: shortText.optional(),
56
+ /** The element's markup, trimmed: deep subtrees, long lists and long texts are cut. */
57
+ html: z.string().max(MAX_HTML).optional(),
58
+ /** A text selection inside the element, with its surroundings. */
59
+ quote: z.object({ exact: z.string().min(1).max(2000), before: shortText, after: shortText }).optional(),
60
+ })
61
+ export type ReviewAnchor = z.infer<typeof reviewAnchorSchema>
62
+
63
+ export const reviewAuthorSchema = z.enum(['user', 'agent'])
64
+ export type ReviewAuthor = z.infer<typeof reviewAuthorSchema>
65
+
66
+ export const reviewCommentSchema = z.object({
67
+ id: z.string(),
68
+ author: reviewAuthorSchema,
69
+ body: z.string().min(1).max(20_000),
70
+ createdAt: z.string(),
71
+ })
72
+ export type ReviewComment = z.infer<typeof reviewCommentSchema>
73
+
74
+ /**
75
+ * - `open`: a note, or an answered thread waiting on the person.
76
+ * - `queued`: sent to the agent, not picked up yet.
77
+ * - `working`: delivered to the agent.
78
+ * - `resolved`: the agent or the person closed it.
79
+ */
80
+ export const reviewThreadStateSchema = z.enum(['open', 'queued', 'working', 'resolved'])
81
+ export type ReviewThreadState = z.infer<typeof reviewThreadStateSchema>
82
+
83
+ export const reviewThreadSchema = z.object({
84
+ id: z.string(),
85
+ anchor: reviewAnchorSchema,
86
+ state: reviewThreadStateSchema,
87
+ comments: z.array(reviewCommentSchema).min(1),
88
+ /** Absolute path of a PNG of the element the reviewer attached, stored by the daemon. */
89
+ screenshot: z.string().optional(),
90
+ createdAt: z.string(),
91
+ updatedAt: z.string(),
92
+ })
93
+ export type ReviewThread = z.infer<typeof reviewThreadSchema>
94
+
95
+ export const reviewThreadsFileSchema = z.object({ threads: z.array(reviewThreadSchema) })
96
+
97
+ const commentBody = z.string().trim().min(1).max(20_000)
98
+
99
+ const PNG_DATA_URL = 'data:image/png;base64,'
100
+
101
+ /** A PNG the widget rendered from the element, as a data URL; at most about 6 MB decoded. */
102
+ export const screenshotDataUrlSchema = z.string().startsWith(PNG_DATA_URL).max(8_000_000)
103
+ .transform((url) => Uint8Array.from(atob(url.slice(PNG_DATA_URL.length)), (char) => char.charCodeAt(0)))
104
+
105
+ export const createThreadBodySchema = z.object({
106
+ anchor: reviewAnchorSchema,
107
+ body: commentBody,
108
+ send: z.boolean(),
109
+ screenshot: screenshotDataUrlSchema.optional(),
110
+ })
111
+ export const addCommentBodySchema = z.object({ body: commentBody, send: z.boolean() })
112
+ export const agentReplyBodySchema = z.object({ body: commentBody, resolve: z.boolean() })
113
+
114
+ /** An application on this machine: the review proxy forwards to its origin and refuses any other host. */
115
+ const loopbackUrlSchema = z.string().url().refine((url) => isLoopbackOrigin(url), 'must be an http(s) URL on localhost')
116
+
117
+ /** The CLI's request to open an application for review through the daemon's proxy. */
118
+ export const openProxyBodySchema = z.object({ target: loopbackUrlSchema })
119
+
120
+ /** A project's review proxy, kept so a restarted daemon serves it on the same port: the reviewer's tab keeps working. */
121
+ export const reviewProxySchema = z.object({ target: loopbackUrlSchema, port: z.number().int().min(1).max(65_535) })
122
+ export type ReviewProxy = z.infer<typeof reviewProxySchema>
123
+
124
+ /** Frames the daemon sends to browsers on a project's topic. */
125
+ export type ReviewBrowserFrame =
126
+ | { type: 'threads'; threads: readonly ReviewThread[] }
127
+ | { type: 'agent-status'; watching: boolean }
128
+
129
+ /** The frame a waiting CLI receives when the person sends threads; it reads them back through the claim endpoint. */
130
+ export interface ReviewWaitFrame {
131
+ type: 'queued'
132
+ }