pmx-canvas 0.7.3 → 0.8.2

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 (244) hide show
  1. package/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  2. package/.github/extensions/pmx-canvas/extension.mjs +25 -172
  3. package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
  4. package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
  5. package/.github/extensions/pmx-canvas/runtime.json +3 -0
  6. package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
  7. package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
  8. package/.github/extensions/pmx-canvas/tools.json +2560 -0
  9. package/CHANGELOG.md +64 -0
  10. package/Readme.md +21 -7
  11. package/dist/canvas/fonts/OFL.txt +93 -0
  12. package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  13. package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  14. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  15. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  16. package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  17. package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  18. package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  19. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
  20. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
  21. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
  22. package/dist/canvas/global.css +726 -674
  23. package/dist/canvas/index.js +90 -88
  24. package/dist/canvas/mcp-app.html +7879 -0
  25. package/dist/canvas/mermaid-entry.js +1 -1
  26. package/dist/canvas/surface-theme.css +151 -46
  27. package/dist/json-render/index.css +81 -1
  28. package/dist/json-render/index.js +1 -1
  29. package/dist/types/cli/commands/record.d.ts +1 -1
  30. package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
  31. package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
  32. package/dist/types/client/icons.d.ts +1 -2
  33. package/dist/types/client/mcp-app/index.d.ts +1 -0
  34. package/dist/types/client/nodes/HtmlNode.d.ts +1 -0
  35. package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
  36. package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
  37. package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
  38. package/dist/types/client/state/attention-store.d.ts +0 -5
  39. package/dist/types/client/state/context-status-store.d.ts +53 -0
  40. package/dist/types/client/state/intent-bridge.d.ts +3 -0
  41. package/dist/types/client/state/near-pin-store.d.ts +16 -0
  42. package/dist/types/client/state/presence-store.d.ts +5 -0
  43. package/dist/types/client/state/session-store.d.ts +33 -3
  44. package/dist/types/client/state/workbench-transport.d.ts +16 -0
  45. package/dist/types/client/theme/tokens.d.ts +1 -3
  46. package/dist/types/client/types.d.ts +9 -0
  47. package/dist/types/json-render/server.d.ts +1 -1
  48. package/dist/types/mcp/app-server.d.ts +3 -0
  49. package/dist/types/mcp/canvas-access.d.ts +1 -1
  50. package/dist/types/mcp/canvas-app.d.ts +4 -0
  51. package/dist/types/mcp/workbench-app.d.ts +4 -0
  52. package/dist/types/server/agent-presence.d.ts +1 -1
  53. package/dist/types/server/ax-state.d.ts +1 -1
  54. package/dist/types/server/canvas-db.d.ts +8 -0
  55. package/dist/types/server/canvas-geometry.d.ts +25 -0
  56. package/dist/types/server/canvas-operations.d.ts +1 -11
  57. package/dist/types/server/canvas-state.d.ts +25 -3
  58. package/dist/types/server/context-brief.d.ts +8 -1
  59. package/dist/types/server/context-reads.d.ts +30 -7
  60. package/dist/types/server/index.d.ts +6 -1
  61. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  62. package/dist/types/server/operations/http.d.ts +2 -0
  63. package/dist/types/server/operations/invoker.d.ts +4 -2
  64. package/dist/types/server/operations/ops/nodes.d.ts +0 -6
  65. package/dist/types/server/server.d.ts +1 -0
  66. package/dist/types/server/spatial-analysis.d.ts +2 -0
  67. package/dist/types/shared/agent-context.d.ts +22 -0
  68. package/dist/types/shared/agent-presence.d.ts +3 -10
  69. package/dist/types/shared/canvas-app.d.ts +54 -0
  70. package/dist/types/shared/themes.d.ts +1 -1
  71. package/docs/RELEASE.md +7 -3
  72. package/docs/agent-plugin.md +123 -0
  73. package/docs/ax-state-contract.md +40 -0
  74. package/docs/cli.md +7 -2
  75. package/docs/design/AgentContext.dc.html +186 -0
  76. package/docs/design/Before.dc.html +208 -0
  77. package/docs/design/BoardPins.dc.html +151 -0
  78. package/docs/design/Chrome.dc.html +211 -0
  79. package/docs/design/Context.dc.html +239 -0
  80. package/docs/design/Flows.dc.html +181 -0
  81. package/docs/design/Home.dc.html +209 -0
  82. package/docs/design/HomeGraph.dc.html +211 -0
  83. package/docs/design/HomeMap.dc.html +117 -0
  84. package/docs/design/HomeTeam.dc.html +176 -0
  85. package/docs/design/Light.dc.html +28 -0
  86. package/docs/design/Main.dc.html +280 -0
  87. package/docs/design/NearPin.dc.html +260 -0
  88. package/docs/design/Pane600.dc.html +120 -0
  89. package/docs/design/Relations.dc.html +297 -0
  90. package/docs/design/Themes.dc.html +121 -0
  91. package/docs/design/TokenSystem.dc.html +136 -0
  92. package/docs/design/Tokens.dc.html +163 -0
  93. package/docs/design/Types1.dc.html +177 -0
  94. package/docs/design/Types2.dc.html +226 -0
  95. package/docs/design/Types3.dc.html +195 -0
  96. package/docs/design/Uses.dc.html +152 -0
  97. package/docs/design/Vision.dc.html +116 -0
  98. package/docs/design/Zoom.dc.html +82 -0
  99. package/docs/design/canvas.json +274 -0
  100. package/docs/design.md +217 -0
  101. package/docs/environment.md +1 -1
  102. package/docs/http-api.md +86 -16
  103. package/docs/mcp.md +202 -4
  104. package/docs/node-types.md +34 -10
  105. package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
  106. package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
  107. package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
  108. package/docs/product-vision-2026-09.md +47 -7
  109. package/docs/screenshot.png +0 -0
  110. package/docs/sdk.md +1 -0
  111. package/package.json +10 -4
  112. package/plugins/pmx-canvas/LICENSE +21 -0
  113. package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  114. package/plugins/pmx-canvas/assets/preview.png +0 -0
  115. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
  116. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
  117. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
  118. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  119. package/plugins/pmx-canvas/mcp.json +12 -0
  120. package/plugins/pmx-canvas/plugin.json +24 -0
  121. package/plugins/pmx-canvas/runtime.json +3 -0
  122. package/plugins/pmx-canvas/runtime.mjs +194 -0
  123. package/plugins/pmx-canvas/start-mcp.mjs +51 -0
  124. package/plugins/pmx-canvas/tools.json +2560 -0
  125. package/skills/pmx-canvas/SKILL.md +29 -9
  126. package/skills/pmx-canvas/evals/evals.json +62 -0
  127. package/skills/pmx-canvas/references/ax-html-control-surface.md +6 -0
  128. package/skills/pmx-canvas/references/full-reference.md +11 -7
  129. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
  130. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  131. package/skills/pmx-canvas-testing/SKILL.md +5 -4
  132. package/src/cli/commands/copilot.ts +5 -0
  133. package/src/cli/commands/nodes.ts +3 -2
  134. package/src/cli/commands/view.ts +1 -1
  135. package/src/cli/index.ts +10 -4
  136. package/src/client/App.tsx +6 -1
  137. package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
  138. package/src/client/canvas/AnnotationLayer.tsx +1 -1
  139. package/src/client/canvas/CanvasNode.tsx +32 -49
  140. package/src/client/canvas/CanvasViewport.tsx +0 -2
  141. package/src/client/canvas/CommandPalette.tsx +3 -2
  142. package/src/client/canvas/ContextMenu.tsx +11 -0
  143. package/src/client/canvas/EdgeLayer.tsx +1 -1
  144. package/src/client/canvas/ExportDialog.tsx +33 -6
  145. package/src/client/canvas/IntentLayer.tsx +2 -2
  146. package/src/client/canvas/Minimap.tsx +12 -7
  147. package/src/client/canvas/NodeContextMark.tsx +156 -0
  148. package/src/client/canvas/SessionPanel.tsx +4 -7
  149. package/src/client/canvas/SessionReceipt.tsx +114 -17
  150. package/src/client/canvas/ShortcutOverlay.tsx +6 -1
  151. package/src/client/canvas/SnapshotPanel.tsx +11 -2
  152. package/src/client/canvas/ToolRail.tsx +12 -9
  153. package/src/client/canvas/TopBar.tsx +16 -11
  154. package/src/client/icons.tsx +2 -12
  155. package/src/client/mcp-app/index.tsx +137 -0
  156. package/src/client/mcp-app/style.css +10 -0
  157. package/src/client/nodes/AttachmentNode.tsx +8 -1
  158. package/src/client/nodes/AxStepControls.tsx +1 -1
  159. package/src/client/nodes/ContextNode.tsx +9 -9
  160. package/src/client/nodes/ExtAppFrame.tsx +13 -8
  161. package/src/client/nodes/FileNode.tsx +8 -5
  162. package/src/client/nodes/HtmlNode.tsx +12 -2
  163. package/src/client/nodes/ImageNode.tsx +4 -2
  164. package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
  165. package/src/client/nodes/LedgerNode.tsx +2 -2
  166. package/src/client/nodes/MarkdownNode.tsx +44 -9
  167. package/src/client/nodes/McpAppNode.tsx +6 -4
  168. package/src/client/nodes/PromptNode.tsx +5 -17
  169. package/src/client/nodes/ResponseNode.tsx +3 -15
  170. package/src/client/nodes/StatusNode.tsx +2 -2
  171. package/src/client/nodes/StatusSummary.tsx +3 -1
  172. package/src/client/nodes/TraceNode.tsx +6 -4
  173. package/src/client/nodes/WebpageNode.tsx +1 -1
  174. package/src/client/nodes/ax-node-actions.ts +2 -2
  175. package/src/client/nodes/iframe-document-url.ts +3 -2
  176. package/src/client/nodes/markdown-sanitize.ts +10 -0
  177. package/src/client/nodes/surface-url.ts +10 -0
  178. package/src/client/nodes/use-surface-frame.ts +3 -1
  179. package/src/client/nodes/use-workbench-asset.ts +26 -0
  180. package/src/client/state/attention-bridge.ts +0 -8
  181. package/src/client/state/attention-store.ts +0 -17
  182. package/src/client/state/canvas-store.ts +4 -2
  183. package/src/client/state/context-status-store.ts +66 -0
  184. package/src/client/state/human-store.ts +2 -0
  185. package/src/client/state/iframe-mode.ts +5 -0
  186. package/src/client/state/intent-bridge.ts +10 -4
  187. package/src/client/state/near-pin-store.ts +53 -0
  188. package/src/client/state/presence-store.ts +5 -10
  189. package/src/client/state/session-store.ts +106 -9
  190. package/src/client/state/sse-bridge.ts +26 -7
  191. package/src/client/state/theme-override.ts +1 -1
  192. package/src/client/state/workbench-transport.ts +36 -0
  193. package/src/client/theme/global.css +726 -674
  194. package/src/client/theme/surface-theme.css +151 -46
  195. package/src/client/theme/tokens.ts +7 -11
  196. package/src/client/types.ts +10 -0
  197. package/src/json-render/renderer/index.css +6 -1
  198. package/src/json-render/renderer/index.tsx +0 -1
  199. package/src/json-render/server.ts +71 -14
  200. package/src/mcp/app-server.ts +64 -0
  201. package/src/mcp/canvas-access.ts +14 -11
  202. package/src/mcp/canvas-app.ts +318 -0
  203. package/src/mcp/server.ts +1 -1
  204. package/src/mcp/workbench-app.ts +211 -0
  205. package/src/server/agent-presence.ts +45 -6
  206. package/src/server/ax-context.ts +1 -1
  207. package/src/server/ax-state.ts +1 -1
  208. package/src/server/board-export.ts +5 -2
  209. package/src/server/canvas-db.ts +46 -12
  210. package/src/server/canvas-geometry.ts +64 -0
  211. package/src/server/canvas-operations.ts +7 -25
  212. package/src/server/canvas-state.ts +105 -15
  213. package/src/server/canvas-validation.ts +19 -4
  214. package/src/server/context-brief.ts +39 -2
  215. package/src/server/context-reads.ts +87 -15
  216. package/src/server/demo-state.json +31 -31
  217. package/src/server/html-surface.ts +4 -5
  218. package/src/server/index.ts +41 -5
  219. package/src/server/mcp-app-runtime.ts +11 -0
  220. package/src/server/operations/composites.ts +4 -3
  221. package/src/server/operations/http.ts +4 -4
  222. package/src/server/operations/invoker.ts +13 -4
  223. package/src/server/operations/mcp.ts +6 -0
  224. package/src/server/operations/ops/ax-read.ts +1 -1
  225. package/src/server/operations/ops/ax-timeline.ts +37 -0
  226. package/src/server/operations/ops/boards.ts +1 -1
  227. package/src/server/operations/ops/canvas-wire.ts +3 -6
  228. package/src/server/operations/ops/intent.ts +31 -2
  229. package/src/server/operations/ops/json-render.ts +6 -2
  230. package/src/server/operations/ops/nodes.ts +8 -76
  231. package/src/server/operations/ops/query.ts +8 -2
  232. package/src/server/operations/ops/snapshots.ts +4 -1
  233. package/src/server/operations/ops/viewport.ts +29 -5
  234. package/src/server/operations/registry.ts +41 -5
  235. package/src/server/scope-fence.ts +55 -10
  236. package/src/server/server.ts +83 -5
  237. package/src/server/spatial-analysis.ts +2 -2
  238. package/src/server/workboard.ts +6 -1
  239. package/src/{server → shared}/agent-context.ts +2 -2
  240. package/src/shared/agent-presence.ts +3 -15
  241. package/src/shared/canvas-app.ts +62 -0
  242. package/src/shared/themes.ts +5 -5
  243. package/src/client/canvas/FocusFieldLayer.tsx +0 -116
  244. package/src/client/canvas/kind-colors.ts +0 -23
@@ -208,6 +208,8 @@ async function collect(
208
208
  if (!read) return null;
209
209
  const { board, state } = read;
210
210
  const theme = canvasThemeScheme(state.theme ?? canvasState.theme);
211
+ // Exports collapse to a scheme; surface documents take the theme that draws it.
212
+ const surfaceTheme = theme === 'light' ? 'daylight' : 'harbor';
211
213
  const remoteImages = new Set<string>();
212
214
  const links = new Set<string>();
213
215
  const frameNetworkDestinations = new Set<string>();
@@ -383,7 +385,8 @@ async function collect(
383
385
  const html = text(data.html) || text(data.content);
384
386
  collectFrameNetworkDestinations(html, frameNetworkDestinations);
385
387
  manifest.frames += 1;
386
- if (withFrames) card.frame = buildHtmlSurfaceDocument(html, { theme, title, inlineThemeCss: themeCss });
388
+ if (withFrames)
389
+ card.frame = buildHtmlSurfaceDocument(html, { theme: surfaceTheme, title, inlineThemeCss: themeCss });
387
390
  break;
388
391
  }
389
392
  case 'mermaid':
@@ -395,7 +398,7 @@ async function collect(
395
398
  '<script src="/canvas/mermaid-entry.js"></script>',
396
399
  `<script src="${MERMAID_SRC}"></script>`,
397
400
  ),
398
- { theme, title, inlineThemeCss: themeCss },
401
+ { theme: surfaceTheme, title, inlineThemeCss: themeCss },
399
402
  );
400
403
  }
401
404
  break;
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { Database } from 'bun:sqlite';
9
+ import type { ActorAttribution } from './attribution.js';
9
10
  import { CONTEXT_READS_SCHEMA_SQL } from './context-reads.js';
10
11
  import { MIGRATED_SNAPSHOT_CATEGORY, normalizeBoardCategory } from '../shared/boards.js';
11
12
  import { tourSchema, type Tour } from '../shared/tour.js';
@@ -48,7 +49,7 @@ const SCHEMA_VERSION = 2;
48
49
 
49
50
  export type CanvasTheme = CanvasThemeName;
50
51
 
51
- export function normalizeCanvasTheme(value: unknown, fallback: CanvasTheme = 'dark'): CanvasTheme {
52
+ export function normalizeCanvasTheme(value: unknown, fallback: CanvasTheme = 'harbor'): CanvasTheme {
52
53
  return normalizeCanvasThemeName(value, fallback);
53
54
  }
54
55
 
@@ -122,6 +123,7 @@ const SCHEMA_SQL = `
122
123
  CREATE TABLE IF NOT EXISTS context_pins (
123
124
  board_id TEXT NOT NULL,
124
125
  node_id TEXT NOT NULL,
126
+ meta TEXT,
125
127
  PRIMARY KEY (board_id, node_id)
126
128
  );
127
129
 
@@ -185,6 +187,7 @@ const SCHEMA_SQL = `
185
187
  CREATE TABLE IF NOT EXISTS snapshot_pins (
186
188
  snapshot_id TEXT NOT NULL,
187
189
  node_id TEXT NOT NULL,
190
+ meta TEXT,
188
191
  PRIMARY KEY (snapshot_id, node_id)
189
192
  );
190
193
 
@@ -335,10 +338,29 @@ export interface PersistedCanvasState {
335
338
  edges: CanvasEdge[];
336
339
  annotations?: CanvasAnnotation[];
337
340
  contextPins: string[];
341
+ /** Who pinned each context pin, when and why; pins without an entry have an unknown pinner. */
342
+ contextPinMeta?: Record<string, ContextPinMeta>;
338
343
  ax?: PmxAxState;
339
344
  revisionState?: PersistedRevisionState;
340
345
  }
341
346
 
347
+ export interface ContextPinMeta {
348
+ pinnedBy: ActorAttribution;
349
+ pinnedAt: string;
350
+ reason?: string;
351
+ }
352
+
353
+ function pinMetaJson(state: PersistedCanvasState, nodeId: string): string | null {
354
+ const meta = state.contextPinMeta?.[nodeId];
355
+ return meta ? JSON.stringify(meta) : null;
356
+ }
357
+
358
+ function pinMetaFromRows(rows: Array<{ node_id: string; meta: string | null }>): Record<string, ContextPinMeta> {
359
+ const meta: Record<string, ContextPinMeta> = {};
360
+ for (const row of rows) if (row.meta) meta[row.node_id] = JSON.parse(row.meta) as ContextPinMeta;
361
+ return meta;
362
+ }
363
+
342
364
  export interface PersistedRevisionState {
343
365
  revision: number;
344
366
  floor: number;
@@ -400,6 +422,9 @@ export function openCanvasDb(dbPath: string, options: OpenCanvasDbOptions = {}):
400
422
  ensureColumn(db, 'ax_steering', 'agent_id', 'agent_id TEXT');
401
423
  ensureColumn(db, 'ax_steering', 'target', 'target TEXT');
402
424
  ensureColumn(db, 'context_reads', 'board_id', 'board_id TEXT');
425
+ ensureColumn(db, 'context_reads', 'read_nodes', "read_nodes TEXT NOT NULL DEFAULT '{}'");
426
+ ensureColumn(db, 'context_pins', 'meta', 'meta TEXT');
427
+ ensureColumn(db, 'snapshot_pins', 'meta', 'meta TEXT');
403
428
  ensureColumn(db, 'boards', 'category', 'category TEXT');
404
429
  ensureColumn(db, 'boards', 'readme_node_id', 'readme_node_id TEXT');
405
430
  ensureColumn(db, 'nodes', 'attribution', "attribution TEXT NOT NULL DEFAULT '{}' ");
@@ -470,6 +495,7 @@ function migrateToBoards(db: Database, dbPath: string, boardName: string): void
470
495
  if (existing.has(table)) db.exec(`ALTER TABLE ${table} RENAME TO ${table}_v1`);
471
496
  }
472
497
  if (existing.has('snapshots')) ensureColumn(db, 'snapshots', 'board_id', 'board_id TEXT');
498
+ if (existing.has('snapshot_pins')) ensureColumn(db, 'snapshot_pins', 'meta', 'meta TEXT');
473
499
  db.exec(SCHEMA_SQL);
474
500
 
475
501
  if (hasBoard) {
@@ -552,10 +578,10 @@ function snapshotsToBoards(db: Database): void {
552
578
  `INSERT INTO annotations (board_id, ${annotationColumns}) SELECT ?, ${annotationColumns} FROM snapshot_annotations WHERE snapshot_id = ?`,
553
579
  [boardId, snapshot.id],
554
580
  );
555
- db.run('INSERT INTO context_pins (board_id, node_id) SELECT ?, node_id FROM snapshot_pins WHERE snapshot_id = ?', [
556
- boardId,
557
- snapshot.id,
558
- ]);
581
+ db.run(
582
+ 'INSERT INTO context_pins (board_id, node_id, meta) SELECT ?, node_id, meta FROM snapshot_pins WHERE snapshot_id = ?',
583
+ [boardId, snapshot.id],
584
+ );
559
585
  const ax = meta.get('ax_state');
560
586
  if (ax) db.run("INSERT INTO ax_state (board_id, key, value) VALUES (?, 'state', ?)", [boardId, ax]);
561
587
  deleteSnapshotFromDB(db, snapshot.id);
@@ -775,10 +801,12 @@ export function saveStateToDB(db: Database, boardId: string, state: PersistedCan
775
801
  }
776
802
  deleteMissingRows(db, 'annotations', 'id', boardId, new Set(annotations.map((annotation) => annotation.id)));
777
803
 
778
- // Save context pins (node_id is the whole row — nothing to update)
779
- const insertPin = db.prepare('INSERT OR IGNORE INTO context_pins (board_id, node_id) VALUES (?, ?)');
804
+ // Save context pins with who pinned them
805
+ const insertPin = db.prepare(
806
+ 'INSERT INTO context_pins (board_id, node_id, meta) VALUES (?, ?, ?) ON CONFLICT(board_id, node_id) DO UPDATE SET meta = excluded.meta WHERE context_pins.meta IS NOT excluded.meta',
807
+ );
780
808
  for (const pinId of state.contextPins) {
781
- insertPin.run(boardId, pinId);
809
+ insertPin.run(boardId, pinId, pinMetaJson(state, pinId));
782
810
  }
783
811
  deleteMissingRows(db, 'context_pins', 'node_id', boardId, new Set(state.contextPins));
784
812
 
@@ -905,9 +933,11 @@ export function loadStateFromDB(db: Database, boardId?: string): PersistedCanvas
905
933
  // Load context pins
906
934
  interface PinRow {
907
935
  node_id: string;
936
+ meta: string | null;
908
937
  }
909
- const pinRows = db.query<PinRow, [string]>('SELECT node_id FROM context_pins WHERE board_id = ?').all(id);
938
+ const pinRows = db.query<PinRow, [string]>('SELECT node_id, meta FROM context_pins WHERE board_id = ?').all(id);
910
939
  const contextPins = pinRows.map((row) => row.node_id);
940
+ const contextPinMeta = pinMetaFromRows(pinRows);
911
941
 
912
942
  const axRow = db
913
943
  .query<{ value: string }, [string, string]>('SELECT value FROM ax_state WHERE board_id = ? AND key = ?')
@@ -925,6 +955,7 @@ export function loadStateFromDB(db: Database, boardId?: string): PersistedCanvas
925
955
  edges,
926
956
  annotations,
927
957
  contextPins,
958
+ contextPinMeta,
928
959
  ax: parsePersistedAxState(axRow?.value),
929
960
  revisionState: revisionRaw ? (JSON.parse(revisionRaw) as PersistedRevisionState) : undefined,
930
961
  };
@@ -1191,9 +1222,9 @@ export function saveSnapshotToDB(
1191
1222
  }
1192
1223
 
1193
1224
  // Insert snapshot pins
1194
- const insertPin = db.prepare('INSERT INTO snapshot_pins (snapshot_id, node_id) VALUES (?, ?)');
1225
+ const insertPin = db.prepare('INSERT INTO snapshot_pins (snapshot_id, node_id, meta) VALUES (?, ?, ?)');
1195
1226
  for (const pinId of state.contextPins) {
1196
- insertPin.run(snapshot.id, pinId);
1227
+ insertPin.run(snapshot.id, pinId, pinMetaJson(state, pinId));
1197
1228
  }
1198
1229
  db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [
1199
1230
  snapshot.id,
@@ -1348,11 +1379,13 @@ export function loadSnapshotFromDB(
1348
1379
  // Load snapshot pins
1349
1380
  interface PinRow {
1350
1381
  node_id: string;
1382
+ meta: string | null;
1351
1383
  }
1352
1384
  const pinRows = db
1353
- .query<PinRow, [string]>('SELECT node_id FROM snapshot_pins WHERE snapshot_id = ?')
1385
+ .query<PinRow, [string]>('SELECT node_id, meta FROM snapshot_pins WHERE snapshot_id = ?')
1354
1386
  .all(snapshotRow.id);
1355
1387
  const contextPins = pinRows.map((row) => row.node_id);
1388
+ const contextPinMeta = pinMetaFromRows(pinRows);
1356
1389
 
1357
1390
  return {
1358
1391
  snapshot,
@@ -1364,6 +1397,7 @@ export function loadSnapshotFromDB(
1364
1397
  edges,
1365
1398
  annotations,
1366
1399
  contextPins,
1400
+ contextPinMeta,
1367
1401
  ax: parsePersistedAxState(metaMap.get('ax_state')),
1368
1402
  revisionState: metaMap.has('revision_state')
1369
1403
  ? (JSON.parse(metaMap.get('revision_state')!) as PersistedRevisionState)
@@ -0,0 +1,64 @@
1
+ import type { CanvasNodeState, CanvasNodeUpdate } from './canvas-state.js';
2
+
3
+ function record(value: unknown): Record<string, unknown> {
4
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
5
+ }
6
+
7
+ function finite(value: unknown): number | undefined {
8
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
9
+ }
10
+
11
+ /** Flat fields take precedence over nested aliases; absent axes remain absent. */
12
+ export function resolveCreateGeometry(body: Record<string, unknown>) {
13
+ const position = record(body.position);
14
+ const size = record(body.size);
15
+ return {
16
+ x: finite(body.x) ?? finite(position.x),
17
+ y: finite(body.y) ?? finite(position.y),
18
+ width: finite(body.width) ?? finite(size.width),
19
+ height: finite(body.height) ?? finite(size.height),
20
+ };
21
+ }
22
+
23
+ /** Guards and PATCH execution must resolve aliases and single-axis moves identically. */
24
+ export function resolvePatchGeometry(body: Record<string, unknown>, existing: CanvasNodeState) {
25
+ const { x, y, width, height } = resolveCreateGeometry(body);
26
+ return {
27
+ ...(x !== undefined || y !== undefined
28
+ ? { position: { x: x ?? existing.position.x, y: y ?? existing.position.y } }
29
+ : {}),
30
+ ...(width !== undefined || height !== undefined
31
+ ? { size: { width: width ?? existing.size.width, height: height ?? existing.size.height } }
32
+ : {}),
33
+ };
34
+ }
35
+
36
+ export function validateCanvasNodePatch(patch: { position?: unknown; size?: unknown }): string | null {
37
+ if (patch.position !== undefined) {
38
+ const position = record(patch.position);
39
+ if (finite(position.x) === undefined || finite(position.y) === undefined) {
40
+ return 'Position must contain finite x and y values.';
41
+ }
42
+ }
43
+ if (patch.size !== undefined) {
44
+ const size = record(patch.size);
45
+ const width = finite(size.width);
46
+ const height = finite(size.height);
47
+ if (width === undefined || height === undefined) return 'Size must contain finite width and height values.';
48
+ if (width <= 0 || height <= 0) return 'Size width and height must be greater than zero.';
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /** Invalid patches cannot count as explicit child-position overrides. */
54
+ export function validCanvasNodeUpdates(input: unknown): CanvasNodeUpdate[] {
55
+ if (!Array.isArray(input)) return [];
56
+ return input.filter((value: unknown): value is CanvasNodeUpdate => {
57
+ const update = record(value);
58
+ return (
59
+ typeof update.id === 'string' &&
60
+ (update.collapsed === undefined || typeof update.collapsed === 'boolean') &&
61
+ validateCanvasNodePatch(update) === null
62
+ );
63
+ });
64
+ }
@@ -23,6 +23,7 @@ import {
23
23
  import { mutationHistory } from './mutation-history.js';
24
24
  import { intentRegistry } from './intent-registry.js';
25
25
  import { clampCreateNodeSize } from './canvas-validation.js';
26
+ import { validCanvasNodeUpdates } from './canvas-geometry.js';
26
27
  import { computeGroupBounds, findOpenCanvasPosition } from './placement.js';
27
28
  import { searchNodes } from './spatial-analysis.js';
28
29
  import { getCanvasNodeTitle } from './canvas-serialization.js';
@@ -393,7 +394,7 @@ export function buildJsonRenderNodeUpdate(
393
394
  ): { data: Record<string, unknown>; spec: JsonRenderSpec } {
394
395
  if (node.type !== 'json-render') throw new Error(`Node "${node.id}" is not a json-render node.`);
395
396
  const spec = normalizeAndValidateJsonRenderSpec(input.spec);
396
- const title = input.title?.trim() || inferJsonRenderNodeTitle(spec);
397
+ const title = input.title?.trim() || pickString(node.data, 'title') || inferJsonRenderNodeTitle(spec);
397
398
  // Bump specVersion so the viewer URL changes: the iframe src is keyed on
398
399
  // nodeId + ?v=specVersion, and the viewer only reads the spec at document
399
400
  // load. Without the bump the src is byte-identical after a content update,
@@ -730,26 +731,6 @@ export async function syncCanvasRuntimeBackends(
730
731
  return { rehydrated, failed };
731
732
  }
732
733
 
733
- export function validateCanvasNodePatch(patch: {
734
- position?: { x: number; y: number };
735
- size?: { width: number; height: number };
736
- }): string | null {
737
- if (patch.position) {
738
- if (!Number.isFinite(patch.position.x) || !Number.isFinite(patch.position.y)) {
739
- return 'Position must contain finite x and y values.';
740
- }
741
- }
742
- if (patch.size) {
743
- if (!Number.isFinite(patch.size.width) || !Number.isFinite(patch.size.height)) {
744
- return 'Size must contain finite width and height values.';
745
- }
746
- if (patch.size.width <= 0 || patch.size.height <= 0) {
747
- return 'Size width and height must be greater than zero.';
748
- }
749
- }
750
- return null;
751
- }
752
-
753
734
  let codeGraphTimer: ReturnType<typeof setTimeout> | null = null;
754
735
 
755
736
  function shouldTreatFileContentAsPath(input: CanvasAddNodeInput): boolean {
@@ -1406,22 +1387,23 @@ export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: numbe
1406
1387
  }
1407
1388
 
1408
1389
  export function applyCanvasNodeUpdates(updates: CanvasNodeUpdate[]): { applied: number; skipped: number } {
1409
- const safe = updates.filter((update) => validateCanvasNodePatch(update) === null);
1410
- return canvasState.applyUpdates(safe);
1390
+ return canvasState.applyUpdates(validCanvasNodeUpdates(updates));
1411
1391
  }
1412
1392
 
1413
1393
  export function setCanvasContextPins(
1414
1394
  nodeIds: string[],
1415
1395
  mode: CanvasPinMode = 'set',
1396
+ reason?: string,
1416
1397
  ): { count: number; nodeIds: string[] } {
1417
1398
  const normalizePins = (ids: string[]): string[] =>
1418
1399
  ids.filter((id, index) => ids.indexOf(id) === index).slice(0, MAX_CONTEXT_PINS);
1419
1400
  const normalizedNodeIds = normalizePins(nodeIds);
1401
+ const options = reason ? { reason } : {};
1420
1402
  if (mode === 'set') {
1421
- canvasState.setContextPins(normalizedNodeIds);
1403
+ canvasState.setContextPins(normalizedNodeIds, options);
1422
1404
  } else if (mode === 'add') {
1423
1405
  const current = Array.from(canvasState.contextPinnedNodeIds);
1424
- canvasState.setContextPins(normalizePins([...current, ...normalizedNodeIds]));
1406
+ canvasState.setContextPins(normalizePins([...current, ...normalizedNodeIds]), options);
1425
1407
  } else {
1426
1408
  const current = Array.from(canvasState.contextPinnedNodeIds);
1427
1409
  canvasState.setContextPins(current.filter((id) => !normalizedNodeIds.includes(id)));
@@ -55,6 +55,7 @@ import {
55
55
  setActiveBoardIdInDB,
56
56
  type CanvasBoard,
57
57
  finalizeCanvasDbForClose,
58
+ type ContextPinMeta,
58
59
  type PersistedCanvasState,
59
60
  type CanvasTheme,
60
61
  type AxTimelineQuery,
@@ -115,9 +116,12 @@ import { currentActor, unknownActor, type ActorAttribution } from './attribution
115
116
  import {
116
117
  appendContextReadToDB,
117
118
  loadContextReadsFromDB,
119
+ loadNodeReadStatusFromDB,
120
+ loadReadNodeIdsSince,
118
121
  type ContextRead,
119
122
  type ContextReadConsumerSummary,
120
123
  type ContextReadInput,
124
+ type NodeReadStatus,
121
125
  } from './context-reads.js';
122
126
 
123
127
  function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
@@ -411,8 +415,10 @@ class CanvasStateManager {
411
415
  private annotations = new Map<string, CanvasAnnotation>();
412
416
  private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
413
417
  private _tour: Tour | undefined;
414
- private _theme: CanvasTheme = 'dark';
418
+ private _theme: CanvasTheme = 'harbor';
415
419
  private _contextPinnedNodeIds = new Set<string>();
420
+ /** Who pinned each pin, when and why; kept only for ids in `_contextPinnedNodeIds`. */
421
+ private _contextPinMeta = new Map<string, ContextPinMeta>();
416
422
  private _workspaceRoot = process.cwd();
417
423
  private _contentRevision = 0;
418
424
  private _revisionFloor = 0;
@@ -450,6 +456,8 @@ class CanvasStateManager {
450
456
 
451
457
  // ── Work-item change listener (single slot, for live workboard refresh) ──
452
458
  private _workItemsChangedListener: (() => void) | null = null;
459
+ /** Single slot (architecture rule 8): server.ts tells the workbench a read landed. Never MCP. */
460
+ private _contextReadListener: ((boardId: string | null) => void) | null = null;
453
461
 
454
462
  /**
455
463
  * Register THE work-item change listener (single slot, last-write-wins).
@@ -457,6 +465,10 @@ class CanvasStateManager {
457
465
  * status mirror), so live views like the workboard can rebuild from the
458
466
  * fresh work-item list.
459
467
  */
468
+ setContextReadListener(listener: ((boardId: string | null) => void) | null): void {
469
+ this._contextReadListener = listener;
470
+ }
471
+
460
472
  setWorkItemsChangedListener(listener: (() => void) | null): void {
461
473
  this._workItemsChangedListener = listener;
462
474
  }
@@ -687,8 +699,9 @@ class CanvasStateManager {
687
699
  }
688
700
  }
689
701
 
690
- private recomputeParentGroupBounds(groupId: string | undefined): void {
691
- if (!groupId) return;
702
+ private recomputeParentGroupBounds(groupId: string | undefined, visited = new Set<string>()): void {
703
+ if (!groupId || visited.has(groupId)) return;
704
+ visited.add(groupId);
692
705
  const snapshot = this.getGroupSnapshot(groupId);
693
706
  if (!snapshot) return;
694
707
  if (snapshot.group.data.frameMode === 'manual') return;
@@ -701,6 +714,10 @@ class CanvasStateManager {
701
714
  position: { x: bounds.x, y: bounds.y },
702
715
  size: { width: bounds.width, height: bounds.height },
703
716
  });
717
+ // Only the affected ancestor chain may re-fit. Never repair unrelated
718
+ // automatic frames as a side effect of an otherwise scoped child update.
719
+ const parentId = snapshot.group.data.parentGroup;
720
+ if (typeof parentId === 'string') this.recomputeParentGroupBounds(parentId, visited);
704
721
  }
705
722
 
706
723
  private compactGroupChildren(groupId: string, layout: 'grid' | 'column' | 'flow' = 'grid'): void {
@@ -1121,6 +1138,7 @@ class CanvasStateManager {
1121
1138
  edges: layout.edges,
1122
1139
  annotations: layout.annotations,
1123
1140
  contextPins: Array.from(this._contextPinnedNodeIds),
1141
+ contextPinMeta: this.pinMetaRecord(),
1124
1142
  revisionState: this.revisionState(),
1125
1143
  },
1126
1144
  };
@@ -1478,6 +1496,7 @@ class CanvasStateManager {
1478
1496
  edges: Array.from(this.edges.values()),
1479
1497
  annotations: Array.from(this.annotations.values()),
1480
1498
  contextPins: Array.from(this._contextPinnedNodeIds),
1499
+ contextPinMeta: this.pinMetaRecord(),
1481
1500
  ax: this.getAxState(),
1482
1501
  revisionState: this.revisionState(),
1483
1502
  });
@@ -1540,6 +1559,7 @@ class CanvasStateManager {
1540
1559
  this.edges.clear();
1541
1560
  this.annotations.clear();
1542
1561
  this._contextPinnedNodeIds.clear();
1562
+ this._contextPinMeta.clear();
1543
1563
  this.ax.resetCanvasBound();
1544
1564
  this._contentRevision = state.revisionState?.revision ?? 0;
1545
1565
  this._revisionFloor = state.revisionState?.floor ?? 0;
@@ -1571,7 +1591,10 @@ class CanvasStateManager {
1571
1591
  }
1572
1592
  if (Array.isArray(state.contextPins)) {
1573
1593
  for (const pinId of state.contextPins) {
1574
- if (this.nodes.has(pinId)) this._contextPinnedNodeIds.add(pinId);
1594
+ if (!this.nodes.has(pinId)) continue;
1595
+ this._contextPinnedNodeIds.add(pinId);
1596
+ const meta = state.contextPinMeta?.[pinId];
1597
+ if (meta) this._contextPinMeta.set(pinId, structuredClone(meta));
1575
1598
  }
1576
1599
  }
1577
1600
  this.ax.applyPersistedAx(state.ax);
@@ -1677,6 +1700,7 @@ class CanvasStateManager {
1677
1700
  edges: Array.from(this.edges.values()),
1678
1701
  annotations: Array.from(this.annotations.values()),
1679
1702
  contextPins: Array.from(this._contextPinnedNodeIds),
1703
+ contextPinMeta: this.pinMetaRecord(),
1680
1704
  ax: this.getAxState(),
1681
1705
  revisionState: this.revisionState(),
1682
1706
  });
@@ -1774,6 +1798,7 @@ class CanvasStateManager {
1774
1798
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1775
1799
  annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
1776
1800
  contextPins: Array.from(this._contextPinnedNodeIds),
1801
+ contextPinMeta: this.pinMetaRecord(),
1777
1802
  ax: this.getAxState(),
1778
1803
  revisionState: this.revisionState(),
1779
1804
  });
@@ -1788,6 +1813,7 @@ class CanvasStateManager {
1788
1813
  ? resolved.state.annotations.map((annotation) => structuredClone(annotation))
1789
1814
  : [],
1790
1815
  contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
1816
+ contextPinMeta: resolved.state.contextPinMeta ? structuredClone(resolved.state.contextPinMeta) : undefined,
1791
1817
  ax: resolved.state.ax ? structuredClone(resolved.state.ax) : createEmptyAxState(),
1792
1818
  revisionState: resolved.state.revisionState ? structuredClone(resolved.state.revisionState) : undefined,
1793
1819
  };
@@ -2062,7 +2088,6 @@ class CanvasStateManager {
2062
2088
  // moved child's requested coordinates. Compaction is opt-in (group
2063
2089
  // create/add with childLayout, or arrange).
2064
2090
  this.recomputeParentGroupBounds(parentGroupId);
2065
- this.reflowAllGroups();
2066
2091
  }
2067
2092
  this.scheduleSave();
2068
2093
  this.notifyChange('nodes');
@@ -2093,6 +2118,7 @@ class CanvasStateManager {
2093
2118
  const cloned = existing ? structuredClone(existing) : null;
2094
2119
  const oldAxState = this.getAxState();
2095
2120
  const wasPinned = this._contextPinnedNodeIds.has(id);
2121
+ const pinMeta = this._contextPinMeta.get(id);
2096
2122
  // The enclosing group's membership before this removal — restored verbatim on undo.
2097
2123
  let enclosingBefore: { id: string; children: string[] } | null = null;
2098
2124
  // A removed group DISSOLVES: its children are released — into the enclosing
@@ -2127,6 +2153,7 @@ class CanvasStateManager {
2127
2153
  this.nodes.delete(id);
2128
2154
  this.removeEdgesForNode(id);
2129
2155
  this._contextPinnedNodeIds.delete(id);
2156
+ this._contextPinMeta.delete(id);
2130
2157
  // Re-normalize canvas-bound AX against the surviving node set. This strips the
2131
2158
  // dangling node ref from work items / approval gates / elicitations / mode
2132
2159
  // requests (re-anchored) and drops node-anchored review annotations (removed).
@@ -2191,7 +2218,10 @@ class CanvasStateManager {
2191
2218
  }
2192
2219
  }
2193
2220
  for (const edge of connectedEdges) this.addEdge(structuredClone(edge));
2194
- if (wasPinned) this._contextPinnedNodeIds.add(id);
2221
+ if (wasPinned) {
2222
+ this._contextPinnedNodeIds.add(id);
2223
+ if (pinMeta) this._contextPinMeta.set(id, pinMeta);
2224
+ }
2195
2225
  this.ax.applyPersistedAx(oldAxState);
2196
2226
  this.scheduleSave();
2197
2227
  this.notifyChange('nodes');
@@ -2444,7 +2474,6 @@ class CanvasStateManager {
2444
2474
  for (const groupId of touchedParentGroups) {
2445
2475
  this.recomputeParentGroupBounds(groupId);
2446
2476
  }
2447
- if (touchedParentGroups.size > 0) this.reflowAllGroups();
2448
2477
 
2449
2478
  if (applied > 0) {
2450
2479
  this.scheduleSave();
@@ -2849,13 +2878,41 @@ class CanvasStateManager {
2849
2878
  recordContextRead(input: ContextReadInput, boardId: string | null = this._activeBoardId): ContextRead | null {
2850
2879
  if (!this._db) return null;
2851
2880
  try {
2852
- return appendContextReadToDB(this._db, input, boardId);
2881
+ const read = appendContextReadToDB(this._db, input, boardId, this.readRevisions(input, boardId));
2882
+ this._contextReadListener?.(boardId);
2883
+ return read;
2853
2884
  } catch (error) {
2854
2885
  logCanvasStateWarning('record context read failed', error);
2855
2886
  return null;
2856
2887
  }
2857
2888
  }
2858
2889
 
2890
+ /** The delivered ids that are nodes on the read board, each with its content revision now. */
2891
+ private readRevisions(input: ContextReadInput, boardId: string | null): Record<string, number> {
2892
+ if (!boardId) return {};
2893
+ const nodes =
2894
+ boardId === this._activeBoardId ? [...this.nodes.values()] : (this.readBoard(boardId, false)?.state.nodes ?? []);
2895
+ const byId = new Map(nodes.map((node) => [node.id, node.contentRevision ?? 0]));
2896
+ const revisions: Record<string, number> = {};
2897
+ for (const id of input.readNodeIds ?? input.deliveredNodeIds) {
2898
+ const revision = byId.get(id);
2899
+ if (revision !== undefined) revisions[id] = revision;
2900
+ }
2901
+ return revisions;
2902
+ }
2903
+
2904
+ /** Per node on a board, the latest agent read that delivered its content. */
2905
+ getNodeReadStatus(boardId: string | null = this._activeBoardId): NodeReadStatus[] {
2906
+ if (!this._db || !boardId) return [];
2907
+ return loadNodeReadStatusFromDB(this._db, boardId);
2908
+ }
2909
+
2910
+ /** Nodes on a board whose content an agent read at or after `since` (ISO). */
2911
+ getReadNodeIdsSince(boardId: string | null, since: string): string[] {
2912
+ if (!this._db || !boardId) return [];
2913
+ return loadReadNodeIdsSince(this._db, boardId, since);
2914
+ }
2915
+
2859
2916
  getContextReads(limit?: number): { reads: ContextRead[]; summary: ContextReadConsumerSummary[] } {
2860
2917
  return this._db ? loadContextReadsFromDB(this._db, limit) : { reads: [], summary: [] };
2861
2918
  }
@@ -2869,25 +2926,57 @@ class CanvasStateManager {
2869
2926
  return this.ax.getAxTimeline(q);
2870
2927
  }
2871
2928
 
2872
- setContextPins(nodeIds: string[]): void {
2929
+ /**
2930
+ * Replace the pin set. Newly pinned nodes are attributed to the current
2931
+ * actor (with `reason` when given); pins that stay keep their attribution.
2932
+ * `restore` re-applies saved attribution, so undo does not re-attribute.
2933
+ */
2934
+ setContextPins(nodeIds: string[], options: { reason?: string; restore?: Map<string, ContextPinMeta> } = {}): void {
2873
2935
  const oldPins = Array.from(this._contextPinnedNodeIds);
2936
+ const oldMeta = new Map(this._contextPinMeta);
2937
+ const pinnedAt = new Date().toISOString();
2874
2938
  this._contextPinnedNodeIds.clear();
2875
2939
  for (const id of nodeIds) {
2876
- if (this.nodes.has(id)) {
2877
- this._contextPinnedNodeIds.add(id);
2878
- }
2879
- }
2940
+ if (!this.nodes.has(id)) continue;
2941
+ this._contextPinnedNodeIds.add(id);
2942
+ const meta =
2943
+ options.restore?.get(id) ??
2944
+ (oldPins.includes(id) ? oldMeta.get(id) : undefined) ??
2945
+ (oldPins.includes(id)
2946
+ ? undefined
2947
+ : { pinnedBy: currentActor(), pinnedAt, ...(options.reason ? { reason: options.reason } : {}) });
2948
+ if (meta) this._contextPinMeta.set(id, meta);
2949
+ }
2950
+ for (const id of [...this._contextPinMeta.keys()]) {
2951
+ if (!this._contextPinnedNodeIds.has(id)) this._contextPinMeta.delete(id);
2952
+ }
2953
+ const newMeta = new Map(this._contextPinMeta);
2880
2954
  this.scheduleSave();
2881
2955
  this.notifyChange('pins');
2882
2956
  this.recordMutation({
2883
2957
  operationType: 'setPins',
2884
2958
  description: `Set context pins (${this._contextPinnedNodeIds.size} nodes)`,
2885
- forward: this.suppressed(() => this.setContextPins([...nodeIds])),
2886
- inverse: this.suppressed(() => this.setContextPins(oldPins)),
2959
+ forward: this.suppressed(() => this.setContextPins([...nodeIds], { restore: newMeta })),
2960
+ inverse: this.suppressed(() => this.setContextPins(oldPins, { restore: oldMeta })),
2887
2961
  });
2888
2962
  }
2889
2963
 
2964
+ /** Who pinned each current pin, when and why (pins with an unknown pinner are absent). */
2965
+ getContextPinMeta(): Record<string, ContextPinMeta> {
2966
+ return this.pinMetaRecord();
2967
+ }
2968
+
2969
+ private pinMetaRecord(): Record<string, ContextPinMeta> {
2970
+ const record: Record<string, ContextPinMeta> = {};
2971
+ for (const id of this._contextPinnedNodeIds) {
2972
+ const meta = this._contextPinMeta.get(id);
2973
+ if (meta) record[id] = structuredClone(meta);
2974
+ }
2975
+ return record;
2976
+ }
2977
+
2890
2978
  clearContextPins(): void {
2979
+ this._contextPinMeta.clear();
2891
2980
  this._contextPinnedNodeIds.clear();
2892
2981
  this.scheduleSave();
2893
2982
  this.notifyChange('pins');
@@ -3019,6 +3108,7 @@ class CanvasStateManager {
3019
3108
  this.edges.clear();
3020
3109
  this.annotations.clear();
3021
3110
  this._contextPinnedNodeIds.clear();
3111
+ this._contextPinMeta.clear();
3022
3112
  // Clears canvas-bound AX state (focus, work items, approvals, review annotations).
3023
3113
  // Timeline tables (ax_events/ax_evidence/ax_steering) and host capability are
3024
3114
  // deliberately retained per the AX state-partition policy.
@@ -71,13 +71,27 @@ function fullyContains(group: CanvasNodeState, child: CanvasNodeState): boolean
71
71
  );
72
72
  }
73
73
 
74
- function isGroupChildPair(group: CanvasNodeState, child: CanvasNodeState): boolean {
75
- if (group.type !== 'group') return false;
74
+ function isDirectGroupChild(group: CanvasNodeState, child: CanvasNodeState): boolean {
76
75
  if (child.data.parentGroup === group.id) return true;
77
76
  const children = group.data.children;
78
77
  return Array.isArray(children) && children.includes(child.id);
79
78
  }
80
79
 
80
+ /** True when `child` sits anywhere inside `group` — a nested group's members are
81
+ * inside every ancestor frame, not overlapping it. */
82
+ function isGroupChildPair(group: CanvasNodeState, child: CanvasNodeState, byId: Map<string, CanvasNodeState>): boolean {
83
+ if (group.type !== 'group') return false;
84
+ const seen = new Set<string>();
85
+ let current: CanvasNodeState | undefined = child;
86
+ while (current && !seen.has(current.id)) {
87
+ if (isDirectGroupChild(group, current)) return true;
88
+ seen.add(current.id);
89
+ const parentId: unknown = current.data.parentGroup;
90
+ current = typeof parentId === 'string' ? byId.get(parentId) : undefined;
91
+ }
92
+ return false;
93
+ }
94
+
81
95
  function pair(a: CanvasNodeState, b: CanvasNodeState): CanvasValidationPair {
82
96
  return {
83
97
  aId: a.id,
@@ -101,6 +115,7 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
101
115
  const containments: CanvasContainmentIssue[] = [];
102
116
  const containmentViolations: CanvasContainmentIssue[] = [];
103
117
  const groupFrameOverlaps: CanvasValidationPair[] = [];
118
+ const byId = new Map(layout.nodes.map((node) => [node.id, node]));
104
119
 
105
120
  for (let i = 0; i < layout.nodes.length; i++) {
106
121
  const a = layout.nodes[i]!;
@@ -108,11 +123,11 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
108
123
  const b = layout.nodes[j]!;
109
124
  if (!overlaps(a, b)) continue;
110
125
 
111
- if (isGroupChildPair(a, b)) {
126
+ if (isGroupChildPair(a, b, byId)) {
112
127
  (fullyContains(a, b) ? containments : containmentViolations).push(containment(a, b));
113
128
  continue;
114
129
  }
115
- if (isGroupChildPair(b, a)) {
130
+ if (isGroupChildPair(b, a, byId)) {
116
131
  (fullyContains(b, a) ? containments : containmentViolations).push(containment(b, a));
117
132
  continue;
118
133
  }