pmx-canvas 0.4.2 → 0.4.4

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 (37) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +5 -2
  2. package/CHANGELOG.md +60 -0
  3. package/Readme.md +35 -12
  4. package/dist/canvas/global.css +92 -28
  5. package/dist/canvas/index.js +62 -62
  6. package/dist/canvas/surface-theme.css +52 -20
  7. package/dist/types/cli/agent.d.ts +1 -0
  8. package/dist/types/cli/commands/skills.d.ts +41 -0
  9. package/dist/types/client/state/sse-bridge.d.ts +1 -0
  10. package/dist/types/client/state/theme-override.d.ts +27 -0
  11. package/dist/types/shared/themes.d.ts +1 -1
  12. package/docs/RELEASE.md +14 -6
  13. package/docs/bun-webview-integration.md +2 -2
  14. package/docs/cli.md +24 -1
  15. package/docs/environment.md +2 -2
  16. package/docs/http-api.md +14 -3
  17. package/docs/plans/plan-009-tech-debt-backlog.md +5 -2
  18. package/docs/screenshot.png +0 -0
  19. package/package.json +3 -3
  20. package/skills/pmx-canvas/SKILL.md +18 -4
  21. package/skills/pmx-canvas/references/full-reference.md +1 -1
  22. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  23. package/src/cli/agent.ts +1 -0
  24. package/src/cli/commands/skills.ts +183 -0
  25. package/src/cli/index.ts +3 -1
  26. package/src/cli/shared.ts +1 -0
  27. package/src/client/App.tsx +85 -78
  28. package/src/client/canvas/CommandPalette.tsx +4 -0
  29. package/src/client/nodes/ExtAppFrame.tsx +15 -4
  30. package/src/client/nodes/McpAppNode.tsx +6 -1
  31. package/src/client/state/sse-bridge.ts +22 -5
  32. package/src/client/state/theme-override.ts +83 -0
  33. package/src/client/theme/global.css +92 -28
  34. package/src/client/theme/surface-theme.css +52 -20
  35. package/src/server/operations/ops/canvas-wire.ts +10 -2
  36. package/src/server/server.ts +13 -2
  37. package/src/shared/themes.ts +3 -1
@@ -218,32 +218,32 @@
218
218
  }
219
219
 
220
220
  :root[data-theme="ember"] {
221
- /* ── Core palette (ember · charcoal + vivid warm orange) ─── */
222
- --c-bg: #0E0E11;
223
- --c-panel: #17181D;
224
- --c-panel-soft: #121317;
225
- --c-line: #2A2C34;
226
- --c-text: #ECEDF0;
227
- --c-text-soft: #C9CCD4;
228
- --c-muted: #8E939F;
229
- --c-dim: #5F646E;
230
- --c-accent: #FF5C2E;
221
+ /* ── Core palette (ember · neutral charcoal + vivid orange-red) ─── */
222
+ --c-bg: #0B0B0C;
223
+ --c-panel: #161618;
224
+ --c-panel-soft: #101012;
225
+ --c-line: #29292C;
226
+ --c-text: #EDEBE6;
227
+ --c-text-soft: #CBC9C2;
228
+ --c-muted: #96948D;
229
+ --c-dim: #66655F;
230
+ --c-accent: #F34E1C;
231
231
  --c-ok: #4CC38A;
232
232
  --c-warn: #F5B944;
233
233
  --c-warn-alt: #FFB300;
234
234
  --c-danger: #F0566A;
235
235
  --c-purple: #A78BFA;
236
236
 
237
- --color-bg: #0E0E11;
238
- --color-panel: #17181D;
239
- --color-surface: #121317;
240
- --color-border: #2A2C34;
241
- --color-text: #ECEDF0;
242
- --color-text-primary: #ECEDF0;
243
- --color-text-secondary: #C9CCD4;
244
- --color-text-muted: #8E939F;
245
- --color-text-dim: #5F646E;
246
- --color-accent: #FF5C2E;
237
+ --color-bg: #0B0B0C;
238
+ --color-panel: #161618;
239
+ --color-surface: #101012;
240
+ --color-border: #29292C;
241
+ --color-text: #EDEBE6;
242
+ --color-text-primary: #EDEBE6;
243
+ --color-text-secondary: #CBC9C2;
244
+ --color-text-muted: #96948D;
245
+ --color-text-dim: #66655F;
246
+ --color-accent: #F34E1C;
247
247
  --color-success: #4CC38A;
248
248
  --color-warning: #F5B944;
249
249
  --color-danger: #F0566A;
@@ -281,6 +281,38 @@
281
281
  --color-danger: #E57373;
282
282
  }
283
283
 
284
+ :root[data-theme="volt"] {
285
+ /* ── Core palette (volt · green-tinted charcoal + coral) ── */
286
+ --c-bg: #0B0D0B;
287
+ --c-panel: #171A17;
288
+ --c-panel-soft: #111311;
289
+ --c-line: #272A27;
290
+ --c-text: #F6FFF5;
291
+ --c-text-soft: #CBD3CA;
292
+ --c-muted: #9CA49C;
293
+ --c-dim: #626962;
294
+ --c-accent: #F34E3F;
295
+ --c-ok: #52C880;
296
+ --c-warn: #E4C465;
297
+ --c-warn-alt: #FFB300;
298
+ --c-danger: #D67670;
299
+ --c-purple: #BC8FB4;
300
+
301
+ --color-bg: #0B0D0B;
302
+ --color-panel: #171A17;
303
+ --color-surface: #111311;
304
+ --color-border: #272A27;
305
+ --color-text: #F6FFF5;
306
+ --color-text-primary: #F6FFF5;
307
+ --color-text-secondary: #CBD3CA;
308
+ --color-text-muted: #9CA49C;
309
+ --color-text-dim: #626962;
310
+ --color-accent: #F34E3F;
311
+ --color-success: #52C880;
312
+ --color-warning: #E4C465;
313
+ --color-danger: #D67670;
314
+ }
315
+
284
316
  html,
285
317
  body {
286
318
  margin: 0;
@@ -22,5 +22,6 @@ import './commands/ax.js';
22
22
  import './commands/webview.js';
23
23
  import './commands/apps.js';
24
24
  import './commands/copilot.js';
25
+ import './commands/skills.js';
25
26
  export { extractGlobalTargetFlags };
26
27
  export declare function runAgentCli(rawArgs: string[]): Promise<void>;
@@ -0,0 +1,41 @@
1
+ export declare const BUNDLED_SKILLS: readonly ["pmx-canvas", "pmx-canvas-testing"];
2
+ export interface SkillTreeComparison {
3
+ inSync: boolean;
4
+ /** Files present in the package tree but missing or different in the mirror. */
5
+ missingOrDifferent: string[];
6
+ /** Files present in the mirror but not in the package tree. */
7
+ extra: string[];
8
+ }
9
+ export declare function compareSkillTrees(sourceRoot: string, targetRoot: string): SkillTreeComparison;
10
+ export interface InstalledSkillMirror {
11
+ /** The directory that owns the skills/ folder (e.g. ".codex", ".github", or any agent dir). */
12
+ host: string;
13
+ skill: string;
14
+ dir: string;
15
+ }
16
+ /**
17
+ * Find the bundled skills' installed copies in this workspace: any immediate
18
+ * child directory holding `skills/<skill>/SKILL.md`, regardless of which agent
19
+ * that directory belongs to. The package's own skills source is excluded so
20
+ * running inside the pmx-canvas checkout never compares a tree to itself.
21
+ */
22
+ export declare function discoverInstalledSkillMirrors(workspaceRoot: string, packageSkillsRoot: string): InstalledSkillMirror[];
23
+ export interface SkillMirrorResult {
24
+ host: string;
25
+ skill: string;
26
+ dir: string;
27
+ status: 'in-sync' | 'synced' | 'drifted' | 'skipped-not-a-package-copy';
28
+ missingOrDifferent?: string[];
29
+ extra?: string[];
30
+ }
31
+ /**
32
+ * A mirror is only replaceable when its SKILL.md frontmatter `name:` matches
33
+ * the bundled skill it sits under. A user's own skill living in a same-named
34
+ * directory (different frontmatter identity) must never be deleted — these
35
+ * trees are typically gitignored, so a wrong replace is unrecoverable.
36
+ */
37
+ export declare function mirrorIdentityMatches(mirrorDir: string, skill: string): boolean;
38
+ /** Sync (or, with check=true, only compare) every INSTALLED mirror against the package trees. */
39
+ export declare function syncInstalledSkillMirrors(packageSkillsRoot: string, workspaceRoot: string, opts: {
40
+ check: boolean;
41
+ }): SkillMirrorResult[];
@@ -1,3 +1,4 @@
1
+ export declare function forcedTransport(): 'poll' | 'sse' | null;
1
2
  /** @internal — exported for testing */
2
3
  export declare function hashPath(path: string): string;
3
4
  /** @internal — exported for testing */
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Per-session theme override — host-default theming.
3
+ *
4
+ * Hosts that embed the workbench (the GitHub Copilot panel, the Claude Code
5
+ * desktop browser pane, ChatGPT-style app browsers, …) can open it with
6
+ * `?theme=<name>` or `?theme=auto` to give THEIR panel a fitting default
7
+ * without touching the server-global theme every other client sees. The
8
+ * override is client-local: server theme frames are ignored while it is
9
+ * active, and it ends the moment the user explicitly picks a theme from the
10
+ * picker (which then applies + saves globally as usual).
11
+ *
12
+ * `theme=auto` follows the host's prefers-color-scheme live, mapping to the
13
+ * built-in light/dark themes — so a light-mode host panel gets a light canvas
14
+ * by default and flips with the host appearance.
15
+ */
16
+ import { type CanvasThemeName } from '../../shared/themes.js';
17
+ export declare function themeOverrideActive(): boolean;
18
+ /** Test-only: reset the module state, including the page-lifetime latch. */
19
+ export declare function resetThemeOverrideForTests(): void;
20
+ /** The user picked a theme explicitly — the session override ends for good. */
21
+ export declare function clearThemeOverride(): void;
22
+ export declare function sessionThemeParam(): CanvasThemeName | 'auto' | null;
23
+ /**
24
+ * Activate the override from the URL (no-op without a valid `?theme=`).
25
+ * `apply` is the canvas theme applier (client-side only — never saved).
26
+ */
27
+ export declare function initSessionThemeOverride(apply: (theme: CanvasThemeName) => void): void;
@@ -9,7 +9,7 @@
9
9
  * theme picker, server validation, and surface documents pick it up from this
10
10
  * list.
11
11
  */
12
- export declare const CANVAS_THEMES: readonly ["dark", "light", "high-contrast", "midnight", "sepia", "arctic", "ember", "forest"];
12
+ export declare const CANVAS_THEMES: readonly ["dark", "light", "high-contrast", "midnight", "sepia", "arctic", "ember", "forest", "volt"];
13
13
  export type CanvasThemeName = (typeof CANVAS_THEMES)[number];
14
14
  export declare function isCanvasTheme(value: unknown): value is CanvasThemeName;
15
15
  export declare function normalizeCanvasThemeName(value: unknown, fallback?: CanvasThemeName): CanvasThemeName;
package/docs/RELEASE.md CHANGED
@@ -134,12 +134,20 @@ publishing, update the installed package in the consumer workspace and re-sync
134
134
  its four mirrors from `<global npm root>/pmx-canvas/skills/` so the first test
135
135
  pass runs against the released skills, not the previous version's guidance.
136
136
 
137
- Sync the **complete tree of every bundled skill** — `pmx-canvas` AND
138
- `pmx-canvas-testing`, including `references/`, `evals/`, and fixtures — and
139
- verify by diffing whole directories, not by comparing the `SKILL.md` hash
140
- alone. The 0.3.4 and 0.4.0 cycles both hit drift where `SKILL.md` matched but
141
- references/evals were stale, or a newly bundled skill was missing from the
142
- mirrors entirely.
137
+ This is now one command in the consumer workspace (added post-0.4.3):
138
+
139
+ ```bash
140
+ pmx-canvas skills sync --yes # refreshes every INSTALLED skill copy from the package
141
+ pmx-canvas skills sync --check # drift detection only (exit 1 when stale)
142
+ ```
143
+
144
+ It discovers the skill copies already installed in the workspace (whatever
145
+ agent layout owns them — no host list is assumed) and compares/replaces
146
+ **whole trees** — `references/`, `evals/`, fixtures — not `SKILL.md` hashes
147
+ alone (the 0.3.x–0.4.x cycles repeatedly hit drift where the hash matched but
148
+ supporting files were stale). Also re-run
149
+ `pmx-canvas copilot install-extension --yes` in repos that use the Copilot
150
+ adapter so the panel picks up new open-URL behavior.
143
151
 
144
152
  ## GitHub release
145
153
 
@@ -12,7 +12,7 @@ This document tracks:
12
12
 
13
13
  **Implemented** for Phase 1.
14
14
 
15
- The repo now targets Bun `>=1.3.12`, and PMX Canvas ships an opt-in, server-owned WebView automation session that can:
15
+ The repo now targets Bun `>=1.3.14`, and PMX Canvas ships an opt-in, server-owned WebView automation session that can:
16
16
 
17
17
  - start a headless browser session for `/workbench`
18
18
  - report WebView runtime status
@@ -192,7 +192,7 @@ If `chromePath` or `chromeArgv` is provided, PMX Canvas forces a Chrome-backed s
192
192
 
193
193
  The repo now requires:
194
194
 
195
- - Bun `>=1.3.12`
195
+ - Bun `>=1.3.14`
196
196
 
197
197
  That change is reflected in:
198
198
 
package/docs/cli.md CHANGED
@@ -20,7 +20,7 @@ pmx-canvas # Start canvas, open browser
20
20
  pmx-canvas --demo # Start with the saved dashboard demo board
21
21
  pmx-canvas --port=8080 # Custom port
22
22
  pmx-canvas --no-open # Headless (for agents/CI)
23
- pmx-canvas --theme=light # dark | light | high-contrast | midnight | sepia | arctic | ember | forest
23
+ pmx-canvas --theme=light # dark | light | high-contrast | midnight | sepia | arctic | ember | forest | volt
24
24
  pmx-canvas --mcp # Run as MCP server (stdio)
25
25
  pmx-canvas --webview-automation # Start headless Bun.WebView session
26
26
  pmx-canvas open # Open the current workbench in a browser
@@ -211,6 +211,29 @@ pmx-canvas copilot install-extension --dry-run # Preview target, writes nothin
211
211
  pmx-canvas copilot install-extension --yes # Install/overwrite into .github/extensions/pmx-canvas/
212
212
  ```
213
213
 
214
+ ## Skill sync
215
+
216
+ Refresh the pmx-canvas skill copies **already installed** in this workspace.
217
+ The command discovers them (any `<agent-dir>/skills/pmx-canvas[-testing]`,
218
+ whichever agent layout the workspace uses) and compares/replaces whole trees —
219
+ references, evals, and fixtures included — so a copy can no longer drift while
220
+ its `SKILL.md` hash matches. It never creates a copy where none is installed
221
+ and makes no assumptions about which agents you use.
222
+
223
+ ```bash
224
+ pmx-canvas skills sync --yes # Refresh every installed copy from the package
225
+ pmx-canvas skills sync --check # Report drift only (exit 1 when stale), change nothing
226
+ ```
227
+
228
+ Replacing a tree is destructive, so the sync requires `--yes`; without it the
229
+ command only reports drift (like `--check`) and hints at the flag. A directory
230
+ whose `SKILL.md` identity doesn't match the bundled skill is skipped, never
231
+ replaced.
232
+
233
+ Run `skills sync --yes` (and `copilot install-extension --yes` if the repo uses the
234
+ Copilot adapter) after every `pmx-canvas` upgrade so agents operate on the
235
+ released guidance.
236
+
214
237
  ## WebView automation
215
238
 
216
239
  Drive a headless Bun.WebView (Chromium or WebKit) pointed at the workbench:
@@ -25,8 +25,8 @@ optional; defaults are listed.
25
25
 
26
26
  | Variable | Read by | Effect |
27
27
  |---|---|---|
28
- | `PMX_CANVAS_THEME` | server | Startup theme: `dark` (default), `light`, `high-contrast`, `midnight`, `sepia`, `arctic`, `ember`, `forest`. |
29
- | `AMP_ORB` | server | Set by Amp orb services. When present, the served canvas page tells the browser it is running in an orb, which skips the iframe embed probe and forces `srcdoc` surface rendering when embedded (the orb portal blocks `src`-URL child iframes and the probe is unreliable there). |
28
+ | `PMX_CANVAS_THEME` | server | Startup theme: `dark` (default), `light`, `high-contrast`, `midnight`, `sepia`, `arctic`, `ember`, `forest`, `volt`. |
29
+ | `AMP_ORB` | server | Set by Amp orb services. When present, the served canvas page tells the browser it is running in an orb, which skips the iframe embed probe, forces `srcdoc` surface rendering when embedded (the orb portal blocks `src`-URL child iframes and the probe is unreliable there), and defaults the event transport to polling instead of SSE (the portal proxy buffers streams; `?transport=sse` overrides for diagnosis). |
30
30
  | `PMX_CANVAS_DISABLE_BROWSER_OPEN` | server | `1` suppresses auto-opening the browser (used by tests/CI). |
31
31
  | `PMX_CANVAS_DIST` | server | Explicit client bundle directory to serve instead of the packaged `dist/canvas`. |
32
32
  | `PMX_CANVAS_WEBVIEW_TIMEOUT_MS` | server | Startup timeout for the Bun.WebView automation session. |
package/docs/http-api.md CHANGED
@@ -341,9 +341,20 @@ Responses are `{ ok, transport: "poll", snapshot, seq, events }` — poll again
341
341
  with `since=<seq>` every couple of seconds. A `since` from a previous server
342
342
  run or one that fell off the bounded event ring recovers with a fresh
343
343
  snapshot. The browser client uses this automatically: if the SSE stream
344
- produces no event within 3 seconds of connecting, it switches to polling.
345
- Force a transport with `/workbench?transport=poll` (or `transport=sse` to
346
- disable the fallback).
344
+ produces no event within 3 seconds of connecting, it switches to polling (and
345
+ tells the boot screen to wait for the fallback instead of alarming). Pages
346
+ served by an Amp orb (`AMP_ORB` set — the same stamp that drives srcdoc mode)
347
+ skip SSE entirely and default straight to polling. Force a transport with
348
+ `/workbench?transport=poll` (or `transport=sse` to disable the fallback /
349
+ override the orb default).
350
+
351
+ ## Session theme override (host-default theming)
352
+
353
+ `/workbench?theme=<name>` gives that browser session its own theme without
354
+ touching the server-global theme other clients see — embedding hosts use it to
355
+ match their chrome (the bundled Copilot extension opens `?theme=light`).
356
+ `?theme=auto` follows the host's `prefers-color-scheme` live. Picking a theme
357
+ from the toolbar ends the override and saves globally as usual.
347
358
 
348
359
  ## Iframe embed probe (nested-iframe hosts)
349
360
 
@@ -66,7 +66,7 @@ The remaining items were re-analyzed with pros/cons and tiered; the maintainer a
66
66
  |---|---|
67
67
  | Windows web-artifacts | The artifact build pipeline is bash-script-backed (POSIX-only); `tests/unit/web-artifacts.test.ts` self-skips on win32 and docs now state the boundary. Decide: port the build scripts to a cross-platform runner, or keep the documented "build on POSIX, render anywhere" stance. |
68
68
  | Composite parity no longer machine-checked | Since v0.3.0 removed the legacy standalones, the byte-identical composite-vs-legacy guarantee has no head-to-head test — regressions in `buildInput`/`formatResult` reuse surface only via output-shape assertions. Accepted at review; revisit if a composite drifts. |
69
- | Portal-layer ext-app mount (contingency) | If the maintainer sets the WebKit UX bar at "never any visible gap", inline ext-app iframes must render outside the world transform — an architectural change that reopens the parked M3. 0.4.1 shipped the cheaper mimic (post-settle WebKit repaint nudge, `repaint-nudge` trail event) and the 0.4.1 Copilot report confirms the nudge RUNS but residual black tiles still appear (Diamond on first-load settle, Ellipse after restart settle) — the contingency's trigger condition is now met. This portal-layer mount is the designated next Finding N step; scope it as its own project (drag/zoom sync, z-order, clipping, minimap interplay), not a patch-release bolt-on. |
69
+ | Portal-layer ext-app mount (contingency) | If the maintainer sets the WebKit UX bar at "never any visible gap", inline ext-app iframes must render outside the world transform — an architectural change that reopens the parked M3. 0.4.1 shipped the cheaper mimic (post-settle WebKit repaint nudge, `repaint-nudge` trail event) and the 0.4.1 Copilot report confirms the nudge RUNS but residual black tiles still appear (Diamond on first-load settle, Ellipse after restart settle) — the contingency's trigger condition is now met. 0.4.3 Codex hardens the evidence: three ready tiles blank in WebKit with **byte-identical** early/late screenshots while the recovery trail reported initialized/settled — DOM-level recovery completion does not prove compositor paint, so no DOM-side nudge can close this. This portal-layer mount is the designated next Finding N step; scope it as its own project (drag/zoom sync, z-order, clipping, minimap interplay), not a patch-release bolt-on. |
70
70
 
71
71
  ## Added post-0.4.0 (2026-08-04 report cycle)
72
72
 
@@ -74,5 +74,8 @@ The remaining items were re-analyzed with pros/cons and tiered; the maintainer a
74
74
  |---|---|
75
75
  | Finding S — frame documents vs server restart | **Done + field-confirmed (0.4.1 reports, both hosts):** the browser revalidates minted frame-document URLs on every reconnect epoch (`workbenchConnectionEpoch`, HEAD probe) and re-mints against the new process when the store is gone; `/api/canvas/frame-documents/:id` answers HEAD. Copilot: IDs fully rotated, all post-restart 200, no frame-not-found tiles. Codex: re-mint without workbench reload. Server-side persistence deliberately NOT done — frames are client-derived and re-mintable. |
76
76
  | Amp portal nested-iframe embedding | **Done (2026-08-04):** boot-time embed probe (`/api/canvas/iframe-probe`) + fetch()+srcdoc fallback for all same-origin surfaces (`useSurfaceFrame`, `useIframeDocument` srcdoc short-circuit), `?iframe-mode` override, e2e-forced-mode coverage. External (cross-origin) app URLs cannot be inlined — documented limitation. |
77
- | Copilot adapter `canvas_snapshot list` descriptor lag | Host-side cached tool schema still requires `name`; fresh `--mcp` is correct. Adapter/packaging issue, not a daemon defect — nothing to change in core; re-verify next cycle. |
77
+ | Copilot adapter `canvas_snapshot list` descriptor lag | Host-side cached tool schema still requires `name`; fresh `--mcp` is correct. Adapter/packaging issue, not a daemon defect — nothing to change in core; 0.4.2 cycle saw the legacy tools gone from the host list after refresh. |
78
+ | Finding T — theme/More menu mis-anchored | **Done + field-confirmed (0.4.3 reports, both hosts):** menus moved inside `.toolbar-menu-anchor` wrappers around their triggers. Copilot measured button/menu right edges equal; Codex measured delta 0. Anchor-geometry e2e regression tests in place. |
79
+ | Finding U — sticky `-32001` session-error banner over rendering content | **Presentation fix field-confirmed (0.4.3 reports, both hosts); root cause open:** forced error state renders the soft saved-content notice. The underlying stressed-reconnect MCP timeout is still not clean-reproducible — investigate session re-establishment retry/backoff if a future cycle reproduces it. |
80
+ | Skill-mirror drift automation | **Done (2026-08-05):** `pmx-canvas skills sync` (+ `--check`, exit 1 on drift) discovers the skill copies already installed in the workspace — no assumed host layout, per maintainer direction — and whole-tree-replaces them from the package. Closes the recurring every-cycle hand-sync complaint in the audit addenda. Remaining audit ask: a true model-vs-baseline benchmark of the 17 eval prompts (a test-harness task, not a repo artifact). |
78
81
  | Registry-migration dead-code sweep | **Done (2026-08-03):** removed the orphaned `describeOp`/`describeGateOp` (composites.ts, dead since the v0.3.0 registration filter) and the `compactNodePayload`/`compactLayoutPayload`/`wantsFullPayload` duplicates in `mcp/server.ts` (live copies are in `ops/nodes.ts`). |
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
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",
@@ -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": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit",
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",
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": "bun test --preload ./tests/client/setup.ts tests/client",
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",
49
49
  "test:coverage": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json 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",
@@ -18,6 +18,14 @@ Humans curate agent context by pinning nodes; agents read that curation through
18
18
 
19
19
  ## Required Operating Sequence
20
20
 
21
+ 0. **After a `pmx-canvas` upgrade, refresh this skill first.** Run
22
+ `pmx-canvas skills sync --yes` (`--check` to detect drift; exit 1 when stale) — it finds the skill
23
+ copies already installed in the workspace (whatever layout your agent uses) and replaces the
24
+ complete trees from the package, references/evals/fixtures included. Sync ONLY from the
25
+ installed package, never from a source checkout: this skill documents the runtime it ships
26
+ with, and a newer repo skill paired with an older installed runtime advertises commands and
27
+ themes the binary does not have (0.4.3 report skew finding). Also restart the HTTP daemon and
28
+ any `--mcp` processes; Bun does not hot-reload a running process.
21
29
  1. **Open or focus the workbench before mutating.** Reuse one visible canvas surface for the
22
30
  session.
23
31
  2. **Verify workspace identity.** Read `GET /health` or `pmx-canvas serve status`; the returned
@@ -175,12 +183,16 @@ Use the visible workbench when the human is actively curating layout:
175
183
  - Drop files or URLs to create matching nodes.
176
184
  - Double-click markdown to edit inline.
177
185
  - Use toolbar snapshots before experiments and restore only after confirmation.
178
- - The toolbar carries view controls (fit, reset, zoom), arrange, minimap, a theme picker (eight
179
- themes: dark, light, high-contrast, midnight, sepia, arctic, ember, forest), snapshots, trace,
186
+ - The toolbar carries view controls (fit, reset, zoom), arrange, minimap, a theme picker (nine
187
+ themes: dark, light, high-contrast, midnight, sepia, arctic, ember, forest, volt), snapshots, trace,
180
188
  the annotation tools, search (Cmd+K), and shortcuts. On narrow viewports (≤900px — most
181
189
  embedded host panes and mobile) the secondary controls collapse into a single "⋯" More menu at
182
190
  the end of the bar; every action is still there as a labeled row, including the theme list. If
183
191
  an automation script can't find a toolbar button at panel width, open the More menu first.
192
+ - Embedding hosts can open `/workbench?theme=<name>` (or `?theme=auto` to follow the host's
193
+ light/dark appearance) for a session-local default theme that never changes the server-global
194
+ theme other clients see; an explicit pick from the theme menu ends the override. The bundled
195
+ Copilot extension opens its panel with `?theme=light`.
184
196
 
185
197
  After changing files under `src/client/`, rebuild with `bun run build` before manual browser
186
198
  verification.
@@ -261,8 +273,10 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
261
273
  with a gray placeholder. The canvas probes this at boot and auto-falls back to fetching
262
274
  same-origin surfaces and rendering them inline via `srcdoc` (HTML, graph, json-render, frame
263
275
  documents). In Amp orbs specifically, the server sees `AMP_ORB` in its environment and stamps
264
- the page so the embedded client skips the (there-unreliable) probe and goes straight to
265
- `srcdoc`; HTML surfaces also inline their theme stylesheet so they render styled. External app
276
+ the page so the embedded client skips the (there-unreliable) probe, goes straight to `srcdoc`,
277
+ AND defaults to the polling transport (the portal proxy buffers SSE — waiting out the watchdog
278
+ could trip the boot modal); HTML surfaces also inline their theme stylesheet so they render
279
+ styled. External app
266
280
  URLs cannot be inlined (cross-origin) and may stay blocked in such hosts. Force a mode with
267
281
  `/workbench?iframe-mode=srcdoc` (or `iframe-mode=src`) when diagnosing.
268
282
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
@@ -132,7 +132,7 @@ pmx-canvas --theme=light # Light theme
132
132
  pmx-canvas --version # Print installed version and exit
133
133
  ```
134
134
 
135
- `--theme` accepts `dark` (default), `light`, `high-contrast`, `midnight`, `sepia`, `arctic`, `ember`, or `forest` (0.4.x; the browser toolbar has a matching theme picker). Same value can be set via
135
+ `--theme` accepts `dark` (default), `light`, `high-contrast`, `midnight`, `sepia`, `arctic`, `ember`, `forest`, or `volt` (0.4.x; the browser toolbar has a matching theme picker). Same value can be set via
136
136
  the `PMX_CANVAS_THEME` environment variable, or toggled live in the browser toolbar.
137
137
 
138
138
  Start the canvas once per session, then reuse it. Use `--no-open` when running as an agent — the
@@ -74,6 +74,10 @@ Prefer extending the existing suites before inventing a one-off script.
74
74
  - MCP harnesses using the official SDK must wind down BOTH halves: call `client.close()` AND
75
75
  `transport.close()` on the `StdioClientTransport`. Closing only the client leaves the spawned
76
76
  stdio server process alive after the test exits (0.4.0 cycle finding)
77
+ - Before a version-test cycle, run `pmx-canvas skills sync --check` in the consumer workspace —
78
+ exit 1 means the installed skill copies are stale against the package; run
79
+ `pmx-canvas skills sync --yes` to refresh them (whole trees, whatever agent layout owns them) before
80
+ trusting skill-guided results
77
81
 
78
82
  ## Layout And Embedded Content Checks
79
83
 
package/src/cli/agent.ts CHANGED
@@ -31,6 +31,7 @@ import './commands/ax.js';
31
31
  import './commands/webview.js';
32
32
  import './commands/apps.js';
33
33
  import './commands/copilot.js';
34
+ import './commands/skills.js';
34
35
 
35
36
  // Re-exported for existing importers (tests import it from this module).
36
37
  export { extractGlobalTargetFlags };
@@ -0,0 +1,183 @@
1
+ // Skill mirror commands: skills sync (+ --check drift detection).
2
+ //
3
+ // Every release cycle found installed skill copies stale against the package,
4
+ // and testers hand-synced complete trees before results were trustworthy.
5
+ // This command automates that WITHOUT assuming any agent layout: it discovers
6
+ // the pmx-canvas skill copies already installed in the workspace (any
7
+ // `<dir>/skills/<skill-name>/SKILL.md`, whatever agent owns `<dir>`) and
8
+ // compares/replaces whole trees — references, evals, fixtures included. It
9
+ // never creates a mirror where none is installed.
10
+
11
+ import { cpSync, existsSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
12
+ import { join, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { cmd, output, parseFlags, showCommandHelp } from '../shared.js';
15
+
16
+ export const BUNDLED_SKILLS = ['pmx-canvas', 'pmx-canvas-testing'] as const;
17
+
18
+ function listFilesRecursive(root: string, prefix = ''): string[] {
19
+ const out: string[] = [];
20
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
21
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
22
+ if (entry.isDirectory()) {
23
+ out.push(...listFilesRecursive(join(root, entry.name), rel));
24
+ } else if (entry.isFile()) {
25
+ out.push(rel);
26
+ }
27
+ }
28
+ return out.sort();
29
+ }
30
+
31
+ export interface SkillTreeComparison {
32
+ inSync: boolean;
33
+ /** Files present in the package tree but missing or different in the mirror. */
34
+ missingOrDifferent: string[];
35
+ /** Files present in the mirror but not in the package tree. */
36
+ extra: string[];
37
+ }
38
+
39
+ export function compareSkillTrees(sourceRoot: string, targetRoot: string): SkillTreeComparison {
40
+ const sourceFiles = listFilesRecursive(sourceRoot);
41
+ const targetFiles = listFilesRecursive(targetRoot);
42
+ const targetSet = new Set(targetFiles);
43
+ const missingOrDifferent: string[] = [];
44
+ for (const rel of sourceFiles) {
45
+ if (!targetSet.has(rel)) {
46
+ missingOrDifferent.push(rel);
47
+ continue;
48
+ }
49
+ if (!readFileSync(join(sourceRoot, rel)).equals(readFileSync(join(targetRoot, rel)))) {
50
+ missingOrDifferent.push(rel);
51
+ }
52
+ }
53
+ const sourceSet = new Set(sourceFiles);
54
+ const extra = targetFiles.filter((rel) => !sourceSet.has(rel));
55
+ return { inSync: missingOrDifferent.length === 0 && extra.length === 0, missingOrDifferent, extra };
56
+ }
57
+
58
+ export interface InstalledSkillMirror {
59
+ /** The directory that owns the skills/ folder (e.g. ".codex", ".github", or any agent dir). */
60
+ host: string;
61
+ skill: string;
62
+ dir: string;
63
+ }
64
+
65
+ /**
66
+ * Find the bundled skills' installed copies in this workspace: any immediate
67
+ * child directory holding `skills/<skill>/SKILL.md`, regardless of which agent
68
+ * that directory belongs to. The package's own skills source is excluded so
69
+ * running inside the pmx-canvas checkout never compares a tree to itself.
70
+ */
71
+ export function discoverInstalledSkillMirrors(
72
+ workspaceRoot: string,
73
+ packageSkillsRoot: string,
74
+ ): InstalledSkillMirror[] {
75
+ const found: InstalledSkillMirror[] = [];
76
+ for (const entry of readdirSync(workspaceRoot, { withFileTypes: true })) {
77
+ if (!entry.isDirectory() || entry.name === 'node_modules') continue;
78
+ const skillsDir = join(workspaceRoot, entry.name, 'skills');
79
+ if (!existsSync(skillsDir)) continue;
80
+ for (const skill of BUNDLED_SKILLS) {
81
+ const dir = join(skillsDir, skill);
82
+ if (!existsSync(join(dir, 'SKILL.md'))) continue;
83
+ if (resolve(dir) === resolve(join(packageSkillsRoot, skill))) continue;
84
+ found.push({ host: entry.name, skill, dir });
85
+ }
86
+ }
87
+ return found;
88
+ }
89
+
90
+ export interface SkillMirrorResult {
91
+ host: string;
92
+ skill: string;
93
+ dir: string;
94
+ status: 'in-sync' | 'synced' | 'drifted' | 'skipped-not-a-package-copy';
95
+ missingOrDifferent?: string[];
96
+ extra?: string[];
97
+ }
98
+
99
+ /**
100
+ * A mirror is only replaceable when its SKILL.md frontmatter `name:` matches
101
+ * the bundled skill it sits under. A user's own skill living in a same-named
102
+ * directory (different frontmatter identity) must never be deleted — these
103
+ * trees are typically gitignored, so a wrong replace is unrecoverable.
104
+ */
105
+ export function mirrorIdentityMatches(mirrorDir: string, skill: string): boolean {
106
+ try {
107
+ const head = readFileSync(join(mirrorDir, 'SKILL.md'), 'utf-8').slice(0, 2000);
108
+ const match = /^name:\s*(\S+)\s*$/m.exec(head);
109
+ return match?.[1] === skill;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /** Sync (or, with check=true, only compare) every INSTALLED mirror against the package trees. */
116
+ export function syncInstalledSkillMirrors(
117
+ packageSkillsRoot: string,
118
+ workspaceRoot: string,
119
+ opts: { check: boolean },
120
+ ): SkillMirrorResult[] {
121
+ const results: SkillMirrorResult[] = [];
122
+ for (const mirror of discoverInstalledSkillMirrors(workspaceRoot, packageSkillsRoot)) {
123
+ const source = join(packageSkillsRoot, mirror.skill);
124
+ if (!mirrorIdentityMatches(mirror.dir, mirror.skill)) {
125
+ results.push({ ...mirror, status: 'skipped-not-a-package-copy' });
126
+ continue;
127
+ }
128
+ const cmp = compareSkillTrees(source, mirror.dir);
129
+ if (cmp.inSync) {
130
+ results.push({ ...mirror, status: 'in-sync' });
131
+ continue;
132
+ }
133
+ if (opts.check) {
134
+ results.push({ ...mirror, status: 'drifted', ...cmp });
135
+ continue;
136
+ }
137
+ // Full-tree replace: a copy-over would leave deleted package files behind
138
+ // in the mirror, which is exactly the drift class release audits kept
139
+ // hitting even when SKILL.md hashes matched. Stage the complete new tree
140
+ // BESIDE the mirror first, so a crash between the rm and the rename leaves
141
+ // the staged copy on disk as recovery instead of losing the install.
142
+ const staging = `${mirror.dir}.pmx-sync-staging`;
143
+ rmSync(staging, { recursive: true, force: true });
144
+ cpSync(source, staging, { recursive: true });
145
+ rmSync(mirror.dir, { recursive: true, force: true });
146
+ renameSync(staging, mirror.dir);
147
+ results.push({ ...mirror, status: 'synced' });
148
+ }
149
+ return results;
150
+ }
151
+
152
+ // ── skills sync ──────────────────────────────────────────────
153
+ cmd(
154
+ 'skills sync',
155
+ 'Refresh the pmx-canvas skill copies already installed in this workspace (any agent layout)',
156
+ ['pmx-canvas skills sync --yes', 'pmx-canvas skills sync --check'],
157
+ async (args) => {
158
+ const { flags } = parseFlags(args);
159
+ if (flags.help || flags.h) return showCommandHelp('skills sync');
160
+
161
+ const packageSkillsRoot = fileURLToPath(new URL('../../../skills', import.meta.url));
162
+ // Replacing a mirror deletes a whole installed tree — destructive, so it
163
+ // follows the CLI's `--yes` contract. Without --yes (or with --check) the
164
+ // command only reports drift; exit 1 signals stale either way.
165
+ const check = flags.check === true || flags.yes !== true;
166
+ const results = syncInstalledSkillMirrors(packageSkillsRoot, process.cwd(), { check });
167
+
168
+ const drifted = results.filter((r) => r.status === 'drifted');
169
+ const ok = drifted.length === 0;
170
+ output({
171
+ ok,
172
+ check,
173
+ packageSkillsRoot,
174
+ mirrorsFound: results.length,
175
+ ...(results.length === 0 ? { note: 'No installed pmx-canvas skill copies found in this workspace.' } : {}),
176
+ ...(drifted.length > 0 && flags.yes !== true
177
+ ? { note: 'Drift detected. Re-run with --yes to replace the drifted trees from the installed package.' }
178
+ : {}),
179
+ results,
180
+ });
181
+ if (!ok) process.exitCode = 1;
182
+ },
183
+ );