pmx-canvas 0.1.31 → 0.1.32

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 (38) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/canvas/global.css +18 -3
  3. package/dist/canvas/index.js +57 -57
  4. package/dist/json-render/index.js +97 -97
  5. package/dist/types/client/nodes/surface-url.d.ts +7 -0
  6. package/dist/types/client/state/canvas-store.d.ts +1 -0
  7. package/dist/types/client/state/intent-bridge.d.ts +7 -0
  8. package/dist/types/json-render/renderer/index.d.ts +1 -0
  9. package/dist/types/json-render/server.d.ts +1 -0
  10. package/dist/types/server/ax-context.d.ts +24 -1
  11. package/dist/types/server/html-surface.d.ts +23 -0
  12. package/dist/types/server/index.d.ts +6 -0
  13. package/package.json +1 -1
  14. package/skills/pmx-canvas/SKILL.md +96 -1
  15. package/src/cli/agent.ts +18 -1
  16. package/src/client/App.tsx +3 -3
  17. package/src/client/canvas/CanvasNode.tsx +16 -1
  18. package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -1
  19. package/src/client/nodes/ContextNode.tsx +1 -1
  20. package/src/client/nodes/HtmlNode.tsx +26 -1
  21. package/src/client/nodes/McpAppNode.tsx +35 -8
  22. package/src/client/nodes/StatusNode.tsx +0 -20
  23. package/src/client/nodes/surface-url.ts +12 -0
  24. package/src/client/state/canvas-store.ts +4 -0
  25. package/src/client/state/intent-bridge.ts +19 -0
  26. package/src/client/state/sse-bridge.ts +17 -0
  27. package/src/client/theme/global.css +18 -3
  28. package/src/json-render/renderer/index.tsx +31 -2
  29. package/src/json-render/server.ts +3 -0
  30. package/src/mcp/canvas-access.ts +3 -0
  31. package/src/mcp/server.ts +23 -1
  32. package/src/server/ax-context.ts +49 -1
  33. package/src/server/ax-interaction.ts +3 -0
  34. package/src/server/ax-state.ts +3 -1
  35. package/src/server/canvas-state.ts +6 -1
  36. package/src/server/html-surface.ts +48 -11
  37. package/src/server/index.ts +8 -0
  38. package/src/server/server.ts +62 -4
@@ -20,3 +20,10 @@ export declare function nodeSurfaceUrl(nodeId: string, opts?: SurfaceUrlOptions)
20
20
  export declare function canOpenAsSite(node: CanvasNodeState): boolean;
21
21
  /** Open the node's surface in a new browser tab. */
22
22
  export declare function openNodeAsSite(node: CanvasNodeState): void;
23
+ /**
24
+ * Open the node's surface in the user's real SYSTEM browser via the server's OS
25
+ * launcher — for hosts (e.g. Codex) whose embedded browser makes a normal
26
+ * `_blank` tab feel in-place. Falls back to a normal new-tab open when the server
27
+ * can't launch (headless / PMX_CANVAS_DISABLE_BROWSER_OPEN).
28
+ */
29
+ export declare function openNodeInSystemBrowser(node: CanvasNodeState): Promise<void>;
@@ -9,6 +9,7 @@ export declare const sessionId: import("@preact/signals-core").Signal<string>;
9
9
  export declare const traceEnabled: import("@preact/signals-core").Signal<boolean>;
10
10
  export declare const canvasTheme: import("@preact/signals-core").Signal<string>;
11
11
  export declare const hasInitialServerLayout: import("@preact/signals-core").Signal<boolean>;
12
+ export declare const axSurfaceState: import("@preact/signals-core").Signal<unknown>;
12
13
  export declare const expandedNodeId: import("@preact/signals-core").Signal<string | null>;
13
14
  export declare const pendingExpandedNodeCloseId: import("@preact/signals-core").Signal<string | null>;
14
15
  export declare const pendingConnection: import("@preact/signals-core").Signal<{
@@ -126,6 +126,13 @@ export interface AxInteractionResponse {
126
126
  code?: string;
127
127
  error?: string;
128
128
  }
129
+ /** Fetch the compact AX state snapshot pushed into AX-enabled surfaces. */
130
+ export declare function fetchAxSurfaceState(): Promise<unknown>;
131
+ /** Ask the server to open a node's surface in the system browser. */
132
+ export declare function openNodeInSystemBrowserRequest(nodeId: string): Promise<{
133
+ ok: boolean;
134
+ opened: boolean;
135
+ }>;
129
136
  /** Submit a capability-gated AX interaction from a native node control. */
130
137
  export declare function submitAxInteractionFromClient(input: AxInteractionRequest): Promise<AxInteractionResponse>;
131
138
  /** Commit the current viewport to the authoritative server state. */
@@ -16,5 +16,6 @@ declare global {
16
16
  __PMX_CANVAS_JSON_RENDER_DEVTOOLS__?: boolean;
17
17
  __PMX_CANVAS_JSON_RENDER_NODE_ID__?: string;
18
18
  __PMX_CANVAS_AX_TOKEN__?: string;
19
+ __PMX_CANVAS_AX_STATE__?: unknown;
19
20
  }
20
21
  }
@@ -92,4 +92,5 @@ export declare function buildJsonRenderViewerHtml(options: {
92
92
  devtools?: boolean;
93
93
  nodeId?: string;
94
94
  axToken?: string;
95
+ axState?: unknown;
95
96
  }): Promise<string>;
@@ -1,3 +1,26 @@
1
- import { type PmxAxContext, type PmxAxPinnedContext } from './ax-state.js';
1
+ import { type PmxAxContext, type PmxAxPinnedContext, type PmxAxWorkItem, type PmxAxApprovalGate, type PmxAxReviewAnnotation, type PmxAxElicitation, type PmxAxModeRequest, type PmxAxPolicy } from './ax-state.js';
2
+ /**
3
+ * Compact, surface-safe view of the canvas-bound AX state, injected into (and
4
+ * pushed to) AX-enabled surfaces so authored boards can RENDER the work queue /
5
+ * focus, not just emit interactions. Deliberately excludes the timeline, pinned
6
+ * preamble, and serialized node bodies to keep the payload small.
7
+ */
8
+ export interface PmxAxSurfaceSnapshot {
9
+ focus: string[];
10
+ workItems: PmxAxWorkItem[];
11
+ approvalGates: PmxAxApprovalGate[];
12
+ reviewAnnotations: Array<Omit<PmxAxReviewAnnotation, 'body' | 'author'>>;
13
+ elicitations: PmxAxElicitation[];
14
+ modeRequests: PmxAxModeRequest[];
15
+ policy: PmxAxPolicy;
16
+ }
17
+ /**
18
+ * NOTE: this is whole-canvas AX state (every work item, etc.), exposed to ANY
19
+ * AX-enabled surface — reads are board-wide while emits are node-scoped. Acceptable
20
+ * under the single-workspace local-trust model, but author surfaces accordingly
21
+ * (don't embed untrusted third-party scripts in an AX-enabled surface). Sensitive
22
+ * human review text is redacted below.
23
+ */
24
+ export declare function buildCanvasAxSurfaceSnapshot(): PmxAxSurfaceSnapshot;
2
25
  export declare function buildCanvasAxPinnedContext(): PmxAxPinnedContext;
3
26
  export declare function buildCanvasAxContext(): PmxAxContext;
@@ -20,6 +20,24 @@ export declare const SURFACE_THEME_STYLESHEET = "/canvas/surface-theme.css";
20
20
  /** CSP sandbox tokens for an `html`/`html-primitive` surface — scripts only, opaque origin. */
21
21
  export declare const HTML_SURFACE_SANDBOX = "allow-scripts";
22
22
  export declare function normalizeSurfaceTheme(value: string | null | undefined): SurfaceTheme;
23
+ /**
24
+ * Bridge that exposes `window.PMX_AX.emit(type, payload)` to author HTML. Calls
25
+ * post a nonce-tagged message to the parent canvas, which validates the nonce +
26
+ * node id and submits the interaction through the capability-gated endpoint. Only
27
+ * injected when the node's AX capabilities are enabled (opt-in for `html`), and
28
+ * the server re-validates every interaction — so this is a convenience surface,
29
+ * not a trust boundary.
30
+ */
31
+ export declare function buildAxBridge(axToken: string, nodeId: string): string;
32
+ /**
33
+ * Read-side bridge: seeds `window.PMX_AX.state` with a snapshot of the canvas AX
34
+ * state and keeps it live via nonce-validated `ax-update` messages from the parent
35
+ * canvas. Author HTML can read `window.PMX_AX.state` and subscribe to the
36
+ * `pmx-ax-update` CustomEvent to render a live work queue / focus. Injected only
37
+ * alongside the emit bridge (AX-enabled nodes). Read-only — no capability beyond
38
+ * the existing AX-enabled gate.
39
+ */
40
+ export declare function buildAxStateBridge(axToken: string, snapshotJson: string): string;
23
41
  export interface HtmlSurfaceOptions {
24
42
  theme: SurfaceTheme;
25
43
  /**
@@ -38,6 +56,11 @@ export interface HtmlSurfaceOptions {
38
56
  axToken?: string;
39
57
  /** Node id stamped on emitted interactions. */
40
58
  nodeId?: string;
59
+ /**
60
+ * Initial AX state snapshot to seed `window.PMX_AX.state` (only used when
61
+ * axBridge is enabled). Kept live via parent → iframe `ax-update` messages.
62
+ */
63
+ axState?: unknown;
41
64
  }
42
65
  /**
43
66
  * Wrap author HTML into a complete, themed standalone document. Accepts either a
@@ -445,6 +445,12 @@ export declare class PmxCanvas extends EventEmitter {
445
445
  width?: number;
446
446
  height?: number;
447
447
  strictSize?: boolean;
448
+ /** Opt this html node into AX interactions (window.PMX_AX.emit). Clamped to
449
+ * the html capability ceiling server-side; cannot escalate. */
450
+ axCapabilities?: {
451
+ enabled?: boolean;
452
+ allowed?: string[];
453
+ };
448
454
  }): SdkCanvasNode;
449
455
  addHtmlPrimitive(input: {
450
456
  kind: HtmlPrimitiveKind;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
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",
@@ -770,7 +770,11 @@ server's `ui://` resource as an iframe node on the canvas
770
770
 
771
771
  Any renderable surface node can be opened full-page in its own browser tab — the same
772
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).
773
+ ↗ **Open as site** button (new tab) or the **Open in system browser** button in the
774
+ node title bar (or the expanded overlay). "Open in system browser" launches the real OS
775
+ browser via `POST /api/canvas/open-external` `{ nodeId }` (it opens only this server's own
776
+ surface URL; falls back to a normal new tab when the server can't launch) — use it when
777
+ the host's embedded browser (e.g. Codex) opens `_blank` tabs in-place.
774
778
 
775
779
  - Works for `html` / `html-primitive`, bundled `web-artifact`, `json-render` / `graph`,
776
780
  `webpage`, and hosted ext-app `mcp-app` nodes.
@@ -879,6 +883,97 @@ elicitation, or mode request. One envelope, many transports:
879
883
  Interactions request PMX-AX primitives only — never arbitrary shell, tool, MCP,
880
884
  or host execution.
881
885
 
886
+ #### Where AX can be used — node capability matrix
887
+
888
+ AX interactions are gated per node type. The lists below are each type's **ceiling**
889
+ — `data.axCapabilities.allowed` can NARROW it, never escalate beyond it.
890
+
891
+ **Enabled by default** (no opt-in needed — an agent/native control can emit straight away):
892
+
893
+ | Node type | Allowed AX interaction types |
894
+ |-----------|------------------------------|
895
+ | `markdown` | `ax.steer`, `ax.work.create`, `ax.evidence.add`, `ax.command.invoke`, `ax.event.record` |
896
+ | `context` | `ax.focus.set`, `ax.steer`, `ax.evidence.add`, `ax.command.invoke`, `ax.event.record` |
897
+ | `status` | `ax.work.create`, `ax.work.update`, `ax.approval.request`, `ax.mode.request`, `ax.event.record` |
898
+ | `file` | `ax.evidence.add`, `ax.review.add`, `ax.focus.set`, `ax.event.record` |
899
+ | `json-render` | `ax.work.create`, `ax.work.update`, `ax.evidence.add`, `ax.elicitation.request`, `ax.event.record` |
900
+ | `graph` | `ax.evidence.add`, `ax.focus.set`, `ax.event.record` |
901
+ | `ledger` | `ax.evidence.add`, `ax.event.record` |
902
+ | `trace` | `ax.evidence.add`, `ax.event.record` |
903
+ | `image` | `ax.evidence.add`, `ax.review.add` |
904
+ | `webpage` | `ax.evidence.add`, `ax.review.add`, `ax.focus.set`, `ax.event.record` |
905
+ | `group` | `ax.focus.set`, `ax.work.create`, `ax.command.invoke`, `ax.event.record` |
906
+
907
+ **Opt-in** — set `data.axCapabilities.enabled = true` (MCP: pass `axCapabilities` to
908
+ `canvas_add_html_node` or `canvas_update_node`; HTTP: nest under `data`):
909
+
910
+ | Node type | Allowed AX interaction types |
911
+ |-----------|------------------------------|
912
+ | `html` / `html-primitive` | the full set: `ax.work.create`, `ax.work.update`, `ax.steer`, `ax.approval.request`, `ax.review.add`, `ax.evidence.add`, `ax.focus.set`, `ax.elicitation.request`, `ax.mode.request`, `ax.command.invoke`, `ax.event.record` |
913
+ | `mcp-app` (incl. **web-artifact**) | `ax.event.record`, `ax.evidence.add`, `ax.work.create`, `ax.work.update`, `ax.focus.set`, `ax.elicitation.request` |
914
+
915
+ **Never (anchor-only):** internal `prompt` / `response` thread nodes — `ax.event.record`
916
+ only, no human-facing emit.
917
+
918
+ The 11 interaction types and what they create: `ax.work.create` / `ax.work.update`
919
+ (work-queue items, status todo→in-progress→blocked→done→cancelled), `ax.evidence.add`
920
+ (timeline evidence), `ax.review.add` (review annotation), `ax.focus.set` (agent focus
921
+ pointer), `ax.steer` (a steering message delivered to the agent), `ax.approval.request`
922
+ (approval gate), `ax.elicitation.request` (structured human input), `ax.mode.request`
923
+ (plan/execute/autonomous transition), `ax.command.invoke` (registry command), and
924
+ `ax.event.record` (diagnostic agent-event).
925
+
926
+ #### Building an AX surface in the canvas (emit + reflect)
927
+
928
+ AX surfaces are **composable** — you can build a live work board, review board, or
929
+ inbox as a canvas node that BOTH emits AX interactions AND renders the current AX
930
+ state. The read side mirrors the write side:
931
+
932
+ - **Opt in** (html/mcp-app are off by default): create with
933
+ `canvas_add_html_node({ html, axCapabilities: { enabled: true, allowed: ["ax.work.create","ax.work.update"] } })`,
934
+ or flip an existing node on with
935
+ `canvas_update_node({ id, axCapabilities: { enabled: true, allowed: [...] } })`.
936
+ json-render / graph nodes are enabled by default.
937
+ - **Emit (write):** in `html`, call `window.PMX_AX.emit("ax.work.create", { title })`;
938
+ in `json-render`, bind a control action named after the AX type
939
+ (`on: { press: { action: "ax.work.create", params: { title } } }`).
940
+ - **Reflect (read):** the canvas seeds the surface with a compact AX snapshot at
941
+ load (the same shape as `GET /api/canvas/ax/surface-snapshot`) and live-updates it
942
+ as AX state changes. Works on all three authored surface types:
943
+ - `html` / `html-primitive`: read `window.PMX_AX.state` (`{ focus, workItems,
944
+ approvalGates, reviewAnnotations, elicitations, modeRequests, policy }`) and
945
+ subscribe to the `pmx-ax-update` event:
946
+ `window.addEventListener("pmx-ax-update", e => render(e.detail))`.
947
+ - `json-render` / `graph`: the snapshot is bound under `/ax`, so a spec reads
948
+ `{ "$state": "/ax/workItems" }` and it stays live as work items change.
949
+ - `web-artifact` (mcp-app): the same `window.PMX_AX.state` + `pmx-ax-update` bridge
950
+ is injected at the `/artifact` route once the node opts in — author the React app
951
+ against `window.PMX_AX`, not direct `fetch()` (the artifact iframe is sandboxed
952
+ opaque-origin, so it can't call the API directly).
953
+
954
+ Minimal html work board (drop-in via `canvas_add_html_node`, `axCapabilities.enabled: true`):
955
+
956
+ ```html
957
+ <button onclick="window.PMX_AX.emit('ax.work.create',{title:'New task'})">+ Task</button>
958
+ <ul id="q"></ul>
959
+ <script>
960
+ function render(s){ document.getElementById('q').innerHTML =
961
+ ((s&&s.workItems)||[]).map(w => '<li>['+w.status+'] '+w.title+'</li>').join(''); }
962
+ render(window.PMX_AX && window.PMX_AX.state);
963
+ window.addEventListener('pmx-ax-update', e => render(e.detail));
964
+ </script>
965
+ ```
966
+
967
+ This is the right home for a deliberate, interactive AX experience — not the
968
+ native node buttons. Any agent (via MCP/SDK) can also create/update the same work
969
+ items, and the board reflects them live.
970
+
971
+ > Security note: an AX-enabled surface can READ the whole canvas AX board (all
972
+ > work items, focus, approval gates, etc. — human review comment text is redacted),
973
+ > while its EMITS are clamped to its own node. Under the single-workspace
974
+ > local-trust model this is fine, but don't embed untrusted third-party scripts in
975
+ > an AX-enabled surface.
976
+
882
977
  ### Reading Spatial Intent
883
978
 
884
979
  The `canvas://spatial-context` resource reveals how the human has organized information:
package/src/cli/agent.ts CHANGED
@@ -1448,6 +1448,8 @@ cmd('node update', 'Update a node by ID', [
1448
1448
  'pmx-canvas node update <node-id> --spec-file ./dashboard.json',
1449
1449
  'pmx-canvas node update <graph-id> --data-file ./metrics.json --chart-height 420',
1450
1450
  'pmx-canvas node update <node-id> --pinned true',
1451
+ 'pmx-canvas node update <node-id> --dock-position right',
1452
+ 'pmx-canvas node update <node-id> --dock-position none # undock back to the canvas',
1451
1453
  'pmx-canvas node update <node-id> --lock-arrange',
1452
1454
  ], async (args) => {
1453
1455
  const { positional, flags } = parseFlags(args);
@@ -1521,10 +1523,25 @@ cmd('node update', 'Update a node by ID', [
1521
1523
 
1522
1524
  if (pinned !== undefined) body.pinned = pinned;
1523
1525
 
1526
+ // --dock-position left|right|none : dock a node into the top HUD or undock it.
1527
+ // `none`/`null`/empty map to JS null (undock). Assigned with a !== undefined
1528
+ // guard so the null survives JSON.stringify to the server (which accepts a
1529
+ // top-level dockPosition: null). HTTP PATCH already supports this; this is the
1530
+ // CLI path the report (#40) found missing.
1531
+ const dockRaw = getStringFlag(flags, 'dock-position', 'dockPosition');
1532
+ let dockPosition: 'left' | 'right' | null | undefined;
1533
+ if (dockRaw !== undefined) {
1534
+ const v = dockRaw.trim().toLowerCase();
1535
+ if (v === 'left' || v === 'right') dockPosition = v;
1536
+ else if (v === 'none' || v === 'null' || v === '') dockPosition = null;
1537
+ else die(`Invalid --dock-position "${dockRaw}".`, 'Use left, right, or none (to undock).');
1538
+ }
1539
+ if (dockPosition !== undefined) body.dockPosition = dockPosition;
1540
+
1524
1541
  if (Object.keys(body).length === 0) {
1525
1542
  die(
1526
1543
  'No updates specified',
1527
- 'Use --title, --content, --x, --y, --width, --height, --strict-size, --pinned, trace fields, --lock-arrange, --unlock-arrange, or --stdin',
1544
+ 'Use --title, --content, --x, --y, --width, --height, --strict-size, --pinned, --dock-position, trace fields, --lock-arrange, --unlock-arrange, or --stdin',
1528
1545
  );
1529
1546
  }
1530
1547
 
@@ -167,7 +167,7 @@ function Toolbar({
167
167
  <ToolbarHint label="Canvas status" detail={hasSynced ? statusLabel : 'Syncing canvas from server'} align="start">
168
168
  <span class={`connection-dot ${status}`} aria-label={`Canvas status: ${statusTitle}`} />
169
169
  </ToolbarHint>
170
- <span style={{ fontSize: '11px', color: 'var(--c-muted)' }}>
170
+ <span class="hud-collapsible-text" style={{ fontSize: '11px', color: 'var(--c-muted)' }}>
171
171
  {sessionId.value ? sessionId.value.slice(0, 12) : '…'}
172
172
  </span>
173
173
 
@@ -209,7 +209,7 @@ function Toolbar({
209
209
  <IconZoomOut />
210
210
  </button>
211
211
  </ToolbarHint>
212
- <span style={{ fontSize: '10px', color: 'var(--c-dim)', minWidth: '36px', textAlign: 'center' }}>
212
+ <span class="hud-collapsible-text" style={{ fontSize: '10px', color: 'var(--c-dim)', minWidth: '36px', textAlign: 'center' }}>
213
213
  {Math.round(v.scale * 100)}%
214
214
  </span>
215
215
 
@@ -357,7 +357,7 @@ function Toolbar({
357
357
  </button>
358
358
  </ToolbarHint>
359
359
 
360
- <span style={{ fontSize: '10px', color: 'var(--c-dim)' }}>{countsLabel}</span>
360
+ <span class="hud-collapsible-text" style={{ fontSize: '10px', color: 'var(--c-dim)' }}>{countsLabel}</span>
361
361
  </div>
362
362
  </div>
363
363
  );
@@ -25,7 +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
+ import { canOpenAsSite, openNodeAsSite, openNodeInSystemBrowser } from '../nodes/surface-url';
29
29
  import { getNodeIcon } from '../icons';
30
30
  import { EXPANDABLE_TYPES, TYPE_LABELS } from '../types';
31
31
  import type { CanvasNodeState } from '../types';
@@ -292,6 +292,21 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
292
292
 
293
293
  </button>
294
294
  )}
295
+ {/* Open in the real system browser (for hosts whose embedded browser
296
+ makes a normal new tab feel in-place, e.g. Codex). Falls back to a
297
+ new tab when the server can't launch the OS browser. */}
298
+ {canOpenAsSite(node) && (
299
+ <button
300
+ type="button"
301
+ onClick={(e) => {
302
+ e.stopPropagation();
303
+ void openNodeInSystemBrowser(node);
304
+ }}
305
+ title="Open in system browser"
306
+ >
307
+
308
+ </button>
309
+ )}
295
310
  {/* Expand — opens node as full-viewport overlay for focused work */}
296
311
  {EXPANDABLE_TYPES.has(node.type) && (
297
312
  <button
@@ -8,7 +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
+ import { canOpenAsSite, openNodeAsSite, openNodeInSystemBrowser } from '../nodes/surface-url';
12
12
  import { PromptNode } from '../nodes/PromptNode';
13
13
  import { ResponseNode } from '../nodes/ResponseNode';
14
14
  import { TraceNode } from '../nodes/TraceNode';
@@ -315,6 +315,17 @@ export function ExpandedNodeOverlay() {
315
315
  </button>
316
316
  )}
317
317
 
318
+ {canOpenAsSite(node) && (
319
+ <button
320
+ type="button"
321
+ class="expanded-action-btn"
322
+ onClick={() => void openNodeInSystemBrowser(node)}
323
+ title="Open in the system browser (e.g. Chrome) — useful when the host browser opens tabs in-place"
324
+ >
325
+ Open in system browser
326
+ </button>
327
+ )}
328
+
318
329
  {canPresent && (
319
330
  <button
320
331
  type="button"
@@ -178,7 +178,7 @@ export function ContextNode({
178
178
  <button
179
179
  type="button"
180
180
  class="ax-node-action"
181
- title="Set AX focus to this node"
181
+ title="Point the agent at this node — sets it as the agent's current AX focus so the agent pulls it into context to work on next (a one-click alternative to manually pinning)."
182
182
  style={axNodeActionButtonStyle}
183
183
  onClick={(e) => {
184
184
  e.stopPropagation();
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useRef } from 'preact/hooks';
2
- import { canvasTheme } from '../state/canvas-store';
2
+ import { axSurfaceState, canvasTheme } from '../state/canvas-store';
3
3
  import { submitAxInteractionFromClient } from '../state/intent-bridge';
4
4
  import { showToast } from '../state/attention-bridge';
5
5
  import type { CanvasNodeState } from '../types';
@@ -82,6 +82,23 @@ export function HtmlNode({
82
82
  if (autoFocus) iframeRef.current?.focus();
83
83
  }, [theme, themeToken]);
84
84
 
85
+ // Read-side AX bridge: push live AX state into the surface so an AX-enabled
86
+ // board reflects the work queue / focus. Validated by the surface against axToken.
87
+ // Gate matches the server's bridge-injection gate (enabled && allowed not empty)
88
+ // so we never push state to a surface the server left without the bridge.
89
+ const axCaps = node.data.axCapabilities as { enabled?: boolean; allowed?: unknown } | undefined;
90
+ const axEnabled = axCaps?.enabled === true && (!Array.isArray(axCaps.allowed) || axCaps.allowed.length > 0);
91
+ const axStateValue = axSurfaceState.value;
92
+ useEffect(() => {
93
+ if (!axEnabled || axStateValue == null) return;
94
+ iframeRef.current?.contentWindow?.postMessage({
95
+ source: 'pmx-canvas-html-node',
96
+ type: 'ax-update',
97
+ token: axToken,
98
+ state: axStateValue,
99
+ }, '*');
100
+ }, [axEnabled, axStateValue, axToken]);
101
+
85
102
  useEffect(() => {
86
103
  if (!autoFocus) return;
87
104
  const id = window.setTimeout(() => iframeRef.current?.focus(), 0);
@@ -95,6 +112,14 @@ export function HtmlNode({
95
112
  token: themeToken,
96
113
  theme,
97
114
  }, '*');
115
+ if (axEnabled && axSurfaceState.value != null) {
116
+ iframeRef.current?.contentWindow?.postMessage({
117
+ source: 'pmx-canvas-html-node',
118
+ type: 'ax-update',
119
+ token: axToken,
120
+ state: axSurfaceState.value,
121
+ }, '*');
122
+ }
98
123
  if (autoFocus) iframeRef.current?.focus();
99
124
  };
100
125
 
@@ -1,11 +1,11 @@
1
1
  import { useEffect, useMemo, useRef } from 'preact/hooks';
2
2
  import type { CanvasNodeState } from '../types';
3
- import { canvasTheme } from '../state/canvas-store';
3
+ import { axSurfaceState, canvasTheme } from '../state/canvas-store';
4
4
  import { submitAxInteractionFromClient } from '../state/intent-bridge';
5
5
  import { showToast } from '../state/attention-bridge';
6
6
  import { ExtAppFrame } from './ExtAppFrame';
7
7
 
8
- function withViewerParams(url: string, expanded: boolean, specVersion?: number, axToken?: string): string {
8
+ function withViewerParams(url: string, expanded: boolean, specVersion?: number, axToken?: string, axNodeId?: string): string {
9
9
  if (!url) return url;
10
10
  try {
11
11
  const resolved = new URL(url, window.location.origin);
@@ -14,8 +14,11 @@ function withViewerParams(url: string, expanded: boolean, specVersion?: number,
14
14
  // Streaming json-render nodes bump specVersion as patches accumulate; including
15
15
  // it in the src reloads the iframe so it re-reads the latest accumulated spec.
16
16
  if (typeof specVersion === 'number') resolved.searchParams.set('v', String(specVersion));
17
- // AX bridge nonce for json-render/graph viewer nodes (Phase 6 follow-up).
17
+ // AX bridge nonce for json-render/graph + web-artifact viewer nodes.
18
18
  if (axToken) resolved.searchParams.set('axToken', axToken);
19
+ // The /artifact route needs the node id to inject the AX bridge (the json-render
20
+ // view route already gets nodeId from its own query param).
21
+ if (axNodeId) resolved.searchParams.set('axNodeId', axNodeId);
19
22
  return resolved.toString();
20
23
  } catch {
21
24
  return url;
@@ -43,9 +46,18 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
43
46
 
44
47
  function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boolean }) {
45
48
  const iframeRef = useRef<HTMLIFrameElement>(null);
46
- // json-render / graph viewers run the json-render bundle, which can forward
47
- // spec actions named ax.* to us. Other viewers (web-artifact, hosted URLs) do not.
48
- const isAxViewer = node.type === 'json-render' || node.type === 'graph';
49
+ // json-render / graph viewers run the json-render bundle, which forwards spec
50
+ // actions named ax.* to us. AX-enabled web-artifacts get the same emit+read
51
+ // bridge injected at the /artifact route. Hosted URL viewers do not.
52
+ const isWebArtifact = node.type === 'mcp-app' && node.data.viewerType === 'web-artifact';
53
+ const isJsonViewer = node.type === 'json-render' || node.type === 'graph';
54
+ const axFlag = (node.data.axCapabilities as { enabled?: boolean } | undefined)?.enabled;
55
+ // json-render/graph are AX-enabled by default (opt OUT with enabled:false, matching
56
+ // the server seed gate); web-artifacts opt IN. So an opted-out viewer is not treated
57
+ // as an AX viewer — no token, no emit, no read-state push.
58
+ const axOn = isWebArtifact ? axFlag === true : axFlag !== false;
59
+ const isAxViewer = (isJsonViewer || isWebArtifact) && axOn;
60
+ const axSurface: 'json-render' | 'mcp-app' = isWebArtifact ? 'mcp-app' : 'json-render';
49
61
  const axToken = useMemo(() => (isAxViewer ? `ax-${crypto.randomUUID()}` : ''), [isAxViewer]);
50
62
 
51
63
  // Receive AX emits forwarded by the json-render viewer; validate (bound to this
@@ -65,7 +77,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
65
77
  void submitAxInteractionFromClient({
66
78
  type: interaction.type,
67
79
  sourceNodeId: node.id,
68
- sourceSurface: 'json-render',
80
+ sourceSurface: axSurface,
69
81
  ...(interaction.payload && typeof interaction.payload === 'object'
70
82
  ? { payload: interaction.payload as Record<string, unknown> }
71
83
  : {}),
@@ -78,8 +90,22 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
78
90
  return () => window.removeEventListener('message', onAxMessage);
79
91
  }, [isAxViewer, axToken, node.id]);
80
92
 
93
+ // Read-side: push live AX state into the json-render viewer so a spec bound to
94
+ // /ax reflects the work queue. Validated by the viewer against axToken.
95
+ const axStateValue = axSurfaceState.value;
96
+ const pushAxState = () => {
97
+ if (!isAxViewer || !axToken || axStateValue == null) return;
98
+ iframeRef.current?.contentWindow?.postMessage({
99
+ source: 'pmx-canvas-html-node',
100
+ type: 'ax-update',
101
+ token: axToken,
102
+ state: axStateValue,
103
+ }, '*');
104
+ };
105
+ useEffect(pushAxState, [isAxViewer, axToken, axStateValue]);
106
+
81
107
  const specVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : undefined;
82
- const url = withViewerParams((node.data.url as string) || '', expanded, specVersion, axToken || undefined);
108
+ const url = withViewerParams((node.data.url as string) || '', expanded, specVersion, axToken || undefined, isAxViewer ? node.id : undefined);
83
109
  const sourceServer = (node.data.sourceServer as string) || '';
84
110
  const hostMode = (node.data.hostMode as string) || 'hosted';
85
111
  const fallbackReason = node.data.fallbackReason as string | undefined;
@@ -146,6 +172,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
146
172
  sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
147
173
  allow="clipboard-read; clipboard-write"
148
174
  loading="lazy"
175
+ onLoad={pushAxState}
149
176
  style={{ flex: 1, minHeight: 0, width: '100%' }}
150
177
  title={`MCP App: ${sourceServer}`}
151
178
  />
@@ -1,6 +1,5 @@
1
1
  import { PHASE_COLORS } from '../theme/tokens';
2
2
  import type { CanvasNodeState } from '../types';
3
- import { axNodeActionButtonStyle, runNodeAxInteraction } from './ax-node-actions';
4
3
 
5
4
  export function getStatusDisplayPhase(node: CanvasNodeState): string {
6
5
  const phase = typeof node.data.phase === 'string' && node.data.phase.trim().length > 0
@@ -95,25 +94,6 @@ export function StatusNode({ node }: { node: CanvasNodeState }) {
95
94
  {message}
96
95
  </div>
97
96
  )}
98
-
99
- {/* AX: turn this status into a tracked work item */}
100
- <button
101
- type="button"
102
- class="ax-node-action"
103
- title="Create an AX work item tied to this node"
104
- style={axNodeActionButtonStyle}
105
- onClick={(e) => {
106
- e.stopPropagation();
107
- void runNodeAxInteraction(
108
- node,
109
- 'ax.work.create',
110
- { title: (node.data.title as string) || message || phase || 'Status update' },
111
- 'Tracked as work',
112
- );
113
- }}
114
- >
115
- Track as work
116
- </button>
117
97
  </div>
118
98
  );
119
99
  }
@@ -1,4 +1,5 @@
1
1
  import { canvasTheme } from '../state/canvas-store';
2
+ import { openNodeInSystemBrowserRequest } from '../state/intent-bridge';
2
3
  import { canOpenNodeAsSurface } from '../../shared/surface.js';
3
4
  import type { CanvasNodeState } from '../types';
4
5
 
@@ -46,3 +47,14 @@ export function canOpenAsSite(node: CanvasNodeState): boolean {
46
47
  export function openNodeAsSite(node: CanvasNodeState): void {
47
48
  window.open(nodeSurfaceUrl(node.id), '_blank', 'noopener');
48
49
  }
50
+
51
+ /**
52
+ * Open the node's surface in the user's real SYSTEM browser via the server's OS
53
+ * launcher — for hosts (e.g. Codex) whose embedded browser makes a normal
54
+ * `_blank` tab feel in-place. Falls back to a normal new-tab open when the server
55
+ * can't launch (headless / PMX_CANVAS_DISABLE_BROWSER_OPEN).
56
+ */
57
+ export async function openNodeInSystemBrowser(node: CanvasNodeState): Promise<void> {
58
+ const res = await openNodeInSystemBrowserRequest(node.id);
59
+ if (!res.opened) openNodeAsSite(node);
60
+ }
@@ -18,6 +18,10 @@ export const sessionId = signal<string>('');
18
18
  export const traceEnabled = signal<boolean>(false);
19
19
  export const canvasTheme = signal<string>('dark');
20
20
  export const hasInitialServerLayout = signal<boolean>(false);
21
+ // Compact AX state snapshot (work items, focus, …) mirrored from the server and
22
+ // pushed into AX-enabled surfaces so authored boards can render the live queue.
23
+ // Refreshed by the SSE bridge on ax-state-changed / ax-event-created.
24
+ export const axSurfaceState = signal<unknown>(null);
21
25
 
22
26
  // ── Expanded (focus) node ─────────────────────────────────────
23
27
  // Only one node at a time can be in expanded/focus mode. When expanded, the
@@ -255,6 +255,25 @@ export interface AxInteractionResponse {
255
255
  error?: string;
256
256
  }
257
257
 
258
+ /** Fetch the compact AX state snapshot pushed into AX-enabled surfaces. */
259
+ export async function fetchAxSurfaceState(): Promise<unknown> {
260
+ return requestJson<unknown>('fetchAxSurfaceState', '/api/canvas/ax/surface-snapshot', null);
261
+ }
262
+
263
+ /** Ask the server to open a node's surface in the system browser. */
264
+ export async function openNodeInSystemBrowserRequest(nodeId: string): Promise<{ ok: boolean; opened: boolean }> {
265
+ return requestJson<{ ok: boolean; opened: boolean }>(
266
+ 'openNodeInSystemBrowserRequest',
267
+ '/api/canvas/open-external',
268
+ { ok: false, opened: false },
269
+ {
270
+ method: 'POST',
271
+ headers: { 'Content-Type': 'application/json' },
272
+ body: JSON.stringify({ nodeId }),
273
+ },
274
+ );
275
+ }
276
+
258
277
  /** Submit a capability-gated AX interaction from a native node control. */
259
278
  export async function submitAxInteractionFromClient(input: AxInteractionRequest): Promise<AxInteractionResponse> {
260
279
  return requestJson<AxInteractionResponse>(