codeloop-mcp-server 0.1.48 → 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/auth/critical_floors.d.ts +8 -4
  2. package/dist/auth/critical_floors.d.ts.map +1 -1
  3. package/dist/auth/critical_floors.js +13 -17
  4. package/dist/auth/critical_floors.js.map +1 -1
  5. package/dist/auth/init_hint_cache.d.ts +35 -0
  6. package/dist/auth/init_hint_cache.d.ts.map +1 -0
  7. package/dist/auth/init_hint_cache.js +143 -0
  8. package/dist/auth/init_hint_cache.js.map +1 -0
  9. package/dist/evidence/screenshot_diff.d.ts +23 -0
  10. package/dist/evidence/screenshot_diff.d.ts.map +1 -1
  11. package/dist/evidence/screenshot_diff.js +46 -13
  12. package/dist/evidence/screenshot_diff.js.map +1 -1
  13. package/dist/index.js +168 -11
  14. package/dist/index.js.map +1 -1
  15. package/dist/runners/csproj_output_path.d.ts +22 -0
  16. package/dist/runners/csproj_output_path.d.ts.map +1 -0
  17. package/dist/runners/csproj_output_path.js +108 -0
  18. package/dist/runners/csproj_output_path.js.map +1 -0
  19. package/dist/runners/png_dims.d.ts +20 -0
  20. package/dist/runners/png_dims.d.ts.map +1 -0
  21. package/dist/runners/png_dims.js +58 -0
  22. package/dist/runners/png_dims.js.map +1 -0
  23. package/dist/runners/window_manager.d.ts +17 -4
  24. package/dist/runners/window_manager.d.ts.map +1 -1
  25. package/dist/runners/window_manager.js +135 -22
  26. package/dist/runners/window_manager.js.map +1 -1
  27. package/dist/tools/design_compare.d.ts.map +1 -1
  28. package/dist/tools/design_compare.js +14 -0
  29. package/dist/tools/design_compare.js.map +1 -1
  30. package/dist/tools/desktop_app_mode.d.ts +48 -0
  31. package/dist/tools/desktop_app_mode.d.ts.map +1 -0
  32. package/dist/tools/desktop_app_mode.js +86 -0
  33. package/dist/tools/desktop_app_mode.js.map +1 -0
  34. package/dist/tools/self_test.d.ts +40 -0
  35. package/dist/tools/self_test.d.ts.map +1 -0
  36. package/dist/tools/self_test.js +205 -0
  37. package/dist/tools/self_test.js.map +1 -0
  38. package/dist/tools/verify.d.ts.map +1 -1
  39. package/dist/tools/verify.js +4 -5
  40. package/dist/tools/verify.js.map +1 -1
  41. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs";
5
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs";
6
6
  function dirHasFile(dir, predicate) {
7
7
  try {
8
8
  if (!existsSync(dir))
@@ -20,6 +20,7 @@ import { loadConfig } from "./config.js";
20
20
  import { validateApiKey, isActivationRequired } from "./auth/api_key.js";
21
21
  import { identifyKeySource, buildRevokedKeyDiagnostic } from "./auth/key_source.js";
22
22
  import { warmCliCache } from "./auth/cli_cache_warmer.js";
23
+ import { recordInitialisedDir, wasInitialisedAtPath, } from "./auth/init_hint_cache.js";
23
24
  import { startUpdateCheck, getUpdateInfo, formatUpdateNotice, getRunningVersion, } from "./auth/update_check.js";
24
25
  import { applyUpdate, applyUpdateInputSchema, } from "./tools/apply_update.js";
25
26
  import { trackUsage } from "./auth/usage_tracker.js";
@@ -87,6 +88,49 @@ if (!process.env.CODELOOP_PROJECT_DIR &&
87
88
  `or set CODELOOP_PROJECT_DIR in your MCP config so future calls auto-resolve. ` +
88
89
  `codeloop_init_project will REFUSE to scaffold here.`);
89
90
  }
91
+ // 0.1.49 — stale CODELOOP_PROJECT_DIR detection.
92
+ //
93
+ // When init writes a workspace pin into .cursor/mcp.json, it bakes
94
+ // the absolute path of the workspace at the time. If the user later
95
+ // renames or moves the workspace folder (common on Windows when a
96
+ // project graduates from D:\Work\<name> to D:\Repos\<name>), the pin
97
+ // keeps pointing at the old path that no longer exists, and every
98
+ // MCP boot resolves projectDir to a non-existent directory — which
99
+ // silently turns init/verify/gate into no-ops because every "does
100
+ // the .codeloop/ folder exist?" check returns false.
101
+ //
102
+ // We log a single, loud, agent-readable line on stderr so the agent
103
+ // knows to re-run `npx codeloop init` (which rewrites the pin to
104
+ // the workspace's current absolute path — see G8 in the CLI).
105
+ {
106
+ const pinned = process.env.CODELOOP_PROJECT_DIR;
107
+ if (pinned) {
108
+ let stale = false;
109
+ let reason = "";
110
+ try {
111
+ if (!existsSync(pinned)) {
112
+ stale = true;
113
+ reason = "path does not exist";
114
+ }
115
+ else if (!statSync(pinned).isDirectory()) {
116
+ stale = true;
117
+ reason = "path is not a directory";
118
+ }
119
+ else if (!existsSync(join(pinned, ".codeloop", "config.json"))) {
120
+ stale = true;
121
+ reason = "no .codeloop/config.json under the pinned path";
122
+ }
123
+ }
124
+ catch (e) {
125
+ stale = true;
126
+ reason = e.message;
127
+ }
128
+ if (stale) {
129
+ console.error(`[CodeLoop] ⚠ CODELOOP_PROJECT_DIR=${pinned} is stale (${reason}) — falling back to discovery. ` +
130
+ `Re-run \`npx codeloop init\` from the workspace's current location to rewrite the pin.`);
131
+ }
132
+ }
133
+ }
90
134
  const config = loadConfig(projectDir);
91
135
  const apiKey = process.env.CODELOOP_API_KEY || config.api_key;
92
136
  // Pre-warm the npx cache for the `codeloop` CLI in the background so
@@ -310,6 +354,13 @@ function rememberInitializedDir(dir) {
310
354
  return;
311
355
  if (isProjectInitialized(dir)) {
312
356
  lastInitializedDir = dir;
357
+ // 0.1.49 — also persist to ~/.codeloop/init-hint-cache.json so
358
+ // the next MCP server boot (every IDE restart) doesn't false-
359
+ // positive the "project not initialised" hint until the agent
360
+ // has happened to forward `dir` to a handler that calls back
361
+ // into this function. Best-effort; failures swallowed inside
362
+ // recordInitialisedDir.
363
+ recordInitialisedDir(dir);
313
364
  }
314
365
  }
315
366
  function withInitHint(content, dir) {
@@ -336,7 +387,12 @@ function withInitHint(content, dir) {
336
387
  // home folder on Windows / Cursor — see CODELOOP_PROJECT_DIR
337
388
  // auto-injection notes in setup-project.ts).
338
389
  const candidates = [dir, lastInitializedDir, projectDir].filter((d) => typeof d === "string" && d.length > 0);
339
- const anyInitialized = candidates.some((d) => isProjectInitialized(d));
390
+ // 0.1.49 also consult the persistent cache so the very first
391
+ // tool call after an IDE restart doesn't false-positive the hint
392
+ // when `dir` wasn't passed and `lastInitializedDir` is empty (the
393
+ // session's not warmed up yet).
394
+ const anyInitialized = candidates.some((d) => isProjectInitialized(d)) ||
395
+ candidates.some((d) => wasInitialisedAtPath(d));
340
396
  if (!anyInitialized) {
341
397
  head.push({ type: "text", text: INIT_HINT });
342
398
  }
@@ -1206,13 +1262,12 @@ Returns: confirmation + the captured image as an MCP ImageContent block so you c
1206
1262
  // agent forgot app_name — and the auto-fix loop would then
1207
1263
  // burn cycles trying to "fix design diffs" against a
1208
1264
  // screenshot of the editor.
1209
- const { detectPlatform } = await import("./tools/verify.js");
1210
1265
  const { loadConfig } = await import("./config.js");
1211
- const platform = detectPlatform(cwd);
1212
- const isDesktopAppProject = platform === "dotnet" || platform === "xcode" || platform === "android";
1266
+ const { isDesktopAppProject } = await import("./tools/desktop_app_mode.js");
1267
+ const desktopApp = isDesktopAppProject(cwd);
1213
1268
  const cfg = loadConfig(cwd);
1214
1269
  const targetApp = params.app_name ?? cfg.evidence?.target_app;
1215
- const result = await captureScreenshot(screenshotsDir, params.screen_name, targetApp, undefined, { desktopAppMode: isDesktopAppProject });
1270
+ const result = await captureScreenshot(screenshotsDir, params.screen_name, targetApp, undefined, { desktopAppMode: desktopApp });
1216
1271
  // Photometry-DB E2E 8 follow-on: when we capture a desktop app
1217
1272
  // window, also resolve its on-screen bounds so the agent can
1218
1273
  // (a) compute window-relative coords from the returned image
@@ -1223,7 +1278,7 @@ Returns: confirmation + the captured image as an MCP ImageContent block so you c
1223
1278
  // of the image and clicked tens or hundreds of pixels off the
1224
1279
  // intended target.
1225
1280
  let windowBounds = null;
1226
- if (isDesktopAppProject && targetApp && result.captured) {
1281
+ if (desktopApp && targetApp && result.captured) {
1227
1282
  try {
1228
1283
  const wm = await import("./runners/window_manager.js");
1229
1284
  const b = await wm.getWindowBounds(targetApp);
@@ -2012,10 +2067,25 @@ Returns: checklist of completed and pending verification steps.`, {
2012
2067
  const verdict = evaluateDepth(coverage, minimums, discoverySnapshot);
2013
2068
  const b = coverage.buckets;
2014
2069
  const breakdown = `click=${b.click}, navigation=${b.navigation}, input=${b.input}, commit=${b.commit}, toggle=${b.toggle}, gesture=${b.gesture}, upload=${b.upload}, keystroke=${b.keystroke}, inspect=${b.inspect}`;
2070
+ // 0.1.49: coordinate_clicks_without_intent is now a HARD
2071
+ // step-7 PENDING blocker so the agent sees the gap BEFORE
2072
+ // gate_check, not after. Pre-0.1.49 this only surfaced as
2073
+ // a verify postscript note, which agents commonly ignored
2074
+ // until the user_journey_evidence gate failed at the
2075
+ // bottom of a long verify→capture→video→gate cycle —
2076
+ // wasting the entire UI-evidence loop.
2077
+ const coordsWithoutIntent = coverage.coordinate_clicks_without_intent;
2015
2078
  if (!minimums.enabled) {
2016
2079
  depthStatus = "n/a";
2017
2080
  depthDetail = `Depth gate disabled in .codeloop/config.json. Observed buckets: ${breakdown}.`;
2018
2081
  }
2082
+ else if (coordsWithoutIntent > 0) {
2083
+ depthStatus = "PENDING";
2084
+ depthDetail =
2085
+ `${coverage.successful} successful interactions across ${runs.length} run(s) (${breakdown}). ` +
2086
+ `BLOCKER: ${coordsWithoutIntent} coordinate-only click(s) dispatched without intent / description / purpose / step fields — the CRUD classifier in user_journey_evidence cannot credit them as edit/delete/create. ` +
2087
+ `Re-run codeloop_interact for those clicks WITH \`intent\` (e.g. intent="confirm delete dialog", intent="save form"). Otherwise gate_check will return continue_fixing on user_journey_evidence even after the rest of the workflow is green.`;
2088
+ }
2019
2089
  else if (verdict.passed) {
2020
2090
  depthStatus = "done";
2021
2091
  depthDetail = `${coverage.successful} successful interactions across ${runs.length} run(s) (${breakdown}). Depth minimums met.`;
@@ -2180,7 +2250,8 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2180
2250
  description: z.string().optional().describe("[Alias for intent] Same semantics."),
2181
2251
  purpose: z.string().optional().describe("[Alias for intent] Same semantics."),
2182
2252
  step: z.string().optional().describe("Plan-step name when this interaction is driving a codeloop_plan_user_journey arc (e.g. 'edit', 'delete', 'create', 'save', 'verify'). Logged alongside `intent` and read by the CRUD classifier."),
2183
- coords: z.enum(["auto", "window", "screen"]).optional().describe("How to interpret x/y for desktop click/double_click/right_click/hover/scroll/drag/long_press. `auto` (default): if `app_name` resolves to a visible window AND (x, y) fits inside the window's client area, treat as window-relative and auto-offset by the window origin; otherwise leave as raw screen-absolute coords. `window`: ALWAYS add the window origin offset (errors if the window isn't found). `screen`: ALWAYS pass through (legacy behaviour, matches CGEvent / user32.dll / xdotool semantics). Fixes the Photometry-DB E2E 8 failure mode where the agent captured a 1600×900 window screenshot, computed click coords against the image, and missed the sidebar because the window's actual top-left was (286, 286) on a 5120×1440 screen."),
2253
+ coords: z.enum(["auto", "window", "screen", "screenshot"]).optional().describe("How to interpret x/y for desktop click/double_click/right_click/hover/scroll/drag/long_press. `auto` (default): if `app_name` resolves to a visible window AND (x, y) fits inside the window's client area, treat as window-relative and auto-offset by the window origin; otherwise leave as raw screen-absolute coords. `window`: ALWAYS add the window origin offset (errors if the window isn't found). `screen`: ALWAYS pass through (legacy behaviour, matches CGEvent / user32.dll / xdotool semantics). `screenshot` (most accurate for vision-driven agents): treat (x, y) as coordinates against a captured screenshot — provide `screenshot_path` so the runner can read the image's actual width/height, scale (x, y) to the window's true pixel dimensions, then add the window origin and apply DPI. Use this whenever you computed coords from the image returned by codeloop_capture_screenshot, especially when the MCP transport may have downscaled the PNG. Fixes the Photometry-DB E2E 8 failure mode where the agent captured a 1600×900 window screenshot, computed click coords against the image, and missed the sidebar because the window's actual top-left was (286, 286) on a 5120×1440 screen."),
2254
+ screenshot_path: z.string().optional().describe("Absolute path to the screenshot PNG that x/y were computed against. Used with `coords: \"screenshot\"` to scale agent-supplied coords from the captured image dimensions to the window's actual pixel dimensions before applying the window origin and DPI factor. Pass the `path` field returned by codeloop_capture_screenshot."),
2184
2255
  project_dir: z.string().optional().describe("Absolute path to project root."),
2185
2256
  workspace_root: z.string().optional().describe("[Alias for project_dir] Pass either; they're equivalent."),
2186
2257
  }, async (params) => {
@@ -2211,6 +2282,7 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2211
2282
  }
2212
2283
  // Bring the app to front before desktop interactions (non-browser, non-mobile).
2213
2284
  let windowOriginOffset = null;
2285
+ let screenshotDims = null;
2214
2286
  if (tt === "desktop") {
2215
2287
  const appName = params.app_name || vr.getActiveRecordingAppName();
2216
2288
  if (appName && action !== "wait") {
@@ -2233,14 +2305,35 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2233
2305
  try {
2234
2306
  const b = await wm.getWindowBounds(appName);
2235
2307
  if (b && b.width > 0 && b.height > 0) {
2236
- windowOriginOffset = { dx: b.x, dy: b.y, width: b.width, height: b.height };
2308
+ windowOriginOffset = {
2309
+ dx: b.x,
2310
+ dy: b.y,
2311
+ width: b.width,
2312
+ height: b.height,
2313
+ dpiX: b.dpi_x,
2314
+ dpiY: b.dpi_y,
2315
+ };
2237
2316
  }
2238
2317
  }
2239
2318
  catch { /* best-effort */ }
2240
2319
  }
2320
+ // For coords:"screenshot", load the actual PNG dims so we
2321
+ // can scale agent-supplied (x, y) up from the (possibly
2322
+ // MCP-downscaled) image to the window's true pixel size.
2323
+ if (coordsMode === "screenshot" && params.screenshot_path) {
2324
+ try {
2325
+ const { readPngDims } = await import("./runners/png_dims.js");
2326
+ screenshotDims = readPngDims(params.screenshot_path);
2327
+ }
2328
+ catch { /* best-effort */ }
2329
+ }
2241
2330
  }
2242
2331
  }
2243
2332
  // Helper used by every coordinate-driven desktop action below.
2333
+ // Photometry-DB E2E 8 + 0.1.49 hardening: handles four modes
2334
+ // (auto / window / screen / screenshot) plus an optional DPI
2335
+ // factor on the window bounds so high-DPI Windows / Retina
2336
+ // displays don't drop clicks 100s of pixels off-target.
2244
2337
  const translateXY = (x, y) => {
2245
2338
  if (tt !== "desktop" || x == null || y == null || !windowOriginOffset) {
2246
2339
  return { x, y };
@@ -2248,8 +2341,35 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2248
2341
  const mode = params.coords ?? "auto";
2249
2342
  if (mode === "screen")
2250
2343
  return { x, y };
2344
+ const applyDpi = (px, py) => {
2345
+ // window_manager records DPI in physical-pixel-per-logical
2346
+ // form (1.0 = 96 DPI baseline; 2.0 = 200% / Retina). When
2347
+ // the screenshot was captured in logical pixels but the
2348
+ // OS click API expects physical pixels (Win32 user32.dll
2349
+ // and modern macOS CGEvent both expect physical), scale up.
2350
+ const dpiX = windowOriginOffset.dpiX ?? 1;
2351
+ const dpiY = windowOriginOffset.dpiY ?? 1;
2352
+ if (dpiX === 1 && dpiY === 1)
2353
+ return { x: px, y: py };
2354
+ return { x: px * dpiX, y: py * dpiY };
2355
+ };
2356
+ if (mode === "screenshot") {
2357
+ // Scale (x, y) from screenshot dims → window dims,
2358
+ // then add the window origin, then DPI.
2359
+ let sx = x;
2360
+ let sy = y;
2361
+ if (screenshotDims && screenshotDims.width > 0 && screenshotDims.height > 0) {
2362
+ const ratioX = windowOriginOffset.width / screenshotDims.width;
2363
+ const ratioY = windowOriginOffset.height / screenshotDims.height;
2364
+ sx = x * ratioX;
2365
+ sy = y * ratioY;
2366
+ }
2367
+ const dpi = applyDpi(sx, sy);
2368
+ return { x: dpi.x + windowOriginOffset.dx, y: dpi.y + windowOriginOffset.dy };
2369
+ }
2251
2370
  if (mode === "window") {
2252
- return { x: x + windowOriginOffset.dx, y: y + windowOriginOffset.dy };
2371
+ const dpi = applyDpi(x, y);
2372
+ return { x: dpi.x + windowOriginOffset.dx, y: dpi.y + windowOriginOffset.dy };
2253
2373
  }
2254
2374
  // auto: if (x, y) fits inside the window's client area,
2255
2375
  // assume the agent computed against a window-cropped
@@ -2258,7 +2378,8 @@ Wait 1-2 seconds between interactions so video frames capture state changes.`, {
2258
2378
  const inside = x >= 0 && x <= windowOriginOffset.width &&
2259
2379
  y >= 0 && y <= windowOriginOffset.height;
2260
2380
  if (inside) {
2261
- return { x: x + windowOriginOffset.dx, y: y + windowOriginOffset.dy };
2381
+ const dpi = applyDpi(x, y);
2382
+ return { x: dpi.x + windowOriginOffset.dx, y: dpi.y + windowOriginOffset.dy };
2262
2383
  }
2263
2384
  return { x, y };
2264
2385
  };
@@ -3113,6 +3234,42 @@ No project_dir / workspace_root required — this tool is workspace-independent.
3113
3234
  ]),
3114
3235
  };
3115
3236
  });
3237
+ server.tool("codeloop_self_test", TOOL_BOOTSTRAP + `Pre-flight smoke test for CodeLoop on the current workspace. Run this on any NEW project BEFORE your first verify cycle, or whenever something looks off (silent IDE captures, "no .exe found", design_compare returning 0%, etc.).
3238
+
3239
+ It validates every critical pre-condition synthetically (no live build, no live screenshot, no network past the platform sniff):
3240
+ - Workspace exists on disk and is a directory
3241
+ - codeloop_init_project has been run (.codeloop/config.json present)
3242
+ - Platform detection produced a known platform
3243
+ - isDesktopAppProject correctly identifies the project type (so captureScreenshot won't silently fall back to fullscreen)
3244
+ - evidence.target_app is set when desktop-app mode is ON (so launchDesktopApp + captureScreenshot can resolve a window)
3245
+ - PNG decoder skip path is wired (corrupt PNGs become skip warnings, not 0% match)
3246
+ - Coordinate translation round-trips on a synthetic high-DPI fixture (so clicks land where the agent expects on Retina / 200%-DPI displays)
3247
+
3248
+ Returns a structured pass/fail report with per-check fix suggestions and a single \`next_step\` directive.
3249
+
3250
+ Use this tool FIRST when:
3251
+ - The user reports CodeLoop "isn't working" or evidence is missing
3252
+ - Switching to a project / repo you've never run CodeLoop against
3253
+ - Debugging unexplained gate failures that don't match the agent's mental model
3254
+
3255
+ Idempotent and free — safe to call as the first step of every new chat.`, {
3256
+ project_dir: z.string().optional().describe("Absolute path to the project root. Defaults to CODELOOP_PROJECT_DIR env var or auto-discovered project directory."),
3257
+ workspace_root: z.string().optional().describe("[Alias for project_dir] Same semantics."),
3258
+ }, async (params) => {
3259
+ const result = await withAuth(async () => {
3260
+ const cwd = (params.project_dir || params.workspace_root || projectDir);
3261
+ const { runSelfTest } = await import("./tools/self_test.js");
3262
+ return runSelfTest(cwd);
3263
+ }, { tool: "codeloop_self_test", cwd: (params.project_dir || params.workspace_root || projectDir), input: params });
3264
+ if (typeof result === "object" && result !== null && "error" in result) {
3265
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3266
+ }
3267
+ return {
3268
+ content: withInitHint([
3269
+ { type: "text", text: JSON.stringify(result, null, 2) },
3270
+ ]),
3271
+ };
3272
+ });
3116
3273
  server.tool("codeloop_apply_update", TOOL_BOOTSTRAP + `Apply a pending CodeLoop MCP server update to the current chat session — without asking the user to restart their IDE.
3117
3274
 
3118
3275
  Use this tool when: