pmx-canvas 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/Readme.md +9 -6
- package/dist/canvas/global.css +83 -6
- package/dist/canvas/index.js +177 -116
- package/dist/canvas/mermaid-entry.js +1303 -1303
- package/dist/json-render/index.js +101 -102
- package/dist/types/client/canvas/canvas-area.d.ts +14 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
- package/dist/types/server/canvas-operations.d.ts +10 -0
- package/docs/RELEASE.md +14 -0
- package/docs/adr-001-bun-only-runtime.md +1 -1
- package/docs/bun-webview-integration.md +2 -2
- package/docs/product-context-vision-2026-09.md +207 -0
- package/docs/product-review-2026-09.md +174 -0
- package/docs/product-vision-2026-09.md +200 -0
- package/docs/screenshot.png +0 -0
- package/package.json +5 -5
- package/skills/pmx-canvas/SKILL.md +61 -30
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +126 -18
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +12 -1
- package/src/cli/commands/pump.ts +13 -2
- package/src/cli/index.ts +1 -0
- package/src/client/canvas/CanvasViewport.tsx +11 -0
- package/src/client/canvas/TopBar.tsx +85 -45
- package/src/client/canvas/canvas-area.ts +33 -0
- package/src/client/canvas/use-pan-zoom.ts +7 -0
- package/src/client/nodes/ExtAppFrame.tsx +152 -23
- package/src/client/state/canvas-store.ts +29 -11
- package/src/client/theme/global.css +83 -6
- package/src/server/canvas-operations.ts +41 -6
- package/src/server/index.ts +4 -2
- package/src/server/operations/mcp.ts +45 -31
- package/src/server/operations/ops/ax-presence.ts +3 -0
- package/src/server/operations/ops/nodes.ts +12 -0
- package/src/server/operations/ops/snapshots.ts +6 -19
- package/src/server/scope-fence.ts +1 -1
|
@@ -986,10 +986,21 @@ body,
|
|
|
986
986
|
/* NOT overflow:hidden — the BarHint tooltips hang below the bar and were
|
|
987
987
|
clipped invisible for everyone (the e2e asserted opacity, which cannot
|
|
988
988
|
see ancestor clipping). Children ellipsize themselves; the narrow-screen
|
|
989
|
-
collapse is media-query display, not clipping.
|
|
989
|
+
collapse is media-query display, not clipping. overflow-x: clip is the
|
|
990
|
+
one safe axis (visible/clip is the valid mixed pair): tooltips still
|
|
991
|
+
hang below, but bar content can never paint over the session panel
|
|
992
|
+
again (0.5.1 user feedback — the agent-chip flood pushed the zoom
|
|
993
|
+
cluster out of the bar). */
|
|
994
|
+
overflow-x: clip;
|
|
990
995
|
min-width: 0;
|
|
991
996
|
z-index: 9998;
|
|
992
997
|
}
|
|
998
|
+
/* CHIP BarHint anchors must pass shrink through to their chip (flex min-auto
|
|
999
|
+
sizing) or an unshrinkable anchor re-creates the overflow. Chip anchors
|
|
1000
|
+
ONLY — a blanket min-width: 0 crushed the zoom buttons to slivers. */
|
|
1001
|
+
.top-bar .toolbar-tooltip-anchor:has(.agent-chip) {
|
|
1002
|
+
min-width: 0;
|
|
1003
|
+
}
|
|
993
1004
|
|
|
994
1005
|
.top-bar-title {
|
|
995
1006
|
font-size: 13px;
|
|
@@ -1104,14 +1115,46 @@ body,
|
|
|
1104
1115
|
font-size: 11px;
|
|
1105
1116
|
font-weight: 600;
|
|
1106
1117
|
white-space: nowrap;
|
|
1118
|
+
/* Rigid: the chip never shrinks and never lies about its min size (a
|
|
1119
|
+
min-width: 0 here let BarHint anchors collapse under their chips, and
|
|
1120
|
+
neighboring chips painted over each other). The .agent-chips wrapper is
|
|
1121
|
+
the ONE squeeze point — it clips whole trailing chips at its edge. */
|
|
1107
1122
|
flex-shrink: 0;
|
|
1108
|
-
min-width: 0;
|
|
1109
1123
|
max-width: 280px;
|
|
1110
1124
|
}
|
|
1125
|
+
/* Many-agent bar (0.5.1 user feedback): the visible chips sit in ONE
|
|
1126
|
+
clipping flex wrapper. Chips keep their natural readable width; when the
|
|
1127
|
+
bar is overconstrained the WRAPPER shrinks and clips at its edge, so chip
|
|
1128
|
+
content can never shove or paint over the census chip, the context meter,
|
|
1129
|
+
or the zoom cluster (16 unshrinkable chips used to push the zoom controls
|
|
1130
|
+
out of the bar and over the session panel). */
|
|
1131
|
+
.agent-chips {
|
|
1132
|
+
display: inline-flex;
|
|
1133
|
+
align-items: center;
|
|
1134
|
+
gap: 12px;
|
|
1135
|
+
min-width: 0;
|
|
1136
|
+
overflow: hidden;
|
|
1137
|
+
}
|
|
1138
|
+
/* Rigid anchors: nested-flex min-content lets a BarHint anchor collapse
|
|
1139
|
+
under its chip (ellipsizing spans zero the chip's min-content), and the
|
|
1140
|
+
chip then paints into the next anchor. Only the wrapper above may give. */
|
|
1141
|
+
.agent-chips .toolbar-tooltip-anchor {
|
|
1142
|
+
flex-shrink: 0;
|
|
1143
|
+
}
|
|
1111
1144
|
.agent-chip.phase-idle {
|
|
1112
1145
|
background: none;
|
|
1113
1146
|
border-color: var(--c-line);
|
|
1114
1147
|
}
|
|
1148
|
+
/* The census chip is the "how many" answer — it never compresses away. Its
|
|
1149
|
+
BarHint anchor must hold width too: a shrinking anchor around an
|
|
1150
|
+
unshrinkable chip lets the chip paint over the next bar item. */
|
|
1151
|
+
.agent-chip-more {
|
|
1152
|
+
flex-shrink: 0;
|
|
1153
|
+
}
|
|
1154
|
+
.top-bar .toolbar-tooltip-anchor:has(.agent-chip-more) {
|
|
1155
|
+
flex-shrink: 0;
|
|
1156
|
+
min-width: max-content;
|
|
1157
|
+
}
|
|
1115
1158
|
.agent-chip-dot {
|
|
1116
1159
|
width: 7px;
|
|
1117
1160
|
height: 7px;
|
|
@@ -1134,6 +1177,11 @@ body,
|
|
|
1134
1177
|
font-weight: 400;
|
|
1135
1178
|
color: var(--c-muted);
|
|
1136
1179
|
white-space: nowrap;
|
|
1180
|
+
/* Leaf-level truncation: a compressed chip must ellipsize the agent name,
|
|
1181
|
+
never paint it past the pill (0.5.1 user feedback, many-agent bar). */
|
|
1182
|
+
overflow: hidden;
|
|
1183
|
+
text-overflow: ellipsis;
|
|
1184
|
+
min-width: 0;
|
|
1137
1185
|
}
|
|
1138
1186
|
|
|
1139
1187
|
/* On-canvas cursor + phase chip (world layer; counter-scaled inner) */
|
|
@@ -1146,7 +1194,9 @@ body,
|
|
|
1146
1194
|
top: 0;
|
|
1147
1195
|
z-index: 9000;
|
|
1148
1196
|
pointer-events: none;
|
|
1149
|
-
transition:
|
|
1197
|
+
transition:
|
|
1198
|
+
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
1199
|
+
opacity 320ms ease;
|
|
1150
1200
|
color: var(--presence-color);
|
|
1151
1201
|
}
|
|
1152
1202
|
.agent-cursor.is-worker {
|
|
@@ -1160,6 +1210,20 @@ body,
|
|
|
1160
1210
|
font-size: 9px;
|
|
1161
1211
|
padding: 1px 6px;
|
|
1162
1212
|
}
|
|
1213
|
+
/* Idle cursors leave the board (user feedback, 0.5.1 cycle): with many agents
|
|
1214
|
+
attached, a wall of parked "Idle" pills covers real node content. The
|
|
1215
|
+
element stays MOUNTED — counts, twin-merge, and cursor-follow logic are
|
|
1216
|
+
untouched, and visibility (not display) keeps its layout box so position
|
|
1217
|
+
polls still resolve — but it paints nothing; any non-idle phase fades it
|
|
1218
|
+
back in. Placed after .is-worker so opacity: 0 wins that collision. */
|
|
1219
|
+
.agent-cursor.phase-idle {
|
|
1220
|
+
opacity: 0;
|
|
1221
|
+
visibility: hidden;
|
|
1222
|
+
transition:
|
|
1223
|
+
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
1224
|
+
opacity 320ms ease,
|
|
1225
|
+
visibility 0s linear 320ms;
|
|
1226
|
+
}
|
|
1163
1227
|
.agent-chip-workers {
|
|
1164
1228
|
margin-left: 4px;
|
|
1165
1229
|
padding: 0 5px;
|
|
@@ -1632,7 +1696,9 @@ body,
|
|
|
1632
1696
|
flex-wrap: nowrap;
|
|
1633
1697
|
gap: 1px;
|
|
1634
1698
|
margin: 2px -12px 8px;
|
|
1635
|
-
|
|
1699
|
+
/* Compact panels (Amp portal, 0.5.0 report B): six chips overflowed the
|
|
1700
|
+
row by 11px — tighter inset + chip padding buys ~16px back. */
|
|
1701
|
+
padding: 0 8px;
|
|
1636
1702
|
overflow-x: auto;
|
|
1637
1703
|
scrollbar-width: none;
|
|
1638
1704
|
}
|
|
@@ -1651,7 +1717,7 @@ body,
|
|
|
1651
1717
|
}
|
|
1652
1718
|
.session-timeline-filters .activity-filter {
|
|
1653
1719
|
flex-shrink: 0;
|
|
1654
|
-
padding: 2px
|
|
1720
|
+
padding: 2px 4px;
|
|
1655
1721
|
}
|
|
1656
1722
|
|
|
1657
1723
|
.session-timeline-row {
|
|
@@ -2828,9 +2894,20 @@ body,
|
|
|
2828
2894
|
}
|
|
2829
2895
|
|
|
2830
2896
|
/* Pan tool / held Space: the world layer goes pointer-inert so drags land on
|
|
2831
|
-
the viewport container itself and pan — nodes cannot swallow the drag.
|
|
2897
|
+
the viewport container itself and pan — nodes cannot swallow the drag.
|
|
2898
|
+
user-select too: text selection sweeps straight through pointer-inert
|
|
2899
|
+
content, and a selection made mid-pan later becomes a native text drag the
|
|
2900
|
+
drop handler mints webpage nodes from (0.5.1 Amp finding D). */
|
|
2832
2901
|
.canvas-viewport.pan-mode .canvas-world {
|
|
2833
2902
|
pointer-events: none;
|
|
2903
|
+
-webkit-user-select: none;
|
|
2904
|
+
user-select: none;
|
|
2905
|
+
}
|
|
2906
|
+
/* The resize handle and connect ports carry their own pointer-events: auto,
|
|
2907
|
+
which punches through the inert world — a pan started on them never pans. */
|
|
2908
|
+
.canvas-viewport.pan-mode .node-resize-handle,
|
|
2909
|
+
.canvas-viewport.pan-mode .node-port {
|
|
2910
|
+
pointer-events: none;
|
|
2834
2911
|
}
|
|
2835
2912
|
.canvas-viewport.pan-mode:active {
|
|
2836
2913
|
cursor: grabbing;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { basename, resolve } from 'node:path';
|
|
3
|
+
import { agentPresence } from './agent-presence.js';
|
|
3
4
|
import { recomputeCodeGraph } from './code-graph.js';
|
|
5
|
+
import { diffLayouts } from './mutation-history.js';
|
|
4
6
|
import {
|
|
5
7
|
canvasState,
|
|
6
8
|
type CanvasEdge,
|
|
@@ -1396,6 +1398,30 @@ export function listCanvasSnapshots(options?: Parameters<typeof canvasState.list
|
|
|
1396
1398
|
return canvasState.listSnapshots(options);
|
|
1397
1399
|
}
|
|
1398
1400
|
|
|
1401
|
+
/**
|
|
1402
|
+
* Snapshot save with unchanged-board reuse — the ONE path every surface must
|
|
1403
|
+
* take (op registry, SDK). The SDK once called saveCanvasSnapshot directly and
|
|
1404
|
+
* stacked identical snapshots the HTTP/MCP path deduplicated (0.5.0 Codex
|
|
1405
|
+
* finding V).
|
|
1406
|
+
*/
|
|
1407
|
+
export function saveCanvasSnapshotWithReuse(name: string): { snapshot: CanvasSnapshot | null; reused: boolean } {
|
|
1408
|
+
const newest = canvasState.listSnapshots({ limit: 1 })[0];
|
|
1409
|
+
if (newest) {
|
|
1410
|
+
const newestData = canvasState.getSnapshotData(newest.id);
|
|
1411
|
+
if (newestData) {
|
|
1412
|
+
const diff = diffLayouts(newestData.name, newestData, canvasState.getLayout());
|
|
1413
|
+
const identical =
|
|
1414
|
+
diff.addedNodes.length === 0 &&
|
|
1415
|
+
diff.removedNodes.length === 0 &&
|
|
1416
|
+
diff.modifiedNodes.length === 0 &&
|
|
1417
|
+
diff.addedEdges.length === 0 &&
|
|
1418
|
+
diff.removedEdges.length === 0;
|
|
1419
|
+
if (identical) return { snapshot: newest, reused: true };
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return { snapshot: saveCanvasSnapshot(name), reused: false };
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1399
1425
|
export function saveCanvasSnapshot(name: string): CanvasSnapshot | null {
|
|
1400
1426
|
return canvasState.saveSnapshot(name);
|
|
1401
1427
|
}
|
|
@@ -1748,7 +1774,7 @@ export function getClientViewportSize(): { width: number; height: number } | nul
|
|
|
1748
1774
|
export function fitCanvasView(options: CanvasFitViewOptions = {}): CanvasFitViewResult {
|
|
1749
1775
|
const width = positiveNumber(options.width, lastClientViewportSize?.width ?? 1440);
|
|
1750
1776
|
const height = positiveNumber(options.height, lastClientViewportSize?.height ?? 900);
|
|
1751
|
-
const padding = positiveNumber(options.padding,
|
|
1777
|
+
const padding = positiveNumber(options.padding, 24); // screen px now, not world units
|
|
1752
1778
|
const maxScale = positiveNumber(options.maxScale, 1);
|
|
1753
1779
|
const nodeIdFilter = options.nodeIds && options.nodeIds.length > 0 ? new Set(options.nodeIds) : null;
|
|
1754
1780
|
const targetNodes = canvasState.getLayout().nodes.filter((node) => !nodeIdFilter || nodeIdFilter.has(node.id));
|
|
@@ -1770,14 +1796,23 @@ export function fitCanvasView(options: CanvasFitViewOptions = {}): CanvasFitView
|
|
|
1770
1796
|
}
|
|
1771
1797
|
|
|
1772
1798
|
const bounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1799
|
+
// Screen-space fit (architecture rule 9): `padding` and the chrome reserve
|
|
1800
|
+
// are SCREEN pixels — world-space padding shrank with the fit zoom and
|
|
1801
|
+
// parked nodes under the command bar and minimap (0.5.0 Amp report,
|
|
1802
|
+
// finding A). The server cannot measure the client's chrome, so it reserves
|
|
1803
|
+
// nominal bottom space: the minimap is always there; the command bar only
|
|
1804
|
+
// while a session is attached.
|
|
1805
|
+
const bottomReserve = agentPresence.snapshot().sessionActive ? 128 : 96;
|
|
1806
|
+
const usableWidth = Math.max(80, width - padding * 2);
|
|
1807
|
+
const usableHeight = Math.max(80, height - padding - bottomReserve);
|
|
1808
|
+
const worldWidth = Math.max(1, bounds.width);
|
|
1809
|
+
const worldHeight = Math.max(1, bounds.height);
|
|
1810
|
+
const scale = Math.min(maxScale, usableWidth / worldWidth, usableHeight / worldHeight);
|
|
1776
1811
|
const centerX = minX + bounds.width / 2;
|
|
1777
1812
|
const centerY = minY + bounds.height / 2;
|
|
1778
1813
|
const viewport = {
|
|
1779
|
-
x:
|
|
1780
|
-
y:
|
|
1814
|
+
x: padding + usableWidth / 2 - centerX * scale,
|
|
1815
|
+
y: padding + usableHeight / 2 - centerY * scale,
|
|
1781
1816
|
scale,
|
|
1782
1817
|
};
|
|
1783
1818
|
|
package/src/server/index.ts
CHANGED
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
removeCanvasEdge,
|
|
60
60
|
resolveHtmlContent,
|
|
61
61
|
restoreCanvasSnapshot,
|
|
62
|
-
|
|
62
|
+
saveCanvasSnapshotWithReuse,
|
|
63
63
|
scheduleCodeGraphRecompute,
|
|
64
64
|
syncCanvasRuntimeBackends,
|
|
65
65
|
setCanvasContextPins,
|
|
@@ -1072,7 +1072,9 @@ export class PmxCanvas extends EventEmitter {
|
|
|
1072
1072
|
}
|
|
1073
1073
|
|
|
1074
1074
|
saveSnapshot(name: string) {
|
|
1075
|
-
|
|
1075
|
+
// Same reuse path as HTTP/MCP: an unchanged board returns the existing
|
|
1076
|
+
// newest snapshot instead of stacking an identical copy (Codex finding V).
|
|
1077
|
+
return saveCanvasSnapshotWithReuse(name).snapshot;
|
|
1076
1078
|
}
|
|
1077
1079
|
|
|
1078
1080
|
async restoreSnapshot(id: string): Promise<{ ok: boolean }> {
|
|
@@ -27,10 +27,18 @@ export function registerOperationTools(server: McpServer, getHost: () => Promise
|
|
|
27
27
|
if (foldedOpNames.has(op.name)) continue;
|
|
28
28
|
const tool = op.mcp;
|
|
29
29
|
if (!tool) continue;
|
|
30
|
-
server.
|
|
30
|
+
server.registerTool(
|
|
31
31
|
tool.toolName,
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
{
|
|
33
|
+
description: tool.description,
|
|
34
|
+
// A LOOSE object, not a raw shape: the SDK wraps raw shapes in a
|
|
35
|
+
// stripping z.object, which silently dropped unknown args before the
|
|
36
|
+
// op layer could reject them — a removed field (dockPosition) looked
|
|
37
|
+
// successful over MCP while the same HTTP call 400'd (0.5.1 Amp
|
|
38
|
+
// finding A). Loose parsing forwards unknown keys to the op layer,
|
|
39
|
+
// the single authority on dead-field rejection, HTTP and MCP alike.
|
|
40
|
+
inputSchema: z.looseObject({ ...op.inputShape, ...(tool.extraShape ?? {}) }),
|
|
41
|
+
},
|
|
34
42
|
async (input: Record<string, unknown>) => {
|
|
35
43
|
try {
|
|
36
44
|
const host = await getHost();
|
|
@@ -231,34 +239,40 @@ export function registerCompositeTools(
|
|
|
231
239
|
for (const [publicKey, opField] of Object.entries(def.fieldRemap ?? {})) {
|
|
232
240
|
publicFieldName.set(opField, publicKey);
|
|
233
241
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
242
|
+
// Loose for the same reason as registerOperationTools above: unknown args
|
|
243
|
+
// must reach the op layer's dead-field rejection instead of being stripped.
|
|
244
|
+
server.registerTool(
|
|
245
|
+
def.toolName,
|
|
246
|
+
{ description: def.description, inputSchema: z.looseObject(buildCompositeShape(def)) },
|
|
247
|
+
async (input: Record<string, unknown>) => {
|
|
248
|
+
try {
|
|
249
|
+
const host = await getHost();
|
|
250
|
+
const opName = resolveCompositeOp(def, input);
|
|
251
|
+
const op = getOperation(opName);
|
|
252
|
+
// Strip the composite discriminators (action + any extra, e.g. `kind`)
|
|
253
|
+
// and undo any field remap; the rest is the op's raw MCP args — the same
|
|
254
|
+
// value the standalone tool would receive.
|
|
255
|
+
const rest = stripCompositeDiscriminators(def, input);
|
|
256
|
+
const missing = requiredMcpFields(op).filter((field) => !requiredFieldSatisfied(opName, field, rest));
|
|
257
|
+
if (missing.length > 0) {
|
|
258
|
+
const named = missing.map((field) => `"${publicFieldName.get(field) ?? field}"`).join(', ');
|
|
259
|
+
throw new OperationError(
|
|
260
|
+
`${def.toolName} action "${String(input.action)}" requires ${named}. Unknown parameters are ignored — check the argument names.`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
|
|
264
|
+
const result = await host.invoker().invoke(opName, opInput);
|
|
265
|
+
if (op.mcp?.formatResult) {
|
|
266
|
+
return await op.mcp.formatResult(result, rest, host);
|
|
267
|
+
}
|
|
268
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return {
|
|
271
|
+
content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
|
|
272
|
+
isError: true,
|
|
273
|
+
};
|
|
254
274
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return {
|
|
258
|
-
content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
|
|
259
|
-
isError: true,
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
});
|
|
275
|
+
},
|
|
276
|
+
);
|
|
263
277
|
}
|
|
264
278
|
}
|
|
@@ -34,6 +34,9 @@ const presenceSetShape = {
|
|
|
34
34
|
focusNodeId: PRESENCE_SET_SHAPE.focusNodeId.describe('Node the agent is working on'),
|
|
35
35
|
cursor: PRESENCE_SET_SHAPE.cursor.describe('Agent cursor in world coordinates'),
|
|
36
36
|
attached: PRESENCE_SET_SHAPE.attached.describe('true attaches a session (Focus Session chrome); false detaches it'),
|
|
37
|
+
contextUsage: PRESENCE_SET_SHAPE.contextUsage.describe(
|
|
38
|
+
"The agent's real token window as {used,total} — shown on the board's context meter instead of the pinned-context estimate (null clears it)",
|
|
39
|
+
),
|
|
37
40
|
};
|
|
38
41
|
const presenceSetSchema = z.looseObject(presenceSetShape);
|
|
39
42
|
|
|
@@ -422,6 +422,13 @@ export function createBasicCanvasNode(
|
|
|
422
422
|
: '"path" is only an image-node compatibility alias — use `content`.',
|
|
423
423
|
);
|
|
424
424
|
}
|
|
425
|
+
// Docking was removed in 0.5.0 — a create that still sends it silently
|
|
426
|
+
// succeeded and read as "docking works" (0.5.0 Copilot report, PASS-ish).
|
|
427
|
+
if (body.dockPosition !== undefined) {
|
|
428
|
+
throw new OperationError(
|
|
429
|
+
'Unknown field "dockPosition" — docking was removed in 0.5.0; use `position` and `pinned`.',
|
|
430
|
+
);
|
|
431
|
+
}
|
|
425
432
|
const content =
|
|
426
433
|
type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string' ? body.path : body.content;
|
|
427
434
|
// For html nodes, accept top-level `html` AND `axCapabilities` and merge into data
|
|
@@ -496,6 +503,11 @@ export function buildNodePatch(
|
|
|
496
503
|
if (body.src !== undefined) {
|
|
497
504
|
throw new OperationError('Unknown field "src" — update the image source via `content`.');
|
|
498
505
|
}
|
|
506
|
+
if (body.dockPosition !== undefined) {
|
|
507
|
+
throw new OperationError(
|
|
508
|
+
'Unknown field "dockPosition" — docking was removed in 0.5.0; use `position` and `pinned`.',
|
|
509
|
+
);
|
|
510
|
+
}
|
|
499
511
|
if (body.path !== undefined && existing.type !== 'image') {
|
|
500
512
|
if (existing.type === 'file') {
|
|
501
513
|
// Repoint: `path` on a file-node patch moves the node to another file
|
|
@@ -29,9 +29,9 @@ import {
|
|
|
29
29
|
gcCanvasSnapshots,
|
|
30
30
|
listCanvasSnapshots,
|
|
31
31
|
restoreCanvasSnapshot,
|
|
32
|
-
|
|
32
|
+
saveCanvasSnapshotWithReuse,
|
|
33
33
|
} from '../../canvas-operations.js';
|
|
34
|
-
import {
|
|
34
|
+
import { formatDiff, diffLayouts } from '../../mutation-history.js';
|
|
35
35
|
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
36
36
|
import { isRecord } from './nodes.js';
|
|
37
37
|
|
|
@@ -129,24 +129,11 @@ const snapshotSaveOperation = defineOperation<z.infer<typeof snapshotSaveSchema>
|
|
|
129
129
|
const name = typeof input.name === 'string' ? input.name.trim() : '';
|
|
130
130
|
if (!name) throw new OperationError('Missing snapshot name');
|
|
131
131
|
// A save of an unchanged board reuses the newest snapshot instead of
|
|
132
|
-
// stacking an identical copy
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
}
|
|
147
|
-
const snapshot = saveCanvasSnapshot(name);
|
|
132
|
+
// stacking an identical copy — via the ONE shared path the SDK uses too
|
|
133
|
+
// (0.5.0 Codex finding V).
|
|
134
|
+
const { snapshot, reused } = saveCanvasSnapshotWithReuse(name);
|
|
148
135
|
if (!snapshot) throw new OperationError('Failed to save snapshot');
|
|
149
|
-
return { ok: true, id: snapshot.id, snapshot };
|
|
136
|
+
return { ok: true, id: snapshot.id, snapshot, ...(reused ? { reused: true } : {}) };
|
|
150
137
|
},
|
|
151
138
|
});
|
|
152
139
|
|
|
@@ -199,7 +199,7 @@ export function checkScopeOwnership(rawInput: unknown, writerKey?: string): stri
|
|
|
199
199
|
return 'the scope fence is set and cleared by the human in the session panel, not by the agent.';
|
|
200
200
|
const agentScopes = input.agentScopes;
|
|
201
201
|
if (writerKey && agentScopes && typeof agentScopes === 'object' && !Array.isArray(agentScopes)) {
|
|
202
|
-
if (Object.
|
|
202
|
+
if (Object.hasOwn(agentScopes, writerKey)) {
|
|
203
203
|
return `an agent cannot set or clear its OWN territory ("${writerKey}") — only the human or a different writer (the orchestrator) may.`;
|
|
204
204
|
}
|
|
205
205
|
}
|