pmx-canvas 0.1.28 → 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 (67) hide show
  1. package/CHANGELOG.md +193 -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 +56 -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 +190 -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/LedgerNode.tsx +39 -5
  42. package/src/client/nodes/McpAppNode.tsx +47 -2
  43. package/src/client/nodes/StatusNode.tsx +20 -0
  44. package/src/client/nodes/ax-node-actions.ts +39 -0
  45. package/src/client/nodes/surface-url.ts +48 -0
  46. package/src/client/state/attention-bridge.ts +5 -0
  47. package/src/client/state/intent-bridge.ts +33 -0
  48. package/src/client/theme/global.css +13 -0
  49. package/src/client/theme/surface-theme.css +142 -0
  50. package/src/json-render/renderer/index.tsx +31 -0
  51. package/src/json-render/schema.ts +4 -0
  52. package/src/json-render/server.ts +31 -1
  53. package/src/mcp/canvas-access.ts +212 -1
  54. package/src/mcp/server.ts +238 -5
  55. package/src/server/ax-context.ts +3 -0
  56. package/src/server/ax-interaction.ts +549 -0
  57. package/src/server/ax-state.ts +188 -2
  58. package/src/server/canvas-db.ts +20 -0
  59. package/src/server/canvas-operations.ts +11 -0
  60. package/src/server/canvas-serialization.ts +9 -0
  61. package/src/server/canvas-state.ts +177 -16
  62. package/src/server/html-surface.ts +170 -0
  63. package/src/server/index.ts +105 -1
  64. package/src/server/mutation-history.ts +5 -0
  65. package/src/server/placement.ts +5 -1
  66. package/src/server/server.ts +305 -0
  67. package/src/shared/surface.ts +38 -0
@@ -0,0 +1,142 @@
1
+ /*
2
+ * surface-theme.css — canonical theme token layer for embedded HTML surfaces.
3
+ *
4
+ * This is the same token set HtmlNode used to inline via buildThemeStyleBlock(),
5
+ * but expressed as a static, same-origin stylesheet so the server can serve a
6
+ * node's HTML surface (/api/canvas/surface/:nodeId) as a real standalone
7
+ * document. Sandboxed (opaque-origin) surface documents can still load this
8
+ * same-origin stylesheet, and live theme switching works by toggling the
9
+ * <html data-theme="..."> attribute — every theme block lives here.
10
+ *
11
+ * Tokens are written as LITERAL values per theme (not var() indirection) so that
12
+ * authored HTML reading them via getComputedStyle().getPropertyValue('--color-*')
13
+ * gets a resolved color, matching the previous inlined-style behavior.
14
+ *
15
+ * Core --c-* values MUST stay in sync with src/client/theme/global.css, and each
16
+ * --color-* alias mirrors its core token. Both are guarded by
17
+ * tests/unit/surface-theme-tokens.test.ts — update them together.
18
+ */
19
+
20
+ :root {
21
+ /* ── Core palette (dark · ink + cyan) ────────────────────── */
22
+ --c-bg: #081524;
23
+ --c-panel: #0f1d31;
24
+ --c-panel-soft: #0a1729;
25
+ --c-line: #1b2c44;
26
+ --c-text: #e6eef7;
27
+ --c-text-soft: #c7d3ea;
28
+ --c-muted: #8ea3bd;
29
+ --c-dim: #5c6b80;
30
+ --c-accent: #4BBCFF;
31
+ --c-ok: #2fd07f;
32
+ --c-warn: #f4c542;
33
+ --c-warn-alt: #FFB300;
34
+ --c-danger: #ff6a7f;
35
+ --c-purple: #b07aff;
36
+
37
+ /* Common aliases authored HTML might use. */
38
+ --color-bg: #081524;
39
+ --color-panel: #0f1d31;
40
+ --color-surface: #0a1729;
41
+ --color-border: #1b2c44;
42
+ --color-text: #e6eef7;
43
+ --color-text-primary: #e6eef7;
44
+ --color-text-secondary: #c7d3ea;
45
+ --color-text-muted: #8ea3bd;
46
+ --color-text-dim: #5c6b80;
47
+ --color-accent: #4BBCFF;
48
+ --color-success: #2fd07f;
49
+ --color-warning: #f4c542;
50
+ --color-danger: #ff6a7f;
51
+
52
+ --font: "IBM Plex Sans", "SF Pro Text", "Avenir Next", system-ui, sans-serif;
53
+ --mono: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
54
+ --font-sans: "IBM Plex Sans", "SF Pro Text", "Avenir Next", system-ui, sans-serif;
55
+ --font-mono: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
56
+
57
+ color-scheme: dark light;
58
+ }
59
+
60
+ :root[data-theme="light"] {
61
+ /* ── Core palette (light · paper + ink) ──────────────────── */
62
+ --c-bg: #F4EFE6;
63
+ --c-panel: #EFE7D4;
64
+ --c-panel-soft: #ECE4D0;
65
+ --c-line: #D6CBB4;
66
+ --c-text: #081524;
67
+ --c-text-soft: #3d4d63;
68
+ --c-muted: #5c6b80;
69
+ --c-dim: #8794a6;
70
+ --c-accent: #1A7ABF;
71
+ --c-ok: #1a9f55;
72
+ --c-warn: #c89b2a;
73
+ --c-warn-alt: #b8860b;
74
+ --c-danger: #d32f2f;
75
+ --c-purple: #7c4dff;
76
+
77
+ --color-bg: #F4EFE6;
78
+ --color-panel: #EFE7D4;
79
+ --color-surface: #ECE4D0;
80
+ --color-border: #D6CBB4;
81
+ --color-text: #081524;
82
+ --color-text-primary: #081524;
83
+ --color-text-secondary: #3d4d63;
84
+ --color-text-muted: #5c6b80;
85
+ --color-text-dim: #8794a6;
86
+ --color-accent: #1A7ABF;
87
+ --color-success: #1a9f55;
88
+ --color-warning: #c89b2a;
89
+ --color-danger: #d32f2f;
90
+ }
91
+
92
+ :root[data-theme="high-contrast"] {
93
+ /* ── Core palette (high-contrast) ────────────────────────── */
94
+ --c-bg: #000000;
95
+ --c-panel: #0a0a0a;
96
+ --c-panel-soft: #0a0a0a;
97
+ --c-line: #ffffff;
98
+ --c-text: #ffffff;
99
+ --c-text-soft: #dddddd;
100
+ --c-muted: #aaaaaa;
101
+ --c-dim: #888888;
102
+ --c-accent: #00ffff;
103
+ --c-ok: #00ff00;
104
+ --c-warn: #ffff00;
105
+ --c-warn-alt: #ffcc00;
106
+ --c-danger: #ff0000;
107
+ --c-purple: #e040fb;
108
+
109
+ --color-bg: #000000;
110
+ --color-panel: #0a0a0a;
111
+ --color-surface: #0a0a0a;
112
+ --color-border: #ffffff;
113
+ --color-text: #ffffff;
114
+ --color-text-primary: #ffffff;
115
+ --color-text-secondary: #dddddd;
116
+ --color-text-muted: #aaaaaa;
117
+ --color-text-dim: #888888;
118
+ --color-accent: #00ffff;
119
+ --color-success: #00ff00;
120
+ --color-warning: #ffff00;
121
+ --color-danger: #ff0000;
122
+ }
123
+
124
+ html,
125
+ body {
126
+ margin: 0;
127
+ padding: 0;
128
+ background: var(--c-bg);
129
+ color: var(--c-text);
130
+ font-family: var(--font, system-ui, sans-serif);
131
+ font-size: 14px;
132
+ line-height: 1.5;
133
+ }
134
+
135
+ body {
136
+ padding: 16px;
137
+ box-sizing: border-box;
138
+ }
139
+
140
+ a {
141
+ color: var(--c-accent);
142
+ }
@@ -79,9 +79,39 @@ declare global {
79
79
  __PMX_CANVAS_JSON_RENDER_THEME__?: string;
80
80
  __PMX_CANVAS_JSON_RENDER_DISPLAY__?: string;
81
81
  __PMX_CANVAS_JSON_RENDER_DEVTOOLS__?: boolean;
82
+ __PMX_CANVAS_JSON_RENDER_NODE_ID__?: string;
83
+ __PMX_CANVAS_AX_TOKEN__?: string;
82
84
  }
83
85
  }
84
86
 
87
+ // AX interaction types a json-render spec can bind actions to. When an action
88
+ // named like one of these fires, we forward it to the parent canvas (which
89
+ // validates + submits through the capability-gated endpoint). Convention-based
90
+ // opt-in: spec authors name the action handler after the AX interaction type.
91
+ const AX_INTERACTION_HANDLER_NAMES = [
92
+ 'ax.event.record', 'ax.steer', 'ax.work.create', 'ax.work.update',
93
+ 'ax.evidence.add', 'ax.approval.request', 'ax.review.add', 'ax.focus.set',
94
+ 'ax.elicitation.request', 'ax.mode.request', 'ax.command.invoke',
95
+ ] as const;
96
+
97
+ function buildAxHandlers(): Record<string, (params: Record<string, unknown>) => void> {
98
+ const nodeId = window.__PMX_CANVAS_JSON_RENDER_NODE_ID__;
99
+ const token = window.__PMX_CANVAS_AX_TOKEN__;
100
+ const handlers: Record<string, (params: Record<string, unknown>) => void> = {};
101
+ if (!nodeId || !token) return handlers;
102
+ for (const type of AX_INTERACTION_HANDLER_NAMES) {
103
+ handlers[type] = (params: Record<string, unknown>) => {
104
+ window.parent.postMessage({
105
+ source: 'pmx-canvas-ax',
106
+ token,
107
+ nodeId,
108
+ interaction: { type, payload: params && typeof params === 'object' ? params : {} },
109
+ }, '*');
110
+ };
111
+ }
112
+ return handlers;
113
+ }
114
+
85
115
  function syncPreferredTheme(): void {
86
116
  const forced = window.__PMX_CANVAS_JSON_RENDER_THEME__;
87
117
  if (forced) {
@@ -125,6 +155,7 @@ function App() {
125
155
  registry={registry}
126
156
  initialState={spec.state ?? undefined}
127
157
  directives={pmxCanvasDirectives}
158
+ handlers={buildAxHandlers()}
128
159
  >
129
160
  <Renderer spec={spec} registry={registry} loading={false} />
130
161
  {window.__PMX_CANVAS_JSON_RENDER_DEVTOOLS__ ? (
@@ -10,6 +10,10 @@ export const schema = defineSchema(
10
10
  props: s.propsOf('catalog.components'),
11
11
  children: s.array(s.string()),
12
12
  visible: s.any(),
13
+ // Event→action bindings (on.press, on.change, …). Preserved through
14
+ // validation so spec authors can wire actions — including the ax.*
15
+ // handlers the viewer forwards to the canvas AX bridge.
16
+ on: s.any(),
13
17
  }),
14
18
  ),
15
19
  }),
@@ -459,6 +459,7 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
459
459
  resolvedType !== element.type ||
460
460
  JSON.stringify(normalizedProps) !== JSON.stringify(rawProps) ||
461
461
  !('visible' in element) ||
462
+ !('on' in element) ||
462
463
  !Array.isArray(element.children) ||
463
464
  normalizedChildren.length !== element.children.length;
464
465
 
@@ -468,6 +469,10 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
468
469
  type: resolvedType,
469
470
  props: normalizedProps,
470
471
  visible: 'visible' in element ? element.visible : true,
472
+ // The schema requires `on` (event→action bindings); default to an
473
+ // empty bindings object when absent so specs without any actions
474
+ // still validate (most elements have no `on`). Mirrors `visible`.
475
+ on: 'on' in element ? element.on : {},
471
476
  children: normalizedChildren,
472
477
  }
473
478
  : rawElement;
@@ -491,6 +496,7 @@ function normalizeJsonRenderInput(spec: unknown): unknown {
491
496
  root: {
492
497
  ...specRecord,
493
498
  visible: 'visible' in specRecord ? specRecord.visible : true,
499
+ on: 'on' in specRecord ? specRecord.on : {},
494
500
  children: Array.isArray(specRecord.children)
495
501
  ? specRecord.children.filter((child: unknown) => typeof child === 'string')
496
502
  : [],
@@ -563,6 +569,20 @@ function collectDataKeys(data: Array<Record<string, unknown>>): Set<string> {
563
569
  return keys;
564
570
  }
565
571
 
572
+ /**
573
+ * Pick the first candidate key that actually exists in the data. Lets a chart
574
+ * accept a conventional synonym (e.g. a bullet chart's "actual" measure as well
575
+ * as "value") without an explicit valueKey, instead of failing the data-key
576
+ * check on a reasonable guess.
577
+ */
578
+ function firstPresentDataKey(
579
+ data: Array<Record<string, unknown>>,
580
+ candidates: string[],
581
+ ): string | undefined {
582
+ const keys = collectDataKeys(data);
583
+ return candidates.find((key) => keys.has(key));
584
+ }
585
+
566
586
  function assertGraphDataKeys(
567
587
  data: Array<Record<string, unknown>>,
568
588
  chartType: GraphChartType,
@@ -729,7 +749,10 @@ export function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec {
729
749
  }
730
750
  case 'BulletChart': {
731
751
  chartProps.labelKey = input.labelKey ?? input.xKey ?? null;
732
- chartProps.valueKey = input.valueKey ?? input.yKey ?? 'value';
752
+ // The measure column is conventionally "value", but bullet charts also
753
+ // call it "actual" — accept either when no valueKey is given.
754
+ chartProps.valueKey =
755
+ input.valueKey ?? input.yKey ?? firstPresentDataKey(input.data, ['value', 'actual']) ?? 'value';
733
756
  chartProps.targetKey = input.targetKey ?? null;
734
757
  chartProps.rangesKey = input.rangesKey ?? null;
735
758
  chartProps.color = input.color ?? null;
@@ -918,7 +941,10 @@ export async function buildJsonRenderViewerHtml(options: {
918
941
  theme?: 'dark' | 'light' | 'high-contrast';
919
942
  display?: 'expanded';
920
943
  devtools?: boolean;
944
+ nodeId?: string;
945
+ axToken?: string;
921
946
  }): Promise<string> {
947
+ const sanitizeAxValue = (v?: string): string => (typeof v === 'string' ? v.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80) : '');
922
948
  try {
923
949
  await ensureJsonRenderBundle();
924
950
  const dir = bundleDir();
@@ -933,6 +959,10 @@ export async function buildJsonRenderViewerHtml(options: {
933
959
  ...(options.theme ? [`window.__PMX_CANVAS_JSON_RENDER_THEME__ = ${JSON.stringify(options.theme)};`] : []),
934
960
  ...(options.display ? [`window.__PMX_CANVAS_JSON_RENDER_DISPLAY__ = ${JSON.stringify(options.display)};`] : []),
935
961
  ...(options.devtools ? ['window.__PMX_CANVAS_JSON_RENDER_DEVTOOLS__ = true;'] : []),
962
+ ...(options.nodeId && options.axToken ? [
963
+ `window.__PMX_CANVAS_JSON_RENDER_NODE_ID__ = ${JSON.stringify(sanitizeAxValue(options.nodeId))};`,
964
+ `window.__PMX_CANVAS_AX_TOKEN__ = ${JSON.stringify(sanitizeAxValue(options.axToken))};`,
965
+ ] : []),
936
966
  jsBundle,
937
967
  ].join('\n');
938
968
  return buildAppHtml({
@@ -40,6 +40,22 @@ type SetAxFocusResult = ReturnType<PmxCanvas['setAxFocus']>;
40
40
  type RecordAxEventInput = Parameters<PmxCanvas['recordAxEvent']>[0];
41
41
  type RecordAxEventResult = ReturnType<PmxCanvas['recordAxEvent']>;
42
42
  type SendSteeringResult = ReturnType<PmxCanvas['sendSteering']>;
43
+ type SubmitAxInteractionInput = Parameters<PmxCanvas['submitAxInteraction']>[0];
44
+ type SubmitAxInteractionResult = ReturnType<PmxCanvas['submitAxInteraction']>;
45
+ type GetPendingSteeringResult = ReturnType<PmxCanvas['getPendingSteering']>;
46
+ type ListElicitationsResult = ReturnType<PmxCanvas['listElicitations']>;
47
+ type RequestElicitationInput = Parameters<PmxCanvas['requestElicitation']>[0];
48
+ type RequestElicitationResult = ReturnType<PmxCanvas['requestElicitation']>;
49
+ type RespondElicitationResult = ReturnType<PmxCanvas['respondElicitation']>;
50
+ type ListModeRequestsResult = ReturnType<PmxCanvas['listModeRequests']>;
51
+ type RequestModeInput = Parameters<PmxCanvas['requestMode']>[0];
52
+ type RequestModeResult = ReturnType<PmxCanvas['requestMode']>;
53
+ type ResolveModeRequestResult = ReturnType<PmxCanvas['resolveModeRequest']>;
54
+ type GetCommandRegistryResult = ReturnType<PmxCanvas['getCommandRegistry']>;
55
+ type InvokeCommandResult = ReturnType<PmxCanvas['invokeCommand']>;
56
+ type GetPolicyResult = ReturnType<PmxCanvas['getPolicy']>;
57
+ type SetPolicyInput = Parameters<PmxCanvas['setPolicy']>[0];
58
+ type SetPolicyResult = ReturnType<PmxCanvas['setPolicy']>;
43
59
  type GetAxTimelineQuery = Parameters<PmxCanvas['getAxTimeline']>[0];
44
60
  type GetAxTimelineResult = ReturnType<PmxCanvas['getAxTimeline']>;
45
61
  type AddWorkItemInput = Parameters<PmxCanvas['addWorkItem']>[0];
@@ -166,6 +182,19 @@ export interface CanvasAccess {
166
182
  listReviewAnnotations(): Promise<ListReviewAnnotationsResult>;
167
183
  getHostCapability(): Promise<GetHostCapabilityResult>;
168
184
  reportHostCapability(input: unknown, options?: { source?: PmxAxSource }): Promise<ReportHostCapabilityResult>;
185
+ submitAxInteraction(input: SubmitAxInteractionInput, options?: { source?: PmxAxSource }): Promise<SubmitAxInteractionResult>;
186
+ getPendingSteering(options?: { consumer?: string; limit?: number }): Promise<GetPendingSteeringResult>;
187
+ markSteeringDelivered(id: string): Promise<boolean>;
188
+ listElicitations(): Promise<ListElicitationsResult>;
189
+ requestElicitation(input: RequestElicitationInput, options?: { source?: PmxAxSource }): Promise<RequestElicitationResult>;
190
+ respondElicitation(id: string, response: Record<string, unknown>, options?: { source?: PmxAxSource }): Promise<RespondElicitationResult>;
191
+ listModeRequests(): Promise<ListModeRequestsResult>;
192
+ requestMode(input: RequestModeInput, options?: { source?: PmxAxSource }): Promise<RequestModeResult>;
193
+ resolveModeRequest(id: string, decision: 'approved' | 'rejected', options?: { resolution?: string; source?: PmxAxSource }): Promise<ResolveModeRequestResult>;
194
+ getCommandRegistry(): Promise<GetCommandRegistryResult>;
195
+ invokeCommand(name: string, args?: Record<string, unknown> | null, options?: { source?: PmxAxSource }): Promise<InvokeCommandResult>;
196
+ getPolicy(): Promise<GetPolicyResult>;
197
+ setPolicy(patch: SetPolicyInput, options?: { source?: PmxAxSource }): Promise<SetPolicyResult>;
169
198
  clear(): Promise<void>;
170
199
  search(query: string): Promise<SearchResult>;
171
200
  undo(): Promise<UndoRedoResult>;
@@ -329,6 +358,58 @@ class LocalCanvasAccess implements CanvasAccess {
329
358
  return this.canvas.addWorkItem(input, { source: options?.source ?? 'mcp' });
330
359
  }
331
360
 
361
+ async submitAxInteraction(input: SubmitAxInteractionInput, options?: { source?: PmxAxSource }): Promise<SubmitAxInteractionResult> {
362
+ return this.canvas.submitAxInteraction(input, { source: options?.source ?? 'mcp' });
363
+ }
364
+
365
+ async getPendingSteering(options?: { consumer?: string; limit?: number }): Promise<GetPendingSteeringResult> {
366
+ return this.canvas.getPendingSteering(options);
367
+ }
368
+
369
+ async markSteeringDelivered(id: string): Promise<boolean> {
370
+ return this.canvas.markSteeringDelivered(id);
371
+ }
372
+
373
+ async listElicitations(): Promise<ListElicitationsResult> {
374
+ return this.canvas.listElicitations();
375
+ }
376
+
377
+ async requestElicitation(input: RequestElicitationInput, options?: { source?: PmxAxSource }): Promise<RequestElicitationResult> {
378
+ return this.canvas.requestElicitation(input, { source: options?.source ?? 'mcp' });
379
+ }
380
+
381
+ async respondElicitation(id: string, response: Record<string, unknown>, options?: { source?: PmxAxSource }): Promise<RespondElicitationResult> {
382
+ return this.canvas.respondElicitation(id, response, { source: options?.source ?? 'mcp' });
383
+ }
384
+
385
+ async listModeRequests(): Promise<ListModeRequestsResult> {
386
+ return this.canvas.listModeRequests();
387
+ }
388
+
389
+ async requestMode(input: RequestModeInput, options?: { source?: PmxAxSource }): Promise<RequestModeResult> {
390
+ return this.canvas.requestMode(input, { source: options?.source ?? 'mcp' });
391
+ }
392
+
393
+ async resolveModeRequest(id: string, decision: 'approved' | 'rejected', options?: { resolution?: string; source?: PmxAxSource }): Promise<ResolveModeRequestResult> {
394
+ return this.canvas.resolveModeRequest(id, decision, { ...(options ?? {}), source: options?.source ?? 'mcp' });
395
+ }
396
+
397
+ async getCommandRegistry(): Promise<GetCommandRegistryResult> {
398
+ return this.canvas.getCommandRegistry();
399
+ }
400
+
401
+ async invokeCommand(name: string, args?: Record<string, unknown> | null, options?: { source?: PmxAxSource }): Promise<InvokeCommandResult> {
402
+ return this.canvas.invokeCommand(name, args ?? null, { source: options?.source ?? 'mcp' });
403
+ }
404
+
405
+ async getPolicy(): Promise<GetPolicyResult> {
406
+ return this.canvas.getPolicy();
407
+ }
408
+
409
+ async setPolicy(patch: SetPolicyInput, options?: { source?: PmxAxSource }): Promise<SetPolicyResult> {
410
+ return this.canvas.setPolicy(patch, { source: options?.source ?? 'mcp' });
411
+ }
412
+
332
413
  async updateWorkItem(id: string, patch: UpdateWorkItemPatch, options?: { source?: PmxAxSource }): Promise<UpdateWorkItemResult> {
333
414
  return this.canvas.updateWorkItem(id, patch, { source: options?.source ?? 'mcp' });
334
415
  }
@@ -790,6 +871,120 @@ class RemoteCanvasAccess implements CanvasAccess {
790
871
  return response.workItem;
791
872
  }
792
873
 
874
+ async submitAxInteraction(input: SubmitAxInteractionInput, options?: { source?: PmxAxSource }): Promise<SubmitAxInteractionResult> {
875
+ // The interaction endpoint returns its structured outcome (ok/code/error) in
876
+ // the body for both accepted and rejected interactions, so read the body
877
+ // regardless of HTTP status rather than throwing on a denial.
878
+ const response = await fetch(`${this.remoteBaseUrl}/api/canvas/ax/interaction`, {
879
+ method: 'POST',
880
+ headers: { 'Content-Type': 'application/json' },
881
+ body: JSON.stringify({ ...input, source: options?.source ?? 'mcp' }),
882
+ });
883
+ const body = await response.json().catch(() => null);
884
+ if (body && typeof body === 'object') return body as SubmitAxInteractionResult;
885
+ throw new Error(`Remote canvas interaction failed with HTTP ${response.status}`);
886
+ }
887
+
888
+ async getPendingSteering(options?: { consumer?: string; limit?: number }): Promise<GetPendingSteeringResult> {
889
+ const params = new URLSearchParams();
890
+ if (options?.consumer) params.set('consumer', options.consumer);
891
+ if (options?.limit) params.set('limit', String(options.limit));
892
+ const qs = params.toString();
893
+ const response = await this.requestJson<{ pending?: GetPendingSteeringResult }>(
894
+ 'GET',
895
+ `/api/canvas/ax/delivery/pending${qs ? `?${qs}` : ''}`,
896
+ );
897
+ return response.pending ?? [];
898
+ }
899
+
900
+ async markSteeringDelivered(id: string): Promise<boolean> {
901
+ const response = await this.requestJson<{ delivered?: boolean }>(
902
+ 'POST',
903
+ `/api/canvas/ax/delivery/${encodeURIComponent(id)}/mark`,
904
+ {},
905
+ );
906
+ return response.delivered ?? false;
907
+ }
908
+
909
+ async listElicitations(): Promise<ListElicitationsResult> {
910
+ const r = await this.requestJson<{ elicitations?: ListElicitationsResult }>('GET', '/api/canvas/ax/elicitation');
911
+ return r.elicitations ?? [];
912
+ }
913
+
914
+ async requestElicitation(input: RequestElicitationInput, options?: { source?: PmxAxSource }): Promise<RequestElicitationResult> {
915
+ const r = await this.requestJson<{ elicitation?: RequestElicitationResult }>('POST', '/api/canvas/ax/elicitation', {
916
+ ...input,
917
+ source: options?.source ?? 'mcp',
918
+ });
919
+ if (!r.elicitation) throw new Error('Remote canvas did not return an elicitation.');
920
+ return r.elicitation;
921
+ }
922
+
923
+ async respondElicitation(id: string, response: Record<string, unknown>, options?: { source?: PmxAxSource }): Promise<RespondElicitationResult> {
924
+ const res = await fetch(`${this.remoteBaseUrl}/api/canvas/ax/elicitation/${encodeURIComponent(id)}/respond`, {
925
+ method: 'POST',
926
+ headers: { 'Content-Type': 'application/json' },
927
+ body: JSON.stringify({ response, source: options?.source ?? 'mcp' }),
928
+ });
929
+ if (res.status === 404) return null;
930
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
931
+ return (await res.json() as { elicitation?: RespondElicitationResult }).elicitation ?? null;
932
+ }
933
+
934
+ async listModeRequests(): Promise<ListModeRequestsResult> {
935
+ const r = await this.requestJson<{ modeRequests?: ListModeRequestsResult }>('GET', '/api/canvas/ax/mode');
936
+ return r.modeRequests ?? [];
937
+ }
938
+
939
+ async requestMode(input: RequestModeInput, options?: { source?: PmxAxSource }): Promise<RequestModeResult> {
940
+ const r = await this.requestJson<{ modeRequest?: RequestModeResult }>('POST', '/api/canvas/ax/mode', {
941
+ ...input,
942
+ source: options?.source ?? 'mcp',
943
+ });
944
+ if (!r.modeRequest) throw new Error('Remote canvas did not return a mode request.');
945
+ return r.modeRequest;
946
+ }
947
+
948
+ async resolveModeRequest(id: string, decision: 'approved' | 'rejected', options?: { resolution?: string; source?: PmxAxSource }): Promise<ResolveModeRequestResult> {
949
+ const res = await fetch(`${this.remoteBaseUrl}/api/canvas/ax/mode/${encodeURIComponent(id)}/resolve`, {
950
+ method: 'POST',
951
+ headers: { 'Content-Type': 'application/json' },
952
+ body: JSON.stringify({ decision, ...(options?.resolution ? { resolution: options.resolution } : {}), source: options?.source ?? 'mcp' }),
953
+ });
954
+ if (res.status === 404) return null;
955
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
956
+ return (await res.json() as { modeRequest?: ResolveModeRequestResult }).modeRequest ?? null;
957
+ }
958
+
959
+ async getCommandRegistry(): Promise<GetCommandRegistryResult> {
960
+ const r = await this.requestJson<{ commands?: GetCommandRegistryResult }>('GET', '/api/canvas/ax/command');
961
+ return r.commands ?? [];
962
+ }
963
+
964
+ async invokeCommand(name: string, args?: Record<string, unknown> | null, options?: { source?: PmxAxSource }): Promise<InvokeCommandResult> {
965
+ const r = await this.requestJson<{ event?: InvokeCommandResult }>('POST', '/api/canvas/ax/command', {
966
+ name,
967
+ ...(args ? { args } : {}),
968
+ source: options?.source ?? 'mcp',
969
+ });
970
+ return r.event ?? null;
971
+ }
972
+
973
+ async getPolicy(): Promise<GetPolicyResult> {
974
+ const r = await this.requestJson<{ policy?: GetPolicyResult }>('GET', '/api/canvas/ax/policy');
975
+ if (!r.policy) throw new Error('Remote canvas did not return a policy.');
976
+ return r.policy;
977
+ }
978
+
979
+ async setPolicy(patch: SetPolicyInput, options?: { source?: PmxAxSource }): Promise<SetPolicyResult> {
980
+ const r = await this.requestJson<{ policy?: SetPolicyResult }>('POST', '/api/canvas/ax/policy', {
981
+ ...patch,
982
+ source: options?.source ?? 'mcp',
983
+ });
984
+ if (!r.policy) throw new Error('Remote canvas did not return a policy.');
985
+ return r.policy;
986
+ }
987
+
793
988
  async updateWorkItem(id: string, patch: UpdateWorkItemPatch, options?: { source?: PmxAxSource }): Promise<UpdateWorkItemResult> {
794
989
  const response = await fetch(`${this.remoteBaseUrl}/api/canvas/ax/work/${encodeURIComponent(id)}`, {
795
990
  method: 'PATCH',
@@ -1063,7 +1258,23 @@ export async function createCanvasAccess(): Promise<CanvasAccess> {
1063
1258
  const remoteBaseUrl = await findExistingCanvasServer(workspaceRoot, port);
1064
1259
  if (remoteBaseUrl) return new RemoteCanvasAccess(remoteBaseUrl);
1065
1260
 
1261
+ // No same-workspace server to attach to. Allow a port fallback so a daemon
1262
+ // already holding the preferred port (e.g. one serving a *different*
1263
+ // workspace) doesn't crash this MCP/SDK session with EADDRINUSE — start our
1264
+ // own canvas on a free port instead, and explain how to share one if intended.
1066
1265
  const canvas = createCanvas({ port });
1067
- await canvas.start({ open: true });
1266
+ await canvas.start({ open: true, allowPortFallback: true });
1267
+ const boundPort = canvas.port;
1268
+ if (boundPort !== port) {
1269
+ const occupant = await readHealth(`http://127.0.0.1:${port}`);
1270
+ const occupantWorkspace =
1271
+ typeof occupant?.workspace === 'string' ? ` (serving ${occupant.workspace})` : '';
1272
+ // stderr only — stdout is the MCP stdio JSON-RPC channel.
1273
+ process.stderr.write(
1274
+ `[pmx-canvas] preferred port ${port} was in use${occupantWorkspace}; ` +
1275
+ `started this canvas on port ${boundPort} instead. To share one canvas, run the daemon ` +
1276
+ `from this workspace or set PMX_CANVAS_URL / PMX_CANVAS_PORT to point at it.\n`,
1277
+ );
1278
+ }
1068
1279
  return new LocalCanvasAccess(canvas, workspaceRoot, port);
1069
1280
  }