pmx-canvas 0.1.35 → 0.2.0

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 (100) hide show
  1. package/CHANGELOG.md +461 -0
  2. package/Readme.md +14 -2
  3. package/dist/canvas/index.js +82 -41
  4. package/dist/json-render/index.js +89 -334
  5. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  6. package/dist/types/mcp/canvas-access.d.ts +12 -159
  7. package/dist/types/server/ax-context.d.ts +1 -1
  8. package/dist/types/server/ax-state-manager.d.ts +256 -0
  9. package/dist/types/server/ax-state.d.ts +29 -1
  10. package/dist/types/server/ax-wait.d.ts +23 -0
  11. package/dist/types/server/canvas-operations.d.ts +1 -12
  12. package/dist/types/server/canvas-state.d.ts +46 -14
  13. package/dist/types/server/html-surface.d.ts +7 -0
  14. package/dist/types/server/index.d.ts +66 -26
  15. package/dist/types/server/operations/composites.d.ts +121 -0
  16. package/dist/types/server/operations/http.d.ts +7 -0
  17. package/dist/types/server/operations/index.d.ts +8 -0
  18. package/dist/types/server/operations/invoker.d.ts +13 -0
  19. package/dist/types/server/operations/mcp.d.ts +15 -0
  20. package/dist/types/server/operations/ops/annotation.d.ts +2 -0
  21. package/dist/types/server/operations/ops/app.d.ts +33 -0
  22. package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
  23. package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
  24. package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
  25. package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
  26. package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
  27. package/dist/types/server/operations/ops/batch.d.ts +19 -0
  28. package/dist/types/server/operations/ops/edges.d.ts +2 -0
  29. package/dist/types/server/operations/ops/groups.d.ts +2 -0
  30. package/dist/types/server/operations/ops/json-render.d.ts +31 -0
  31. package/dist/types/server/operations/ops/nodes.d.ts +62 -0
  32. package/dist/types/server/operations/ops/query.d.ts +2 -0
  33. package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
  34. package/dist/types/server/operations/ops/validate.d.ts +2 -0
  35. package/dist/types/server/operations/ops/viewport.d.ts +2 -0
  36. package/dist/types/server/operations/ops/webview.d.ts +2 -0
  37. package/dist/types/server/operations/registry.d.ts +15 -0
  38. package/dist/types/server/operations/types.d.ts +116 -0
  39. package/dist/types/server/operations/webview-runner.d.ts +69 -0
  40. package/docs/RELEASE.md +5 -0
  41. package/docs/adr-001-bun-only-runtime.md +46 -0
  42. package/docs/api-stability.md +57 -0
  43. package/docs/ax-host-adapter-contract.md +65 -0
  44. package/docs/ax-state-contract.md +72 -0
  45. package/docs/http-api.md +34 -2
  46. package/docs/mcp.md +64 -11
  47. package/docs/plans/plan-005-operation-registry.md +84 -0
  48. package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
  49. package/docs/plans/plan-007-ax-domain.md +99 -0
  50. package/docs/plans/plan-008-registry-finish.md +91 -0
  51. package/docs/screenshot.png +0 -0
  52. package/docs/tech-debt-assessment-2026-06.md +90 -0
  53. package/package.json +3 -3
  54. package/skills/pmx-canvas/SKILL.md +233 -185
  55. package/skills/pmx-canvas/evals/evals.json +3 -3
  56. package/skills/pmx-canvas/references/codex-app-adapter.md +24 -11
  57. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +31 -1
  58. package/src/cli/agent.ts +52 -31
  59. package/src/client/nodes/ExtAppFrame.tsx +73 -5
  60. package/src/client/nodes/HtmlNode.tsx +12 -3
  61. package/src/client/nodes/McpAppNode.tsx +12 -3
  62. package/src/json-render/renderer/index.tsx +3 -0
  63. package/src/mcp/canvas-access.ts +43 -774
  64. package/src/mcp/server.ts +190 -2001
  65. package/src/server/ax-context.ts +7 -1
  66. package/src/server/ax-state-manager.ts +808 -0
  67. package/src/server/ax-state.ts +89 -2
  68. package/src/server/ax-wait.ts +56 -0
  69. package/src/server/canvas-operations.ts +2 -328
  70. package/src/server/canvas-schema.ts +2 -2
  71. package/src/server/canvas-state.ts +140 -382
  72. package/src/server/html-surface.ts +49 -11
  73. package/src/server/index.ts +136 -192
  74. package/src/server/operations/composites.ts +355 -0
  75. package/src/server/operations/http.ts +103 -0
  76. package/src/server/operations/index.ts +65 -0
  77. package/src/server/operations/invoker.ts +87 -0
  78. package/src/server/operations/mcp.ts +221 -0
  79. package/src/server/operations/ops/annotation.ts +60 -0
  80. package/src/server/operations/ops/app.ts +447 -0
  81. package/src/server/operations/ops/ax-await.ts +216 -0
  82. package/src/server/operations/ops/ax-shared.ts +38 -0
  83. package/src/server/operations/ops/ax-state.ts +249 -0
  84. package/src/server/operations/ops/ax-timeline.ts +381 -0
  85. package/src/server/operations/ops/ax-work.ts +635 -0
  86. package/src/server/operations/ops/batch.ts +365 -0
  87. package/src/server/operations/ops/edges.ts +166 -0
  88. package/src/server/operations/ops/groups.ts +176 -0
  89. package/src/server/operations/ops/json-render.ts +691 -0
  90. package/src/server/operations/ops/nodes.ts +1047 -0
  91. package/src/server/operations/ops/query.ts +281 -0
  92. package/src/server/operations/ops/snapshots.ts +366 -0
  93. package/src/server/operations/ops/validate.ts +37 -0
  94. package/src/server/operations/ops/viewport.ts +219 -0
  95. package/src/server/operations/ops/webview.ts +339 -0
  96. package/src/server/operations/registry.ts +79 -0
  97. package/src/server/operations/types.ts +150 -0
  98. package/src/server/operations/webview-runner.ts +77 -0
  99. package/src/server/server.ts +253 -2170
  100. package/src/server/web-artifacts.ts +6 -2
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared helpers for the AX operation modules (ax-state.ts, ax-work.ts, and the
3
+ * later timeline/delivery waves). These were replicated per-file during the
4
+ * plan-007 Slice B migration; centralizing them here keeps one definition site
5
+ * as more AX op files land.
6
+ *
7
+ * `normalizeAxSource` / `normalizeAxNodeIds` are reimplemented from server.ts
8
+ * because `operations/` must never import server.ts (the SSE emitter is injected;
9
+ * see plan-005). This module likewise must not import server.ts or index.ts.
10
+ */
11
+ import { z } from 'zod';
12
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
13
+ import type { PmxAxSource } from '../../ax-state.js';
14
+
15
+ export const AX_SOURCES = ['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'] as const;
16
+
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.');
22
+
23
+ /** An absent or unrecognized source falls back to the per-surface default. */
24
+ 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;
28
+ }
29
+
30
+ export function normalizeAxNodeIds(value: unknown): string[] {
31
+ if (!Array.isArray(value)) return [];
32
+ return value.filter((id): id is string => typeof id === 'string');
33
+ }
34
+
35
+ /** The plain JSON tool result shared by AX ops whose MCP body is the wire body. */
36
+ export function axJsonResult(result: unknown): CallToolResult {
37
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
38
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Plan-007 Slice B.1 operations: the four AX "state" reads/mutators —
3
+ * ax.get / ax.focus.set / ax.policy.set / ax.host-capability.report.
4
+ *
5
+ * These do NOT change the node/edge layout, so every op is `mutates: false`
6
+ * (no `canvas-layout-update`). The three mutators emit the SAME AX SSE frame
7
+ * the legacy handlers emitted — `ax-state-changed` via `ctx.emit` — and the
8
+ * injected emitter adds the `sessionId`/`timestamp` envelope fields the legacy
9
+ * handlers set explicitly (see server.ts emitPrimaryWorkbenchEvent).
10
+ *
11
+ * Source defaulting matches the legacy surfaces exactly: MCP `buildInput`
12
+ * injects `source: 'mcp'`; the HTTP handlers default an absent source to 'api'.
13
+ *
14
+ * Cross-surface unification (documented, same class as plan-005 slices 1–4):
15
+ * the legacy HTTP `GET /api/canvas/ax` served only `{ ok, state }`, while the
16
+ * legacy `canvas_get_ax` MCP tool aggregated `{ ok, state, host, context }` via
17
+ * three separate access calls. One op = one wire body, so `ax.get` now serves
18
+ * the FULL aggregate over HTTP too. (No HTTP test asserted the slim body; they
19
+ * only read `state.*`.) MCP `buildInput` passes `consumer: 'mcp'` so the
20
+ * context's compact delivery lead block stays loop-filtered for MCP, as before.
21
+ *
22
+ * This module must never import server.ts or index.ts.
23
+ */
24
+ import { z } from 'zod';
25
+ import { canvasState } from '../../canvas-state.js';
26
+ import { buildCanvasAxContext } from '../../ax-context.js';
27
+ import type { PmxAxPolicy } from '../../ax-state.js';
28
+ import { defineOperation, type Operation } from '../types.js';
29
+ import { isRecord } from './nodes.js';
30
+ import { normalizeAxNodeIds, normalizeAxSource } from './ax-shared.js';
31
+
32
+ // ── ax.get (canvas_get_ax) ────────────────────────────────────
33
+
34
+ const axGetShape = {
35
+ includeContext: z.unknown().optional().describe('Include serialized agent-ready AX context. Default true.'),
36
+ // `consumer` is read by the handler (HTTP callers may pass ?consumer=) but is
37
+ // intentionally NOT in the advertised shape: agents must not override it (MCP
38
+ // buildInput forces 'mcp' for loop-safe delivery filtering). The loose schema
39
+ // still passes a query-supplied `consumer` through to the handler.
40
+ };
41
+
42
+ const axGetSchema = z.looseObject(axGetShape);
43
+
44
+ const axGetOperation = defineOperation<z.infer<typeof axGetSchema>, Record<string, unknown>>({
45
+ name: 'ax.get',
46
+ mutates: false,
47
+ input: axGetSchema,
48
+ inputShape: axGetShape,
49
+ http: {
50
+ method: 'GET',
51
+ path: '/api/canvas/ax',
52
+ },
53
+ mcp: {
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.',
56
+ extraShape: {
57
+ includeContext: z.boolean().optional().describe('Include serialized agent-ready AX context. Default true.'),
58
+ },
59
+ // The legacy MCP tool aggregated state + host + (consumer:'mcp') context.
60
+ buildInput: (input) => ({
61
+ ...(input.includeContext === false ? { includeContext: false } : {}),
62
+ consumer: 'mcp',
63
+ }),
64
+ formatResult: (result) => ({
65
+ content: [{ type: 'text' as const, text: JSON.stringify(result) }],
66
+ }),
67
+ },
68
+ handler: (input) => {
69
+ const state = canvasState.getAxState();
70
+ const host = canvasState.getHostCapability();
71
+ const consumer = typeof input.consumer === 'string' ? input.consumer : undefined;
72
+ // `false` over MCP (boolean) or `'false'` over HTTP query (string) both skip context.
73
+ const excludeContext = input.includeContext === false || input.includeContext === 'false';
74
+ const context = excludeContext ? undefined : buildCanvasAxContext(consumer);
75
+ return {
76
+ ok: true,
77
+ state,
78
+ host,
79
+ ...(context ? { context } : {}),
80
+ } as unknown as Record<string, unknown>;
81
+ },
82
+ });
83
+
84
+ // ── ax.focus.set (canvas_set_ax_focus) ────────────────────────
85
+
86
+ const axFocusSetShape = {
87
+ 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
+ };
90
+
91
+ const axFocusSetSchema = z.looseObject(axFocusSetShape);
92
+
93
+ const axFocusSetOperation = defineOperation<z.infer<typeof axFocusSetSchema>, Record<string, unknown>>({
94
+ name: 'ax.focus.set',
95
+ mutates: false,
96
+ input: axFocusSetSchema,
97
+ inputShape: axFocusSetShape,
98
+ http: {
99
+ method: 'POST',
100
+ path: '/api/canvas/ax/focus',
101
+ },
102
+ mcp: {
103
+ 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.',
105
+ extraShape: {
106
+ 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'])
108
+ .optional()
109
+ .describe('Optional host/source label for adapter-originated focus. Defaults to mcp. Use codex from the Codex app adapter.'),
110
+ },
111
+ buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
112
+ formatResult: (result) => {
113
+ const body = isRecord(result) ? result : {};
114
+ return {
115
+ content: [{ type: 'text' as const, text: JSON.stringify({ ok: true, focus: body.focus }) }],
116
+ };
117
+ },
118
+ },
119
+ handler: (input, ctx) => {
120
+ const nodeIds = normalizeAxNodeIds(input.nodeIds);
121
+ const source = normalizeAxSource(input.source, 'api');
122
+ const focus = canvasState.setAxFocus(nodeIds, { source });
123
+ ctx.emit('ax-state-changed', { focus });
124
+ return { ok: true, focus } as unknown as Record<string, unknown>;
125
+ },
126
+ });
127
+
128
+ // ── ax.policy.set (canvas_set_ax_policy) ──────────────────────
129
+
130
+ const axPolicySetShape = {
131
+ tools: z.unknown().optional().describe('Tool policy patch: allowed/excluded/approvalRequired arrays.'),
132
+ prompt: z.unknown().optional().describe('Prompt policy patch: systemAppend/mode.'),
133
+ source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
134
+ };
135
+
136
+ const axPolicySetSchema = z.looseObject(axPolicySetShape);
137
+
138
+ const axPolicySetOperation = defineOperation<z.infer<typeof axPolicySetSchema>, Record<string, unknown>>({
139
+ name: 'ax.policy.set',
140
+ mutates: false,
141
+ input: axPolicySetSchema,
142
+ inputShape: axPolicySetShape,
143
+ http: {
144
+ method: 'POST',
145
+ path: '/api/canvas/ax/policy',
146
+ },
147
+ mcp: {
148
+ 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.',
150
+ 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(),
156
+ prompt: z.object({ systemAppend: z.string().optional(), mode: z.string().optional() }).optional(),
157
+ source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']).optional(),
158
+ },
159
+ // Legacy MCP tool forwarded only the present tools/prompt fields (source split out).
160
+ buildInput: (input) => ({
161
+ ...(input.tools ? { tools: input.tools } : {}),
162
+ ...(input.prompt ? { prompt: input.prompt } : {}),
163
+ source: normalizeAxSource(input.source, 'mcp'),
164
+ }),
165
+ formatResult: (result) => {
166
+ const body = isRecord(result) ? result : {};
167
+ return {
168
+ content: [{ type: 'text' as const, text: JSON.stringify({ ok: true, policy: body.policy }) }],
169
+ };
170
+ },
171
+ },
172
+ handler: (input, ctx) => {
173
+ const patch: { tools?: Partial<PmxAxPolicy['tools']>; prompt?: Partial<PmxAxPolicy['prompt']> } = {};
174
+ if (isRecord(input.tools)) patch.tools = input.tools as Partial<PmxAxPolicy['tools']>;
175
+ if (isRecord(input.prompt)) patch.prompt = input.prompt as Partial<PmxAxPolicy['prompt']>;
176
+ const policy = canvasState.setPolicy(patch, { source: normalizeAxSource(input.source, 'api') });
177
+ ctx.emit('ax-state-changed', { policy });
178
+ return { ok: true, policy } as unknown as Record<string, unknown>;
179
+ },
180
+ });
181
+
182
+ // ── ax.host-capability.report (canvas_report_host_capability) ──
183
+
184
+ const axHostCapabilityReportShape = {
185
+ host: z.unknown().optional().describe('Host identifier (e.g. copilot, codex).'),
186
+ canvas: z.unknown().optional(),
187
+ hooks: z.unknown().optional(),
188
+ tools: z.unknown().optional(),
189
+ sessionMessaging: z.unknown().optional(),
190
+ permissions: z.unknown().optional(),
191
+ files: z.unknown().optional(),
192
+ uiPrompts: z.unknown().optional(),
193
+ raw: z.unknown().optional().describe('Optional raw capability payload for diagnostics.'),
194
+ source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
195
+ };
196
+
197
+ const axHostCapabilityReportSchema = z.looseObject(axHostCapabilityReportShape);
198
+
199
+ const axHostCapabilityReportOperation = defineOperation<z.infer<typeof axHostCapabilityReportSchema>, Record<string, unknown>>({
200
+ name: 'ax.host-capability.report',
201
+ mutates: false,
202
+ input: axHostCapabilityReportSchema,
203
+ inputShape: axHostCapabilityReportShape,
204
+ http: {
205
+ // Legacy route is PUT (not POST) /api/canvas/ax/host-capability.
206
+ method: 'PUT',
207
+ path: '/api/canvas/ax/host-capability',
208
+ },
209
+ mcp: {
210
+ 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.',
212
+ extraShape: {
213
+ host: z.string().optional().describe('Host identifier (e.g. copilot, codex).'),
214
+ canvas: z.boolean().optional(),
215
+ hooks: z.boolean().optional(),
216
+ tools: z.boolean().optional(),
217
+ sessionMessaging: z.boolean().optional(),
218
+ permissions: z.boolean().optional(),
219
+ files: z.boolean().optional(),
220
+ uiPrompts: z.boolean().optional(),
221
+ 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'])
223
+ .optional()
224
+ .describe('Optional host/source label. Defaults to mcp.'),
225
+ },
226
+ buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
227
+ formatResult: (result) => {
228
+ const body = isRecord(result) ? result : {};
229
+ return {
230
+ content: [{ type: 'text' as const, text: JSON.stringify({ ok: true, host: body.host }) }],
231
+ };
232
+ },
233
+ },
234
+ handler: (input, ctx) => {
235
+ // setHostCapability normalizes only the known capability keys; an extra
236
+ // `source` field is ignored, so passing the whole input (legacy HTTP) and
237
+ // passing source-stripped capability (legacy MCP) are equivalent.
238
+ const host = canvasState.setHostCapability(input, { source: normalizeAxSource(input.source, 'api') });
239
+ ctx.emit('ax-state-changed', { host });
240
+ return { ok: true, host } as unknown as Record<string, unknown>;
241
+ },
242
+ });
243
+
244
+ export const axStateOperations: Operation[] = [
245
+ axGetOperation,
246
+ axFocusSetOperation,
247
+ axPolicySetOperation,
248
+ axHostCapabilityReportOperation,
249
+ ];
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Plan-007 Slice B (wave 3) operations: the AX timeline writes, the timeline
3
+ * read, the adapterless delivery surface, and the allowlist-gated command
4
+ * invoke —
5
+ * ax.event.record / canvas_record_ax_event
6
+ * ax.evidence.add / canvas_add_evidence
7
+ * ax.steer / canvas_send_steering
8
+ * ax.timeline.get / canvas_get_ax_timeline
9
+ * ax.delivery.pending / canvas_claim_ax_delivery
10
+ * ax.delivery.mark / canvas_mark_ax_delivery
11
+ * ax.command.invoke / canvas_invoke_command
12
+ *
13
+ * Like the wave-1/2 AX ops, none of these change the node/edge layout, so every
14
+ * op is `mutates: false` (no `canvas-layout-update`).
15
+ *
16
+ * SSE differs from waves 1–2: the TIMELINE ops do NOT emit `ax-state-changed`.
17
+ * Confirmed against the legacy server.ts handlers + mcp/server.ts tools:
18
+ * - record / evidence / steer / command → `ax-event-created` with the same
19
+ * single-key payload the legacy handlers broadcast (`{ event }`, `{ evidence }`,
20
+ * `{ steering }`, `{ event }`). The injected emitter adds the sessionId/timestamp
21
+ * envelope (see server.ts emitPrimaryWorkbenchEvent).
22
+ * - delivery.mark → `ax-event-created` with `{ steeringDelivered: id }`, but ONLY
23
+ * when the message was actually marked delivered (legacy handleAxDeliveryMark).
24
+ * - reads (timeline.get, delivery.pending) emit nothing.
25
+ *
26
+ * Source defaulting matches the legacy surfaces exactly: MCP `buildInput`
27
+ * injects `source: 'mcp'`; the HTTP handlers default an absent source to 'api'.
28
+ * `delivery.pending` is the exception — its `consumer` is NOT a source label and
29
+ * is never defaulted (loop-safety scoping is opt-in, byte-stable with legacy).
30
+ *
31
+ * Wire-body reconciliation (one op = one wire body; documented, same class as
32
+ * the wave-1 `ax.get` aggregate broadening):
33
+ * - ax.delivery.pending: the legacy HTTP route served only `{ ok, pending }`,
34
+ * while the legacy `canvas_claim_ax_delivery` MCP tool served
35
+ * `{ ok, pending, pendingActivity }`. `formatResult` receives the SERIALIZED
36
+ * wire body, so the handler must return the full aggregate (including
37
+ * `pendingActivity`) and there is NO serialize override — the HTTP body now
38
+ * also carries `pendingActivity`. (No HTTP test asserted the slim body; the
39
+ * server-api delivery test only reads `body.pending`.)
40
+ *
41
+ * Allowlist gate preserved byte-for-byte:
42
+ * - ax.command.invoke: canvasState.invokeCommand returns null for an unknown
43
+ * command name → OperationError("Unknown command \"<name>\".", 400), matching
44
+ * the legacy HTTP 400 body. (The MCP tool surfaces it as an isError result.)
45
+ *
46
+ * This module must never import server.ts or index.ts.
47
+ */
48
+ import { z } from 'zod';
49
+ import { canvasState } from '../../canvas-state.js';
50
+ import { buildPendingAxActivity, isAxEventKind, isAxEvidenceKind } from '../../ax-state.js';
51
+ import type { PmxAxEventKind, PmxAxEvidenceKind } from '../../ax-state.js';
52
+ import { defineOperation, OperationError, type Operation } from '../types.js';
53
+ import { isRecord } from './nodes.js';
54
+ import { AX_SOURCE_SHAPE, axJsonResult, normalizeAxNodeIds, normalizeAxSource } from './ax-shared.js';
55
+
56
+ const AX_EVENT_KINDS = ['prompt', 'assistant-message', 'tool-start', 'tool-result', 'failure', 'approval', 'steering'] as const;
57
+ const AX_EVIDENCE_KINDS = ['logs', 'tool-result', 'screenshot', 'file', 'diff', 'test-output'] as const;
58
+
59
+ // ── ax.event.record (canvas_record_ax_event) ──────────────────
60
+
61
+ const axEventRecordShape = {
62
+ kind: z.unknown().optional().describe('Normalized event kind.'),
63
+ summary: z.unknown().optional().describe('Short human-readable summary of the event.'),
64
+ detail: z.unknown().optional().describe('Optional longer detail or payload text.'),
65
+ nodeIds: z.unknown().optional().describe('Optional node IDs this event relates to.'),
66
+ data: z.unknown().optional().describe('Optional structured data payload.'),
67
+ source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
68
+ };
69
+
70
+ const axEventRecordSchema = z.looseObject(axEventRecordShape);
71
+
72
+ const axEventRecordOperation = defineOperation<z.infer<typeof axEventRecordSchema>, Record<string, unknown>>({
73
+ name: 'ax.event.record',
74
+ mutates: false,
75
+ input: axEventRecordSchema,
76
+ inputShape: axEventRecordShape,
77
+ http: {
78
+ method: 'POST',
79
+ path: '/api/canvas/ax/event',
80
+ },
81
+ mcp: {
82
+ 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.',
84
+ extraShape: {
85
+ kind: z.enum(AX_EVENT_KINDS).describe('Normalized event kind.'),
86
+ summary: z.string().describe('Short human-readable summary of the event.'),
87
+ detail: z.string().optional().describe('Optional longer detail or payload text.'),
88
+ nodeIds: z.array(z.string()).optional().describe('Optional node IDs this event relates to.'),
89
+ data: z.record(z.string(), z.unknown()).optional().describe('Optional structured data payload.'),
90
+ source: AX_SOURCE_SHAPE,
91
+ },
92
+ buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
93
+ formatResult: axJsonResult,
94
+ },
95
+ handler: (input, ctx) => {
96
+ if (!isAxEventKind(input.kind) || typeof input.summary !== 'string') {
97
+ throw new OperationError('event requires kind and summary.');
98
+ }
99
+ const event = canvasState.recordAxEvent(
100
+ {
101
+ kind: input.kind as PmxAxEventKind,
102
+ summary: input.summary,
103
+ detail: typeof input.detail === 'string' ? input.detail : null,
104
+ nodeIds: normalizeAxNodeIds(input.nodeIds),
105
+ data: isRecord(input.data) ? input.data : null,
106
+ },
107
+ { source: normalizeAxSource(input.source, 'api') },
108
+ );
109
+ ctx.emit('ax-event-created', { event });
110
+ return { ok: true, event } as unknown as Record<string, unknown>;
111
+ },
112
+ });
113
+
114
+ // ── ax.evidence.add (canvas_add_evidence) ─────────────────────
115
+
116
+ const axEvidenceAddShape = {
117
+ kind: z.unknown().optional().describe('Evidence kind.'),
118
+ title: z.unknown().optional().describe('Short human-readable title for the evidence.'),
119
+ body: z.unknown().optional().describe('Optional inline body/content.'),
120
+ ref: z.unknown().optional().describe('Optional reference (path, URL, or external locator).'),
121
+ nodeIds: z.unknown().optional().describe('Optional node IDs this evidence relates to.'),
122
+ data: z.unknown().optional().describe('Optional structured data payload.'),
123
+ source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
124
+ };
125
+
126
+ const axEvidenceAddSchema = z.looseObject(axEvidenceAddShape);
127
+
128
+ const axEvidenceAddOperation = defineOperation<z.infer<typeof axEvidenceAddSchema>, Record<string, unknown>>({
129
+ name: 'ax.evidence.add',
130
+ mutates: false,
131
+ input: axEvidenceAddSchema,
132
+ inputShape: axEvidenceAddShape,
133
+ http: {
134
+ method: 'POST',
135
+ path: '/api/canvas/ax/evidence',
136
+ },
137
+ mcp: {
138
+ 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.',
140
+ extraShape: {
141
+ kind: z.enum(AX_EVIDENCE_KINDS).describe('Evidence kind.'),
142
+ title: z.string().describe('Short human-readable title for the evidence.'),
143
+ body: z.string().optional().describe('Optional inline body/content.'),
144
+ ref: z.string().optional().describe('Optional reference (path, URL, or external locator).'),
145
+ nodeIds: z.array(z.string()).optional().describe('Optional node IDs this evidence relates to.'),
146
+ data: z.record(z.string(), z.unknown()).optional().describe('Optional structured data payload.'),
147
+ source: AX_SOURCE_SHAPE,
148
+ },
149
+ buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
150
+ formatResult: axJsonResult,
151
+ },
152
+ handler: (input, ctx) => {
153
+ if (!isAxEvidenceKind(input.kind) || typeof input.title !== 'string' || !input.title.trim()) {
154
+ throw new OperationError('evidence requires kind and title.');
155
+ }
156
+ const evidence = canvasState.addEvidence(
157
+ {
158
+ kind: input.kind as PmxAxEvidenceKind,
159
+ title: input.title,
160
+ body: typeof input.body === 'string' ? input.body : null,
161
+ ref: typeof input.ref === 'string' ? input.ref : null,
162
+ nodeIds: normalizeAxNodeIds(input.nodeIds),
163
+ data: isRecord(input.data) ? input.data : null,
164
+ },
165
+ { source: normalizeAxSource(input.source, 'api') },
166
+ );
167
+ ctx.emit('ax-event-created', { evidence });
168
+ return { ok: true, evidence } as unknown as Record<string, unknown>;
169
+ },
170
+ });
171
+
172
+ // ── ax.steer (canvas_send_steering) ───────────────────────────
173
+
174
+ const axSteerShape = {
175
+ message: z.unknown().optional().describe('The steering instruction to deliver to the active agent session.'),
176
+ source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
177
+ };
178
+
179
+ const axSteerSchema = z.looseObject(axSteerShape);
180
+
181
+ const axSteerOperation = defineOperation<z.infer<typeof axSteerSchema>, Record<string, unknown>>({
182
+ name: 'ax.steer',
183
+ mutates: false,
184
+ input: axSteerSchema,
185
+ inputShape: axSteerShape,
186
+ http: {
187
+ method: 'POST',
188
+ path: '/api/canvas/ax/steer',
189
+ },
190
+ mcp: {
191
+ 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.',
193
+ extraShape: {
194
+ message: z.string().describe('The steering instruction to deliver to the active agent session.'),
195
+ source: AX_SOURCE_SHAPE,
196
+ },
197
+ buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
198
+ formatResult: axJsonResult,
199
+ },
200
+ handler: (input, ctx) => {
201
+ if (typeof input.message !== 'string' || !input.message.trim()) {
202
+ throw new OperationError('steer requires a non-empty message.');
203
+ }
204
+ const steering = canvasState.recordSteeringMessage(input.message, {
205
+ source: normalizeAxSource(input.source, 'api'),
206
+ });
207
+ ctx.emit('ax-event-created', { steering });
208
+ return { ok: true, steering } as unknown as Record<string, unknown>;
209
+ },
210
+ });
211
+
212
+ // ── ax.timeline.get (canvas_get_ax_timeline) ──────────────────
213
+
214
+ const axTimelineGetShape = {
215
+ limit: z.unknown().optional().describe('Max rows per timeline table (default 50, max 200).'),
216
+ };
217
+
218
+ const axTimelineGetSchema = z.looseObject(axTimelineGetShape);
219
+
220
+ const axTimelineGetOperation = defineOperation<z.infer<typeof axTimelineGetSchema>, Record<string, unknown>>({
221
+ name: 'ax.timeline.get',
222
+ mutates: false,
223
+ input: axTimelineGetSchema,
224
+ inputShape: axTimelineGetShape,
225
+ http: {
226
+ method: 'GET',
227
+ path: '/api/canvas/ax/timeline',
228
+ },
229
+ mcp: {
230
+ 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.',
232
+ extraShape: {
233
+ limit: z.number().optional().describe('Max rows per timeline table (default 50, max 200).'),
234
+ },
235
+ formatResult: axJsonResult,
236
+ },
237
+ handler: (input) => {
238
+ // `limit` arrives as a number over MCP or a string over the HTTP query;
239
+ // Number() normalizes both. Only a finite positive limit is forwarded.
240
+ const limit = Number(input.limit ?? '');
241
+ const timeline = canvasState.getAxTimeline(
242
+ Number.isFinite(limit) && limit > 0 ? { limit } : {},
243
+ );
244
+ return { ok: true, ...timeline } as unknown as Record<string, unknown>;
245
+ },
246
+ });
247
+
248
+ // ── ax.delivery.pending (canvas_claim_ax_delivery) ────────────
249
+
250
+ const axDeliveryPendingShape = {
251
+ consumer: z.unknown().optional().describe('Consumer/source label to exclude from results (e.g. copilot, mcp).'),
252
+ limit: z.unknown().optional().describe('Max steering messages to return.'),
253
+ };
254
+
255
+ const axDeliveryPendingSchema = z.looseObject(axDeliveryPendingShape);
256
+
257
+ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPendingSchema>, Record<string, unknown>>({
258
+ name: 'ax.delivery.pending',
259
+ mutates: false,
260
+ input: axDeliveryPendingSchema,
261
+ inputShape: axDeliveryPendingShape,
262
+ http: {
263
+ method: 'GET',
264
+ path: '/api/canvas/ax/delivery/pending',
265
+ },
266
+ mcp: {
267
+ toolName: 'canvas_claim_ax_delivery',
268
+ 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). 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.',
269
+ extraShape: {
270
+ consumer: z.string().optional().describe('Consumer/source label to exclude from results (e.g. copilot, mcp).'),
271
+ limit: z.number().optional().describe('Max steering messages to return.'),
272
+ },
273
+ // `consumer` is a loop-safety scope, not a source label — never defaulted.
274
+ formatResult: axJsonResult,
275
+ },
276
+ handler: (input) => {
277
+ const consumer = typeof input.consumer === 'string' ? input.consumer : undefined;
278
+ const limitRaw = Number(input.limit ?? '');
279
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : undefined;
280
+ const pending = canvasState.getPendingSteering({
281
+ ...(consumer ? { consumer } : {}),
282
+ ...(limit ? { limit } : {}),
283
+ });
284
+ // The MCP tool aggregated pendingActivity; one wire body now serves it over
285
+ // HTTP too (documented broadening). Loop-safe: consumer scopes both queries.
286
+ const pendingActivity = buildPendingAxActivity(canvasState.getAxState(), consumer);
287
+ return { ok: true, pending, pendingActivity } as unknown as Record<string, unknown>;
288
+ },
289
+ });
290
+
291
+ // ── ax.delivery.mark (canvas_mark_ax_delivery) ────────────────
292
+
293
+ const axDeliveryMarkShape = {
294
+ id: z.string().optional().catch(undefined).describe('The steering message id to mark delivered.'),
295
+ };
296
+
297
+ const axDeliveryMarkSchema = z.looseObject(axDeliveryMarkShape);
298
+
299
+ const axDeliveryMarkOperation = defineOperation<z.infer<typeof axDeliveryMarkSchema>, Record<string, unknown>>({
300
+ name: 'ax.delivery.mark',
301
+ mutates: false,
302
+ input: axDeliveryMarkSchema,
303
+ inputShape: axDeliveryMarkShape,
304
+ http: {
305
+ method: 'POST',
306
+ path: '/api/canvas/ax/delivery/:id/mark',
307
+ },
308
+ mcp: {
309
+ toolName: 'canvas_mark_ax_delivery',
310
+ description: 'Mark a PMX AX steering message as delivered so it is not handed out again.',
311
+ extraShape: {
312
+ id: z.string().describe('The steering message id to mark delivered.'),
313
+ },
314
+ formatResult: axJsonResult,
315
+ },
316
+ handler: (input, ctx) => {
317
+ const id = typeof input.id === 'string' ? input.id : '';
318
+ const delivered = canvasState.markSteeringDelivered(id);
319
+ // Legacy handleAxDeliveryMark only broadcasts when a message was marked.
320
+ if (delivered) {
321
+ ctx.emit('ax-event-created', { steeringDelivered: id });
322
+ }
323
+ return { ok: true, delivered } as unknown as Record<string, unknown>;
324
+ },
325
+ });
326
+
327
+ // ── ax.command.invoke (canvas_invoke_command) ─────────────────
328
+
329
+ const axCommandInvokeShape = {
330
+ name: z.unknown().optional().describe('A command name from the PMX command registry.'),
331
+ args: z.unknown().optional(),
332
+ source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
333
+ };
334
+
335
+ const axCommandInvokeSchema = z.looseObject(axCommandInvokeShape);
336
+
337
+ const axCommandInvokeOperation = defineOperation<z.infer<typeof axCommandInvokeSchema>, Record<string, unknown>>({
338
+ name: 'ax.command.invoke',
339
+ mutates: false,
340
+ input: axCommandInvokeSchema,
341
+ inputShape: axCommandInvokeShape,
342
+ http: {
343
+ method: 'POST',
344
+ path: '/api/canvas/ax/command',
345
+ },
346
+ mcp: {
347
+ toolName: 'canvas_invoke_command',
348
+ 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.',
349
+ extraShape: {
350
+ name: z.string().describe('A command name from the PMX command registry.'),
351
+ args: z.record(z.string(), z.unknown()).optional(),
352
+ source: AX_SOURCE_SHAPE,
353
+ },
354
+ buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
355
+ formatResult: axJsonResult,
356
+ },
357
+ handler: (input, ctx) => {
358
+ if (typeof input.name !== 'string') {
359
+ throw new OperationError('command requires a name.');
360
+ }
361
+ const event = canvasState.invokeCommand(
362
+ input.name,
363
+ isRecord(input.args) ? input.args : null,
364
+ { source: normalizeAxSource(input.source, 'api') },
365
+ );
366
+ // Allowlist gate: invokeCommand returns null for an unknown command name.
367
+ if (!event) throw new OperationError(`Unknown command "${input.name}".`, 400);
368
+ ctx.emit('ax-event-created', { event });
369
+ return { ok: true, event } as unknown as Record<string, unknown>;
370
+ },
371
+ });
372
+
373
+ export const axTimelineOperations: Operation[] = [
374
+ axEventRecordOperation,
375
+ axEvidenceAddOperation,
376
+ axSteerOperation,
377
+ axTimelineGetOperation,
378
+ axDeliveryPendingOperation,
379
+ axDeliveryMarkOperation,
380
+ axCommandInvokeOperation,
381
+ ];