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.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/bridge/app-bridge-entry.js +6 -0
- package/mcp-app/LICENSE.md +9 -0
- package/mcp-app/PI_TLDRAW_PROVENANCE.json +32 -0
- package/mcp-app/README.md +129 -0
- package/mcp-app/dev-tunnel.sh +51 -0
- package/mcp-app/dist/editor-api.json +8493 -0
- package/mcp-app/dist/mcp-app.html +643 -0
- package/mcp-app/dist/method-map.json +915 -0
- package/mcp-app/package.json +42 -0
- package/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json +10 -0
- package/mcp-app/plugins/tldraw-mcp/assets/logo.svg +3 -0
- package/mcp-app/plugins/tldraw-mcp/mcp.json +8 -0
- package/mcp-app/scripts/extract-editor-api.ts +1374 -0
- package/mcp-app/server.json +21 -0
- package/mcp-app/src/logger.ts +45 -0
- package/mcp-app/src/register-tools.ts +368 -0
- package/mcp-app/src/shared/generated-data.ts +160 -0
- package/mcp-app/src/shared/pending-requests.ts +69 -0
- package/mcp-app/src/shared/types.ts +76 -0
- package/mcp-app/src/shared/utils.ts +132 -0
- package/mcp-app/src/tools/exec.ts +120 -0
- package/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts +16 -0
- package/mcp-app/src/tools/search.ts +150 -0
- package/mcp-app/src/widget/app-context.tsx +29 -0
- package/mcp-app/src/widget/dev-log.tsx +70 -0
- package/mcp-app/src/widget/exec-helpers.ts +232 -0
- package/mcp-app/src/widget/export-tldr.ts +35 -0
- package/mcp-app/src/widget/focused/defaults.ts +141 -0
- package/mcp-app/src/widget/focused/focused-editor-proxy.ts +434 -0
- package/mcp-app/src/widget/focused/format.ts +366 -0
- package/mcp-app/src/widget/focused/to-focused.ts +258 -0
- package/mcp-app/src/widget/focused/to-tldraw.ts +570 -0
- package/mcp-app/src/widget/image-guard.tsx +106 -0
- package/mcp-app/src/widget/index.html +33 -0
- package/mcp-app/src/widget/mcp-app.css +113 -0
- package/mcp-app/src/widget/mcp-app.tsx +857 -0
- package/mcp-app/src/widget/persistence.ts +337 -0
- package/mcp-app/src/widget/snapshot.ts +157 -0
- package/mcp-app/src/worker.ts +305 -0
- package/mcp-app/tsconfig.json +23 -0
- package/mcp-app/vite.config.ts +13 -0
- package/mcp-app/wrangler.toml +45 -0
- package/mcp-app-source.json +36 -0
- package/package.json +90 -0
- package/patches/tldraw-mcp-app/001-pi-runtime.patch +35 -0
- package/scripts/assemble-mcp-app.mjs +193 -0
- package/scripts/build-bridge.mjs +74 -0
- package/scripts/e2e-mcp.mjs +69 -0
- package/scripts/e2e-packaged-mcp-app.mjs +79 -0
- package/scripts/run-mcp-app-dev.mjs +44 -0
- package/scripts/verify-bundle.mjs +41 -0
- package/scripts/verify-mcp-app-source.mjs +51 -0
- package/scripts/verify-mcp-app.mjs +38 -0
- package/scripts/verify-package-files.mjs +50 -0
- package/src/canvas/export.ts +164 -0
- package/src/canvas/state.ts +117 -0
- package/src/canvas/workflow.ts +105 -0
- package/src/commands/tldraw-command.ts +48 -0
- package/src/diagram/guidance.ts +44 -0
- package/src/host/local-host.ts +289 -0
- package/src/index.ts +762 -0
- package/src/mcp/client.ts +126 -0
- package/src/mcp/response.ts +74 -0
- package/src/semantic/layer.ts +309 -0
- package/src/server/server-manager.ts +153 -0
- package/src/store/export-store.ts +33 -0
- package/src/store/project-store.ts +251 -0
- package/src/ui/tldraw-status.ts +88 -0
- package/static/app-bridge-bundle.js +18114 -0
- package/static/app-bridge-bundle.meta.json +164 -0
- package/static/host.html +390 -0
- 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
|
+
}
|