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.
Files changed (36) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/Readme.md +9 -6
  3. package/dist/canvas/global.css +83 -6
  4. package/dist/canvas/index.js +177 -116
  5. package/dist/canvas/mermaid-entry.js +1303 -1303
  6. package/dist/json-render/index.js +101 -102
  7. package/dist/types/client/canvas/canvas-area.d.ts +14 -0
  8. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  9. package/dist/types/server/canvas-operations.d.ts +10 -0
  10. package/docs/RELEASE.md +14 -0
  11. package/docs/adr-001-bun-only-runtime.md +1 -1
  12. package/docs/bun-webview-integration.md +2 -2
  13. package/docs/product-context-vision-2026-09.md +207 -0
  14. package/docs/product-review-2026-09.md +174 -0
  15. package/docs/product-vision-2026-09.md +200 -0
  16. package/docs/screenshot.png +0 -0
  17. package/package.json +5 -5
  18. package/skills/pmx-canvas/SKILL.md +61 -30
  19. package/skills/pmx-canvas/references/installing-pmx-canvas.md +126 -18
  20. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +12 -1
  21. package/src/cli/commands/pump.ts +13 -2
  22. package/src/cli/index.ts +1 -0
  23. package/src/client/canvas/CanvasViewport.tsx +11 -0
  24. package/src/client/canvas/TopBar.tsx +85 -45
  25. package/src/client/canvas/canvas-area.ts +33 -0
  26. package/src/client/canvas/use-pan-zoom.ts +7 -0
  27. package/src/client/nodes/ExtAppFrame.tsx +152 -23
  28. package/src/client/state/canvas-store.ts +29 -11
  29. package/src/client/theme/global.css +83 -6
  30. package/src/server/canvas-operations.ts +41 -6
  31. package/src/server/index.ts +4 -2
  32. package/src/server/operations/mcp.ts +45 -31
  33. package/src/server/operations/ops/ax-presence.ts +3 -0
  34. package/src/server/operations/ops/nodes.ts +12 -0
  35. package/src/server/operations/ops/snapshots.ts +6 -19
  36. 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: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
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
- padding: 0 10px;
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 5px;
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, 60);
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
- const worldWidth = Math.max(1, bounds.width + padding * 2);
1774
- const worldHeight = Math.max(1, bounds.height + padding * 2);
1775
- const scale = Math.min(maxScale, width / worldWidth, height / worldHeight);
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: width / 2 - centerX * scale,
1780
- y: height / 2 - centerY * scale,
1814
+ x: padding + usableWidth / 2 - centerX * scale,
1815
+ y: padding + usableHeight / 2 - centerY * scale,
1781
1816
  scale,
1782
1817
  };
1783
1818
 
@@ -59,7 +59,7 @@ import {
59
59
  removeCanvasEdge,
60
60
  resolveHtmlContent,
61
61
  restoreCanvasSnapshot,
62
- saveCanvasSnapshot,
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
- return saveCanvasSnapshot(name);
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.tool(
30
+ server.registerTool(
31
31
  tool.toolName,
32
- tool.description,
33
- { ...op.inputShape, ...(tool.extraShape ?? {}) },
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
- server.tool(def.toolName, def.description, buildCompositeShape(def), async (input: Record<string, unknown>) => {
235
- try {
236
- const host = await getHost();
237
- const opName = resolveCompositeOp(def, input);
238
- const op = getOperation(opName);
239
- // Strip the composite discriminators (action + any extra, e.g. `kind`)
240
- // and undo any field remap; the rest is the op's raw MCP args — the same
241
- // value the standalone tool would receive.
242
- const rest = stripCompositeDiscriminators(def, input);
243
- const missing = requiredMcpFields(op).filter((field) => !requiredFieldSatisfied(opName, field, rest));
244
- if (missing.length > 0) {
245
- const named = missing.map((field) => `"${publicFieldName.get(field) ?? field}"`).join(', ');
246
- throw new OperationError(
247
- `${def.toolName} action "${String(input.action)}" requires ${named}. Unknown parameters are ignored — check the argument names.`,
248
- );
249
- }
250
- const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
251
- const result = await host.invoker().invoke(opName, opInput);
252
- if (op.mcp?.formatResult) {
253
- return await op.mcp.formatResult(result, rest, host);
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
- return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
256
- } catch (error) {
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
- saveCanvasSnapshot,
32
+ saveCanvasSnapshotWithReuse,
33
33
  } from '../../canvas-operations.js';
34
- import { diffLayouts, formatDiff } from '../../mutation-history.js';
34
+ import { formatDiff, diffLayouts } from '../../mutation-history.js';
35
35
  import { defineOperation, OperationError, type Operation } from '../types.js';
36
36
  import { isRecord } from './nodes.js';
37
37
 
@@ -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 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
- }
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.prototype.hasOwnProperty.call(agentScopes, writerKey)) {
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
  }