pmx-canvas 0.1.11 → 0.1.13

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.
@@ -0,0 +1,676 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import {
4
+ createCanvas,
5
+ canvasState,
6
+ type CanvasEdge,
7
+ type CanvasLayout,
8
+ type CanvasNodeState,
9
+ type CanvasSnapshot,
10
+ type PmxCanvas,
11
+ } from '../server/index.js';
12
+
13
+ type AddNodeInput = Parameters<PmxCanvas['addNode']>[0];
14
+ type AddWebpageNodeInput = Parameters<PmxCanvas['addWebpageNode']>[0];
15
+ type RefreshWebpageNodeResult = Awaited<ReturnType<PmxCanvas['refreshWebpageNode']>>;
16
+ type OpenMcpAppInput = Parameters<PmxCanvas['openMcpApp']>[0];
17
+ type OpenMcpAppResult = Awaited<ReturnType<PmxCanvas['openMcpApp']>>;
18
+ type AddDiagramInput = Parameters<PmxCanvas['addDiagram']>[0];
19
+ type AddJsonRenderNodeInput = Parameters<PmxCanvas['addJsonRenderNode']>[0];
20
+ type AddJsonRenderNodeResult = ReturnType<PmxCanvas['addJsonRenderNode']>;
21
+ type AddGraphNodeInput = Parameters<PmxCanvas['addGraphNode']>[0];
22
+ type AddGraphNodeResult = ReturnType<PmxCanvas['addGraphNode']>;
23
+ type UpdateNodePatch = Parameters<PmxCanvas['updateNode']>[1];
24
+ type AddEdgeInput = Parameters<PmxCanvas['addEdge']>[0];
25
+ type CreateGroupInput = Parameters<PmxCanvas['createGroup']>[0];
26
+ type GroupNodesOptions = Parameters<PmxCanvas['groupNodes']>[2];
27
+ type ArrangeLayout = Parameters<PmxCanvas['arrange']>[0];
28
+ type FocusNodeResult = ReturnType<PmxCanvas['focusNode']>;
29
+ type FitViewOptions = Parameters<PmxCanvas['fitView']>[0];
30
+ type FitViewResult = ReturnType<PmxCanvas['fitView']>;
31
+ type SearchResult = ReturnType<PmxCanvas['search']>;
32
+ type UndoRedoResult = Awaited<ReturnType<PmxCanvas['undo']>>;
33
+ type HistoryResult = ReturnType<PmxCanvas['getHistory']>;
34
+ type SetContextPinsResult = ReturnType<PmxCanvas['setContextPins']>;
35
+ type RunBatchInput = Parameters<PmxCanvas['runBatch']>[0];
36
+ type RunBatchResult = Awaited<ReturnType<PmxCanvas['runBatch']>>;
37
+ type SnapshotList = ReturnType<PmxCanvas['listSnapshots']>;
38
+ type DeleteSnapshotResult = ReturnType<PmxCanvas['deleteSnapshot']>;
39
+ type DiffSnapshotResult = ReturnType<PmxCanvas['diffSnapshot']>;
40
+ type CodeGraphResult = ReturnType<PmxCanvas['getCodeGraph']>;
41
+ type ValidationResult = ReturnType<PmxCanvas['validate']>;
42
+ type WebArtifactInput = Parameters<PmxCanvas['buildWebArtifact']>[0];
43
+ type WebArtifactResult = Awaited<ReturnType<PmxCanvas['buildWebArtifact']>>;
44
+ type AutomationWebViewOptions = Parameters<PmxCanvas['startAutomationWebView']>[0];
45
+ type AutomationWebViewStatus = Awaited<ReturnType<PmxCanvas['startAutomationWebView']>>;
46
+ type AutomationEvaluateResult = Awaited<ReturnType<PmxCanvas['evaluateAutomationWebView']>>;
47
+ type AutomationScreenshotOptions = Parameters<PmxCanvas['screenshotAutomationWebView']>[0];
48
+
49
+ interface HealthResponse {
50
+ ok?: boolean;
51
+ workspace?: string;
52
+ }
53
+
54
+ interface NodeResponse {
55
+ id?: string;
56
+ node?: { id?: string };
57
+ }
58
+
59
+ interface JsonRenderNodeResponse extends NodeResponse {
60
+ url: string;
61
+ spec: AddJsonRenderNodeResult['spec'];
62
+ }
63
+
64
+ interface GraphNodeResponse extends NodeResponse {
65
+ url: string;
66
+ spec: AddGraphNodeResult['spec'];
67
+ }
68
+
69
+ interface SearchResponse {
70
+ results?: SearchResult;
71
+ }
72
+
73
+ interface SnapshotSaveResponse {
74
+ snapshot?: CanvasSnapshot;
75
+ }
76
+
77
+ interface WebViewEnvelope {
78
+ webview?: AutomationWebViewStatus;
79
+ }
80
+
81
+ interface WebViewStopEnvelope extends WebViewEnvelope {
82
+ stopped?: boolean;
83
+ }
84
+
85
+ interface WebViewEvaluateEnvelope {
86
+ value?: AutomationEvaluateResult;
87
+ }
88
+
89
+ export interface CanvasAccess {
90
+ readonly port: number;
91
+ readonly remoteBaseUrl: string | null;
92
+ getLayout(): Promise<CanvasLayout>;
93
+ getNode(id: string): Promise<CanvasNodeState | undefined>;
94
+ addNode(input: AddNodeInput): Promise<string>;
95
+ addWebpageNode(input: AddWebpageNodeInput): Promise<Awaited<ReturnType<PmxCanvas['addWebpageNode']>>>;
96
+ refreshWebpageNode(id: string, url?: string): Promise<RefreshWebpageNodeResult>;
97
+ openMcpApp(input: OpenMcpAppInput): Promise<OpenMcpAppResult>;
98
+ addDiagram(input: AddDiagramInput): Promise<OpenMcpAppResult>;
99
+ addJsonRenderNode(input: AddJsonRenderNodeInput): Promise<AddJsonRenderNodeResult>;
100
+ addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult>;
101
+ buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult>;
102
+ updateNode(id: string, patch: UpdateNodePatch): Promise<void>;
103
+ removeNode(id: string): Promise<void>;
104
+ addEdge(input: AddEdgeInput): Promise<string>;
105
+ removeEdge(id: string): Promise<void>;
106
+ createGroup(input: CreateGroupInput): Promise<string>;
107
+ groupNodes(groupId: string, childIds: string[], options?: GroupNodesOptions): Promise<boolean>;
108
+ ungroupNodes(groupId: string): Promise<boolean>;
109
+ arrange(layout?: ArrangeLayout): Promise<void>;
110
+ focusNode(id: string, options?: { noPan?: boolean }): Promise<FocusNodeResult>;
111
+ fitView(options?: FitViewOptions): Promise<FitViewResult>;
112
+ clear(): Promise<void>;
113
+ search(query: string): Promise<SearchResult>;
114
+ undo(): Promise<UndoRedoResult>;
115
+ redo(): Promise<UndoRedoResult>;
116
+ getHistory(): Promise<HistoryResult>;
117
+ setContextPins(nodeIds: string[], mode?: 'set' | 'add' | 'remove'): Promise<SetContextPinsResult>;
118
+ getPinnedNodeIds(): Promise<string[]>;
119
+ runBatch(operations: RunBatchInput): Promise<RunBatchResult>;
120
+ listSnapshots(): Promise<SnapshotList>;
121
+ saveSnapshot(name: string): Promise<CanvasSnapshot | null>;
122
+ restoreSnapshot(id: string): Promise<{ ok: boolean }>;
123
+ deleteSnapshot(id: string): Promise<DeleteSnapshotResult>;
124
+ diffSnapshot(idOrName: string): Promise<DiffSnapshotResult>;
125
+ getCodeGraph(): Promise<CodeGraphResult>;
126
+ validate(): Promise<ValidationResult>;
127
+ getAutomationWebViewStatus(): Promise<AutomationWebViewStatus>;
128
+ startAutomationWebView(options?: AutomationWebViewOptions): Promise<AutomationWebViewStatus>;
129
+ stopAutomationWebView(): Promise<boolean>;
130
+ evaluateAutomationWebView(expression: string): Promise<AutomationEvaluateResult>;
131
+ resizeAutomationWebView(width: number, height: number): Promise<AutomationWebViewStatus>;
132
+ screenshotAutomationWebView(options?: AutomationScreenshotOptions): Promise<Uint8Array>;
133
+ }
134
+
135
+ class LocalCanvasAccess implements CanvasAccess {
136
+ readonly remoteBaseUrl = null;
137
+
138
+ constructor(
139
+ private readonly canvas: PmxCanvas,
140
+ readonly workspaceRoot: string,
141
+ readonly targetPort: number,
142
+ ) {}
143
+
144
+ get port(): number {
145
+ return this.canvas.port;
146
+ }
147
+
148
+ async getLayout(): Promise<CanvasLayout> {
149
+ return this.canvas.getLayout();
150
+ }
151
+
152
+ async getNode(id: string): Promise<CanvasNodeState | undefined> {
153
+ return this.canvas.getNode(id);
154
+ }
155
+
156
+ async addNode(input: AddNodeInput): Promise<string> {
157
+ return this.canvas.addNode(input);
158
+ }
159
+
160
+ async addWebpageNode(input: AddWebpageNodeInput): Promise<Awaited<ReturnType<PmxCanvas['addWebpageNode']>>> {
161
+ return await this.canvas.addWebpageNode(input);
162
+ }
163
+
164
+ async refreshWebpageNode(id: string, url?: string): Promise<RefreshWebpageNodeResult> {
165
+ return await this.canvas.refreshWebpageNode(id, url);
166
+ }
167
+
168
+ async openMcpApp(input: OpenMcpAppInput): Promise<OpenMcpAppResult> {
169
+ return await this.canvas.openMcpApp(input);
170
+ }
171
+
172
+ async addDiagram(input: AddDiagramInput): Promise<OpenMcpAppResult> {
173
+ return await this.canvas.addDiagram(input);
174
+ }
175
+
176
+ async addJsonRenderNode(input: AddJsonRenderNodeInput): Promise<AddJsonRenderNodeResult> {
177
+ return this.canvas.addJsonRenderNode(input);
178
+ }
179
+
180
+ async addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult> {
181
+ return this.canvas.addGraphNode(input);
182
+ }
183
+
184
+ async buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult> {
185
+ return await this.canvas.buildWebArtifact(input);
186
+ }
187
+
188
+ async updateNode(id: string, patch: UpdateNodePatch): Promise<void> {
189
+ this.canvas.updateNode(id, patch);
190
+ }
191
+
192
+ async removeNode(id: string): Promise<void> {
193
+ this.canvas.removeNode(id);
194
+ }
195
+
196
+ async addEdge(input: AddEdgeInput): Promise<string> {
197
+ return this.canvas.addEdge(input);
198
+ }
199
+
200
+ async removeEdge(id: string): Promise<void> {
201
+ this.canvas.removeEdge(id);
202
+ }
203
+
204
+ async createGroup(input: CreateGroupInput): Promise<string> {
205
+ return this.canvas.createGroup(input);
206
+ }
207
+
208
+ async groupNodes(groupId: string, childIds: string[], options?: GroupNodesOptions): Promise<boolean> {
209
+ return this.canvas.groupNodes(groupId, childIds, options);
210
+ }
211
+
212
+ async ungroupNodes(groupId: string): Promise<boolean> {
213
+ return this.canvas.ungroupNodes(groupId);
214
+ }
215
+
216
+ async arrange(layout?: ArrangeLayout): Promise<void> {
217
+ this.canvas.arrange(layout);
218
+ }
219
+
220
+ async focusNode(id: string, options?: { noPan?: boolean }): Promise<FocusNodeResult> {
221
+ return this.canvas.focusNode(id, options);
222
+ }
223
+
224
+ async fitView(options?: FitViewOptions): Promise<FitViewResult> {
225
+ return this.canvas.fitView(options);
226
+ }
227
+
228
+ async clear(): Promise<void> {
229
+ this.canvas.clear();
230
+ }
231
+
232
+ async search(query: string): Promise<SearchResult> {
233
+ return this.canvas.search(query);
234
+ }
235
+
236
+ async undo(): Promise<UndoRedoResult> {
237
+ return await this.canvas.undo();
238
+ }
239
+
240
+ async redo(): Promise<UndoRedoResult> {
241
+ return await this.canvas.redo();
242
+ }
243
+
244
+ async getHistory(): Promise<HistoryResult> {
245
+ return this.canvas.getHistory();
246
+ }
247
+
248
+ async setContextPins(nodeIds: string[], mode: 'set' | 'add' | 'remove' = 'set'): Promise<SetContextPinsResult> {
249
+ return this.canvas.setContextPins(nodeIds, mode);
250
+ }
251
+
252
+ async getPinnedNodeIds(): Promise<string[]> {
253
+ return Array.from(canvasState.contextPinnedNodeIds);
254
+ }
255
+
256
+ async runBatch(operations: RunBatchInput): Promise<RunBatchResult> {
257
+ return await this.canvas.runBatch(operations);
258
+ }
259
+
260
+ async listSnapshots(): Promise<SnapshotList> {
261
+ return this.canvas.listSnapshots();
262
+ }
263
+
264
+ async saveSnapshot(name: string): Promise<CanvasSnapshot | null> {
265
+ return this.canvas.saveSnapshot(name);
266
+ }
267
+
268
+ async restoreSnapshot(id: string): Promise<{ ok: boolean }> {
269
+ return await this.canvas.restoreSnapshot(id);
270
+ }
271
+
272
+ async deleteSnapshot(id: string): Promise<DeleteSnapshotResult> {
273
+ return this.canvas.deleteSnapshot(id);
274
+ }
275
+
276
+ async diffSnapshot(idOrName: string): Promise<DiffSnapshotResult> {
277
+ return this.canvas.diffSnapshot(idOrName);
278
+ }
279
+
280
+ async getCodeGraph(): Promise<CodeGraphResult> {
281
+ return this.canvas.getCodeGraph();
282
+ }
283
+
284
+ async validate(): Promise<ValidationResult> {
285
+ return this.canvas.validate();
286
+ }
287
+
288
+ async getAutomationWebViewStatus(): Promise<AutomationWebViewStatus> {
289
+ return this.canvas.getAutomationWebViewStatus();
290
+ }
291
+
292
+ async startAutomationWebView(options: AutomationWebViewOptions = {}): Promise<AutomationWebViewStatus> {
293
+ return await this.canvas.startAutomationWebView(options);
294
+ }
295
+
296
+ async stopAutomationWebView(): Promise<boolean> {
297
+ return await this.canvas.stopAutomationWebView();
298
+ }
299
+
300
+ async evaluateAutomationWebView(expression: string): Promise<AutomationEvaluateResult> {
301
+ return await this.canvas.evaluateAutomationWebView(expression);
302
+ }
303
+
304
+ async resizeAutomationWebView(width: number, height: number): Promise<AutomationWebViewStatus> {
305
+ return await this.canvas.resizeAutomationWebView(width, height);
306
+ }
307
+
308
+ async screenshotAutomationWebView(options: AutomationScreenshotOptions = {}): Promise<Uint8Array> {
309
+ return await this.canvas.screenshotAutomationWebView(options);
310
+ }
311
+ }
312
+
313
+ class RemoteCanvasAccess implements CanvasAccess {
314
+ readonly remoteBaseUrl: string;
315
+ readonly port: number;
316
+
317
+ constructor(baseUrl: string) {
318
+ this.remoteBaseUrl = baseUrl.replace(/\/$/, '');
319
+ const parsed = new URL(this.remoteBaseUrl);
320
+ this.port = Number(parsed.port || '80');
321
+ }
322
+
323
+ private async requestJson<T>(method: string, path: string, body?: unknown): Promise<T> {
324
+ const response = await fetch(`${this.remoteBaseUrl}${path}`, {
325
+ method,
326
+ headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
327
+ body: body === undefined ? undefined : JSON.stringify(body),
328
+ });
329
+ const text = await response.text();
330
+ let parsed: unknown = {};
331
+ if (text.length > 0) {
332
+ try {
333
+ parsed = JSON.parse(text) as unknown;
334
+ } catch {
335
+ parsed = { error: text };
336
+ }
337
+ }
338
+ if (!response.ok) {
339
+ const error = parsed && typeof parsed === 'object' && 'error' in parsed
340
+ ? String((parsed as { error?: unknown }).error)
341
+ : `HTTP ${response.status}`;
342
+ if (path === '/api/canvas/batch' && parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
343
+ return parsed as T;
344
+ }
345
+ throw new Error(error);
346
+ }
347
+ return parsed as T;
348
+ }
349
+
350
+ private async requestNodeId(method: string, path: string, body?: unknown): Promise<string> {
351
+ const response = await this.requestJson<NodeResponse>(method, path, body);
352
+ const id = typeof response.id === 'string'
353
+ ? response.id
354
+ : typeof response.node?.id === 'string'
355
+ ? response.node.id
356
+ : '';
357
+ if (!id) throw new Error('Canvas response did not include a node id.');
358
+ return id;
359
+ }
360
+
361
+ async getLayout(): Promise<CanvasLayout> {
362
+ return await this.requestJson<CanvasLayout>('GET', '/api/canvas/state');
363
+ }
364
+
365
+ async getNode(id: string): Promise<CanvasNodeState | undefined> {
366
+ const response = await fetch(`${this.remoteBaseUrl}/api/canvas/node/${encodeURIComponent(id)}`);
367
+ if (response.status === 404) return undefined;
368
+ const text = await response.text();
369
+ let parsed: unknown = undefined;
370
+ if (text.length > 0) {
371
+ try {
372
+ parsed = JSON.parse(text) as unknown;
373
+ } catch {
374
+ parsed = { error: text };
375
+ }
376
+ }
377
+ if (!response.ok) {
378
+ const error = parsed && typeof parsed === 'object' && 'error' in parsed
379
+ ? String((parsed as { error?: unknown }).error)
380
+ : `HTTP ${response.status}`;
381
+ throw new Error(error);
382
+ }
383
+ return parsed as CanvasNodeState;
384
+ }
385
+
386
+ async addNode(input: AddNodeInput): Promise<string> {
387
+ return await this.requestNodeId('POST', '/api/canvas/node', input);
388
+ }
389
+
390
+ async addWebpageNode(input: AddWebpageNodeInput): Promise<Awaited<ReturnType<PmxCanvas['addWebpageNode']>>> {
391
+ return await this.requestJson<Awaited<ReturnType<PmxCanvas['addWebpageNode']>>>('POST', '/api/canvas/node', {
392
+ type: 'webpage',
393
+ ...input,
394
+ });
395
+ }
396
+
397
+ async refreshWebpageNode(id: string, url?: string): Promise<RefreshWebpageNodeResult> {
398
+ return await this.requestJson<RefreshWebpageNodeResult>('POST', `/api/canvas/node/${encodeURIComponent(id)}/refresh`, {
399
+ ...(url ? { url } : {}),
400
+ });
401
+ }
402
+
403
+ async openMcpApp(input: OpenMcpAppInput): Promise<OpenMcpAppResult> {
404
+ return await this.requestJson<OpenMcpAppResult>('POST', '/api/canvas/mcp-app/open', input);
405
+ }
406
+
407
+ async addDiagram(input: AddDiagramInput): Promise<OpenMcpAppResult> {
408
+ return await this.requestJson<OpenMcpAppResult>('POST', '/api/canvas/diagram', input);
409
+ }
410
+
411
+ async addJsonRenderNode(input: AddJsonRenderNodeInput): Promise<AddJsonRenderNodeResult> {
412
+ const response = await this.requestJson<JsonRenderNodeResponse>('POST', '/api/canvas/json-render', input);
413
+ const id = typeof response.id === 'string' ? response.id : response.node?.id;
414
+ if (!id) throw new Error('json-render response did not include a node id.');
415
+ return { id, url: response.url, spec: response.spec };
416
+ }
417
+
418
+ async addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult> {
419
+ const response = await this.requestJson<GraphNodeResponse>('POST', '/api/canvas/graph', {
420
+ ...input,
421
+ ...(typeof input.heightPx === 'number' ? { nodeHeight: input.heightPx } : {}),
422
+ });
423
+ const id = typeof response.id === 'string' ? response.id : response.node?.id;
424
+ if (!id) throw new Error('graph response did not include a node id.');
425
+ return { id, url: response.url, spec: response.spec };
426
+ }
427
+
428
+ async buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult> {
429
+ return await this.requestJson<WebArtifactResult>('POST', '/api/canvas/web-artifact', input);
430
+ }
431
+
432
+ async updateNode(id: string, patch: UpdateNodePatch): Promise<void> {
433
+ await this.requestJson<unknown>('PATCH', `/api/canvas/node/${encodeURIComponent(id)}`, patch);
434
+ }
435
+
436
+ async removeNode(id: string): Promise<void> {
437
+ await this.requestJson<unknown>('DELETE', `/api/canvas/node/${encodeURIComponent(id)}`);
438
+ }
439
+
440
+ async addEdge(input: AddEdgeInput): Promise<string> {
441
+ const response = await this.requestJson<{ id?: string }>('POST', '/api/canvas/edge', input);
442
+ if (!response.id) throw new Error('Canvas edge response did not include an edge id.');
443
+ return response.id;
444
+ }
445
+
446
+ async removeEdge(id: string): Promise<void> {
447
+ await this.requestJson<unknown>('DELETE', '/api/canvas/edge', { edge_id: id });
448
+ }
449
+
450
+ async createGroup(input: CreateGroupInput): Promise<string> {
451
+ return await this.requestNodeId('POST', '/api/canvas/group', input);
452
+ }
453
+
454
+ async groupNodes(groupId: string, childIds: string[], options?: GroupNodesOptions): Promise<boolean> {
455
+ const response = await this.requestJson<{ ok?: boolean }>('POST', '/api/canvas/group/add', {
456
+ groupId,
457
+ childIds,
458
+ ...(options?.childLayout ? { childLayout: options.childLayout } : {}),
459
+ });
460
+ return response.ok === true;
461
+ }
462
+
463
+ async ungroupNodes(groupId: string): Promise<boolean> {
464
+ const response = await this.requestJson<{ ok?: boolean }>('POST', '/api/canvas/group/ungroup', { groupId });
465
+ return response.ok === true;
466
+ }
467
+
468
+ async arrange(layout?: ArrangeLayout): Promise<void> {
469
+ await this.requestJson<unknown>('POST', '/api/canvas/arrange', { ...(layout ? { layout } : {}) });
470
+ }
471
+
472
+ async focusNode(id: string, options?: { noPan?: boolean }): Promise<FocusNodeResult> {
473
+ const response = await fetch(`${this.remoteBaseUrl}/api/canvas/focus`, {
474
+ method: 'POST',
475
+ headers: { 'Content-Type': 'application/json' },
476
+ body: JSON.stringify({ id, ...(options?.noPan === true ? { noPan: true } : {}) }),
477
+ });
478
+ if (response.status === 404) return null;
479
+ const parsed = await response.json() as { focused?: string; panned?: boolean };
480
+ if (!response.ok || typeof parsed.focused !== 'string' || typeof parsed.panned !== 'boolean') return null;
481
+ return { focused: parsed.focused, panned: parsed.panned };
482
+ }
483
+
484
+ async fitView(options?: FitViewOptions): Promise<FitViewResult> {
485
+ return await this.requestJson<FitViewResult>('POST', '/api/canvas/fit', options ?? {});
486
+ }
487
+
488
+ async clear(): Promise<void> {
489
+ await this.requestJson<unknown>('POST', '/api/canvas/clear', {});
490
+ }
491
+
492
+ async search(query: string): Promise<SearchResult> {
493
+ const response = await this.requestJson<SearchResponse>('GET', `/api/canvas/search?q=${encodeURIComponent(query)}`);
494
+ return response.results ?? [];
495
+ }
496
+
497
+ async undo(): Promise<UndoRedoResult> {
498
+ return await this.requestJson<UndoRedoResult>('POST', '/api/canvas/undo', {});
499
+ }
500
+
501
+ async redo(): Promise<UndoRedoResult> {
502
+ return await this.requestJson<UndoRedoResult>('POST', '/api/canvas/redo', {});
503
+ }
504
+
505
+ async getHistory(): Promise<HistoryResult> {
506
+ return await this.requestJson<HistoryResult>('GET', '/api/canvas/history');
507
+ }
508
+
509
+ async setContextPins(nodeIds: string[], mode: 'set' | 'add' | 'remove' = 'set'): Promise<SetContextPinsResult> {
510
+ const existing = mode === 'set' ? [] : await this.getPinnedNodeIds();
511
+ const requested = new Set(nodeIds);
512
+ const next = mode === 'set'
513
+ ? nodeIds
514
+ : mode === 'add'
515
+ ? [...new Set([...existing, ...nodeIds])]
516
+ : existing.filter((id) => !requested.has(id));
517
+ const response = await this.requestJson<{ count?: number }>('POST', '/api/canvas/context-pins', { nodeIds: next });
518
+ return { count: response.count ?? next.length, nodeIds: next };
519
+ }
520
+
521
+ async getPinnedNodeIds(): Promise<string[]> {
522
+ const response = await this.requestJson<{ nodeIds?: string[] }>('GET', '/api/canvas/pinned-context');
523
+ return Array.isArray(response.nodeIds) ? response.nodeIds : [];
524
+ }
525
+
526
+ async runBatch(operations: RunBatchInput): Promise<RunBatchResult> {
527
+ return await this.requestJson<RunBatchResult>('POST', '/api/canvas/batch', { operations });
528
+ }
529
+
530
+ async listSnapshots(): Promise<SnapshotList> {
531
+ return await this.requestJson<SnapshotList>('GET', '/api/canvas/snapshots');
532
+ }
533
+
534
+ async saveSnapshot(name: string): Promise<CanvasSnapshot | null> {
535
+ const response = await this.requestJson<SnapshotSaveResponse>('POST', '/api/canvas/snapshots', { name });
536
+ return response.snapshot ?? null;
537
+ }
538
+
539
+ async restoreSnapshot(id: string): Promise<{ ok: boolean }> {
540
+ return await this.requestJson<{ ok: boolean }>('POST', `/api/canvas/snapshots/${encodeURIComponent(id)}`, {});
541
+ }
542
+
543
+ async deleteSnapshot(id: string): Promise<DeleteSnapshotResult> {
544
+ return await this.requestJson<DeleteSnapshotResult>('DELETE', `/api/canvas/snapshots/${encodeURIComponent(id)}`);
545
+ }
546
+
547
+ async diffSnapshot(idOrName: string): Promise<DiffSnapshotResult> {
548
+ return await this.requestJson<DiffSnapshotResult>('GET', `/api/canvas/snapshots/${encodeURIComponent(idOrName)}/diff`);
549
+ }
550
+
551
+ async getCodeGraph(): Promise<CodeGraphResult> {
552
+ const summary = await this.requestJson<CodeGraphResult['summary']>('GET', '/api/canvas/code-graph');
553
+ return { text: JSON.stringify(summary, null, 2), summary };
554
+ }
555
+
556
+ async validate(): Promise<ValidationResult> {
557
+ return await this.requestJson<ValidationResult>('GET', '/api/canvas/validate');
558
+ }
559
+
560
+ async getAutomationWebViewStatus(): Promise<AutomationWebViewStatus> {
561
+ return await this.requestJson<AutomationWebViewStatus>('GET', '/api/workbench/webview');
562
+ }
563
+
564
+ async startAutomationWebView(options: AutomationWebViewOptions = {}): Promise<AutomationWebViewStatus> {
565
+ const response = await this.requestJson<WebViewEnvelope>('POST', '/api/workbench/webview/start', options);
566
+ if (!response.webview) throw new Error('WebView start response did not include status.');
567
+ return response.webview;
568
+ }
569
+
570
+ async stopAutomationWebView(): Promise<boolean> {
571
+ const response = await this.requestJson<WebViewStopEnvelope>('DELETE', '/api/workbench/webview');
572
+ return response.stopped === true;
573
+ }
574
+
575
+ async evaluateAutomationWebView(expression: string): Promise<AutomationEvaluateResult> {
576
+ const response = await this.requestJson<WebViewEvaluateEnvelope>('POST', '/api/workbench/webview/evaluate', { expression });
577
+ return response.value as AutomationEvaluateResult;
578
+ }
579
+
580
+ async resizeAutomationWebView(width: number, height: number): Promise<AutomationWebViewStatus> {
581
+ const response = await this.requestJson<WebViewEnvelope>('POST', '/api/workbench/webview/resize', { width, height });
582
+ if (!response.webview) throw new Error('WebView resize response did not include status.');
583
+ return response.webview;
584
+ }
585
+
586
+ async screenshotAutomationWebView(options: AutomationScreenshotOptions = {}): Promise<Uint8Array> {
587
+ const response = await fetch(`${this.remoteBaseUrl}/api/workbench/webview/screenshot`, {
588
+ method: 'POST',
589
+ headers: { 'Content-Type': 'application/json' },
590
+ body: JSON.stringify(options),
591
+ });
592
+ if (!response.ok) {
593
+ const text = await response.text();
594
+ throw new Error(text || `HTTP ${response.status}`);
595
+ }
596
+ return new Uint8Array(await response.arrayBuffer());
597
+ }
598
+ }
599
+
600
+ function targetPort(): number {
601
+ const raw = process.env.PMX_CANVAS_PORT ?? process.env.PMX_WEB_CANVAS_PORT ?? '4313';
602
+ const parsed = Number(raw);
603
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 4313;
604
+ }
605
+
606
+ function canonicalWorkspacePath(pathLike: string): string {
607
+ const resolved = resolve(pathLike);
608
+ try {
609
+ return realpathSync.native(resolved);
610
+ } catch {
611
+ return resolved;
612
+ }
613
+ }
614
+
615
+ function candidateBaseUrls(port: number): string[] {
616
+ const urls: string[] = [];
617
+ const push = (value: string | undefined) => {
618
+ const trimmed = value?.trim().replace(/\/$/, '');
619
+ if (trimmed && !urls.includes(trimmed)) urls.push(trimmed);
620
+ };
621
+ push(process.env.PMX_CANVAS_URL);
622
+ push(`http://127.0.0.1:${port}`);
623
+ push(`http://localhost:${port}`);
624
+ return urls;
625
+ }
626
+
627
+ function localBaseUrls(port: number): string[] {
628
+ return [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
629
+ }
630
+
631
+ async function readHealth(baseUrl: string): Promise<HealthResponse | null> {
632
+ try {
633
+ const response = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(400) });
634
+ if (!response.ok) return null;
635
+ return await response.json() as HealthResponse;
636
+ } catch {
637
+ return null;
638
+ }
639
+ }
640
+
641
+ async function findExistingCanvasServer(
642
+ workspaceRoot: string,
643
+ port: number,
644
+ options: { excludeBaseUrls?: string[] } = {},
645
+ ): Promise<string | null> {
646
+ const canonicalWorkspaceRoot = canonicalWorkspacePath(workspaceRoot);
647
+ const excluded = new Set((options.excludeBaseUrls ?? []).map((baseUrl) => baseUrl.replace(/\/$/, '')));
648
+ for (const baseUrl of candidateBaseUrls(port)) {
649
+ if (excluded.has(baseUrl)) continue;
650
+ const health = await readHealth(baseUrl);
651
+ if (health?.ok !== true) continue;
652
+ const healthWorkspace = typeof health.workspace === 'string' ? canonicalWorkspacePath(health.workspace) : '';
653
+ if (healthWorkspace && healthWorkspace !== canonicalWorkspaceRoot) continue;
654
+ return baseUrl;
655
+ }
656
+ return null;
657
+ }
658
+
659
+ export async function refreshCanvasAccess(access: CanvasAccess): Promise<CanvasAccess> {
660
+ if (!(access instanceof LocalCanvasAccess)) return access;
661
+ const remoteBaseUrl = await findExistingCanvasServer(access.workspaceRoot, access.targetPort, {
662
+ excludeBaseUrls: localBaseUrls(access.port),
663
+ });
664
+ return remoteBaseUrl ? new RemoteCanvasAccess(remoteBaseUrl) : access;
665
+ }
666
+
667
+ export async function createCanvasAccess(): Promise<CanvasAccess> {
668
+ const workspaceRoot = resolve(process.cwd());
669
+ const port = targetPort();
670
+ const remoteBaseUrl = await findExistingCanvasServer(workspaceRoot, port);
671
+ if (remoteBaseUrl) return new RemoteCanvasAccess(remoteBaseUrl);
672
+
673
+ const canvas = createCanvas({ port });
674
+ await canvas.start({ open: true });
675
+ return new LocalCanvasAccess(canvas, workspaceRoot, port);
676
+ }