pmx-canvas 0.1.30 → 0.1.32
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/CHANGELOG.md +117 -0
- package/dist/canvas/global.css +56 -59
- package/dist/canvas/index.js +59 -59
- package/dist/json-render/index.js +97 -97
- package/dist/types/client/nodes/surface-url.d.ts +7 -0
- package/dist/types/client/state/canvas-store.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +7 -0
- package/dist/types/json-render/renderer/index.d.ts +1 -0
- package/dist/types/json-render/server.d.ts +1 -0
- package/dist/types/server/ax-context.d.ts +24 -1
- package/dist/types/server/canvas-state.d.ts +7 -0
- package/dist/types/server/html-surface.d.ts +29 -0
- package/dist/types/server/index.d.ts +19 -3
- package/dist/types/server/server.d.ts +12 -0
- package/docs/sdk.md +3 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +96 -1
- package/src/cli/agent.ts +18 -1
- package/src/cli/index.ts +8 -1
- package/src/client/App.tsx +3 -3
- package/src/client/canvas/CanvasNode.tsx +16 -1
- package/src/client/canvas/DockedNode.tsx +38 -38
- package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -1
- package/src/client/nodes/ContextNode.tsx +1 -1
- package/src/client/nodes/HtmlNode.tsx +26 -1
- package/src/client/nodes/McpAppNode.tsx +35 -8
- package/src/client/nodes/StatusNode.tsx +0 -20
- package/src/client/nodes/surface-url.ts +12 -0
- package/src/client/state/canvas-store.ts +4 -0
- package/src/client/state/intent-bridge.ts +19 -0
- package/src/client/state/sse-bridge.ts +17 -0
- package/src/client/theme/global.css +56 -59
- package/src/json-render/renderer/index.tsx +31 -2
- package/src/json-render/server.ts +3 -0
- package/src/mcp/canvas-access.ts +6 -1
- package/src/mcp/server.ts +23 -1
- package/src/server/ax-context.ts +49 -1
- package/src/server/ax-interaction.ts +3 -0
- package/src/server/ax-state.ts +3 -1
- package/src/server/canvas-state.ts +30 -11
- package/src/server/html-surface.ts +70 -13
- package/src/server/index.ts +32 -7
- package/src/server/server.ts +117 -4
package/src/server/ax-context.ts
CHANGED
|
@@ -1,7 +1,55 @@
|
|
|
1
1
|
import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildAxContext,
|
|
4
|
+
type PmxAxContext,
|
|
5
|
+
type PmxAxPinnedContext,
|
|
6
|
+
type PmxAxWorkItem,
|
|
7
|
+
type PmxAxApprovalGate,
|
|
8
|
+
type PmxAxReviewAnnotation,
|
|
9
|
+
type PmxAxElicitation,
|
|
10
|
+
type PmxAxModeRequest,
|
|
11
|
+
type PmxAxPolicy,
|
|
12
|
+
} from './ax-state.js';
|
|
3
13
|
import { canvasState, type CanvasNodeState } from './canvas-state.js';
|
|
4
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Compact, surface-safe view of the canvas-bound AX state, injected into (and
|
|
17
|
+
* pushed to) AX-enabled surfaces so authored boards can RENDER the work queue /
|
|
18
|
+
* focus, not just emit interactions. Deliberately excludes the timeline, pinned
|
|
19
|
+
* preamble, and serialized node bodies to keep the payload small.
|
|
20
|
+
*/
|
|
21
|
+
export interface PmxAxSurfaceSnapshot {
|
|
22
|
+
focus: string[];
|
|
23
|
+
workItems: PmxAxWorkItem[];
|
|
24
|
+
approvalGates: PmxAxApprovalGate[];
|
|
25
|
+
// Free-text human fields (`body`, `author`) are redacted — a surface gets review
|
|
26
|
+
// status/severity/anchor for a review board, but not raw human comment text.
|
|
27
|
+
reviewAnnotations: Array<Omit<PmxAxReviewAnnotation, 'body' | 'author'>>;
|
|
28
|
+
elicitations: PmxAxElicitation[];
|
|
29
|
+
modeRequests: PmxAxModeRequest[];
|
|
30
|
+
policy: PmxAxPolicy;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* NOTE: this is whole-canvas AX state (every work item, etc.), exposed to ANY
|
|
35
|
+
* AX-enabled surface — reads are board-wide while emits are node-scoped. Acceptable
|
|
36
|
+
* under the single-workspace local-trust model, but author surfaces accordingly
|
|
37
|
+
* (don't embed untrusted third-party scripts in an AX-enabled surface). Sensitive
|
|
38
|
+
* human review text is redacted below.
|
|
39
|
+
*/
|
|
40
|
+
export function buildCanvasAxSurfaceSnapshot(): PmxAxSurfaceSnapshot {
|
|
41
|
+
const ax = canvasState.getAxState();
|
|
42
|
+
return {
|
|
43
|
+
focus: ax.focus.nodeIds,
|
|
44
|
+
workItems: ax.workItems,
|
|
45
|
+
approvalGates: ax.approvalGates,
|
|
46
|
+
reviewAnnotations: ax.reviewAnnotations.map(({ body: _body, author: _author, ...rest }) => rest),
|
|
47
|
+
elicitations: ax.elicitations,
|
|
48
|
+
modeRequests: ax.modeRequests,
|
|
49
|
+
policy: ax.policy,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
5
53
|
function serializeNodes(nodes: CanvasNodeState[]) {
|
|
6
54
|
return nodes.map((node) => serializeNodeForAgentContext(node, {
|
|
7
55
|
defaultTextLength: 700,
|
|
@@ -500,6 +500,9 @@ export function applyAxInteraction(
|
|
|
500
500
|
const p = payloadParsed.data as { body: string; kind?: PmxAxReviewKind; severity?: PmxAxReviewSeverity; anchorType?: PmxAxReviewAnchorType; nodeId?: string; file?: string; author?: string };
|
|
501
501
|
// Sandboxed surfaces may only review their own node; trusted surfaces may
|
|
502
502
|
// anchor to a file/region or another node.
|
|
503
|
+
// A node-interaction review carries a sourceNodeId, so it defaults to a node
|
|
504
|
+
// anchor on that source (see nodeId resolution below). Body-only/unanchored
|
|
505
|
+
// is the adapter/HTTP/MCP path (addReviewAnnotation's context-aware default).
|
|
503
506
|
const anchorType: PmxAxReviewAnchorType = scoped ? 'node' : (p.anchorType ?? 'node');
|
|
504
507
|
const reviewAnnotation = manager.addReviewAnnotation(
|
|
505
508
|
{
|
package/src/server/ax-state.ts
CHANGED
|
@@ -636,7 +636,9 @@ export function createAxReviewAnnotation(
|
|
|
636
636
|
source: PmxAxSource | null,
|
|
637
637
|
): PmxAxReviewAnnotation {
|
|
638
638
|
const now = nowIso();
|
|
639
|
-
|
|
639
|
+
// Mirror addReviewAnnotation's context-aware default so a body-only annotation
|
|
640
|
+
// (no anchorType, no nodeId) becomes an unanchored note instead of a node anchor.
|
|
641
|
+
const anchorType = input.anchorType ?? (typeof input.nodeId === 'string' && input.nodeId ? 'node' : 'file');
|
|
640
642
|
return {
|
|
641
643
|
id: axId('rev'),
|
|
642
644
|
kind: input.kind ?? 'comment',
|
|
@@ -442,18 +442,14 @@ class CanvasStateManager {
|
|
|
442
442
|
}
|
|
443
443
|
|
|
444
444
|
private normalizeNode(node: CanvasNodeState): CanvasNodeState {
|
|
445
|
-
|
|
445
|
+
// Context nodes default to a right-docked, collapsed pill (see DockedNode.tsx),
|
|
446
|
+
// but that default is applied at CREATE time only — it must not be re-forced on
|
|
447
|
+
// every write, or the node could never be undocked. Undocking (dockPosition →
|
|
448
|
+
// null) is a deliberate user action and is respected here.
|
|
449
|
+
return {
|
|
446
450
|
...node,
|
|
447
451
|
data: normalizeCanvasNodeData(node.type, node.data),
|
|
448
452
|
};
|
|
449
|
-
// Context nodes are always docked to the right side as a pill/panel widget
|
|
450
|
-
// (see DockedNode.tsx). They start collapsed so the user sees the slim
|
|
451
|
-
// pill first; expanding reveals the full context overview panel.
|
|
452
|
-
if (normalized.type === 'context' && normalized.dockPosition !== 'right') {
|
|
453
|
-
normalized.dockPosition = 'right';
|
|
454
|
-
normalized.collapsed = true;
|
|
455
|
-
}
|
|
456
|
-
return normalized;
|
|
457
453
|
}
|
|
458
454
|
|
|
459
455
|
private nodeForRead(node: CanvasNodeState): CanvasNodeState {
|
|
@@ -930,6 +926,16 @@ class CanvasStateManager {
|
|
|
930
926
|
return false;
|
|
931
927
|
}
|
|
932
928
|
|
|
929
|
+
/**
|
|
930
|
+
* Whether this workspace's canvas DB already holds saved state. Used to gate
|
|
931
|
+
* brand-new-workspace seeding (e.g. the default docked status/context widgets)
|
|
932
|
+
* so we never add nodes to a canvas that already has content. Reflects the
|
|
933
|
+
* pre-run persisted flag until the next save.
|
|
934
|
+
*/
|
|
935
|
+
hasPersistedState(): boolean {
|
|
936
|
+
return this._db ? isDbPopulated(this._db) : false;
|
|
937
|
+
}
|
|
938
|
+
|
|
933
939
|
/** Debounced save — coalesces rapid mutations into a single write. */
|
|
934
940
|
private scheduleSave(): void {
|
|
935
941
|
if (!this._db) return;
|
|
@@ -1343,7 +1349,15 @@ class CanvasStateManager {
|
|
|
1343
1349
|
}
|
|
1344
1350
|
|
|
1345
1351
|
addNode(node: CanvasNodeState): void {
|
|
1346
|
-
|
|
1352
|
+
// Context nodes default to a right-docked, collapsed pill when created without
|
|
1353
|
+
// an explicit dock position. CREATE-time default only — once placed, updates
|
|
1354
|
+
// (including undock → dockPosition null) are respected (see normalizeNode).
|
|
1355
|
+
// Skip during suppressed replay (undo/redo re-add) so a deliberately-undocked
|
|
1356
|
+
// context node is restored verbatim instead of being snapped back to the dock.
|
|
1357
|
+
const seeded = node.type === 'context' && node.dockPosition == null && this._suppressRecordingDepth === 0
|
|
1358
|
+
? { ...node, dockPosition: 'right' as const, collapsed: true }
|
|
1359
|
+
: node;
|
|
1360
|
+
const cloned = structuredClone(this.normalizeNode(seeded));
|
|
1347
1361
|
this.nodes.set(node.id, cloned);
|
|
1348
1362
|
this.scheduleSave();
|
|
1349
1363
|
this.notifyChange('nodes');
|
|
@@ -1874,7 +1888,12 @@ class CanvasStateManager {
|
|
|
1874
1888
|
// normalizeAxForCurrentNodes after apply, yet still returned as a phantom
|
|
1875
1889
|
// success object — false success / silent data loss. Reject instead so the
|
|
1876
1890
|
// HTTP/MCP layers surface ok:false / 4xx.
|
|
1877
|
-
|
|
1891
|
+
// Context-aware default: only fall back to a node anchor when a usable nodeId
|
|
1892
|
+
// is present; otherwise treat it as an unanchored (body-only) note so a
|
|
1893
|
+
// `{ body }`-only annotation succeeds (anchorType is documented optional).
|
|
1894
|
+
const anchorType = input.anchorType ?? (typeof input.nodeId === 'string' && input.nodeId ? 'node' : 'file');
|
|
1895
|
+
// An EXPLICIT node anchor still requires a real nodeId — reject a phantom
|
|
1896
|
+
// node-anchored review rather than silently dropping it post-apply.
|
|
1878
1897
|
if (anchorType === 'node' && (typeof input.nodeId !== 'string' || !this.currentNodeIdSet().has(input.nodeId))) {
|
|
1879
1898
|
return null;
|
|
1880
1899
|
}
|
|
@@ -107,27 +107,62 @@ function injectIntoHead(html: string, content: string): string {
|
|
|
107
107
|
* the server re-validates every interaction — so this is a convenience surface,
|
|
108
108
|
* not a trust boundary.
|
|
109
109
|
*/
|
|
110
|
-
function buildAxBridge(axToken: string, nodeId: string): string {
|
|
110
|
+
export function buildAxBridge(axToken: string, nodeId: string): string {
|
|
111
111
|
const token = JSON.stringify(axToken);
|
|
112
112
|
const node = JSON.stringify(nodeId);
|
|
113
113
|
return `<script data-pmx-canvas-ax-bridge>
|
|
114
114
|
const PMX_AX_TOKEN = ${token};
|
|
115
115
|
const PMX_AX_NODE_ID = ${node};
|
|
116
|
-
window.PMX_AX = {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
},
|
|
116
|
+
window.PMX_AX = window.PMX_AX || {};
|
|
117
|
+
window.PMX_AX.emit = function (type, payload) {
|
|
118
|
+
window.parent.postMessage({
|
|
119
|
+
source: 'pmx-canvas-ax',
|
|
120
|
+
token: PMX_AX_TOKEN,
|
|
121
|
+
nodeId: PMX_AX_NODE_ID,
|
|
122
|
+
interaction: { type: String(type), payload: payload && typeof payload === 'object' ? payload : {} },
|
|
123
|
+
}, '*');
|
|
125
124
|
};
|
|
126
125
|
</script>`;
|
|
127
126
|
}
|
|
128
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Read-side bridge: seeds `window.PMX_AX.state` with a snapshot of the canvas AX
|
|
130
|
+
* state and keeps it live via nonce-validated `ax-update` messages from the parent
|
|
131
|
+
* canvas. Author HTML can read `window.PMX_AX.state` and subscribe to the
|
|
132
|
+
* `pmx-ax-update` CustomEvent to render a live work queue / focus. Injected only
|
|
133
|
+
* alongside the emit bridge (AX-enabled nodes). Read-only — no capability beyond
|
|
134
|
+
* the existing AX-enabled gate.
|
|
135
|
+
*/
|
|
136
|
+
export function buildAxStateBridge(axToken: string, snapshotJson: string): string {
|
|
137
|
+
const token = JSON.stringify(axToken);
|
|
138
|
+
return `<script data-pmx-canvas-ax-state-bridge>
|
|
139
|
+
(function () {
|
|
140
|
+
const PMX_AX_STATE_TOKEN = ${token};
|
|
141
|
+
window.PMX_AX = window.PMX_AX || {};
|
|
142
|
+
window.PMX_AX.state = ${snapshotJson};
|
|
143
|
+
window.addEventListener('message', function (event) {
|
|
144
|
+
const m = event.data;
|
|
145
|
+
if (!m || m.source !== 'pmx-canvas-html-node' || m.type !== 'ax-update' || m.token !== PMX_AX_STATE_TOKEN) return;
|
|
146
|
+
window.PMX_AX.state = m.state;
|
|
147
|
+
try { window.dispatchEvent(new CustomEvent('pmx-ax-update', { detail: m.state })); } catch (e) {}
|
|
148
|
+
});
|
|
149
|
+
})();
|
|
150
|
+
</script>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Escape a string for safe interpolation into element text (e.g. `<title>`). */
|
|
154
|
+
function escapeSurfaceHtml(value: string): string {
|
|
155
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
156
|
+
}
|
|
157
|
+
|
|
129
158
|
export interface HtmlSurfaceOptions {
|
|
130
159
|
theme: SurfaceTheme;
|
|
160
|
+
/**
|
|
161
|
+
* Tab/document title. Injected as `<title>` only when the author HTML does not
|
|
162
|
+
* already declare one, so a standalone "Open as site" tab shows the node title
|
|
163
|
+
* instead of falling back to the raw URL.
|
|
164
|
+
*/
|
|
165
|
+
title?: string;
|
|
131
166
|
/** Client nonce that authorizes parent → iframe theme-update messages. */
|
|
132
167
|
themeToken?: string;
|
|
133
168
|
presentation?: boolean;
|
|
@@ -138,6 +173,11 @@ export interface HtmlSurfaceOptions {
|
|
|
138
173
|
axToken?: string;
|
|
139
174
|
/** Node id stamped on emitted interactions. */
|
|
140
175
|
nodeId?: string;
|
|
176
|
+
/**
|
|
177
|
+
* Initial AX state snapshot to seed `window.PMX_AX.state` (only used when
|
|
178
|
+
* axBridge is enabled). Kept live via parent → iframe `ax-update` messages.
|
|
179
|
+
*/
|
|
180
|
+
axState?: unknown;
|
|
141
181
|
}
|
|
142
182
|
|
|
143
183
|
/**
|
|
@@ -154,17 +194,34 @@ export function buildHtmlSurfaceDocument(userHtml: string, options: HtmlSurfaceO
|
|
|
154
194
|
const axBridge = options.axBridge
|
|
155
195
|
? buildAxBridge(sanitizeToken(options.axToken), sanitizeToken(options.nodeId))
|
|
156
196
|
: '';
|
|
157
|
-
|
|
197
|
+
// Read-side AX state bridge (seed + live push). `</` is escaped so a work-item
|
|
198
|
+
// title containing "</script>" can't break out of the inline script.
|
|
199
|
+
const axStateBridge = options.axBridge
|
|
200
|
+
? buildAxStateBridge(
|
|
201
|
+
sanitizeToken(options.axToken),
|
|
202
|
+
options.axState !== undefined ? JSON.stringify(options.axState).replace(/</g, '\\u003c') : 'null',
|
|
203
|
+
)
|
|
204
|
+
: '';
|
|
205
|
+
const injectedHeadContent = `${link}${themeBridge}${presentationBridge}${axBridge}${axStateBridge}`;
|
|
158
206
|
const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
|
|
159
207
|
const trimmed = userHtml.trim();
|
|
160
208
|
const isFullDoc = /<html[\s>]/i.test(trimmed);
|
|
209
|
+
// Only supply a fallback <title> when the author HTML does not already set a
|
|
210
|
+
// DOCUMENT title. Strip inline <svg>/<math> first so a nested accessibility
|
|
211
|
+
// <title> (e.g. <svg><title>…</title></svg>) doesn't suppress the fallback.
|
|
212
|
+
const withoutNestedTitles = trimmed
|
|
213
|
+
.replace(/<svg[\s\S]*?<\/svg>/gi, '')
|
|
214
|
+
.replace(/<math[\s\S]*?<\/math>/gi, '');
|
|
215
|
+
const titleTag = options.title && !/<title[\s>]/i.test(withoutNestedTitles)
|
|
216
|
+
? `<title>${escapeSurfaceHtml(options.title)}</title>`
|
|
217
|
+
: '';
|
|
161
218
|
if (isFullDoc) {
|
|
162
219
|
const withTheme = trimmed.replace(
|
|
163
220
|
/<html([^>]*)>/i,
|
|
164
221
|
`<html$1 data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}>`,
|
|
165
222
|
);
|
|
166
|
-
return injectIntoHead(withTheme, injectedHeadContent);
|
|
223
|
+
return injectIntoHead(withTheme, `${titleTag}${injectedHeadContent}`);
|
|
167
224
|
}
|
|
168
225
|
// Fragment — wrap in a full document.
|
|
169
|
-
return `<!doctype html><html data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}><head><meta charset="utf-8">${injectedHeadContent}</head><body>${userHtml}</body></html>`;
|
|
226
|
+
return `<!doctype html><html data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}><head><meta charset="utf-8">${titleTag}${injectedHeadContent}</head><body>${userHtml}</body></html>`;
|
|
170
227
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
} from './canvas-operations.js';
|
|
75
75
|
import { validateCanvasLayout } from './canvas-validation.js';
|
|
76
76
|
import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
|
|
77
|
+
import { serializeCanvasNode, type SerializedCanvasNode } from './canvas-serialization.js';
|
|
77
78
|
import { buildHtmlPrimitive, getHtmlPrimitiveSemanticMetadata, isHtmlPrimitiveKind, listHtmlPrimitiveDescriptors } from './html-primitives.js';
|
|
78
79
|
import type { HtmlPrimitiveKind } from './html-primitives.js';
|
|
79
80
|
import {
|
|
@@ -124,6 +125,19 @@ import type {
|
|
|
124
125
|
PrimaryWorkbenchIntent,
|
|
125
126
|
} from './server.js';
|
|
126
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Node object returned by the SDK's create/get methods. It is the fully
|
|
130
|
+
* serialized node (adds `surfaceUrl`, `kind`, `title`, `content`, …) plus a
|
|
131
|
+
* `nodeId` alias for `id`, so the SDK return shape matches the HTTP/CLI
|
|
132
|
+
* `node`-create responses field-for-field.
|
|
133
|
+
*/
|
|
134
|
+
export type SdkCanvasNode = SerializedCanvasNode & { nodeId: string };
|
|
135
|
+
|
|
136
|
+
/** Enrich a raw canvas node into the SDK return shape (surfaceUrl + nodeId). */
|
|
137
|
+
function toSdkNode(node: CanvasNodeState): SdkCanvasNode {
|
|
138
|
+
return { ...serializeCanvasNode(node), nodeId: node.id };
|
|
139
|
+
}
|
|
140
|
+
|
|
127
141
|
export class PmxCanvas extends EventEmitter {
|
|
128
142
|
private _port: number;
|
|
129
143
|
private _server: string | null = null;
|
|
@@ -224,7 +238,7 @@ export class PmxCanvas extends EventEmitter {
|
|
|
224
238
|
width?: number;
|
|
225
239
|
height?: number;
|
|
226
240
|
strictSize?: boolean;
|
|
227
|
-
}):
|
|
241
|
+
}): SdkCanvasNode {
|
|
228
242
|
if (input.type === 'webpage') {
|
|
229
243
|
throw new Error('Use addWebpageNode for webpage nodes so page content is fetched and cached on the server.');
|
|
230
244
|
}
|
|
@@ -241,7 +255,7 @@ export class PmxCanvas extends EventEmitter {
|
|
|
241
255
|
});
|
|
242
256
|
const groupNode = canvasState.getNode(groupId);
|
|
243
257
|
if (!groupNode) throw new Error(`Group node "${groupId}" was not created.`);
|
|
244
|
-
return groupNode;
|
|
258
|
+
return toSdkNode(groupNode);
|
|
245
259
|
}
|
|
246
260
|
const { id, needsCodeGraphRecompute } = addCanvasNode({
|
|
247
261
|
...input,
|
|
@@ -277,7 +291,7 @@ export class PmxCanvas extends EventEmitter {
|
|
|
277
291
|
|
|
278
292
|
const node = canvasState.getNode(id);
|
|
279
293
|
if (!node) throw new Error(`Node "${id}" was not created.`);
|
|
280
|
-
return node;
|
|
294
|
+
return toSdkNode(node);
|
|
281
295
|
}
|
|
282
296
|
|
|
283
297
|
async addWebpageNode(input: {
|
|
@@ -735,8 +749,9 @@ export class PmxCanvas extends EventEmitter {
|
|
|
735
749
|
return canvasState.getLayout();
|
|
736
750
|
}
|
|
737
751
|
|
|
738
|
-
getNode(id: string):
|
|
739
|
-
|
|
752
|
+
getNode(id: string): SdkCanvasNode | undefined {
|
|
753
|
+
const node = canvasState.getNode(id);
|
|
754
|
+
return node ? toSdkNode(node) : undefined;
|
|
740
755
|
}
|
|
741
756
|
|
|
742
757
|
search(query: string): ReturnType<typeof searchNodes> {
|
|
@@ -753,6 +768,9 @@ export class PmxCanvas extends EventEmitter {
|
|
|
753
768
|
if (!entry) return { ok: false, description: 'Nothing to undo' };
|
|
754
769
|
await syncCanvasRuntimeBackends();
|
|
755
770
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
771
|
+
// Undo can reverse an AX mutation (work item, focus, …); nudge AX surfaces to
|
|
772
|
+
// re-fetch so a live board reflects the reversal (debounced client-side).
|
|
773
|
+
emitPrimaryWorkbenchEvent('ax-state-changed', {});
|
|
756
774
|
return { ok: true, description: `Undid: ${entry.description}` };
|
|
757
775
|
}
|
|
758
776
|
|
|
@@ -761,6 +779,7 @@ export class PmxCanvas extends EventEmitter {
|
|
|
761
779
|
if (!entry) return { ok: false, description: 'Nothing to redo' };
|
|
762
780
|
await syncCanvasRuntimeBackends();
|
|
763
781
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
782
|
+
emitPrimaryWorkbenchEvent('ax-state-changed', {});
|
|
764
783
|
return { ok: true, description: `Redid: ${entry.description}` };
|
|
765
784
|
}
|
|
766
785
|
|
|
@@ -1027,7 +1046,10 @@ export class PmxCanvas extends EventEmitter {
|
|
|
1027
1046
|
width?: number;
|
|
1028
1047
|
height?: number;
|
|
1029
1048
|
strictSize?: boolean;
|
|
1030
|
-
|
|
1049
|
+
/** Opt this html node into AX interactions (window.PMX_AX.emit). Clamped to
|
|
1050
|
+
* the html capability ceiling server-side; cannot escalate. */
|
|
1051
|
+
axCapabilities?: { enabled?: boolean; allowed?: string[] };
|
|
1052
|
+
}): SdkCanvasNode {
|
|
1031
1053
|
const { id } = addCanvasNode({
|
|
1032
1054
|
type: 'html',
|
|
1033
1055
|
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
@@ -1040,6 +1062,7 @@ export class PmxCanvas extends EventEmitter {
|
|
|
1040
1062
|
...(Array.isArray(input.slideTitles) ? { slideTitles: input.slideTitles } : {}),
|
|
1041
1063
|
...(Array.isArray(input.embeddedNodeIds) ? { embeddedNodeIds: input.embeddedNodeIds } : {}),
|
|
1042
1064
|
...(Array.isArray(input.embeddedUrls) ? { embeddedUrls: input.embeddedUrls } : {}),
|
|
1065
|
+
...(input.axCapabilities ? { axCapabilities: input.axCapabilities } : {}),
|
|
1043
1066
|
},
|
|
1044
1067
|
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
1045
1068
|
...(typeof input.y === 'number' ? { y: input.y } : {}),
|
|
@@ -1050,7 +1073,9 @@ export class PmxCanvas extends EventEmitter {
|
|
|
1050
1073
|
defaultHeight: 640,
|
|
1051
1074
|
});
|
|
1052
1075
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1053
|
-
|
|
1076
|
+
const node = canvasState.getNode(id);
|
|
1077
|
+
if (!node) throw new Error(`HTML node "${id}" was not created.`);
|
|
1078
|
+
return toSdkNode(node);
|
|
1054
1079
|
}
|
|
1055
1080
|
|
|
1056
1081
|
addHtmlPrimitive(input: {
|
package/src/server/server.ts
CHANGED
|
@@ -48,7 +48,7 @@ import type {
|
|
|
48
48
|
ListToolsResult,
|
|
49
49
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
50
50
|
import { type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
|
|
51
|
-
import { buildHtmlSurfaceDocument, HTML_SURFACE_SANDBOX, normalizeSurfaceTheme } from './html-surface.js';
|
|
51
|
+
import { buildAxBridge, buildAxStateBridge, buildHtmlSurfaceDocument, HTML_SURFACE_SANDBOX, normalizeSurfaceTheme } from './html-surface.js';
|
|
52
52
|
import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
|
|
53
53
|
import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
|
|
54
54
|
import { getMcpAppHostSnapshot } from './mcp-app-host.js';
|
|
@@ -77,7 +77,7 @@ import {
|
|
|
77
77
|
} from './canvas-serialization.js';
|
|
78
78
|
import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
|
|
79
79
|
import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
|
|
80
|
-
import { buildCanvasAxContext } from './ax-context.js';
|
|
80
|
+
import { buildCanvasAxContext, buildCanvasAxSurfaceSnapshot } from './ax-context.js';
|
|
81
81
|
import { applyAxInteraction, resolveNodeAxCapabilities } from './ax-interaction.js';
|
|
82
82
|
import { isAxEventKind, isAxEvidenceKind } from './ax-state.js';
|
|
83
83
|
import type {
|
|
@@ -1432,14 +1432,21 @@ function handleNodeSurface(pathname: string, url: URL): Response {
|
|
|
1432
1432
|
if (!html) return responseText('HTML node has no content', 404);
|
|
1433
1433
|
const present = url.searchParams.get('present') === '1';
|
|
1434
1434
|
const axCaps = resolveNodeAxCapabilities(node);
|
|
1435
|
+
const axEnabled = axCaps.enabled && axCaps.allowed.length > 0;
|
|
1436
|
+
const surfaceTitle = typeof node.data.title === 'string' && node.data.title.trim()
|
|
1437
|
+
? node.data.title
|
|
1438
|
+
: node.id;
|
|
1435
1439
|
const doc = buildHtmlSurfaceDocument(html, {
|
|
1436
1440
|
theme,
|
|
1441
|
+
title: surfaceTitle,
|
|
1437
1442
|
themeToken: url.searchParams.get('themeToken') ?? undefined,
|
|
1438
1443
|
presentation: present,
|
|
1439
1444
|
presentationExitToken: url.searchParams.get('presentToken') ?? undefined,
|
|
1440
|
-
axBridge:
|
|
1445
|
+
axBridge: axEnabled,
|
|
1441
1446
|
axToken: url.searchParams.get('axToken') ?? undefined,
|
|
1442
1447
|
nodeId: node.id,
|
|
1448
|
+
// Seed the read-side bridge with the current AX state (only for AX surfaces).
|
|
1449
|
+
...(axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
|
|
1443
1450
|
});
|
|
1444
1451
|
return surfaceHtmlResponse(doc, HTML_SURFACE_SANDBOX);
|
|
1445
1452
|
}
|
|
@@ -2532,6 +2539,7 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
|
|
|
2532
2539
|
process.env.PMX_CANVAS_JSON_RENDER_DEVTOOLS === '1' &&
|
|
2533
2540
|
url.searchParams.get('devtools') === '1';
|
|
2534
2541
|
const axToken = url.searchParams.get('axToken');
|
|
2542
|
+
const axEnabled = resolveNodeAxCapabilities(node).enabled;
|
|
2535
2543
|
const html = await buildJsonRenderViewerHtml({
|
|
2536
2544
|
title,
|
|
2537
2545
|
spec,
|
|
@@ -2539,6 +2547,8 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
|
|
|
2539
2547
|
...(url.searchParams.get('display') === 'expanded' ? { display: 'expanded' as const } : {}),
|
|
2540
2548
|
...(devtoolsEnabled ? { devtools: true } : {}),
|
|
2541
2549
|
...(axToken ? { nodeId, axToken } : {}),
|
|
2550
|
+
// Seed the read-side AX state (only for AX-enabled nodes) so specs can bind /ax.
|
|
2551
|
+
...(axToken && axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
|
|
2542
2552
|
});
|
|
2543
2553
|
return new Response(html, {
|
|
2544
2554
|
headers: {
|
|
@@ -2596,7 +2606,27 @@ function handleArtifactView(url: URL): Response {
|
|
|
2596
2606
|
}
|
|
2597
2607
|
|
|
2598
2608
|
if (ext === '.html' || ext === '.htm') {
|
|
2599
|
-
|
|
2609
|
+
let content = readFileSync(safePath, 'utf-8');
|
|
2610
|
+
// AX bridge for web-artifacts (same opaque-origin postMessage bridge as html
|
|
2611
|
+
// surfaces — a sandboxed artifact can't fetch the API directly). The viewer
|
|
2612
|
+
// appends axToken + axNodeId only for AX-enabled artifacts; the server still
|
|
2613
|
+
// re-validates every interaction.
|
|
2614
|
+
const axToken = url.searchParams.get('axToken');
|
|
2615
|
+
const axNodeId = url.searchParams.get('axNodeId');
|
|
2616
|
+
if (axToken && axNodeId) {
|
|
2617
|
+
const node = canvasState.getNode(axNodeId);
|
|
2618
|
+
if (node && resolveNodeAxCapabilities(node).enabled) {
|
|
2619
|
+
const safeToken = axToken.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80);
|
|
2620
|
+
// Use the canonical node.id (server-generated [a-z0-9-]) rather than the raw
|
|
2621
|
+
// query param so nothing untrusted reaches the inline bridge script.
|
|
2622
|
+
const safeNodeId = node.id.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80);
|
|
2623
|
+
const stateJson = JSON.stringify(buildCanvasAxSurfaceSnapshot()).replace(/</g, '\\u003c');
|
|
2624
|
+
const bridge = `${buildAxBridge(safeToken, safeNodeId)}${buildAxStateBridge(safeToken, stateJson)}`;
|
|
2625
|
+
content = content.includes('</head>')
|
|
2626
|
+
? content.replace('</head>', `${bridge}</head>`)
|
|
2627
|
+
: `${bridge}${content}`;
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2600
2630
|
return new Response(content, {
|
|
2601
2631
|
headers: {
|
|
2602
2632
|
'Content-Type': 'text/html; charset=utf-8',
|
|
@@ -3823,6 +3853,30 @@ function handleGetAxContext(): Response {
|
|
|
3823
3853
|
return responseJson(buildCanvasAxContext());
|
|
3824
3854
|
}
|
|
3825
3855
|
|
|
3856
|
+
// Compact AX state for surfaces (the same shape seeded into AX-enabled iframes).
|
|
3857
|
+
// The client fetches this and pushes it to surfaces over the ax-update channel.
|
|
3858
|
+
function handleGetAxSurfaceSnapshot(): Response {
|
|
3859
|
+
return responseJson(buildCanvasAxSurfaceSnapshot());
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
// Open a node's surface in the user's real system browser (for hosts whose
|
|
3863
|
+
// embedded browser makes window.open('_blank') feel in-place, e.g. Codex).
|
|
3864
|
+
// Accepts ONLY { nodeId } and opens this server's own surface URL — never an
|
|
3865
|
+
// arbitrary URL — so it can't be used to launch external sites (no SSRF). Honors
|
|
3866
|
+
// the PMX_CANVAS_DISABLE_BROWSER_OPEN kill switch via openUrlInExternalBrowser.
|
|
3867
|
+
async function handleOpenExternalSurface(req: Request): Promise<Response> {
|
|
3868
|
+
const body = await readJson(req);
|
|
3869
|
+
const nodeId = typeof body.nodeId === 'string' ? body.nodeId : '';
|
|
3870
|
+
if (!nodeId) return responseJson({ ok: false, error: 'nodeId is required.' }, 400);
|
|
3871
|
+
const node = canvasState.getNode(nodeId);
|
|
3872
|
+
if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
|
|
3873
|
+
const port = getCanvasServerPort();
|
|
3874
|
+
if (!port) return responseJson({ ok: false, opened: false, error: 'Server port unavailable.' }, 503);
|
|
3875
|
+
const surfacePath = `/api/canvas/surface/${encodeURIComponent(nodeId)}`;
|
|
3876
|
+
const opened = openUrlInExternalBrowser(`http://localhost:${port}${surfacePath}`);
|
|
3877
|
+
return responseJson({ ok: true, opened, url: surfacePath });
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3826
3880
|
async function handleAxInteraction(req: Request): Promise<Response> {
|
|
3827
3881
|
const body = await readJson(req);
|
|
3828
3882
|
const { result, events } = applyAxInteraction(canvasState, body, normalizeAxSource(body.source, 'api'));
|
|
@@ -4482,6 +4536,57 @@ function syncContextNodeToCanvasState(
|
|
|
4482
4536
|
canvasState.updateNode(id, { data: mergedData });
|
|
4483
4537
|
}
|
|
4484
4538
|
|
|
4539
|
+
/**
|
|
4540
|
+
* Seed the docked status (left) + context (right) widgets so a freshly opened
|
|
4541
|
+
* canvas shows them by default — the same nodes the agent-event path creates on
|
|
4542
|
+
* demand (`status-main`, `context-main`), just present from the start.
|
|
4543
|
+
*
|
|
4544
|
+
* First-run only: we bail if the workspace canvas already has persisted state,
|
|
4545
|
+
* so we never add them to a board with content, and — because first-run state is
|
|
4546
|
+
* persisted on save — deleting or undocking them later is respected (they are
|
|
4547
|
+
* not re-seeded). Create-if-missing keeps it idempotent if the agent path
|
|
4548
|
+
* already made one. Returns true if anything was seeded.
|
|
4549
|
+
*/
|
|
4550
|
+
export function ensureDefaultDockedNodes(): boolean {
|
|
4551
|
+
if (canvasState.hasPersistedState()) return false;
|
|
4552
|
+
let seeded = false;
|
|
4553
|
+
// NOTE: these node specs mirror the agent-event create paths below
|
|
4554
|
+
// (`canvas-status` for status-main, `syncContextNodeToCanvasState` for
|
|
4555
|
+
// context-main) — keep geometry/dock defaults in sync if you change them.
|
|
4556
|
+
if (!canvasState.getNode('status-main')) {
|
|
4557
|
+
canvasState.addNode({
|
|
4558
|
+
id: 'status-main',
|
|
4559
|
+
type: 'status',
|
|
4560
|
+
position: { x: 40, y: 80 },
|
|
4561
|
+
size: { width: 300, height: 120 },
|
|
4562
|
+
zIndex: 0,
|
|
4563
|
+
collapsed: true,
|
|
4564
|
+
pinned: false,
|
|
4565
|
+
dockPosition: 'left',
|
|
4566
|
+
data: { phase: 'idle', message: '', elapsed: 0 },
|
|
4567
|
+
});
|
|
4568
|
+
seeded = true;
|
|
4569
|
+
}
|
|
4570
|
+
if (!canvasState.getNode('context-main')) {
|
|
4571
|
+
canvasState.addNode({
|
|
4572
|
+
id: 'context-main',
|
|
4573
|
+
type: 'context',
|
|
4574
|
+
position: { x: 1130, y: 80 },
|
|
4575
|
+
size: { width: 320, height: 400 },
|
|
4576
|
+
zIndex: 1,
|
|
4577
|
+
collapsed: true,
|
|
4578
|
+
pinned: false,
|
|
4579
|
+
dockPosition: 'right',
|
|
4580
|
+
data: { cards: [], auxTabs: [] },
|
|
4581
|
+
});
|
|
4582
|
+
seeded = true;
|
|
4583
|
+
}
|
|
4584
|
+
if (seeded) {
|
|
4585
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
4586
|
+
}
|
|
4587
|
+
return seeded;
|
|
4588
|
+
}
|
|
4589
|
+
|
|
4485
4590
|
// Maps responseNodeId -> thread prompt node ID for O(1) routing of response events
|
|
4486
4591
|
const serverResponseToThreadMap = new Map<string, string>();
|
|
4487
4592
|
|
|
@@ -5249,6 +5354,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
5249
5354
|
return handleGetAxContext();
|
|
5250
5355
|
}
|
|
5251
5356
|
|
|
5357
|
+
if (url.pathname === '/api/canvas/ax/surface-snapshot' && req.method === 'GET') {
|
|
5358
|
+
return handleGetAxSurfaceSnapshot();
|
|
5359
|
+
}
|
|
5360
|
+
|
|
5361
|
+
if (url.pathname === '/api/canvas/open-external' && req.method === 'POST') {
|
|
5362
|
+
return handleOpenExternalSurface(req);
|
|
5363
|
+
}
|
|
5364
|
+
|
|
5252
5365
|
if (url.pathname === '/api/canvas/ax/focus' && req.method === 'POST') {
|
|
5253
5366
|
return handleAxFocusUpdate(req);
|
|
5254
5367
|
}
|