pmx-canvas 0.1.29 → 0.1.30

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 (66) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/Readme.md +20 -10
  3. package/dist/canvas/global.css +13 -0
  4. package/dist/canvas/index.js +80 -163
  5. package/dist/canvas/surface-theme.css +142 -0
  6. package/dist/json-render/index.js +103 -103
  7. package/dist/types/client/nodes/HtmlNode.d.ts +0 -7
  8. package/dist/types/client/nodes/ax-node-actions.d.ts +18 -0
  9. package/dist/types/client/nodes/surface-url.d.ts +22 -0
  10. package/dist/types/client/state/attention-bridge.d.ts +3 -0
  11. package/dist/types/client/state/intent-bridge.d.ts +17 -0
  12. package/dist/types/json-render/renderer/index.d.ts +2 -0
  13. package/dist/types/json-render/schema.d.ts +2 -0
  14. package/dist/types/json-render/server.d.ts +2 -0
  15. package/dist/types/mcp/canvas-access.d.ts +47 -0
  16. package/dist/types/server/ax-interaction.d.ts +210 -0
  17. package/dist/types/server/ax-state.d.ts +67 -1
  18. package/dist/types/server/canvas-db.d.ts +4 -0
  19. package/dist/types/server/canvas-serialization.d.ts +2 -0
  20. package/dist/types/server/canvas-state.d.ts +47 -2
  21. package/dist/types/server/html-surface.d.ts +40 -0
  22. package/dist/types/server/index.d.ts +50 -2
  23. package/dist/types/server/mutation-history.d.ts +1 -1
  24. package/dist/types/server/placement.d.ts +1 -1
  25. package/dist/types/shared/surface.d.ts +19 -0
  26. package/docs/cli.md +30 -0
  27. package/docs/http-api.md +55 -0
  28. package/docs/mcp.md +40 -2
  29. package/docs/node-types.md +26 -0
  30. package/docs/plans/plan-004-pmx-ax-primitives.md +623 -394
  31. package/docs/sdk.md +20 -0
  32. package/package.json +2 -2
  33. package/skills/pmx-canvas/SKILL.md +107 -9
  34. package/src/cli/agent.ts +177 -0
  35. package/src/client/canvas/CanvasNode.tsx +8 -4
  36. package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -0
  37. package/src/client/nodes/ContextNode.tsx +17 -0
  38. package/src/client/nodes/ExtAppFrame.tsx +40 -3
  39. package/src/client/nodes/FileNode.tsx +26 -0
  40. package/src/client/nodes/HtmlNode.tsx +60 -188
  41. package/src/client/nodes/McpAppNode.tsx +47 -2
  42. package/src/client/nodes/StatusNode.tsx +20 -0
  43. package/src/client/nodes/ax-node-actions.ts +39 -0
  44. package/src/client/nodes/surface-url.ts +48 -0
  45. package/src/client/state/attention-bridge.ts +5 -0
  46. package/src/client/state/intent-bridge.ts +33 -0
  47. package/src/client/theme/global.css +13 -0
  48. package/src/client/theme/surface-theme.css +142 -0
  49. package/src/json-render/renderer/index.tsx +31 -0
  50. package/src/json-render/schema.ts +4 -0
  51. package/src/json-render/server.ts +13 -0
  52. package/src/mcp/canvas-access.ts +195 -0
  53. package/src/mcp/server.ts +232 -2
  54. package/src/server/ax-context.ts +3 -0
  55. package/src/server/ax-interaction.ts +549 -0
  56. package/src/server/ax-state.ts +188 -2
  57. package/src/server/canvas-db.ts +20 -0
  58. package/src/server/canvas-operations.ts +11 -0
  59. package/src/server/canvas-serialization.ts +9 -0
  60. package/src/server/canvas-state.ts +177 -16
  61. package/src/server/html-surface.ts +170 -0
  62. package/src/server/index.ts +98 -0
  63. package/src/server/mutation-history.ts +5 -0
  64. package/src/server/placement.ts +5 -1
  65. package/src/server/server.ts +305 -0
  66. package/src/shared/surface.ts +38 -0
package/docs/sdk.md CHANGED
@@ -106,6 +106,26 @@ canvas.addReviewAnnotation({ body: 'off-by-one', kind: 'finding', severity: 'err
106
106
  // Host/session capability (own table, survives clear)
107
107
  canvas.reportHostCapability({ host: 'copilot', canvas: true, sessionMessaging: true }, { source: 'sdk' });
108
108
  console.log(canvas.getHostCapability());
109
+
110
+ // Node interactions — one capability-gated envelope; the server re-validates and
111
+ // clamps sandboxed surfaces (html-node/mcp-app/json-render) to their own node.
112
+ canvas.submitAxInteraction({ type: 'ax.work.create', sourceNodeId: n1, payload: { title: 'Wire auth' } });
113
+
114
+ // Delivery — claim pending steering for a consumer (loop-safe), then acknowledge
115
+ const pending = canvas.getPendingSteering({ consumer: 'copilot', limit: 20 });
116
+ if (pending[0]) canvas.markSteeringDelivered(pending[0].id, { consumer: 'copilot' });
117
+
118
+ // Elicitation + mode requests (canvas-bound, snapshotted)
119
+ const elic = canvas.requestElicitation({ prompt: 'Who owns this?', fields: ['owner'] }, { source: 'sdk' });
120
+ canvas.respondElicitation(elic.id, { owner: 'alice' });
121
+ const mode = canvas.requestMode({ mode: 'execute', reason: 'plan approved' }, { source: 'sdk' });
122
+ canvas.resolveModeRequest(mode.id, 'approved');
123
+
124
+ // Command registry + tool/prompt policy
125
+ console.log(canvas.getCommandRegistry());
126
+ canvas.invokeCommand('pmx.plan', { note: 'draft a plan' }, { source: 'sdk' });
127
+ canvas.setPolicy({ tools: { excluded: ['shell'] }, prompt: { mode: 'concise' } }, { source: 'sdk' });
128
+ console.log(canvas.getPolicy());
109
129
  ```
110
130
 
111
131
  ## WebView automation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "Spatial canvas workbench for coding agents — 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",
@@ -32,7 +32,7 @@
32
32
  "dev:demo": "bun run src/cli/index.ts --demo",
33
33
  "dev:portless": "portless run --name pmx --app-port 4313 bun run src/cli/index.ts --no-open --port=4313",
34
34
  "dev:portless:demo": "portless run --name pmx --app-port 4313 bun run src/cli/index.ts --no-open --port=4313 --demo",
35
- "build:client": "bun build src/client/index.tsx --outdir dist/canvas --minify && cp src/client/theme/global.css dist/canvas/global.css",
35
+ "build:client": "bun build src/client/index.tsx --outdir dist/canvas --minify && cp src/client/theme/global.css dist/canvas/global.css && cp src/client/theme/surface-theme.css dist/canvas/surface-theme.css",
36
36
  "build:json-render": "bash scripts/build-json-render.sh",
37
37
  "build:types": "tsc -p tsconfig.types.json",
38
38
  "build": "bun run build:client && bun run build:json-render && bun run build:types",
@@ -75,14 +75,19 @@ reference before using adapter-native features:
75
75
  - `references/codex-app-adapter.md` — Codex app native Browser + MCP adapter, AX context reading,
76
76
  focus labeling, and live-test checklist.
77
77
 
78
- Host-aware visibility rule:
79
- - If a native PMX Canvas adapter/panel is available and already represents the workbench (for example
80
- the GitHub Copilot app `pmx-canvas` canvas extension, or Codex's in-app Browser opened to
81
- `/workbench`), use that panel. Do **not** also open a separate browser panel to the same workbench;
82
- it wastes space and confuses which surface is authoritative.
83
- - If no native adapter/panel is available (generic MCP client, shell-only agent, raw CLI harness,
84
- or another agent harness without canvas support), open the normal PMX Canvas browser workbench first
85
- so the human can see mutations as they happen. Use the server URL's `/workbench` route.
78
+ Open the canvas first — always:
79
+ The canvas is the shared human↔agent surface. **Before you create or mutate any nodes, make the
80
+ workbench visible.** Do **not** assume the host opened it for you some hosts (e.g. the Codex app)
81
+ do not open it on their own. Take the action to open it yourself, whatever the host:
82
+ - **Native adapter/panel available** (the GitHub Copilot app `pmx-canvas` canvas extension, or the
83
+ Codex in-app Browser): open/focus that panel to the server's `/workbench` route.
84
+ - **Any other browser** (Chrome, Safari, Arc, Edge, …) **or a generic/CLI agent** with no native
85
+ panel: open the server's `/workbench` URL in a browser.
86
+
87
+ Then reuse that **single** surface for the rest of the session — do **not** open a second panel to
88
+ the same workbench (it wastes space and confuses which surface is authoritative). If you genuinely
89
+ cannot open any browser (headless/CI), say so and proceed, but still print the `/workbench` URL so a
90
+ human can open it.
86
91
  - External URLs in `mcp-app` nodes show the "Unverified domain" interstitial by design. Only
87
92
  same-origin `/api/canvas/frame-documents/<id>` URLs are auto-trusted. For external tools, use a
88
93
  bundled `web-artifact`, same-origin frame document, or set `data.trustedDomain: true` only when the
@@ -213,6 +218,9 @@ pmx-canvas spatial
213
218
  `ax review add|list` — canvas-bound AX state (work items, approval gates,
214
219
  review annotations) that rides snapshots and restore and is cleared by `clear`.
215
220
  - `ax host report|status` — report/read the host/session capability (own partition).
221
+ - `ax command list|invoke`, `ax policy get|set` — list/invoke registry commands
222
+ (`pmx.plan`, `pmx.execute`, `pmx.promote-context`, `pmx.summarize`, `pmx.review`)
223
+ and read/patch the canvas-bound tool/prompt policy.
216
224
  - `copilot install-extension [--dry-run] [--yes]` — install the bundled GitHub
217
225
  Copilot adapter into a repo; the core stays host-agnostic.
218
226
  - `fit [id ...]` — set the server viewport to fit the whole canvas or selected nodes before screenshots or whole-board review
@@ -290,6 +298,24 @@ points from `from` to `to`, indicating sequence or data flow direction.
290
298
  pending dependencies, and `dotted` for weak/optional relationships. Use `animated: true` to
291
299
  draw visual attention to critical paths.
292
300
 
301
+ ### Layout: spacing and groups
302
+
303
+ Agents tend to pack boards too tightly. Give nodes room to breathe — readability beats density.
304
+
305
+ - **Default spacing:** leave a clear gap between neighbors — roughly half a node's width
306
+ horizontally and half its height vertically. A 280×180 node reads well with ~360px between
307
+ column origins and ~260px between row origins.
308
+ - **When nodes are connected by edges, space them further apart** so the edge line, its arrowhead,
309
+ and its label are clearly visible in the gap between nodes. Crowded nodes hide the flow — this is
310
+ the most common cause of an unreadable board.
311
+ - **Inside a group, keep the same breathing room** (groups no longer auto-pack children, so the
312
+ spacing you set is what the human sees). Edges between grouped children especially need the gap.
313
+ - **Size a group frame larger than its children's bounding box** so the group header — including the
314
+ node-count badge — stays visible and isn't hidden under the top-left child. For an explicit
315
+ (manual) group frame, add margin on every side (≈56px) plus room at the top for the header rather
316
+ than hugging the children. Auto-fit groups (created without an explicit width/height) already
317
+ reserve this margin.
318
+
293
319
  ### Colors (Semantic)
294
320
 
295
321
  Use color consistently to convey meaning:
@@ -740,6 +766,28 @@ server's `ui://` resource as an iframe node on the canvas
740
766
  - Read `htmlPrimitives` from `canvas_describe_schema` for the data shape and examples before constructing a payload
741
767
  - For payload patterns, export loops, and the primitive catalog, read `references/html-primitives.md` before creating dense or editable artifacts
742
768
 
769
+ ### Open as Site (standalone surfaces)
770
+
771
+ Any renderable surface node can be opened full-page in its own browser tab — the same
772
+ document it shows in the canvas, just without the node chrome. In the workbench, use the
773
+ ↗ **Open as site** button in the node title bar (or the expanded overlay).
774
+
775
+ - Works for `html` / `html-primitive`, bundled `web-artifact`, `json-render` / `graph`,
776
+ `webpage`, and hosted ext-app `mcp-app` nodes.
777
+ - The tab loads the node's stable surface URL, `/api/canvas/surface/<nodeId>`. The
778
+ in-canvas iframe loads the **exact same URL**, so there is one render path and no
779
+ separate "preview" version — what you see in the canvas is what opens. The URL reflects
780
+ current node state and survives a refresh.
781
+ - Agents can read this URL from any node payload (`canvas_get_node` / `canvas_get_layout`)
782
+ as `surfaceUrl` — a reliable way to tell a human "open the artifact" without disturbing
783
+ the canvas.
784
+ - Served HTML stays sandboxed (opaque origin via a `Content-Security-Policy: sandbox`
785
+ response header), so opening author code top-level cannot reach the canvas origin.
786
+ - ext-app `mcp-app` nodes open their UI, but interactive tool-calls only work inside the
787
+ canvas (the host bridge has no peer in a bare tab). `webpage` and URL-backed `mcp-app`
788
+ nodes redirect to their external site.
789
+ - This is additive — opening a site never evicts or replaces canvas nodes.
790
+
743
791
  ### Choosing the Right Visual Tier
744
792
 
745
793
  When the output is more than markdown, pick the lightest tier that fits:
@@ -761,6 +809,8 @@ Use native `json-render` and `graph` nodes when the output should stay fully ins
761
809
  3. Use the repo-local `json-render-*` skills when you need help authoring or refining the spec itself
762
810
  4. Use `canvas_build_web_artifact` instead when the result needs a full custom React app rather than a schema-driven UI
763
811
 
812
+ Spec elements support an `on` map (`on.press`, `on.change`, …) binding events to actions (`{ action, params }`) — built-in actions (`setState`, `pushState`, …) or, when named after an AX interaction type, a capability-gated AX emit. e.g. a Button with `on: { press: { action: 'ax.work.create', params: { title: '…' } } }` lets a human turn a panel control into a tracked work item; the viewer forwards it to the canvas, which validates and submits it server-side (clamped to the node's own id). See **Node AX Interactions** above.
813
+
764
814
  ## MCP Resources
765
815
 
766
816
  These resources give you read access to canvas intelligence. Read them to understand
@@ -777,10 +827,58 @@ what the human has set up and what they're focusing on.
777
827
  | `canvas://code-graph` | Auto-detected file import dependencies (JS/TS, Python, Go, Rust) |
778
828
  | `canvas://ax` | Host-agnostic AX state: focus, work items, approval gates, review annotations, host capability |
779
829
  | `canvas://ax-context` | Agent-ready AX context: pinned context + current focus |
780
- | `canvas://ax-work` | Canvas-bound AX work: work items, approval gates, review annotations |
830
+ | `canvas://ax-work` | Canvas-bound AX work: work items, approval gates, review annotations, elicitations, mode requests, and tool/prompt policy |
781
831
  | `canvas://ax-timeline` | Bounded AX timeline: recent agent events, evidence, and steering messages |
832
+ | `canvas://ax-pending-steering` | Undelivered steering an MCP client can claim, act on, and mark delivered (adapterless delivery) |
833
+ | `canvas://ax-delivery` | Steering delivery state (delivered flag) for diagnostics |
782
834
  | `canvas://skills` | Index of bundled agent skills shipped with the install. Each skill is also addressable as `canvas://skills/<name>` (e.g. `canvas://skills/web-artifacts-builder`) and returns the full SKILL.md. Read this resource first to discover companion workflows the canvas is built to support. |
783
835
 
836
+ ### Node AX Interactions (capability-gated)
837
+
838
+ Eligible nodes can emit one normalized, validated AX interaction that maps onto an
839
+ AX operation — work item, evidence, approval, review, focus, steering, event,
840
+ elicitation, or mode request. One envelope, many transports:
841
+
842
+ - **Endpoint:** `POST /api/canvas/ax/interaction` with
843
+ `{ type, sourceNodeId, payload }` (MCP: `canvas_ax_interaction`; CLI:
844
+ `pmx-canvas ax interaction`). Returns `{ ok, primitive }` or
845
+ `{ ok: false, code }` if the node type/metadata disallows the type.
846
+ - **Capabilities:** each node type has a default capability set (a ceiling). A
847
+ node may opt in or narrow via `data.axCapabilities` (`{ enabled, allowed }`),
848
+ clamped to the ceiling — a node can never escalate beyond its type's ceiling.
849
+ `html` / `html-primitive`, `mcp-app`, and internal `prompt` / `response` nodes
850
+ are **disabled by default** (opt-in).
851
+ - **Transports:** native node controls call the endpoint directly. Sandboxed
852
+ surfaces emit via a nonce-tagged `postMessage` the parent canvas validates
853
+ before submitting: `html` / `html-primitive` nodes (when opted in) call
854
+ `window.PMX_AX.emit(type, payload)`; **json-render / graph** viewers forward a
855
+ spec action named after an AX type (e.g. `on.press → { action:
856
+ "ax.work.create", params }`, `sourceSurface: 'json-render'`); opted-in ext-app
857
+ **`mcp-app`** nodes get the same `window.PMX_AX.emit` injected
858
+ (`sourceSurface: 'mcp-app'`). The server re-validates capabilities regardless
859
+ of transport — bridges are convenience, not a trust boundary.
860
+ - **Delivery:** steering can be claimed by adapterless MCP clients via
861
+ `canvas://ax-pending-steering` / `canvas_claim_ax_delivery` and acknowledged
862
+ with `canvas_mark_ax_delivery` (loop-safe — a consumer never receives steering
863
+ it originated).
864
+ - **Elicitation / mode:** request structured human input
865
+ (`canvas_request_elicitation` → `canvas_respond_elicitation`) or a workflow
866
+ mode transition (`canvas_request_mode` → `canvas_resolve_mode`); both are
867
+ canvas-bound and snapshotted.
868
+ - **Commands:** invoke a registry command — `pmx.plan`, `pmx.execute`,
869
+ `pmx.promote-context`, `pmx.summarize`, `pmx.review` — via
870
+ `canvas_invoke_command` (HTTP `POST /api/canvas/ax/command`; CLI
871
+ `pmx-canvas ax command invoke`; envelope `ax.command.invoke`). Unknown names
872
+ are rejected; an invocation records an `agent-event` of kind `command`.
873
+ - **Policy:** a canvas-bound, snapshotted tool/prompt policy
874
+ (`tools.allowed|excluded|approvalRequired`, `prompt.systemAppend|mode`) read
875
+ into `canvas://ax-context`. Patch it with `canvas_set_ax_policy` (HTTP
876
+ `GET|POST /api/canvas/ax/policy`; CLI `pmx-canvas ax policy get|set`); patches
877
+ merge and are normalized server-side.
878
+
879
+ Interactions request PMX-AX primitives only — never arbitrary shell, tool, MCP,
880
+ or host execution.
881
+
784
882
  ### Reading Spatial Intent
785
883
 
786
884
  The `canvas://spatial-context` resource reveals how the human has organized information:
package/src/cli/agent.ts CHANGED
@@ -1099,6 +1099,11 @@ const RESOURCE_SUBCOMMAND_HINTS: Record<string, Record<string, string>> = {
1099
1099
  work: 'Pick an action: pmx-canvas ax work add | update | list',
1100
1100
  approval: 'Pick an action: pmx-canvas ax approval request | resolve | list',
1101
1101
  review: 'Pick an action: pmx-canvas ax review add | list',
1102
+ delivery: 'Pick an action: pmx-canvas ax delivery list | mark',
1103
+ elicitation: 'Pick an action: pmx-canvas ax elicitation request | respond | list',
1104
+ mode: 'Pick an action: pmx-canvas ax mode request | resolve | list',
1105
+ command: 'Pick an action: pmx-canvas ax command list | invoke',
1106
+ policy: 'Pick an action: pmx-canvas ax policy get | set',
1102
1107
  },
1103
1108
  };
1104
1109
 
@@ -1879,6 +1884,178 @@ cmd('ax steer', 'Send a steering message to the active agent session', [
1879
1884
  output(await api('POST', '/api/canvas/ax/steer', { message, source: 'cli' }));
1880
1885
  });
1881
1886
 
1887
+ cmd('ax interaction', 'Submit a node-originated AX interaction (capability-gated)', [
1888
+ 'pmx-canvas ax interaction --type ax.work.create --node node-1 --payload \'{"title":"Wire auth"}\'',
1889
+ 'pmx-canvas ax interaction --type ax.focus.set --node node-2',
1890
+ ], async (args) => {
1891
+ const { flags } = parseFlags(args);
1892
+ if (flags.help || flags.h) return showCommandHelp('ax interaction');
1893
+
1894
+ const type = getStringFlag(flags, 'type');
1895
+ if (!type) die('Missing --type', 'pmx-canvas ax interaction --type <ax.*> --node <id> [--payload <json>]');
1896
+ const sourceNodeId = getStringFlag(flags, 'node');
1897
+ if (!sourceNodeId) die('Missing --node', 'pmx-canvas ax interaction --type <ax.*> --node <id>');
1898
+
1899
+ let payload: unknown;
1900
+ const payloadRaw = getStringFlag(flags, 'payload');
1901
+ if (payloadRaw) {
1902
+ try {
1903
+ payload = JSON.parse(payloadRaw);
1904
+ } catch {
1905
+ die('Invalid --payload JSON', 'pmx-canvas ax interaction --payload \'{"title":"..."}\'');
1906
+ }
1907
+ }
1908
+
1909
+ output(await api('POST', '/api/canvas/ax/interaction', {
1910
+ type,
1911
+ sourceNodeId,
1912
+ ...(payload !== undefined ? { payload } : {}),
1913
+ source: 'cli',
1914
+ }));
1915
+ });
1916
+
1917
+ cmd('ax delivery list', 'List pending AX steering for a consumer (loop-safe)', [
1918
+ 'pmx-canvas ax delivery list',
1919
+ 'pmx-canvas ax delivery list --consumer copilot --limit 20',
1920
+ ], async (args) => {
1921
+ const { flags } = parseFlags(args);
1922
+ if (flags.help || flags.h) return showCommandHelp('ax delivery list');
1923
+ const consumer = getStringFlag(flags, 'consumer');
1924
+ const limit = optionalNumberFlag(flags, 'limit', 'pmx-canvas ax delivery list --limit <n>');
1925
+ const params = new URLSearchParams();
1926
+ if (consumer) params.set('consumer', consumer);
1927
+ if (limit) params.set('limit', String(limit));
1928
+ const qs = params.toString();
1929
+ output(await api('GET', `/api/canvas/ax/delivery/pending${qs ? `?${qs}` : ''}`));
1930
+ });
1931
+
1932
+ cmd('ax delivery mark', 'Mark an AX steering message as delivered', [
1933
+ 'pmx-canvas ax delivery mark <steering-id>',
1934
+ ], async (args) => {
1935
+ const { positional, flags } = parseFlags(args);
1936
+ if (flags.help || flags.h) return showCommandHelp('ax delivery mark');
1937
+ const id = getStringFlag(flags, 'id') ?? positional[0];
1938
+ if (!id) die('Missing steering id', 'pmx-canvas ax delivery mark <steering-id>');
1939
+ output(await api('POST', `/api/canvas/ax/delivery/${encodeURIComponent(id)}/mark`, {}));
1940
+ });
1941
+
1942
+ cmd('ax elicitation request', 'Request structured human input', [
1943
+ 'pmx-canvas ax elicitation request --prompt "Who owns this migration?"',
1944
+ 'pmx-canvas ax elicitation request --prompt "Pick a region" --fields region,owner',
1945
+ ], async (args) => {
1946
+ const { flags } = parseFlags(args);
1947
+ if (flags.help || flags.h) return showCommandHelp('ax elicitation request');
1948
+ const prompt = requireFlag(flags, 'prompt', 'pmx-canvas ax elicitation request --prompt <text>');
1949
+ const fields = getStringFlag(flags, 'fields');
1950
+ output(await api('POST', '/api/canvas/ax/elicitation', {
1951
+ prompt,
1952
+ ...(fields ? { fields: fields.split(',').map((f) => f.trim()).filter(Boolean) } : {}),
1953
+ source: 'cli',
1954
+ }));
1955
+ });
1956
+
1957
+ cmd('ax elicitation respond', 'Answer a pending elicitation', [
1958
+ 'pmx-canvas ax elicitation respond <id> --response \'{"owner":"alice"}\'',
1959
+ ], async (args) => {
1960
+ const { positional, flags } = parseFlags(args);
1961
+ if (flags.help || flags.h) return showCommandHelp('ax elicitation respond');
1962
+ const id = getStringFlag(flags, 'id') ?? positional[0];
1963
+ if (!id) die('Missing elicitation id', 'pmx-canvas ax elicitation respond <id> --response <json>');
1964
+ let response: unknown = {};
1965
+ const raw = getStringFlag(flags, 'response');
1966
+ if (raw) {
1967
+ try { response = JSON.parse(raw); } catch { die('Invalid --response JSON', '--response \'{"k":"v"}\''); }
1968
+ }
1969
+ output(await api('POST', `/api/canvas/ax/elicitation/${encodeURIComponent(id)}/respond`, { response, source: 'cli' }));
1970
+ });
1971
+
1972
+ cmd('ax elicitation list', 'List elicitations', ['pmx-canvas ax elicitation list'], async (args) => {
1973
+ const { flags } = parseFlags(args);
1974
+ if (flags.help || flags.h) return showCommandHelp('ax elicitation list');
1975
+ output(await api('GET', '/api/canvas/ax/elicitation'));
1976
+ });
1977
+
1978
+ cmd('ax mode request', 'Request a workflow mode transition (plan/execute/autonomous)', [
1979
+ 'pmx-canvas ax mode request --mode execute --reason "plan approved"',
1980
+ ], async (args) => {
1981
+ const { flags } = parseFlags(args);
1982
+ if (flags.help || flags.h) return showCommandHelp('ax mode request');
1983
+ const mode = requireFlag(flags, 'mode', 'pmx-canvas ax mode request --mode plan|execute|autonomous');
1984
+ const reason = getStringFlag(flags, 'reason');
1985
+ output(await api('POST', '/api/canvas/ax/mode', { mode, ...(reason ? { reason } : {}), source: 'cli' }));
1986
+ });
1987
+
1988
+ cmd('ax mode resolve', 'Resolve a pending mode request', [
1989
+ 'pmx-canvas ax mode resolve <id> --decision approved',
1990
+ ], async (args) => {
1991
+ const { positional, flags } = parseFlags(args);
1992
+ if (flags.help || flags.h) return showCommandHelp('ax mode resolve');
1993
+ const id = getStringFlag(flags, 'id') ?? positional[0];
1994
+ if (!id) die('Missing mode request id', 'pmx-canvas ax mode resolve <id> --decision approved|rejected');
1995
+ const decision = getStringFlag(flags, 'decision');
1996
+ if (decision !== 'approved' && decision !== 'rejected') die('Invalid --decision', '--decision approved|rejected');
1997
+ const resolution = getStringFlag(flags, 'resolution');
1998
+ output(await api('POST', `/api/canvas/ax/mode/${encodeURIComponent(id)}/resolve`, {
1999
+ decision,
2000
+ ...(resolution ? { resolution } : {}),
2001
+ source: 'cli',
2002
+ }));
2003
+ });
2004
+
2005
+ cmd('ax mode list', 'List mode requests', ['pmx-canvas ax mode list'], async (args) => {
2006
+ const { flags } = parseFlags(args);
2007
+ if (flags.help || flags.h) return showCommandHelp('ax mode list');
2008
+ output(await api('GET', '/api/canvas/ax/mode'));
2009
+ });
2010
+
2011
+ cmd('ax command list', 'List the PMX command registry', ['pmx-canvas ax command list'], async (args) => {
2012
+ const { flags } = parseFlags(args);
2013
+ if (flags.help || flags.h) return showCommandHelp('ax command list');
2014
+ output(await api('GET', '/api/canvas/ax/command'));
2015
+ });
2016
+
2017
+ cmd('ax command invoke', 'Invoke a registry-gated PMX command intent', [
2018
+ 'pmx-canvas ax command invoke pmx.plan',
2019
+ 'pmx-canvas ax command invoke pmx.promote-context --args \'{"nodeIds":["n1"]}\'',
2020
+ ], async (args) => {
2021
+ const { positional, flags } = parseFlags(args);
2022
+ if (flags.help || flags.h) return showCommandHelp('ax command invoke');
2023
+ const name = getStringFlag(flags, 'name') ?? positional[0];
2024
+ if (!name) die('Missing command name', 'pmx-canvas ax command invoke <name>');
2025
+ let cmdArgs: unknown;
2026
+ const raw = getStringFlag(flags, 'args');
2027
+ if (raw) {
2028
+ try { cmdArgs = JSON.parse(raw); } catch { die('Invalid --args JSON', '--args \'{"k":"v"}\''); }
2029
+ }
2030
+ output(await api('POST', '/api/canvas/ax/command', { name, ...(cmdArgs !== undefined ? { args: cmdArgs } : {}), source: 'cli' }));
2031
+ });
2032
+
2033
+ cmd('ax policy get', 'Show the current declarative AX policy', ['pmx-canvas ax policy get'], async (args) => {
2034
+ const { flags } = parseFlags(args);
2035
+ if (flags.help || flags.h) return showCommandHelp('ax policy get');
2036
+ output(await api('GET', '/api/canvas/ax/policy'));
2037
+ });
2038
+
2039
+ cmd('ax policy set', 'Set the declarative AX policy (stored by PMX, enforced by adapters)', [
2040
+ 'pmx-canvas ax policy set --excluded-tools shell,write --mode concise',
2041
+ ], async (args) => {
2042
+ const { flags } = parseFlags(args);
2043
+ if (flags.help || flags.h) return showCommandHelp('ax policy set');
2044
+ const csv = (v?: string) => (v ? v.split(',').map((s) => s.trim()).filter(Boolean) : undefined);
2045
+ const allowed = csv(getStringFlag(flags, 'allowed-tools'));
2046
+ const excluded = csv(getStringFlag(flags, 'excluded-tools'));
2047
+ const approvalRequired = csv(getStringFlag(flags, 'approval-tools'));
2048
+ const mode = getStringFlag(flags, 'mode');
2049
+ const systemAppend = getStringFlag(flags, 'system-append');
2050
+ const tools = (allowed || excluded || approvalRequired)
2051
+ ? { ...(allowed ? { allowed } : {}), ...(excluded ? { excluded } : {}), ...(approvalRequired ? { approvalRequired } : {}) }
2052
+ : undefined;
2053
+ const prompt = (mode || systemAppend)
2054
+ ? { ...(mode ? { mode } : {}), ...(systemAppend ? { systemAppend } : {}) }
2055
+ : undefined;
2056
+ output(await api('POST', '/api/canvas/ax/policy', { ...(tools ? { tools } : {}), ...(prompt ? { prompt } : {}), source: 'cli' }));
2057
+ });
2058
+
1882
2059
  cmd('ax timeline', 'Read the bounded AX timeline (events, evidence, steering)', [
1883
2060
  'pmx-canvas ax timeline',
1884
2061
  'pmx-canvas ax timeline --limit 100',
@@ -25,6 +25,7 @@ import {
25
25
  viewport,
26
26
  } from '../state/canvas-store';
27
27
  import { removeNodeFromClient, updateNodeFromClient } from '../state/intent-bridge';
28
+ import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
28
29
  import { getNodeIcon } from '../icons';
29
30
  import { EXPANDABLE_TYPES, TYPE_LABELS } from '../types';
30
31
  import type { CanvasNodeState } from '../types';
@@ -275,15 +276,18 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
275
276
  >
276
277
  {'\u2726'}
277
278
  </button>
278
- {/* Open externallyonly for URL-based MCP app nodes (not ext-apps which need a host bridge) */}
279
- {(node.type === 'mcp-app' || node.type === 'webpage') && node.data.url && !node.data.mode && (
279
+ {/* Open as site full-page standalone view of this node's surface,
280
+ served from /api/canvas/surface/:id (same document as the canvas
281
+ iframe). Covers html, web-artifact/ext-app/url mcp-apps,
282
+ json-render/graph, and webpage nodes. */}
283
+ {canOpenAsSite(node) && (
280
284
  <button
281
285
  type="button"
282
286
  onClick={(e) => {
283
287
  e.stopPropagation();
284
- window.open(node.data.url as string, '_blank', 'noopener');
288
+ openNodeAsSite(node);
285
289
  }}
286
- title="Open in new tab"
290
+ title="Open as site (new tab)"
287
291
  >
288
292
 
289
293
  </button>
@@ -8,6 +8,7 @@ import { StatusNode } from '../nodes/StatusNode';
8
8
  import { ImageNode } from '../nodes/ImageNode';
9
9
  import { WebpageNode } from '../nodes/WebpageNode';
10
10
  import { HtmlNode, shouldShowPresentationControls } from '../nodes/HtmlNode';
11
+ import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
11
12
  import { PromptNode } from '../nodes/PromptNode';
12
13
  import { ResponseNode } from '../nodes/ResponseNode';
13
14
  import { TraceNode } from '../nodes/TraceNode';
@@ -303,6 +304,17 @@ export function ExpandedNodeOverlay() {
303
304
  </button>
304
305
  )}
305
306
 
307
+ {canOpenAsSite(node) && (
308
+ <button
309
+ type="button"
310
+ class="expanded-action-btn"
311
+ onClick={() => openNodeAsSite(node)}
312
+ title="Open as a full-page site in a new tab"
313
+ >
314
+ Open as site
315
+ </button>
316
+ )}
317
+
306
318
  {canPresent && (
307
319
  <button
308
320
  type="button"
@@ -1,5 +1,6 @@
1
1
  import { openWorkbenchFile } from '../state/intent-bridge';
2
2
  import { TYPE_LABELS, type CanvasNodeState } from '../types';
3
+ import { axNodeActionButtonStyle, runNodeAxInteraction } from './ax-node-actions';
3
4
 
4
5
  interface ContextCard {
5
6
  key?: string;
@@ -172,6 +173,22 @@ export function ContextNode({
172
173
  padding: expanded ? '8px 0' : undefined,
173
174
  }}
174
175
  >
176
+ {/* AX: focus the agent on this context node */}
177
+ <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
178
+ <button
179
+ type="button"
180
+ class="ax-node-action"
181
+ title="Set AX focus to this node"
182
+ style={axNodeActionButtonStyle}
183
+ onClick={(e) => {
184
+ e.stopPropagation();
185
+ void runNodeAxInteraction(node, 'ax.focus.set', undefined, 'Focus set');
186
+ }}
187
+ >
188
+ Set focus
189
+ </button>
190
+ </div>
191
+
175
192
  {tokenLimit !== null && tokenLimit > 0 && (
176
193
  <div style={{ marginBottom: '8px' }}>
177
194
  <div
@@ -1,8 +1,11 @@
1
1
  import type { CallToolResult, ListToolsResult, RequestId, Tool } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge';
4
- import { useEffect, useRef, useState } from 'preact/hooks';
4
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
5
5
  import { extAppToolResultsMatch } from '../../shared/ext-app-tool-result.js';
6
+ import { DEFAULT_EXT_APP_SANDBOX } from '../../shared/surface.js';
7
+ import { submitAxInteractionFromClient } from '../state/intent-bridge';
8
+ import { showToast } from '../state/attention-bridge';
6
9
  import {
7
10
  canvasTheme,
8
11
  collapseExpandedNode,
@@ -20,7 +23,6 @@ type McpUiTheme = 'light' | 'dark';
20
23
 
21
24
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
22
25
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
23
- const DEFAULT_EXT_APP_SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
24
26
 
25
27
  interface ExtAppHostDimensionsTarget {
26
28
  clientWidth?: number;
@@ -156,7 +158,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
156
158
  const nodeId = node.id;
157
159
  const frameKey = getExtAppBridgeInitKey(node, retryKey);
158
160
  const iframeSandbox = resolveExtAppSandbox(null);
159
- const iframeDocument = useIframeDocument(html ?? '', iframeSandbox);
161
+ // Phase 6 opt-in ext-app AX bridge. When the node sets data.axCapabilities.enabled,
162
+ // inject window.PMX_AX into the app HTML and accept emits below (server re-validates).
163
+ const axCaps = node.data.axCapabilities as { enabled?: boolean } | undefined;
164
+ const axEnabled = axCaps?.enabled === true && typeof html === 'string' && html.length > 0;
165
+ const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
166
+ const axBridgeScript = axEnabled
167
+ ? `<script data-pmx-canvas-ax-bridge>window.PMX_AX={emit:function(t,p){window.parent.postMessage({source:'pmx-canvas-ax',token:${JSON.stringify(axToken)},nodeId:${JSON.stringify(nodeId)},interaction:{type:String(t),payload:p&&typeof p==='object'?p:{}}},'*');}};</script>`
168
+ : '';
169
+ const iframeDocument = useIframeDocument((html ?? '') + axBridgeScript, iframeSandbox);
170
+
171
+ useEffect(() => {
172
+ if (!axEnabled) return;
173
+ function onAxMessage(event: MessageEvent) {
174
+ if (event.source !== iframeRef.current?.contentWindow) return;
175
+ const data = event.data as {
176
+ source?: string; token?: string; nodeId?: string;
177
+ interaction?: { type?: unknown; payload?: unknown };
178
+ } | null;
179
+ if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== nodeId) return;
180
+ const interaction = data.interaction;
181
+ if (!interaction || typeof interaction.type !== 'string') return;
182
+ void submitAxInteractionFromClient({
183
+ type: interaction.type,
184
+ sourceNodeId: nodeId,
185
+ sourceSurface: 'mcp-app',
186
+ ...(interaction.payload && typeof interaction.payload === 'object'
187
+ ? { payload: interaction.payload as Record<string, unknown> }
188
+ : {}),
189
+ }).then((res) => {
190
+ if (res.ok) showToast('context', 'AX interaction', interaction.type as string, [nodeId]);
191
+ else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
192
+ });
193
+ }
194
+ window.addEventListener('message', onAxMessage);
195
+ return () => window.removeEventListener('message', onAxMessage);
196
+ }, [axEnabled, axToken, nodeId]);
160
197
  const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
161
198
  const isExpanded = expanded || expandedNodeId.value === nodeId;
162
199
 
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'preact/hooks';
2
2
  import { updateNodeData } from '../state/canvas-store';
3
3
  import { fetchFile, updateNodeFromClient } from '../state/intent-bridge';
4
4
  import type { CanvasNodeState } from '../types';
5
+ import { runNodeAxInteraction } from './ax-node-actions';
5
6
 
6
7
  /** Guess a language label from a file extension for display. */
7
8
  function langFromPath(path: string): string {
@@ -170,6 +171,31 @@ export function FileNode({
170
171
  {new Date(updatedAt).toLocaleTimeString()}
171
172
  </span>
172
173
  )}
174
+ <button
175
+ type="button"
176
+ class="ax-node-action"
177
+ title="Mark this file as AX evidence"
178
+ onClick={(e) => {
179
+ e.stopPropagation();
180
+ void runNodeAxInteraction(
181
+ node,
182
+ 'ax.evidence.add',
183
+ { kind: 'file', title: filePath.split('/').pop() || filePath, ref: filePath },
184
+ 'Marked as evidence',
185
+ );
186
+ }}
187
+ style={{
188
+ background: 'none',
189
+ border: 'none',
190
+ color: 'var(--c-muted)',
191
+ cursor: 'pointer',
192
+ padding: '2px 4px',
193
+ fontSize: '12px',
194
+ flexShrink: 0,
195
+ }}
196
+ >
197
+
198
+ </button>
173
199
  <button
174
200
  type="button"
175
201
  onClick={handleReload}