pmx-canvas 0.6.5 → 0.7.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 (169) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +54 -0
  3. package/Readme.md +29 -5
  4. package/dist/canvas/global.css +440 -24
  5. package/dist/canvas/index.js +90 -75
  6. package/dist/types/cli/agent.d.ts +3 -0
  7. package/dist/types/cli/commands/backup.d.ts +1 -0
  8. package/dist/types/cli/commands/boards.d.ts +1 -0
  9. package/dist/types/cli/commands/export.d.ts +1 -0
  10. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  11. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  12. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  13. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  14. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  15. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  16. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  17. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  18. package/dist/types/client/state/boards-store.d.ts +79 -0
  19. package/dist/types/client/state/canvas-store.d.ts +2 -0
  20. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  21. package/dist/types/client/state/session-store.d.ts +7 -0
  22. package/dist/types/client/types.d.ts +2 -2
  23. package/dist/types/json-render/catalog.d.ts +1 -1
  24. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  25. package/dist/types/json-render/server.d.ts +6 -0
  26. package/dist/types/mcp/canvas-access.d.ts +10 -3
  27. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  28. package/dist/types/server/agent-presence.d.ts +1 -1
  29. package/dist/types/server/attribution.d.ts +11 -0
  30. package/dist/types/server/ax-context.d.ts +3 -1
  31. package/dist/types/server/ax-interaction.d.ts +2 -2
  32. package/dist/types/server/ax-state.d.ts +13 -3
  33. package/dist/types/server/backup.d.ts +36 -0
  34. package/dist/types/server/board-export.d.ts +45 -0
  35. package/dist/types/server/canvas-db.d.ts +63 -9
  36. package/dist/types/server/canvas-operations.d.ts +23 -0
  37. package/dist/types/server/canvas-provenance.d.ts +1 -1
  38. package/dist/types/server/canvas-state.d.ts +140 -3
  39. package/dist/types/server/context-brief.d.ts +111 -0
  40. package/dist/types/server/context-reads.d.ts +55 -0
  41. package/dist/types/server/document-import.d.ts +66 -0
  42. package/dist/types/server/index.d.ts +103 -4
  43. package/dist/types/server/mutation-history.d.ts +1 -0
  44. package/dist/types/server/operations/invoker.d.ts +2 -0
  45. package/dist/types/server/operations/mcp.d.ts +7 -1
  46. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  47. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  48. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  49. package/dist/types/server/operations/ops/export.d.ts +2 -0
  50. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  51. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  52. package/dist/types/server/operations/ops/query.d.ts +2 -0
  53. package/dist/types/server/operations/registry.d.ts +9 -1
  54. package/dist/types/server/operations/types.d.ts +1 -1
  55. package/dist/types/server/server.d.ts +1 -0
  56. package/dist/types/server/trace-manager.d.ts +7 -0
  57. package/dist/types/server/webpage-node.d.ts +22 -1
  58. package/dist/types/server/workbench-auth.d.ts +3 -0
  59. package/dist/types/shared/boards.d.ts +6 -0
  60. package/docs/RELEASE.md +5 -0
  61. package/docs/cli.md +68 -0
  62. package/docs/http-api.md +198 -2
  63. package/docs/mcp.md +71 -6
  64. package/docs/node-types.md +26 -0
  65. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  66. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  67. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  68. package/docs/plans/plan-013-static-board-export.md +47 -0
  69. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  70. package/docs/product-vision-2026-09.md +118 -18
  71. package/docs/sdk.md +50 -0
  72. package/package.json +1 -1
  73. package/skills/pmx-canvas/SKILL.md +53 -4
  74. package/skills/pmx-canvas/references/full-reference.md +43 -7
  75. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  76. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  77. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  78. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  79. package/src/cli/agent.ts +4 -0
  80. package/src/cli/commands/ax.ts +13 -0
  81. package/src/cli/commands/backup.ts +66 -0
  82. package/src/cli/commands/boards.ts +71 -0
  83. package/src/cli/commands/export.ts +30 -0
  84. package/src/cli/commands/nodes.ts +4 -1
  85. package/src/cli/commands/pins.ts +6 -2
  86. package/src/cli/commands/pump.ts +3 -0
  87. package/src/cli/commands/query.ts +33 -7
  88. package/src/cli/commands/view.ts +7 -5
  89. package/src/cli/index.ts +19 -9
  90. package/src/client/App.tsx +21 -3
  91. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  92. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  93. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  94. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  95. package/src/client/canvas/CanvasViewport.tsx +3 -0
  96. package/src/client/canvas/CommandPalette.tsx +107 -4
  97. package/src/client/canvas/ContextMenu.tsx +4 -0
  98. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  99. package/src/client/canvas/ExportDialog.tsx +197 -0
  100. package/src/client/canvas/HomeView.tsx +277 -0
  101. package/src/client/canvas/SessionPanel.tsx +28 -1
  102. package/src/client/canvas/ToolRail.tsx +38 -3
  103. package/src/client/canvas/TopBar.tsx +34 -18
  104. package/src/client/canvas/import-files.ts +19 -1
  105. package/src/client/canvas/kind-colors.ts +1 -0
  106. package/src/client/icons.tsx +2 -0
  107. package/src/client/nodes/AttachmentNode.tsx +148 -0
  108. package/src/client/nodes/BoardNode.tsx +35 -0
  109. package/src/client/nodes/FileNode.tsx +6 -0
  110. package/src/client/nodes/MarkdownNode.tsx +47 -19
  111. package/src/client/nodes/ax-node-actions.ts +2 -5
  112. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  113. package/src/client/state/boards-store.ts +167 -0
  114. package/src/client/state/canvas-store.ts +22 -0
  115. package/src/client/state/intent-bridge.ts +30 -2
  116. package/src/client/state/node-factory.ts +1 -0
  117. package/src/client/state/session-store.ts +5 -0
  118. package/src/client/state/sse-bridge.ts +49 -5
  119. package/src/client/theme/global.css +440 -24
  120. package/src/client/types.ts +2 -0
  121. package/src/json-render/server.ts +16 -9
  122. package/src/mcp/canvas-access.ts +59 -22
  123. package/src/mcp/context-uri-template.ts +28 -0
  124. package/src/mcp/server.ts +122 -15
  125. package/src/server/agent-context.ts +116 -29
  126. package/src/server/attribution.ts +29 -0
  127. package/src/server/ax-context.ts +4 -2
  128. package/src/server/ax-interaction.ts +1 -0
  129. package/src/server/ax-state-manager.ts +72 -3
  130. package/src/server/ax-state.ts +41 -0
  131. package/src/server/backup.ts +178 -0
  132. package/src/server/board-export.ts +686 -0
  133. package/src/server/canvas-db.ts +599 -107
  134. package/src/server/canvas-operations.ts +68 -9
  135. package/src/server/canvas-provenance.ts +1 -0
  136. package/src/server/canvas-schema.ts +12 -0
  137. package/src/server/canvas-state.ts +774 -45
  138. package/src/server/context-brief.ts +372 -0
  139. package/src/server/context-reads.ts +238 -0
  140. package/src/server/demo-state.json +659 -2
  141. package/src/server/document-import.ts +223 -0
  142. package/src/server/index.ts +231 -5
  143. package/src/server/mutation-history.ts +10 -13
  144. package/src/server/operations/composites.ts +26 -4
  145. package/src/server/operations/http.ts +9 -3
  146. package/src/server/operations/index.ts +8 -0
  147. package/src/server/operations/invoker.ts +14 -3
  148. package/src/server/operations/mcp.ts +50 -11
  149. package/src/server/operations/ops/app.ts +5 -0
  150. package/src/server/operations/ops/ax-read.ts +10 -6
  151. package/src/server/operations/ops/ax-timeline.ts +75 -0
  152. package/src/server/operations/ops/backup.ts +134 -0
  153. package/src/server/operations/ops/batch.ts +8 -1
  154. package/src/server/operations/ops/boards.ts +351 -0
  155. package/src/server/operations/ops/export.ts +74 -0
  156. package/src/server/operations/ops/ext-app.ts +4 -0
  157. package/src/server/operations/ops/imports.ts +220 -0
  158. package/src/server/operations/ops/nodes.ts +57 -5
  159. package/src/server/operations/ops/query.ts +206 -9
  160. package/src/server/operations/registry.ts +123 -31
  161. package/src/server/operations/types.ts +1 -1
  162. package/src/server/server.ts +146 -2
  163. package/src/server/spatial-analysis.ts +9 -17
  164. package/src/server/trace-manager.ts +19 -0
  165. package/src/server/web-artifacts.ts +2 -1
  166. package/src/server/webpage-node.ts +173 -13
  167. package/src/server/workbench-auth.ts +11 -0
  168. package/src/shared/boards.ts +18 -0
  169. package/src/shared/node-sizes.ts +1 -0
@@ -23,6 +23,7 @@
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
25
  * - GET /api/canvas/file-bytes?nodeId=... -> raw bytes for a file node
26
+ * - GET /api/canvas/exports/<file> -> a written static board export (sandboxed)
26
27
  * - POST /api/canvas/web-artifact -> build bundled HTML artifact + optional canvas node
27
28
  * - GET /api/workbench/events -> SSE event stream
28
29
  * - GET /api/workbench/poll -> proxy-safe polling transport (same events, JSON)
@@ -38,9 +39,11 @@
38
39
 
39
40
  import { spawnSync } from 'node:child_process';
40
41
  import { randomUUID } from 'node:crypto';
42
+ import { isWorkbenchToken, workbenchToken } from './workbench-auth.js';
43
+ import { withCurrentActor } from './attribution.js';
41
44
  import { existsSync, readFileSync, realpathSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
42
45
  import { readFile } from 'node:fs/promises';
43
- import { basename, extname, join, relative, resolve } from 'node:path';
46
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
44
47
  import * as marked from 'marked';
45
48
  import { type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
46
49
  import {
@@ -67,11 +70,13 @@ import { canvasThemeScheme, isCanvasTheme } from '../shared/themes.js';
67
70
  import { canOpenNodeAsSurface } from '../shared/surface.js';
68
71
  import { validateLocalImageFile } from './image-source.js';
69
72
  import {
73
+ addCanvasNode,
70
74
  cancelCodeGraphRecompute,
71
75
  primeCanvasRuntimeBackends,
72
76
  setCanvasLayoutUpdateEmitter,
73
77
  syncCanvasRuntimeBackends,
74
78
  } from './canvas-operations.js';
79
+ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_NAME_LENGTH } from './document-import.js';
75
80
  import { dispatchOperationRoute, setOperationEventEmitter } from './operations/index.js';
76
81
  import { intentRegistry } from './intent-registry.js';
77
82
  import { agentPresence } from './agent-presence.js';
@@ -79,6 +84,8 @@ import { HUMAN_STARTED_SESSION_LABEL } from '../shared/agent-presence.js';
79
84
  import { diffLayouts } from './mutation-history.js';
80
85
  import { humanPresence } from './human-presence.js';
81
86
  import { startGateTtlSweeper, stopGateTtlSweeper } from './ax-gate-ttl.js';
87
+ import { startBackupScheduler, stopBackupScheduler } from './backup.js';
88
+ import { exportsFolder } from './board-export.js';
82
89
  import { setWebviewRunner } from './operations/webview-runner.js';
83
90
  import { closeNodeAppSession, nodeAppSessionId } from './operations/ops/nodes.js';
84
91
  import { traceManager } from './trace-manager.js';
@@ -971,6 +978,39 @@ function normalizeMarkdownExternalUrls(markdown: string): string {
971
978
  const CANVAS_ASSET_VERSION = Date.now().toString(36);
972
979
  const MAX_FRAME_DOCUMENTS = 128;
973
980
  const MAX_FRAME_DOCUMENT_BYTES = 5 * 1024 * 1024;
981
+
982
+ async function readBoundedBody(req: Request, limit: number): Promise<Uint8Array | null> {
983
+ if (!req.body) return new Uint8Array();
984
+ const reader = req.body.getReader();
985
+ const chunks: Uint8Array[] = [];
986
+ let size = 0;
987
+ try {
988
+ while (true) {
989
+ const { done, value } = await reader.read();
990
+ if (done) break;
991
+ size += value.byteLength;
992
+ if (size > limit) {
993
+ await reader.cancel();
994
+ return null;
995
+ }
996
+ chunks.push(value);
997
+ }
998
+ } finally {
999
+ reader.releaseLock();
1000
+ }
1001
+ const bytes = new Uint8Array(size);
1002
+ let offset = 0;
1003
+ for (const chunk of chunks) {
1004
+ bytes.set(chunk, offset);
1005
+ offset += chunk.byteLength;
1006
+ }
1007
+ return bytes;
1008
+ }
1009
+
1010
+ function attachmentDisposition(name: string): string {
1011
+ const fallback = name.replace(/[^\x20-\x7e]|["\\]/g, '_');
1012
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(name)}`;
1013
+ }
974
1014
  const DEFAULT_FRAME_DOCUMENT_SANDBOX = 'allow-scripts';
975
1015
  const SAFE_FRAME_DOCUMENT_SANDBOX_TOKENS = new Set([
976
1016
  'allow-downloads',
@@ -1112,6 +1152,7 @@ function canvasSpaHtml(): string {
1112
1152
  }
1113
1153
  <script>window.__PMX_BOOT_SERVER_VERSION = ${JSON.stringify(serverPackageVersion())};</script>
1114
1154
  <script>window.__PMX_BOOT_BUNDLE_STAMP = ${JSON.stringify(canvasBundleStamp())};</script>
1155
+ <script>window.__PMX_WORKBENCH_TOKEN = ${JSON.stringify(workbenchToken)};</script>
1115
1156
  <script type="module" src="/canvas/index.js?v=${CANVAS_ASSET_VERSION}"></script>
1116
1157
  </body>
1117
1158
  </html>`;
@@ -1558,6 +1599,39 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
1558
1599
  });
1559
1600
  }
1560
1601
 
1602
+ // ── Serve a written board export (plan 013) ─────────────────
1603
+ // Only names the exporter writes, only from the exports folder. The page runs
1604
+ // its own viewer script, so it is served with an opaque-origin sandbox: it can
1605
+ // never reach this server's API as the workbench.
1606
+ const EXPORT_FILE_NAME = /^[a-z0-9-]+-\d{8}T\d{9}Z(?:-\d+)?\.html$/;
1607
+
1608
+ function handleBoardExportFile(url: URL): Response {
1609
+ const name = decodeURIComponent(url.pathname.slice('/api/canvas/exports/'.length));
1610
+ const folder = exportsFolder();
1611
+ if (!folder || !EXPORT_FILE_NAME.test(name)) return responseText('Export not found', 404);
1612
+ let path: string;
1613
+ try {
1614
+ const realFolder = realpathSync(folder);
1615
+ path = realpathSync(join(folder, name));
1616
+ const rel = relative(realFolder, path);
1617
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) || !statSync(path).isFile()) {
1618
+ return responseText('Export not found', 404);
1619
+ }
1620
+ } catch {
1621
+ return responseText('Export not found', 404);
1622
+ }
1623
+ const download = url.searchParams.get('download') === '1';
1624
+ return new Response(Bun.file(path), {
1625
+ headers: {
1626
+ 'Content-Type': 'text/html; charset=utf-8',
1627
+ 'Content-Security-Policy': 'sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox',
1628
+ 'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${name}"`,
1629
+ 'X-Content-Type-Options': 'nosniff',
1630
+ 'Cache-Control': 'no-store',
1631
+ },
1632
+ });
1633
+ }
1634
+
1561
1635
  // ── Serve raw bytes for file nodes (PDF viewer, downloads) ───
1562
1636
  /**
1563
1637
  * Ceiling for the raw byte route. The route streams via Bun.file, so this is a
@@ -3220,6 +3294,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3220
3294
  const loaded = canvasState.loadFromDisk({ clearExisting: true });
3221
3295
  // Unattended-approval policy: hold pending gates whose TTL elapsed.
3222
3296
  startGateTtlSweeper((event, payload) => emitPrimaryWorkbenchEvent(event, payload));
3297
+ startBackupScheduler();
3223
3298
  setCanvasLayoutUpdateEmitter(() => {
3224
3299
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
3225
3300
  });
@@ -3373,6 +3448,69 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3373
3448
  // theme/update/viewport/annotation/refresh (plan-009 C1 slices 1-3). A
3374
3449
  // null return falls through to the remaining hand-written routes
3375
3450
  // (SSE, binary, HTML/static serving, and the server-coupled handlers).
3451
+ if (url.pathname === '/api/canvas/attachments' && req.method === 'POST') {
3452
+ const boardId = url.searchParams.get('boardId')?.trim() ?? '';
3453
+ const name = url.searchParams.get('name')?.trim() ?? '';
3454
+ const mime = url.searchParams.get('mime')?.trim() || 'application/octet-stream';
3455
+ const x = Number(url.searchParams.get('x') ?? 40);
3456
+ const y = Number(url.searchParams.get('y') ?? 40);
3457
+ if (!boardId || !name || !Number.isFinite(x) || !Number.isFinite(y))
3458
+ return responseJson({ ok: false, error: 'boardId, name, and finite x/y are required.' }, 400);
3459
+ if (name.length > MAX_ATTACHMENT_NAME_LENGTH)
3460
+ return responseJson(
3461
+ { ok: false, error: `Attachment name exceeds ${MAX_ATTACHMENT_NAME_LENGTH} characters.` },
3462
+ 400,
3463
+ );
3464
+ if (Number(req.headers.get('content-length') ?? 0) > MAX_ATTACHMENT_BYTES)
3465
+ return responseJson({ ok: false, error: 'Attachment exceeds the 20 MiB limit.' }, 413);
3466
+ const bytes = await readBoundedBody(req, MAX_ATTACHMENT_BYTES);
3467
+ if (!bytes) return responseJson({ ok: false, error: 'Attachment exceeds the 20 MiB limit.' }, 413);
3468
+ if (bytes.byteLength === 0) return responseJson({ ok: false, error: 'Attachment is empty.' }, 400);
3469
+ if (canvasState.activeBoardId !== boardId)
3470
+ return responseJson({ ok: false, error: 'The captured target board is no longer active.' }, 409);
3471
+ if (!isWorkbenchToken(req.headers.get('x-pmx-workbench-token')))
3472
+ return responseJson({ ok: false, error: 'A trusted human upload is required.' }, 403);
3473
+ const attachment = withCurrentActor({ actor: 'human', source: 'browser' }, () =>
3474
+ canvasState.storeAttachment({ boardId, name, mime, bytes }),
3475
+ );
3476
+ if (!attachment) return responseJson({ ok: false, error: 'Board not found.' }, 404);
3477
+ const { node } = withCurrentActor({ actor: 'human', source: 'browser' }, () =>
3478
+ addCanvasNode({
3479
+ type: 'file',
3480
+ title: name,
3481
+ data: { attachmentId: attachment.id, mime, size: attachment.size },
3482
+ x,
3483
+ y,
3484
+ defaultWidth: 440,
3485
+ defaultHeight: 360,
3486
+ fileMode: 'auto',
3487
+ }),
3488
+ );
3489
+ const created = canvasState.createDocumentImport(attachment.id, { x, y: y + 400 });
3490
+ const documentImport = created;
3491
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
3492
+ return responseJson({ ok: true, attachment, nodeId: node.id, import: documentImport }, 201);
3493
+ }
3494
+
3495
+ const attachmentBytesMatch = url.pathname.match(/^\/api\/canvas\/attachments\/([^/]+)\/bytes$/);
3496
+ if (attachmentBytesMatch && (req.method === 'GET' || req.method === 'HEAD')) {
3497
+ const id = decodeURIComponent(attachmentBytesMatch[1]!);
3498
+ const attachment = canvasState.getAttachment(id);
3499
+ if (!attachment || !canvasState.listBoards().some((board) => board.id === attachment.boardId))
3500
+ return responseJson({ ok: false, error: 'Attachment not found.' }, 404);
3501
+ const bytes = canvasState.readAttachmentBytes(id);
3502
+ if (!bytes) return responseJson({ ok: false, error: 'Attachment bytes are missing.' }, 404);
3503
+ return new Response(req.method === 'HEAD' ? null : new Blob([Uint8Array.from(bytes)]), {
3504
+ headers: {
3505
+ 'Content-Type': 'application/octet-stream',
3506
+ 'Content-Length': String(bytes.byteLength),
3507
+ 'Content-Disposition': attachmentDisposition(attachment.name),
3508
+ 'X-Content-Type-Options': 'nosniff',
3509
+ 'Cache-Control': 'no-store',
3510
+ },
3511
+ });
3512
+ }
3513
+
3376
3514
  if (url.pathname.startsWith('/api/')) {
3377
3515
  const operationResponse = await dispatchOperationRoute(req, url);
3378
3516
  if (operationResponse) return operationResponse;
@@ -3396,6 +3534,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3396
3534
 
3397
3535
  // HEAD answers like GET (see the standalone-surface note above): a PDF
3398
3536
  // viewer probes this URL for type/length before fetching the bytes.
3537
+ if (url.pathname.startsWith('/api/canvas/exports/') && (req.method === 'GET' || req.method === 'HEAD')) {
3538
+ return handleBoardExportFile(url);
3539
+ }
3540
+
3399
3541
  if (url.pathname === '/api/canvas/file-bytes' && (req.method === 'GET' || req.method === 'HEAD')) {
3400
3542
  return await handleCanvasFileBytes(url);
3401
3543
  }
@@ -3431,11 +3573,13 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3431
3573
  }
3432
3574
 
3433
3575
  export function stopCanvasServer(): void {
3576
+ // A failed flush must leave the live server and its runtime state available for recovery.
3577
+ canvasState.close();
3434
3578
  stopGateTtlSweeper();
3579
+ stopBackupScheduler();
3435
3580
  agentPresence.reset();
3436
3581
  humanPresence.reset();
3437
3582
  intentRegistry.reset();
3438
- canvasState.close();
3439
3583
  closeAllMcpAppSessions();
3440
3584
  cancelCodeGraphRecompute();
3441
3585
  setCanvasLayoutUpdateEmitter(null);
@@ -306,14 +306,12 @@ export function searchNodes(
306
306
 
307
307
  for (const node of nodes) {
308
308
  const title = ((node.data.title as string) ?? '').toLowerCase();
309
- const content = (
310
- (node.data.content as string) ??
311
- (node.data.agentSummary as string) ??
312
- (node.data.contentSummary as string) ??
313
- (node.data.description as string) ??
314
- (node.data.fileContent as string) ??
315
- ''
316
- ).toLowerCase();
309
+ // Search the same meaning delivered to agents, without a brief's truncation.
310
+ const fullContent = summarizeNodeForAgentContext(node, {
311
+ defaultTextLength: Number.POSITIVE_INFINITY,
312
+ webpageTextLength: Number.POSITIVE_INFINITY,
313
+ });
314
+ const content = fullContent.toLowerCase();
317
315
  const path = ((node.data.path as string) ?? '').toLowerCase();
318
316
  const description = ((node.data.description as string) ?? '').toLowerCase();
319
317
  const summary = (
@@ -337,16 +335,10 @@ export function searchNodes(
337
335
 
338
336
  if (score === 0) continue;
339
337
 
340
- // Extract a snippet around the first match in content
338
+ // Any query term may have matched, not necessarily the first one.
341
339
  let snippet = '';
342
- const fullContent =
343
- (node.data.content as string) ??
344
- (node.data.agentSummary as string) ??
345
- (node.data.contentSummary as string) ??
346
- (node.data.description as string) ??
347
- (node.data.fileContent as string) ??
348
- '';
349
- const matchIdx = fullContent.toLowerCase().indexOf(terms[0]);
340
+ const matches = terms.map((term) => content.indexOf(term)).filter((index) => index >= 0);
341
+ const matchIdx = matches.length ? Math.min(...matches) : -1;
350
342
  if (matchIdx >= 0) {
351
343
  const start = Math.max(0, matchIdx - 40);
352
344
  const end = Math.min(fullContent.length, matchIdx + 80);
@@ -88,6 +88,7 @@ class TraceManager {
88
88
  mcpToolName?: string | null;
89
89
  }): void {
90
90
  if (!this._enabled) return;
91
+ this.dropStaleChain();
91
92
 
92
93
  const id = nextTraceNodeId();
93
94
  const category = categorize(payload.name, payload.mcpServerName);
@@ -178,6 +179,7 @@ class TraceManager {
178
179
 
179
180
  onSubagentStarted(payload: { agentName: string; agentDisplayName?: string }): void {
180
181
  if (!this._enabled) return;
182
+ this.dropStaleChain();
181
183
 
182
184
  const id = nextTraceNodeId();
183
185
  const origin = this.getOrigin();
@@ -264,6 +266,23 @@ class TraceManager {
264
266
  this.broadcastUpdate();
265
267
  }
266
268
 
269
+ /**
270
+ * The chain's last node is gone (another board was opened, or it was
271
+ * deleted): start a new chain instead of wiring edges to a missing node.
272
+ */
273
+ private dropStaleChain(): void {
274
+ if (this.lastTraceNodeId && !canvasState.getNode(this.lastTraceNodeId)) this.resetChain();
275
+ }
276
+
277
+ /** Forget the trace chain without touching nodes. */
278
+ resetChain(): void {
279
+ this.traceNodeIds = [];
280
+ this.lastTraceNodeId = null;
281
+ this.toolCallToNodeId.clear();
282
+ this.traceOrigin = null;
283
+ this.chainIndex = 0;
284
+ }
285
+
267
286
  clearTrace(): void {
268
287
  const traceNodeIds = new Set(this.traceNodeIds);
269
288
  for (const node of canvasState.getLayout().nodes) {
@@ -754,6 +754,7 @@ export async function buildWebArtifactOnCanvas(
754
754
  openInCanvas?: boolean;
755
755
  },
756
756
  ): Promise<WebArtifactCanvasBuildResult> {
757
+ const boardGeneration = canvasState.boardGeneration;
757
758
  const startedMs = Date.now();
758
759
  const startedAt = new Date(startedMs).toISOString();
759
760
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
@@ -765,7 +766,7 @@ export async function buildWebArtifactOnCanvas(
765
766
  durationMs: completedMs - startedMs,
766
767
  timeoutMs,
767
768
  };
768
- if (input.openInCanvas === false) {
769
+ if (input.openInCanvas === false || canvasState.boardGeneration !== boardGeneration) {
769
770
  return { ...build, openedInCanvas: false, ...timing };
770
771
  }
771
772
  const opened = openWebArtifactInCanvas({
@@ -1,7 +1,14 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { request as httpRequest } from 'node:http';
3
+ import { request as httpsRequest } from 'node:https';
4
+ import { isIP } from 'node:net';
5
+ import { Readable } from 'node:stream';
6
+
1
7
  const FETCH_TIMEOUT_MS = 10000;
2
8
  const MAX_HTML_LENGTH = 1_000_000;
3
9
  const MAX_TEXT_LENGTH = 50_000;
4
10
  const EXCERPT_LENGTH = 420;
11
+ const MAX_REDIRECTS = 5;
5
12
 
6
13
  export const WEBPAGE_NODE_DEFAULT_SIZE = {
7
14
  width: 520,
@@ -236,24 +243,177 @@ export function summarizeWebpageContent(data: Record<string, unknown>, maxLength
236
243
  return parts.join('\n').trim();
237
244
  }
238
245
 
239
- export async function fetchWebpageSnapshot(inputUrl: string): Promise<WebpageSnapshot> {
246
+ export type AddressScope = 'public' | 'private' | 'link-local';
247
+
248
+ /**
249
+ * Where an IP literal points. `private` covers loopback, RFC 1918, CGNAT and
250
+ * IPv6 ULA; `link-local` is kept apart because it holds cloud metadata
251
+ * (169.254.169.254) and is refused on every hop.
252
+ */
253
+ export function classifyAddress(address: string): AddressScope {
254
+ let ip = address.toLowerCase().replace(/^\[|\]$/g, '');
255
+ // URL canonicalization also covers expanded IPv6 and dotted mapped IPv4.
256
+ // Checking only ::ffff:a.b.c.d misses the hex form used by URL.hostname.
257
+ if (isIP(ip) === 6) ip = new URL(`http://[${ip}]/`).hostname.slice(1, -1);
258
+ const mapped = ip.match(/^::ffff:([\da-f]+):([\da-f]+)$/);
259
+ if (mapped) {
260
+ const high = Number.parseInt(mapped[1], 16);
261
+ const low = Number.parseInt(mapped[2], 16);
262
+ return classifyAddress(`${high >>> 8}.${high & 255}.${low >>> 8}.${low & 255}`);
263
+ }
264
+ if (isIP(ip) === 4) {
265
+ const [a, b] = ip.split('.').map(Number);
266
+ if (a === 169 && b === 254) return 'link-local';
267
+ if (a === 127 || a === 10 || a === 0) return 'private';
268
+ if (a === 172 && b >= 16 && b <= 31) return 'private';
269
+ if (a === 192 && b === 168) return 'private';
270
+ if (a === 100 && b >= 64 && b <= 127) return 'private';
271
+ return 'public';
272
+ }
273
+ if (/^fe[89ab]/.test(ip)) return 'link-local';
274
+ if (ip === '::1' || ip === '::' || /^f[cd]/.test(ip)) return 'private';
275
+ return 'public';
276
+ }
277
+
278
+ interface ResolvedHop {
279
+ addresses: string[];
280
+ scope: AddressScope;
281
+ }
282
+
283
+ type ResolveHost = (hostname: string) => Promise<Array<{ address: string; family: number }>>;
284
+
285
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
286
+ return new Promise((resolve, reject) => {
287
+ const abort = () => reject(new DOMException('The operation was aborted.', 'AbortError'));
288
+ if (signal.aborted) return abort();
289
+ signal.addEventListener('abort', abort, { once: true });
290
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
291
+ });
292
+ }
293
+
294
+ /** Resolves a hop once, retaining the narrowest scope any returned address reaches. */
295
+ async function resolveHop(url: URL, resolveHost: ResolveHost): Promise<ResolvedHop> {
296
+ const host = url.hostname.replace(/^\[|\]$/g, '');
297
+ const addresses = isIP(host) ? [{ address: host, family: isIP(host) }] : await resolveHost(host);
298
+ const selected = addresses[0];
299
+ if (!selected || (selected.family !== 4 && selected.family !== 6)) {
300
+ throw new WebpageFetchError(`Could not resolve ${host}.`);
301
+ }
302
+ const scopes = addresses.map((entry) => classifyAddress(entry.address));
303
+ const scope = scopes.includes('link-local') ? 'link-local' : scopes.includes('private') ? 'private' : 'public';
304
+ return { addresses: addresses.map((entry) => entry.address), scope };
305
+ }
306
+
307
+ async function requestHop(url: URL, resolved: ResolvedHop, signal: AbortSignal): Promise<Response> {
308
+ let failure: unknown;
309
+ for (const address of resolved.addresses) {
310
+ try {
311
+ return await requestAddress(url, address, signal);
312
+ } catch (error) {
313
+ if (signal.aborted) throw error;
314
+ failure = error;
315
+ }
316
+ }
317
+ throw failure;
318
+ }
319
+
320
+ function requestAddress(url: URL, address: string, signal: AbortSignal): Promise<Response> {
321
+ return new Promise((resolve, reject) => {
322
+ const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)(
323
+ url,
324
+ {
325
+ method: 'GET',
326
+ hostname: address,
327
+ servername: isIP(url.hostname.replace(/^\[|\]$/g, '')) ? undefined : url.hostname,
328
+ signal,
329
+ headers: {
330
+ Accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.1',
331
+ Host: url.host,
332
+ 'User-Agent': 'pmx-canvas webpage node',
333
+ },
334
+ },
335
+ (incoming) => {
336
+ const headers = new Headers();
337
+ for (let index = 0; index < incoming.rawHeaders.length; index += 2) {
338
+ headers.append(incoming.rawHeaders[index], incoming.rawHeaders[index + 1]);
339
+ }
340
+ const status = incoming.statusCode ?? 500;
341
+ const empty = status === 204 || status === 205 || status === 304;
342
+ // Node and DOM declare separate types for the same web-stream API.
343
+ const body = empty ? null : (Readable.toWeb(incoming) as unknown as ReadableStream<Uint8Array>);
344
+ if (empty) incoming.resume();
345
+ resolve(new Response(body, { status, statusText: incoming.statusMessage, headers }));
346
+ },
347
+ );
348
+ request.on('error', reject);
349
+ request.end();
350
+ });
351
+ }
352
+
353
+ async function readBoundedBody(response: Response): Promise<string> {
354
+ const reader = response.body?.getReader();
355
+ if (!reader) return '';
356
+ const decoder = new TextDecoder();
357
+ let body = '';
358
+ let bytes = 0;
359
+ while (bytes < MAX_HTML_LENGTH) {
360
+ const { done, value } = await reader.read();
361
+ if (done) break;
362
+ const remaining = MAX_HTML_LENGTH - bytes;
363
+ body += decoder.decode(value.subarray(0, remaining), { stream: value.byteLength <= remaining });
364
+ bytes += Math.min(value.byteLength, remaining);
365
+ if (value.byteLength > remaining || bytes === MAX_HTML_LENGTH) {
366
+ await reader.cancel();
367
+ break;
368
+ }
369
+ }
370
+ return body + decoder.decode();
371
+ }
372
+
373
+ /**
374
+ * Refuses link-local on every hop, and a redirect that moves a public fetch
375
+ * onto a loopback or private address. A URL the caller points at a local dev
376
+ * server on purpose still loads, and may redirect within the local network.
377
+ */
378
+ export function checkWebpageHop(originScope: AddressScope, hopScope: AddressScope, hopUrl: string): void {
379
+ if (hopScope === 'link-local') {
380
+ throw new WebpageFetchError(`Refused to fetch ${hopUrl}: link-local addresses are not allowed.`);
381
+ }
382
+ if (originScope === 'public' && hopScope === 'private') {
383
+ throw new WebpageFetchError(`Refused redirect to ${hopUrl}: a public page may not redirect to a private address.`);
384
+ }
385
+ }
386
+
387
+ export async function fetchWebpageSnapshot(
388
+ inputUrl: string,
389
+ options: { resolveHost?: ResolveHost; timeoutMs?: number } = {},
390
+ ): Promise<WebpageSnapshot> {
240
391
  const url = normalizeWebpageUrl(inputUrl);
241
392
  const controller = new AbortController();
242
- const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
393
+ const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
394
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
395
+ const resolveHost = options.resolveHost ?? ((host) => lookup(host, { all: true }));
243
396
 
244
397
  try {
245
- const response = await fetch(url, {
246
- redirect: 'follow',
247
- signal: controller.signal,
248
- headers: {
249
- Accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.1',
250
- 'User-Agent': 'pmx-canvas webpage node',
251
- },
252
- });
398
+ // Redirects are followed by hand so every hop's host is checked before it is fetched.
399
+ let resolved = await abortable(resolveHop(new URL(url), resolveHost), controller.signal);
400
+ const originScope = resolved.scope;
401
+ let hopUrl = url;
402
+ let response: Response;
403
+ for (let hop = 0; ; hop++) {
404
+ checkWebpageHop(originScope, resolved.scope, hopUrl);
405
+ response = await requestHop(new URL(hopUrl), resolved, controller.signal);
406
+ const location = response.headers.get('location');
407
+ if (response.status < 300 || response.status >= 400 || !location) break;
408
+ await response.body?.cancel();
409
+ if (hop === MAX_REDIRECTS) throw new WebpageFetchError(`Too many redirects while fetching ${url}.`);
410
+ hopUrl = normalizeWebpageUrl(new URL(location, hopUrl).toString());
411
+ resolved = await abortable(resolveHop(new URL(hopUrl), resolveHost), controller.signal);
412
+ }
253
413
 
254
414
  const contentType = response.headers.get('content-type');
255
- const responseUrl = normalizeWebpageUrl(response.url || url);
256
- const body = (await response.text()).slice(0, MAX_HTML_LENGTH);
415
+ const responseUrl = hopUrl;
416
+ const body = await readBoundedBody(response);
257
417
 
258
418
  if (!response.ok) {
259
419
  throw new WebpageFetchError(`Request failed with ${response.status} ${response.statusText}`.trim(), {
@@ -292,7 +452,7 @@ export async function fetchWebpageSnapshot(inputUrl: string): Promise<WebpageSna
292
452
  } catch (error) {
293
453
  if (error instanceof WebpageFetchError) throw error;
294
454
  if (error instanceof Error && error.name === 'AbortError') {
295
- throw new WebpageFetchError(`Timed out after ${FETCH_TIMEOUT_MS}ms while fetching ${url}.`);
455
+ throw new WebpageFetchError(`Timed out after ${timeoutMs}ms while fetching ${url}.`);
296
456
  }
297
457
  throw new WebpageFetchError(error instanceof Error ? error.message : `Failed to fetch ${url}.`);
298
458
  } finally {
@@ -0,0 +1,11 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
+
3
+ /** Per-process capability used only to distinguish workbench clicks from HTTP agents. */
4
+ export const workbenchToken = randomBytes(32).toString('base64url');
5
+
6
+ export function isWorkbenchToken(value: string | null): boolean {
7
+ if (!value) return false;
8
+ const actual = Buffer.from(value);
9
+ const expected = Buffer.from(workbenchToken);
10
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
11
+ }
@@ -0,0 +1,18 @@
1
+ /** The Home category migrated 0.6 snapshots land on, so they do not bury the boards in use. */
2
+ export const MIGRATED_SNAPSHOT_CATEGORY = 'From old snapshots';
3
+
4
+ /** Slash-separated folder paths shared by the UI, HTTP/MCP and SDK. */
5
+ export function normalizeBoardCategory(value: string | null): string | null {
6
+ if (!value?.trim()) return null;
7
+ const parts = value.split('/').map((part) => part.trim());
8
+ if (parts.length > 8) throw new Error('Folders can be nested up to 8 levels.');
9
+ if (parts.some((part) => !part || part === '.' || part === '..' || part.length > 60)) {
10
+ throw new Error('Each folder name must be 1–60 characters, and cannot be "." or "..".');
11
+ }
12
+ return parts.join('/');
13
+ }
14
+
15
+ /** Includes implicit parents, so a board in A/B can also be moved into A. */
16
+ export function categoryAncestors(path: string): string[] {
17
+ return path.split('/').map((_, index, parts) => parts.slice(0, index + 1).join('/'));
18
+ }
@@ -19,6 +19,7 @@
19
19
  */
20
20
  export const NODE_MIN_SIZES: Record<string, { width: number; height: number }> = {
21
21
  markdown: { width: 360, height: 180 },
22
+ board: { width: 320, height: 140 },
22
23
  context: { width: 360, height: 180 },
23
24
  file: { width: 360, height: 200 },
24
25
  diff: { width: 420, height: 240 },