pmx-canvas 0.3.4 → 0.4.1

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 (51) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/Readme.md +16 -3
  3. package/dist/canvas/index.js +80 -80
  4. package/dist/types/client/index.d.ts +1 -1
  5. package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
  6. package/dist/types/client/nodes/iframe-document-url.d.ts +1 -0
  7. package/dist/types/client/nodes/use-surface-frame.d.ts +13 -0
  8. package/dist/types/client/state/canvas-store.d.ts +1 -0
  9. package/dist/types/client/state/iframe-mode.d.ts +25 -0
  10. package/dist/types/client/state/node-factory.d.ts +29 -0
  11. package/dist/types/client/uuid-polyfill.d.ts +0 -0
  12. package/dist/types/server/canvas-db.d.ts +0 -1
  13. package/dist/types/server/canvas-state.d.ts +14 -15
  14. package/dist/types/server/operations/composites.d.ts +6 -21
  15. package/dist/types/server/server.d.ts +1 -0
  16. package/docs/RELEASE.md +10 -4
  17. package/docs/cli.md +5 -0
  18. package/docs/environment.md +2 -2
  19. package/docs/http-api.md +47 -1
  20. package/docs/mcp.md +21 -30
  21. package/docs/plans/plan-009-tech-debt-backlog.md +22 -13
  22. package/docs/screenshot.png +0 -0
  23. package/docs/tech-debt-assessment-2026-06.md +5 -5
  24. package/package.json +7 -7
  25. package/skills/pmx-canvas/SKILL.md +36 -10
  26. package/skills/pmx-canvas/evals/evals.json +2 -2
  27. package/skills/pmx-canvas/references/full-reference.md +17 -15
  28. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  29. package/src/cli/commands/history.ts +1 -1
  30. package/src/cli/commands/nodes.ts +31 -1
  31. package/src/cli/index.ts +8 -2
  32. package/src/cli/shared.ts +1 -0
  33. package/src/client/index.tsx +7 -0
  34. package/src/client/nodes/ExtAppFrame.tsx +50 -2
  35. package/src/client/nodes/HtmlNode.tsx +5 -1
  36. package/src/client/nodes/McpAppNode.tsx +5 -1
  37. package/src/client/nodes/iframe-document-url.ts +62 -9
  38. package/src/client/nodes/use-surface-frame.ts +53 -0
  39. package/src/client/state/canvas-store.ts +5 -0
  40. package/src/client/state/iframe-mode.ts +103 -0
  41. package/src/client/state/node-factory.ts +72 -0
  42. package/src/client/state/sse-bridge.ts +212 -86
  43. package/src/client/uuid-polyfill.ts +14 -0
  44. package/src/server/canvas-db.ts +0 -8
  45. package/src/server/canvas-schema.ts +2 -8
  46. package/src/server/canvas-state.ts +51 -151
  47. package/src/server/index.ts +3 -9
  48. package/src/server/operations/composites.ts +24 -21
  49. package/src/server/operations/ops/snapshots.ts +6 -7
  50. package/src/server/operations/registry.ts +4 -1
  51. package/src/server/server.ts +194 -75
@@ -24,6 +24,7 @@
24
24
  * - GET /api/canvas/json-render/view?nodeId=... -> local json-render viewer
25
25
  * - POST /api/canvas/web-artifact -> build bundled HTML artifact + optional canvas node
26
26
  * - GET /api/workbench/events -> SSE event stream
27
+ * - GET /api/workbench/poll -> proxy-safe polling transport (same events, JSON)
27
28
  * - GET /api/workbench/state -> workbench state snapshot
28
29
  * - POST /api/workbench/intent -> workbench intents
29
30
  * - GET /api/workbench/webview -> Bun.WebView automation status
@@ -710,7 +711,24 @@ function toSseFrame(event: string, payload: PrimaryWorkbenchEventPayload): Uint8
710
711
  return textEncoder.encode(`${lines.join('\n')}\n`);
711
712
  }
712
713
 
714
+ // ── Workbench event ring (proxy-safe polling transport) ─────────
715
+ // Buffering proxies (e.g. the Amp orb portal) hold a long-lived SSE response
716
+ // open and never flush it to the browser, so an EventSource client behind one
717
+ // receives nothing and the canvas never boots. Every broadcast event is also
718
+ // recorded here with a monotonic seq; GET /api/workbench/poll replays them as
719
+ // a normal, immediately-closed JSON response that any proxy delivers. Bounded:
720
+ // a client whose `since` fell off the ring gets a fresh connect snapshot,
721
+ // which fully rebuilds client state (same events an SSE connect sends).
722
+ const WORKBENCH_EVENT_RING_MAX = 500;
723
+ let workbenchEventSeq = 0;
724
+ const workbenchEventRing: Array<{ seq: number; event: string; payload: PrimaryWorkbenchEventPayload }> = [];
725
+
713
726
  function broadcastWorkbenchEvent(event: string, payload: PrimaryWorkbenchEventPayload): void {
727
+ workbenchEventSeq += 1;
728
+ workbenchEventRing.push({ seq: workbenchEventSeq, event, payload });
729
+ if (workbenchEventRing.length > WORKBENCH_EVENT_RING_MAX) {
730
+ workbenchEventRing.splice(0, workbenchEventRing.length - WORKBENCH_EVENT_RING_MAX);
731
+ }
714
732
  const frame = toSseFrame(event, payload);
715
733
  for (const [subscriberId, controller] of workbenchSubscribers.entries()) {
716
734
  try {
@@ -1121,6 +1139,20 @@ function handleFrameDocument(pathname: string): Response {
1121
1139
  });
1122
1140
  }
1123
1141
 
1142
+ // Tiny always-200 document the client loads in a hidden iframe at boot to learn
1143
+ // whether this embedding context loads iframes from src URLs at all — nested
1144
+ // iframe hosts like the Amp orb portal block them, and the client then falls
1145
+ // back to fetch() + srcdoc surfaces (see src/client/state/iframe-mode.ts).
1146
+ function handleIframeProbe(): Response {
1147
+ return new Response('<!doctype html><title>pmx-canvas iframe probe</title>', {
1148
+ headers: {
1149
+ 'Content-Type': 'text/html; charset=utf-8',
1150
+ 'Cache-Control': 'no-store',
1151
+ 'Content-Security-Policy': 'sandbox allow-scripts',
1152
+ },
1153
+ });
1154
+ }
1155
+
1124
1156
  // ── Ext-app recovery diagnostics (0.3.2 report Finding N) ──────
1125
1157
  //
1126
1158
  // The WebKit black-tile recovery trail is otherwise trapped inside host panels
@@ -1372,14 +1404,19 @@ function responseJson(data: unknown, status = 200): Response {
1372
1404
  });
1373
1405
  }
1374
1406
 
1407
+ // v0.4.0 (M4): every hand-written error response is a JSON envelope
1408
+ // ({ ok: false, error }, same status codes) matching the registry dispatcher —
1409
+ // consumers no longer need to branch on text/plain vs JSON error bodies.
1375
1410
  function responseText(text: string, status = 400): Response {
1376
- return new Response(text, {
1377
- status,
1378
- headers: {
1379
- 'Content-Type': 'text/plain; charset=utf-8',
1380
- 'Cache-Control': 'no-store',
1411
+ return Response.json(
1412
+ { ok: false, error: text },
1413
+ {
1414
+ status,
1415
+ headers: {
1416
+ 'Cache-Control': 'no-store',
1417
+ },
1381
1418
  },
1382
- });
1419
+ );
1383
1420
  }
1384
1421
 
1385
1422
  function handleArtifactView(url: URL): Response {
@@ -1692,15 +1729,134 @@ async function handleWorkbenchIntent(req: Request): Promise<Response> {
1692
1729
  return responseJson({ ok: true, intent });
1693
1730
  }
1694
1731
 
1695
- function handleWorkbenchEvents(req: Request): Response {
1696
- const reqUrl = new URL(req.url);
1697
- const requestedSessionId = reqUrl.searchParams.get('session')?.trim() ?? '';
1732
+ /**
1733
+ * The connect snapshot: the ordered event list that fully rebuilds a client's
1734
+ * state. Single source for BOTH transports — the SSE stream enqueues these
1735
+ * frames on connect, and /api/workbench/poll returns them as JSON when a
1736
+ * poller has no usable `since` (fresh client, server restart, or ring
1737
+ * eviction). Adding a new connect-time event here reaches both paths.
1738
+ */
1739
+ function buildWorkbenchConnectSnapshot(
1740
+ requestedSessionId: string,
1741
+ ): Array<{ event: string; payload: Record<string, unknown> }> {
1698
1742
  const continuity =
1699
1743
  requestedSessionId.length === 0
1700
1744
  ? 'fresh'
1701
1745
  : requestedSessionId === primaryWorkbenchSessionId
1702
1746
  ? 'resumed'
1703
1747
  : 'rotated';
1748
+ const events: Array<{ event: string; payload: Record<string, unknown> }> = [];
1749
+ events.push({
1750
+ event: 'connected',
1751
+ payload: {
1752
+ sessionId: primaryWorkbenchSessionId,
1753
+ requestedSessionId: requestedSessionId || null,
1754
+ continuity,
1755
+ path: primaryWorkbenchPath,
1756
+ theme: canvasState.theme,
1757
+ timestamp: new Date().toISOString(),
1758
+ },
1759
+ });
1760
+ if (primaryWorkbenchPath) {
1761
+ events.push({
1762
+ event: 'workbench-open',
1763
+ payload: {
1764
+ sessionId: primaryWorkbenchSessionId,
1765
+ path: primaryWorkbenchPath,
1766
+ title: basename(primaryWorkbenchPath),
1767
+ source: 'snapshot',
1768
+ updatedAt: new Date().toISOString(),
1769
+ },
1770
+ });
1771
+ }
1772
+ if (lastWorkbenchContextCardsEnvelope) {
1773
+ events.push({
1774
+ event: 'context-cards',
1775
+ payload: {
1776
+ ...lastWorkbenchContextCardsEnvelope,
1777
+ sessionId: primaryWorkbenchSessionId,
1778
+ timestamp: new Date().toISOString(),
1779
+ },
1780
+ });
1781
+ }
1782
+ events.push({
1783
+ event: 'mcp-app-host-snapshot',
1784
+ payload: {
1785
+ reason: 'connect-snapshot',
1786
+ ...getMcpAppHostSnapshot(),
1787
+ timestamp: new Date().toISOString(),
1788
+ },
1789
+ });
1790
+ events.push({
1791
+ event: 'canvas-layout-update',
1792
+ payload: {
1793
+ layout: canvasState.getLayout(),
1794
+ sessionId: primaryWorkbenchSessionId,
1795
+ timestamp: new Date().toISOString(),
1796
+ },
1797
+ });
1798
+ events.push({
1799
+ event: 'context-pins-changed',
1800
+ payload: {
1801
+ count: canvasState.contextPinnedNodeIds.size,
1802
+ nodeIds: Array.from(canvasState.contextPinnedNodeIds),
1803
+ sessionId: primaryWorkbenchSessionId,
1804
+ timestamp: new Date().toISOString(),
1805
+ },
1806
+ });
1807
+ for (const intent of intentRegistry.list()) {
1808
+ events.push({
1809
+ event: 'ax-intent',
1810
+ payload: {
1811
+ intent,
1812
+ reason: 'connect-snapshot',
1813
+ sessionId: primaryWorkbenchSessionId,
1814
+ timestamp: new Date().toISOString(),
1815
+ },
1816
+ });
1817
+ }
1818
+ return events;
1819
+ }
1820
+
1821
+ /**
1822
+ * Proxy-safe transport (Amp orb portals and any proxy that buffers streaming
1823
+ * responses): returns the buffered events after `since` — or the full connect
1824
+ * snapshot when `since` is absent, from a previous server run, or already
1825
+ * evicted from the ring — as a normal JSON response that closes immediately.
1826
+ * The client polls every couple of seconds; each response flushes through any
1827
+ * proxy. `seq` is the cursor for the next poll.
1828
+ */
1829
+ function handleWorkbenchPoll(url: URL): Response {
1830
+ rotatePrimaryWorkbenchSessionIfNeeded();
1831
+ const requestedSessionId = url.searchParams.get('session')?.trim() ?? '';
1832
+ const sinceRaw = url.searchParams.get('since');
1833
+ const since = sinceRaw === null ? Number.NaN : Number(sinceRaw);
1834
+ const ringHeadSeq =
1835
+ workbenchEventRing.length > 0 ? (workbenchEventRing[0] as { seq: number }).seq : workbenchEventSeq + 1;
1836
+ const usableSince = Number.isInteger(since) && since >= 0 && since <= workbenchEventSeq && since + 1 >= ringHeadSeq;
1837
+ if (!usableSince) {
1838
+ return responseJson({
1839
+ ok: true,
1840
+ transport: 'poll',
1841
+ snapshot: true,
1842
+ seq: workbenchEventSeq,
1843
+ sessionId: primaryWorkbenchSessionId,
1844
+ events: buildWorkbenchConnectSnapshot(requestedSessionId),
1845
+ });
1846
+ }
1847
+ return responseJson({
1848
+ ok: true,
1849
+ transport: 'poll',
1850
+ snapshot: false,
1851
+ seq: workbenchEventSeq,
1852
+ sessionId: primaryWorkbenchSessionId,
1853
+ events: workbenchEventRing.filter((entry) => entry.seq > since),
1854
+ });
1855
+ }
1856
+
1857
+ function handleWorkbenchEvents(req: Request): Response {
1858
+ const reqUrl = new URL(req.url);
1859
+ const requestedSessionId = reqUrl.searchParams.get('session')?.trim() ?? '';
1704
1860
  const subscriberId = nextWorkbenchSubscriberId++;
1705
1861
  let pingTimer: ReturnType<typeof setInterval> | null = null;
1706
1862
 
@@ -1708,68 +1864,8 @@ function handleWorkbenchEvents(req: Request): Response {
1708
1864
  start(controller) {
1709
1865
  workbenchSubscribers.set(subscriberId, controller);
1710
1866
  syncCanvasBrowserOpenedFromSubscribers();
1711
- controller.enqueue(
1712
- toSseFrame('connected', {
1713
- sessionId: primaryWorkbenchSessionId,
1714
- requestedSessionId: requestedSessionId || null,
1715
- continuity,
1716
- path: primaryWorkbenchPath,
1717
- theme: canvasState.theme,
1718
- timestamp: new Date().toISOString(),
1719
- }),
1720
- );
1721
- if (primaryWorkbenchPath) {
1722
- controller.enqueue(
1723
- toSseFrame('workbench-open', {
1724
- sessionId: primaryWorkbenchSessionId,
1725
- path: primaryWorkbenchPath,
1726
- title: basename(primaryWorkbenchPath),
1727
- source: 'snapshot',
1728
- updatedAt: new Date().toISOString(),
1729
- }),
1730
- );
1731
- }
1732
- if (lastWorkbenchContextCardsEnvelope) {
1733
- controller.enqueue(
1734
- toSseFrame('context-cards', {
1735
- ...lastWorkbenchContextCardsEnvelope,
1736
- sessionId: primaryWorkbenchSessionId,
1737
- timestamp: new Date().toISOString(),
1738
- }),
1739
- );
1740
- }
1741
- controller.enqueue(
1742
- toSseFrame('mcp-app-host-snapshot', {
1743
- reason: 'connect-snapshot',
1744
- ...getMcpAppHostSnapshot(),
1745
- timestamp: new Date().toISOString(),
1746
- }),
1747
- );
1748
- const layout = canvasState.getLayout();
1749
- controller.enqueue(
1750
- toSseFrame('canvas-layout-update', {
1751
- layout,
1752
- sessionId: primaryWorkbenchSessionId,
1753
- timestamp: new Date().toISOString(),
1754
- }),
1755
- );
1756
- controller.enqueue(
1757
- toSseFrame('context-pins-changed', {
1758
- count: canvasState.contextPinnedNodeIds.size,
1759
- nodeIds: Array.from(canvasState.contextPinnedNodeIds),
1760
- sessionId: primaryWorkbenchSessionId,
1761
- timestamp: new Date().toISOString(),
1762
- }),
1763
- );
1764
- for (const intent of intentRegistry.list()) {
1765
- controller.enqueue(
1766
- toSseFrame('ax-intent', {
1767
- intent,
1768
- reason: 'connect-snapshot',
1769
- sessionId: primaryWorkbenchSessionId,
1770
- timestamp: new Date().toISOString(),
1771
- }),
1772
- );
1867
+ for (const { event, payload } of buildWorkbenchConnectSnapshot(requestedSessionId)) {
1868
+ controller.enqueue(toSseFrame(event, payload));
1773
1869
  }
1774
1870
  pingTimer = setInterval(() => {
1775
1871
  try {
@@ -2855,7 +2951,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2855
2951
 
2856
2952
  rotatePrimaryWorkbenchSessionIfNeeded();
2857
2953
 
2858
- const preferredPort = options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? DEFAULT_PORT);
2954
+ // M11: PMX_WEB_CANVAS_PORT (server-specific) wins, then PMX_CANVAS_PORT — so
2955
+ // setting only the client var no longer splits the client and server ports.
2956
+ const preferredPort =
2957
+ options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? DEFAULT_PORT);
2859
2958
  const portCandidates =
2860
2959
  options.port === 0
2861
2960
  ? [0]
@@ -2884,8 +2983,15 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2884
2983
  if (url.pathname === '/health') {
2885
2984
  // `pid` lets `serve status` report the ACTUAL serving process even
2886
2985
  // when the pid file is stale (e.g. an adapter respawned the server
2887
- // outside `serve --daemon` — 0.3.2 report Finding P).
2888
- return responseJson({ ok: true, workspace: activeWorkspaceRoot, pid: process.pid });
2986
+ // outside `serve --daemon` — 0.3.2 report Finding P). `persistence`
2987
+ // surfaces silent save failures (M4): the canvas keeps serving from
2988
+ // memory, but ok:false means durability is degraded.
2989
+ return responseJson({
2990
+ ok: true,
2991
+ workspace: activeWorkspaceRoot,
2992
+ pid: process.pid,
2993
+ persistence: canvasState.persistenceHealth,
2994
+ });
2889
2995
  }
2890
2996
 
2891
2997
  if (url.pathname === '/favicon.ico' || url.pathname === '/favicon.svg') {
@@ -2919,10 +3025,19 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2919
3025
  return handleCreateFrameDocument(req);
2920
3026
  }
2921
3027
 
2922
- if (url.pathname.startsWith('/api/canvas/frame-documents/') && req.method === 'GET') {
3028
+ // HEAD lets clients revalidate a minted frame URL after reconnect
3029
+ // (Finding S) without downloading the whole document.
3030
+ if (
3031
+ url.pathname.startsWith('/api/canvas/frame-documents/') &&
3032
+ (req.method === 'GET' || req.method === 'HEAD')
3033
+ ) {
2923
3034
  return handleFrameDocument(url.pathname);
2924
3035
  }
2925
3036
 
3037
+ if (url.pathname === '/api/canvas/iframe-probe' && (req.method === 'GET' || req.method === 'HEAD')) {
3038
+ return handleIframeProbe();
3039
+ }
3040
+
2926
3041
  if (url.pathname.startsWith('/api/canvas/surface/') && (req.method === 'GET' || req.method === 'HEAD')) {
2927
3042
  return handleNodeSurface(url.pathname, url);
2928
3043
  }
@@ -2952,6 +3067,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2952
3067
  return handleWorkbenchEvents(req);
2953
3068
  }
2954
3069
 
3070
+ if (url.pathname === '/api/workbench/poll' && req.method === 'GET') {
3071
+ return handleWorkbenchPoll(url);
3072
+ }
3073
+
2955
3074
  if (url.pathname === '/api/workbench/intent' && req.method === 'POST') {
2956
3075
  return handleWorkbenchIntent(req);
2957
3076
  }