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
package/src/index.ts ADDED
@@ -0,0 +1,762 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { Type } from 'typebox'
4
+ import {
5
+ applySceneLens,
6
+ asCanvasBundle,
7
+ READ_CANVAS_STATE_CODE,
8
+ type CanvasStateBundle,
9
+ type SceneLens,
10
+ summarizeSnapshot,
11
+ toRawCanvasState,
12
+ } from './canvas/state'
13
+ import {
14
+ buildCanvasExportCode,
15
+ formatBytes,
16
+ parseCanvasExportPayload,
17
+ parseDataUrl,
18
+ } from './canvas/export'
19
+ import { createCanvasWorkflows } from './canvas/workflow'
20
+ import { createCanvasHost } from './host/local-host'
21
+ import { TldrawMcpClient, type McpTool } from './mcp/client'
22
+ import {
23
+ extractReturnValue,
24
+ extractTextContent,
25
+ parseCanvasIdFromResult,
26
+ type McpToolResult,
27
+ } from './mcp/response'
28
+ import {
29
+ getCanvasDir,
30
+ getCurrentCanvasId as getStoredCurrentCanvasId,
31
+ listCanvasSnapshots,
32
+ loadCanvasSnapshot,
33
+ saveCanvasSnapshot,
34
+ setCurrentCanvasId as setStoredCurrentCanvasId,
35
+ } from './store/project-store'
36
+ import { saveCanvasImageExport } from './store/export-store'
37
+ import { createMcpServerManager } from './server/server-manager'
38
+ import {
39
+ buildTensor,
40
+ chooseAperture,
41
+ renderOverview,
42
+ renderSummary,
43
+ type Aperture,
44
+ } from './semantic/layer'
45
+ import { parseTldrawCommand } from './commands/tldraw-command'
46
+ import { createTldrawStatusIndicator } from './ui/tldraw-status'
47
+ import { buildDiagramGuidance } from './diagram/guidance'
48
+
49
+ const DEFAULT_ENDPOINT = 'http://127.0.0.1:8787/mcp'
50
+ const CANVAS_RESOURCE_URI = 'ui://show-canvas/mcp-app.html'
51
+
52
+ function compactToolList(tools: McpTool[]) {
53
+ return tools.map((tool) => {
54
+ const appOnly = JSON.stringify(tool._meta ?? {}).includes('"app"')
55
+ return `- ${tool.name}${tool.title ? ` (${tool.title})` : ''}${appOnly ? ' [app-only]' : ''}`
56
+ })
57
+ }
58
+
59
+ function isBlockedTool(name: string) {
60
+ return name === 'exec' || name.startsWith('_') || name === 'save_checkpoint' || name === 'read_checkpoint'
61
+ }
62
+
63
+ function createProjectCanvasId() {
64
+ return randomUUID().replace(/-/g, '').slice(0, 8)
65
+ }
66
+
67
+ export default function (pi: ExtensionAPI) {
68
+ const endpoint = process.env.TLDRAW_MCP_URL || DEFAULT_ENDPOINT
69
+ const serverManager = createMcpServerManager(endpoint)
70
+ const client = new TldrawMcpClient(endpoint, serverManager.ensure)
71
+ let sessionCwd = process.cwd()
72
+ let sessionCanvasId: string | undefined
73
+
74
+ const statusIndicator = createTldrawStatusIndicator()
75
+ const canvasHost = createCanvasHost(pi, endpoint, CANVAS_RESOURCE_URI, {
76
+ cwd: sessionCwd,
77
+ canvasDir: getCanvasDir(sessionCwd),
78
+ async onAutoSave({
79
+ canvasId,
80
+ state,
81
+ }: {
82
+ canvasId: string
83
+ state: { shapes?: unknown[]; assets?: unknown[]; bindings?: unknown[] }
84
+ }) {
85
+ const saved = await saveCanvasSnapshot(sessionCwd, canvasId, state)
86
+ await rememberCanvasId(sessionCwd, saved.canvasId)
87
+ },
88
+ async onRestore(canvasId: string) {
89
+ return loadCanvasSnapshot(sessionCwd, canvasId)
90
+ },
91
+ })
92
+
93
+ async function resolveCanvasId(cwd: string, explicitCanvasId?: string) {
94
+ return explicitCanvasId ?? sessionCanvasId ?? (await getStoredCurrentCanvasId(cwd))
95
+ }
96
+
97
+ async function rememberCanvasId(cwd: string, canvasId: string) {
98
+ sessionCanvasId = canvasId
99
+ await setStoredCurrentCanvasId(cwd, canvasId)
100
+ }
101
+
102
+
103
+ const workflows = createCanvasWorkflows({
104
+ canvasHost,
105
+ loadCanvasSnapshot,
106
+ saveCanvasSnapshot,
107
+ resolveCanvasId,
108
+ rememberCanvasId,
109
+ createProjectCanvasId,
110
+ })
111
+
112
+ pi.registerTool({
113
+ name: 'tldraw_status',
114
+ label: 'tldraw MCP Status',
115
+ description:
116
+ 'Check the configured tldraw MCP server, list exposed MCP tools, and report whether the canvas app resource is available.',
117
+ promptSnippet: 'Check local tldraw MCP server health and list its tools/resources.',
118
+ parameters: Type.Object({}),
119
+ async execute(_toolCallId, _params, signal) {
120
+ const [tools, resources] = await Promise.all([
121
+ client.listTools(signal),
122
+ client.listResources(signal),
123
+ ])
124
+ const hasCanvas = resources.some((resource) => resource.uri === CANVAS_RESOURCE_URI)
125
+ const text = [
126
+ `tldraw MCP endpoint: ${endpoint}`,
127
+ `Tools: ${tools.length}`,
128
+ ...compactToolList(tools),
129
+ `Resources: ${resources.length}`,
130
+ ...resources.map((resource) => `- ${resource.uri}${resource.title ? ` (${resource.title})` : ''}`),
131
+ hasCanvas
132
+ ? 'Canvas app resource is available. It still needs an MCP app-capable host to render the iframe.'
133
+ : 'Canvas app resource was not advertised.',
134
+ ].join('\n')
135
+ return { content: [{ type: 'text', text }], details: { endpoint, tools, resources, hasCanvas } }
136
+ },
137
+ })
138
+
139
+ pi.registerTool({
140
+ name: 'tldraw_search',
141
+ label: 'tldraw MCP Search',
142
+ description:
143
+ 'Call the tldraw MCP search tool. This is read-only and works without rendering the canvas widget.',
144
+ promptSnippet: 'Search the tldraw Editor API exposed by the local tldraw MCP server.',
145
+ promptGuidelines: [
146
+ 'Use tldraw_search to inspect tldraw Editor APIs before proposing canvas execution code.',
147
+ ],
148
+ parameters: Type.Object({
149
+ code: Type.String({
150
+ description:
151
+ 'JavaScript code for the MCP search tool. It receives `spec`; use `return` to produce output.',
152
+ }),
153
+ }),
154
+ async execute(_toolCallId, params, signal) {
155
+ const result = (await client.callTool('search', { code: params.code }, signal)) as McpToolResult
156
+ return {
157
+ content: [{ type: 'text', text: extractTextContent(result) }],
158
+ details: { endpoint, mcpTool: 'search', result },
159
+ }
160
+ },
161
+ })
162
+
163
+ pi.registerTool({
164
+ name: 'tldraw_read_canvas_resource',
165
+ label: 'Read tldraw MCP Canvas Resource',
166
+ description:
167
+ 'Read metadata for the tldraw MCP canvas app resource. This proves the artifact HTML is exposed, but Pi does not render MCP app iframes yet.',
168
+ parameters: Type.Object({}),
169
+ async execute(_toolCallId, _params, signal) {
170
+ const result = (await client.readResource(CANVAS_RESOURCE_URI, signal)) as {
171
+ contents?: Array<{ text?: unknown }>
172
+ }
173
+ const html = result.contents?.[0]?.text
174
+ const text = [
175
+ `Resource: ${CANVAS_RESOURCE_URI}`,
176
+ `HTML bytes: ${typeof html === 'string' ? html.length : 0}`,
177
+ 'Pi can fetch this MCP app resource, but this terminal session does not mount the iframe.',
178
+ 'To see drawings, open the same MCP server from an MCP client that supports app resources.',
179
+ ].join('\n')
180
+ return {
181
+ content: [{ type: 'text', text }],
182
+ details: { endpoint, resourceUri: CANVAS_RESOURCE_URI, htmlBytes: typeof html === 'string' ? html.length : 0 },
183
+ }
184
+ },
185
+ })
186
+
187
+ pi.registerTool({
188
+ name: 'tldraw_canvas_open',
189
+ label: 'Open tldraw Canvas Host',
190
+ description:
191
+ 'Open a local browser host that renders the tldraw MCP app iframe and bridges it to Pi. Optionally restore a project-scoped saved canvas.',
192
+ parameters: Type.Object({
193
+ canvasId: Type.Optional(
194
+ Type.String({ description: 'Project canvas ID to restore. Omit to use the current project canvas if one exists.' })
195
+ ),
196
+ restore: Type.Optional(
197
+ Type.Boolean({ description: 'Whether to restore the project snapshot after opening. Defaults to true.' })
198
+ ),
199
+ }),
200
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
201
+ const cwd = ctx?.cwd ?? sessionCwd
202
+ await serverManager.ensure(signal)
203
+ const { url, restoreText } = await workflows.ensureBrowserAndRestore(cwd, params.canvasId, signal, {
204
+ restore: params.restore !== false,
205
+ })
206
+ return {
207
+ content: [
208
+ {
209
+ type: 'text',
210
+ text: `Opened local tldraw canvas host: ${url}\n${restoreText}\nKeep that browser tab open while using tldraw_canvas_exec.`,
211
+ },
212
+ ],
213
+ details: { url, status: canvasHost.getStatus(), currentCanvasId: sessionCanvasId },
214
+ }
215
+ },
216
+ })
217
+
218
+ pi.registerTool({
219
+ name: 'tldraw_diagram_tips',
220
+ label: 'tldraw Diagram Tips',
221
+ description:
222
+ 'Return minimalist drawing guidance for spacious, readable tldraw diagrams: negative space, legibility, alignment, connection routing, and visual restraint.',
223
+ promptSnippet: 'Get minimalist drawing guidance before creating or revising a tldraw diagram.',
224
+ promptGuidelines: [
225
+ 'Use tldraw_diagram_tips before tldraw_canvas_exec when creating or significantly rearranging any non-trivial tldraw diagram.',
226
+ 'Use tldraw_diagram_tips when a tldraw diagram looks cramped, has overlapping arrows, unclear hierarchy, or needs visual polish.',
227
+ ],
228
+ parameters: Type.Object({
229
+ focus: Type.Optional(
230
+ Type.String({ description: 'Optional drawing concern to emphasize.' })
231
+ ),
232
+ }),
233
+ async execute(_toolCallId, params) {
234
+ const guidance = buildDiagramGuidance(params)
235
+ return {
236
+ content: [{ type: 'text', text: guidance.text }],
237
+ details: { focus: guidance.focus },
238
+ }
239
+ },
240
+ })
241
+
242
+ pi.registerTool({
243
+ name: 'tldraw_canvas_export',
244
+ label: 'Export tldraw Canvas Image',
245
+ description:
246
+ 'Export the current tldraw selection or whole canvas as an image through the live browser host. Returns PNG by default for immediate visual feedback; SVG is also supported.',
247
+ promptSnippet: 'Export the current tldraw selection or whole canvas as a PNG/SVG image for visual feedback.',
248
+ promptGuidelines: [
249
+ 'Use tldraw_canvas_export after drawing or rearranging a diagram when visual appearance matters and the model needs immediate feedback.',
250
+ 'Use tldraw_canvas_export with scope:"selected" when the user has selected the region of interest; use scope:"all" for the whole canvas.',
251
+ 'tldraw_canvas_export requires a live browser canvas. If it fails because no browser is connected, open the canvas with tldraw_canvas_open first.',
252
+ ],
253
+ parameters: Type.Object({
254
+ scope: Type.Optional(
255
+ Type.String({ description: 'selected | all. Defaults to selected; if nothing is selected, selected falls back to all.' })
256
+ ),
257
+ format: Type.Optional(
258
+ Type.String({ description: 'png | svg. Defaults to png.' })
259
+ ),
260
+ canvasId: Type.Optional(
261
+ Type.String({ description: 'Canvas ID to export. Omit to use the current project canvas.' })
262
+ ),
263
+ open: Type.Optional(
264
+ Type.Boolean({ description: 'Whether to open/focus the local browser host before exporting. Defaults to true.' })
265
+ ),
266
+ background: Type.Optional(
267
+ Type.Boolean({ description: 'Include a background in the export. Defaults to true.' })
268
+ ),
269
+ padding: Type.Optional(
270
+ Type.Number({ description: 'Optional fixed export padding in pixels. Omit to let tldraw trim automatically.' })
271
+ ),
272
+ scale: Type.Optional(
273
+ Type.Number({ description: 'Optional logical export scale.' })
274
+ ),
275
+ pixelRatio: Type.Optional(
276
+ Type.Number({ description: 'Optional bitmap pixel ratio. Defaults to 1 to keep feedback images compact.' })
277
+ ),
278
+ save: Type.Optional(
279
+ Type.Boolean({ description: 'Save a copy under .pi/tldraw-exports. Defaults to true.' })
280
+ ),
281
+ }),
282
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
283
+ const cwd = ctx?.cwd ?? sessionCwd
284
+ await serverManager.ensure(signal)
285
+ let started: { url: string | null; port?: number | null; spawned?: boolean }
286
+ let canvasId: string | undefined
287
+ if (params.open === false) {
288
+ started = await canvasHost.ensureStarted(signal)
289
+ canvasId = await resolveCanvasId(cwd, params.canvasId)
290
+ } else {
291
+ const opened = await workflows.ensureBrowserAndRestore(cwd, params.canvasId, signal)
292
+ started = opened
293
+ canvasId = opened.resolvedId
294
+ }
295
+ if (!canvasHost.getStatus().browserConnected) {
296
+ throw new Error('No live browser canvas is connected. Use tldraw_canvas_open first, wait for Canvas ready, then export.')
297
+ }
298
+
299
+ const code = buildCanvasExportCode(params)
300
+ const result = (await canvasHost.execOnCanvas(
301
+ { code, canvasId, timeoutMs: 60_000 },
302
+ signal
303
+ )) as McpToolResult
304
+ const returnedCanvasId = parseCanvasIdFromResult(result) ?? canvasId
305
+ if (returnedCanvasId) await rememberCanvasId(cwd, returnedCanvasId)
306
+
307
+ const payload = parseCanvasExportPayload(extractReturnValue(result))
308
+ const image = parseDataUrl(payload.dataUrl)
309
+ let savedPath: string | undefined
310
+ if (params.save !== false) {
311
+ savedPath = await saveCanvasImageExport(cwd, {
312
+ canvasId: returnedCanvasId,
313
+ format: payload.format,
314
+ scope: payload.scope,
315
+ data: image.data,
316
+ })
317
+ }
318
+
319
+ const fallbackText = payload.fallbackToAll ? ' Selection was empty, so exported the whole canvas.' : ''
320
+ const text = [
321
+ `Exported ${payload.exportedShapeIds.length} shape(s) as ${payload.format.toUpperCase()} (${payload.width}×${payload.height}, ${formatBytes(image.bytes)}).${fallbackText}`,
322
+ `Scope: ${payload.scope}. Canvas host: ${started.url}`,
323
+ savedPath ? `Saved: ${savedPath}` : 'Saved: no',
324
+ ].join('\n')
325
+
326
+ return {
327
+ content: [
328
+ { type: 'text', text },
329
+ { type: 'image', data: image.data, mimeType: image.mimeType },
330
+ ],
331
+ details: {
332
+ endpoint,
333
+ host: canvasHost.getStatus(),
334
+ canvasId: returnedCanvasId,
335
+ format: payload.format,
336
+ scope: payload.scope,
337
+ width: payload.width,
338
+ height: payload.height,
339
+ bytes: image.bytes,
340
+ mimeType: image.mimeType,
341
+ exportedShapeIds: payload.exportedShapeIds,
342
+ selectedCount: payload.selectedCount,
343
+ fallbackToAll: payload.fallbackToAll,
344
+ savedPath,
345
+ },
346
+ }
347
+ },
348
+ })
349
+
350
+ pi.registerTool({
351
+ name: 'tldraw_canvas_exec',
352
+ label: 'Execute on tldraw Canvas',
353
+ description:
354
+ 'Execute JavaScript on the visible local tldraw canvas host. This starts/opens a browser bridge if needed, then sends code to the tldraw MCP exec app tool.',
355
+ promptSnippet: 'Draw or update a visible tldraw canvas through the local browser bridge.',
356
+ promptGuidelines: [
357
+ 'Use tldraw_canvas_exec when the user asks to create a visible tldraw diagram from Pi.',
358
+ 'Before creating or significantly rearranging a non-trivial diagram with tldraw_canvas_exec, call tldraw_diagram_tips and follow its drawing principles.',
359
+ 'When laying out diagrams with tldraw_canvas_exec, add generous negative space: large gaps between nodes, roomy group padding, clear lanes, and no arrows or labels running through unrelated shapes.',
360
+ 'After drawing with tldraw_canvas_exec, call tldraw_canvas_scene to review structure and fix cramped clusters, overlapping arrows, or ambiguous relationships before declaring the diagram done.',
361
+ 'Shapes use the FOCUSED format with _type — NOT tldraw\'s internal types. Valid _type values: rectangle, ellipse, triangle, diamond, hexagon, pill, cloud, x-box, check-box, heart, pentagon, octagon, star, parallelogram-right, parallelogram-left, fat-arrow-right, fat-arrow-left, fat-arrow-up, fat-arrow-down, arrow, note, text, line, draw.',
362
+ 'DO NOT use _type: "geo" — that is tldraw\'s internal type name. Use _type: "rectangle" instead.',
363
+ 'Create shapes: editor.createShape({ _type: \'rectangle\', shapeId: \'box1\', x: 100, y: 100, w: 240, h: 120, text: \'Label\', color: \'blue\', fill: \'tint\' }).',
364
+ 'Connect shapes: createArrowBetweenShapes(\'box1\', \'box2\', { text: \'label\' }) — pass shape IDs as strings, not shape objects.',
365
+ 'Group shapes: boxShapes([\'box1\', \'box2\'], { text: \'Group label\', color: \'blue\' }) — pass shape IDs as strings.',
366
+ 'Available colors: black, grey, light-grey, red, orange, yellow, green, blue, light-blue, violet, light-violet.',
367
+ 'Available fills: none, tint, solid.',
368
+ 'Shape IDs must be unique strings. Reuse an ID to update an existing shape.',
369
+ 'Read shapes: const shapes = editor.getCurrentPageShapes() — returns focused shapes with _type, shapeId, text, x, y, w, h, color.',
370
+ 'Note: note shapes auto-size and do NOT have w/h. Use editor.getShapePageBounds(shapeId) for bounds if needed (but this method may return the editor object via the proxy — prefer reading w/h from focused shapes for geo shapes).',
371
+ ],
372
+ parameters: Type.Object({
373
+ code: Type.String({
374
+ description:
375
+ 'JavaScript code to run in the tldraw MCP app iframe. It has access to `editor` (focused proxy — use _type not type), `createArrowBetweenShapes(fromId, toId, opts)`, and `boxShapes(ids, opts)`. Use _type: "rectangle" not "geo".',
376
+ }),
377
+ canvasId: Type.Optional(
378
+ Type.String({ description: 'Canvas ID returned by an earlier tldraw MCP exec result.' })
379
+ ),
380
+ open: Type.Optional(
381
+ Type.Boolean({ description: 'Whether to open/focus the local browser host before executing.' })
382
+ ),
383
+ }),
384
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
385
+ const cwd = ctx?.cwd ?? sessionCwd
386
+ await serverManager.ensure(signal)
387
+ let started: { url: string | null; port?: number | null; spawned?: boolean }
388
+ let canvasId: string | undefined
389
+ if (params.open === false) {
390
+ started = await canvasHost.ensureStarted(signal)
391
+ canvasId = await resolveCanvasId(cwd, params.canvasId)
392
+ } else {
393
+ const opened = await workflows.ensureBrowserAndRestore(cwd, params.canvasId, signal)
394
+ started = opened
395
+ canvasId = opened.resolvedId
396
+ }
397
+ const result = (await canvasHost.execOnCanvas(
398
+ { code: params.code, canvasId },
399
+ signal
400
+ )) as McpToolResult
401
+ const resultText = extractTextContent(result)
402
+ const returnedCanvasId = parseCanvasIdFromResult(result) ?? canvasId
403
+ let saveText = 'Project snapshot was not saved.'
404
+ if (returnedCanvasId) {
405
+ await rememberCanvasId(cwd, returnedCanvasId)
406
+ try {
407
+ const snapshot = await workflows.snapshotLiveCanvas(cwd, returnedCanvasId, signal)
408
+ saveText = `Saved project snapshot ${snapshot.canvasId} to ${getCanvasDir(cwd)} (${summarizeSnapshot(snapshot)}).`
409
+ } catch (error) {
410
+ saveText = `Could not save project snapshot: ${error instanceof Error ? error.message : String(error)}`
411
+ }
412
+ }
413
+ return {
414
+ content: [
415
+ {
416
+ type: 'text',
417
+ text: `Executed code on local tldraw canvas host: ${started.url}\n\n${resultText}\n\n${saveText}`,
418
+ },
419
+ ],
420
+ details: { endpoint, host: canvasHost.getStatus(), canvasId: returnedCanvasId, result },
421
+ }
422
+ },
423
+ })
424
+
425
+ pi.registerTool({
426
+ name: 'tldraw_canvas_state',
427
+ label: 'Inspect tldraw Canvas State',
428
+ description:
429
+ 'Read RAW tldraw canvas shapes (every shape with exact x/y/w/h and all props) through the local browser host, and optionally save to the project-scoped .pi/tldraw-canvases store. This is verbose; to UNDERSTAND the canvas prefer tldraw_canvas_scene (compact semantic view). Use this when you need precise geometry to edit a specific shape, or to force a project save.',
430
+ promptSnippet: 'Read raw tldraw shapes with exact geometry (verbose) or save the project snapshot. For understanding the canvas, prefer tldraw_canvas_scene.',
431
+ promptGuidelines: [
432
+ 'Prefer tldraw_canvas_scene to look at or reason about the canvas — it is far cheaper. Use tldraw_canvas_state only for exact per-shape geometry before an edit, or to save a snapshot.',
433
+ ],
434
+ parameters: Type.Object({
435
+ canvasId: Type.Optional(
436
+ Type.String({ description: 'Canvas ID to read. Omit to use the current project canvas.' })
437
+ ),
438
+ save: Type.Optional(
439
+ Type.Boolean({ description: 'Whether to save the snapshot to .pi/tldraw-canvases. Defaults to true.' })
440
+ ),
441
+ open: Type.Optional(
442
+ Type.Boolean({ description: 'Whether to open/focus the local browser host before reading.' })
443
+ ),
444
+ force: Type.Optional(
445
+ Type.Boolean({ description: 'Allow overwriting a non-empty saved snapshot with an empty live canvas.' })
446
+ ),
447
+ }),
448
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
449
+ const cwd = ctx?.cwd ?? sessionCwd
450
+ await serverManager.ensure(signal)
451
+ let started: { url: string | null; port?: number | null; spawned?: boolean }
452
+ if (params.open === true) {
453
+ started = await workflows.ensureBrowserAndRestore(cwd, params.canvasId, signal)
454
+ } else {
455
+ started = await canvasHost.ensureStarted(signal)
456
+ }
457
+ if (!canvasHost.getStatus().browserConnected) {
458
+ throw new Error('No live browser canvas is connected. Use /tldraw open first, wait for Canvas ready, then inspect/save.')
459
+ }
460
+ const canvasId = await resolveCanvasId(cwd, params.canvasId)
461
+ const result = (await canvasHost.execOnCanvas(
462
+ { code: READ_CANVAS_STATE_CODE, canvasId },
463
+ signal
464
+ )) as McpToolResult
465
+ const returnedCanvasId = parseCanvasIdFromResult(result) ?? canvasId
466
+ const state = (extractReturnValue(result) ?? {}) as Partial<CanvasStateBundle>
467
+ let saveText = 'Snapshot not saved.'
468
+ if (params.save !== false && returnedCanvasId) {
469
+ const snapshot = await saveCanvasSnapshot(
470
+ cwd,
471
+ returnedCanvasId,
472
+ {
473
+ shapes: Array.isArray(state.shapes) ? state.shapes : [],
474
+ assets: Array.isArray(state.assets) ? state.assets : [],
475
+ bindings: Array.isArray(state.bindings) ? state.bindings : [],
476
+ },
477
+ { allowEmptyOverwrite: params.force === true }
478
+ )
479
+ await rememberCanvasId(cwd, returnedCanvasId)
480
+ saveText = `Saved project snapshot ${snapshot.canvasId} to ${getCanvasDir(cwd)} (${summarizeSnapshot(snapshot)}).`
481
+ } else if (returnedCanvasId) {
482
+ await rememberCanvasId(cwd, returnedCanvasId)
483
+ }
484
+ return {
485
+ content: [
486
+ {
487
+ type: 'text',
488
+ text: [
489
+ `Canvas host: ${started.url}`,
490
+ `Canvas ID: ${returnedCanvasId ?? 'none'}`,
491
+ `State: ${summarizeSnapshot(state)}`,
492
+ saveText,
493
+ ].join('\n'),
494
+ },
495
+ ],
496
+ details: { endpoint, host: canvasHost.getStatus(), canvasId: returnedCanvasId, state },
497
+ }
498
+ },
499
+ })
500
+
501
+ pi.registerTool({
502
+ name: 'tldraw_canvas_scene',
503
+ label: 'Read tldraw Canvas (semantic view)',
504
+ description:
505
+ 'Read the canvas as a compact, meaning-first view — a census, the connection graph, and the spatial structure, NOT raw coordinates. This is the cheap "glance at the canvas" read; the level of detail is chosen automatically by canvas size, so it scales to large canvases. Use this FIRST whenever you need to look at, understand, or build on what is drawn. Falls back to the saved snapshot when no live browser is connected, so it also works offline.',
506
+ promptSnippet: 'Glance at the canvas as a compact semantic view (cheap; scales to large canvases).',
507
+ promptGuidelines: [
508
+ 'tldraw_canvas_scene is the DEFAULT way to look at the canvas — call it first to understand what the user has drawn. You do not choose the detail level; it is picked automatically (overview for small canvases, summary for large ones).',
509
+ 'It returns a meaning-first view (graph + structure), not raw geometry. This is far cheaper than reading raw shapes and is what you should reason over for layout, relationships, and intent.',
510
+ 'Use lens:"selected" when the user has selected shapes and wants a semantic view of only that selection; use lens:"all" or omit it for the whole canvas.',
511
+ 'Reach for tldraw_canvas_state (raw shapes with exact x/y/w/h) ONLY when you need precise geometry to move or resize a specific shape — i.e. right before an edit. For understanding, always prefer the scene.',
512
+ 'You can override the level with detail: "overview" | "summary" | "raw" if you really need to; the default "auto" is almost always correct.',
513
+ ],
514
+ parameters: Type.Object({
515
+ detail: Type.Optional(
516
+ Type.String({
517
+ description:
518
+ 'auto | overview | summary | raw. Default "auto" picks overview for small canvases and summary for large ones. Use "raw" only when you need exact per-shape geometry to edit.',
519
+ })
520
+ ),
521
+ open: Type.Optional(
522
+ Type.Boolean({ description: 'Open/focus the browser host before reading. Defaults to false (reuse a connected browser, else fall back to the saved snapshot).' })
523
+ ),
524
+ canvasId: Type.Optional(
525
+ Type.String({ description: 'Canvas to read from the saved snapshot when no live browser is connected.' })
526
+ ),
527
+ lens: Type.Optional(
528
+ Type.String({ description: 'all | selected. Default "all" reads the whole canvas; "selected" reads only currently selected shapes.' })
529
+ ),
530
+ }),
531
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
532
+ const cwd = ctx?.cwd ?? sessionCwd
533
+ await serverManager.ensure(signal)
534
+ const detail = (params.detail ?? 'auto') as 'auto' | Aperture | 'raw'
535
+ const lensParam = params.lens ?? 'all'
536
+ if (lensParam !== 'all' && lensParam !== 'selected') throw new Error('lens must be "all" or "selected"')
537
+ const lens = lensParam as SceneLens
538
+
539
+ if (params.open === true) await workflows.ensureBrowserAndRestore(cwd, params.canvasId, signal)
540
+ else await canvasHost.ensureStarted(signal)
541
+
542
+ // Prefer the live canvas; fall back to the saved snapshot so this read
543
+ // works headless/offline. The semantic layer runs in-extension on either.
544
+ let resolvedCanvasId = await resolveCanvasId(cwd, params.canvasId)
545
+ let bundle: CanvasStateBundle | null = null
546
+ let source = 'live'
547
+ if (canvasHost.getStatus().browserConnected) {
548
+ const result = (await canvasHost.execOnCanvas(
549
+ { code: READ_CANVAS_STATE_CODE, canvasId: resolvedCanvasId },
550
+ signal
551
+ )) as McpToolResult
552
+ resolvedCanvasId = parseCanvasIdFromResult(result) ?? resolvedCanvasId
553
+ bundle = (result.structuredContent ?? extractReturnValue(result) ?? null) as CanvasStateBundle | null
554
+ } else {
555
+ const snapshot = await loadCanvasSnapshot(cwd, resolvedCanvasId)
556
+ if (snapshot) {
557
+ bundle = { shapes: snapshot.shapes, assets: snapshot.assets, bindings: snapshot.bindings }
558
+ source = 'snapshot'
559
+ }
560
+ }
561
+ if (!bundle || !Array.isArray(bundle.shapes)) {
562
+ throw new Error(
563
+ 'No canvas to read. Open the canvas with /tldraw open (wait for "Canvas ready"), or pass a canvasId that has a saved snapshot.'
564
+ )
565
+ }
566
+ if (resolvedCanvasId) await rememberCanvasId(cwd, resolvedCanvasId)
567
+
568
+ const selectedCount = Array.isArray(bundle.selectedIds) ? bundle.selectedIds.length : 0
569
+ const lensedBundle = applySceneLens(bundle, lens)
570
+
571
+ if (detail === 'raw') {
572
+ const raw = toRawCanvasState(lensedBundle)
573
+ return {
574
+ content: [{ type: 'text', text: JSON.stringify(raw, null, 2) }],
575
+ details: { endpoint, source, canvasId: resolvedCanvasId, detail: 'raw', lens, selectedCount },
576
+ }
577
+ }
578
+
579
+ const rows = buildTensor(asCanvasBundle(lensedBundle))
580
+ const aperture: Aperture = detail === 'auto' ? chooseAperture(rows) : detail
581
+ const view = aperture === 'summary' ? renderSummary(rows) : renderOverview(rows)
582
+ const lensNote = lens === 'selected' ? ' · lens=selected' : ''
583
+ const emptySelectionNote = lens === 'selected' && selectedCount === 0 ? ' · no selected shapes' : ''
584
+ const header = `tldraw canvas · ${rows.length} elements · aperture=${aperture}${lensNote}${source === 'snapshot' ? ' · from saved snapshot' : ''}${selectedCount ? ` · ${selectedCount} selected` : ''}${emptySelectionNote}`
585
+ const hint =
586
+ aperture === 'summary'
587
+ ? 'Bounded summary of a large canvas. For a specific node\'s edges use detail:"overview"; for exact geometry to edit, use tldraw_canvas_state or detail:"raw".'
588
+ : 'For exact x/y/w/h to move or resize a shape, call tldraw_canvas_state or detail:"raw".'
589
+ return {
590
+ content: [{ type: 'text', text: `${header}\n\n${view}\n\n${hint}` }],
591
+ details: { endpoint, source, canvasId: resolvedCanvasId, aperture, lens, elements: rows.length, selectedCount },
592
+ }
593
+ },
594
+ })
595
+
596
+ pi.registerTool({
597
+ name: 'tldraw_call_readonly_tool',
598
+ label: 'Call read-only tldraw MCP tool',
599
+ description:
600
+ 'Experimental curated MCP bridge: call a non-app-only, non-exec tldraw MCP tool by name. Blocks exec and app-only checkpoint/callback tools.',
601
+ parameters: Type.Object({
602
+ toolName: Type.String({ description: 'MCP tool name to call, e.g. search' }),
603
+ argsJson: Type.String({ description: 'Tool arguments as a JSON object string' }),
604
+ }),
605
+ async execute(_toolCallId, params, signal) {
606
+ if (isBlockedTool(params.toolName)) {
607
+ throw new Error(
608
+ `Blocked ${params.toolName}: this Pi experiment only exposes read-only MCP calls. The tldraw MCP exec tool requires an MCP app iframe host.`
609
+ )
610
+ }
611
+ const args = JSON.parse(params.argsJson) as unknown
612
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
613
+ throw new Error('argsJson must parse to a JSON object')
614
+ }
615
+ const toolArgs = args as Record<string, unknown>
616
+ const result = (await client.callTool(params.toolName, toolArgs, signal)) as McpToolResult
617
+ return {
618
+ content: [{ type: 'text', text: extractTextContent(result) }],
619
+ details: { endpoint, mcpTool: params.toolName, result },
620
+ }
621
+ },
622
+ })
623
+
624
+ pi.registerCommand('tldraw', {
625
+ description: 'Inspect/control tldraw MCP (/tldraw status|start|restart|tools|resource|open [canvasId]|save|canvases|current|host|reset).',
626
+ handler: async (args, ctx) => {
627
+ const command = parseTldrawCommand(args)
628
+ const clearStatus = () => ctx.ui.setStatus('tldraw', undefined)
629
+ const commandHandlers: Record<typeof command.type, () => Promise<void>> = {
630
+ reset: async () => {
631
+ client.reset()
632
+ ctx.ui.notify('tldraw MCP session reset.', 'info')
633
+ },
634
+ current: async () => {
635
+ const current = await getStoredCurrentCanvasId(ctx.cwd)
636
+ ctx.ui.setWidget('tldraw-current', [
637
+ 'tldraw current canvas:',
638
+ `cwd: ${ctx.cwd}`,
639
+ `canvasId: ${sessionCanvasId ?? current ?? 'none'}`,
640
+ `store: ${getCanvasDir(ctx.cwd)}`,
641
+ ])
642
+ },
643
+ canvases: async () => {
644
+ const canvases = await listCanvasSnapshots(ctx.cwd)
645
+ ctx.ui.setWidget('tldraw-canvases', [
646
+ `tldraw project canvases (${canvases.length}):`,
647
+ `store: ${getCanvasDir(ctx.cwd)}`,
648
+ ...canvases.map(
649
+ (entry: { canvasId: string; shapeCount: number; updatedAt: string }) =>
650
+ `- ${entry.canvasId} · ${entry.shapeCount} shape(s) · ${entry.updatedAt || 'unknown time'}`
651
+ ),
652
+ ])
653
+ },
654
+ save: async () => {
655
+ clearStatus()
656
+ statusIndicator.update(ctx, 'working')
657
+ await serverManager.ensure(ctx.signal)
658
+ const status = canvasHost.getStatus()
659
+ if (!status.url) {
660
+ throw new Error('Canvas host is not started. Use /tldraw open first, make edits, then /tldraw save.')
661
+ }
662
+ if (!status.browserConnected) {
663
+ throw new Error(`No live browser canvas is connected at ${status.url}. Reopen with /tldraw open before saving.`)
664
+ }
665
+ const canvasId = await resolveCanvasId(ctx.cwd, command.canvasId)
666
+ const snapshot = await workflows.snapshotLiveCanvas(ctx.cwd, canvasId, ctx.signal, {
667
+ allowEmptyOverwrite: command.force,
668
+ })
669
+ ctx.ui.notify(`Saved ${snapshot.canvasId} (${summarizeSnapshot(snapshot)})`, 'info')
670
+ clearStatus()
671
+ statusIndicator.update(ctx, canvasHost.getStatus().browserConnected ? 'connected' : 'ready')
672
+ },
673
+ start: async () => {
674
+ clearStatus()
675
+ statusIndicator.update(ctx, 'starting')
676
+ await serverManager.start(ctx.signal)
677
+ ctx.ui.notify(`tldraw MCP server reachable at ${endpoint}`, 'info')
678
+ clearStatus()
679
+ statusIndicator.update(ctx, 'ready')
680
+ },
681
+ restart: async () => {
682
+ clearStatus()
683
+ statusIndicator.update(ctx, 'starting')
684
+ client.reset()
685
+ await serverManager.restart(ctx.signal)
686
+ ctx.ui.notify(`tldraw MCP server restarted at ${endpoint}`, 'info')
687
+ clearStatus()
688
+ statusIndicator.update(ctx, 'ready')
689
+ },
690
+ tools: async () => {
691
+ const tools = await client.listTools(ctx.signal)
692
+ ctx.ui.setWidget('tldraw', ['tldraw MCP tools:', ...compactToolList(tools)])
693
+ },
694
+ open: async () => {
695
+ clearStatus()
696
+ statusIndicator.update(ctx, 'starting')
697
+ await serverManager.ensure(ctx.signal)
698
+ const { url, restoreText } = await workflows.ensureBrowserAndRestore(ctx.cwd, command.canvasId, ctx.signal)
699
+ ctx.ui.notify(`Opened tldraw canvas host: ${url}. ${restoreText}`, 'info')
700
+ clearStatus()
701
+ statusIndicator.update(ctx, canvasHost.getStatus().browserConnected ? 'connected' : 'ready')
702
+ },
703
+ host: async () => {
704
+ const status = canvasHost.getStatus()
705
+ const server = serverManager.getStatus()
706
+ ctx.ui.setWidget('tldraw-host', [
707
+ 'tldraw canvas host:',
708
+ `url: ${status.url ?? 'not started'}`,
709
+ `browser connected: ${status.browserConnected ? 'yes' : 'no'}`,
710
+ `queued: ${status.queued}`,
711
+ `pending: ${status.pending}`,
712
+ 'canvas logs:',
713
+ ...status.logs.slice(-12),
714
+ '',
715
+ 'tldraw MCP server:',
716
+ `endpoint: ${server.endpoint}`,
717
+ `auto start: ${server.autoStart ? 'yes' : 'no'}`,
718
+ `managed pid: ${server.managedPid ?? 'none'}`,
719
+ `app dir: ${server.appDir}`,
720
+ ...server.logs.slice(-8),
721
+ ])
722
+ },
723
+ resource: async () => {
724
+ const result = (await client.readResource(CANVAS_RESOURCE_URI, ctx.signal)) as {
725
+ contents?: Array<{ text?: unknown }>
726
+ }
727
+ const html = result.contents?.[0]?.text
728
+ const bytes = typeof html === 'string' ? html.length : 0
729
+ ctx.ui.notify(`Canvas resource fetched (${bytes} bytes).`, 'info')
730
+ },
731
+ status: async () => {
732
+ await Promise.all([client.listTools(ctx.signal), client.listResources(ctx.signal)])
733
+ const server = serverManager.getStatus()
734
+ clearStatus()
735
+ statusIndicator.update(ctx, 'ready')
736
+ ctx.ui.notify(`tldraw MCP OK at ${endpoint}${server.managedPid ? ` (pid ${server.managedPid})` : ''}`, 'info')
737
+ },
738
+ }
739
+
740
+ try {
741
+ await commandHandlers[command.type]()
742
+ } catch (error) {
743
+ clearStatus()
744
+ statusIndicator.update(ctx, 'error')
745
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error')
746
+ }
747
+ },
748
+ })
749
+
750
+ pi.on('session_start', async (_event, ctx) => {
751
+ sessionCwd = ctx.cwd
752
+ sessionCanvasId = await getStoredCurrentCanvasId(ctx.cwd)
753
+ statusIndicator.update(ctx, 'idle')
754
+ })
755
+
756
+ pi.on('session_shutdown', async () => {
757
+ statusIndicator.stop()
758
+ client.reset()
759
+ await canvasHost.close()
760
+ await serverManager.stop()
761
+ })
762
+ }