pmx-canvas 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/Readme.md +11 -3
  3. package/dist/canvas/index.js +67 -67
  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/state/node-factory.d.ts +29 -0
  7. package/dist/types/client/uuid-polyfill.d.ts +0 -0
  8. package/dist/types/server/canvas-db.d.ts +0 -1
  9. package/dist/types/server/canvas-state.d.ts +14 -15
  10. package/dist/types/server/operations/composites.d.ts +6 -21
  11. package/dist/types/server/server.d.ts +1 -0
  12. package/docs/cli.md +5 -0
  13. package/docs/environment.md +2 -2
  14. package/docs/http-api.md +25 -1
  15. package/docs/mcp.md +21 -30
  16. package/docs/plans/plan-009-tech-debt-backlog.md +13 -12
  17. package/docs/screenshot.png +0 -0
  18. package/docs/tech-debt-assessment-2026-06.md +5 -5
  19. package/package.json +7 -7
  20. package/skills/pmx-canvas/SKILL.md +16 -6
  21. package/skills/pmx-canvas/evals/evals.json +2 -2
  22. package/skills/pmx-canvas/references/full-reference.md +17 -15
  23. package/src/cli/commands/history.ts +1 -1
  24. package/src/cli/commands/nodes.ts +31 -1
  25. package/src/cli/index.ts +8 -2
  26. package/src/cli/shared.ts +1 -0
  27. package/src/client/index.tsx +2 -0
  28. package/src/client/nodes/ExtAppFrame.tsx +17 -2
  29. package/src/client/state/node-factory.ts +72 -0
  30. package/src/client/state/sse-bridge.ts +209 -86
  31. package/src/client/uuid-polyfill.ts +14 -0
  32. package/src/server/canvas-db.ts +0 -8
  33. package/src/server/canvas-schema.ts +2 -8
  34. package/src/server/canvas-state.ts +51 -151
  35. package/src/server/index.ts +3 -9
  36. package/src/server/operations/composites.ts +24 -21
  37. package/src/server/operations/ops/snapshots.ts +6 -7
  38. package/src/server/operations/registry.ts +4 -1
  39. package/src/server/server.ts +170 -74
@@ -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 {
@@ -1372,14 +1390,19 @@ function responseJson(data: unknown, status = 200): Response {
1372
1390
  });
1373
1391
  }
1374
1392
 
1393
+ // v0.4.0 (M4): every hand-written error response is a JSON envelope
1394
+ // ({ ok: false, error }, same status codes) matching the registry dispatcher —
1395
+ // consumers no longer need to branch on text/plain vs JSON error bodies.
1375
1396
  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',
1397
+ return Response.json(
1398
+ { ok: false, error: text },
1399
+ {
1400
+ status,
1401
+ headers: {
1402
+ 'Cache-Control': 'no-store',
1403
+ },
1381
1404
  },
1382
- });
1405
+ );
1383
1406
  }
1384
1407
 
1385
1408
  function handleArtifactView(url: URL): Response {
@@ -1692,15 +1715,134 @@ async function handleWorkbenchIntent(req: Request): Promise<Response> {
1692
1715
  return responseJson({ ok: true, intent });
1693
1716
  }
1694
1717
 
1695
- function handleWorkbenchEvents(req: Request): Response {
1696
- const reqUrl = new URL(req.url);
1697
- const requestedSessionId = reqUrl.searchParams.get('session')?.trim() ?? '';
1718
+ /**
1719
+ * The connect snapshot: the ordered event list that fully rebuilds a client's
1720
+ * state. Single source for BOTH transports — the SSE stream enqueues these
1721
+ * frames on connect, and /api/workbench/poll returns them as JSON when a
1722
+ * poller has no usable `since` (fresh client, server restart, or ring
1723
+ * eviction). Adding a new connect-time event here reaches both paths.
1724
+ */
1725
+ function buildWorkbenchConnectSnapshot(
1726
+ requestedSessionId: string,
1727
+ ): Array<{ event: string; payload: Record<string, unknown> }> {
1698
1728
  const continuity =
1699
1729
  requestedSessionId.length === 0
1700
1730
  ? 'fresh'
1701
1731
  : requestedSessionId === primaryWorkbenchSessionId
1702
1732
  ? 'resumed'
1703
1733
  : 'rotated';
1734
+ const events: Array<{ event: string; payload: Record<string, unknown> }> = [];
1735
+ events.push({
1736
+ event: 'connected',
1737
+ payload: {
1738
+ sessionId: primaryWorkbenchSessionId,
1739
+ requestedSessionId: requestedSessionId || null,
1740
+ continuity,
1741
+ path: primaryWorkbenchPath,
1742
+ theme: canvasState.theme,
1743
+ timestamp: new Date().toISOString(),
1744
+ },
1745
+ });
1746
+ if (primaryWorkbenchPath) {
1747
+ events.push({
1748
+ event: 'workbench-open',
1749
+ payload: {
1750
+ sessionId: primaryWorkbenchSessionId,
1751
+ path: primaryWorkbenchPath,
1752
+ title: basename(primaryWorkbenchPath),
1753
+ source: 'snapshot',
1754
+ updatedAt: new Date().toISOString(),
1755
+ },
1756
+ });
1757
+ }
1758
+ if (lastWorkbenchContextCardsEnvelope) {
1759
+ events.push({
1760
+ event: 'context-cards',
1761
+ payload: {
1762
+ ...lastWorkbenchContextCardsEnvelope,
1763
+ sessionId: primaryWorkbenchSessionId,
1764
+ timestamp: new Date().toISOString(),
1765
+ },
1766
+ });
1767
+ }
1768
+ events.push({
1769
+ event: 'mcp-app-host-snapshot',
1770
+ payload: {
1771
+ reason: 'connect-snapshot',
1772
+ ...getMcpAppHostSnapshot(),
1773
+ timestamp: new Date().toISOString(),
1774
+ },
1775
+ });
1776
+ events.push({
1777
+ event: 'canvas-layout-update',
1778
+ payload: {
1779
+ layout: canvasState.getLayout(),
1780
+ sessionId: primaryWorkbenchSessionId,
1781
+ timestamp: new Date().toISOString(),
1782
+ },
1783
+ });
1784
+ events.push({
1785
+ event: 'context-pins-changed',
1786
+ payload: {
1787
+ count: canvasState.contextPinnedNodeIds.size,
1788
+ nodeIds: Array.from(canvasState.contextPinnedNodeIds),
1789
+ sessionId: primaryWorkbenchSessionId,
1790
+ timestamp: new Date().toISOString(),
1791
+ },
1792
+ });
1793
+ for (const intent of intentRegistry.list()) {
1794
+ events.push({
1795
+ event: 'ax-intent',
1796
+ payload: {
1797
+ intent,
1798
+ reason: 'connect-snapshot',
1799
+ sessionId: primaryWorkbenchSessionId,
1800
+ timestamp: new Date().toISOString(),
1801
+ },
1802
+ });
1803
+ }
1804
+ return events;
1805
+ }
1806
+
1807
+ /**
1808
+ * Proxy-safe transport (Amp orb portals and any proxy that buffers streaming
1809
+ * responses): returns the buffered events after `since` — or the full connect
1810
+ * snapshot when `since` is absent, from a previous server run, or already
1811
+ * evicted from the ring — as a normal JSON response that closes immediately.
1812
+ * The client polls every couple of seconds; each response flushes through any
1813
+ * proxy. `seq` is the cursor for the next poll.
1814
+ */
1815
+ function handleWorkbenchPoll(url: URL): Response {
1816
+ rotatePrimaryWorkbenchSessionIfNeeded();
1817
+ const requestedSessionId = url.searchParams.get('session')?.trim() ?? '';
1818
+ const sinceRaw = url.searchParams.get('since');
1819
+ const since = sinceRaw === null ? Number.NaN : Number(sinceRaw);
1820
+ const ringHeadSeq =
1821
+ workbenchEventRing.length > 0 ? (workbenchEventRing[0] as { seq: number }).seq : workbenchEventSeq + 1;
1822
+ const usableSince = Number.isInteger(since) && since >= 0 && since <= workbenchEventSeq && since + 1 >= ringHeadSeq;
1823
+ if (!usableSince) {
1824
+ return responseJson({
1825
+ ok: true,
1826
+ transport: 'poll',
1827
+ snapshot: true,
1828
+ seq: workbenchEventSeq,
1829
+ sessionId: primaryWorkbenchSessionId,
1830
+ events: buildWorkbenchConnectSnapshot(requestedSessionId),
1831
+ });
1832
+ }
1833
+ return responseJson({
1834
+ ok: true,
1835
+ transport: 'poll',
1836
+ snapshot: false,
1837
+ seq: workbenchEventSeq,
1838
+ sessionId: primaryWorkbenchSessionId,
1839
+ events: workbenchEventRing.filter((entry) => entry.seq > since),
1840
+ });
1841
+ }
1842
+
1843
+ function handleWorkbenchEvents(req: Request): Response {
1844
+ const reqUrl = new URL(req.url);
1845
+ const requestedSessionId = reqUrl.searchParams.get('session')?.trim() ?? '';
1704
1846
  const subscriberId = nextWorkbenchSubscriberId++;
1705
1847
  let pingTimer: ReturnType<typeof setInterval> | null = null;
1706
1848
 
@@ -1708,68 +1850,8 @@ function handleWorkbenchEvents(req: Request): Response {
1708
1850
  start(controller) {
1709
1851
  workbenchSubscribers.set(subscriberId, controller);
1710
1852
  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
- );
1853
+ for (const { event, payload } of buildWorkbenchConnectSnapshot(requestedSessionId)) {
1854
+ controller.enqueue(toSseFrame(event, payload));
1773
1855
  }
1774
1856
  pingTimer = setInterval(() => {
1775
1857
  try {
@@ -2855,7 +2937,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2855
2937
 
2856
2938
  rotatePrimaryWorkbenchSessionIfNeeded();
2857
2939
 
2858
- const preferredPort = options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? DEFAULT_PORT);
2940
+ // M11: PMX_WEB_CANVAS_PORT (server-specific) wins, then PMX_CANVAS_PORT — so
2941
+ // setting only the client var no longer splits the client and server ports.
2942
+ const preferredPort =
2943
+ options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? DEFAULT_PORT);
2859
2944
  const portCandidates =
2860
2945
  options.port === 0
2861
2946
  ? [0]
@@ -2884,8 +2969,15 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2884
2969
  if (url.pathname === '/health') {
2885
2970
  // `pid` lets `serve status` report the ACTUAL serving process even
2886
2971
  // 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 });
2972
+ // outside `serve --daemon` — 0.3.2 report Finding P). `persistence`
2973
+ // surfaces silent save failures (M4): the canvas keeps serving from
2974
+ // memory, but ok:false means durability is degraded.
2975
+ return responseJson({
2976
+ ok: true,
2977
+ workspace: activeWorkspaceRoot,
2978
+ pid: process.pid,
2979
+ persistence: canvasState.persistenceHealth,
2980
+ });
2889
2981
  }
2890
2982
 
2891
2983
  if (url.pathname === '/favicon.ico' || url.pathname === '/favicon.svg') {
@@ -2952,6 +3044,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
2952
3044
  return handleWorkbenchEvents(req);
2953
3045
  }
2954
3046
 
3047
+ if (url.pathname === '/api/workbench/poll' && req.method === 'GET') {
3048
+ return handleWorkbenchPoll(url);
3049
+ }
3050
+
2955
3051
  if (url.pathname === '/api/workbench/intent' && req.method === 'POST') {
2956
3052
  return handleWorkbenchIntent(req);
2957
3053
  }