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.
- package/.github/extensions/pmx-canvas/extension.mjs +154 -5
- package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/CHANGELOG.md +139 -0
- package/Readme.md +72 -23
- package/dist/canvas/global.css +3069 -796
- package/dist/canvas/index.js +104 -104
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/pump.d.ts +9 -0
- package/dist/types/client/App.d.ts +1 -1
- package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
- package/dist/types/client/canvas/BarHint.d.ts +22 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
- package/dist/types/client/canvas/CommandBar.d.ts +10 -0
- package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
- package/dist/types/client/canvas/EmptyState.d.ts +11 -0
- package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
- package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/Minimap.d.ts +12 -1
- package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
- package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
- package/dist/types/client/canvas/ToolRail.d.ts +21 -0
- package/dist/types/client/canvas/TopBar.d.ts +7 -0
- package/dist/types/client/canvas/canvas-area.d.ts +25 -0
- package/dist/types/client/canvas/create-in-view.d.ts +18 -0
- package/dist/types/client/canvas/import-files.d.ts +8 -0
- package/dist/types/client/canvas/kind-colors.d.ts +3 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
- package/dist/types/client/canvas/use-now.d.ts +6 -0
- package/dist/types/client/icons.d.ts +11 -4
- package/dist/types/client/nodes/GroupNode.d.ts +6 -1
- package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
- package/dist/types/client/state/canvas-store.d.ts +49 -5
- package/dist/types/client/state/human-store.d.ts +26 -0
- package/dist/types/client/state/iframe-mode.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +37 -5
- package/dist/types/client/state/node-factory.d.ts +0 -1
- package/dist/types/client/state/presence-store.d.ts +78 -0
- package/dist/types/client/state/session-store.d.ts +176 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/server/agent-presence.d.ts +192 -0
- package/dist/types/server/ax-gate-ttl.d.ts +6 -0
- package/dist/types/server/ax-interaction.d.ts +4 -10
- package/dist/types/server/ax-state-manager.d.ts +21 -2
- package/dist/types/server/ax-state.d.ts +49 -3
- package/dist/types/server/ax-wait.d.ts +13 -0
- package/dist/types/server/canvas-db.d.ts +9 -1
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +27 -13
- package/dist/types/server/canvas-validation.d.ts +0 -14
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/human-presence.d.ts +40 -0
- package/dist/types/server/index.d.ts +29 -3
- package/dist/types/server/intent-registry.d.ts +7 -0
- package/dist/types/server/mutation-history.d.ts +15 -2
- package/dist/types/server/operations/invoker.d.ts +13 -2
- package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
- package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
- package/dist/types/server/operations/registry.d.ts +11 -0
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/scope-fence.d.ts +62 -0
- package/dist/types/server/server.d.ts +0 -12
- package/dist/types/shared/agent-presence.d.ts +130 -0
- package/dist/types/shared/approval-gates.d.ts +19 -0
- package/dist/types/shared/auto-arrange.d.ts +0 -1
- package/dist/types/shared/ax-kinds.d.ts +11 -0
- package/dist/types/shared/human-presence.d.ts +31 -0
- package/dist/types/shared/iframe-probe.d.ts +8 -0
- package/dist/types/shared/scope-fence.d.ts +21 -0
- package/docs/RELEASE.md +6 -0
- package/docs/ax-host-adapter-contract.md +30 -14
- package/docs/cli.md +37 -1
- package/docs/environment.md +3 -0
- package/docs/http-api.md +222 -5
- package/docs/mcp.md +6 -5
- package/docs/node-types.md +3 -1
- package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/multi-agent-live.png +0 -0
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +1 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +103 -16
- package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
- package/skills/pmx-canvas/references/full-reference.md +55 -16
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
- package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
- package/skills/pmx-canvas-testing/SKILL.md +17 -0
- package/src/cli/agent.ts +1 -0
- package/src/cli/commands/copilot.ts +15 -8
- package/src/cli/commands/nodes.ts +1 -19
- package/src/cli/commands/pump.ts +252 -0
- package/src/cli/index.ts +1 -5
- package/src/cli/shared.ts +1 -1
- package/src/client/App.tsx +209 -585
- package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
- package/src/client/canvas/AttentionHistory.tsx +0 -10
- package/src/client/canvas/BarHint.tsx +49 -0
- package/src/client/canvas/CanvasNode.tsx +488 -136
- package/src/client/canvas/CanvasViewport.tsx +190 -94
- package/src/client/canvas/CommandBar.tsx +140 -0
- package/src/client/canvas/CommandPalette.tsx +133 -58
- package/src/client/canvas/ConnectionBanner.tsx +65 -0
- package/src/client/canvas/ContextMenu.tsx +88 -27
- package/src/client/canvas/ContextPinBar.tsx +2 -2
- package/src/client/canvas/EdgeLayer.tsx +121 -38
- package/src/client/canvas/EmptyState.tsx +93 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
- package/src/client/canvas/ExternalWriters.tsx +308 -0
- package/src/client/canvas/FocusFieldLayer.tsx +12 -1
- package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
- package/src/client/canvas/IntentLayer.tsx +1 -1
- package/src/client/canvas/Minimap.tsx +117 -190
- package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
- package/src/client/canvas/SelectionBar.tsx +175 -23
- package/src/client/canvas/SessionPanel.tsx +481 -0
- package/src/client/canvas/SessionReceipt.tsx +141 -0
- package/src/client/canvas/ShortcutOverlay.tsx +21 -6
- package/src/client/canvas/SnapshotPanel.tsx +166 -80
- package/src/client/canvas/TextPrompt.tsx +128 -0
- package/src/client/canvas/ToolRail.tsx +447 -0
- package/src/client/canvas/TopBar.tsx +308 -0
- package/src/client/canvas/auto-fit.ts +1 -5
- package/src/client/canvas/canvas-area.ts +41 -0
- package/src/client/canvas/create-in-view.ts +48 -0
- package/src/client/canvas/import-files.ts +64 -0
- package/src/client/canvas/kind-colors.ts +22 -0
- package/src/client/canvas/snap-guides.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +54 -0
- package/src/client/canvas/use-now.ts +17 -0
- package/src/client/canvas/use-pan-zoom.ts +13 -2
- package/src/client/icons.tsx +57 -18
- package/src/client/nodes/GroupNode.tsx +9 -28
- package/src/client/nodes/InlineFormatBar.tsx +4 -3
- package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
- package/src/client/nodes/MarkdownNode.tsx +59 -24
- package/src/client/nodes/MdFormatBar.tsx +2 -4
- package/src/client/nodes/inline-editor-commands.ts +17 -6
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/state/attention-bridge.ts +3 -0
- package/src/client/state/canvas-store.ts +273 -84
- package/src/client/state/human-store.ts +175 -0
- package/src/client/state/iframe-mode.ts +33 -9
- package/src/client/state/intent-bridge.ts +75 -13
- package/src/client/state/node-factory.ts +2 -8
- package/src/client/state/presence-store.ts +168 -0
- package/src/client/state/session-store.ts +419 -0
- package/src/client/state/sse-bridge.ts +93 -30
- package/src/client/theme/global.css +3069 -796
- package/src/client/types.ts +3 -1
- package/src/client/utils/platform.ts +6 -1
- package/src/mcp/canvas-access.ts +2 -2
- package/src/server/agent-presence.ts +755 -0
- package/src/server/ax-context.ts +45 -0
- package/src/server/ax-gate-ttl.ts +61 -0
- package/src/server/ax-interaction.ts +29 -1
- package/src/server/ax-state-manager.ts +78 -6
- package/src/server/ax-state.ts +82 -15
- package/src/server/ax-wait.ts +33 -0
- package/src/server/canvas-db.ts +103 -33
- package/src/server/canvas-operations.ts +17 -8
- package/src/server/canvas-state.ts +131 -58
- package/src/server/canvas-validation.ts +3 -53
- package/src/server/demo-state.json +2 -70
- package/src/server/html-surface.ts +11 -2
- package/src/server/human-presence.ts +160 -0
- package/src/server/index.ts +107 -4
- package/src/server/intent-registry.ts +13 -0
- package/src/server/mutation-history.ts +47 -2
- package/src/server/operations/composites.ts +6 -3
- package/src/server/operations/http.ts +10 -1
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/invoker.ts +33 -6
- package/src/server/operations/ops/ax-presence.ts +62 -0
- package/src/server/operations/ops/ax-read.ts +8 -0
- package/src/server/operations/ops/ax-state.ts +106 -1
- package/src/server/operations/ops/ax-timeline.ts +54 -5
- package/src/server/operations/ops/ax-work.ts +46 -0
- package/src/server/operations/ops/batch.ts +3 -1
- package/src/server/operations/ops/edges.ts +88 -2
- package/src/server/operations/ops/groups.ts +4 -3
- package/src/server/operations/ops/human-presence.ts +35 -0
- package/src/server/operations/ops/json-render.ts +11 -1
- package/src/server/operations/ops/nodes.ts +65 -16
- package/src/server/operations/ops/query.ts +1 -0
- package/src/server/operations/ops/snapshots.ts +16 -0
- package/src/server/operations/registry.ts +196 -1
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +207 -0
- package/src/server/server.ts +177 -79
- package/src/server/trace-manager.ts +0 -2
- package/src/server/web-artifacts.ts +0 -1
- package/src/shared/agent-presence.ts +162 -0
- package/src/shared/approval-gates.ts +35 -0
- package/src/shared/auto-arrange.ts +121 -13
- package/src/shared/ax-kinds.ts +23 -0
- package/src/shared/human-presence.ts +30 -0
- package/src/shared/iframe-probe.ts +8 -0
- package/src/shared/scope-fence.ts +31 -0
- package/dist/types/client/canvas/DockedNode.d.ts +0 -4
- 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
|
-
'
|
|
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
|
|
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
|
-
|
|
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.
|
|
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,23 @@ 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
|
+
}
|
|
407
425
|
const content =
|
|
408
426
|
type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string' ? body.path : body.content;
|
|
409
427
|
// For html nodes, accept top-level `html` AND `axCapabilities` and merge into data
|
|
@@ -472,6 +490,23 @@ export function buildNodePatch(
|
|
|
472
490
|
existing: CanvasNodeState,
|
|
473
491
|
body: Record<string, unknown>,
|
|
474
492
|
): { patch: Partial<CanvasNodeState>; groupChildIds?: string[] } {
|
|
493
|
+
// Same dead-field traps as create (gauntlet, 2026-08-27): reject loudly
|
|
494
|
+
// instead of dropping — and note that patching `content` on a file node
|
|
495
|
+
// updates the FILE CONTENT snapshot, it does not re-point the node.
|
|
496
|
+
if (body.src !== undefined) {
|
|
497
|
+
throw new OperationError('Unknown field "src" — update the image source via `content`.');
|
|
498
|
+
}
|
|
499
|
+
if (body.path !== undefined && existing.type !== 'image') {
|
|
500
|
+
if (existing.type === 'file') {
|
|
501
|
+
// Repoint: `path` on a file-node patch moves the node to another file
|
|
502
|
+
// (content re-read, watcher rewired) while keeping its edges and pins.
|
|
503
|
+
if (typeof body.path !== 'string' || !body.path.trim()) {
|
|
504
|
+
throw new OperationError('File node "path" must be a non-empty string.');
|
|
505
|
+
}
|
|
506
|
+
} else {
|
|
507
|
+
throw new OperationError('"path" is only an image-node compatibility alias — use `content`.');
|
|
508
|
+
}
|
|
509
|
+
}
|
|
475
510
|
const groupChildList = existing.type === 'group' ? pickGroupChildIds(body) : {};
|
|
476
511
|
if (groupChildList.error) throw new OperationError(`Cannot update group: ${groupChildList.error}`);
|
|
477
512
|
const groupChildIds = groupChildList.value;
|
|
@@ -482,9 +517,6 @@ export function buildNodePatch(
|
|
|
482
517
|
const patch: Record<string, unknown> = resolvePatchGeometry(body, existing);
|
|
483
518
|
if (body.collapsed !== undefined) patch.collapsed = body.collapsed;
|
|
484
519
|
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
520
|
if (hasStructuredNodeUpdateFields(body)) {
|
|
489
521
|
try {
|
|
490
522
|
patch.data = buildStructuredNodeUpdate(existing, body).data;
|
|
@@ -499,9 +531,11 @@ export function buildNodePatch(
|
|
|
499
531
|
typeof body.strictSize === 'boolean' ||
|
|
500
532
|
(existing.type === 'trace' && hasTraceNodeDataFields(body)) ||
|
|
501
533
|
(existing.type === 'html' && body.html !== undefined) ||
|
|
534
|
+
(existing.type === 'file' && typeof body.path === 'string') ||
|
|
502
535
|
body.axCapabilities !== undefined
|
|
503
536
|
) {
|
|
504
537
|
const data = { ...existing.data };
|
|
538
|
+
if (existing.type === 'file' && typeof body.path === 'string') data.path = body.path.trim();
|
|
505
539
|
if (body.title !== undefined) {
|
|
506
540
|
data.title = String(body.title);
|
|
507
541
|
if (existing.type === 'webpage') {
|
|
@@ -995,10 +1029,6 @@ const nodeUpdateShape = {
|
|
|
995
1029
|
resultSummary: z.string().optional().catch(undefined).describe('Trace node result summary'),
|
|
996
1030
|
error: z.string().optional().catch(undefined).describe('Trace node error message'),
|
|
997
1031
|
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
1032
|
pinned: z.unknown().optional().describe('Pin or unpin the node to exclude it from auto-arrange'),
|
|
1003
1033
|
arrangeLocked: z
|
|
1004
1034
|
.boolean()
|
|
@@ -1039,7 +1069,7 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
|
|
|
1039
1069
|
},
|
|
1040
1070
|
mcp: {
|
|
1041
1071
|
toolName: 'canvas_update_node',
|
|
1042
|
-
description: 'Update an existing node. You can change its content, title, position, size,
|
|
1072
|
+
description: 'Update an existing node. You can change its content, title, position, size, or data.',
|
|
1043
1073
|
extraShape: {
|
|
1044
1074
|
title: z.string().optional().describe('New title'),
|
|
1045
1075
|
content: z.string().optional().describe('New content'),
|
|
@@ -1053,11 +1083,6 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
|
|
|
1053
1083
|
yKey: z.string().optional().describe('Graph y/value key'),
|
|
1054
1084
|
chartHeight: z.number().optional().describe('Graph chart content height, distinct from node height'),
|
|
1055
1085
|
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
1086
|
pinned: z.boolean().optional().describe('Pin or unpin the node to exclude it from auto-arrange'),
|
|
1062
1087
|
arrangeLocked: z
|
|
1063
1088
|
.boolean()
|
|
@@ -1107,7 +1132,7 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
|
|
|
1107
1132
|
};
|
|
1108
1133
|
},
|
|
1109
1134
|
},
|
|
1110
|
-
handler: async (input) => {
|
|
1135
|
+
handler: async (input, ctx) => {
|
|
1111
1136
|
const body: Record<string, unknown> = input;
|
|
1112
1137
|
const id = input.id;
|
|
1113
1138
|
const existing = canvasState.getNode(id);
|
|
@@ -1130,6 +1155,30 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
|
|
|
1130
1155
|
if (groupChildIds !== undefined && !setGroupChildrenFromApi(id, groupChildIds)) {
|
|
1131
1156
|
throw new OperationError(`Group "${id}" not found.`, 404);
|
|
1132
1157
|
}
|
|
1158
|
+
// File repoint: a data.path change re-reads the new file and rewires the
|
|
1159
|
+
// watcher IN PLACE — before this, pointing a file node elsewhere meant
|
|
1160
|
+
// recreating it, which lost its edges, pins, and position.
|
|
1161
|
+
if (existing.type === 'file') {
|
|
1162
|
+
const prevPath = typeof existing.data.path === 'string' ? existing.data.path : null;
|
|
1163
|
+
const repointed = canvasState.getNode(id);
|
|
1164
|
+
const nextPath = repointed && typeof repointed.data.path === 'string' ? repointed.data.path : null;
|
|
1165
|
+
if (nextPath && nextPath !== prevPath) {
|
|
1166
|
+
if (prevPath) unwatchFileForNode(id, prevPath);
|
|
1167
|
+
const data = { ...repointed!.data };
|
|
1168
|
+
// The title follows the file unless the caller pinned one in this
|
|
1169
|
+
// patch or had renamed the node away from the old filename.
|
|
1170
|
+
const prevBase = prevPath?.split(/[\\/]/).pop();
|
|
1171
|
+
if (typeof body.title !== 'string' && (!data.title || data.title === prevBase)) {
|
|
1172
|
+
data.title = nextPath.split(/[\\/]/).pop();
|
|
1173
|
+
}
|
|
1174
|
+
applyFileContentToNodeData(data, readFileNodeContent(nextPath));
|
|
1175
|
+
canvasState.updateNode(id, { data });
|
|
1176
|
+
watchFileForNode(id, nextPath);
|
|
1177
|
+
scheduleCodeGraphRecompute(() => {
|
|
1178
|
+
ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1133
1182
|
const updated = canvasState.getNode(id);
|
|
1134
1183
|
return updated ? buildNodeResponse(updated) : { ok: true, id };
|
|
1135
1184
|
},
|
|
@@ -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
|
}),
|
|
@@ -128,6 +128,22 @@ 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
|
+
// A save of an unchanged board reuses the newest snapshot instead of
|
|
132
|
+
// stacking an identical copy in the History drawer.
|
|
133
|
+
const newest = canvasState.listSnapshots({ limit: 1 })[0];
|
|
134
|
+
if (newest) {
|
|
135
|
+
const newestData = canvasState.getSnapshotData(newest.id);
|
|
136
|
+
if (newestData) {
|
|
137
|
+
const diff = diffLayouts(newestData.name, newestData, canvasState.getLayout());
|
|
138
|
+
const identical =
|
|
139
|
+
diff.addedNodes.length === 0 &&
|
|
140
|
+
diff.removedNodes.length === 0 &&
|
|
141
|
+
diff.modifiedNodes.length === 0 &&
|
|
142
|
+
diff.addedEdges.length === 0 &&
|
|
143
|
+
diff.removedEdges.length === 0;
|
|
144
|
+
if (identical) return { ok: true, id: newest.id, snapshot: newest, reused: true };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
131
147
|
const snapshot = saveCanvasSnapshot(name);
|
|
132
148
|
if (!snapshot) throw new OperationError('Failed to save snapshot');
|
|
133
149
|
return { ok: true, id: snapshot.id, snapshot };
|
|
@@ -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
|
-
|
|
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 {
|