pmx-canvas 0.1.26 → 0.1.28

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 (64) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +191 -0
  2. package/CHANGELOG.md +110 -0
  3. package/Readme.md +74 -27
  4. package/dist/canvas/index.js +82 -82
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/json-render/index.js +944 -164
  7. package/dist/types/json-render/catalog.d.ts +195 -20
  8. package/dist/types/json-render/charts/components.d.ts +17 -0
  9. package/dist/types/json-render/charts/definitions.d.ts +13 -1
  10. package/dist/types/json-render/charts/tufte-components.d.ts +65 -0
  11. package/dist/types/json-render/charts/tufte-definitions.d.ts +164 -0
  12. package/dist/types/json-render/directives.d.ts +33 -0
  13. package/dist/types/json-render/renderer/index.d.ts +1 -0
  14. package/dist/types/json-render/server.d.ts +32 -1
  15. package/dist/types/mcp/canvas-access.d.ts +62 -0
  16. package/dist/types/server/ax-state.d.ts +170 -0
  17. package/dist/types/server/canvas-db.d.ts +17 -1
  18. package/dist/types/server/canvas-operations.d.ts +53 -0
  19. package/dist/types/server/canvas-schema.d.ts +5 -1
  20. package/dist/types/server/canvas-state.d.ts +95 -4
  21. package/dist/types/server/index.d.ts +120 -3
  22. package/dist/types/server/mutation-history.d.ts +1 -1
  23. package/docs/cli.md +42 -0
  24. package/docs/http-api.md +64 -0
  25. package/docs/mcp.md +23 -5
  26. package/docs/node-types.md +1 -1
  27. package/docs/screenshots/codex-app.png +0 -0
  28. package/docs/screenshots/github-copilot-app.png +0 -0
  29. package/docs/sdk.md +23 -5
  30. package/package.json +10 -7
  31. package/skills/control-session-orchestrator/SKILL.md +359 -0
  32. package/skills/control-session-orchestrator/evals/evals.json +75 -0
  33. package/skills/data-analysis/SKILL.md +6 -0
  34. package/skills/pmx-canvas/SKILL.md +50 -4
  35. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +6 -0
  36. package/skills/tufte-viz/SKILL.md +157 -0
  37. package/skills/tufte-viz/references/analytical-design.md +217 -0
  38. package/skills/tufte-viz/references/tufte-principles.md +147 -0
  39. package/src/cli/agent.ts +302 -3
  40. package/src/cli/index.ts +2 -1
  41. package/src/client/nodes/ExtAppFrame.tsx +48 -1
  42. package/src/client/nodes/McpAppNode.tsx +6 -2
  43. package/src/json-render/catalog.ts +22 -1
  44. package/src/json-render/charts/components.tsx +127 -15
  45. package/src/json-render/charts/definitions.ts +19 -2
  46. package/src/json-render/charts/extra-components.tsx +5 -4
  47. package/src/json-render/charts/tufte-components.tsx +395 -0
  48. package/src/json-render/charts/tufte-definitions.ts +128 -0
  49. package/src/json-render/directives.ts +64 -0
  50. package/src/json-render/renderer/index.css +107 -1
  51. package/src/json-render/renderer/index.tsx +33 -0
  52. package/src/json-render/server.ts +275 -5
  53. package/src/mcp/canvas-access.ts +264 -1
  54. package/src/mcp/server.ts +498 -9
  55. package/src/server/ax-context.ts +8 -3
  56. package/src/server/ax-state.ts +447 -0
  57. package/src/server/canvas-db.ts +184 -1
  58. package/src/server/canvas-operations.ts +123 -2
  59. package/src/server/canvas-schema.ts +27 -3
  60. package/src/server/canvas-state.ts +349 -2
  61. package/src/server/index.ts +259 -7
  62. package/src/server/mutation-history.ts +6 -0
  63. package/src/server/server.ts +442 -5
  64. package/src/server/web-artifacts.ts +31 -5
@@ -22,9 +22,11 @@ import { searchNodes } from './spatial-analysis.js';
22
22
  import { getCanvasNodeTitle, serializeCanvasNodeCompact, type SerializedCanvasNode } from './canvas-serialization.js';
23
23
  import { computeAutoArrange } from '../shared/auto-arrange.js';
24
24
  import {
25
+ applyJsonRenderStreamPatches,
25
26
  buildGraphSpec,
26
27
  buildGraphConfig,
27
28
  createJsonRenderNodeData,
29
+ emptyStreamingSpec,
28
30
  GRAPH_NODE_SIZE,
29
31
  inferJsonRenderNodeTitle,
30
32
  JSON_RENDER_NODE_SIZE,
@@ -105,6 +107,12 @@ interface CanvasAddNodeInput {
105
107
 
106
108
  export const MARKDOWN_NODE_DEFAULT_SIZE = { width: 640, height: 420 };
107
109
  export const MCP_APP_NODE_DEFAULT_SIZE = { width: 960, height: 600 };
110
+ // Image and ledger nodes previously fell through to the generic 360x200 frame,
111
+ // which clipped content (a 360-wide image / log stream is cramped). Give them
112
+ // roomier defaults; height still auto-fits to content (see auto-fit.ts), so the
113
+ // width bump is the reliable lever.
114
+ export const IMAGE_NODE_DEFAULT_SIZE = { width: 480, height: 360 };
115
+ export const LEDGER_NODE_DEFAULT_SIZE = { width: 420, height: 280 };
108
116
 
109
117
  interface CanvasCreateGroupInput {
110
118
  title?: string;
@@ -827,6 +835,39 @@ export function scheduleCodeGraphRecompute(onComplete?: () => void): void {
827
835
  }, 300);
828
836
  }
829
837
 
838
+ /**
839
+ * Resolve an html-node `html` field that may be a path to a local .html/.htm file.
840
+ *
841
+ * If the string looks like a bare filesystem path to an existing HTML file
842
+ * (no markup, no newlines, short, ends in .html/.htm, exists on disk), read the
843
+ * file and return its contents. Otherwise return the string unchanged as raw HTML.
844
+ * On read failure, fall back to the raw string and warn — never throw.
845
+ *
846
+ * This is a local dev tool, so reading a user-pointed-at local file is acceptable;
847
+ * the markup/newline guards prevent misclassifying genuine HTML as a path.
848
+ */
849
+ export function resolveHtmlContent(html: string): string {
850
+ const trimmed = html.trim();
851
+ const looksLikePath =
852
+ trimmed.length > 0 &&
853
+ trimmed.length <= 1024 &&
854
+ !trimmed.includes('\n') &&
855
+ !trimmed.includes('<') &&
856
+ /\.html?$/i.test(trimmed);
857
+ if (!looksLikePath) return html;
858
+
859
+ const resolved = resolve(trimmed);
860
+ if (!existsSync(resolved) || !statSync(resolved).isFile()) return html;
861
+
862
+ try {
863
+ return readFileSync(resolved, 'utf-8');
864
+ } catch (error) {
865
+ const message = error instanceof Error ? error.message : String(error);
866
+ console.warn(`[pmx-canvas] html node: failed to read "${resolved}" (${message}); treating --content as raw HTML.`);
867
+ return html;
868
+ }
869
+ }
870
+
830
871
  export function addCanvasNode(input: CanvasAddNodeInput): {
831
872
  id: string;
832
873
  node: CanvasNodeState;
@@ -1471,6 +1512,78 @@ export function createCanvasJsonRenderNode(
1471
1512
  return { id, url: String(node.data.url), spec, node };
1472
1513
  }
1473
1514
 
1515
+ /**
1516
+ * Create an empty streaming json-render node. Unlike createCanvasJsonRenderNode
1517
+ * this does NOT validate a complete spec — the node starts blank and is filled
1518
+ * in by appendCanvasJsonRenderStream as SpecStream patches arrive.
1519
+ */
1520
+ export function createCanvasStreamingJsonRenderNode(input: {
1521
+ title?: string;
1522
+ x?: number;
1523
+ y?: number;
1524
+ width?: number;
1525
+ height?: number;
1526
+ strictSize?: boolean;
1527
+ }): { id: string; url: string; spec: JsonRenderSpec; node: CanvasNodeState } {
1528
+ const spec = emptyStreamingSpec();
1529
+ const width = input.width ?? JSON_RENDER_NODE_SIZE.width;
1530
+ const height = input.height ?? JSON_RENDER_NODE_SIZE.height;
1531
+ const position =
1532
+ input.x !== undefined && input.y !== undefined
1533
+ ? { x: input.x, y: input.y }
1534
+ : findOpenCanvasPosition(canvasState.getLayout().nodes, width, height);
1535
+ const id = `ui-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
1536
+ const node: CanvasNodeState = {
1537
+ id,
1538
+ type: 'json-render',
1539
+ position,
1540
+ size: { width, height },
1541
+ zIndex: 1,
1542
+ collapsed: false,
1543
+ pinned: false,
1544
+ dockPosition: null,
1545
+ data: createJsonRenderNodeData(id, input.title?.trim() || 'Streaming', spec, {
1546
+ viewerType: 'json-render',
1547
+ streamStatus: 'open',
1548
+ specVersion: 0,
1549
+ ...(input.strictSize ? { strictSize: true } : {}),
1550
+ }),
1551
+ };
1552
+
1553
+ canvasState.addJsonRenderNode(node);
1554
+ return { id, url: String(node.data.url), spec, node };
1555
+ }
1556
+
1557
+ /**
1558
+ * Apply a batch of SpecStream patches to an existing json-render node, bumping
1559
+ * its specVersion so the browser reloads the viewer with the accumulated spec.
1560
+ */
1561
+ export function appendCanvasJsonRenderStream(
1562
+ nodeId: string,
1563
+ patches: unknown[],
1564
+ done: boolean,
1565
+ ):
1566
+ | { ok: true; applied: number; skipped: number; specVersion: number; elementCount: number; streamStatus: 'open' | 'closed' }
1567
+ | { ok: false; error: string } {
1568
+ const node = canvasState.getNode(nodeId);
1569
+ if (!node) return { ok: false, error: `Node "${nodeId}" not found.` };
1570
+ if (node.type !== 'json-render') return { ok: false, error: `Node "${nodeId}" is not a json-render node.` };
1571
+
1572
+ const currentSpec = (node.data.spec as JsonRenderSpec | undefined) ?? emptyStreamingSpec();
1573
+ const { spec, applied, skipped } = applyJsonRenderStreamPatches(currentSpec, patches);
1574
+ const prevVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : 0;
1575
+ const specVersion = prevVersion + 1;
1576
+ const streamStatus: 'open' | 'closed' = done ? 'closed' : 'open';
1577
+
1578
+ canvasState.updateNode(nodeId, {
1579
+ data: { ...node.data, spec, specVersion, streamStatus },
1580
+ });
1581
+
1582
+ const elementCount =
1583
+ spec.elements && typeof spec.elements === 'object' ? Object.keys(spec.elements).length : 0;
1584
+ return { ok: true, applied, skipped, specVersion, elementCount, streamStatus };
1585
+ }
1586
+
1474
1587
  export function createCanvasGraphNode(
1475
1588
  input: GraphNodeInput,
1476
1589
  ): { id: string; url: string; spec: JsonRenderSpec; node: CanvasNodeState } {
@@ -1664,14 +1777,22 @@ export async function executeCanvasBatch(
1664
1777
  ? MARKDOWN_NODE_DEFAULT_SIZE.width
1665
1778
  : type === 'mcp-app'
1666
1779
  ? MCP_APP_NODE_DEFAULT_SIZE.width
1667
- : 360,
1780
+ : type === 'image'
1781
+ ? IMAGE_NODE_DEFAULT_SIZE.width
1782
+ : type === 'ledger'
1783
+ ? LEDGER_NODE_DEFAULT_SIZE.width
1784
+ : 360,
1668
1785
  defaultHeight: type === 'html'
1669
1786
  ? 640
1670
1787
  : type === 'markdown'
1671
1788
  ? MARKDOWN_NODE_DEFAULT_SIZE.height
1672
1789
  : type === 'mcp-app'
1673
1790
  ? MCP_APP_NODE_DEFAULT_SIZE.height
1674
- : 200,
1791
+ : type === 'image'
1792
+ ? IMAGE_NODE_DEFAULT_SIZE.height
1793
+ : type === 'ledger'
1794
+ ? LEDGER_NODE_DEFAULT_SIZE.height
1795
+ : 200,
1675
1796
  fileMode: 'auto',
1676
1797
  });
1677
1798
  result = { ok: true, ...serializeCreatedNode(created.node) };
@@ -56,6 +56,10 @@ const CANONICAL_GRAPH_TYPES = [
56
56
  'radar',
57
57
  'stacked-bar',
58
58
  'composed',
59
+ 'sparkline',
60
+ 'dot-plot',
61
+ 'bullet',
62
+ 'slopegraph',
59
63
  ] as const;
60
64
 
61
65
  type CanvasGraphType = typeof CANONICAL_GRAPH_TYPES[number];
@@ -406,9 +410,9 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
406
410
  fields: [
407
411
  {
408
412
  name: 'graphType',
409
- type: '"line" | "bar" | "pie" | "area" | "scatter" | "radar" | "stacked-bar" | "composed"',
413
+ type: '"line" | "bar" | "pie" | "area" | "scatter" | "radar" | "stacked-bar" | "composed" | "sparkline" | "dot-plot" | "bullet" | "slopegraph"',
410
414
  required: true,
411
- description: 'Chart type. Aliases like "stack" and "combo" are normalized server-side.',
415
+ description: 'Chart type. Includes the Tufte primitives sparkline, dot-plot (Cleveland), bullet (Few KPI vs target), and slopegraph (paired before/after). Aliases like "stack", "combo", "dot", and "slope" are normalized server-side.',
412
416
  aliases: ['graph-type'],
413
417
  },
414
418
  { name: 'data', type: 'Record<string, unknown>[]', required: true, description: 'Chart dataset. The CLI also accepts piped JSON via --stdin.', aliases: ['data-json', 'data-file'] },
@@ -417,7 +421,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
417
421
  { name: 'yKey', type: 'string', required: false, description: 'Y-axis value key for line, bar, area, and scatter charts. Also used as a fallback bar key for composed charts.', aliases: ['y-key'] },
418
422
  { name: 'zKey', type: 'string', required: false, description: 'Optional bubble-size key for scatter charts.', aliases: ['z-key'] },
419
423
  { name: 'nameKey', type: 'string', required: false, description: 'Slice name key for pie graphs.', aliases: ['name-key'] },
420
- { name: 'valueKey', type: 'string', required: false, description: 'Slice value key for pie graphs.', aliases: ['value-key'] },
424
+ { name: 'valueKey', type: 'string', required: false, description: 'Value key for pie slices, sparkline, dot-plot, and the bullet measure.', aliases: ['value-key'] },
421
425
  { name: 'axisKey', type: 'string', required: false, description: 'Category key for radar charts.', aliases: ['axis-key'] },
422
426
  { name: 'metrics', type: 'string[]', required: false, description: 'Series keys to plot as radar polygons. Defaults to non-axis numeric columns.' },
423
427
  { name: 'series', type: 'string[]', required: false, description: 'Series keys for stacked-bar segments. Defaults to non-x numeric columns.' },
@@ -427,6 +431,14 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
427
431
  { name: 'color', type: 'string', required: false, description: 'Optional series color for line, bar, area, and scatter charts.' },
428
432
  { name: 'barColor', type: 'string', required: false, description: 'Optional bar color for composed charts.', aliases: ['bar-color'] },
429
433
  { name: 'lineColor', type: 'string', required: false, description: 'Optional line color for composed charts.', aliases: ['line-color'] },
434
+ { name: 'colorBy', type: '"series" | "category" | "value" | "none"', required: false, description: 'Bar charts only: how bars are colored. Default "series" (single accent + one highlighted bar). "category" rotates the palette, "value" shades by magnitude, "none" is flat. Color should encode data, not decorate.', aliases: ['color-by'] },
435
+ { name: 'highlight', type: 'number | "max" | "min"', required: false, description: 'Bar charts (colorBy="series") only: which bar gets the accent — "max" (default), "min", a 0-based index, or null for no emphasis.' },
436
+ { name: 'labelKey', type: 'string', required: false, description: 'Category label key for dot-plot, bullet, and slopegraph rows.', aliases: ['label-key'] },
437
+ { name: 'targetKey', type: 'string', required: false, description: 'Per-row target value key for bullet charts.', aliases: ['target-key'] },
438
+ { name: 'rangesKey', type: 'string', required: false, description: 'Per-row qualitative band thresholds (number[]) key for bullet charts.', aliases: ['ranges-key'] },
439
+ { name: 'beforeKey', type: 'string', required: false, description: 'Left-column value key for slopegraph.', aliases: ['before-key'] },
440
+ { name: 'afterKey', type: 'string', required: false, description: 'Right-column value key for slopegraph.', aliases: ['after-key'] },
441
+ { name: 'sort', type: '"asc" | "desc" | "none"', required: false, description: 'Row sort order for dot-plot (defaults to desc).' },
430
442
  { name: 'height', type: 'number', required: false, description: 'Optional chart content height.', aliases: ['chart-height'] },
431
443
  { name: 'showLegend', type: 'boolean', required: false, description: 'Show chart legend when supported; pass false for compact node layouts.', aliases: ['show-legend'] },
432
444
  { name: 'showLabels', type: 'boolean', required: false, description: 'Show direct labels when supported, such as pie slice labels; defaults to true.', aliases: ['show-labels'] },
@@ -502,6 +514,7 @@ export function describeCanvasSchema(): {
502
514
  jsonRender: {
503
515
  rootShape: Record<string, string>;
504
516
  components: JsonRenderComponentDescriptor[];
517
+ directives: Array<{ name: string; usage: string }>;
505
518
  };
506
519
  graph: {
507
520
  graphTypes: CanvasGraphType[];
@@ -526,6 +539,16 @@ export function describeCanvasSchema(): {
526
539
  state: 'record<string, unknown> | optional',
527
540
  },
528
541
  components: clone(describeJsonRenderCatalog()),
542
+ directives: [
543
+ { name: '$state', usage: '{ "$state": "/path/to/value" } — read a value from the state model by path (one-way). Use this to bind a value by path; there is no $path directive.' },
544
+ { name: '$format', usage: '{ "$format": "currency"|"number"|"percent"|"date", "value": <num|state-ref>, "currency"?: "USD", "locale"?, "style"?, "options"? } — Intl-formatted string' },
545
+ { name: '$math', usage: '{ "$math": "add"|"subtract"|"multiply"|"divide"|"mod"|"min"|"max"|"round"|"floor"|"ceil"|"abs", "a": <num>, "b"?: <num> }' },
546
+ { name: '$concat', usage: '{ "$concat": [<value>, <value>, ...] } — join values into one string' },
547
+ { name: '$count', usage: '{ "$count": <array|state-ref> } — length of an array' },
548
+ { name: '$truncate', usage: '{ "$truncate": <string>, "length": <num>, "suffix"?: "…" }' },
549
+ { name: '$pluralize', usage: '{ "$pluralize": <count>, "one": "item", "other": "items" }' },
550
+ { name: '$join', usage: '{ "$join": <array>, "separator"?: ", " }' },
551
+ ],
529
552
  },
530
553
  graph: {
531
554
  graphTypes: [...CANONICAL_GRAPH_TYPES],
@@ -537,6 +560,7 @@ export function describeCanvasSchema(): {
537
560
  'canvas_add_html_node',
538
561
  'canvas_add_html_primitive',
539
562
  'canvas_add_json_render_node',
563
+ 'canvas_stream_json_render_node',
540
564
  'canvas_add_graph_node',
541
565
  'canvas_build_web_artifact',
542
566
  'canvas_open_mcp_app',
@@ -29,8 +29,19 @@ import {
29
29
  isDbPopulated,
30
30
  checkpointCanvasDb,
31
31
  finalizeCanvasDbForClose,
32
+ appendAxEventToDB,
33
+ appendAxEvidenceToDB,
34
+ appendAxSteeringToDB,
35
+ markAxSteeringDeliveredInDB,
36
+ loadAxEventsFromDB,
37
+ loadAxEvidenceFromDB,
38
+ loadAxSteeringFromDB,
39
+ loadAxTimelineSummaryFromDB,
40
+ upsertAxHostCapabilityToDB,
41
+ loadAxHostCapabilityFromDB,
32
42
  type PersistedCanvasState,
33
43
  type CanvasTheme,
44
+ type AxTimelineQuery,
34
45
  } from './canvas-db.js';
35
46
  import { normalizeCanvasTheme } from './canvas-db.js';
36
47
  import {
@@ -43,10 +54,34 @@ import {
43
54
  } from './placement.js';
44
55
  import {
45
56
  createEmptyAxState,
57
+ createEmptyAxHostCapability,
46
58
  normalizeAxState,
59
+ normalizeAxHostCapability,
60
+ createAxWorkItem,
61
+ createAxApprovalGate,
62
+ createAxReviewAnnotation,
63
+ createAxEvent,
64
+ createAxEvidence,
65
+ createAxSteeringMessage,
47
66
  type PmxAxFocusState,
48
67
  type PmxAxSource,
49
68
  type PmxAxState,
69
+ type PmxAxWorkItem,
70
+ type PmxAxWorkItemStatus,
71
+ type PmxAxApprovalGate,
72
+ type PmxAxReviewAnnotation,
73
+ type PmxAxReviewKind,
74
+ type PmxAxReviewSeverity,
75
+ type PmxAxReviewStatus,
76
+ type PmxAxReviewAnchorType,
77
+ type PmxAxReviewRegion,
78
+ type PmxAxEvent,
79
+ type PmxAxEventKind,
80
+ type PmxAxEvidence,
81
+ type PmxAxEvidenceKind,
82
+ type PmxAxSteeringMessage,
83
+ type PmxAxHostCapability,
84
+ type PmxAxTimelineSummary,
50
85
  } from './ax-state.js';
51
86
 
52
87
  function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
@@ -209,10 +244,10 @@ export interface CanvasNodeUpdate {
209
244
  dockPosition?: 'left' | 'right' | null;
210
245
  }
211
246
 
212
- export type CanvasChangeType = 'pins' | 'nodes' | 'ax';
247
+ export type CanvasChangeType = 'pins' | 'nodes' | 'ax' | 'ax-timeline';
213
248
 
214
249
  export interface MutationRecordInfo {
215
- operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'setAxFocus' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
250
+ operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'setAxFocus' | 'addWorkItem' | 'updateWorkItem' | 'requestApproval' | 'resolveApproval' | 'addReviewAnnotation' | 'updateReviewAnnotation' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
216
251
  description: string;
217
252
  forward: () => void;
218
253
  inverse: () => void;
@@ -255,6 +290,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
255
290
  return value !== null && typeof value === 'object' && !Array.isArray(value);
256
291
  }
257
292
 
293
+ function replaceById<T extends { id: string }>(list: T[], item: T): T[] {
294
+ const idx = list.findIndex((x) => x.id === item.id);
295
+ if (idx === -1) return [...list, item];
296
+ const copy = list.slice();
297
+ copy[idx] = item;
298
+ return copy;
299
+ }
300
+
258
301
  function isPersistedBlobRef(value: unknown): value is PersistedBlobRef {
259
302
  return isRecord(value) &&
260
303
  value.__pmxCanvasBlob === 'v1' &&
@@ -273,6 +316,7 @@ class CanvasStateManager {
273
316
  private _theme: CanvasTheme = 'dark';
274
317
  private _contextPinnedNodeIds = new Set<string>();
275
318
  private _axState: PmxAxState = createEmptyAxState();
319
+ private _axHostCapability: PmxAxHostCapability | null = null;
276
320
  private _workspaceRoot = process.cwd();
277
321
 
278
322
  // ── Change listeners (for MCP resource notifications) ──────
@@ -832,6 +876,14 @@ class CanvasStateManager {
832
876
 
833
877
  /** Load canvas state from SQLite (or legacy JSON fallback). Call once on server startup. */
834
878
  loadFromDisk(options: LoadFromDiskOptions = {}): boolean {
879
+ // Host capability lives in its own table (not snapshotted / not in PmxAxState).
880
+ if (this._db) {
881
+ try {
882
+ this._axHostCapability = loadAxHostCapabilityFromDB(this._db);
883
+ } catch (error) {
884
+ logCanvasStateWarning('load host capability failed', error, {});
885
+ }
886
+ }
835
887
  // Try SQLite first (only if DB has been populated)
836
888
  if (this._db && isDbPopulated(this._db)) {
837
889
  try {
@@ -1683,6 +1735,298 @@ class CanvasStateManager {
1683
1735
  return this.setAxFocus([], { source: 'system' });
1684
1736
  }
1685
1737
 
1738
+ // ── Work items (canvas-bound; snapshotted via getAxState blob) ────
1739
+ getWorkItems(): PmxAxWorkItem[] {
1740
+ return this.getAxState().workItems;
1741
+ }
1742
+
1743
+ addWorkItem(
1744
+ input: { title: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
1745
+ options: { source?: PmxAxSource } = {},
1746
+ ): PmxAxWorkItem {
1747
+ const oldAxState = this.getAxState();
1748
+ const item = createAxWorkItem(input, options.source ?? 'api', this.currentNodeIdSet());
1749
+ this.applyAxState({ ...oldAxState, workItems: [...oldAxState.workItems, item] });
1750
+ const applied = this.getAxState();
1751
+ this.scheduleSave();
1752
+ this.notifyChange('ax');
1753
+ this.recordMutation({
1754
+ operationType: 'addWorkItem',
1755
+ description: `Added work item "${item.title}"`,
1756
+ forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
1757
+ inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
1758
+ });
1759
+ return applied.workItems.find((w) => w.id === item.id) ?? item;
1760
+ }
1761
+
1762
+ updateWorkItem(
1763
+ id: string,
1764
+ patch: { title?: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
1765
+ options: { source?: PmxAxSource } = {},
1766
+ ): PmxAxWorkItem | null {
1767
+ const oldAxState = this.getAxState();
1768
+ const existing = oldAxState.workItems.find((w) => w.id === id);
1769
+ if (!existing) return null;
1770
+ const merged: PmxAxWorkItem = {
1771
+ ...existing,
1772
+ ...(patch.title !== undefined ? { title: patch.title } : {}),
1773
+ ...(patch.status !== undefined ? { status: patch.status } : {}),
1774
+ ...(patch.detail !== undefined ? { detail: patch.detail } : {}),
1775
+ ...(patch.nodeIds !== undefined ? { nodeIds: patch.nodeIds.filter((n) => this.nodes.has(n)) } : {}),
1776
+ updatedAt: new Date().toISOString(),
1777
+ source: options.source ?? existing.source,
1778
+ };
1779
+ this.applyAxState({ ...oldAxState, workItems: replaceById(oldAxState.workItems, merged) });
1780
+ const applied = this.getAxState();
1781
+ this.scheduleSave();
1782
+ this.notifyChange('ax');
1783
+ this.recordMutation({
1784
+ operationType: 'updateWorkItem',
1785
+ description: `Updated work item ${id}`,
1786
+ forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
1787
+ inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
1788
+ });
1789
+ return applied.workItems.find((w) => w.id === id) ?? null;
1790
+ }
1791
+
1792
+ // ── Approval gates (canvas-bound) ─────────────────────────────────
1793
+ getApprovalGates(): PmxAxApprovalGate[] {
1794
+ return this.getAxState().approvalGates;
1795
+ }
1796
+
1797
+ requestApproval(
1798
+ input: { title: string; detail?: string | null; action?: string | null; nodeIds?: string[] },
1799
+ options: { source?: PmxAxSource } = {},
1800
+ ): PmxAxApprovalGate {
1801
+ const oldAxState = this.getAxState();
1802
+ const gate = createAxApprovalGate(input, options.source ?? 'api', this.currentNodeIdSet());
1803
+ this.applyAxState({ ...oldAxState, approvalGates: [...oldAxState.approvalGates, gate] });
1804
+ const applied = this.getAxState();
1805
+ this.scheduleSave();
1806
+ this.notifyChange('ax');
1807
+ this.recordMutation({
1808
+ operationType: 'requestApproval',
1809
+ description: `Requested approval "${gate.title}"`,
1810
+ forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
1811
+ inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
1812
+ });
1813
+ return applied.approvalGates.find((g) => g.id === gate.id) ?? gate;
1814
+ }
1815
+
1816
+ resolveApproval(
1817
+ id: string,
1818
+ decision: 'approved' | 'rejected',
1819
+ options: { resolution?: string; source?: PmxAxSource } = {},
1820
+ ): PmxAxApprovalGate | null {
1821
+ const oldAxState = this.getAxState();
1822
+ const gate = oldAxState.approvalGates.find((g) => g.id === id);
1823
+ if (!gate || gate.status !== 'pending') return null;
1824
+ const resolved: PmxAxApprovalGate = {
1825
+ ...gate,
1826
+ status: decision,
1827
+ resolvedAt: new Date().toISOString(),
1828
+ resolution: options.resolution ?? null,
1829
+ source: options.source ?? gate.source,
1830
+ };
1831
+ this.applyAxState({ ...oldAxState, approvalGates: replaceById(oldAxState.approvalGates, resolved) });
1832
+ const applied = this.getAxState();
1833
+ this.scheduleSave();
1834
+ this.notifyChange('ax');
1835
+ this.recordMutation({
1836
+ operationType: 'resolveApproval',
1837
+ description: `Resolved approval ${id} -> ${decision}`,
1838
+ forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
1839
+ inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
1840
+ });
1841
+ return applied.approvalGates.find((g) => g.id === id) ?? null;
1842
+ }
1843
+
1844
+ // ── Review annotations (canvas-bound) ─────────────────────────────
1845
+ getReviewAnnotations(): PmxAxReviewAnnotation[] {
1846
+ return this.getAxState().reviewAnnotations;
1847
+ }
1848
+
1849
+ addReviewAnnotation(
1850
+ input: {
1851
+ body: string;
1852
+ kind?: PmxAxReviewKind;
1853
+ severity?: PmxAxReviewSeverity;
1854
+ anchorType?: PmxAxReviewAnchorType;
1855
+ nodeId?: string | null;
1856
+ file?: string | null;
1857
+ region?: PmxAxReviewRegion | null;
1858
+ author?: string | null;
1859
+ },
1860
+ options: { source?: PmxAxSource } = {},
1861
+ ): PmxAxReviewAnnotation | null {
1862
+ // Validate the node anchor up front. A node-anchored review whose nodeId is
1863
+ // missing or unknown would otherwise be silently dropped by
1864
+ // normalizeAxForCurrentNodes after apply, yet still returned as a phantom
1865
+ // success object — false success / silent data loss. Reject instead so the
1866
+ // HTTP/MCP layers surface ok:false / 4xx.
1867
+ const anchorType = input.anchorType ?? 'node';
1868
+ if (anchorType === 'node' && (typeof input.nodeId !== 'string' || !this.currentNodeIdSet().has(input.nodeId))) {
1869
+ return null;
1870
+ }
1871
+ const oldAxState = this.getAxState();
1872
+ const annotation = createAxReviewAnnotation(input, options.source ?? 'api');
1873
+ this.applyAxState({ ...oldAxState, reviewAnnotations: [...oldAxState.reviewAnnotations, annotation] });
1874
+ const applied = this.getAxState();
1875
+ this.scheduleSave();
1876
+ this.notifyChange('ax');
1877
+ this.recordMutation({
1878
+ operationType: 'addReviewAnnotation',
1879
+ description: `Added review ${annotation.kind} (${annotation.severity})`,
1880
+ forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
1881
+ inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
1882
+ });
1883
+ return applied.reviewAnnotations.find((r) => r.id === annotation.id) ?? annotation;
1884
+ }
1885
+
1886
+ updateReviewAnnotation(
1887
+ id: string,
1888
+ patch: { body?: string; status?: PmxAxReviewStatus; severity?: PmxAxReviewSeverity; kind?: PmxAxReviewKind },
1889
+ options: { source?: PmxAxSource } = {},
1890
+ ): PmxAxReviewAnnotation | null {
1891
+ const oldAxState = this.getAxState();
1892
+ const existing = oldAxState.reviewAnnotations.find((r) => r.id === id);
1893
+ if (!existing) return null;
1894
+ const merged: PmxAxReviewAnnotation = {
1895
+ ...existing,
1896
+ ...(patch.body !== undefined ? { body: patch.body } : {}),
1897
+ ...(patch.status !== undefined ? { status: patch.status } : {}),
1898
+ ...(patch.severity !== undefined ? { severity: patch.severity } : {}),
1899
+ ...(patch.kind !== undefined ? { kind: patch.kind } : {}),
1900
+ updatedAt: new Date().toISOString(),
1901
+ source: options.source ?? existing.source,
1902
+ };
1903
+ this.applyAxState({ ...oldAxState, reviewAnnotations: replaceById(oldAxState.reviewAnnotations, merged) });
1904
+ const applied = this.getAxState();
1905
+ this.scheduleSave();
1906
+ this.notifyChange('ax');
1907
+ this.recordMutation({
1908
+ operationType: 'updateReviewAnnotation',
1909
+ description: `Updated review ${id}`,
1910
+ forward: this.suppressed(() => { this.applyAxState(applied); this.scheduleSave(); this.notifyChange('ax'); }),
1911
+ inverse: this.suppressed(() => { this.applyAxState(oldAxState); this.scheduleSave(); this.notifyChange('ax'); }),
1912
+ });
1913
+ return applied.reviewAnnotations.find((r) => r.id === id) ?? null;
1914
+ }
1915
+
1916
+ // ── Host capability (own table; reported by adapters) ─────────────
1917
+ getHostCapability(): PmxAxHostCapability | null {
1918
+ return this._axHostCapability;
1919
+ }
1920
+
1921
+ setHostCapability(input: unknown, _options: { source?: PmxAxSource } = {}): PmxAxHostCapability {
1922
+ const cap = normalizeAxHostCapability(
1923
+ isRecord(input)
1924
+ ? { ...input, reportedAt: new Date().toISOString() }
1925
+ : { reportedAt: new Date().toISOString() },
1926
+ ) ?? createEmptyAxHostCapability();
1927
+ this._axHostCapability = cap;
1928
+ if (this._db) {
1929
+ try {
1930
+ upsertAxHostCapabilityToDB(this._db, cap);
1931
+ } catch (error) {
1932
+ logCanvasStateWarning('save host capability failed', error, {});
1933
+ }
1934
+ }
1935
+ this.notifyChange('ax');
1936
+ return cap;
1937
+ }
1938
+
1939
+ // ── Timeline (DB-direct; NOT in _axState; NOT history-recorded) ───
1940
+ recordAxEvent(
1941
+ input: { kind: PmxAxEventKind; summary: string; detail?: string | null; nodeIds?: string[]; data?: Record<string, unknown> | null },
1942
+ options: { source?: PmxAxSource } = {},
1943
+ ): PmxAxEvent {
1944
+ const draft = createAxEvent(input, options.source ?? 'api');
1945
+ if (this._db) {
1946
+ try {
1947
+ const ev = appendAxEventToDB(this._db, draft);
1948
+ this.notifyChange('ax-timeline');
1949
+ return ev;
1950
+ } catch (error) {
1951
+ logCanvasStateWarning('record ax event failed', error, { id: draft.id });
1952
+ }
1953
+ }
1954
+ this.notifyChange('ax-timeline');
1955
+ return { ...draft, seq: 0 };
1956
+ }
1957
+
1958
+ addEvidence(
1959
+ input: { kind: PmxAxEvidenceKind; title: string; body?: string | null; ref?: string | null; nodeIds?: string[]; data?: Record<string, unknown> | null },
1960
+ options: { source?: PmxAxSource } = {},
1961
+ ): PmxAxEvidence {
1962
+ const draft = createAxEvidence(input, options.source ?? 'api');
1963
+ if (this._db) {
1964
+ try {
1965
+ const ev = appendAxEvidenceToDB(this._db, draft);
1966
+ this.notifyChange('ax-timeline');
1967
+ return ev;
1968
+ } catch (error) {
1969
+ logCanvasStateWarning('add evidence failed', error, { id: draft.id });
1970
+ }
1971
+ }
1972
+ this.notifyChange('ax-timeline');
1973
+ return { ...draft, seq: 0 };
1974
+ }
1975
+
1976
+ recordSteeringMessage(message: string, options: { source?: PmxAxSource } = {}): PmxAxSteeringMessage {
1977
+ const draft = createAxSteeringMessage(message, options.source ?? 'api');
1978
+ if (this._db) {
1979
+ try {
1980
+ const s = appendAxSteeringToDB(this._db, draft);
1981
+ this.notifyChange('ax-timeline');
1982
+ return s;
1983
+ } catch (error) {
1984
+ logCanvasStateWarning('record steering failed', error, { id: draft.id });
1985
+ }
1986
+ }
1987
+ this.notifyChange('ax-timeline');
1988
+ return { ...draft, seq: 0 };
1989
+ }
1990
+
1991
+ markSteeringDelivered(id: string): boolean {
1992
+ if (!this._db) return false;
1993
+ try {
1994
+ const ok = markAxSteeringDeliveredInDB(this._db, id);
1995
+ if (ok) this.notifyChange('ax-timeline');
1996
+ return ok;
1997
+ } catch (error) {
1998
+ logCanvasStateWarning('mark steering delivered failed', error, { id });
1999
+ return false;
2000
+ }
2001
+ }
2002
+
2003
+ getAxEvents(q: AxTimelineQuery = {}): PmxAxEvent[] {
2004
+ return this._db ? loadAxEventsFromDB(this._db, q) : [];
2005
+ }
2006
+
2007
+ getAxEvidence(q: AxTimelineQuery = {}): PmxAxEvidence[] {
2008
+ return this._db ? loadAxEvidenceFromDB(this._db, q) : [];
2009
+ }
2010
+
2011
+ getAxSteering(q: AxTimelineQuery & { onlyPending?: boolean } = {}): PmxAxSteeringMessage[] {
2012
+ return this._db ? loadAxSteeringFromDB(this._db, q) : [];
2013
+ }
2014
+
2015
+ getAxTimelineSummary(): PmxAxTimelineSummary {
2016
+ return this._db
2017
+ ? loadAxTimelineSummaryFromDB(this._db)
2018
+ : { recentEvents: [], recentEvidence: [], pendingSteering: [], counts: { events: 0, evidence: 0, steering: 0 } };
2019
+ }
2020
+
2021
+ getAxTimeline(q: AxTimelineQuery = {}): { events: PmxAxEvent[]; evidence: PmxAxEvidence[]; steering: PmxAxSteeringMessage[]; summary: PmxAxTimelineSummary } {
2022
+ return {
2023
+ events: this.getAxEvents(q),
2024
+ evidence: this.getAxEvidence(q),
2025
+ steering: this.getAxSteering(q),
2026
+ summary: this.getAxTimelineSummary(),
2027
+ };
2028
+ }
2029
+
1686
2030
  setContextPins(nodeIds: string[]): void {
1687
2031
  const oldPins = Array.from(this._contextPinnedNodeIds);
1688
2032
  this._contextPinnedNodeIds.clear();
@@ -1814,6 +2158,9 @@ class CanvasStateManager {
1814
2158
  this.edges.clear();
1815
2159
  this.annotations.clear();
1816
2160
  this._contextPinnedNodeIds.clear();
2161
+ // Clears canvas-bound AX state (focus, work items, approvals, review annotations).
2162
+ // Timeline tables (ax_events/ax_evidence/ax_steering) and host capability are
2163
+ // deliberately retained per the AX state-partition policy.
1817
2164
  this._axState = createEmptyAxState();
1818
2165
  this._viewport = { x: 0, y: 0, scale: 1 };
1819
2166
  this.scheduleSave();