pmx-canvas 0.4.7 → 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 (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 +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/ExtAppFrame.d.ts +32 -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 +23 -1
  57. package/dist/types/server/canvas-operations.d.ts +1 -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 +54 -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 +1 -1
  93. package/skills/pmx-canvas/SKILL.md +109 -18
  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 +57 -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 +252 -0
  103. package/src/cli/index.ts +1 -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 +41 -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 +79 -2
  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 +273 -84
  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 +3069 -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 +237 -61
  172. package/src/server/canvas-operations.ts +17 -8
  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 +107 -4
  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 +65 -16
  196. package/src/server/operations/ops/query.ts +1 -0
  197. package/src/server/operations/ops/snapshots.ts +16 -0
  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
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Scope fence enforcement (rail-chrome-v2 phase 4, design item 4).
3
+ *
4
+ * When the AX policy carries a scope fence, an attached agent may only WRITE
5
+ * inside it: existing-node writes must target fenced nodes, new nodes must
6
+ * land inside the fence's bounding box (fenced nodes + padding), and
7
+ * board-wide writes (arrange, clear, restore) are refused. Reads are never
8
+ * fenced, and neither are the human's own workbench writes — the fence is the
9
+ * human's tool, not a cage for the human.
10
+ *
11
+ * One core check (`checkFenceTarget`) serves two describers: the operation
12
+ * registry (`checkScopeFence`, every HTTP/MCP/CLI write) and the sync SDK,
13
+ * whose methods bypass the registry and describe their own targets.
14
+ *
15
+ * Safe default: a mutating op this module does not know how to scope is
16
+ * refused while a fence is set, rather than silently allowed. Likewise a
17
+ * write this module cannot resolve (an edge given by search, a missing id)
18
+ * is refused — fail closed, never a bypass.
19
+ *
20
+ * Trust note: the workbench marker is a self-reported header. Under the
21
+ * local single-workspace model that is by design (any local process may
22
+ * write; the safety model is human veto plus this fence), so the fence holds
23
+ * cooperating agents to the region the human granted — it is not an
24
+ * authentication boundary.
25
+ */
26
+ import { type FenceRect, fenceRectFromNodes } from '../shared/scope-fence.js';
27
+ import { canvasState } from './canvas-state.js';
28
+ import type { Operation } from './operations/types.js';
29
+
30
+ /** Bounding box of the fenced nodes plus padding; null when none of them exist. */
31
+ export function scopeFenceRect(fence: { nodeIds: string[]; padding: number }): FenceRect | null {
32
+ const nodes = fence.nodeIds
33
+ .map((id) => canvasState.getNode(id))
34
+ .filter((node): node is NonNullable<typeof node> => node !== undefined);
35
+ return fenceRectFromNodes(nodes, fence.padding);
36
+ }
37
+
38
+ /** What a write touches, in fence terms. */
39
+ export interface FenceTarget {
40
+ /** Existing nodes the write targets — every one must be fenced. */
41
+ nodeIds?: string[];
42
+ /** World points a write introduces (a new node, annotation points) — all must be inside the box. */
43
+ points?: Array<{ x: number; y: number }>;
44
+ /** A new node with no position: refused (it cannot be placed inside the fence). */
45
+ unplacedCreate?: boolean;
46
+ /** Rewrites the whole board — never allowed under a fence. */
47
+ boardWide?: boolean;
48
+ /** The op is not fence-aware — refused while a fence is set. */
49
+ unknown?: string;
50
+ }
51
+
52
+ function inside(rect: FenceRect, x: number, y: number): boolean {
53
+ return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
54
+ }
55
+
56
+ /** Returns a human-readable refusal, or null when the target is inside the fence. */
57
+ export function checkFenceTarget(target: FenceTarget, opName: string, writerKey?: string): string | null {
58
+ const policy = canvasState.getPolicy();
59
+ // Per-agent territory (fleet orchestration) overrides the global fence for
60
+ // that writer; writers without a territory fall under the global fence.
61
+ const territory = writerKey ? policy.agentScopes[writerKey] : undefined;
62
+ const fence = territory ?? policy.scope;
63
+ if (!fence) return null;
64
+ const describe = territory
65
+ ? `your assigned territory (${fence.nodeIds.length} node${fence.nodeIds.length === 1 ? '' : 's'}); reads are allowed — ask the orchestrator or the human to widen it`
66
+ : `the agent scope fence (${fence.nodeIds.length} node${fence.nodeIds.length === 1 ? '' : 's'}); reads are allowed — ask the human to widen the fence`;
67
+ if (target.boardWide) return `"${opName}" rewrites the whole board, which is outside ${describe}.`;
68
+ if (target.unknown) return `"${target.unknown}" is not fence-aware and was refused under ${describe}.`;
69
+ const fenced = new Set(fence.nodeIds);
70
+ for (const id of target.nodeIds ?? []) {
71
+ if (!fenced.has(id)) return `node "${id}" is outside ${describe}.`;
72
+ }
73
+ if (target.unplacedCreate) return `new nodes need an explicit x/y inside ${describe}.`;
74
+ if (target.points && target.points.length > 0) {
75
+ const rect = scopeFenceRect(fence);
76
+ if (!rect) return `no fenced node exists to place new content near — outside ${describe}.`;
77
+ for (const point of target.points) {
78
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y) || !inside(rect, point.x, point.y)) {
79
+ return `position (${point.x}, ${point.y}) is outside ${describe}.`;
80
+ }
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function asRecord(value: unknown): Record<string, unknown> {
87
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
88
+ }
89
+
90
+ function stringList(value: unknown): string[] {
91
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
92
+ }
93
+
94
+ function str(value: unknown): string {
95
+ return typeof value === 'string' ? value : '';
96
+ }
97
+
98
+ /** Group membership as `node.add` / `node.update` accept it (mirrors `pickGroupChildIds`). */
99
+ function groupChildren(input: Record<string, unknown>): string[] {
100
+ if ('children' in input) return stringList(input.children);
101
+ if ('childIds' in input) return stringList(input.childIds);
102
+ const data = asRecord(input.data);
103
+ return 'children' in data ? stringList(data.children) : [];
104
+ }
105
+
106
+ function createTarget(input: Record<string, unknown>, extraNodeIds: string[] = []): FenceTarget {
107
+ const x = Number(input.x);
108
+ const y = Number(input.y);
109
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return { unplacedCreate: true, nodeIds: extraNodeIds };
110
+ return { points: [{ x, y }], nodeIds: extraNodeIds };
111
+ }
112
+
113
+ /** Ops that rewrite the whole board — never allowed under a fence. */
114
+ const BOARD_WIDE_OPS = new Set(['arrange', 'canvas.clear', 'snapshot.restore', 'render.workboard']);
115
+
116
+ /** Describe what a registry op would write, in fence terms. */
117
+ export function describeOpTarget(op: Operation, rawInput: unknown): FenceTarget {
118
+ const input = asRecord(rawInput);
119
+ if (BOARD_WIDE_OPS.has(op.name)) return { boardWide: true };
120
+ switch (op.name) {
121
+ case 'node.update': {
122
+ // A group's membership change reparents (and may reposition) its children.
123
+ const nodeIds = [str(input.id), ...groupChildren(input)];
124
+ // A move's DESTINATION must also land inside the fence — otherwise an
125
+ // agent relocates a fenced node outward, and since the fence rect is
126
+ // derived from live node positions, that grows its own writable region
127
+ // past the human-seeded region.
128
+ const x = Number(input.x);
129
+ const y = Number(input.y);
130
+ if (Number.isFinite(x) && Number.isFinite(y)) return { points: [{ x, y }], nodeIds };
131
+ return { nodeIds };
132
+ }
133
+ case 'node.remove':
134
+ return { nodeIds: [str(input.id)] };
135
+ case 'node.add':
136
+ return createTarget(input, input.type === 'group' ? groupChildren(input) : []);
137
+ case 'jsonrender.add':
138
+ case 'graph.add':
139
+ return createTarget(input);
140
+ case 'jsonrender.stream':
141
+ // Appends to an existing node by `nodeId`, or creates one at x/y.
142
+ return typeof input.nodeId === 'string' ? { nodeIds: [input.nodeId] } : createTarget(input);
143
+ case 'edge.add':
144
+ // Search-resolved endpoints (`fromSearch`/`toSearch`) cannot be checked
145
+ // before resolution and so read as '' — refused, not bypassed.
146
+ return { nodeIds: [str(input.from), str(input.to)] };
147
+ case 'edge.remove': {
148
+ const id = str(input.id) || str(input.edge_id);
149
+ const edge = canvasState.getEdges().find((entry) => entry.id === id);
150
+ return edge ? { nodeIds: [edge.from, edge.to] } : {}; // missing edge: the op 404s on its own
151
+ }
152
+ case 'group.create':
153
+ return { nodeIds: stringList(input.childIds ?? input.children) };
154
+ case 'group.add':
155
+ case 'group.remove':
156
+ return { nodeIds: [str(input.groupId), ...stringList(input.childIds)] };
157
+ case 'annotation.add': {
158
+ const points = Array.isArray(input.points) ? input.points : [];
159
+ return {
160
+ points: points.map((point) => {
161
+ const p = asRecord(point);
162
+ return { x: Number(p.x), y: Number(p.y) };
163
+ }),
164
+ };
165
+ }
166
+ case 'annotation.remove': {
167
+ const annotation = canvasState.getAnnotations().find((entry) => entry.id === str(input.id));
168
+ if (!annotation) return {};
169
+ const { bounds } = annotation;
170
+ return {
171
+ points: [
172
+ { x: bounds.x, y: bounds.y },
173
+ { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
174
+ ],
175
+ };
176
+ }
177
+ case 'node.focus':
178
+ return {}; // navigation, not a write
179
+ default:
180
+ return { unknown: op.name };
181
+ }
182
+ }
183
+
184
+ /** Registry entry point: refusal text or null. Only call for mutating, agent-originated ops. */
185
+ export function checkScopeFence(op: Operation, rawInput: unknown, writerKey?: string): string | null {
186
+ return checkFenceTarget(describeOpTarget(op, rawInput), op.name, writerKey);
187
+ }
188
+
189
+ /**
190
+ * The GLOBAL fence belongs to the human: an agent must not clear, widen, or
191
+ * replace it through `ax.policy.set`. Per-agent territories (`agentScopes`)
192
+ * are the orchestration surface — an agent MAY set territories for OTHER
193
+ * writers (fencing its workers), but never its own: granting yourself a wider
194
+ * territory (or deleting it) is escalation.
195
+ */
196
+ export function checkScopeOwnership(rawInput: unknown, writerKey?: string): string | null {
197
+ const input = asRecord(rawInput);
198
+ if ('scope' in input)
199
+ return 'the scope fence is set and cleared by the human in the session panel, not by the agent.';
200
+ const agentScopes = input.agentScopes;
201
+ if (writerKey && agentScopes && typeof agentScopes === 'object' && !Array.isArray(agentScopes)) {
202
+ if (Object.prototype.hasOwnProperty.call(agentScopes, writerKey)) {
203
+ return `an agent cannot set or clear its OWN territory ("${writerKey}") — only the human or a different writer (the orchestrator) may.`;
204
+ }
205
+ }
206
+ return null;
207
+ }
@@ -74,9 +74,15 @@ import {
74
74
  } from './canvas-operations.js';
75
75
  import { dispatchOperationRoute, setOperationEventEmitter } from './operations/index.js';
76
76
  import { intentRegistry } from './intent-registry.js';
77
+ import { agentPresence } from './agent-presence.js';
78
+ import { HUMAN_STARTED_SESSION_LABEL } from '../shared/agent-presence.js';
79
+ import { diffLayouts } from './mutation-history.js';
80
+ import { humanPresence } from './human-presence.js';
81
+ import { startGateTtlSweeper, stopGateTtlSweeper } from './ax-gate-ttl.js';
77
82
  import { setWebviewRunner } from './operations/webview-runner.js';
78
83
  import { closeNodeAppSession, nodeAppSessionId } from './operations/ops/nodes.js';
79
84
  import { traceManager } from './trace-manager.js';
85
+ import { IFRAME_PROBE_MESSAGE_SOURCE } from '../shared/iframe-probe.js';
80
86
  import { buildJsonRenderViewerHtml } from '../json-render/server.js';
81
87
  import type { JsonRenderSpec } from '../json-render/server.js';
82
88
 
@@ -112,6 +118,80 @@ intentRegistry.setEmitter((event, payload) => {
112
118
  emitPrimaryWorkbenchEvent(event, payload);
113
119
  });
114
120
 
121
+ // Agent presence (rail-chrome-v2 phase 2) emits its `agent-presence` snapshot
122
+ // frames the same way — including TTL-expiry sweeps, so clients never run
123
+ // their own expiry ticker.
124
+ agentPresence.setEmitter((event, payload) => {
125
+ emitPrimaryWorkbenchEvent(event, payload);
126
+ });
127
+ humanPresence.setEmitter((event, payload) => {
128
+ emitPrimaryWorkbenchEvent(event, payload);
129
+ });
130
+
131
+ // Session receipt (rail-chrome-v2 phase 5, design item 2). The snapshot is
132
+ // taken when a session ATTACHES — the board before the agent touched it — so
133
+ // the receipt's View diff shows exactly what the session did and a restore
134
+ // undoes it. (A detach-time snapshot would equal the current board.)
135
+ agentPresence.setSessionStartListener((presence) => {
136
+ if (canvasState.getLayout().nodes.length === 0) return null;
137
+ const startedAt = new Date().toISOString();
138
+ return canvasState.saveSnapshot(`Before session · ${presence.label} · ${startedAt.slice(11, 16)}`)?.id ?? null;
139
+ });
140
+ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy) => {
141
+ const ax = canvasState.getAxState();
142
+ const endedAt = new Date().toISOString();
143
+ let snapshot = startSnapshotId
144
+ ? (canvasState.listSnapshots({ all: true }).find((entry) => entry.id === startSnapshotId) ?? null)
145
+ : null;
146
+ let unchanged = false;
147
+ // A session that changed NOTHING on the board leaves no snapshot behind —
148
+ // session churn (idle timeouts, receipt tests, attach/detach cycles) was
149
+ // flooding the History drawer with identical boards.
150
+ if (snapshot) {
151
+ const snapData = canvasState.getSnapshotData(snapshot.id);
152
+ if (snapData) {
153
+ const diff = diffLayouts(snapData.name, snapData, canvasState.getLayout());
154
+ if (
155
+ diff.addedNodes.length === 0 &&
156
+ diff.removedNodes.length === 0 &&
157
+ diff.modifiedNodes.length === 0 &&
158
+ diff.addedEdges.length === 0 &&
159
+ diff.removedEdges.length === 0
160
+ ) {
161
+ canvasState.deleteSnapshot(snapshot.id);
162
+ snapshot = null;
163
+ unchanged = true;
164
+ }
165
+ }
166
+ }
167
+ // A human-started session was named after the agent that filled it; the
168
+ // snapshot taken at attach still carries the placeholder — settle it now.
169
+ if (snapshot?.name.includes(HUMAN_STARTED_SESSION_LABEL) && presence.label !== HUMAN_STARTED_SESSION_LABEL) {
170
+ const name = snapshot.name.replace(HUMAN_STARTED_SESSION_LABEL, presence.label);
171
+ if (canvasState.renameSnapshot(snapshot.id, name)) snapshot.name = name;
172
+ }
173
+ // Three DIFFERENT outcomes, never one "vetoed" pile: a cancelled item was
174
+ // withdrawn (duplicate, scratch), a rejected gate is a human's explicit no,
175
+ // and a held gate is a TTL expiry nobody answered. The receipt tells them apart.
176
+ const counts = {
177
+ items: ax.workItems.length,
178
+ done: ax.workItems.filter((item) => item.status === 'done').length,
179
+ cancelled: ax.workItems.filter((item) => item.status === 'cancelled').length,
180
+ rejected: ax.approvalGates.filter((gate) => gate.status === 'rejected').length,
181
+ held: ax.approvalGates.filter((gate) => gate.status === 'held').length,
182
+ };
183
+ // No agent `sessionId` here: the SSE envelope reserves that key for the
184
+ // workbench session and would overwrite it. The label is what the receipt shows.
185
+ emitPrimaryWorkbenchEvent('agent-session-ended', {
186
+ label: presence.label,
187
+ endedAt,
188
+ endedBy,
189
+ counts,
190
+ unchanged,
191
+ snapshot: snapshot ? { id: snapshot.id, name: snapshot.name } : null,
192
+ });
193
+ });
194
+
115
195
  // Webview-runner wiring (plan-008 Wave 3): the webview ops never import this
116
196
  // module — the Bun.WebView automation runner is injected here, mirroring the
117
197
  // setOperationEventEmitter pattern. The closures call the real automation
@@ -236,7 +316,9 @@ interface BunWithOptionalWebView {
236
316
  WebView?: CanvasWebViewConstructor;
237
317
  }
238
318
 
239
- const DEFAULT_CANVAS_AUTOMATION_WEBVIEW_TIMEOUT_MS = 5000;
319
+ // A COLD WebView launch on macOS regularly needs >5s (0.5.0 readiness: the
320
+ // default-backend start timed out, the immediate retry succeeded warm).
321
+ const DEFAULT_CANVAS_AUTOMATION_WEBVIEW_TIMEOUT_MS = 15_000;
240
322
 
241
323
  export interface CanvasAutomationWebViewOptions {
242
324
  backend?: 'webkit' | 'chrome';
@@ -1028,11 +1110,28 @@ function canvasSpaHtml(): string {
1028
1110
  process.env.AMP_ORB ? '<script>window.__PMX_AMP_ORB = true;</script>' : ''
1029
1111
  }
1030
1112
  <script>window.__PMX_BOOT_SERVER_VERSION = ${JSON.stringify(serverPackageVersion())};</script>
1113
+ <script>window.__PMX_BOOT_BUNDLE_STAMP = ${JSON.stringify(canvasBundleStamp())};</script>
1031
1114
  <script type="module" src="/canvas/index.js?v=${CANVAS_ASSET_VERSION}"></script>
1032
1115
  </body>
1033
1116
  </html>`;
1034
1117
  }
1035
1118
 
1119
+ /**
1120
+ * Identity of the SERVED client bundle, read once per process: a tab whose
1121
+ * boot stamp differs from the live server's reloads itself on (re)connect.
1122
+ */
1123
+ let _bundleStamp: string | null = null;
1124
+ function canvasBundleStamp(): string {
1125
+ if (_bundleStamp !== null) return _bundleStamp;
1126
+ try {
1127
+ const stat = statSync(join(resolveCanvasBundleDir(), 'index.js'));
1128
+ _bundleStamp = `${Math.round(stat.mtimeMs)}-${stat.size}`;
1129
+ } catch {
1130
+ _bundleStamp = 'unknown';
1131
+ }
1132
+ return _bundleStamp;
1133
+ }
1134
+
1036
1135
  const CANVAS_STATIC_MIME: Record<string, string> = {
1037
1136
  '.js': 'application/javascript',
1038
1137
  '.css': 'text/css',
@@ -1090,6 +1189,17 @@ function resolveCanvasBundleDir(): string {
1090
1189
  // the same bundle root that serves /canvas/surface-theme.css; null keeps the
1091
1190
  // <link> fallback when the asset is missing (e.g. an unbuilt dev tree).
1092
1191
  let _surfaceThemeCss: string | null | undefined;
1192
+ let _mermaidEntryJs: string | null | undefined;
1193
+ function mermaidEntryJsInline(): string | null {
1194
+ if (_mermaidEntryJs !== undefined) return _mermaidEntryJs;
1195
+ try {
1196
+ _mermaidEntryJs = readFileSync(resolve(resolveCanvasBundleDir(), 'mermaid-entry.js'), 'utf-8');
1197
+ } catch {
1198
+ _mermaidEntryJs = null;
1199
+ }
1200
+ return _mermaidEntryJs;
1201
+ }
1202
+
1093
1203
  function surfaceThemeCssInline(): string | null {
1094
1204
  if (_surfaceThemeCss !== undefined) return _surfaceThemeCss;
1095
1205
  try {
@@ -1204,13 +1314,22 @@ function handleFrameDocument(pathname: string): Response {
1204
1314
  // iframe hosts like the Amp orb portal block them, and the client then falls
1205
1315
  // back to fetch() + srcdoc surfaces (see src/client/state/iframe-mode.ts).
1206
1316
  function handleIframeProbe(): Response {
1207
- return new Response('<!doctype html><title>pmx-canvas iframe probe</title>', {
1208
- headers: {
1209
- 'Content-Type': 'text/html; charset=utf-8',
1210
- 'Cache-Control': 'no-store',
1211
- 'Content-Security-Policy': 'sandbox allow-scripts',
1317
+ // The postMessage handshake IS the probe signal: hosts that block sub-frame
1318
+ // requests (the Claude Code desktop browser) still fire `load` on the error
1319
+ // page they commit, so only this document actually executing proves that
1320
+ // src-URL iframes work (see src/client/state/iframe-mode.ts).
1321
+ return new Response(
1322
+ `<!doctype html><title>pmx-canvas iframe probe</title><script>parent.postMessage({ source: ${JSON.stringify(
1323
+ IFRAME_PROBE_MESSAGE_SOURCE,
1324
+ )} }, "*")</script>`,
1325
+ {
1326
+ headers: {
1327
+ 'Content-Type': 'text/html; charset=utf-8',
1328
+ 'Cache-Control': 'no-store',
1329
+ 'Content-Security-Policy': 'sandbox allow-scripts',
1330
+ },
1212
1331
  },
1213
- });
1332
+ );
1214
1333
  }
1215
1334
 
1216
1335
  // ── Ext-app recovery diagnostics (0.3.2 report Finding N) ──────
@@ -1310,18 +1429,22 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1310
1429
  // Display-only surface: same theme/height plumbing as html nodes, but no AX
1311
1430
  // bridge and no presentation mode — the document is our own wrapper around
1312
1431
  // escaped diagram source, rendered by /canvas/mermaid-entry.js.
1313
- const doc = buildHtmlSurfaceDocument(buildMermaidSurfaceHtml(source), {
1314
- theme,
1315
- title: surfaceTitle,
1316
- themeToken: url.searchParams.get('themeToken') ?? undefined,
1317
- // Content-height reporter nonce (lets the node grow to fit the diagram).
1318
- ...(url.searchParams.get('frameToken')
1319
- ? { contentHeightToken: url.searchParams.get('frameToken') as string }
1320
- : {}),
1321
- // Inline the theme tokens so srcdoc-rendered surfaces (Amp orb portals)
1322
- // are styled without depending on the <link> subresource.
1323
- ...(surfaceThemeCssInline() ? { inlineThemeCss: surfaceThemeCssInline() as string } : {}),
1324
- });
1432
+ const inlineAssets = url.searchParams.get('inline-assets') === '1';
1433
+ const doc = buildHtmlSurfaceDocument(
1434
+ buildMermaidSurfaceHtml(source, inlineAssets ? mermaidEntryJsInline() : null),
1435
+ {
1436
+ theme,
1437
+ title: surfaceTitle,
1438
+ themeToken: url.searchParams.get('themeToken') ?? undefined,
1439
+ // Content-height reporter nonce (lets the node grow to fit the diagram).
1440
+ ...(url.searchParams.get('frameToken')
1441
+ ? { contentHeightToken: url.searchParams.get('frameToken') as string }
1442
+ : {}),
1443
+ // Inline the theme tokens so srcdoc-rendered surfaces (Amp orb portals)
1444
+ // are styled without depending on the <link> subresource.
1445
+ ...(surfaceThemeCssInline() ? { inlineThemeCss: surfaceThemeCssInline() as string } : {}),
1446
+ },
1447
+ );
1325
1448
  return surfaceHtmlResponse(doc, HTML_SURFACE_SANDBOX);
1326
1449
  }
1327
1450
 
@@ -1931,8 +2054,11 @@ function buildWorkbenchConnectSnapshot(
1931
2054
  path: primaryWorkbenchPath,
1932
2055
  theme: canvasState.theme,
1933
2056
  // Lets a long-lived client detect that the server was upgraded under it
1934
- // (stale in-memory SPA — 0.4.5 report Finding W) and reload once.
2057
+ // (stale in-memory SPA — 0.4.5 report Finding W) and reload once. The
2058
+ // bundle stamp catches DEV rolls too: a rebuild+restart never bumps the
2059
+ // package version, so tabs silently kept month-old JS (rule 3's trap).
1935
2060
  version: serverPackageVersion(),
2061
+ bundleStamp: canvasBundleStamp(),
1936
2062
  timestamp: new Date().toISOString(),
1937
2063
  },
1938
2064
  });
@@ -1994,6 +2120,25 @@ function buildWorkbenchConnectSnapshot(
1994
2120
  },
1995
2121
  });
1996
2122
  }
2123
+ // A fresh tab must not start blind to who is on the board: both presence
2124
+ // registries broadcast only on CHANGE, so without these frames a reload
2125
+ // shows no agent cursors/chips and no human cursors until the next move.
2126
+ events.push({
2127
+ event: 'agent-presence',
2128
+ payload: {
2129
+ ...(agentPresence.snapshot() as unknown as Record<string, unknown>),
2130
+ reason: 'connect-snapshot',
2131
+ timestamp: new Date().toISOString(),
2132
+ },
2133
+ });
2134
+ events.push({
2135
+ event: 'human-presence',
2136
+ payload: {
2137
+ ...(humanPresence.snapshot() as unknown as Record<string, unknown>),
2138
+ reason: 'connect-snapshot',
2139
+ timestamp: new Date().toISOString(),
2140
+ },
2141
+ });
1997
2142
  return events;
1998
2143
  }
1999
2144
 
@@ -2157,7 +2302,6 @@ async function handleCanvasPrompt(req: Request): Promise<Response> {
2157
2302
  zIndex: 1,
2158
2303
  collapsed: false,
2159
2304
  pinned: false,
2160
- dockPosition: null,
2161
2305
  data: {
2162
2306
  text,
2163
2307
  turns: [],
@@ -2287,7 +2431,6 @@ async function handleCanvasPrompt(req: Request): Promise<Response> {
2287
2431
  zIndex: 1,
2288
2432
  collapsed: false,
2289
2433
  pinned: false,
2290
- dockPosition: null,
2291
2434
  data: {
2292
2435
  text,
2293
2436
  turns: [{ role: 'user', text, status: 'pending' }],
@@ -2601,9 +2744,8 @@ function syncContextNodeToCanvasState(
2601
2744
  position: { x: 1130, y: 80 },
2602
2745
  size: { width: 320, height: 400 },
2603
2746
  zIndex: 1,
2604
- collapsed: true,
2747
+ collapsed: false,
2605
2748
  pinned: false,
2606
- dockPosition: 'right',
2607
2749
  data: mergedData,
2608
2750
  });
2609
2751
  return;
@@ -2612,57 +2754,6 @@ function syncContextNodeToCanvasState(
2612
2754
  canvasState.updateNode(id, { data: mergedData });
2613
2755
  }
2614
2756
 
2615
- /**
2616
- * Seed the docked status (left) + context (right) widgets so a freshly opened
2617
- * canvas shows them by default — the same nodes the agent-event path creates on
2618
- * demand (`status-main`, `context-main`), just present from the start.
2619
- *
2620
- * First-run only: we bail if the workspace canvas already has persisted state,
2621
- * so we never add them to a board with content, and — because first-run state is
2622
- * persisted on save — deleting or undocking them later is respected (they are
2623
- * not re-seeded). Create-if-missing keeps it idempotent if the agent path
2624
- * already made one. Returns true if anything was seeded.
2625
- */
2626
- export function ensureDefaultDockedNodes(): boolean {
2627
- if (canvasState.hasPersistedState()) return false;
2628
- let seeded = false;
2629
- // NOTE: these node specs mirror the agent-event create paths below
2630
- // (`canvas-status` for status-main, `syncContextNodeToCanvasState` for
2631
- // context-main) — keep geometry/dock defaults in sync if you change them.
2632
- if (!canvasState.getNode('status-main')) {
2633
- canvasState.addNode({
2634
- id: 'status-main',
2635
- type: 'status',
2636
- position: { x: 40, y: 80 },
2637
- size: { width: 300, height: 120 },
2638
- zIndex: 0,
2639
- collapsed: true,
2640
- pinned: false,
2641
- dockPosition: 'left',
2642
- data: { phase: 'idle', message: '', elapsed: 0 },
2643
- });
2644
- seeded = true;
2645
- }
2646
- if (!canvasState.getNode('context-main')) {
2647
- canvasState.addNode({
2648
- id: 'context-main',
2649
- type: 'context',
2650
- position: { x: 1130, y: 80 },
2651
- size: { width: 320, height: 400 },
2652
- zIndex: 1,
2653
- collapsed: true,
2654
- pinned: false,
2655
- dockPosition: 'right',
2656
- data: { cards: [], auxTabs: [] },
2657
- });
2658
- seeded = true;
2659
- }
2660
- if (seeded) {
2661
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2662
- }
2663
- return seeded;
2664
- }
2665
-
2666
2757
  // Maps responseNodeId -> thread prompt node ID for O(1) routing of response events
2667
2758
  const serverResponseToThreadMap = new Map<string, string>();
2668
2759
 
@@ -2683,7 +2774,6 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
2683
2774
  zIndex: 1,
2684
2775
  collapsed: false,
2685
2776
  pinned: false,
2686
- dockPosition: null,
2687
2777
  data: { path, title, content: '', rendered: '' },
2688
2778
  });
2689
2779
  } else {
@@ -2772,7 +2862,6 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
2772
2862
  zIndex: 1,
2773
2863
  collapsed: false,
2774
2864
  pinned: false,
2775
- dockPosition: null,
2776
2865
  data: dataPatch,
2777
2866
  });
2778
2867
  }
@@ -2836,6 +2925,12 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
2836
2925
  utilization: payload.utilization,
2837
2926
  nearLimit: payload.nearLimit,
2838
2927
  });
2928
+ // The same numbers feed the top-bar context meter of the attached session.
2929
+ const used = Number(payload.currentTokens);
2930
+ const total = Number(payload.tokenLimit);
2931
+ if (Number.isFinite(used) && Number.isFinite(total) && total > 0) {
2932
+ agentPresence.reportContextUsage({ used: Math.max(0, used), total });
2933
+ }
2839
2934
  } else if (event === 'aux-open') {
2840
2935
  const existing = canvasState.getNode('context-main');
2841
2936
  const auxTabs = Array.isArray(existing?.data.auxTabs)
@@ -2864,7 +2959,6 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
2864
2959
  zIndex: 0,
2865
2960
  collapsed: false,
2866
2961
  pinned: false,
2867
- dockPosition: 'left',
2868
2962
  data: { phase: 'idle', message: '', elapsed: 0 },
2869
2963
  });
2870
2964
  }
@@ -2901,7 +2995,6 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
2901
2995
  zIndex: 1,
2902
2996
  collapsed: false,
2903
2997
  pinned: false,
2904
- dockPosition: null,
2905
2998
  data: { content: '', status: 'streaming', promptNodeId },
2906
2999
  });
2907
3000
 
@@ -3122,6 +3215,8 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3122
3215
  canvasState.setWorkspaceRoot(activeWorkspaceRoot);
3123
3216
  canvasState.setTheme(initialCanvasThemeSetting as CanvasTheme);
3124
3217
  const loaded = canvasState.loadFromDisk({ clearExisting: true });
3218
+ // Unattended-approval policy: hold pending gates whose TTL elapsed.
3219
+ startGateTtlSweeper((event, payload) => emitPrimaryWorkbenchEvent(event, payload));
3125
3220
  setCanvasLayoutUpdateEmitter(() => {
3126
3221
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
3127
3222
  });
@@ -3333,6 +3428,9 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3333
3428
  }
3334
3429
 
3335
3430
  export function stopCanvasServer(): void {
3431
+ stopGateTtlSweeper();
3432
+ agentPresence.reset();
3433
+ humanPresence.reset();
3336
3434
  intentRegistry.reset();
3337
3435
  canvasState.close();
3338
3436
  closeAllMcpAppSessions();
@@ -101,7 +101,6 @@ class TraceManager {
101
101
  zIndex: 0,
102
102
  collapsed: false,
103
103
  pinned: true,
104
- dockPosition: null,
105
104
  data: {
106
105
  toolName: payload.name,
107
106
  category,
@@ -199,7 +198,6 @@ class TraceManager {
199
198
  zIndex: 0,
200
199
  collapsed: false,
201
200
  pinned: true,
202
- dockPosition: null,
203
201
  data: {
204
202
  toolName: payload.agentDisplayName ?? payload.agentName,
205
203
  category: 'subagent' as TraceCategory,
@@ -716,7 +716,6 @@ export function openWebArtifactInCanvas(input: {
716
716
  zIndex: 1,
717
717
  collapsed: false,
718
718
  pinned: false,
719
- dockPosition: null,
720
719
  data: {
721
720
  title: input.title,
722
721
  ...(typeof input.content === 'string' ? { content: input.content } : {}),