pmx-canvas 0.7.3 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
- package/.github/extensions/pmx-canvas/extension.mjs +25 -172
- package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
- package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
- package/.github/extensions/pmx-canvas/runtime.json +3 -0
- package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
- package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
- package/.github/extensions/pmx-canvas/tools.json +2560 -0
- package/CHANGELOG.md +64 -0
- package/Readme.md +21 -7
- package/dist/canvas/fonts/OFL.txt +93 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
- package/dist/canvas/global.css +726 -674
- package/dist/canvas/index.js +90 -88
- package/dist/canvas/mcp-app.html +7879 -0
- package/dist/canvas/mermaid-entry.js +1 -1
- package/dist/canvas/surface-theme.css +151 -46
- package/dist/json-render/index.css +81 -1
- package/dist/json-render/index.js +1 -1
- package/dist/types/cli/commands/record.d.ts +1 -1
- package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
- package/dist/types/client/icons.d.ts +1 -2
- package/dist/types/client/mcp-app/index.d.ts +1 -0
- package/dist/types/client/nodes/HtmlNode.d.ts +1 -0
- package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
- package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
- package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
- package/dist/types/client/state/attention-store.d.ts +0 -5
- package/dist/types/client/state/context-status-store.d.ts +53 -0
- package/dist/types/client/state/intent-bridge.d.ts +3 -0
- package/dist/types/client/state/near-pin-store.d.ts +16 -0
- package/dist/types/client/state/presence-store.d.ts +5 -0
- package/dist/types/client/state/session-store.d.ts +33 -3
- package/dist/types/client/state/workbench-transport.d.ts +16 -0
- package/dist/types/client/theme/tokens.d.ts +1 -3
- package/dist/types/client/types.d.ts +9 -0
- package/dist/types/json-render/server.d.ts +1 -1
- package/dist/types/mcp/app-server.d.ts +3 -0
- package/dist/types/mcp/canvas-access.d.ts +1 -1
- package/dist/types/mcp/canvas-app.d.ts +4 -0
- package/dist/types/mcp/workbench-app.d.ts +4 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/ax-state.d.ts +1 -1
- package/dist/types/server/canvas-db.d.ts +8 -0
- package/dist/types/server/canvas-geometry.d.ts +25 -0
- package/dist/types/server/canvas-operations.d.ts +1 -11
- package/dist/types/server/canvas-state.d.ts +25 -3
- package/dist/types/server/context-brief.d.ts +8 -1
- package/dist/types/server/context-reads.d.ts +30 -7
- package/dist/types/server/index.d.ts +6 -1
- package/dist/types/server/mcp-app-runtime.d.ts +1 -0
- package/dist/types/server/operations/http.d.ts +2 -0
- package/dist/types/server/operations/invoker.d.ts +4 -2
- package/dist/types/server/operations/ops/nodes.d.ts +0 -6
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/spatial-analysis.d.ts +2 -0
- package/dist/types/shared/agent-context.d.ts +22 -0
- package/dist/types/shared/agent-presence.d.ts +3 -10
- package/dist/types/shared/canvas-app.d.ts +54 -0
- package/dist/types/shared/themes.d.ts +1 -1
- package/docs/RELEASE.md +7 -3
- package/docs/agent-plugin.md +123 -0
- package/docs/ax-state-contract.md +40 -0
- package/docs/cli.md +7 -2
- package/docs/design/AgentContext.dc.html +186 -0
- package/docs/design/Before.dc.html +208 -0
- package/docs/design/BoardPins.dc.html +151 -0
- package/docs/design/Chrome.dc.html +211 -0
- package/docs/design/Context.dc.html +239 -0
- package/docs/design/Flows.dc.html +181 -0
- package/docs/design/Home.dc.html +209 -0
- package/docs/design/HomeGraph.dc.html +211 -0
- package/docs/design/HomeMap.dc.html +117 -0
- package/docs/design/HomeTeam.dc.html +176 -0
- package/docs/design/Light.dc.html +28 -0
- package/docs/design/Main.dc.html +280 -0
- package/docs/design/NearPin.dc.html +260 -0
- package/docs/design/Pane600.dc.html +120 -0
- package/docs/design/Relations.dc.html +297 -0
- package/docs/design/Themes.dc.html +121 -0
- package/docs/design/TokenSystem.dc.html +136 -0
- package/docs/design/Tokens.dc.html +163 -0
- package/docs/design/Types1.dc.html +177 -0
- package/docs/design/Types2.dc.html +226 -0
- package/docs/design/Types3.dc.html +195 -0
- package/docs/design/Uses.dc.html +152 -0
- package/docs/design/Vision.dc.html +116 -0
- package/docs/design/Zoom.dc.html +82 -0
- package/docs/design/canvas.json +274 -0
- package/docs/design.md +217 -0
- package/docs/environment.md +1 -1
- package/docs/http-api.md +86 -16
- package/docs/mcp.md +202 -4
- package/docs/node-types.md +34 -10
- package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
- package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
- package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
- package/docs/product-vision-2026-09.md +47 -7
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +1 -0
- package/package.json +10 -4
- package/plugins/pmx-canvas/LICENSE +21 -0
- package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
- package/plugins/pmx-canvas/assets/preview.png +0 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/plugins/pmx-canvas/mcp.json +12 -0
- package/plugins/pmx-canvas/plugin.json +24 -0
- package/plugins/pmx-canvas/runtime.json +3 -0
- package/plugins/pmx-canvas/runtime.mjs +194 -0
- package/plugins/pmx-canvas/start-mcp.mjs +51 -0
- package/plugins/pmx-canvas/tools.json +2560 -0
- package/skills/pmx-canvas/SKILL.md +29 -9
- package/skills/pmx-canvas/evals/evals.json +62 -0
- package/skills/pmx-canvas/references/ax-html-control-surface.md +6 -0
- package/skills/pmx-canvas/references/full-reference.md +11 -7
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
- package/skills/pmx-canvas-testing/SKILL.md +5 -4
- package/src/cli/commands/copilot.ts +5 -0
- package/src/cli/commands/nodes.ts +3 -2
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +10 -4
- package/src/client/App.tsx +6 -1
- package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
- package/src/client/canvas/AnnotationLayer.tsx +1 -1
- package/src/client/canvas/CanvasNode.tsx +32 -49
- package/src/client/canvas/CanvasViewport.tsx +0 -2
- package/src/client/canvas/CommandPalette.tsx +3 -2
- package/src/client/canvas/ContextMenu.tsx +11 -0
- package/src/client/canvas/EdgeLayer.tsx +1 -1
- package/src/client/canvas/ExportDialog.tsx +33 -6
- package/src/client/canvas/IntentLayer.tsx +2 -2
- package/src/client/canvas/Minimap.tsx +12 -7
- package/src/client/canvas/NodeContextMark.tsx +156 -0
- package/src/client/canvas/SessionPanel.tsx +4 -7
- package/src/client/canvas/SessionReceipt.tsx +114 -17
- package/src/client/canvas/ShortcutOverlay.tsx +6 -1
- package/src/client/canvas/SnapshotPanel.tsx +11 -2
- package/src/client/canvas/ToolRail.tsx +12 -9
- package/src/client/canvas/TopBar.tsx +16 -11
- package/src/client/icons.tsx +2 -12
- package/src/client/mcp-app/index.tsx +137 -0
- package/src/client/mcp-app/style.css +10 -0
- package/src/client/nodes/AttachmentNode.tsx +8 -1
- package/src/client/nodes/AxStepControls.tsx +1 -1
- package/src/client/nodes/ContextNode.tsx +9 -9
- package/src/client/nodes/ExtAppFrame.tsx +13 -8
- package/src/client/nodes/FileNode.tsx +8 -5
- package/src/client/nodes/HtmlNode.tsx +12 -2
- package/src/client/nodes/ImageNode.tsx +4 -2
- package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
- package/src/client/nodes/LedgerNode.tsx +2 -2
- package/src/client/nodes/MarkdownNode.tsx +44 -9
- package/src/client/nodes/McpAppNode.tsx +6 -4
- package/src/client/nodes/PromptNode.tsx +5 -17
- package/src/client/nodes/ResponseNode.tsx +3 -15
- package/src/client/nodes/StatusNode.tsx +2 -2
- package/src/client/nodes/StatusSummary.tsx +3 -1
- package/src/client/nodes/TraceNode.tsx +6 -4
- package/src/client/nodes/WebpageNode.tsx +1 -1
- package/src/client/nodes/ax-node-actions.ts +2 -2
- package/src/client/nodes/iframe-document-url.ts +3 -2
- package/src/client/nodes/markdown-sanitize.ts +10 -0
- package/src/client/nodes/surface-url.ts +10 -0
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/nodes/use-workbench-asset.ts +26 -0
- package/src/client/state/attention-bridge.ts +0 -8
- package/src/client/state/attention-store.ts +0 -17
- package/src/client/state/canvas-store.ts +4 -2
- package/src/client/state/context-status-store.ts +66 -0
- package/src/client/state/human-store.ts +2 -0
- package/src/client/state/iframe-mode.ts +5 -0
- package/src/client/state/intent-bridge.ts +10 -4
- package/src/client/state/near-pin-store.ts +53 -0
- package/src/client/state/presence-store.ts +5 -10
- package/src/client/state/session-store.ts +106 -9
- package/src/client/state/sse-bridge.ts +26 -7
- package/src/client/state/theme-override.ts +1 -1
- package/src/client/state/workbench-transport.ts +36 -0
- package/src/client/theme/global.css +726 -674
- package/src/client/theme/surface-theme.css +151 -46
- package/src/client/theme/tokens.ts +7 -11
- package/src/client/types.ts +10 -0
- package/src/json-render/renderer/index.css +6 -1
- package/src/json-render/renderer/index.tsx +0 -1
- package/src/json-render/server.ts +71 -14
- package/src/mcp/app-server.ts +64 -0
- package/src/mcp/canvas-access.ts +14 -11
- package/src/mcp/canvas-app.ts +318 -0
- package/src/mcp/server.ts +1 -1
- package/src/mcp/workbench-app.ts +211 -0
- package/src/server/agent-presence.ts +45 -6
- package/src/server/ax-context.ts +1 -1
- package/src/server/ax-state.ts +1 -1
- package/src/server/board-export.ts +5 -2
- package/src/server/canvas-db.ts +46 -12
- package/src/server/canvas-geometry.ts +64 -0
- package/src/server/canvas-operations.ts +7 -25
- package/src/server/canvas-state.ts +105 -15
- package/src/server/canvas-validation.ts +19 -4
- package/src/server/context-brief.ts +39 -2
- package/src/server/context-reads.ts +87 -15
- package/src/server/demo-state.json +31 -31
- package/src/server/html-surface.ts +4 -5
- package/src/server/index.ts +41 -5
- package/src/server/mcp-app-runtime.ts +11 -0
- package/src/server/operations/composites.ts +4 -3
- package/src/server/operations/http.ts +4 -4
- package/src/server/operations/invoker.ts +13 -4
- package/src/server/operations/mcp.ts +6 -0
- package/src/server/operations/ops/ax-read.ts +1 -1
- package/src/server/operations/ops/ax-timeline.ts +37 -0
- package/src/server/operations/ops/boards.ts +1 -1
- package/src/server/operations/ops/canvas-wire.ts +3 -6
- package/src/server/operations/ops/intent.ts +31 -2
- package/src/server/operations/ops/json-render.ts +6 -2
- package/src/server/operations/ops/nodes.ts +8 -76
- package/src/server/operations/ops/query.ts +8 -2
- package/src/server/operations/ops/snapshots.ts +4 -1
- package/src/server/operations/ops/viewport.ts +29 -5
- package/src/server/operations/registry.ts +41 -5
- package/src/server/scope-fence.ts +55 -10
- package/src/server/server.ts +83 -5
- package/src/server/spatial-analysis.ts +2 -2
- package/src/server/workboard.ts +6 -1
- package/src/{server → shared}/agent-context.ts +2 -2
- package/src/shared/agent-presence.ts +3 -15
- package/src/shared/canvas-app.ts +62 -0
- package/src/shared/themes.ts +5 -5
- package/src/client/canvas/FocusFieldLayer.tsx +0 -116
- package/src/client/canvas/kind-colors.ts +0 -23
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
|
|
4
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { summarizeNodeForAgentContext } from '../shared/agent-context.js';
|
|
8
|
+
import type { CanvasNodeState } from '../server/canvas-state.js';
|
|
9
|
+
import { OperationError } from '../server/operations/index.js';
|
|
10
|
+
import { CANVAS_APP_URI, type CanvasAppSnapshot } from '../shared/canvas-app.js';
|
|
11
|
+
import type { CanvasAccess } from './canvas-access.js';
|
|
12
|
+
|
|
13
|
+
const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
|
|
14
|
+
const additive = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
|
|
15
|
+
const editing = { readOnlyHint: false, destructiveHint: true, openWorldHint: false };
|
|
16
|
+
const boardId = z.string().min(1).describe('Active board id from pmx_read_canvas; refuses a stale board.');
|
|
17
|
+
const boardListSchema = z.array(z.object({ id: z.string(), name: z.string() }));
|
|
18
|
+
const summaryShape = {
|
|
19
|
+
boardId: z.string().nullable(),
|
|
20
|
+
boardName: z.string(),
|
|
21
|
+
boards: boardListSchema,
|
|
22
|
+
revision: z.string(),
|
|
23
|
+
nodeCount: z.number(),
|
|
24
|
+
edgeCount: z.number(),
|
|
25
|
+
pinnedNodeIds: z.array(z.string()),
|
|
26
|
+
nodes: z.array(z.object({ id: z.string(), title: z.string(), type: z.string(), contentRevision: z.number() })),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function editableContent(node: CanvasNodeState): string | undefined {
|
|
30
|
+
return node.type === 'markdown' &&
|
|
31
|
+
!node.data.path &&
|
|
32
|
+
typeof node.data.content === 'string' &&
|
|
33
|
+
node.data.content.length <= 50_000
|
|
34
|
+
? node.data.content
|
|
35
|
+
: undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readSnapshot(canvas: CanvasAccess): Promise<CanvasAppSnapshot> {
|
|
39
|
+
const target = await canvas.prepareContextRead();
|
|
40
|
+
const { boards } = z.object({ boards: boardListSchema }).parse(await canvas.invoker().invoke('board.list', {}));
|
|
41
|
+
if (!target.boardId) {
|
|
42
|
+
return {
|
|
43
|
+
boardId: null,
|
|
44
|
+
boardName: 'No board open',
|
|
45
|
+
boards,
|
|
46
|
+
revision: createHash('sha256').update(JSON.stringify(boards)).digest('hex'),
|
|
47
|
+
nodes: [],
|
|
48
|
+
edges: [],
|
|
49
|
+
pinnedNodeIds: [],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// Explicit board reads prevent a concurrent workbench switch mixing two boards.
|
|
53
|
+
const layout = await canvas.getLayout(target.boardId);
|
|
54
|
+
const info = z
|
|
55
|
+
.object({ board: z.object({ name: z.string() }) })
|
|
56
|
+
.parse(await canvas.invoker().invoke('board.get', { id: target.boardId }));
|
|
57
|
+
const projection = {
|
|
58
|
+
boardId: target.boardId,
|
|
59
|
+
boardName: info.board.name,
|
|
60
|
+
boards,
|
|
61
|
+
nodes: layout.nodes.map((node) => {
|
|
62
|
+
const src = node.data.src;
|
|
63
|
+
// No network fetches, local paths, SVG or active documents in the app iframe.
|
|
64
|
+
const image =
|
|
65
|
+
node.type === 'image' &&
|
|
66
|
+
typeof src === 'string' &&
|
|
67
|
+
src.length <= 1_400_000 &&
|
|
68
|
+
/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+$/.test(src)
|
|
69
|
+
? src
|
|
70
|
+
: undefined;
|
|
71
|
+
return {
|
|
72
|
+
id: node.id,
|
|
73
|
+
type: node.type,
|
|
74
|
+
title: typeof node.data.title === 'string' ? node.data.title : node.type,
|
|
75
|
+
text: summarizeNodeForAgentContext(node, { defaultTextLength: 8000, webpageTextLength: 8000 }),
|
|
76
|
+
contentRevision: node.contentRevision ?? 0,
|
|
77
|
+
editableContent: editableContent(node),
|
|
78
|
+
position: node.position,
|
|
79
|
+
size: node.size,
|
|
80
|
+
...(image ? { image } : {}),
|
|
81
|
+
};
|
|
82
|
+
}),
|
|
83
|
+
edges: layout.edges.map(({ id, from, to, label }) => ({ id, from, to, label: label ?? '' })),
|
|
84
|
+
pinnedNodeIds: target.pinnedNodeIds,
|
|
85
|
+
};
|
|
86
|
+
return { ...projection, revision: createHash('sha256').update(JSON.stringify(projection)).digest('hex') };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function snapshotResult(snapshot: CanvasAppSnapshot): CallToolResult {
|
|
90
|
+
return {
|
|
91
|
+
content: [
|
|
92
|
+
{ type: 'text', text: `${snapshot.boardName}: ${snapshot.nodes.length} nodes, ${snapshot.edges.length} edges.` },
|
|
93
|
+
],
|
|
94
|
+
structuredContent: {
|
|
95
|
+
boardId: snapshot.boardId,
|
|
96
|
+
boardName: snapshot.boardName,
|
|
97
|
+
boards: snapshot.boards,
|
|
98
|
+
revision: snapshot.revision,
|
|
99
|
+
nodeCount: snapshot.nodes.length,
|
|
100
|
+
edgeCount: snapshot.edges.length,
|
|
101
|
+
pinnedNodeIds: snapshot.pinnedNodeIds,
|
|
102
|
+
nodes: snapshot.nodes.map(({ id, title, type, contentRevision }) => ({ id, title, type, contentRevision })),
|
|
103
|
+
},
|
|
104
|
+
// Full preview text and image bytes go to the app, not the model's tool transcript.
|
|
105
|
+
_meta: { canvas: snapshot },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function toolResult(run: () => Promise<CallToolResult>): Promise<CallToolResult> {
|
|
110
|
+
try {
|
|
111
|
+
return await run();
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error instanceof OperationError) {
|
|
114
|
+
return { isError: true, content: [{ type: 'text', text: error.message }] };
|
|
115
|
+
}
|
|
116
|
+
console.error('[canvas-app] tool failed', error);
|
|
117
|
+
return {
|
|
118
|
+
isError: true,
|
|
119
|
+
content: [{ type: 'text', text: 'Could not access the canvas. Check the PMX server logs.' }],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Focused app profile; deliberately does not register the general-purpose composites. */
|
|
125
|
+
export function registerCanvasApp(server: McpServer, ensureCanvas: () => Promise<CanvasAccess>): void {
|
|
126
|
+
const read = () => toolResult(async () => snapshotResult(await readSnapshot(await ensureCanvas())));
|
|
127
|
+
const invoke = (operation: string, input: Record<string, unknown>) =>
|
|
128
|
+
toolResult(async () => {
|
|
129
|
+
const result = await (await ensureCanvas()).invoker().invoke(operation, input);
|
|
130
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
131
|
+
});
|
|
132
|
+
registerAppTool(
|
|
133
|
+
server,
|
|
134
|
+
'pmx_open_canvas',
|
|
135
|
+
{
|
|
136
|
+
title: 'PMX Canvas',
|
|
137
|
+
description: 'Open a live view of the shared PMX board. Select nodes to share context with the conversation.',
|
|
138
|
+
inputSchema: {},
|
|
139
|
+
outputSchema: summaryShape,
|
|
140
|
+
annotations: readOnly,
|
|
141
|
+
_meta: {
|
|
142
|
+
ui: { resourceUri: CANVAS_APP_URI },
|
|
143
|
+
// ChatGPT-specific extensions are confined to this launcher descriptor.
|
|
144
|
+
'openai/ui': { entrypoints: [{ type: 'global' }, { type: 'thread' }], preferredModelDisplayMode: 'fullscreen' },
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
read,
|
|
148
|
+
);
|
|
149
|
+
registerAppTool(
|
|
150
|
+
server,
|
|
151
|
+
'pmx_read_canvas',
|
|
152
|
+
{
|
|
153
|
+
title: 'Read PMX Canvas',
|
|
154
|
+
description:
|
|
155
|
+
'Read the shared active board, node identifiers and context pins. Use pmx_read_node for full note content before editing. Does not change boards.',
|
|
156
|
+
inputSchema: {},
|
|
157
|
+
outputSchema: summaryShape,
|
|
158
|
+
annotations: readOnly,
|
|
159
|
+
_meta: { ui: { visibility: ['model', 'app'] } },
|
|
160
|
+
},
|
|
161
|
+
read,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
server.registerTool(
|
|
165
|
+
'pmx_read_node',
|
|
166
|
+
{
|
|
167
|
+
title: 'Read a complete PMX note',
|
|
168
|
+
description:
|
|
169
|
+
'Read full editable Markdown, content revision, position and incident edges for one note. Always read this before replacing or appending content; selection context is truncated. Other node types, file-linked and oversized notes require the workbench.',
|
|
170
|
+
inputSchema: { boardId, id: z.string().min(1) },
|
|
171
|
+
annotations: readOnly,
|
|
172
|
+
},
|
|
173
|
+
(input) =>
|
|
174
|
+
toolResult(async () => {
|
|
175
|
+
const layout = await (await ensureCanvas()).getLayout(input.boardId);
|
|
176
|
+
const node = layout.nodes.find((entry) => entry.id === input.id);
|
|
177
|
+
const content = node && editableContent(node);
|
|
178
|
+
if (!node || content === undefined)
|
|
179
|
+
throw new OperationError('This node is not an editable standalone note. Use the PMX workbench.');
|
|
180
|
+
const result = {
|
|
181
|
+
boardId: input.boardId,
|
|
182
|
+
id: node.id,
|
|
183
|
+
type: node.type,
|
|
184
|
+
title: typeof node.data.title === 'string' ? node.data.title : node.type,
|
|
185
|
+
content,
|
|
186
|
+
contentRevision: node.contentRevision ?? 0,
|
|
187
|
+
position: node.position,
|
|
188
|
+
size: node.size,
|
|
189
|
+
edges: layout.edges
|
|
190
|
+
.filter((edge) => edge.from === node.id || edge.to === node.id)
|
|
191
|
+
.map(({ id, from, to, type, label }) => ({ id, from, to, type, label: label ?? '' })),
|
|
192
|
+
};
|
|
193
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
server.registerTool(
|
|
198
|
+
'pmx_add_note',
|
|
199
|
+
{
|
|
200
|
+
title: 'Add a PMX note',
|
|
201
|
+
description: 'Add a Markdown note to the shared active board at a canvas position. Existing nodes are preserved.',
|
|
202
|
+
inputSchema: {
|
|
203
|
+
boardId,
|
|
204
|
+
title: z.string().min(1).max(200),
|
|
205
|
+
content: z.string().max(50_000),
|
|
206
|
+
x: z.number().finite(),
|
|
207
|
+
y: z.number().finite(),
|
|
208
|
+
},
|
|
209
|
+
annotations: additive,
|
|
210
|
+
},
|
|
211
|
+
(input) =>
|
|
212
|
+
toolResult(async () => {
|
|
213
|
+
const canvas = await ensureCanvas();
|
|
214
|
+
const result = await canvas.invoker().invoke('node.add', { ...input, type: 'markdown' });
|
|
215
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
server.registerTool(
|
|
219
|
+
'pmx_connect_nodes',
|
|
220
|
+
{
|
|
221
|
+
title: 'Connect PMX nodes',
|
|
222
|
+
description: 'Add a labelled relationship between two nodes on the shared active board.',
|
|
223
|
+
inputSchema: {
|
|
224
|
+
boardId,
|
|
225
|
+
from: z.string().min(1),
|
|
226
|
+
to: z.string().min(1),
|
|
227
|
+
type: z.enum(['flow', 'depends-on', 'relation', 'references']),
|
|
228
|
+
label: z.string().max(500).optional(),
|
|
229
|
+
},
|
|
230
|
+
annotations: additive,
|
|
231
|
+
},
|
|
232
|
+
(input) =>
|
|
233
|
+
toolResult(async () => {
|
|
234
|
+
const canvas = await ensureCanvas();
|
|
235
|
+
const result = await canvas.invoker().invoke('edge.add', { ...input, expectedBoardId: input.boardId });
|
|
236
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
server.registerTool(
|
|
241
|
+
'pmx_create_board',
|
|
242
|
+
{
|
|
243
|
+
title: 'Create a PMX board',
|
|
244
|
+
description:
|
|
245
|
+
'Create an empty board without switching the shared workspace. Open it explicitly with pmx_open_board.',
|
|
246
|
+
inputSchema: { name: z.string().trim().min(1).max(120) },
|
|
247
|
+
annotations: additive,
|
|
248
|
+
},
|
|
249
|
+
(input) => invoke('board.create', input),
|
|
250
|
+
);
|
|
251
|
+
server.registerTool(
|
|
252
|
+
'pmx_open_board',
|
|
253
|
+
{
|
|
254
|
+
title: 'Switch the shared PMX board',
|
|
255
|
+
description: 'Switch the active board for ALL conversations and the workbench. Null opens Home.',
|
|
256
|
+
inputSchema: { id: z.string().min(1).nullable(), expectedBoardId: boardId.nullable() },
|
|
257
|
+
annotations: editing,
|
|
258
|
+
},
|
|
259
|
+
(input) => invoke('board.open', input),
|
|
260
|
+
);
|
|
261
|
+
server.registerTool(
|
|
262
|
+
'pmx_pin_nodes',
|
|
263
|
+
{
|
|
264
|
+
title: 'Curate PMX context pins',
|
|
265
|
+
description: 'Add or remove context pins without replacing unrelated pins.',
|
|
266
|
+
inputSchema: { boardId, nodeIds: z.array(z.string().min(1)).min(1).max(20), mode: z.enum(['add', 'remove']) },
|
|
267
|
+
annotations: editing,
|
|
268
|
+
},
|
|
269
|
+
(input) => invoke('pin.set', { ...input, expectedBoardId: input.boardId }),
|
|
270
|
+
);
|
|
271
|
+
server.registerTool(
|
|
272
|
+
'pmx_move_node',
|
|
273
|
+
{
|
|
274
|
+
title: 'Move a PMX node',
|
|
275
|
+
description: 'Set the position of an existing node. Moving a group also moves its children.',
|
|
276
|
+
inputSchema: { boardId, id: z.string().min(1), x: z.number().finite(), y: z.number().finite() },
|
|
277
|
+
annotations: editing,
|
|
278
|
+
},
|
|
279
|
+
(input) => invoke('node.update', { ...input, expectedBoardId: input.boardId }),
|
|
280
|
+
);
|
|
281
|
+
server.registerTool(
|
|
282
|
+
'pmx_update_note',
|
|
283
|
+
{
|
|
284
|
+
title: 'Edit a PMX note',
|
|
285
|
+
description:
|
|
286
|
+
'Replace standalone Markdown using full content and revision from pmx_read_node, never from a truncated selection excerpt. Refuses stale edits and file-linked notes.',
|
|
287
|
+
inputSchema: {
|
|
288
|
+
boardId,
|
|
289
|
+
id: z.string().min(1),
|
|
290
|
+
expectedContentRevision: z.number().int().nonnegative(),
|
|
291
|
+
title: z.string().min(1).max(200),
|
|
292
|
+
content: z.string().max(50_000),
|
|
293
|
+
},
|
|
294
|
+
annotations: editing,
|
|
295
|
+
},
|
|
296
|
+
(input) =>
|
|
297
|
+
toolResult(async () => {
|
|
298
|
+
const canvas = await ensureCanvas();
|
|
299
|
+
const node = await canvas.getNode(input.id, input.boardId);
|
|
300
|
+
if (!node || editableContent(node) === undefined)
|
|
301
|
+
throw new OperationError('Edit this node in the PMX workbench.');
|
|
302
|
+
// Compare again inside node.update, atomically with the actual mutation.
|
|
303
|
+
const result = await canvas.invoker().invoke('node.update', { ...input, expectedBoardId: input.boardId });
|
|
304
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
registerAppResource(server, 'PMX Canvas', CANVAS_APP_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
|
|
309
|
+
contents: [
|
|
310
|
+
{
|
|
311
|
+
uri: CANVAS_APP_URI,
|
|
312
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
313
|
+
text: await readFile(new URL('../../dist/canvas/mcp-app.html', import.meta.url), 'utf8'),
|
|
314
|
+
_meta: { ui: { prefersBorder: false, csp: { connectDomains: [], resourceDomains: [], frameDomains: [] } } },
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
}));
|
|
318
|
+
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -34,7 +34,7 @@ import { registerOperationTools, registerCompositeTools } from '../server/operat
|
|
|
34
34
|
import { agentSourceLabel } from '../server/operations/invoker.js';
|
|
35
35
|
import { contextReadFromPayload } from '../server/context-reads.js';
|
|
36
36
|
import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './canvas-access.js';
|
|
37
|
-
import { serializeNodeForAgentContext } from '../
|
|
37
|
+
import { serializeNodeForAgentContext } from '../shared/agent-context.js';
|
|
38
38
|
import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
|
|
39
39
|
import {
|
|
40
40
|
getCanvasNodeTitle,
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { listOperations, OperationError } from '../server/operations/index.js';
|
|
4
|
+
import { matchOperationPath, readOperationInput } from '../server/operations/http.js';
|
|
5
|
+
import type { CanvasAccess } from './canvas-access.js';
|
|
6
|
+
|
|
7
|
+
// Explicitly bounded to workbench operations. No shell, automation, arbitrary
|
|
8
|
+
// HTTP proxy, external MCP tool calls, or caller-supplied authority headers.
|
|
9
|
+
const writes = new Set([
|
|
10
|
+
'board.create',
|
|
11
|
+
'board.open',
|
|
12
|
+
'board.update',
|
|
13
|
+
'board.create-from',
|
|
14
|
+
'board.delete',
|
|
15
|
+
'node.add',
|
|
16
|
+
'jsonrender.add',
|
|
17
|
+
'graph.add',
|
|
18
|
+
'node.update',
|
|
19
|
+
'node.remove',
|
|
20
|
+
'node.refresh',
|
|
21
|
+
'edge.add',
|
|
22
|
+
'edge.update',
|
|
23
|
+
'edge.remove',
|
|
24
|
+
'group.create',
|
|
25
|
+
'group.add',
|
|
26
|
+
'group.remove',
|
|
27
|
+
'pin.set',
|
|
28
|
+
'annotation.add',
|
|
29
|
+
'annotation.remove',
|
|
30
|
+
'theme.set',
|
|
31
|
+
'viewport.set',
|
|
32
|
+
'canvas.apply-updates',
|
|
33
|
+
'canvas.clear',
|
|
34
|
+
'arrange',
|
|
35
|
+
'node.focus',
|
|
36
|
+
'view.fit',
|
|
37
|
+
'snapshot.save',
|
|
38
|
+
'snapshot.restore',
|
|
39
|
+
'snapshot.delete',
|
|
40
|
+
'tour.set',
|
|
41
|
+
'ax.focus.set',
|
|
42
|
+
'ax.policy.set',
|
|
43
|
+
'ax.work.create',
|
|
44
|
+
'ax.work.update',
|
|
45
|
+
'ax.review.add',
|
|
46
|
+
'ax.review.update',
|
|
47
|
+
'ax.elicitation.respond',
|
|
48
|
+
'ax.mode.resolve',
|
|
49
|
+
'ax.steer',
|
|
50
|
+
'ax.event.record',
|
|
51
|
+
'ax.presence.set',
|
|
52
|
+
'ax.interaction.submit',
|
|
53
|
+
'intent.clear',
|
|
54
|
+
'import.request',
|
|
55
|
+
'import.cancel',
|
|
56
|
+
'import.commit',
|
|
57
|
+
'export.run',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
function readableAsset(path: string): boolean {
|
|
61
|
+
return (
|
|
62
|
+
[
|
|
63
|
+
'/health',
|
|
64
|
+
'/api/workbench/poll',
|
|
65
|
+
'/api/workbench/state',
|
|
66
|
+
'/api/file',
|
|
67
|
+
'/api/canvas/json-render/view',
|
|
68
|
+
'/api/canvas/file-bytes',
|
|
69
|
+
].includes(path) ||
|
|
70
|
+
/^\/api\/canvas\/(surface|image|frame-documents|exports)\/[^/]+$/.test(path) ||
|
|
71
|
+
/^\/api\/canvas\/attachments\/[^/]+\/bytes$/.test(path)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** HTTP-shaped replies let the real workbench retain its existing API contracts. */
|
|
76
|
+
export function registerWorkbenchApp(server: McpServer, ensureCanvas: () => Promise<CanvasAccess>): void {
|
|
77
|
+
server.registerTool(
|
|
78
|
+
'pmx_workbench_request',
|
|
79
|
+
{
|
|
80
|
+
title: 'PMX workbench transport',
|
|
81
|
+
description: 'App-only transport for the PMX workbench. Not an assistant tool.',
|
|
82
|
+
inputSchema: {
|
|
83
|
+
path: z.string().max(8192),
|
|
84
|
+
method: z.enum(['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'HEAD']),
|
|
85
|
+
body: z.string().max(4_000_000).optional(),
|
|
86
|
+
expectedBoardId: z.string().nullable(),
|
|
87
|
+
},
|
|
88
|
+
// Workbench webpage/image actions can fetch user-chosen external URLs.
|
|
89
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
90
|
+
_meta: { ui: { visibility: ['app'] } },
|
|
91
|
+
},
|
|
92
|
+
async (input) => {
|
|
93
|
+
const reply = (status: number, body: string, contentType = 'application/json', encoding = 'text') => ({
|
|
94
|
+
content: [
|
|
95
|
+
{ type: 'text' as const, text: status < 400 ? 'Workbench request completed.' : 'Workbench request refused.' },
|
|
96
|
+
],
|
|
97
|
+
_meta: { response: { status, body, contentType, encoding } },
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
const canvas = await ensureCanvas();
|
|
101
|
+
const base = canvas.remoteBaseUrl ?? `http://127.0.0.1:${canvas.port}`;
|
|
102
|
+
const url = new URL(input.path, base);
|
|
103
|
+
if (
|
|
104
|
+
!input.path.startsWith('/') ||
|
|
105
|
+
input.path.startsWith('//') ||
|
|
106
|
+
input.path.includes('\\') ||
|
|
107
|
+
url.origin !== new URL(base).origin ||
|
|
108
|
+
url.username ||
|
|
109
|
+
url.password ||
|
|
110
|
+
url.hash
|
|
111
|
+
) {
|
|
112
|
+
throw new OperationError('Only local PMX workbench paths are allowed.', 403);
|
|
113
|
+
}
|
|
114
|
+
const req = new Request(url, {
|
|
115
|
+
method: input.method,
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
...(input.body === undefined ? {} : { body: input.body }),
|
|
118
|
+
});
|
|
119
|
+
for (const op of listOperations()) {
|
|
120
|
+
const route = op.http;
|
|
121
|
+
if (!route || route.method !== input.method) continue;
|
|
122
|
+
const params = matchOperationPath(route.path, url.pathname);
|
|
123
|
+
if (!params) continue;
|
|
124
|
+
if (!url.pathname.startsWith('/api/canvas/') || (input.method !== 'GET' && !writes.has(op.name)))
|
|
125
|
+
throw new OperationError('This action requires the local PMX workbench.', 403);
|
|
126
|
+
const args = route.readInput
|
|
127
|
+
? await route.readInput(req, params, url)
|
|
128
|
+
: await readOperationInput(req, params, url);
|
|
129
|
+
if (input.method !== 'GET') args.expectedBoardId = input.expectedBoardId;
|
|
130
|
+
// This profile is one agent transport, never browser/system authority
|
|
131
|
+
// or a caller-selected writer whose wider territory could bypass a fence.
|
|
132
|
+
args.source = 'mcp';
|
|
133
|
+
delete args.agentId;
|
|
134
|
+
if (
|
|
135
|
+
op.name === 'ax.interaction.submit' &&
|
|
136
|
+
(args.type === 'ax.approval.resolve' || args.type === 'ax.flow.materialize')
|
|
137
|
+
)
|
|
138
|
+
throw new OperationError('Resolve approvals and materialize flows in the local PMX workbench.', 403);
|
|
139
|
+
if (op.name === 'node.add' || op.name === 'node.update') {
|
|
140
|
+
const data = args.data;
|
|
141
|
+
const nodeType = op.name === 'node.add' ? args.type : (await canvas.getNode(String(args.id)))?.type;
|
|
142
|
+
// Trace tool names describe past activity; they never launch a backend.
|
|
143
|
+
const launchFields = ['transportConfig', 'toolInput', 'serverName', 'appSessionId'];
|
|
144
|
+
if (nodeType !== 'trace') launchFields.push('toolName');
|
|
145
|
+
if (
|
|
146
|
+
(op.name === 'node.add' && args.type === 'mcp-app') ||
|
|
147
|
+
(data && typeof data === 'object' && launchFields.some((field) => field in data))
|
|
148
|
+
)
|
|
149
|
+
throw new OperationError('Configure external MCP apps in the local PMX workbench.', 403);
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
op.name === 'node.update' &&
|
|
153
|
+
('content' in args || (args.data && typeof args.data === 'object' && 'content' in args.data))
|
|
154
|
+
) {
|
|
155
|
+
if (typeof args.expectedContentRevision !== 'number')
|
|
156
|
+
throw new OperationError('Read the note before editing: a content revision is required.', 409);
|
|
157
|
+
const node = await canvas.getNode(String(args.id));
|
|
158
|
+
if (node?.data.path) throw new OperationError('Edit file-linked content in the local PMX workbench.', 403);
|
|
159
|
+
}
|
|
160
|
+
const result = await canvas.invoker().invoke(op.name, args);
|
|
161
|
+
return reply(route.status ? route.status(result) : 200, JSON.stringify(result));
|
|
162
|
+
}
|
|
163
|
+
if (
|
|
164
|
+
!((input.method === 'GET' || input.method === 'HEAD') && readableAsset(url.pathname)) &&
|
|
165
|
+
!(input.method === 'POST' && url.pathname === '/api/render')
|
|
166
|
+
) {
|
|
167
|
+
throw new OperationError('This action requires the local PMX workbench.', 403);
|
|
168
|
+
}
|
|
169
|
+
// Resolve only PMX's internal surface redirects. Never follow to arbitrary
|
|
170
|
+
// hosts or return the workbench HTML (which carries its human boot token).
|
|
171
|
+
let target = url;
|
|
172
|
+
let response: Response | undefined;
|
|
173
|
+
for (let redirects = 0; redirects < 3; redirects++) {
|
|
174
|
+
response = await fetch(target, {
|
|
175
|
+
method: input.method,
|
|
176
|
+
body: input.body,
|
|
177
|
+
headers: { 'Content-Type': 'application/json' },
|
|
178
|
+
redirect: 'manual',
|
|
179
|
+
signal: AbortSignal.timeout(15_000),
|
|
180
|
+
});
|
|
181
|
+
if (response.status < 300 || response.status >= 400) break;
|
|
182
|
+
const next = new URL(response.headers.get('location') ?? '', target);
|
|
183
|
+
if (next.origin !== url.origin || !readableAsset(next.pathname))
|
|
184
|
+
throw new OperationError('External surfaces must be opened outside the embedded workbench.', 403);
|
|
185
|
+
target = next;
|
|
186
|
+
}
|
|
187
|
+
if (!response || (response.status >= 300 && response.status < 400))
|
|
188
|
+
throw new OperationError('Could not resolve the PMX surface.');
|
|
189
|
+
const bytes = await response.arrayBuffer();
|
|
190
|
+
if (bytes.byteLength > 24 * 1024 * 1024)
|
|
191
|
+
throw new OperationError('This asset is too large for the MCP connection.');
|
|
192
|
+
const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
|
|
193
|
+
const text = /^(text\/|application\/(json|javascript))/.test(contentType);
|
|
194
|
+
return reply(
|
|
195
|
+
response.status,
|
|
196
|
+
text ? new TextDecoder().decode(bytes) : Buffer.from(bytes).toString('base64'),
|
|
197
|
+
contentType,
|
|
198
|
+
text ? 'text' : 'base64',
|
|
199
|
+
);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error instanceof OperationError)
|
|
202
|
+
return reply(error.status, JSON.stringify({ ok: false, error: error.message }));
|
|
203
|
+
console.error('[workbench-app] request failed', error);
|
|
204
|
+
return reply(
|
|
205
|
+
502,
|
|
206
|
+
JSON.stringify({ ok: false, error: 'PMX workbench request failed. Check the daemon and retry.' }),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
PRESENCE_TOOLING_SETTLE_MS,
|
|
33
33
|
TRANSPORT_SOURCES,
|
|
34
34
|
} from '../shared/agent-presence.js';
|
|
35
|
-
import { serializeNodeForAgentContext } from '
|
|
35
|
+
import { serializeNodeForAgentContext } from '../shared/agent-context.js';
|
|
36
36
|
import type { PmxAxActivityKind } from './ax-state.js';
|
|
37
37
|
import { type CanvasNodeState, canvasState } from './canvas-state.js';
|
|
38
38
|
import { OperationError } from './operations/types.js';
|
|
@@ -205,6 +205,8 @@ interface StoredPresence extends AgentPresence {
|
|
|
205
205
|
lastActivityNodeId: string | null;
|
|
206
206
|
/** Snapshot of the board taken when this session attached (the receipt diffs against it). */
|
|
207
207
|
startSnapshotId: string | null;
|
|
208
|
+
/** When this session attached (ISO); the receipt counts reads and pins from here. */
|
|
209
|
+
startedAt: string | null;
|
|
208
210
|
}
|
|
209
211
|
|
|
210
212
|
function presenceKey(source: string, agentId: string | null | undefined): string {
|
|
@@ -230,7 +232,12 @@ export function estimateContextBudget(): ContextBudget {
|
|
|
230
232
|
/** Returns the id of the pre-session snapshot the server took, if any. */
|
|
231
233
|
type SessionStartListener = (presence: AgentPresence) => string | null;
|
|
232
234
|
export type SessionEndReason = 'human' | 'agent' | 'idle-timeout';
|
|
233
|
-
type SessionEndListener = (
|
|
235
|
+
type SessionEndListener = (
|
|
236
|
+
presence: AgentPresence,
|
|
237
|
+
startSnapshotId: string | null,
|
|
238
|
+
endedBy: SessionEndReason,
|
|
239
|
+
startedAt: string | null,
|
|
240
|
+
) => void;
|
|
234
241
|
|
|
235
242
|
export class AgentPresenceRegistry {
|
|
236
243
|
private readonly presences = new Map<string, StoredPresence>();
|
|
@@ -392,6 +399,7 @@ export class AgentPresenceRegistry {
|
|
|
392
399
|
phaseSetMs: now,
|
|
393
400
|
toolingUntilMs: null,
|
|
394
401
|
startSnapshotId: null,
|
|
402
|
+
startedAt: null,
|
|
395
403
|
};
|
|
396
404
|
const wasAttached = stored.attached;
|
|
397
405
|
stored.lastSeenMs = now;
|
|
@@ -448,6 +456,7 @@ export class AgentPresenceRegistry {
|
|
|
448
456
|
// moment stays the receipt baseline via the inherited snapshot. A
|
|
449
457
|
// placeholder that already adopted work is a real session and stays.
|
|
450
458
|
let inheritedSnapshotId: string | null = null;
|
|
459
|
+
let inheritedStartedAt: string | null = null;
|
|
451
460
|
if (stored.source !== 'browser') {
|
|
452
461
|
const placeholder = [...this.presences.values()].find(
|
|
453
462
|
(presence) =>
|
|
@@ -463,13 +472,20 @@ export class AgentPresenceRegistry {
|
|
|
463
472
|
if (target === placeholder.sessionId) this.aliases.set(channel, key);
|
|
464
473
|
}
|
|
465
474
|
inheritedSnapshotId = placeholder.startSnapshotId;
|
|
475
|
+
inheritedStartedAt = placeholder.startedAt;
|
|
466
476
|
}
|
|
467
477
|
}
|
|
468
478
|
stored.startSnapshotId = inheritedSnapshotId ?? this.onSessionStart(this.publicView(stored, now));
|
|
479
|
+
stored.startedAt = inheritedStartedAt ?? new Date(now).toISOString();
|
|
469
480
|
}
|
|
470
481
|
if (input.attached === false) {
|
|
471
482
|
if (wasAttached)
|
|
472
|
-
this.onSessionEnd(
|
|
483
|
+
this.onSessionEnd(
|
|
484
|
+
this.publicView(stored, now),
|
|
485
|
+
stored.startSnapshotId,
|
|
486
|
+
input.endedBy ?? 'agent',
|
|
487
|
+
stored.startedAt,
|
|
488
|
+
);
|
|
473
489
|
// An explicit detach always removes the presence. In particular, a
|
|
474
490
|
// worker that reports attached:false must not be refreshed into another
|
|
475
491
|
// full activity TTL as an external writer.
|
|
@@ -481,7 +497,24 @@ export class AgentPresenceRegistry {
|
|
|
481
497
|
}
|
|
482
498
|
if (input.focusNodeId !== undefined) stored.focusNodeId = input.focusNodeId;
|
|
483
499
|
if (input.parentAgentId !== undefined) stored.parentAgentId = input.parentAgentId;
|
|
484
|
-
if (input.cursor !== undefined)
|
|
500
|
+
if (input.cursor !== undefined) {
|
|
501
|
+
// A MOVING cursor is the agent visibly working: it keeps a live explicit
|
|
502
|
+
// `tooling` from settling (a host streaming cursor moves after one
|
|
503
|
+
// `tooling` report read as Idle within seconds). A stationary heartbeat
|
|
504
|
+
// is not activity, and `thinking` keeps its own longer clock (below).
|
|
505
|
+
const moved =
|
|
506
|
+
input.cursor !== null && (stored.cursor?.x !== input.cursor.x || stored.cursor?.y !== input.cursor.y);
|
|
507
|
+
if (
|
|
508
|
+
moved &&
|
|
509
|
+
input.phase === undefined &&
|
|
510
|
+
stored.phase === 'tooling' &&
|
|
511
|
+
stored.toolingUntilMs !== null &&
|
|
512
|
+
stored.toolingUntilMs > now
|
|
513
|
+
) {
|
|
514
|
+
stored.toolingUntilMs = now + PRESENCE_TOOLING_SETTLE_MS;
|
|
515
|
+
}
|
|
516
|
+
stored.cursor = input.cursor;
|
|
517
|
+
}
|
|
485
518
|
if (input.contextUsage !== undefined) stored.contextUsage = input.contextUsage;
|
|
486
519
|
if (input.phase === 'tooling' && input.derivedTooling) {
|
|
487
520
|
// Copilot's finding: an op-derived tooling touch used to CLOBBER the
|
|
@@ -585,7 +618,8 @@ export class AgentPresenceRegistry {
|
|
|
585
618
|
const removed = this.presences.delete(sessionId);
|
|
586
619
|
if (removed) {
|
|
587
620
|
this.dropAliasesTo(sessionId);
|
|
588
|
-
if (stored?.attached)
|
|
621
|
+
if (stored?.attached)
|
|
622
|
+
this.onSessionEnd(this.publicView(stored, Date.now()), stored.startSnapshotId, 'agent', stored.startedAt);
|
|
589
623
|
this.scheduleEmit();
|
|
590
624
|
}
|
|
591
625
|
this.maybeStopSweeper();
|
|
@@ -700,7 +734,12 @@ export class AgentPresenceRegistry {
|
|
|
700
734
|
this.presences.delete(key);
|
|
701
735
|
this.dropAliasesTo(key);
|
|
702
736
|
if (presence.attached)
|
|
703
|
-
this.onSessionEnd(
|
|
737
|
+
this.onSessionEnd(
|
|
738
|
+
this.publicView(presence, now),
|
|
739
|
+
presence.startSnapshotId,
|
|
740
|
+
'idle-timeout',
|
|
741
|
+
presence.startedAt,
|
|
742
|
+
);
|
|
704
743
|
changed = true;
|
|
705
744
|
continue;
|
|
706
745
|
}
|
package/src/server/ax-context.ts
CHANGED
package/src/server/ax-state.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
|
|
2
2
|
import { clampGateTtlMs, DEFAULT_GATE_TTL_MS } from '../shared/approval-gates.js';
|
|
3
3
|
import type { AxApprovalStatus, AxEventKind, AxWorkItemStatus } from '../shared/ax-kinds.js';
|
|
4
|
-
import type { AgentContextNode } from '
|
|
4
|
+
import type { AgentContextNode } from '../shared/agent-context.js';
|
|
5
5
|
import type { ActorAttribution } from './attribution.js';
|
|
6
6
|
|
|
7
7
|
export type PmxAxSource = 'agent' | 'amp' | 'api' | 'browser' | 'cli' | 'codex' | 'copilot' | 'mcp' | 'sdk' | 'system';
|