pmx-canvas 0.1.30 → 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 (43) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/dist/canvas/global.css +56 -59
  3. package/dist/canvas/index.js +59 -59
  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/canvas-state.d.ts +7 -0
  12. package/dist/types/server/html-surface.d.ts +29 -0
  13. package/dist/types/server/index.d.ts +19 -3
  14. package/dist/types/server/server.d.ts +12 -0
  15. package/docs/sdk.md +3 -1
  16. package/package.json +1 -1
  17. package/skills/pmx-canvas/SKILL.md +96 -1
  18. package/src/cli/agent.ts +18 -1
  19. package/src/cli/index.ts +8 -1
  20. package/src/client/App.tsx +3 -3
  21. package/src/client/canvas/CanvasNode.tsx +16 -1
  22. package/src/client/canvas/DockedNode.tsx +38 -38
  23. package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -1
  24. package/src/client/nodes/ContextNode.tsx +1 -1
  25. package/src/client/nodes/HtmlNode.tsx +26 -1
  26. package/src/client/nodes/McpAppNode.tsx +35 -8
  27. package/src/client/nodes/StatusNode.tsx +0 -20
  28. package/src/client/nodes/surface-url.ts +12 -0
  29. package/src/client/state/canvas-store.ts +4 -0
  30. package/src/client/state/intent-bridge.ts +19 -0
  31. package/src/client/state/sse-bridge.ts +17 -0
  32. package/src/client/theme/global.css +56 -59
  33. package/src/json-render/renderer/index.tsx +31 -2
  34. package/src/json-render/server.ts +3 -0
  35. package/src/mcp/canvas-access.ts +6 -1
  36. package/src/mcp/server.ts +23 -1
  37. package/src/server/ax-context.ts +49 -1
  38. package/src/server/ax-interaction.ts +3 -0
  39. package/src/server/ax-state.ts +3 -1
  40. package/src/server/canvas-state.ts +30 -11
  41. package/src/server/html-surface.ts +70 -13
  42. package/src/server/index.ts +32 -7
  43. package/src/server/server.ts +117 -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;
@@ -199,6 +199,13 @@ declare class CanvasStateManager {
199
199
  private emptyPersistedState;
200
200
  /** Load canvas state from SQLite (or legacy JSON fallback). Call once on server startup. */
201
201
  loadFromDisk(options?: LoadFromDiskOptions): boolean;
202
+ /**
203
+ * Whether this workspace's canvas DB already holds saved state. Used to gate
204
+ * brand-new-workspace seeding (e.g. the default docked status/context widgets)
205
+ * so we never add nodes to a canvas that already has content. Reflects the
206
+ * pre-run persisted flag until the next save.
207
+ */
208
+ hasPersistedState(): boolean;
202
209
  /** Debounced save — coalesces rapid mutations into a single write. */
203
210
  private scheduleSave;
204
211
  flushToDisk(): void;
@@ -20,8 +20,32 @@ 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;
43
+ /**
44
+ * Tab/document title. Injected as `<title>` only when the author HTML does not
45
+ * already declare one, so a standalone "Open as site" tab shows the node title
46
+ * instead of falling back to the raw URL.
47
+ */
48
+ title?: string;
25
49
  /** Client nonce that authorizes parent → iframe theme-update messages. */
26
50
  themeToken?: string;
27
51
  presentation?: boolean;
@@ -32,6 +56,11 @@ export interface HtmlSurfaceOptions {
32
56
  axToken?: string;
33
57
  /** Node id stamped on emitted interactions. */
34
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;
35
64
  }
36
65
  /**
37
66
  * Wrap author HTML into a complete, themed standalone document. Accepts either a
@@ -7,12 +7,22 @@ import type { AxTimelineQuery } from './canvas-db.js';
7
7
  import { searchNodes } from './spatial-analysis.js';
8
8
  import { diffLayouts } from './mutation-history.js';
9
9
  import { fitCanvasView, gcCanvasSnapshots, listCanvasSnapshots } from './canvas-operations.js';
10
+ import { type SerializedCanvasNode } from './canvas-serialization.js';
10
11
  import type { HtmlPrimitiveKind } from './html-primitives.js';
11
12
  import { type WebArtifactBuildInput, type WebArtifactCanvasBuildResult } from './web-artifacts.js';
12
13
  import { type ExternalMcpTransportConfig } from './mcp-app-runtime.js';
13
14
  import { type DiagramPresetOpenInput } from './diagram-presets.js';
14
15
  import { type GraphNodeInput, type JsonRenderNodeInput, type JsonRenderSpec } from '../json-render/server.js';
15
16
  import type { CanvasAutomationWebViewOptions, CanvasAutomationWebViewStatus } from './server.js';
17
+ /**
18
+ * Node object returned by the SDK's create/get methods. It is the fully
19
+ * serialized node (adds `surfaceUrl`, `kind`, `title`, `content`, …) plus a
20
+ * `nodeId` alias for `id`, so the SDK return shape matches the HTTP/CLI
21
+ * `node`-create responses field-for-field.
22
+ */
23
+ export type SdkCanvasNode = SerializedCanvasNode & {
24
+ nodeId: string;
25
+ };
16
26
  export declare class PmxCanvas extends EventEmitter {
17
27
  private _port;
18
28
  private _server;
@@ -54,7 +64,7 @@ export declare class PmxCanvas extends EventEmitter {
54
64
  width?: number;
55
65
  height?: number;
56
66
  strictSize?: boolean;
57
- }): CanvasNodeState;
67
+ }): SdkCanvasNode;
58
68
  addWebpageNode(input: {
59
69
  title?: string;
60
70
  url: string;
@@ -262,7 +272,7 @@ export declare class PmxCanvas extends EventEmitter {
262
272
  nodeIds?: string[];
263
273
  }): ReturnType<typeof fitCanvasView>;
264
274
  getLayout(): CanvasLayout;
265
- getNode(id: string): CanvasNodeState | undefined;
275
+ getNode(id: string): SdkCanvasNode | undefined;
266
276
  search(query: string): ReturnType<typeof searchNodes>;
267
277
  getSpatialContext(): import("./spatial-analysis.js").SpatialContext;
268
278
  undo(): Promise<{
@@ -435,7 +445,13 @@ export declare class PmxCanvas extends EventEmitter {
435
445
  width?: number;
436
446
  height?: number;
437
447
  strictSize?: boolean;
438
- }): string;
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
+ };
454
+ }): SdkCanvasNode;
439
455
  addHtmlPrimitive(input: {
440
456
  kind: HtmlPrimitiveKind;
441
457
  title?: string;
@@ -83,6 +83,18 @@ export declare function hasWorkbenchSubscribers(): boolean;
83
83
  export declare function setPrimaryWorkbenchCanvasPromptHandler(handler: PrimaryWorkbenchCanvasPromptHandler | null): void;
84
84
  export declare function buildMacBrowserOpenScript(appName: string, url: string): string;
85
85
  export declare function openUrlInExternalBrowser(url: string): boolean;
86
+ /**
87
+ * Seed the docked status (left) + context (right) widgets so a freshly opened
88
+ * canvas shows them by default — the same nodes the agent-event path creates on
89
+ * demand (`status-main`, `context-main`), just present from the start.
90
+ *
91
+ * First-run only: we bail if the workspace canvas already has persisted state,
92
+ * so we never add them to a board with content, and — because first-run state is
93
+ * persisted on save — deleting or undocking them later is respected (they are
94
+ * not re-seeded). Create-if-missing keeps it idempotent if the agent path
95
+ * already made one. Returns true if anything was seeded.
96
+ */
97
+ export declare function ensureDefaultDockedNodes(): boolean;
86
98
  export declare function emitPrimaryWorkbenchEvent(event: string, payload?: PrimaryWorkbenchEventPayload): void;
87
99
  export declare function consumePrimaryWorkbenchIntents(limit?: number): PrimaryWorkbenchIntent[];
88
100
  export declare function getPrimaryWorkbenchUrl(workspaceRoot?: string): string | null;
package/docs/sdk.md CHANGED
@@ -15,7 +15,9 @@ import { createCanvas } from 'pmx-canvas';
15
15
  const canvas = createCanvas({ port: 4313 });
16
16
  await canvas.start({ open: true });
17
17
 
18
- // Add nodes — addNode returns the created node (with `.id`, geometry, and data)
18
+ // Add nodes — addNode/getNode/addHtmlNode return the created node: `.id`
19
+ // (plus a `.nodeId` alias), geometry, `.data`, and `.surfaceUrl` for
20
+ // surface-eligible types (html, json-render, graph, …).
19
21
  const n1 = canvas.addNode({ type: 'markdown', title: 'Plan', content: '# Step 1\nDo the thing.' });
20
22
  const n2 = canvas.addNode({ type: 'status', title: 'Build', content: 'passing' });
21
23
  const n3 = canvas.addNode({ type: 'file', content: 'src/index.ts' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.30",
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
 
package/src/cli/index.ts CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { runAgentCli } from './agent.js';
7
7
  import { createCanvas } from '../server/index.js';
8
8
  import { seedDemoCanvas } from '../server/demo.js';
9
+ import { ensureDefaultDockedNodes } from '../server/server.js';
9
10
 
10
11
  const args = process.argv.slice(2);
11
12
 
@@ -599,7 +600,13 @@ Examples:
599
600
  process.exit(1);
600
601
  }
601
602
 
602
- if (demo && canvas.getLayout().nodes.length === 0) seedDemoCanvas();
603
+ if (demo && canvas.getLayout().nodes.length === 0) {
604
+ seedDemoCanvas();
605
+ } else if (!demo) {
606
+ // First-run only: dock a status (left) + context (right) widget by default so
607
+ // a fresh canvas isn't empty. No-op once the workspace has saved state.
608
+ ensureDefaultDockedNodes();
609
+ }
603
610
 
604
611
  console.log(`\n PMX Canvas running at http://localhost:${canvas.port}`);
605
612
  console.log(` Health: http://localhost:${canvas.port}/health\n`);
@@ -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
@@ -2,7 +2,7 @@ import { ContextNode } from '../nodes/ContextNode';
2
2
  import { LedgerNode } from '../nodes/LedgerNode';
3
3
  import { StatusNode } from '../nodes/StatusNode';
4
4
  import { StatusSummary } from '../nodes/StatusSummary';
5
- import { attentionHistoryOpen, closeAttentionHistory } from '../state/attention-store';
5
+ import { closeAttentionHistory } from '../state/attention-store';
6
6
  import { getContextPinnedNodes, toggleCollapsed, undockNode } from '../state/canvas-store';
7
7
  import { TYPE_LABELS } from '../types';
8
8
  import type { CanvasNodeState } from '../types';
@@ -40,44 +40,44 @@ function ContextDockedNode({ node }: { node: CanvasNodeState }) {
40
40
  toggleCollapsed(node.id);
41
41
  };
42
42
 
43
- // Hide the collapsed Context pill while the Updates side panel is open.
44
- // Mutual exclusion guarantees both panels can't be expanded simultaneously,
45
- // but the pill itself would otherwise sit beneath/beside the Updates panel
46
- // at the same right edge — better to hide until Updates is closed.
47
- if (collapsed && attentionHistoryOpen.value) return null;
48
-
49
43
  if (collapsed) {
44
+ // Collapsed = a menu-height pill in the right of the top HUD row, mirroring
45
+ // the docked status widget on the left so the bar reads as one continuous menu.
50
46
  return (
51
- <button
52
- type="button"
53
- class="context-dock-tab"
54
- data-docked-node="true"
55
- onClick={expand}
56
- aria-label={hasItems ? `Context — ${count} item${count === 1 ? '' : 's'}` : 'Context'}
57
- title={hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context` : 'Agent context'}
58
- >
59
- <svg
60
- width="14"
61
- height="14"
62
- viewBox="0 0 16 16"
63
- fill="none"
64
- stroke="currentColor"
65
- stroke-width="1.5"
66
- stroke-linecap="round"
67
- stroke-linejoin="round"
68
- aria-hidden="true"
69
- >
70
- <rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
71
- <line x1="1.5" y1="6" x2="14.5" y2="6" />
72
- <circle cx="4" cy="4.25" r="0.6" fill="currentColor" stroke="none" />
73
- </svg>
74
- <span class="context-dock-tab-label">Context</span>
75
- {hasItems && (
76
- <span class="context-dock-tab-badge" aria-hidden="true">
77
- {count > 99 ? '99+' : count}
78
- </span>
79
- )}
80
- </button>
47
+ <div class="docked-node docked-node--collapsed" data-docked-node="true">
48
+ <div class="docked-node-header">
49
+ <span class="node-type-badge">Context</span>
50
+ {hasItems && (
51
+ <span class="docked-node-count" aria-hidden="true">
52
+ {count > 99 ? '99+' : count}
53
+ </span>
54
+ )}
55
+ <div class="docked-node-controls">
56
+ <button
57
+ type="button"
58
+ onClick={(e) => {
59
+ e.stopPropagation();
60
+ expand();
61
+ }}
62
+ title={hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context — expand` : 'Expand agent context'}
63
+ aria-label={hasItems ? `Context — ${count} item${count === 1 ? '' : 's'}` : 'Expand agent context'}
64
+ >
65
+ {'▸'}
66
+ </button>
67
+ <button
68
+ type="button"
69
+ onClick={(e) => {
70
+ e.stopPropagation();
71
+ undockNode(node.id);
72
+ }}
73
+ title="Undock to canvas"
74
+ aria-label="Undock to canvas"
75
+ >
76
+ {'⊙'}
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </div>
81
81
  );
82
82
  }
83
83
 
@@ -130,7 +130,7 @@ export function DockedNode({ node }: { node: CanvasNodeState }) {
130
130
  }
131
131
 
132
132
  return (
133
- <div class="docked-node" data-docked-node="true">
133
+ <div class={`docked-node${node.collapsed ? ' docked-node--collapsed' : ''}`} data-docked-node="true">
134
134
  <div class="docked-node-header">
135
135
  <span class="node-type-badge">{TYPE_LABELS[node.type] ?? node.type}</span>
136
136
  {node.type === 'status' && node.collapsed && <StatusSummary node={node} />}
@@ -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();