pmx-canvas 0.4.6 → 0.4.8

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 (76) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/Readme.md +56 -33
  3. package/dist/canvas/global.css +101 -3
  4. package/dist/canvas/index.js +93 -89
  5. package/dist/types/cli/shared.d.ts +13 -1
  6. package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
  7. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  8. package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
  9. package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
  10. package/dist/types/client/nodes/FileNode.d.ts +14 -0
  11. package/dist/types/client/state/canvas-store.d.ts +10 -0
  12. package/dist/types/client/state/intent-bridge.d.ts +9 -0
  13. package/dist/types/server/ax-flow-loop.d.ts +5 -0
  14. package/dist/types/server/ax-interaction.d.ts +21 -2
  15. package/dist/types/server/canvas-db.d.ts +14 -0
  16. package/dist/types/server/canvas-operations.d.ts +5 -0
  17. package/dist/types/server/canvas-state.d.ts +8 -0
  18. package/dist/types/server/canvas-validation.d.ts +15 -21
  19. package/dist/types/server/file-content.d.ts +46 -0
  20. package/dist/types/server/html-primitives.d.ts +13 -1
  21. package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
  22. package/dist/types/server/server.d.ts +1 -0
  23. package/dist/types/server/web-artifacts.d.ts +14 -0
  24. package/dist/types/shared/ax-flow.d.ts +51 -0
  25. package/dist/types/shared/node-sizes.d.ts +33 -0
  26. package/docs/RELEASE.md +48 -0
  27. package/docs/cli.md +19 -2
  28. package/docs/environment.md +1 -0
  29. package/docs/http-api.md +17 -0
  30. package/docs/mcp.md +12 -0
  31. package/docs/node-types.md +45 -1
  32. package/docs/screenshot.png +0 -0
  33. package/package.json +2 -2
  34. package/skills/pmx-canvas/SKILL.md +74 -9
  35. package/skills/pmx-canvas/evals/evals.json +54 -3
  36. package/skills/pmx-canvas/references/full-reference.md +11 -6
  37. package/skills/pmx-canvas-testing/SKILL.md +40 -0
  38. package/src/cli/commands/history.ts +1 -1
  39. package/src/cli/commands/nodes.ts +2 -2
  40. package/src/cli/commands/query.ts +1 -1
  41. package/src/cli/commands/view.ts +1 -1
  42. package/src/cli/index.ts +6 -0
  43. package/src/cli/shared.ts +34 -5
  44. package/src/client/App.tsx +28 -15
  45. package/src/client/canvas/CanvasNode.tsx +54 -5
  46. package/src/client/canvas/EdgeLayer.tsx +44 -24
  47. package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
  48. package/src/client/canvas/auto-fit.ts +10 -1
  49. package/src/client/canvas/use-pan-zoom.ts +16 -1
  50. package/src/client/nodes/AxStepControls.tsx +226 -0
  51. package/src/client/nodes/ExtAppFrame.tsx +79 -2
  52. package/src/client/nodes/FileNode.tsx +249 -51
  53. package/src/client/state/canvas-store.ts +19 -0
  54. package/src/client/state/intent-bridge.ts +32 -0
  55. package/src/client/state/sse-bridge.ts +4 -1
  56. package/src/client/theme/global.css +101 -3
  57. package/src/server/ax-flow-loop.ts +114 -0
  58. package/src/server/ax-interaction.ts +299 -4
  59. package/src/server/canvas-db.ts +138 -32
  60. package/src/server/canvas-operations.ts +44 -17
  61. package/src/server/canvas-state.ts +20 -10
  62. package/src/server/canvas-validation.ts +50 -41
  63. package/src/server/demo-state.json +4605 -523
  64. package/src/server/demo.ts +24 -0
  65. package/src/server/file-content.ts +152 -0
  66. package/src/server/file-watcher.ts +7 -10
  67. package/src/server/html-primitives.ts +636 -0
  68. package/src/server/operations/ops/ax-work.ts +5 -0
  69. package/src/server/operations/ops/nodes.ts +15 -1
  70. package/src/server/operations/ops/viewport.ts +7 -1
  71. package/src/server/server.ts +106 -5
  72. package/src/server/web-artifacts.ts +109 -0
  73. package/src/shared/ax-flow.ts +102 -0
  74. package/src/shared/node-sizes.ts +52 -0
  75. package/dist/types/server/chart-template.d.ts +0 -29
  76. package/src/server/chart-template.ts +0 -448
@@ -45,6 +45,11 @@
45
45
  * This module must never import server.ts or index.ts.
46
46
  */
47
47
  import { z } from 'zod';
48
+ // Side-effect import: wires the work-item change listener (live workboard refresh
49
+ // + materialized-flow loop advance). Work items are this module's domain, and the
50
+ // ops registry loads it on every entry path (HTTP, MCP, SDK), so the flow loop is
51
+ // armed wherever work items can change.
52
+ import '../../ax-flow-loop.js';
48
53
  import { canvasState } from '../../canvas-state.js';
49
54
  import type {
50
55
  PmxAxMode,
@@ -28,7 +28,12 @@ import {
28
28
  LEDGER_NODE_DEFAULT_SIZE,
29
29
  } from '../../canvas-operations.js';
30
30
  import { normalizeNodeAxCapabilities } from '../../ax-interaction.js';
31
- import { buildHtmlPrimitive, getHtmlPrimitiveSemanticMetadata, isHtmlPrimitiveKind } from '../../html-primitives.js';
31
+ import {
32
+ buildHtmlPrimitive,
33
+ getHtmlPrimitiveDescriptor,
34
+ getHtmlPrimitiveSemanticMetadata,
35
+ isHtmlPrimitiveKind,
36
+ } from '../../html-primitives.js';
32
37
  import { closeMcpAppSession } from '../../mcp-app-runtime.js';
33
38
  import {
34
39
  getCanvasNodeTitle,
@@ -631,6 +636,14 @@ function createHtmlPrimitiveNode(body: Record<string, unknown>): NodeAddResult {
631
636
  throw new OperationError(error instanceof Error ? error.message : String(error));
632
637
  }
633
638
  const geometry = resolveCreateGeometry(body);
639
+ // `html` is AX-opt-in, so an emitting primitive is inert without capabilities:
640
+ // apply the ones its descriptor declares. An explicit caller value wins (both are
641
+ // clamped to the `html` type ceiling by resolveNodeAxCapabilities). The declared
642
+ // set is copied — the descriptor is a module constant shared by every node.
643
+ const declared = getHtmlPrimitiveDescriptor(built.kind).axCapabilities;
644
+ const axCapabilities =
645
+ normalizeNodeAxCapabilities(body.axCapabilities) ??
646
+ (declared ? { enabled: declared.enabled, allowed: [...declared.allowed] } : undefined);
634
647
  const { node } = addCanvasNode({
635
648
  type: 'html',
636
649
  title: built.title,
@@ -641,6 +654,7 @@ function createHtmlPrimitiveNode(body: Record<string, unknown>): NodeAddResult {
641
654
  description: built.summary,
642
655
  agentSummary: typeof data.agentSummary === 'string' ? data.agentSummary : built.summary,
643
656
  ...(typeof data.summary === 'string' ? { summary: data.summary } : {}),
657
+ ...(axCapabilities ? { axCapabilities } : {}),
644
658
  ...getHtmlPrimitiveSemanticMetadata(built.data),
645
659
  },
646
660
  ...(body.strictSize === true ? { strictSize: true } : {}),
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { z } from 'zod';
16
16
  import { canvasState } from '../../canvas-state.js';
17
- import { arrangeCanvasNodes, clearCanvas, fitCanvasView } from '../../canvas-operations.js';
17
+ import { arrangeCanvasNodes, clearCanvas, fitCanvasView, setClientViewportSize } from '../../canvas-operations.js';
18
18
  import { validateCanvasLayout } from '../../canvas-validation.js';
19
19
  import { defineOperation, OperationError, type Operation } from '../types.js';
20
20
  import { closeNodeAppSession, isRecord } from './nodes.js';
@@ -228,6 +228,8 @@ const viewportSetShape = {
228
228
  y: z.unknown().optional().describe('Viewport y offset'),
229
229
  scale: z.unknown().optional().describe('Viewport zoom scale'),
230
230
  recordHistory: z.unknown().optional().describe('Pass false to skip the undo-history entry'),
231
+ clientWidth: z.unknown().optional().describe("The reporting client's window width in CSS pixels"),
232
+ clientHeight: z.unknown().optional().describe("The reporting client's window height in CSS pixels"),
231
233
  };
232
234
 
233
235
  const viewportSetSchema = z.looseObject(viewportSetShape);
@@ -244,6 +246,10 @@ const viewportSetOperation = defineOperation<z.infer<typeof viewportSetSchema>,
244
246
  // Legacy wire: emits canvas-viewport-update (never a layout update).
245
247
  handler: (input, ctx) => {
246
248
  const body: Record<string, unknown> = input;
249
+ // The browser piggybacks its real window size here so a later agent `fit`
250
+ // sizes to the human's actual window instead of a hardcoded 1440x900
251
+ // (0.4.6 orb feedback #2).
252
+ setClientViewportSize(body.clientWidth, body.clientHeight);
247
253
  const next = {
248
254
  x: typeof body.x === 'number' ? body.x : canvasState.viewport.x,
249
255
  y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
@@ -22,6 +22,7 @@
22
22
  * - POST /api/canvas/json-render -> create a native json-render node
23
23
  * - POST /api/canvas/graph -> create a native graph node
24
24
  * - GET /api/canvas/json-render/view?nodeId=... -> local json-render viewer
25
+ * - GET /api/canvas/file-bytes?nodeId=... -> raw bytes for a file node
25
26
  * - POST /api/canvas/web-artifact -> build bundled HTML artifact + optional canvas node
26
27
  * - GET /api/workbench/events -> SSE event stream
27
28
  * - GET /api/workbench/poll -> proxy-safe polling transport (same events, JSON)
@@ -37,7 +38,7 @@
37
38
 
38
39
  import { spawnSync } from 'node:child_process';
39
40
  import { randomUUID } from 'node:crypto';
40
- import { existsSync, readFileSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
41
+ import { existsSync, readFileSync, realpathSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
41
42
  import { readFile } from 'node:fs/promises';
42
43
  import { basename, extname, join, relative, resolve } from 'node:path';
43
44
  import * as marked from 'marked';
@@ -52,6 +53,7 @@ import {
52
53
  normalizeSurfaceTheme,
53
54
  } from './html-surface.js';
54
55
  import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
56
+ import { isPdfFilePath } from './file-content.js';
55
57
  import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
56
58
  import { getMcpAppHostSnapshot } from './mcp-app-host.js';
57
59
  import { closeMcpAppSession, closeAllMcpAppSessions } from './mcp-app-runtime.js';
@@ -62,6 +64,7 @@ import { buildCanvasAxSurfaceSnapshot } from './ax-context.js';
62
64
  import { resolveNodeAxCapabilities } from './ax-interaction.js';
63
65
  import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js';
64
66
  import { canvasThemeScheme, isCanvasTheme } from '../shared/themes.js';
67
+ import { canOpenNodeAsSurface } from '../shared/surface.js';
65
68
  import { validateLocalImageFile } from './image-source.js';
66
69
  import {
67
70
  cancelCodeGraphRecompute,
@@ -626,9 +629,28 @@ function resolveWorkspaceMarkdownPath(pathLike: string): string | null {
626
629
  function resolveWorkspaceArtifactPath(pathLike: string): string | null {
627
630
  if (!pathLike || typeof pathLike !== 'string') return null;
628
631
  const resolved = resolve(pathLike);
629
- const workspaceRel = relative(activeWorkspaceRoot, resolved);
632
+ // Containment must be checked on REAL paths: a lexical resolve() only stops
633
+ // `..` and absolute escapes, so a symlink INSIDE the workspace pointing at
634
+ // /etc/passwd or ~/.ssh/id_rsa would pass. Both sides are realpath'd — the
635
+ // root itself is commonly a symlink (macOS /tmp → /private/tmp), and
636
+ // comparing a real candidate against a symlinked root would reject
637
+ // legitimate files.
638
+ let realResolved = resolved;
639
+ let realRoot = activeWorkspaceRoot;
640
+ try {
641
+ realResolved = realpathSync(resolved);
642
+ } catch {
643
+ // Not yet on disk (callers handle existence themselves) — fall back to the
644
+ // lexical path, whose parent chain is still checked below.
645
+ }
646
+ try {
647
+ realRoot = realpathSync(activeWorkspaceRoot);
648
+ } catch {
649
+ // Workspace root unreadable — keep the configured value.
650
+ }
651
+ const workspaceRel = relative(realRoot, realResolved);
630
652
  const insideWorkspace = !(workspaceRel.startsWith('..') || workspaceRel === '..');
631
- return insideWorkspace ? resolved : null;
653
+ return insideWorkspace ? realResolved : null;
632
654
  }
633
655
 
634
656
  function escapeHtml(text: string): string {
@@ -1265,6 +1287,14 @@ function isSafeSurfaceRedirect(target: string): boolean {
1265
1287
  return false;
1266
1288
  }
1267
1289
 
1290
+ // Why a node has no standalone surface, in one wording shared by the route's own
1291
+ // 404 and by open-external's refusal — the two must never disagree about a node.
1292
+ function surfaceRefusalMessage(type: string): string {
1293
+ return type === 'mcp-app'
1294
+ ? 'This MCP app renders in the canvas and cannot be opened as a standalone site.'
1295
+ : 'Node type cannot be opened as a site';
1296
+ }
1297
+
1268
1298
  function handleNodeSurface(pathname: string, url: URL): Response {
1269
1299
  const nodeId = decodeURIComponent(pathname.slice('/api/canvas/surface/'.length));
1270
1300
  if (!nodeId) return responseText('Missing node id', 400);
@@ -1353,7 +1383,7 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1353
1383
  // `-32601` (report #61). It is intentionally NOT openable as a site — open such
1354
1384
  // apps externally through their own app, or view them in the canvas. (Kept in sync
1355
1385
  // with canOpenNodeAsSurface in src/shared/surface.ts, which hides the UI control.)
1356
- return responseText('This MCP app renders in the canvas and cannot be opened as a standalone site.', 404);
1386
+ return responseText(surfaceRefusalMessage(node.type), 404);
1357
1387
  }
1358
1388
 
1359
1389
  if (node.type === 'webpage') {
@@ -1363,7 +1393,7 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1363
1393
  return responseText('Webpage node has no url', 404);
1364
1394
  }
1365
1395
 
1366
- return responseText('Node type cannot be opened as a site', 404);
1396
+ return responseText(surfaceRefusalMessage(node.type), 404);
1367
1397
  }
1368
1398
 
1369
1399
  // ── Serve image file for image nodes ─────────────────────────
@@ -1402,6 +1432,66 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
1402
1432
  });
1403
1433
  }
1404
1434
 
1435
+ // ── Serve raw bytes for file nodes (PDF viewer, downloads) ───
1436
+ /**
1437
+ * Ceiling for the raw byte route. The route streams via Bun.file, so this is a
1438
+ * sanity bound (an embedded PDF viewer is the only consumer), not a memory
1439
+ * guard — but it stops a stray multi-GB path from tying up a connection.
1440
+ */
1441
+ const FILE_BYTES_MAX = 100 * 1024 * 1024;
1442
+
1443
+ async function handleCanvasFileBytes(url: URL): Promise<Response> {
1444
+ const nodeId = url.searchParams.get('nodeId') ?? '';
1445
+ if (!nodeId) return responseText('Missing nodeId', 400);
1446
+ const node = canvasState.getNode(nodeId);
1447
+ if (node?.type !== 'file') {
1448
+ return responseText('File node not found', 404);
1449
+ }
1450
+ const filePath = typeof node.data.path === 'string' ? node.data.path : '';
1451
+ if (!filePath) {
1452
+ return responseText('File node is not backed by a path', 404);
1453
+ }
1454
+ // Same containment guard as the image route: `path` comes from node data,
1455
+ // which any unauthenticated local caller can set, so an unguarded read here
1456
+ // would serve arbitrary host files.
1457
+ const safePath = resolveWorkspaceArtifactPath(filePath);
1458
+ if (!safePath) {
1459
+ return responseText('File path is outside the workspace; the byte route only serves workspace files', 403);
1460
+ }
1461
+ if (!existsSync(safePath)) {
1462
+ return responseText('File not found', 404);
1463
+ }
1464
+ let stats: ReturnType<typeof statSync>;
1465
+ try {
1466
+ stats = statSync(safePath);
1467
+ } catch {
1468
+ return responseText('File not found', 404);
1469
+ }
1470
+ // Regular files only: a directory would throw EISDIR into a 500, and a FIFO
1471
+ // would block the read forever (the server has no request timeout).
1472
+ if (!stats.isFile()) {
1473
+ return responseText('File path is not a regular file', 400);
1474
+ }
1475
+ if (stats.size > FILE_BYTES_MAX) {
1476
+ return responseText(`File is larger than the ${Math.floor(FILE_BYTES_MAX / 1_000_000)} MB byte-route limit`, 413);
1477
+ }
1478
+ // Content-Type is derived from the BYTES, never from node.data.mimeType:
1479
+ // node data is freely writable by any local caller, and this response is
1480
+ // framed same-origin — echoing a caller-chosen `text/html` here would run
1481
+ // attacker markup on the canvas origin. PDFs are the only inline-rendered
1482
+ // type; everything else is an opaque download. nosniff stops the browser
1483
+ // from second-guessing us.
1484
+ const contentType = isPdfFilePath(safePath) ? 'application/pdf' : 'application/octet-stream';
1485
+ return new Response(Bun.file(safePath), {
1486
+ headers: {
1487
+ 'Content-Type': contentType,
1488
+ 'Content-Disposition': contentType === 'application/pdf' ? 'inline' : 'attachment',
1489
+ 'X-Content-Type-Options': 'nosniff',
1490
+ 'Cache-Control': 'no-store',
1491
+ },
1492
+ });
1493
+ }
1494
+
1405
1495
  async function handleJsonRenderView(url: URL): Promise<Response> {
1406
1496
  const nodeId = url.searchParams.get('nodeId') ?? '';
1407
1497
  if (!nodeId) return responseText('Missing nodeId', 400);
@@ -2285,6 +2375,11 @@ async function handleOpenExternalSurface(req: Request): Promise<Response> {
2285
2375
  if (!nodeId) return responseJson({ ok: false, error: 'nodeId is required.' }, 400);
2286
2376
  const node = canvasState.getNode(nodeId);
2287
2377
  if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
2378
+ // Same rule the surface route and the "Open as site" control apply: refuse here
2379
+ // rather than launching a browser at a URL that is guaranteed to 404 (report #61).
2380
+ if (!canOpenNodeAsSurface(node.type, node.data)) {
2381
+ return responseJson({ ok: false, opened: false, error: surfaceRefusalMessage(node.type) }, 400);
2382
+ }
2288
2383
  const port = getCanvasServerPort();
2289
2384
  if (!port) return responseJson({ ok: false, opened: false, error: 'Server port unavailable.' }, 503);
2290
2385
  const defaultSurfacePath = `/api/canvas/surface/${encodeURIComponent(nodeId)}`;
@@ -3201,6 +3296,12 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3201
3296
  return await handleCanvasImage(url.pathname);
3202
3297
  }
3203
3298
 
3299
+ // HEAD answers like GET (see the standalone-surface note above): a PDF
3300
+ // viewer probes this URL for type/length before fetching the bytes.
3301
+ if (url.pathname === '/api/canvas/file-bytes' && (req.method === 'GET' || req.method === 'HEAD')) {
3302
+ return await handleCanvasFileBytes(url);
3303
+ }
3304
+
3204
3305
  if (url.pathname === '/api/canvas/open-external' && req.method === 'POST') {
3205
3306
  return handleOpenExternalSurface(req);
3206
3307
  }
@@ -27,6 +27,19 @@ const LEGACY_SKILL_WEB_ARTIFACT_SCRIPTS_DIR = join(
27
27
  );
28
28
  const DEFAULT_TIMEOUT_MS = 10 * 60_000;
29
29
  const DEFAULT_PACKAGE_MANAGER = 'pnpm@10.33.0';
30
+ const NODE_VERSION_PROBE_TIMEOUT_MS = 15_000;
31
+ /**
32
+ * Minimum Node.js (`major.minor`) each pnpm major declares in its published
33
+ * `engines.node` — pnpm 10.x is `>=18.12`, pnpm 11.x is `>=22.13`. The pnpm that
34
+ * runs a build is not a local dependency (the scripts take it from PATH, `bun x`,
35
+ * or a global install), so its engines field cannot be read from disk; this table
36
+ * carries the same constraint keyed by the pinned major. An unlisted major
37
+ * imposes no preflight floor rather than an invented one.
38
+ */
39
+ const PNPM_MAJOR_MIN_NODE: Record<string, string> = {
40
+ '10': '18.12',
41
+ '11': '22.13',
42
+ };
30
43
  const DEFAULT_WEB_ARTIFACT_NODE_SIZE = { width: 960, height: 720 };
31
44
  const FALLBACK_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'];
32
45
  const WEB_ARTIFACT_CONTEXT_MAX_LENGTH = 1200;
@@ -470,6 +483,101 @@ function summarizeArtifactLog(text: string): WebArtifactLogSummary | undefined {
470
483
  };
471
484
  }
472
485
 
486
+ export type NodeVersionCheck = { ok: true } | { ok: false; message: string };
487
+
488
+ function parseMajorMinor(value: string): { major: number; minor: number } | null {
489
+ const match = /^v?(\d+)\.(\d+)/.exec(value.trim());
490
+ if (!match) return null;
491
+ return { major: Number(match[1]), minor: Number(match[2]) };
492
+ }
493
+
494
+ /**
495
+ * Pure preflight comparison used before any package-manager invocation.
496
+ * `detected` is raw `node --version` output ("v20.11.1"), or null when no node
497
+ * executable answered; `required` is a `major.minor` floor; `packageManager` is
498
+ * the pin that imposes it (e.g. "pnpm@11.1.2"). Returns an actionable message
499
+ * instead of throwing so every caller surfaces it on its own error channel.
500
+ */
501
+ export function checkWebArtifactNodeVersion(
502
+ detected: string | null,
503
+ required: string,
504
+ packageManager: string,
505
+ ): NodeVersionCheck {
506
+ const requiredParts = parseMajorMinor(required);
507
+ if (!requiredParts) return { ok: true };
508
+
509
+ const detectedLabel = detected === null ? null : detected.trim();
510
+ const detectedParts = detectedLabel === null ? null : parseMajorMinor(detectedLabel);
511
+ if (
512
+ detectedParts &&
513
+ (detectedParts.major > requiredParts.major ||
514
+ (detectedParts.major === requiredParts.major && detectedParts.minor >= requiredParts.minor))
515
+ ) {
516
+ return { ok: true };
517
+ }
518
+
519
+ const found =
520
+ detectedLabel && detectedParts
521
+ ? `the build environment has Node.js ${detectedLabel}`
522
+ : 'no usable `node` executable was found on the build PATH';
523
+ return {
524
+ ok: false,
525
+ message:
526
+ `Web-artifact build preflight failed: ${packageManager} requires Node.js >= ${required}, but ${found}. ` +
527
+ `Run PMX Canvas under Node.js ${required} or newer, then retry the build.`,
528
+ };
529
+ }
530
+
531
+ /** `node --version` as the build subprocesses see it, or null when node is unavailable. */
532
+ async function detectSubprocessNodeVersion(cwd: string): Promise<string | null> {
533
+ try {
534
+ const { stdout } = await runProcess('node', ['--version'], { cwd, timeoutMs: NODE_VERSION_PROBE_TIMEOUT_MS });
535
+ return stdout.trim() || null;
536
+ } catch {
537
+ // A missing or non-runnable node IS the preflight failure — reported below
538
+ // with the required version, not rethrown as a spawn error here.
539
+ return null;
540
+ }
541
+ }
542
+
543
+ /**
544
+ * The `packageManager` pin that governs this build: a reused artifact project
545
+ * carries its own pin, a fresh one gets the bundled default that init-artifact.sh
546
+ * and ensurePackageManagerBoundary write.
547
+ */
548
+ function resolvePinnedPackageManager(projectPath: string): string {
549
+ const packageJsonPath = join(projectPath, 'package.json');
550
+ if (!existsSync(packageJsonPath)) return DEFAULT_PACKAGE_MANAGER;
551
+ try {
552
+ const parsed: unknown = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
553
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
554
+ const pinned = (parsed as Record<string, unknown>).packageManager;
555
+ if (typeof pinned === 'string' && pinned.includes('@')) return pinned;
556
+ }
557
+ } catch {
558
+ // A corrupt project package.json is the init script's problem; the preflight
559
+ // falls back to the pin PMX Canvas would write anyway.
560
+ }
561
+ return DEFAULT_PACKAGE_MANAGER;
562
+ }
563
+
564
+ /**
565
+ * Fail fast when the Node.js available to the build subprocesses is older than
566
+ * the pinned package manager supports. Without this, pnpm's engine error lands
567
+ * deep inside the init/bundle script output and reads as an unrelated build
568
+ * failure (reported: pnpm 11 needs Node 22.13+, the build ran on Node 20).
569
+ */
570
+ async function preflightBuildRuntime(cwd: string, projectPath: string): Promise<void> {
571
+ const packageManager = resolvePinnedPackageManager(projectPath);
572
+ const [name, version] = packageManager.split('@');
573
+ if (name !== 'pnpm') return;
574
+ const required = PNPM_MAJOR_MIN_NODE[version.split('.')[0]];
575
+ if (!required) return;
576
+
577
+ const check = checkWebArtifactNodeVersion(await detectSubprocessNodeVersion(cwd), required, packageManager);
578
+ if (!check.ok) throw new Error(check.message);
579
+ }
580
+
473
581
  export async function executeWebArtifactBuild(input: WebArtifactBuildInput): Promise<WebArtifactBuildOutput> {
474
582
  const workspaceRoot = currentWorkspaceRoot();
475
583
  const artifactsDir = ensureArtifactsDir(workspaceRoot);
@@ -490,6 +598,7 @@ export async function executeWebArtifactBuild(input: WebArtifactBuildInput): Pro
490
598
  const parentDir = dirname(projectPath);
491
599
  mkdirSync(parentDir, { recursive: true });
492
600
  ensurePackageManagerBoundary(parentDir);
601
+ await preflightBuildRuntime(parentDir, projectPath);
493
602
 
494
603
  let stdout = '';
495
604
  let stderr = '';
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The node-data contract for a materialized AX flow.
3
+ *
4
+ * `ax.flow.materialize` stamps every step node with `data.axStep` and the anchor
5
+ * (first) step with `data.axFlow`. Three consumers read those stamps and none of
6
+ * them owns the shape, so it lives here:
7
+ * - the server loop (`ax-flow-loop.ts`) advances the flow from work-item changes
8
+ * - the browser renders native Start/Done/Blocked + Run loop/Stop controls
9
+ * - snapshots/restore carry the stamps as ordinary node data
10
+ *
11
+ * Node data is `Record<string, unknown>` on both sides, so the readers below are
12
+ * the ONLY way to narrow it: they validate rather than cast, because a stamp can
13
+ * also arrive from a restored snapshot or a hand-edited node.
14
+ */
15
+
16
+ /** Hard ceiling on loop passes, whatever a node's stamped `maxRuns` claims. */
17
+ export const AX_FLOW_LOOP_HARD_CAP = 20;
18
+
19
+ /** One step of a flow, as recorded on the anchor node. */
20
+ export interface AxFlowStepRef {
21
+ /** 1-based position in the flow. */
22
+ index: number;
23
+ nodeId: string;
24
+ workItemId: string;
25
+ title: string;
26
+ }
27
+
28
+ export interface AxFlowLoopState {
29
+ /** The loop only advances while this is true; Stop persists `false`. */
30
+ running: boolean;
31
+ /** Completed passes. `run >= maxRuns` ends the loop. */
32
+ run: number;
33
+ maxRuns: number;
34
+ }
35
+
36
+ /** `data.axStep` — stamped on EVERY step node, including the anchor. */
37
+ export interface AxFlowStepStamp {
38
+ flowId: string;
39
+ index: number;
40
+ total: number;
41
+ workItemId: string;
42
+ }
43
+
44
+ /** `data.axFlow` — stamped on the anchor (first) step node only. */
45
+ export interface AxFlowStamp {
46
+ flowId: string;
47
+ title: string;
48
+ steps: AxFlowStepRef[];
49
+ loop: AxFlowLoopState;
50
+ }
51
+
52
+ /** Clamp a stamped `maxRuns` into [1, AX_FLOW_LOOP_HARD_CAP]. */
53
+ export function clampAxFlowMaxRuns(value: unknown): number {
54
+ const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 1;
55
+ return Math.max(1, Math.min(AX_FLOW_LOOP_HARD_CAP, n));
56
+ }
57
+
58
+ function isRecord(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
60
+ }
61
+
62
+ function nonEmptyString(value: unknown): value is string {
63
+ return typeof value === 'string' && value.length > 0;
64
+ }
65
+
66
+ /** Read `data.axStep`, or null when the node carries no valid step stamp. */
67
+ export function readAxStep(data: Record<string, unknown>): AxFlowStepStamp | null {
68
+ const step = data.axStep;
69
+ if (!isRecord(step)) return null;
70
+ if (!nonEmptyString(step.flowId) || !nonEmptyString(step.workItemId)) return null;
71
+ if (typeof step.index !== 'number' || typeof step.total !== 'number') return null;
72
+ return { flowId: step.flowId, index: step.index, total: step.total, workItemId: step.workItemId };
73
+ }
74
+
75
+ /** Read `data.axFlow`, or null when the node is not a flow anchor. */
76
+ export function readAxFlow(data: Record<string, unknown>): AxFlowStamp | null {
77
+ const flow = data.axFlow;
78
+ if (!isRecord(flow) || !nonEmptyString(flow.flowId) || !Array.isArray(flow.steps)) return null;
79
+ const steps: AxFlowStepRef[] = [];
80
+ for (const raw of flow.steps) {
81
+ if (!isRecord(raw) || !nonEmptyString(raw.nodeId) || !nonEmptyString(raw.workItemId)) return null;
82
+ if (typeof raw.index !== 'number') return null;
83
+ steps.push({
84
+ index: raw.index,
85
+ nodeId: raw.nodeId,
86
+ workItemId: raw.workItemId,
87
+ title: typeof raw.title === 'string' ? raw.title : '',
88
+ });
89
+ }
90
+ if (steps.length === 0) return null;
91
+ const loop = isRecord(flow.loop) ? flow.loop : {};
92
+ return {
93
+ flowId: flow.flowId,
94
+ title: typeof flow.title === 'string' ? flow.title : 'Flow',
95
+ steps,
96
+ loop: {
97
+ running: loop.running === true,
98
+ run: typeof loop.run === 'number' && Number.isFinite(loop.run) ? Math.max(0, Math.floor(loop.run)) : 0,
99
+ maxRuns: clampAxFlowMaxRuns(loop.maxRuns),
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-type minimum node size — the readability floor.
3
+ *
4
+ * Agents frequently create nodes with explicit frames far too small for their
5
+ * content (clipped markdown, charts squeezed behind inner scrollbars), so the
6
+ * server clamps undersized explicit sizes UP at creation and `validate` reports
7
+ * anything below the floor.
8
+ *
9
+ * This lives in `shared/` because BOTH sides must honor it. The 0.4.6 report's
10
+ * Finding AA: the server clamped a 200x100 markdown node up to 360x180, and
11
+ * then the browser's DOM auto-fit measured the (short) content and persisted
12
+ * 360x132 — undoing the guarantee on the most common path, a connected
13
+ * workbench. A floor only one side knows about is not a floor.
14
+ *
15
+ * `strictSize: true` is the escape hatch for a deliberately small fixed frame,
16
+ * and it exempts a node from both the creation clamp and the auto-fit floor.
17
+ * Types absent from the table are intentionally unclamped — `trace` is small by
18
+ * design, `group` sizes to its children.
19
+ */
20
+ export const NODE_MIN_SIZES: Record<string, { width: number; height: number }> = {
21
+ markdown: { width: 360, height: 180 },
22
+ context: { width: 360, height: 180 },
23
+ file: { width: 360, height: 200 },
24
+ diff: { width: 420, height: 240 },
25
+ mermaid: { width: 360, height: 240 },
26
+ status: { width: 280, height: 120 },
27
+ ledger: { width: 320, height: 200 },
28
+ image: { width: 240, height: 180 },
29
+ html: { width: 420, height: 280 },
30
+ webpage: { width: 420, height: 280 },
31
+ 'json-render': { width: 420, height: 280 },
32
+ graph: { width: 420, height: 280 },
33
+ 'mcp-app': { width: 480, height: 320 },
34
+ };
35
+
36
+ /** The readability floor for a node type, or undefined when it has none. */
37
+ export function nodeMinSize(type: string): { width: number; height: number } | undefined {
38
+ return NODE_MIN_SIZES[type];
39
+ }
40
+
41
+ /** Creation-time clamp: an explicit size below the floor is raised to it. */
42
+ export function clampCreateNodeSize(
43
+ type: string,
44
+ width: number,
45
+ height: number,
46
+ strictSize?: boolean,
47
+ ): { width: number; height: number } {
48
+ if (strictSize === true) return { width, height };
49
+ const min = nodeMinSize(type);
50
+ if (!min) return { width, height };
51
+ return { width: Math.max(width, min.width), height: Math.max(height, min.height) };
52
+ }
@@ -1,29 +0,0 @@
1
- /**
2
- * Chart HTML template generator — produces self-contained ext-app HTML
3
- * documents that render interactive Chart.js charts inside the canvas
4
- * ExtAppFrame iframe.
5
- *
6
- * The generated HTML:
7
- * 1. Renders immediately from inline data (no bridge needed)
8
- * 2. Connects to host AppBridge via the embedded ext-app App SDK runtime
9
- * 3. Accepts updated data via toolInput for re-rendering
10
- */
11
- export interface ChartDataset {
12
- label: string;
13
- values: number[];
14
- color?: string;
15
- }
16
- export interface ChartConfig {
17
- title: string;
18
- chartType: 'bar' | 'line' | 'pie' | 'scatter' | 'doughnut' | 'radar';
19
- labels: string[];
20
- datasets: ChartDataset[];
21
- xAxisLabel?: string;
22
- yAxisLabel?: string;
23
- stacked?: boolean;
24
- }
25
- /**
26
- * Generate a self-contained HTML document that renders a Chart.js chart
27
- * and optionally connects to the host via the ext-app App SDK.
28
- */
29
- export declare function generateChartHtml(config: ChartConfig): string;