pmx-canvas 0.6.5 → 0.7.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.
- package/.github/extensions/pmx-canvas/extension.mjs +67 -3
- package/CHANGELOG.md +54 -0
- package/Readme.md +29 -5
- package/dist/canvas/global.css +440 -24
- package/dist/canvas/index.js +90 -75
- package/dist/types/cli/agent.d.ts +3 -0
- package/dist/types/cli/commands/backup.d.ts +1 -0
- package/dist/types/cli/commands/boards.d.ts +1 -0
- package/dist/types/cli/commands/export.d.ts +1 -0
- package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
- package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
- package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
- package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
- package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
- package/dist/types/client/canvas/HomeView.d.ts +9 -0
- package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
- package/dist/types/client/nodes/BoardNode.d.ts +5 -0
- package/dist/types/client/state/boards-store.d.ts +79 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -0
- package/dist/types/client/state/session-store.d.ts +7 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/json-render/charts/definitions.d.ts +1 -1
- package/dist/types/json-render/server.d.ts +6 -0
- package/dist/types/mcp/canvas-access.d.ts +10 -3
- package/dist/types/mcp/context-uri-template.d.ts +6 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/attribution.d.ts +11 -0
- package/dist/types/server/ax-context.d.ts +3 -1
- package/dist/types/server/ax-interaction.d.ts +2 -2
- package/dist/types/server/ax-state.d.ts +13 -3
- package/dist/types/server/backup.d.ts +36 -0
- package/dist/types/server/board-export.d.ts +45 -0
- package/dist/types/server/canvas-db.d.ts +63 -9
- package/dist/types/server/canvas-operations.d.ts +23 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +140 -3
- package/dist/types/server/context-brief.d.ts +111 -0
- package/dist/types/server/context-reads.d.ts +55 -0
- package/dist/types/server/document-import.d.ts +66 -0
- package/dist/types/server/index.d.ts +103 -4
- package/dist/types/server/mutation-history.d.ts +1 -0
- package/dist/types/server/operations/invoker.d.ts +2 -0
- package/dist/types/server/operations/mcp.d.ts +7 -1
- package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
- package/dist/types/server/operations/ops/backup.d.ts +2 -0
- package/dist/types/server/operations/ops/boards.d.ts +10 -0
- package/dist/types/server/operations/ops/export.d.ts +2 -0
- package/dist/types/server/operations/ops/imports.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +9 -1
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/trace-manager.d.ts +7 -0
- package/dist/types/server/webpage-node.d.ts +22 -1
- package/dist/types/server/workbench-auth.d.ts +3 -0
- package/dist/types/shared/boards.d.ts +6 -0
- package/docs/RELEASE.md +5 -0
- package/docs/cli.md +68 -0
- package/docs/http-api.md +198 -2
- package/docs/mcp.md +71 -6
- package/docs/node-types.md +26 -0
- package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
- package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
- package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
- package/docs/plans/plan-013-static-board-export.md +47 -0
- package/docs/plans/plan-014-connected-board-memory.md +140 -0
- package/docs/product-vision-2026-09.md +118 -18
- package/docs/sdk.md +50 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +53 -4
- package/skills/pmx-canvas/references/full-reference.md +43 -7
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
- package/skills/pmx-canvas-testing/SKILL.md +4 -0
- package/src/cli/agent.ts +4 -0
- package/src/cli/commands/ax.ts +13 -0
- package/src/cli/commands/backup.ts +66 -0
- package/src/cli/commands/boards.ts +71 -0
- package/src/cli/commands/export.ts +30 -0
- package/src/cli/commands/nodes.ts +4 -1
- package/src/cli/commands/pins.ts +6 -2
- package/src/cli/commands/pump.ts +3 -0
- package/src/cli/commands/query.ts +33 -7
- package/src/cli/commands/view.ts +7 -5
- package/src/cli/index.ts +19 -9
- package/src/client/App.tsx +21 -3
- package/src/client/canvas/BoardCopyDialog.tsx +153 -0
- package/src/client/canvas/BoardFolderDialog.tsx +120 -0
- package/src/client/canvas/BoardLinkDialog.tsx +93 -0
- package/src/client/canvas/BoardSwitcher.tsx +149 -0
- package/src/client/canvas/CanvasViewport.tsx +3 -0
- package/src/client/canvas/CommandPalette.tsx +107 -4
- package/src/client/canvas/ContextMenu.tsx +4 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
- package/src/client/canvas/ExportDialog.tsx +197 -0
- package/src/client/canvas/HomeView.tsx +277 -0
- package/src/client/canvas/SessionPanel.tsx +28 -1
- package/src/client/canvas/ToolRail.tsx +38 -3
- package/src/client/canvas/TopBar.tsx +34 -18
- package/src/client/canvas/import-files.ts +19 -1
- package/src/client/canvas/kind-colors.ts +1 -0
- package/src/client/icons.tsx +2 -0
- package/src/client/nodes/AttachmentNode.tsx +148 -0
- package/src/client/nodes/BoardNode.tsx +35 -0
- package/src/client/nodes/FileNode.tsx +6 -0
- package/src/client/nodes/MarkdownNode.tsx +47 -19
- package/src/client/nodes/ax-node-actions.ts +2 -5
- package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
- package/src/client/state/boards-store.ts +167 -0
- package/src/client/state/canvas-store.ts +22 -0
- package/src/client/state/intent-bridge.ts +30 -2
- package/src/client/state/node-factory.ts +1 -0
- package/src/client/state/session-store.ts +5 -0
- package/src/client/state/sse-bridge.ts +49 -5
- package/src/client/theme/global.css +440 -24
- package/src/client/types.ts +2 -0
- package/src/json-render/server.ts +16 -9
- package/src/mcp/canvas-access.ts +59 -22
- package/src/mcp/context-uri-template.ts +28 -0
- package/src/mcp/server.ts +122 -15
- package/src/server/agent-context.ts +116 -29
- package/src/server/attribution.ts +29 -0
- package/src/server/ax-context.ts +4 -2
- package/src/server/ax-interaction.ts +1 -0
- package/src/server/ax-state-manager.ts +72 -3
- package/src/server/ax-state.ts +41 -0
- package/src/server/backup.ts +178 -0
- package/src/server/board-export.ts +686 -0
- package/src/server/canvas-db.ts +599 -107
- package/src/server/canvas-operations.ts +68 -9
- package/src/server/canvas-provenance.ts +1 -0
- package/src/server/canvas-schema.ts +12 -0
- package/src/server/canvas-state.ts +774 -45
- package/src/server/context-brief.ts +372 -0
- package/src/server/context-reads.ts +238 -0
- package/src/server/demo-state.json +659 -2
- package/src/server/document-import.ts +223 -0
- package/src/server/index.ts +231 -5
- package/src/server/mutation-history.ts +10 -13
- package/src/server/operations/composites.ts +26 -4
- package/src/server/operations/http.ts +9 -3
- package/src/server/operations/index.ts +8 -0
- package/src/server/operations/invoker.ts +14 -3
- package/src/server/operations/mcp.ts +50 -11
- package/src/server/operations/ops/app.ts +5 -0
- package/src/server/operations/ops/ax-read.ts +10 -6
- package/src/server/operations/ops/ax-timeline.ts +75 -0
- package/src/server/operations/ops/backup.ts +134 -0
- package/src/server/operations/ops/batch.ts +8 -1
- package/src/server/operations/ops/boards.ts +351 -0
- package/src/server/operations/ops/export.ts +74 -0
- package/src/server/operations/ops/ext-app.ts +4 -0
- package/src/server/operations/ops/imports.ts +220 -0
- package/src/server/operations/ops/nodes.ts +57 -5
- package/src/server/operations/ops/query.ts +206 -9
- package/src/server/operations/registry.ts +123 -31
- package/src/server/operations/types.ts +1 -1
- package/src/server/server.ts +146 -2
- package/src/server/spatial-analysis.ts +9 -17
- package/src/server/trace-manager.ts +19 -0
- package/src/server/web-artifacts.ts +2 -1
- package/src/server/webpage-node.ts +173 -13
- package/src/server/workbench-auth.ts +11 -0
- package/src/shared/boards.ts +18 -0
- package/src/shared/node-sizes.ts +1 -0
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
} from '../../canvas-serialization.js';
|
|
53
53
|
import { WEBPAGE_NODE_DEFAULT_SIZE, normalizeWebpageUrl } from '../../webpage-node.js';
|
|
54
54
|
import { defineOperation, OperationError, type Operation, type OperationContext } from '../types.js';
|
|
55
|
+
import { readTargetBoard } from './boards.js';
|
|
55
56
|
|
|
56
57
|
// ── Node types ────────────────────────────────────────────────
|
|
57
58
|
// Single definition site for the basic node-type list: drives the HTTP type
|
|
@@ -59,6 +60,7 @@ import { defineOperation, OperationError, type Operation, type OperationContext
|
|
|
59
60
|
// and the MCP type enum.
|
|
60
61
|
export const NODE_TYPES = [
|
|
61
62
|
'markdown',
|
|
63
|
+
'board',
|
|
62
64
|
'status',
|
|
63
65
|
'context',
|
|
64
66
|
'ledger',
|
|
@@ -91,6 +93,8 @@ export function defaultNodeSize(type: string): { width: number; height: number }
|
|
|
91
93
|
return { width: 640, height: 460 };
|
|
92
94
|
case 'markdown':
|
|
93
95
|
return MARKDOWN_NODE_DEFAULT_SIZE;
|
|
96
|
+
case 'board':
|
|
97
|
+
return { width: 360, height: 160 };
|
|
94
98
|
case 'mcp-app':
|
|
95
99
|
return MCP_APP_NODE_DEFAULT_SIZE;
|
|
96
100
|
case 'image':
|
|
@@ -400,6 +404,12 @@ export function createBasicCanvasNode(
|
|
|
400
404
|
): { node: CanvasNodeState; needsCodeGraphRecompute: boolean; sizeAdjustment?: CanvasSizeAdjustment } {
|
|
401
405
|
const type = typeof body.type === 'string' ? body.type : '';
|
|
402
406
|
const extraData = isRecord(body.data) ? body.data : undefined;
|
|
407
|
+
if (
|
|
408
|
+
extraData &&
|
|
409
|
+
('attachmentId' in extraData || (isRecord(extraData.source) && 'attachmentId' in extraData.source))
|
|
410
|
+
) {
|
|
411
|
+
throw new OperationError('Attachment identity and import provenance are server-managed.');
|
|
412
|
+
}
|
|
403
413
|
if (type === 'html') {
|
|
404
414
|
if ('html' in body && typeof body.html !== 'string') {
|
|
405
415
|
throw new OperationError('HTML node field "html" must be a string.');
|
|
@@ -500,6 +510,25 @@ export function buildNodePatch(
|
|
|
500
510
|
existing: CanvasNodeState,
|
|
501
511
|
body: Record<string, unknown>,
|
|
502
512
|
): { patch: Partial<CanvasNodeState>; groupChildIds?: string[] } {
|
|
513
|
+
if (body.type !== undefined && body.type !== existing.type) throw new OperationError('Node type is immutable.');
|
|
514
|
+
if (
|
|
515
|
+
existing.type === 'file' &&
|
|
516
|
+
typeof existing.data.attachmentId === 'string' &&
|
|
517
|
+
(body.path !== undefined || body.content !== undefined)
|
|
518
|
+
) {
|
|
519
|
+
throw new OperationError('Attached file nodes cannot be repointed or changed into disk-backed files.');
|
|
520
|
+
}
|
|
521
|
+
if (body.data && typeof body.data === 'object' && !Array.isArray(body.data)) {
|
|
522
|
+
const data = body.data as Record<string, unknown>;
|
|
523
|
+
const source = data.source;
|
|
524
|
+
const importedSource = source && typeof source === 'object' && 'attachmentId' in source;
|
|
525
|
+
const attachedFileContent =
|
|
526
|
+
existing.type === 'file' &&
|
|
527
|
+
typeof existing.data.attachmentId === 'string' &&
|
|
528
|
+
('path' in data || 'content' in data || 'fileContent' in data);
|
|
529
|
+
if ('attachmentId' in data || importedSource || attachedFileContent)
|
|
530
|
+
throw new OperationError('Attachment identity and import provenance are immutable.');
|
|
531
|
+
}
|
|
503
532
|
// Same dead-field traps as create (gauntlet, 2026-08-27): reject loudly
|
|
504
533
|
// instead of dropping — and note that patching `content` on a file node
|
|
505
534
|
// updates the FILE CONTENT snapshot, it does not re-point the node.
|
|
@@ -657,7 +686,11 @@ async function createWebpageNode(body: Record<string, unknown>, ctx: OperationCo
|
|
|
657
686
|
// The node should appear before the (slow) page fetch completes; the
|
|
658
687
|
// registry emits the final layout update after the handler returns.
|
|
659
688
|
ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
689
|
+
const boardGeneration = canvasState.boardGeneration;
|
|
660
690
|
const refreshed = await refreshCanvasWebpageNode(id);
|
|
691
|
+
if (canvasState.boardGeneration !== boardGeneration) {
|
|
692
|
+
throw new OperationError('The board changed while the webpage was loading.', 409);
|
|
693
|
+
}
|
|
661
694
|
const created = canvasState.getNode(id) ?? node;
|
|
662
695
|
return {
|
|
663
696
|
node: created,
|
|
@@ -974,6 +1007,7 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
|
|
|
974
1007
|
|
|
975
1008
|
const nodeGetShape = {
|
|
976
1009
|
id: z.string().describe('The node ID to retrieve'),
|
|
1010
|
+
board: z.unknown().optional().describe('Board id to read without opening it.'),
|
|
977
1011
|
includeBlobs: z.unknown().optional().describe('Include full blob payloads instead of blob summaries.'),
|
|
978
1012
|
};
|
|
979
1013
|
|
|
@@ -993,18 +1027,25 @@ const nodeGetOperation = defineOperation<z.infer<typeof nodeGetSchema>, Serializ
|
|
|
993
1027
|
description:
|
|
994
1028
|
'Get a single node by ID. Defaults to compact metadata; pass full:true to include full data/tool results.',
|
|
995
1029
|
extraShape: {
|
|
1030
|
+
board: z.string().optional().describe('Board id to read without opening it.'),
|
|
996
1031
|
full: z.boolean().optional().describe('Include full node data, including mcp-app tool results. Default false.'),
|
|
997
1032
|
verbose: z.boolean().optional().describe('Alias for full:true.'),
|
|
998
1033
|
},
|
|
999
|
-
buildInput: (input) => ({ id: input.id, includeBlobs: true }),
|
|
1034
|
+
buildInput: (input) => ({ id: input.id, board: input.board, includeBlobs: true }),
|
|
1000
1035
|
formatResult: (result, input) => {
|
|
1001
1036
|
const node = result as CanvasNodeState;
|
|
1002
1037
|
const payload = wantsFullPayload(input) ? serializeCanvasNodeForAgent(node) : compactNodePayload(node);
|
|
1003
1038
|
return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
|
|
1004
1039
|
},
|
|
1005
1040
|
},
|
|
1006
|
-
handler: ({ id, includeBlobs }) => {
|
|
1041
|
+
handler: ({ id, includeBlobs, board }) => {
|
|
1007
1042
|
const full = includeBlobs === true || includeBlobs === 'true';
|
|
1043
|
+
if (typeof board === 'string' && board.trim()) {
|
|
1044
|
+
const target = readTargetBoard(board, full);
|
|
1045
|
+
const node = target.layout.nodes.find((candidate) => candidate.id === id);
|
|
1046
|
+
if (!node) throw new OperationError(`Node "${id}" not found on board "${board}".`, 404);
|
|
1047
|
+
return full ? serializeCanvasNode(node) : serializeCanvasNodeWithBlobSummaries(node);
|
|
1048
|
+
}
|
|
1008
1049
|
const node = full ? canvasState.getNode(id) : canvasState.getNodeForPersistence(id);
|
|
1009
1050
|
if (!node) throw new OperationError(`Node "${id}" not found.`, 404);
|
|
1010
1051
|
const responseNode = withContextPinReadState(node);
|
|
@@ -1297,6 +1338,7 @@ const nodeRemoveOperation = defineOperation<z.infer<typeof nodeRemoveSchema>, Re
|
|
|
1297
1338
|
// ── layout.get ────────────────────────────────────────────────
|
|
1298
1339
|
|
|
1299
1340
|
const layoutGetShape = {
|
|
1341
|
+
board: z.unknown().optional().describe('Board id to read without opening it.'),
|
|
1300
1342
|
includeBlobs: z.unknown().optional().describe('Include full blob payloads instead of blob summaries.'),
|
|
1301
1343
|
};
|
|
1302
1344
|
|
|
@@ -1316,23 +1358,33 @@ const layoutGetOperation = defineOperation<z.infer<typeof layoutGetSchema>, Reco
|
|
|
1316
1358
|
description:
|
|
1317
1359
|
'Get the canvas layout. Defaults to a compact agent-safe projection; pass full:true for full node data.',
|
|
1318
1360
|
extraShape: {
|
|
1361
|
+
board: z.string().optional().describe('Board id to read without opening it.'),
|
|
1319
1362
|
full: z
|
|
1320
1363
|
.boolean()
|
|
1321
1364
|
.optional()
|
|
1322
1365
|
.describe('Return the full layout including node data. Default false keeps responses compact.'),
|
|
1323
1366
|
verbose: z.boolean().optional().describe('Alias for full:true.'),
|
|
1324
1367
|
},
|
|
1325
|
-
buildInput: () => ({ includeBlobs: true }),
|
|
1368
|
+
buildInput: (input) => ({ board: input.board, includeBlobs: true }),
|
|
1326
1369
|
formatResult: async (result, input, host) => {
|
|
1327
1370
|
const layout = result as CanvasLayout;
|
|
1328
1371
|
const payload = wantsFullPayload(input)
|
|
1329
1372
|
? agentSafeFullLayoutPayload(layout)
|
|
1330
|
-
: compactLayoutPayload(
|
|
1373
|
+
: compactLayoutPayload(
|
|
1374
|
+
layout,
|
|
1375
|
+
await host.getPinnedNodeIds(typeof input.board === 'string' ? input.board : undefined),
|
|
1376
|
+
);
|
|
1331
1377
|
return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
|
|
1332
1378
|
},
|
|
1333
1379
|
},
|
|
1334
|
-
handler: ({ includeBlobs }) => {
|
|
1380
|
+
handler: ({ includeBlobs, board }) => {
|
|
1335
1381
|
const full = includeBlobs === true || includeBlobs === 'true';
|
|
1382
|
+
if (typeof board === 'string' && board.trim()) {
|
|
1383
|
+
const target = readTargetBoard(board, full);
|
|
1384
|
+
return (full
|
|
1385
|
+
? serializeCanvasLayout(target.layout)
|
|
1386
|
+
: serializeCanvasLayoutWithBlobSummaries(target.layout)) as unknown as Record<string, unknown>;
|
|
1387
|
+
}
|
|
1336
1388
|
return (full
|
|
1337
1389
|
? serializeCanvasLayout(canvasState.getLayout())
|
|
1338
1390
|
: serializeCanvasLayoutWithBlobSummaries(
|
|
@@ -23,13 +23,22 @@ import { setCanvasContextPins, syncCanvasRuntimeBackends } from '../../canvas-op
|
|
|
23
23
|
import { buildCanvasSummary } from '../../canvas-serialization.js';
|
|
24
24
|
import { mutationHistory } from '../../mutation-history.js';
|
|
25
25
|
import { buildSpatialContext, searchNodes } from '../../spatial-analysis.js';
|
|
26
|
-
import {
|
|
27
|
-
|
|
26
|
+
import {
|
|
27
|
+
compileContextBrief,
|
|
28
|
+
type ContextBriefLibraryBoard,
|
|
29
|
+
type ContextBriefSourceEntry,
|
|
30
|
+
} from '../../context-brief.js';
|
|
31
|
+
import { summarizeNodeForAgentContext } from '../../agent-context.js';
|
|
32
|
+
import { defineOperation, OperationError, type Operation, type OperationMcpToolHost } from '../types.js';
|
|
33
|
+
import { buildSummaryFromLayout, isRecord } from './nodes.js';
|
|
34
|
+
import { readTargetBoard } from './boards.js';
|
|
28
35
|
|
|
29
36
|
// ── pin.set ───────────────────────────────────────────────────
|
|
30
37
|
|
|
31
38
|
/** Legacy server.ts handleContextPinsUpdate capped the requested list at 20. */
|
|
32
39
|
const MAX_PINS = 20;
|
|
40
|
+
export const DEFAULT_CONTEXT_BRIEF_BUDGET = 16_000;
|
|
41
|
+
export const MAX_CONTEXT_BRIEF_BUDGET = 100_000;
|
|
33
42
|
|
|
34
43
|
const pinShape = {
|
|
35
44
|
nodeIds: z.unknown().optional().describe('Array of node IDs to pin'),
|
|
@@ -92,6 +101,7 @@ const pinOperation = defineOperation<z.infer<typeof pinSchema>, Record<string, u
|
|
|
92
101
|
|
|
93
102
|
const searchShape = {
|
|
94
103
|
q: z.unknown().optional().describe('Search query — matches against node titles, content, and file paths'),
|
|
104
|
+
scope: z.unknown().optional().describe('active (default) or library (all boards)'),
|
|
95
105
|
limit: z.unknown().optional().describe('Max results to return (default: all over HTTP, 10 via the MCP tool).'),
|
|
96
106
|
};
|
|
97
107
|
|
|
@@ -113,12 +123,14 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
|
|
|
113
123
|
extraShape: {
|
|
114
124
|
query: z.string().describe('Search query — matches against node titles, content, and file paths'),
|
|
115
125
|
limit: z.number().optional().describe('Max results to return (default: 10)'),
|
|
126
|
+
scope: z.enum(['active', 'library']).optional().describe('Search the active board or the full board library'),
|
|
116
127
|
},
|
|
117
128
|
// Map the MCP-facing `query` arg onto the wire's `q`. The handler caps by
|
|
118
129
|
// `limit` on every transport; the MCP tool additionally defaults it to 10.
|
|
119
130
|
buildInput: (input) => ({
|
|
120
131
|
q: typeof input.query === 'string' ? input.query : '',
|
|
121
132
|
...(typeof input.limit === 'number' ? { limit: input.limit } : {}),
|
|
133
|
+
...(input.scope === 'library' ? { scope: 'library' } : {}),
|
|
122
134
|
}),
|
|
123
135
|
formatResult: (result, input) => {
|
|
124
136
|
const body = isRecord(result) ? result : {};
|
|
@@ -154,7 +166,40 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
|
|
|
154
166
|
: typeof rawLimit === 'string' && rawLimit.trim() !== ''
|
|
155
167
|
? Number(rawLimit)
|
|
156
168
|
: Number.NaN;
|
|
157
|
-
const
|
|
169
|
+
const library = input.scope === 'library';
|
|
170
|
+
const results = library
|
|
171
|
+
? canvasState.listBoards().flatMap((board) => {
|
|
172
|
+
const read = canvasState.readBoard(board.id, true);
|
|
173
|
+
if (!read) return [];
|
|
174
|
+
const cards = searchNodes(read.layout.nodes, q).map((result) => ({
|
|
175
|
+
...result,
|
|
176
|
+
boardId: board.id,
|
|
177
|
+
boardTitle: board.name,
|
|
178
|
+
cardId: result.id,
|
|
179
|
+
}));
|
|
180
|
+
const metadata = `${board.name} ${board.category ?? ''}`.toLocaleLowerCase();
|
|
181
|
+
const readme = read.layout.nodes.find((node) => node.id === board.readmeNodeId);
|
|
182
|
+
const readmeText = typeof readme?.data.content === 'string' ? readme.data.content : '';
|
|
183
|
+
if (`${metadata} ${readmeText.toLocaleLowerCase()}`.includes(q.toLocaleLowerCase())) {
|
|
184
|
+
cards.unshift({
|
|
185
|
+
id: board.id,
|
|
186
|
+
type: 'board',
|
|
187
|
+
title: board.name,
|
|
188
|
+
snippet: readmeText.slice(0, 160) || board.category || board.name,
|
|
189
|
+
score: metadata.includes(q.toLocaleLowerCase()) ? 4 : 2,
|
|
190
|
+
boardId: board.id,
|
|
191
|
+
boardTitle: board.name,
|
|
192
|
+
cardId: readme?.id ?? board.id,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return cards;
|
|
196
|
+
})
|
|
197
|
+
: searchNodes(canvasState.getLayout().nodes, q).map((result) => ({
|
|
198
|
+
...result,
|
|
199
|
+
boardId: canvasState.activeBoardId,
|
|
200
|
+
cardId: result.id,
|
|
201
|
+
}));
|
|
202
|
+
results.sort((a, b) => b.score - a.score);
|
|
158
203
|
return {
|
|
159
204
|
results: Number.isFinite(limit) && limit > 0 ? results.slice(0, Math.floor(limit)) : results,
|
|
160
205
|
query: q,
|
|
@@ -162,6 +207,152 @@ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<str
|
|
|
162
207
|
},
|
|
163
208
|
});
|
|
164
209
|
|
|
210
|
+
// ── context.get ───────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
const contextShape = {
|
|
213
|
+
consumer: z.unknown().optional().describe('Stable consumer id used for the durable board-scoped cursor'),
|
|
214
|
+
since: z.unknown().optional().describe('Explicit content revision; bypasses the durable consumer cursor'),
|
|
215
|
+
budget: z.unknown().optional().describe('Maximum response size in UTF-16 code units'),
|
|
216
|
+
};
|
|
217
|
+
const contextSchema = z.looseObject(contextShape);
|
|
218
|
+
|
|
219
|
+
const contextOperation = defineOperation<z.infer<typeof contextSchema>, Record<string, unknown>>({
|
|
220
|
+
name: 'context.get',
|
|
221
|
+
mutates: false,
|
|
222
|
+
input: contextSchema,
|
|
223
|
+
inputShape: contextShape,
|
|
224
|
+
http: { method: 'GET', path: '/api/canvas/context' },
|
|
225
|
+
handler: (input) => {
|
|
226
|
+
const active = canvasState.getActiveBoard();
|
|
227
|
+
if (!active) throw new OperationError('Open a board before requesting its context.', 409);
|
|
228
|
+
const consumer =
|
|
229
|
+
typeof input.consumer === 'string' && input.consumer.trim() ? input.consumer.trim().slice(0, 200) : null;
|
|
230
|
+
const explicitSince = input.since !== undefined;
|
|
231
|
+
const sinceValue = explicitSince
|
|
232
|
+
? Number(input.since)
|
|
233
|
+
: consumer
|
|
234
|
+
? canvasState.getContextBriefCursor(active.id, consumer)
|
|
235
|
+
: null;
|
|
236
|
+
const budgetValue = input.budget === undefined ? DEFAULT_CONTEXT_BRIEF_BUDGET : Number(input.budget);
|
|
237
|
+
const budget = Number.isFinite(budgetValue)
|
|
238
|
+
? Math.min(MAX_CONTEXT_BRIEF_BUDGET, Math.max(0, Math.floor(budgetValue)))
|
|
239
|
+
: DEFAULT_CONTEXT_BRIEF_BUDGET;
|
|
240
|
+
|
|
241
|
+
// Capture the active target and revision synchronously. Library boards are
|
|
242
|
+
// read from SQLite and are never opened or made writable by this operation.
|
|
243
|
+
const layout = canvasState.getLayout();
|
|
244
|
+
const revision = canvasState.getContentRevision();
|
|
245
|
+
const pinnedNodeIds = [...canvasState.contextPinnedNodeIds];
|
|
246
|
+
const linkedIds = new Map<string, string[]>();
|
|
247
|
+
for (const node of layout.nodes) {
|
|
248
|
+
if (node.type !== 'board' || typeof node.data.boardId !== 'string') continue;
|
|
249
|
+
const ids = linkedIds.get(node.data.boardId) ?? [];
|
|
250
|
+
ids.push(node.id);
|
|
251
|
+
linkedIds.set(node.data.boardId, ids);
|
|
252
|
+
}
|
|
253
|
+
const libraryBoards: ContextBriefLibraryBoard[] = [];
|
|
254
|
+
for (const board of canvasState.listBoards()) {
|
|
255
|
+
if (board.id === active.id) continue;
|
|
256
|
+
if (!linkedIds.has(board.id) && (!active.category || active.category !== board.category)) continue;
|
|
257
|
+
const read = canvasState.readBoard(board.id, true);
|
|
258
|
+
if (!read) continue;
|
|
259
|
+
const readme = read.layout.nodes.find((node) => node.id === board.readmeNodeId);
|
|
260
|
+
const pins = new Set(read.state.contextPins);
|
|
261
|
+
libraryBoards.push({
|
|
262
|
+
boardId: board.id,
|
|
263
|
+
name: board.name,
|
|
264
|
+
category: board.category,
|
|
265
|
+
...(readme
|
|
266
|
+
? {
|
|
267
|
+
readme: {
|
|
268
|
+
nodeId: readme.id,
|
|
269
|
+
title: typeof readme.data.title === 'string' ? readme.data.title : readme.id,
|
|
270
|
+
summary: summarizeNodeForAgentContext(readme, { defaultTextLength: 1_000, webpageTextLength: 1_000 }),
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
: {}),
|
|
274
|
+
pinnedTitles: read.layout.nodes
|
|
275
|
+
.filter((node) => pins.has(node.id))
|
|
276
|
+
.map((node) => ({
|
|
277
|
+
nodeId: node.id,
|
|
278
|
+
title: typeof node.data.title === 'string' ? node.data.title : node.id,
|
|
279
|
+
})),
|
|
280
|
+
linkIds: linkedIds.get(board.id) ?? [],
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
const entries: ContextBriefSourceEntry[] = layout.nodes
|
|
284
|
+
.filter((node) => node.createdBy?.actor === 'human')
|
|
285
|
+
.map((node) => ({
|
|
286
|
+
sourceBoardId: active.id,
|
|
287
|
+
nodeId: node.id,
|
|
288
|
+
reason: 'human' as const,
|
|
289
|
+
title: typeof node.data.title === 'string' ? node.data.title : node.id,
|
|
290
|
+
text: summarizeNodeForAgentContext(node, { defaultTextLength: budget, webpageTextLength: budget }),
|
|
291
|
+
}));
|
|
292
|
+
entries.push(
|
|
293
|
+
...canvasState
|
|
294
|
+
.getApprovalGates()
|
|
295
|
+
.filter((item) => item.status === 'pending')
|
|
296
|
+
.map((item) => ({
|
|
297
|
+
sourceBoardId: active.id,
|
|
298
|
+
nodeId: `approval:${item.id}`,
|
|
299
|
+
reason: 'ask' as const,
|
|
300
|
+
title: item.title,
|
|
301
|
+
text: item.detail || item.action || 'Approval requested.',
|
|
302
|
+
})),
|
|
303
|
+
...canvasState
|
|
304
|
+
.getModeRequests()
|
|
305
|
+
.filter((item) => item.status === 'pending')
|
|
306
|
+
.map((item) => ({
|
|
307
|
+
sourceBoardId: active.id,
|
|
308
|
+
nodeId: `mode:${item.id}`,
|
|
309
|
+
reason: 'ask' as const,
|
|
310
|
+
title: `Mode request: ${item.mode}`,
|
|
311
|
+
text: item.reason || 'Mode change requested.',
|
|
312
|
+
})),
|
|
313
|
+
...canvasState
|
|
314
|
+
.getElicitations()
|
|
315
|
+
.filter((item) => item.status === 'pending')
|
|
316
|
+
.map((item) => ({
|
|
317
|
+
sourceBoardId: active.id,
|
|
318
|
+
nodeId: `elicitation:${item.id}`,
|
|
319
|
+
reason: 'ask' as const,
|
|
320
|
+
title: 'Open question',
|
|
321
|
+
text: item.prompt,
|
|
322
|
+
})),
|
|
323
|
+
);
|
|
324
|
+
entries.push(
|
|
325
|
+
...canvasState.getPendingSteering({ consumer: consumer ?? undefined, limit: 50 }).map((item) => ({
|
|
326
|
+
sourceBoardId: active.id,
|
|
327
|
+
nodeId: `steering:${item.id}`,
|
|
328
|
+
reason: 'steer' as const,
|
|
329
|
+
title: 'Human steering',
|
|
330
|
+
text: item.message,
|
|
331
|
+
})),
|
|
332
|
+
);
|
|
333
|
+
const result = compileContextBrief({
|
|
334
|
+
activeBoard: { boardId: active.id, name: active.name, category: active.category },
|
|
335
|
+
nodes: layout.nodes,
|
|
336
|
+
pinnedNodeIds,
|
|
337
|
+
contentRevision: revision.revision,
|
|
338
|
+
retentionFloor: revision.retentionFloor,
|
|
339
|
+
tombstones: canvasState.readContentDelta(sinceValue ?? 0).deleted,
|
|
340
|
+
since: explicitSince ? (Number.isSafeInteger(sinceValue) ? sinceValue : Number.NaN) : sinceValue,
|
|
341
|
+
libraryBoards,
|
|
342
|
+
entries,
|
|
343
|
+
budget,
|
|
344
|
+
});
|
|
345
|
+
if (consumer && !explicitSince && result.document && result.nextCursor !== null && !result.invalidCursor) {
|
|
346
|
+
canvasState.advanceContextBriefCursor(active.id, consumer, result.nextCursor);
|
|
347
|
+
}
|
|
348
|
+
return (result.document ?? {
|
|
349
|
+
version: 1,
|
|
350
|
+
budgetUnit: 'utf16-code-units',
|
|
351
|
+
error: 'Budget is too small for the context envelope.',
|
|
352
|
+
}) as unknown as Record<string, unknown>;
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
165
356
|
// ── history.get (HTTP/CLI only — canvas://history stays a resource) ──
|
|
166
357
|
|
|
167
358
|
const historyGetShape = {};
|
|
@@ -258,7 +449,7 @@ const redoOperation = defineOperation<z.infer<typeof undoRedoSchema>, Record<str
|
|
|
258
449
|
|
|
259
450
|
// ── spatial.get (HTTP only — canvas://spatial-context stays a resource) ──
|
|
260
451
|
|
|
261
|
-
const spatialGetShape = {};
|
|
452
|
+
const spatialGetShape = { board: z.unknown().optional().describe('Board id to read without opening it.') };
|
|
262
453
|
|
|
263
454
|
const spatialGetSchema = z.looseObject(spatialGetShape);
|
|
264
455
|
|
|
@@ -271,12 +462,13 @@ const spatialGetOperation = defineOperation<z.infer<typeof spatialGetSchema>, Re
|
|
|
271
462
|
method: 'GET',
|
|
272
463
|
path: '/api/canvas/spatial-context',
|
|
273
464
|
},
|
|
274
|
-
handler: () => {
|
|
275
|
-
const
|
|
465
|
+
handler: ({ board }) => {
|
|
466
|
+
const target = readTargetBoard(board);
|
|
467
|
+
const layout = target.layout;
|
|
276
468
|
return buildSpatialContext(
|
|
277
469
|
layout.nodes,
|
|
278
470
|
layout.edges,
|
|
279
|
-
|
|
471
|
+
target.pinnedNodeIds,
|
|
280
472
|
layout.annotations,
|
|
281
473
|
) as unknown as Record<string, unknown>;
|
|
282
474
|
},
|
|
@@ -284,7 +476,7 @@ const spatialGetOperation = defineOperation<z.infer<typeof spatialGetSchema>, Re
|
|
|
284
476
|
|
|
285
477
|
// ── summary.get (HTTP only — canvas://summary stays a resource) ──
|
|
286
478
|
|
|
287
|
-
const summaryGetShape = {};
|
|
479
|
+
const summaryGetShape = { board: z.unknown().optional().describe('Board id to read without opening it.') };
|
|
288
480
|
|
|
289
481
|
const summaryGetSchema = z.looseObject(summaryGetShape);
|
|
290
482
|
|
|
@@ -297,12 +489,17 @@ const summaryGetOperation = defineOperation<z.infer<typeof summaryGetSchema>, Re
|
|
|
297
489
|
method: 'GET',
|
|
298
490
|
path: '/api/canvas/summary',
|
|
299
491
|
},
|
|
300
|
-
handler: (
|
|
492
|
+
handler: ({ board }) => {
|
|
493
|
+
if (typeof board !== 'string' || !board.trim()) return buildCanvasSummary() as unknown as Record<string, unknown>;
|
|
494
|
+
const target = readTargetBoard(board);
|
|
495
|
+
return buildSummaryFromLayout(target.layout, [...target.pinnedNodeIds]);
|
|
496
|
+
},
|
|
301
497
|
});
|
|
302
498
|
|
|
303
499
|
export const queryOperations: Operation[] = [
|
|
304
500
|
pinOperation,
|
|
305
501
|
searchOperation,
|
|
502
|
+
contextOperation,
|
|
306
503
|
historyGetOperation,
|
|
307
504
|
undoOperation,
|
|
308
505
|
redoOperation,
|
|
@@ -7,12 +7,16 @@
|
|
|
7
7
|
* Handlers never emit `canvas-layout-update` themselves for the final state —
|
|
8
8
|
* `mutates: true` is the single source; extra events go through `ctx.emit`.
|
|
9
9
|
*/
|
|
10
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
10
11
|
import { canvasState } from '../canvas-state.js';
|
|
11
12
|
import { intentRegistry } from '../intent-registry.js';
|
|
12
13
|
import { humanPresence } from '../human-presence.js';
|
|
13
|
-
import {
|
|
14
|
+
import { withCurrentActor } from '../attribution.js';
|
|
14
15
|
import { agentPresence, describeWrite } from '../agent-presence.js';
|
|
15
16
|
import { checkScopeFence, checkScopeOwnership } from '../scope-fence.js';
|
|
17
|
+
import { CONTEXT_READ_OPS, contextReadFromPayload } from '../context-reads.js';
|
|
18
|
+
import { openBoardForWrite } from '../canvas-operations.js';
|
|
19
|
+
import { boardsPayload } from './ops/boards.js';
|
|
16
20
|
import type { PmxAxIntent, PmxAxIntentKind } from '../../shared/ax-intent.js';
|
|
17
21
|
import { OperationError, type Operation, type OperationContext } from './types.js';
|
|
18
22
|
|
|
@@ -43,18 +47,12 @@ export function setOperationEventEmitter(emitter: OperationEventEmitter | null):
|
|
|
43
47
|
operationEventEmitter = emitter;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
// still emitting SSE — merging the two counters would break one or the other).
|
|
50
|
-
// While > 0, emitOperationEvent is a no-op so a meta-op (canvas.batch) can run
|
|
51
|
-
// many sub-ops without producing per-entry SSE frames, then emit ONE final
|
|
52
|
-
// layout frame itself. Both the `mutates` auto-emit and `ctx.emit` route through
|
|
53
|
-
// emitOperationEvent, so this covers both. Re-entrant-safe via the depth counter.
|
|
54
|
-
let suppressEmitDepth = 0;
|
|
50
|
+
// Batch suppression belongs to its async call chain, never concurrent human
|
|
51
|
+
// operations (especially board switches while a batch waits on the network).
|
|
52
|
+
const suppressedEmits = new AsyncLocalStorage<boolean>();
|
|
55
53
|
|
|
56
54
|
function emitOperationEvent(event: string, payload: Record<string, unknown> = {}): void {
|
|
57
|
-
if (
|
|
55
|
+
if (isEmitSuppressed()) return;
|
|
58
56
|
operationEventEmitter?.(event, payload);
|
|
59
57
|
}
|
|
60
58
|
|
|
@@ -63,17 +61,12 @@ function emitOperationEvent(event: string, payload: Record<string, unknown> = {}
|
|
|
63
61
|
* mcpapp.open, whose canvas node is created as a side-effect of `ext-app-open` —
|
|
64
62
|
* use this to reject loudly instead of silently no-op'ing in a suppressed run. */
|
|
65
63
|
export function isEmitSuppressed(): boolean {
|
|
66
|
-
return
|
|
64
|
+
return suppressedEmits.getStore() === true;
|
|
67
65
|
}
|
|
68
66
|
|
|
69
|
-
/**
|
|
67
|
+
/** Suppress this call chain's per-entry frames; the batch emits its final frame afterward. */
|
|
70
68
|
export async function runWithSuppressedEmits<T>(fn: () => Promise<T>): Promise<T> {
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
return await fn();
|
|
74
|
-
} finally {
|
|
75
|
-
suppressEmitDepth--;
|
|
76
|
-
}
|
|
69
|
+
return suppressedEmits.run(true, fn);
|
|
77
70
|
}
|
|
78
71
|
|
|
79
72
|
const operationContext: OperationContext = { emit: emitOperationEvent };
|
|
@@ -208,6 +201,14 @@ export interface ExecuteOperationMeta {
|
|
|
208
201
|
* to agents only, and batch inner writes are always agent-originated.
|
|
209
202
|
*/
|
|
210
203
|
fromWorkbench?: boolean;
|
|
204
|
+
/** Authorship only: true when the per-boot workbench token matched. */
|
|
205
|
+
humanAuthor?: boolean;
|
|
206
|
+
/**
|
|
207
|
+
* An MCP server attached to this daemon, or a host adapter, fetching on an
|
|
208
|
+
* agent's behalf. It records the read the agent actually made itself
|
|
209
|
+
* (POST /api/canvas/ax/context-reads), so this fetch is not recorded.
|
|
210
|
+
*/
|
|
211
|
+
proxiedRead?: boolean;
|
|
211
212
|
}
|
|
212
213
|
|
|
213
214
|
/**
|
|
@@ -296,6 +297,14 @@ const PRESENCE_EXEMPT_OPS = new Set([
|
|
|
296
297
|
'ax.activity.ingest',
|
|
297
298
|
'human.presence.set',
|
|
298
299
|
'human.presence.get',
|
|
300
|
+
// Recording a context read is instrumentation of a read, not a write.
|
|
301
|
+
'ax.reads.record',
|
|
302
|
+
// Backup and restore are maintenance of the library file, not edits.
|
|
303
|
+
'backup.run',
|
|
304
|
+
'backup.schedule',
|
|
305
|
+
'backup.restore',
|
|
306
|
+
// Writing an export file shares nothing by itself and edits nothing.
|
|
307
|
+
'export.run',
|
|
299
308
|
// Camera and attention controls are observation/navigation, not edits.
|
|
300
309
|
// They must not create a writer, bump opCount, or refresh an existing one.
|
|
301
310
|
'viewport.set',
|
|
@@ -353,22 +362,105 @@ export async function executeOperation(
|
|
|
353
362
|
rawInput: unknown,
|
|
354
363
|
meta: ExecuteOperationMeta = {},
|
|
355
364
|
): Promise<unknown> {
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
?
|
|
361
|
-
: (meta.source ?? 'api')
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
365
|
+
// Attribution follows this call chain across awaits and nested operations.
|
|
366
|
+
const inputAgentId = asRecord(rawInput).agentId;
|
|
367
|
+
return withCurrentActor(
|
|
368
|
+
{
|
|
369
|
+
actor: meta.humanAuthor ? 'human' : 'agent',
|
|
370
|
+
source: meta.humanAuthor ? 'browser' : (meta.source ?? 'api'),
|
|
371
|
+
...(typeof inputAgentId === 'string' && inputAgentId.trim() ? { agentId: inputAgentId.trim() } : {}),
|
|
372
|
+
},
|
|
373
|
+
async () => {
|
|
374
|
+
const recordRead = CONTEXT_READ_OPS.has(name) && !meta.fromWorkbench && !meta.proxiedRead;
|
|
375
|
+
const input = asRecord(rawInput);
|
|
376
|
+
const requestedBoard =
|
|
377
|
+
recordRead && 'board' in getOperation(name).inputShape && isString(input.board) && input.board.trim()
|
|
378
|
+
? input.board.trim()
|
|
379
|
+
: null;
|
|
380
|
+
const readBoardId = requestedBoard ?? canvasState.activeBoardId;
|
|
381
|
+
const readPins = recordRead
|
|
382
|
+
? requestedBoard
|
|
383
|
+
? (canvasState.readBoard(requestedBoard, false)?.state.contextPins ?? [])
|
|
384
|
+
: [...canvasState.contextPinnedNodeIds]
|
|
385
|
+
: [];
|
|
386
|
+
const result = await executeOperationInner(name, rawInput, meta);
|
|
387
|
+
if (recordRead) {
|
|
388
|
+
canvasState.recordContextRead(
|
|
389
|
+
contextReadFromPayload(
|
|
390
|
+
{
|
|
391
|
+
channel: 'operation',
|
|
392
|
+
resource: name,
|
|
393
|
+
source: meta.source ?? 'api',
|
|
394
|
+
consumer: isString(input.consumer) && input.consumer.trim() ? input.consumer.trim() : null,
|
|
395
|
+
agentId: isString(input.agentId) && input.agentId.trim() ? input.agentId.trim() : null,
|
|
396
|
+
pinnedNodeIds: readPins,
|
|
397
|
+
},
|
|
398
|
+
result,
|
|
399
|
+
),
|
|
400
|
+
readBoardId,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return result;
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Opening, switching and deleting boards is the human's; an agent asks for another board. */
|
|
409
|
+
const HUMAN_ONLY_OPS = new Set(['board.open', 'board.delete', 'import.request', 'import.cancel', 'import.commit']);
|
|
410
|
+
const TRUSTED_HUMAN_ONLY_OPS = new Set([
|
|
411
|
+
'board.open',
|
|
412
|
+
'board.delete',
|
|
413
|
+
'import.request',
|
|
414
|
+
'import.cancel',
|
|
415
|
+
'import.commit',
|
|
416
|
+
]);
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Writes that need a board beyond the layout mutations: canvas-bound AX items,
|
|
420
|
+
* pins, snapshots and app nodes. `canvas.clear` on Home clears nothing, so it
|
|
421
|
+
* never opens a board it would then wipe.
|
|
422
|
+
*/
|
|
423
|
+
const BOARD_WRITE_OPS = new Set(['pin.set', 'snapshot.save', 'mcpapp.open', 'diagram.open', 'webartifact.build']);
|
|
424
|
+
|
|
425
|
+
const CANVAS_BOUND_AX_WRITE_PREFIXES = [
|
|
426
|
+
'ax.work.',
|
|
427
|
+
'ax.review.',
|
|
428
|
+
'ax.approval.',
|
|
429
|
+
'ax.elicitation.',
|
|
430
|
+
'ax.mode.',
|
|
431
|
+
] as const;
|
|
432
|
+
|
|
433
|
+
function isCanvasBoundAxWrite(name: string): boolean {
|
|
434
|
+
if (name === 'ax.focus.set' || name === 'ax.policy.set') return true;
|
|
435
|
+
return CANVAS_BOUND_AX_WRITE_PREFIXES.some((prefix) => name.startsWith(prefix)) && !name.endsWith('.await');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function needsOpenBoard(op: Operation): boolean {
|
|
439
|
+
if (op.name === 'canvas.clear' || PRESENCE_EXEMPT_OPS.has(op.name)) return false;
|
|
440
|
+
return op.mutates || BOARD_WRITE_OPS.has(op.name) || isCanvasBoundAxWrite(op.name);
|
|
368
441
|
}
|
|
369
442
|
|
|
370
443
|
async function executeOperationInner(name: string, rawInput: unknown, meta: ExecuteOperationMeta): Promise<unknown> {
|
|
371
444
|
const op = getOperation(name);
|
|
445
|
+
if (HUMAN_ONLY_OPS.has(name) && !meta.fromWorkbench) {
|
|
446
|
+
throw new OperationError(
|
|
447
|
+
'Only the human opens or deletes boards. Ask them in the chat to open the board you need.',
|
|
448
|
+
403,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
if (TRUSTED_HUMAN_ONLY_OPS.has(name) && !meta.humanAuthor) {
|
|
452
|
+
throw new OperationError('This action requires trusted human authority.', 403);
|
|
453
|
+
}
|
|
454
|
+
// File drops carry their captured board through the HTTP round trip. Check
|
|
455
|
+
// before opening a recent board from Home, not just before reading the file.
|
|
456
|
+
const capturedBoard = asRecord(rawInput).boardId;
|
|
457
|
+
if (name === 'node.add' && typeof capturedBoard === 'string' && capturedBoard !== canvasState.activeBoardId) {
|
|
458
|
+
throw new OperationError('The board changed while the file was loading.', 409);
|
|
459
|
+
}
|
|
460
|
+
if (!canvasState.activeBoardId && needsOpenBoard(op)) {
|
|
461
|
+
openBoardForWrite();
|
|
462
|
+
if (canvasState.activeBoardId) emitOperationEvent('boards-changed', boardsPayload());
|
|
463
|
+
}
|
|
372
464
|
// Scope fence (design item 4): an attached agent's writes must stay inside
|
|
373
465
|
// the fence the human granted. Reads and the human's own writes pass, and
|
|
374
466
|
// the fence itself is the human's to set — an agent cannot clear or widen it.
|
|
@@ -60,7 +60,7 @@ export interface OperationHttpRoute {
|
|
|
60
60
|
|
|
61
61
|
/** Host capabilities available to MCP result formatters. */
|
|
62
62
|
export interface OperationMcpToolHost {
|
|
63
|
-
getPinnedNodeIds(): Promise<string[]>;
|
|
63
|
+
getPinnedNodeIds(board?: string): Promise<string[]>;
|
|
64
64
|
/**
|
|
65
65
|
* Invoke another registered operation over the host's transport (local or
|
|
66
66
|
* HTTP) — structural subset of OperationInvoker to avoid an import cycle.
|