pmx-canvas 0.5.0 → 0.5.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.
@@ -23,3 +23,17 @@ export declare function canvasAreaCenter(): {
23
23
  x: number;
24
24
  y: number;
25
25
  };
26
+ export interface CanvasFitInsets {
27
+ top: number;
28
+ right: number;
29
+ bottom: number;
30
+ left: number;
31
+ }
32
+ /**
33
+ * Screen-space insets Fit must reserve for the floating chrome that overlays
34
+ * the canvas region: the bottom command bar (whose pinned-chip row grows it)
35
+ * and the bottom-right minimap. Measured from the live elements — fit's old
36
+ * world-space padding shrank with the fit zoom and parked nodes underneath
37
+ * both (0.5.0 Amp report, finding A; architecture rule 9's trap).
38
+ */
39
+ export declare function canvasFitInsets(): CanvasFitInsets;
@@ -3,6 +3,8 @@ import { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';
3
3
  import type { CanvasNodeState } from '../types';
4
4
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
5
5
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
6
+ /** Paint probe outcome: only a content-carrying tick counts as painted. */
7
+ export type PaintProbeVerdict = 'content-verified' | 'tick-no-content' | 'timeout';
6
8
  interface ExtAppHostDimensionsTarget {
7
9
  clientWidth?: number;
8
10
  clientHeight?: number;
@@ -178,6 +178,16 @@ export declare function setCanvasContextPins(nodeIds: string[], mode?: CanvasPin
178
178
  nodeIds: string[];
179
179
  };
180
180
  export declare function listCanvasSnapshots(options?: Parameters<typeof canvasState.listSnapshots>[0]): CanvasSnapshot[];
181
+ /**
182
+ * Snapshot save with unchanged-board reuse — the ONE path every surface must
183
+ * take (op registry, SDK). The SDK once called saveCanvasSnapshot directly and
184
+ * stacked identical snapshots the HTTP/MCP path deduplicated (0.5.0 Codex
185
+ * finding V).
186
+ */
187
+ export declare function saveCanvasSnapshotWithReuse(name: string): {
188
+ snapshot: CanvasSnapshot | null;
189
+ reused: boolean;
190
+ };
181
191
  export declare function saveCanvasSnapshot(name: string): CanvasSnapshot | null;
182
192
  export declare function restoreCanvasSnapshot(idOrName: string): Promise<{
183
193
  ok: boolean;
package/docs/RELEASE.md CHANGED
@@ -250,3 +250,17 @@ with `ok: true`, the published tarball is intact end-to-end.
250
250
  - **Node 24 deprecation timeline**: GitHub Actions is migrating
251
251
  `actions/checkout`, `setup-node`, `upload-artifact` to Node 24 by
252
252
  June 2, 2026. The publish workflow already pins `@v5` of all three.
253
+ - **npm token failures wear two disguises** (both hit the 0.5.0 publish,
254
+ after build/tests/provenance all succeeded):
255
+ - `404 Not Found - PUT …/pmx-canvas` on a package that exists = the
256
+ token was rejected (npm masks permission refusals as 404). Rotate the
257
+ `NPM_TOKEN` repo secret.
258
+ - `EOTP: This operation requires a one-time password` = the account
259
+ enforces 2FA on writes and the token doesn't have **Bypass 2FA**
260
+ ticked — CI cannot answer an OTP. A granular token needs that box
261
+ checked to publish from Actions.
262
+ - Both are fixed without re-tagging: update the secret, then
263
+ `gh run rerun <publish-run-id> --failed` re-publishes the same tag.
264
+ - npm restricts bypass-2FA tokens for direct publishing by **Jan 2027**
265
+ — migrate to Trusted Publishing (OIDC; `publish.yml` already has
266
+ `id-token: write`) and delete the token before then.
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.5.0",
4
- "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
3
+ "version": "0.5.1",
4
+ "description": "Spatial canvas workbench for coding agents \u2014 infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
7
7
  "module": "./src/server/index.ts",
@@ -43,9 +43,9 @@
43
43
  "typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json",
44
44
  "lint": "biome check .",
45
45
  "lint:fix": "biome check --write .",
46
- "test": "if [ -d tests/unit ]; then PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit; else echo 'tests/ is not shipped in the npm package — clone the repo to run the suite'; fi",
46
+ "test": "if [ -d tests/unit ]; then PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit; else echo 'tests/ is not shipped in the npm package \u2014 clone the repo to run the suite'; fi",
47
47
  "test:unit": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit",
48
- "test:client": "if [ -d tests/client ]; then bun test --preload ./tests/client/setup.ts tests/client; else echo 'tests/ is not shipped in the npm package — clone the repo to run the suite'; fi",
48
+ "test:client": "if [ -d tests/client ]; then bun test --preload ./tests/client/setup.ts tests/client; else echo 'tests/ is not shipped in the npm package \u2014 clone the repo to run the suite'; fi",
49
49
  "test:coverage": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir coverage && bun scripts/check-coverage.ts",
50
50
  "test:web-canvas": "PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun run build && PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bash scripts/run-playwright.sh",
51
51
  "test:e2e": "bun run test:web-canvas",
@@ -147,7 +147,7 @@ this table is kept in sync with the server's composite registry; `tools/list` on
147
147
  | `canvas_query` | `search`, `layout`, `validate` |
148
148
  | `canvas_webview` | `status`, `start`, `stop`, `resize`, `evaluate` |
149
149
  | `canvas_app` | `open-mcp-app`, `diagram`, `build-artifact` |
150
- | `canvas_ax_state` | `get`, `set-focus`, `set-policy`, `report-capability` |
150
+ | `canvas_ax_state` | `get`, `set-focus`, `set-policy`, `report-capability`, `presence`, `set-presence` |
151
151
  | `canvas_ax_work` | `add`, `update`, `annotate` |
152
152
  | `canvas_ax_gate` | `request`, `resolve`, `await` with `approval`, `elicitation`, or `mode` |
153
153
  | `canvas_ax_timeline` | `read`, `record-event`, `add-evidence`, `send-steering` |
@@ -262,6 +262,14 @@ The board has three modes, all gated on one fact — whether a session is attach
262
262
  extension) attach for you; the human can also start a session from the board's *Start agent
263
263
  session* button — your writes (transport or `PMX_CANVAS_AGENT_SOURCE` label alike) are
264
264
  attributed to it and it takes your name.
265
+ - **No adapter? Use the pump.** Any CLI agent becomes steer-reactive with one command in a
266
+ terminal: `pmx-canvas pump --consumer <your-key> --exec '<command>'`. It long-polls your
267
+ delivery queue, runs the command once per steer (message on stdin plus the
268
+ `PMX_STEER_MESSAGE`/`ID`/`SOURCE`/`TARGET`/`CREATED_AT` env envelope), and marks
269
+ per-consumer only after the command exits 0 — a failed hand-off stays pending and the pump
270
+ exits non-zero. `{message}` in the template expands to a quoted env reference (never spliced;
271
+ refused on Windows — read stdin there). `--parent <key>` rolls you up under an orchestrator's
272
+ chip; `--once` for scripts. See `pmx-canvas pump --help`.
265
273
 
266
274
  What the session asks of you:
267
275
 
@@ -16,7 +16,15 @@
16
16
  * takes the message from stdin instead. See `renderExecTemplate`.
17
17
  */
18
18
  import { spawn } from 'node:child_process';
19
- import { cmd, getBaseUrl, getStringFlag, invokeOperation, parseFlags, requireFlag } from '../shared.js';
19
+ import {
20
+ cmd,
21
+ getBaseUrl,
22
+ getStringFlag,
23
+ invokeOperation,
24
+ parseFlags,
25
+ requireFlag,
26
+ showCommandHelp,
27
+ } from '../shared.js';
20
28
 
21
29
  interface PendingSteer {
22
30
  id: string;
@@ -137,7 +145,10 @@ cmd(
137
145
  `pmx-canvas pump --consumer testbot --exec 'cat' --once`,
138
146
  ],
139
147
  async (args) => {
140
- const { flags } = parseFlags(args, { boolFlags: ['once'] });
148
+ const { flags } = parseFlags(args, { boolFlags: ['once', 'help', 'h'] });
149
+ // Help before required-flag validation — `pump --help` died on
150
+ // "Missing required flag: --consumer" (0.5.0 Codex finding W).
151
+ if (flags.help || flags.h) return showCommandHelp('pump');
141
152
  const consumer = requireFlag(
142
153
  flags,
143
154
  'consumer',
package/src/cli/index.ts CHANGED
@@ -314,6 +314,7 @@ Agent CLI (works against running server):
314
314
  code-graph File dependencies
315
315
  spatial Spatial analysis
316
316
  watch Semantic watch stream
317
+ pump Reactive steering loop for CLI agents (Amp, Codex CLI)
317
318
  smoke Check that a running canvas works here
318
319
  skills sync Sync installed copies of the bundled skills
319
320
  copilot install-extension Install the GitHub Copilot app extension
@@ -39,3 +39,36 @@ export function canvasAreaCenter(): { x: number; y: number } {
39
39
  const rect = canvasArea();
40
40
  return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
41
41
  }
42
+
43
+ export interface CanvasFitInsets {
44
+ top: number;
45
+ right: number;
46
+ bottom: number;
47
+ left: number;
48
+ }
49
+
50
+ /**
51
+ * Screen-space insets Fit must reserve for the floating chrome that overlays
52
+ * the canvas region: the bottom command bar (whose pinned-chip row grows it)
53
+ * and the bottom-right minimap. Measured from the live elements — fit's old
54
+ * world-space padding shrank with the fit zoom and parked nodes underneath
55
+ * both (0.5.0 Amp report, finding A; architecture rule 9's trap).
56
+ */
57
+ export function canvasFitInsets(): CanvasFitInsets {
58
+ let bottom = 16;
59
+ try {
60
+ const area = canvasArea();
61
+ const areaBottom = area.top + area.height;
62
+ for (const selector of ['.command-bar', '.minimap']) {
63
+ const el = document.querySelector(selector);
64
+ if (!(el instanceof HTMLElement) || !el.isConnected) continue;
65
+ const rect = el.getBoundingClientRect();
66
+ if (rect.height <= 0) continue;
67
+ const overlap = areaBottom - rect.top;
68
+ if (overlap > 0) bottom = Math.max(bottom, overlap + 12);
69
+ }
70
+ } catch {
71
+ // Chromeless embeds and unit DOMs: the defaults below are the contract.
72
+ }
73
+ return { top: 16, right: 16, bottom, left: 16 };
74
+ }
@@ -30,6 +30,8 @@ type McpUiTheme = 'light' | 'dark';
30
30
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
31
31
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
32
32
  type ExtAppFrameStatus = 'loading' | 'ready' | 'done';
33
+ /** Paint probe outcome: only a content-carrying tick counts as painted. */
34
+ export type PaintProbeVerdict = 'content-verified' | 'tick-no-content' | 'timeout';
33
35
 
34
36
  interface ExtAppHostDimensionsTarget {
35
37
  clientWidth?: number;
@@ -285,10 +287,28 @@ export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string):
285
287
  // rendering pipeline is actually running — a frame whose layer WebKit never
286
288
  // composites stays silent, and that silence drives the parent's recovery
287
289
  // ladder. One unsolicited tick at boot, then on-demand answers to probes.
290
+ // Content oracle (Finding N, 0.5.0 reopen): a double rAF fires even while
291
+ // WKWebKit composites nothing — the tick alone was a false green. The tick
292
+ // now carries a verdict: does this document actually CONTAIN rendered
293
+ // content (a laid-out canvas/svg, or real text)? The parent only accepts
294
+ // paint-ok when it does.
295
+ function contentReady() {
296
+ try {
297
+ var els = document.querySelectorAll('canvas, svg');
298
+ for (var i = 0; i < els.length; i++) {
299
+ var r = els[i].getBoundingClientRect();
300
+ if (r.width > 8 && r.height > 8) return true;
301
+ }
302
+ var body = document.body;
303
+ return !!(body && body.innerText && body.innerText.trim().length > 0);
304
+ } catch (e) {
305
+ return false;
306
+ }
307
+ }
288
308
  function sendPaintTick() {
289
309
  requestAnimationFrame(function () {
290
310
  requestAnimationFrame(function () {
291
- window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick' }, '*');
311
+ window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick', content: contentReady() }, '*');
292
312
  });
293
313
  });
294
314
  }
@@ -367,8 +387,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
367
387
  const bridgeReadyRef = useRef(false);
368
388
  const themeUnsubRef = useRef<(() => void) | null>(null);
369
389
  const webkitRemountAttemptsRef = useRef(0);
370
- // Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick.
371
- const paintTickWaitersRef = useRef<Array<() => void>>([]);
390
+ // Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick,
391
+ // each handed the tick's content verdict.
392
+ const paintTickWaitersRef = useRef<Array<(content: boolean) => void>>([]);
372
393
  // Genuine boot signal: set ONLY when the app completes the ui/initialize
373
394
  // handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
374
395
  // which flips status via notifications that resolve even into a dead iframe.
@@ -450,7 +471,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
450
471
  return;
451
472
  appScriptsRanRef.current = true;
452
473
  if (data.kind === 'paint-tick') {
453
- for (const waiter of paintTickWaitersRef.current.splice(0)) waiter();
474
+ const content = (data as { content?: unknown }).content === true;
475
+ for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(content);
454
476
  }
455
477
  }
456
478
  window.addEventListener('message', onBootBeacon);
@@ -503,7 +525,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
503
525
  enqueueWebkitRemount({
504
526
  remount: () => {
505
527
  remountQueuedRef.current = false;
506
- if (unmountedRef.current || expandedNodeId.value === nodeId) {
528
+ if (unmountedRef.current || (!expanded && expandedNodeId.value === nodeId)) {
507
529
  extAppRecoveryLog(nodeId, 'remount-skipped');
508
530
  // The ladder left paintPending set on the promise that this remount
509
531
  // would re-enter it via the new frame's settle path. A skip means
@@ -555,28 +577,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
555
577
  // again → serialized remount (bounded by the shared attempt cap) → explicit
556
578
  // Retry affordance. Never silent eternal black. WebKit inline frames only.
557
579
  const PAINT_TICK_TIMEOUT_MS = 1500;
558
- const probePaint = (): Promise<boolean> =>
580
+ const probePaint = (): Promise<PaintProbeVerdict> =>
559
581
  new Promise((resolve) => {
560
582
  const contentWindow = iframeRef.current?.contentWindow;
561
583
  if (!contentWindow || unmountedRef.current) {
562
- resolve(false);
584
+ resolve('timeout');
563
585
  return;
564
586
  }
565
587
  let settled = false;
566
588
  const timer = window.setTimeout(() => {
567
589
  if (settled) return;
568
590
  settled = true;
569
- resolve(false);
591
+ resolve('timeout');
570
592
  }, PAINT_TICK_TIMEOUT_MS);
571
- paintTickWaitersRef.current.push(() => {
593
+ paintTickWaitersRef.current.push((content) => {
572
594
  if (settled) return;
573
595
  settled = true;
574
596
  window.clearTimeout(timer);
575
- resolve(true);
597
+ // A tick without content is NOT paint-ok: the rAF pipeline runs while
598
+ // the document is empty cream / never composited (Finding N reopen).
599
+ resolve(content ? 'content-verified' : 'tick-no-content');
576
600
  });
577
601
  contentWindow.postMessage({ source: EXT_APP_PAINT_PROBE_SOURCE, token: axToken }, '*');
578
602
  });
579
603
 
604
+ // Bounded wait for the frame's content oracle to turn green — the mount
605
+ // queue's release gate (guidance: never green-light the NEXT app while the
606
+ // previous document is still contentless; D-then-E was the repro).
607
+ const awaitContentVerified = async (maxMs: number): Promise<boolean> => {
608
+ const deadline = Date.now() + maxMs;
609
+ while (Date.now() < deadline && !unmountedRef.current) {
610
+ if ((await probePaint()) === 'content-verified') return true;
611
+ await new Promise((resolve) => window.setTimeout(resolve, 400));
612
+ }
613
+ return false;
614
+ };
615
+
580
616
  // Force a real layer reallocation: grow the iframe's box meaningfully for two
581
617
  // frames, then restore. Invisible under the paint-pending overlay.
582
618
  const runSoftExpandCycle = (): Promise<void> =>
@@ -638,29 +674,44 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
638
674
  // 0.4.7). Say so in the trail instead of recording an unqualified success.
639
675
  const paintOkLabel = (suffix = ''): string =>
640
676
  typeof document !== 'undefined' && document.visibilityState === 'hidden'
641
- ? `paint-ok${suffix} (unverified: host hidden)`
642
- : `paint-ok${suffix}`;
677
+ ? `paint-ok (content-verified)${suffix} (host hidden)`
678
+ : `paint-ok (content-verified)${suffix}`;
643
679
 
644
680
  const runPaintLadder = async (): Promise<void> => {
645
- if (expanded) return;
681
+ // Runs for the EXPANDED instance too (Finding N reopen: expanded C was a
682
+ // black frame with no recovery — the ladder used to early-return here).
646
683
  if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
647
684
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
648
685
  setPaintPending(true);
649
- if (await probePaint()) {
686
+ let verdict = await probePaint();
687
+ if (verdict === 'content-verified') {
650
688
  extAppRecoveryLog(nodeId, paintOkLabel());
651
689
  setPaintPending(false);
652
690
  maybeAssumeVisibleRearm();
653
691
  return;
654
692
  }
655
- extAppRecoveryLog(nodeId, 'paint-fail');
693
+ extAppRecoveryLog(
694
+ nodeId,
695
+ verdict === 'tick-no-content' ? 'paint-fail (tick without content)' : 'paint-fail (no tick)',
696
+ );
656
697
  await runSoftExpandCycle();
657
- if (await probePaint()) {
698
+ verdict = await probePaint();
699
+ if (verdict === 'content-verified') {
658
700
  extAppRecoveryLog(nodeId, paintOkLabel(' (after soft-expand)'));
659
701
  setPaintPending(false);
660
702
  maybeAssumeVisibleRearm();
661
703
  return;
662
704
  }
663
705
  if (unmountedRef.current) return;
706
+ // A contentless-but-alive app may still be DRAWING its scene: give the
707
+ // oracle a bounded settle before burning a remount attempt on it.
708
+ if (verdict === 'tick-no-content' && (await awaitContentVerified(3_000))) {
709
+ extAppRecoveryLog(nodeId, paintOkLabel(' (after content wait)'));
710
+ setPaintPending(false);
711
+ maybeAssumeVisibleRearm();
712
+ return;
713
+ }
714
+ if (unmountedRef.current) return;
664
715
  // The remounted frame re-enters this ladder from its own settle path.
665
716
  if (!scheduleWebkitRemount('paint-fail')) {
666
717
  extAppRecoveryLog(nodeId, 'recovery-exhausted');
@@ -709,7 +760,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
709
760
  // serialized queue (bounded by the shared attempt cap).
710
761
  const WEBKIT_BOOT_WATCHDOG_MS = 6000;
711
762
  useEffect(() => {
712
- if (expanded) return;
763
+ // Covers the expanded instance too — its fresh iframe was black with no
764
+ // recovery at all (Finding N reopen, expanded C).
713
765
  if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
714
766
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
715
767
  if (!iframeDocument.ready || !mountGranted) return;
@@ -725,7 +777,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
725
777
  () => () => {
726
778
  unmountedRef.current = true;
727
779
  for (const waiter of bootWaitersRef.current.splice(0)) waiter();
728
- for (const waiter of paintTickWaitersRef.current.splice(0)) waiter();
780
+ for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(false);
729
781
  },
730
782
  [],
731
783
  );
@@ -1035,8 +1087,22 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1035
1087
  // its own settle pause covering the draw) only from this genuine path;
1036
1088
  // the bootstrap fallback never releases it (a dead iframe must run the
1037
1089
  // queue's bounded timeout instead of green-lighting the next remount).
1038
- extAppRecoveryLog(nodeId, 'settled');
1039
- for (const waiter of bootWaitersRef.current.splice(0)) waiter();
1090
+ // Release the mount queue only once this document's CONTENT
1091
+ // oracle is green (bounded): a settled-but-empty app must not
1092
+ // green-light the next mount slot (D-then-E repro, Finding N
1093
+ // reopen). WebKit only — elsewhere the oracle race doesn't exist.
1094
+ const releaseQueue = () => {
1095
+ for (const waiter of bootWaitersRef.current.splice(0)) waiter();
1096
+ };
1097
+ if (typeof navigator !== 'undefined' && isWebKitOnlyHost(navigator.userAgent)) {
1098
+ void awaitContentVerified(4_000).then((verified) => {
1099
+ extAppRecoveryLog(nodeId, verified ? 'settled (content-verified)' : 'settled (content-timeout)');
1100
+ releaseQueue();
1101
+ });
1102
+ } else {
1103
+ extAppRecoveryLog(nodeId, 'settled');
1104
+ releaseQueue();
1105
+ }
1040
1106
  nudgeHostContextAfterLayout();
1041
1107
  schedulePaintLadder();
1042
1108
  })
@@ -9,7 +9,7 @@ import {
9
9
  type ViewportState,
10
10
  } from '../types';
11
11
  import { computeAutoArrange } from '../../shared/auto-arrange';
12
- import { canvasAreaCenter } from '../canvas/canvas-area';
12
+ import { canvasAreaCenter, canvasFitInsets } from '../canvas/canvas-area';
13
13
  import { pushCanvasUpdate, requestBestEffort, requestOk, updateViewportFromClient } from './intent-bridge';
14
14
 
15
15
  function logCanvasStoreError(action: string, error: unknown): void {
@@ -808,16 +808,22 @@ export function fitAll(containerW: number, containerH: number): void {
808
808
  maxY = Math.max(maxY, n.position.y + n.size.height);
809
809
  }
810
810
 
811
- const PAD = 60;
812
- const worldW = maxX - minX + PAD * 2;
813
- const worldH = maxY - minY + PAD * 2;
814
- const scale = Math.min(1, Math.min(containerW / worldW, containerH / worldH));
811
+ // Screen-space fit (architecture rule 9): the margins that must clear the
812
+ // floating chrome are SCREEN constants. The old world-space PAD shrank with
813
+ // the fit zoom, parking fitted nodes under the command bar and minimap
814
+ // (0.5.0 Amp report, finding A).
815
+ const insets = canvasFitInsets();
816
+ const usableW = Math.max(80, containerW - insets.left - insets.right);
817
+ const usableH = Math.max(80, containerH - insets.top - insets.bottom);
818
+ const worldW = Math.max(1, maxX - minX);
819
+ const worldH = Math.max(1, maxY - minY);
820
+ const scale = Math.min(1, Math.min(usableW / worldW, usableH / worldH));
815
821
  const cx = (minX + maxX) / 2;
816
822
  const cy = (minY + maxY) / 2;
817
823
 
818
824
  animateViewport({
819
- x: containerW / 2 - cx * scale,
820
- y: containerH / 2 - cy * scale,
825
+ x: insets.left + usableW / 2 - cx * scale,
826
+ y: insets.top + usableH / 2 - cy * scale,
821
827
  scale,
822
828
  });
823
829
  }
@@ -1632,7 +1632,9 @@ body,
1632
1632
  flex-wrap: nowrap;
1633
1633
  gap: 1px;
1634
1634
  margin: 2px -12px 8px;
1635
- padding: 0 10px;
1635
+ /* Compact panels (Amp portal, 0.5.0 report B): six chips overflowed the
1636
+ row by 11px — tighter inset + chip padding buys ~16px back. */
1637
+ padding: 0 8px;
1636
1638
  overflow-x: auto;
1637
1639
  scrollbar-width: none;
1638
1640
  }
@@ -1651,7 +1653,7 @@ body,
1651
1653
  }
1652
1654
  .session-timeline-filters .activity-filter {
1653
1655
  flex-shrink: 0;
1654
- padding: 2px 5px;
1656
+ padding: 2px 4px;
1655
1657
  }
1656
1658
 
1657
1659
  .session-timeline-row {
@@ -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 }> {
@@ -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