pmx-canvas 0.4.8 → 0.5.1

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 (212) 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 +156 -0
  4. package/Readme.md +72 -23
  5. package/dist/canvas/global.css +3071 -796
  6. package/dist/canvas/index.js +122 -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 +39 -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/ExtAppFrame.d.ts +2 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -1
  39. package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
  40. package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
  41. package/dist/types/client/state/canvas-store.d.ts +49 -5
  42. package/dist/types/client/state/human-store.d.ts +26 -0
  43. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  44. package/dist/types/client/state/intent-bridge.d.ts +37 -5
  45. package/dist/types/client/state/node-factory.d.ts +0 -1
  46. package/dist/types/client/state/presence-store.d.ts +78 -0
  47. package/dist/types/client/state/session-store.d.ts +176 -0
  48. package/dist/types/client/types.d.ts +2 -1
  49. package/dist/types/client/utils/platform.d.ts +2 -0
  50. package/dist/types/server/agent-presence.d.ts +192 -0
  51. package/dist/types/server/ax-gate-ttl.d.ts +6 -0
  52. package/dist/types/server/ax-interaction.d.ts +4 -10
  53. package/dist/types/server/ax-state-manager.d.ts +21 -2
  54. package/dist/types/server/ax-state.d.ts +49 -3
  55. package/dist/types/server/ax-wait.d.ts +13 -0
  56. package/dist/types/server/canvas-db.d.ts +9 -1
  57. package/dist/types/server/canvas-operations.d.ts +11 -0
  58. package/dist/types/server/canvas-state.d.ts +27 -13
  59. package/dist/types/server/canvas-validation.d.ts +0 -14
  60. package/dist/types/server/html-surface.d.ts +1 -1
  61. package/dist/types/server/human-presence.d.ts +40 -0
  62. package/dist/types/server/index.d.ts +29 -3
  63. package/dist/types/server/intent-registry.d.ts +7 -0
  64. package/dist/types/server/mutation-history.d.ts +15 -2
  65. package/dist/types/server/operations/invoker.d.ts +13 -2
  66. package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
  67. package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
  68. package/dist/types/server/operations/registry.d.ts +11 -0
  69. package/dist/types/server/operations/types.d.ts +1 -1
  70. package/dist/types/server/scope-fence.d.ts +62 -0
  71. package/dist/types/server/server.d.ts +0 -12
  72. package/dist/types/shared/agent-presence.d.ts +130 -0
  73. package/dist/types/shared/approval-gates.d.ts +19 -0
  74. package/dist/types/shared/auto-arrange.d.ts +0 -1
  75. package/dist/types/shared/ax-kinds.d.ts +11 -0
  76. package/dist/types/shared/human-presence.d.ts +31 -0
  77. package/dist/types/shared/iframe-probe.d.ts +8 -0
  78. package/dist/types/shared/scope-fence.d.ts +21 -0
  79. package/docs/RELEASE.md +20 -0
  80. package/docs/ax-host-adapter-contract.md +30 -14
  81. package/docs/cli.md +37 -1
  82. package/docs/environment.md +3 -0
  83. package/docs/http-api.md +222 -5
  84. package/docs/mcp.md +6 -5
  85. package/docs/node-types.md +3 -1
  86. package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
  87. package/docs/screenshot.png +0 -0
  88. package/docs/screenshots/multi-agent-live.png +0 -0
  89. package/docs/screenshots/workbench-live-dark.png +0 -0
  90. package/docs/screenshots/workbench-live-light.png +0 -0
  91. package/docs/sdk.md +1 -1
  92. package/package.json +4 -4
  93. package/skills/pmx-canvas/SKILL.md +112 -17
  94. package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
  95. package/skills/pmx-canvas/references/full-reference.md +55 -16
  96. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
  97. package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
  98. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  99. package/src/cli/agent.ts +1 -0
  100. package/src/cli/commands/copilot.ts +15 -8
  101. package/src/cli/commands/nodes.ts +1 -19
  102. package/src/cli/commands/pump.ts +263 -0
  103. package/src/cli/index.ts +2 -5
  104. package/src/cli/shared.ts +1 -1
  105. package/src/client/App.tsx +209 -585
  106. package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
  107. package/src/client/canvas/AttentionHistory.tsx +0 -10
  108. package/src/client/canvas/BarHint.tsx +49 -0
  109. package/src/client/canvas/CanvasNode.tsx +488 -136
  110. package/src/client/canvas/CanvasViewport.tsx +190 -94
  111. package/src/client/canvas/CommandBar.tsx +140 -0
  112. package/src/client/canvas/CommandPalette.tsx +133 -58
  113. package/src/client/canvas/ConnectionBanner.tsx +65 -0
  114. package/src/client/canvas/ContextMenu.tsx +88 -27
  115. package/src/client/canvas/ContextPinBar.tsx +2 -2
  116. package/src/client/canvas/EdgeLayer.tsx +121 -38
  117. package/src/client/canvas/EmptyState.tsx +93 -0
  118. package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
  119. package/src/client/canvas/ExternalWriters.tsx +308 -0
  120. package/src/client/canvas/FocusFieldLayer.tsx +12 -1
  121. package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
  122. package/src/client/canvas/IntentLayer.tsx +1 -1
  123. package/src/client/canvas/Minimap.tsx +117 -190
  124. package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
  125. package/src/client/canvas/SelectionBar.tsx +175 -23
  126. package/src/client/canvas/SessionPanel.tsx +481 -0
  127. package/src/client/canvas/SessionReceipt.tsx +141 -0
  128. package/src/client/canvas/ShortcutOverlay.tsx +21 -6
  129. package/src/client/canvas/SnapshotPanel.tsx +166 -80
  130. package/src/client/canvas/TextPrompt.tsx +128 -0
  131. package/src/client/canvas/ToolRail.tsx +447 -0
  132. package/src/client/canvas/TopBar.tsx +308 -0
  133. package/src/client/canvas/auto-fit.ts +1 -5
  134. package/src/client/canvas/canvas-area.ts +74 -0
  135. package/src/client/canvas/create-in-view.ts +48 -0
  136. package/src/client/canvas/import-files.ts +64 -0
  137. package/src/client/canvas/kind-colors.ts +22 -0
  138. package/src/client/canvas/snap-guides.ts +1 -1
  139. package/src/client/canvas/use-focus-trap.ts +54 -0
  140. package/src/client/canvas/use-now.ts +17 -0
  141. package/src/client/canvas/use-pan-zoom.ts +13 -2
  142. package/src/client/icons.tsx +57 -18
  143. package/src/client/nodes/ExtAppFrame.tsx +86 -20
  144. package/src/client/nodes/GroupNode.tsx +9 -28
  145. package/src/client/nodes/InlineFormatBar.tsx +4 -3
  146. package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
  147. package/src/client/nodes/MarkdownNode.tsx +59 -24
  148. package/src/client/nodes/MdFormatBar.tsx +2 -4
  149. package/src/client/nodes/inline-editor-commands.ts +17 -6
  150. package/src/client/nodes/use-surface-frame.ts +3 -1
  151. package/src/client/state/attention-bridge.ts +3 -0
  152. package/src/client/state/canvas-store.ts +285 -90
  153. package/src/client/state/human-store.ts +175 -0
  154. package/src/client/state/iframe-mode.ts +33 -9
  155. package/src/client/state/intent-bridge.ts +75 -13
  156. package/src/client/state/node-factory.ts +2 -8
  157. package/src/client/state/presence-store.ts +168 -0
  158. package/src/client/state/session-store.ts +419 -0
  159. package/src/client/state/sse-bridge.ts +93 -30
  160. package/src/client/theme/global.css +3071 -796
  161. package/src/client/types.ts +3 -1
  162. package/src/client/utils/platform.ts +6 -1
  163. package/src/mcp/canvas-access.ts +2 -2
  164. package/src/server/agent-presence.ts +755 -0
  165. package/src/server/ax-context.ts +45 -0
  166. package/src/server/ax-gate-ttl.ts +61 -0
  167. package/src/server/ax-interaction.ts +29 -1
  168. package/src/server/ax-state-manager.ts +78 -6
  169. package/src/server/ax-state.ts +82 -15
  170. package/src/server/ax-wait.ts +33 -0
  171. package/src/server/canvas-db.ts +103 -33
  172. package/src/server/canvas-operations.ts +58 -14
  173. package/src/server/canvas-state.ts +131 -58
  174. package/src/server/canvas-validation.ts +3 -53
  175. package/src/server/demo-state.json +2 -70
  176. package/src/server/html-surface.ts +11 -2
  177. package/src/server/human-presence.ts +160 -0
  178. package/src/server/index.ts +111 -6
  179. package/src/server/intent-registry.ts +13 -0
  180. package/src/server/mutation-history.ts +47 -2
  181. package/src/server/operations/composites.ts +6 -3
  182. package/src/server/operations/http.ts +10 -1
  183. package/src/server/operations/index.ts +4 -0
  184. package/src/server/operations/invoker.ts +33 -6
  185. package/src/server/operations/ops/ax-presence.ts +62 -0
  186. package/src/server/operations/ops/ax-read.ts +8 -0
  187. package/src/server/operations/ops/ax-state.ts +106 -1
  188. package/src/server/operations/ops/ax-timeline.ts +54 -5
  189. package/src/server/operations/ops/ax-work.ts +46 -0
  190. package/src/server/operations/ops/batch.ts +3 -1
  191. package/src/server/operations/ops/edges.ts +88 -2
  192. package/src/server/operations/ops/groups.ts +4 -3
  193. package/src/server/operations/ops/human-presence.ts +35 -0
  194. package/src/server/operations/ops/json-render.ts +11 -1
  195. package/src/server/operations/ops/nodes.ts +77 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +7 -4
  198. package/src/server/operations/registry.ts +196 -1
  199. package/src/server/operations/types.ts +1 -1
  200. package/src/server/scope-fence.ts +207 -0
  201. package/src/server/server.ts +177 -79
  202. package/src/server/trace-manager.ts +0 -2
  203. package/src/server/web-artifacts.ts +0 -1
  204. package/src/shared/agent-presence.ts +162 -0
  205. package/src/shared/approval-gates.ts +35 -0
  206. package/src/shared/auto-arrange.ts +121 -13
  207. package/src/shared/ax-kinds.ts +23 -0
  208. package/src/shared/human-presence.ts +30 -0
  209. package/src/shared/iframe-probe.ts +8 -0
  210. package/src/shared/scope-fence.ts +31 -0
  211. package/dist/types/client/canvas/DockedNode.d.ts +0 -4
  212. package/src/client/canvas/DockedNode.tsx +0 -165
@@ -188,7 +188,7 @@ const groupRemoveOperation = defineOperation<z.infer<typeof groupRemoveSchema>,
188
188
  mcp: {
189
189
  toolName: 'canvas_ungroup',
190
190
  description:
191
- 'Remove all children from a group, releasing them as independent nodes. The group node itself remains (delete it separately with canvas_remove_node if desired).',
191
+ 'Dissolve a group: its children become independent nodes (members of the enclosing group when nested) and the frame is removed — one undo step, the same result as the human’s Ungroup.',
192
192
  extraShape: {
193
193
  groupId: z.string().describe('The group node ID to ungroup'),
194
194
  },
@@ -199,9 +199,10 @@ const groupRemoveOperation = defineOperation<z.infer<typeof groupRemoveSchema>,
199
199
  handler: (input) => {
200
200
  const groupId = typeof input.groupId === 'string' ? input.groupId : '';
201
201
  if (!groupId) throw new OperationError('Missing groupId.');
202
+ const title = canvasState.getNode(groupId)?.data.title;
202
203
  const { ok } = ungroupCanvasNodes(groupId);
203
- if (!ok) throw new OperationError('Group not found or empty.');
204
- return { ok: true, groupId };
204
+ if (!ok) throw new OperationError('Group not found.', 404);
205
+ return { ok: true, groupId, ...(typeof title === 'string' ? { title } : {}) };
205
206
  },
206
207
  });
207
208
 
@@ -0,0 +1,35 @@
1
+ /**
2
+ * human.presence.set / human.presence.get — the workbench's own heartbeat
3
+ * (rail-chrome-v2 phase 8). HTTP only: tabs report themselves; agents read
4
+ * the snapshot through `canvas_ax_state { action: "presence" }`'s sibling
5
+ * GET if they want to know who is on the board. Never counts as agent
6
+ * activity (registry exempt list).
7
+ */
8
+ import { z } from 'zod';
9
+ import { HUMAN_PRESENCE_SHAPE, humanPresence } from '../../human-presence.js';
10
+ import { defineOperation } from '../types.js';
11
+
12
+ const setSchema = z.looseObject(HUMAN_PRESENCE_SHAPE);
13
+
14
+ export const humanPresenceSetOperation = defineOperation<z.infer<typeof setSchema>, Record<string, unknown>>({
15
+ name: 'human.presence.set',
16
+ mutates: false,
17
+ input: setSchema,
18
+ inputShape: HUMAN_PRESENCE_SHAPE,
19
+ http: { method: 'POST', path: '/api/canvas/human-presence' },
20
+ handler: (input) => ({ ok: true, human: humanPresence.set(input) }),
21
+ });
22
+
23
+ const getShape = {};
24
+ const getSchema = z.looseObject(getShape);
25
+
26
+ export const humanPresenceGetOperation = defineOperation<z.infer<typeof getSchema>, Record<string, unknown>>({
27
+ name: 'human.presence.get',
28
+ mutates: false,
29
+ input: getSchema,
30
+ inputShape: getShape,
31
+ http: { method: 'GET', path: '/api/canvas/human-presence' },
32
+ handler: () => ({ ok: true, ...humanPresence.snapshot() }),
33
+ });
34
+
35
+ export const humanPresenceOperations = [humanPresenceSetOperation, humanPresenceGetOperation];
@@ -113,6 +113,11 @@ const jsonRenderAddShape = {
113
113
  y: z.number().optional().catch(undefined).describe('Optional Y position'),
114
114
  width: z.number().optional().catch(undefined).describe('Optional node width'),
115
115
  height: z.number().optional().catch(undefined).describe('Optional node height'),
116
+ nodeHeight: z
117
+ .number()
118
+ .optional()
119
+ .catch(undefined)
120
+ .describe('Alias for height — the frame-height name the graph and stream actions use'),
116
121
  strictSize: z
117
122
  .boolean()
118
123
  .optional()
@@ -146,7 +151,12 @@ const jsonRenderAddOperation = defineOperation<
146
151
  // Legacy fallback: a body without an object `spec` is treated as the spec
147
152
  // itself (bare-component compatibility path).
148
153
  const rawSpec = body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec) ? body.spec : body;
149
- const geometry = resolveCreateGeometry(body);
154
+ // `nodeHeight` is the frame-height name the sibling actions (add-graph,
155
+ // stream-json-render) teach callers — dropping it here silently produced
156
+ // default-height frames (0.5.0 readiness, Copilot finding).
157
+ const geometry = resolveCreateGeometry(
158
+ body.nodeHeight !== undefined && body.height === undefined ? { ...body, height: body.nodeHeight } : body,
159
+ );
150
160
  try {
151
161
  return createCanvasJsonRenderNode({
152
162
  ...(title ? { title } : {}),
@@ -28,6 +28,8 @@ import {
28
28
  LEDGER_NODE_DEFAULT_SIZE,
29
29
  } from '../../canvas-operations.js';
30
30
  import { normalizeNodeAxCapabilities } from '../../ax-interaction.js';
31
+ import { applyFileContentToNodeData, readFileNodeContent } from '../../file-content.js';
32
+ import { unwatchFileForNode, watchFileForNode } from '../../file-watcher.js';
31
33
  import {
32
34
  buildHtmlPrimitive,
33
35
  getHtmlPrimitiveDescriptor,
@@ -241,7 +243,7 @@ export function setGroupChildrenFromApi(groupId: string, childIds: string[]): bo
241
243
  if (currentChildIds.length !== dataChildIds.length || currentChildIds.some((id) => !dataChildIds.includes(id))) {
242
244
  canvasState.updateNode(groupId, { data: { ...group.data, children: currentChildIds } });
243
245
  }
244
- canvasState.ungroupNodes(groupId);
246
+ canvasState.releaseGroupChildren(groupId);
245
247
  }
246
248
  if (childIds.length === 0) return true;
247
249
 
@@ -308,7 +310,6 @@ export function compactNodePayload(node: CanvasNodeState | undefined): Record<st
308
310
  size: serialized.size,
309
311
  pinned: serialized.pinned,
310
312
  collapsed: serialized.collapsed,
311
- dockPosition: serialized.dockPosition,
312
313
  provenance: serialized.provenance,
313
314
  };
314
315
  }
@@ -404,6 +405,30 @@ export function createBasicCanvasNode(
404
405
  throw new OperationError('HTML node field "data.html" must be a string.');
405
406
  }
406
407
  }
408
+ // Loud rejection of the two dead-field traps (gauntlet, 2026-08-27): these
409
+ // names read as if they work, were dropped silently, and the node rendered
410
+ // empty. The canonical create field is `content` for every type.
411
+ if (body.src !== undefined) {
412
+ throw new OperationError(
413
+ type === 'image'
414
+ ? 'Unknown field "src" — pass the image source (path, URL, or data: URI) as `content`.'
415
+ : 'Unknown field "src" — node content lives in `content`.',
416
+ );
417
+ }
418
+ if (body.path !== undefined && type !== 'image') {
419
+ throw new OperationError(
420
+ type === 'file'
421
+ ? 'Unknown field "path" for file nodes — pass the workspace path as `content`.'
422
+ : '"path" is only an image-node compatibility alias — use `content`.',
423
+ );
424
+ }
425
+ // Docking was removed in 0.5.0 — a create that still sends it silently
426
+ // succeeded and read as "docking works" (0.5.0 Copilot report, PASS-ish).
427
+ if (body.dockPosition !== undefined) {
428
+ throw new OperationError(
429
+ 'Unknown field "dockPosition" — docking was removed in 0.5.0; use `position` and `pinned`.',
430
+ );
431
+ }
407
432
  const content =
408
433
  type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string' ? body.path : body.content;
409
434
  // For html nodes, accept top-level `html` AND `axCapabilities` and merge into data
@@ -472,6 +497,28 @@ export function buildNodePatch(
472
497
  existing: CanvasNodeState,
473
498
  body: Record<string, unknown>,
474
499
  ): { patch: Partial<CanvasNodeState>; groupChildIds?: string[] } {
500
+ // Same dead-field traps as create (gauntlet, 2026-08-27): reject loudly
501
+ // instead of dropping — and note that patching `content` on a file node
502
+ // updates the FILE CONTENT snapshot, it does not re-point the node.
503
+ if (body.src !== undefined) {
504
+ throw new OperationError('Unknown field "src" — update the image source via `content`.');
505
+ }
506
+ if (body.dockPosition !== undefined) {
507
+ throw new OperationError(
508
+ 'Unknown field "dockPosition" — docking was removed in 0.5.0; use `position` and `pinned`.',
509
+ );
510
+ }
511
+ if (body.path !== undefined && existing.type !== 'image') {
512
+ if (existing.type === 'file') {
513
+ // Repoint: `path` on a file-node patch moves the node to another file
514
+ // (content re-read, watcher rewired) while keeping its edges and pins.
515
+ if (typeof body.path !== 'string' || !body.path.trim()) {
516
+ throw new OperationError('File node "path" must be a non-empty string.');
517
+ }
518
+ } else {
519
+ throw new OperationError('"path" is only an image-node compatibility alias — use `content`.');
520
+ }
521
+ }
475
522
  const groupChildList = existing.type === 'group' ? pickGroupChildIds(body) : {};
476
523
  if (groupChildList.error) throw new OperationError(`Cannot update group: ${groupChildList.error}`);
477
524
  const groupChildIds = groupChildList.value;
@@ -482,9 +529,6 @@ export function buildNodePatch(
482
529
  const patch: Record<string, unknown> = resolvePatchGeometry(body, existing);
483
530
  if (body.collapsed !== undefined) patch.collapsed = body.collapsed;
484
531
  if (body.pinned !== undefined) patch.pinned = Boolean(body.pinned);
485
- if (body.dockPosition === null || body.dockPosition === 'left' || body.dockPosition === 'right') {
486
- patch.dockPosition = body.dockPosition;
487
- }
488
532
  if (hasStructuredNodeUpdateFields(body)) {
489
533
  try {
490
534
  patch.data = buildStructuredNodeUpdate(existing, body).data;
@@ -499,9 +543,11 @@ export function buildNodePatch(
499
543
  typeof body.strictSize === 'boolean' ||
500
544
  (existing.type === 'trace' && hasTraceNodeDataFields(body)) ||
501
545
  (existing.type === 'html' && body.html !== undefined) ||
546
+ (existing.type === 'file' && typeof body.path === 'string') ||
502
547
  body.axCapabilities !== undefined
503
548
  ) {
504
549
  const data = { ...existing.data };
550
+ if (existing.type === 'file' && typeof body.path === 'string') data.path = body.path.trim();
505
551
  if (body.title !== undefined) {
506
552
  data.title = String(body.title);
507
553
  if (existing.type === 'webpage') {
@@ -995,10 +1041,6 @@ const nodeUpdateShape = {
995
1041
  resultSummary: z.string().optional().catch(undefined).describe('Trace node result summary'),
996
1042
  error: z.string().optional().catch(undefined).describe('Trace node error message'),
997
1043
  collapsed: z.unknown().optional().describe('Collapse or expand the node'),
998
- dockPosition: z
999
- .unknown()
1000
- .optional()
1001
- .describe('Dock the node to the left/right HUD column, or pass null to return it to the canvas'),
1002
1044
  pinned: z.unknown().optional().describe('Pin or unpin the node to exclude it from auto-arrange'),
1003
1045
  arrangeLocked: z
1004
1046
  .boolean()
@@ -1039,7 +1081,7 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
1039
1081
  },
1040
1082
  mcp: {
1041
1083
  toolName: 'canvas_update_node',
1042
- description: 'Update an existing node. You can change its content, title, position, size, dock placement, or data.',
1084
+ description: 'Update an existing node. You can change its content, title, position, size, or data.',
1043
1085
  extraShape: {
1044
1086
  title: z.string().optional().describe('New title'),
1045
1087
  content: z.string().optional().describe('New content'),
@@ -1053,11 +1095,6 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
1053
1095
  yKey: z.string().optional().describe('Graph y/value key'),
1054
1096
  chartHeight: z.number().optional().describe('Graph chart content height, distinct from node height'),
1055
1097
  collapsed: z.boolean().optional().describe('Collapse or expand the node'),
1056
- dockPosition: z
1057
- .enum(['left', 'right'])
1058
- .nullable()
1059
- .optional()
1060
- .describe('Dock the node to the left/right HUD column, or pass null to return it to the canvas'),
1061
1098
  pinned: z.boolean().optional().describe('Pin or unpin the node to exclude it from auto-arrange'),
1062
1099
  arrangeLocked: z
1063
1100
  .boolean()
@@ -1107,7 +1144,7 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
1107
1144
  };
1108
1145
  },
1109
1146
  },
1110
- handler: async (input) => {
1147
+ handler: async (input, ctx) => {
1111
1148
  const body: Record<string, unknown> = input;
1112
1149
  const id = input.id;
1113
1150
  const existing = canvasState.getNode(id);
@@ -1130,6 +1167,30 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
1130
1167
  if (groupChildIds !== undefined && !setGroupChildrenFromApi(id, groupChildIds)) {
1131
1168
  throw new OperationError(`Group "${id}" not found.`, 404);
1132
1169
  }
1170
+ // File repoint: a data.path change re-reads the new file and rewires the
1171
+ // watcher IN PLACE — before this, pointing a file node elsewhere meant
1172
+ // recreating it, which lost its edges, pins, and position.
1173
+ if (existing.type === 'file') {
1174
+ const prevPath = typeof existing.data.path === 'string' ? existing.data.path : null;
1175
+ const repointed = canvasState.getNode(id);
1176
+ const nextPath = repointed && typeof repointed.data.path === 'string' ? repointed.data.path : null;
1177
+ if (nextPath && nextPath !== prevPath) {
1178
+ if (prevPath) unwatchFileForNode(id, prevPath);
1179
+ const data = { ...repointed!.data };
1180
+ // The title follows the file unless the caller pinned one in this
1181
+ // patch or had renamed the node away from the old filename.
1182
+ const prevBase = prevPath?.split(/[\\/]/).pop();
1183
+ if (typeof body.title !== 'string' && (!data.title || data.title === prevBase)) {
1184
+ data.title = nextPath.split(/[\\/]/).pop();
1185
+ }
1186
+ applyFileContentToNodeData(data, readFileNodeContent(nextPath));
1187
+ canvasState.updateNode(id, { data });
1188
+ watchFileForNode(id, nextPath);
1189
+ scheduleCodeGraphRecompute(() => {
1190
+ ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
1191
+ });
1192
+ }
1193
+ }
1133
1194
  const updated = canvasState.getNode(id);
1134
1195
  return updated ? buildNodeResponse(updated) : { ok: true, id };
1135
1196
  },
@@ -180,6 +180,7 @@ const historyGetOperation = defineOperation<z.infer<typeof historyGetSchema>, Re
180
180
  handler: () => ({
181
181
  text: mutationHistory.toHumanReadable(),
182
182
  entries: mutationHistory.getSummaries(),
183
+ top: mutationHistory.top(),
183
184
  canUndo: mutationHistory.canUndo(),
184
185
  canRedo: mutationHistory.canRedo(),
185
186
  }),
@@ -29,9 +29,9 @@ import {
29
29
  gcCanvasSnapshots,
30
30
  listCanvasSnapshots,
31
31
  restoreCanvasSnapshot,
32
- saveCanvasSnapshot,
32
+ saveCanvasSnapshotWithReuse,
33
33
  } from '../../canvas-operations.js';
34
- import { diffLayouts, formatDiff } from '../../mutation-history.js';
34
+ import { formatDiff, diffLayouts } from '../../mutation-history.js';
35
35
  import { defineOperation, OperationError, type Operation } from '../types.js';
36
36
  import { isRecord } from './nodes.js';
37
37
 
@@ -128,9 +128,12 @@ const snapshotSaveOperation = defineOperation<z.infer<typeof snapshotSaveSchema>
128
128
  handler: (input) => {
129
129
  const name = typeof input.name === 'string' ? input.name.trim() : '';
130
130
  if (!name) throw new OperationError('Missing snapshot name');
131
- const snapshot = saveCanvasSnapshot(name);
131
+ // A save of an unchanged board reuses the newest snapshot instead of
132
+ // stacking an identical copy — via the ONE shared path the SDK uses too
133
+ // (0.5.0 Codex finding V).
134
+ const { snapshot, reused } = saveCanvasSnapshotWithReuse(name);
132
135
  if (!snapshot) throw new OperationError('Failed to save snapshot');
133
- return { ok: true, id: snapshot.id, snapshot };
136
+ return { ok: true, id: snapshot.id, snapshot, ...(reused ? { reused: true } : {}) };
134
137
  },
135
138
  });
136
139
 
@@ -9,6 +9,10 @@
9
9
  */
10
10
  import { canvasState } from '../canvas-state.js';
11
11
  import { intentRegistry } from '../intent-registry.js';
12
+ import { humanPresence } from '../human-presence.js';
13
+ import { setMutationActor } from '../mutation-history.js';
14
+ import { agentPresence, describeWrite } from '../agent-presence.js';
15
+ import { checkScopeFence, checkScopeOwnership } from '../scope-fence.js';
12
16
  import type { PmxAxIntent, PmxAxIntentKind } from '../../shared/ax-intent.js';
13
17
  import { OperationError, type Operation, type OperationContext } from './types.js';
14
18
 
@@ -193,6 +197,149 @@ export interface ExecuteOperationMeta {
193
197
  * its inner dispatches (batch churn is exempt, matching the skill contract).
194
198
  */
195
199
  suppressAutoGhost?: boolean;
200
+ /**
201
+ * Who is calling — the presence writer label ('mcp', 'sdk', 'api', …).
202
+ * Defaults to 'api'. Workbench calls (suppressAutoGhost) never touch presence.
203
+ */
204
+ source?: string;
205
+ /**
206
+ * The human's own browser issued this call. Distinct from suppressAutoGhost
207
+ * (which batch also sets for its inner dispatches): the scope fence applies
208
+ * to agents only, and batch inner writes are always agent-originated.
209
+ */
210
+ fromWorkbench?: boolean;
211
+ }
212
+
213
+ /**
214
+ * Agent presence (rail-chrome-v2 phase 2): every agent-originated MUTATION —
215
+ * the same calls that synthesize auto-ghosts — touches the caller's presence
216
+ * as `tooling`. Reads never do (polling an agent's own context is not work).
217
+ * Presence must never break the mutation, so this is fire-and-forget.
218
+ */
219
+ function asRecord(value: unknown): Record<string, unknown> {
220
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
221
+ }
222
+
223
+ const isString = (value: unknown): value is string => typeof value === 'string';
224
+
225
+ /**
226
+ * Ops that change which group a node belongs to — a held member is a held
227
+ * target. `node.update` is here because `node.update {id:G, children:[X]}`
228
+ * reparents X: the scope fence already counts that as a write to X
229
+ * (`scope-fence.ts` `groupChildren`), and the edit-lock must agree, or a
230
+ * human's in-progress drag is overridden through the ordinary reparent path.
231
+ */
232
+ const MEMBERSHIP_OPS = new Set(['node.remove', 'group.remove', 'group.add', 'group.create', 'node.update']);
233
+
234
+ /** The node an agent write targets, if a human currently holds it. */
235
+ function heldByHuman(name: string, rawInput: unknown): { nodeId: string; name: string } | null {
236
+ const locked = humanPresence.lockedNodes();
237
+ if (locked.size === 0) return null;
238
+ const input = asRecord(rawInput);
239
+ const ids = [input.id, input.nodeId, input.groupId, input.from, input.to].filter(isString);
240
+ if (MEMBERSHIP_OPS.has(name)) {
241
+ for (const key of ['childIds', 'children']) {
242
+ const list = input[key];
243
+ if (Array.isArray(list)) ids.push(...list.filter(isString));
244
+ }
245
+ // node.update carries the reparent list under data.children too.
246
+ const data = asRecord(input.data);
247
+ if (Array.isArray(data.children)) ids.push(...data.children.filter(isString));
248
+ // Removing a group dissolves it: every member changes hands too.
249
+ for (const id of [...ids]) {
250
+ const node = canvasState.getNode(id);
251
+ if (node?.type === 'group' && Array.isArray(node.data.children)) ids.push(...node.data.children.filter(isString));
252
+ }
253
+ }
254
+ for (const id of ids) {
255
+ const name = locked.get(id);
256
+ if (name) return { nodeId: id, name };
257
+ }
258
+ return null;
259
+ }
260
+
261
+ /** The node an operation touched — the agent cursor glides there. */
262
+ function nodeIdFromResult(record: Record<string, unknown>): string | null {
263
+ if (typeof record.id === 'string') return record.id;
264
+ const node = asRecord(record.node);
265
+ return typeof node.id === 'string' ? node.id : null;
266
+ }
267
+
268
+ function touchedNodeId(rawInput: unknown, result: unknown): string | null {
269
+ const input = asRecord(rawInput);
270
+ if (typeof input.id === 'string') return input.id;
271
+ if (typeof input.nodeId === 'string') return input.nodeId;
272
+ const res = asRecord(result);
273
+ const direct = nodeIdFromResult(res);
274
+ if (direct) return direct;
275
+ // canvas.batch: park the presence cursor on the LAST op that touched a node —
276
+ // batch-writing agents (ghost-approved creates) otherwise never get a focus,
277
+ // which left their cursor invisible through entire rounds of work.
278
+ if (Array.isArray(res.results)) {
279
+ for (let i = res.results.length - 1; i >= 0; i -= 1) {
280
+ const id = nodeIdFromResult(asRecord(res.results[i]));
281
+ if (id) return id;
282
+ }
283
+ }
284
+ return null;
285
+ }
286
+
287
+ /**
288
+ * Ops that are writes for PRESENCE purposes but must not touch it themselves:
289
+ * the presence endpoint (it IS the explicit phase — a derived `tooling` touch
290
+ * would clobber it) and activity ingest (observeActivity already mapped it,
291
+ * and re-touching after a `session-end` would resurrect the writer).
292
+ */
293
+ const PRESENCE_EXEMPT_OPS = new Set([
294
+ 'ax.presence.set',
295
+ 'ax.presence.get',
296
+ 'ax.activity.ingest',
297
+ 'human.presence.set',
298
+ 'human.presence.get',
299
+ ]);
300
+
301
+ /** Layout mutations AND non-GET AX writes (work items, gates, evidence, steering) are agent activity. */
302
+ function isPresenceWrite(op: Operation): boolean {
303
+ if (PRESENCE_EXEMPT_OPS.has(op.name)) return false;
304
+ return op.mutates || (op.http != null && op.http.method !== 'GET');
305
+ }
306
+
307
+ function notePresence(
308
+ name: string,
309
+ rawInput: unknown,
310
+ meta: ExecuteOperationMeta,
311
+ op: Operation,
312
+ result: unknown,
313
+ ): void {
314
+ if (!isPresenceWrite(op) || meta.suppressAutoGhost) return;
315
+ const input = asRecord(rawInput);
316
+ // Delivery plumbing (claim/mark) must not book an anonymous transport
317
+ // writer nor spam the timeline: attribute it to the consumer that marked,
318
+ // and record no activity row — the Steer row flipping to delivered IS the
319
+ // signal the human reads.
320
+ const isDeliveryOp = name.startsWith('ax.delivery.');
321
+ try {
322
+ const touched = touchedNodeId(rawInput, result);
323
+ // Work items, gates, and evidence results carry ids too — only a REAL
324
+ // canvas node may become the cursor's focus (orchestration review #9:
325
+ // the cursor aimed at a work-item id and pointed at nothing).
326
+ const focusNodeId = touched && canvasState.getNode(touched) ? touched : null;
327
+ const { summary, nodeId } = describeWrite(name, rawInput, result);
328
+ const consumerIdentity =
329
+ isDeliveryOp && typeof input.consumer === 'string' && input.consumer.trim() ? input.consumer.trim() : null;
330
+ agentPresence.touch({
331
+ source: meta.source ?? 'api',
332
+ agentId: typeof input.agentId === 'string' ? input.agentId : (consumerIdentity ?? null),
333
+ phase: 'tooling',
334
+ derivedTooling: true,
335
+ detail: name,
336
+ ...(focusNodeId ? { focusNodeId } : {}),
337
+ op: true,
338
+ ...(isDeliveryOp ? {} : { activity: { op: name, summary, nodeId } }),
339
+ });
340
+ } catch {
341
+ // never let presence bookkeeping fail a mutation
342
+ }
196
343
  }
197
344
 
198
345
  export async function executeOperation(
@@ -200,8 +347,53 @@ export async function executeOperation(
200
347
  rawInput: unknown,
201
348
  meta: ExecuteOperationMeta = {},
202
349
  ): Promise<unknown> {
350
+ // History entries recorded while this op runs carry who made them (item
351
+ // 10: the session panel offers undo on the agent's latest edit).
352
+ const historyWriter =
353
+ typeof asRecord(rawInput).agentId === 'string' && String(asRecord(rawInput).agentId).trim()
354
+ ? String(asRecord(rawInput).agentId).trim()
355
+ : (meta.source ?? 'api');
356
+ setMutationActor(meta.fromWorkbench ? 'human' : 'agent', historyWriter);
357
+ try {
358
+ return await executeOperationInner(name, rawInput, meta);
359
+ } finally {
360
+ setMutationActor(null);
361
+ }
362
+ }
363
+
364
+ async function executeOperationInner(name: string, rawInput: unknown, meta: ExecuteOperationMeta): Promise<unknown> {
203
365
  const op = getOperation(name);
204
- const intentId = linkedIntentId(rawInput);
366
+ // Scope fence (design item 4): an attached agent's writes must stay inside
367
+ // the fence the human granted. Reads and the human's own writes pass, and
368
+ // the fence itself is the human's to set — an agent cannot clear or widen it.
369
+ if (!meta.fromWorkbench) {
370
+ // Fence identity: the same key presence uses — the write's agentId when it
371
+ // carries one, else its transport/source label.
372
+ const rawAgentId = asRecord(rawInput).agentId;
373
+ const writerKey = typeof rawAgentId === 'string' && rawAgentId.trim() ? rawAgentId.trim() : (meta.source ?? 'api');
374
+ const refusal = op.mutates
375
+ ? checkScopeFence(op, rawInput, writerKey)
376
+ : name === 'ax.policy.set'
377
+ ? checkScopeOwnership(rawInput, writerKey)
378
+ : null;
379
+ if (refusal) throw new OperationError(`Outside the agent scope: ${refusal}`, 403);
380
+ // User wins (item 6): a node a human is holding right now is not the
381
+ // agent's to change — refuse with 409 so the agent requeues the edit.
382
+ if (op.mutates) {
383
+ const held = heldByHuman(name, rawInput);
384
+ if (held) {
385
+ throw new OperationError(
386
+ `Node "${held.nodeId}" is being edited by ${held.name} right now — requeue this change and try again in a moment.`,
387
+ 409,
388
+ );
389
+ }
390
+ }
391
+ }
392
+ const linkedId = linkedIntentId(rawInput);
393
+ // A ghost that expired QUIETLY (no veto) does not block its mutation — the
394
+ // write proceeds unlinked (and takes the auto-ghost path below). Vetoed and
395
+ // already-committing intents still refuse inside runCommit.
396
+ const intentId = linkedId && intentRegistry.commitDisposition(linkedId) !== 'absent' ? linkedId : undefined;
205
397
  const allowedKinds = intentId ? allowedIntentKinds(name, rawInput) : undefined;
206
398
  if (intentId && !allowedKinds) {
207
399
  throw new OperationError(`Operation "${name}" cannot be committed through a ghost intent.`);
@@ -215,6 +407,7 @@ export async function executeOperation(
215
407
  if (op.mutates) {
216
408
  emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
217
409
  }
410
+ notePresence(name, rawInput, meta, op, result);
218
411
  return result;
219
412
  },
220
413
  settledNodeId,
@@ -253,6 +446,7 @@ export async function executeOperation(
253
446
  const result = await op.execute(rawInput, operationContext);
254
447
  if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
255
448
  intentRegistry.clear(ghostId, { settledNodeId: settledNodeId(result, ghost) ?? undefined });
449
+ notePresence(name, rawInput, meta, op, result);
256
450
  return result;
257
451
  } catch (error) {
258
452
  intentRegistry.clear(ghostId);
@@ -263,5 +457,6 @@ export async function executeOperation(
263
457
 
264
458
  const result = await op.execute(rawInput, operationContext);
265
459
  if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
460
+ notePresence(name, rawInput, meta, op, result);
266
461
  return result;
267
462
  }
@@ -12,7 +12,7 @@
12
12
  import type { ZodRawShape } from 'zod';
13
13
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
14
14
 
15
- export type OperationErrorStatus = 400 | 404 | 409;
15
+ export type OperationErrorStatus = 400 | 403 | 404 | 409;
16
16
 
17
17
  /** Operation failure that maps to an HTTP status + `{ ok:false, error }` body and MCP `isError`. */
18
18
  export class OperationError extends Error {