pmx-canvas 0.3.0 → 0.3.1

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 (156) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/canvas/index.js +2 -2
  3. package/dist/types/cli/daemon.d.ts +74 -0
  4. package/dist/types/cli/watch.d.ts +2 -2
  5. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  6. package/dist/types/client/canvas/CommandPalette.d.ts +1 -1
  7. package/dist/types/client/canvas/Minimap.d.ts +1 -1
  8. package/dist/types/client/nodes/FileNode.d.ts +1 -1
  9. package/dist/types/client/nodes/ImageNode.d.ts +1 -1
  10. package/dist/types/client/nodes/InlineFormatBar.d.ts +1 -1
  11. package/dist/types/client/nodes/MarkdownNode.d.ts +1 -1
  12. package/dist/types/client/nodes/PromptNode.d.ts +1 -1
  13. package/dist/types/client/nodes/ResponseNode.d.ts +1 -1
  14. package/dist/types/server/canvas-schema.d.ts +1 -1
  15. package/dist/types/server/html-primitives.d.ts +1 -1
  16. package/dist/types/server/index.d.ts +4 -4
  17. package/dist/types/server/operations/index.d.ts +1 -1
  18. package/dist/types/server/operations/ops/ax-read.d.ts +2 -0
  19. package/dist/types/server/operations/ops/canvas-wire.d.ts +2 -0
  20. package/dist/types/server/operations/ops/ext-app.d.ts +2 -0
  21. package/docs/http-api.md +28 -0
  22. package/docs/plans/plan-009-tech-debt-backlog.md +5 -5
  23. package/docs/tech-debt-assessment-2026-07.md +2 -2
  24. package/package.json +5 -2
  25. package/skills/pmx-canvas/SKILL.md +3 -1
  26. package/skills/pmx-canvas/references/full-reference.md +10 -3
  27. package/src/cli/agent.ts +1861 -1548
  28. package/src/cli/daemon.ts +460 -0
  29. package/src/cli/index.ts +63 -326
  30. package/src/cli/watch.ts +2 -10
  31. package/src/client/App.tsx +48 -46
  32. package/src/client/canvas/AttentionHistory.tsx +11 -1
  33. package/src/client/canvas/CanvasNode.tsx +41 -29
  34. package/src/client/canvas/CanvasViewport.tsx +101 -66
  35. package/src/client/canvas/CommandPalette.tsx +61 -27
  36. package/src/client/canvas/ContextMenu.tsx +13 -20
  37. package/src/client/canvas/ContextPinBar.tsx +1 -5
  38. package/src/client/canvas/ContextPinHud.tsx +1 -6
  39. package/src/client/canvas/DockedNode.tsx +4 -4
  40. package/src/client/canvas/EdgeLayer.tsx +37 -36
  41. package/src/client/canvas/ExpandedNodeOverlay.tsx +69 -45
  42. package/src/client/canvas/FocusFieldLayer.tsx +20 -22
  43. package/src/client/canvas/IntentLayer.tsx +31 -14
  44. package/src/client/canvas/Minimap.tsx +11 -16
  45. package/src/client/canvas/SelectionBar.tsx +4 -11
  46. package/src/client/canvas/ShortcutOverlay.tsx +3 -1
  47. package/src/client/canvas/SnapshotPanel.tsx +77 -95
  48. package/src/client/canvas/auto-fit.ts +15 -14
  49. package/src/client/canvas/snap-guides.ts +12 -12
  50. package/src/client/canvas/use-node-resize.ts +1 -5
  51. package/src/client/canvas/use-pan-zoom.ts +25 -26
  52. package/src/client/ext-app/bridge.ts +3 -12
  53. package/src/client/icons.tsx +63 -20
  54. package/src/client/nodes/ContextNode.tsx +14 -25
  55. package/src/client/nodes/ExtAppFrame.tsx +60 -39
  56. package/src/client/nodes/FileNode.tsx +74 -62
  57. package/src/client/nodes/GroupNode.tsx +4 -6
  58. package/src/client/nodes/HtmlNode.tsx +76 -46
  59. package/src/client/nodes/ImageNode.tsx +18 -27
  60. package/src/client/nodes/InlineFormatBar.tsx +4 -21
  61. package/src/client/nodes/InlineMarkdownEditor.tsx +0 -1
  62. package/src/client/nodes/LedgerNode.tsx +10 -4
  63. package/src/client/nodes/MarkdownNode.tsx +3 -10
  64. package/src/client/nodes/McpAppNode.tsx +26 -22
  65. package/src/client/nodes/MdFormatBar.tsx +10 -7
  66. package/src/client/nodes/PromptNode.tsx +23 -51
  67. package/src/client/nodes/ResponseNode.tsx +3 -13
  68. package/src/client/nodes/StatusNode.tsx +5 -9
  69. package/src/client/nodes/StatusSummary.tsx +2 -8
  70. package/src/client/nodes/WebpageNode.tsx +20 -14
  71. package/src/client/nodes/iframe-document-url.ts +25 -16
  72. package/src/client/nodes/image-warnings.ts +1 -7
  73. package/src/client/nodes/md-format.ts +20 -5
  74. package/src/client/state/attention-bridge.ts +4 -9
  75. package/src/client/state/attention-store.ts +1 -7
  76. package/src/client/state/canvas-store.ts +52 -36
  77. package/src/client/state/intent-bridge.ts +176 -112
  78. package/src/client/state/intent-store.ts +4 -1
  79. package/src/client/state/sse-bridge.ts +53 -70
  80. package/src/json-render/catalog.ts +12 -16
  81. package/src/json-render/charts/components.tsx +16 -20
  82. package/src/json-render/charts/extra-components.tsx +8 -16
  83. package/src/json-render/charts/extra-definitions.ts +1 -2
  84. package/src/json-render/charts/tufte-components.tsx +37 -20
  85. package/src/json-render/charts/tufte-definitions.ts +8 -2
  86. package/src/json-render/renderer/index.tsx +42 -22
  87. package/src/json-render/schema.ts +6 -3
  88. package/src/json-render/server.ts +33 -39
  89. package/src/mcp/canvas-access.ts +35 -21
  90. package/src/mcp/server.ts +132 -70
  91. package/src/server/agent-context.ts +63 -36
  92. package/src/server/ax-context.ts +7 -5
  93. package/src/server/ax-interaction.ts +176 -43
  94. package/src/server/ax-state-manager.ts +182 -39
  95. package/src/server/ax-state.ts +142 -47
  96. package/src/server/canvas-db.ts +213 -95
  97. package/src/server/canvas-operations.ts +177 -120
  98. package/src/server/canvas-provenance.ts +1 -4
  99. package/src/server/canvas-schema.ts +454 -73
  100. package/src/server/canvas-serialization.ts +27 -35
  101. package/src/server/canvas-state.ts +150 -58
  102. package/src/server/chart-template.ts +4 -5
  103. package/src/server/code-graph.ts +19 -6
  104. package/src/server/diagram-presets.ts +28 -29
  105. package/src/server/ext-app-lookup.ts +3 -12
  106. package/src/server/html-node-summary.ts +19 -10
  107. package/src/server/html-primitives.ts +326 -97
  108. package/src/server/html-surface.ts +6 -9
  109. package/src/server/image-source.ts +6 -4
  110. package/src/server/index.ts +320 -217
  111. package/src/server/intent-registry.ts +2 -5
  112. package/src/server/mcp-app-candidate.ts +5 -10
  113. package/src/server/mcp-app-host.ts +14 -38
  114. package/src/server/mcp-app-runtime.ts +12 -20
  115. package/src/server/mutation-history.ts +15 -5
  116. package/src/server/operations/composites.ts +1 -3
  117. package/src/server/operations/http.ts +2 -3
  118. package/src/server/operations/index.ts +7 -1
  119. package/src/server/operations/invoker.ts +4 -3
  120. package/src/server/operations/mcp.ts +22 -30
  121. package/src/server/operations/ops/annotation.ts +122 -10
  122. package/src/server/operations/ops/app.ts +98 -73
  123. package/src/server/operations/ops/ax-await.ts +17 -10
  124. package/src/server/operations/ops/ax-read.ts +347 -0
  125. package/src/server/operations/ops/ax-shared.ts +2 -7
  126. package/src/server/operations/ops/ax-state.ts +32 -14
  127. package/src/server/operations/ops/ax-timeline.ts +32 -19
  128. package/src/server/operations/ops/ax-work.ts +54 -37
  129. package/src/server/operations/ops/batch.ts +39 -14
  130. package/src/server/operations/ops/canvas-wire.ts +91 -0
  131. package/src/server/operations/ops/edges.ts +37 -25
  132. package/src/server/operations/ops/ext-app.ts +346 -0
  133. package/src/server/operations/ops/groups.ts +49 -20
  134. package/src/server/operations/ops/intent.ts +18 -12
  135. package/src/server/operations/ops/json-render.ts +239 -98
  136. package/src/server/operations/ops/nodes.ts +298 -109
  137. package/src/server/operations/ops/query.ts +46 -28
  138. package/src/server/operations/ops/snapshots.ts +35 -26
  139. package/src/server/operations/ops/validate.ts +2 -1
  140. package/src/server/operations/ops/viewport.ts +60 -16
  141. package/src/server/operations/ops/webview.ts +44 -18
  142. package/src/server/operations/registry.ts +2 -3
  143. package/src/server/operations/types.ts +7 -5
  144. package/src/server/operations/webview-runner.ts +1 -3
  145. package/src/server/placement.ts +8 -18
  146. package/src/server/server.ts +108 -1027
  147. package/src/server/spatial-analysis.ts +39 -25
  148. package/src/server/trace-manager.ts +3 -8
  149. package/src/server/web-artifacts.ts +23 -27
  150. package/src/server/webpage-node.ts +5 -13
  151. package/src/shared/auto-arrange.ts +12 -5
  152. package/src/shared/content-height-reporter.ts +8 -6
  153. package/src/shared/ext-app-tool-result.ts +2 -6
  154. package/src/shared/placement.ts +1 -4
  155. package/src/shared/semantic-attention.ts +39 -37
  156. package/src/shared/surface.ts +8 -4
@@ -15,16 +15,11 @@ import type { PmxAxSource } from '../../ax-state.js';
15
15
  export const AX_SOURCES = ['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'] as const;
16
16
 
17
17
  /** Zod schema for the optional `source` field shared by AX MCP tool shapes. */
18
- export const AX_SOURCE_SHAPE = z
19
- .enum(AX_SOURCES)
20
- .optional()
21
- .describe('Optional host/source label. Defaults to mcp.');
18
+ export const AX_SOURCE_SHAPE = z.enum(AX_SOURCES).optional().describe('Optional host/source label. Defaults to mcp.');
22
19
 
23
20
  /** An absent or unrecognized source falls back to the per-surface default. */
24
21
  export function normalizeAxSource(value: unknown, fallback: PmxAxSource): PmxAxSource {
25
- return (AX_SOURCES as readonly string[]).includes(value as string)
26
- ? (value as PmxAxSource)
27
- : fallback;
22
+ return (AX_SOURCES as readonly string[]).includes(value as string) ? (value as PmxAxSource) : fallback;
28
23
  }
29
24
 
30
25
  export function normalizeAxNodeIds(value: unknown): string[] {
@@ -52,7 +52,8 @@ const axGetOperation = defineOperation<z.infer<typeof axGetSchema>, Record<strin
52
52
  },
53
53
  mcp: {
54
54
  toolName: 'canvas_get_ax',
55
- description: 'Read the host-agnostic PMX AX state and agent-ready AX context. Use this when you need pinned context plus the current focus field.',
55
+ description:
56
+ 'Read the host-agnostic PMX AX state and agent-ready AX context. Use this when you need pinned context plus the current focus field.',
56
57
  extraShape: {
57
58
  includeContext: z.boolean().optional().describe('Include serialized agent-ready AX context. Default true.'),
58
59
  },
@@ -85,7 +86,12 @@ const axGetOperation = defineOperation<z.infer<typeof axGetSchema>, Record<strin
85
86
 
86
87
  const axFocusSetShape = {
87
88
  nodeIds: z.unknown().optional().describe('Node IDs to place in the AX focus field. Missing nodes are ignored.'),
88
- source: z.unknown().optional().describe('Optional host/source label for adapter-originated focus. Defaults to mcp. Use codex from the Codex app adapter.'),
89
+ source: z
90
+ .unknown()
91
+ .optional()
92
+ .describe(
93
+ 'Optional host/source label for adapter-originated focus. Defaults to mcp. Use codex from the Codex app adapter.',
94
+ ),
89
95
  };
90
96
 
91
97
  const axFocusSetSchema = z.looseObject(axFocusSetShape);
@@ -101,12 +107,16 @@ const axFocusSetOperation = defineOperation<z.infer<typeof axFocusSetSchema>, Re
101
107
  },
102
108
  mcp: {
103
109
  toolName: 'canvas_set_ax_focus',
104
- description: 'Set the PMX AX focus field without requiring viewport movement. Focus is persisted and available through canvas://ax-context.',
110
+ description:
111
+ 'Set the PMX AX focus field without requiring viewport movement. Focus is persisted and available through canvas://ax-context.',
105
112
  extraShape: {
106
113
  nodeIds: z.array(z.string()).describe('Node IDs to place in the AX focus field. Missing nodes are ignored.'),
107
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
114
+ source: z
115
+ .enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
108
116
  .optional()
109
- .describe('Optional host/source label for adapter-originated focus. Defaults to mcp. Use codex from the Codex app adapter.'),
117
+ .describe(
118
+ 'Optional host/source label for adapter-originated focus. Defaults to mcp. Use codex from the Codex app adapter.',
119
+ ),
110
120
  },
111
121
  buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
112
122
  formatResult: (result) => {
@@ -146,13 +156,16 @@ const axPolicySetOperation = defineOperation<z.infer<typeof axPolicySetSchema>,
146
156
  },
147
157
  mcp: {
148
158
  toolName: 'canvas_set_ax_policy',
149
- description: 'Set the declarative AX policy (allowed/excluded/approval-required tools; prompt mode/append). PMX stores it and exposes it via canvas://ax-context; host adapters READ and enforce it. Merges with the existing policy.',
159
+ description:
160
+ 'Set the declarative AX policy (allowed/excluded/approval-required tools; prompt mode/append). PMX stores it and exposes it via canvas://ax-context; host adapters READ and enforce it. Merges with the existing policy.',
150
161
  extraShape: {
151
- tools: z.object({
152
- allowed: z.array(z.string()).optional(),
153
- excluded: z.array(z.string()).optional(),
154
- approvalRequired: z.array(z.string()).optional(),
155
- }).optional(),
162
+ tools: z
163
+ .object({
164
+ allowed: z.array(z.string()).optional(),
165
+ excluded: z.array(z.string()).optional(),
166
+ approvalRequired: z.array(z.string()).optional(),
167
+ })
168
+ .optional(),
156
169
  prompt: z.object({ systemAppend: z.string().optional(), mode: z.string().optional() }).optional(),
157
170
  source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']).optional(),
158
171
  },
@@ -196,7 +209,10 @@ const axHostCapabilityReportShape = {
196
209
 
197
210
  const axHostCapabilityReportSchema = z.looseObject(axHostCapabilityReportShape);
198
211
 
199
- const axHostCapabilityReportOperation = defineOperation<z.infer<typeof axHostCapabilityReportSchema>, Record<string, unknown>>({
212
+ const axHostCapabilityReportOperation = defineOperation<
213
+ z.infer<typeof axHostCapabilityReportSchema>,
214
+ Record<string, unknown>
215
+ >({
200
216
  name: 'ax.host-capability.report',
201
217
  mutates: false,
202
218
  input: axHostCapabilityReportSchema,
@@ -208,7 +224,8 @@ const axHostCapabilityReportOperation = defineOperation<z.infer<typeof axHostCap
208
224
  },
209
225
  mcp: {
210
226
  toolName: 'canvas_report_host_capability',
211
- description: 'Report host/session capability from an adapter: what the host can do (canvas/hooks/tools/sessionMessaging/permissions/files/uiPrompts). Stored for diagnostics; core does not depend on a host.',
227
+ description:
228
+ 'Report host/session capability from an adapter: what the host can do (canvas/hooks/tools/sessionMessaging/permissions/files/uiPrompts). Stored for diagnostics; core does not depend on a host.',
212
229
  extraShape: {
213
230
  host: z.string().optional().describe('Host identifier (e.g. copilot, codex).'),
214
231
  canvas: z.boolean().optional(),
@@ -219,7 +236,8 @@ const axHostCapabilityReportOperation = defineOperation<z.infer<typeof axHostCap
219
236
  files: z.boolean().optional(),
220
237
  uiPrompts: z.boolean().optional(),
221
238
  raw: z.record(z.string(), z.unknown()).optional().describe('Optional raw capability payload for diagnostics.'),
222
- source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
239
+ source: z
240
+ .enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
223
241
  .optional()
224
242
  .describe('Optional host/source label. Defaults to mcp.'),
225
243
  },
@@ -53,7 +53,15 @@ import { defineOperation, OperationError, type Operation } from '../types.js';
53
53
  import { isRecord } from './nodes.js';
54
54
  import { AX_SOURCE_SHAPE, axJsonResult, normalizeAxNodeIds, normalizeAxSource } from './ax-shared.js';
55
55
 
56
- const AX_EVENT_KINDS = ['prompt', 'assistant-message', 'tool-start', 'tool-result', 'failure', 'approval', 'steering'] as const;
56
+ const AX_EVENT_KINDS = [
57
+ 'prompt',
58
+ 'assistant-message',
59
+ 'tool-start',
60
+ 'tool-result',
61
+ 'failure',
62
+ 'approval',
63
+ 'steering',
64
+ ] as const;
57
65
  const AX_EVIDENCE_KINDS = ['logs', 'tool-result', 'screenshot', 'file', 'diff', 'test-output'] as const;
58
66
 
59
67
  // ── ax.event.record (canvas_record_ax_event) ──────────────────
@@ -80,7 +88,8 @@ const axEventRecordOperation = defineOperation<z.infer<typeof axEventRecordSchem
80
88
  },
81
89
  mcp: {
82
90
  toolName: 'canvas_record_ax_event',
83
- description: 'Record a normalized AX timeline event (prompt/assistant-message/tool-start/tool-result/failure/approval/steering). Timeline events persist for diagnostics and continuity but are not restored by snapshots.',
91
+ description:
92
+ 'Record a normalized AX timeline event (prompt/assistant-message/tool-start/tool-result/failure/approval/steering). Timeline events persist for diagnostics and continuity but are not restored by snapshots.',
84
93
  extraShape: {
85
94
  kind: z.enum(AX_EVENT_KINDS).describe('Normalized event kind.'),
86
95
  summary: z.string().describe('Short human-readable summary of the event.'),
@@ -136,7 +145,8 @@ const axEvidenceAddOperation = defineOperation<z.infer<typeof axEvidenceAddSchem
136
145
  },
137
146
  mcp: {
138
147
  toolName: 'canvas_add_evidence',
139
- description: 'Record an AX evidence item (logs/tool-result/screenshot/file/diff/test-output) on the timeline. Evidence persists for diagnostics and continuity but is not restored by snapshots; exposed via canvas://ax-timeline.',
148
+ description:
149
+ 'Record an AX evidence item (logs/tool-result/screenshot/file/diff/test-output) on the timeline. Evidence persists for diagnostics and continuity but is not restored by snapshots; exposed via canvas://ax-timeline.',
140
150
  extraShape: {
141
151
  kind: z.enum(AX_EVIDENCE_KINDS).describe('Evidence kind.'),
142
152
  title: z.string().describe('Short human-readable title for the evidence.'),
@@ -189,7 +199,8 @@ const axSteerOperation = defineOperation<z.infer<typeof axSteerSchema>, Record<s
189
199
  },
190
200
  mcp: {
191
201
  toolName: 'canvas_send_steering',
192
- description: 'Record a steering message: a user instruction from the surface to the active agent session. Persisted on the AX timeline and exposed via canvas://ax-timeline.',
202
+ description:
203
+ 'Record a steering message: a user instruction from the surface to the active agent session. Persisted on the AX timeline and exposed via canvas://ax-timeline.',
193
204
  extraShape: {
194
205
  message: z.string().describe('The steering instruction to deliver to the active agent session.'),
195
206
  source: AX_SOURCE_SHAPE,
@@ -228,7 +239,8 @@ const axTimelineGetOperation = defineOperation<z.infer<typeof axTimelineGetSchem
228
239
  },
229
240
  mcp: {
230
241
  toolName: 'canvas_get_ax_timeline',
231
- description: 'Read the bounded AX timeline: recent agent-events, evidence, and steering messages plus counts. Use this for diagnostics and session continuity.',
242
+ description:
243
+ 'Read the bounded AX timeline: recent agent-events, evidence, and steering messages plus counts. Use this for diagnostics and session continuity.',
232
244
  extraShape: {
233
245
  limit: z.number().optional().describe('Max rows per timeline table (default 50, max 200).'),
234
246
  },
@@ -238,9 +250,7 @@ const axTimelineGetOperation = defineOperation<z.infer<typeof axTimelineGetSchem
238
250
  // `limit` arrives as a number over MCP or a string over the HTTP query;
239
251
  // Number() normalizes both. Only a finite positive limit is forwarded.
240
252
  const limit = Number(input.limit ?? '');
241
- const timeline = canvasState.getAxTimeline(
242
- Number.isFinite(limit) && limit > 0 ? { limit } : {},
243
- );
253
+ const timeline = canvasState.getAxTimeline(Number.isFinite(limit) && limit > 0 ? { limit } : {});
244
254
  return { ok: true, ...timeline } as unknown as Record<string, unknown>;
245
255
  },
246
256
  });
@@ -266,11 +276,17 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
266
276
  },
267
277
  mcp: {
268
278
  toolName: 'canvas_claim_ax_delivery',
269
- description: 'Claim pending PMX AX deliveries for a consumer (adapterless delivery). Returns `pending` undelivered steering (mark each with canvas_mark_ax_delivery after acting) AND `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — typically created by the human in the browser. Both exclude items the consumer itself originated (loop prevention). `pending` defaults to oldest-first (FIFO, for ordered processing); pass `order:"newest"` to surface the human\'s LATEST in-canvas steering first when a small `limit` would otherwise bury it behind a stale backlog (report #68). pendingActivity is read-only here: resolve each via its own tool (canvas_resolve_approval / canvas_respond_elicitation / canvas_resolve_mode / canvas_update_work_item), not canvas_mark_ax_delivery.',
279
+ description:
280
+ 'Claim pending PMX AX deliveries for a consumer (adapterless delivery). Returns `pending` undelivered steering (mark each with canvas_mark_ax_delivery after acting) AND `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — typically created by the human in the browser. Both exclude items the consumer itself originated (loop prevention). `pending` defaults to oldest-first (FIFO, for ordered processing); pass `order:"newest"` to surface the human\'s LATEST in-canvas steering first when a small `limit` would otherwise bury it behind a stale backlog (report #68). pendingActivity is read-only here: resolve each via its own tool (canvas_resolve_approval / canvas_respond_elicitation / canvas_resolve_mode / canvas_update_work_item), not canvas_mark_ax_delivery.',
270
281
  extraShape: {
271
282
  consumer: z.string().optional().describe('Consumer/source label to exclude from results (e.g. copilot, mcp).'),
272
283
  limit: z.number().optional().describe('Max steering messages to return.'),
273
- order: z.enum(['newest', 'oldest']).optional().describe('Order of returned steering: "oldest" (FIFO, default) for ordered processing, or "newest" first to see the latest browser action when limited.'),
284
+ order: z
285
+ .enum(['newest', 'oldest'])
286
+ .optional()
287
+ .describe(
288
+ 'Order of returned steering: "oldest" (FIFO, default) for ordered processing, or "newest" first to see the latest browser action when limited.',
289
+ ),
274
290
  },
275
291
  // `consumer` is a loop-safety scope, not a source label — never defaulted.
276
292
  formatResult: axJsonResult,
@@ -285,9 +301,7 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
285
301
  // queries apply the same loop-safe consumer filter before the limit.
286
302
  const newest = input.order === 'newest';
287
303
  const scope = { ...(consumer ? { consumer } : {}), ...(limit ? { limit } : {}) };
288
- const pending = newest
289
- ? canvasState.getPendingSteeringForContext(scope)
290
- : canvasState.getPendingSteering(scope);
304
+ const pending = newest ? canvasState.getPendingSteeringForContext(scope) : canvasState.getPendingSteering(scope);
291
305
  // The MCP tool aggregated pendingActivity; one wire body now serves it over
292
306
  // HTTP too (documented broadening). Loop-safe: consumer scopes both queries.
293
307
  const pendingActivity = buildPendingAxActivity(canvasState.getAxState(), consumer);
@@ -352,7 +366,8 @@ const axCommandInvokeOperation = defineOperation<z.infer<typeof axCommandInvokeS
352
366
  },
353
367
  mcp: {
354
368
  toolName: 'canvas_invoke_command',
355
- description: 'Invoke a registry-gated PMX command intent (pmx.plan | pmx.execute | pmx.promote-context | pmx.summarize | pmx.review). Records a timeline event a host/agent can observe — NOT arbitrary execution; unknown names are rejected.',
369
+ description:
370
+ 'Invoke a registry-gated PMX command intent (pmx.plan | pmx.execute | pmx.promote-context | pmx.summarize | pmx.review). Records a timeline event a host/agent can observe — NOT arbitrary execution; unknown names are rejected.',
356
371
  extraShape: {
357
372
  name: z.string().describe('A command name from the PMX command registry.'),
358
373
  args: z.record(z.string(), z.unknown()).optional(),
@@ -365,11 +380,9 @@ const axCommandInvokeOperation = defineOperation<z.infer<typeof axCommandInvokeS
365
380
  if (typeof input.name !== 'string') {
366
381
  throw new OperationError('command requires a name.');
367
382
  }
368
- const event = canvasState.invokeCommand(
369
- input.name,
370
- isRecord(input.args) ? input.args : null,
371
- { source: normalizeAxSource(input.source, 'api') },
372
- );
383
+ const event = canvasState.invokeCommand(input.name, isRecord(input.args) ? input.args : null, {
384
+ source: normalizeAxSource(input.source, 'api'),
385
+ });
373
386
  // Allowlist gate: invokeCommand returns null for an unknown command name.
374
387
  if (!event) throw new OperationError(`Unknown command "${input.name}".`, 400);
375
388
  ctx.emit('ax-event-created', { event });
@@ -61,9 +61,7 @@ import { AX_SOURCE_SHAPE, AX_SOURCES, axJsonResult, normalizeAxNodeIds, normaliz
61
61
 
62
62
  const AX_WORK_ITEM_STATUSES = new Set(['todo', 'in-progress', 'blocked', 'done', 'cancelled']);
63
63
  function normalizeAxWorkItemStatus(value: unknown): PmxAxWorkItemStatus | undefined {
64
- return typeof value === 'string' && AX_WORK_ITEM_STATUSES.has(value)
65
- ? value as PmxAxWorkItemStatus
66
- : undefined;
64
+ return typeof value === 'string' && AX_WORK_ITEM_STATUSES.has(value) ? (value as PmxAxWorkItemStatus) : undefined;
67
65
  }
68
66
 
69
67
  const AX_REVIEW_KINDS = new Set(['comment', 'finding']);
@@ -72,16 +70,16 @@ const AX_REVIEW_STATUSES = new Set(['open', 'resolved', 'dismissed']);
72
70
  const AX_REVIEW_ANCHORS = new Set(['node', 'file', 'region']);
73
71
 
74
72
  function normalizeAxReviewKind(value: unknown): PmxAxReviewKind | undefined {
75
- return typeof value === 'string' && AX_REVIEW_KINDS.has(value) ? value as PmxAxReviewKind : undefined;
73
+ return typeof value === 'string' && AX_REVIEW_KINDS.has(value) ? (value as PmxAxReviewKind) : undefined;
76
74
  }
77
75
  function normalizeAxReviewSeverity(value: unknown): PmxAxReviewSeverity | undefined {
78
- return typeof value === 'string' && AX_REVIEW_SEVERITIES.has(value) ? value as PmxAxReviewSeverity : undefined;
76
+ return typeof value === 'string' && AX_REVIEW_SEVERITIES.has(value) ? (value as PmxAxReviewSeverity) : undefined;
79
77
  }
80
78
  function normalizeAxReviewStatus(value: unknown): PmxAxReviewStatus | undefined {
81
- return typeof value === 'string' && AX_REVIEW_STATUSES.has(value) ? value as PmxAxReviewStatus : undefined;
79
+ return typeof value === 'string' && AX_REVIEW_STATUSES.has(value) ? (value as PmxAxReviewStatus) : undefined;
82
80
  }
83
81
  function normalizeAxReviewAnchor(value: unknown): PmxAxReviewAnchorType | undefined {
84
- return typeof value === 'string' && AX_REVIEW_ANCHORS.has(value) ? value as PmxAxReviewAnchorType : undefined;
82
+ return typeof value === 'string' && AX_REVIEW_ANCHORS.has(value) ? (value as PmxAxReviewAnchorType) : undefined;
85
83
  }
86
84
  function normalizeAxReviewRegion(value: unknown): PmxAxReviewRegion | undefined {
87
85
  if (!isRecord(value)) return undefined;
@@ -115,10 +113,12 @@ const axWorkCreateOperation = defineOperation<z.infer<typeof axWorkCreateSchema>
115
113
  },
116
114
  mcp: {
117
115
  toolName: 'canvas_add_work_item',
118
- description: 'Add a canvas-bound AX work item: a visible task/plan/status tied to nodes and agent work. Work items participate in snapshots and are exposed via canvas://ax-work.',
116
+ description:
117
+ 'Add a canvas-bound AX work item: a visible task/plan/status tied to nodes and agent work. Work items participate in snapshots and are exposed via canvas://ax-work.',
119
118
  extraShape: {
120
119
  title: z.string().describe('Short title of the work item.'),
121
- status: z.enum(['todo', 'in-progress', 'blocked', 'done', 'cancelled'])
120
+ status: z
121
+ .enum(['todo', 'in-progress', 'blocked', 'done', 'cancelled'])
122
122
  .optional()
123
123
  .describe('Work item status. Defaults to todo.'),
124
124
  detail: z.string().optional().describe('Optional longer description.'),
@@ -135,7 +135,9 @@ const axWorkCreateOperation = defineOperation<z.infer<typeof axWorkCreateSchema>
135
135
  // Report #56: reject an unknown status (e.g. "in_progress") instead of
136
136
  // silently dropping it — the accepted tokens use hyphens.
137
137
  if (input.status !== undefined && !normalizeAxWorkItemStatus(input.status)) {
138
- throw new OperationError(`invalid work item status "${String(input.status)}"; expected one of: todo, in-progress, blocked, done, cancelled.`);
138
+ throw new OperationError(
139
+ `invalid work item status "${String(input.status)}"; expected one of: todo, in-progress, blocked, done, cancelled.`,
140
+ );
139
141
  }
140
142
  const status = normalizeAxWorkItemStatus(input.status);
141
143
  const workItem = canvasState.addWorkItem(
@@ -176,13 +178,12 @@ const axWorkUpdateOperation = defineOperation<z.infer<typeof axWorkUpdateSchema>
176
178
  },
177
179
  mcp: {
178
180
  toolName: 'canvas_update_work_item',
179
- description: 'Update a canvas-bound AX work item by ID (title/status/detail/nodeIds). Returns null if the work item does not exist.',
181
+ description:
182
+ 'Update a canvas-bound AX work item by ID (title/status/detail/nodeIds). Returns null if the work item does not exist.',
180
183
  extraShape: {
181
184
  id: z.string().describe('Work item ID to update.'),
182
185
  title: z.string().optional().describe('New title.'),
183
- status: z.enum(['todo', 'in-progress', 'blocked', 'done', 'cancelled'])
184
- .optional()
185
- .describe('New status.'),
186
+ status: z.enum(['todo', 'in-progress', 'blocked', 'done', 'cancelled']).optional().describe('New status.'),
186
187
  detail: z.string().optional().describe('New detail text.'),
187
188
  nodeIds: z.array(z.string()).optional().describe('Replacement node IDs.'),
188
189
  source: AX_SOURCE_SHAPE,
@@ -194,7 +195,9 @@ const axWorkUpdateOperation = defineOperation<z.infer<typeof axWorkUpdateSchema>
194
195
  const id = typeof input.id === 'string' ? input.id : '';
195
196
  // Report #56: reject an unknown status instead of returning ok:true + no-op.
196
197
  if (input.status !== undefined && !normalizeAxWorkItemStatus(input.status)) {
197
- throw new OperationError(`invalid work item status "${String(input.status)}"; expected one of: todo, in-progress, blocked, done, cancelled.`);
198
+ throw new OperationError(
199
+ `invalid work item status "${String(input.status)}"; expected one of: todo, in-progress, blocked, done, cancelled.`,
200
+ );
198
201
  }
199
202
  const status = normalizeAxWorkItemStatus(input.status);
200
203
  const workItem = canvasState.updateWorkItem(
@@ -240,7 +243,8 @@ const axReviewAddOperation = defineOperation<z.infer<typeof axReviewAddSchema>,
240
243
  },
241
244
  mcp: {
242
245
  toolName: 'canvas_add_review_annotation',
243
- description: 'Add a canvas-bound review annotation: a comment or finding anchored to a node, file, or region. Review annotations participate in snapshots and are exposed via canvas://ax-work.',
246
+ description:
247
+ 'Add a canvas-bound review annotation: a comment or finding anchored to a node, file, or region. Review annotations participate in snapshots and are exposed via canvas://ax-work.',
244
248
  extraShape: {
245
249
  body: z.string().describe('Annotation body text.'),
246
250
  kind: z.enum(['comment', 'finding']).optional().describe('Annotation kind. Default comment.'),
@@ -248,11 +252,14 @@ const axReviewAddOperation = defineOperation<z.infer<typeof axReviewAddSchema>,
248
252
  anchorType: z.enum(['node', 'file', 'region']).optional().describe('Anchor type. Default node.'),
249
253
  nodeId: z.string().optional().describe('Node ID when anchorType is node.'),
250
254
  file: z.string().optional().describe('File path when anchorType is file.'),
251
- region: z.object({
252
- line: z.number().optional(),
253
- endLine: z.number().optional(),
254
- label: z.string().optional(),
255
- }).optional().describe('Region descriptor when anchorType is region.'),
255
+ region: z
256
+ .object({
257
+ line: z.number().optional(),
258
+ endLine: z.number().optional(),
259
+ label: z.string().optional(),
260
+ })
261
+ .optional()
262
+ .describe('Region descriptor when anchorType is region.'),
256
263
  author: z.string().optional().describe('Optional author label.'),
257
264
  source: AX_SOURCE_SHAPE,
258
265
  },
@@ -356,7 +363,8 @@ const axApprovalRequestOperation = defineOperation<z.infer<typeof axApprovalRequ
356
363
  },
357
364
  mcp: {
358
365
  toolName: 'canvas_request_approval',
359
- description: 'Request human approval before a high-impact AX action: creates a pending approval gate tied to nodes. Canvas-bound and snapshotted; exposed via canvas://ax-work.',
366
+ description:
367
+ 'Request human approval before a high-impact AX action: creates a pending approval gate tied to nodes. Canvas-bound and snapshotted; exposed via canvas://ax-work.',
360
368
  extraShape: {
361
369
  title: z.string().describe('Short title of what needs approval.'),
362
370
  detail: z.string().optional().describe('Optional explanation of the action and its impact.'),
@@ -407,7 +415,8 @@ const axApprovalResolveOperation = defineOperation<z.infer<typeof axApprovalReso
407
415
  },
408
416
  mcp: {
409
417
  toolName: 'canvas_resolve_approval',
410
- description: 'Resolve a pending approval gate by ID with approved or rejected. Returns null if the gate does not exist or is already resolved.',
418
+ description:
419
+ 'Resolve a pending approval gate by ID with approved or rejected. Returns null if the gate does not exist or is already resolved.',
411
420
  extraShape: {
412
421
  id: z.string().describe('Approval gate ID to resolve.'),
413
422
  decision: z.enum(['approved', 'rejected']).describe('Approval decision.'),
@@ -422,14 +431,10 @@ const axApprovalResolveOperation = defineOperation<z.infer<typeof axApprovalReso
422
431
  if (input.decision !== 'approved' && input.decision !== 'rejected') {
423
432
  throw new OperationError('resolve requires decision approved or rejected.');
424
433
  }
425
- const approvalGate = canvasState.resolveApproval(
426
- id,
427
- input.decision,
428
- {
429
- ...(typeof input.resolution === 'string' ? { resolution: input.resolution } : {}),
430
- source: normalizeAxSource(input.source, 'api'),
431
- },
432
- );
434
+ const approvalGate = canvasState.resolveApproval(id, input.decision, {
435
+ ...(typeof input.resolution === 'string' ? { resolution: input.resolution } : {}),
436
+ source: normalizeAxSource(input.source, 'api'),
437
+ });
433
438
  if (!approvalGate) throw new OperationError('approval gate not found or already resolved.', 404);
434
439
  ctx.emit('ax-state-changed', { approvalGate });
435
440
  return { ok: true, approvalGate } as unknown as Record<string, unknown>;
@@ -447,7 +452,10 @@ const axElicitationRequestShape = {
447
452
 
448
453
  const axElicitationRequestSchema = z.looseObject(axElicitationRequestShape);
449
454
 
450
- const axElicitationRequestOperation = defineOperation<z.infer<typeof axElicitationRequestSchema>, Record<string, unknown>>({
455
+ const axElicitationRequestOperation = defineOperation<
456
+ z.infer<typeof axElicitationRequestSchema>,
457
+ Record<string, unknown>
458
+ >({
451
459
  name: 'ax.elicitation.request',
452
460
  mutates: false,
453
461
  input: axElicitationRequestSchema,
@@ -458,7 +466,8 @@ const axElicitationRequestOperation = defineOperation<z.infer<typeof axElicitati
458
466
  },
459
467
  mcp: {
460
468
  toolName: 'canvas_request_elicitation',
461
- description: 'Request structured human input (an elicitation): a pending question/form tied to nodes. Canvas-bound and snapshotted; exposed via canvas://ax-work. Answer it with canvas_respond_elicitation.',
469
+ description:
470
+ 'Request structured human input (an elicitation): a pending question/form tied to nodes. Canvas-bound and snapshotted; exposed via canvas://ax-work. Answer it with canvas_respond_elicitation.',
462
471
  extraShape: {
463
472
  prompt: z.string().describe('The question or instruction for the human.'),
464
473
  fields: z.array(z.string()).optional().describe('Optional field names to request (a simple structured form).'),
@@ -475,7 +484,9 @@ const axElicitationRequestOperation = defineOperation<z.infer<typeof axElicitati
475
484
  const elicitation = canvasState.requestElicitation(
476
485
  {
477
486
  prompt: input.prompt,
478
- ...(Array.isArray(input.fields) ? { fields: input.fields.filter((f): f is string => typeof f === 'string') } : {}),
487
+ ...(Array.isArray(input.fields)
488
+ ? { fields: input.fields.filter((f): f is string => typeof f === 'string') }
489
+ : {}),
479
490
  ...(Array.isArray(input.nodeIds) ? { nodeIds: normalizeAxNodeIds(input.nodeIds) } : {}),
480
491
  },
481
492
  { source: normalizeAxSource(input.source, 'api') },
@@ -495,7 +506,10 @@ const axElicitationRespondShape = {
495
506
 
496
507
  const axElicitationRespondSchema = z.looseObject(axElicitationRespondShape);
497
508
 
498
- const axElicitationRespondOperation = defineOperation<z.infer<typeof axElicitationRespondSchema>, Record<string, unknown>>({
509
+ const axElicitationRespondOperation = defineOperation<
510
+ z.infer<typeof axElicitationRespondSchema>,
511
+ Record<string, unknown>
512
+ >({
499
513
  name: 'ax.elicitation.respond',
500
514
  mutates: false,
501
515
  input: axElicitationRespondSchema,
@@ -518,7 +532,9 @@ const axElicitationRespondOperation = defineOperation<z.infer<typeof axElicitati
518
532
  handler: (input, ctx) => {
519
533
  const id = typeof input.id === 'string' ? input.id : '';
520
534
  const response = isRecord(input.response) ? input.response : {};
521
- const elicitation = canvasState.respondElicitation(id, response, { source: normalizeAxSource(input.source, 'api') });
535
+ const elicitation = canvasState.respondElicitation(id, response, {
536
+ source: normalizeAxSource(input.source, 'api'),
537
+ });
522
538
  if (!elicitation) throw new OperationError('elicitation not found or already answered.', 404);
523
539
  ctx.emit('ax-state-changed', { elicitation });
524
540
  return { ok: true, elicitation } as unknown as Record<string, unknown>;
@@ -547,7 +563,8 @@ const axModeRequestOperation = defineOperation<z.infer<typeof axModeRequestSchem
547
563
  },
548
564
  mcp: {
549
565
  toolName: 'canvas_request_mode',
550
- description: 'Request a workflow mode transition (plan/execute/autonomous): a pending mode request tied to nodes. Canvas-bound and snapshotted; exposed via canvas://ax-work. Resolve with canvas_resolve_mode.',
566
+ description:
567
+ 'Request a workflow mode transition (plan/execute/autonomous): a pending mode request tied to nodes. Canvas-bound and snapshotted; exposed via canvas://ax-work. Resolve with canvas_resolve_mode.',
551
568
  extraShape: {
552
569
  mode: z.enum(['plan', 'execute', 'autonomous']).describe('Requested target mode.'),
553
570
  reason: z.string().optional(),
@@ -24,10 +24,7 @@
24
24
  */
25
25
  import { z } from 'zod';
26
26
  import { canvasState } from '../../canvas-state.js';
27
- import {
28
- serializeCanvasNode,
29
- serializeCanvasNodeCompact,
30
- } from '../../canvas-serialization.js';
27
+ import { serializeCanvasNode, serializeCanvasNodeCompact } from '../../canvas-serialization.js';
31
28
  import { addCanvasNode } from '../../canvas-operations.js';
32
29
  import { executeOperation, runWithSuppressedEmits } from '../registry.js';
33
30
  import { defineOperation, type Operation, type OperationContext } from '../types.js';
@@ -261,12 +258,19 @@ async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
261
258
  }
262
259
 
263
260
  const batchShape = {
264
- operations: z.array(z.object({
265
- op: z.string().describe('Operation name, e.g. "node.add" or "edge.add"'),
266
- assign: z.string().optional().describe('Optional reference name for later operations'),
267
- args: z.record(z.string(), z.unknown()).optional().describe('Operation arguments'),
268
- })).describe('Ordered array of batch operations'),
269
- full: z.boolean().optional().describe('Return full batch operation results. Default false compacts node-like payloads.'),
261
+ operations: z
262
+ .array(
263
+ z.object({
264
+ op: z.string().describe('Operation name, e.g. "node.add" or "edge.add"'),
265
+ assign: z.string().optional().describe('Optional reference name for later operations'),
266
+ args: z.record(z.string(), z.unknown()).optional().describe('Operation arguments'),
267
+ }),
268
+ )
269
+ .describe('Ordered array of batch operations'),
270
+ full: z
271
+ .boolean()
272
+ .optional()
273
+ .describe('Return full batch operation results. Default false compacts node-like payloads.'),
270
274
  verbose: z.boolean().optional().describe('Alias for full:true.'),
271
275
  };
272
276
 
@@ -287,7 +291,25 @@ function compactBatchValue(value: unknown): unknown {
287
291
  const record = value as Record<string, unknown>;
288
292
  const nodeLike = typeof record.id === 'string' && typeof record.type === 'string';
289
293
  const compact: Record<string, unknown> = {};
290
- for (const key of ['ok', 'id', 'type', 'kind', 'title', 'content', 'position', 'size', 'fetch', 'error', 'from', 'to', 'groupId', 'nodeIds', 'snapshot', 'arranged', 'layout']) {
294
+ for (const key of [
295
+ 'ok',
296
+ 'id',
297
+ 'type',
298
+ 'kind',
299
+ 'title',
300
+ 'content',
301
+ 'position',
302
+ 'size',
303
+ 'fetch',
304
+ 'error',
305
+ 'from',
306
+ 'to',
307
+ 'groupId',
308
+ 'nodeIds',
309
+ 'snapshot',
310
+ 'arranged',
311
+ 'layout',
312
+ ]) {
291
313
  if (record[key] !== undefined) compact[key] = record[key];
292
314
  }
293
315
  if (nodeLike) return compact;
@@ -325,13 +347,16 @@ const batchOperation = defineOperation<z.infer<typeof batchSchema>, BatchEnvelop
325
347
  const body = await readJsonValue(req);
326
348
  const operations = Array.isArray(body)
327
349
  ? body
328
- : isRecord(body) && Array.isArray(body.operations) ? body.operations : [];
350
+ : isRecord(body) && Array.isArray(body.operations)
351
+ ? body.operations
352
+ : [];
329
353
  return { operations };
330
354
  },
331
355
  },
332
356
  mcp: {
333
357
  toolName: 'canvas_batch',
334
- description: 'Run a non-atomic batch of canvas operations with optional assigned references. Use assign to name a result, then reference it later as "$name" for the created node id or "$name.id" for a specific result field. On failure, earlier successful operations remain applied and the response includes ok:false, failedIndex, error, results, and refs. Supports node.add, node.update, node.remove, graph.add, edge.add, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
358
+ description:
359
+ 'Run a non-atomic batch of canvas operations with optional assigned references. Use assign to name a result, then reference it later as "$name" for the created node id or "$name.id" for a specific result field. On failure, earlier successful operations remain applied and the response includes ok:false, failedIndex, error, results, and refs. Supports node.add, node.update, node.remove, graph.add, edge.add, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
335
360
  formatResult: (result, input) => {
336
361
  const envelope = result as BatchEnvelope;
337
362
  const payload = wantsFullBatch(input) ? envelope : compactBatchResult(envelope);
@@ -361,5 +386,5 @@ export const batchOperations: Operation[] = [batchOperation];
361
386
  export async function runCanvasBatchOperation(
362
387
  operations: Array<{ op: string; assign?: string; args?: Record<string, unknown> }>,
363
388
  ): Promise<BatchEnvelope> {
364
- return await executeOperation('canvas.batch', { operations }) as BatchEnvelope;
389
+ return (await executeOperation('canvas.batch', { operations })) as BatchEnvelope;
365
390
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Canvas wire ops (plan-009 C1 slice 3): theme.get / theme.set /
3
+ * canvas.apply-updates — the remaining simple browser-client routes
4
+ * (GET+POST /api/canvas/theme, POST /api/canvas/update), wire-identical to
5
+ * the legacy server.ts handlers they replace. HTTP-only: no MCP tools.
6
+ *
7
+ * This module must never import server.ts or index.ts.
8
+ */
9
+ import { z } from 'zod';
10
+ import { normalizeCanvasTheme } from '../../canvas-db.js';
11
+ import { applyCanvasNodeUpdates } from '../../canvas-operations.js';
12
+ import { canvasState } from '../../canvas-state.js';
13
+ import { defineOperation, type Operation } from '../types.js';
14
+
15
+ const emptyShape = {};
16
+ const emptySchema = z.looseObject(emptyShape);
17
+
18
+ const themeGetOperation = defineOperation<z.infer<typeof emptySchema>, Record<string, unknown>>({
19
+ name: 'theme.get',
20
+ mutates: false,
21
+ input: emptySchema,
22
+ inputShape: emptyShape,
23
+ http: {
24
+ method: 'GET',
25
+ path: '/api/canvas/theme',
26
+ },
27
+ handler: () => ({ ok: true, theme: canvasState.theme }),
28
+ });
29
+
30
+ const themeSetShape = {
31
+ theme: z.unknown().optional().describe('Theme: dark, light, or high-contrast'),
32
+ };
33
+ const themeSetSchema = z.looseObject(themeSetShape);
34
+
35
+ const themeSetOperation = defineOperation<z.infer<typeof themeSetSchema>, Record<string, unknown>>({
36
+ name: 'theme.set',
37
+ mutates: false,
38
+ input: themeSetSchema,
39
+ inputShape: themeSetShape,
40
+ http: {
41
+ method: 'POST',
42
+ path: '/api/canvas/theme',
43
+ },
44
+ handler: (input, ctx) => {
45
+ const theme = normalizeCanvasTheme(input.theme, canvasState.theme);
46
+ const next = canvasState.setTheme(theme);
47
+ ctx.emit('theme-changed', { theme: next });
48
+ return { ok: true, theme: next };
49
+ },
50
+ });
51
+
52
+ const applyUpdatesShape = {
53
+ updates: z.unknown().optional().describe('Array of node updates: { id, position?, size?, … }'),
54
+ recordHistory: z.unknown().optional().describe('Pass false to skip the undo-history entries'),
55
+ };
56
+ const applyUpdatesSchema = z.looseObject(applyUpdatesShape);
57
+
58
+ const applyUpdatesOperation = defineOperation<z.infer<typeof applyUpdatesSchema>, Record<string, unknown>>({
59
+ name: 'canvas.apply-updates',
60
+ mutates: false,
61
+ input: applyUpdatesSchema,
62
+ inputShape: applyUpdatesShape,
63
+ http: {
64
+ method: 'POST',
65
+ path: '/api/canvas/update',
66
+ },
67
+ // Legacy wire: one layout update only when something actually applied.
68
+ handler: (input, ctx) => {
69
+ const body: Record<string, unknown> = input;
70
+ const updates = Array.isArray(body.updates) ? body.updates : [];
71
+ const result =
72
+ body.recordHistory === false
73
+ ? (() => {
74
+ let suppressedResult: ReturnType<typeof applyCanvasNodeUpdates> = {
75
+ applied: 0,
76
+ skipped: updates.length,
77
+ };
78
+ canvasState.withSuppressedRecording(() => {
79
+ suppressedResult = applyCanvasNodeUpdates(updates);
80
+ });
81
+ return suppressedResult;
82
+ })()
83
+ : applyCanvasNodeUpdates(updates);
84
+ if (result.applied > 0) {
85
+ ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
86
+ }
87
+ return { ok: true, ...result };
88
+ },
89
+ });
90
+
91
+ export const canvasWireOperations: Operation[] = [themeGetOperation, themeSetOperation, applyUpdatesOperation];