pmx-canvas 0.1.33 → 0.1.34

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.
@@ -5,6 +5,7 @@ import { buildAppHtml } from '@json-render/mcp/build-app-html';
5
5
  import { applySpecPatch, parseSpecStreamLine, type Spec, type SpecStreamLine } from '@json-render/core';
6
6
  import { allComponentDefinitions, catalog, validateShadcnElementProps, type JsonRenderIssue } from './catalog.js';
7
7
  import { findUnknownDirectiveKey, isDynamicPropValue } from './directives.js';
8
+ import { contentHeightReporterTag } from '../shared/content-height-reporter.js';
8
9
 
9
10
  export interface JsonRenderSpec {
10
11
  root: string;
@@ -944,6 +945,12 @@ export async function buildJsonRenderViewerHtml(options: {
944
945
  nodeId?: string;
945
946
  axToken?: string;
946
947
  axState?: unknown;
948
+ /** Nonce for the content-height reporter so the node can grow to fit the chart. */
949
+ frameToken?: string;
950
+ /** When true, charts render at their natural (intrinsic) height instead of
951
+ * filling the viewport down — so the reported scrollHeight is stable and the
952
+ * node grows to it. Off for strictSize / user-resized nodes (they fill-down). */
953
+ fitContent?: boolean;
947
954
  }): Promise<string> {
948
955
  const sanitizeAxValue = (v?: string): string => (typeof v === 'string' ? v.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80) : '');
949
956
  try {
@@ -966,13 +973,18 @@ export async function buildJsonRenderViewerHtml(options: {
966
973
  // Read-side AX state: seed for initial render + bound under /ax for specs.
967
974
  `window.__PMX_CANVAS_AX_STATE__ = ${JSON.stringify(options.axState ?? null).replace(/</g, '\\u003c')};`,
968
975
  ] : []),
976
+ ...(options.fitContent ? ['window.__PMX_CANVAS_FIT_CONTENT__ = true;'] : []),
969
977
  jsBundle,
970
978
  ].join('\n');
979
+ // Content-height reporter: posts the viewer's natural scrollHeight so the
980
+ // parent node grows to fit (the #48 graph-clipping fix). Shared with the html
981
+ // surface (src/shared, no src/server import) so the two stay identical.
982
+ const heightReporter = options.frameToken ? contentHeightReporterTag(options.frameToken) : '';
971
983
  return buildAppHtml({
972
984
  title: options.title,
973
985
  css: cssBundle,
974
986
  js: escapeInlineScriptSource(boot),
975
- head: '<meta name="color-scheme" content="light dark" />',
987
+ head: `<meta name="color-scheme" content="light dark" />${heightReporter}`,
976
988
  });
977
989
  } catch (error) {
978
990
  const message = error instanceof Error ? error.message : String(error);
package/src/mcp/server.ts CHANGED
@@ -200,6 +200,56 @@ function wantsFullPayload(input: { full?: boolean; verbose?: boolean; includeDat
200
200
  return input.full === true || input.verbose === true || input.includeData === true;
201
201
  }
202
202
 
203
+ interface PendingAxActivityItem {
204
+ kind: 'work-item' | 'approval-gate' | 'elicitation' | 'mode-request';
205
+ id: string;
206
+ title: string;
207
+ status: string;
208
+ nodeIds: string[];
209
+ source: string | null;
210
+ }
211
+
212
+ const OPEN_AX_WORK_STATUSES = new Set(['todo', 'in-progress', 'blocked']);
213
+
214
+ /**
215
+ * Open, agent-actionable canvas-bound AX items (open work items + pending approval
216
+ * gates / elicitations / mode requests). Unlike steering (a directive routed through
217
+ * the claim/ack delivery queue), these are STATE the human curates in the browser —
218
+ * they fire `ax-state-changed` (so resource-subscribers are pushed canvas://ax-work),
219
+ * but an adapterless client that only POLLS the delivery surface never saw them.
220
+ * Surfacing this digest there closes report #43 without conflating state with steering.
221
+ * Optionally excludes items the consumer itself originated (loop prevention), mirroring
222
+ * getPendingSteering.
223
+ */
224
+ function buildPendingAxActivity(
225
+ state: Awaited<ReturnType<CanvasAccess['getAxState']>>,
226
+ consumer?: string,
227
+ ): PendingAxActivityItem[] {
228
+ const notMine = (source: string | null) => !consumer || source !== consumer;
229
+ const out: PendingAxActivityItem[] = [];
230
+ for (const w of state.workItems ?? []) {
231
+ if (OPEN_AX_WORK_STATUSES.has(w.status) && notMine(w.source)) {
232
+ out.push({ kind: 'work-item', id: w.id, title: w.title, status: w.status, nodeIds: w.nodeIds ?? [], source: w.source });
233
+ }
234
+ }
235
+ for (const g of state.approvalGates ?? []) {
236
+ if (g.status === 'pending' && notMine(g.source)) {
237
+ out.push({ kind: 'approval-gate', id: g.id, title: g.title, status: g.status, nodeIds: g.nodeIds ?? [], source: g.source });
238
+ }
239
+ }
240
+ for (const e of state.elicitations ?? []) {
241
+ if (e.status === 'pending' && notMine(e.source)) {
242
+ out.push({ kind: 'elicitation', id: e.id, title: e.prompt, status: e.status, nodeIds: e.nodeIds ?? [], source: e.source });
243
+ }
244
+ }
245
+ for (const m of state.modeRequests ?? []) {
246
+ if (m.status === 'pending' && notMine(m.source)) {
247
+ out.push({ kind: 'mode-request', id: m.id, title: m.reason ? `${m.mode}: ${m.reason}` : `mode: ${m.mode}`, status: m.status, nodeIds: m.nodeIds ?? [], source: m.source });
248
+ }
249
+ }
250
+ return out;
251
+ }
252
+
203
253
  function compactNodePayload(node: Awaited<ReturnType<CanvasAccess['getNode']>>): Record<string, unknown> | null {
204
254
  if (!node) return null;
205
255
  const serialized = serializeCanvasNode(node);
@@ -1688,10 +1738,10 @@ export async function startMcpServer(): Promise<void> {
1688
1738
 
1689
1739
  server.tool(
1690
1740
  'canvas_claim_ax_delivery',
1691
- 'Claim pending PMX AX steering messages for a consumer (adapterless delivery). Returns undelivered steering, excluding messages the consumer itself originated (loop prevention). After acting on a message, call canvas_mark_ax_delivery.',
1741
+ 'Claim pending PMX AX deliveries for a consumer (adapterless delivery). Returns `pending` undelivered steering (mark each with canvas_mark_ax_delivery after acting) AND `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — typically created by the human in the browser. Both exclude items the consumer itself originated (loop prevention). pendingActivity is read-only here: resolve each via its own tool (canvas_resolve_approval / canvas_respond_elicitation / canvas_resolve_mode / canvas_update_work_item), not canvas_mark_ax_delivery.',
1692
1742
  {
1693
1743
  consumer: z.string().optional().describe('Consumer/source label to exclude from results (e.g. copilot, mcp).'),
1694
- limit: z.number().optional().describe('Max messages to return.'),
1744
+ limit: z.number().optional().describe('Max steering messages to return.'),
1695
1745
  },
1696
1746
  async ({ consumer, limit }) => {
1697
1747
  const c = await ensureCanvas();
@@ -1699,7 +1749,8 @@ export async function startMcpServer(): Promise<void> {
1699
1749
  ...(consumer ? { consumer } : {}),
1700
1750
  ...(typeof limit === 'number' ? { limit } : {}),
1701
1751
  });
1702
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, pending }) }] };
1752
+ const pendingActivity = buildPendingAxActivity(await c.getAxState(), consumer);
1753
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, pending, pendingActivity }) }] };
1703
1754
  },
1704
1755
  );
1705
1756
 
@@ -2304,15 +2355,16 @@ export async function startMcpServer(): Promise<void> {
2304
2355
  'canvas://ax-pending-steering',
2305
2356
  {
2306
2357
  description:
2307
- 'Undelivered PMX AX steering messages an MCP client can claim and act on without a host-native adapter. After delivering an instruction to your agent, mark it via canvas_mark_ax_delivery so it is not handed out again.',
2358
+ 'Adapterless AX delivery surface. `pending`: undelivered steering messages to claim and act on, then mark via canvas_mark_ax_delivery. `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — usually created by the human in the browser; these fire ax-state-changed (resource-subscribers are also pushed canvas://ax-work). Resolve pendingActivity via its own tool, not canvas_mark_ax_delivery. Use canvas_claim_ax_delivery for the loop-safe, consumer-scoped view.',
2308
2359
  mimeType: 'application/json',
2309
2360
  },
2310
2361
  async () => {
2311
2362
  const c = await ensureCanvas();
2312
- const pending = await c.getPendingSteering();
2363
+ const [pending, state] = await Promise.all([c.getPendingSteering(), c.getAxState()]);
2364
+ const pendingActivity = buildPendingAxActivity(state);
2313
2365
  return {
2314
2366
  contents: [
2315
- { uri: 'canvas://ax-pending-steering', mimeType: 'application/json', text: JSON.stringify({ pending }, null, 2) },
2367
+ { uri: 'canvas://ax-pending-steering', mimeType: 'application/json', text: JSON.stringify({ pending, pendingActivity }, null, 2) },
2316
2368
  ],
2317
2369
  };
2318
2370
  },
@@ -15,6 +15,8 @@
15
15
  * postMessage required.
16
16
  */
17
17
 
18
+ import { contentHeightReporterTag } from '../shared/content-height-reporter.js';
19
+
18
20
  export type SurfaceTheme = 'dark' | 'light' | 'high-contrast';
19
21
 
20
22
  /** Path the surface document links for its theme tokens (served from dist/canvas). */
@@ -150,6 +152,16 @@ export function buildAxStateBridge(axToken: string, snapshotJson: string): strin
150
152
  </script>`;
151
153
  }
152
154
 
155
+ /**
156
+ * Reports the surface's natural content height to the parent canvas so the node
157
+ * can GROW to fit it (the fix for iframe nodes the parent can't measure — graph,
158
+ * json-render, html, web-artifact). Thin wrapper over the shared reporter so this
159
+ * and the json-render injection site stay byte-identical (no drift).
160
+ */
161
+ export function buildContentHeightReporter(frameToken: string): string {
162
+ return contentHeightReporterTag(frameToken);
163
+ }
164
+
153
165
  /** Escape a string for safe interpolation into element text (e.g. `<title>`). */
154
166
  function escapeSurfaceHtml(value: string): string {
155
167
  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -178,6 +190,8 @@ export interface HtmlSurfaceOptions {
178
190
  * axBridge is enabled). Kept live via parent → iframe `ax-update` messages.
179
191
  */
180
192
  axState?: unknown;
193
+ /** Nonce for the content-height reporter (lets the node grow to fit content). */
194
+ contentHeightToken?: string;
181
195
  }
182
196
 
183
197
  /**
@@ -202,7 +216,10 @@ export function buildHtmlSurfaceDocument(userHtml: string, options: HtmlSurfaceO
202
216
  options.axState !== undefined ? JSON.stringify(options.axState).replace(/</g, '\\u003c') : 'null',
203
217
  )
204
218
  : '';
205
- const injectedHeadContent = `${link}${themeBridge}${presentationBridge}${axBridge}${axStateBridge}`;
219
+ const contentHeightBridge = options.contentHeightToken
220
+ ? buildContentHeightReporter(sanitizeToken(options.contentHeightToken))
221
+ : '';
222
+ const injectedHeadContent = `${link}${themeBridge}${presentationBridge}${axBridge}${axStateBridge}${contentHeightBridge}`;
206
223
  const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
207
224
  const trimmed = userHtml.trim();
208
225
  const isFullDoc = /<html[\s>]/i.test(trimmed);
@@ -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 { buildAxBridge, buildAxStateBridge, buildHtmlSurfaceDocument, HTML_SURFACE_SANDBOX, normalizeSurfaceTheme } from './html-surface.js';
51
+ import { buildAxBridge, buildAxStateBridge, buildContentHeightReporter, 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';
@@ -1456,6 +1456,8 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1456
1456
  nodeId: node.id,
1457
1457
  // Seed the read-side bridge with the current AX state (only for AX surfaces).
1458
1458
  ...(axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
1459
+ // Content-height reporter nonce (lets an html node grow to fit its content).
1460
+ ...(url.searchParams.get('frameToken') ? { contentHeightToken: url.searchParams.get('frameToken') as string } : {}),
1459
1461
  });
1460
1462
  return surfaceHtmlResponse(doc, HTML_SURFACE_SANDBOX);
1461
1463
  }
@@ -2422,7 +2424,14 @@ async function handleCanvasAddGraph(req: Request): Promise<Response> {
2422
2424
  const x = pickFiniteNumber(body, 'x') ?? (position ? pickFiniteNumber(position, 'x') : undefined);
2423
2425
  const y = pickFiniteNumber(body, 'y') ?? (position ? pickFiniteNumber(position, 'y') : undefined);
2424
2426
  const width = pickPositiveNumber(body, 'width') ?? (size ? pickPositiveNumber(size, 'width') : undefined);
2425
- const nodeHeight = pickPositiveNumber(body, 'nodeHeight') ?? (size ? pickPositiveNumber(size, 'height') : undefined);
2427
+ // Node FRAME height. `body.height` is the CHART plot height (passed through as
2428
+ // `input.height` below), so the node frame accepts `nodeHeight` / `heightPx` /
2429
+ // `size.height` as aliases — `heightPx` matches createCanvasGraphNode's own input
2430
+ // field, the natural thing a caller reaches for. (With content-fit the node grows
2431
+ // to the chart anyway; this just removes the silent "height ignored" surprise.)
2432
+ const nodeHeight = pickPositiveNumber(body, 'nodeHeight')
2433
+ ?? pickPositiveNumber(body, 'heightPx')
2434
+ ?? (size ? pickPositiveNumber(size, 'height') : undefined);
2426
2435
  const showLegend = typeof body.showLegend === 'boolean' ? body.showLegend : undefined;
2427
2436
  const showLabels = typeof body.showLabels === 'boolean' ? body.showLabels : undefined;
2428
2437
  const colorBy =
@@ -2549,6 +2558,8 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
2549
2558
  url.searchParams.get('devtools') === '1';
2550
2559
  const axToken = url.searchParams.get('axToken');
2551
2560
  const axEnabled = resolveNodeAxCapabilities(node).enabled;
2561
+ const frameToken = url.searchParams.get('frameToken');
2562
+ const fitContent = url.searchParams.get('fit') === 'content';
2552
2563
  const html = await buildJsonRenderViewerHtml({
2553
2564
  title,
2554
2565
  spec,
@@ -2558,6 +2569,9 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
2558
2569
  ...(axToken ? { nodeId, axToken } : {}),
2559
2570
  // Seed the read-side AX state (only for AX-enabled nodes) so specs can bind /ax.
2560
2571
  ...(axToken && axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
2572
+ // Content-fit: report natural height (charts render intrinsic) so the node grows.
2573
+ ...(frameToken ? { frameToken } : {}),
2574
+ ...(fitContent ? { fitContent: true } : {}),
2561
2575
  });
2562
2576
  return new Response(html, {
2563
2577
  headers: {
@@ -2636,6 +2650,14 @@ function handleArtifactView(url: URL): Response {
2636
2650
  : `${bridge}${content}`;
2637
2651
  }
2638
2652
  }
2653
+ // Content-height reporter so a web-artifact node grows to fit its app (#48).
2654
+ const frameToken = url.searchParams.get('frameToken');
2655
+ if (frameToken) {
2656
+ const reporter = buildContentHeightReporter(frameToken.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80));
2657
+ content = content.includes('</head>')
2658
+ ? content.replace('</head>', `${reporter}</head>`)
2659
+ : `${reporter}${content}`;
2660
+ }
2639
2661
  return new Response(content, {
2640
2662
  headers: {
2641
2663
  'Content-Type': 'text/html; charset=utf-8',
@@ -3870,9 +3892,10 @@ function handleGetAxSurfaceSnapshot(): Response {
3870
3892
 
3871
3893
  // Open a node's surface in the user's real system browser (for hosts whose
3872
3894
  // embedded browser makes window.open('_blank') feel in-place, e.g. Codex).
3873
- // Accepts ONLY { nodeId } and opens this server's own surface URL — never an
3874
- // arbitrary URL — so it can't be used to launch external sites (no SSRF). Honors
3875
- // the PMX_CANVAS_DISABLE_BROWSER_OPEN kill switch via openUrlInExternalBrowser.
3895
+ // Accepts ONLY { nodeId, url? } and opens this server's own surface URL — never
3896
+ // an arbitrary URL — so it can't be used to launch external sites (no SSRF).
3897
+ // The optional URL is limited to the same node surface route so callers can keep
3898
+ // safe presentation query params like the current theme.
3876
3899
  async function handleOpenExternalSurface(req: Request): Promise<Response> {
3877
3900
  const body = await readJson(req);
3878
3901
  const nodeId = typeof body.nodeId === 'string' ? body.nodeId : '';
@@ -3881,7 +3904,14 @@ async function handleOpenExternalSurface(req: Request): Promise<Response> {
3881
3904
  if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
3882
3905
  const port = getCanvasServerPort();
3883
3906
  if (!port) return responseJson({ ok: false, opened: false, error: 'Server port unavailable.' }, 503);
3884
- const surfacePath = `/api/canvas/surface/${encodeURIComponent(nodeId)}`;
3907
+ const defaultSurfacePath = `/api/canvas/surface/${encodeURIComponent(nodeId)}`;
3908
+ const rawUrl = typeof body.url === 'string' ? body.url : defaultSurfacePath;
3909
+ const parsedUrl = new URL(rawUrl, `http://localhost:${port}`);
3910
+ if (parsedUrl.origin !== `http://localhost:${port}` || parsedUrl.pathname !== defaultSurfacePath) {
3911
+ return responseJson({ ok: false, error: 'url must target the requested node surface.' }, 400);
3912
+ }
3913
+ const theme = normalizeSurfaceTheme(parsedUrl.searchParams.get('theme'));
3914
+ const surfacePath = `${defaultSurfacePath}?theme=${encodeURIComponent(theme)}`;
3885
3915
  const opened = openUrlInExternalBrowser(`http://localhost:${port}${surfacePath}`);
3886
3916
  return responseJson({ ok: true, opened, url: surfacePath });
3887
3917
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Content-height reporter — injected into iframe-backed canvas surfaces so the
3
+ * parent canvas can grow the node to fit its content (the #48 graph-clipping fix).
4
+ *
5
+ * The surface posts its natural `document` scrollHeight to `window.parent` over a
6
+ * nonce-validated channel; the parent (use-iframe-content-height) grows the node
7
+ * grow-only to fit. Debounced (~100ms) + dead-banded (>4px) so a stray re-measure
8
+ * can't spam, and grow-only growth on the parent side cannot oscillate.
9
+ *
10
+ * Shared by both injection sites — src/server/html-surface.ts (html / web-artifact
11
+ * surfaces) and src/json-render/server.ts (the json-render/graph viewer) — so the
12
+ * two stay byte-identical. This module is framework-agnostic and imports nothing
13
+ * from src/server, preserving the json-render package's decoupling.
14
+ */
15
+
16
+ /** Sanitize a nonce for safe interpolation into an inline script literal. */
17
+ export function sanitizeFrameToken(token: string): string {
18
+ return token.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80);
19
+ }
20
+
21
+ /** Inline JS (no `<script>` wrapper) that reports content height to the parent. */
22
+ export function contentHeightReporterSource(frameToken: string): string {
23
+ const token = JSON.stringify(sanitizeFrameToken(frameToken));
24
+ return `(function(){var T=${token};var last=0,timer=null;`
25
+ + `function m(){var d=document.documentElement;return Math.max(d?d.scrollHeight:0,document.body?document.body.scrollHeight:0);}`
26
+ + `function r(){var h=m();if(Math.abs(h-last)<=4)return;last=h;window.parent.postMessage({source:'pmx-canvas-frame',type:'content-height',token:T,height:h},'*');}`
27
+ + `function s(){if(timer)return;timer=setTimeout(function(){timer=null;r();},100);}`
28
+ + `if(document.readyState!=='loading')s();window.addEventListener('load',s);`
29
+ + `try{new ResizeObserver(s).observe(document.documentElement);}catch(e){}setTimeout(s,60);})();`;
30
+ }
31
+
32
+ /** `<script>`-wrapped reporter for injection into an HTML `<head>` / document. */
33
+ export function contentHeightReporterTag(frameToken: string): string {
34
+ return `<script data-pmx-canvas-content-height>${contentHeightReporterSource(frameToken)}</script>`;
35
+ }