From 534dbf903f057963d4908089da71bb929ac2a5c3 Mon Sep 17 00:00:00 2001 From: Rajaram Srinivasan Date: Wed, 20 May 2026 20:31:02 +0000 Subject: [PATCH 1/3] feat(discovery): early "detected tools" pre-report for snappy live UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the client's detection phase (every detected tool already known), send one lightweight tools-only report per user listing every detected tool with empty projects — BEFORE the slow per-tool extraction (rules / MCP / skills / projects) begins. Why --- With the existing flow the client serially fully-extracts each tool then sends one full report per tool, taking ~80s per tool / ~5 min total on a typical Mac. The onboarding panel shows one tile every ~minute as each report lands. After this change the dashboard shows every detected agent tile within seconds of scan start, then enriches each with its full data as the per-tool reports trickle in. Implementation -------------- - Reuses the existing send_report_to_backend path (curl-only, per repo CLAUDE.md — no urllib, system cert store). - Per-tool entries are the detected tool dicts minus internal keys minus projects (projects=[]). - Best-effort, wrapped in try/except per user — a preview-send failure must never break the rest of discovery. - Zero server-side changes needed. The existing per-(device, tool, home_user) retire-then-recreate in process_tool_report supersedes the preview entries when the full per-tool reports later land, so this changes *when* a tile first appears, not the final data. Test plan --------- - Run unbound-cli discover --api-key --domain against any tenant. - Within seconds of scan start, observe every detected agent tile in the onboarding "+ Agents" panel. - As each per-tool extraction completes, tiles enrich (rules / MCP / project counts populate). Final state is unchanged from prior. Notes ----- - Discovered while testing agent-scanning-always-present end-to-end locally. Companion server-side PR in ai-gateway-data lands the live per-run aggregates that surface the preview installs interactively. - A separate, customer-impacting issue was found during this work: get_claude_subscription_type invokes `claude auth status` which can silently downgrade Pro/Max -> API on the user's Mac. Team has been notified; reportedly already resolved on main. Write-up: /home/raj/Projects/CODING-DISCOVERY-CLAUDE-SUBSCRIPTION-P0.md (also on Notion). Out of scope for this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ai_tools_discovery.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/scripts/coding_discovery_tools/ai_tools_discovery.py b/scripts/coding_discovery_tools/ai_tools_discovery.py index 7b0b218b..a23df8a8 100644 --- a/scripts/coding_discovery_tools/ai_tools_discovery.py +++ b/scripts/coding_discovery_tools/ai_tools_discovery.py @@ -1780,6 +1780,48 @@ def time_step(name: str, phase: str) -> Iterator[None]: logger.info(f"Detection complete: {len(tools)} unique tool(s) found across all users") logger.info("") + # --- Early "detected" pre-report (snappy live UI) ------------------- + # Every tool is already known here, BEFORE the slow per-tool + # extraction (rules / MCP / skills / projects). Send a lightweight + # tools-only report (no projects) per user up front so the dashboard + # shows every agent tile within seconds of scan start, instead of one + # tile every ~tool-extraction. The full per-tool reports below + # supersede these (the backend keys installs by + # device + tool + home_user, retire-then-recreate), so this only + # changes *when* a tile first appears, not the final data. + if tools: + logger.info("Sending early detected-tools report (for live UI)...") + preview_tools = [] + for t in tools: + pt = {k: v for k, v in t.items() + if not k.startswith('_') and k != 'projects'} + pt['projects'] = [] + preview_tools.append(pt) + with time_step("send_detected_preview", "send"): + for user_name in all_users: + preview_report = { + "home_user": user_name, + "system_user": system_user, + "device_id": device_id, + "run_id": run_id, + "tools": preview_tools, + } + try: + ok, _ = send_report_to_backend( + args.domain, args.api_key, preview_report, + args.app_name, sentry_context=sentry_ctx, + ) + logger.info( + f" {'OK' if ok else 'XX'} detected-tools preview " + f"for {user_name}" + ) + except Exception as e: + logger.warning( + f" detected-tools preview failed for " + f"{user_name}: {e}" + ) + logger.info("") + # Process each tool, then explore all users for that tool and send reports for tool in tools: tool_name = tool.get('name', 'Unknown') From 0069f6d5b1b261a827b55592040743b336e6404b Mon Sep 17 00:00:00 2001 From: Rajaram Srinivasan Date: Wed, 20 May 2026 22:12:38 +0000 Subject: [PATCH 2/3] fix(discovery): scope detected-tools preview to per-user attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses #130 review (Sumit + Greptile P1 "phantom tiles for the wrong user"). The early detected-tools preview (sent before per-tool extraction so the dashboard shows tiles within seconds) was building a single device-wide `preview_tools` list — the union of every user's tools — and posting that same list under each `home_user` in turn. On a multi-user box this created phantom tiles: Alice would get a Cursor tile because Bob has Cursor, and vice-versa. The full per-tool reports sent later are correctly per-user, but until they landed (or for tools the wrong user genuinely has nothing of) the phantom tile stuck around. - Track per-user tool-key sets during the detection loop (`tool_keys_by_user[user] = {tool_name:install_path, ...}`), in parallel to the existing device-wide `tools_by_user` dedup map. - When sending the preview, look up each user's own set and post only those stubs. Users with no detected tools skip the preview send entirely — no empty payload, no empty tile. Pairs with the server-side preview-only short-circuit on `ai-gateway-data#1945` (preview no longer hollows a returning user's existing rich row), but is independently sufficient: the wrong user never receives a preview for a tool they don't own in the first place. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ai_tools_discovery.py | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/scripts/coding_discovery_tools/ai_tools_discovery.py b/scripts/coding_discovery_tools/ai_tools_discovery.py index a23df8a8..19c00f2b 100644 --- a/scripts/coding_discovery_tools/ai_tools_discovery.py +++ b/scripts/coding_discovery_tools/ai_tools_discovery.py @@ -1747,6 +1747,12 @@ def time_step(name: str, phase: str) -> Iterator[None]: logger.info("Detecting AI tools...") all_tools = [] # Store all unique tools across all users tools_by_user = {} # Track which tools belong to which user + # Per-user tool-key sets, used to scope the early detected-tools + # preview report below to the tools each user actually has. Review + # feedback (#130): the previous version sent the device-wide union + # to every user, creating phantom tiles for tools that user didn't + # own (e.g. Alice gets a Cursor tile because Bob has Cursor). + tool_keys_by_user = {u: set() for u in all_users} for user in all_users: if platform.system() == "Darwin": @@ -1769,6 +1775,7 @@ def time_step(name: str, phase: str) -> Iterator[None]: # Track which user this tool belongs to tool_key = f"{tool_name}:{tool_path}" + tool_keys_by_user[user].add(tool_key) if tool_key not in tools_by_user: tools_by_user[tool_key] = tool all_tools.append(tool) @@ -1791,20 +1798,39 @@ def time_step(name: str, phase: str) -> Iterator[None]: # changes *when* a tile first appears, not the final data. if tools: logger.info("Sending early detected-tools report (for live UI)...") - preview_tools = [] + # Build preview stubs keyed by tool_key so we can scope to per-user + # attribution below. Phantom-tile fix (#130 review): a user only + # receives stubs for tools their OWN detection pass found, never + # the device-wide union. + preview_tool_by_key = {} for t in tools: pt = {k: v for k, v in t.items() if not k.startswith('_') and k != 'projects'} pt['projects'] = [] - preview_tools.append(pt) + tool_key = f"{t.get('name', 'Unknown')}:{t.get('install_path', 'Unknown path')}" + preview_tool_by_key[tool_key] = pt with time_step("send_detected_preview", "send"): for user_name in all_users: + user_keys = tool_keys_by_user.get(user_name, set()) + user_preview_tools = [ + preview_tool_by_key[k] + for k in user_keys + if k in preview_tool_by_key + ] + if not user_preview_tools: + # User has no tools — skip rather than send an empty + # preview that would create an empty tile. + logger.info( + f" -- detected-tools preview skipped for " + f"{user_name} (no tools)" + ) + continue preview_report = { "home_user": user_name, "system_user": system_user, "device_id": device_id, "run_id": run_id, - "tools": preview_tools, + "tools": user_preview_tools, } try: ok, _ = send_report_to_backend( @@ -1813,7 +1839,7 @@ def time_step(name: str, phase: str) -> Iterator[None]: ) logger.info( f" {'OK' if ok else 'XX'} detected-tools preview " - f"for {user_name}" + f"for {user_name} ({len(user_preview_tools)} tool(s))" ) except Exception as e: logger.warning( From b5ad81adb79cbbb9673e80604cdd9e3d9221c3d1 Mon Sep 17 00:00:00 2001 From: Rajaram Srinivasan Date: Wed, 20 May 2026 22:59:01 +0000 Subject: [PATCH 3/3] fix(discovery): omit projects key from preview payload (not projects=[]) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to ai-gateway-data#1945 Greptile P1. The server's preview-only short-circuit uses key-absence as the disambiguator: a payload missing the `projects` key is a preview, a payload with `projects: ` (even empty) is a full per-tool report. Setting `pt['projects'] = []` here defeated that disambiguation — making a preview indistinguishable on the wire from a real full-report-with-zero-projects, which would either misroute a real report into the short-circuit (stale projects forever) or, if the server tightened the check, defeat Item 3 entirely. Drop the explicit empty list. The preview payload now contains only the detector's identity fields (name, install_path, version, etc.). The server short-circuits on key-absence, the full per-tool report that lands later always supplies `projects`. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/coding_discovery_tools/ai_tools_discovery.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/coding_discovery_tools/ai_tools_discovery.py b/scripts/coding_discovery_tools/ai_tools_discovery.py index 19c00f2b..cd0d99f0 100644 --- a/scripts/coding_discovery_tools/ai_tools_discovery.py +++ b/scripts/coding_discovery_tools/ai_tools_discovery.py @@ -1802,11 +1802,20 @@ def time_step(name: str, phase: str) -> Iterator[None]: # attribution below. Phantom-tile fix (#130 review): a user only # receives stubs for tools their OWN detection pass found, never # the device-wide union. + # + # IMPORTANT: the preview must OMIT the `projects` key entirely (not + # set it to []). The server uses key-absence as the only + # unambiguous "this is a preview" signal — a full per-tool report + # from a user with zero projects legitimately sends `projects: []`, + # so an explicit empty list here would be indistinguishable from a + # real zero-projects report and the server's short-circuit would + # misroute it, leaving stale projects on returning users. + # Companion change: ai-gateway-data#1945 + # (webapp/services/ai_tools_service.py preview-only short-circuit). preview_tool_by_key = {} for t in tools: pt = {k: v for k, v in t.items() if not k.startswith('_') and k != 'projects'} - pt['projects'] = [] tool_key = f"{t.get('name', 'Unknown')}:{t.get('install_path', 'Unknown path')}" preview_tool_by_key[tool_key] = pt with time_step("send_detected_preview", "send"):