pmx-canvas 0.2.1 → 0.2.2

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 (45) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/Readme.md +2 -2
  3. package/dist/canvas/global.css +260 -0
  4. package/dist/canvas/index.js +76 -76
  5. package/dist/json-render/index.js +2 -2
  6. package/dist/types/client/canvas/IntentLayer.d.ts +1 -0
  7. package/dist/types/client/state/intent-bridge.d.ts +10 -0
  8. package/dist/types/client/state/intent-store.d.ts +25 -0
  9. package/dist/types/json-render/server.d.ts +1 -1
  10. package/dist/types/server/index.d.ts +34 -4
  11. package/dist/types/server/intent-registry.d.ts +45 -0
  12. package/dist/types/server/operations/ops/intent.d.ts +2 -0
  13. package/dist/types/shared/ax-intent.d.ts +58 -0
  14. package/docs/mcp.md +21 -2
  15. package/docs/screenshot.png +0 -0
  16. package/package.json +1 -1
  17. package/skills/pmx-canvas/SKILL.md +197 -1305
  18. package/skills/pmx-canvas/evals/evals.json +199 -0
  19. package/skills/pmx-canvas/references/full-reference.md +1441 -0
  20. package/src/cli/index.ts +21 -4
  21. package/src/client/canvas/CanvasNode.tsx +13 -13
  22. package/src/client/canvas/CanvasViewport.tsx +2 -0
  23. package/src/client/canvas/ContextMenu.tsx +25 -19
  24. package/src/client/canvas/IntentLayer.tsx +278 -0
  25. package/src/client/nodes/ExtAppFrame.tsx +31 -22
  26. package/src/client/state/intent-bridge.ts +31 -0
  27. package/src/client/state/intent-store.ts +107 -0
  28. package/src/client/state/sse-bridge.ts +31 -0
  29. package/src/client/theme/global.css +260 -0
  30. package/src/json-render/charts/components.tsx +18 -4
  31. package/src/json-render/renderer/index.tsx +11 -2
  32. package/src/json-render/server.ts +1 -1
  33. package/src/server/index.ts +240 -158
  34. package/src/server/intent-registry.ts +324 -0
  35. package/src/server/operations/composites.ts +11 -0
  36. package/src/server/operations/index.ts +2 -0
  37. package/src/server/operations/ops/edges.ts +1 -0
  38. package/src/server/operations/ops/groups.ts +3 -0
  39. package/src/server/operations/ops/intent.ts +132 -0
  40. package/src/server/operations/ops/json-render.ts +3 -0
  41. package/src/server/operations/ops/nodes.ts +3 -0
  42. package/src/server/operations/registry.ts +68 -3
  43. package/src/server/server.ts +40 -12
  44. package/src/shared/ax-intent.ts +64 -0
  45. package/src/shared/surface.ts +5 -1
@@ -0,0 +1,324 @@
1
+ /**
2
+ * IntentRegistry — the server-side home of the Ghost Cursor of Intent.
3
+ *
4
+ * Intents are EPHEMERAL PRESENCE, deliberately modelled like the attention /
5
+ * timeline ephemerality layer rather than canvas-bound state:
6
+ * - an in-memory Map (NOT CanvasStateManager) — never serialized, never
7
+ * snapshotted, never returned by canvas_get_layout;
8
+ * - count-capped (oldest evicted) and TTL-swept so a ghost can never linger;
9
+ * - emitted over the same workbench SSE stream as `ax-intent` /
10
+ * `ax-intent-clear` frames via an INJECTED emitter (server.ts wires it,
11
+ * mirroring setOperationEventEmitter) so this module never imports server.ts.
12
+ *
13
+ * The single trust boundary is `signal()` / `update()`: every envelope is
14
+ * zod-validated and per-kind-checked here, so HTTP, MCP, and the SDK all funnel
15
+ * through the same validation (consistent with applyAxInteraction).
16
+ */
17
+ import { z } from 'zod';
18
+ import {
19
+ DEFAULT_INTENT_TTL_MS,
20
+ INTENT_EDGE_TYPES,
21
+ INTENT_KINDS,
22
+ MAX_INTENT_TTL_MS,
23
+ MAX_LIVE_INTENTS,
24
+ type PmxAxIntent,
25
+ type PmxAxIntentKind,
26
+ } from '../shared/ax-intent.js';
27
+ import { OperationError } from './operations/types.js';
28
+
29
+ type IntentEmitter = (event: string, payload: Record<string, unknown>) => void;
30
+ const MAX_VETO_TOMBSTONES = 128;
31
+
32
+ const positionSchema = z.object({ x: z.number().finite(), y: z.number().finite() });
33
+
34
+ const intentSignalSchema = z.looseObject({
35
+ id: z.string().min(1).max(200).optional(),
36
+ kind: z.enum(INTENT_KINDS),
37
+ position: positionSchema.optional(),
38
+ nodeId: z.string().min(1).max(200).optional(),
39
+ edge: z
40
+ .object({
41
+ from: z.string().min(1).max(200),
42
+ to: z.string().min(1).max(200),
43
+ type: z.enum(INTENT_EDGE_TYPES),
44
+ })
45
+ .optional(),
46
+ nodeType: z.string().max(60).optional(),
47
+ label: z.string().max(120).optional(),
48
+ reason: z.string().max(400).optional(),
49
+ confidence: z.number().min(0).max(1).optional(),
50
+ seq: z.number().int().min(0).max(9999).optional(),
51
+ ttlMs: z.number().positive().max(MAX_INTENT_TTL_MS).optional(),
52
+ source: z.string().max(60).optional(),
53
+ });
54
+
55
+ const intentUpdateSchema = z.looseObject({
56
+ position: positionSchema.optional(),
57
+ nodeType: z.string().max(60).optional(),
58
+ label: z.string().max(120).optional(),
59
+ reason: z.string().max(400).optional(),
60
+ confidence: z.number().min(0).max(1).optional(),
61
+ seq: z.number().int().min(0).max(9999).optional(),
62
+ ttlMs: z.number().positive().max(MAX_INTENT_TTL_MS).optional(),
63
+ });
64
+
65
+ function parseOrThrow<T>(schema: z.ZodType<T>, raw: unknown, label: string): T {
66
+ const parsed = schema.safeParse(raw ?? {});
67
+ if (!parsed.success) {
68
+ const detail = parsed.error.issues
69
+ .map((issue) => (issue.path.length > 0 ? `${issue.path.map(String).join('.')}: ${issue.message}` : issue.message))
70
+ .join('; ');
71
+ throw new OperationError(`Invalid ${label}: ${detail}`);
72
+ }
73
+ return parsed.data;
74
+ }
75
+
76
+ /** Each kind needs the spatial anchor it renders against — fail loud otherwise. */
77
+ function requireKindFields(kind: PmxAxIntentKind, value: z.infer<typeof intentSignalSchema>): void {
78
+ switch (kind) {
79
+ case 'create':
80
+ if (!value.position) throw new OperationError('intent kind "create" requires a position.');
81
+ break;
82
+ case 'move':
83
+ if (!value.nodeId) throw new OperationError('intent kind "move" requires a nodeId.');
84
+ if (!value.position) throw new OperationError('intent kind "move" requires a destination position.');
85
+ break;
86
+ case 'connect':
87
+ if (!value.edge) throw new OperationError('intent kind "connect" requires an edge { from, to, type }.');
88
+ break;
89
+ case 'remove':
90
+ case 'edit':
91
+ if (!value.nodeId) throw new OperationError(`intent kind "${kind}" requires a nodeId.`);
92
+ break;
93
+ }
94
+ }
95
+
96
+ let intentSeq = 0;
97
+
98
+ function nextIntentId(): string {
99
+ intentSeq += 1;
100
+ return `intent-${Date.now().toString(36)}-${intentSeq.toString(36)}`;
101
+ }
102
+
103
+ export class IntentRegistry {
104
+ private readonly intents = new Map<string, PmxAxIntent>();
105
+ private readonly vetoedIntentIds = new Map<string, number>();
106
+ private readonly committingIntentIds = new Set<string>();
107
+ private emit: IntentEmitter = () => {};
108
+ private sweepTimer: ReturnType<typeof setInterval> | null = null;
109
+
110
+ /** Inject the workbench SSE emitter (server.ts wires this at module load). */
111
+ setEmitter(emitter: IntentEmitter | null): void {
112
+ this.emit = emitter ?? (() => {});
113
+ }
114
+
115
+ list(): PmxAxIntent[] {
116
+ return [...this.intents.values()];
117
+ }
118
+
119
+ /** Signal a new (or replace an existing) intent. Returns the stored envelope. */
120
+ signal(raw: unknown): PmxAxIntent {
121
+ const input = parseOrThrow(intentSignalSchema, raw, 'intent');
122
+ requireKindFields(input.kind, input);
123
+
124
+ const now = Date.now();
125
+ const ttl = typeof input.ttlMs === 'number' ? input.ttlMs : DEFAULT_INTENT_TTL_MS;
126
+ const id = input.id && this.intents.has(input.id) ? input.id : input.id ?? nextIntentId();
127
+ this.pruneVetoTombstones();
128
+ if (this.vetoedIntentIds.has(id)) {
129
+ throw new OperationError(`Intent "${id}" was vetoed. Use a new id for a revised intent.`, 409);
130
+ }
131
+ if (this.committingIntentIds.has(id)) {
132
+ throw new OperationError(`Intent "${id}" is already committing.`, 409);
133
+ }
134
+ const existing = this.intents.get(id);
135
+
136
+ const intent: PmxAxIntent = {
137
+ id,
138
+ kind: input.kind,
139
+ ...(input.position ? { position: input.position } : {}),
140
+ ...(input.nodeId ? { nodeId: input.nodeId } : {}),
141
+ ...(input.edge ? { edge: input.edge } : {}),
142
+ ...(input.nodeType ? { nodeType: input.nodeType } : {}),
143
+ ...(input.label ? { label: input.label } : {}),
144
+ ...(input.reason ? { reason: input.reason } : {}),
145
+ ...(typeof input.confidence === 'number' ? { confidence: input.confidence } : {}),
146
+ ...(typeof input.seq === 'number' ? { seq: input.seq } : {}),
147
+ ...(input.source ? { source: input.source } : {}),
148
+ createdAt: existing?.createdAt ?? now,
149
+ expiresAt: now + ttl,
150
+ };
151
+
152
+ this.intents.set(id, intent);
153
+ this.evictOverflow();
154
+ this.ensureSweeper();
155
+ this.emit('ax-intent', { intent });
156
+ return intent;
157
+ }
158
+
159
+ /** Patch a live intent (position/label/reason/confidence/seq) and bump its TTL. */
160
+ update(id: string, raw: unknown): PmxAxIntent {
161
+ if (this.committingIntentIds.has(id)) {
162
+ throw new OperationError(`Intent "${id}" is already committing.`, 409);
163
+ }
164
+ const existing = this.intents.get(id);
165
+ if (!existing) throw new OperationError(`No live intent "${id}".`, 404);
166
+ const patch = parseOrThrow(intentUpdateSchema, raw, 'intent update');
167
+ const now = Date.now();
168
+ const ttl = typeof patch.ttlMs === 'number' ? patch.ttlMs : DEFAULT_INTENT_TTL_MS;
169
+
170
+ const intent: PmxAxIntent = {
171
+ ...existing,
172
+ ...(patch.position ? { position: patch.position } : {}),
173
+ ...(patch.nodeType ? { nodeType: patch.nodeType } : {}),
174
+ ...(patch.label ? { label: patch.label } : {}),
175
+ ...(patch.reason ? { reason: patch.reason } : {}),
176
+ ...(typeof patch.confidence === 'number' ? { confidence: patch.confidence } : {}),
177
+ ...(typeof patch.seq === 'number' ? { seq: patch.seq } : {}),
178
+ expiresAt: now + ttl,
179
+ };
180
+ this.intents.set(id, intent);
181
+ this.emit('ax-intent', { intent });
182
+ return intent;
183
+ }
184
+
185
+ /**
186
+ * Clear an intent. `settledNodeId` resolves it INTO a real node (the settle
187
+ * morph); `vetoed` marks a human pre-emptive veto. Either way the ghost
188
+ * dissolves. Returns true when an intent was actually removed.
189
+ */
190
+ clear(id: string, opts: { settledNodeId?: string; vetoed?: boolean } = {}): boolean {
191
+ if (this.committingIntentIds.has(id)) return false;
192
+ if (!this.intents.delete(id)) return false;
193
+ if (opts.vetoed) this.rememberVeto(id);
194
+ this.emit('ax-intent-clear', {
195
+ id,
196
+ ...(opts.settledNodeId ? { nodeId: opts.settledNodeId, settled: true } : {}),
197
+ ...(opts.vetoed ? { vetoed: true } : {}),
198
+ });
199
+ this.maybeStopSweeper();
200
+ return true;
201
+ }
202
+
203
+ /**
204
+ * Gate one real mutation behind a live, non-vetoed intent. The claim is
205
+ * synchronous: once this method has accepted the intent, a later veto cannot
206
+ * race in between the check and the mutation.
207
+ */
208
+ beginCommit(id: string, allowedKinds: readonly PmxAxIntentKind[]): PmxAxIntent {
209
+ this.pruneVetoTombstones();
210
+ if (this.committingIntentIds.has(id)) {
211
+ throw new OperationError(`Intent "${id}" is already committing.`, 409);
212
+ }
213
+ if (this.vetoedIntentIds.has(id)) {
214
+ throw new OperationError(`Intent "${id}" was vetoed.`, 409);
215
+ }
216
+ const intent = this.intents.get(id);
217
+ if (!intent) {
218
+ throw new OperationError(`No live intent "${id}" to commit.`, 409);
219
+ }
220
+ if (!allowedKinds.includes(intent.kind)) {
221
+ throw new OperationError(
222
+ `Intent "${id}" has kind "${intent.kind}", which cannot commit this mutation.`,
223
+ 409,
224
+ );
225
+ }
226
+ this.committingIntentIds.add(id);
227
+ return intent;
228
+ }
229
+
230
+ completeCommit(id: string, settledNodeId?: string): void {
231
+ this.committingIntentIds.delete(id);
232
+ this.clear(id, { ...(settledNodeId ? { settledNodeId } : {}) });
233
+ }
234
+
235
+ abortCommit(id: string): void {
236
+ this.committingIntentIds.delete(id);
237
+ }
238
+
239
+ async runCommit<T>(
240
+ id: string,
241
+ allowedKinds: readonly PmxAxIntentKind[],
242
+ mutate: () => T | Promise<T>,
243
+ settledNodeId: (result: T, intent: PmxAxIntent) => string | undefined,
244
+ ): Promise<T> {
245
+ const intent = this.beginCommit(id, allowedKinds);
246
+ try {
247
+ const result = await mutate();
248
+ this.completeCommit(id, settledNodeId(result, intent));
249
+ return result;
250
+ } catch (error) {
251
+ this.abortCommit(id);
252
+ throw error;
253
+ }
254
+ }
255
+
256
+ /** Drop every live intent without per-id SSE (used on hard resets). */
257
+ reset(): void {
258
+ this.intents.clear();
259
+ this.vetoedIntentIds.clear();
260
+ this.committingIntentIds.clear();
261
+ this.maybeStopSweeper();
262
+ }
263
+
264
+ private rememberVeto(id: string): void {
265
+ this.vetoedIntentIds.delete(id);
266
+ this.vetoedIntentIds.set(id, Date.now() + MAX_INTENT_TTL_MS);
267
+ while (this.vetoedIntentIds.size > MAX_VETO_TOMBSTONES) {
268
+ const oldest = this.vetoedIntentIds.keys().next().value as string | undefined;
269
+ if (!oldest) break;
270
+ this.vetoedIntentIds.delete(oldest);
271
+ }
272
+ }
273
+
274
+ private pruneVetoTombstones(): void {
275
+ const now = Date.now();
276
+ for (const [id, expiresAt] of this.vetoedIntentIds) {
277
+ if (expiresAt <= now) this.vetoedIntentIds.delete(id);
278
+ }
279
+ }
280
+
281
+ private evictOverflow(): void {
282
+ while (this.intents.size > MAX_LIVE_INTENTS) {
283
+ // Map preserves insertion order; the first key is the oldest live intent.
284
+ let oldest: string | undefined;
285
+ for (const id of this.intents.keys()) {
286
+ if (!this.committingIntentIds.has(id)) {
287
+ oldest = id;
288
+ break;
289
+ }
290
+ }
291
+ if (!oldest) break;
292
+ this.intents.delete(oldest);
293
+ this.emit('ax-intent-clear', { id: oldest, evicted: true });
294
+ }
295
+ }
296
+
297
+ private sweep(): void {
298
+ const now = Date.now();
299
+ for (const [id, intent] of this.intents) {
300
+ if (!this.committingIntentIds.has(id) && intent.expiresAt <= now) {
301
+ this.intents.delete(id);
302
+ this.emit('ax-intent-clear', { id, expired: true });
303
+ }
304
+ }
305
+ this.maybeStopSweeper();
306
+ }
307
+
308
+ private ensureSweeper(): void {
309
+ if (this.sweepTimer || this.intents.size === 0) return;
310
+ this.sweepTimer = setInterval(() => this.sweep(), 1000);
311
+ // Don't keep the process (or a test runner) alive just for ghost expiry.
312
+ (this.sweepTimer as { unref?: () => void }).unref?.();
313
+ }
314
+
315
+ private maybeStopSweeper(): void {
316
+ if (this.sweepTimer && this.intents.size === 0) {
317
+ clearInterval(this.sweepTimer);
318
+ this.sweepTimer = null;
319
+ }
320
+ }
321
+ }
322
+
323
+ /** Process-wide singleton, shared across HTTP handlers, MCP ops, and the SDK. */
324
+ export const intentRegistry = new IntentRegistry();
@@ -317,6 +317,17 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
317
317
  mark: 'ax.delivery.mark',
318
318
  },
319
319
  },
320
+ {
321
+ toolName: 'canvas_intent',
322
+ description:
323
+ 'Ghost Cursor of Intent — announce the spatial move you are ABOUT to make so the canvas paints a faint pre-commit placeholder (legibility: the human sees the next move forming, and can veto it). Action "signal" registers an intent (kind create|move|connect|remove|edit; pass position for create/move, nodeId for move/edit/remove, edge for connect; optional label, reason, confidence 0..1, seq, ttlMs, and a stable id); "update" patches a live intent by id; "clear" abandons/dissolves it. To make veto authoritative, pass the returned id as intentId on the real canvas_node/canvas_edge/canvas_group/canvas_render mutation: vetoed or expired intents are rejected, and a successful linked mutation settles the ghost automatically. Intents are ephemeral presence: never persisted, never snapshotted, auto-expire (~8s).',
324
+ actionSummary: 'signal | update | clear',
325
+ actions: {
326
+ signal: 'intent.signal',
327
+ update: 'intent.update',
328
+ clear: 'intent.clear',
329
+ },
330
+ },
320
331
  ];
321
332
 
322
333
  /**
@@ -19,6 +19,7 @@ import { axAwaitOperations } from './ops/ax-await.js';
19
19
  import { batchOperations } from './ops/batch.js';
20
20
  import { webviewOperations } from './ops/webview.js';
21
21
  import { appOperations } from './ops/app.js';
22
+ import { intentOperations } from './ops/intent.js';
22
23
 
23
24
  for (const op of [
24
25
  ...nodeOperations,
@@ -37,6 +38,7 @@ for (const op of [
37
38
  ...batchOperations,
38
39
  ...webviewOperations,
39
40
  ...appOperations,
41
+ ...intentOperations,
40
42
  ]) {
41
43
  registerOperation(op);
42
44
  }
@@ -16,6 +16,7 @@ const VALID_EDGE_STYLES = new Set(['solid', 'dashed', 'dotted']);
16
16
  // ── edge.add ──────────────────────────────────────────────────
17
17
 
18
18
  const edgeAddShape = {
19
+ intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
19
20
  from: z.string().optional().catch(undefined).describe('Source node ID'),
20
21
  to: z.string().optional().catch(undefined).describe('Target node ID'),
21
22
  fromSearch: z.string().optional().catch(undefined).describe('Resolve the source node by exact or fuzzy title/content search'),
@@ -25,6 +25,7 @@ function pickChildLayout(value: unknown): 'grid' | 'column' | 'flow' | undefined
25
25
  // ── group.create ──────────────────────────────────────────────
26
26
 
27
27
  const groupCreateShape = {
28
+ intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
28
29
  title: z.string().optional().catch(undefined).describe('Group title (default: "Group")'),
29
30
  childIds: z.unknown().optional().describe('Node IDs to include in the group. Group auto-sizes to fit them.'),
30
31
  color: z.string().optional().catch(undefined).describe('Group accent color (CSS color string, e.g. "#4a9eff")'),
@@ -90,6 +91,7 @@ const groupCreateOperation = defineOperation<z.infer<typeof groupCreateSchema>,
90
91
  // ── group.add ─────────────────────────────────────────────────
91
92
 
92
93
  const groupAddShape = {
94
+ intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
93
95
  groupId: z.string().optional().catch(undefined).describe('The group node ID'),
94
96
  childIds: z.unknown().optional().describe('Node IDs to add to the group'),
95
97
  childLayout: z.enum(['grid', 'column', 'flow']).optional().catch(undefined).describe('Optional child layout to apply while grouping'),
@@ -136,6 +138,7 @@ const groupAddOperation = defineOperation<z.infer<typeof groupAddSchema>, Record
136
138
  // ── group.remove (ungroup) ────────────────────────────────────
137
139
 
138
140
  const groupRemoveShape = {
141
+ intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
139
142
  groupId: z.string().optional().catch(undefined).describe('The group node ID to ungroup'),
140
143
  };
141
144
 
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Ghost Cursor of Intent operations: signal / update / clear a pre-commit
3
+ * intent. Surfaced over HTTP (POST/PATCH/DELETE /api/canvas/ax/intent) and,
4
+ * folded into the `canvas_intent` composite, over MCP.
5
+ *
6
+ * Intents are ephemeral presence — every op is `mutates: false` (NO
7
+ * `canvas-layout-update`; the ghost lives on its own `ax-intent` /
8
+ * `ax-intent-clear` channel, emitted by the IntentRegistry). These ops have no
9
+ * standalone `mcp` block on purpose: the composite reuses `op.inputShape` for
10
+ * advertising and dispatches by op name, so the only new MCP tool is the
11
+ * composite itself.
12
+ *
13
+ * The IntentRegistry is the single trust boundary (zod + per-kind validation);
14
+ * these handlers stay thin. This module must never import server.ts or index.ts.
15
+ */
16
+ import { z } from 'zod';
17
+ import { INTENT_EDGE_TYPES, INTENT_KINDS } from '../../../shared/ax-intent.js';
18
+ import { intentRegistry } from '../../intent-registry.js';
19
+ import { readJsonValue } from '../http.js';
20
+ import { defineOperation, OperationError, type Operation } from '../types.js';
21
+
22
+ const positionShape = z.object({ x: z.number(), y: z.number() });
23
+
24
+ // ── intent.signal (canvas_intent action "signal") ─────────────
25
+
26
+ const intentSignalShape = {
27
+ kind: z.enum(INTENT_KINDS).optional().describe('create | move | connect | remove | edit — the move about to be made.'),
28
+ position: positionShape.optional().describe('World coords: where a create forms, or the destination of a move.'),
29
+ nodeId: z.string().optional().describe('The existing node a move/edit/remove targets.'),
30
+ edge: z
31
+ .object({ from: z.string(), to: z.string(), type: z.enum(INTENT_EDGE_TYPES) })
32
+ .optional()
33
+ .describe('connect: the edge about to be drawn (from/to node ids + type).'),
34
+ nodeType: z.string().optional().describe('Node type the ghost renders (icon + type badge).'),
35
+ label: z.string().optional().describe('Short action label shown on the ghost chip ("Add evidence").'),
36
+ reason: z.string().optional().describe('Why — shown beneath the ghost. The legibility payoff.'),
37
+ confidence: z.number().optional().describe('0..1 → ghost opacity/solidity.'),
38
+ seq: z.number().optional().describe('Ordering hint for staged-batch ghosts (numbered previsualization).'),
39
+ ttlMs: z.number().optional().describe('Auto-expire after this many ms (default 8000, max 60000).'),
40
+ id: z.string().optional().describe('Stable id to update/clear/veto later; auto-generated if omitted.'),
41
+ source: z.string().optional().describe('Optional source label of the signalling surface.'),
42
+ };
43
+
44
+ const intentSignalSchema = z.looseObject(intentSignalShape);
45
+
46
+ const intentSignalOperation = defineOperation<z.infer<typeof intentSignalSchema>, Record<string, unknown>>({
47
+ name: 'intent.signal',
48
+ mutates: false,
49
+ input: intentSignalSchema,
50
+ inputShape: intentSignalShape,
51
+ http: { method: 'POST', path: '/api/canvas/ax/intent' },
52
+ handler: (input) => {
53
+ const intent = intentRegistry.signal(input);
54
+ return { ok: true, intent } as unknown as Record<string, unknown>;
55
+ },
56
+ });
57
+
58
+ // ── intent.update (canvas_intent action "update") ─────────────
59
+
60
+ const intentUpdateShape = {
61
+ id: z.string().optional().describe('The intent id to update.'),
62
+ position: positionShape.optional().describe('New world coords for the ghost.'),
63
+ nodeType: z.string().optional().describe('New node type for the ghost.'),
64
+ label: z.string().optional().describe('New ghost chip label.'),
65
+ reason: z.string().optional().describe('New rationale shown beneath the ghost.'),
66
+ confidence: z.number().optional().describe('0..1 → ghost opacity/solidity.'),
67
+ seq: z.number().optional().describe('New ordering hint.'),
68
+ ttlMs: z.number().optional().describe('Reset the TTL to this many ms from now.'),
69
+ };
70
+
71
+ const intentUpdateSchema = z.looseObject(intentUpdateShape);
72
+
73
+ const intentUpdateOperation = defineOperation<z.infer<typeof intentUpdateSchema>, Record<string, unknown>>({
74
+ name: 'intent.update',
75
+ mutates: false,
76
+ input: intentUpdateSchema,
77
+ inputShape: intentUpdateShape,
78
+ http: { method: 'PATCH', path: '/api/canvas/ax/intent/:id' },
79
+ handler: (input) => {
80
+ const id = typeof input.id === 'string' ? input.id : '';
81
+ if (!id) throw new OperationError('intent update requires an id.');
82
+ const intent = intentRegistry.update(id, input);
83
+ return { ok: true, intent } as unknown as Record<string, unknown>;
84
+ },
85
+ });
86
+
87
+ // ── intent.clear (canvas_intent action "clear") ───────────────
88
+
89
+ const intentClearShape = {
90
+ id: z.string().optional().describe('The intent id to clear.'),
91
+ settledNodeId: z.string().optional().describe('The real node this intent became — triggers the settle morph.'),
92
+ vetoed: z.boolean().optional().describe('Mark this as a human pre-emptive veto (dissolve).'),
93
+ };
94
+
95
+ const intentClearSchema = z.looseObject(intentClearShape);
96
+
97
+ const intentClearOperation = defineOperation<z.infer<typeof intentClearSchema>, Record<string, unknown>>({
98
+ name: 'intent.clear',
99
+ mutates: false,
100
+ input: intentClearSchema,
101
+ inputShape: intentClearShape,
102
+ http: {
103
+ method: 'DELETE',
104
+ path: '/api/canvas/ax/intent/:id',
105
+ readInput: async (req, params, url) => {
106
+ const query: Record<string, string> = {};
107
+ url.searchParams.forEach((value, key) => {
108
+ query[key] = value;
109
+ });
110
+ const body = await readJsonValue(req);
111
+ const record = body !== null && typeof body === 'object' && !Array.isArray(body)
112
+ ? body as Record<string, unknown>
113
+ : {};
114
+ return { ...query, ...record, ...params };
115
+ },
116
+ },
117
+ handler: (input) => {
118
+ const id = typeof input.id === 'string' ? input.id : '';
119
+ if (!id) throw new OperationError('intent clear requires an id.');
120
+ const cleared = intentRegistry.clear(id, {
121
+ ...(typeof input.settledNodeId === 'string' ? { settledNodeId: input.settledNodeId } : {}),
122
+ ...(input.vetoed === true ? { vetoed: true } : {}),
123
+ });
124
+ return { ok: true, cleared } as unknown as Record<string, unknown>;
125
+ },
126
+ });
127
+
128
+ export const intentOperations: Operation[] = [
129
+ intentSignalOperation,
130
+ intentUpdateOperation,
131
+ intentClearOperation,
132
+ ];
@@ -89,6 +89,7 @@ function structuredNodeToolResult(result: unknown): { content: Array<{ type: 'te
89
89
  // ── jsonrender.add ────────────────────────────────────────────
90
90
 
91
91
  const jsonRenderAddShape = {
92
+ intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
92
93
  title: z.string().optional().catch(undefined).describe('Optional node title. If omitted, PMX Canvas infers one from the root element.'),
93
94
  spec: z.unknown().describe('json-render spec. Prefer a complete {root, elements, state?} document; a single bare component object is accepted for legacy callers.'),
94
95
  x: z.number().optional().catch(undefined).describe('Optional X position'),
@@ -193,6 +194,7 @@ export function streamJsonRenderCore(input: StreamJsonRenderInput): StreamJsonRe
193
194
  }
194
195
 
195
196
  const jsonRenderStreamShape = {
197
+ intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
196
198
  nodeId: z.string().optional().catch(undefined).describe('Existing streaming node id to append to; omit to create a new streaming node'),
197
199
  title: z.string().optional().catch(undefined).describe('Title when creating a new streaming node'),
198
200
  patches: z.unknown().optional().describe('SpecStream patches to apply this call: JSON-Patch objects ({op,path,value}) or raw JSONL patch lines'),
@@ -277,6 +279,7 @@ const jsonRenderStreamOperation = defineOperation<
277
279
  // ── graph.add ─────────────────────────────────────────────────
278
280
 
279
281
  const graphAddShape = {
282
+ intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
280
283
  title: z.string().optional().catch(undefined).describe('Optional node title'),
281
284
  graphType: z.string().optional().catch(undefined).describe('Graph type: line, bar, pie, area, scatter, radar, stacked-bar (or "stack"), composed (or "combo"), sparkline, dot-plot (or "dot"), bullet, slopegraph (or "slope")'),
282
285
  data: z.unknown().optional().describe('Array of chart data objects'),
@@ -653,6 +653,7 @@ function createGroupNode(body: Record<string, unknown>): NodeAddResult {
653
653
  }
654
654
 
655
655
  const nodeAddShape = {
656
+ intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
656
657
  type: z.string().optional().catch(undefined).describe('Node type (prefer canvas_create_group for groups)'),
657
658
  title: z.string().optional().catch(undefined).describe('Node title'),
658
659
  content: z.string().optional().catch(undefined).describe('Node content (markdown for markdown nodes, file path for file nodes, image path/URL/data-URI for image nodes, URL for webpage nodes)'),
@@ -834,6 +835,7 @@ const nodeGetOperation = defineOperation<z.infer<typeof nodeGetSchema>, Serializ
834
835
 
835
836
  const nodeUpdateShape = {
836
837
  id: z.string().describe('Node ID to update'),
838
+ intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
837
839
  title: z.unknown().optional().describe('New title'),
838
840
  content: z.unknown().optional().describe('New content'),
839
841
  x: z.number().optional().catch(undefined).describe('New X position'),
@@ -965,6 +967,7 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
965
967
 
966
968
  const nodeRemoveShape = {
967
969
  id: z.string().describe('Node ID to remove'),
970
+ intentId: z.string().optional().catch(undefined).describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
968
971
  };
969
972
 
970
973
  const nodeRemoveSchema = z.looseObject(nodeRemoveShape);
@@ -8,6 +8,8 @@
8
8
  * `mutates: true` is the single source; extra events go through `ctx.emit`.
9
9
  */
10
10
  import { canvasState } from '../canvas-state.js';
11
+ import { intentRegistry } from '../intent-registry.js';
12
+ import type { PmxAxIntent, PmxAxIntentKind } from '../../shared/ax-intent.js';
11
13
  import { OperationError, type Operation, type OperationContext } from './types.js';
12
14
 
13
15
  const operations = new Map<string, Operation>();
@@ -69,11 +71,74 @@ export async function runWithSuppressedEmits<T>(fn: () => Promise<T>): Promise<T
69
71
 
70
72
  const operationContext: OperationContext = { emit: emitOperationEvent };
71
73
 
74
+ const INTENT_KINDS_BY_OPERATION: Record<string, readonly PmxAxIntentKind[]> = {
75
+ 'node.add': ['create'],
76
+ 'jsonrender.add': ['create'],
77
+ 'graph.add': ['create'],
78
+ 'group.create': ['create'],
79
+ 'node.update': ['move', 'edit'],
80
+ 'group.add': ['edit'],
81
+ 'group.remove': ['edit'],
82
+ 'edge.add': ['connect'],
83
+ 'node.remove': ['remove'],
84
+ };
85
+
86
+ function linkedIntentId(rawInput: unknown): string | undefined {
87
+ if (!rawInput || typeof rawInput !== 'object' || Array.isArray(rawInput)) return undefined;
88
+ const record = rawInput as Record<string, unknown>;
89
+ if (record.intentId === undefined) return undefined;
90
+ if (typeof record.intentId !== 'string' || record.intentId.trim().length === 0) {
91
+ throw new OperationError('intentId must be a non-empty string.');
92
+ }
93
+ return record.intentId;
94
+ }
95
+
96
+ function allowedIntentKinds(name: string, rawInput: unknown): readonly PmxAxIntentKind[] | undefined {
97
+ if (name === 'jsonrender.stream') {
98
+ const input = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
99
+ ? rawInput as Record<string, unknown>
100
+ : {};
101
+ return typeof input.nodeId === 'string' && input.nodeId.length > 0 ? ['edit'] : ['create'];
102
+ }
103
+ return INTENT_KINDS_BY_OPERATION[name];
104
+ }
105
+
106
+ function settledNodeId(result: unknown, intent: PmxAxIntent): string | undefined {
107
+ if (intent.kind === 'connect' || intent.kind === 'remove') return undefined;
108
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return undefined;
109
+ const record = result as Record<string, unknown>;
110
+ if (typeof record.nodeId === 'string') return record.nodeId;
111
+ if (record.node && typeof record.node === 'object' && !Array.isArray(record.node)) {
112
+ const id = (record.node as Record<string, unknown>).id;
113
+ if (typeof id === 'string') return id;
114
+ }
115
+ if (typeof record.groupId === 'string') return record.groupId;
116
+ return typeof record.id === 'string' ? record.id : undefined;
117
+ }
118
+
72
119
  export async function executeOperation(name: string, rawInput: unknown): Promise<unknown> {
73
120
  const op = getOperation(name);
74
- const result = await op.execute(rawInput, operationContext);
75
- if (op.mutates) {
76
- emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
121
+ const intentId = linkedIntentId(rawInput);
122
+ const allowedKinds = intentId ? allowedIntentKinds(name, rawInput) : undefined;
123
+ if (intentId && !allowedKinds) {
124
+ throw new OperationError(`Operation "${name}" cannot be committed through a ghost intent.`);
77
125
  }
126
+ if (intentId) {
127
+ return intentRegistry.runCommit(
128
+ intentId,
129
+ allowedKinds!,
130
+ async () => {
131
+ const result = await op.execute(rawInput, operationContext);
132
+ if (op.mutates) {
133
+ emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
134
+ }
135
+ return result;
136
+ },
137
+ settledNodeId,
138
+ );
139
+ }
140
+
141
+ const result = await op.execute(rawInput, operationContext);
142
+ if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
78
143
  return result;
79
144
  }