dev3000 0.0.91 → 0.0.93
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/dist/screencast-manager.d.ts +1 -0
- package/dist/screencast-manager.d.ts.map +1 -1
- package/dist/screencast-manager.js +29 -4
- package/dist/screencast-manager.js.map +1 -1
- package/dist/src/tui-interface-impl.tsx +28 -13
- package/dist/tui-interface-impl.d.ts.map +1 -1
- package/dist/tui-interface-impl.js +23 -11
- package/dist/tui-interface-impl.js.map +1 -1
- package/mcp-server/.next/BUILD_ID +1 -1
- package/mcp-server/.next/build-manifest.json +2 -2
- package/mcp-server/.next/fallback-build-manifest.json +2 -2
- package/mcp-server/.next/next-minimal-server.js.nft.json +1 -1
- package/mcp-server/.next/next-server.js.nft.json +1 -1
- package/mcp-server/.next/prerender-manifest.json +3 -3
- package/mcp-server/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/mcp-server/.next/server/app/_global-error.html +2 -2
- package/mcp-server/.next/server/app/_global-error.rsc +1 -1
- package/mcp-server/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/mcp-server/.next/server/app/_not-found.html +1 -1
- package/mcp-server/.next/server/app/_not-found.rsc +1 -1
- package/mcp-server/.next/server/app/api/jank/[session]/route.js.nft.json +1 -1
- package/mcp-server/.next/server/app/api/logs/append/route.js.nft.json +1 -1
- package/mcp-server/.next/server/app/api/logs/head/route.js.nft.json +1 -1
- package/mcp-server/.next/server/app/api/logs/list/route.js.nft.json +1 -1
- package/mcp-server/.next/server/app/api/logs/rotate/route.js.nft.json +1 -1
- package/mcp-server/.next/server/app/api/logs/stream/route.js.nft.json +1 -1
- package/mcp-server/.next/server/app/api/logs/tail/route.js.nft.json +1 -1
- package/mcp-server/.next/server/app/api/orchestrator/route.js.nft.json +1 -1
- package/mcp-server/.next/server/app/api/screenshots/[filename]/route.js.nft.json +1 -1
- package/mcp-server/.next/server/app/api/screenshots/list/route.js.nft.json +1 -1
- package/mcp-server/.next/server/app/api/tools/route.js.nft.json +1 -1
- package/mcp-server/.next/server/app/index.html +1 -1
- package/mcp-server/.next/server/app/index.rsc +1 -1
- package/mcp-server/.next/server/app/logs/page.js.nft.json +1 -1
- package/mcp-server/.next/server/app/mcp/route.js.nft.json +1 -1
- package/mcp-server/.next/server/app/page.js.nft.json +1 -1
- package/mcp-server/.next/server/app/video/[session]/page.js.nft.json +1 -1
- package/mcp-server/.next/server/chunks/[root-of-the-server]__94037b23._.js +2 -2
- package/mcp-server/.next/server/chunks/[root-of-the-server]__94037b23._.js.map +1 -1
- package/mcp-server/.next/server/chunks/[root-of-the-server]__ae49815f._.js +6 -6
- package/mcp-server/.next/server/chunks/[root-of-the-server]__ae49815f._.js.map +1 -1
- package/mcp-server/.next/server/chunks/[root-of-the-server]__cc74dbef._.js +2 -2
- package/mcp-server/.next/server/chunks/[root-of-the-server]__cc74dbef._.js.map +1 -1
- package/mcp-server/.next/server/chunks/[root-of-the-server]__dc0b0022._.js +1 -1
- package/mcp-server/.next/server/chunks/[root-of-the-server]__dc0b0022._.js.map +1 -1
- package/mcp-server/.next/server/server-reference-manifest.js +1 -1
- package/mcp-server/.next/server/server-reference-manifest.json +1 -1
- package/mcp-server/app/mcp/client-manager.ts +76 -23
- package/mcp-server/app/mcp/route.ts +140 -15
- package/mcp-server/app/mcp/tools.ts +151 -24
- package/package.json +1 -1
- package/src/tui-interface-impl.tsx +28 -13
- /package/mcp-server/.next/static/{eVL_05d0pOH_qw2twMoct → JCjxP4rkK3FXLwJCSQET6}/_buildManifest.js +0 -0
- /package/mcp-server/.next/static/{eVL_05d0pOH_qw2twMoct → JCjxP4rkK3FXLwJCSQET6}/_clientMiddlewareManifest.json +0 -0
- /package/mcp-server/.next/static/{eVL_05d0pOH_qw2twMoct → JCjxP4rkK3FXLwJCSQET6}/_ssgManifest.js +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"
|
|
1
|
+
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"rm261543LMD/+EU2F6XJHyupyv2BrageVvOxdXx78XI=\"\n}"
|
|
@@ -32,6 +32,8 @@ export class MCPClientManager {
|
|
|
32
32
|
private tools: Map<string, Tool[]> = new Map()
|
|
33
33
|
private reconnectTimers: Map<string, NodeJS.Timeout> = new Map()
|
|
34
34
|
private lastConfigs: Map<string, MCPClientConfig> = new Map()
|
|
35
|
+
private toolDiscoveryListeners: Array<() => void> = []
|
|
36
|
+
private toolUpdateSubscribers: Array<(info: { mcpName: string; tools: Tool[] }) => void> = []
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* Initialize MCP clients for available downstream servers
|
|
@@ -118,26 +120,23 @@ export class MCPClientManager {
|
|
|
118
120
|
|
|
119
121
|
// Discover available tools (non-fatal - connection already succeeded)
|
|
120
122
|
try {
|
|
121
|
-
const toolsResult = await client.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (toolsResult && "tools" in toolsResult) {
|
|
130
|
-
this.tools.set(config.name, toolsResult.tools as Tool[])
|
|
131
|
-
console.log(`[MCP Orchestrator] Discovered ${toolsResult.tools.length} tools from ${config.name}`)
|
|
123
|
+
const toolsResult = await client.listTools()
|
|
124
|
+
const discoveredTools = Array.isArray(toolsResult?.tools) ? (toolsResult.tools as Tool[]) : []
|
|
125
|
+
|
|
126
|
+
this.tools.set(config.name, discoveredTools)
|
|
127
|
+
|
|
128
|
+
if (discoveredTools.length > 0) {
|
|
129
|
+
console.log(`[MCP Orchestrator] Discovered ${discoveredTools.length} tools from ${config.name}`)
|
|
130
|
+
this.notifyToolDiscovery()
|
|
132
131
|
} else {
|
|
133
|
-
// Set empty array if no tools found
|
|
134
|
-
this.tools.set(config.name, [])
|
|
135
132
|
console.log(`[MCP Orchestrator] No tools discovered from ${config.name} (will retry on first use)`)
|
|
136
133
|
}
|
|
134
|
+
this.notifyToolsUpdated(config.name)
|
|
137
135
|
} catch (_error) {
|
|
138
136
|
// Tool discovery failed but connection succeeded - tools will be discovered on first use
|
|
139
137
|
this.tools.set(config.name, [])
|
|
140
138
|
console.log(`[MCP Orchestrator] Tool discovery deferred for ${config.name} (will discover on first tool call)`)
|
|
139
|
+
this.notifyToolsUpdated(config.name)
|
|
141
140
|
}
|
|
142
141
|
}
|
|
143
142
|
|
|
@@ -167,16 +166,10 @@ export class MCPClientManager {
|
|
|
167
166
|
}
|
|
168
167
|
|
|
169
168
|
try {
|
|
170
|
-
const result = await client.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
name: toolName,
|
|
175
|
-
arguments: args
|
|
176
|
-
}
|
|
177
|
-
},
|
|
178
|
-
undefined as any // Type inference will work at runtime
|
|
179
|
-
)
|
|
169
|
+
const result = await client.callTool({
|
|
170
|
+
name: toolName,
|
|
171
|
+
arguments: args
|
|
172
|
+
})
|
|
180
173
|
|
|
181
174
|
return result as CallToolResult
|
|
182
175
|
} catch (error) {
|
|
@@ -199,6 +192,66 @@ export class MCPClientManager {
|
|
|
199
192
|
return Array.from(this.clients.keys())
|
|
200
193
|
}
|
|
201
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Subscribe to tool updates for a specific MCP. Returns an unsubscribe function.
|
|
197
|
+
*/
|
|
198
|
+
onToolsUpdated(subscriber: (info: { mcpName: string; tools: Tool[] }) => void): () => void {
|
|
199
|
+
this.toolUpdateSubscribers.push(subscriber)
|
|
200
|
+
return () => {
|
|
201
|
+
this.toolUpdateSubscribers = this.toolUpdateSubscribers.filter((fn) => fn !== subscriber)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Wait for at least one downstream MCP to finish tool discovery, or resolve after a timeout.
|
|
207
|
+
* Prevents registering an empty toolset before downstream servers are ready.
|
|
208
|
+
*/
|
|
209
|
+
async waitForInitialTools(timeoutMs: number = 8000): Promise<void> {
|
|
210
|
+
if (this.getAllTools().length > 0) {
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await new Promise<void>((resolve) => {
|
|
215
|
+
let timeoutId: NodeJS.Timeout
|
|
216
|
+
|
|
217
|
+
const onDiscovery = () => {
|
|
218
|
+
clearTimeout(timeoutId)
|
|
219
|
+
this.toolDiscoveryListeners = this.toolDiscoveryListeners.filter((listener) => listener !== onDiscovery)
|
|
220
|
+
resolve()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
timeoutId = setTimeout(() => {
|
|
224
|
+
this.toolDiscoveryListeners = this.toolDiscoveryListeners.filter((listener) => listener !== onDiscovery)
|
|
225
|
+
resolve()
|
|
226
|
+
}, timeoutMs)
|
|
227
|
+
|
|
228
|
+
this.toolDiscoveryListeners.push(onDiscovery)
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private notifyToolDiscovery(): void {
|
|
233
|
+
if (this.toolDiscoveryListeners.length === 0 || this.getAllTools().length === 0) {
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const listeners = [...this.toolDiscoveryListeners]
|
|
238
|
+
this.toolDiscoveryListeners = []
|
|
239
|
+
for (const listener of listeners) {
|
|
240
|
+
listener()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private notifyToolsUpdated(mcpName: string): void {
|
|
245
|
+
const tools = this.tools.get(mcpName) ?? []
|
|
246
|
+
for (const subscriber of this.toolUpdateSubscribers) {
|
|
247
|
+
try {
|
|
248
|
+
subscriber({ mcpName, tools })
|
|
249
|
+
} catch (error) {
|
|
250
|
+
console.warn(`[MCP Orchestrator] Tool update subscriber error for ${mcpName}:`, error)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
202
255
|
/**
|
|
203
256
|
* Schedule reconnection for a disconnected MCP
|
|
204
257
|
*/
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs"
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
3
|
import { join } from "node:path"
|
|
4
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js"
|
|
4
5
|
import { createMcpHandler } from "mcp-handler"
|
|
5
6
|
import { z } from "zod"
|
|
6
7
|
import { getMCPClientManager } from "./client-manager"
|
|
@@ -95,14 +96,39 @@ const initializeOrchestration = async () => {
|
|
|
95
96
|
return config
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
const waitForInitialConfig = async (
|
|
100
|
+
timeoutMs: number = 10000,
|
|
101
|
+
pollIntervalMs: number = 250
|
|
102
|
+
): Promise<{ config: Parameters<typeof clientManager.initialize>[0]; waited: boolean }> => {
|
|
103
|
+
const startTime = Date.now()
|
|
104
|
+
let waited = false
|
|
105
|
+
let config = getConfigFromSessions()
|
|
106
|
+
|
|
107
|
+
while (Object.keys(config).length === 0 && Date.now() - startTime < timeoutMs) {
|
|
108
|
+
if (!waited) {
|
|
109
|
+
console.log("[MCP Orchestrator] Waiting for session info before connecting downstream MCPs...")
|
|
110
|
+
waited = true
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
|
|
114
|
+
config = getConfigFromSessions()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { config, waited }
|
|
118
|
+
}
|
|
119
|
+
|
|
98
120
|
try {
|
|
99
121
|
// Initial attempt to connect
|
|
100
|
-
const config =
|
|
122
|
+
const { config, waited } = await waitForInitialConfig()
|
|
101
123
|
if (Object.keys(config).length > 0) {
|
|
102
124
|
await clientManager.initialize(config)
|
|
103
125
|
console.log(`[MCP Orchestrator] Initialized with ${Object.keys(config).join(", ")}`)
|
|
104
126
|
} else {
|
|
105
|
-
|
|
127
|
+
if (waited) {
|
|
128
|
+
console.log("[MCP Orchestrator] No downstream MCPs detected after waiting for session info (will retry)")
|
|
129
|
+
} else {
|
|
130
|
+
console.log("[MCP Orchestrator] No downstream MCPs found yet (will retry)")
|
|
131
|
+
}
|
|
106
132
|
}
|
|
107
133
|
|
|
108
134
|
// Since MCP server starts before Chrome, periodically retry connection
|
|
@@ -146,29 +172,128 @@ const initializeOrchestration = async () => {
|
|
|
146
172
|
}
|
|
147
173
|
|
|
148
174
|
// Initialize on module load
|
|
149
|
-
initializeOrchestration().catch(
|
|
175
|
+
const orchestrationReady = initializeOrchestration().catch((error) => {
|
|
176
|
+
console.error("[MCP Orchestrator] Failed to initialize downstream MCPs:", error)
|
|
177
|
+
})
|
|
150
178
|
|
|
151
179
|
const handler = createMcpHandler(
|
|
152
|
-
(server) => {
|
|
153
|
-
// Dynamically register proxied tools from downstream MCPs
|
|
180
|
+
async (server) => {
|
|
154
181
|
const clientManager = getMCPClientManager()
|
|
155
|
-
const downstreamTools = clientManager.getAllTools()
|
|
156
182
|
|
|
157
|
-
|
|
158
|
-
|
|
183
|
+
await orchestrationReady
|
|
184
|
+
await clientManager.waitForInitialTools()
|
|
185
|
+
|
|
186
|
+
const registeredProxiedTools = new Map<
|
|
187
|
+
string,
|
|
188
|
+
{
|
|
189
|
+
mcpName: string
|
|
190
|
+
toolName: string
|
|
191
|
+
registered: ReturnType<typeof server.tool>
|
|
192
|
+
}
|
|
193
|
+
>()
|
|
194
|
+
|
|
195
|
+
const registerOrUpdateProxiedTool = (mcpName: string, tool: Tool): boolean => {
|
|
159
196
|
const proxiedToolName = `${mcpName}_${tool.name}`
|
|
197
|
+
const existing = registeredProxiedTools.get(proxiedToolName)
|
|
198
|
+
const description = `[${mcpName}] ${tool.description || ""}`
|
|
199
|
+
const annotations = {
|
|
200
|
+
...(tool.annotations ?? {}),
|
|
201
|
+
proxiedFrom: mcpName,
|
|
202
|
+
originalInputSchema: tool.inputSchema
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (existing) {
|
|
206
|
+
existing.registered.update({
|
|
207
|
+
description,
|
|
208
|
+
annotations
|
|
209
|
+
})
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
160
212
|
|
|
161
|
-
|
|
162
|
-
proxiedToolName,
|
|
163
|
-
`[${mcpName}] ${tool.description || ""}`,
|
|
164
|
-
tool.inputSchema as any,
|
|
165
|
-
async (params: Record<string, unknown>) => {
|
|
166
|
-
// Proxy the call to the downstream MCP
|
|
213
|
+
try {
|
|
214
|
+
const proxiedTool = server.tool(proxiedToolName, description, {}, async (params: Record<string, unknown>) => {
|
|
167
215
|
return clientManager.callTool(mcpName, tool.name, params)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Allow arbitrary argument objects to pass through to downstream MCPs
|
|
219
|
+
proxiedTool.inputSchema = z.object({}).passthrough()
|
|
220
|
+
|
|
221
|
+
proxiedTool.update({
|
|
222
|
+
annotations
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
registeredProxiedTools.set(proxiedToolName, {
|
|
226
|
+
mcpName,
|
|
227
|
+
toolName: tool.name,
|
|
228
|
+
registered: proxiedTool
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
console.log(`[MCP Orchestrator] Registered proxied tool ${proxiedToolName}`)
|
|
232
|
+
return true
|
|
233
|
+
} catch (error) {
|
|
234
|
+
console.warn(`[MCP Orchestrator] Failed to register proxied tool ${proxiedToolName}:`, error)
|
|
235
|
+
return false
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const removeToolsForMcp = (mcpName: string): number => {
|
|
240
|
+
let removed = 0
|
|
241
|
+
for (const [proxiedToolName, entry] of registeredProxiedTools.entries()) {
|
|
242
|
+
if (entry.mcpName === mcpName) {
|
|
243
|
+
try {
|
|
244
|
+
entry.registered.remove()
|
|
245
|
+
registeredProxiedTools.delete(proxiedToolName)
|
|
246
|
+
removed++
|
|
247
|
+
console.log(`[MCP Orchestrator] Removed proxied tool ${proxiedToolName}`)
|
|
248
|
+
} catch (error) {
|
|
249
|
+
console.warn(`[MCP Orchestrator] Failed to remove proxied tool ${proxiedToolName}:`, error)
|
|
250
|
+
}
|
|
168
251
|
}
|
|
169
|
-
|
|
252
|
+
}
|
|
253
|
+
return removed
|
|
170
254
|
}
|
|
171
255
|
|
|
256
|
+
// Dynamically register proxied tools from downstream MCPs
|
|
257
|
+
const downstreamTools = clientManager.getAllTools()
|
|
258
|
+
|
|
259
|
+
if (downstreamTools.length === 0) {
|
|
260
|
+
console.log("[MCP Orchestrator] No downstream MCP tools available during initial registration")
|
|
261
|
+
} else {
|
|
262
|
+
console.log(`[MCP Orchestrator] Registering ${downstreamTools.length} downstream MCP tools`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let initialNewTools = 0
|
|
266
|
+
for (const { mcpName, tool } of downstreamTools) {
|
|
267
|
+
if (registerOrUpdateProxiedTool(mcpName, tool)) {
|
|
268
|
+
initialNewTools++
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (initialNewTools > 0) {
|
|
273
|
+
server.sendToolListChanged()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
clientManager.onToolsUpdated(({ mcpName, tools }) => {
|
|
277
|
+
if (tools.length === 0) {
|
|
278
|
+
const removed = removeToolsForMcp(mcpName)
|
|
279
|
+
if (removed > 0) {
|
|
280
|
+
server.sendToolListChanged()
|
|
281
|
+
}
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let addedOrUpdated = 0
|
|
286
|
+
for (const tool of tools) {
|
|
287
|
+
if (registerOrUpdateProxiedTool(mcpName, tool)) {
|
|
288
|
+
addedOrUpdated++
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (addedOrUpdated > 0) {
|
|
293
|
+
server.sendToolListChanged()
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
172
297
|
// Dev3000's own tools below:
|
|
173
298
|
// Enhanced fix_my_app - the ultimate error fixing tool
|
|
174
299
|
server.tool(
|
|
@@ -669,7 +669,13 @@ export async function fixMyApp({
|
|
|
669
669
|
)
|
|
670
670
|
}
|
|
671
671
|
|
|
672
|
-
|
|
672
|
+
const triggerLabel =
|
|
673
|
+
jankResult.captureTrigger === "navigation"
|
|
674
|
+
? "Navigation complete"
|
|
675
|
+
: jankResult.captureTrigger === "load"
|
|
676
|
+
? "Load complete"
|
|
677
|
+
: "View all frames"
|
|
678
|
+
results.push(`📹 **${triggerLabel}**: ${videoUrl}`)
|
|
673
679
|
results.push(`🎞️ **Session ID**: ${jankResult.sessionId} (${jankResult.totalFrames} frames)`)
|
|
674
680
|
results.push("")
|
|
675
681
|
|
|
@@ -2122,48 +2128,128 @@ export async function getMcpCapabilities({
|
|
|
2122
2128
|
* - Layout shifts: Elements move to new positions (top region changes while bottom stays same)
|
|
2123
2129
|
* - Content changes: Same regions change in-place (image loads with pixels appearing)
|
|
2124
2130
|
*/
|
|
2125
|
-
function detectLayoutShiftVsContentChange(
|
|
2131
|
+
function detectLayoutShiftVsContentChange(
|
|
2132
|
+
prevPng: PNG,
|
|
2133
|
+
currPng: PNG
|
|
2134
|
+
): { isLayoutShift: boolean; shiftScore: number; isOverlayNoise: boolean } {
|
|
2126
2135
|
const width = prevPng.width
|
|
2127
2136
|
const height = prevPng.height
|
|
2128
2137
|
|
|
2129
|
-
//
|
|
2130
|
-
const
|
|
2131
|
-
const bands = Array(8).fill(0)
|
|
2138
|
+
// Track changes at row-level for detecting correlated shifts
|
|
2139
|
+
const rowChangeCounts = new Array(height).fill(0)
|
|
2132
2140
|
|
|
2133
|
-
// Count changed pixels
|
|
2141
|
+
// Count changed pixels per row (for correlation analysis)
|
|
2134
2142
|
for (let y = 0; y < height; y++) {
|
|
2135
|
-
const bandIndex = Math.min(Math.floor(y / bandHeight), 7)
|
|
2136
2143
|
for (let x = 0; x < width; x++) {
|
|
2137
2144
|
const idx = (width * y + x) << 2
|
|
2138
2145
|
const rDiff = Math.abs(prevPng.data[idx] - currPng.data[idx])
|
|
2139
2146
|
const gDiff = Math.abs(prevPng.data[idx + 1] - currPng.data[idx + 1])
|
|
2140
2147
|
const bDiff = Math.abs(prevPng.data[idx + 2] - currPng.data[idx + 2])
|
|
2141
2148
|
|
|
2142
|
-
// If any channel differs significantly, count as changed pixel
|
|
2143
2149
|
if (rDiff > 30 || gDiff > 30 || bDiff > 30) {
|
|
2144
|
-
|
|
2150
|
+
rowChangeCounts[y]++
|
|
2145
2151
|
}
|
|
2146
2152
|
}
|
|
2147
2153
|
}
|
|
2148
2154
|
|
|
2149
|
-
// Calculate percentage of pixels changed
|
|
2155
|
+
// Calculate percentage of pixels changed per row
|
|
2156
|
+
const rowChangePercents = rowChangeCounts.map((count) => (count / width) * 100)
|
|
2157
|
+
|
|
2158
|
+
// Detect consecutive rows with high change (indicates shift boundary)
|
|
2159
|
+
// True CLS: Many consecutive rows change together (content moved as a block)
|
|
2160
|
+
let maxConsecutiveHighChangeRows = 0
|
|
2161
|
+
let currentConsecutive = 0
|
|
2162
|
+
|
|
2163
|
+
for (let i = 0; i < height; i++) {
|
|
2164
|
+
if (rowChangePercents[i] > 50) {
|
|
2165
|
+
// >50% of row changed
|
|
2166
|
+
currentConsecutive++
|
|
2167
|
+
maxConsecutiveHighChangeRows = Math.max(maxConsecutiveHighChangeRows, currentConsecutive)
|
|
2168
|
+
} else {
|
|
2169
|
+
currentConsecutive = 0
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
// Detect isolated hotspots (fixed/absolute overlay noise)
|
|
2174
|
+
// Pattern: low change → spike → low change (element appearing in place)
|
|
2175
|
+
let isolatedHotspots = 0
|
|
2176
|
+
const windowSize = 5
|
|
2177
|
+
|
|
2178
|
+
for (let i = windowSize; i < height - windowSize; i++) {
|
|
2179
|
+
// Calculate average change in windows before, during, and after
|
|
2180
|
+
const before = rowChangePercents.slice(i - windowSize, i).reduce((a, b) => a + b, 0) / windowSize
|
|
2181
|
+
const during = rowChangePercents[i]
|
|
2182
|
+
const after = rowChangePercents.slice(i + 1, i + windowSize + 1).reduce((a, b) => a + b, 0) / windowSize
|
|
2183
|
+
|
|
2184
|
+
// Isolated spike: calm before/after, high during
|
|
2185
|
+
if (before < 10 && during > 60 && after < 10) {
|
|
2186
|
+
isolatedHotspots++
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// Detect narrow fixed elements (toolbars, indicators)
|
|
2191
|
+
// Pattern: Many rows with LOW percentage change (5-25%) = narrow element across many rows
|
|
2192
|
+
// This catches toolbars/indicators that are thin but tall
|
|
2193
|
+
let narrowChangeRows = 0
|
|
2194
|
+
for (let i = 0; i < height; i++) {
|
|
2195
|
+
// Low but consistent change (narrow element)
|
|
2196
|
+
if (rowChangePercents[i] > 5 && rowChangePercents[i] < 25) {
|
|
2197
|
+
narrowChangeRows++
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// If many rows have narrow changes, this is likely a fixed toolbar/sidebar
|
|
2202
|
+
const hasNarrowFixedElement = narrowChangeRows > height * 0.3 // >30% of rows have narrow changes
|
|
2203
|
+
|
|
2204
|
+
// Calculate band-based metrics for backward compatibility
|
|
2205
|
+
const bandHeight = Math.floor(height / 8)
|
|
2206
|
+
const bands = Array(8).fill(0)
|
|
2207
|
+
|
|
2208
|
+
for (let y = 0; y < height; y++) {
|
|
2209
|
+
const bandIndex = Math.min(Math.floor(y / bandHeight), 7)
|
|
2210
|
+
bands[bandIndex] += rowChangeCounts[y]
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2150
2213
|
const pixelsPerBand = width * bandHeight
|
|
2151
2214
|
const bandPercentages = bands.map((count) => (count / pixelsPerBand) * 100)
|
|
2152
|
-
|
|
2153
|
-
// Layout shift pattern: High change in top bands (nav/header area), low change in bottom
|
|
2154
|
-
// Content change pattern: Evenly distributed or contained to specific regions
|
|
2155
2215
|
const topBandChange = (bandPercentages[0] + bandPercentages[1]) / 2
|
|
2156
2216
|
const bottomBandChange = (bandPercentages[6] + bandPercentages[7]) / 2
|
|
2157
2217
|
|
|
2158
|
-
//
|
|
2159
|
-
const
|
|
2160
|
-
|
|
2161
|
-
|
|
2218
|
+
// Calculate variance to detect if changes are uniform (shift) or scattered (overlay)
|
|
2219
|
+
const meanChange = bandPercentages.reduce((a, b) => a + b, 0) / bandPercentages.length
|
|
2220
|
+
const variance = bandPercentages.reduce((sum, val) => sum + (val - meanChange) ** 2, 0) / bandPercentages.length
|
|
2221
|
+
|
|
2222
|
+
// Determine if this is a layout shift or overlay noise
|
|
2223
|
+
// True layout shift indicators:
|
|
2224
|
+
// 1. Many consecutive rows changed (>20 rows = significant shift)
|
|
2225
|
+
// 2. Top heavy change pattern (topBandChange > bottomBandChange)
|
|
2226
|
+
// 3. Low variance (uniform change across bands)
|
|
2227
|
+
// 4. Few isolated hotspots
|
|
2228
|
+
|
|
2229
|
+
const hasConsecutiveShift = maxConsecutiveHighChangeRows > 20
|
|
2230
|
+
const hasTopHeavyPattern = topBandChange > 5 && bottomBandChange < 2 && topBandChange > bottomBandChange * 2
|
|
2231
|
+
const hasUniformChange = variance < 200 && meanChange > 10
|
|
2232
|
+
const hasIsolatedHotspots = isolatedHotspots >= 3
|
|
2233
|
+
|
|
2234
|
+
// Overlay noise indicators:
|
|
2235
|
+
// 1. High variance (scattered changes)
|
|
2236
|
+
// 2. Multiple isolated hotspots
|
|
2237
|
+
// 3. Few consecutive rows changed
|
|
2238
|
+
// 4. Narrow fixed element (toolbar/indicator pattern)
|
|
2239
|
+
const isOverlayNoise =
|
|
2240
|
+
hasNarrowFixedElement || // Narrow element like toolbar
|
|
2241
|
+
(hasIsolatedHotspots && !hasConsecutiveShift && (variance > 500 || meanChange < 10))
|
|
2242
|
+
|
|
2243
|
+
// Layout shift: Either consecutive shift pattern OR traditional top-heavy pattern
|
|
2244
|
+
// But NOT if it looks like overlay noise
|
|
2245
|
+
const isLayoutShift = !isOverlayNoise && (hasConsecutiveShift || hasTopHeavyPattern || hasUniformChange)
|
|
2246
|
+
|
|
2247
|
+
// Calculate shift score
|
|
2162
2248
|
const totalChanged = bands.reduce((sum, count) => sum + count, 0)
|
|
2163
2249
|
const totalPixels = width * height
|
|
2164
|
-
const shiftScore = (totalChanged / totalPixels) * 0.1
|
|
2250
|
+
const shiftScore = (totalChanged / totalPixels) * 0.1
|
|
2165
2251
|
|
|
2166
|
-
return { isLayoutShift, shiftScore }
|
|
2252
|
+
return { isLayoutShift, shiftScore, isOverlayNoise }
|
|
2167
2253
|
}
|
|
2168
2254
|
|
|
2169
2255
|
/**
|
|
@@ -2186,6 +2272,7 @@ async function detectJankFromScreenshots(_projectName?: string): Promise<{
|
|
|
2186
2272
|
totalFrames: number
|
|
2187
2273
|
screenshotDir: string
|
|
2188
2274
|
realCLS?: { score: number; grade: string }
|
|
2275
|
+
captureTrigger?: "navigation" | "load"
|
|
2189
2276
|
}> {
|
|
2190
2277
|
const screenshotDir = process.env.SCREENSHOT_DIR || join(tmpdir(), "dev3000-mcp-deps", "public", "screenshots")
|
|
2191
2278
|
|
|
@@ -2224,13 +2311,20 @@ async function detectJankFromScreenshots(_projectName?: string): Promise<{
|
|
|
2224
2311
|
| {
|
|
2225
2312
|
score: number
|
|
2226
2313
|
grade: string
|
|
2227
|
-
shifts: Array<{
|
|
2314
|
+
shifts: Array<{
|
|
2315
|
+
score: number
|
|
2316
|
+
timestamp: number
|
|
2317
|
+
sources?: Array<{ node?: string; position?: string | null }>
|
|
2318
|
+
}>
|
|
2228
2319
|
}
|
|
2229
2320
|
| undefined
|
|
2321
|
+
let captureTrigger: "navigation" | "load" | undefined
|
|
2230
2322
|
|
|
2231
2323
|
if (existsSync(metadataPath)) {
|
|
2232
2324
|
try {
|
|
2233
2325
|
const metadata = JSON.parse(readFileSync(metadataPath, "utf-8"))
|
|
2326
|
+
// Capture the trigger type for use in output messages
|
|
2327
|
+
captureTrigger = metadata.captureTrigger
|
|
2234
2328
|
// Set realCLSData even if there are zero shifts - this tells us Chrome ran and found nothing
|
|
2235
2329
|
if (metadata.layoutShifts !== undefined) {
|
|
2236
2330
|
realCLSData = {
|
|
@@ -2329,15 +2423,27 @@ async function detectJankFromScreenshots(_projectName?: string): Promise<{
|
|
|
2329
2423
|
continue
|
|
2330
2424
|
}
|
|
2331
2425
|
|
|
2332
|
-
// Detect if this is a layout shift vs content change
|
|
2426
|
+
// Detect if this is a layout shift vs content change vs overlay noise
|
|
2333
2427
|
const shiftAnalysis = detectLayoutShiftVsContentChange(prevPng, currPng)
|
|
2334
2428
|
|
|
2335
|
-
//
|
|
2429
|
+
// Skip if this looks like overlay noise (fixed/absolute elements like Next.js dev indicator or Vercel toolbar)
|
|
2430
|
+
if (shiftAnalysis.isOverlayNoise) {
|
|
2431
|
+
logToDevFile(
|
|
2432
|
+
`Pixel Diff Hydration: Skipping frame ${i} - detected overlay noise (fixed/absolute elements), not true CLS`
|
|
2433
|
+
)
|
|
2434
|
+
continue
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
// If we detect a true layout shift (not just content loading or overlay noise), flag it
|
|
2336
2438
|
if (shiftAnalysis.isLayoutShift) {
|
|
2337
2439
|
foundHydrationShift = true
|
|
2338
2440
|
const timeMatch = earlyFrames[i].match(/-(\d+)ms\.png$/)
|
|
2339
2441
|
const timeSinceStart = timeMatch ? parseInt(timeMatch[1], 10) : 0
|
|
2340
2442
|
|
|
2443
|
+
logToDevFile(
|
|
2444
|
+
`Pixel Diff Hydration: Detected true layout shift at ${timeSinceStart}ms (score: ${shiftAnalysis.shiftScore.toFixed(4)})`
|
|
2445
|
+
)
|
|
2446
|
+
|
|
2341
2447
|
const mcpPort = process.env.MCP_PORT || "3684"
|
|
2342
2448
|
jankDetections.push({
|
|
2343
2449
|
timestamp: `${timeSinceStart}ms`,
|
|
@@ -2378,9 +2484,29 @@ async function detectJankFromScreenshots(_projectName?: string): Promise<{
|
|
|
2378
2484
|
}
|
|
2379
2485
|
|
|
2380
2486
|
// Process actual layout shifts detected by Chrome
|
|
2381
|
-
// Trust Chrome's Layout Instability API
|
|
2487
|
+
// Trust Chrome's Layout Instability API - BUT ONLY if we can identify the culprit element
|
|
2488
|
+
// and verify it's not a fixed/absolute positioned overlay
|
|
2382
2489
|
realCLSData.shifts.forEach((shift) => {
|
|
2383
2490
|
const element = shift.sources?.[0]?.node || "unknown"
|
|
2491
|
+
const position = shift.sources?.[0]?.position
|
|
2492
|
+
|
|
2493
|
+
// FILTER: Skip shifts where we couldn't identify the element
|
|
2494
|
+
// Chrome sometimes reports CLS for fixed overlays but fails to identify the element
|
|
2495
|
+
if (!shift.sources?.[0] || element === "unknown" || position === null || position === undefined) {
|
|
2496
|
+
logToDevFile(
|
|
2497
|
+
`Chrome CLS: Skipping unidentified shift (score: ${shift.score.toFixed(4)}) - cannot verify if it's a true CLS or fixed overlay noise`
|
|
2498
|
+
)
|
|
2499
|
+
return // Skip this shift - can't verify it's real
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
// FILTER: Skip fixed/absolute positioned elements - these are overlays, not true CLS
|
|
2503
|
+
if (position === "fixed" || position === "absolute") {
|
|
2504
|
+
logToDevFile(
|
|
2505
|
+
`Chrome CLS: Filtering out ${element} shift (position: ${position}) - fixed/absolute elements don't cause true layout shifts`
|
|
2506
|
+
)
|
|
2507
|
+
return // Skip this shift
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2384
2510
|
const isCriticalElement = ["NAV", "HEADER", "BUTTON", "A"].includes(element.toUpperCase())
|
|
2385
2511
|
const isDuringLoad = shift.timestamp < 1000 // First second
|
|
2386
2512
|
|
|
@@ -2479,7 +2605,8 @@ async function detectJankFromScreenshots(_projectName?: string): Promise<{
|
|
|
2479
2605
|
detections: jankDetections,
|
|
2480
2606
|
sessionId: latestSessionId,
|
|
2481
2607
|
totalFrames: sessionFiles.length,
|
|
2482
|
-
screenshotDir
|
|
2608
|
+
screenshotDir,
|
|
2609
|
+
captureTrigger
|
|
2483
2610
|
}
|
|
2484
2611
|
}
|
|
2485
2612
|
|