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
@@ -218,8 +218,17 @@ function escapeSurfaceHtml(value: string): string {
218
218
  * of the main SPA bundle). Escaping is mandatory: the source is arbitrary node
219
219
  * data and must not be parsed as markup.
220
220
  */
221
- export function buildMermaidSurfaceHtml(source: string): string {
222
- return `<pre class="mermaid-source" style="display:none">${escapeSurfaceHtml(source)}</pre><script src="/canvas/mermaid-entry.js"></script>`;
221
+ export function buildMermaidSurfaceHtml(source: string, inlineEntry?: string | null): string {
222
+ // srcdoc-rendered surfaces in hosts that block sub-frame subresources (the
223
+ // Claude Code desktop browser blocks the script request too, not just the
224
+ // frame document) get the renderer embedded as a data: URL — no network
225
+ // request for the host to block, and base64 has no `<` for the HTML script
226
+ // parser to trip on (a 3.5MB bundle inlined as literal script text hits the
227
+ // `<!--` script-data-escaped states and renders as page text).
228
+ const entry = inlineEntry
229
+ ? `<script src="data:text/javascript;charset=utf-8;base64,${Buffer.from(inlineEntry, 'utf-8').toString('base64')}"></script>`
230
+ : '<script src="/canvas/mermaid-entry.js"></script>';
231
+ return `<pre class="mermaid-source" style="display:none">${escapeSurfaceHtml(source)}</pre>${entry}`;
223
232
  }
224
233
 
225
234
  export interface HtmlSurfaceOptions {
@@ -0,0 +1,160 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ HUMAN_GRAB_TTL_MS,
4
+ HUMAN_PRESENCE_TTL_MS,
5
+ type HumanPresence,
6
+ type HumanPresenceSnapshot,
7
+ MAX_HUMANS,
8
+ } from '../shared/human-presence.js';
9
+ import { OperationError } from './operations/types.js';
10
+
11
+ /**
12
+ * Registry of open workbench tabs (rail-chrome-v2 phase 8). Fed by the
13
+ * browser's own `human.presence.set` heartbeats; broadcast as one coalesced
14
+ * `human-presence` SSE frame on every change including expiry, so clients
15
+ * never run their own expiry timers. `lockedNodes()` is the edit lock the
16
+ * operation registry consults for agent writes (user wins).
17
+ */
18
+ export const HUMAN_PRESENCE_SHAPE = {
19
+ clientId: z.string().min(1).max(64),
20
+ name: z.string().min(1).max(40).optional(),
21
+ cursor: z.object({ x: z.number().finite(), y: z.number().finite() }).nullable().optional(),
22
+ grabbingNodeId: z.string().max(200).nullable().optional(),
23
+ /** Explicit leave (tab closing): drop the presence now. */
24
+ left: z.boolean().optional(),
25
+ };
26
+ const presenceSchema = z.object(HUMAN_PRESENCE_SHAPE);
27
+
28
+ interface Stored extends HumanPresence {
29
+ lastSeenMs: number;
30
+ grabSinceMs: number | null;
31
+ }
32
+
33
+ type Emitter = (event: string, payload: Record<string, unknown>) => void;
34
+
35
+ export class HumanPresenceRegistry {
36
+ private readonly humans = new Map<string, Stored>();
37
+ private emit: Emitter = () => {};
38
+ private sweepTimer: ReturnType<typeof setInterval> | null = null;
39
+ private emitTimer: ReturnType<typeof setTimeout> | null = null;
40
+
41
+ setEmitter(emitter: Emitter | null): void {
42
+ this.emit = emitter ?? (() => {});
43
+ }
44
+
45
+ set(raw: unknown, now = Date.now()): HumanPresence | null {
46
+ const parsed = presenceSchema.safeParse(raw);
47
+ if (!parsed.success) {
48
+ throw new OperationError(
49
+ `Invalid human presence: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`,
50
+ );
51
+ }
52
+ const input = parsed.data;
53
+ if (input.left) {
54
+ if (this.humans.delete(input.clientId)) this.scheduleEmit();
55
+ this.maybeStopSweeper();
56
+ return null;
57
+ }
58
+ const existing = this.humans.get(input.clientId);
59
+ const stored: Stored = existing ?? {
60
+ clientId: input.clientId,
61
+ name: input.name ?? 'Human',
62
+ cursor: null,
63
+ grabbingNodeId: null,
64
+ lastSeenAt: new Date(now).toISOString(),
65
+ lastSeenMs: now,
66
+ grabSinceMs: null,
67
+ };
68
+ stored.lastSeenMs = now;
69
+ stored.lastSeenAt = new Date(now).toISOString();
70
+ if (input.name) stored.name = input.name;
71
+ if (input.cursor !== undefined) stored.cursor = input.cursor;
72
+ if (input.grabbingNodeId !== undefined) {
73
+ if (input.grabbingNodeId !== stored.grabbingNodeId) stored.grabSinceMs = input.grabbingNodeId ? now : null;
74
+ else if (input.grabbingNodeId) stored.grabSinceMs = now; // renewed
75
+ stored.grabbingNodeId = input.grabbingNodeId;
76
+ }
77
+ this.humans.set(input.clientId, stored);
78
+ while (this.humans.size > MAX_HUMANS) {
79
+ const oldest = [...this.humans.values()].sort((a, b) => a.lastSeenMs - b.lastSeenMs)[0];
80
+ if (!oldest) break;
81
+ this.humans.delete(oldest.clientId);
82
+ }
83
+ this.ensureSweeper();
84
+ this.scheduleEmit();
85
+ return this.publicView(stored);
86
+ }
87
+
88
+ snapshot(now = Date.now()): HumanPresenceSnapshot {
89
+ this.sweep(now, { emit: false });
90
+ return { humans: [...this.humans.values()].map((stored) => this.publicView(stored)) };
91
+ }
92
+
93
+ /** Nodes a human is holding right now → that human's name. The edit lock. */
94
+ lockedNodes(now = Date.now()): Map<string, string> {
95
+ const locked = new Map<string, string>();
96
+ for (const human of this.humans.values()) {
97
+ if (human.grabbingNodeId && human.grabSinceMs !== null && now - human.grabSinceMs <= HUMAN_GRAB_TTL_MS) {
98
+ locked.set(human.grabbingNodeId, human.name);
99
+ }
100
+ }
101
+ return locked;
102
+ }
103
+
104
+ reset(): void {
105
+ this.humans.clear();
106
+ if (this.emitTimer) {
107
+ clearTimeout(this.emitTimer);
108
+ this.emitTimer = null;
109
+ }
110
+ this.maybeStopSweeper();
111
+ }
112
+
113
+ private publicView(stored: Stored): HumanPresence {
114
+ const { lastSeenMs: _lastSeenMs, grabSinceMs, ...human } = stored;
115
+ // An expired grab reads as released even before the sweep drops it.
116
+ const grabbing =
117
+ human.grabbingNodeId && grabSinceMs !== null && Date.now() - grabSinceMs <= HUMAN_GRAB_TTL_MS
118
+ ? human.grabbingNodeId
119
+ : null;
120
+ return { ...human, grabbingNodeId: grabbing };
121
+ }
122
+
123
+ private sweep(now = Date.now(), options: { emit?: boolean } = {}): void {
124
+ let changed = false;
125
+ for (const [key, human] of this.humans) {
126
+ if (human.lastSeenMs + HUMAN_PRESENCE_TTL_MS <= now) {
127
+ this.humans.delete(key);
128
+ changed = true;
129
+ } else if (human.grabbingNodeId && human.grabSinceMs !== null && now - human.grabSinceMs > HUMAN_GRAB_TTL_MS) {
130
+ human.grabbingNodeId = null;
131
+ human.grabSinceMs = null;
132
+ changed = true;
133
+ }
134
+ }
135
+ if (changed && options.emit !== false) this.scheduleEmit();
136
+ this.maybeStopSweeper();
137
+ }
138
+
139
+ private scheduleEmit(): void {
140
+ if (this.emitTimer) return;
141
+ this.emitTimer = setTimeout(() => {
142
+ this.emitTimer = null;
143
+ this.emit('human-presence', this.snapshot() as unknown as Record<string, unknown>);
144
+ }, 16);
145
+ }
146
+
147
+ private ensureSweeper(): void {
148
+ if (this.sweepTimer) return;
149
+ this.sweepTimer = setInterval(() => this.sweep(), 1000);
150
+ }
151
+
152
+ private maybeStopSweeper(): void {
153
+ if (this.humans.size === 0 && this.sweepTimer) {
154
+ clearInterval(this.sweepTimer);
155
+ this.sweepTimer = null;
156
+ }
157
+ }
158
+ }
159
+
160
+ export const humanPresence = new HumanPresenceRegistry();
@@ -1,4 +1,7 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ import { agentPresence } from './agent-presence.js';
3
+ import { checkFenceTarget, type FenceTarget } from './scope-fence.js';
4
+ import type { AgentPhase, AgentPresence, AgentPresenceSnapshot } from '../shared/agent-presence.js';
2
5
  import { canvasState, IMAGE_MIME_MAP } from './canvas-state.js';
3
6
  import type { CanvasAnnotation, CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from './canvas-state.js';
4
7
  import { buildCanvasAxContext } from './ax-context.js';
@@ -132,6 +135,31 @@ function toSdkNode(node: CanvasNodeState): SdkCanvasNode {
132
135
  return { ...serializeCanvasNode(node), nodeId: node.id };
133
136
  }
134
137
 
138
+ /**
139
+ * The SDK bypasses the operation registry, so each mutation describes what it
140
+ * writes to the same fence core the registry uses (scope-fence.ts). Reads and
141
+ * presence/AX calls are never fenced.
142
+ */
143
+ function assertInsideFence(opName: string, target: FenceTarget): void {
144
+ const refusal = checkFenceTarget(target, opName);
145
+ if (refusal) throw new Error(`Outside the agent scope: ${refusal}`);
146
+ }
147
+
148
+ function createTarget(input: { x?: number; y?: number }, nodeIds: string[] = []): FenceTarget {
149
+ return typeof input.x === 'number' && typeof input.y === 'number'
150
+ ? { points: [{ x: input.x, y: input.y }], nodeIds }
151
+ : { unplacedCreate: true, nodeIds };
152
+ }
153
+
154
+ /** Presence `detail` for SDK writes, by the intent kind the wrapper commits. */
155
+ const SDK_PRESENCE_DETAIL: Record<PmxAxIntentKind, string> = {
156
+ create: 'node.add',
157
+ move: 'node.move',
158
+ edit: 'node.update',
159
+ remove: 'node.remove',
160
+ connect: 'edge.add',
161
+ };
162
+
135
163
  export class PmxCanvas extends EventEmitter {
136
164
  private _port: number;
137
165
  private _server: string | null = null;
@@ -146,6 +174,27 @@ export class PmxCanvas extends EventEmitter {
146
174
  allowedKinds: readonly PmxAxIntentKind[],
147
175
  mutate: () => T,
148
176
  settledNodeId: (result: T) => string | undefined,
177
+ ): T {
178
+ const result = this.runIntentCommitInner(intentId, allowedKinds, mutate, settledNodeId);
179
+ // SDK writes bypass executeOperation, so they register agent presence
180
+ // here — the same derived `tooling` + focus-node touch the HTTP/MCP
181
+ // transports get, attributed to the attached session by the registry.
182
+ const focusNodeId = settledNodeId(result);
183
+ agentPresence.touch({
184
+ source: 'sdk',
185
+ phase: 'tooling',
186
+ detail: SDK_PRESENCE_DETAIL[allowedKinds[0] ?? 'edit'],
187
+ ...(focusNodeId ? { focusNodeId } : {}),
188
+ op: true,
189
+ });
190
+ return result;
191
+ }
192
+
193
+ private runIntentCommitInner<T>(
194
+ intentId: string | undefined,
195
+ allowedKinds: readonly PmxAxIntentKind[],
196
+ mutate: () => T,
197
+ settledNodeId: (result: T) => string | undefined,
149
198
  ): T {
150
199
  if (intentId === undefined) return mutate();
151
200
  intentRegistry.beginCommit(intentId, allowedKinds);
@@ -246,6 +295,10 @@ export class PmxCanvas extends EventEmitter {
246
295
  height?: number;
247
296
  strictSize?: boolean;
248
297
  }): SdkCanvasNode {
298
+ assertInsideFence(
299
+ 'node.add',
300
+ createTarget(input, input.type === 'group' ? (input.childIds ?? input.children ?? []) : []),
301
+ );
249
302
  return this.runIntentCommit(
250
303
  input.intentId,
251
304
  ['create'],
@@ -296,6 +349,7 @@ export class PmxCanvas extends EventEmitter {
296
349
  height?: number;
297
350
  strictSize?: boolean;
298
351
  }): Promise<{ ok: boolean; id: string; error?: string; fetch: { ok: boolean; error?: string } }> {
352
+ assertInsideFence('node.add', createTarget(input));
299
353
  const mutate = async () => {
300
354
  const { id } = addCanvasNode({
301
355
  type: 'webpage',
@@ -330,6 +384,7 @@ export class PmxCanvas extends EventEmitter {
330
384
  }
331
385
 
332
386
  updateNode(id: string, patch: Partial<CanvasNodeState> & Record<string, unknown>): void {
387
+ assertInsideFence('node.update', { nodeIds: [id] });
333
388
  const intentId = typeof patch.intentId === 'string' ? patch.intentId : undefined;
334
389
  this.runIntentCommit(
335
390
  intentId,
@@ -354,6 +409,7 @@ export class PmxCanvas extends EventEmitter {
354
409
 
355
410
  /** Remove a node. Missing id throws (plan-005 unifies this across surfaces). */
356
411
  removeNode(id: string, options?: { intentId?: string }): void {
412
+ assertInsideFence('node.remove', { nodeIds: [id] });
357
413
  this.runIntentCommit(
358
414
  options?.intentId,
359
415
  ['remove'],
@@ -382,6 +438,7 @@ export class PmxCanvas extends EventEmitter {
382
438
  style?: CanvasEdge['style'];
383
439
  animated?: boolean;
384
440
  }): string {
441
+ assertInsideFence('edge.add', { nodeIds: [input.from ?? '', input.to ?? ''] });
385
442
  return this.runIntentCommit(
386
443
  input.intentId,
387
444
  ['connect'],
@@ -412,6 +469,8 @@ export class PmxCanvas extends EventEmitter {
412
469
  }
413
470
 
414
471
  removeEdge(id: string): void {
472
+ const edge = canvasState.getEdges().find((entry) => entry.id === id);
473
+ if (edge) assertInsideFence('edge.remove', { nodeIds: [edge.from, edge.to] });
415
474
  removeCanvasEdge(id);
416
475
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
417
476
  }
@@ -431,6 +490,7 @@ export class PmxCanvas extends EventEmitter {
431
490
  color?: string;
432
491
  childLayout?: 'grid' | 'column' | 'flow';
433
492
  }): string {
493
+ assertInsideFence('group.create', { nodeIds: input.childIds ?? [] });
434
494
  return this.runIntentCommit(
435
495
  input.intentId,
436
496
  ['create'],
@@ -449,6 +509,7 @@ export class PmxCanvas extends EventEmitter {
449
509
  childIds: string[],
450
510
  options?: { childLayout?: 'grid' | 'column' | 'flow'; intentId?: string },
451
511
  ): boolean {
512
+ assertInsideFence('group.add', { nodeIds: [groupId, ...childIds] });
452
513
  return this.runIntentCommit(
453
514
  options?.intentId,
454
515
  ['edit'],
@@ -466,8 +527,9 @@ export class PmxCanvas extends EventEmitter {
466
527
  );
467
528
  }
468
529
 
469
- /** Remove all children from a group (the group node remains). */
530
+ /** Dissolve a group: children released (into the enclosing group when nested), frame removed — one undo step. */
470
531
  ungroupNodes(groupId: string, options?: { intentId?: string }): boolean {
532
+ assertInsideFence('group.remove', { nodeIds: [groupId] });
471
533
  return this.runIntentCommit(
472
534
  options?.intentId,
473
535
  ['edit'],
@@ -486,6 +548,7 @@ export class PmxCanvas extends EventEmitter {
486
548
  }
487
549
 
488
550
  clear(): void {
551
+ assertInsideFence('canvas.clear', { boardWide: true });
489
552
  for (const node of canvasState.getLayout().nodes) {
490
553
  if (node.type !== 'mcp-app') continue;
491
554
  const sessionId = typeof node.data.appSessionId === 'string' ? node.data.appSessionId : '';
@@ -496,6 +559,7 @@ export class PmxCanvas extends EventEmitter {
496
559
  }
497
560
 
498
561
  arrange(layout?: 'grid' | 'column' | 'flow'): void {
562
+ assertInsideFence('arrange', { boardWide: true });
499
563
  arrangeCanvasNodes(layout ?? 'grid');
500
564
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
501
565
  }
@@ -562,8 +626,8 @@ export class PmxCanvas extends EventEmitter {
562
626
  return steering;
563
627
  }
564
628
 
565
- markSteeringDelivered(id: string): boolean {
566
- const ok = canvasState.markSteeringDelivered(id);
629
+ markSteeringDelivered(id: string, consumer?: string | null): boolean {
630
+ const ok = canvasState.markSteeringDelivered(id, consumer);
567
631
  if (ok) emitPrimaryWorkbenchEvent('ax-event-created', { steeringDelivered: id });
568
632
  return ok;
569
633
  }
@@ -667,6 +731,16 @@ export class PmxCanvas extends EventEmitter {
667
731
  return approvalGate;
668
732
  }
669
733
 
734
+ /** Reopen a resolved (typically auto-held) approval gate with a fresh TTL. */
735
+ reopenApproval(id: string, options?: { ttlMs?: number; source?: PmxAxSource }): PmxAxApprovalGate | null {
736
+ const approvalGate = canvasState.reopenApproval(id, {
737
+ ...(options?.ttlMs !== undefined ? { ttlMs: options.ttlMs } : {}),
738
+ source: options?.source ?? 'sdk',
739
+ });
740
+ if (approvalGate) emitPrimaryWorkbenchEvent('ax-state-changed', { approvalGate });
741
+ return approvalGate;
742
+ }
743
+
670
744
  addEvidence(
671
745
  input: {
672
746
  kind: PmxAxEvidenceKind;
@@ -726,6 +800,30 @@ export class PmxCanvas extends EventEmitter {
726
800
  return host;
727
801
  }
728
802
 
803
+ /** Who is writing to this board right now, their phase, and the context budget. */
804
+ getAgentPresence(): AgentPresenceSnapshot {
805
+ return agentPresence.snapshot();
806
+ }
807
+
808
+ /**
809
+ * Update this SDK caller's presence (rail-chrome-v2 phase 2): phase, detail,
810
+ * focus node, cursor, and `attached` (true starts a session, false ends it).
811
+ */
812
+ setAgentPresence(
813
+ input: {
814
+ agentId?: string | null;
815
+ label?: string;
816
+ phase?: AgentPhase;
817
+ detail?: string | null;
818
+ focusNodeId?: string | null;
819
+ cursor?: { x: number; y: number } | null;
820
+ attached?: boolean;
821
+ },
822
+ options?: { source?: PmxAxSource },
823
+ ): AgentPresence {
824
+ return agentPresence.set(input, options?.source ?? 'sdk');
825
+ }
826
+
729
827
  listElicitations(): PmxAxElicitation[] {
730
828
  return canvasState.getElicitations();
731
829
  }
@@ -957,9 +1055,9 @@ export class PmxCanvas extends EventEmitter {
957
1055
  position?: { x: number; y: number };
958
1056
  size?: { width: number; height: number };
959
1057
  collapsed?: boolean;
960
- dockPosition?: 'left' | 'right' | null;
961
1058
  }>,
962
1059
  ): { applied: number; skipped: number } {
1060
+ assertInsideFence('node.update', { nodeIds: updates.map((update) => update.id) });
963
1061
  return applyCanvasNodeUpdates(updates);
964
1062
  }
965
1063
 
@@ -978,6 +1076,7 @@ export class PmxCanvas extends EventEmitter {
978
1076
  }
979
1077
 
980
1078
  async restoreSnapshot(id: string): Promise<{ ok: boolean }> {
1079
+ assertInsideFence('snapshot.restore', { boardWide: true });
981
1080
  const result = await restoreCanvasSnapshot(id);
982
1081
  if (result.ok) {
983
1082
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
@@ -1076,6 +1175,7 @@ export class PmxCanvas extends EventEmitter {
1076
1175
  url: string;
1077
1176
  spec: JsonRenderSpec;
1078
1177
  } {
1178
+ assertInsideFence('jsonrender.add', createTarget(input));
1079
1179
  return this.runIntentCommit(
1080
1180
  input.intentId,
1081
1181
  ['create'],
@@ -1160,6 +1260,7 @@ export class PmxCanvas extends EventEmitter {
1160
1260
  * the html capability ceiling server-side; cannot escalate. */
1161
1261
  axCapabilities?: { enabled?: boolean; allowed?: string[] };
1162
1262
  }): SdkCanvasNode {
1263
+ assertInsideFence('node.add', createTarget(input));
1163
1264
  return this.runIntentCommit(
1164
1265
  input.intentId,
1165
1266
  ['create'],
@@ -1206,6 +1307,7 @@ export class PmxCanvas extends EventEmitter {
1206
1307
  height?: number;
1207
1308
  strictSize?: boolean;
1208
1309
  }): { id: string; kind: HtmlPrimitiveKind; title: string; htmlBytes: number } {
1310
+ assertInsideFence('node.add', createTarget(input));
1209
1311
  return this.runIntentCommit(
1210
1312
  input.intentId,
1211
1313
  ['create'],
@@ -1243,6 +1345,7 @@ export class PmxCanvas extends EventEmitter {
1243
1345
  }
1244
1346
 
1245
1347
  addGraphNode(input: GraphNodeInput & { intentId?: string }): { id: string; url: string; spec: JsonRenderSpec } {
1348
+ assertInsideFence('graph.add', createTarget(input));
1246
1349
  return this.runIntentCommit(
1247
1350
  input.intentId,
1248
1351
  ['create'],
@@ -223,6 +223,19 @@ export class IntentRegistry {
223
223
  * synchronous: once this method has accepted the intent, a later veto cannot
224
224
  * race in between the check and the mutation.
225
225
  */
226
+ /**
227
+ * How a linked commit would fare right now. 'absent' = the ghost expired
228
+ * quietly (TTL sweep) with no veto — the mutation should proceed UNLINKED
229
+ * instead of failing: a slow spawn is not a human "no", and inside a batch
230
+ * the 409 poisoned every sibling op (orchestration review #5).
231
+ */
232
+ commitDisposition(id: string): 'live' | 'vetoed' | 'committing' | 'absent' {
233
+ this.pruneVetoTombstones();
234
+ if (this.committingIntentIds.has(id)) return 'committing';
235
+ if (this.vetoedIntentIds.has(id)) return 'vetoed';
236
+ return this.intents.has(id) ? 'live' : 'absent';
237
+ }
238
+
226
239
  beginCommit(id: string, allowedKinds: readonly PmxAxIntentKind[]): PmxAxIntent {
227
240
  this.pruneVetoTombstones();
228
241
  if (this.committingIntentIds.has(id)) {
@@ -21,6 +21,7 @@ export type MutationOp =
21
21
  | 'updateNode'
22
22
  | 'removeNode'
23
23
  | 'addEdge'
24
+ | 'updateEdge'
24
25
  | 'removeEdge'
25
26
  | 'addAnnotation'
26
27
  | 'removeAnnotation'
@@ -43,13 +44,20 @@ export type MutationOp =
43
44
  | 'batch'
44
45
  | 'viewport'
45
46
  | 'groupNodes'
46
- | 'ungroupNodes';
47
+ | 'releaseGroupChildren';
48
+
49
+ /** Who made a mutation: the human in the workbench, or an agent through any transport. */
50
+ export type MutationActor = 'human' | 'agent';
47
51
 
48
52
  export interface MutationEntry {
49
53
  id: string;
50
54
  timestamp: string;
51
55
  description: string;
52
56
  operationType: MutationOp;
57
+ actor: MutationActor;
58
+ /** Writer key (agentId, else transport source) for agent entries — lets a
59
+ * client prove "the top edit is MINE" before undoing on a multi-agent board. */
60
+ writer?: string;
53
61
  forward: () => void;
54
62
  inverse: () => void;
55
63
  }
@@ -58,6 +66,8 @@ export interface MutationSummary {
58
66
  id: string;
59
67
  timestamp: string;
60
68
  description: string;
69
+ actor: MutationActor;
70
+ writer?: string;
61
71
  operationType: MutationOp;
62
72
  isCurrent: boolean;
63
73
  isUndone: boolean;
@@ -103,11 +113,13 @@ class MutationHistory {
103
113
  * Record a new mutation. Truncates any redo-able future, then appends.
104
114
  * If called while replaying (undo/redo), the call is silently ignored.
105
115
  */
106
- record(entry: Omit<MutationEntry, 'id' | 'timestamp'>): void {
116
+ record(entry: Omit<MutationEntry, 'id' | 'timestamp' | 'actor'> & { actor?: MutationActor }): void {
107
117
  if (this._replaying) return;
108
118
 
109
119
  const full: MutationEntry = {
110
120
  ...entry,
121
+ actor: entry.actor ?? currentActor ?? 'agent',
122
+ ...((entry.writer ?? currentWriter) ? { writer: entry.writer ?? currentWriter ?? undefined } : {}),
111
123
  id: `mut-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
112
124
  timestamp: new Date().toISOString(),
113
125
  };
@@ -169,12 +181,31 @@ class MutationHistory {
169
181
  id: e.id,
170
182
  timestamp: e.timestamp,
171
183
  description: e.description,
184
+ actor: e.actor,
185
+ ...(e.writer ? { writer: e.writer } : {}),
172
186
  operationType: e.operationType,
173
187
  isCurrent: i === this.cursor,
174
188
  isUndone: i > this.cursor,
175
189
  }));
176
190
  }
177
191
 
192
+ /** The entry Ctrl+Z would undo next (top of the shared stack), or null. */
193
+ top(): MutationSummary | null {
194
+ const entry = this.entries[this.cursor];
195
+ return entry
196
+ ? {
197
+ id: entry.id,
198
+ timestamp: entry.timestamp,
199
+ description: entry.description,
200
+ actor: entry.actor,
201
+ ...(entry.writer ? { writer: entry.writer } : {}),
202
+ operationType: entry.operationType,
203
+ isCurrent: true,
204
+ isUndone: false,
205
+ }
206
+ : null;
207
+ }
208
+
178
209
  /** Human-readable timeline for the canvas://history resource. */
179
210
  toHumanReadable(): string {
180
211
  if (this.entries.length === 0) {
@@ -383,3 +414,17 @@ export function formatDiff(diff: SnapshotDiffResult): string {
383
414
  // ── Singleton ────────────────────────────────────────────────────────
384
415
 
385
416
  export const mutationHistory = new MutationHistory();
417
+
418
+ /**
419
+ * Actor context for entries recorded while an operation runs. executeOperation
420
+ * sets it from the workbench marker around each op; writes outside the
421
+ * registry (the sync SDK) default to `agent`. Only the undo affordance reads
422
+ * it, so an async op interleaving with another request costs at most a
423
+ * mislabelled row — never a wrong undo.
424
+ */
425
+ let currentActor: MutationActor | null = null;
426
+ let currentWriter: string | null = null;
427
+ export function setMutationActor(actor: MutationActor | null, writer: string | null = null): void {
428
+ currentActor = actor;
429
+ currentWriter = actor === 'agent' ? writer : null;
430
+ }
@@ -170,10 +170,11 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
170
170
  {
171
171
  toolName: 'canvas_edge',
172
172
  description:
173
- 'Add or remove an edge (connection) between two nodes. Action "add" connects two nodes (type: flow=sequential, depends-on=dependency, relation=general, references=cross-reference; from/to take node ids, or fromSearch/toSearch resolve by title/content); "remove" deletes an edge by id.',
173
+ 'Add, update, or remove an edge (connection) between two nodes. Action "add" connects two nodes (type: flow=sequential, depends-on=dependency, relation=general, references=cross-reference; from/to take node ids, or fromSearch/toSearch resolve by title/content); "update" patches an existing edge in place (label, type, style, animated); "remove" deletes an edge by id.',
174
174
  actionSummary: 'add | remove',
175
175
  actions: {
176
176
  add: 'edge.add',
177
+ update: 'edge.update',
177
178
  remove: 'edge.remove',
178
179
  },
179
180
  },
@@ -250,13 +251,15 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
250
251
  {
251
252
  toolName: 'canvas_ax_state',
252
253
  description:
253
- 'Read or set host-agnostic PMX AX state. Action "get" reads the AX state + agent-ready context (pinned/focused context, focus field); "set-focus" places node IDs in the AX focus field (nodeIds); "set-policy" patches the tool/prompt policy (tools.allowed|excluded|approvalRequired, prompt.systemAppend|mode — merges with existing); "report-capability" records a host/session capability for diagnostics. Adapters may pass a source label (e.g. codex).',
254
- actionSummary: 'get | set-focus | set-policy | report-capability',
254
+ 'Read or set host-agnostic PMX AX state. Action "get" reads the AX state + agent-ready context (pinned/focused context, focus field); "set-focus" places node IDs in the AX focus field (nodeIds); "set-policy" patches the tool/prompt policy (tools.allowed|excluded|approvalRequired, prompt.systemAppend|mode — merges with existing); "report-capability" records a host/session capability for diagnostics; "presence" reads agent presence (who is writing, phase, attached session, context budget); "set-presence" updates this caller\'s presence (phase idle|thinking|tooling|waiting-approval, detail, focusNodeId, cursor, attached — attached:true starts a session, false ends it; contextUsage {used,total} reports the agent\'s real token window so the board\'s meter shows it instead of the pinned-context estimate). Adapters may pass a source label (e.g. codex).',
255
+ actionSummary: 'get | set-focus | set-policy | report-capability | presence | set-presence',
255
256
  actions: {
256
257
  get: 'ax.get',
257
258
  'set-focus': 'ax.focus.set',
258
259
  'set-policy': 'ax.policy.set',
259
260
  'report-capability': 'ax.host-capability.report',
261
+ presence: 'ax.presence.get',
262
+ 'set-presence': 'ax.presence.set',
260
263
  },
261
264
  },
262
265
  {
@@ -7,6 +7,7 @@
7
7
  * the remaining legacy routes.
8
8
  */
9
9
  import { executeOperation, listOperations } from './registry.js';
10
+ import { SOURCE_LABEL_RE } from '../agent-presence.js';
10
11
  import { OperationError } from './types.js';
11
12
 
12
13
  function responseJson(data: unknown, status = 200): Response {
@@ -88,7 +89,15 @@ export async function dispatchOperationRoute(req: Request, url: URL): Promise<Re
88
89
  // the browser is not agent activity, so it never synthesizes an
89
90
  // auto-ghost. Everything else (CLI, scripts, adapters) counts as agent.
90
91
  const fromWorkbench = req.headers.get('x-pmx-workbench') === '1';
91
- const result = await executeOperation(op.name, input, { suppressAutoGhost: fromWorkbench });
92
+ // Presence writer label: adapters/CLI may identify themselves; plain
93
+ // HTTP callers read as 'api'.
94
+ const sourceHeader = req.headers.get('x-pmx-source');
95
+ const source = sourceHeader && SOURCE_LABEL_RE.test(sourceHeader) ? sourceHeader : 'api';
96
+ const result = await executeOperation(op.name, input, {
97
+ suppressAutoGhost: fromWorkbench,
98
+ fromWorkbench,
99
+ source,
100
+ });
92
101
  return responseJson(result, route.status ? route.status(result) : 200);
93
102
  } catch (error) {
94
103
  if (error instanceof OperationError) {
@@ -22,6 +22,8 @@ import { appOperations } from './ops/app.js';
22
22
  import { intentOperations } from './ops/intent.js';
23
23
  import { extAppOperations } from './ops/ext-app.js';
24
24
  import { axReadOperations } from './ops/ax-read.js';
25
+ import { axPresenceOperations } from './ops/ax-presence.js';
26
+ import { humanPresenceOperations } from './ops/human-presence.js';
25
27
  import { canvasWireOperations } from './ops/canvas-wire.js';
26
28
 
27
29
  for (const op of [
@@ -44,6 +46,8 @@ for (const op of [
44
46
  ...intentOperations,
45
47
  ...extAppOperations,
46
48
  ...axReadOperations,
49
+ ...axPresenceOperations,
50
+ ...humanPresenceOperations,
47
51
  ...canvasWireOperations,
48
52
  ]) {
49
53
  registerOperation(op);