pi-tldraw 0.1.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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/bridge/app-bridge-entry.js +6 -0
  4. package/mcp-app/LICENSE.md +9 -0
  5. package/mcp-app/PI_TLDRAW_PROVENANCE.json +32 -0
  6. package/mcp-app/README.md +129 -0
  7. package/mcp-app/dev-tunnel.sh +51 -0
  8. package/mcp-app/dist/editor-api.json +8493 -0
  9. package/mcp-app/dist/mcp-app.html +643 -0
  10. package/mcp-app/dist/method-map.json +915 -0
  11. package/mcp-app/package.json +42 -0
  12. package/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json +10 -0
  13. package/mcp-app/plugins/tldraw-mcp/assets/logo.svg +3 -0
  14. package/mcp-app/plugins/tldraw-mcp/mcp.json +8 -0
  15. package/mcp-app/scripts/extract-editor-api.ts +1374 -0
  16. package/mcp-app/server.json +21 -0
  17. package/mcp-app/src/logger.ts +45 -0
  18. package/mcp-app/src/register-tools.ts +368 -0
  19. package/mcp-app/src/shared/generated-data.ts +160 -0
  20. package/mcp-app/src/shared/pending-requests.ts +69 -0
  21. package/mcp-app/src/shared/types.ts +76 -0
  22. package/mcp-app/src/shared/utils.ts +132 -0
  23. package/mcp-app/src/tools/exec.ts +120 -0
  24. package/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts +16 -0
  25. package/mcp-app/src/tools/search.ts +150 -0
  26. package/mcp-app/src/widget/app-context.tsx +29 -0
  27. package/mcp-app/src/widget/dev-log.tsx +70 -0
  28. package/mcp-app/src/widget/exec-helpers.ts +232 -0
  29. package/mcp-app/src/widget/export-tldr.ts +35 -0
  30. package/mcp-app/src/widget/focused/defaults.ts +141 -0
  31. package/mcp-app/src/widget/focused/focused-editor-proxy.ts +434 -0
  32. package/mcp-app/src/widget/focused/format.ts +366 -0
  33. package/mcp-app/src/widget/focused/to-focused.ts +258 -0
  34. package/mcp-app/src/widget/focused/to-tldraw.ts +570 -0
  35. package/mcp-app/src/widget/image-guard.tsx +106 -0
  36. package/mcp-app/src/widget/index.html +33 -0
  37. package/mcp-app/src/widget/mcp-app.css +113 -0
  38. package/mcp-app/src/widget/mcp-app.tsx +857 -0
  39. package/mcp-app/src/widget/persistence.ts +337 -0
  40. package/mcp-app/src/widget/snapshot.ts +157 -0
  41. package/mcp-app/src/worker.ts +305 -0
  42. package/mcp-app/tsconfig.json +23 -0
  43. package/mcp-app/vite.config.ts +13 -0
  44. package/mcp-app/wrangler.toml +45 -0
  45. package/mcp-app-source.json +36 -0
  46. package/package.json +90 -0
  47. package/patches/tldraw-mcp-app/001-pi-runtime.patch +35 -0
  48. package/scripts/assemble-mcp-app.mjs +193 -0
  49. package/scripts/build-bridge.mjs +74 -0
  50. package/scripts/e2e-mcp.mjs +69 -0
  51. package/scripts/e2e-packaged-mcp-app.mjs +79 -0
  52. package/scripts/run-mcp-app-dev.mjs +44 -0
  53. package/scripts/verify-bundle.mjs +41 -0
  54. package/scripts/verify-mcp-app-source.mjs +51 -0
  55. package/scripts/verify-mcp-app.mjs +38 -0
  56. package/scripts/verify-package-files.mjs +50 -0
  57. package/src/canvas/export.ts +164 -0
  58. package/src/canvas/state.ts +117 -0
  59. package/src/canvas/workflow.ts +105 -0
  60. package/src/commands/tldraw-command.ts +48 -0
  61. package/src/diagram/guidance.ts +44 -0
  62. package/src/host/local-host.ts +289 -0
  63. package/src/index.ts +762 -0
  64. package/src/mcp/client.ts +126 -0
  65. package/src/mcp/response.ts +74 -0
  66. package/src/semantic/layer.ts +309 -0
  67. package/src/server/server-manager.ts +153 -0
  68. package/src/store/export-store.ts +33 -0
  69. package/src/store/project-store.ts +251 -0
  70. package/src/ui/tldraw-status.ts +88 -0
  71. package/static/app-bridge-bundle.js +18114 -0
  72. package/static/app-bridge-bundle.meta.json +164 -0
  73. package/static/host.html +390 -0
  74. package/tsconfig.json +13 -0
@@ -0,0 +1,289 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ interface CanvasExecRequest {
8
+ id: string
9
+ code: string
10
+ canvasId?: string
11
+ createdAt: number
12
+ }
13
+
14
+ interface PendingExec {
15
+ resolve(value: unknown): void
16
+ reject(error: Error): void
17
+ timer: ReturnType<typeof setTimeout>
18
+ }
19
+
20
+ interface CanvasHostOptions {
21
+ /** Project working directory the canvases belong to. Shown in the host UI. */
22
+ cwd?: string
23
+ /** Directory where canvas snapshots are persisted. Shown in the host UI. */
24
+ canvasDir?: string
25
+ onAutoSave?(input: {
26
+ canvasId: string
27
+ state: { shapes?: unknown[]; assets?: unknown[]; bindings?: unknown[] }
28
+ source?: string
29
+ }): Promise<void>
30
+ onRestore?(canvasId: string): Promise<{ shapes: unknown[]; assets?: unknown[]; bindings?: unknown[] } | null>
31
+ }
32
+
33
+ const ROUTES = [
34
+ { method: 'GET', path: '/', key: 'host' },
35
+ { method: 'GET', path: '/static/app-bridge-bundle.js', key: 'bridgeBundle' },
36
+ { method: 'GET', path: '/api/config', key: 'config' },
37
+ { method: 'GET', path: '/api/status', key: 'status' },
38
+ { method: 'POST', path: '/api/log', key: 'log' },
39
+ { method: 'GET', path: '/api/next', key: 'next' },
40
+ { method: 'POST', path: '/api/autosave', key: 'autosave' },
41
+ { method: 'GET', path: '/api/restore', key: 'restore' },
42
+ { method: 'POST', path: '/api/result', key: 'result' },
43
+ ] as const
44
+
45
+ type RouteKey = (typeof ROUTES)[number]['key']
46
+
47
+ const routeFor = (method: string | undefined, path: string): RouteKey | null =>
48
+ ROUTES.find((route) => route.method === method && route.path === path)?.key ?? null
49
+
50
+ export function createCanvasHost(
51
+ pi: ExtensionAPI,
52
+ endpoint: string,
53
+ resourceUri: string,
54
+ opts: CanvasHostOptions = {}
55
+ ) {
56
+ const cwd = opts.cwd
57
+ const canvasDir = opts.canvasDir
58
+ let server: Server | null = null
59
+ let url: string | null = null
60
+ let port: number | null = null
61
+ let lastPollAt = 0
62
+ const logs: string[] = []
63
+ const queue: CanvasExecRequest[] = []
64
+ const pending = new Map<string, PendingExec>()
65
+
66
+ async function ensureStarted(signal?: AbortSignal) {
67
+ if (server && url) return { url, port: port! }
68
+
69
+ server = createServer((req, res) => {
70
+ void handleRequest(req, res)
71
+ })
72
+
73
+ await new Promise<void>((resolve, reject) => {
74
+ server!.once('error', reject)
75
+ server!.listen(0, '127.0.0.1', () => resolve())
76
+ })
77
+
78
+ const address = server.address()
79
+ if (!address || typeof address === 'string') throw new Error('Could not determine canvas host port')
80
+ port = address.port
81
+ url = `http://127.0.0.1:${port}/`
82
+ return { url, port }
83
+ }
84
+
85
+ async function open(signal?: AbortSignal) {
86
+ const started = await ensureStarted(signal)
87
+ // If a browser is already actively polling, reuse it instead of spawning
88
+ // another tab. This keeps pair diagramming on one shared canvas and
89
+ // prevents blank duplicate windows from appearing on repeated calls.
90
+ if (Date.now() - lastPollAt < 3000) return { ...started, spawned: false }
91
+ await pi.exec('open', [started.url], { signal, timeout: 5000 }).catch(() => undefined)
92
+ return { ...started, spawned: true }
93
+ }
94
+
95
+ async function execOnCanvas(input: { code: string; canvasId?: string; timeoutMs?: number }, signal?: AbortSignal) {
96
+ await ensureStarted(signal)
97
+ const id = randomUUID()
98
+ const timeoutMs = input.timeoutMs ?? 60_000
99
+ const promise = new Promise<unknown>((resolve, reject) => {
100
+ const timer = setTimeout(() => {
101
+ pending.delete(id)
102
+ removeQueued(id)
103
+ appendLog(`timeout ${id}: no browser result after ${timeoutMs}ms`)
104
+ reject(
105
+ new Error(
106
+ `tldraw canvas host timed out after ${timeoutMs}ms. Is ${url} open in a browser?`
107
+ )
108
+ )
109
+ }, timeoutMs)
110
+ pending.set(id, { resolve, reject, timer })
111
+ })
112
+
113
+ const abort = () => {
114
+ const entry = pending.get(id)
115
+ if (!entry) return
116
+ clearTimeout(entry.timer)
117
+ pending.delete(id)
118
+ removeQueued(id)
119
+ appendLog(`cancelled ${id}`)
120
+ entry.reject(new Error('Cancelled'))
121
+ }
122
+ signal?.addEventListener('abort', abort, { once: true })
123
+
124
+ queue.push({ id, code: input.code, canvasId: input.canvasId, createdAt: Date.now() })
125
+ appendLog(`queued ${id}: canvasId=${input.canvasId ?? 'new'} codeBytes=${input.code.length}`)
126
+ return promise.finally(() => signal?.removeEventListener('abort', abort))
127
+ }
128
+
129
+ function removeQueued(id: string) {
130
+ const index = queue.findIndex((item) => item.id === id)
131
+ if (index !== -1) queue.splice(index, 1)
132
+ }
133
+
134
+ function appendLog(message: string) {
135
+ logs.push(`${new Date().toISOString()} ${message}`)
136
+ while (logs.length > 200) logs.shift()
137
+ }
138
+
139
+ async function close() {
140
+ for (const [id, entry] of pending) {
141
+ clearTimeout(entry.timer)
142
+ entry.reject(new Error('Canvas host closed'))
143
+ pending.delete(id)
144
+ }
145
+ queue.splice(0)
146
+ if (!server) return
147
+ await new Promise<void>((resolve) => server!.close(() => resolve()))
148
+ server = null
149
+ url = null
150
+ port = null
151
+ }
152
+
153
+ function getStatus() {
154
+ return {
155
+ url,
156
+ port,
157
+ queued: queue.length,
158
+ pending: pending.size,
159
+ lastPollAt,
160
+ browserConnected: Date.now() - lastPollAt < 3000,
161
+ logs: logs.slice(-80),
162
+ }
163
+ }
164
+
165
+ const routeHandlers: Record<RouteKey, (req: IncomingMessage, res: ServerResponse, reqUrl: URL) => Promise<void> | void> = {
166
+ host: async (_req, res) => {
167
+ const path = fileURLToPath(new URL('../../static/host.html', import.meta.url))
168
+ const body = await readFile(path, 'utf8')
169
+ return send(res, 200, body, 'text/html; charset=utf-8')
170
+ },
171
+ bridgeBundle: async (_req, res) => {
172
+ const path = fileURLToPath(new URL('../../static/app-bridge-bundle.js', import.meta.url))
173
+ const body = await readFile(path, 'utf8')
174
+ return send(res, 200, body, 'application/javascript; charset=utf-8')
175
+ },
176
+ config: (_req, res) => sendJson(res, { endpoint, resourceUri, cwd, canvasDir }),
177
+ status: (_req, res) => sendJson(res, getStatus()),
178
+ log: async (req, res) => {
179
+ const body = (await readJson(req)) as { level?: string; message?: string }
180
+ appendLog(`${body.level ?? 'log'} ${body.message ?? ''}`)
181
+ return sendJson(res, { ok: true })
182
+ },
183
+ next: (_req, res) => {
184
+ lastPollAt = Date.now()
185
+ const next = queue.shift() ?? null
186
+ if (next) appendLog(`dequeued ${next.id}: canvasId=${next.canvasId ?? 'new'}`)
187
+ return sendJson(res, next)
188
+ },
189
+ autosave: async (req, res) => {
190
+ const body = (await readJson(req)) as {
191
+ canvasId?: string
192
+ state?: { shapes?: unknown[]; assets?: unknown[]; bindings?: unknown[] }
193
+ source?: string
194
+ }
195
+ if (!body.canvasId) return sendJson(res, { ok: false, error: 'Missing canvasId' }, 400)
196
+ if (!body.state || typeof body.state !== 'object') {
197
+ return sendJson(res, { ok: false, error: 'Missing state' }, 400)
198
+ }
199
+ try {
200
+ await opts.onAutoSave?.({
201
+ canvasId: body.canvasId,
202
+ state: body.state,
203
+ source: body.source,
204
+ })
205
+ appendLog(
206
+ `autosave ${body.canvasId}: shapes=${Array.isArray(body.state.shapes) ? body.state.shapes.length : 0}`
207
+ )
208
+ return sendJson(res, { ok: true })
209
+ } catch (error) {
210
+ const message = error instanceof Error ? error.message : String(error)
211
+ appendLog(`autosave ${body.canvasId}: error ${message}`)
212
+ return sendJson(res, { ok: false, error: message }, 409)
213
+ }
214
+ },
215
+ restore: async (_req, res, reqUrl) => {
216
+ const restoreCanvasId = reqUrl.searchParams.get('canvasId')
217
+ if (!restoreCanvasId) return sendJson(res, { ok: false, error: 'Missing canvasId' }, 400)
218
+ if (!opts.onRestore) return sendJson(res, { ok: false, error: 'No restore handler' }, 501)
219
+ try {
220
+ const snapshot = await opts.onRestore(restoreCanvasId)
221
+ if (!snapshot) {
222
+ appendLog(`restore ${restoreCanvasId}: no saved snapshot`)
223
+ return sendJson(res, { ok: false, error: 'No saved snapshot' }, 404)
224
+ }
225
+ appendLog(`restore ${restoreCanvasId}: ${Array.isArray(snapshot.shapes) ? snapshot.shapes.length : 0} shape(s)`)
226
+ return sendJson(res, { ok: true, snapshot })
227
+ } catch (error) {
228
+ const message = error instanceof Error ? error.message : String(error)
229
+ appendLog(`restore ${restoreCanvasId}: error ${message}`)
230
+ return sendJson(res, { ok: false, error: message }, 500)
231
+ }
232
+ },
233
+ result: async (req, res) => {
234
+ const body = (await readJson(req)) as { id?: string; ok?: boolean; result?: unknown; error?: string }
235
+ if (!body.id) return sendJson(res, { ok: false, error: 'Missing id' }, 400)
236
+ const entry = pending.get(body.id)
237
+ if (!entry) {
238
+ appendLog(`late result ${body.id}: no pending request`)
239
+ return sendJson(res, { ok: false, error: 'No pending request for id' }, 404)
240
+ }
241
+ clearTimeout(entry.timer)
242
+ pending.delete(body.id)
243
+ if (body.ok) {
244
+ appendLog(`result ${body.id}: ok`)
245
+ entry.resolve(body.result)
246
+ } else {
247
+ appendLog(`result ${body.id}: error ${body.error ?? 'unknown'}`)
248
+ entry.reject(new Error(body.error ?? 'Unknown canvas host error'))
249
+ }
250
+ return sendJson(res, { ok: true })
251
+ },
252
+ }
253
+
254
+ async function handleRequest(req: IncomingMessage, res: ServerResponse) {
255
+ try {
256
+ const reqUrl = new URL(req.url ?? '/', 'http://127.0.0.1')
257
+ const route = routeFor(req.method, reqUrl.pathname)
258
+ if (!route) return send(res, 404, 'Not found', 'text/plain; charset=utf-8')
259
+ return await routeHandlers[route](req, res, reqUrl)
260
+ } catch (error) {
261
+ return sendJson(
262
+ res,
263
+ { ok: false, error: error instanceof Error ? error.message : String(error) },
264
+ 500
265
+ )
266
+ }
267
+ }
268
+
269
+
270
+ return { ensureStarted, open, execOnCanvas, close, getStatus }
271
+ }
272
+
273
+ function sendJson(res: ServerResponse, value: unknown, status = 200) {
274
+ return send(res, status, JSON.stringify(value, null, 2), 'application/json; charset=utf-8')
275
+ }
276
+
277
+ function send(res: ServerResponse, status: number, body: string, contentType: string) {
278
+ res.writeHead(status, {
279
+ 'content-type': contentType,
280
+ 'cache-control': 'no-store',
281
+ })
282
+ res.end(body)
283
+ }
284
+
285
+ async function readJson(req: IncomingMessage): Promise<unknown> {
286
+ let body = ''
287
+ for await (const chunk of req) body += chunk
288
+ return body ? JSON.parse(body) : null
289
+ }