pmx-canvas 0.9.2 → 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 (87) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +9 -3
  2. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  3. package/.github/extensions/pmx-canvas/tools.json +9278 -2014
  4. package/CHANGELOG.md +34 -1
  5. package/dist/canvas/global.css +12 -17
  6. package/dist/canvas/index.js +58 -58
  7. package/dist/canvas/mcp-app.html +75 -80
  8. package/dist/types/client/canvas/submit-without-navigation.d.ts +3 -0
  9. package/dist/types/client/state/intent-bridge.d.ts +0 -3
  10. package/dist/types/client/state/presence-store.d.ts +3 -3
  11. package/dist/types/client/types.d.ts +1 -1
  12. package/dist/types/mcp/canvas-access.d.ts +1 -12
  13. package/dist/types/mcp/resource-subscriptions.d.ts +7 -0
  14. package/dist/types/mcp/server.d.ts +2 -0
  15. package/dist/types/server/ax-wait.d.ts +1 -0
  16. package/dist/types/server/canvas-operations.d.ts +1 -0
  17. package/dist/types/server/canvas-state.d.ts +2 -0
  18. package/dist/types/server/index.d.ts +5 -5
  19. package/dist/types/server/knowledge-lifecycle.d.ts +11 -2
  20. package/dist/types/server/lifecycle-status.d.ts +3 -1
  21. package/dist/types/server/operations/http.d.ts +10 -0
  22. package/dist/types/server/operations/index.d.ts +2 -2
  23. package/dist/types/server/operations/invoker.d.ts +4 -3
  24. package/dist/types/server/operations/mcp-contracts.d.ts +20 -0
  25. package/dist/types/server/operations/mcp.d.ts +2 -0
  26. package/dist/types/server/operations/ops/batch.d.ts +1 -0
  27. package/dist/types/server/operations/registry.d.ts +2 -2
  28. package/dist/types/server/operations/types.d.ts +12 -2
  29. package/dist/types/server/server.d.ts +2 -0
  30. package/dist/types/server/tour-control.d.ts +6 -3
  31. package/dist/types/server/webpage-node.d.ts +1 -0
  32. package/docs/api-reference.md +2 -2
  33. package/docs/cli.md +10 -1
  34. package/docs/design/Chrome.dc.html +1 -0
  35. package/docs/design.md +3 -1
  36. package/docs/http-api.md +31 -0
  37. package/docs/mcp.md +98 -22
  38. package/docs/node-types.md +3 -0
  39. package/docs/plans/plan-019-post-0.9-roadmap.md +187 -11
  40. package/docs/sdk.md +10 -0
  41. package/package.json +1 -1
  42. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +9 -3
  43. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  44. package/plugins/pmx-canvas/plugin.json +1 -1
  45. package/plugins/pmx-canvas/runtime.json +1 -1
  46. package/plugins/pmx-canvas/tools.json +9278 -2014
  47. package/skills/pmx-canvas/SKILL.md +12 -7
  48. package/skills/pmx-canvas/references/full-reference.md +35 -2
  49. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
  50. package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
  51. package/src/client/canvas/BoardCopyDialog.tsx +2 -1
  52. package/src/client/canvas/KnowledgeDialog.tsx +3 -0
  53. package/src/client/canvas/LifecyclePanel.tsx +4 -0
  54. package/src/client/canvas/RelationPresetsDialog.tsx +2 -0
  55. package/src/client/canvas/ToolRail.tsx +2 -0
  56. package/src/client/canvas/submit-without-navigation.ts +14 -0
  57. package/src/client/state/canvas-store.ts +12 -24
  58. package/src/client/state/intent-bridge.ts +0 -6
  59. package/src/client/state/presence-store.ts +3 -3
  60. package/src/client/state/sse-bridge.ts +11 -6
  61. package/src/client/theme/global.css +12 -17
  62. package/src/mcp/canvas-access.ts +6 -47
  63. package/src/mcp/resource-subscriptions.ts +181 -0
  64. package/src/mcp/server.ts +185 -219
  65. package/src/server/ax-wait.ts +21 -6
  66. package/src/server/canvas-operations.ts +6 -2
  67. package/src/server/canvas-state.ts +10 -0
  68. package/src/server/index.ts +20 -32
  69. package/src/server/knowledge-lifecycle.ts +66 -0
  70. package/src/server/lifecycle-status.ts +79 -7
  71. package/src/server/operations/http.ts +69 -21
  72. package/src/server/operations/index.ts +8 -1
  73. package/src/server/operations/invoker.ts +53 -5
  74. package/src/server/operations/mcp-contracts.ts +339 -0
  75. package/src/server/operations/mcp.ts +153 -18
  76. package/src/server/operations/ops/ax-await.ts +18 -10
  77. package/src/server/operations/ops/ax-read.ts +2 -0
  78. package/src/server/operations/ops/ax-timeline.ts +2 -2
  79. package/src/server/operations/ops/batch.ts +27 -8
  80. package/src/server/operations/ops/lifecycle.ts +3 -7
  81. package/src/server/operations/ops/nodes.ts +20 -8
  82. package/src/server/operations/ops/viewport.ts +3 -7
  83. package/src/server/operations/registry.ts +24 -4
  84. package/src/server/operations/types.ts +16 -2
  85. package/src/server/server.ts +23 -10
  86. package/src/server/tour-control.ts +17 -8
  87. package/src/server/webpage-node.ts +9 -4
@@ -1,13 +1,21 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import { derivedTour, type Camera, type Tour, type TourStop } from '../shared/tour.js';
3
- import { exitTour, goToTourStop, moveCamera, type TourStep, type TourStepResult } from './tour-control.js';
2
+ import type { Camera, Tour, TourStop } from '../shared/tour.js';
3
+ import {
4
+ currentTour,
5
+ exitTour,
6
+ goToTourStop,
7
+ moveCamera,
8
+ type TourState,
9
+ type TourStep,
10
+ type TourStepResult,
11
+ } from './tour-control.js';
4
12
  export type { Tour, TourStop } from '../shared/tour.js';
5
- export type { TourStep, TourStepResult } from './tour-control.js';
13
+ export type { TourState, TourStep, TourStepResult } from './tour-control.js';
6
14
  import { agentPresence } from './agent-presence.js';
7
15
  import { checkFenceTarget, type FenceTarget } from './scope-fence.js';
8
16
  import type { AgentPhase, AgentPresence, AgentPresenceSnapshot } from '../shared/agent-presence.js';
9
- import { canvasState, IMAGE_MIME_MAP } from './canvas-state.js';
10
- import type { CanvasAnnotation, CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from './canvas-state.js';
17
+ import { canvasState } from './canvas-state.js';
18
+ import type { CanvasAnnotation, CanvasNodeState, CanvasEdge, CanvasLayout } from './canvas-state.js';
11
19
  import { buildCanvasAxContext } from './ax-context.js';
12
20
  import { applyAxInteraction, type AxInteractionInput, type AxInteractionPublicResult } from './ax-interaction.js';
13
21
  import { intentRegistry } from './intent-registry.js';
@@ -46,12 +54,12 @@ import { workbenchToken } from './workbench-auth.js';
46
54
  import type { BackupFile, BackupStatus } from './backup.js';
47
55
  import type { ExportManifest } from './board-export.js';
48
56
  import { onFileNodeChanged } from './file-watcher.js';
49
- import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
57
+ import { findOpenCanvasPosition } from './placement.js';
50
58
  import { overlapsAny } from '../shared/placement.js';
51
59
  import { buildSpatialContext } from './spatial-analysis.js';
52
60
  import { searchCanvasNodes } from './operations/ops/query.js';
53
61
  import { mutationHistory, diffLayouts, formatDiff } from './mutation-history.js';
54
- import { recomputeCodeGraph, buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
62
+ import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
55
63
  import {
56
64
  addCanvasNode,
57
65
  addCanvasEdge,
@@ -90,12 +98,7 @@ import { validateCanvasLayout } from './canvas-validation.js';
90
98
  import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
91
99
  import { serializeCanvasNode, type SerializedCanvasNode } from './canvas-serialization.js';
92
100
  import { contextReadFromPayload } from './context-reads.js';
93
- import {
94
- buildHtmlPrimitive,
95
- getHtmlPrimitiveSemanticMetadata,
96
- isHtmlPrimitiveKind,
97
- listHtmlPrimitiveDescriptors,
98
- } from './html-primitives.js';
101
+ import { buildHtmlPrimitive, getHtmlPrimitiveSemanticMetadata } from './html-primitives.js';
99
102
  import type { HtmlPrimitiveKind } from './html-primitives.js';
100
103
  import {
101
104
  buildWebArtifactOnCanvas,
@@ -104,15 +107,7 @@ import {
104
107
  } from './web-artifacts.js';
105
108
  import { closeMcpAppSession, type ExternalMcpTransportConfig } from './mcp-app-runtime.js';
106
109
  import type { DiagramPresetOpenInput } from './diagram-presets.js';
107
- import {
108
- buildGraphSpec,
109
- GRAPH_NODE_SIZE,
110
- JSON_RENDER_NODE_SIZE,
111
- normalizeAndValidateJsonRenderSpec,
112
- type GraphNodeInput,
113
- type JsonRenderNodeInput,
114
- type JsonRenderSpec,
115
- } from '../json-render/server.js';
110
+ import type { GraphNodeInput, JsonRenderNodeInput, JsonRenderSpec } from '../json-render/server.js';
116
111
  import {
117
112
  startCanvasServer,
118
113
  stopCanvasServer,
@@ -126,15 +121,8 @@ import {
126
121
  screenshotCanvasAutomationWebView,
127
122
  emitPrimaryWorkbenchEvent,
128
123
  setPrimaryWorkbenchCanvasPromptHandler,
129
- setPrimaryWorkbenchAutoOpenEnabled,
130
- consumePrimaryWorkbenchIntents,
131
- } from './server.js';
132
- import type {
133
- CanvasAutomationWebViewOptions,
134
- CanvasAutomationWebViewStatus,
135
- PrimaryWorkbenchCanvasPromptRequest,
136
- PrimaryWorkbenchIntent,
137
124
  } from './server.js';
125
+ import type { CanvasAutomationWebViewOptions, CanvasAutomationWebViewStatus } from './server.js';
138
126
 
139
127
  /**
140
128
  * Node object returned by the SDK's create/get methods. It is the fully
@@ -632,8 +620,8 @@ export class PmxCanvas extends EventEmitter {
632
620
  );
633
621
  }
634
622
 
635
- getTour(): Tour {
636
- return canvasState.getTour() ?? derivedTour(canvasState.getLayout().nodes);
623
+ getTour(): TourState {
624
+ return currentTour();
637
625
  }
638
626
 
639
627
  setTour(tour: Tour | null): void {
@@ -1,6 +1,7 @@
1
1
  import type { Database } from 'bun:sqlite';
2
2
  import { z } from 'zod';
3
3
  import { normalizeBoardFolder } from '../shared/boards.js';
4
+ import { memoryKey, type MemoryTarget } from '../shared/memory-graph.js';
4
5
  import { currentActor, type ActorAttribution } from './attribution.js';
5
6
  import { OperationError } from './operations/types.js';
6
7
  import type { LifecycleQualification } from '../shared/lifecycle-status.js';
@@ -47,6 +48,16 @@ export interface ReviewRecord {
47
48
  sourceRevisions?: Record<string, number | null>;
48
49
  }
49
50
 
51
+ // SQLite trim defaults to ASCII space; match JavaScript evidence.trim(), including Unicode whitespace.
52
+ const evidenceWhitespace =
53
+ '\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff';
54
+ const validationPredicate = `json_extract(value, '$.applied') = 1
55
+ AND json_extract(value, '$.outcome') IN ('retain', 'revise', 'externalize')
56
+ AND trim(json_extract(value, '$.evidence'), '${evidenceWhitespace}') <> ''`;
57
+ const retirementPredicate = `(json_extract(value, '$.outcome') = 'restore'
58
+ OR (json_extract(value, '$.applied') = 1 AND json_extract(value, '$.outcome') = 'retire'))`;
59
+ const indexedDatabases = new WeakSet<Database>();
60
+
50
61
  export type KnowledgeInput =
51
62
  | { action: 'list' }
52
63
  | { action: 'category-set'; category: KnowledgeCategory }
@@ -240,6 +251,61 @@ export class KnowledgeLifecycle {
240
251
  return resolved;
241
252
  }
242
253
 
254
+ private indexedDb(): Database {
255
+ const db = this.db();
256
+ if (!indexedDatabases.has(db)) {
257
+ db.exec(`CREATE INDEX IF NOT EXISTS review_latest_validation ON review_records
258
+ (board_id, node_id, sequence DESC) WHERE ${validationPredicate};
259
+ CREATE INDEX IF NOT EXISTS review_latest_retirement ON review_records
260
+ (board_id, node_id, sequence DESC) WHERE ${retirementPredicate};
261
+ CREATE INDEX IF NOT EXISTS lifecycle_incoming_sources ON edges (to_board_id, to_node)
262
+ WHERE COALESCE(kind, type) IN ('derived-from', 'contradicts', 'supersedes');`);
263
+ indexedDatabases.add(db);
264
+ }
265
+ return db;
266
+ }
267
+
268
+ /** Stored owners of semantic sources; the caller overlays the active board's unsaved edges. */
269
+ incomingSourceBoards(target: MemoryTarget, activeBoardId: string | null): string[] {
270
+ return this.indexedDb()
271
+ .query<{ board_id: string }, [string, string | null, string | null]>(
272
+ `SELECT DISTINCT board_id FROM edges WHERE to_board_id = ? AND to_node = ?
273
+ AND board_id IS NOT ? AND COALESCE(kind, type) IN ('derived-from', 'contradicts', 'supersedes')`,
274
+ )
275
+ .all(target.boardId, target.cardId ?? null, activeBoardId)
276
+ .map((row) => row.board_id);
277
+ }
278
+
279
+ /** One indexed latest validation and retirement per card, never its full audit history. */
280
+ reviewSummaries(targets: MemoryTarget[]): Map<string, { lastValidation: ReviewRecord | null; retired: boolean }> {
281
+ if (!targets.length) return new Map();
282
+ const rows = this.indexedDb()
283
+ .query<{ boardId: string; nodeId: string; validation: string | null; retirement: string | null }, [string]>(
284
+ `WITH targets AS (
285
+ SELECT json_extract(value, '$.boardId') AS boardId, json_extract(value, '$.cardId') AS nodeId
286
+ FROM json_each(?)
287
+ ) SELECT boardId, nodeId,
288
+ (SELECT value FROM review_records WHERE board_id = boardId AND node_id = nodeId
289
+ AND ${validationPredicate} ORDER BY sequence DESC LIMIT 1) AS validation,
290
+ (SELECT json_extract(value, '$.outcome') FROM review_records WHERE board_id = boardId AND node_id = nodeId
291
+ AND ${retirementPredicate} ORDER BY sequence DESC LIMIT 1) AS retirement
292
+ FROM targets`,
293
+ )
294
+ .all(JSON.stringify(targets));
295
+ return new Map(
296
+ rows.map((row) => {
297
+ const record: ReviewRecord | null = row.validation ? JSON.parse(row.validation) : null;
298
+ return [
299
+ memoryKey({ boardId: row.boardId, cardId: row.nodeId }),
300
+ {
301
+ lastValidation: record ? { ...record, contentRevision: record.contentRevision ?? null } : null,
302
+ retired: row.retirement === 'retire',
303
+ },
304
+ ];
305
+ }),
306
+ );
307
+ }
308
+
243
309
  reviews(boardId: string, nodeId: string) {
244
310
  const records = (
245
311
  this.db()
@@ -25,8 +25,70 @@ export function lifecycleQualifications(
25
25
  return lifecycleSnapshot(now, atUse).cards;
26
26
  }
27
27
 
28
+ /** A card being inspected is in use, including its transitive sources and portal contents. */
29
+ export function lifecycleQualification(target: MemoryTarget, now = Date.now()): LifecycleQualification | undefined {
30
+ return lifecycleSnapshot(now, [target], target).cards[memoryKey(target)];
31
+ }
32
+
33
+ /** Read closure boards once; never activate them or flush the active board to discover edges. */
34
+ function dependencyClosure(target: MemoryTarget) {
35
+ const reads = new Map<string, ReturnType<typeof canvasState.readBoard>>();
36
+ const nodes = new Map<string, CanvasNodeState>();
37
+ const dependencies = new Map<string, MemoryTarget[]>();
38
+ const selected = new Set<string>();
39
+ const queue: MemoryTarget[] = [target];
40
+ const read = (boardId: string) => {
41
+ if (!reads.has(boardId)) {
42
+ const board = canvasState.readBoard(boardId, false);
43
+ reads.set(boardId, board);
44
+ for (const node of board?.layout.nodes ?? []) nodes.set(memoryKey({ boardId, cardId: node.id }), node);
45
+ for (const edge of board?.layout.edges ?? []) {
46
+ const end = relationSourceEnd(edge);
47
+ if (!end) continue;
48
+ const from = { boardId, cardId: edge.from };
49
+ const to = { boardId: edge.toBoardId ?? boardId, cardId: edge.to };
50
+ const key = memoryKey(end === 'from' ? to : from);
51
+ const sources = dependencies.get(key) ?? [];
52
+ sources.push(end === 'from' ? from : to);
53
+ dependencies.set(key, sources);
54
+ }
55
+ }
56
+ return reads.get(boardId);
57
+ };
58
+ const activeBoardId = canvasState.activeBoardId;
59
+ const incomingActive = new Set(
60
+ canvasState
61
+ .getEdges()
62
+ .flatMap((edge) =>
63
+ edge.toBoardId && relationSourceEnd(edge) === 'from'
64
+ ? [memoryKey({ boardId: edge.toBoardId, cardId: edge.to })]
65
+ : [],
66
+ ),
67
+ );
68
+ for (const current of queue) {
69
+ const key = memoryKey(current);
70
+ if (selected.has(key)) continue;
71
+ selected.add(key);
72
+ const board = read(current.boardId);
73
+ if (!current.cardId) {
74
+ for (const node of board?.layout.nodes ?? []) queue.push({ boardId: current.boardId, cardId: node.id });
75
+ continue;
76
+ }
77
+ const node = nodes.get(key);
78
+ if (!node) continue;
79
+ const provenance = normalizeCanvasNodeData(node.type, node.data).provenance as CanvasNodeProvenance | undefined;
80
+ queue.push(...(provenance?.sources ?? []).map(({ boardId, cardId }) => ({ boardId, cardId })));
81
+ if (node.type === 'board' && typeof node.data.boardId === 'string') queue.push({ boardId: node.data.boardId });
82
+ const owners = new Set([current.boardId, ...canvasState.knowledge.incomingSourceBoards(current, activeBoardId)]);
83
+ if (activeBoardId && incomingActive.has(key)) owners.add(activeBoardId);
84
+ for (const owner of owners) read(owner);
85
+ queue.push(...(dependencies.get(key) ?? []));
86
+ }
87
+ return { reads, selected };
88
+ }
89
+
28
90
  /** Compact queue metadata and qualifications come from the same authoritative read. */
29
- export function lifecycleSnapshot(now = Date.now(), atUse: MemoryTarget[] = []) {
91
+ export function lifecycleSnapshot(now = Date.now(), atUse: MemoryTarget[] = [], only?: MemoryTarget) {
30
92
  const result: Record<string, LifecycleQualification> = {};
31
93
  const cards = new Map<string, CanvasNodeState>();
32
94
  const validations = new Map<string, ReviewRecord | null>();
@@ -34,26 +96,36 @@ export function lifecycleSnapshot(now = Date.now(), atUse: MemoryTarget[] = [])
34
96
  const portals = new Map<string, string>();
35
97
  const policies = new Map<string, Lifecycle | null>();
36
98
  const used = new Set<string>();
37
- const reads = canvasState.listBoards().map((board) => ({ board, read: canvasState.readBoard(board.id, false) }));
99
+ const boards = canvasState.listBoards();
100
+ const closure = only ? dependencyClosure(only) : null;
101
+ const reads = boards
102
+ .filter((board) => !closure || closure.reads.has(board.id))
103
+ .map((board) => ({
104
+ board,
105
+ read: closure ? closure.reads.get(board.id) : canvasState.readBoard(board.id, false),
106
+ }));
107
+ const targets = reads
108
+ .flatMap(({ board, read }) => (read?.layout.nodes ?? []).map((node) => ({ boardId: board.id, cardId: node.id })))
109
+ .filter((target) => !closure || closure.selected.has(memoryKey(target)));
110
+ const summaries = canvasState.databasePath ? canvasState.knowledge.reviewSummaries(targets) : null;
38
111
  const catalog = canvasState.databasePath
39
112
  ? canvasState.knowledge.list()
40
113
  : { categories: [], folders: [], relationPresets: [] };
41
114
  const session =
42
115
  agentPresence.reviewSessionStartedAt() ??
43
116
  Date.parse(
44
- reads.find(({ board }) => board.id === canvasState.activeBoardId)?.board.lastOpenedAt ??
117
+ boards.find((board) => board.id === canvasState.activeBoardId)?.lastOpenedAt ??
45
118
  new Date(processStartedAt).toISOString(),
46
119
  );
47
120
  for (const { board, read } of reads) {
48
121
  const seen = new Map(canvasState.getNodeReadStatus(board.id).map((status) => [status.nodeId, status]));
49
122
  for (const node of read?.layout.nodes ?? []) {
50
123
  const key = memoryKey({ boardId: board.id, cardId: node.id });
124
+ if (closure && !closure.selected.has(key)) continue;
51
125
  cards.set(key, node);
52
126
  const { lifecycle } = canvasState.knowledge.resolveCard(board.folder, node, catalog);
53
127
  policies.set(key, lifecycle);
54
- const { lastValidation, retired } = canvasState.databasePath
55
- ? canvasState.knowledge.reviews(board.id, node.id)
56
- : { lastValidation: null, retired: false };
128
+ const { lastValidation, retired } = summaries?.get(key) ?? { lastValidation: null, retired: false };
57
129
  validations.set(key, lastValidation);
58
130
  const lastRead = seen.get(node.id);
59
131
  const pinned = read!.state.contextPins.includes(node.id);
@@ -218,7 +290,7 @@ export function lifecycleSnapshot(now = Date.now(), atUse: MemoryTarget[] = [])
218
290
  for (const { board, read } of reads) {
219
291
  for (const node of read?.layout.nodes ?? []) {
220
292
  const status = result[memoryKey({ boardId: board.id, cardId: node.id })];
221
- if (status.retired || !(status.due || status.sources.length)) continue;
293
+ if (!status || status.retired || !(status.due || status.sources.length)) continue;
222
294
  reviewCards.push({
223
295
  boardId: board.id,
224
296
  cardId: node.id,
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { executeOperation, listOperations } from './registry.js';
10
10
  import { SOURCE_LABEL_RE } from '../agent-presence.js';
11
- import { OperationError } from './types.js';
11
+ import { OperationError, type OperationExecutionOptions, type OperationProgress } from './types.js';
12
12
  import { isWorkbenchToken, refuseStaleWorkbenchWrite } from '../workbench-auth.js';
13
13
 
14
14
  function responseJson(data: unknown, status = 200): Response {
@@ -20,6 +20,57 @@ function responseJson(data: unknown, status = 200): Response {
20
20
  });
21
21
  }
22
22
 
23
+ /** Opt-in NDJSON; the terminal frame retains the ordinary HTTP status/body. */
24
+ export type OperationStreamFrame =
25
+ | { type: 'progress'; progress: OperationProgress }
26
+ | { type: 'result'; status: number; body: unknown };
27
+
28
+ function operationFailure(name: string, error: unknown, signal: AbortSignal): Response {
29
+ if (signal.aborted) return responseJson({ ok: false, cancelled: true, error: 'Operation cancelled.' }, 499);
30
+ if (error instanceof OperationError) {
31
+ return responseJson({ ok: false, error: error.message, ...(error.details ?? {}) }, error.status);
32
+ }
33
+ // Keep unexpected errors server-side, never leak a Bun development overlay.
34
+ console.error(`[operation] unhandled error dispatching ${name}:`, error);
35
+ return responseJson({ ok: false, error: 'Internal error processing the request.' }, 500);
36
+ }
37
+
38
+ function progressResponse(
39
+ req: Request,
40
+ name: string,
41
+ run: (options: OperationExecutionOptions) => Promise<Response>,
42
+ ): Response {
43
+ const cancelled = new AbortController();
44
+ const signal = AbortSignal.any([req.signal, cancelled.signal]);
45
+ const encoder = new TextEncoder();
46
+ let closed = false;
47
+ const stream = new ReadableStream<Uint8Array>({
48
+ async start(controller) {
49
+ const send = (frame: OperationStreamFrame): void => {
50
+ if (!closed) controller.enqueue(encoder.encode(`${JSON.stringify(frame)}\n`));
51
+ };
52
+ let response: Response;
53
+ try {
54
+ response = await run({ signal, onProgress: (progress) => send({ type: 'progress', progress }) });
55
+ } catch (error) {
56
+ response = operationFailure(name, error, signal);
57
+ }
58
+ if (!closed) {
59
+ send({ type: 'result', status: response.status, body: await response.json() });
60
+ closed = true;
61
+ controller.close();
62
+ }
63
+ },
64
+ cancel() {
65
+ closed = true;
66
+ cancelled.abort();
67
+ },
68
+ });
69
+ return new Response(stream, {
70
+ headers: { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' },
71
+ });
72
+ }
73
+
23
74
  export function matchOperationPath(template: string, pathname: string): Record<string, string> | null {
24
75
  const templateSegments = template.split('/');
25
76
  const pathSegments = pathname.split('/');
@@ -83,6 +134,7 @@ export async function dispatchOperationRoute(req: Request, url: URL): Promise<Re
83
134
  const params = matchOperationPath(route.path, url.pathname);
84
135
  if (!params) continue;
85
136
  try {
137
+ req.signal.throwIfAborted();
86
138
  const input = route.readInput
87
139
  ? await route.readInput(req, params, url)
88
140
  : await readOperationInput(req, params, url);
@@ -100,27 +152,23 @@ export async function dispatchOperationRoute(req: Request, url: URL): Promise<Re
100
152
  // HTTP callers read as 'api'.
101
153
  const sourceHeader = req.headers.get('x-pmx-source');
102
154
  const source = sourceHeader && SOURCE_LABEL_RE.test(sourceHeader) ? sourceHeader : 'api';
103
- const result = await executeOperation(op.name, input, {
104
- // The legacy marker remains a UI-policy signal (ghost suppression).
105
- // Only the unguessable boot token classifies authorship as human.
106
- suppressAutoGhost: workbenchMarked,
107
- fromWorkbench: workbenchMarked,
108
- humanAuthor,
109
- source,
110
- proxiedRead: req.headers.get('x-pmx-proxied-read') === '1',
111
- });
112
- return responseJson(result, route.status ? route.status(result) : 200);
155
+ const run = async (options: OperationExecutionOptions): Promise<Response> => {
156
+ const result = await executeOperation(op.name, input, {
157
+ ...options,
158
+ // The legacy marker remains a UI-policy signal (ghost suppression).
159
+ // Only the unguessable boot token classifies authorship as human.
160
+ suppressAutoGhost: workbenchMarked,
161
+ fromWorkbench: workbenchMarked,
162
+ humanAuthor,
163
+ source,
164
+ proxiedRead: req.headers.get('x-pmx-proxied-read') === '1',
165
+ });
166
+ return responseJson(result, route.status ? route.status(result) : 200);
167
+ };
168
+ if (req.headers.get('accept') === 'application/x-ndjson') return progressResponse(req, op.name, run);
169
+ return await run({ signal: req.signal });
113
170
  } catch (error) {
114
- if (error instanceof OperationError) {
115
- return responseJson({ ok: false, error: error.message, ...(error.details ?? {}) }, error.status);
116
- }
117
- // An unexpected (non-OperationError) throw from a handler MUST NOT escape the
118
- // dispatcher: Bun.serve has no per-request boundary, so an escaped throw renders
119
- // its dev error overlay (HTTP 500 text/html leaking the absolute server source
120
- // path + stack). Return a clean JSON 500 with a generic message instead (the
121
- // real error is logged server-side, never echoed to the client).
122
- console.error(`[operation] unhandled error dispatching ${op.name}:`, error);
123
- return responseJson({ ok: false, error: 'Internal error processing the request.' }, 500);
171
+ return operationFailure(op.name, error, req.signal);
124
172
  }
125
173
  }
126
174
  return null;
@@ -77,7 +77,12 @@ export { runCanvasBatchOperation, type BatchEnvelope } from './ops/batch.js';
77
77
  // OpenMcpAppCoreResult is the SDK's cast target for executeOperation('mcpapp.open').
78
78
  export type { OpenMcpAppCoreResult } from './ops/app.js';
79
79
  export { LocalOperationInvoker, HttpOperationInvoker, type OperationInvoker } from './invoker.js';
80
- export { registerOperationTools, registerCompositeTools, type OperationToolHost } from './mcp.js';
80
+ export {
81
+ registerOperationTools,
82
+ registerCompositeTools,
83
+ registerOperationHelpTool,
84
+ type OperationToolHost,
85
+ } from './mcp.js';
81
86
  export { compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
82
87
  export {
83
88
  OperationError,
@@ -85,5 +90,7 @@ export {
85
90
  type Operation,
86
91
  type OperationContext,
87
92
  type OperationDefinition,
93
+ type OperationExecutionOptions,
88
94
  type OperationErrorStatus,
95
+ type OperationProgress,
89
96
  } from './types.js';
@@ -5,10 +5,11 @@
5
5
  */
6
6
  import { SOURCE_LABEL_RE } from '../agent-presence.js';
7
7
  import { executeOperation, getOperation } from './registry.js';
8
- import { OperationError, type OperationErrorStatus } from './types.js';
8
+ import { OperationError, type OperationErrorStatus, type OperationExecutionOptions } from './types.js';
9
+ import type { OperationStreamFrame } from './http.js';
9
10
 
10
11
  export interface OperationInvoker {
11
- invoke(name: string, input: Record<string, unknown>): Promise<unknown>;
12
+ invoke(name: string, input: Record<string, unknown>, options?: OperationExecutionOptions): Promise<unknown>;
12
13
  }
13
14
 
14
15
  /**
@@ -40,9 +41,14 @@ export class LocalOperationInvoker implements OperationInvoker {
40
41
  this.proxiedRead = source === 'mcp';
41
42
  }
42
43
 
43
- async invoke(name: string, input: Record<string, unknown>): Promise<unknown> {
44
+ async invoke(
45
+ name: string,
46
+ input: Record<string, unknown>,
47
+ options: OperationExecutionOptions = {},
48
+ ): Promise<unknown> {
44
49
  const args = this.allowExternalMcp ? input : { ...input, allowExternalMcp: false };
45
50
  return await executeOperation(name, defaultPresenceSource(name, args, this.source), {
51
+ ...options,
46
52
  source: this.source,
47
53
  proxiedRead: this.proxiedRead,
48
54
  });
@@ -53,6 +59,37 @@ function toOperationErrorStatus(status: number): OperationErrorStatus {
53
59
  return status === 404 ? 404 : status === 409 ? 409 : status === 403 ? 403 : 400;
54
60
  }
55
61
 
62
+ async function readProgressResponse(response: Response, options: OperationExecutionOptions): Promise<Response> {
63
+ if (!response.body) throw new OperationError('Missing operation progress stream.');
64
+ const reader = response.body.getReader();
65
+ const decoder = new TextDecoder();
66
+ let buffer = '';
67
+ try {
68
+ while (true) {
69
+ const { done, value } = await reader.read();
70
+ buffer += decoder.decode(value, { stream: !done });
71
+ let end = buffer.indexOf('\n');
72
+ while (end !== -1) {
73
+ const frame = JSON.parse(buffer.slice(0, end)) as OperationStreamFrame;
74
+ buffer = buffer.slice(end + 1);
75
+ if (frame.type === 'result') return Response.json(frame.body, { status: frame.status });
76
+ if (frame.type !== 'progress') throw new OperationError('Invalid operation progress frame.');
77
+ try {
78
+ await options.onProgress?.(frame.progress);
79
+ } catch (error) {
80
+ if (!options.signal?.aborted) console.error('[operation] progress callback failed:', error);
81
+ }
82
+ end = buffer.indexOf('\n');
83
+ }
84
+ if (done) throw new OperationError('Operation progress stream ended without a result.');
85
+ }
86
+ } finally {
87
+ // A fetch abort already errors the reader; preserve that original failure.
88
+ await reader.cancel().catch(() => {});
89
+ reader.releaseLock();
90
+ }
91
+ }
92
+
56
93
  /** Builds the HTTP request from the op's route template (`:id` from input, GET flags to query). */
57
94
  export class HttpOperationInvoker implements OperationInvoker {
58
95
  private readonly baseUrl: string;
@@ -70,7 +107,12 @@ export class HttpOperationInvoker implements OperationInvoker {
70
107
  this.proxiedRead = source === 'mcp';
71
108
  }
72
109
 
73
- async invoke(name: string, rawInput: Record<string, unknown>): Promise<unknown> {
110
+ async invoke(
111
+ name: string,
112
+ rawInput: Record<string, unknown>,
113
+ options: OperationExecutionOptions = {},
114
+ ): Promise<unknown> {
115
+ options.signal?.throwIfAborted();
74
116
  const args = this.allowExternalMcp ? rawInput : { ...rawInput, allowExternalMcp: false };
75
117
  const input = defaultPresenceSource(name, args, this.source);
76
118
  const op = getOperation(name);
@@ -96,8 +138,10 @@ export class HttpOperationInvoker implements OperationInvoker {
96
138
 
97
139
  let url = `${this.baseUrl}${path}`;
98
140
  const proxyHeaders: Record<string, string> = this.proxiedRead ? { 'x-pmx-proxied-read': '1' } : {};
141
+ if (options.onProgress) proxyHeaders.accept = 'application/x-ndjson';
99
142
  const init: RequestInit = {
100
143
  method: route.method,
144
+ signal: options.signal,
101
145
  headers: { 'x-pmx-source': this.source, ...proxyHeaders },
102
146
  };
103
147
  if (route.method === 'GET' || route.method === 'DELETE') {
@@ -112,7 +156,10 @@ export class HttpOperationInvoker implements OperationInvoker {
112
156
  init.body = JSON.stringify(rest);
113
157
  }
114
158
 
115
- const response = await fetch(url, init);
159
+ let response = await fetch(url, init);
160
+ if (response.headers.get('content-type')?.startsWith('application/x-ndjson')) {
161
+ response = await readProgressResponse(response, options);
162
+ }
116
163
  const text = await response.text();
117
164
  let parsed: unknown = null;
118
165
  let parsedAsJson = false;
@@ -125,6 +172,7 @@ export class HttpOperationInvoker implements OperationInvoker {
125
172
  }
126
173
  }
127
174
  if (!response.ok) {
175
+ if (response.status === 499) throw new DOMException('Operation cancelled.', 'AbortError');
128
176
  // errorBodyAsResult carries the route's STRUCTURED partial-failure
129
177
  // envelope (e.g. canvas_batch { ok:false, failedIndex, ... }) through as
130
178
  // a result. A non-JSON error body (an HTML 502 from a proxy, a foreign