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
@@ -77,7 +77,15 @@ import {
77
77
  import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
78
78
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
79
79
  import { buildCanvasAxContext } from './ax-context.js';
80
- import type { PmxAxSource } from './ax-state.js';
80
+ import { isAxEventKind, isAxEvidenceKind } from './ax-state.js';
81
+ import type {
82
+ PmxAxReviewAnchorType,
83
+ PmxAxReviewKind,
84
+ PmxAxReviewRegion,
85
+ PmxAxReviewSeverity,
86
+ PmxAxReviewStatus,
87
+ PmxAxSource,
88
+ } from './ax-state.js';
81
89
  import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js';
82
90
  import { validateLocalImageFile } from './image-source.js';
83
91
  import {
@@ -85,13 +93,17 @@ import {
85
93
  addCanvasEdge,
86
94
  MARKDOWN_NODE_DEFAULT_SIZE,
87
95
  MCP_APP_NODE_DEFAULT_SIZE,
96
+ IMAGE_NODE_DEFAULT_SIZE,
97
+ LEDGER_NODE_DEFAULT_SIZE,
88
98
  applyCanvasNodeUpdates,
99
+ appendCanvasJsonRenderStream,
89
100
  buildStructuredNodeUpdate,
90
101
  arrangeCanvasNodes,
91
102
  clearCanvas,
92
103
  createCanvasGraphNode,
93
104
  createCanvasGroup,
94
105
  createCanvasJsonRenderNode,
106
+ createCanvasStreamingJsonRenderNode,
95
107
  deleteCanvasSnapshot,
96
108
  executeCanvasBatch,
97
109
  fitCanvasView,
@@ -101,6 +113,7 @@ import {
101
113
  refreshCanvasWebpageNode,
102
114
  removeCanvasNode,
103
115
  removeCanvasEdge,
116
+ resolveHtmlContent,
104
117
  restoreCanvasSnapshot,
105
118
  saveCanvasSnapshot,
106
119
  scheduleCodeGraphRecompute,
@@ -1495,7 +1508,13 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
1495
1508
  if (!src || src.startsWith('data:') || src.startsWith('http')) {
1496
1509
  return responseText('Not a file-based image', 400);
1497
1510
  }
1498
- const safePath = resolve(src);
1511
+ // Contain the file read to the active workspace. `src` comes from node data,
1512
+ // which any unauthenticated local caller can set — without this guard the
1513
+ // image route serves arbitrary host files (e.g. ../../etc/passwd).
1514
+ const safePath = resolveWorkspaceArtifactPath(src);
1515
+ if (!safePath) {
1516
+ return responseText('Image path is outside the workspace', 403);
1517
+ }
1499
1518
  if (!existsSync(safePath)) {
1500
1519
  return responseText('Image file not found', 404);
1501
1520
  }
@@ -1656,7 +1675,7 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1656
1675
  const htmlMergedData = type === 'html'
1657
1676
  ? {
1658
1677
  ...(extraData ?? {}),
1659
- ...(typeof body.html === 'string' ? { html: body.html } : {}),
1678
+ ...(typeof body.html === 'string' ? { html: resolveHtmlContent(body.html) } : {}),
1660
1679
  ...(typeof body.summary === 'string' ? { summary: body.summary } : {}),
1661
1680
  ...(typeof body.agentSummary === 'string' ? { agentSummary: body.agentSummary } : {}),
1662
1681
  ...(typeof body.description === 'string' ? { description: body.description } : {}),
@@ -1688,14 +1707,22 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1688
1707
  ? MARKDOWN_NODE_DEFAULT_SIZE.width
1689
1708
  : type === 'mcp-app'
1690
1709
  ? MCP_APP_NODE_DEFAULT_SIZE.width
1691
- : 360,
1710
+ : type === 'image'
1711
+ ? IMAGE_NODE_DEFAULT_SIZE.width
1712
+ : type === 'ledger'
1713
+ ? LEDGER_NODE_DEFAULT_SIZE.width
1714
+ : 360,
1692
1715
  defaultHeight: type === 'html'
1693
1716
  ? 640
1694
1717
  : type === 'markdown'
1695
1718
  ? MARKDOWN_NODE_DEFAULT_SIZE.height
1696
1719
  : type === 'mcp-app'
1697
1720
  ? MCP_APP_NODE_DEFAULT_SIZE.height
1698
- : 200,
1721
+ : type === 'image'
1722
+ ? IMAGE_NODE_DEFAULT_SIZE.height
1723
+ : type === 'ledger'
1724
+ ? LEDGER_NODE_DEFAULT_SIZE.height
1725
+ : 200,
1699
1726
  fileMode: 'auto',
1700
1727
  });
1701
1728
  } catch (error) {
@@ -2061,6 +2088,10 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
2061
2088
  ...(typeof body.outputPath === 'string'
2062
2089
  ? { outputPath: resolveWorkspacePath(body.outputPath, activeWorkspaceRoot) }
2063
2090
  : {}),
2091
+ // Script-path overrides are honored only when contained inside the
2092
+ // workspace (enforced by resolveTrustedScriptPath in
2093
+ // executeWebArtifactBuild), so they cannot point at an arbitrary host
2094
+ // script for bash execution.
2064
2095
  ...(typeof body.initScriptPath === 'string'
2065
2096
  ? { initScriptPath: body.initScriptPath }
2066
2097
  : {}),
@@ -2214,6 +2245,34 @@ async function handleCanvasAddJsonRender(req: Request): Promise<Response> {
2214
2245
  }
2215
2246
  }
2216
2247
 
2248
+ async function handleJsonRenderStream(req: Request): Promise<Response> {
2249
+ const body = await readJson(req);
2250
+ const patches = Array.isArray(body.patches) ? body.patches : [];
2251
+ const done = body.done === true;
2252
+ const geometry = resolveCreateGeometry(body);
2253
+ try {
2254
+ let nodeId = typeof body.nodeId === 'string' && body.nodeId ? body.nodeId : undefined;
2255
+ let url = '';
2256
+ if (!nodeId) {
2257
+ const created = createCanvasStreamingJsonRenderNode({
2258
+ ...(typeof body.title === 'string' ? { title: body.title } : {}),
2259
+ ...(body.strictSize === true ? { strictSize: true } : {}),
2260
+ ...geometry,
2261
+ });
2262
+ nodeId = created.id;
2263
+ url = created.url;
2264
+ }
2265
+ const result = appendCanvasJsonRenderStream(nodeId, patches, done);
2266
+ if (!result.ok) return responseJson({ ok: false, error: result.error }, 400);
2267
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2268
+ const node = canvasState.getNode(nodeId);
2269
+ return responseJson({ id: nodeId, url: url || String(node?.data.url ?? ''), ...result });
2270
+ } catch (error) {
2271
+ const message = error instanceof Error ? error.message : String(error);
2272
+ return responseJson({ ok: false, error: message }, 400);
2273
+ }
2274
+ }
2275
+
2217
2276
  async function handleCanvasAddGraph(req: Request): Promise<Response> {
2218
2277
  const body = await readJson(req);
2219
2278
  const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Graph';
@@ -2242,6 +2301,16 @@ async function handleCanvasAddGraph(req: Request): Promise<Response> {
2242
2301
  const nodeHeight = pickPositiveNumber(body, 'nodeHeight') ?? (size ? pickPositiveNumber(size, 'height') : undefined);
2243
2302
  const showLegend = typeof body.showLegend === 'boolean' ? body.showLegend : undefined;
2244
2303
  const showLabels = typeof body.showLabels === 'boolean' ? body.showLabels : undefined;
2304
+ const colorBy =
2305
+ body.colorBy === 'series' || body.colorBy === 'category' || body.colorBy === 'value' || body.colorBy === 'none'
2306
+ ? body.colorBy
2307
+ : undefined;
2308
+ const highlight =
2309
+ typeof body.highlight === 'number' || body.highlight === 'max' || body.highlight === 'min' || body.highlight === null
2310
+ ? body.highlight
2311
+ : undefined;
2312
+ const sort =
2313
+ body.sort === 'asc' || body.sort === 'desc' || body.sort === 'none' ? body.sort : undefined;
2245
2314
  const result = createCanvasGraphNode({
2246
2315
  title,
2247
2316
  graphType,
@@ -2258,8 +2327,23 @@ async function handleCanvasAddGraph(req: Request): Promise<Response> {
2258
2327
  ...(typeof body.lineKey === 'string' ? { lineKey: body.lineKey } : {}),
2259
2328
  ...(aggregate ? { aggregate } : {}),
2260
2329
  ...(typeof body.color === 'string' ? { color: body.color } : {}),
2330
+ ...(colorBy ? { colorBy } : {}),
2331
+ ...(highlight !== undefined ? { highlight } : {}),
2261
2332
  ...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
2262
2333
  ...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
2334
+ ...(typeof body.labelKey === 'string' ? { labelKey: body.labelKey } : {}),
2335
+ ...(typeof body.targetKey === 'string' ? { targetKey: body.targetKey } : {}),
2336
+ ...(typeof body.rangesKey === 'string' ? { rangesKey: body.rangesKey } : {}),
2337
+ ...(typeof body.beforeKey === 'string' ? { beforeKey: body.beforeKey } : {}),
2338
+ ...(typeof body.afterKey === 'string' ? { afterKey: body.afterKey } : {}),
2339
+ ...(typeof body.beforeLabel === 'string' ? { beforeLabel: body.beforeLabel } : {}),
2340
+ ...(typeof body.afterLabel === 'string' ? { afterLabel: body.afterLabel } : {}),
2341
+ ...(sort ? { sort } : {}),
2342
+ ...(typeof body.fill === 'boolean' ? { fill: body.fill } : {}),
2343
+ ...(typeof body.showEndDot === 'boolean' ? { showEndDot: body.showEndDot } : {}),
2344
+ ...(typeof body.showMinMax === 'boolean' ? { showMinMax: body.showMinMax } : {}),
2345
+ ...(typeof body.showValue === 'boolean' ? { showValue: body.showValue } : {}),
2346
+ ...(typeof body.colorByDirection === 'boolean' ? { colorByDirection: body.colorByDirection } : {}),
2263
2347
  ...(typeof body.height === 'number' ? { height: body.height } : {}),
2264
2348
  ...(showLegend !== undefined ? { showLegend } : {}),
2265
2349
  ...(showLabels !== undefined ? { showLabels } : {}),
@@ -2334,11 +2418,17 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
2334
2418
  ? themeValue
2335
2419
  : undefined;
2336
2420
  const title = (node.data.title as string) || node.id;
2421
+ // Devtools panel is double-gated: the operator must opt in via the env flag
2422
+ // AND the request must carry ?devtools=1. Off by default in all normal runs.
2423
+ const devtoolsEnabled =
2424
+ process.env.PMX_CANVAS_JSON_RENDER_DEVTOOLS === '1' &&
2425
+ url.searchParams.get('devtools') === '1';
2337
2426
  const html = await buildJsonRenderViewerHtml({
2338
2427
  title,
2339
2428
  spec,
2340
2429
  ...(theme ? { theme } : {}),
2341
2430
  ...(url.searchParams.get('display') === 'expanded' ? { display: 'expanded' as const } : {}),
2431
+ ...(devtoolsEnabled ? { devtools: true } : {}),
2342
2432
  });
2343
2433
  return new Response(html, {
2344
2434
  headers: {
@@ -3653,6 +3743,284 @@ async function handleAxStatePatch(req: Request): Promise<Response> {
3653
3743
  return responseJson({ ok: true, state: canvasState.getAxState() });
3654
3744
  }
3655
3745
 
3746
+ async function handleAxEventAdd(req: Request): Promise<Response> {
3747
+ const body = await readJson(req);
3748
+ if (!isAxEventKind(body.kind) || typeof body.summary !== 'string') {
3749
+ return responseJson({ ok: false, error: 'event requires kind and summary.' }, 400);
3750
+ }
3751
+ const event = canvasState.recordAxEvent(
3752
+ {
3753
+ kind: body.kind,
3754
+ summary: body.summary,
3755
+ detail: typeof body.detail === 'string' ? body.detail : null,
3756
+ nodeIds: normalizeAxNodeIds(body.nodeIds),
3757
+ data: isRecord(body.data) ? body.data : null,
3758
+ },
3759
+ { source: normalizeAxSource(body.source, 'api') },
3760
+ );
3761
+ broadcastWorkbenchEvent('ax-event-created', {
3762
+ event,
3763
+ sessionId: primaryWorkbenchSessionId,
3764
+ timestamp: new Date().toISOString(),
3765
+ });
3766
+ return responseJson({ ok: true, event });
3767
+ }
3768
+
3769
+ async function handleAxSteer(req: Request): Promise<Response> {
3770
+ const body = await readJson(req);
3771
+ if (typeof body.message !== 'string' || !body.message.trim()) {
3772
+ return responseJson({ ok: false, error: 'steer requires a non-empty message.' }, 400);
3773
+ }
3774
+ const steering = canvasState.recordSteeringMessage(body.message, {
3775
+ source: normalizeAxSource(body.source, 'api'),
3776
+ });
3777
+ broadcastWorkbenchEvent('ax-event-created', {
3778
+ steering,
3779
+ sessionId: primaryWorkbenchSessionId,
3780
+ timestamp: new Date().toISOString(),
3781
+ });
3782
+ return responseJson({ ok: true, steering });
3783
+ }
3784
+
3785
+ function handleAxTimelineGet(url: URL): Response {
3786
+ const limit = Number(url.searchParams.get('limit') ?? '');
3787
+ return responseJson({
3788
+ ok: true,
3789
+ ...canvasState.getAxTimeline(Number.isFinite(limit) && limit > 0 ? { limit } : {}),
3790
+ });
3791
+ }
3792
+
3793
+ const AX_WORK_STATUSES = new Set(['todo', 'in-progress', 'blocked', 'done', 'cancelled']);
3794
+
3795
+ function normalizeAxWorkItemStatus(value: unknown): 'todo' | 'in-progress' | 'blocked' | 'done' | 'cancelled' | undefined {
3796
+ return typeof value === 'string' && AX_WORK_STATUSES.has(value)
3797
+ ? value as 'todo' | 'in-progress' | 'blocked' | 'done' | 'cancelled'
3798
+ : undefined;
3799
+ }
3800
+
3801
+ function handleAxWorkList(): Response {
3802
+ return responseJson({ ok: true, workItems: canvasState.getWorkItems() });
3803
+ }
3804
+
3805
+ async function handleAxWorkAdd(req: Request): Promise<Response> {
3806
+ const body = await readJson(req);
3807
+ if (typeof body.title !== 'string' || !body.title.trim()) {
3808
+ return responseJson({ ok: false, error: 'work item requires a title.' }, 400);
3809
+ }
3810
+ const status = normalizeAxWorkItemStatus(body.status);
3811
+ const workItem = canvasState.addWorkItem(
3812
+ {
3813
+ title: body.title,
3814
+ ...(status ? { status } : {}),
3815
+ ...(typeof body.detail === 'string' ? { detail: body.detail } : {}),
3816
+ ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
3817
+ },
3818
+ { source: normalizeAxSource(body.source, 'api') },
3819
+ );
3820
+ broadcastWorkbenchEvent('ax-state-changed', {
3821
+ workItem,
3822
+ sessionId: primaryWorkbenchSessionId,
3823
+ timestamp: new Date().toISOString(),
3824
+ });
3825
+ return responseJson({ ok: true, workItem });
3826
+ }
3827
+
3828
+ async function handleAxWorkUpdate(req: Request, id: string): Promise<Response> {
3829
+ const body = await readJson(req);
3830
+ const status = normalizeAxWorkItemStatus(body.status);
3831
+ const workItem = canvasState.updateWorkItem(
3832
+ id,
3833
+ {
3834
+ ...(typeof body.title === 'string' ? { title: body.title } : {}),
3835
+ ...(status ? { status } : {}),
3836
+ ...(typeof body.detail === 'string' || body.detail === null ? { detail: body.detail as string | null } : {}),
3837
+ ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
3838
+ },
3839
+ { source: normalizeAxSource(body.source, 'api') },
3840
+ );
3841
+ if (!workItem) return responseJson({ ok: false, error: 'work item not found.' }, 404);
3842
+ broadcastWorkbenchEvent('ax-state-changed', {
3843
+ workItem,
3844
+ sessionId: primaryWorkbenchSessionId,
3845
+ timestamp: new Date().toISOString(),
3846
+ });
3847
+ return responseJson({ ok: true, workItem });
3848
+ }
3849
+
3850
+ function handleAxApprovalList(): Response {
3851
+ return responseJson({ ok: true, approvalGates: canvasState.getApprovalGates() });
3852
+ }
3853
+
3854
+ async function handleAxApprovalRequest(req: Request): Promise<Response> {
3855
+ const body = await readJson(req);
3856
+ if (typeof body.title !== 'string' || !body.title.trim()) {
3857
+ return responseJson({ ok: false, error: 'approval request requires a title.' }, 400);
3858
+ }
3859
+ const approvalGate = canvasState.requestApproval(
3860
+ {
3861
+ title: body.title,
3862
+ ...(typeof body.detail === 'string' ? { detail: body.detail } : {}),
3863
+ ...(typeof body.action === 'string' ? { action: body.action } : {}),
3864
+ ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
3865
+ },
3866
+ { source: normalizeAxSource(body.source, 'api') },
3867
+ );
3868
+ broadcastWorkbenchEvent('ax-state-changed', {
3869
+ approvalGate,
3870
+ sessionId: primaryWorkbenchSessionId,
3871
+ timestamp: new Date().toISOString(),
3872
+ });
3873
+ return responseJson({ ok: true, approvalGate });
3874
+ }
3875
+
3876
+ async function handleAxApprovalResolve(req: Request, id: string): Promise<Response> {
3877
+ const body = await readJson(req);
3878
+ if (body.decision !== 'approved' && body.decision !== 'rejected') {
3879
+ return responseJson({ ok: false, error: 'resolve requires decision approved or rejected.' }, 400);
3880
+ }
3881
+ const approvalGate = canvasState.resolveApproval(
3882
+ id,
3883
+ body.decision,
3884
+ {
3885
+ ...(typeof body.resolution === 'string' ? { resolution: body.resolution } : {}),
3886
+ source: normalizeAxSource(body.source, 'api'),
3887
+ },
3888
+ );
3889
+ if (!approvalGate) return responseJson({ ok: false, error: 'approval gate not found or already resolved.' }, 404);
3890
+ broadcastWorkbenchEvent('ax-state-changed', {
3891
+ approvalGate,
3892
+ sessionId: primaryWorkbenchSessionId,
3893
+ timestamp: new Date().toISOString(),
3894
+ });
3895
+ return responseJson({ ok: true, approvalGate });
3896
+ }
3897
+
3898
+ async function handleAxEvidenceAdd(req: Request): Promise<Response> {
3899
+ const body = await readJson(req);
3900
+ if (!isAxEvidenceKind(body.kind) || typeof body.title !== 'string' || !body.title.trim()) {
3901
+ return responseJson({ ok: false, error: 'evidence requires kind and title.' }, 400);
3902
+ }
3903
+ const evidence = canvasState.addEvidence(
3904
+ {
3905
+ kind: body.kind,
3906
+ title: body.title,
3907
+ body: typeof body.body === 'string' ? body.body : null,
3908
+ ref: typeof body.ref === 'string' ? body.ref : null,
3909
+ nodeIds: normalizeAxNodeIds(body.nodeIds),
3910
+ data: isRecord(body.data) ? body.data : null,
3911
+ },
3912
+ { source: normalizeAxSource(body.source, 'api') },
3913
+ );
3914
+ broadcastWorkbenchEvent('ax-event-created', {
3915
+ evidence,
3916
+ sessionId: primaryWorkbenchSessionId,
3917
+ timestamp: new Date().toISOString(),
3918
+ });
3919
+ return responseJson({ ok: true, evidence });
3920
+ }
3921
+
3922
+ const AX_REVIEW_KINDS = new Set(['comment', 'finding']);
3923
+ const AX_REVIEW_SEVERITIES = new Set(['info', 'warning', 'error']);
3924
+ const AX_REVIEW_STATUSES = new Set(['open', 'resolved', 'dismissed']);
3925
+ const AX_REVIEW_ANCHORS = new Set(['node', 'file', 'region']);
3926
+
3927
+ function normalizeAxReviewKind(value: unknown): PmxAxReviewKind | undefined {
3928
+ return typeof value === 'string' && AX_REVIEW_KINDS.has(value) ? value as PmxAxReviewKind : undefined;
3929
+ }
3930
+ function normalizeAxReviewSeverity(value: unknown): PmxAxReviewSeverity | undefined {
3931
+ return typeof value === 'string' && AX_REVIEW_SEVERITIES.has(value) ? value as PmxAxReviewSeverity : undefined;
3932
+ }
3933
+ function normalizeAxReviewStatus(value: unknown): PmxAxReviewStatus | undefined {
3934
+ return typeof value === 'string' && AX_REVIEW_STATUSES.has(value) ? value as PmxAxReviewStatus : undefined;
3935
+ }
3936
+ function normalizeAxReviewAnchor(value: unknown): PmxAxReviewAnchorType | undefined {
3937
+ return typeof value === 'string' && AX_REVIEW_ANCHORS.has(value) ? value as PmxAxReviewAnchorType : undefined;
3938
+ }
3939
+ function normalizeAxReviewRegion(value: unknown): PmxAxReviewRegion | undefined {
3940
+ if (!isRecord(value)) return undefined;
3941
+ return {
3942
+ ...(typeof value.line === 'number' ? { line: value.line } : {}),
3943
+ ...(typeof value.endLine === 'number' ? { endLine: value.endLine } : {}),
3944
+ ...(typeof value.label === 'string' ? { label: value.label } : {}),
3945
+ };
3946
+ }
3947
+
3948
+ function handleAxReviewList(): Response {
3949
+ return responseJson({ ok: true, reviewAnnotations: canvasState.getReviewAnnotations() });
3950
+ }
3951
+
3952
+ async function handleAxReviewAdd(req: Request): Promise<Response> {
3953
+ const body = await readJson(req);
3954
+ if (typeof body.body !== 'string' || !body.body.trim()) {
3955
+ return responseJson({ ok: false, error: 'review annotation requires a body.' }, 400);
3956
+ }
3957
+ const kind = normalizeAxReviewKind(body.kind);
3958
+ const severity = normalizeAxReviewSeverity(body.severity);
3959
+ const anchorType = normalizeAxReviewAnchor(body.anchorType);
3960
+ const region = normalizeAxReviewRegion(body.region);
3961
+ const reviewAnnotation = canvasState.addReviewAnnotation(
3962
+ {
3963
+ body: body.body,
3964
+ ...(kind ? { kind } : {}),
3965
+ ...(severity ? { severity } : {}),
3966
+ ...(anchorType ? { anchorType } : {}),
3967
+ ...(typeof body.nodeId === 'string' ? { nodeId: body.nodeId } : {}),
3968
+ ...(typeof body.file === 'string' ? { file: body.file } : {}),
3969
+ ...(region ? { region } : {}),
3970
+ ...(typeof body.author === 'string' ? { author: body.author } : {}),
3971
+ },
3972
+ { source: normalizeAxSource(body.source, 'api') },
3973
+ );
3974
+ if (!reviewAnnotation) {
3975
+ return responseJson({ ok: false, error: 'node-anchored review annotation requires a nodeId that exists on the canvas.' }, 400);
3976
+ }
3977
+ broadcastWorkbenchEvent('ax-state-changed', {
3978
+ reviewAnnotation,
3979
+ sessionId: primaryWorkbenchSessionId,
3980
+ timestamp: new Date().toISOString(),
3981
+ });
3982
+ return responseJson({ ok: true, reviewAnnotation });
3983
+ }
3984
+
3985
+ async function handleAxReviewUpdate(req: Request, id: string): Promise<Response> {
3986
+ const body = await readJson(req);
3987
+ const status = normalizeAxReviewStatus(body.status);
3988
+ const severity = normalizeAxReviewSeverity(body.severity);
3989
+ const kind = normalizeAxReviewKind(body.kind);
3990
+ const reviewAnnotation = canvasState.updateReviewAnnotation(
3991
+ id,
3992
+ {
3993
+ ...(typeof body.body === 'string' ? { body: body.body } : {}),
3994
+ ...(status ? { status } : {}),
3995
+ ...(severity ? { severity } : {}),
3996
+ ...(kind ? { kind } : {}),
3997
+ },
3998
+ { source: normalizeAxSource(body.source, 'api') },
3999
+ );
4000
+ if (!reviewAnnotation) return responseJson({ ok: false, error: 'review annotation not found.' }, 404);
4001
+ broadcastWorkbenchEvent('ax-state-changed', {
4002
+ reviewAnnotation,
4003
+ sessionId: primaryWorkbenchSessionId,
4004
+ timestamp: new Date().toISOString(),
4005
+ });
4006
+ return responseJson({ ok: true, reviewAnnotation });
4007
+ }
4008
+
4009
+ function handleAxHostCapabilityGet(): Response {
4010
+ return responseJson({ ok: true, host: canvasState.getHostCapability() });
4011
+ }
4012
+
4013
+ async function handleAxHostCapabilityReport(req: Request): Promise<Response> {
4014
+ const body = await readJson(req);
4015
+ const host = canvasState.setHostCapability(body, { source: normalizeAxSource(body.source, 'api') });
4016
+ broadcastWorkbenchEvent('ax-state-changed', {
4017
+ host,
4018
+ sessionId: primaryWorkbenchSessionId,
4019
+ timestamp: new Date().toISOString(),
4020
+ });
4021
+ return responseJson({ ok: true, host });
4022
+ }
4023
+
3656
4024
  // ── Port resolution ───────────────────────────────────────────
3657
4025
 
3658
4026
  function buildPortCandidates(preferredPort: number): number[] {
@@ -4641,6 +5009,71 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4641
5009
  return handleAxFocusUpdate(req);
4642
5010
  }
4643
5011
 
5012
+ if (url.pathname === '/api/canvas/ax/event' && req.method === 'POST') {
5013
+ return handleAxEventAdd(req);
5014
+ }
5015
+
5016
+ if (url.pathname === '/api/canvas/ax/steer' && req.method === 'POST') {
5017
+ return handleAxSteer(req);
5018
+ }
5019
+
5020
+ if (url.pathname === '/api/canvas/ax/timeline' && req.method === 'GET') {
5021
+ return handleAxTimelineGet(url);
5022
+ }
5023
+
5024
+ if (url.pathname === '/api/canvas/ax/work' && req.method === 'GET') {
5025
+ return handleAxWorkList();
5026
+ }
5027
+
5028
+ if (url.pathname === '/api/canvas/ax/work' && req.method === 'POST') {
5029
+ return handleAxWorkAdd(req);
5030
+ }
5031
+
5032
+ if (url.pathname.startsWith('/api/canvas/ax/work/') && req.method === 'PATCH') {
5033
+ const workItemId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/work/'.length));
5034
+ return handleAxWorkUpdate(req, workItemId);
5035
+ }
5036
+
5037
+ if (url.pathname === '/api/canvas/ax/approval' && req.method === 'GET') {
5038
+ return handleAxApprovalList();
5039
+ }
5040
+
5041
+ if (url.pathname === '/api/canvas/ax/approval' && req.method === 'POST') {
5042
+ return handleAxApprovalRequest(req);
5043
+ }
5044
+
5045
+ if (url.pathname.startsWith('/api/canvas/ax/approval/') && url.pathname.endsWith('/resolve') && req.method === 'POST') {
5046
+ const approvalId = decodeURIComponent(
5047
+ url.pathname.slice('/api/canvas/ax/approval/'.length, -'/resolve'.length),
5048
+ );
5049
+ return handleAxApprovalResolve(req, approvalId);
5050
+ }
5051
+
5052
+ if (url.pathname === '/api/canvas/ax/evidence' && req.method === 'POST') {
5053
+ return handleAxEvidenceAdd(req);
5054
+ }
5055
+
5056
+ if (url.pathname === '/api/canvas/ax/review' && req.method === 'GET') {
5057
+ return handleAxReviewList();
5058
+ }
5059
+
5060
+ if (url.pathname === '/api/canvas/ax/review' && req.method === 'POST') {
5061
+ return handleAxReviewAdd(req);
5062
+ }
5063
+
5064
+ if (url.pathname.startsWith('/api/canvas/ax/review/') && req.method === 'PATCH') {
5065
+ const reviewId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/review/'.length));
5066
+ return handleAxReviewUpdate(req, reviewId);
5067
+ }
5068
+
5069
+ if (url.pathname === '/api/canvas/ax/host-capability' && req.method === 'GET') {
5070
+ return handleAxHostCapabilityGet();
5071
+ }
5072
+
5073
+ if (url.pathname === '/api/canvas/ax/host-capability' && req.method === 'PUT') {
5074
+ return handleAxHostCapabilityReport(req);
5075
+ }
5076
+
4644
5077
  // Spatial context API
4645
5078
  if (url.pathname === '/api/canvas/spatial-context' && req.method === 'GET') {
4646
5079
  const layout = canvasState.getLayout();
@@ -4703,6 +5136,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4703
5136
  return handleCanvasAddJsonRender(req);
4704
5137
  }
4705
5138
 
5139
+ if (url.pathname === '/api/canvas/json-render/stream' && req.method === 'POST') {
5140
+ return handleJsonRenderStream(req);
5141
+ }
5142
+
4706
5143
  if (url.pathname === '/api/canvas/graph' && req.method === 'POST') {
4707
5144
  return handleCanvasAddGraph(req);
4708
5145
  }
@@ -5,11 +5,12 @@ import {
5
5
  readdirSync,
6
6
  mkdirSync,
7
7
  readFileSync,
8
+ realpathSync,
8
9
  statSync,
9
10
  unlinkSync,
10
11
  writeFileSync,
11
12
  } from 'node:fs';
12
- import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
13
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
13
14
  import { ensureArtifactsDir, getWorkspaceRoot } from './artifact-paths.js';
14
15
  import { canvasState, type CanvasNodeState } from './canvas-state.js';
15
16
  import { findOpenCanvasPosition } from './placement.js';
@@ -338,6 +339,33 @@ async function runProcess(
338
339
  return { stdout: stdout.trim(), stderr: stderr.trim() };
339
340
  }
340
341
 
342
+ /**
343
+ * Resolve the init/bundle script path. Without an override the trusted bundled
344
+ * resolver is used. An override (a test/debugging escape hatch exposed on every
345
+ * surface — CLI --init-script-path/--bundle-script-path, the MCP tool, the HTTP
346
+ * endpoint, and the SDK) is only honored when it resolves inside the active
347
+ * workspace root — otherwise it would let a caller exec an arbitrary host script
348
+ * via bash as the server user. This containment is the single chokepoint that
349
+ * makes forwarding the field safe across all of those surfaces.
350
+ */
351
+ function resolveTrustedScriptPath(override: string | undefined, kind: 'init' | 'bundle'): string {
352
+ if (!override) return resolveWebArtifactScriptPath(kind);
353
+ const resolved = resolve(override);
354
+ // Compare real (symlink-resolved) paths on both sides so a legitimately
355
+ // contained override under a symlinked root (e.g. macOS /var -> /private/var,
356
+ // or a workspace that itself lives beneath a symlink) is not wrongly rejected.
357
+ // The candidate is realpath'd only when it exists; a non-existent escape path
358
+ // still fails the containment check below (or the existsSync guard at the call
359
+ // site). This keeps the guard strict — genuine escapes are still rejected.
360
+ const realRoot = realpathSync(currentWorkspaceRoot());
361
+ const realCandidate = existsSync(resolved) ? realpathSync(resolved) : resolved;
362
+ const workspaceRel = relative(realRoot, realCandidate);
363
+ if (workspaceRel === '..' || workspaceRel.startsWith(`..${sep}`) || isAbsolute(workspaceRel)) {
364
+ throw new Error(`Web-artifact ${kind} script override must resolve inside the workspace: ${override}`);
365
+ }
366
+ return resolved;
367
+ }
368
+
341
369
  export function resolveWebArtifactScriptPath(kind: 'init' | 'bundle'): string {
342
370
  const scriptFile = kind === 'init' ? 'init-artifact.sh' : 'bundle-artifact.sh';
343
371
  const candidates = [
@@ -459,10 +487,8 @@ export async function executeWebArtifactBuild(
459
487
  const slug = slugify(input.title);
460
488
  const projectPath = resolve(input.projectPath ?? join(artifactsDir, '.web-artifacts', slug));
461
489
  const outputPath = resolve(input.outputPath ?? join(artifactsDir, `${slug}.html`));
462
- const initScriptPath = resolve(input.initScriptPath ?? resolveWebArtifactScriptPath('init'));
463
- const bundleScriptPath = resolve(
464
- input.bundleScriptPath ?? resolveWebArtifactScriptPath('bundle'),
465
- );
490
+ const initScriptPath = resolveTrustedScriptPath(input.initScriptPath, 'init');
491
+ const bundleScriptPath = resolveTrustedScriptPath(input.bundleScriptPath, 'bundle');
466
492
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
467
493
 
468
494
  if (!existsSync(initScriptPath)) {