pmx-canvas 0.9.3 → 0.10.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 (56) hide show
  1. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  2. package/.github/extensions/pmx-canvas/tools.json +9278 -2014
  3. package/CHANGELOG.md +22 -1
  4. package/dist/canvas/index.js +1 -1
  5. package/dist/canvas/mcp-app.html +1 -1
  6. package/dist/types/mcp/canvas-access.d.ts +1 -12
  7. package/dist/types/mcp/resource-subscriptions.d.ts +7 -0
  8. package/dist/types/mcp/server.d.ts +2 -0
  9. package/dist/types/server/ax-wait.d.ts +1 -0
  10. package/dist/types/server/canvas-operations.d.ts +1 -0
  11. package/dist/types/server/index.d.ts +3 -3
  12. package/dist/types/server/operations/http.d.ts +10 -0
  13. package/dist/types/server/operations/index.d.ts +2 -2
  14. package/dist/types/server/operations/invoker.d.ts +4 -3
  15. package/dist/types/server/operations/mcp-contracts.d.ts +20 -0
  16. package/dist/types/server/operations/mcp.d.ts +2 -0
  17. package/dist/types/server/operations/ops/batch.d.ts +1 -0
  18. package/dist/types/server/operations/registry.d.ts +2 -2
  19. package/dist/types/server/operations/types.d.ts +12 -2
  20. package/dist/types/server/server.d.ts +2 -0
  21. package/dist/types/server/webpage-node.d.ts +1 -0
  22. package/docs/api-reference.md +2 -2
  23. package/docs/cli.md +1 -1
  24. package/docs/http-api.md +31 -0
  25. package/docs/mcp.md +93 -21
  26. package/docs/plans/plan-019-post-0.9-roadmap.md +123 -6
  27. package/docs/sdk.md +5 -2
  28. package/package.json +1 -1
  29. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  30. package/plugins/pmx-canvas/plugin.json +1 -1
  31. package/plugins/pmx-canvas/runtime.json +1 -1
  32. package/plugins/pmx-canvas/tools.json +9278 -2014
  33. package/skills/pmx-canvas/SKILL.md +9 -4
  34. package/skills/pmx-canvas/references/full-reference.md +35 -2
  35. package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
  36. package/src/client/canvas/ToolRail.tsx +2 -0
  37. package/src/mcp/canvas-access.ts +6 -47
  38. package/src/mcp/resource-subscriptions.ts +181 -0
  39. package/src/mcp/server.ts +185 -219
  40. package/src/server/ax-wait.ts +21 -6
  41. package/src/server/canvas-operations.ts +6 -2
  42. package/src/server/index.ts +12 -4
  43. package/src/server/operations/http.ts +69 -21
  44. package/src/server/operations/index.ts +8 -1
  45. package/src/server/operations/invoker.ts +53 -5
  46. package/src/server/operations/mcp-contracts.ts +339 -0
  47. package/src/server/operations/mcp.ts +153 -18
  48. package/src/server/operations/ops/ax-await.ts +18 -10
  49. package/src/server/operations/ops/ax-read.ts +2 -0
  50. package/src/server/operations/ops/ax-timeline.ts +2 -2
  51. package/src/server/operations/ops/batch.ts +27 -8
  52. package/src/server/operations/ops/nodes.ts +18 -3
  53. package/src/server/operations/registry.ts +24 -4
  54. package/src/server/operations/types.ts +16 -2
  55. package/src/server/server.ts +10 -0
  56. package/src/server/webpage-node.ts +9 -4
@@ -9,9 +9,17 @@
9
9
  */
10
10
  import { z, type ZodRawShape, type ZodTypeAny } from 'zod';
11
11
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
+ import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
13
+ import type { ServerRequest, ServerNotification } from '@modelcontextprotocol/sdk/types.js';
12
14
  import { getOperation, listOperations } from './registry.js';
13
15
  import { agentSourceLabel, type OperationInvoker } from './invoker.js';
14
- import { OperationError, type Operation, type OperationMcpToolHost } from './types.js';
16
+ import { OperationError, type Operation, type OperationMcpToolHost, type OperationExecutionOptions } from './types.js';
17
+ import {
18
+ operationOutputSchema,
19
+ operationToolAnnotations,
20
+ operationToolError,
21
+ structuredOperationResult,
22
+ } from './mcp-contracts.js';
15
23
  import {
16
24
  CONTEXT_READ_OPS,
17
25
  contextReadFromPayload,
@@ -28,23 +36,51 @@ export interface OperationToolHost extends OperationMcpToolHost {
28
36
  recordContextRead(read: ContextReadInput): Promise<void>;
29
37
  }
30
38
 
39
+ function executionOptions(extra: RequestHandlerExtra<ServerRequest, ServerNotification>): OperationExecutionOptions {
40
+ const progressToken = extra._meta?.progressToken;
41
+ return {
42
+ signal: extra.signal,
43
+ ...(progressToken !== undefined
44
+ ? {
45
+ onProgress: (progress: Parameters<NonNullable<OperationExecutionOptions['onProgress']>>[0]) =>
46
+ extra.sendNotification({ method: 'notifications/progress', params: { ...progress, progressToken } }),
47
+ }
48
+ : {}),
49
+ };
50
+ }
51
+
31
52
  async function invokeMcpOperation(
32
53
  host: OperationToolHost,
33
54
  op: Operation,
34
55
  opInput: Record<string, unknown>,
35
56
  formatInput: Record<string, unknown>,
36
57
  consumer: string | null,
58
+ options: OperationExecutionOptions,
37
59
  ) {
60
+ options.signal?.throwIfAborted();
38
61
  const board = typeof opInput.board === 'string' && opInput.board.trim() ? opInput.board.trim() : undefined;
39
62
  const readTarget = CONTEXT_READ_OPS.has(op.name) ? await host.prepareContextRead(board) : null;
40
63
  if (readTarget?.boardId && 'board' in op.inputShape) {
41
64
  opInput = { ...opInput, board: readTarget.boardId };
42
65
  formatInput = { ...formatInput, board: readTarget.boardId };
43
66
  }
44
- const result = await host.invoker().invoke(op.name, opInput);
45
- const formatted = op.mcp?.formatResult
46
- ? await op.mcp.formatResult(result, formatInput, host)
47
- : { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
67
+ options.signal?.throwIfAborted();
68
+ const invoker = host.invoker();
69
+ const result = await invoker.invoke(op.name, opInput, options);
70
+ // Preserve the host receiver, and bind options to formatter follow-up reads
71
+ // (history flags, snapshot summary, streaming node metadata).
72
+ const formatterHost: OperationMcpToolHost = {
73
+ getPinnedNodeIds: (target) => host.getPinnedNodeIds(target),
74
+ invoker: () => ({ invoke: (name, input, followUp) => invoker.invoke(name, input, { ...options, ...followUp }) }),
75
+ };
76
+ const formatted = structuredOperationResult(
77
+ op.name,
78
+ op.mcp?.formatResult
79
+ ? await op.mcp.formatResult(result, formatInput, formatterHost)
80
+ : { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] },
81
+ );
82
+ // Do not throwIfAborted here: batch may already have returned a partial
83
+ // outcome, and cancellation must never erase completed work from the reply.
48
84
  if (readTarget) {
49
85
  const text = formatted.content
50
86
  .filter((item): item is Extract<(typeof formatted.content)[number], { type: 'text' }> => item.type === 'text')
@@ -116,17 +152,24 @@ export function registerOperationTools(server: McpServer, getHost: () => Promise
116
152
  // finding A). Loose parsing forwards unknown keys to the op layer,
117
153
  // the single authority on dead-field rejection, HTTP and MCP alike.
118
154
  inputSchema: z.looseObject({ ...op.inputShape, ...(tool.extraShape ?? {}) }),
155
+ outputSchema: operationOutputSchema([op.name]),
156
+ annotations: operationToolAnnotations(tool.toolName),
119
157
  },
120
- async (input: Record<string, unknown>) => {
158
+ async (input: Record<string, unknown>, extra) => {
121
159
  try {
122
160
  const host = await getHost();
161
+ extra.signal.throwIfAborted();
123
162
  const opInput = tool.buildInput ? tool.buildInput(input) : input;
124
- return await invokeMcpOperation(host, op, opInput, input, server.server.getClientVersion()?.name ?? null);
163
+ return await invokeMcpOperation(
164
+ host,
165
+ op,
166
+ opInput,
167
+ input,
168
+ server.server.getClientVersion()?.name ?? null,
169
+ executionOptions(extra),
170
+ );
125
171
  } catch (error) {
126
- return {
127
- content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
128
- isError: true,
129
- };
172
+ return operationToolError(error);
130
173
  }
131
174
  },
132
175
  );
@@ -295,6 +338,88 @@ function requiredMcpFields(op: Operation): string[] {
295
338
  return keys;
296
339
  }
297
340
 
341
+ /** Registry metadata only: never boots a canvas or records a content read. */
342
+ export function registerOperationHelpTool(server: McpServer): void {
343
+ const folded = compositeFoldedOpNames();
344
+ server.registerTool(
345
+ 'canvas_help',
346
+ {
347
+ description:
348
+ 'Look up an MCP operation by registry name (e.g. node.add, ax.approval.request). Returns its public tool, argument schema and guidance. Omit op to list operation names. Does not execute operations or read canvas content.',
349
+ inputSchema: z.object({ op: z.string().min(1).optional() }),
350
+ outputSchema: z.object({
351
+ result: z.union([
352
+ z.strictObject({ operations: z.array(z.string()) }),
353
+ z.strictObject({
354
+ operation: z.string(),
355
+ tool: z.string(),
356
+ description: z.string(),
357
+ inputSchema: z.record(z.string(), z.unknown()),
358
+ }),
359
+ ]),
360
+ }),
361
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
362
+ },
363
+ ({ op: name }) => {
364
+ const operations = listOperations().filter((op) => op.mcp || folded.has(op.name));
365
+ if (name === undefined) {
366
+ const result = { operations: operations.map((op) => op.name).sort() };
367
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: { result } };
368
+ }
369
+ const op = operations.find((op) => op.name === name);
370
+ if (!op) {
371
+ return {
372
+ content: [{ type: 'text', text: `Unknown MCP operation "${name}". Omit op to list available operations.` }],
373
+ isError: true,
374
+ };
375
+ }
376
+ const composite = compositeToolDefinitions.find((def) => compositeMemberOps(def).includes(name));
377
+ const publicName = (field: string) =>
378
+ Object.entries(composite?.fieldRemap ?? {}).find(([, original]) => original === field)?.[0] ?? field;
379
+ const shape = Object.fromEntries(
380
+ Object.entries({ ...op.inputShape, ...op.mcp?.extraShape }).map(([field, schema]) => [
381
+ publicName(field),
382
+ schema,
383
+ ]),
384
+ );
385
+ if (composite?.resolveOp) {
386
+ // Resolve the same kind/action matrix used by composite dispatch.
387
+ const kinds = composite.extraDiscriminatorShape?.kind;
388
+ if (!(kinds instanceof z.ZodEnum)) throw new Error(`Missing kind enum for ${composite.toolName}.`);
389
+ for (const kind of kinds.options) {
390
+ for (const action of composite.actionEnum ?? []) {
391
+ if (composite.resolveOp({ kind: String(kind), action }) === name) {
392
+ shape.kind = z.literal(kind);
393
+ shape.action = z.literal(action);
394
+ }
395
+ }
396
+ }
397
+ } else if (composite) {
398
+ const action = Object.entries(composite.actions).find(([, operation]) => operation === name)![0];
399
+ shape.action = z.literal(action);
400
+ }
401
+ const inputSchema = z.toJSONSchema(z.looseObject(shape), { target: 'draft-7', io: 'input' });
402
+ // Mirror dispatch's required-name alternatives rather than demanding both names.
403
+ for (const [field, aliases] of Object.entries(REQUIRED_FIELD_ALIASES[name] ?? {})) {
404
+ const key = publicName(field);
405
+ if (!inputSchema.required?.includes(key)) continue;
406
+ inputSchema.required = inputSchema.required.filter((required) => required !== key);
407
+ inputSchema.allOf = [
408
+ ...(inputSchema.allOf ?? []),
409
+ { anyOf: [key, ...aliases.map(publicName)].map((alternative) => ({ required: [alternative] })) },
410
+ ];
411
+ }
412
+ const result = {
413
+ operation: name,
414
+ tool: composite?.toolName ?? op.mcp!.toolName,
415
+ description: op.mcp?.description ?? composite!.description,
416
+ inputSchema,
417
+ };
418
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: { result } };
419
+ },
420
+ );
421
+ }
422
+
298
423
  /**
299
424
  * Register composite (action-discriminated) MCP tools (plan-006). Each action
300
425
  * dispatches to a registered operation, reusing that op's `mcp.buildInput` and
@@ -317,10 +442,16 @@ export function registerCompositeTools(
317
442
  // must reach the op layer's dead-field rejection instead of being stripped.
318
443
  server.registerTool(
319
444
  def.toolName,
320
- { description: def.description, inputSchema: z.looseObject(buildCompositeShape(def)) },
321
- async (input: Record<string, unknown>) => {
445
+ {
446
+ description: def.description,
447
+ inputSchema: z.looseObject(buildCompositeShape(def)),
448
+ outputSchema: operationOutputSchema(compositeMemberOps(def)),
449
+ annotations: operationToolAnnotations(def.toolName),
450
+ },
451
+ async (input: Record<string, unknown>, extra) => {
322
452
  try {
323
453
  const host = await getHost();
454
+ extra.signal.throwIfAborted();
324
455
  const opName = resolveCompositeOp(def, input);
325
456
  const op = getOperation(opName);
326
457
  // Strip the composite discriminators (action + any extra, e.g. `kind`)
@@ -335,12 +466,16 @@ export function registerCompositeTools(
335
466
  );
336
467
  }
337
468
  const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
338
- return await invokeMcpOperation(host, op, opInput, rest, server.server.getClientVersion()?.name ?? null);
469
+ return await invokeMcpOperation(
470
+ host,
471
+ op,
472
+ opInput,
473
+ rest,
474
+ server.server.getClientVersion()?.name ?? null,
475
+ executionOptions(extra),
476
+ );
339
477
  } catch (error) {
340
- return {
341
- content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
342
- isError: true,
343
- };
478
+ return operationToolError(error);
344
479
  }
345
480
  },
346
481
  );
@@ -39,11 +39,7 @@
39
39
  * the legacy error body — server-api only reads `missing.status`.
40
40
  *
41
41
  * Documented behavior change (accepted long-poll tradeoff, plan-007 sub-slice):
42
- * - The legacy HTTP handler passed `req.signal` to waitForAxResolution, so a
43
- * client disconnect aborted the wait early. The registry handler has no access
44
- * to the HTTP Request, so the wait now runs to its (≤120s, subscription-based,
45
- * cheap) timeout instead of aborting on disconnect. Resolution detection,
46
- * timeout, and the `{ value, pending }` result are otherwise identical.
42
+ * - Request cancellation rejects the wait and disposes its subscription/timer.
47
43
  * - Remote-MCP only (daemon mode, untested for these reads): the legacy
48
44
  * RemoteCanvasAccess.await* methods special-cased a 404 → `{ <key>:null }`. The
49
45
  * generic HttpOperationInvoker throws on 404, so an await-on-missing over a
@@ -70,9 +66,12 @@ function clampTimeoutMs(value: unknown): number {
70
66
  return Math.min(ms, AX_WAIT_MAX_MS);
71
67
  }
72
68
 
73
- /** HTTP `?waitMs` → `timeoutMs`; absent/non-positive ⇒ 0 (plain read). Matches parseAxWaitMs. */
69
+ /** Accept the operation's timeoutMs as well as the direct HTTP waitMs spelling. */
74
70
  function readWaitMsInput(_req: Request, params: Record<string, string>, url: URL): Record<string, unknown> {
75
- return { ...params, timeoutMs: clampTimeoutMs(url.searchParams.get('waitMs') ?? '') };
71
+ return {
72
+ ...params,
73
+ timeoutMs: clampTimeoutMs(url.searchParams.get('timeoutMs') ?? url.searchParams.get('waitMs') ?? ''),
74
+ };
76
75
  }
77
76
 
78
77
  /** MCP buildInput: forward id + timeoutMs (omitted ⇒ 30000), clamped to [0, max]. */
@@ -131,13 +130,16 @@ const axApprovalGetOperation = defineOperation<z.infer<typeof awaitInputSchema>,
131
130
  };
132
131
  },
133
132
  },
134
- handler: async (input) => {
133
+ handler: async (input, ctx) => {
135
134
  const id = typeof input.id === 'string' ? input.id : '';
135
+ await ctx.onProgress?.({ progress: 0, total: 1, message: 'Waiting for approval' });
136
136
  const { value, pending } = await waitForAxResolution<PmxAxApprovalGate>({
137
137
  read: () => canvasState.getApproval(id),
138
138
  isResolved: (g) => g.status !== 'pending',
139
139
  timeoutMs: clampTimeoutMs(input.timeoutMs),
140
+ signal: ctx.signal,
140
141
  });
142
+ await ctx.onProgress?.({ progress: 1, total: 1, message: pending ? 'Wait timed out' : 'Wait complete' });
141
143
  return { ok: value !== null, approvalGate: value, pending } as unknown as Record<string, unknown>;
142
144
  },
143
145
  });
@@ -170,13 +172,16 @@ const axElicitationGetOperation = defineOperation<z.infer<typeof awaitInputSchem
170
172
  };
171
173
  },
172
174
  },
173
- handler: async (input) => {
175
+ handler: async (input, ctx) => {
174
176
  const id = typeof input.id === 'string' ? input.id : '';
177
+ await ctx.onProgress?.({ progress: 0, total: 1, message: 'Waiting for elicitation' });
175
178
  const { value, pending } = await waitForAxResolution<PmxAxElicitation>({
176
179
  read: () => canvasState.getElicitation(id),
177
180
  isResolved: (e) => e.status !== 'pending',
178
181
  timeoutMs: clampTimeoutMs(input.timeoutMs),
182
+ signal: ctx.signal,
179
183
  });
184
+ await ctx.onProgress?.({ progress: 1, total: 1, message: pending ? 'Wait timed out' : 'Wait complete' });
180
185
  return { ok: value !== null, elicitation: value, pending } as unknown as Record<string, unknown>;
181
186
  },
182
187
  });
@@ -209,13 +214,16 @@ const axModeGetOperation = defineOperation<z.infer<typeof awaitInputSchema>, Rec
209
214
  };
210
215
  },
211
216
  },
212
- handler: async (input) => {
217
+ handler: async (input, ctx) => {
213
218
  const id = typeof input.id === 'string' ? input.id : '';
219
+ await ctx.onProgress?.({ progress: 0, total: 1, message: 'Waiting for mode resolution' });
214
220
  const { value, pending } = await waitForAxResolution<PmxAxModeRequest>({
215
221
  read: () => canvasState.getModeRequest(id),
216
222
  isResolved: (m) => m.status !== 'pending',
217
223
  timeoutMs: clampTimeoutMs(input.timeoutMs),
224
+ signal: ctx.signal,
218
225
  });
226
+ await ctx.onProgress?.({ progress: 1, total: 1, message: pending ? 'Wait timed out' : 'Wait complete' });
219
227
  return { ok: value !== null, modeRequest: value, pending } as unknown as Record<string, unknown>;
220
228
  },
221
229
  });
@@ -335,6 +335,8 @@ const interactionOperation = defineOperation<z.infer<typeof interactionSchema>,
335
335
  http: {
336
336
  method: 'POST',
337
337
  path: '/api/canvas/ax/interaction',
338
+ // MCP preserves the structured rejection code as well as its message.
339
+ errorBodyAsResult: true,
338
340
  // Legacy wire: 200 when accepted, the envelope's own status when refused.
339
341
  status: (result) =>
340
342
  isRecord(result) && result.ok !== true && typeof result.status === 'number' ? result.status : 200,
@@ -341,7 +341,7 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
341
341
  // `consumer` is a loop-safety scope, not a source label — never defaulted.
342
342
  formatResult: axJsonResult,
343
343
  },
344
- handler: async (input) => {
344
+ handler: async (input, ctx) => {
345
345
  const consumer = typeof input.consumer === 'string' ? input.consumer : undefined;
346
346
  // A claim is proof this consumer polls steering — its presence (session or
347
347
  // external writer) becomes a legitimate composer steer target.
@@ -363,7 +363,7 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
363
363
  // Long-poll: hosts that cannot be woken from outside (Copilot, Codex —
364
364
  // their model runs only while the host gives it a turn) park here and
365
365
  // wake the moment steering lands, instead of burning the turn polling.
366
- await waitForAxCondition({ ready: () => readPending().length > 0, timeoutMs });
366
+ await waitForAxCondition({ ready: () => readPending().length > 0, timeoutMs, signal: ctx.signal });
367
367
  pending = readPending();
368
368
  }
369
369
  // The MCP tool aggregated pendingActivity; one wire body now serves it over
@@ -44,6 +44,7 @@ export interface BatchEnvelope {
44
44
  refs: Record<string, unknown>;
45
45
  failedIndex?: number;
46
46
  error?: string;
47
+ cancelled?: boolean;
47
48
  }
48
49
 
49
50
  const SUPPORTED_BATCH_OPS = new Set([
@@ -220,7 +221,7 @@ function shapeBatchEntry(op: string, result: unknown, creationReceipt = false):
220
221
  return body;
221
222
  }
222
223
 
223
- async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
224
+ async function runBatch(operations: BatchEntry[], ctx: OperationContext): Promise<BatchEnvelope> {
224
225
  const refs: Record<string, unknown> = {};
225
226
  const results: Array<Record<string, unknown>> = [];
226
227
 
@@ -231,6 +232,15 @@ async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
231
232
  return { ok: false, failedIndex: index, error: `Operation ${index} has invalid args.`, results, refs };
232
233
  }
233
234
  try {
235
+ ctx.signal?.throwIfAborted();
236
+ if (ctx.onProgress) {
237
+ const boardGeneration = canvasState.boardGeneration;
238
+ await ctx.onProgress({ progress: index, total: operations.length, message: operation.op });
239
+ if (canvasState.boardGeneration !== boardGeneration) {
240
+ throw new Error('The board changed while the batch was running.');
241
+ }
242
+ }
243
+ ctx.signal?.throwIfAborted();
234
244
  if (!SUPPORTED_BATCH_OPS.has(operation.op)) {
235
245
  return {
236
246
  ok: false,
@@ -259,35 +269,43 @@ async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
259
269
  // ghost-cursor exemption for batch operations). No `fromWorkbench`: the
260
270
  // browser never issues batches, so inner writes are agent writes and the
261
271
  // scope fence applies to each of them.
262
- const pending = executeOperation(dispatch.name, dispatch.args, { suppressAutoGhost: true });
272
+ const pending = executeOperation(dispatch.name, dispatch.args, {
273
+ suppressAutoGhost: true,
274
+ signal: ctx.signal,
275
+ onProgress: ctx.onProgress
276
+ ? (progress) => ctx.onProgress!({ progress: index, total: operations.length, message: progress.message })
277
+ : undefined,
278
+ });
263
279
  // The first write may synchronously open a board from Home. Capture after
264
280
  // dispatch, then refuse to continue a partially completed batch elsewhere.
265
281
  const boardGeneration = canvasState.boardGeneration;
266
282
  const raw = await pending;
267
- if (canvasState.boardGeneration !== boardGeneration) {
268
- throw new Error('The board changed while the batch was running.');
269
- }
283
+ const boardChanged = canvasState.boardGeneration !== boardGeneration;
270
284
  const result = shapeBatchEntry(
271
285
  operation.op,
272
286
  raw,
273
- ['node.add', 'group.create', 'edge.add'].includes(operation.op) &&
274
- (args.requestId !== undefined || args.destinationBoardId !== undefined),
287
+ boardChanged ||
288
+ (['node.add', 'group.create', 'edge.add'].includes(operation.op) &&
289
+ (args.requestId !== undefined || args.destinationBoardId !== undefined)),
275
290
  );
276
291
  results.push(result);
277
292
  if (typeof operation.assign === 'string' && operation.assign.trim().length > 0) {
278
293
  refs[operation.assign] = result;
279
294
  }
295
+ if (boardChanged) throw new Error('The board changed while the batch was running.');
280
296
  } catch (error) {
281
297
  return {
282
298
  ok: false,
283
299
  failedIndex: index,
284
300
  error: error instanceof Error ? error.message : String(error),
301
+ ...(ctx.signal?.aborted ? { cancelled: true } : {}),
285
302
  results,
286
303
  refs,
287
304
  };
288
305
  }
289
306
  }
290
307
 
308
+ await ctx.onProgress?.({ progress: operations.length, total: operations.length, message: 'Batch complete' });
291
309
  return { ok: true, results, refs };
292
310
  }
293
311
 
@@ -356,6 +374,7 @@ function compactBatchResult(result: BatchEnvelope): Record<string, unknown> {
356
374
  ok: result.ok,
357
375
  ...(result.failedIndex !== undefined ? { failedIndex: result.failedIndex } : {}),
358
376
  ...(result.error ? { error: result.error } : {}),
377
+ ...(result.cancelled ? { cancelled: true } : {}),
359
378
  results: result.results.map((entry) => compactBatchValue(entry)),
360
379
  refs: Object.fromEntries(Object.entries(result.refs).map(([key, value]) => [key, compactBatchValue(value)])),
361
380
  };
@@ -406,7 +425,7 @@ const batchOperation = defineOperation<z.infer<typeof batchSchema>, BatchEnvelop
406
425
  // Suppress every per-entry SSE frame (auto layout emit + each handler's
407
426
  // ctx.emit). After the loop completes, emit ONE final canvas-layout-update.
408
427
  const revision = canvasState.getContentRevision().revision;
409
- const envelope = await runWithSuppressedEmits(() => runBatch(operations));
428
+ const envelope = await runWithSuppressedEmits(() => runBatch(operations, ctx));
410
429
  if (
411
430
  revision !== canvasState.getContentRevision().revision ||
412
431
  operations.some(
@@ -633,6 +633,7 @@ async function createWebpageNode(body: Record<string, unknown>, ctx: OperationCo
633
633
 
634
634
  const extraData = isRecord(body.data) ? body.data : undefined;
635
635
  const geometry = resolveCreateGeometry(body);
636
+ ctx.signal?.throwIfAborted();
636
637
  const { id, node, sizeAdjustment } = addCanvasNode({
637
638
  type: 'webpage',
638
639
  ...(typeof body.title === 'string' ? { title: body.title } : {}),
@@ -648,7 +649,21 @@ async function createWebpageNode(body: Record<string, unknown>, ctx: OperationCo
648
649
  // registry emits the final layout update after the handler returns.
649
650
  ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
650
651
  const boardGeneration = canvasState.boardGeneration;
651
- const refreshed = await refreshCanvasWebpageNode(id);
652
+ let refreshed: Awaited<ReturnType<typeof refreshCanvasWebpageNode>>;
653
+ try {
654
+ refreshed = await refreshCanvasWebpageNode(id, { signal: ctx.signal });
655
+ } catch (error) {
656
+ if (!ctx.signal?.aborted) throw error;
657
+ // Creation already committed. Return its receipt without writing an error
658
+ // status; batches retain this node and stop before their next entry.
659
+ return {
660
+ node,
661
+ extras: {
662
+ fetch: { ok: false, cancelled: true, error: 'Webpage fetch cancelled.' },
663
+ ...(sizeAdjustment ? { sizeAdjustment } : {}),
664
+ },
665
+ };
666
+ }
652
667
  if (canvasState.boardGeneration !== boardGeneration) {
653
668
  throw new OperationError('The board changed while the webpage was loading.', 409);
654
669
  }
@@ -1186,7 +1201,7 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
1186
1201
  throw new OperationError(error instanceof Error ? error.message : 'Invalid webpage URL.');
1187
1202
  }
1188
1203
  }
1189
- const result = await refreshCanvasWebpageNode(id, { ...(url ? { url } : {}) });
1204
+ const result = await refreshCanvasWebpageNode(id, { ...(url ? { url } : {}), signal: ctx.signal });
1190
1205
  return result as unknown as Record<string, unknown>;
1191
1206
  }
1192
1207
 
@@ -1442,7 +1457,7 @@ const nodeRefreshOperation = defineOperation<z.infer<typeof nodeRefreshSchema>,
1442
1457
  }
1443
1458
  // Legacy wire: the layout update fires after every completed refresh
1444
1459
  // attempt, success or failure (the node may have partial refresh state).
1445
- const result = await refreshCanvasWebpageNode(nodeId, { ...(url ? { url } : {}) });
1460
+ const result = await refreshCanvasWebpageNode(nodeId, { ...(url ? { url } : {}), signal: ctx.signal });
1446
1461
  ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
1447
1462
  return result as unknown as Record<string, unknown>;
1448
1463
  },
@@ -20,7 +20,7 @@ import type { MemoryGraph } from '../../shared/memory-graph.js';
20
20
  import { openBoardForWrite } from '../canvas-operations.js';
21
21
  import { boardsPayload } from './ops/boards.js';
22
22
  import type { PmxAxIntent, PmxAxIntentKind } from '../../shared/ax-intent.js';
23
- import { OperationError, type Operation, type OperationContext } from './types.js';
23
+ import { OperationError, type Operation, type OperationContext, type OperationExecutionOptions } from './types.js';
24
24
 
25
25
  const operations = new Map<string, Operation>();
26
26
 
@@ -75,8 +75,6 @@ export async function runWithSuppressedEmits<T>(fn: () => Promise<T>): Promise<T
75
75
  return suppressedEmits.run(true, fn);
76
76
  }
77
77
 
78
- const operationContext: OperationContext = { emit: emitOperationEvent };
79
-
80
78
  const INTENT_KINDS_BY_OPERATION: Record<string, readonly PmxAxIntentKind[]> = {
81
79
  'node.add': ['create'],
82
80
  'jsonrender.add': ['create'],
@@ -204,7 +202,7 @@ function autoGhostInput(name: string, rawInput: unknown, kind: string): Record<s
204
202
  }
205
203
  }
206
204
 
207
- export interface ExecuteOperationMeta {
205
+ export interface ExecuteOperationMeta extends OperationExecutionOptions {
208
206
  /**
209
207
  * Skip the synthesized auto-ghost: set by the workbench's own HTTP calls
210
208
  * (a human dragging a node is not agent activity) and by canvas.batch for
@@ -395,6 +393,8 @@ export async function executeOperation(
395
393
  rawInput: unknown,
396
394
  meta: ExecuteOperationMeta = {},
397
395
  ): Promise<unknown> {
396
+ // Check before attribution, board auto-open, ghosts, or any handler writes.
397
+ meta.signal?.throwIfAborted();
398
398
  // Attribution follows this call chain across awaits and nested operations.
399
399
  const inputAgentId = asRecord(rawInput).agentId;
400
400
  return withCurrentActor(
@@ -479,6 +479,22 @@ function needsOpenBoard(op: Operation): boolean {
479
479
  }
480
480
 
481
481
  async function executeOperationInner(name: string, rawInput: unknown, meta: ExecuteOperationMeta): Promise<unknown> {
482
+ meta.signal?.throwIfAborted();
483
+ const operationContext: OperationContext = {
484
+ emit: emitOperationEvent,
485
+ signal: meta.signal,
486
+ // Progress is observational. A disconnected observer must not erase the
487
+ // receipt of a completed write or strand a batch's final layout emit.
488
+ onProgress: meta.onProgress
489
+ ? async (progress) => {
490
+ try {
491
+ await meta.onProgress!(progress);
492
+ } catch (error) {
493
+ if (!meta.signal?.aborted) console.error(`[operation] progress callback failed for ${name}:`, error);
494
+ }
495
+ }
496
+ : undefined,
497
+ };
482
498
  const op = getOperation(name);
483
499
  const storedCreation =
484
500
  ['node.add', 'group.create', 'edge.add'].includes(name) &&
@@ -543,6 +559,7 @@ async function executeOperationInner(name: string, rawInput: unknown, meta: Exec
543
559
  }
544
560
  if (storedCreation) {
545
561
  const revision = canvasState.getContentRevision().revision;
562
+ meta.signal?.throwIfAborted();
546
563
  const result = await op.execute(rawInput, operationContext);
547
564
  const destination = asRecord(rawInput).destinationBoardId ?? canvasState.activeBoardId;
548
565
  if (destination === canvasState.activeBoardId || revision !== canvasState.getContentRevision().revision)
@@ -566,6 +583,7 @@ async function executeOperationInner(name: string, rawInput: unknown, meta: Exec
566
583
  intentId,
567
584
  allowedKinds!,
568
585
  async () => {
586
+ meta.signal?.throwIfAborted();
569
587
  const result = await op.execute(input, operationContext);
570
588
  if (op.mutates) {
571
589
  emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
@@ -606,6 +624,7 @@ async function executeOperationInner(name: string, rawInput: unknown, meta: Exec
606
624
  if (ghost) {
607
625
  const ghostId = ghost.id;
608
626
  try {
627
+ meta.signal?.throwIfAborted();
609
628
  const result = await op.execute(rawInput, operationContext);
610
629
  if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
611
630
  intentRegistry.clear(ghostId, { settledNodeId: settledNodeId(result, ghost) ?? undefined });
@@ -621,6 +640,7 @@ async function executeOperationInner(name: string, rawInput: unknown, meta: Exec
621
640
  const beforeBoard = canvasState.activeBoardId;
622
641
  const beforeRevision =
623
642
  name === 'board.create-from' || name === 'board.delete' ? canvasState.getContentRevision().revision : null;
643
+ meta.signal?.throwIfAborted();
624
644
  const result = await op.execute(rawInput, operationContext);
625
645
  // Library writes can invalidate relations on the open board without being
626
646
  // layout mutations themselves. Publish those clocks once, after the write.
@@ -29,7 +29,19 @@ export class OperationError extends Error {
29
29
  }
30
30
  }
31
31
 
32
- export interface OperationContext {
32
+ export interface OperationProgress {
33
+ progress: number;
34
+ total?: number;
35
+ message?: string;
36
+ }
37
+
38
+ /** Cooperative cancellation: completed writes are never rolled back. */
39
+ export interface OperationExecutionOptions {
40
+ signal?: AbortSignal;
41
+ onProgress?: (progress: OperationProgress) => void | Promise<void>;
42
+ }
43
+
44
+ export interface OperationContext extends OperationExecutionOptions {
33
45
  /** Emit a workbench SSE event (e.g. extra `canvas-layout-update` frames, focus, viewport). */
34
46
  emit(event: string, payload?: Record<string, unknown>): void;
35
47
  }
@@ -67,7 +79,9 @@ export interface OperationMcpToolHost {
67
79
  * Used by formatters that need a follow-up read (undo/redo history flags,
68
80
  * restore summary).
69
81
  */
70
- invoker(): { invoke(name: string, input: Record<string, unknown>): Promise<unknown> };
82
+ invoker(): {
83
+ invoke(name: string, input: Record<string, unknown>, options?: OperationExecutionOptions): Promise<unknown>;
84
+ };
71
85
  }
72
86
 
73
87
  export interface OperationMcpTool {
@@ -930,6 +930,15 @@ let workbenchEventSeq = 0;
930
930
  const workbenchEventRing: Array<{ seq: number; event: string; payload: PrimaryWorkbenchEventPayload }> = [];
931
931
  const workbenchPollWaiters = new Set<() => void>();
932
932
  let remoteEdgesSignature = '';
933
+ const workbenchEventListeners = new Set<(event: string) => void>();
934
+
935
+ /** In-process invalidation observers share the same events as daemon SSE clients. */
936
+ export function onWorkbenchEvent(listener: (event: string) => void): () => void {
937
+ workbenchEventListeners.add(listener);
938
+ return () => {
939
+ workbenchEventListeners.delete(listener);
940
+ };
941
+ }
933
942
 
934
943
  function broadcastWorkbenchEvent(event: string, payload: PrimaryWorkbenchEventPayload): void {
935
944
  let remoteEdgesChanged = false;
@@ -975,6 +984,7 @@ function broadcastWorkbenchEvent(event: string, payload: PrimaryWorkbenchEventPa
975
984
  syncCanvasBrowserOpenedFromSubscribers();
976
985
  }
977
986
  }
987
+ for (const listener of workbenchEventListeners) listener(event);
978
988
  if (remoteEdgesChanged) emitPrimaryWorkbenchEvent('boards-changed', boardsPayload());
979
989
  }
980
990