pmx-canvas 0.4.8 → 0.5.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 (210) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +154 -5
  2. package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  3. package/CHANGELOG.md +139 -0
  4. package/Readme.md +72 -23
  5. package/dist/canvas/global.css +3069 -796
  6. package/dist/canvas/index.js +104 -104
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/cli/agent.d.ts +1 -0
  9. package/dist/types/cli/commands/pump.d.ts +9 -0
  10. package/dist/types/client/App.d.ts +1 -1
  11. package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
  12. package/dist/types/client/canvas/BarHint.d.ts +22 -0
  13. package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
  15. package/dist/types/client/canvas/CommandBar.d.ts +10 -0
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
  18. package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
  19. package/dist/types/client/canvas/EmptyState.d.ts +11 -0
  20. package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
  21. package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/Minimap.d.ts +12 -1
  23. package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
  24. package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
  25. package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
  26. package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
  27. package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
  28. package/dist/types/client/canvas/ToolRail.d.ts +21 -0
  29. package/dist/types/client/canvas/TopBar.d.ts +7 -0
  30. package/dist/types/client/canvas/canvas-area.d.ts +25 -0
  31. package/dist/types/client/canvas/create-in-view.d.ts +18 -0
  32. package/dist/types/client/canvas/import-files.d.ts +8 -0
  33. package/dist/types/client/canvas/kind-colors.d.ts +3 -0
  34. package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
  35. package/dist/types/client/canvas/use-now.d.ts +6 -0
  36. package/dist/types/client/icons.d.ts +11 -4
  37. package/dist/types/client/nodes/GroupNode.d.ts +6 -1
  38. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  39. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  40. package/dist/types/client/state/canvas-store.d.ts +49 -5
  41. package/dist/types/client/state/human-store.d.ts +26 -0
  42. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  43. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  44. package/dist/types/client/state/node-factory.d.ts +0 -1
  45. package/dist/types/client/state/presence-store.d.ts +78 -0
  46. package/dist/types/client/state/session-store.d.ts +176 -0
  47. package/dist/types/client/types.d.ts +2 -1
  48. package/dist/types/client/utils/platform.d.ts +2 -0
  49. package/dist/types/server/agent-presence.d.ts +192 -0
  50. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  51. package/dist/types/server/ax-interaction.d.ts +4 -10
  52. package/dist/types/server/ax-state-manager.d.ts +21 -2
  53. package/dist/types/server/ax-state.d.ts +49 -3
  54. package/dist/types/server/ax-wait.d.ts +13 -0
  55. package/dist/types/server/canvas-db.d.ts +9 -1
  56. package/dist/types/server/canvas-operations.d.ts +1 -0
  57. package/dist/types/server/canvas-state.d.ts +27 -13
  58. package/dist/types/server/canvas-validation.d.ts +0 -14
  59. package/dist/types/server/html-surface.d.ts +1 -1
  60. package/dist/types/server/human-presence.d.ts +40 -0
  61. package/dist/types/server/index.d.ts +29 -3
  62. package/dist/types/server/intent-registry.d.ts +7 -0
  63. package/dist/types/server/mutation-history.d.ts +15 -2
  64. package/dist/types/server/operations/invoker.d.ts +13 -2
  65. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  66. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  67. package/dist/types/server/operations/registry.d.ts +11 -0
  68. package/dist/types/server/operations/types.d.ts +1 -1
  69. package/dist/types/server/scope-fence.d.ts +62 -0
  70. package/dist/types/server/server.d.ts +0 -12
  71. package/dist/types/shared/agent-presence.d.ts +130 -0
  72. package/dist/types/shared/approval-gates.d.ts +19 -0
  73. package/dist/types/shared/auto-arrange.d.ts +0 -1
  74. package/dist/types/shared/ax-kinds.d.ts +11 -0
  75. package/dist/types/shared/human-presence.d.ts +31 -0
  76. package/dist/types/shared/iframe-probe.d.ts +8 -0
  77. package/dist/types/shared/scope-fence.d.ts +21 -0
  78. package/docs/RELEASE.md +6 -0
  79. package/docs/ax-host-adapter-contract.md +30 -14
  80. package/docs/cli.md +37 -1
  81. package/docs/environment.md +3 -0
  82. package/docs/http-api.md +222 -5
  83. package/docs/mcp.md +6 -5
  84. package/docs/node-types.md +3 -1
  85. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  86. package/docs/screenshot.png +0 -0
  87. package/docs/screenshots/multi-agent-live.png +0 -0
  88. package/docs/screenshots/workbench-live-dark.png +0 -0
  89. package/docs/screenshots/workbench-live-light.png +0 -0
  90. package/docs/sdk.md +1 -1
  91. package/package.json +1 -1
  92. package/skills/pmx-canvas/SKILL.md +103 -16
  93. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  94. package/skills/pmx-canvas/references/full-reference.md +55 -16
  95. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  96. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  97. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  98. package/src/cli/agent.ts +1 -0
  99. package/src/cli/commands/copilot.ts +15 -8
  100. package/src/cli/commands/nodes.ts +1 -19
  101. package/src/cli/commands/pump.ts +252 -0
  102. package/src/cli/index.ts +1 -5
  103. package/src/cli/shared.ts +1 -1
  104. package/src/client/App.tsx +209 -585
  105. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  106. package/src/client/canvas/AttentionHistory.tsx +0 -10
  107. package/src/client/canvas/BarHint.tsx +49 -0
  108. package/src/client/canvas/CanvasNode.tsx +488 -136
  109. package/src/client/canvas/CanvasViewport.tsx +190 -94
  110. package/src/client/canvas/CommandBar.tsx +140 -0
  111. package/src/client/canvas/CommandPalette.tsx +133 -58
  112. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  113. package/src/client/canvas/ContextMenu.tsx +88 -27
  114. package/src/client/canvas/ContextPinBar.tsx +2 -2
  115. package/src/client/canvas/EdgeLayer.tsx +121 -38
  116. package/src/client/canvas/EmptyState.tsx +93 -0
  117. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  118. package/src/client/canvas/ExternalWriters.tsx +308 -0
  119. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  120. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  121. package/src/client/canvas/IntentLayer.tsx +1 -1
  122. package/src/client/canvas/Minimap.tsx +117 -190
  123. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  124. package/src/client/canvas/SelectionBar.tsx +175 -23
  125. package/src/client/canvas/SessionPanel.tsx +481 -0
  126. package/src/client/canvas/SessionReceipt.tsx +141 -0
  127. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  128. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  129. package/src/client/canvas/TextPrompt.tsx +128 -0
  130. package/src/client/canvas/ToolRail.tsx +447 -0
  131. package/src/client/canvas/TopBar.tsx +308 -0
  132. package/src/client/canvas/auto-fit.ts +1 -5
  133. package/src/client/canvas/canvas-area.ts +41 -0
  134. package/src/client/canvas/create-in-view.ts +48 -0
  135. package/src/client/canvas/import-files.ts +64 -0
  136. package/src/client/canvas/kind-colors.ts +22 -0
  137. package/src/client/canvas/snap-guides.ts +1 -1
  138. package/src/client/canvas/use-focus-trap.ts +54 -0
  139. package/src/client/canvas/use-now.ts +17 -0
  140. package/src/client/canvas/use-pan-zoom.ts +13 -2
  141. package/src/client/icons.tsx +57 -18
  142. package/src/client/nodes/GroupNode.tsx +9 -28
  143. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  144. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  145. package/src/client/nodes/MarkdownNode.tsx +59 -24
  146. package/src/client/nodes/MdFormatBar.tsx +2 -4
  147. package/src/client/nodes/inline-editor-commands.ts +17 -6
  148. package/src/client/nodes/use-surface-frame.ts +3 -1
  149. package/src/client/state/attention-bridge.ts +3 -0
  150. package/src/client/state/canvas-store.ts +273 -84
  151. package/src/client/state/human-store.ts +175 -0
  152. package/src/client/state/iframe-mode.ts +33 -9
  153. package/src/client/state/intent-bridge.ts +75 -13
  154. package/src/client/state/node-factory.ts +2 -8
  155. package/src/client/state/presence-store.ts +168 -0
  156. package/src/client/state/session-store.ts +419 -0
  157. package/src/client/state/sse-bridge.ts +93 -30
  158. package/src/client/theme/global.css +3069 -796
  159. package/src/client/types.ts +3 -1
  160. package/src/client/utils/platform.ts +6 -1
  161. package/src/mcp/canvas-access.ts +2 -2
  162. package/src/server/agent-presence.ts +755 -0
  163. package/src/server/ax-context.ts +45 -0
  164. package/src/server/ax-gate-ttl.ts +61 -0
  165. package/src/server/ax-interaction.ts +29 -1
  166. package/src/server/ax-state-manager.ts +78 -6
  167. package/src/server/ax-state.ts +82 -15
  168. package/src/server/ax-wait.ts +33 -0
  169. package/src/server/canvas-db.ts +103 -33
  170. package/src/server/canvas-operations.ts +17 -8
  171. package/src/server/canvas-state.ts +131 -58
  172. package/src/server/canvas-validation.ts +3 -53
  173. package/src/server/demo-state.json +2 -70
  174. package/src/server/html-surface.ts +11 -2
  175. package/src/server/human-presence.ts +160 -0
  176. package/src/server/index.ts +107 -4
  177. package/src/server/intent-registry.ts +13 -0
  178. package/src/server/mutation-history.ts +47 -2
  179. package/src/server/operations/composites.ts +6 -3
  180. package/src/server/operations/http.ts +10 -1
  181. package/src/server/operations/index.ts +4 -0
  182. package/src/server/operations/invoker.ts +33 -6
  183. package/src/server/operations/ops/ax-presence.ts +62 -0
  184. package/src/server/operations/ops/ax-read.ts +8 -0
  185. package/src/server/operations/ops/ax-state.ts +106 -1
  186. package/src/server/operations/ops/ax-timeline.ts +54 -5
  187. package/src/server/operations/ops/ax-work.ts +46 -0
  188. package/src/server/operations/ops/batch.ts +3 -1
  189. package/src/server/operations/ops/edges.ts +88 -2
  190. package/src/server/operations/ops/groups.ts +4 -3
  191. package/src/server/operations/ops/human-presence.ts +35 -0
  192. package/src/server/operations/ops/json-render.ts +11 -1
  193. package/src/server/operations/ops/nodes.ts +65 -16
  194. package/src/server/operations/ops/query.ts +1 -0
  195. package/src/server/operations/ops/snapshots.ts +16 -0
  196. package/src/server/operations/registry.ts +196 -1
  197. package/src/server/operations/types.ts +1 -1
  198. package/src/server/scope-fence.ts +207 -0
  199. package/src/server/server.ts +177 -79
  200. package/src/server/trace-manager.ts +0 -2
  201. package/src/server/web-artifacts.ts +0 -1
  202. package/src/shared/agent-presence.ts +162 -0
  203. package/src/shared/approval-gates.ts +35 -0
  204. package/src/shared/auto-arrange.ts +121 -13
  205. package/src/shared/ax-kinds.ts +23 -0
  206. package/src/shared/human-presence.ts +30 -0
  207. package/src/shared/iframe-probe.ts +8 -0
  208. package/src/shared/scope-fence.ts +31 -0
  209. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  210. package/src/client/canvas/DockedNode.tsx +0 -165
@@ -25,6 +25,7 @@ import {
25
25
  loadSnapshotFromDB,
26
26
  listSnapshotsFromDB,
27
27
  deleteSnapshotFromDB,
28
+ renameSnapshotInDB,
28
29
  writeBlobToDB,
29
30
  readBlobFromDB,
30
31
  isDbPopulated,
@@ -185,7 +186,6 @@ export interface CanvasNodeState {
185
186
  zIndex: number;
186
187
  collapsed: boolean;
187
188
  pinned: boolean;
188
- dockPosition: 'left' | 'right' | null;
189
189
  data: Record<string, unknown>;
190
190
  }
191
191
 
@@ -235,7 +235,6 @@ export interface CanvasNodeUpdate {
235
235
  position?: { x: number; y: number };
236
236
  size?: { width: number; height: number };
237
237
  collapsed?: boolean;
238
- dockPosition?: 'left' | 'right' | null;
239
238
  }
240
239
 
241
240
  export type CanvasChangeType = 'pins' | 'nodes' | 'ax' | 'ax-timeline';
@@ -246,6 +245,7 @@ export interface MutationRecordInfo {
246
245
  | 'updateNode'
247
246
  | 'removeNode'
248
247
  | 'addEdge'
248
+ | 'updateEdge'
249
249
  | 'removeEdge'
250
250
  | 'addAnnotation'
251
251
  | 'removeAnnotation'
@@ -267,7 +267,7 @@ export interface MutationRecordInfo {
267
267
  | 'arrange'
268
268
  | 'batch'
269
269
  | 'groupNodes'
270
- | 'ungroupNodes'
270
+ | 'releaseGroupChildren'
271
271
  | 'viewport';
272
272
  description: string;
273
273
  forward: () => void;
@@ -288,20 +288,17 @@ function formatBatchUpdateDescription(updates: CanvasNodeUpdate[]): string {
288
288
  let moved = 0;
289
289
  let resized = 0;
290
290
  let collapsed = 0;
291
- let docked = 0;
292
291
 
293
292
  for (const update of updates) {
294
293
  if (update.position) moved++;
295
294
  if (update.size) resized++;
296
295
  if (update.collapsed !== undefined) collapsed++;
297
- if (update.dockPosition !== undefined) docked++;
298
296
  }
299
297
 
300
298
  const parts: string[] = [];
301
299
  if (moved > 0) parts.push(`${moved} moved`);
302
300
  if (resized > 0) parts.push(`${resized} resized`);
303
301
  if (collapsed > 0) parts.push(`${collapsed} collapsed`);
304
- if (docked > 0) parts.push(`${docked} docked`);
305
302
 
306
303
  const prefix = `Updated ${updates.length} node${updates.length === 1 ? '' : 's'}`;
307
304
  return parts.length > 0 ? `${prefix} (${parts.join(', ')})` : prefix;
@@ -506,10 +503,6 @@ class CanvasStateManager {
506
503
  }
507
504
 
508
505
  private normalizeNode(node: CanvasNodeState): CanvasNodeState {
509
- // Context nodes default to a right-docked, collapsed pill (see DockedNode.tsx),
510
- // but that default is applied at CREATE time only — it must not be re-forced on
511
- // every write, or the node could never be undocked. Undocking (dockPosition →
512
- // null) is a deliberate user action and is respected here.
513
506
  return {
514
507
  ...node,
515
508
  data: normalizeCanvasNodeData(node.type, node.data),
@@ -874,16 +867,6 @@ class CanvasStateManager {
874
867
  return false;
875
868
  }
876
869
 
877
- /**
878
- * Whether this workspace's canvas DB already holds saved state. Used to gate
879
- * brand-new-workspace seeding (e.g. the default docked status/context widgets)
880
- * so we never add nodes to a canvas that already has content. Reflects the
881
- * pre-run persisted flag until the next save.
882
- */
883
- hasPersistedState(): boolean {
884
- return this._db ? isDbPopulated(this._db) : false;
885
- }
886
-
887
870
  /** Debounced save — coalesces rapid mutations into a single write. */
888
871
  private scheduleSave(): void {
889
872
  if (!this._db) return;
@@ -1272,6 +1255,16 @@ class CanvasStateManager {
1272
1255
  }
1273
1256
 
1274
1257
  /** Delete a snapshot. */
1258
+ renameSnapshot(id: string, name: string): boolean {
1259
+ if (!this._db) return false;
1260
+ try {
1261
+ return renameSnapshotInDB(this._db, id, name);
1262
+ } catch (error) {
1263
+ logCanvasStateWarning('rename snapshot failed', error, { id, name });
1264
+ return false;
1265
+ }
1266
+ }
1267
+
1275
1268
  deleteSnapshot(id: string): boolean {
1276
1269
  // Try SQLite first
1277
1270
  if (this._db) {
@@ -1320,14 +1313,6 @@ class CanvasStateManager {
1320
1313
  }
1321
1314
 
1322
1315
  addNode(node: CanvasNodeState): void {
1323
- // Docking is EXPLICIT (0.4.6). Context nodes used to auto-dock to a collapsed
1324
- // right-hand pill when created without a dock position, which meant an agent
1325
- // creating a context node — even at explicit coordinates — got a node that
1326
- // existed in the API and passed validation but never rendered on the canvas,
1327
- // and any edge to it terminated in empty space (0.4.6 orb feedback #1). The
1328
- // seeded HUD widgets (status-main / context-main) pass `dockPosition` on
1329
- // create, so the docked pills are unaffected; anything else reaches the
1330
- // canvas where the caller put it. Dock deliberately via node.update.
1331
1316
  const cloned = structuredClone(this.normalizeNode(node));
1332
1317
  this.nodes.set(node.id, cloned);
1333
1318
  this.scheduleSave();
@@ -1385,29 +1370,35 @@ class CanvasStateManager {
1385
1370
  const connectedEdges = existing ? this.getEdgesForNode(id).map((e) => structuredClone(e)) : [];
1386
1371
  const cloned = existing ? structuredClone(existing) : null;
1387
1372
  const oldAxState = this.getAxState();
1373
+ const wasPinned = this._contextPinnedNodeIds.has(id);
1374
+ // The enclosing group's membership before this removal — restored verbatim on undo.
1375
+ let enclosingBefore: { id: string; children: string[] } | null = null;
1376
+ // A removed group DISSOLVES: its children are released — into the enclosing
1377
+ // group when nested, else to the board. This is the one implementation of
1378
+ // ungroup, so the human's Ungroup and the agent's `group.remove` cannot differ.
1379
+ let released: string[] = [];
1388
1380
 
1389
- // Prune from parent group's children list
1390
1381
  if (existing) {
1391
1382
  const parentGroupId = existing.data.parentGroup as string | undefined;
1392
- if (parentGroupId) {
1393
- const parent = this.nodes.get(parentGroupId);
1394
- if (parent && parent.type === 'group') {
1395
- const children = (parent.data.children as string[]) ?? [];
1396
- const pruned = children.filter((cid) => cid !== id);
1397
- this.nodes.set(parentGroupId, { ...parent, data: { ...parent.data, children: pruned } });
1398
- }
1399
- }
1400
- // If removing a group, clear parentGroup on all its children
1383
+ const parent = parentGroupId ? this.nodes.get(parentGroupId) : undefined;
1384
+ const enclosing = parent && parent.type === 'group' ? parent : null;
1385
+ if (enclosing)
1386
+ enclosingBefore = { id: enclosing.id, children: ((enclosing.data.children as string[]) ?? []).slice() };
1401
1387
  if (existing.type === 'group') {
1402
- const childIds = (existing.data.children as string[]) ?? [];
1403
- for (const cid of childIds) {
1404
- const child = this.nodes.get(cid);
1405
- if (!child) continue;
1388
+ released = ((existing.data.children as string[]) ?? []).filter((cid) => this.nodes.has(cid));
1389
+ for (const cid of released) {
1390
+ const child = this.nodes.get(cid)!;
1406
1391
  const d = { ...child.data };
1407
- delete d.parentGroup;
1392
+ if (enclosing) d.parentGroup = enclosing.id;
1393
+ else delete d.parentGroup;
1408
1394
  this.nodes.set(cid, { ...child, data: d });
1409
1395
  }
1410
1396
  }
1397
+ if (enclosing && enclosingBefore) {
1398
+ // The removed node leaves the enclosing group; a dissolved group's children take its place.
1399
+ const children = enclosingBefore.children.flatMap((cid) => (cid === id ? released : [cid]));
1400
+ this.nodes.set(enclosing.id, { ...enclosing, data: { ...enclosing.data, children } });
1401
+ }
1411
1402
  }
1412
1403
 
1413
1404
  this.nodes.delete(id);
@@ -1450,15 +1441,36 @@ class CanvasStateManager {
1450
1441
  );
1451
1442
  }
1452
1443
  if (cloned) {
1444
+ const title = (cloned.data.title as string) ?? id;
1453
1445
  this.recordMutation({
1454
1446
  operationType: 'removeNode',
1455
- description: `Removed ${cloned.type} node "${(cloned.data.title as string) ?? id}"`,
1447
+ description:
1448
+ cloned.type === 'group'
1449
+ ? `Dissolved group "${title}" — ${released.length} node${released.length === 1 ? '' : 's'} released`
1450
+ : `Removed ${cloned.type} node "${title}"`,
1456
1451
  forward: this.suppressed(() => this.removeNode(id)),
1457
1452
  inverse: this.suppressed(() => {
1458
1453
  this.addNode(structuredClone(cloned));
1454
+ // A restored group takes its children back; the enclosing group's membership is restored verbatim.
1455
+ for (const cid of released) {
1456
+ const child = this.nodes.get(cid);
1457
+ if (child) this.nodes.set(cid, { ...child, data: { ...child.data, parentGroup: id } });
1458
+ }
1459
+ if (enclosingBefore) {
1460
+ const enclosing = this.nodes.get(enclosingBefore.id);
1461
+ if (enclosing) {
1462
+ this.nodes.set(enclosing.id, {
1463
+ ...enclosing,
1464
+ data: { ...enclosing.data, children: enclosingBefore.children },
1465
+ });
1466
+ }
1467
+ }
1459
1468
  for (const edge of connectedEdges) this.addEdge(structuredClone(edge));
1469
+ if (wasPinned) this._contextPinnedNodeIds.add(id);
1460
1470
  this.ax.applyPersistedAx(oldAxState);
1461
1471
  this.scheduleSave();
1472
+ this.notifyChange('nodes');
1473
+ this.notifyChange('pins');
1462
1474
  this.notifyChange('ax');
1463
1475
  }),
1464
1476
  });
@@ -1497,6 +1509,46 @@ class CanvasStateManager {
1497
1509
  return true;
1498
1510
  }
1499
1511
 
1512
+ updateEdge(id: string, patch: Partial<Pick<CanvasEdge, 'type' | 'label' | 'style' | 'animated'>>): CanvasEdge | null {
1513
+ const existing = this.edges.get(id);
1514
+ if (!existing) return null;
1515
+ // Retyping onto an existing from/to/type triple would create the duplicate
1516
+ // addEdge refuses.
1517
+ if (patch.type && patch.type !== existing.type) {
1518
+ for (const other of this.edges.values()) {
1519
+ if (other.id !== id && other.from === existing.from && other.to === existing.to && other.type === patch.type) {
1520
+ return null;
1521
+ }
1522
+ }
1523
+ }
1524
+ const before = structuredClone(existing);
1525
+ const updated: CanvasEdge = { ...existing, ...patch };
1526
+ // An explicit undefined in the patch clears the field (label/style/animated
1527
+ // are optional on the wire shape — never store undefined values).
1528
+ for (const key of ['label', 'style', 'animated'] as const) {
1529
+ if (key in patch && patch[key] === undefined) delete updated[key];
1530
+ }
1531
+ const after = structuredClone(updated);
1532
+ this.edges.set(id, updated);
1533
+ this.scheduleSave();
1534
+ this.notifyChange('nodes');
1535
+ this.recordMutation({
1536
+ operationType: 'updateEdge',
1537
+ description: `Updated ${updated.type} edge ${updated.from} → ${updated.to}`,
1538
+ forward: this.suppressed(() => {
1539
+ this.edges.set(id, structuredClone(after));
1540
+ this.scheduleSave();
1541
+ this.notifyChange('nodes');
1542
+ }),
1543
+ inverse: this.suppressed(() => {
1544
+ this.edges.set(id, structuredClone(before));
1545
+ this.scheduleSave();
1546
+ this.notifyChange('nodes');
1547
+ }),
1548
+ });
1549
+ return structuredClone(updated);
1550
+ }
1551
+
1500
1552
  removeEdge(id: string): boolean {
1501
1553
  const existing = this.edges.get(id);
1502
1554
  const cloned = existing ? structuredClone(existing) : null;
@@ -1612,9 +1664,6 @@ class CanvasStateManager {
1612
1664
  if (update.collapsed !== undefined && update.collapsed !== existing.collapsed) {
1613
1665
  nextPatch.collapsed = update.collapsed;
1614
1666
  }
1615
- if (update.dockPosition !== undefined && update.dockPosition !== existing.dockPosition) {
1616
- nextPatch.dockPosition = update.dockPosition;
1617
- }
1618
1667
  if (Object.keys(nextPatch).length === 0) {
1619
1668
  skipped++;
1620
1669
  continue;
@@ -1815,7 +1864,7 @@ class CanvasStateManager {
1815
1864
  }
1816
1865
 
1817
1866
  requestApproval(
1818
- input: { title: string; detail?: string | null; action?: string | null; nodeIds?: string[] },
1867
+ input: { title: string; detail?: string | null; action?: string | null; nodeIds?: string[]; ttlMs?: number },
1819
1868
  options: { source?: PmxAxSource } = {},
1820
1869
  ): PmxAxApprovalGate {
1821
1870
  return this.ax.requestApproval(input, options);
@@ -1823,12 +1872,17 @@ class CanvasStateManager {
1823
1872
 
1824
1873
  resolveApproval(
1825
1874
  id: string,
1826
- decision: 'approved' | 'rejected',
1875
+ decision: 'approved' | 'rejected' | 'held',
1827
1876
  options: { resolution?: string; source?: PmxAxSource } = {},
1828
1877
  ): PmxAxApprovalGate | null {
1829
1878
  return this.ax.resolveApproval(id, decision, options);
1830
1879
  }
1831
1880
 
1881
+ /** Reopen a resolved (typically auto-held) gate with a fresh TTL. */
1882
+ reopenApproval(id: string, options: { ttlMs?: number; source?: PmxAxSource } = {}): PmxAxApprovalGate | null {
1883
+ return this.ax.reopenApproval(id, options);
1884
+ }
1885
+
1832
1886
  // ── Review annotations (canvas-bound) ─────────────────────────────
1833
1887
  getReviewAnnotations(): PmxAxReviewAnnotation[] {
1834
1888
  return this.ax.getReviewAnnotations();
@@ -1933,7 +1987,11 @@ class CanvasStateManager {
1933
1987
 
1934
1988
  /** Merge a declarative tool/prompt policy patch (canvas-bound, snapshotted). */
1935
1989
  setPolicy(
1936
- patch: { tools?: Partial<PmxAxPolicy['tools']>; prompt?: Partial<PmxAxPolicy['prompt']> },
1990
+ patch: {
1991
+ tools?: Partial<PmxAxPolicy['tools']>;
1992
+ prompt?: Partial<PmxAxPolicy['prompt']>;
1993
+ scope?: { nodeIds: string[]; padding?: number } | null;
1994
+ },
1937
1995
  options: { source?: PmxAxSource } = {},
1938
1996
  ): PmxAxPolicy {
1939
1997
  return this.ax.setPolicy(patch, options);
@@ -1978,8 +2036,8 @@ class CanvasStateManager {
1978
2036
  return this.ax.recordSteeringMessage(message, options);
1979
2037
  }
1980
2038
 
1981
- markSteeringDelivered(id: string): boolean {
1982
- return this.ax.markSteeringDelivered(id);
2039
+ markSteeringDelivered(id: string, consumer?: string | null): boolean {
2040
+ return this.ax.markSteeringDelivered(id, consumer);
1983
2041
  }
1984
2042
 
1985
2043
  ingestActivity(
@@ -2140,19 +2198,34 @@ class CanvasStateManager {
2140
2198
  return true;
2141
2199
  }
2142
2200
 
2143
- /** Remove all children from a group, clearing their parentGroup. */
2201
+ /**
2202
+ * Ungroup = dissolve: the children become independent nodes (members of the
2203
+ * enclosing group when nested) and the frame is removed, in one undo step.
2204
+ * Delegates to `removeNode` so the human's Ungroup and the agent's
2205
+ * `group.remove` are the same operation. False when `groupId` is not a group.
2206
+ */
2144
2207
  ungroupNodes(groupId: string): boolean {
2145
2208
  const group = this.nodes.get(groupId);
2146
2209
  if (!group || group.type !== 'group') return false;
2210
+ this.removeNode(groupId);
2211
+ return true;
2212
+ }
2213
+
2214
+ /**
2215
+ * Internal membership step: release every child but keep the frame (the
2216
+ * first half of "set this group's children to …"). Not an ungroup — a
2217
+ * human or agent ungroup dissolves the frame (`ungroupNodes`).
2218
+ */
2219
+ releaseGroupChildren(groupId: string): boolean {
2220
+ const group = this.nodes.get(groupId);
2221
+ if (!group || group.type !== 'group') return false;
2147
2222
 
2148
2223
  const childIds = (group.data.children as string[]) ?? [];
2149
2224
  if (childIds.length === 0) return false;
2150
2225
 
2151
2226
  const snapshot = childIds.slice();
2152
2227
 
2153
- // Clear children from group
2154
2228
  this.nodes.set(groupId, { ...group, data: { ...group.data, children: [] } });
2155
- // Clear parentGroup from each child
2156
2229
  for (const id of childIds) {
2157
2230
  const child = this.nodes.get(id);
2158
2231
  if (!child) continue;
@@ -2164,9 +2237,9 @@ class CanvasStateManager {
2164
2237
  this.scheduleSave();
2165
2238
  this.notifyChange('nodes');
2166
2239
  this.recordMutation({
2167
- operationType: 'ungroupNodes',
2168
- description: `Ungrouped ${childIds.length} nodes from "${(group.data.title as string) ?? groupId}"`,
2169
- forward: this.suppressed(() => this.ungroupNodes(groupId)),
2240
+ operationType: 'releaseGroupChildren',
2241
+ description: `Released ${childIds.length} nodes from "${(group.data.title as string) ?? groupId}"`,
2242
+ forward: this.suppressed(() => this.releaseGroupChildren(groupId)),
2170
2243
  inverse: this.suppressed(() => this.groupNodes(groupId, snapshot)),
2171
2244
  });
2172
2245
  return true;
@@ -31,26 +31,12 @@ export interface CanvasSizeWarning {
31
31
  minHeight: number;
32
32
  }
33
33
 
34
- export interface CanvasHiddenEdgeEndpoint {
35
- edgeId: string;
36
- nodeId: string;
37
- nodeTitle: string | null;
38
- dockPosition: 'left' | 'right';
39
- }
40
-
41
34
  export interface CanvasValidationResult {
42
35
  ok: boolean;
43
36
  collisions: CanvasValidationPair[];
44
37
  containments: CanvasContainmentIssue[];
45
38
  containmentViolations: CanvasContainmentIssue[];
46
39
  missingEdgeEndpoints: Array<{ edgeId: string; from: string; to: string }>;
47
- /**
48
- * Edges whose endpoint node is DOCKED — it renders in the HUD column, not on
49
- * the canvas, so the edge visually terminates in empty space even though both
50
- * endpoint IDs resolve (0.4.6 orb feedback #1). Advisory: reported for
51
- * diagnosis, but does NOT fail `ok` (see the note at the return site).
52
- */
53
- hiddenEdgeEndpoints: CanvasHiddenEdgeEndpoint[];
54
40
  /** Nodes below their type's readable minimum (advisory — does not fail `ok`). */
55
41
  sizeWarnings: CanvasSizeWarning[];
56
42
  summary: {
@@ -60,7 +46,6 @@ export interface CanvasValidationResult {
60
46
  containments: number;
61
47
  containmentViolations: number;
62
48
  missingEdgeEndpoints: number;
63
- hiddenEdgeEndpoints: number;
64
49
  sizeWarnings: number;
65
50
  };
66
51
  }
@@ -74,10 +59,6 @@ function overlaps(a: CanvasNodeState, b: CanvasNodeState): boolean {
74
59
  );
75
60
  }
76
61
 
77
- function participatesInCanvasCollisionValidation(node: CanvasNodeState): boolean {
78
- return node.dockPosition === null;
79
- }
80
-
81
62
  function fullyContains(group: CanvasNodeState, child: CanvasNodeState): boolean {
82
63
  return (
83
64
  child.position.x >= group.position.x &&
@@ -119,10 +100,8 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
119
100
 
120
101
  for (let i = 0; i < layout.nodes.length; i++) {
121
102
  const a = layout.nodes[i]!;
122
- if (!participatesInCanvasCollisionValidation(a)) continue;
123
103
  for (let j = i + 1; j < layout.nodes.length; j++) {
124
104
  const b = layout.nodes[j]!;
125
- if (!participatesInCanvasCollisionValidation(b)) continue;
126
105
  if (!overlaps(a, b)) continue;
127
106
 
128
107
  if (isGroupChildPair(a, b)) {
@@ -143,35 +122,13 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
143
122
  .filter((edge) => !nodeIds.has(edge.from) || !nodeIds.has(edge.to))
144
123
  .map((edge) => ({ edgeId: edge.id, from: edge.from, to: edge.to }));
145
124
 
146
- // An edge to a DOCKED node cannot be drawn: docked nodes live in the HUD
147
- // column, and the world-space edge layer skips them, so the edge trails off
148
- // into empty canvas. Structural validation used to miss this entirely because
149
- // both endpoint IDs resolve (0.4.6 orb feedback #1).
150
- const dockedById = new Map(
151
- layout.nodes.filter((node) => node.dockPosition != null).map((node) => [node.id, node] as const),
152
- );
153
- const hiddenEdgeEndpoints: CanvasHiddenEdgeEndpoint[] = layout.edges.flatMap((edge) =>
154
- [edge.from, edge.to].flatMap((nodeId) => {
155
- const docked = dockedById.get(nodeId);
156
- if (!docked || docked.dockPosition == null) return [];
157
- return [
158
- {
159
- edgeId: edge.id,
160
- nodeId,
161
- nodeTitle: getCanvasNodeTitle(docked),
162
- dockPosition: docked.dockPosition,
163
- },
164
- ];
165
- }),
166
- );
167
-
168
125
  // Advisory: nodes below their type's readable floor (clipped/unreadable
169
126
  // content — 0.4.5 report follow-up). Creation clamps these, but resized or
170
127
  // strictSize-created nodes can still be undersized; surface them so an
171
- // agent's validate pass sees the problem. Collapsed/docked nodes render as
172
- // bars, and strictSize is the deliberate opt-out — all skipped.
128
+ // agent's validate pass sees the problem. Collapsed nodes render as bars,
129
+ // and strictSize is the deliberate opt-out — both skipped.
173
130
  const sizeWarnings: CanvasSizeWarning[] = layout.nodes
174
- .filter((node) => !node.collapsed && node.dockPosition == null && node.data?.strictSize !== true)
131
+ .filter((node) => !node.collapsed && node.data?.strictSize !== true)
175
132
  .flatMap((node) => {
176
133
  const min = nodeMinSize(node.type);
177
134
  if (!min || (node.size.width >= min.width && node.size.height >= min.height)) return [];
@@ -189,17 +146,11 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
189
146
  });
190
147
 
191
148
  return {
192
- // `hiddenEdgeEndpoints` is ADVISORY (like sizeWarnings): a docked endpoint
193
- // is a rendering observation, not a structural defect, and folding it into
194
- // `ok` would flip previously-healthy boards to ok:false with no user change
195
- // — `ok` is a documented response field of a frozen surface, so that is a
196
- // contract break, not a patch. It is still reported for diagnosis.
197
149
  ok: collisions.length === 0 && containmentViolations.length === 0 && missingEdgeEndpoints.length === 0,
198
150
  collisions,
199
151
  containments,
200
152
  containmentViolations,
201
153
  missingEdgeEndpoints,
202
- hiddenEdgeEndpoints,
203
154
  sizeWarnings,
204
155
  summary: {
205
156
  nodes: layout.nodes.length,
@@ -208,7 +159,6 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
208
159
  containments: containments.length,
209
160
  containmentViolations: containmentViolations.length,
210
161
  missingEdgeEndpoints: missingEdgeEndpoints.length,
211
- hiddenEdgeEndpoints: hiddenEdgeEndpoints.length,
212
162
  sizeWarnings: sizeWarnings.length,
213
163
  },
214
164
  };